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:
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
63
ui/src/app/login/login.component.spec.ts
Normal file
63
ui/src/app/login/login.component.spec.ts
Normal 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();
|
||||
}));
|
||||
});
|
||||
73
ui/src/app/login/login.component.ts
Normal file
73
ui/src/app/login/login.component.ts
Normal 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';
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user