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

@@ -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;
}
}