Files
HIPCTF2/frontend/src/app/core/services/auth.service.ts
T

512 lines
14 KiB
TypeScript

import { Injectable, signal, computed, inject, DestroyRef } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import {
readStoredSession,
writeStoredSession,
clearStoredSession,
StoredSession,
} from './auth.session-storage';
import {
CROSS_TAB_CHANNEL_NAME,
CROSS_TAB_STORAGE_KEY,
CROSS_TAB_ROLE_CHANNEL_NAME,
CROSS_TAB_ROLE_STORAGE_KEY,
CrossTabInvalidationMessage,
CrossTabRoleChangeMessage,
encodeInvalidationMessage,
encodeRoleChangeMessage,
isCrossTabInvalidationMessage,
isCrossTabRoleChangeMessage,
isStorageInvalidationEvent,
isStorageRoleChangeEvent,
} from './auth-session-events.pure';
export interface CurrentUser {
id: string;
username: string;
role: 'admin' | 'player';
rank?: number | null;
points?: number;
}
interface RefreshResponse {
accessToken: string;
expiresIn: number;
user: CurrentUser;
}
export interface MeResponse {
id: string;
username: string;
role: 'admin' | 'player';
rank: number | null;
points: number;
}
export interface LoginSuccess {
ok: true;
accessToken: string;
expiresIn: number;
user: CurrentUser;
}
export interface LoginFailure {
ok: false;
status: number;
code: string;
message: string;
}
export type LoginResult = LoginSuccess | LoginFailure;
export interface RegisterSuccess {
ok: true;
accessToken: string;
expiresIn: number;
user: CurrentUser;
}
export interface RegisterFailure {
ok: false;
status: number;
code: string;
message: string;
}
export type RegisterResult = RegisterSuccess | RegisterFailure;
export interface ChangePasswordSuccess {
ok: true;
}
export interface ChangePasswordFailure {
ok: false;
status: number;
code: string;
message: string;
}
export type ChangePasswordResult = ChangePasswordSuccess | ChangePasswordFailure;
@Injectable({ providedIn: 'root' })
export class AuthService {
private http: HttpClient;
private readonly destroyRef = inject(DestroyRef);
private readonly crossTabOrigin =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `tab-${Math.random().toString(36).slice(2)}-${Date.now()}`;
private channel: BroadcastChannel | null = null;
private roleChannel: BroadcastChannel | null = null;
private storageListener: ((ev: StorageEvent) => void) | null = null;
private suppressBroadcast = false;
private peerInvalidationListeners = new Set<(reason: 'peer-logout' | 'sse-unauthorized') => void>();
private peerRoleChangeListeners = new Set<(msg: CrossTabRoleChangeMessage) => void>();
constructor(http?: HttpClient) {
this.http = http ?? inject(HttpClient);
this.installCrossTabListener();
this.destroyRef.onDestroy(() => this.teardownCrossTabListener());
}
private accessToken = signal<string | null>(null);
private user = signal<CurrentUser | null>(null);
readonly hydrated = signal<boolean>(false);
private hydrateWaiters: Array<() => void> = [];
readonly currentUser = computed(() => this.user());
readonly isAuthenticated = computed(() => !!this.user());
async ensureCsrf(): Promise<void> {
try {
await firstValueFrom(
this.http.get('/api/v1/auth/csrf', { withCredentials: true }),
);
} catch {
// middleware will still mint the cookie on the next response
}
}
async login(creds: { username: string; password: string }): Promise<LoginResult> {
await this.ensureCsrf();
try {
const res = await firstValueFrom(
this.http.post<{ accessToken: string; expiresIn: number; user: CurrentUser }>(
'/api/v1/auth/login',
creds,
{ withCredentials: true, observe: 'response' },
),
);
const success: LoginSuccess = {
ok: true,
accessToken: res.body!.accessToken,
expiresIn: res.body!.expiresIn,
user: res.body!.user,
};
return success;
} catch (e) {
return mapAuthError(e as HttpErrorResponse);
}
}
async register(creds: {
username: string;
password: string;
passwordConfirm: string;
}): Promise<RegisterResult> {
await this.ensureCsrf();
try {
const res = await firstValueFrom(
this.http.post<{ accessToken: string; expiresIn: number; user: CurrentUser }>(
'/api/v1/auth/register',
creds,
{ withCredentials: true, observe: 'response' },
),
);
const success: RegisterSuccess = {
ok: true,
accessToken: res.body!.accessToken,
expiresIn: res.body!.expiresIn,
user: res.body!.user,
};
return success;
} catch (e) {
return mapAuthError(e as HttpErrorResponse) as RegisterFailure;
}
}
async logout(): Promise<void> {
await this.ensureCsrf();
try {
await firstValueFrom(
this.http.post('/api/v1/auth/logout', {}, { withCredentials: true, observe: 'response' }),
);
} catch {
// ignore network/auth errors; we still clear local state
}
this.clearAndBroadcast();
}
async me(): Promise<MeResponse> {
const res = await firstValueFrom(
this.http.get<MeResponse>('/api/v1/auth/me', { withCredentials: true }),
);
const current = this.user();
if (current && current.id === res.id) {
this.user.set({ ...current, rank: res.rank, points: res.points });
}
return res;
}
async changePassword(dto: {
oldPassword: string;
newPassword: string;
confirmNewPassword: string;
}): Promise<ChangePasswordResult> {
await this.ensureCsrf();
try {
await firstValueFrom(
this.http.post(
'/api/v1/auth/change-password',
dto,
{ withCredentials: true, observe: 'response' },
),
);
return { ok: true };
} catch (e) {
return mapAuthError(e as HttpErrorResponse);
}
}
setSession(token: string, user: CurrentUser): void {
this.accessToken.set(token);
this.user.set(user);
writeStoredSession(this.sessionStorage(), { token, user });
}
clear(): void {
this.accessToken.set(null);
this.user.set(null);
clearStoredSession(this.sessionStorage());
}
clearAndBroadcast(): void {
this.clear();
if (this.suppressBroadcast) return;
const message = encodeInvalidationMessage(this.crossTabOrigin);
this.publishInvalidation(message);
}
/**
* Forcibly invalidate the local session (and broadcast peer
* invalidation) — used after a destructive server-initiated action such
* as a successful restore that revokes access. Same as a normal logout
* from the client's perspective.
*/
forceServerInvalidation(): void {
this.suppressBroadcast = false;
this.clearAndBroadcast();
}
handleSseUnauthorized(): void {
this.clearAndBroadcast();
this.notifyPeerInvalidation('sse-unauthorized');
}
onPeerInvalidation(cb: (reason: 'peer-logout' | 'sse-unauthorized') => void): () => void {
this.peerInvalidationListeners.add(cb);
return () => this.peerInvalidationListeners.delete(cb);
}
onPeerRoleChange(cb: (msg: CrossTabRoleChangeMessage) => void): () => void {
this.peerRoleChangeListeners.add(cb);
return () => this.peerRoleChangeListeners.delete(cb);
}
applyPeerRoleChange(newRole: 'admin' | 'player'): void {
const current = this.user();
if (!current) return;
if (current.role === newRole) return;
const updated = { ...current, role: newRole };
this.user.set(updated);
if (typeof window !== 'undefined') {
try {
writeStoredSession(this.sessionStorage(), { token: this.accessToken() ?? '', user: updated });
} catch {
// ignore storage failures
}
}
}
publishRoleChange(userId: string, newRole: 'admin' | 'player'): void {
const message = encodeRoleChangeMessage(this.crossTabOrigin, userId, newRole);
if (this.roleChannel) {
try {
this.roleChannel.postMessage(message);
} catch {
// ignore serialization failures; storage fallback still fires
}
}
if (typeof window !== 'undefined' && window.localStorage) {
try {
window.localStorage.setItem(CROSS_TAB_ROLE_STORAGE_KEY, JSON.stringify(message));
} catch {
// ignore quota / serialization errors
}
}
}
getAccessToken(): string | null {
return this.accessToken();
}
private publishInvalidation(message: CrossTabInvalidationMessage): void {
if (this.channel) {
try {
this.channel.postMessage(message);
} catch {
// ignore serialization failures; the storage fallback will still fire
}
}
if (typeof window !== 'undefined' && window.localStorage) {
try {
window.localStorage.setItem(CROSS_TAB_STORAGE_KEY, JSON.stringify(message));
} catch {
// ignore quota / serialization errors
}
}
}
private installCrossTabListener(): void {
if (typeof window === 'undefined') return;
if (typeof BroadcastChannel !== 'undefined') {
try {
this.channel = new BroadcastChannel(CROSS_TAB_CHANNEL_NAME);
this.channel.addEventListener('message', this.onBroadcastMessage);
} catch {
this.channel = null;
}
try {
this.roleChannel = new BroadcastChannel(CROSS_TAB_ROLE_CHANNEL_NAME);
this.roleChannel.addEventListener('message', this.onRoleBroadcastMessage);
} catch {
this.roleChannel = null;
}
}
this.storageListener = (ev: StorageEvent) => {
if (isStorageInvalidationEvent(ev)) {
this.handlePeerInvalidation(ev.newValue, 'peer-logout');
return;
}
if (isStorageRoleChangeEvent(ev)) {
this.handlePeerRoleChange(ev.newValue);
}
};
window.addEventListener('storage', this.storageListener);
}
private teardownCrossTabListener(): void {
if (this.channel) {
try {
this.channel.removeEventListener('message', this.onBroadcastMessage);
this.channel.close();
} catch {
// ignore
}
this.channel = null;
}
if (this.roleChannel) {
try {
this.roleChannel.removeEventListener('message', this.onRoleBroadcastMessage);
this.roleChannel.close();
} catch {
// ignore
}
this.roleChannel = null;
}
if (this.storageListener && typeof window !== 'undefined') {
try {
window.removeEventListener('storage', this.storageListener);
} catch {
// ignore
}
this.storageListener = null;
}
this.peerInvalidationListeners.clear();
this.peerRoleChangeListeners.clear();
}
private readonly onBroadcastMessage = (ev: MessageEvent<unknown>) => {
if (!isCrossTabInvalidationMessage(ev.data)) return;
if (ev.data.origin === this.crossTabOrigin) return;
this.handlePeerInvalidation(JSON.stringify(ev.data), 'peer-logout');
};
private readonly onRoleBroadcastMessage = (ev: MessageEvent<unknown>) => {
if (!isCrossTabRoleChangeMessage(ev.data)) return;
if (ev.data.origin === this.crossTabOrigin) return;
this.handlePeerRoleChange(JSON.stringify(ev.data));
};
private handlePeerInvalidation(
raw: string | null,
reason: 'peer-logout' | 'sse-unauthorized',
): void {
if (raw) {
try {
const parsed = JSON.parse(raw) as unknown;
if (!isCrossTabInvalidationMessage(parsed)) return;
if (parsed.origin === this.crossTabOrigin) return;
} catch {
return;
}
}
if (!this.isAuthenticated()) return;
this.suppressBroadcast = true;
this.clear();
this.suppressBroadcast = false;
this.notifyPeerInvalidation(reason);
}
private notifyPeerInvalidation(reason: 'peer-logout' | 'sse-unauthorized'): void {
for (const cb of this.peerInvalidationListeners) {
try {
cb(reason);
} catch {
// ignore listener errors
}
}
}
private handlePeerRoleChange(raw: string | null): void {
if (!raw) return;
let parsed: CrossTabRoleChangeMessage | null = null;
try {
const candidate = JSON.parse(raw) as unknown;
if (!isCrossTabRoleChangeMessage(candidate)) return;
if (candidate.origin === this.crossTabOrigin) return;
parsed = candidate;
} catch {
return;
}
if (!parsed) return;
const me = this.user();
if (!me) return;
if (me.id !== parsed.userId) return;
this.applyPeerRoleChange(parsed.newRole);
for (const cb of this.peerRoleChangeListeners) {
try {
cb(parsed);
} catch {
// ignore listener errors
}
}
}
private sessionStorage(): Storage {
return typeof window !== 'undefined' ? window.sessionStorage : (undefined as unknown as Storage);
}
private restoreFromStorage(): void {
const stored = readStoredSession(this.sessionStorage());
if (stored) {
this.accessToken.set(stored.token);
this.user.set(stored.user as CurrentUser);
}
}
private markHydrated(): void {
if (this.hydrated()) return;
this.hydrated.set(true);
const waiters = this.hydrateWaiters;
this.hydrateWaiters = [];
for (const resolve of waiters) resolve();
}
waitUntilHydrated(): Promise<void> {
if (this.hydrated()) return Promise.resolve();
return new Promise<void>((resolve) => {
this.hydrateWaiters.push(resolve);
});
}
async restoreSession(): Promise<boolean> {
this.restoreFromStorage();
try {
const res = await firstValueFrom(
this.http.post<RefreshResponse>(
'/api/v1/auth/refresh',
{},
{ withCredentials: true },
),
);
if (res && res.accessToken && res.user) {
this.setSession(res.accessToken, res.user);
this.markHydrated();
return true;
}
this.clear();
this.markHydrated();
return false;
} catch {
this.clear();
this.markHydrated();
return false;
}
}
}
function mapAuthError(err: HttpErrorResponse): LoginFailure {
const body = err.error as { code?: string; message?: string } | null;
return {
ok: false,
status: err.status ?? 0,
code: body?.code ?? 'INTERNAL',
message: body?.message ?? err.message ?? 'Authentication failed',
};
}