Библиотека для Token-Based Authentication на основе Access и Refresh токенов для Angular приложений.
Эта библиотека настраивается для любых вариантов использования.
Angular version | 13 | 14 | 15 |
---|---|---|---|
ngx-jwt-auth version | 1 | 2 | 15 |
- Описание
- Настройка и применение
- Описание всех параметров библиотеки
- Список предопределенных хранилищ токенов
- Создание своего хранилища токенов
- Смена хранилища токенов в рантайме
- Реализация кастомной стратегии сохранения последней посещенной страницы
- Troubleshooting
Данная библиотека реализует управление аутентификацией на сайте.
Позволяет:
- выбирать где будут храниться токены, выбирая хранилище токенов;
- изменять хранилища токенов прямо в рантайме;
- создать свое кастомное хранилище токенов;
- автоматически обновлять токен доступа (access token). Обновление происходит либо по истечению срока валидности токена доступа, либо при указании коэффициента протухания токена
refreshThreshold
, по достижению которого будет выполнено обновление токена, для этих целей используется interceptor JwtAuthInterceptor. - ограничивать доступ на определенные роуты для неавторизованных пользователей, используя AuthGuard;
- ограничивать доступ на определенные роуты для авторизованных пользователей, используя UnAuthGuard;
- подписаться на поток
isLoggedIn$
, в котором хранится текущий статус аутентификации пользователя JwtAuthService; - самому управлять токенами (получить, удалить, сохранить токен) через сервис AuthTokenManager;
- управлять не только авторизационными токенами, но и любыми другими JWT токенами. Для этих целей выделены отдельные настройки в
JwtAuthModule
, отдельное хранилище токенов (можно использовать те же предопределенные хранилища, либо создать свое), отдельный сервис для работы с токенами TokenManager и отдельный сервис для управления хранилищем токенов TokenStorageManager. - расширить базовые возможности путем создания кастомных хранилищ токенов, кастомных решений для управления токенами (расширить BaseTokenManager) и хранилищами токенов (расширить BaseTokenStorageManager).
- Импортировать
JwtAuthModule
в root/core модуль вашего приложения с вызовом методаforRoot
, и в данный метод передать параметры:
import { NgModule } from '@angular/core';
import { JwtAuthModule } from '@dekh/ngx-jwt-auth';
@NgModule({
imports: [
JwtAuthModule.forRoot({ ... }),
],
})
export class AppModule {}
- Необходимо создать Api-сервис, реализуя базовый класс BaseAuthApiService. Данный класс обязует реализовать 3 метода
login
,logout
иrefresh
. Методыlogin
иrefresh
должны возвращать Observable cо значением{ accessToken: string; refreshToken?: string; }
, если ваш сервер в методе авторизацииlogin
и\или в методе обновления токена доступаrefresh
возвращает другой формат, то достаточно просто можно смаппить значение операторомmap
из rxjs в нужный формат. Пример такого сервиса:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BaseAuthApiService, AuthResponseTokens } from '@dekh/ngx-jwt-auth';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { environments } from 'environment/environments';
import { Login, Registration } from '../../models';
@Injectable({
providedIn: 'root',
})
export class AuthApiService extends BaseAuthApiService {
constructor(private readonly _httpClient: HttpClient) {
super();
}
// Данный метод возвращает AuthResponseTokens который имеет структуру
// { accessToken: string; refreshToken?: string; },
// поэтому маппить ничего не нужно!
public login(credentials: Login): Observable<AuthResponseTokens> {
return this._httpClient.post<AuthResponseTokens>(
environments.apiUrl + '/auth/login',
credentials,
{ withCredentials: true }
);
}
public logout(): Observable<void> {
return this._httpClient.post<void>(environments.apiUrl + '/auth/logout', null, {
withCredentials: true,
});
}
// Так как данный метод не возвращает с сервера нужную нам модель,
// то мы ее с помощью оператора map маппим в { accessToken: string; refreshToken?: string; }
public refresh(): Observable<RefreshTokenResponse> {
return this._httpClient.post<RefreshTokenResponse>(environments.apiUrl + '/auth/refresh', null, {
withCredentials: true,
}).pipe(
map((res) => ({
accessToken: res.tokens.newAccessToken,
refreshToken: res.tokens.newRefreshToken,
}))
);
}
public register(credentials: Registration): Observable<void> {
return this._httpClient.post<void>(environments.apiUrl + '/auth/register', credentials);
}
}
- Далее нужно передать в параметры
JwtAuthModule.forRoot(options)
обязательные параметры:authApiService
,tokenStorage
,authTokenStorage
иunsecuredUrls
.
authApiService: Type<BaseAuthApiService>
- Класс реализующий BaseAuthApiService и выполняющий запросы к серверу.tokenStorage: Type<BaseTokenStorage>
- Хранилище обычных jwt токенов (не авторизационных).authTokenStorage: Type<BaseTokenStorage>
- Хранилище авторизационных токенов.unsecuredUrls: string[]
- Массив urls и/или endpoints, на которых не требуется авторизация. Необходимо обязательно указать endpoint на авторизацию и обновление access токена. Подробнее оunsecuredUrls
можно почитать тут
import { NgModule } from '@angular/core';
import {
JwtAuthModule,
InMemoryTokenStorage,
LocalStorageTokenStorage
} from '@dekh/ngx-jwt-auth';
import { AuthApiService } from './auth/services/auth-api.service';
@NgModule({
imports: [
JwtAuthModule.forRoot({
// Наш ранее созданный AuthApiService
authApiService: AuthApiService,
tokenStorage: LocalStorageTokenStorage,
authTokenStorage: InMemoryTokenStorage,
unsecuredUrls: ['api/auth/login', 'api/auth/refresh'],
}),
],
})
export class AppModule {}
- Запровайдить Interceptor JwtAuthInterceptor.
JwtAuthInterceptor
реализует механизм обновления токена доступа путем проверки валидности токена и порога валидностиrefreshTreshold
перед каждым запросом, за исключением url запросов, которые указаны в параметреunsecuredUrls
. Если токен не валиден, то будет произведена попытка обновления токена с последующим выполнением оригинального запроса, но если токен не сможет обновиться - тогда пользователя разлогинет методомlogout
изBaseAuthApiService
.
Необязательно использовать
JwtAuthInterceptor
, можно реализовать собственный механизм перехвата запросов с последующим обновлением токена доступа.
Пример:
import { NgModule } from '@angular/core';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import {
JwtAuthModule,
InMemoryTokenStorage,
LocalStorageTokenStorage,
JwtAuthInterceptor
} from '@dekh/ngx-jwt-auth';
import { AuthApiService } from './auth/services/auth-api.service';
@NgModule({
imports: [
JwtAuthModule.forRoot({
authApiService: AuthApiService,
tokenStorage: LocalStorageTokenStorage,
authTokenStorage: InMemoryTokenStorage,
}),
],
providers: [
{
provide: HTTP_INTERCEPTORS,
useClass: JwtAuthInterceptor,
multi: true,
},
],
})
export class AppModule {}
- Если в приложении нам нужно выполнить авторизацию или разлогиниться, то мы должны использовать proxy сервис
JwtAuthService
, который под капотом вызывает методы из нашегоAuthApiService
сервиса и выполняет дополнительные действия: сохраняет accessToken и refreshToken в хранилище, обновляет статус авторизации вisLoggedIn$
.
Например:
На форме авторизации, при ее отправке, нужно использовать
JwtAuthService
и вызывать методlogin(...args[]: any)
. Все переданные аргументы в данный метод будут прокинуты в методlogin(...args[]: any)
нашего ранее созданного Api-сервиса для авторизацииAuthApiService
(все параметры прокидываются для каждого метода определенного вBaseAuthApiService
):
import { Component, ChangeDetectionStrategy, OnDestroy } from "@angular/core";
import { FormGroup, FormBuilder, Validators } from "@angular/forms";
import { BehaviorSubject, Subject, tap, catchError, EMPTY, finalize } from "rxjs";
import { JwtAuthService } from "./jwt-auth.service";
import { Login, ServerErrorDto } from '../../models';
import { HttpError } from '../../exceptions';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LoginComponent implements OnDestroy {
public form!: FormGroup;
private readonly _isLoading$ = new BehaviorSubject<boolean>(false);
public readonly isLoading$ = this._isLoading$.asObservable();
private readonly _loginError$ = new BehaviorSubject<string | null>(null);
public readonly loginError$ = this._loginError$.asObservable();
private readonly _destroy$ = new Subject<void>();
constructor(
private readonly _fb: FormBuilder,
private readonly _jwtAuthService: JwtAuthService,
) {
this._createForm();
}
public ngOnDestroy(): void {
this._destroy$.next();
this._destroy$.complete();
}
public login(): void {
this._isLoading$.next(true);
// Login class it`s Domain model
const credentials = new Login(this.form.value);
this._jwtAuthService
.login(credentials)
.pipe(
tap(() => this._loginError$.next(null)),
catchError((error: HttpError<ServerErrorDto>) => {
this._loginError$.next(error.error.message);
return EMPTY;
}),
finalize(() => this._isLoading$.next(false))
)
.subscribe();
}
private _createForm(): void {
this.form = this._fb.group({
email: [null, [Validators.required, Validators.email]],
password: [null, [Validators.required]],
});
}
}
- Ограничить доступ на роуты, на которые может заходить только авторизованный пользователь или наоборот только неавторизованный.
На примере ниже, на страницу
/auth/login
и/auth/registration
может зайти только неавторизованный пользователь, а открыть страницу/dashboard
может только авторизованный:
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard, UnAuthGuard } from '@dekh/ngx-jwt-auth';
import { LoginComponent, RegistrationComponent } from '../auth';
import { DashboardComponent } from '../dashboard';
const routes: Routes = [
{
path: 'auth',
children: [
{
path: 'login',
component: LoginComponent,
canActivate: [UnAuthGuard],
},
{
path: 'registration',
component: RegistrationComponent,
canActivate: [UnAuthGuard],
},
],
},
{
path: 'dashboard',
component: DashboardComponent,
canActivate: [AuthGuard],
},
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule {}
-
authApiService: Type<BaseAuthApiService>
- Класс реализующий BaseAuthApiService и выполняющий запросы к серверу. -
tokenStorage: Type<BaseTokenStorage>
- Хранилище обычных jwt токенов (не авторизационных). -
authTokenStorage: Type<BaseTokenStorage>
- Хранилище авторизационных токенов. -
authHeaderName?: string
- Название Http Header который будет использоваться для авторизации. By defaultAuthorization
. -
authScheme?: string
- Префикс в значении Http Header, определяющий схему авторизации. By defaultBearer
. -
tokenExpField?: string
- Поле в payload токена, в котором хранится timestamp когда токен просрочится. By defaultexp
. -
tokenIatField?: string
- Поле в payload токена, в котором хранится timestamp когда токен был выпущен. By defaultiat
. -
customTokenStorages?: BaseTokenStorage[]
- Массив кастомных (пользовательских) хранилищ токена. By default empty array[]
. -
unsecuredUrls?: string[]
- Массив URL и Path, которые не будут обрабатываться AuthInterceptor'ом. Т.е., на указанных URL и Path, не будет проверяться access token и выполнятся обновление access token'а, если он истек. By default empty array[]
.Важно: указывайте ваши URL для авторизации, например
http://localhost:5000/auth
или часть URL -/auth/refresh
,/auth/login
,/auth/registration
или все URL для аутентификации/auth
, чтобы избежать бесконечно рекурсивного вызова обновления токена при выдаче 401 статус кода.Важно: всегда указывайте URL для обновления токена, иначе будет циркулярная зависимость:
ERROR Error: NG0200: Circular dependency in DI detected for JwtAuthService.
Это происходит потому что
HttpClient
зависит от ->Interceptor (JwtAuthInterceptor)
зависит от ->AuthApiService
зависит от ->HttpClient
. -
refreshThreshold?: number
- Коэффициент порога обновления токена, если expireIn access token'а приблизится к данному коэффициенту, то будет произведен рефреш токена. By default0.8
. -
saveRefreshTokenInStorage?: boolean
- Сохранять ли refresh token, который приходит при авторизации и/или при обновлении токена. By defaultfalse
.Стоит включать данную опцию только если сервер не сохраняет refresh token в cookie, тогда для обновления токена нужно его передавать в запрос на обновление, а хранить придется на клиенте, что изначально является плохой практикой и может привести к проблемам в защите приложения путем кражи злоумышленником access и refresh токенов.
Важно: если данная опция включена, то следует сменить
authTokenStorage
сInMemoryTokenStorage
на любой другой предопределенный или кастомныйTokenStorage
. Если этого не сделать, то пользователю придется каждый раз логинится при обновлении страницы, так как при обновлении страницы будет очищатся память и соответственноInMemoryTokenStorage
, так устроен JS. -
unAuthGuardRedirectUrl?: string
- URL, куда будет редиректить авторизованного пользователя, если он попробует зайти на route, защищенный UnAuthGuard. Если не задать значение, то route, защищенный UnAuthGuard будет просто отклонять переход на данный route. -
authGuardRedirectUrl?: string
- URL, куда будет редиректить не авторизованного пользователя, если он попробует зайти на route, защищенный AuthGuard. Если не задать значение, то route, защищенный AuthGuard будет просто отклонять переход на данный route. -
redirectToLastPage?: boolean | Type<BaseLastPageWatcher>
- Перенаправляет пользователя на последнюю посещенную страницу после авторизации. By defaultfalse
.Если вы установите значение
Type<BaseLastPageWatcher>
, то будет использоваться ваш провайдер. С другой стороны, если вы установите значениеtrue
, то будет использоваться стандартныйLastPageWatcher
.
CookiesTokenStorage
- абстракция над cookies, сохраняет токены в cookies;LocalStorageTokenStorage
- абстракция над localStorage, сохраняет токены в localStorage;SessionStorageTokenStorage
- абстракция над sessionStorage, сохраняет токены в sessionStorage;InMemoryTokenStorage
- сохраняет токены в памяти приложения, есть свои недостатки. При использовании данного хранилища для авторизационных токенов после перезагрузки страницы будет выполнен запрос на обновление токена доступа (для SPA приложений это не критично), но зато самое безопасное хранилище для авторизационных токенов;
Для того чтобы создать свое хранилище токенов, достаточно реализовать базовый класс BaseTokenStorage и указать в параметре customTokenStorages
модуля JwtAuthModule.forRoot()
массив кастомных хранилище токенов. Пример:
// my-custom-token-storage.ts
import { BaseTokenStorage } from '@dekh/ngx-jwt-auth';
export class MyCustomTokenStorage extends BaseTokenStorage {
public get(key: string): string | null {
// custom realisation
}
public set(key: string, token: string): void {
// custom realisation
}
public delete(key: string): void {
// custom realisation
}
// можем переопределить метод для проверки валидности токенами
// но делать этого не рекомендуется!
public override isValid(key: string): boolean {
// super.isValid();
// custom realisation
}
}
Определяем наше хранилище в параметрах модуля JwtAuthModule
:
// app.module.ts
import { NgModule } from '@angular/core';
import {
JwtAuthModule,
LocalStorageTokenStorage,
InMemoryTokenStorage
} from '@dekh/ngx-jwt-auth';
import { AuthApiService } from './auth/services/auth-api.service';
import { MyCustomTokenStorage } from './auth/token-storage/my-custom-token-storage';
@NgModule({
imports: [
AppRoutingModule,
JwtAuthModule.forRoot({
authApiService: AuthApiService,
tokenStorage: LocalStorageTokenStorage,
authTokenStorage: MyCustomTokenStorage,
customTokenStorages: [new MyCustomTokenStorage()],
}),
],
})
export class AppModule {}
Либо мы можем зарегистрировать наше хранилище посредством сервиса TokenStorageRegistry
:
// app.service.ts
import { NgModule } from '@angular/core';
import {
JwtAuthModule,
LocalStorageTokenStorage,
InMemoryTokenStorage,
TokenStorageRegistry
} from '@dekh/ngx-jwt-auth';
import { AuthApiService } from './auth/services/auth-api.service';
import { MyCustomTokenStorage } from './auth/token-storage/my-custom-token-storage';
@NgModule({
imports: [
JwtAuthModule.forRoot({
authApiService: AuthApiService,
tokenStorage: LocalStorageTokenStorage,
authTokenStorage: MyCustomTokenStorage,
}),
],
})
export class AppModule {
constructor(private readonly _tokenStorageRegistry: TokenStorageRegistry) {
this._tokenStorageRegistry.register(new MyCustomTokenStorage());
}
}
В редких случаях может понадобиться в рантайме изменить хранилище токенов, для этого существует два сервиса: TokenStorageManager и AuthTokenStorageManager, оба этих сервиса имеют одинаковый интерфейс взаимодествия. TokenStorageManager
используется для управление хранилищем не авторизационных токенов, а AuthTokenStorageManager
для управление хранилищем авторизационных токенов.
Пример:
// token-storage-changer.service.ts
import { Injectable } from '@angular/core';
import {
AuthTokenStorageManager,
TokenStorageRegistry,
CookiesTokenStorage,
BaseTokenStorage,
} from '@dekh/ngx-jwt-auth';
import { MyCustomTokenStorage } from './auth/token-storage/my-custom-token-storage';
@Injectable({
provideIn: 'root'
})
export class TokenStorageChangerService {
constructor(
private readonly _authTokenStorageManager: AuthTokenStorageManager,
private readonly _tokenStorageRegistry: TokenStorageRegistry,
) {
this._tokenStorageRegistry.register(new MyCustomTokenStorage());
}
public setMyCustomStorage(): void {
if (!this._tokenStorageRegistry.isRegistered(MyCustomTokenStorage)) {
throw new Error('MyCustomTokenStorage is not registered!');
}
const myCustomStorage = this._tokenStorageRegistry.get(MyCustomTokenStorage);
// or
// const myCustomStorage = this._tokenStorageRegistry.get(new MyCustomTokenStorage());
// or
// const myCustomStorage = this._tokenStorageRegistry.get('MyCustomTokenStorage');
this.changeAuthStorage(myCustomStorage);
}
public setCookiesStorage(): void {
const cookiesStorage = this._tokenStorageRegistry.get(CookiesTokenStorage);
// or
// const cookiesStorage = this._tokenStorageRegistry.get(new CookiesTokenStorage());
// or
// const cookiesStorage = this._tokenStorageRegistry.get('CookiesTokenStorage');
this.changeAuthStorage(cookiesStorage);
}
public changeAuthStorage(storage: BaseTokenStorage): void {
this._authTokenStorageManager.setStorage(storage);
}
}
- Создать кастомный сервис для отслеживания изменения страниц:
@Injectable()
export class CustomLastPageWatcher extends BaseLastPageWatcher {
constructor() {
this.watch();
}
public savePath(path: string): void {
// logic to save path, e.g send to server to save it in DB
}
public getPath(): string | null {
// logic to get path, e.g from server
}
}
- Указать свой класс в настройках:
JwtAuthModule.forRoot({
[...],
redirectToLastPage: CustomLastPageWatcher
})
-
При старте приложения выдает ошибку "ERROR Error: NG0200: Circular dependency in DI detected for JwtAuthService."
Причинна данной ошибки - цикличный вызов
JwtAuthInterceptor
. Так как interceptor обрабатывает каждый запрос, за исключением тех запросов url, которые указаны в параметре конфигаunsecuredUrls
, запрос на обновление токена создает цикличную зависимость.Решением данной проблемы является указать в массиве
unsecuredUrls
URL или path запроса на обновление accessToken'а, либо указать корневой path для всех запросов, связанных с авторизацией/регистрацией пользователя, например:"/auth/"
, тогда все запросы с pathauth
будут исключены из проверки interceptor'a -server.api/auth/login
,server.api/auth/register
,server.api/auth/refresh
и подобные.