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,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']);
});
});