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, чтобы узнать больше о том, как мы демократизируем бесплатное образование в области программирования во всем мире.