JWT 是什么,為何要使用 JWT?
JWT 是 JSON Web Tokens 的簡稱,對于這個問題最精簡的回答是,JWT 具有簡便、緊湊、安全的特點,具體來看:
- 簡便:只要用戶登陸后,使用 JWT 認(rèn)證僅需要添加一個 http header 認(rèn)證信息,這可以用一個函數(shù)簡單實現(xiàn),我們會在后面的例子中看到這一點。
- 緊湊:JWT token 是一個 base 64 編碼的字符串,包含若干頭部信息及一些必要的數(shù)據(jù),非常簡單。簽名后的 JWT 字符串通常不超過 200 字節(jié)。
- 安全: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):
-
/authPOST 提交用戶名username和密碼password進(jìn)行登陸認(rèn)證,返回 JWT 字符串 -
/todosGET 返回待辦事項清單 -
/todos/{id}GET 返回指定的待辦事項 -
/usersGET 返回用戶列表
我們將會在后面看到創(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 存儲在 localStorage 或 sessionStorage,但實際如何存儲可以自行決定,只要后續(xù)在應(yīng)用中可以方便獲取。
當(dāng)我們訪問需要身份認(rèn)證的 API 服務(wù),最簡單的方法是將 JWT 字符串加到 http 頭部的 Authorization 字段。
Authorization: Bearer {JWT Token}
當(dāng)后臺服務(wù)接收到 JWT, 它可以對其進(jìn)行解碼,使用私鑰校驗真實性,并通過 exp 和 nbf 值判斷其有效性。 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 之前的版本。
目錄中包含 server 和 client 兩個文件夾。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ù):
- http://localhost:4000/ :測試頁面,驗證服務(wù)器正常運行
- http://localhost:4000/api/users : 返回系統(tǒng)中的用戶列表
-
http://localhost:4000/api/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ù)只有兩個方法,login 和 logout:
-
login將username和password發(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,paul,sebastian,密碼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)容,希望對各位有所幫助,感謝閱讀