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
This commit is contained in:
Schiefelbein, Andrew 2020-08-25 14:26:39 -05:00
parent f59f2bc6eb
commit b7260326eb
30 changed files with 692 additions and 81 deletions

1
client/package.json Normal file → Executable file
View File

@ -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",

View File

@ -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 {}

View File

@ -57,7 +57,7 @@
<mat-toolbar color="primary" class="toolbar-header">
<button mat-icon-button (click)="sidenav.toggle()"><mat-icon svgIcon="list"></mat-icon></button>
<span class="spacer"></span>
<button mat-icon-button><mat-icon svgIcon="account"></mat-icon></button>
<button mat-icon-button (click)="authToggle()" id="loginButton">Login</button>
</mat-toolbar>
<router-outlet></router-outlet>
<span class="page-body"></span>

View File

@ -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();
}

View File

@ -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 {

View File

@ -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
}];

View File

@ -3,7 +3,6 @@ import {Component} from '@angular/core';
@Component({
selector: 'app-ctl',
templateUrl: './ctl.component.html',
styleUrls: ['./ctl.component.css']
})
export class CtlComponent {
}

View File

@ -3,6 +3,5 @@ import {Component} from '@angular/core';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.css']
})
export class HomeComponent {}

View File

@ -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%;
}
}

View File

@ -0,0 +1,29 @@
<div class="container">
<p></p>
<table class="table">
<tbody>
<tr>
<td>
<label for="uname"><b>Username</b></label>
</td>
<td>
<input type="text" id="userName" placeholder="Enter Username" name="uname" #id required>
</td>
</tr>
<tr>
<td>
<label for="psw"><b>Password</b></label>
</td>
<td>
<input type="password" id="passwd" placeholder="Enter Password" name="psw" #passwd required>
</td>
</tr>
<tr>
<td></td>
<td style="text-align:right">
<button type="submit" id="loginSubmit" (click)="formSubmit(id.value,passwd.value)">Login</button>
</td>
</tr>
</tbody>
</table>
</div>

View File

@ -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<LoginComponent>;
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();
});
});

View File

@ -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<void> {
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);
}
}

View File

@ -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 { }

View File

@ -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();
});
});

View File

@ -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<void> {
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;
}
}
}

View File

@ -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;
}
}

View File

@ -1,5 +1,4 @@
import { TestBed } from '@angular/core/testing';
import { Log } from './log.service';
describe('LogService', () => {

View File

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

View File

@ -7,6 +7,7 @@ export interface WSReceiver {
receiver(message: WebsocketMessage): Promise<void>;
}
// 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;
}
}

View File

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

View File

@ -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"

3
go.mod
View File

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

10
go.sum
View File

@ -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=

View File

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

124
pkg/webservice/auth.go Executable file
View File

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

View File

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

View File

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