Feat: Implement JWT bearer token authentication

Protects image upload, delete, and tag-update endpoints behind
Bearer token auth. Public read endpoints remain open. Angular SPA
gains a login page, auth interceptor, and route guard for /upload.

- JWTAuthProvider (HS256, sub/iat/exp, secrets.compare_digest)
- POST /api/v1/auth/token login endpoint
- require_auth FastAPI dependency on all write routes
- AuthService, LoginComponent, authInterceptor, authGuard
- Detail page hides write controls for unauthenticated visitors
- 43 unit tests passing; integration tests require Docker stack

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 19:12:38 +00:00
parent d91a65abe5
commit 5fbbc1e67f
36 changed files with 3998 additions and 42 deletions

View File

@@ -1,12 +1,31 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { CommonModule } from '@angular/common';
import { Router, RouterOutlet } from '@angular/router';
import { AuthService } from './auth/auth.service';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
template: `<router-outlet />`,
imports: [CommonModule, RouterOutlet],
template: `
<header class="app-header" *ngIf="auth.isAuthenticated()">
<button class="logout-btn" (click)="onLogout()">Sign out</button>
</header>
<router-outlet />
`,
styles: [`
.app-header { display: flex; justify-content: flex-end; padding: 8px 16px; background: #1a1a1a; border-bottom: 1px solid #333; }
.logout-btn { background: none; border: 1px solid #555; color: #aaa; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 0.9rem; }
.logout-btn:hover { border-color: #aaa; color: #e0e0e0; }
`],
})
export class AppComponent {
title = 'reactbin-ui';
constructor(public auth: AuthService, private router: Router) {}
onLogout(): void {
this.auth.logout();
this.router.navigate(['/login']);
}
}

View File

@@ -1,13 +1,14 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './auth/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(),
provideHttpClient(withInterceptors([authInterceptor])),
],
};

View File

@@ -1,4 +1,5 @@
import { Routes } from '@angular/router';
import { authGuard } from './auth/auth.guard';
export const routes: Routes = [
{
@@ -6,8 +7,14 @@ export const routes: Routes = [
loadComponent: () =>
import('./library/library.component').then((m) => m.LibraryComponent),
},
{
path: 'login',
loadComponent: () =>
import('./login/login.component').then((m) => m.LoginComponent),
},
{
path: 'upload',
canActivate: [authGuard],
loadComponent: () =>
import('./upload/upload.component').then((m) => m.UploadComponent),
},

View File

@@ -0,0 +1,34 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router';
import { provideLocationMocks } from '@angular/common/testing';
import { authGuard } from './auth.guard';
import { AuthService } from './auth.service';
describe('authGuard', () => {
let authService: jasmine.SpyObj<AuthService>;
let router: Router;
beforeEach(() => {
authService = jasmine.createSpyObj('AuthService', ['isAuthenticated']);
TestBed.configureTestingModule({
providers: [
provideRouter([]),
provideLocationMocks(),
{ provide: AuthService, useValue: authService },
],
});
router = TestBed.inject(Router);
});
it('redirects to login when not authenticated', () => {
authService.isAuthenticated.and.returnValue(false);
const route = {} as ActivatedRouteSnapshot;
const state = { url: '/upload' } as RouterStateSnapshot;
const result = TestBed.runInInjectionContext(() => authGuard(route, state));
expect(result).toBeTruthy();
const urlTree = result as ReturnType<Router['createUrlTree']>;
expect(urlTree.toString()).toContain('/login');
});
});

View File

@@ -0,0 +1,12 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = (_route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isAuthenticated()) {
return true;
}
return router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
};

View File

@@ -0,0 +1,57 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient, HttpErrorResponse, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Router } from '@angular/router';
import { authInterceptor } from './auth.interceptor';
import { AuthService } from './auth.service';
describe('authInterceptor', () => {
let http: HttpClient;
let httpMock: HttpTestingController;
let authService: jasmine.SpyObj<AuthService>;
let router: jasmine.SpyObj<Router>;
beforeEach(() => {
authService = jasmine.createSpyObj('AuthService', ['getToken', 'logout']);
router = jasmine.createSpyObj('Router', ['navigate']);
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
{ provide: AuthService, useValue: authService },
{ provide: Router, useValue: router },
],
});
http = TestBed.inject(HttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('adds Authorization header when authenticated', () => {
authService.getToken.and.returnValue('test-token');
http.get('/api/v1/images').subscribe();
const req = httpMock.expectOne('/api/v1/images');
expect(req.request.headers.get('Authorization')).toBe('Bearer test-token');
req.flush([]);
});
it('does not add Authorization header when not authenticated', () => {
authService.getToken.and.returnValue(null);
http.get('/api/v1/images').subscribe();
const req = httpMock.expectOne('/api/v1/images');
expect(req.request.headers.has('Authorization')).toBeFalse();
req.flush([]);
});
it('redirects to login on 401 response', () => {
authService.getToken.and.returnValue('test-token');
http.get('/api/v1/images').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/images');
req.flush('Unauthorized', { status: 401, statusText: 'Unauthorized' });
expect(authService.logout).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/login']);
});
});

View File

@@ -0,0 +1,23 @@
import { inject } from '@angular/core';
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const router = inject(Router);
const token = auth.getToken();
if (token) {
req = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
}
return next(req).pipe(
catchError((err) => {
if (err instanceof HttpErrorResponse && err.status === 401) {
auth.logout();
router.navigate(['/login']);
}
return throwError(() => err);
}),
);
};

View File

@@ -0,0 +1,55 @@
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;
beforeEach(() => {
sessionStorage.clear();
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [AuthService],
});
service = TestBed.inject(AuthService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
sessionStorage.clear();
});
it('login stores token in sessionStorage', (done) => {
service.login('owner', 'password').subscribe(() => {
expect(sessionStorage.getItem('auth_token')).toBe('test-token');
done();
});
const req = httpMock.expectOne('/api/v1/auth/token');
expect(req.request.method).toBe('POST');
req.flush({ access_token: 'test-token', token_type: 'bearer', expires_in: 3600 });
});
it('isAuthenticated returns true when token is present', () => {
sessionStorage.setItem('auth_token', 'some-token');
expect(service.isAuthenticated()).toBeTrue();
});
it('isAuthenticated returns false when no token', () => {
expect(service.isAuthenticated()).toBeFalse();
});
// US4 logout tests (T025)
it('logout removes token from sessionStorage', () => {
sessionStorage.setItem('auth_token', 'tok');
service.logout();
expect(sessionStorage.getItem('auth_token')).toBeNull();
});
it('isAuthenticated returns false after logout', () => {
sessionStorage.setItem('auth_token', 'tok');
service.logout();
expect(service.isAuthenticated()).toBeFalse();
});
});

View File

@@ -0,0 +1,37 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, map, tap } from 'rxjs';
const TOKEN_KEY = 'auth_token';
interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
}
@Injectable({ providedIn: 'root' })
export class AuthService {
constructor(private http: HttpClient) {}
login(username: string, password: string): Observable<void> {
return this.http
.post<TokenResponse>('/api/v1/auth/token', { username, password })
.pipe(
tap((res) => sessionStorage.setItem(TOKEN_KEY, res.access_token)),
map(() => undefined),
);
}
logout(): void {
sessionStorage.removeItem(TOKEN_KEY);
}
getToken(): string | null {
return sessionStorage.getItem(TOKEN_KEY);
}
isAuthenticated(): boolean {
return this.getToken() !== null;
}
}

View File

@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { ImageRecord, ImageService } from '../services/image.service';
import { AuthService } from '../auth/auth.service';
@Component({
selector: 'app-detail',
@@ -20,10 +21,10 @@ import { ImageRecord, ImageService } from '../services/image.service';
<h3>Tags</h3>
<div class="chips">
<span *ngFor="let tag of image.tags" class="chip">
{{ tag }} <button (click)="removeTag(tag)">×</button>
{{ tag }} <button *ngIf="auth.isAuthenticated()" (click)="removeTag(tag)">×</button>
</span>
</div>
<div class="add-tag">
<div class="add-tag" *ngIf="auth.isAuthenticated()">
<input
[(ngModel)]="newTagInput"
placeholder="Add tag…"
@@ -34,7 +35,7 @@ import { ImageRecord, ImageService } from '../services/image.service';
<p class="tag-error" *ngIf="tagError">{{ tagError }}</p>
</section>
<button class="delete-btn" (click)="showDeleteDialog = true">Delete Image</button>
<button *ngIf="auth.isAuthenticated()" class="delete-btn" (click)="showDeleteDialog = true">Delete Image</button>
<div class="dialog-overlay" *ngIf="showDeleteDialog">
<div class="dialog">
@@ -75,6 +76,7 @@ export class DetailComponent implements OnInit {
constructor(
public imageService: ImageService,
public auth: AuthService,
private route: ActivatedRoute,
public router: Router,
private cdr: ChangeDetectorRef,

View File

@@ -0,0 +1,63 @@
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { LoginComponent } from './login.component';
import { AuthService } from '../auth/auth.service';
describe('LoginComponent', () => {
let component: LoginComponent;
let authService: jasmine.SpyObj<AuthService>;
let router: jasmine.SpyObj<Router>;
beforeEach(async () => {
authService = jasmine.createSpyObj('AuthService', ['login']);
router = jasmine.createSpyObj('Router', ['navigate', 'navigateByUrl']);
await TestBed.configureTestingModule({
imports: [LoginComponent, ReactiveFormsModule],
providers: [
{ provide: AuthService, useValue: authService },
{ provide: Router, useValue: router },
],
}).compileComponents();
const fixture = TestBed.createComponent(LoginComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('submit calls AuthService.login with username and password', fakeAsync(() => {
authService.login.and.returnValue(of(undefined));
component.form.setValue({ username: 'owner', password: 'hunter2' });
component.onSubmit();
tick();
expect(authService.login).toHaveBeenCalledWith('owner', 'hunter2');
}));
it('navigates to library on success', fakeAsync(() => {
authService.login.and.returnValue(of(undefined));
router.navigateByUrl.and.returnValue(Promise.resolve(true));
component.form.setValue({ username: 'owner', password: 'hunter2' });
component.onSubmit();
tick();
expect(router.navigateByUrl).toHaveBeenCalledWith('/');
}));
it('shows error message on 401', fakeAsync(() => {
const err = new HttpErrorResponse({ status: 401 });
authService.login.and.returnValue(throwError(() => err));
component.form.setValue({ username: 'owner', password: 'wrong' });
component.onSubmit();
tick();
expect(component.errorMessage).toBeTruthy();
}));
it('does not call login when fields are empty', fakeAsync(() => {
component.form.setValue({ username: '', password: '' });
component.onSubmit();
tick();
expect(authService.login).not.toHaveBeenCalled();
}));
});

View File

@@ -0,0 +1,73 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router';
import { AuthService } from '../auth/auth.service';
@Component({
selector: 'app-login',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
template: `
<div class="login-page">
<h1>Sign In</h1>
<form [formGroup]="form" (ngSubmit)="onSubmit()" novalidate>
<div class="field">
<label for="username">Username</label>
<input id="username" type="text" formControlName="username" />
<span *ngIf="form.get('username')?.invalid && form.get('username')?.touched" class="validation-error">
Username is required
</span>
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" type="password" formControlName="password" />
<span *ngIf="form.get('password')?.invalid && form.get('password')?.touched" class="validation-error">
Password is required
</span>
</div>
<p *ngIf="errorMessage" class="error-message">{{ errorMessage }}</p>
<button type="submit" [disabled]="loading">
{{ loading ? 'Signing in…' : 'Sign In' }}
</button>
</form>
</div>
`,
})
export class LoginComponent {
form: FormGroup;
loading = false;
errorMessage = '';
constructor(
private fb: FormBuilder,
private auth: AuthService,
private router: Router,
private route: ActivatedRoute,
) {
this.form = this.fb.group({
username: ['', Validators.required],
password: ['', Validators.required],
});
}
onSubmit(): void {
if (this.form.invalid) {
return;
}
this.loading = true;
this.errorMessage = '';
const { username, password } = this.form.value;
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') ?? '/';
this.auth.login(username, password).subscribe({
next: () => {
this.loading = false;
this.router.navigateByUrl(returnUrl);
},
error: () => {
this.loading = false;
this.errorMessage = 'Invalid username or password';
},
});
}
}