From b7260326eb82480408f5f269876f9e7472a859ec Mon Sep 17 00:00:00 2001 From: "Schiefelbein, Andrew" Date: Tue, 25 Aug 2020 14:26:39 -0500 Subject: [PATCH] add auth login page and related functionality 1. Created a login page to create a JWT with the backend 2. Store the token locally so it can be reused between runs 3. Redirect to login on no auth 4. Redirect from login when already authenticated 5. Add a login / logout link 6. Clean up empty .css files from the tree TODO: the JWT needs to generate a refresh key yet Change-Id: I97b6f92e4ca897768c91d7816e2ef44dcd9d3acf --- client/package.json | 1 + client/src/app/app-routing.module.ts | 12 +- client/src/app/app.component.html | 2 +- client/src/app/app.component.ts | 22 +- .../app/ctl/baremetal/baremetal.component.css | 0 .../app/ctl/baremetal/baremetal.component.ts | 1 - client/src/app/ctl/ctl-routing.module.ts | 3 + client/src/app/ctl/ctl.component.css | 0 client/src/app/ctl/ctl.component.ts | 1 - client/src/app/home/home.component.css | 0 client/src/app/home/home.component.ts | 1 - client/src/app/login/login.component.css | 69 ++++++ client/src/app/login/login.component.html | 29 +++ client/src/app/login/login.component.spec.ts | 30 +++ client/src/app/login/login.component.ts | 40 ++++ client/src/app/login/login.module.ts | 14 ++ .../auth-guard/auth-guard.service.spec.ts | 23 ++ .../services/auth-guard/auth-guard.service.ts | 196 ++++++++++++++++++ client/src/services/log/log-message.ts | 6 +- client/src/services/log/log.service.spec.ts | 1 - client/src/services/log/log.service.ts | 2 +- .../services/websocket/websocket.models.ts | 15 ++ .../services/websocket/websocket.service.ts | 11 +- client/yarn.lock | 7 + go.mod | 3 +- go.sum | 10 +- pkg/configs/configs.go | 66 ++++-- pkg/webservice/auth.go | 124 +++++++++++ pkg/webservice/server.go | 17 -- pkg/webservice/websocket.go | 67 ++++-- 30 files changed, 692 insertions(+), 81 deletions(-) mode change 100644 => 100755 client/package.json delete mode 100644 client/src/app/ctl/baremetal/baremetal.component.css delete mode 100644 client/src/app/ctl/ctl.component.css delete mode 100644 client/src/app/home/home.component.css create mode 100755 client/src/app/login/login.component.css create mode 100755 client/src/app/login/login.component.html create mode 100755 client/src/app/login/login.component.spec.ts create mode 100755 client/src/app/login/login.component.ts create mode 100755 client/src/app/login/login.module.ts create mode 100755 client/src/services/auth-guard/auth-guard.service.spec.ts create mode 100755 client/src/services/auth-guard/auth-guard.service.ts create mode 100755 pkg/webservice/auth.go 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) }