AI Implementation feature(881): Authenticated Shell, Quick Tabs and Change Password 1.05 (#20)
This commit was merged in pull request #20.
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
export const CROSS_TAB_CHANNEL_NAME = 'hipctf.auth.v1';
|
||||
export const CROSS_TAB_STORAGE_KEY = 'hipctf.auth.invalidate.v1';
|
||||
export const CROSS_TAB_EVENT_TYPE = 'hipctf.session.invalidated';
|
||||
|
||||
export interface CrossTabInvalidationMessage {
|
||||
type: 'session-invalidated';
|
||||
origin: string;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
export function encodeInvalidationMessage(origin: string): CrossTabInvalidationMessage {
|
||||
return {
|
||||
type: 'session-invalidated',
|
||||
origin,
|
||||
ts: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function isCrossTabInvalidationMessage(
|
||||
value: unknown,
|
||||
): value is CrossTabInvalidationMessage {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return (
|
||||
v['type'] === 'session-invalidated' &&
|
||||
typeof v['origin'] === 'string' &&
|
||||
typeof v['ts'] === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
export function isStorageInvalidationEvent(
|
||||
event: { key?: string | null; newValue?: string | null } | null | undefined,
|
||||
): boolean {
|
||||
if (!event) return false;
|
||||
if (event.key !== CROSS_TAB_STORAGE_KEY) return false;
|
||||
if (event.newValue == null) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(event.newValue) as unknown;
|
||||
return isCrossTabInvalidationMessage(parsed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||
import { Injectable, signal, computed, inject, DestroyRef } from '@angular/core';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
@@ -7,6 +7,14 @@ import {
|
||||
clearStoredSession,
|
||||
StoredSession,
|
||||
} from './auth.session-storage';
|
||||
import {
|
||||
CROSS_TAB_CHANNEL_NAME,
|
||||
CROSS_TAB_STORAGE_KEY,
|
||||
CrossTabInvalidationMessage,
|
||||
encodeInvalidationMessage,
|
||||
isCrossTabInvalidationMessage,
|
||||
isStorageInvalidationEvent,
|
||||
} from './auth-session-events.pure';
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
@@ -78,9 +86,22 @@ 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 storageListener: ((ev: StorageEvent) => void) | null = null;
|
||||
private suppressBroadcast = false;
|
||||
|
||||
private peerInvalidationListeners = new Set<(reason: 'peer-logout' | 'sse-unauthorized') => void>();
|
||||
|
||||
constructor(http?: HttpClient) {
|
||||
this.http = http ?? inject(HttpClient);
|
||||
this.installCrossTabListener();
|
||||
this.destroyRef.onDestroy(() => this.teardownCrossTabListener());
|
||||
}
|
||||
|
||||
private accessToken = signal<string | null>(null);
|
||||
@@ -158,7 +179,7 @@ export class AuthService {
|
||||
} catch {
|
||||
// ignore network/auth errors; we still clear local state
|
||||
}
|
||||
this.clear();
|
||||
this.clearAndBroadcast();
|
||||
}
|
||||
|
||||
async me(): Promise<MeResponse> {
|
||||
@@ -204,10 +225,118 @@ export class AuthService {
|
||||
clearStoredSession(this.sessionStorage());
|
||||
}
|
||||
|
||||
clearAndBroadcast(): void {
|
||||
this.clear();
|
||||
if (this.suppressBroadcast) return;
|
||||
const message = encodeInvalidationMessage(this.crossTabOrigin);
|
||||
this.publishInvalidation(message);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
this.storageListener = (ev: StorageEvent) => {
|
||||
if (!isStorageInvalidationEvent(ev)) return;
|
||||
this.handlePeerInvalidation(ev.newValue, 'peer-logout');
|
||||
};
|
||||
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.storageListener && typeof window !== 'undefined') {
|
||||
try {
|
||||
window.removeEventListener('storage', this.storageListener);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this.storageListener = null;
|
||||
}
|
||||
this.peerInvalidationListeners.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 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 sessionStorage(): Storage {
|
||||
return typeof window !== 'undefined' ? window.sessionStorage : (undefined as unknown as Storage);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ export class AuthenticatedEventSourceService {
|
||||
dispatch('error', new Event('error'));
|
||||
};
|
||||
|
||||
const fireUnauthorized = () => {
|
||||
if (closed) return;
|
||||
dispatch('unauthorized', new Event('unauthorized'));
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
@@ -56,6 +61,10 @@ export class AuthenticatedEventSourceService {
|
||||
credentials: 'include',
|
||||
signal: controller!.signal,
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
if (!closed) fireUnauthorized();
|
||||
return;
|
||||
}
|
||||
if (!res.ok || !res.body) {
|
||||
fireError();
|
||||
return;
|
||||
|
||||
@@ -10,7 +10,10 @@ export interface EventStatePayload {
|
||||
}
|
||||
|
||||
export interface EventSourceLike {
|
||||
addEventListener(type: 'open' | 'message' | 'error', listener: (ev: MessageEvent | Event) => void): void;
|
||||
addEventListener(
|
||||
type: 'open' | 'message' | 'error' | 'unauthorized',
|
||||
listener: (ev: MessageEvent | Event) => void,
|
||||
): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ export class EventStatusStore {
|
||||
this.anchorDeltaMs.set(Date.now() - new Date(payload.serverNowUtc).getTime());
|
||||
}
|
||||
|
||||
start(createSource: () => EventSourceLike): void {
|
||||
start(createSource: () => EventSourceLike, onUnauthorized?: () => void): void {
|
||||
this.stop();
|
||||
const src = createSource();
|
||||
this.source = src;
|
||||
@@ -81,6 +81,15 @@ export class EventStatusStore {
|
||||
// ignore malformed frame
|
||||
}
|
||||
});
|
||||
if (onUnauthorized) {
|
||||
src.addEventListener('unauthorized', () => {
|
||||
try {
|
||||
onUnauthorized();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!this.intervalId) {
|
||||
this.intervalId = setInterval(() => this.tick.update((v) => v + 1), 1000);
|
||||
}
|
||||
|
||||
@@ -129,11 +129,21 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
ngOnInit(): void {
|
||||
this.userStore.hydrateFromAuth();
|
||||
void this.userStore.loadMe();
|
||||
this.eventStatus.start(() => this.authenticatedEventSource.open('/api/v1/events/status'));
|
||||
this.eventStatus.start(
|
||||
() => this.authenticatedEventSource.open('/api/v1/events/status'),
|
||||
() => this.handleSessionInvalidated('sse-unauthorized'),
|
||||
);
|
||||
this.peerInvalidationUnsubscribe = this.auth.onPeerInvalidation((reason) =>
|
||||
this.handleSessionInvalidated(reason),
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.eventStatus.stop();
|
||||
if (this.peerInvalidationUnsubscribe) {
|
||||
this.peerInvalidationUnsubscribe();
|
||||
this.peerInvalidationUnsubscribe = null;
|
||||
}
|
||||
}
|
||||
|
||||
goHome(): void {
|
||||
@@ -192,4 +202,23 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
this.userStore.reset();
|
||||
await this.router.navigateByUrl('/login');
|
||||
}
|
||||
|
||||
private peerInvalidationUnsubscribe: (() => void) | null = null;
|
||||
private navigatingToLogin = false;
|
||||
|
||||
private async handleSessionInvalidated(
|
||||
_reason: 'peer-logout' | 'sse-unauthorized',
|
||||
): Promise<void> {
|
||||
this.userStore.reset();
|
||||
this.changePasswordOpen.set(false);
|
||||
this.userMenuOpen.set(false);
|
||||
if (this.navigatingToLogin) return;
|
||||
if (this.router.url.startsWith('/login')) return;
|
||||
this.navigatingToLogin = true;
|
||||
try {
|
||||
await this.router.navigateByUrl('/login');
|
||||
} finally {
|
||||
this.navigatingToLogin = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user