diff --git a/Caddyfile b/Caddyfile
index f28b4dda..e88a629a 100644
--- a/Caddyfile
+++ b/Caddyfile
@@ -17,4 +17,8 @@
X-Permitted-Cross-Domain-Policies "none"
Referrer-Policy "no-referrer"
}
+
+ header /assets/auth-config.json {
+ Cache-Control "no-store"
+ }
}
diff --git a/Dockerfile b/Dockerfile
index 72032813..4ed073cc 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -25,5 +25,13 @@ FROM caddy:2.10.2
ENV PORT=8080
COPY Caddyfile /etc/caddy/Caddyfile
+COPY docker/docker-entrypoint.sh /usr/local/bin/dsomm-entrypoint
COPY --from=build ["/usr/src/app/dist/dsomm/", "/srv"]
COPY --from=yaml ["/var/www/html/generated/model.yaml", "/srv/assets/YAML/default/model.yaml"]
+
+RUN chmod +x /usr/local/bin/dsomm-entrypoint \
+ && mkdir -p /srv/assets \
+ && chmod -R u+rwX,go+rX /srv/assets
+
+ENTRYPOINT ["/usr/local/bin/dsomm-entrypoint"]
+CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
diff --git a/README.md b/README.md
index 40c908d7..7f27bcaa 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,54 @@ 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 runtime-configured authentication for demo and internal
+deployments. All users have the same permissions.
+
+For local development, default demo credentials are defined in `src/assets/auth-config.json`:
+
+| Username | Password |
+| --- | --- |
+| `admin` | `dsomm-admin` |
+| `auditor` | `dsomm-audit` |
+| `developer` | `dsomm-dev` |
+| `viewer` | `dsomm-view` |
+
+For Docker deployments, define users at container startup instead of rebuilding the image:
+
+```yaml
+services:
+ dsomm:
+ image: wurstbrot/dsomm:latest
+ ports:
+ - "8080:8080"
+ environment:
+ DSOMM_AUTH_USERS: >-
+ [
+ {"username":"admin","password":"change-me"},
+ {"username":"auditor","password":"audit-me"}
+ ]
+```
+
+The container entrypoint writes `DSOMM_AUTH_USERS` to `/srv/assets/auth-config.json`. You can also
+mount your own config file at `/srv/assets/auth-config.json` with this shape:
+
+```json
+{
+ "users": [
+ { "username": "admin", "password": "change-me" }
+ ]
+}
+```
+
+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
+the browser must receive the auth config and credentials 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).
diff --git a/docker-compose.example.yml b/docker-compose.example.yml
new file mode 100644
index 00000000..eab2c2bf
--- /dev/null
+++ b/docker-compose.example.yml
@@ -0,0 +1,12 @@
+services:
+ dsomm:
+ build: .
+ ports:
+ - "8080:8080"
+ environment:
+ DSOMM_AUTH_USERS: >-
+ [
+ {"username":"admin","password":"change-me"},
+ {"username":"auditor","password":"audit-me"},
+ {"username":"developer","password":"dev-me"}
+ ]
diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh
new file mode 100644
index 00000000..7f0ecb6d
--- /dev/null
+++ b/docker/docker-entrypoint.sh
@@ -0,0 +1,27 @@
+#!/bin/sh
+set -eu
+
+AUTH_CONFIG_PATH="${DSOMM_AUTH_CONFIG_PATH:-/srv/assets/auth-config.json}"
+AUTH_CONFIG_DIR="$(dirname "$AUTH_CONFIG_PATH")"
+
+write_auth_config() {
+ tmp_config="${AUTH_CONFIG_PATH}.tmp.$$"
+
+ {
+ printf '{\n "users": '
+ printf '%s' "$1"
+ printf '\n}\n'
+ } > "$tmp_config"
+
+ mv "$tmp_config" "$AUTH_CONFIG_PATH"
+}
+
+if [ -n "${DSOMM_AUTH_USERS:-}" ]; then
+ mkdir -p "$AUTH_CONFIG_DIR"
+ write_auth_config "$DSOMM_AUTH_USERS"
+elif [ ! -f "$AUTH_CONFIG_PATH" ]; then
+ mkdir -p "$AUTH_CONFIG_DIR"
+ printf '{\n "users": []\n}\n' > "$AUTH_CONFIG_PATH"
+fi
+
+exec "$@"
diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts
index fe90fbf0..25a12dbf 100644
--- a/src/app/app-routing.module.ts
+++ b/src/app/app-routing.module.ts
@@ -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';
@@ -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({
diff --git a/src/app/app.component.css b/src/app/app.component.css
index d9d39268..2099b4d9 100644
--- a/src/app/app.component.css
+++ b/src/app/app.component.css
@@ -75,6 +75,27 @@
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;
@@ -82,6 +103,12 @@
box-sizing: border-box;
overflow-y: auto;
}
+
+.login-content {
+ flex: 1;
+ overflow-y: auto;
+}
+
@keyframes fadeSlide {
from {
opacity: 0;
@@ -102,6 +129,9 @@
.tag-subtitle {
font-size: 11px;
}
+ .current-user {
+ display: none;
+ }
.logo,
.logo-icon {
opacity: 0;
@@ -110,4 +140,4 @@
margin: 0;
overflow: hidden;
}
-}
\ No newline at end of file
+}
diff --git a/src/app/app.component.html b/src/app/app.component.html
index 3cbe51e5..f2962848 100644
--- a/src/app/app.component.html
+++ b/src/app/app.component.html
@@ -1,5 +1,5 @@
-
-
-
-
-
-
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/app.component.ts b/src/app/app.component.ts
index 35334189..71de8820 100644
--- a/src/app/app.component.ts
+++ b/src/app/app.component.ts
@@ -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',
@@ -14,10 +16,16 @@ export class AppComponent implements OnInit, OnDestroy {
subtitle = '';
menuIsOpen: boolean = true;
sidenavWidth: string = '250px';
+ isLoginPage = false;
private destroy$ = new Subject();
- constructor(private themeService: ThemeService, private titleService: TitleService) {
+ constructor(
+ private themeService: ThemeService,
+ private titleService: TitleService,
+ private authService: AuthService,
+ private router: Router
+ ) {
this.themeService.initTheme();
}
@@ -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 {
@@ -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']);
+ }
}
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index a86dca2e..45683ac4 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -1,4 +1,4 @@
-import { NgModule } from '@angular/core';
+import { APP_INITIALIZER, NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { ReactiveFormsModule, FormsModule } from '@angular/forms';
import { MatToolbarModule } from '@angular/material/toolbar';
@@ -40,6 +40,12 @@ 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';
+import { AuthService } from './services/auth.service';
+
+export function initializeAuth(authService: AuthService): () => Promise {
+ return () => authService.loadConfig();
+}
@NgModule({
declarations: [
@@ -71,6 +77,7 @@ import { ViewEvidenceModalComponent } from './component/view-evidence-modal/view
AddEvidenceModalComponent,
EvidencePanelComponent,
ViewEvidenceModalComponent,
+ LoginComponent,
],
imports: [
BrowserModule,
@@ -89,6 +96,7 @@ import { ViewEvidenceModalComponent } from './component/view-evidence-modal/view
providers: [
LoaderService,
ModalMessageComponent,
+ { provide: APP_INITIALIZER, useFactory: initializeAuth, deps: [AuthService], multi: true },
{ provide: MAT_DIALOG_DATA, useValue: {} },
{ provide: MatDialogRef, useValue: { close: (dialogResult: any) => {} } },
],
diff --git a/src/app/guards/auth.guard.spec.ts b/src/app/guards/auth.guard.spec.ts
new file mode 100644
index 00000000..7c521f86
--- /dev/null
+++ b/src/app/guards/auth.guard.spec.ts
@@ -0,0 +1,89 @@
+import { TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/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';
+
+const authUsers = [
+ { username: 'auditor', password: 'dsomm-audit' },
+ { username: 'developer', password: 'dsomm-dev' },
+];
+
+describe('AuthGuard', () => {
+ let guard: AuthGuard;
+ let authService: AuthService;
+ let router: Router;
+ let httpMock: HttpTestingController;
+ const sessionUserKey = 'dsomm.auth.currentUser';
+
+ beforeEach(async () => {
+ TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule, RouterTestingModule.withRoutes([])],
+ });
+
+ guard = TestBed.inject(AuthGuard);
+ authService = TestBed.inject(AuthService);
+ router = TestBed.inject(Router);
+ httpMock = TestBed.inject(HttpTestingController);
+ sessionStorage.removeItem(sessionUserKey);
+
+ await loadAuthConfig();
+ });
+
+ afterEach(() => {
+ httpMock.verify();
+ 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;
+}
+
+async function loadAuthConfig(): Promise {
+ const authService = TestBed.inject(AuthService);
+ const httpMock = TestBed.inject(HttpTestingController);
+ const configLoaded = authService.loadConfig();
+ const request = httpMock.expectOne('assets/auth-config.json');
+ request.flush({ users: authUsers });
+ await configLoaded;
+}
diff --git a/src/app/guards/auth.guard.ts b/src/app/guards/auth.guard.ts
new file mode 100644
index 00000000..831e94ee
--- /dev/null
+++ b/src/app/guards/auth.guard.ts
@@ -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';
+ }
+}
diff --git a/src/app/pages/login/login.component.css b/src/app/pages/login/login.component.css
new file mode 100644
index 00000000..dda959b6
--- /dev/null
+++ b/src/app/pages/login/login.component.css
@@ -0,0 +1,31 @@
+.login-page {
+ min-height: calc(100vh - 64px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ box-sizing: border-box;
+}
+
+.login-panel {
+ width: 100%;
+ max-width: 380px;
+ border-radius: 8px;
+}
+
+form {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ margin-top: 16px;
+}
+
+.login-error {
+ color: #b00020;
+ min-height: 20px;
+ font-size: 14px;
+}
+
+button[type='submit'] {
+ align-self: stretch;
+}
diff --git a/src/app/pages/login/login.component.html b/src/app/pages/login/login.component.html
new file mode 100644
index 00000000..8861437a
--- /dev/null
+++ b/src/app/pages/login/login.component.html
@@ -0,0 +1,35 @@
+
+
+
+ Sign in to DSOMM
+
+
+
+
+
+
+
diff --git a/src/app/pages/login/login.component.spec.ts b/src/app/pages/login/login.component.spec.ts
new file mode 100644
index 00000000..1a9538b2
--- /dev/null
+++ b/src/app/pages/login/login.component.spec.ts
@@ -0,0 +1,117 @@
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+import { AuthService } from '../../services/auth.service';
+import { LoginComponent } from './login.component';
+
+describe('LoginComponent', () => {
+ let component: LoginComponent;
+ let fixture: ComponentFixture;
+ let authService: AuthService;
+ let router: Router;
+ let httpMock: HttpTestingController;
+ let routeStub: { snapshot: { queryParamMap: ReturnType } };
+ const sessionUserKey = 'dsomm.auth.currentUser';
+ const authUsers = [
+ { username: 'admin', password: 'dsomm-admin' },
+ { username: 'auditor', password: 'dsomm-audit' },
+ { username: 'viewer', password: 'dsomm-view' },
+ ];
+
+ beforeEach(async () => {
+ routeStub = {
+ snapshot: {
+ queryParamMap: convertToParamMap({}),
+ },
+ };
+
+ await TestBed.configureTestingModule({
+ declarations: [LoginComponent],
+ imports: [HttpClientTestingModule, ReactiveFormsModule, RouterTestingModule.withRoutes([])],
+ providers: [{ provide: ActivatedRoute, useValue: routeStub }],
+ schemas: [NO_ERRORS_SCHEMA],
+ }).compileComponents();
+
+ authService = TestBed.inject(AuthService);
+ router = TestBed.inject(Router);
+ httpMock = TestBed.inject(HttpTestingController);
+ sessionStorage.removeItem(sessionUserKey);
+
+ await loadAuthConfig();
+ });
+
+ afterEach(() => {
+ httpMock.verify();
+ sessionStorage.removeItem(sessionUserKey);
+ });
+
+ function createComponent(): void {
+ fixture = TestBed.createComponent(LoginComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ }
+
+ it('requires username and password before attempting login', () => {
+ createComponent();
+ const loginSpy = spyOn(authService, 'login').and.callThrough();
+
+ component.login();
+
+ expect(component.loginForm.invalid).toBeTrue();
+ expect(loginSpy).not.toHaveBeenCalled();
+ });
+
+ it('shows a generic error for invalid credentials', () => {
+ createComponent();
+ const navigateSpy = spyOn(router, 'navigateByUrl').and.resolveTo(true);
+ component.loginForm.setValue({ username: 'admin', password: 'wrong-password' });
+
+ component.login();
+
+ expect(component.loginFailed).toBeTrue();
+ expect(navigateSpy).not.toHaveBeenCalled();
+ });
+
+ it('logs in and redirects to the DSOMM default page', () => {
+ createComponent();
+ const navigateSpy = spyOn(router, 'navigateByUrl').and.resolveTo(true);
+ component.loginForm.setValue({ username: 'admin', password: 'dsomm-admin' });
+
+ component.login();
+
+ expect(authService.getCurrentUser()).toBe('admin');
+ expect(navigateSpy).toHaveBeenCalledOnceWith('/');
+ });
+
+ it('redirects to the originally requested URL after login', () => {
+ routeStub.snapshot.queryParamMap = convertToParamMap({ returnUrl: '/matrix?team=Team%20A' });
+ createComponent();
+ const navigateSpy = spyOn(router, 'navigateByUrl').and.resolveTo(true);
+ component.loginForm.setValue({ username: 'viewer', password: 'dsomm-view' });
+
+ component.login();
+
+ expect(navigateSpy).toHaveBeenCalledOnceWith('/matrix?team=Team%20A');
+ });
+
+ it('falls back to the default route for unsafe return URLs', () => {
+ routeStub.snapshot.queryParamMap = convertToParamMap({ returnUrl: 'https://example.com' });
+ createComponent();
+ const navigateSpy = spyOn(router, 'navigateByUrl').and.resolveTo(true);
+ component.loginForm.setValue({ username: 'auditor', password: 'dsomm-audit' });
+
+ component.login();
+
+ expect(navigateSpy).toHaveBeenCalledOnceWith('/');
+ });
+
+ async function loadAuthConfig(): Promise {
+ const configLoaded = authService.loadConfig();
+ const request = httpMock.expectOne('assets/auth-config.json');
+ request.flush({ users: authUsers });
+ await configLoaded;
+ }
+});
diff --git a/src/app/pages/login/login.component.ts b/src/app/pages/login/login.component.ts
new file mode 100644
index 00000000..51f7965d
--- /dev/null
+++ b/src/app/pages/login/login.component.ts
@@ -0,0 +1,56 @@
+import { Component } from '@angular/core';
+import { FormBuilder, Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { AuthGuard } from '../../guards/auth.guard';
+import { AuthService } from '../../services/auth.service';
+
+@Component({
+ selector: 'app-login',
+ templateUrl: './login.component.html',
+ styleUrls: ['./login.component.css'],
+})
+export class LoginComponent {
+ loginFailed = false;
+
+ loginForm = this.formBuilder.group({
+ username: ['', Validators.required],
+ password: ['', Validators.required],
+ });
+
+ constructor(
+ private formBuilder: FormBuilder,
+ private authService: AuthService,
+ private router: Router,
+ private route: ActivatedRoute
+ ) {}
+
+ login(): void {
+ this.loginFailed = false;
+
+ if (this.loginForm.invalid) {
+ this.loginForm.markAllAsTouched();
+ return;
+ }
+
+ const username = this.loginForm.controls['username'].value || '';
+ const password = this.loginForm.controls['password'].value || '';
+
+ if (!this.authService.login(username, password)) {
+ this.loginFailed = true;
+ return;
+ }
+
+ void this.router.navigateByUrl(this.getReturnUrl());
+ }
+
+ private getReturnUrl(): string {
+ const returnUrl =
+ this.route.snapshot.queryParamMap.get('returnUrl') || AuthGuard.DEFAULT_AUTHENTICATED_ROUTE;
+
+ if (!returnUrl.startsWith('/') || returnUrl.startsWith('//')) {
+ return AuthGuard.DEFAULT_AUTHENTICATED_ROUTE;
+ }
+
+ return returnUrl;
+ }
+}
diff --git a/src/app/services/auth.service.spec.ts b/src/app/services/auth.service.spec.ts
new file mode 100644
index 00000000..eb01bbb6
--- /dev/null
+++ b/src/app/services/auth.service.spec.ts
@@ -0,0 +1,77 @@
+import { TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { AuthService } from './auth.service';
+
+describe('AuthService', () => {
+ let service: AuthService;
+ let httpMock: HttpTestingController;
+ const sessionUserKey = 'dsomm.auth.currentUser';
+ const authUsers = [
+ { username: 'admin', password: 'dsomm-admin' },
+ { username: 'viewer', password: 'dsomm-view' },
+ ];
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule],
+ });
+ service = TestBed.inject(AuthService);
+ httpMock = TestBed.inject(HttpTestingController);
+ sessionStorage.removeItem(sessionUserKey);
+ });
+
+ afterEach(() => {
+ httpMock.verify();
+ sessionStorage.removeItem(sessionUserKey);
+ });
+
+ it('logs in a configured user with valid credentials', async () => {
+ await loadAuthConfig();
+
+ const loggedIn = service.login('admin', 'dsomm-admin');
+
+ expect(loggedIn).toBeTrue();
+ expect(service.isAuthenticated()).toBeTrue();
+ expect(service.getCurrentUser()).toBe('admin');
+ });
+
+ it('rejects invalid credentials', async () => {
+ await loadAuthConfig();
+
+ const loggedIn = service.login('admin', 'wrong-password');
+
+ expect(loggedIn).toBeFalse();
+ expect(service.isAuthenticated()).toBeFalse();
+ expect(service.getCurrentUser()).toBeNull();
+ });
+
+ it('clears the authenticated user on logout', async () => {
+ await loadAuthConfig();
+
+ service.login('viewer', 'dsomm-view');
+
+ service.logout();
+
+ expect(service.isAuthenticated()).toBeFalse();
+ expect(service.getCurrentUser()).toBeNull();
+ });
+
+ it('rejects login when the auth config cannot be loaded', async () => {
+ const configLoaded = service.loadConfig();
+ const request = httpMock.expectOne('assets/auth-config.json');
+ request.flush('Not found', { status: 404, statusText: 'Not Found' });
+
+ await configLoaded;
+
+ expect(service.login('admin', 'dsomm-admin')).toBeFalse();
+ expect(service.isAuthenticated()).toBeFalse();
+ });
+
+ async function loadAuthConfig(): Promise {
+ const configLoaded = service.loadConfig();
+ const request = httpMock.expectOne('assets/auth-config.json');
+ expect(request.request.method).toBe('GET');
+ request.flush({ users: authUsers });
+ await configLoaded;
+ }
+});
diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts
new file mode 100644
index 00000000..c62568d8
--- /dev/null
+++ b/src/app/services/auth.service.ts
@@ -0,0 +1,76 @@
+import { Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { firstValueFrom } from 'rxjs';
+
+interface StaticUser {
+ username: string;
+ password: string;
+}
+
+interface AuthConfig {
+ users?: StaticUser[];
+}
+
+@Injectable({ providedIn: 'root' })
+export class AuthService {
+ private readonly SESSION_USER_KEY = 'dsomm.auth.currentUser';
+ private readonly configUrl = 'assets/auth-config.json';
+ private users: StaticUser[] = [];
+
+ /*
+ * Demo/internal-only frontend auth.
+ * This is not secure for production: browser-delivered auth config is visible
+ * to anyone who can load the application.
+ */
+ constructor(private http: HttpClient) {}
+
+ async loadConfig(): Promise {
+ try {
+ const config = await firstValueFrom(this.http.get(this.configUrl));
+ this.users = this.normalizeUsers(config);
+ } catch (_err) {
+ this.users = [];
+ }
+ }
+
+ login(username: string, password: string): boolean {
+ const matchedUser = this.users.find(
+ user => user.username === username && user.password === password
+ );
+
+ if (!matchedUser) {
+ return false;
+ }
+
+ sessionStorage.setItem(this.SESSION_USER_KEY, matchedUser.username);
+ return true;
+ }
+
+ logout(): void {
+ sessionStorage.removeItem(this.SESSION_USER_KEY);
+ }
+
+ isAuthenticated(): boolean {
+ return this.getCurrentUser() !== null;
+ }
+
+ getCurrentUser(): string | null {
+ return sessionStorage.getItem(this.SESSION_USER_KEY);
+ }
+
+ private normalizeUsers(config: AuthConfig | null): StaticUser[] {
+ if (!config || !Array.isArray(config.users)) {
+ return [];
+ }
+
+ const users: StaticUser[] = config.users;
+
+ return users.filter(
+ (user): user is StaticUser =>
+ typeof user?.username === 'string' &&
+ user.username.length > 0 &&
+ typeof user?.password === 'string' &&
+ user.password.length > 0
+ );
+ }
+}
diff --git a/src/assets/Markdown Files/README.md b/src/assets/Markdown Files/README.md
index fabed8af..df718e62 100644
--- a/src/assets/Markdown Files/README.md
+++ b/src/assets/Markdown Files/README.md
@@ -24,6 +24,54 @@ 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 runtime-configured authentication for demo and internal
+deployments. All users have the same permissions.
+
+For local development, default demo credentials are defined in `src/assets/auth-config.json`:
+
+| Username | Password |
+| --- | --- |
+| `admin` | `dsomm-admin` |
+| `auditor` | `dsomm-audit` |
+| `developer` | `dsomm-dev` |
+| `viewer` | `dsomm-view` |
+
+For Docker deployments, define users at container startup instead of rebuilding the image:
+
+```yaml
+services:
+ dsomm:
+ image: wurstbrot/dsomm:latest
+ ports:
+ - "8080:8080"
+ environment:
+ DSOMM_AUTH_USERS: >-
+ [
+ {"username":"admin","password":"change-me"},
+ {"username":"auditor","password":"audit-me"}
+ ]
+```
+
+The container entrypoint writes `DSOMM_AUTH_USERS` to `/srv/assets/auth-config.json`. You can also
+mount your own config file at `/srv/assets/auth-config.json` with this shape:
+
+```json
+{
+ "users": [
+ { "username": "admin", "password": "change-me" }
+ ]
+}
+```
+
+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
+the browser must receive the auth config and credentials 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).
diff --git a/src/assets/auth-config.json b/src/assets/auth-config.json
new file mode 100644
index 00000000..8e0f527a
--- /dev/null
+++ b/src/assets/auth-config.json
@@ -0,0 +1,20 @@
+{
+ "users": [
+ {
+ "username": "admin",
+ "password": "dsomm-admin"
+ },
+ {
+ "username": "auditor",
+ "password": "dsomm-audit"
+ },
+ {
+ "username": "developer",
+ "password": "dsomm-dev"
+ },
+ {
+ "username": "viewer",
+ "password": "dsomm-view"
+ }
+ ]
+}