mirror of
https://github.com/booklore-app/booklore.git
synced 2025-12-23 22:28:11 -05:00
OIDC Authentication Revamp: Smarter Init, Error Handling, and Fallback
This commit is contained in:
committed by
Aditya Chandel
parent
44541849ff
commit
c75c4dce5b
@@ -52,7 +52,7 @@ public class DualJwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
String path = request.getRequestURI();
|
||||
|
||||
if (path.startsWith("/api/v1/opds/") || path.equals("/api/v1/auth/refresh")) {
|
||||
if (path.startsWith("/api/v1/opds/") || path.equals("/api/v1/auth/refresh") || path.equals("/api/v1/setup/status")) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ public class SecurityConfig {
|
||||
"/ws/**",
|
||||
"/api/v1/auth/**",
|
||||
"/api/v1/public-settings",
|
||||
"/api/v1/setup/**",
|
||||
"/api/v1/setup/status",
|
||||
"/api/v1/books/*/cover",
|
||||
"/api/v1/books/*/backup-cover",
|
||||
"/api/v1/opds/*/cover.jpg",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {inject} from '@angular/core';
|
||||
import {OAuthService} from 'angular-oauth2-oidc';
|
||||
import {AuthService, websocketInitializer} from './core/service/auth.service';
|
||||
import {AppSettingsService} from './core/service/app-settings.service';
|
||||
import {AuthInitializationService} from './auth-initialization-service';
|
||||
import {PublicAppSettingService} from './public-app-settings.service';
|
||||
|
||||
const OIDC_BYPASS_KEY = 'booklore-oidc-bypass';
|
||||
const OIDC_ERROR_COUNT_KEY = 'booklore-oidc-error-count';
|
||||
@@ -21,17 +21,22 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
export function initializeAuthFactory() {
|
||||
return () => {
|
||||
const oauthService = inject(OAuthService);
|
||||
const publicAppSettingService = inject(PublicAppSettingService);
|
||||
const appSettingsService = inject(AppSettingsService);
|
||||
const authService = inject(AuthService);
|
||||
const authInitService = inject(AuthInitializationService);
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const sub = publicAppSettingService.publicAppSettings$.subscribe(publicSettings => {
|
||||
const sub = appSettingsService.publicAppSettings$.subscribe(publicSettings => {
|
||||
if (publicSettings) {
|
||||
const forceLocalOnly = new URLSearchParams(window.location.search).get('localOnly') === 'true';
|
||||
const oidcBypassed = localStorage.getItem(OIDC_BYPASS_KEY) === 'true';
|
||||
const errorCount = parseInt(localStorage.getItem(OIDC_ERROR_COUNT_KEY) || '0', 10);
|
||||
|
||||
if (publicSettings.oidcEnabled && publicSettings.oidcProviderDetails && !oidcBypassed && errorCount < MAX_OIDC_RETRIES) {
|
||||
if (!forceLocalOnly &&
|
||||
publicSettings.oidcEnabled &&
|
||||
publicSettings.oidcProviderDetails &&
|
||||
!oidcBypassed &&
|
||||
errorCount < MAX_OIDC_RETRIES) {
|
||||
const details = publicSettings.oidcProviderDetails;
|
||||
|
||||
oauthService.configure({
|
||||
@@ -53,7 +58,7 @@ export function initializeAuthFactory() {
|
||||
localStorage.removeItem(OIDC_ERROR_COUNT_KEY);
|
||||
|
||||
if (oauthService.hasValidAccessToken()) {
|
||||
authService.tokenSubject.next(oauthService.getAccessToken())
|
||||
authService.tokenSubject.next(oauthService.getAccessToken());
|
||||
console.log('[OIDC] Valid access token found after tryLogin');
|
||||
oauthService.setupAutomaticSilentRefresh();
|
||||
websocketInitializer(authService);
|
||||
@@ -90,7 +95,9 @@ export function initializeAuthFactory() {
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
if (oidcBypassed) {
|
||||
if (forceLocalOnly) {
|
||||
console.warn('[OIDC] Forced local-only login via ?localOnly=true');
|
||||
} else if (oidcBypassed) {
|
||||
console.log('[OIDC] OIDC is manually bypassed, using local authentication only');
|
||||
} else if (errorCount >= MAX_OIDC_RETRIES) {
|
||||
console.log(`[OIDC] OIDC automatically bypassed due to ${errorCount} consecutive errors`);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
style="background: linear-gradient(60deg, var(--primary-color) 0%, #1e3a8a 100%);">
|
||||
<div class="flex flex-col items-center justify-center -mt-48 w-[90%] sm:w-auto sm:min-w-[400px] md:min-w-[500px]">
|
||||
<div class="w-full" style="border-radius: 56px; padding: 0.3rem; background: linear-gradient(180deg, var(--primary-color) 10%, rgba(33, 150, 243, 0) 30%)">
|
||||
<div class="w-full bg-surface-900 pt-8 pb-12 px-8 sm:px-8" style="border-radius: 53px">
|
||||
<div class="w-full bg-surface-900 pt-8 pb-14 px-8 sm:px-12" style="border-radius: 53px">
|
||||
<div class="text-center mb-8">
|
||||
<svg class="mb-8 w-14 h-14 mx-auto" viewBox="0 0 126 126" fill="var(--primary-color)" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
@@ -57,6 +57,7 @@
|
||||
[(ngModel)]="username"
|
||||
placeholder="Enter your username"
|
||||
class="w-full"
|
||||
autocomplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -72,6 +73,7 @@
|
||||
[toggleMask]="true"
|
||||
class="mb-4"
|
||||
[fluid]="true"
|
||||
autocomplete="current-password"
|
||||
></p-password>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Component, inject, OnInit, OnDestroy} from '@angular/core';
|
||||
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
|
||||
import {AuthService} from '../../service/auth.service';
|
||||
import {Router} from '@angular/router';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
@@ -8,9 +8,9 @@ import {Message} from 'primeng/message';
|
||||
import {InputText} from 'primeng/inputtext';
|
||||
import {OAuthService} from 'angular-oauth2-oidc';
|
||||
import {Observable, Subject} from 'rxjs';
|
||||
import {filter, take, takeUntil} from 'rxjs/operators';
|
||||
import {PublicAppSettings, PublicAppSettingService} from '../../../public-app-settings.service';
|
||||
import {filter, take} from 'rxjs/operators';
|
||||
import {getOidcErrorCount, isOidcBypassed, resetOidcBypass} from '../../../auth-initializer';
|
||||
import {AppSettingsService, PublicAppSettings} from '../../service/app-settings.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
@@ -37,7 +37,7 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
|
||||
private authService = inject(AuthService);
|
||||
private oAuthService = inject(OAuthService);
|
||||
private appSettingsService = inject(PublicAppSettingService);
|
||||
private appSettingsService = inject(AppSettingsService);
|
||||
private router = inject(Router);
|
||||
|
||||
publicAppSettings$: Observable<PublicAppSettings | null> = this.appSettingsService.publicAppSettings$;
|
||||
|
||||
@@ -113,13 +113,7 @@ export class AuthenticationSettingsComponent implements OnInit {
|
||||
|
||||
toggleOidcEnabled(): void {
|
||||
if (!this.isOidcFormComplete()) return;
|
||||
const payload = [
|
||||
{
|
||||
key: AppSettingKey.OIDC_ENABLED,
|
||||
newValue: this.oidcEnabled
|
||||
}
|
||||
];
|
||||
this.appSettingsService.saveSettings(payload).subscribe({
|
||||
this.appSettingsService.toggleOidcEnabled(this.oidcEnabled).subscribe({
|
||||
next: () => this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Saved',
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {inject, Injectable, Injector} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {BehaviorSubject, Observable, of} from 'rxjs';
|
||||
import {AppSettings} from '../model/app-settings.model';
|
||||
import {API_CONFIG} from '../../config/api-config';
|
||||
import {catchError, finalize, shareReplay, tap} from 'rxjs/operators';
|
||||
import {API_CONFIG} from '../../config/api-config';
|
||||
import {AppSettings, OidcProviderDetails} from '../model/app-settings.model';
|
||||
import {AuthService} from './auth.service';
|
||||
|
||||
export interface PublicAppSettings {
|
||||
oidcEnabled: boolean;
|
||||
oidcProviderDetails: OidcProviderDetails;
|
||||
}
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class AppSettingsService {
|
||||
private http = inject(HttpClient);
|
||||
private injector = inject(Injector);
|
||||
|
||||
private readonly apiUrl = `${API_CONFIG.BASE_URL}/api/v1/settings`;
|
||||
private readonly publicApiUrl = `${API_CONFIG.BASE_URL}/api/v1/public-settings`;
|
||||
|
||||
private loading$: Observable<AppSettings> | null = null;
|
||||
private appSettingsSubject = new BehaviorSubject<AppSettings | null>(null);
|
||||
|
||||
appSettings$ = this.appSettingsSubject.asObservable().pipe(
|
||||
tap(state => {
|
||||
if (!state && !this.loading$) {
|
||||
@@ -24,9 +34,27 @@ export class AppSettingsService {
|
||||
})
|
||||
);
|
||||
|
||||
private publicLoading$: Observable<PublicAppSettings> | null = null;
|
||||
private publicAppSettingsSubject = new BehaviorSubject<PublicAppSettings | null>(null);
|
||||
|
||||
publicAppSettings$ = this.publicAppSettingsSubject.asObservable().pipe(
|
||||
tap(state => {
|
||||
if (!state && !this.publicLoading$) {
|
||||
this.publicLoading$ = this.fetchPublicSettings().pipe(
|
||||
shareReplay(1),
|
||||
finalize(() => (this.publicLoading$ = null))
|
||||
);
|
||||
this.publicLoading$.subscribe();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
private fetchAppSettings(): Observable<AppSettings> {
|
||||
return this.http.get<AppSettings>(this.apiUrl).pipe(
|
||||
tap(settings => this.appSettingsSubject.next(settings)),
|
||||
tap(settings => {
|
||||
this.appSettingsSubject.next(settings);
|
||||
this.syncPublicSettings(settings);
|
||||
}),
|
||||
catchError(err => {
|
||||
console.error('Error loading app settings:', err);
|
||||
this.appSettingsSubject.next(null);
|
||||
@@ -35,6 +63,32 @@ export class AppSettingsService {
|
||||
);
|
||||
}
|
||||
|
||||
private fetchPublicSettings(): Observable<PublicAppSettings> {
|
||||
return this.http.get<PublicAppSettings>(this.publicApiUrl).pipe(
|
||||
tap(settings => this.publicAppSettingsSubject.next(settings)),
|
||||
catchError(err => {
|
||||
console.error('Failed to fetch public settings', err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private syncPublicSettings(appSettings: AppSettings): void {
|
||||
const updatedPublicSettings: PublicAppSettings = {
|
||||
oidcEnabled: appSettings.oidcEnabled,
|
||||
oidcProviderDetails: appSettings.oidcProviderDetails
|
||||
};
|
||||
const current = this.publicAppSettingsSubject.value;
|
||||
|
||||
if (
|
||||
!current ||
|
||||
current.oidcEnabled !== updatedPublicSettings.oidcEnabled ||
|
||||
JSON.stringify(current.oidcProviderDetails) !== JSON.stringify(updatedPublicSettings.oidcProviderDetails)
|
||||
) {
|
||||
this.publicAppSettingsSubject.next(updatedPublicSettings);
|
||||
}
|
||||
}
|
||||
|
||||
saveSettings(settings: { key: string; newValue: any }[]): Observable<void> {
|
||||
const payload = settings.map(setting => ({
|
||||
name: setting.key,
|
||||
@@ -43,11 +97,18 @@ export class AppSettingsService {
|
||||
|
||||
return this.http.put<void>(this.apiUrl, payload).pipe(
|
||||
tap(() => {
|
||||
this.loading$ = this.fetchAppSettings().pipe(
|
||||
shareReplay(1),
|
||||
finalize(() => (this.loading$ = null))
|
||||
);
|
||||
this.loading$.subscribe();
|
||||
const current = this.appSettingsSubject.value;
|
||||
if (current) {
|
||||
settings.forEach(s => (current as any)[s.key] = s.newValue);
|
||||
this.appSettingsSubject.next({...current});
|
||||
this.syncPublicSettings(current);
|
||||
} else {
|
||||
this.loading$ = this.fetchAppSettings().pipe(
|
||||
shareReplay(1),
|
||||
finalize(() => (this.loading$ = null))
|
||||
);
|
||||
this.loading$.subscribe();
|
||||
}
|
||||
}),
|
||||
catchError(err => {
|
||||
console.error('Error saving settings:', err);
|
||||
@@ -55,4 +116,28 @@ export class AppSettingsService {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
toggleOidcEnabled(enabled: boolean): Observable<void> {
|
||||
const payload = [{name: 'OIDC_ENABLED', value: enabled}];
|
||||
return this.http.put<void>(this.apiUrl, payload).pipe(
|
||||
tap(() => {
|
||||
const current = this.appSettingsSubject.value;
|
||||
if (current) {
|
||||
current.oidcEnabled = enabled;
|
||||
this.appSettingsSubject.next({...current});
|
||||
this.syncPublicSettings(current);
|
||||
}
|
||||
if (!enabled) {
|
||||
setTimeout(() => {
|
||||
const authService = this.injector.get(AuthService);
|
||||
authService.clearOIDCTokens();
|
||||
});
|
||||
}
|
||||
}),
|
||||
catchError(err => {
|
||||
console.error('Error toggling OIDC:', err);
|
||||
return of();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {inject, Injectable, Injector} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {BehaviorSubject, Observable, Subject, tap} from 'rxjs';
|
||||
import {BehaviorSubject, Observable, tap} from 'rxjs';
|
||||
import {RxStompService} from '../../shared/websocket/rx-stomp.service';
|
||||
import {API_CONFIG} from '../../config/api-config';
|
||||
import {createRxStompConfig} from '../../shared/websocket/rx-stomp.config';
|
||||
import {OAuthService} from 'angular-oauth2-oidc';
|
||||
import {OAuthService, OAuthStorage} from 'angular-oauth2-oidc';
|
||||
import {Router} from '@angular/router';
|
||||
|
||||
@Injectable({
|
||||
@@ -18,6 +18,7 @@ export class AuthService {
|
||||
private http = inject(HttpClient);
|
||||
private injector = inject(Injector);
|
||||
private oAuthService = inject(OAuthService);
|
||||
private oAuthStorage = inject(OAuthStorage);
|
||||
private router = inject(Router);
|
||||
|
||||
public tokenSubject = new BehaviorSubject<string | null>(this.getOidcAccessToken() || this.getInternalAccessToken());
|
||||
@@ -64,9 +65,22 @@ export class AuthService {
|
||||
return localStorage.getItem('refreshToken_Internal');
|
||||
}
|
||||
|
||||
clearOIDCTokens(): void {
|
||||
const hasInternalTokens = this.getInternalAccessToken() || this.getInternalRefreshToken();
|
||||
if (!hasInternalTokens) {
|
||||
this.oAuthStorage.removeItem("access_token");
|
||||
this.oAuthStorage.removeItem("refresh_token");
|
||||
this.oAuthStorage.removeItem("id_token");
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
localStorage.removeItem('accessToken_Internal');
|
||||
localStorage.removeItem('refreshToken_Internal');
|
||||
this.oAuthStorage.removeItem("access_token");
|
||||
this.oAuthStorage.removeItem("refresh_token");
|
||||
this.oAuthStorage.removeItem("id_token");
|
||||
this.tokenSubject.next(null);
|
||||
this.getRxStompService().deactivate();
|
||||
this.router.navigate(['/login']);
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {API_CONFIG} from './config/api-config';
|
||||
import {BehaviorSubject, Observable} from 'rxjs';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {OidcProviderDetails} from './core/model/app-settings.model';
|
||||
import {catchError, finalize, shareReplay, tap} from 'rxjs/operators';
|
||||
|
||||
export interface PublicAppSettings {
|
||||
oidcEnabled: boolean;
|
||||
oidcProviderDetails: OidcProviderDetails;
|
||||
}
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class PublicAppSettingService {
|
||||
private http = inject(HttpClient);
|
||||
private readonly url = `${API_CONFIG.BASE_URL}/api/v1/public-settings`;
|
||||
|
||||
private loading$: Observable<PublicAppSettings> | null = null;
|
||||
private publicAppSettingsSubject = new BehaviorSubject<PublicAppSettings | null>(null);
|
||||
|
||||
publicAppSettings$ = this.publicAppSettingsSubject.asObservable().pipe(
|
||||
tap(state => {
|
||||
if (!state && !this.loading$) {
|
||||
this.loading$ = this.fetchPublicSettings().pipe(
|
||||
shareReplay(1),
|
||||
finalize(() => (this.loading$ = null))
|
||||
);
|
||||
this.loading$.subscribe();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
private fetchPublicSettings(): Observable<PublicAppSettings> {
|
||||
return this.http.get<PublicAppSettings>(this.url).pipe(
|
||||
tap(settings => this.publicAppSettingsSubject.next(settings)),
|
||||
catchError(err => {
|
||||
console.error('Failed to fetch public settings', err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user