JWT 是什么,为何要使用 JWT?
JWT 是 JSON Web Tokens 的简称,对于这个问题最精简的回答是,JWT 具有简便、紧凑、安全的特点,具体来看:
- 简便:只要用户登陆后,使用 JWT 认证仅需要添加一个 http header 认证信息,这可以用一个函数简单实现,我们会在后面的例子中看到这一点。
- 紧凑:JWT token 是一个 base 64 编码的字符串,包含若干头部信息及一些必要的数据,非常简单。签名后的 JWT 字符串通常不超过 200 字节。
- 安全:JWT 可以使用 RSA 或 HMAC 加密算法进行加密,确保 token 有效且防止篡改。
总之你可以有一种安全有效的方式来认证用户,并且对所有 api 调用都进行认证,而不需要解析复杂的数据结构或者实现自己的加密算法。
关于 JWT 的详细介绍可以参考 什么是 JWT -- JSON WEB TOKEN
应用概述
基于以上背景,我们现在可以来看看如何实现一个真正的应用。例如,假设我们已经通过 node.js 搭建了一个 API 服务器,现在要使用 angular 6 开发一个 todo 待办事项的应用。我们首先来看一下 API 结构:
-
/auth
POST 提交用户名username
和密码password
进行登陆认证,返回 JWT 字符串 -
/todos
GET 返回待办事项清单 -
/todos/{id}
GET 返回指定的待办事项 -
/users
GET 返回用户列表
我们将会在后面看到创建这个应用的整个过程,不过首先,我们先关注一下应用的交互过程。我们有一个简单的登陆页面,用户在此输入用户名和密码。当提交登陆表单后,前端应用将数据发送到后台的/auth
路径。后台服务可以采用合适的方式(数据库查询,调用其他 web service 等)去对这个用户进行认证,最后向前端返回 JWT 字符串。
在本例中, JWT 字符串会包含一些标准声明及私有声明。标准声明是指 JWT 标准中建议使用的 key value 键值对,而私有声明是指仅用于本应用的私有数据:
标准声明
-
iss
: token 的签发者,通常是服务器 FQDN, 但也可以设置成任何客户端应用希望识别的形式。
FQDN:(Fully Qualified Domain Name)全限定域名:同时带有主机名和域名的名称。( 通过符号“.”) 例如:主机名是bigserver,域名是mycompany.com,那么FQDN就是bigserver.mycompany.com。
-
exp
: 过期时间。用 unix 时间戳表示。 -
nbf
: not valid before timestamp。用于标识 token 串启用时间。用 unix 时间戳表示。
私有声明
-
uid
: 登陆用户id。 -
role
: 登陆用户角色。
本例中数据会使用 base64 编码,然后通过 HMAC 算法加密,使用的密钥是todo-app-super-shared-secret
。下面是一个 JWT 字符串的例子:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b2RvYXBpIiwibmJmIjoxNDk4MTE3NjQyLCJleHAiOjE0OTgxMjEyNDIsInVpZCI6MSwicm9sZSI6ImFkbWluIn0.ZDz_1vcIlnZz64nSM28yA1s-4c_iw3Z2ZtP-SgcYRPQ
该字符串包含了我们所需要的全部信息,可以保证我们已经合法登陆,且知道登陆的是哪个用户,甚至该用户的角色。
多数应用会将 JWT 存储在 localStorage
或 sessionStorage
,但实际如何存储可以自行决定,只要后续在应用中可以方便获取。
当我们访问需要身份认证的 API 服务,最简单的方法是将 JWT 字符串加到 http 头部的 Authorization
字段。
Authorization: Bearer {JWT Token}
当后台服务接收到 JWT, 它可以对其进行解码,使用私钥校验真实性,并通过 exp
和 nbf
值判断其有效性。 iss
字段可以用来确认原始签发者。
当 token 合法性校验完成,服务器即可使用 JWT 中存储的其他信息。例如 uid
可用于识别登陆用户, role
可以用于识别用户角色,判断其是否拥有获取资源的权限。
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);
}
创建 TODO 应用
为了完成后面的步骤,首先需要安全最新版本的 Node.js (6.x 以上),npm (3.x以上),angular-cli。可以从此处获取到最新版本的 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
命令用于将文件切换到实现 JWT 之前的版本。
目录中包含 server
和 client
两个文件夹。server 内存在一个 node api 服务程序,用于提供基本的 api 服务。client 中即为我们解下来要编写的 angular 应用。
Node Api Server
首先启动 API 服务:
cd server
npm install
node app.js
以下链接可以获取相应的 JSON 数据。在实现认证前,我们写死了 todos
接口用于返回 userID=1
的任务:
- http://localhost:4000/ :测试页面,验证服务器正常运行
- http://localhost:4000/api/users : 返回系统中的用户列表
-
http://localhost:4000/api/todos : 返回
userID=1
的任务列表
Angular 应用
安装依赖然后启动 client 端服务。
cd client
npm install
npm start
请使用
npm start
而不是ng serve
,因为 npm start 会根据配置文件加上运行参数,将 http 请求转发到 4000 端口
如果一切正常,现在访问 http://localhost:4200 应该可以出现一下界面:
添加 JWT 认证
我们可以安装标准库使 JWT 认证更加简便。
首先在 client 端安装组件。该组件由 Auth0
开发和维护。
cd client
npm install @auth0/angular-jwt
在 server 端安装 body-parse
,jsonwebtoken
,express-jwt
,用于读取 JSON 和 JWT。
cd server
npm install body-parser jsonwebtoken express-jwt
认证 API 接口
在向服务器发送 token 前我们首先要需要一个验证用户的方法。作为简单示例,此处可以先写死用户名和密码。这里最重要的事情是在最后返回 JWT 字符串。
打开 server/app.js
,在现有的 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 数据,找到用户名对应的用户,校验密码,如果出现错误,返回 401 Unauthorized
HTTP 错误状态。
最重要的部分是 token 的生成。jwt.sign(payload, secretOrPrivateKey, [options, callback])
方法可以接受以下参数:
-
payload
是一个键值对象,存储必要的数据,此例中仅包含user.id
,通过该字段,当服务器再次接收到 token 后,就可以解码获得用户 id 并返回相应的资源。 -
secretOrPrivateKey
此例中为了简化过程,传入的是 HMAC 加密算法私钥。除此以外也可以传入 RSA/ECDSA 私钥。 -
options
可以传入其他选项,例如这里的expiresIn
,会被转为exp
标准声明。 -
callback
用于传入编码完成后的回调函数。
点击此处查看详细用法。
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']
}
})
],
// ...
}
这些就是必须的基础代码。当然,我们需要更多代码来完成认证过程,不过 angular-jwt
模块主要用来将 JWT 认证信息添加到每个 HTTP请求当中。
-
tokenGetter()
函数顾名思义,用来获取 token,不过其实现方式由开发者来决定。我们在此处选择从localStorage
获得 token,将来我们也会将 token 存储在此处。 -
whiteListedDomains
限制 JWT 发送的域名,这样公开 API 将不会接收到 JWT。 -
blackListedRoutes
允许我们指定不用接收 JWT 的路径,即使这些路径包含在 whitelisted 域名中。通常我们需要将登陆接口路径加在此处。
共同工作
至此,我们已经有了一个生成 JWT 的接口,并且配置完成往所有 HTTP 请求中加入 JWT。但对于用户来说,还看不到任何变化,我们依然可以进入所有的页面并调用原有接口。
接下来我们需要升级应用,让它判断用户是否登陆,并且升级 API,使其在提供服务前校验 JWT。
下面我们新建一个用于登陆的 angular 组件,一个处理认证请求的服务,以及 Angular Guard 来保护需要登陆的路径。输入下列命令:
cd client
ng g component login --spec=false --inline-style
ng g service auth --flat --spec=false
ng g guard auth --flat --spec=false
现在 client
目录中已经添加了下列文件:
src/app/login/login.component.html
src/app/login/login.component.ts
src/app/auth.service.ts
src/app/auth.guard.ts
接着我们将 service 和 guard 添加到应用引用中。更新 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
文件,将路径保护起来,并且为登陆组件添加一个路由。
// ...
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;
}
}
在这个示例应用中,我们只是简单检查 JWT 是否存储在本地存储中。在实际应用中,我们还需要解码 token 来校验合法性、有效时间等。JwtHelperService 可以帮助我们完成这些工作。
此时,我们的应用将只会把页面定向到登陆页面,因为现在还没有完成登陆的办法。下面编写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);
}
}
认证服务只有两个方法,login
和 logout
:
-
login
将username
和password
发送到后台,等接收到返回的 JWT 后将其存储到localStorage
,键值为access_token
,为了简化,此处没有进行错误处理。 -
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'
);
}
}
此处需要重新运行服务端 app.js
现在我们的应用将会变成这样:
此时我们可以登陆,查看所有的界面(用户名
jemma
,paul
,sebastian
,密码todo
)。但我们的应用只能显示相同的导航,且不具有登出的功能。让我们在改进 api 前来修正这些问题。将
client/src/app/app.component.ts
文件的内容替换如下:
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>
标签中的内容替换如下:
<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>
如此我们已经让导航栏与内容相关,并且根据登陆状态选择菜单是否隐藏。
API 安全性
现在的问题是,对于三个不同的用户,后台返回的 TODO 列表是一样的。这是因为现在 /todos 接口对所有用户返回的是相同的 userID=1
的待办事项。我们在代码中并没有去获取登陆用户。
我们可以在 server/app.js
文件中新增 app.use():
app.use(expressJwt({secret: 'todo-app-super-shared-secret'}).unless({path: ['/api/auth']}));
利用 express-jwt
中间件,获取到 JWT 中包含的数据,在接口处理函数中可以用 req.user.userID
的形式获取。下面改写 /todos
接口方法:
res.send(getTodos(req.user.userID));
重启服务后即可根据用户返回列表内容。
以上即为翻译的内容,希望对各位有所帮助,感谢阅读