前言
上一篇介紹了如何使用 雲開發cloudbase,接下來,在原來代碼的基礎上進行擴展,實現用戶的注冊和登錄功能。
這里簡單提一下 JWT:
JWT
JWT(JSON Web Token)是為了在網絡應用環境間傳遞聲明而執行的一種基於 JSON 的開放標准(RFC 7519)。該 Token 被設計為緊湊且安全的,特別適用於分布式站點的單點登錄(SSO)場景。JWT 的聲明一般被用來在身份提供者和服務提供者間傳遞被認證的用戶身份信息,以便於從資源服務器獲取資源,也可以增加一些額外的其它業務邏輯所必須的聲明信息,該 Token 也可直接被用於認證,也可被加密。
具體原理可以參考《JSON Web Token 入門教程 - 阮一峰》
所以 JWT 實現【登錄】的大致流程是:
- 客戶端用戶進行登錄請求;
- 服務端拿到請求,根據參數查詢用戶表;
- 若匹配到用戶,將用戶信息進行簽證,並頒發 Token;
- 客戶端拿到 Token 后,存儲至某一地方,在之后的請求中都帶上 Token ;
- 服務端接收到帶 Token 的請求后,直接根據簽證進行校驗,無需再查詢用戶信息;
下面,就開始我們的實戰:
一、編寫加密的工具函數
在 src
目錄下,新建文件夾 utils
,里面將存放各種工具函數,然后新建 cryptogram.ts
文件:
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),另一個是根據鹽來加密密碼。
這兩個函數將貫穿注冊和登錄的功能。
二、用戶注冊
在寫注冊邏輯之前,我們需要先修改一下上一篇寫過的代碼,即 user.service.ts
中的 findeOne()
方法:
// src/logical/user/user.service.ts
import { Injectable } from "@nestjs/common";
import { db } from "../../database/init";
import { makeSalt, encryptPassword } from "../../utils/cryptogram";
@Injectable()
export class UserService {
/**
* 查詢用戶是否存在
* */
async findOne(account: string): Promise<any | undefined> {
if (!account) {
return {
code: 400,
msg: "請輸入用戶名"
};
}
try {
const user = await db.collection("admin").where({
account
}).get();
// 若查不到用戶,則 user === undefined
return user.data[0];
} catch (err) {
console.error(err);
return void 0;
}
}
}
現在,findOne()
的功能更符合它的方法名了,查到了,就返回用戶信息,查不到,就返回 undefined
。
接下來,我們開始編寫注冊功能:
// src/logical/user/user.service.ts
import { Injectable } from "@nestjs/common";
import { db } from "../../database/init";
import { makeSalt, encryptPassword } from "../../utils/cryptogram";
@Injectable()
export class UserService {
/**
* 查詢用戶是否存在
* */
async findOne(account: string): Promise<any | undefined> {
...
}
/**
* 注冊
* @param requestBody 請求體
* */
async register(requestBody: any): Promise<any> {
const { account, password } = requestBody;
const user = await this.findOne(account);
if (user) {
return {
code: 400,
msg: "用戶已存在"
};
}
const salt = makeSalt(); // 制作密碼鹽
const hashPwd = encryptPassword(password, salt); // 加密密碼
try {
await db.collection("admin").add({ account, password: hashPwd, salt });
return {
code: 200,
msg: "新增成功"
};
} catch (err) {
return {
code: 503,
msg: `Service error: ${err}`
};
}
}
}
編寫好后,在 user.controller.ts
中添加路由
// src/logical/user/user.controller.ts
import { Body, Controller, Get, Post, Query, Req, Request, UseGuards, UsePipes } 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: any) {
return await this.usersService.register(body);
}
}
現在,我們使用 ApiPost 來測試一下,先故意輸入不一樣的密碼和已存在的用戶名:
我們再去數據庫看一下:
發現已經將信息插入表中了,而且密碼也是加密后的,至此,注冊功能已基本完成。
三、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: 'shinobi7414', // 秘鑰
};
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
,將 AuthService
從 providers
數組中移除,並在 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.ts
將 controllers
注釋掉:
此時看控制台,沒有 User 相關的路由,我們需要去 app.module.ts
將 Controller 添加回去:
這么做是因為如果在 user.module.ts
中引入 AuthService
的話,就還要將其他的策略又引入一次,個人覺得很麻煩,就干脆直接用 app 來統一管理了。
四、登錄驗證
前面列了一大堆代碼,是時候檢驗效果了,我們就按照原來注冊的信息,進行登錄請求:
圖中可以看到,已經返回了一長串 token 了,而且控制台也打印了登錄的步驟和用戶信息。前端拿到這個 token,就可以請求其他有守衛的接口了。
五、守衛
既然發放了 Token,就要能驗證 Token,因此就要用到 Guard(守衛)了。
我們拿之前的注冊接口測試一下,修改 user.controller.ts
的代碼,引入 UseGuards
和 AuthGuard
,並在路由上添加 @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';
('user')
export class UserController {
constructor(private readonly authService: AuthService, private readonly usersService: UserService) {}
('login')
async login(() loginParmas: any) {
...
}
(AuthGuard('jwt')) // 使用 'JWT' 進行驗證
('register')
async register(() body: any) {
return await this.usersService.register(body);
}
}
然后,我們先來試試請求頭沒有帶 token 的情況:
可以看到,返回 401 狀態碼,Unauthorized 表示未授權,也就是判斷你沒有登錄。
現在,我們試試帶 Token 的情況,把登錄拿到的 Token 復制到 ApiPost 的 header里:
此時,已經可以正常訪問了,至此,登錄功能已基本完成。
總結
本篇介紹了如何使用 JWT 對用戶登錄進行 Token 簽發,並在接受到含 Token 請求的時候,如何驗證用戶信息,從而實現了登錄驗證。
當然,實現登錄驗證並不局限於 JWT,還有很多方法,有興趣的讀者可以自己查閱。
這里也說一下 JWT 的缺點,主要是無法在使用同一賬號登錄的情況下,后登錄的,擠掉先登錄的,也就是讓先前的 Token 失效,從而保證信息安全(至少我是沒查到相關解決方法,如果有大神解決過該問題,還請指點),只能使用一些其他黑科技擠掉 Token(如 Redis)。
現在,注冊、登錄功能都有了,接下來應該完善一個服務端應有的其他公共功能。
下一篇將介紹攔截器、異常處理以及日志的收集。