refactor: adapting token storage to the API

This commit is contained in:
Polianin Nikita 2024-10-09 03:10:11 +03:00
parent 9209b31db2
commit 2b09086902
3 changed files with 73 additions and 149 deletions

View File

@ -1,35 +1,21 @@
import {catchError, distinctUntilChanged, filter, first, mergeMap, Observable, retryWhen, switchMap, timer} from "rxjs"; import {
BehaviorSubject,
catchError,
distinctUntilChanged,
filter,
first,
Observable,
of,
ReplaySubject,
switchMap
} from "rxjs";
import {HttpClient, HttpErrorResponse} from "@angular/common/http"; import {HttpClient, HttpErrorResponse} from "@angular/common/http";
import {environment} from "@environment"; import {environment} from "@environment";
import {Router} from "@angular/router"; import {Router} from "@angular/router";
import {Injectable} from "@angular/core"; import {Injectable} from "@angular/core";
import {RequestBuilder, RequestData} from "@api/RequestBuilder"; import {RequestBuilder, RequestData} from "@api/RequestBuilder";
import {TokenRefreshService} from "@service/token-refresh.service";
import {AuthToken} from "@service/auth.service";
import {ToastrService} from "ngx-toastr"; import {ToastrService} from "ngx-toastr";
import {AuthRoles} from "@model/AuthRoles";
export function retryWithInterval<T>(): (source: Observable<T>) => Observable<T> {
return (source: Observable<T>) =>
source.pipe(
retryWhen((errors: Observable<any>) =>
errors.pipe(
mergeMap((error, index) => {
if (index < (environment.maxRetry < 0 ? Infinity : environment.maxRetry - 1) && !error.status.toString().startsWith('4') && !error.status.toString().startsWith('5')) {
console.log(`Retrying after ${environment.retryDelay}ms...`);
return timer(environment.retryDelay);
} else {
if (error.status.toString().startsWith('4'))
console.error(`Server returned a client code error`);
else
console.error(`Exceeded maximum retries (${environment.maxRetry})`);
throw error;
}
})
)
)
);
}
export enum AvailableVersion { export enum AvailableVersion {
v1 v1
@ -37,16 +23,16 @@ export enum AvailableVersion {
@Injectable() @Injectable()
export default abstract class ApiService { export default abstract class ApiService {
constructor(private http: HttpClient, private notify: ToastrService, private router: Router, protected tokenRefreshService: TokenRefreshService) { constructor(private http: HttpClient, private notify: ToastrService, private router: Router) {
} }
private apiUrl = environment.apiUrl; private apiUrl = environment.apiUrl;
private static isRefreshingToken: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
private static refreshTokenSubject: ReplaySubject<any> = new ReplaySubject(1);
protected abstract basePath: string; protected abstract basePath: string;
protected abstract version: AvailableVersion; protected abstract version: AvailableVersion;
public static readonly tokenKey = 'auth_token';
private static addQuery(endpoint: string, queryParams?: Record<string, string | number | boolean | Array<any> | null> | null): string { private static addQuery(endpoint: string, queryParams?: Record<string, string | number | boolean | Array<any> | null> | null): string {
const url = new URL(endpoint); const url = new URL(endpoint);
@ -73,7 +59,7 @@ export default abstract class ApiService {
return ApiService.addQuery(ApiService.combineUrls(this.apiUrl, AvailableVersion[this.version], this.basePath, request.endpoint), request.queryParams); return ApiService.addQuery(ApiService.combineUrls(this.apiUrl, AvailableVersion[this.version], this.basePath, request.endpoint), request.queryParams);
} }
private sendHttpRequest<Type>(method: 'get' | 'post' | 'delete' | 'put', request: RequestData): Observable<Type> { private sendHttpRequest<Type>(method: 'get' | 'post' | 'delete' | 'put', request: RequestData, secondTry: boolean = false): Observable<Type> {
const doneEndpoint = this.combinedUrl(request); const doneEndpoint = this.combinedUrl(request);
return this.http.request<Type>(method, doneEndpoint, { return this.http.request<Type>(method, doneEndpoint, {
@ -81,36 +67,59 @@ export default abstract class ApiService {
headers: request.httpHeaders, headers: request.httpHeaders,
body: request.data body: request.data
}).pipe( }).pipe(
retryWithInterval<Type>(),
catchError(error => { catchError(error => {
if (!secondTry && error.status === 401)
return this.handle401Error().pipe(
switchMap(() => this.sendHttpRequest<Type>(method, request, true))
);
else {
if (!request.silenceMode) if (!request.silenceMode)
this.handleError(error); this.handleError(error);
throw error; throw error;
}
})
);
}
private refreshToken(): Observable<AuthRoles> {
return this.http.get<AuthRoles>(ApiService.combineUrls(this.apiUrl, AvailableVersion[AvailableVersion.v1], 'Auth', 'ReLogin'), {
withCredentials: true
});
}
private handle401Error(): Observable<any> {
if (ApiService.isRefreshingToken.value)
return ApiService.refreshTokenSubject.asObservable();
ApiService.isRefreshingToken.next(true);
return this.refreshToken().pipe(
switchMap(_ => {
ApiService.isRefreshingToken.next(false);
ApiService.refreshTokenSubject.next(null);
return of(null);
}),
catchError(err => {
ApiService.isRefreshingToken.next(false);
ApiService.refreshTokenSubject.error(err);
ApiService.refreshTokenSubject = new ReplaySubject(1);
throw err;
}) })
); );
} }
private makeHttpRequest<Type>(method: 'get' | 'post' | 'delete' | 'put', request: RequestData): Observable<Type> { private makeHttpRequest<Type>(method: 'get' | 'post' | 'delete' | 'put', request: RequestData): Observable<Type> {
if (request.needAuth) if (request.needAuth) {
return this.tokenRefreshService.getTokenRefreshing$().pipe( return ApiService.isRefreshingToken.pipe(
distinctUntilChanged(), distinctUntilChanged(),
filter(isRefreshing => !isRefreshing), filter(isRefreshing => !isRefreshing),
first(), first(),
switchMap(() => { switchMap(() => this.sendHttpRequest<Type>(method, request))
const token = localStorage.getItem(ApiService.tokenKey);
if (token) {
const authToken = AuthToken.httpHeader((JSON.parse(token) as AuthToken));
authToken.keys().forEach(key => request.httpHeaders = request.httpHeaders.append(key, authToken.get(key) ?? ''));
}
return this.sendHttpRequest<Type>(method, request);
})
); );
} else {
return this.sendHttpRequest<Type>(method, request); return this.sendHttpRequest<Type>(method, request);
} }
}
private getRequest(request: RequestData | string | null): RequestData { private getRequest(request: RequestData | string | null): RequestData {
if (request === null) if (request === null)
@ -143,6 +152,7 @@ export default abstract class ApiService {
public addAuth(request: RequestData) { public addAuth(request: RequestData) {
request.needAuth = true; request.needAuth = true;
request.withCredentials = true;
return this; return this;
} }
@ -187,8 +197,10 @@ export default abstract class ApiService {
} }
if (error.error?.Error) if (error.error?.Error)
message = error.error.Error; message = error.error.Error;
else if (error.error instanceof String)
message = error.error.toString();
else else
message = error.error; message = error.error.statusMessage;
} }
this.notify.error(message == '' ? undefined : message, title); this.notify.error(message == '' ? undefined : message, title);
} }

View File

@ -1,10 +1,8 @@
import {Injectable} from "@angular/core"; import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service"; import ApiService, {AvailableVersion} from "@api/api.service";
import {LoginRequest} from "@api/v1/loginRequest"; import {LoginRequest} from "@api/v1/loginRequest";
import {TokenResponse} from "@api/v1/tokenResponse"; import {catchError, of} from "rxjs";
import {catchError, of, tap} from "rxjs";
import {AuthRoles} from "@model/AuthRoles"; import {AuthRoles} from "@model/AuthRoles";
import {AuthService, AvailableAuthenticationProvider} from "@service/auth.service";
@Injectable() @Injectable()
export default class AuthApiService extends ApiService { export default class AuthApiService extends ApiService {
@ -18,13 +16,16 @@ export default class AuthApiService extends ApiService {
.setWithCredentials() .setWithCredentials()
.build; .build;
return this.post<TokenResponse>(request) return this.post<AuthRoles>(request);
.pipe( }
tap(response => {
AuthService.setToken(response, AvailableAuthenticationProvider.Bearer, this.combinedUrl(this.createRequestBuilder().setEndpoint('ReLogin').build)); public reLogin(){
this.tokenRefreshService.setRefreshTokenExpireMs(response.expiresIn); let request = this.createRequestBuilder()
}) .setEndpoint('ReLogin')
); .setWithCredentials()
.build;
return this.get<AuthRoles>(request);
} }
public logout() { public logout() {
@ -33,13 +34,7 @@ export default class AuthApiService extends ApiService {
.setEndpoint('Logout') .setEndpoint('Logout')
.build; .build;
return this.addAuth(request) return this.addAuth(request).get(request);
.get(request)
.pipe(
tap(_ => {
localStorage.removeItem(ApiService.tokenKey);
})
);
} }
public getRole(isSilence: boolean = true) { public getRole(isSilence: boolean = true) {

View File

@ -1,83 +0,0 @@
import {Injectable} from '@angular/core';
import {HttpClient, HttpHeaders} from "@angular/common/http";
import {map, Observable, throwError} from "rxjs";
import {TokenResponse} from "@api/v1/tokenResponse";
import ApiService from "@api/api.service";
export enum AvailableAuthenticationProvider {
Bearer
}
export class AuthToken {
accessToken: string;
expiresIn: Date;
authProvider: AvailableAuthenticationProvider;
endpoint: string;
constructor(accessToken: string, expiresIn: Date, authProvider: AvailableAuthenticationProvider, refreshEndpoint: string) {
this.accessToken = accessToken;
this.expiresIn = expiresIn;
this.authProvider = authProvider;
this.endpoint = refreshEndpoint;
}
public static httpHeader(token: AuthToken): HttpHeaders {
let header = new HttpHeaders();
if (token.authProvider === AvailableAuthenticationProvider.Bearer)
header = header.set('Authorization', `Bearer ${token.accessToken}`);
return header;
}
}
@Injectable({
providedIn: 'root',
})
export class AuthService {
constructor(private http: HttpClient) {
}
public static setToken(token: TokenResponse, provider: AvailableAuthenticationProvider, refreshEndpoint: string) {
localStorage.setItem(ApiService.tokenKey, JSON.stringify(
new AuthToken(token.accessToken, token.expiresIn, provider, refreshEndpoint)
));
}
public static get tokenExpiresIn(): Date {
const token = localStorage.getItem(ApiService.tokenKey);
if (!token)
return new Date();
const result = new Date((JSON.parse(token) as AuthToken).expiresIn);
return result <= new Date() ? new Date() : result;
}
public refreshToken(): Observable<Date> {
const token = localStorage.getItem(ApiService.tokenKey);
if (!token)
return throwError(() => new Error("Token is not found"));
const authToken = JSON.parse(token) as AuthToken;
switch (authToken.authProvider) {
case AvailableAuthenticationProvider.Bearer:
return this.http.get<TokenResponse>(authToken.endpoint, {withCredentials: true})
.pipe(
map(response => {
const newExpireDate = new Date(response.expiresIn);
const oldExpireDate = new Date(authToken.expiresIn);
if (newExpireDate.getTime() !== oldExpireDate.getTime()) {
AuthService.setToken(response, AvailableAuthenticationProvider.Bearer, authToken.endpoint);
return newExpireDate;
}
return newExpireDate;
})
);
}
}
}