Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,27 @@ You can switch on to show open TODO's for evidence by changing IS_SHOW_EVIDENCE_

This page uses the Browser's localStorage to store the state of the circular headmap.

# Static Demo Authentication

This Angular frontend includes simple static-user authentication for demo and internal
deployments. All users have the same permissions.

Default credentials are defined in `src/app/services/auth.service.ts`:

| Username | Password |
| --- | --- |
| `admin` | `dsomm-admin` |
| `auditor` | `dsomm-audit` |
| `developer` | `dsomm-dev` |
| `viewer` | `dsomm-view` |

Sign in at `/login`. The app stores the current user in the browser's `sessionStorage`, so the
login lasts only for the current browser session.

Security warning: this is frontend-only authentication. It is not secure for production because
static credentials are shipped in the browser bundle and can be inspected by users. Use a backend
identity provider or server-side access control for production deployments.

# Changes
Changes to the application are displayed at the release page of [DevSecOps-MaturityModel](https://github.com/devsecopsmaturitymodel/DevSecOps-MaturityModel-data/releases).

Expand Down
37 changes: 23 additions & 14 deletions src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, NgModule } from '@angular/core';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AboutUsComponent } from './pages/about-us/about-us.component';
import { UserdayComponent } from './pages/userday/userday.component';
Expand All @@ -11,21 +11,30 @@ import { TeamsComponent } from './pages/teams/teams.component';
import { RoadmapComponent } from './pages/roadmap/roadmap.component';
import { SettingsComponent } from './pages/settings/settings.component';
import { ReportComponent } from './pages/report/report.component';
import { AuthGuard } from './guards/auth.guard';
import { LoginComponent } from './pages/login/login.component';

const routes: Routes = [
{ path: '', component: CircularHeatmapComponent },
{ path: 'circular-heatmap', component: CircularHeatmapComponent },
{ path: 'matrix', component: MatrixComponent },
{ path: 'activity-description', component: ActivityDescriptionPageComponent },
{ path: 'mapping', component: MappingComponent },
{ path: 'usage', redirectTo: 'usage/' },
{ path: 'usage/:page', component: UsageComponent },
{ path: 'teams', component: TeamsComponent },
{ path: 'about', component: AboutUsComponent },
{ path: 'userday', component: UserdayComponent },
{ path: 'roadmap', component: RoadmapComponent },
{ path: 'settings', component: SettingsComponent },
{ path: 'report', component: ReportComponent },
{ path: 'login', component: LoginComponent, canActivate: [AuthGuard] },
{
path: '',
canActivateChild: [AuthGuard],
children: [
{ path: '', component: CircularHeatmapComponent },
{ path: 'circular-heatmap', component: CircularHeatmapComponent },
{ path: 'matrix', component: MatrixComponent },
{ path: 'activity-description', component: ActivityDescriptionPageComponent },
{ path: 'mapping', component: MappingComponent },
{ path: 'usage', redirectTo: 'usage/' },
{ path: 'usage/:page', component: UsageComponent },
{ path: 'teams', component: TeamsComponent },
{ path: 'about', component: AboutUsComponent },
{ path: 'userday', component: UserdayComponent },
{ path: 'roadmap', component: RoadmapComponent },
{ path: 'settings', component: SettingsComponent },
{ path: 'report', component: ReportComponent },
],
},
];

@NgModule({
Expand Down
32 changes: 31 additions & 1 deletion src/app/app.component.css
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,40 @@
transform: scale(1.05);
}

.toolbar-spacer {
flex: 1 1 auto;
}

.auth-actions {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}

.current-user {
font-size: 14px;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.content {
padding: 24px;
animation: fadeSlide 1s ease;
height: 100%;
box-sizing: border-box;
overflow-y: auto;
}

.login-content {
flex: 1;
overflow-y: auto;
}

@keyframes fadeSlide {
from {
opacity: 0;
Expand All @@ -102,6 +129,9 @@
.tag-subtitle {
font-size: 11px;
}
.current-user {
display: none;
}
.logo,
.logo-icon {
opacity: 0;
Expand All @@ -110,4 +140,4 @@
margin: 0;
overflow: hidden;
}
}
}
30 changes: 22 additions & 8 deletions src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<mat-toolbar class="mat-elevation-z2 navbar">
<button mat-icon-button (click)="toggleMenu()" class="menu-btn">
<button mat-icon-button (click)="toggleMenu()" class="menu-btn" *ngIf="!isLoginPage">
<mat-icon>menu</mat-icon>
</button>
<a routerLink="/" class="logo">
Expand All @@ -16,12 +16,26 @@
</div>
<div class="dummy"></div>
</div>
<span class="toolbar-spacer"></span>
<div class="auth-actions" *ngIf="isAuthenticated">
<span class="current-user">{{ currentUser }}</span>
<button mat-icon-button (click)="logout()" title="Logout" aria-label="Logout">
<mat-icon>logout</mat-icon>
</button>
</div>
</mat-toolbar>
<mat-sidenav-container class="sidenav-container">
<mat-sidenav mode="side" opened class="sidenav" [style.width]="sidenavWidth">
<app-sidenav-buttons></app-sidenav-buttons>
</mat-sidenav>
<mat-sidenav-content class="content" [style.margin-left]="sidenavWidth">
<ng-container *ngIf="isLoginPage; else dsommLayout">
<main class="login-content">
<router-outlet></router-outlet>
</mat-sidenav-content>
</mat-sidenav-container>
</main>
</ng-container>
<ng-template #dsommLayout>
<mat-sidenav-container class="sidenav-container">
<mat-sidenav mode="side" opened class="sidenav" [style.width]="sidenavWidth">
<app-sidenav-buttons></app-sidenav-buttons>
</mat-sidenav>
<mat-sidenav-content class="content" [style.margin-left]="sidenavWidth">
<router-outlet></router-outlet>
</mat-sidenav-content>
</mat-sidenav-container>
</ng-template>
35 changes: 33 additions & 2 deletions src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subject, takeUntil } from 'rxjs';
import { NavigationEnd, Router } from '@angular/router';
import { filter, Subject, takeUntil } from 'rxjs';
import { ThemeService } from './service/theme.service';
import { TitleService } from './service/title.service';
import { AuthService } from './services/auth.service';

@Component({
selector: 'app-root',
Expand All @@ -14,10 +16,16 @@ export class AppComponent implements OnInit, OnDestroy {
subtitle = '';
menuIsOpen: boolean = true;
sidenavWidth: string = '250px';
isLoginPage = false;

private destroy$ = new Subject<void>();

constructor(private themeService: ThemeService, private titleService: TitleService) {
constructor(
private themeService: ThemeService,
private titleService: TitleService,
private authService: AuthService,
private router: Router
) {
this.themeService.initTheme();
}

Expand All @@ -37,6 +45,16 @@ export class AppComponent implements OnInit, OnDestroy {
this.title = titleInfo?.dimension || '';
this.subtitle = titleInfo?.level ? 'Level ' + titleInfo?.level : '';
});

this.isLoginPage = this.router.url.split('?')[0] === '/login';
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntil(this.destroy$)
)
.subscribe(event => {
this.isLoginPage = event.urlAfterRedirects.split('?')[0] === '/login';
});
}

ngOnDestroy(): void {
Expand All @@ -49,4 +67,17 @@ export class AppComponent implements OnInit, OnDestroy {
this.sidenavWidth = this.menuIsOpen ? '250px' : '0px';
localStorage.setItem('state.menuIsOpen', this.menuIsOpen.toString());
}

get isAuthenticated(): boolean {
return this.authService.isAuthenticated();
}

get currentUser(): string | null {
return this.authService.getCurrentUser();
}

logout(): void {
this.authService.logout();
void this.router.navigate(['/login']);
}
}
2 changes: 2 additions & 0 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { ColResizeDirective } from './directive/col-resize.directive';
import { AddEvidenceModalComponent } from './component/add-evidence-modal/add-evidence-modal.component';
import { EvidencePanelComponent } from './component/evidence-panel/evidence-panel.component';
import { ViewEvidenceModalComponent } from './component/view-evidence-modal/view-evidence-modal.component';
import { LoginComponent } from './pages/login/login.component';

@NgModule({
declarations: [
Expand Down Expand Up @@ -71,6 +72,7 @@ import { ViewEvidenceModalComponent } from './component/view-evidence-modal/view
AddEvidenceModalComponent,
EvidencePanelComponent,
ViewEvidenceModalComponent,
LoginComponent,
],
imports: [
BrowserModule,
Expand Down
69 changes: 69 additions & 0 deletions src/app/guards/auth.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { AuthGuard } from './auth.guard';
import { AuthService } from '../services/auth.service';

describe('AuthGuard', () => {
let guard: AuthGuard;
let authService: AuthService;
let router: Router;
const sessionUserKey = 'dsomm.auth.currentUser';

beforeEach(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule.withRoutes([])],
});

guard = TestBed.inject(AuthGuard);
authService = TestBed.inject(AuthService);
router = TestBed.inject(Router);
sessionStorage.removeItem(sessionUserKey);
});

afterEach(() => {
sessionStorage.removeItem(sessionUserKey);
});

it('allows authenticated access to protected routes', () => {
authService.login('developer', 'dsomm-dev');

const result = guard.canActivate(routeSnapshot(), routerState('/matrix'));

expect(result).toBeTrue();
});

it('redirects unauthenticated users to login with the requested URL', () => {
const result = guard.canActivate(routeSnapshot(), routerState('/matrix'));

expect(router.serializeUrl(result as UrlTree)).toBe('/login?returnUrl=%2Fmatrix');
});

it('allows unauthenticated users to visit login', () => {
const result = guard.canActivate(routeSnapshot(), routerState('/login'));

expect(result).toBeTrue();
});

it('redirects authenticated users away from login', () => {
authService.login('auditor', 'dsomm-audit');

const result = guard.canActivate(routeSnapshot(), routerState('/login'));

expect(router.serializeUrl(result as UrlTree)).toBe(AuthGuard.DEFAULT_AUTHENTICATED_ROUTE);
});

it('redirects unauthenticated child route activation to login', () => {
const result = guard.canActivateChild(routeSnapshot(), routerState('/teams'));

expect(router.serializeUrl(result as UrlTree)).toBe('/login?returnUrl=%2Fteams');
});
});

function routeSnapshot(): ActivatedRouteSnapshot {
return {} as ActivatedRouteSnapshot;
}

function routerState(url: string): RouterStateSnapshot {
return { url } as RouterStateSnapshot;
}
43 changes: 43 additions & 0 deletions src/app/guards/auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
CanActivate,
CanActivateChild,
Router,
RouterStateSnapshot,
UrlTree,
} from '@angular/router';
import { AuthService } from '../services/auth.service';

@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate, CanActivateChild {
static readonly DEFAULT_AUTHENTICATED_ROUTE = '/';

constructor(private authService: AuthService, private router: Router) {}

canActivate(_route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean | UrlTree {
return this.checkAccess(state.url);
}

canActivateChild(_route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean | UrlTree {
return this.checkAccess(state.url);
}

private checkAccess(url: string): boolean | UrlTree {
if (this.isLoginUrl(url)) {
return this.authService.isAuthenticated()
? this.router.parseUrl(AuthGuard.DEFAULT_AUTHENTICATED_ROUTE)
: true;
}

if (this.authService.isAuthenticated()) {
return true;
}

return this.router.createUrlTree(['/login'], { queryParams: { returnUrl: url } });
}

private isLoginUrl(url: string): boolean {
return url.split('?')[0] === '/login';
}
}
Loading