Merge branch 'bvander/not-authorized-flow' into 'master'

blocking access on 401/403

See merge request grassrootseconomics/cic-staff-client!9
This commit is contained in:
Blair Vanderlugt 2021-04-29 05:29:55 +00:00
commit d3285a5478
10 changed files with 173 additions and 123 deletions

View File

@ -22,6 +22,9 @@ Run `ng generate module module-name --route module-name --module app.module` to
## Build ## Build
set you environment variables - set these via environment variables as found in set-env.ts
// TODO create a .env file so people don't have to set these one-by-one
Run `npm run build:dev` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `build:prod` script for a production build. Run `npm run build:dev` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `build:prod` script for a production build.
## Running unit tests ## Running unit tests

View File

@ -1,6 +1,7 @@
// @ts-ignore // @ts-ignore
import * as registry from '@src/assets/js/block-sync/data/Registry.json'; import * as registry from '@src/assets/js/block-sync/data/Registry.json';
import {environment} from '@src/environments/environment'; import {environment} from '@src/environments/environment';
import {LoggingService} from '@app/_services/logging.service';
const Web3 = require('web3'); const Web3 = require('web3');
const web3 = new Web3(environment.web3Provider); const web3 = new Web3(environment.web3Provider);
@ -27,6 +28,12 @@ export class Registry {
public async addressOf(identifier: string): Promise<string> { public async addressOf(identifier: string): Promise<string> {
const id = '0x' + web3.utils.padRight(new Buffer(identifier).toString('hex'), 64); const id = '0x' + web3.utils.padRight(new Buffer(identifier).toString('hex'), 64);
return await this.contract.methods.addressOf(id).call(); try {
return await this.contract.methods.addressOf(id).call();
} catch (error) {
// TODO logger service
// this.loggingService.sendInfoLevelMessage
console.log('Unable to fetch addressOf', error)
}
} }
} }

View File

@ -12,10 +12,9 @@ export class AuthGuard implements CanActivate {
canActivate( canActivate(
route: ActivatedRouteSnapshot, route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree { state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
if (sessionStorage.getItem(btoa('CICADA_SESSION_TOKEN'))) { if (localStorage.getItem(btoa('CICADA_PRIVATE_KEY'))) {
return true; return true;
} }
this.router.navigate(['/auth']); this.router.navigate(['/auth']);
return false; return false;
} }

View File

@ -3,6 +3,16 @@ import {LoggingService} from '@app/_services/logging.service';
import {HttpErrorResponse} from '@angular/common/http'; import {HttpErrorResponse} from '@angular/common/http';
import {Router} from '@angular/router'; import {Router} from '@angular/router';
// A generalized http repsonse error
export class HttpError extends Error {
public status: number
constructor(message, status) {
super(message);
this.status = status;
this.name = 'HttpError';
}
}
@Injectable() @Injectable()
export class GlobalErrorHandler extends ErrorHandler { export class GlobalErrorHandler extends ErrorHandler {
private sentencesForWarningLogging: string[] = []; private sentencesForWarningLogging: string[] = [];
@ -14,13 +24,13 @@ export class GlobalErrorHandler extends ErrorHandler {
super(); super();
} }
handleError(error: any): void { handleError(error: Error): void {
this.logError(error); this.logError(error);
const message = error.message ? error.message : error.toString(); const message = error.message ? error.message : error.toString();
if (error.status) { // if (error.status) {
error = new Error(message); // error = new Error(message);
} // }
const errorTraceString = `Error message:\n${message}.\nStack trace: ${error.stack}`; const errorTraceString = `Error message:\n${message}.\nStack trace: ${error.stack}`;

View File

@ -20,29 +20,30 @@ export class ErrorInterceptor implements HttpInterceptor {
) {} ) {}
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> { intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
return next.handle(request).pipe( return next.handle(request).pipe(
catchError((err: HttpErrorResponse) => { catchError((err: HttpErrorResponse) => {
let errorMessage; let errorMessage;
if (err.error instanceof ErrorEvent) { if (err.error instanceof ErrorEvent) {
// A client-side or network error occurred. Handle it accordingly. // A client-side or network error occurred. Handle it accordingly.
errorMessage = `An error occurred: ${err.error.message}`; errorMessage = `An error occurred: ${err.error.message}`;
} else { } else {
// The backend returned an unsuccessful response code. // The backend returned an unsuccessful response code.
// The response body may contain clues as to what went wrong. // The response body may contain clues as to what went wrong.
errorMessage = `Backend returned code ${err.status}, body was: ${JSON.stringify(err.error)}`; errorMessage = `Backend returned code ${err.status}, body was: ${JSON.stringify(err.error)}`;
} }
this.loggingService.sendErrorLevelMessage(errorMessage, this, {error: err}); this.loggingService.sendErrorLevelMessage(errorMessage, this, {error: err});
switch (err.status) { switch (err.status) {
case 401: // unauthorized case 401: // unauthorized
this.router.navigateByUrl('/auth').then(); this.router.navigateByUrl('/auth').then();
break; break;
case 403: // forbidden case 403: // forbidden
location.reload(true); location.reload(true);
break; break;
} }
// Return an observable with a user-facing error message. // Return an observable with a user-facing error message.
return throwError(err); return throwError(err);
}) })
); );
return next.handle(request);
} }
} }

View File

@ -18,20 +18,21 @@ export class LoggingInterceptor implements HttpInterceptor {
) {} ) {}
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> { intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
this.loggingService.sendInfoLevelMessage(request); return next.handle(request);
const startTime = Date.now(); // this.loggingService.sendInfoLevelMessage(request);
let status: string; // const startTime = Date.now();
// let status: string;
return next.handle(request).pipe(tap(event => { // return next.handle(request).pipe(tap(event => {
status = ''; // status = '';
if (event instanceof HttpResponse) { // if (event instanceof HttpResponse) {
status = 'succeeded'; // status = 'succeeded';
} // }
}, error => status = 'failed'), // }, error => status = 'failed'),
finalize(() => { // finalize(() => {
const elapsedTime = Date.now() - startTime; // const elapsedTime = Date.now() - startTime;
const message = `${request.method} request for ${request.urlWithParams} ${status} in ${elapsedTime} ms`; // const message = `${request.method} request for ${request.urlWithParams} ${status} in ${elapsedTime} ms`;
this.loggingService.sendInfoLevelMessage(message); // this.loggingService.sendInfoLevelMessage(message);
})); // }));
} }
} }

View File

@ -1,12 +1,13 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { hobaParseChallengeHeader } from '@src/assets/js/hoba.js'; import { hobaParseChallengeHeader } from '@src/assets/js/hoba.js';
import { signChallenge } from '@src/assets/js/hoba-pgp.js'; import { signChallenge } from '@src/assets/js/hoba-pgp.js';
import {environment} from '@src/environments/environment'; import { environment } from '@src/environments/environment';
import {LoggingService} from '@app/_services/logging.service'; import { LoggingService } from '@app/_services/logging.service';
import {MutableKeyStore, MutablePgpKeyStore} from '@app/_pgp'; import { MutableKeyStore, MutablePgpKeyStore } from '@app/_pgp';
import {ErrorDialogService} from '@app/_services/error-dialog.service'; import { ErrorDialogService } from '@app/_services/error-dialog.service';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import {Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { HttpError } from '@app/_helpers/global-error-handler';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -14,20 +15,25 @@ import {Observable } from 'rxjs';
export class AuthService { export class AuthService {
sessionToken: any; sessionToken: any;
sessionLoginCount = 0; sessionLoginCount = 0;
privateKey: any; mutableKeyStore: MutableKeyStore;
mutableKeyStore: MutableKeyStore = new MutablePgpKeyStore();
constructor( constructor(
private httpClient: HttpClient, private httpClient: HttpClient,
private loggingService: LoggingService, private loggingService: LoggingService,
private errorDialogService: ErrorDialogService private errorDialogService: ErrorDialogService
) { ) {
this.mutableKeyStore = new MutablePgpKeyStore()
}
async init(): Promise<void> {
this.mutableKeyStore.loadKeyring();
// TODO setting these together shoulds be atomic // TODO setting these together shoulds be atomic
if (sessionStorage.getItem(btoa('CICADA_SESSION_TOKEN'))) { if (sessionStorage.getItem(btoa('CICADA_SESSION_TOKEN'))) {
this.sessionToken = sessionStorage.getItem(btoa('CICADA_SESSION_TOKEN')); this.sessionToken = sessionStorage.getItem(btoa('CICADA_SESSION_TOKEN'));
} }
if (localStorage.getItem(btoa('CICADA_PRIVATE_KEY'))) { if (localStorage.getItem(btoa('CICADA_PRIVATE_KEY'))) {
this.privateKey = localStorage.getItem(btoa('CICADA_PRIVATE_KEY')); this.mutableKeyStore.importPrivateKey(localStorage.getItem(btoa('CICADA_PRIVATE_KEY')))
// this.privateKey = localStorage.getItem(btoa('CICADA_PRIVATE_KEY'));
} }
} }
@ -53,24 +59,28 @@ export class AuthService {
xhr.send(); xhr.send();
} }
sendResponse(hobaResponseEncoded): void { //TODO renmae to send signed challenge and set session. Also seperate these responsibilities
const xhr = new XMLHttpRequest(); sendResponse(hobaResponseEncoded): Promise<boolean> {
xhr.responseType = 'text'; return new Promise((resolve, reject) => {
xhr.open('GET', environment.cicMetaUrl + window.location.search.substring(1)); const xhr = new XMLHttpRequest();
xhr.setRequestHeader('Authorization', 'HOBA ' + hobaResponseEncoded); xhr.responseType = 'text';
xhr.setRequestHeader('Content-Type', 'application/json'); xhr.open('GET', environment.cicMetaUrl + window.location.search.substring(1));
xhr.setRequestHeader('x-cic-automerge', 'none'); xhr.setRequestHeader('Authorization', 'HOBA ' + hobaResponseEncoded);
xhr.addEventListener('load', (e) => { xhr.setRequestHeader('Content-Type', 'application/json');
if (xhr.status === 401) { xhr.setRequestHeader('x-cic-automerge', 'none');
throw new Error('login rejected'); xhr.addEventListener('load', (e) => {
} if (xhr.status !== 200) {
this.sessionToken = xhr.getResponseHeader('Token'); const error = new HttpError(xhr.statusText, xhr.status);
sessionStorage.setItem(btoa('CICADA_SESSION_TOKEN'), this.sessionToken); return reject(error);
this.sessionLoginCount++; }
this.setState('Click button to log in'); this.sessionToken = xhr.getResponseHeader('Token');
return; sessionStorage.setItem(btoa('CICADA_SESSION_TOKEN'), this.sessionToken);
}); this.sessionLoginCount++;
xhr.send(); this.setState('Click button to log in');
return resolve(true);
});
xhr.send();
})
} }
getChallenge(): void { getChallenge(): void {
@ -81,39 +91,38 @@ export class AuthService {
if (xhr.status === 401) { if (xhr.status === 401) {
const authHeader = xhr.getResponseHeader('WWW-Authenticate'); const authHeader = xhr.getResponseHeader('WWW-Authenticate');
const o = hobaParseChallengeHeader(authHeader); const o = hobaParseChallengeHeader(authHeader);
await this.loginResponse(o); this.loginResponse(o);
} }
}; };
xhr.send(); xhr.send();
} }
login(): boolean {
if (this.sessionToken !== undefined) {
try {
this.getWithToken();
return true;
} catch (e) {
this.loggingService.sendErrorLevelMessage('Login token failed', this, {error: e});
}
} else {
try {
this.getChallenge();
} catch (e) {
this.loggingService.sendErrorLevelMessage('Login challenge failed', this, {error: e});
}
}
return false;
}
async loginResponse(o): Promise<any> { async loginResponse(o): Promise<any> {
try { return new Promise(async(resolve, reject) => {
const r = await signChallenge(o.challenge, o.realm, environment.cicMetaUrl, this.mutableKeyStore); try {
this.sendResponse(r); const r = await signChallenge(o.challenge,
} catch (error) { o.realm,
this.errorDialogService.openDialog({message: 'Incorrect key passphrase.'}); environment.cicMetaUrl,
} this.mutableKeyStore);
const sessionTokenResult = await this.sendResponse(r);
} catch (error) {
if (error instanceof HttpError) {
if (error.status === 403) {
this.errorDialogService.openDialog({ message: 'You are not authorized to use this system' })
}
if (error.status === 401) {
this.errorDialogService.openDialog({ message: 'Unable to authenticate with the service. ' +
'Please speak with the staff at Grassroots ' +
'Economics for requesting access ' +
'staff@grassrootseconomics.net.' })
}
}
// TODO define this error
this.errorDialogService.openDialog({message: 'Incorrect key passphrase.'});
resolve(false);
}
});
} }
loginView(): void { loginView(): void {
@ -128,10 +137,11 @@ export class AuthService {
if (!isValidKeyCheck) { if (!isValidKeyCheck) {
throw Error('The private key is invalid'); throw Error('The private key is invalid');
} }
const isEncryptedKeyCheck = await this.mutableKeyStore.isEncryptedPrivateKey(privateKeyArmored); // TODO leaving this out for now.
if (!isEncryptedKeyCheck) { //const isEncryptedKeyCheck = await this.mutableKeyStore.isEncryptedPrivateKey(privateKeyArmored);
throw Error('The private key doesn\'t have a password!'); //if (!isEncryptedKeyCheck) {
} // throw Error('The private key doesn\'t have a password!');
//}
const key = await this.mutableKeyStore.importPrivateKey(privateKeyArmored); const key = await this.mutableKeyStore.importPrivateKey(privateKeyArmored);
localStorage.setItem(btoa('CICADA_PRIVATE_KEY'), privateKeyArmored); localStorage.setItem(btoa('CICADA_PRIVATE_KEY'), privateKeyArmored);
} catch (err) { } catch (err) {
@ -157,13 +167,19 @@ export class AuthService {
return trustedUsers; return trustedUsers;
} }
getPublicKeys(): Observable<any> { async getPublicKeys(): Promise<any> {
return this.httpClient.get(`${environment.publicKeysUrl}`, {responseType: 'text'}); const data = await fetch(environment.publicKeysUrl)
.then(res => {
if (!res.ok) {
//TODO does angular recommend an error interface?
throw Error(`${res.statusText} - ${res.status}`);
}
return res.text();
})
return data;
} }
async getPrivateKeys(): Promise<void> { getPrivateKey(): any {
if (this.privateKey !== undefined) { return this.mutableKeyStore.getPrivateKey();
await this.mutableKeyStore.importPrivateKey(this.privateKey);
}
} }
} }

View File

@ -21,12 +21,20 @@ export class AppComponent {
private errorDialogService: ErrorDialogService private errorDialogService: ErrorDialogService
) { ) {
(async () => { (async () => {
await this.authService.mutableKeyStore.loadKeyring(); try {
this.authService.getPublicKeys() await this.authService.mutableKeyStore.loadKeyring();
.pipe(catchError(async (error) => { // this.authService.getPublicKeys()
this.loggingService.sendErrorLevelMessage('Unable to load trusted public keys.', this, {error}); // .pipe(catchError(async (error) => {
this.errorDialogService.openDialog({message: 'Trusted keys endpoint can\'t be reached. Please try again later.'}); // this.loggingService.sendErrorLevelMessage('Unable to load trusted public keys.', this, {error});
})).subscribe(this.authService.mutableKeyStore.importPublicKey); // this.errorDialogService.openDialog({message: 'Trusted keys endpoint can\'t be reached. Please try again later.'});
// })).subscribe(this.authService.mutableKeyStore.importPublicKey);
const publicKeys = await this.authService.getPublicKeys()
await this.authService.mutableKeyStore.importPublicKey(publicKeys);
} catch(error) {
this.errorDialogService.openDialog({message: 'Trusted keys endpoint can\'t be reached. Please try again later.'});
// TODO do something to halt user progress...show a sad cicada page 🦗?
}
})(); })();
this.mediaQuery.addListener(this.onResize); this.mediaQuery.addListener(this.onResize);
this.onResize(this.mediaQuery); this.onResize(this.mediaQuery);

View File

@ -26,12 +26,11 @@ export class AuthComponent implements OnInit {
this.keyForm = this.formBuilder.group({ this.keyForm = this.formBuilder.group({
key: ['', Validators.required], key: ['', Validators.required],
}); });
if (this.authService.privateKey !== undefined) { await this.authService.init();
const setKey = await this.authService.setKey(this.authService.privateKey); //if (this.authService.privateKey !== undefined) {
if (setKey && this.authService.sessionToken !== undefined) { // const setKey = await this.authService.setKey(this.authService.privateKey);
this.authService.setState('Click button to log in'); // }
} //}
}
} }
get keyFormStub(): any { return this.keyForm.controls; } get keyFormStub(): any { return this.keyForm.controls; }
@ -47,8 +46,12 @@ export class AuthComponent implements OnInit {
} }
login(): void { login(): void {
const loginStatus = this.authService.login(); // TODO check if we have privatekey
if (loginStatus) { // Send us to home if we have a private key
// talk to meta somehow
// in the error interceptor if 401/403 handle it
// if 200 go /home
if (this.authService.getPrivateKey()) {
this.router.navigate(['/home']); this.router.navigate(['/home']);
} }
} }

View File

@ -27,9 +27,11 @@ export class AccountsComponent implements OnInit {
private userService: UserService, private userService: UserService,
private loggingService: LoggingService, private loggingService: LoggingService,
private router: Router private router: Router
) { )
{
(async () => { (async () => {
try { try {
// TODO it feels like this shuold be in the onInit handler
await this.userService.loadAccounts(100); await this.userService.loadAccounts(100);
} catch (error) { } catch (error) {
this.loggingService.sendErrorLevelMessage('Failed to load accounts', this, {error}); this.loggingService.sendErrorLevelMessage('Failed to load accounts', this, {error});