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

2018-08-20  本文已影响0人  DAI_WEI

原文地址

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 结构:

标准声明

FQDN:(Fully Qualified Domain Name)全限定域名:同时带有主机名和域名的名称。( 通过符号“.”) 例如:主机名是bigserver,域名是mycompany.com,那么FQDN就是bigserver.mycompany.com。

私有声明
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]) 方法可以接受以下参数:

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请求当中。

共同工作

至此,我们已经有了一个生成 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

<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));

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


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

上一篇下一篇

猜你喜欢

热点阅读