fix 401 loop hell

This commit is contained in:
Chris Chen
2026-05-27 15:09:05 -07:00
parent e83fa4c2e9
commit d79b1faa8f
13 changed files with 196 additions and 90 deletions
+1
View File
@@ -92,3 +92,4 @@ logs/
*.tmp *.tmp
*.temp *.temp
/.claude /.claude
/API/ROLAC.API/bin-verify
+4 -8
View File
@@ -21,10 +21,10 @@ builder.Services.AddDbContext<AppDbContext>((sp, opt) =>
.AddInterceptors(sp.GetRequiredService<AuditSaveChangesInterceptor>())); .AddInterceptors(sp.GetRequiredService<AuditSaveChangesInterceptor>()));
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Identity // Identity (API-only — no cookie auth; JWT is the default scheme)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
builder.Services builder.Services
.AddIdentity<AppUser, AppRole>(opt => .AddIdentityCore<AppUser>(opt =>
{ {
opt.Password.RequiredLength = 8; opt.Password.RequiredLength = 8;
opt.Password.RequireDigit = true; opt.Password.RequireDigit = true;
@@ -32,8 +32,8 @@ builder.Services
opt.Password.RequireLowercase = true; opt.Password.RequireLowercase = true;
opt.Password.RequireNonAlphanumeric = true; opt.Password.RequireNonAlphanumeric = true;
opt.User.RequireUniqueEmail = true; opt.User.RequireUniqueEmail = true;
opt.SignIn.RequireConfirmedAccount = false;
}) })
.AddRoles<AppRole>()
.AddEntityFrameworkStores<AppDbContext>() .AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders(); .AddDefaultTokenProviders();
@@ -44,11 +44,7 @@ var jwtKey = config["Jwt:SecretKey"]
?? throw new InvalidOperationException("Jwt:SecretKey is not configured."); ?? throw new InvalidOperationException("Jwt:SecretKey is not configured.");
builder.Services builder.Services
.AddAuthentication(opt => .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
{
opt.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
opt.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(opt => .AddJwtBearer(opt =>
{ {
opt.TokenValidationParameters = new TokenValidationParameters opt.TokenValidationParameters = new TokenValidationParameters
+9 -3
View File
@@ -1,16 +1,22 @@
import { ApplicationConfig } from '@angular/core'; import { ApplicationConfig, APP_INITIALIZER } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { provideAnimations } from '@angular/platform-browser/animations'; import { provideAnimations } from '@angular/platform-browser/animations';
import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor'; import { authInterceptor } from './core/interceptors/auth.interceptor';
import { AuthService } from './shared/services/auth.service';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideRouter(routes), provideRouter(routes),
provideAnimations(), provideAnimations(),
provideHttpClient(withInterceptors([authInterceptor])) provideHttpClient(withInterceptors([authInterceptor])),
{
provide: APP_INITIALIZER,
useFactory: (authService: AuthService) => () => authService.initializeFromRefreshToken(),
deps: [AuthService],
multi: true,
},
] ]
}; };
+7 -1
View File
@@ -3,6 +3,7 @@ import { DashboardComponent } from './portals/user-portal/pages/dashboard/dashbo
import { LoginPage } from './features/login-page/login-page'; import { LoginPage } from './features/login-page/login-page';
import { UserPortalComponent } from './portals/user-portal/user-portal.component'; import { UserPortalComponent } from './portals/user-portal/user-portal.component';
import { AuthGuard } from './core/guards/auth.guard'; import { AuthGuard } from './core/guards/auth.guard';
import { RoleGuard } from './core/guards/role.guard';
import { MembersPageComponent } from './features/members/pages/members-page/members-page.component'; import { MembersPageComponent } from './features/members/pages/members-page/members-page.component';
import { UsersPageComponent } from './features/users/pages/users-page/users-page.component'; import { UsersPageComponent } from './features/users/pages/users-page/users-page.component';
@@ -19,7 +20,12 @@ export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' }, { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: DashboardComponent }, { path: 'dashboard', component: DashboardComponent },
{ path: 'admin/members', component: MembersPageComponent }, { path: 'admin/members', component: MembersPageComponent },
{ path: 'admin/users', component: UsersPageComponent }, {
path: 'admin/users',
component: UsersPageComponent,
canActivate: [RoleGuard],
data: { roles: ['super_admin'] },
},
] ]
}, },
+7 -7
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; import { CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { Observable, of } from 'rxjs'; import { Observable } from 'rxjs';
import { map, catchError } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { AuthService } from '../../shared/services/auth.service'; import { AuthService } from '../../shared/services/auth.service';
@Injectable({ @Injectable({
@@ -14,19 +14,19 @@ export class AuthGuard implements CanActivate {
) { } ) { }
canActivate( canActivate(
route: ActivatedRouteSnapshot, _route: ActivatedRouteSnapshot,
state: RouterStateSnapshot state: RouterStateSnapshot
): Observable<boolean> | Promise<boolean> | boolean { ): Observable<boolean> | Promise<boolean> | boolean {
// Check if user is authenticated return this.authService.whenSessionReady().pipe(
map(() => {
if (this.authService.isAuthenticated()) { if (this.authService.isAuthenticated()) {
return true; return true;
} }
// Store the attempted URL for redirecting after login
this.authService.setRedirectUrl(state.url); this.authService.setRedirectUrl(state.url);
// Redirect to login page
this.router.navigate(['/login']); this.router.navigate(['/login']);
return false; return false;
})
);
} }
} }
+27
View File
@@ -0,0 +1,27 @@
import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, Router } from '@angular/router';
import { AuthService } from '../../shared/services/auth.service';
@Injectable({ providedIn: 'root' })
export class RoleGuard implements CanActivate {
constructor(
private authService: AuthService,
private router: Router
) { }
canActivate(route: ActivatedRouteSnapshot): boolean {
const requiredRoles = route.data['roles'] as string[] | undefined;
if (!requiredRoles?.length) {
return true;
}
const user = this.authService.getCurrentUser();
const allowed = user?.roles?.some(r => requiredRoles.includes(r)) ?? false;
if (!allowed) {
this.router.navigate(['/user-portal/dashboard']);
}
return allowed;
}
}
@@ -1,60 +1,75 @@
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http'; import { HttpInterceptorFn, HttpErrorResponse, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs'; import { catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from '../../shared/services/auth.service'; import { AuthService } from '../../shared/services/auth.service';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { ApiConfigService } from '../services/api-config.service'; import { ApiConfigService } from '../services/api-config.service';
export const authInterceptor: HttpInterceptorFn = (request, next) => { /**
* Attaches Bearer tokens to ROLAC API calls and silently refreshes on 401.
*/
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService); const authService = inject(AuthService);
const apiConfigService = inject(ApiConfigService); const apiConfig = inject(ApiConfigService);
const router = inject(Router); const router = inject(Router);
// Get the current user and token const request = attachToken(req, authService, apiConfig);
const token = authService.getToken();
// Clone the request and add the Authorization header if token exists
if (token && shouldAddToken(apiConfigService, request)) {
request = request.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
}
// Handle the request and catch 401 errors
return next(request).pipe( return next(request).pipe(
catchError((error: HttpErrorResponse) => { catchError((error: HttpErrorResponse) => {
if (
error.status === 401 &&
!req.headers.has('X-Retry') &&
!isPublicAuthRequest(req, apiConfig)
) {
return authService.refresh().pipe(
switchMap(success => {
if (success) {
const retryReq = attachToken(
req.clone({ setHeaders: { 'X-Retry': 'true' } }),
authService,
apiConfig
);
return next(retryReq);
}
authService.logout();
router.navigate(['/login']);
return throwError(() => error);
})
);
}
if (error.status === 401) { if (error.status === 401) {
// Token is invalid or expired, logout user
authService.logout(); authService.logout();
router.navigate(['/login']); router.navigate(['/login']);
} }
return throwError(() => error); return throwError(() => error);
}) })
); );
}; };
/** function attachToken(
* Determine if the token should be added to this request req: HttpRequest<unknown>,
* Skip adding token to login requests and other public endpoints authService: AuthService,
*/ apiConfig: ApiConfigService
function shouldAddToken(apiConfigService: ApiConfigService, request: any): boolean { ): HttpRequest<unknown> {
// Don't add token to outbound requests to other domains const token = authService.getToken();
if (!request.url.startsWith(apiConfigService.getBaseUrl())) { if (!token || !shouldAddToken(apiConfig, req)) {
return false; return req;
} }
return req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
// Don't add token to login requests }
if (request.url.includes('/Auth/login') || request.url.includes('/Token/Create')) {
return false; const PUBLIC_AUTH_PATHS = ['/auth/login', '/auth/refresh', '/auth/logout'];
}
// Don't add token to public endpoints (you can customize this list) function isPublicAuthRequest(req: HttpRequest<unknown>, apiConfig: ApiConfigService): boolean {
const publicEndpoints = [ if (!req.url.startsWith(apiConfig.getBaseUrl())) {
'/Auth/register', return false;
'/Auth/forgot-password', }
'/Auth/reset-password' return PUBLIC_AUTH_PATHS.some(path => req.url.toLowerCase().includes(path));
]; }
return !publicEndpoints.some(endpoint => request.url.includes(endpoint)); function shouldAddToken(apiConfig: ApiConfigService, req: HttpRequest<unknown>): boolean {
return !isPublicAuthRequest(req, apiConfig);
} }
@@ -30,7 +30,7 @@ export class ApiConfigService {
* Get specific API endpoints * Get specific API endpoints
*/ */
get authUrl(): string { get authUrl(): string {
return this.getApiUrl('Auth'); return this.getApiUrl('auth');
} }
get tokenUrl(): string { get tokenUrl(): string {
@@ -36,14 +36,20 @@
</button> </button>
</div> </div>
<!-- Administration (role-guarded) --> <div class="nav-section" *ngIf="showMemberAdminSection || showUserAdminSection">
<div class="nav-section" *ngIf="showAdminSection">
<h4>Administration</h4> <h4>Administration</h4>
<button *ngFor="let item of adminNavItems" kendoButton <button *ngFor="let item of memberAdminNavItems" kendoButton
[fillMode]="item.active ? 'solid' : 'flat'" [themeColor]="item.active ? 'primary' : 'base'" [fillMode]="item.active ? 'solid' : 'flat'" [themeColor]="item.active ? 'primary' : 'base'"
[svgIcon]="item.icon" class="nav-button" (click)="navigateTo(item.path)"> [svgIcon]="item.icon" class="nav-button" (click)="navigateTo(item.path)">
{{ item.text }} {{ item.text }}
</button> </button>
<ng-container *ngIf="showUserAdminSection">
<button *ngFor="let item of userAdminNavItems" kendoButton
[fillMode]="item.active ? 'solid' : 'flat'" [themeColor]="item.active ? 'primary' : 'base'"
[svgIcon]="item.icon" class="nav-button" (click)="navigateTo(item.path)">
{{ item.text }}
</button>
</ng-container>
</div> </div>
</nav> </nav>
</div> </div>
@@ -60,12 +60,16 @@ export class UserNavbarComponent implements OnInit, OnDestroy {
{ text: 'Support', icon: this.supportIcon, path: '/user-portal/support' } { text: 'Support', icon: this.supportIcon, path: '/user-portal/support' }
]; ];
public adminNavItems: NavItem[] = [ public memberAdminNavItems: NavItem[] = [
{ text: 'Members', icon: groupIcon, path: '/user-portal/admin/members' }, { text: 'Members', icon: groupIcon, path: '/user-portal/admin/members' },
{ text: 'Users', icon: userIcon, path: '/user-portal/admin/users' },
]; ];
public showAdminSection = false; public userAdminNavItems: NavItem[] = [
{ text: 'User Management', icon: userIcon, path: '/user-portal/admin/users' },
];
public showMemberAdminSection = false;
public showUserAdminSection = false;
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
@@ -89,9 +93,10 @@ export class UserNavbarComponent implements OnInit, OnDestroy {
// Set initial active state // Set initial active state
this.updateActiveStates(this.router.url); this.updateActiveStates(this.router.url);
// Show Administration section for super_admin or secretary
this.authService.currentUser$.pipe(takeUntil(this.destroy$)).subscribe(user => { this.authService.currentUser$.pipe(takeUntil(this.destroy$)).subscribe(user => {
this.showAdminSection = !!user?.roles?.some(r => r === 'super_admin' || r === 'secretary'); const roles = user?.roles ?? [];
this.showMemberAdminSection = roles.some(r => r === 'super_admin' || r === 'secretary');
this.showUserAdminSection = roles.includes('super_admin');
}); });
} }
@@ -107,11 +112,13 @@ export class UserNavbarComponent implements OnInit, OnDestroy {
private updateActiveStates(currentUrl: string): void { private updateActiveStates(currentUrl: string): void {
// Reset all active states // Reset all active states
[...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems, ...this.adminNavItems] [...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems,
...this.memberAdminNavItems, ...this.userAdminNavItems]
.forEach(item => item.active = false); .forEach(item => item.active = false);
// Set active state for current route // Set active state for current route
const activeItem = [...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems, ...this.adminNavItems] const activeItem = [...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems,
...this.memberAdminNavItems, ...this.userAdminNavItems]
.find(item => currentUrl.startsWith(item.path)); .find(item => currentUrl.startsWith(item.path));
if (activeItem) { if (activeItem) {
@@ -47,6 +47,22 @@
<span *ngIf="!sidebarCollapsed">Dashboard</span> <span *ngIf="!sidebarCollapsed">Dashboard</span>
</a> </a>
</div> </div>
<div class="nav-section" *ngIf="showAdminSection">
<h4 *ngIf="!sidebarCollapsed">Administration</h4>
<a routerLink="/user-portal/admin/users" routerLinkActive="active" class="nav-item"
(click)="onNavigationClick()">
<div class="nav-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
</div>
<span *ngIf="!sidebarCollapsed">User Management</span>
</a>
</div>
</nav> </nav>
<div class="sidebar-footer" *ngIf="!sidebarCollapsed"> <div class="sidebar-footer" *ngIf="!sidebarCollapsed">
@@ -23,6 +23,7 @@ export class UserPortalComponent implements OnInit, OnDestroy {
isMobile = false; isMobile = false;
currentUser: UserInfo | null = null; currentUser: UserInfo | null = null;
currentPageTitle = 'Dashboard'; currentPageTitle = 'Dashboard';
showAdminSection = false;
unreadMessages = 3; unreadMessages = 3;
unreadNotifications = 2; unreadNotifications = 2;
@@ -64,6 +65,7 @@ export class UserPortalComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe(user => { .subscribe(user => {
this.currentUser = user; this.currentUser = user;
this.showAdminSection = !!user?.roles?.includes('super_admin');
}); });
} }
@@ -76,6 +78,8 @@ export class UserPortalComponent implements OnInit, OnDestroy {
.subscribe(() => { .subscribe(() => {
this.updatePageTitle(); this.updatePageTitle();
}); });
this.updatePageTitle();
} }
private updatePageTitle(): void { private updatePageTitle(): void {
+27 -5
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, of } from 'rxjs'; import { BehaviorSubject, Observable, of } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators'; import { catchError, filter, finalize, map, shareReplay, take, tap } from 'rxjs/operators';
import { ApiConfigService } from '../../core/services/api-config.service'; import { ApiConfigService } from '../../core/services/api-config.service';
// ── Public interfaces ───────────────────────────────────────────────────────── // ── Public interfaces ─────────────────────────────────────────────────────────
@@ -69,6 +69,8 @@ export class AuthService {
/** Observable stream of the current user (null = not authenticated). */ /** Observable stream of the current user (null = not authenticated). */
public currentUser = this.currentUser$.asObservable(); public currentUser = this.currentUser$.asObservable();
private readonly sessionReady$ = new BehaviorSubject(false);
private refreshInFlight$: Observable<boolean> | null = null;
private redirectUrl = '/dashboard'; private redirectUrl = '/dashboard';
constructor( constructor(
@@ -118,7 +120,8 @@ export class AuthService {
* Never throws. * Never throws.
*/ */
refresh(): Observable<boolean> { refresh(): Observable<boolean> {
return this.http.post<ApiLoginResponse>( if (!this.refreshInFlight$) {
this.refreshInFlight$ = this.http.post<ApiLoginResponse>(
`${this.apiConfig.authUrl}/refresh`, `${this.apiConfig.authUrl}/refresh`,
{}, {},
{ withCredentials: true } { withCredentials: true }
@@ -128,9 +131,15 @@ export class AuthService {
this.currentUser$.next(response.user); this.currentUser$.next(response.user);
}), }),
map(() => true), map(() => true),
catchError(() => of(false)) catchError(() => of(false)),
finalize(() => {
this.refreshInFlight$ = null;
}),
shareReplay(1)
); );
} }
return this.refreshInFlight$;
}
/** /**
* Clears in-memory auth state immediately, then fires a fire-and-forget * Clears in-memory auth state immediately, then fires a fire-and-forget
@@ -155,10 +164,23 @@ export class AuthService {
*/ */
initializeFromRefreshToken(): Promise<void> { initializeFromRefreshToken(): Promise<void> {
return new Promise(resolve => { return new Promise(resolve => {
this.refresh().subscribe(() => resolve()); this.refresh().pipe(
finalize(() => {
this.sessionReady$.next(true);
resolve();
})
).subscribe();
}); });
} }
/** Resolves once startup session restore has finished (success or failure). */
whenSessionReady(): Observable<boolean> {
if (this.sessionReady$.value) {
return of(true);
}
return this.sessionReady$.pipe(filter(Boolean), take(1));
}
// ── State accessors ───────────────────────────────────────────────────── // ── State accessors ─────────────────────────────────────────────────────
getToken(): string | null { getToken(): string | null {
@@ -166,7 +188,7 @@ export class AuthService {
} }
isAuthenticated(): boolean { isAuthenticated(): boolean {
return this.currentUser$.value !== null; return this.currentUser$.value !== null && this.getToken() !== null;
} }
getCurrentUser(): UserInfo | null { getCurrentUser(): UserInfo | null {