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:
34
ui/src/app/auth/auth.guard.spec.ts
Normal file
34
ui/src/app/auth/auth.guard.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
12
ui/src/app/auth/auth.guard.ts
Normal file
12
ui/src/app/auth/auth.guard.ts
Normal 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 } });
|
||||
};
|
||||
57
ui/src/app/auth/auth.interceptor.spec.ts
Normal file
57
ui/src/app/auth/auth.interceptor.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
23
ui/src/app/auth/auth.interceptor.ts
Normal file
23
ui/src/app/auth/auth.interceptor.ts
Normal 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);
|
||||
}),
|
||||
);
|
||||
};
|
||||
55
ui/src/app/auth/auth.service.spec.ts
Normal file
55
ui/src/app/auth/auth.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
37
ui/src/app/auth/auth.service.ts
Normal file
37
ui/src/app/auth/auth.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user