diff --git a/client/package.json b/client/package.json old mode 100644 new mode 100755 index 9acd1b9..971d375 --- a/client/package.json +++ b/client/package.json @@ -21,6 +21,7 @@ "@angular/platform-browser": "~10.0.3", "@angular/platform-browser-dynamic": "~10.0.3", "@angular/router": "~10.0.3", + "@auth0/angular-jwt": "^5.0.1", "material-design-icons": "^3.0.1", "ngx-monaco-editor": "^9.0.0", "ngx-toastr": "^13.0.0", diff --git a/client/src/app/app-routing.module.ts b/client/src/app/app-routing.module.ts index 60e0633..b762870 100644 --- a/client/src/app/app-routing.module.ts +++ b/client/src/app/app-routing.module.ts @@ -2,21 +2,27 @@ import {NgModule} from '@angular/core'; import {RouterModule, Routes} from '@angular/router'; import {HomeComponent} from './home/home.component'; import {CtlComponent} from './ctl/ctl.component'; - +import {LoginComponent} from './login/login.component'; +import {AuthGuard} from 'src/services/auth-guard/auth-guard.service'; const routes: Routes = [{ path: 'ctl', component: CtlComponent, + canActivate: [AuthGuard], loadChildren: './ctl/ctl.module#CtlModule', }, { path: '', + canActivate: [AuthGuard], component: HomeComponent +}, { + path: 'login', + canActivate: [AuthGuard], + component: LoginComponent }]; @NgModule({ imports: [RouterModule.forRoot(routes)], exports: [RouterModule] }) -export class AppRoutingModule { -} +export class AppRoutingModule {} diff --git a/client/src/app/app.component.html b/client/src/app/app.component.html index a6bd085..94187e6 100644 --- a/client/src/app/app.component.html +++ b/client/src/app/app.component.html @@ -57,7 +57,7 @@ - + diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index cf8de9d..aa77999 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts @@ -1,11 +1,12 @@ import { Component, OnInit } from '@angular/core'; -import { environment } from '../environments/environment'; -import { IconService } from '../services/icon/icon.service'; -import { WebsocketService } from '../services/websocket/websocket.service'; -import { Log } from '../services/log/log.service'; -import { LogMessage } from '../services/log/log-message'; -import { Dashboard, WSReceiver, WebsocketMessage } from '../services/websocket/websocket.models'; +import { environment } from 'src/environments/environment'; +import { IconService } from 'src/services/icon/icon.service'; +import { WebsocketService } from 'src/services/websocket/websocket.service'; +import { Log } from 'src/services/log/log.service'; +import { LogMessage } from 'src/services/log/log-message'; +import { Dashboard, WSReceiver, WebsocketMessage } from 'src/services/websocket/websocket.models'; import { Nav } from './app.models'; +import { AuthGuard } from 'src/services/auth-guard/auth-guard.service'; @Component({ selector: 'app-root', @@ -60,6 +61,15 @@ export class AppComponent implements OnInit, WSReceiver { } } + public authToggle(): void { + const button = document.getElementById('loginButton'); + + if (button.innerText === 'Logout') { + AuthGuard.logout(); + button.innerText = 'Login'; + } + } + ngOnInit(): void { this.iconService.registerIcons(); } diff --git a/client/src/app/ctl/baremetal/baremetal.component.css b/client/src/app/ctl/baremetal/baremetal.component.css deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/app/ctl/baremetal/baremetal.component.ts b/client/src/app/ctl/baremetal/baremetal.component.ts index f04e90f..41cef5d 100644 --- a/client/src/app/ctl/baremetal/baremetal.component.ts +++ b/client/src/app/ctl/baremetal/baremetal.component.ts @@ -7,7 +7,6 @@ import { LogMessage } from '../../../services/log/log-message'; @Component({ selector: 'app-bare-metal', templateUrl: './baremetal.component.html', - styleUrls: ['./baremetal.component.css'] }) export class BaremetalComponent implements WSReceiver { diff --git a/client/src/app/ctl/ctl-routing.module.ts b/client/src/app/ctl/ctl-routing.module.ts index 7c9fea5..1b78bfb 100644 --- a/client/src/app/ctl/ctl-routing.module.ts +++ b/client/src/app/ctl/ctl-routing.module.ts @@ -2,12 +2,15 @@ import {NgModule} from '@angular/core'; import {RouterModule, Routes} from '@angular/router'; import {DocumentComponent} from './document/document.component'; import {BaremetalComponent} from './baremetal/baremetal.component'; +import {AuthGuard} from 'src/services/auth-guard/auth-guard.service'; const routes: Routes = [{ path: 'documents', + canActivate: [AuthGuard], component: DocumentComponent, }, { path: 'baremetal', + canActivate: [AuthGuard], component: BaremetalComponent }]; diff --git a/client/src/app/ctl/ctl.component.css b/client/src/app/ctl/ctl.component.css deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/app/ctl/ctl.component.ts b/client/src/app/ctl/ctl.component.ts index 13ac6fe..58ac327 100644 --- a/client/src/app/ctl/ctl.component.ts +++ b/client/src/app/ctl/ctl.component.ts @@ -3,7 +3,6 @@ import {Component} from '@angular/core'; @Component({ selector: 'app-ctl', templateUrl: './ctl.component.html', - styleUrls: ['./ctl.component.css'] }) export class CtlComponent { } diff --git a/client/src/app/home/home.component.css b/client/src/app/home/home.component.css deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/app/home/home.component.ts b/client/src/app/home/home.component.ts index 4b6eefc..8127a81 100644 --- a/client/src/app/home/home.component.ts +++ b/client/src/app/home/home.component.ts @@ -3,6 +3,5 @@ import {Component} from '@angular/core'; @Component({ selector: 'app-home', templateUrl: './home.component.html', - styleUrls: ['./home.component.css'] }) export class HomeComponent {} diff --git a/client/src/app/login/login.component.css b/client/src/app/login/login.component.css new file mode 100755 index 0000000..da5ecb6 --- /dev/null +++ b/client/src/app/login/login.component.css @@ -0,0 +1,69 @@ +/* Bordered form */ +form { + border: 3px solid #f1f1f1; +} + +/* Full-width inputs */ +input[type=text], input[type=password] { + width: 300px; + padding: 12px 20px; + margin: 8px 0; + display: inline-block; + border: 1px solid #ccc; + box-sizing: border-box; +} + +/* Set a style for all buttons */ +button { + background-color: #4CAF50; + color: white; + padding: 14px 20px; + margin: 8px 0; + border: none; + cursor: pointer; + width: 100px; +} + +/* Add a hover effect for buttons */ +button:hover { + opacity: 0.8; +} + +/* Extra style for the cancel button (red) */ +.cancelbtn { + width: auto; + padding: 10px 18px; + background-color: #f44336; +} + +/* Add padding to containers */ +.container { + padding: 16px; + display: flex; + justify-content: center; +} + +/* add border & center the table */ +.table { + border-spacing: 10px; + border:1px gray solid; + border-radius: 5px; + align-self: center; +} + +/* The "Forgot password" text */ +span.psw { + float: right; + padding-top: 16px; +} + +/* Change styles for span and cancel button on extra small screens */ +@media screen and (max-width: 300px) { + span.psw { + display: block; + float: none; + } + .cancelbtn { + width: 100%; + } +} diff --git a/client/src/app/login/login.component.html b/client/src/app/login/login.component.html new file mode 100755 index 0000000..5b5d9a0 --- /dev/null +++ b/client/src/app/login/login.component.html @@ -0,0 +1,29 @@ +
+

+ + + + + + + + + + + + + + + +
+ + + +
+ + + +
+ +
+
diff --git a/client/src/app/login/login.component.spec.ts b/client/src/app/login/login.component.spec.ts new file mode 100755 index 0000000..b0c41f7 --- /dev/null +++ b/client/src/app/login/login.component.spec.ts @@ -0,0 +1,30 @@ +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {RouterTestingModule} from '@angular/router/testing'; +import {LoginComponent} from './login.component'; +import {ToastrModule} from 'ngx-toastr'; + +describe('CtlComponent', () => { + let component: LoginComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ToastrModule.forRoot(), + RouterTestingModule.withRoutes([]), + ], + declarations: [LoginComponent] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/login/login.component.ts b/client/src/app/login/login.component.ts new file mode 100755 index 0000000..c7b2280 --- /dev/null +++ b/client/src/app/login/login.component.ts @@ -0,0 +1,40 @@ +import { Component, OnInit } from '@angular/core'; +import {WebsocketService} from 'src/services/websocket/websocket.service'; +import { WSReceiver, WebsocketMessage, Authentication } from 'src/services/websocket/websocket.models'; + +@Component({ + styleUrls: ['login.component.css'], + templateUrl: 'login.component.html', +}) + +export class LoginComponent implements WSReceiver, OnInit { + className = this.constructor.name; + type = 'ui'; // needed to have the websocket service in the constructor + component = 'auth'; // needed to have the websocket service in the constructor + + constructor(private websocketService: WebsocketService) {} + + ngOnInit(): void { + // bind the enter key to the submit button on the page + document.getElementById('passwd') + .addEventListener('keyup', (event) => { + event.preventDefault(); + if (event.key === 'Enter') { + document.getElementById('loginSubmit').click(); + } + }); + } + + // This will always throw an error but should never be called because we did not register a receiver + // The auth guard will take care of the auth messages since it's dealing with the tokens + receiver(message: WebsocketMessage): Promise { + throw new Error('Method not implemented.'); + } + + // formSubmit sends the auth request to the backend + public formSubmit(id, passwd): void { + const message = new WebsocketMessage(this.type, this.component, 'authenticate'); + message.authentication = new Authentication(id, passwd); + this.websocketService.sendMessage(message); + } +} diff --git a/client/src/app/login/login.module.ts b/client/src/app/login/login.module.ts new file mode 100755 index 0000000..affca77 --- /dev/null +++ b/client/src/app/login/login.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from '@angular/core'; +import { LoginComponent } from './login.component'; +import {ToastrModule} from 'ngx-toastr'; + +@NgModule({ + imports: [ + ToastrModule + ], + declarations: [ + LoginComponent, + ] +}) + +export class LoginModule { } diff --git a/client/src/services/auth-guard/auth-guard.service.spec.ts b/client/src/services/auth-guard/auth-guard.service.spec.ts new file mode 100755 index 0000000..d13614a --- /dev/null +++ b/client/src/services/auth-guard/auth-guard.service.spec.ts @@ -0,0 +1,23 @@ +import { async, TestBed } from '@angular/core/testing'; +import { AuthGuard } from './auth-guard.service'; +import { RouterTestingModule } from '@angular/router/testing'; +import {ToastrModule} from 'ngx-toastr'; + +describe('AuthGuardService', () => { + let service: AuthGuard; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([]), + ToastrModule.forRoot(), + ], + declarations: [] + }); + service = TestBed.inject(AuthGuard); + })); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/services/auth-guard/auth-guard.service.ts b/client/src/services/auth-guard/auth-guard.service.ts new file mode 100755 index 0000000..f8e38ff --- /dev/null +++ b/client/src/services/auth-guard/auth-guard.service.ts @@ -0,0 +1,196 @@ +import { Injectable, OnDestroy } from '@angular/core'; +import { Router, CanActivate, Event as RouterEvent, NavigationStart, NavigationEnd, NavigationCancel, NavigationError } from '@angular/router'; +import { Log } from 'src/services/log/log.service'; +import { LogMessage } from 'src/services/log/log-message'; +import { WebsocketService } from 'src/services/websocket/websocket.service'; +import { WSReceiver, WebsocketMessage, Authentication } from 'src/services/websocket/websocket.models'; + +@Injectable({ + providedIn: 'root' +}) + +export class AuthGuard implements WSReceiver, CanActivate { + // static router for those who may need it, I'm looking at your app components + public static router: Router; + + private className = this.constructor.name; + private loading = false; + private sendToLogin = false; + type = 'ui'; + component = 'auth'; + + // Called by the logout link at the top right of the page + public static logout(): void { + // blank out the object storage so we can't get re authenticate + WebsocketService.token = undefined; + WebsocketService.tokenExpiration = 0; + + // blank out the local storage so we can't get re authenticate + localStorage.removeItem('airshipUI-token'); + + // best to begin at the beginning so send the user back to /login + this.router.navigate(['/login']); + } + + constructor(private websocketService: WebsocketService, private router: Router) { + // create a static router so other components can access it if needs be + AuthGuard.router = router; + + this.websocketService.registerFunctions(this); + // listen to the evens that are sent out from the angular router so we don't wind up in an endless loop + this.router.events.subscribe((e: RouterEvent) => { + this.navigationInterceptor(e); + }); + } + + async receiver(message: WebsocketMessage): Promise { + if (message.hasOwnProperty('error')) { + Log.Error(new LogMessage('Error received in AuthGuard', this.className, message)); + this.websocketService.printIfToast(message); + AuthGuard.logout(); + } else { + switch (message.subComponent) { + case 'approved': + Log.Debug(new LogMessage('Auth approved received', this.className, message)); + this.setToken(message.token); + this.router.navigate(['/']); + break; + case 'denied': + Log.Debug(new LogMessage('Auth denied received', this.className, message)); + AuthGuard.logout(); + break; + default: + Log.Debug(new LogMessage('Unknown auth message received', this.className, message)); + AuthGuard.logout(); + break; + } + } + } + + // this decides if you can show a page + // TODO: maybe RBAC type of stuff may need to go here + canActivate(): boolean { + const location = window.location.pathname; + const authenticated = this.isAuthenticated(); + + // redirect everything to /login if not authenticated + if (!authenticated && location !== '/login/') { + // TODO: store the reference url and redirect after login + // let the loading function complete before sending to login otherwise the redirect fails + if (this.loading) { + this.sendToLogin = true; + } else { + // loading is complete just send to login + this.router.navigate(['/login']); + } + return true; + } + + // login page specific details + // redirect /login to / if authenticated and landing on /login + // TODO (aschiefe): not super happy about this setup, may need to simplify + if (location === '/login/') { + if (authenticated) { + this.router.navigate(['/']); + return false; + } else { + return true; + } + } + + // flip the link if we're in or out of the fold + this.toggleAuthButton(authenticated); + + return authenticated; + } + + // flip the text of the login / logout button according to where we are in the world + private toggleAuthButton(authenticated): void { + const button = document.getElementById('loginButton'); + const text = button.innerText; + if (authenticated && text === 'Login') { + button.innerText = 'Logout'; + } else if (!authenticated && text === 'Logout') { + button.innerText = 'Login'; + } + } + + // test the auth token to see if we can let the user see the page + // TODO: maybe RBAC type of stuff may need to go here + private isAuthenticated(): boolean { + if (WebsocketService.token === undefined) { this.getStoredToken(); } + try { + let authenticated = false; + // test for token expiration + // if the token is null the date test will always return true + if (WebsocketService.token !== undefined && WebsocketService.tokenExpiration > 0) { + authenticated = WebsocketService.tokenExpiration >= new Date().getTime(); + } + return authenticated; + } catch (ex) { + return false; + } + } + + // retrieve the stored token & send it to the go backend for validation + private getStoredToken(): void { + const tokenString = localStorage.getItem('airshipUI-token'); + const token = JSON.parse(tokenString); + if (token !== null) { + if (token.hasOwnProperty('token')) { + WebsocketService.token = token.token; + } + if (token.hasOwnProperty('date')) { + WebsocketService.tokenExpiration = token.date; + } + + // even after all this it's possible to have nothing. I started with nothing and still have most of it left + if (WebsocketService.token !== undefined) { + this.validateToken(); + } + } + } + + // the UI frontend is not the decider, the back end is. If this token is good we continue, if it's not we stop + private validateToken(): void { + const message = new WebsocketMessage(this.type, this.component, 'validate'); + message.token = WebsocketService.token; + this.websocketService.sendMessage(message); + } + + // store the token locally so we can be authenticated between runs + private setToken(token): void { + // calculate 1 hour expiration + const date = new Date(); + date.setTime(date.getTime() + (1 * 60 * 60 * 1000)); + + // set the token for auth check going forward + WebsocketService.token = token; + WebsocketService.tokenExpiration = date.getTime(); + + // set the token locally to have a login till browser exits + const json = { date: WebsocketService.tokenExpiration, token: WebsocketService.token }; + localStorage.setItem('airshipUI-token', JSON.stringify(json)); + } + + // detect navigation events in case we redirect from authguard which would happen too fast to protect /login and cause an endless loop + // Random Shack Data Processing Dictionary: Endless Loop: n., see Loop, Endless. Loop, Endless: n., see Endless Loop + private navigationInterceptor(event: RouterEvent): void { + if (event instanceof NavigationStart) { + this.loading = true; + } + if (event instanceof NavigationEnd) { + this.loading = false; + if (this.sendToLogin) { + this.router.navigate(['/login']); + this.sendToLogin = false; + } + } + if (event instanceof NavigationCancel) { + this.loading = false; + } + if (event instanceof NavigationError) { + this.loading = false; + } + } +} diff --git a/client/src/services/log/log-message.ts b/client/src/services/log/log-message.ts index 6719e4b..74f67a5 100755 --- a/client/src/services/log/log-message.ts +++ b/client/src/services/log/log-message.ts @@ -4,11 +4,11 @@ export class LogMessage { // the holy trinity of the websocket messages, a triumvirate if you will, which is how all are routed message: string; className: string; - wsMessage: WebsocketMessage; + logMessage: string | WebsocketMessage; - constructor(message?: string | undefined, className?: string | undefined, wsMessage?: WebsocketMessage | undefined) { + constructor(message?: string | undefined, className?: string | undefined, logMessage?: string | WebsocketMessage | undefined) { this.message = message; this.className = className; - this.wsMessage = wsMessage; + this.logMessage = logMessage; } } diff --git a/client/src/services/log/log.service.spec.ts b/client/src/services/log/log.service.spec.ts index 3aa6236..1b31592 100755 --- a/client/src/services/log/log.service.spec.ts +++ b/client/src/services/log/log.service.spec.ts @@ -1,5 +1,4 @@ import { TestBed } from '@angular/core/testing'; - import { Log } from './log.service'; describe('LogService', () => { diff --git a/client/src/services/log/log.service.ts b/client/src/services/log/log.service.ts index b0bef4d..a8774ef 100755 --- a/client/src/services/log/log.service.ts +++ b/client/src/services/log/log.service.ts @@ -34,7 +34,7 @@ export class Log { if (level <= this.Level) { console.log( '[airshipui][' + LogLevel[level] + '] ' + new Date().toLocaleString() + ' - ' + - message.className + ' - ' + message.message + ': ', message.wsMessage); + message.className + ' - ' + message.message + ': ', message.logMessage); } } } diff --git a/client/src/services/websocket/websocket.models.ts b/client/src/services/websocket/websocket.models.ts index 1731d37..464dfa8 100755 --- a/client/src/services/websocket/websocket.models.ts +++ b/client/src/services/websocket/websocket.models.ts @@ -7,6 +7,7 @@ export interface WSReceiver { receiver(message: WebsocketMessage): Promise; } +// WebsocketMessage is the structure for the json that is used to talk to the backend export class WebsocketMessage { sessionID: string; type: string; @@ -20,8 +21,10 @@ export class WebsocketMessage { id: string; isAuthenticated: boolean; message: string; + token: string; data: JSON; yaml: string; + authentication: Authentication; // this constructor looks like this in case anyone decides they want just a raw message with no data predefined // or an easy way to specify the defaults @@ -32,9 +35,21 @@ export class WebsocketMessage { } } +// Dashboard has the urls of the links that will pop out new dashboard tabs on the left hand side export class Dashboard { name: string; baseURL: string; path: string; isProxied: boolean; } + +// AuthMessage is used to send and auth request and hold the token if it's authenticated +export class Authentication { + id: string; + password: string; + + constructor(id?: string | undefined, password?: string | undefined) { + this.id = id; + this.password = password; + } +} diff --git a/client/src/services/websocket/websocket.service.ts b/client/src/services/websocket/websocket.service.ts index 1f2c1cb..f517088 100644 --- a/client/src/services/websocket/websocket.service.ts +++ b/client/src/services/websocket/websocket.service.ts @@ -1,5 +1,5 @@ import {Injectable, OnDestroy} from '@angular/core'; -import {WebsocketMessage, WSReceiver} from './websocket.models'; +import {WebsocketMessage, WSReceiver, Authentication} from './websocket.models'; import {ToastrService} from 'ngx-toastr'; import 'reflect-metadata'; @@ -8,6 +8,10 @@ import 'reflect-metadata'; }) export class WebsocketService implements OnDestroy { + // to avoid circular includes this has to go here + public static token: string; + public static tokenExpiration: number; + private ws: WebSocket; private timeout: any; private sessionID: string; @@ -39,11 +43,14 @@ export class WebsocketService implements OnDestroy { try { message.sessionID = this.sessionID; message.timestamp = new Date().getTime(); + if (WebsocketService.token !== undefined) { message.token = WebsocketService.token; } + // TODO (aschiefe): determine if this debug statement is a good thing (tm) + // Log.Debug(new LogMessage('Sending WebSocket Message', this.className, message)); this.ws.send(JSON.stringify(message)); } catch (err) { // on a refresh it may fire a request before the backend is ready so give it ye'ol retry // TODO (aschiefe): determine if there's a limit on retries - return new Promise( resolve => setTimeout(() => { this.sendMessage(message); }, 100)); + return new Promise(() => setTimeout(() => { this.sendMessage(message); }, 100)); } } diff --git a/client/yarn.lock b/client/yarn.lock index 6af08b0..b6f1779 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -262,6 +262,13 @@ dependencies: tslib "^2.0.0" +"@auth0/angular-jwt@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@auth0/angular-jwt/-/angular-jwt-5.0.1.tgz#37851d3ca2a0e88b3e673afd7dd2891f0c61bdf5" + integrity sha512-djllMh6rthPscEj5n5T9zF223q8t+sDqnUuAYTJjdKoHvMAzYwwi2yP67HbojqjODG4ZLFAcPtRuzGgp+r7nDQ== + dependencies: + tslib "^2.0.0" + "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.8.3": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a" diff --git a/go.mod b/go.mod index a25cc76..11d2567 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,12 @@ module opendev.org/airship/airshipui go 1.13 require ( + github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/google/uuid v1.1.1 github.com/gorilla/websocket v1.4.2 github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.0.0 github.com/stretchr/testify v1.6.1 - golang.org/x/net v0.0.0-20200625001655-4c5254603344 // indirect - golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f // indirect opendev.org/airship/airshipctl v0.0.0-20200812155702-f61953bcf558 sigs.k8s.io/kustomize/api v0.5.1 ) diff --git a/go.sum b/go.sum index 63b2ed3..8024810 100644 --- a/go.sum +++ b/go.sum @@ -1094,9 +1094,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191112222119-e1110fd1c708/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200128174031-69ecbb4d6d5d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 h1:xMPOj6Pz6UipU1wXLkrtqpHbR0AVFnyPEQq/wRWz9lM= golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1164,9 +1163,8 @@ golang.org/x/net v0.0.0-20191028085509-fe3aa8a45271/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20191112182307-2180aed22343/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a h1:GuSPYbZzB5/dcLNCwLQLsg3obCJtX9IJhpXkvY7kzk0= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200625001655-4c5254603344 h1:vGXIOMxbNfDTk/aXCmfdLgkrSV+Z2tcbze+pEc3v5W4= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1230,10 +1228,8 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191210023423-ac6580df4449/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 h1:uYVVQ9WP/Ds2ROhcaGPeIdVq0RIXVLwsHlnvJ+cT1So= golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f h1:gWF768j/LaZugp8dyS4UwsslYCYz9XgFxvlgsn0n9H8= -golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20171227012246-e19ae1496984/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/pkg/configs/configs.go b/pkg/configs/configs.go index ab8b312..390c81c 100644 --- a/pkg/configs/configs.go +++ b/pkg/configs/configs.go @@ -16,6 +16,8 @@ package configs import ( "crypto/rsa" + "crypto/sha512" + "encoding/hex" "encoding/json" "io/ioutil" "os" @@ -36,9 +38,10 @@ var ( // Config basic structure to hold configuration params for Airship UI type Config struct { - WebService *WebService `json:"webservice,omitempty"` - AuthMethod *AuthMethod `json:"authMethod,omitempty"` - Dashboards []Dashboard `json:"dashboards,omitempty"` + WebService *WebService `json:"webservice,omitempty"` + AuthMethod *AuthMethod `json:"authMethod,omitempty"` + Dashboards []Dashboard `json:"dashboards,omitempty"` + Users map[string]string `json:"users,omitempty"` } // AuthMethod structure to hold authentication parameters @@ -56,6 +59,12 @@ type WebService struct { PrivateKey string `json:"privateKey,omitempty"` } +// Authentication structure to hold authentication parameters +type Authentication struct { + ID string `json:"id,omitempty"` + Password string `json:"password,omitempty"` +} + // Dashboard structure type Dashboard struct { Name string `json:"name,omitempty"` @@ -86,15 +95,24 @@ const ( CTLConfig WsComponentType = "config" Baremetal WsComponentType = "baremetal" Document WsComponentType = "document" + Auth WsComponentType = "auth" - SetContext WsSubComponentType = "context" - SetCluster WsSubComponentType = "cluster" - SetCredential WsSubComponentType = "credential" + // auth sub components + Approved WsSubComponentType = "approved" + Authenticate WsSubComponentType = "authenticate" + Denied WsSubComponentType = "denied" + Refresh WsSubComponentType = "refresh" + Validate WsSubComponentType = "validate" + + // ctl components + GetDefaults WsSubComponentType = "getDefaults" GenerateISO WsSubComponentType = "generateISO" DocPull WsSubComponentType = "docPull" Yaml WsSubComponentType = "yaml" YamlWrite WsSubComponentType = "yamlWrite" GetYaml WsSubComponentType = "getYaml" + GetSource WsSubComponentType = "getSource" + GetRendered WsSubComponentType = "getRendered" GetPhaseTree WsSubComponentType = "getPhaseTree" GetPhaseSourceFiles WsSubComponentType = "getPhaseSource" GetPhaseDocuments WsSubComponentType = "getPhaseDocs" @@ -118,10 +136,14 @@ type WsMessage struct { YAML string `json:"yaml,omitempty"` Name string `json:"name,omitempty"` ID string `json:"id,omitempty"` + Token *string `json:"token,omitempty"` + + // used for auth + Authentication *Authentication `json:"authentication,omitempty"` // information related to the init of the UI Dashboards []Dashboard `json:"dashboards,omitempty"` - Authentication *AuthMethod `json:"authentication,omitempty"` + AuthMethod *AuthMethod `json:"authMethod,omitempty"` AuthInfoOptions *config.AuthInfoOptions `json:"authInfoOptions,omitempty"` ContextOptions *config.ContextOptions `json:"contextOptions,omitempty"` ClusterOptions *config.ClusterOptions `json:"clusterOptions,omitempty"` @@ -151,7 +173,9 @@ func SetUIConfig() error { } func checkConfigs() error { + writeFile := false if UIConfig.WebService == nil { + writeFile = true log.Debug("No UI config found, generating ssl keys & host & port info") err := setEtcDir() if err != nil { @@ -176,16 +200,32 @@ func checkConfigs() error { if err != nil { return err } - - bytes, err := json.Marshal(UIConfig) - if err != nil { - return err - } - err = ioutil.WriteFile(UIConfigFile, bytes, 0440) + } + if UIConfig.Users == nil { + writeFile = true + err := createDefaultUser() if err != nil { return err } } + + if writeFile { + bytes, err := json.Marshal(UIConfig) + if err != nil { + return err + } + return ioutil.WriteFile(UIConfigFile, bytes, 0600) + } + return nil +} + +func createDefaultUser() error { + hash := sha512.New() + _, err := hash.Write([]byte("admin")) + if err != nil { + return err + } + UIConfig.Users = map[string]string{"admin": hex.EncodeToString(hash.Sum(nil))} return nil } diff --git a/pkg/webservice/auth.go b/pkg/webservice/auth.go new file mode 100755 index 0000000..6f85d13 --- /dev/null +++ b/pkg/webservice/auth.go @@ -0,0 +1,124 @@ +/* + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package webservice + +import ( + "crypto/sha512" + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/dgrijalva/jwt-go" + "opendev.org/airship/airshipui/pkg/configs" + "opendev.org/airship/airshipui/pkg/log" +) + +// Create the JWT key used to create the signature +// TODO: use a private key for this instead of a phrase +var jwtKey = []byte("airshipUI_JWT_key") + +// The UI will either request authentication or validation, handle those situations here +func handleAuth(request configs.WsMessage) configs.WsMessage { + response := configs.WsMessage{ + Type: configs.UI, + Component: configs.Auth, + } + + var err error + switch request.SubComponent { + case configs.Authenticate: + if request.Authentication != nil { + var token *string + authRequest := request.Authentication + token, err = createToken(authRequest.ID, authRequest.Password) + sessions[request.SessionID].jwt = *token + response.SubComponent = configs.Approved + response.Token = token + } else { + err = errors.New("No AuthRequest found in the request") + } + case configs.Validate: + if request.Token != nil { + err = validateToken(*request.Token) + response.SubComponent = configs.Approved + response.Token = request.Token + } else { + err = errors.New("No token found in the request") + } + default: + err = errors.New("Invalid authentication request") + } + + if err != nil { + log.Error(err) + response.Error = err.Error() + response.SubComponent = configs.Denied + } + + return response +} + +// validate JWT (JSON Web Token) +func validateToken(tokenString string) error { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return jwtKey, nil + }) + + if err != nil { + return err + } + + if _, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + return nil + } + return errors.New("Invalid JWT Token") +} + +// create a JWT (JSON Web Token) +// TODO (aschiefe): for demo purposes, this is not to be used in production +func createToken(id string, passwd string) (*string, error) { + origPasswdHash, ok := configs.UIConfig.Users[id] + if !ok { + return nil, errors.New("Not authenticated") + } + + // test the password to make sure it's valid + hash := sha512.New() + _, err := hash.Write([]byte(passwd)) + if err != nil { + return nil, errors.New("Error authenticating") + } + if origPasswdHash != hex.EncodeToString(hash.Sum(nil)) { + return nil, errors.New("Not authenticated") + } + + // set some claims + claims := make(jwt.MapClaims) + claims["username"] = id + claims["password"] = passwd + claims["exp"] = time.Now().Add(time.Hour * 1).Unix() + + // create the token + jwtClaim := jwt.New(jwt.SigningMethodHS256) + jwtClaim.Claims = claims + + // Sign and get the complete encoded token as string + token, err := jwtClaim.SignedString(jwtKey) + return &token, err +} diff --git a/pkg/webservice/server.go b/pkg/webservice/server.go index 79ec641..eb668db 100755 --- a/pkg/webservice/server.go +++ b/pkg/webservice/server.go @@ -54,27 +54,10 @@ func serveFile(w http.ResponseWriter, r *http.Request) { } } -// handle an auth complete attempt -func handleAuth(http.ResponseWriter, *http.Request) { - // TODO: handle the response body to capture the credentials - err := WebSocketSend(configs.WsMessage{ - Type: configs.UI, - Component: configs.Authcomplete, - }) - - // error sending the websocket request - if err != nil { - log.Fatal(err) - } -} - // WebServer will run the handler functions for WebSockets func WebServer() { webServerMux := http.NewServeMux() - // some things may need a redirect so we'll give them a url to do that with - webServerMux.HandleFunc("/auth", handleAuth) - // hand off the websocket upgrade over http webServerMux.HandleFunc("/ws", onOpen) diff --git a/pkg/webservice/websocket.go b/pkg/webservice/websocket.go index db45ec0..f0d1542 100644 --- a/pkg/webservice/websocket.go +++ b/pkg/webservice/websocket.go @@ -31,6 +31,7 @@ import ( // session is a struct to hold information about a given session type session struct { id string + jwt string writeMutex sync.Mutex ws *websocket.Conn } @@ -49,6 +50,7 @@ var upgrader = websocket.Upgrader{ var functionMap = map[configs.WsRequestType]map[configs.WsComponentType]func(configs.WsMessage) configs.WsMessage{ configs.UI: { configs.Keepalive: keepaliveReply, + configs.Auth: handleAuth, }, configs.CTL: ctl.CTLFunctionMap, } @@ -86,27 +88,49 @@ func (session *session) onMessage() { // this has to be a go routine otherwise it will block any incoming messages waiting for a command return go func() { - // look through the function map to find the type to handle the request - if reqType, ok := functionMap[request.Type]; ok { - // the function map may have a component (function) to process the request - if component, ok := reqType[request.Component]; ok { - response := component(request) - if err = session.webSocketSend(response); err != nil { - session.onError(err) - } + // test the auth token for request validity on non auth requests + // TODO (aschiefe): this will need to be amended when refresh tokens are implemented + if request.Type != configs.UI && request.Component != configs.Auth && request.SubComponent != configs.Authenticate { + if request.Token != nil { + err = validateToken(*request.Token) } else { - if err = session.webSocketSend(requestErrorHelper(fmt.Sprintf("Requested component: %s, not found", - request.Component), request)); err != nil { - session.onError(err) - } - log.Errorf("Requested component: %s, not found\n", request.Component) + err = errors.New("No authentication token found") } - } else { - if err = session.webSocketSend(requestErrorHelper(fmt.Sprintf("Requested type: %s, not found", - request.Type), request)); err != nil { + } + if err != nil { + // deny the request if we get a bad token, this will force the UI to a login screen + response := configs.WsMessage{ + Type: configs.UI, + Component: configs.Auth, + SubComponent: configs.Denied, + Error: "Invalid token, authentication denied", + } + if err = session.webSocketSend(response); err != nil { session.onError(err) } - log.Errorf("Requested type: %s, not found\n", request.Type) + } else { + // look through the function map to find the type to handle the request + if reqType, ok := functionMap[request.Type]; ok { + // the function map may have a component (function) to process the request + if component, ok := reqType[request.Component]; ok { + response := component(request) + if err = session.webSocketSend(response); err != nil { + session.onError(err) + } + } else { + if err = session.webSocketSend(requestErrorHelper(fmt.Sprintf("Requested component: %s, not found", + request.Component), request)); err != nil { + session.onError(err) + } + log.Errorf("Requested component: %s, not found\n", request.Component) + } + } else { + if err = session.webSocketSend(requestErrorHelper(fmt.Sprintf("Requested type: %s, not found", + request.Type), request)); err != nil { + session.onError(err) + } + log.Errorf("Requested type: %s, not found\n", request.Type) + } } }() } @@ -181,11 +205,10 @@ func WebSocketSend(response configs.WsMessage) error { // sendInit is generated on the onOpen event and sends the information the UI needs to startup func (session *session) sendInit() { if err := session.webSocketSend(configs.WsMessage{ - Type: configs.UI, - Component: configs.Initialize, - IsAuthenticated: true, - Dashboards: configs.UIConfig.Dashboards, - Authentication: configs.UIConfig.AuthMethod, + Type: configs.UI, + Component: configs.Initialize, + Dashboards: configs.UIConfig.Dashboards, + AuthMethod: configs.UIConfig.AuthMethod, }); err != nil { log.Errorf("Error receiving / sending init to session %s: %s\n", session.id, err) }