在 angular 6 中利用 JWT 進(jìn)行身份認(rèn)證

原文地址

JWT 是什么,為何要使用 JWT?

JWT 是 JSON Web Tokens 的簡稱,對于這個問題最精簡的回答是,JWT 具有簡便、緊湊、安全的特點,具體來看:

  1. 簡便:只要用戶登陸后,使用 JWT 認(rèn)證僅需要添加一個 http header 認(rèn)證信息,這可以用一個函數(shù)簡單實現(xiàn),我們會在后面的例子中看到這一點。
  2. 緊湊:JWT token 是一個 base 64 編碼的字符串,包含若干頭部信息及一些必要的數(shù)據(jù),非常簡單。簽名后的 JWT 字符串通常不超過 200 字節(jié)。
  3. 安全:JWT 可以使用 RSA 或 HMAC 加密算法進(jìn)行加密,確保 token 有效且防止篡改。
    總之你可以有一種安全有效的方式來認(rèn)證用戶,并且對所有 api 調(diào)用都進(jìn)行認(rèn)證,而不需要解析復(fù)雜的數(shù)據(jù)結(jié)構(gòu)或者實現(xiàn)自己的加密算法。
    關(guān)于 JWT 的詳細(xì)介紹可以參考 什么是 JWT -- JSON WEB TOKEN

應(yīng)用概述

交互過程

基于以上背景,我們現(xiàn)在可以來看看如何實現(xiàn)一個真正的應(yīng)用。例如,假設(shè)我們已經(jīng)通過 node.js 搭建了一個 API 服務(wù)器,現(xiàn)在要使用 angular 6 開發(fā)一個 todo 待辦事項的應(yīng)用。我們首先來看一下 API 結(jié)構(gòu):

  • /auth POST 提交用戶名 username 和密碼 password 進(jìn)行登陸認(rèn)證,返回 JWT 字符串
  • /todos GET 返回待辦事項清單
  • /todos/{id} GET 返回指定的待辦事項
  • /users GET 返回用戶列表
    我們將會在后面看到創(chuàng)建這個應(yīng)用的整個過程,不過首先,我們先關(guān)注一下應(yīng)用的交互過程。我們有一個簡單的登陸頁面,用戶在此輸入用戶名和密碼。當(dāng)提交登陸表單后,前端應(yīng)用將數(shù)據(jù)發(fā)送到后臺的 /auth 路徑。后臺服務(wù)可以采用合適的方式(數(shù)據(jù)庫查詢,調(diào)用其他 web service 等)去對這個用戶進(jìn)行認(rèn)證,最后向前端返回 JWT 字符串。
    在本例中, JWT 字符串會包含一些標(biāo)準(zhǔn)聲明及私有聲明。標(biāo)準(zhǔn)聲明是指 JWT 標(biāo)準(zhǔn)中建議使用的 key value 鍵值對,而私有聲明是指僅用于本應(yīng)用的私有數(shù)據(jù):
標(biāo)準(zhǔn)聲明
  • iss: token 的簽發(fā)者,通常是服務(wù)器 FQDN, 但也可以設(shè)置成任何客戶端應(yīng)用希望識別的形式。

FQDN:(Fully Qualified Domain Name)全限定域名:同時帶有主機名和域名的名稱。( 通過符號“.”) 例如:主機名是bigserver,域名是mycompany.com,那么FQDN就是bigserver.mycompany.com。

  • exp: 過期時間。用 unix 時間戳表示。
  • nbf: not valid before timestamp。用于標(biāo)識 token 串啟用時間。用 unix 時間戳表示。
私有聲明
  • uid: 登陸用戶id。
  • role: 登陸用戶角色。
    本例中數(shù)據(jù)會使用 base64 編碼,然后通過 HMAC 算法加密,使用的密鑰是 todo-app-super-shared-secret。下面是一個 JWT 字符串的例子:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b2RvYXBpIiwibmJmIjoxNDk4MTE3NjQyLCJleHAiOjE0OTgxMjEyNDIsInVpZCI6MSwicm9sZSI6ImFkbWluIn0.ZDz_1vcIlnZz64nSM28yA1s-4c_iw3Z2ZtP-SgcYRPQ

該字符串包含了我們所需要的全部信息,可以保證我們已經(jīng)合法登陸,且知道登陸的是哪個用戶,甚至該用戶的角色。
多數(shù)應(yīng)用會將 JWT 存儲在 localStoragesessionStorage,但實際如何存儲可以自行決定,只要后續(xù)在應(yīng)用中可以方便獲取。
當(dāng)我們訪問需要身份認(rèn)證的 API 服務(wù),最簡單的方法是將 JWT 字符串加到 http 頭部的 Authorization 字段。
Authorization: Bearer {JWT Token}
當(dāng)后臺服務(wù)接收到 JWT, 它可以對其進(jìn)行解碼,使用私鑰校驗真實性,并通過 expnbf 值判斷其有效性。 iss 字段可以用來確認(rèn)原始簽發(fā)者。
當(dāng) token 合法性校驗完成,服務(wù)器即可使用 JWT 中存儲的其他信息。例如 uid 可用于識別登陸用戶, role 可以用于識別用戶角色,判斷其是否擁有獲取資源的權(quán)限。

function getTodos(jwtString)
{
  var token = JWTDecode(jwtstring);
  if( Date.now() < token.nbf*1000) {
    throw new Error('Token not yet valid');
  }
  if( Date.now() > token.exp*1000) {
    throw new Error('Token has expired');
  }
  if( token.iss != 'todoapi') {
    throw new Error('Token not issued here');
  }

  var userID = token.uid;
  var todos = loadUserTodosFromDB(userID);

  return JSON.stringify(todos);
}

創(chuàng)建 TODO 應(yīng)用

為了完成后面的步驟,首先需要安全最新版本的 Node.js (6.x 以上),npm (3.x以上),angular-cli??梢詮?a target="_blank" rel="nofollow">此處獲取到最新版本的 Node.js 及 npm,安裝完成后用 npm 安裝 angular-cli:

npm install -g @angular/cli

從 github 獲取腳手架工程:

git clone https://github.com/sschocke/angular-jwt-todo.git
cd angular-jwt-todo
git checkout pre-jwt

git checkout pre-jwt命令用于將文件切換到實現(xiàn) JWT 之前的版本。
目錄中包含 serverclient 兩個文件夾。server 內(nèi)存在一個 node api 服務(wù)程序,用于提供基本的 api 服務(wù)。client 中即為我們解下來要編寫的 angular 應(yīng)用。

Node Api Server

首先啟動 API 服務(wù):

cd server
npm install
node app.js

以下鏈接可以獲取相應(yīng)的 JSON 數(shù)據(jù)。在實現(xiàn)認(rèn)證前,我們寫死了 todos 接口用于返回 userID=1 的任務(wù):

Angular 應(yīng)用

安裝依賴然后啟動 client 端服務(wù)。

cd client
npm install
npm start

請使用 npm start 而不是 ng serve,因為 npm start 會根據(jù)配置文件加上運行參數(shù),將 http 請求轉(zhuǎn)發(fā)到 4000 端口

如果一切正常,現(xiàn)在訪問 http://localhost:4200 應(yīng)該可以出現(xiàn)一下界面:

添加 JWT 認(rèn)證

我們可以安裝標(biāo)準(zhǔn)庫使 JWT 認(rèn)證更加簡便。
首先在 client 端安裝組件。該組件由 Auth0
開發(fā)和維護(hù)。

cd client
npm install @auth0/angular-jwt

在 server 端安裝 body-parse,jsonwebtoken,express-jwt,用于讀取 JSON 和 JWT。

cd server
npm install body-parser jsonwebtoken express-jwt

認(rèn)證 API 接口

在向服務(wù)器發(fā)送 token 前我們首先要需要一個驗證用戶的方法。作為簡單示例,此處可以先寫死用戶名和密碼。這里最重要的事情是在最后返回 JWT 字符串。
打開 server/app.js,在現(xiàn)有的 require 后面添加下列代碼:

const bodyParser = require('body-parser');
const jwt = require('jsonwebtoken');
const expressJwt = require('express-jwt');
app.use(bodyParser.json());

app.post('/api/auth', function(req, res) {
  const body = req.body;

  const user = USERS.find(user => user.username == body.username);
  if(!user || body.password != 'todo') return res.sendStatus(401);
  
  var token = jwt.sign({userID: user.id}, 'todo-app-super-shared-secret', {expiresIn: '2h'});
  res.send({token});
});

我們從 /auth 接口獲取到傳入的 JSON 數(shù)據(jù),找到用戶名對應(yīng)的用戶,校驗密碼,如果出現(xiàn)錯誤,返回 401 Unauthorized HTTP 錯誤狀態(tài)。
最重要的部分是 token 的生成。jwt.sign(payload, secretOrPrivateKey, [options, callback]) 方法可以接受以下參數(shù):

  • payload 是一個鍵值對象,存儲必要的數(shù)據(jù),此例中僅包含 user.id,通過該字段,當(dāng)服務(wù)器再次接收到 token 后,就可以解碼獲得用戶 id 并返回相應(yīng)的資源。
  • secretOrPrivateKey 此例中為了簡化過程,傳入的是 HMAC 加密算法私鑰。除此以外也可以傳入 RSA/ECDSA 私鑰。
  • options 可以傳入其他選項,例如這里的 expiresIn,會被轉(zhuǎn)為 exp 標(biāo)準(zhǔn)聲明。
  • callback 用于傳入編碼完成后的回調(diào)函數(shù)。
    點擊此處查看詳細(xì)用法。

Angular 6 JWT 集成

client/src/app/app.modules.ts 添加下列代碼,引入 angular-jwt 模塊:

import { JwtModule } from '@auth0/angular-jwt';
// ...
export function tokenGetter() {
  return localStorage.getItem('access_token');
}

@NgModule({
// ...
  imports: [
    BrowserModule,
    AppRoutingModule,
    HttpClientModule,
    FormsModule,
    // Add this import here
    JwtModule.forRoot({
      config: {
        tokenGetter: tokenGetter,
        whitelistedDomains: ['localhost:4000'],
        blacklistedRoutes: ['localhost:4000/api/auth']
      }
    })
  ],
// ...
}

這些就是必須的基礎(chǔ)代碼。當(dāng)然,我們需要更多代碼來完成認(rèn)證過程,不過 angular-jwt 模塊主要用來將 JWT 認(rèn)證信息添加到每個 HTTP請求當(dāng)中。

  • tokenGetter() 函數(shù)顧名思義,用來獲取 token,不過其實現(xiàn)方式由開發(fā)者來決定。我們在此處選擇從 localStorage 獲得 token,將來我們也會將 token 存儲在此處。
  • whiteListedDomains 限制 JWT 發(fā)送的域名,這樣公開 API 將不會接收到 JWT。
  • blackListedRoutes 允許我們指定不用接收 JWT 的路徑,即使這些路徑包含在 whitelisted 域名中。通常我們需要將登陸接口路徑加在此處。

共同工作

至此,我們已經(jīng)有了一個生成 JWT 的接口,并且配置完成往所有 HTTP 請求中加入 JWT。但對于用戶來說,還看不到任何變化,我們依然可以進(jìn)入所有的頁面并調(diào)用原有接口。
接下來我們需要升級應(yīng)用,讓它判斷用戶是否登陸,并且升級 API,使其在提供服務(wù)前校驗 JWT。
下面我們新建一個用于登陸的 angular 組件,一個處理認(rèn)證請求的服務(wù),以及 Angular Guard 來保護(hù)需要登陸的路徑。輸入下列命令:

cd client
ng g component login --spec=false --inline-style
ng g service auth --flat --spec=false
ng g guard auth --flat --spec=false

現(xiàn)在 client 目錄中已經(jīng)添加了下列文件:

src/app/login/login.component.html
src/app/login/login.component.ts
src/app/auth.service.ts
src/app/auth.guard.ts

接著我們將 service 和 guard 添加到應(yīng)用引用中。更新 client/src/app/app.modules.ts

import { AuthService } from './auth.service';
import { AuthGuard } from './auth.guard';

// ...

providers: [
  TodoService,
  UserService,
  AuthService,
  AuthGuard
],

然后更新client/src/app/app-routing.modules.ts文件,將路徑保護(hù)起來,并且為登陸組件添加一個路由。

// ...
import { LoginComponent } from './login/login.component';
import { AuthGuard } from './auth.guard';

const routes: Routes = [
  { path: 'todos', component: TodoListComponent, canActivate: [AuthGuard] },
  { path: 'users', component: UserListComponent, canActivate: [AuthGuard] },
  { path: 'login', component: LoginComponent},
  // ...

最后,更新client/src/app/auth.guard.ts

import { Injectable } from '@angular/core';
import { Router, CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private router: Router) { }

  canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
    if (localStorage.getItem('access_token')) {
      return true;
    }

    this.router.navigate(['login']);
    return false;
  }
}

在這個示例應(yīng)用中,我們只是簡單檢查 JWT 是否存儲在本地存儲中。在實際應(yīng)用中,我們還需要解碼 token 來校驗合法性、有效時間等。JwtHelperService 可以幫助我們完成這些工作。
此時,我們的應(yīng)用將只會把頁面定向到登陸頁面,因為現(xiàn)在還沒有完成登陸的辦法。下面編寫client/src/app/auth.service.ts

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable()
export class AuthService {
  constructor(private http: HttpClient) { }

  login(username: string, password: string): Observable<boolean> {
    return this.http.post<{token: string}>('/api/auth', {username: username, password: password})
      .pipe(
        map(result => {
          localStorage.setItem('access_token', result.token);
          return true;
        })
      );
  }

  logout() {
    localStorage.removeItem('access_token');
  }

  public get loggedIn(): boolean {
    return (localStorage.getItem('access_token') !== null);
  }
}

認(rèn)證服務(wù)只有兩個方法,loginlogout

  • loginusernamepassword 發(fā)送到后臺,等接收到返回的 JWT 后將其存儲到 localStorage,鍵值為 access_token,為了簡化,此處沒有進(jìn)行錯誤處理。
  • logout 簡單從 localStorage 清除了·access_token` 的值。
  • loggedIn 返回一個布爾值,我們可以用來判斷用戶是否登陸。
    最后修改登陸組件,編輯client/src/app/login/login.components.html
<h4 *ngIf="error">{{error}}</h4>
<form (ngSubmit)="submit()">
  <div class="form-group col-3">
    <label for="username">Username</label>
    <input type="text" name="username" class="form-control" [(ngModel)]="username" />
  </div>
  <div class="form-group col-3">
    <label for="password">Password</label>
    <input type="password" name="password" class="form-control" [(ngModel)]="password" />
  </div>
  <div class="form-group col-3">
    <button class="btn btn-primary" type="submit">Login</button>
  </div>
</form>

client/src/app/login/login.components.ts

import { Component, OnInit } from '@angular/core';
import { AuthService } from '../auth.service';
import { Router } from '@angular/router';
import { first } from 'rxjs/operators';

@Component({
  selector: 'app-login',
  templateUrl: './login.component.html'
})
export class LoginComponent {
  public username: string;
  public password: string;
  public error: string;

  constructor(private auth: AuthService, private router: Router) { }

  public submit() {
    this.auth.login(this.username, this.password)
      .pipe(first())
      .subscribe(
        result => this.router.navigate(['todos']),
        err => this.error = 'Could not authenticate'
      );
  }
}

此處需要重新運行服務(wù)端 app.js
現(xiàn)在我們的應(yīng)用將會變成這樣:


此時我們可以登陸,查看所有的界面(用戶名jemma,paulsebastian,密碼todo)。但我們的應(yīng)用只能顯示相同的導(dǎo)航,且不具有登出的功能。讓我們在改進(jìn) api 前來修正這些問題。
client/src/app/app.component.ts 文件的內(nèi)容替換如下:

import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from './auth.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  constructor(private auth: AuthService, private router: Router) { }

  logout() {
    this.auth.logout();
    this.router.navigate(['login']);
  }
}

打開 client/src/app/app.component.html ,將 <nav> 標(biāo)簽中的內(nèi)容替換如下:

 <nav class="nav nav-pills">
    <a class="nav-link" routerLink="todos" routerLinkActive="active" *ngIf="auth.loggedIn">Todo List</a>
    <a class="nav-link" routerLink="users" routerLinkActive="active" *ngIf="auth.loggedIn">Users</a>
    <a class="nav-link" routerLink="login" routerLinkActive="active" *ngIf="!auth.loggedIn">Login</a>
    <a class="nav-link" (click)="logout()" href="#" *ngIf="auth.loggedIn">Logout</a>
  </nav>

如此我們已經(jīng)讓導(dǎo)航欄與內(nèi)容相關(guān),并且根據(jù)登陸狀態(tài)選擇菜單是否隱藏。

API 安全性

現(xiàn)在的問題是,對于三個不同的用戶,后臺返回的 TODO 列表是一樣的。這是因為現(xiàn)在 /todos 接口對所有用戶返回的是相同的 userID=1 的待辦事項。我們在代碼中并沒有去獲取登陸用戶。
我們可以在 server/app.js 文件中新增 app.use():

app.use(expressJwt({secret: 'todo-app-super-shared-secret'}).unless({path: ['/api/auth']}));

利用 express-jwt 中間件,獲取到 JWT 中包含的數(shù)據(jù),在接口處理函數(shù)中可以用 req.user.userID 的形式獲取。下面改寫 /todos 接口方法:

res.send(getTodos(req.user.userID));

重啟服務(wù)后即可根據(jù)用戶返回列表內(nèi)容。


以上即為翻譯的內(nèi)容,希望對各位有所幫助,感謝閱讀

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容