最近项目项目中用angular8进行前端开发,所以近期会收集整理angular相关的文章发出来,中间遇到的坑也会发出来。本文原文是angular6的,不过angular8同样适用.
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 8 开发一个 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));
重启服务后即可根据用户返回列表内容。
最新评论