Skip to content

Commit f7d8538

Browse files
author
morteza.khoramdel
committed
Add static basic authentication (login/logout/AuthGuard) for DSOMM routes
1 parent 5bb1232 commit f7d8538

14 files changed

Lines changed: 475 additions & 25 deletions

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,27 @@ You can switch on to show open TODO's for evidence by changing IS_SHOW_EVIDENCE_
2727

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

30+
# Static Demo Authentication
31+
32+
This Angular frontend includes simple static-user authentication for demo and internal
33+
deployments. All users have the same permissions.
34+
35+
Default credentials are defined in `src/app/services/auth.service.ts`:
36+
37+
| Username | Password |
38+
| --- | --- |
39+
| `admin` | `dsomm-admin` |
40+
| `auditor` | `dsomm-audit` |
41+
| `developer` | `dsomm-dev` |
42+
| `viewer` | `dsomm-view` |
43+
44+
Sign in at `/login`. The app stores the current user in the browser's `sessionStorage`, so the
45+
login lasts only for the current browser session.
46+
47+
Security warning: this is frontend-only authentication. It is not secure for production because
48+
static credentials are shipped in the browser bundle and can be inspected by users. Use a backend
49+
identity provider or server-side access control for production deployments.
50+
3051
# Changes
3152
Changes to the application are displayed at the release page of [DevSecOps-MaturityModel](https://github.com/devsecopsmaturitymodel/DevSecOps-MaturityModel-data/releases).
3253

src/app/app-routing.module.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Component, NgModule } from '@angular/core';
1+
import { NgModule } from '@angular/core';
22
import { RouterModule, Routes } from '@angular/router';
33
import { AboutUsComponent } from './pages/about-us/about-us.component';
44
import { UserdayComponent } from './pages/userday/userday.component';
@@ -11,21 +11,30 @@ import { TeamsComponent } from './pages/teams/teams.component';
1111
import { RoadmapComponent } from './pages/roadmap/roadmap.component';
1212
import { SettingsComponent } from './pages/settings/settings.component';
1313
import { ReportComponent } from './pages/report/report.component';
14+
import { AuthGuard } from './guards/auth.guard';
15+
import { LoginComponent } from './pages/login/login.component';
1416

1517
const routes: Routes = [
16-
{ path: '', component: CircularHeatmapComponent },
17-
{ path: 'circular-heatmap', component: CircularHeatmapComponent },
18-
{ path: 'matrix', component: MatrixComponent },
19-
{ path: 'activity-description', component: ActivityDescriptionPageComponent },
20-
{ path: 'mapping', component: MappingComponent },
21-
{ path: 'usage', redirectTo: 'usage/' },
22-
{ path: 'usage/:page', component: UsageComponent },
23-
{ path: 'teams', component: TeamsComponent },
24-
{ path: 'about', component: AboutUsComponent },
25-
{ path: 'userday', component: UserdayComponent },
26-
{ path: 'roadmap', component: RoadmapComponent },
27-
{ path: 'settings', component: SettingsComponent },
28-
{ path: 'report', component: ReportComponent },
18+
{ path: 'login', component: LoginComponent, canActivate: [AuthGuard] },
19+
{
20+
path: '',
21+
canActivateChild: [AuthGuard],
22+
children: [
23+
{ path: '', component: CircularHeatmapComponent },
24+
{ path: 'circular-heatmap', component: CircularHeatmapComponent },
25+
{ path: 'matrix', component: MatrixComponent },
26+
{ path: 'activity-description', component: ActivityDescriptionPageComponent },
27+
{ path: 'mapping', component: MappingComponent },
28+
{ path: 'usage', redirectTo: 'usage/' },
29+
{ path: 'usage/:page', component: UsageComponent },
30+
{ path: 'teams', component: TeamsComponent },
31+
{ path: 'about', component: AboutUsComponent },
32+
{ path: 'userday', component: UserdayComponent },
33+
{ path: 'roadmap', component: RoadmapComponent },
34+
{ path: 'settings', component: SettingsComponent },
35+
{ path: 'report', component: ReportComponent },
36+
],
37+
},
2938
];
3039

3140
@NgModule({

src/app/app.component.css

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,40 @@
7575
transform: scale(1.05);
7676
}
7777

78+
.toolbar-spacer {
79+
flex: 1 1 auto;
80+
}
81+
82+
.auth-actions {
83+
position: relative;
84+
z-index: 1;
85+
display: flex;
86+
align-items: center;
87+
gap: 8px;
88+
margin-left: auto;
89+
}
90+
91+
.current-user {
92+
font-size: 14px;
93+
max-width: 160px;
94+
overflow: hidden;
95+
text-overflow: ellipsis;
96+
white-space: nowrap;
97+
}
98+
7899
.content {
79100
padding: 24px;
80101
animation: fadeSlide 1s ease;
81102
height: 100%;
82103
box-sizing: border-box;
83104
overflow-y: auto;
84105
}
106+
107+
.login-content {
108+
flex: 1;
109+
overflow-y: auto;
110+
}
111+
85112
@keyframes fadeSlide {
86113
from {
87114
opacity: 0;
@@ -102,6 +129,9 @@
102129
.tag-subtitle {
103130
font-size: 11px;
104131
}
132+
.current-user {
133+
display: none;
134+
}
105135
.logo,
106136
.logo-icon {
107137
opacity: 0;
@@ -110,4 +140,4 @@
110140
margin: 0;
111141
overflow: hidden;
112142
}
113-
}
143+
}

src/app/app.component.html

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<mat-toolbar class="mat-elevation-z2 navbar">
2-
<button mat-icon-button (click)="toggleMenu()" class="menu-btn">
2+
<button mat-icon-button (click)="toggleMenu()" class="menu-btn" *ngIf="!isLoginPage">
33
<mat-icon>menu</mat-icon>
44
</button>
55
<a routerLink="/" class="logo">
@@ -16,12 +16,26 @@
1616
</div>
1717
<div class="dummy"></div>
1818
</div>
19+
<span class="toolbar-spacer"></span>
20+
<div class="auth-actions" *ngIf="isAuthenticated">
21+
<span class="current-user">{{ currentUser }}</span>
22+
<button mat-icon-button (click)="logout()" title="Logout" aria-label="Logout">
23+
<mat-icon>logout</mat-icon>
24+
</button>
25+
</div>
1926
</mat-toolbar>
20-
<mat-sidenav-container class="sidenav-container">
21-
<mat-sidenav mode="side" opened class="sidenav" [style.width]="sidenavWidth">
22-
<app-sidenav-buttons></app-sidenav-buttons>
23-
</mat-sidenav>
24-
<mat-sidenav-content class="content" [style.margin-left]="sidenavWidth">
27+
<ng-container *ngIf="isLoginPage; else dsommLayout">
28+
<main class="login-content">
2529
<router-outlet></router-outlet>
26-
</mat-sidenav-content>
27-
</mat-sidenav-container>
30+
</main>
31+
</ng-container>
32+
<ng-template #dsommLayout>
33+
<mat-sidenav-container class="sidenav-container">
34+
<mat-sidenav mode="side" opened class="sidenav" [style.width]="sidenavWidth">
35+
<app-sidenav-buttons></app-sidenav-buttons>
36+
</mat-sidenav>
37+
<mat-sidenav-content class="content" [style.margin-left]="sidenavWidth">
38+
<router-outlet></router-outlet>
39+
</mat-sidenav-content>
40+
</mat-sidenav-container>
41+
</ng-template>

src/app/app.component.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { Component, OnInit, OnDestroy } from '@angular/core';
2-
import { Subject, takeUntil } from 'rxjs';
2+
import { NavigationEnd, Router } from '@angular/router';
3+
import { filter, Subject, takeUntil } from 'rxjs';
34
import { ThemeService } from './service/theme.service';
45
import { TitleService } from './service/title.service';
6+
import { AuthService } from './services/auth.service';
57

68
@Component({
79
selector: 'app-root',
@@ -14,10 +16,16 @@ export class AppComponent implements OnInit, OnDestroy {
1416
subtitle = '';
1517
menuIsOpen: boolean = true;
1618
sidenavWidth: string = '250px';
19+
isLoginPage = false;
1720

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

20-
constructor(private themeService: ThemeService, private titleService: TitleService) {
23+
constructor(
24+
private themeService: ThemeService,
25+
private titleService: TitleService,
26+
private authService: AuthService,
27+
private router: Router
28+
) {
2129
this.themeService.initTheme();
2230
}
2331

@@ -37,6 +45,16 @@ export class AppComponent implements OnInit, OnDestroy {
3745
this.title = titleInfo?.dimension || '';
3846
this.subtitle = titleInfo?.level ? 'Level ' + titleInfo?.level : '';
3947
});
48+
49+
this.isLoginPage = this.router.url.split('?')[0] === '/login';
50+
this.router.events
51+
.pipe(
52+
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
53+
takeUntil(this.destroy$)
54+
)
55+
.subscribe(event => {
56+
this.isLoginPage = event.urlAfterRedirects.split('?')[0] === '/login';
57+
});
4058
}
4159

4260
ngOnDestroy(): void {
@@ -49,4 +67,17 @@ export class AppComponent implements OnInit, OnDestroy {
4967
this.sidenavWidth = this.menuIsOpen ? '250px' : '0px';
5068
localStorage.setItem('state.menuIsOpen', this.menuIsOpen.toString());
5169
}
70+
71+
get isAuthenticated(): boolean {
72+
return this.authService.isAuthenticated();
73+
}
74+
75+
get currentUser(): string | null {
76+
return this.authService.getCurrentUser();
77+
}
78+
79+
logout(): void {
80+
this.authService.logout();
81+
void this.router.navigate(['/login']);
82+
}
5283
}

src/app/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { ColResizeDirective } from './directive/col-resize.directive';
4040
import { AddEvidenceModalComponent } from './component/add-evidence-modal/add-evidence-modal.component';
4141
import { EvidencePanelComponent } from './component/evidence-panel/evidence-panel.component';
4242
import { ViewEvidenceModalComponent } from './component/view-evidence-modal/view-evidence-modal.component';
43+
import { LoginComponent } from './pages/login/login.component';
4344

4445
@NgModule({
4546
declarations: [
@@ -71,6 +72,7 @@ import { ViewEvidenceModalComponent } from './component/view-evidence-modal/view
7172
AddEvidenceModalComponent,
7273
EvidencePanelComponent,
7374
ViewEvidenceModalComponent,
75+
LoginComponent,
7476
],
7577
imports: [
7678
BrowserModule,

src/app/guards/auth.guard.spec.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
3+
import { RouterTestingModule } from '@angular/router/testing';
4+
import { AuthGuard } from './auth.guard';
5+
import { AuthService } from '../services/auth.service';
6+
7+
describe('AuthGuard', () => {
8+
let guard: AuthGuard;
9+
let authService: AuthService;
10+
let router: Router;
11+
12+
beforeEach(() => {
13+
TestBed.configureTestingModule({
14+
imports: [RouterTestingModule.withRoutes([])],
15+
});
16+
17+
guard = TestBed.inject(AuthGuard);
18+
authService = TestBed.inject(AuthService);
19+
router = TestBed.inject(Router);
20+
sessionStorage.clear();
21+
});
22+
23+
afterEach(() => {
24+
sessionStorage.clear();
25+
});
26+
27+
it('allows authenticated access to protected routes', () => {
28+
authService.login('developer', 'dsomm-dev');
29+
30+
const result = guard.canActivate(routeSnapshot(), routerState('/matrix'));
31+
32+
expect(result).toBeTrue();
33+
});
34+
35+
it('redirects unauthenticated users to login with the requested URL', () => {
36+
const result = guard.canActivate(routeSnapshot(), routerState('/matrix'));
37+
38+
expect(router.serializeUrl(result as UrlTree)).toBe('/login?returnUrl=%2Fmatrix');
39+
});
40+
41+
it('allows unauthenticated users to visit login', () => {
42+
const result = guard.canActivate(routeSnapshot(), routerState('/login'));
43+
44+
expect(result).toBeTrue();
45+
});
46+
47+
it('redirects authenticated users away from login', () => {
48+
authService.login('auditor', 'dsomm-audit');
49+
50+
const result = guard.canActivate(routeSnapshot(), routerState('/login'));
51+
52+
expect(router.serializeUrl(result as UrlTree)).toBe(AuthGuard.DEFAULT_AUTHENTICATED_ROUTE);
53+
});
54+
55+
it('redirects unauthenticated child route activation to login', () => {
56+
const result = guard.canActivateChild(routeSnapshot(), routerState('/teams'));
57+
58+
expect(router.serializeUrl(result as UrlTree)).toBe('/login?returnUrl=%2Fteams');
59+
});
60+
});
61+
62+
function routeSnapshot(): ActivatedRouteSnapshot {
63+
return {} as ActivatedRouteSnapshot;
64+
}
65+
66+
function routerState(url: string): RouterStateSnapshot {
67+
return { url } as RouterStateSnapshot;
68+
}

src/app/guards/auth.guard.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Injectable } from '@angular/core';
2+
import {
3+
ActivatedRouteSnapshot,
4+
CanActivate,
5+
CanActivateChild,
6+
Router,
7+
RouterStateSnapshot,
8+
UrlTree,
9+
} from '@angular/router';
10+
import { AuthService } from '../services/auth.service';
11+
12+
@Injectable({ providedIn: 'root' })
13+
export class AuthGuard implements CanActivate, CanActivateChild {
14+
static readonly DEFAULT_AUTHENTICATED_ROUTE = '/';
15+
16+
constructor(private authService: AuthService, private router: Router) {}
17+
18+
canActivate(_route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean | UrlTree {
19+
return this.checkAccess(state.url);
20+
}
21+
22+
canActivateChild(_route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean | UrlTree {
23+
return this.checkAccess(state.url);
24+
}
25+
26+
private checkAccess(url: string): boolean | UrlTree {
27+
if (this.isLoginUrl(url)) {
28+
return this.authService.isAuthenticated()
29+
? this.router.parseUrl(AuthGuard.DEFAULT_AUTHENTICATED_ROUTE)
30+
: true;
31+
}
32+
33+
if (this.authService.isAuthenticated()) {
34+
return true;
35+
}
36+
37+
return this.router.createUrlTree(['/login'], { queryParams: { returnUrl: url } });
38+
}
39+
40+
private isLoginUrl(url: string): boolean {
41+
return url.split('?')[0] === '/login';
42+
}
43+
}

0 commit comments

Comments
 (0)