diff --git a/src/api/api.service.ts b/src/api/api.service.ts index cb0d7ff..3b0ebdb 100644 --- a/src/api/api.service.ts +++ b/src/api/api.service.ts @@ -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 {environment} from "@environment"; import {Router} from "@angular/router"; import {Injectable} from "@angular/core"; import {RequestBuilder, RequestData} from "@api/RequestBuilder"; -import {TokenRefreshService} from "@service/token-refresh.service"; -import {AuthToken} from "@service/auth.service"; import {ToastrService} from "ngx-toastr"; - -export function retryWithInterval(): (source: Observable) => Observable { - return (source: Observable) => - source.pipe( - retryWhen((errors: Observable) => - 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; - } - }) - ) - ) - ); -} +import {AuthRoles} from "@model/AuthRoles"; export enum AvailableVersion { v1 @@ -37,16 +23,16 @@ export enum AvailableVersion { @Injectable() 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 static isRefreshingToken: BehaviorSubject = new BehaviorSubject(false); + private static refreshTokenSubject: ReplaySubject = new ReplaySubject(1); protected abstract basePath: string; protected abstract version: AvailableVersion; - public static readonly tokenKey = 'auth_token'; - private static addQuery(endpoint: string, queryParams?: Record | null> | null): string { 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); } - private sendHttpRequest(method: 'get' | 'post' | 'delete' | 'put', request: RequestData): Observable { + private sendHttpRequest(method: 'get' | 'post' | 'delete' | 'put', request: RequestData, secondTry: boolean = false): Observable { const doneEndpoint = this.combinedUrl(request); return this.http.request(method, doneEndpoint, { @@ -81,35 +67,58 @@ export default abstract class ApiService { headers: request.httpHeaders, body: request.data }).pipe( - retryWithInterval(), catchError(error => { - if (!request.silenceMode) - this.handleError(error); + if (!secondTry && error.status === 401) + return this.handle401Error().pipe( + switchMap(() => this.sendHttpRequest(method, request, true)) + ); + else { + if (!request.silenceMode) + this.handleError(error); - throw error; + throw error; + } + }) + ); + } + + private refreshToken(): Observable { + return this.http.get(ApiService.combineUrls(this.apiUrl, AvailableVersion[AvailableVersion.v1], 'Auth', 'ReLogin'), { + withCredentials: true + }); + } + + private handle401Error(): Observable { + 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(method: 'get' | 'post' | 'delete' | 'put', request: RequestData): Observable { - if (request.needAuth) - return this.tokenRefreshService.getTokenRefreshing$().pipe( + if (request.needAuth) { + return ApiService.isRefreshingToken.pipe( distinctUntilChanged(), filter(isRefreshing => !isRefreshing), first(), - switchMap(() => { - 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(method, request); - }) + switchMap(() => this.sendHttpRequest(method, request)) ); - - return this.sendHttpRequest(method, request); + } else { + return this.sendHttpRequest(method, request); + } } private getRequest(request: RequestData | string | null): RequestData { @@ -143,6 +152,7 @@ export default abstract class ApiService { public addAuth(request: RequestData) { request.needAuth = true; + request.withCredentials = true; return this; } @@ -187,8 +197,10 @@ export default abstract class ApiService { } if (error.error?.Error) message = error.error.Error; + else if (error.error instanceof String) + message = error.error.toString(); else - message = error.error; + message = error.error.statusMessage; } this.notify.error(message == '' ? undefined : message, title); } diff --git a/src/api/v1/authApiService.ts b/src/api/v1/authApiService.ts index c47483a..6dcfcc1 100644 --- a/src/api/v1/authApiService.ts +++ b/src/api/v1/authApiService.ts @@ -1,10 +1,8 @@ import {Injectable} from "@angular/core"; import ApiService, {AvailableVersion} from "@api/api.service"; import {LoginRequest} from "@api/v1/loginRequest"; -import {TokenResponse} from "@api/v1/tokenResponse"; -import {catchError, of, tap} from "rxjs"; +import {catchError, of} from "rxjs"; import {AuthRoles} from "@model/AuthRoles"; -import {AuthService, AvailableAuthenticationProvider} from "@service/auth.service"; @Injectable() export default class AuthApiService extends ApiService { @@ -18,13 +16,16 @@ export default class AuthApiService extends ApiService { .setWithCredentials() .build; - return this.post(request) - .pipe( - tap(response => { - AuthService.setToken(response, AvailableAuthenticationProvider.Bearer, this.combinedUrl(this.createRequestBuilder().setEndpoint('ReLogin').build)); - this.tokenRefreshService.setRefreshTokenExpireMs(response.expiresIn); - }) - ); + return this.post(request); + } + + public reLogin(){ + let request = this.createRequestBuilder() + .setEndpoint('ReLogin') + .setWithCredentials() + .build; + + return this.get(request); } public logout() { @@ -33,13 +34,7 @@ export default class AuthApiService extends ApiService { .setEndpoint('Logout') .build; - return this.addAuth(request) - .get(request) - .pipe( - tap(_ => { - localStorage.removeItem(ApiService.tokenKey); - }) - ); + return this.addAuth(request).get(request); } public getRole(isSilence: boolean = true) { diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts deleted file mode 100644 index d0cab20..0000000 --- a/src/services/auth.service.ts +++ /dev/null @@ -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 { - 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(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; - }) - ); - } - } -}