1. Введение
Мы рассказали, как генерировать JWT
на стороне сервера в предыдущей статье, поэтому в этой статье мы продолжим знакомить с тем, как обрабатывать JWT
на стороне клиента с помощью Angular.
Для демонстрации нам нужно создать простую страницу входа для вызова API входа в систему и создать еще одну простую страницу для проверки правильности токена входа. Хорошо, давайте сделаем это!
2. Создайте модели
Для описания я возьму за основу этот демо-проект. Для связи с API нам необходимо создать соответствующие модели для сопоставления результата, поэтому мы создаем login-request
для передачи данных для входа.
//MyDemo.Client\src\app\models\login-request.ts export interface LoginRequest { username: string; password: string; }
и создайте модель token
//MyDemo.Client\src\app\models\token.ts export interface Token { [prop: string]: any; //the Jwt token return from API after login successfully access_token: string; //the current user id user_id?: string; //should be just handle the 'Bearer' type in this sample token_type?: string; //How long will be the token expired(e.g. after 30 mins). This is a timestamp format expires_in?: number; //the actually expire time, so should be the expires_in + current time, e.g. //if expires_in = 30 mins, then exp would be current time + 30 mins exp?: number; }
3. Создайте службы
Нам нужно обработать 3 службы:
3.1. Обработать токен
Во-первых, нам нужно создать jwt-token
для обработки логики токена, поместить эту логику в папку core
.
//MyDemo.Client\src\app\core\jwt-token.ts import { Token } from "../models/token"; import { capitalize, currentTimestamp } from "./util"; export class JwtToken { constructor(protected attributes: Token) {} get access_token(): string { return this.attributes.access_token; } get user_id(): string { return this.attributes.user_id ?? ''; } get token_type(): string { return this.attributes.token_type ?? 'bearer'; } get exp(): number | void { return this.attributes.exp; } valid(): boolean { return this.hasAccessToken() && !this.isExpired(); } getBearerToken(): string { return this.access_token ? [capitalize(this.token_type), this.access_token].join(' ').trim() : ''; } private hasAccessToken(): boolean { return !!this.access_token; } /** Check the expired time Unit: seconds */ private isExpired(): boolean { return this.exp !== undefined && this.exp - currentTimestamp() <= 0; } }
создайте util
для общих вспомогательных методов
//MyDemo.Client\src\app\core\util.ts /** * Capitalize first letter * @param text the text wants to be capitalized * @returns */ export function capitalize(text: string): string { return text.substring(0, 1).toUpperCase() + text.substring(1, text.length).toLowerCase(); } /** * Get the current timestamp * @returns */ export function currentTimestamp(): number { return Math.ceil(new Date().getTime() / 1000); } /** * Filter the Non null object to make sure the object is valid * @param obj filter object * @returns */ export function filterObject<T extends Record<string, unknown>>(obj: T) { return Object.fromEntries( Object.entries(obj).filter(([, value]) => value !== undefined && value !== null) ); }
потому что нам нужно сохранить token
в локальном хранилище, поэтому создайте простую службу локального хранилища.
//MyDemo.Client\src\app\services\local-storage.service.ts import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root', }) export class LocalStorageService { get(key: string) { return JSON.parse(localStorage.getItem(key) || '{}') || {}; } set(key: string, value: any): boolean { localStorage.setItem(key, JSON.stringify(value)); return true; } has(key: string): boolean { return !!localStorage.getItem(key); } remove(key: string) { localStorage.removeItem(key); } clear() { localStorage.clear(); } }
в конце концов, создайте службу токенов, чтобы соединить их вместе.
//MyDemo.Client\src\app\services\token.service.ts import { Injectable, OnDestroy } from '@angular/core'; import { Token } from '../models/token'; import { LocalStorageService } from './local-storage.service'; import { JwtToken } from '../core/jwt-token'; import { currentTimestamp, filterObject } from '../core/util'; @Injectable({ providedIn: 'root', }) export class TokenService implements OnDestroy { private key = 'MyDemo-token'; private _token?: JwtToken; constructor(private store: LocalStorageService) {} private get token(): JwtToken | undefined { if (!this._token) { this._token = new JwtToken(this.store.get(this.key)); } return this._token; } set(token?: Token): TokenService { this.save(token); return this; } clear(): void { this.save(); } valid(): boolean { return this.token?.valid() ?? false; } getUserid(): string { return this.token?.user_id ?? ''; } getBearerToken(): string { return this.token?.getBearerToken() ?? ''; } ngOnDestroy(): void { } /** * Save the token to local storage * @param token token model */ private save(token?: Token): void { this._token = undefined; if (!token) { this.store.remove(this.key); } else { const value = Object.assign({ access_token: '', token_type: 'Bearer' }, token, { exp: token.expires_in ? currentTimestamp() + token.expires_in : null, }); this.store.set(this.key, filterObject(value)); } } }
3.2. Служба аутентификации
Мы вызовем API для входа в систему и получения токена Jwt, поэтому нам нужна служба аутентификации для обработки логики входа и выхода из системы.
import { Injectable } from '@angular/core'; import { map, tap } from 'rxjs/operators'; import { HttpClient } from '@angular/common/http'; import { config } from 'src/assets/config'; import { Token } from '../models/token'; import { TokenService } from './token.service'; import { Router } from '@angular/router'; @Injectable({ providedIn: 'root', }) export class AuthService { constructor( private tokenService: TokenService, private router: Router, protected http: HttpClient) {} /** * Call the API to login * @param username user name * @param password password * @returns Jwt token if login successfully */ login(username: string, password: string) { var url = config.apiUrl + "/auth/login"; //call the API to get token after login successfully return this.http.post<Token>(url, { username, password }).pipe( tap(token => { console.log('auth service logined ', token); //save the token into local storage this.tokenService.set(token); }), map(() => { console.log('auth service logined and map ', this.check()); //check the token whether is valid return this.check(); }) ); } /** * Clear the token after logout */ logout(){ this.tokenService.clear(); this.router.navigateByUrl('/login'); } check() { return this.tokenService.valid(); } }
4. Создайте страницу входа.
После создания необходимых models
и services
мы можем теперь создать страницу входа. Запустите команду ниже, чтобы создать страницу входа.
ng g c Login --skip-tests
Потому что мы предназначены только для демонстрации, поэтому нужно всего лишь создать простой макет входа в систему:
<!-- MyDemo.Client\src\app\login\login.component.html --> <p>Login</p> <div class="row"> <form class="form-field-full" [formGroup]="loginForm"> <div class="col-sm-12"> <mat-form-field class="col-sm-3"> <mat-label>User Name: </mat-label> <input matInput formControlName="username" placeholder="User Name"> </mat-form-field> </div> <div class="col-sm-12"> <mat-form-field class="col-sm-3"> <mat-label>Password: </mat-label> <input matInput formControlName="password" type="password" placeholder="Password"> </mat-form-field> </div> <div class="col-sm-12"> <button class="m-r-8 bg-green-700 text-light" mat-raised-button (click)="login()">Login</button> </div> <div class="col-sm-12"> <span style="color: red;" *ngIf="errorMsg != ''">{{ errorMsg }}</span> </div> </form> </div>
мы можем использовать FormBuilder
для получения входных значений и передачи их в функцию входа в систему.
//MyDemo.Client\src\app\login\login.component.ts import { Component } from '@angular/core'; import { Router } from '@angular/router'; import { AuthService } from '../services/auth.service'; import { FormBuilder } from '@angular/forms'; import { filter } from 'rxjs/operators'; import { HttpErrorResponse } from '@angular/common/http'; @Component({ selector: 'app-login', templateUrl: './login.component.html', styleUrls: ['./login.component.scss'] }) export class LoginComponent { constructor(private fb: FormBuilder, private router: Router, private auth: AuthService) { } public errorMsg: string = ''; //init the loginForm loginForm = this.fb.nonNullable.group({ username: '', password: '', }); get username() { return this.loginForm.get('username')!; } get password() { return this.loginForm.get('password')!; } login() { this.auth .login(this.username.value, this.password.value) .pipe(filter(authenticated => authenticated)) .subscribe( () => { //redirect to user management page if login successfully this.router.navigateByUrl('/user-management'); }, (errorRes: HttpErrorResponse) => { //otherwise then update the error message in the page if(errorRes.status == 401){ this.errorMsg = 'User name or password is not valid!'; } console.log('Error', errorRes); } ); } }
Хорошо, можем посмотреть на результат:
1) Введите имя пользователя и пароль и нажмите «Войти».
2) Он вернет токен (его можно найти в локальном хранилище) и перенаправит на страницу управления пользователями.
Кажется здорово, правда? 🙂
Но, пожалуйста, подождите, у нас все еще есть проблемы. Вы обнаружите, что если вы напрямую получаете доступ к странице управления пользователями и это также может быть успешным, это означает, что страница не проверила токен входа, что не имеет смысла.
5. Добавьте защиту для пользовательских страниц.
Мы можем решить вышеуказанную проблему с помощью guard
в Angular. guard
в Angular относится к route guards
, которые представляют собой интерфейсы, которые позволяют вам управлять навигацией и доступом к маршрутам в вашем приложении Angular. route guards
позволяют вам проверить, может ли пользователь активировать или деактивировать маршрут, путем реализации интерфейсов CanActivate
или CanDeactivate
. Более подробную информацию вы можете найти здесь.
Создайте route guards
, как показано ниже:
//MyDemo.Client\src\app\core\auth.guard.ts import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, RoutesRecognized, Router, RouterStateSnapshot, UrlTree, } from '@angular/router'; import { filter, pairwise } from 'rxjs/operators'; import { AuthService } from '../services/auth.service'; @Injectable({ providedIn: 'root', }) export class AuthGuard implements CanActivate, CanActivateChild { previousUrl!: string; currentUrl!: string; constructor(private auth: AuthService, private router: Router) { this.router.events .pipe(filter((evt: any) => evt instanceof RoutesRecognized), pairwise()) .subscribe((events: RoutesRecognized[]) => { this.previousUrl = events[0].urlAfterRedirects; this.currentUrl = events[1].urlAfterRedirects; }); } canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { return this.authenticate(); } canActivateChild( childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot ): boolean | UrlTree { return this.authenticate(); } private authenticate(): boolean | UrlTree { //check whether is login successfully if (this.auth.check()) { return true; } else{ this.router.navigateByUrl('/login'); } return false; } }
и используйте его в маршрутизации приложений, добавьте canActivate
в маршруты
//MyDemo.Client\src\app\app-routing.module.ts const routes: Routes = [ { path: 'user-management', component: UserManagementComponent, canActivate: [AuthGuard] }, { path: 'login', component: LoginComponent }, ];
После этого выйдите из системы и попробуйте напрямую получить доступ к странице управления пользователями. Вы обнаружите, что она будет автоматически перенаправлена на страницу входа в систему!
6. Создайте перехватчик
Мы почти закончили, но все же есть проблема, которую нужно решить! Даже если мы сможем успешно войти в систему и перенаправиться на страницу управления пользователями, мы все равно не сможем получить пользовательские данные, поскольку существует авторизованная проверка с помощью API пользовательского контроллера, поэтому нам нужно передать токен в API, когда мы получим данные пользователя.
Мы можем добавить HTTP-заголовок с Authorization
при вызове API get user /api/users
, но нам также нужно делать это для каждого API, так что это не очень хороший подход!
Лучший способ использовать interceptor
.
Перехватчики в Angular — это сервисы, которые позволяют вам перехватывать и преобразовывать HTTP-запросы и ответы между вашим приложением и сервером. Перехватчики запросов могут изменять заголовки, добавлять токены аутентификации, регистрировать запросы и т. д.
Создайте token-interceptor
, как показано ниже.
//MyDemo.Client\src\app\core\token-interceptor.ts import { Injectable } from '@angular/core'; import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, } from '@angular/common/http'; import { Router } from '@angular/router'; import { Observable, throwError } from 'rxjs'; import { catchError, tap } from 'rxjs/operators'; import { TokenService } from '../services/token.service'; @Injectable() export class TokenInterceptor implements HttpInterceptor { constructor( private tokenService: TokenService, private router: Router ) {} intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> { const handler = () => { //check the url if login then just redirect to user management page after login if (this.router.url.includes('/login')) { this.router.navigateByUrl('/user-management'); } }; if (this.tokenService.valid()) { //if the token is valid, then append to the http header for each request return next .handle( request.clone({ headers: request.headers.append('Authorization', this.tokenService.getBearerToken()), withCredentials: true, }) ) .pipe( catchError((error: HttpErrorResponse) => { //error handler if (error.status === 401) { this.tokenService.clear(); } return throwError(error); }), tap(() =>{ handler();}) ); } return next.handle(request).pipe(tap(() =>{ handler(); })); } }
добавить провайдера в app.module
@NgModule({ ... providers: [ { provide: HTTP_INTERCEPTORS, useClass: TokenInterceptor, multi: true }, ], ... })
После этого TokenInterceptor
автоматически добавит token
в заголовок http для каждого запроса.
войдите на страницу еще раз, вы также увидите все данные пользователя!
7. Заключение
Jwt
— лучший способ обработки авторизации API. После этой статьи мы узнали, как обрабатывать токен Jwt в Angular, порядок действий должен быть таким, как показано ниже:
1) Вызовите функцию входа в API и после успешного завершения получите токен
2) Сохраните токен в локальном хранилище
3) Добавьте проверку для страниц каждого пользователя
4) Передайте токен каждому API запрос на получение данных
В конце концов, не забывайте, что есть два основных момента, которые создают охрану маршрутизатора для проверки токенов входа и перехватчик для отправки токенов в API.
Если вам понравилась эта статья, подпишитесь на меня здесь, на Medium, чтобы узнавать больше историй о .Net Core, Angular и других технологиях! :)
Спасибо, что дочитали до конца. Пожалуйста, подумайте о том, чтобы подписаться на автора и эту публикацию. Посетите Stackademic, чтобы узнать больше о том, как мы демократизируем бесплатное образование в области программирования во всем мире.