Improve auth error handling and session expiration behavior

This commit is contained in:
Lucio Lelii 2026-03-09 11:36:50 +01:00
parent dbe1ab4588
commit a2f7152202
5 changed files with 121 additions and 22 deletions

View File

@ -1,27 +1,61 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { Authorization } from '@services/authorization/authorization';
import { catchError, throwError } from 'rxjs';
let lastSessionExpiredNotificationAt = 0;
let lastServiceErrorNotificationAt = 0;
export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
const token = getToken();
if (!token) return next(req);
const requestPath = req.url.split('?')[0];
const isAuthEndpoint =
requestPath.endsWith('/auth/login') ||
requestPath.endsWith('/auth/register');
if (isAuthEndpoint) {
return next(req);
const hasToken = !!token;
if (token && !isAuthEndpoint && isTokenExpired(token)) {
clearAuthStorage();
notifySessionExpired();
redirectToLogin();
return throwError(() =>
new HttpErrorResponse({
status: 401,
statusText: 'Session expired',
error: { message: 'Session expired' }
})
);
}
if (req.headers.has('Authorization')) {
return next(req);
}
return next(
req.clone({
let requestToSend = req;
if (token && !isAuthEndpoint && !req.headers.has('Authorization')) {
requestToSend = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
}
return next(requestToSend).pipe(
catchError((error: unknown) => {
if (
hasToken &&
!isAuthEndpoint &&
error instanceof HttpErrorResponse &&
error.status === 401
) {
clearAuthStorage();
notifySessionExpired();
redirectToLogin();
}
if (
error instanceof HttpErrorResponse &&
(error.status === 0 || error.status >= 500)
) {
notifyServiceContactError();
}
return throwError(() => error);
})
);
};
@ -40,3 +74,45 @@ function getToken(): string | null {
return null;
}
}
function isTokenExpired(token: string): boolean {
try {
const payloadSegment = token.split('.')[1];
if (!payloadSegment) return false;
const normalized = payloadSegment.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
const decoded = atob(padded);
const payload = JSON.parse(decoded) as { exp?: unknown };
if (typeof payload.exp !== 'number') return false;
const nowSeconds = Math.floor(Date.now() / 1000);
return payload.exp <= nowSeconds;
} catch {
return false;
}
}
function clearAuthStorage() {
localStorage.removeItem(Authorization.USER_STORAGE_KEY);
localStorage.removeItem(Authorization.TOKEN_STORAGE_KEY);
}
function notifySessionExpired() {
const now = Date.now();
if (now - lastSessionExpiredNotificationAt < 1200) return;
lastSessionExpiredNotificationAt = now;
window.alert('Sessione scaduta. Effettua di nuovo il login.');
}
function notifyServiceContactError() {
const now = Date.now();
if (now - lastServiceErrorNotificationAt < 1200) return;
lastServiceErrorNotificationAt = now;
window.alert('Error contacting service, please retry later.');
}
function redirectToLogin() {
if (window.location.pathname === '/login') return;
window.location.assign('/login');
}

View File

@ -54,9 +54,14 @@
<span class="input-group-text"><i class="bi bi-key"></i></span>
<input type="password" #password id="password" class="form-control" placeholder="Password"
[field]="loginForm.password" />
<button class="input-group-text bg-white"
<button class="input-group-text bg-white" type="button" tabindex="-1"
(click)="password.type = password.type === 'password' ? 'text' : 'password'"
[disabled]="loginFormModel().password.length === 0"><i class="bi bi-eye"></i></button>
[disabled]="loginFormModel().password.length === 0">
<i
class="bi"
[class.bi-eye]="password.type === 'password'"
[class.bi-eye-slash]="password.type === 'text'"></i>
</button>
</div>
<!-- Sign up -->
@ -75,4 +80,4 @@
</form>
</div>
</div>
</div>
</div>

View File

@ -47,7 +47,12 @@
placeholder="********" />
<button class="input-group-text bg-white" type="button" tabindex="-1"
(click)="password.type = password.type === 'password' ? 'text' : 'password'"
[disabled]="signupModel().password.length === 0"><i class="bi bi-eye"></i></button>
[disabled]="signupModel().password.length === 0">
<i
class="bi"
[class.bi-eye]="password.type === 'password'"
[class.bi-eye-slash]="password.type === 'text'"></i>
</button>
@if (isInvalid(signupForm.password())) {
<small class="invalid-feedback">{{ signupForm.password().errors()[0].message }}</small>
}
@ -62,8 +67,12 @@
[field]="signupForm.confirmPassword" placeholder="********" />
<button class="input-group-text bg-white" type="button" tabindex="-1"
(click)="confirmPassword.type = confirmPassword.type === 'password' ? 'text' : 'password'"
[disabled]="signupModel().confirmPassword.length === 0"><i
class="bi bi-eye"></i></button>
[disabled]="signupModel().confirmPassword.length === 0">
<i
class="bi"
[class.bi-eye]="confirmPassword.type === 'password'"
[class.bi-eye-slash]="confirmPassword.type === 'text'"></i>
</button>
@if (!isInvalid(signupForm.password()) && isInvalid(signupForm.confirmPassword())) {
<small class="invalid-feedback">{{
signupForm.confirmPassword().errors()[0].message}}</small>
@ -101,4 +110,4 @@
</div>
</div>
</div>

View File

@ -13,7 +13,7 @@ export class AuthorizationCallFakeService extends AuthorizationCallServiceBase {
return new Observable<User>((observer) => {
const user = this.users.find(u => u.username === username);
if (!user) {
observer.error(new Error('Invalid username'));
observer.error(new Error('User not found'));
return;
}
if (password !== user.password) {
@ -45,4 +45,4 @@ export class AuthorizationCallFakeService extends AuthorizationCallServiceBase {
}
}

View File

@ -1,7 +1,7 @@
import { AuthorizationCallServiceBase } from "./authorization-call.base";
import { User, UserRegistration } from "@models/user";
import { map, Observable } from "rxjs";
import { HttpClient } from "@angular/common/http";
import { catchError, map, Observable, throwError } from "rxjs";
import { HttpClient, HttpErrorResponse } from "@angular/common/http";
import { inject } from "@angular/core";
import { environment } from "@environment";
@ -25,6 +25,15 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase {
email: String(userSource["email"] ?? ''),
token: typeof token === "string" && token.length > 0 ? token : undefined
} satisfies User;
}),
catchError((error: unknown) => {
if (error instanceof HttpErrorResponse && error.status === 404) {
return throwError(() => new Error('User not found'));
}
if (error instanceof HttpErrorResponse && error.status === 401) {
return throwError(() => new Error('Invalid password'));
}
return throwError(() => error);
})
);
}