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)
}