在 angular 6 中利用 JWT 进行身份认证

原文地址

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

JWT 是 JSON Web Tokens 的简称,对于这个问题最精简的回答是,JWT 具有简便、紧凑、安全的特点,具体来看:

  1. 简便:只要用户登陆后,使用 JWT 认证仅需要添加一个 http header 认证信息,这可以用一个函数简单实现,我们会在后面的例子中看到这一点。
  2. 紧凑:JWT token 是一个 base 64 编码的字符串,包含若干头部信息及一些必要的数据,非常简单。签名后的 JWT 字符串通常不超过 200 字节。
  3. 安全: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 存储在 localStoragesessionStorage,但实际如何存储可以自行决定,只要后续在应用中可以方便获取。
当我们访问需要身份认证的 API 服务,最简单的方法是将 JWT 字符串加到 http 头部的 Authorization 字段。
Authorization: Bearer {JWT Token}
当后台服务接收到 JWT, 它可以对其进行解码,使用私钥校验真实性,并通过 expnbf 值判断其有效性。 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 之前的版本。
目录中包含 serverclient 两个文件夹。server 内存在一个 node api 服务程序,用于提供基本的 api 服务。client 中即为我们解下来要编写的 angular 应用。

Node Api Server

首先启动 API 服务:

cd server
npm install
node app.js

以下链接可以获取相应的 JSON 数据。在实现认证前,我们写死了 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-parsejsonwebtokenexpress-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);
  }
}

认证服务只有两个方法,loginlogout

  • loginusernamepassword 发送到后台,等接收到返回的 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
现在我们的应用将会变成这样:


此时我们可以登陆,查看所有的界面(用户名jemmapaulsebastian,密码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));

重启服务后即可根据用户返回列表内容。


以上即为翻译的内容,希望对各位有所帮助,感谢阅读

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,723评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,485评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,998评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,323评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,355评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,079评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,389评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,019评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,519评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,971评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,100评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,738评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,293评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,289评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,517评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,547评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,834评论 2 345

推荐阅读更多精彩内容