Nest.js 從零到壹系列(三):使用 JWT 實現單點登陸

前言

上一篇介紹瞭如何使用 Sequelize 鏈接 MySQL,接下來,在原來代碼的基礎上進行擴展,實現用戶的註冊和登陸功能。html

這裏須要簡單提一下兩個概念 JWT 和 單點登陸:前端

JWT

JWT(JSON Web Token)是爲了在網絡應用環境間傳遞聲明而執行的一種基於 JSON 的開放標準(RFC 7519)。該 Token 被設計爲緊湊且安全的,特別適用於分佈式站點的單點登陸(SSO)場景。JWT的聲明通常被用來在身份提供者和服務提供者間傳遞被認證的用戶身份信息,以便於從資源服務器獲取資源,也能夠增長一些額外的其它業務邏輯所必須的聲明信息,該 Token 也可直接被用於認證,也可被加密。git

具體原理能夠參考《JSON Web Token 入門教程 - 阮一峯》github

單點登陸

單點登陸(Single Sign On),簡稱爲 SSO,是比較流行的企業業務整合的解決方案之一。SSO的定義是在多個應用系統中,用戶只須要登陸一次就能夠訪問全部相互信任的應用系統。web

因此 JWT 實現【單點登陸】的大體流程是:sql

  1. 客戶端用戶進行登陸請求;
  2. 服務端拿到請求,根據參數查詢用戶表;
  3. 若匹配到用戶,將用戶信息進行簽證,並頒發 Token;
  4. 客戶端拿到 Token 後,存儲至某一地方,在以後的請求中都帶上 Token ;
  5. 服務端接收到帶 Token 的請求後,直接根據簽證進行校驗,無需再查詢用戶信息;

下面,就開始咱們的實戰:typescript

GitHub 項目地址,歡迎各位大佬 Star。數據庫

1、編寫加密的工具函數

src 目錄下,新建文件夾 utils,裏面將存放各類工具函數,而後新建 cryptogram.ts 文件:json

import * as crypto from 'crypto';

/**
 * Make salt
 */
export function makeSalt(): string {
  return crypto.randomBytes(3).toString('base64');
}

/**
 * Encrypt password
 * @param password 密碼
 * @param salt 密碼鹽
 */
export function encryptPassword(password: string, salt: string): string {
  if (!password || !salt) {
    return '';
  }
  const tempSalt = Buffer.from(salt, 'base64');
  return (
    // 10000 表明迭代次數 16表明長度
    crypto.pbkdf2Sync(password, tempSalt, 10000, 16, 'sha1').toString('base64')
  );
}
複製代碼

上面寫了兩個方法,一個是製做一個隨機鹽(salt),另外一個是根據鹽來加密密碼。數組

這兩個函數將貫穿註冊和登陸的功能。

2、用戶註冊

在寫註冊邏輯以前,咱們須要先修改一下上一篇寫過的代碼,即 user.service.ts 中的 findeOne() 方法:

// src/logical/user/user.service.ts
import { Injectable } from '@nestjs/common';
import * as Sequelize from 'sequelize'; // 引入 Sequelize 庫
import sequelize from '../../database/sequelize'; // 引入 Sequelize 實例

@Injectable()
export class UserService {
  /** * 查詢是否有該用戶 * @param username 用戶名 */
  async findOne(username: string): Promise<any | undefined> {
    const sql = ` SELECT user_id userId, account_name username, real_name realName, passwd password, passwd_salt salt, mobile, role FROM admin_user WHERE account_name = '${username}' `; // 一段平淡無奇的 SQL 查詢語句
    try {
      const user = (await sequelize.query(sql, {
        type: Sequelize.QueryTypes.SELECT, // 查詢方式
        raw: true, // 是否使用數組組裝的方式展現結果
        logging: true, // 是否將 SQL 語句打印到控制檯
      }))[0];
      // 若查不到用戶,則 user === undefined
      return user;
    } catch (error) {
      console.error(error);
      return void 0;
    }
  }
}
複製代碼

如今,findOne() 的功能更符合它的方法名了,查到了,就返回用戶信息,查不到,就返回 undefined

接下來,咱們開始編寫註冊功能:

// src/logical/user/user.service.ts
import { Injectable } from '@nestjs/common';
import * as Sequelize from 'sequelize'; // 引入 Sequelize 庫
import sequelize from '../../database/sequelize'; // 引入 Sequelize 實例

import { makeSalt, encryptPassword } from '../../utils/cryptogram'; // 引入加密函數

@Injectable()
export class UserService {
  /** * 查詢是否有該用戶 * @param username 用戶名 */
  async findOne(username: string): Promise<any | undefined> {
    ...
  }

  /** * 註冊 * @param requestBody 請求體 */
  async register(requestBody: any): Promise<any> {
    const { accountName, realName, password, repassword, mobile } = requestBody;
    if (password !== repassword) {
      return {
        code: 400,
        msg: '兩次密碼輸入不一致',
      };
    }
    const user = await this.findOne(accountName);
    if (user) {
      return {
        code: 400,
        msg: '用戶已存在',
      };
    }
    const salt = makeSalt(); // 製做密碼鹽
    const hashPwd = encryptPassword(password, salt);  // 加密密碼
    const registerSQL = ` INSERT INTO admin_user (account_name, real_name, passwd, passwd_salt, mobile, user_status, role, create_by) VALUES ('${accountName}', '${realName}', '${hashPwd}', '${salt}', '${mobile}', 1, 3, 0) `;
    try {
      await sequelize.query(registerSQL, { logging: false });
      return {
        code: 200,
        msg: 'Success',
      };
    } catch (error) {
      return {
        code: 503,
        msg: `Service error: ${error}`,
      };
    }
  }
}
複製代碼

編寫好後,在 user.controller.ts 中添加路由

// src/logical/user/user.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('user')
export class UserController {
  constructor(private readonly usersService: UserService) {}

  // @Post('find-one')
  // findOne(@Body() body: any) {
  // return this.usersService.findOne(body.username);
  // }

  @Post('register')
  async register(@Body() body: any) {
    return await this.usersService.register(body);
  }
}
複製代碼

如今,咱們使用 Postman 來測試一下,先故意輸入不同的密碼和已存在的用戶名:

如圖,密碼不一致的校驗觸發了。

而後,咱們把密碼改爲一致的:

如圖,已有用戶的校驗觸發了。

而後,咱們再輸入正確的參數:

咱們再去數據庫看一下:

發現已經將信息插入表中了,並且密碼也是加密後的,至此,註冊功能已基本完成。

3、JWT 的配置與驗證

爲了更直觀的感覺處理順序,我在代碼中加入了步驟打印

1. 安裝依賴包

$ yarn add passport passport-jwt passport-local @nestjs/passport @nestjs/jwt -S
複製代碼

2. 建立 Auth 模塊

$ nest g service auth logical
$ nest g module auth logical
複製代碼

3. 新建一個存儲常量的文件

auth 文件夾下新增一個 constants.ts,用於存儲各類用到的常量:

// src/logical/auth/constats.ts
export const jwtConstants = {
  secret: 'shinobi' // 祕鑰
};
複製代碼

4. 編寫 JWT 策略

auth 文件夾下新增一個 jwt.strategy.ts,用於編寫 JWT 的驗證策略:

// src/logical/auth/jwt.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { jwtConstants } from './constants';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: jwtConstants.secret,
    });
  }
  
  // JWT驗證 - Step 4: 被守衛調用
  async validate(payload: any) {
    console.log(`JWT驗證 - Step 4: 被守衛調用`);
    return { userId: payload.sub, username: payload.username, realName: payload.realName, role: payload.role };
  }
}
複製代碼

5. 編寫 auth.service.ts 的驗證邏輯

// src/logical/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { UserService } from '../user/user.service';
import { JwtService } from '@nestjs/jwt';
import { encryptPassword } from '../../utils/cryptogram';

@Injectable()
export class AuthService {
  constructor(private readonly usersService: UserService, private readonly jwtService: JwtService) {}

  // JWT驗證 - Step 2: 校驗用戶信息
  async validateUser(username: string, password: string): Promise<any> {
    console.log('JWT驗證 - Step 2: 校驗用戶信息');
    const user = await this.usersService.findOne(username);
    if (user) {
      const hashedPassword = user.password;
      const salt = user.salt;
      // 經過密碼鹽,加密傳參,再與數據庫裏的比較,判斷是否相等
      const hashPassword = encryptPassword(password, salt);
      if (hashedPassword === hashPassword) {
        // 密碼正確
        return {
          code: 1,
          user,
        };
      } else {
        // 密碼錯誤
        return {
          code: 2,
          user: null,
        };
      }
    }
    // 查無此人
    return {
      code: 3,
      user: null,
    };
  }

  // JWT驗證 - Step 3: 處理 jwt 簽證
  async certificate(user: any) {
    const payload = { username: user.username, sub: user.userId, realName: user.realName, role: user.role };
    console.log('JWT驗證 - Step 3: 處理 jwt 簽證');
    try {
      const token = this.jwtService.sign(payload);
      return {
        code: 200,
        data: {
          token,
        },
        msg: `登陸成功`,
      };
    } catch (error) {
      return {
        code: 600,
        msg: `帳號或密碼錯誤`,
      };
    }
  }
}
複製代碼

此時保存文件,控制檯會報錯:

能夠先無論,這是由於尚未把 JwtService 和 UserService 關聯到 auth.module.ts 中。

5. 編寫本地策略

這一步非必須,根據項目的需求來決定是否須要本地策略

// src/logical/auth/local.strategy.ts
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super();
  }

  async validate(username: string, password: string): Promise<any> {
    const user = await this.authService.validateUser(username, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}
複製代碼

6. 關聯 Module

// src/logical/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
import { JwtStrategy } from './jwt.strategy';
import { UserModule } from '../user/user.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';

@Module({
  imports: [
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '8h' }, // token 過時時效
    }),
    UserModule,
  ],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}
複製代碼

此時保存文件,若還有上文的報錯,則須要去 app.module.ts,將 AuthServiceproviders 數組中移除,並在 imports 數組中添加 AuthModule 便可:

// src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './logical/user/user.module';
// import { AuthService } from './logical/auth/auth.service';
import { AuthModule } from './logical/auth/auth.module';

@Module({
  imports: [UserModule, AuthModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
複製代碼

7. 編寫 login 路由

此時,迴歸到 user.controller.ts,咱們將組裝好的 JWT 相關文件引入,並根據驗證碼來判斷用戶狀態:

// src/logical/user/user.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { AuthService } from '../auth/auth.service';
import { UserService } from './user.service';

@Controller('user')
export class UserController {
  constructor(private readonly authService: AuthService, private readonly usersService: UserService) {}

  // JWT驗證 - Step 1: 用戶請求登陸
  @Post('login')
  async login(@Body() loginParmas: any) {
    console.log('JWT驗證 - Step 1: 用戶請求登陸');
    const authResult = await this.authService.validateUser(loginParmas.username, loginParmas.password);
    switch (authResult.code) {
      case 1:
        return this.authService.certificate(authResult.user);
      case 2:
        return {
          code: 600,
          msg: `帳號或密碼不正確`,
        };
      default:
        return {
          code: 600,
          msg: `查無此人`,
        };
    }
  }

  @Post('register')
  async register(@Body() body: any) {
    return await this.usersService.register(body);
  }
}
複製代碼

此時保存文件,一樣的報錯又出現了:

此次咱們先去 user.module.tscontrollers 註釋掉:

此時看控制檯,沒有 User 相關的路由,咱們須要去 app.module.ts 將 Controller 添加回去:

這麼作是由於若是在 user.module.ts 中引入 AuthService 的話,就還要將其餘的策略又引入一次,我的以爲很麻煩,就乾脆直接用 app 來統一管理了。

4、登陸驗證

前面列了一大堆代碼,是時候檢驗效果了,咱們就按照原來註冊的信息,進行登陸請求:

圖中能夠看到,已經返回了一長串 token 了,並且控制檯也打印了登陸的步驟和用戶信息。前端拿到這個 token,就能夠請求其餘有守衛的接口了。

接下來咱們試試輸錯帳號或密碼的狀況:

5、守衛

既然發放了 Token,就要能驗證 Token,所以就要用到 Guard(守衛)了。

咱們拿以前的註冊接口測試一下,修改 user.controller.ts 的代碼,引入 UseGuardsAuthGuard,並在路由上添加 @UseGuards(AuthGuard('jwt'))

// src/logical/user/user.controller.ts
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthService } from '../auth/auth.service';
import { UserService } from './user.service';

@Controller('user')
export class UserController {
  constructor(private readonly authService: AuthService, private readonly usersService: UserService) {}

  @Post('login')
  async login(@Body() loginParmas: any) {
    ...
  }

  @UseGuards(AuthGuard('jwt')) // 使用 'JWT' 進行驗證
  @Post('register')
  async register(@Body() body: any) {
    return await this.usersService.register(body);
  }
}
複製代碼

而後,咱們先來試試請求頭沒有帶 token 的狀況:

能夠看到,返回 401 狀態碼,Unauthorized 表示未受權,也就是判斷你沒有登陸。

如今,咱們試試帶 Token 的狀況,把登陸拿到的 Token 複製到 Postman 的 Authorzation 裏(選擇 Bearer Token):

而後再請求接口:

此時,已經能夠正常訪問了,再看看控制檯打印的信息,步驟也正如代碼中註釋的那樣:

至此,單點登陸功能已基本完成。

總結

本篇介紹瞭如何使用 JWT 對用戶登陸進行 Token 簽發,並在接受到含 Token 請求的時候,如何驗證用戶信息,從而實現了單點登陸。

固然,實現單點登陸並不侷限於 JWT,還有不少方法,有興趣的讀者能夠本身查閱。

這裏也說一下 JWT 的缺點,主要是沒法在使用同一帳號登陸的狀況下,後登陸的,擠掉先登陸的,也就是讓先前的 Token 失效,從而保證信息安全(至少我是沒查到相關解決方法,若是有大神解決過該問題,還請指點),只能使用一些其餘黑科技擠掉 Token。

如今,註冊、登陸功能都有了,接下來應該完善一個服務端應有的其餘公共功能。

下一篇將介紹攔截器、異常處理以及日誌的收集。

`

相關文章
相關標籤/搜索