前言
哈喽大家好!我是 嘟老板。今天我们来梳理下 zimu-admin 的登录全流程,看看 nodejs 如何实现用户会话控制。
阅读本文您将收获:
- 了解登录流程设计及实现。
- 了解授权验证逻辑及实现。
- 后续优化点。
流程设计
以下是登录流程简图:
客户端首次向服务端发送请求时,由于没有登录,服务端在验证后会响应 401,提示没有授权;
前端接收到 401 状态,发现需要先登录才行,就重定向到登录页,要求用户输入用户名、密码进行登录;
服务端接收到客户端的登录请求,会先验证用户名和密码是否正确,验证通过后,则会生成加密的 token,返回给客户端,后续服务端会通过该 token 进行会话验证。
客户端接收到 token 后,需要将其缓存到浏览器(localStorage、sessionStorage、cookie 都可,按实际需求而定),后续每次向服务端发送请求时,都要在请求头 Authorization 标头中携带该 token。
服务端后续收到客户端的请求时,发现 Authorization 标头携带了 token,很好,然后对该 token 进行有效性验证,若验证通过,则可以执行后续的业务逻辑。
实现过程
相关技术
zimu-admin 服务端基于以下技术栈构建:
- express :node 框架。
- typescript 类型系统。
- routing-controllers :加速构建 controller 层。
- mysql :数据持久化。
- typeorm :ORM 框架,处理数据库相关操作。
用户模块
要做登录,自然离不开用户,我们先来定义下用户模块。
实体类 UserEntity
根据系统实际需求,定义 User 实体类及用户属性:
/**
* 用户实体
*/
import { Column, Entity, PrimaryColumn, PrimaryGeneratedColumn } from 'typeorm'
import { encryptPassword } from '../utils/pwd'
@Entity('user')
export class User {
// rowId
@PrimaryGeneratedColumn()
id!: number
// 用户账号/工号
@PrimaryColumn({ name: 'user_name' })
username!: string
// 用户密码
@Column({
transformer: {
to(entityValue) {
return encryptPassword(entityValue)
},
from(dbValue) {
return dbValue
}
}
})
password!: string
// 姓名
@Column()
name!: string
// 性别
@Column()
sex!: string
// 电话
@Column()
tel!: string
// 电子邮箱
@Column()
email!: string
// 住址
@Column()
address!: string
// 创建人
@Column({ name: 'created_by' })
createdBy!: string
// 创建时间
@Column({ type: 'date', name: 'created_at' })
createdAt!: string
// 更新人
@Column({ name: 'updated_by' })
updatedBy!: string
// 更新时间
@Column({ type: 'date', name: 'updated_at' })
updatedAt!: string
}
UserEntity 类属性中,除了 id 是数据库数据主键外,其他都是用户相关信息,还算全面,包括如 账号、密码、姓名、性别、电话 等,可根据项目实际场景适当增减。
服务类 UserService
UserService 主要用来定义用户服务的具体业务逻辑,我们在登录时需要根据用户账号获取用户信息,先定义个 queryUserByUsername 函数。
/**
* 用户服务类
*/
import db from '../data-source'
import { User } from '../entities/user.entity'
import { BaseService } from './base.service'
export class UserService extends BaseService {
userRepository = db.getRepository(User)
// 根据用户名查询用户详情
async queryByUsername(username: string) {
return await this.userRepository.findOneBy({
username
})
}
}
queryUserByUsername 函数通过传递的 username,查询数据库中的用户数据,返回用户详细信息
登录服务
登录流程图如下:
登录请求处理步骤如下:
- 服务端接收到客户端发送的登录请求,验证用户是否存在,若不存在,则报错“用户不存在”;
- 若用户存在,则比较客户端传递的密码和数据库中存储的用户密码是否匹配,若不匹配,则报错“密码不正确”;
- 若密码匹配,则生成一串 token 字符串,返回给客户端,作为后续鉴权的依据。
LoginController
我们在 LoginController 中定义 login 接口。
import { Body, Controller, Post, QueryParam } from 'routing-controllers'
import jwt from 'jsonwebtoken'
import { comparePassword } from '../utils/pwd'
import { error } from '../utils/r'
import { JWT_SECRET } from '../constants/secrets'
import { UserService } from '../services/user.service'
/**
* 登录 controller
*/
@Controller()
export class LoginController {
// 用户服务
userService = new UserService()
/**
* 登录接口
* @param body 请求体
* @param href 登录成功后,浏览器重定向的地址
*/
@Post('/login')
async login(@Body() body: any) {
const { username, password } = body
if (!username || !password) {
return error('用户名和密码不允许为空')
}
// 校验用户是否存在
const user = await this.userService.queryByUsername(username)
if (!user) {
return error('用户不存在')
}
// 验证用户名和密码是否正确
const isPwdSame = await comparePassword(password, user.password)
if (isPwdSame) {
// 生成 jwt
const token = jwt.sign({ username }, JWT_SECRET, {
expiresIn: 60 * 60
})
return success({ token })
} else {
return error('密码错误,请检查后重试')
}
}
}
用户密码使用 bcrypt 加密,匹配密码时,需要使用 bcrypt 提供的 compare api,代码中 comparePassword 函数的内部逻辑就是如此,只是做了简单封装。
token 使用 jsonwebtoken 生成,通过 秘钥(常量 JWT_SECRET)加密用户信息,后续可通过解析 token 获取用户信息。
授权验证
于系统来说,完成登录不是终点,登录后的授权验证同样重要,能不能把一切妖魔鬼怪隔离在系统之外,就看验证逻辑的缜密性。
前文提到,token 是授权验证的依据,那到底需要验证哪些呢?以下是大致内容:
- 客户端请求是否携带 token。
- token 是否有效,即 token 是否通过系统逻辑生成、是否在有效期内等。
- token 中携带的用户信息是否真实,即数据库中是否存在真实的用户数据。
我们将验证逻辑封装为 authChecker 工具函数,以实现统一验证。
实现 authChecker 函数
/**
* 接口授权验证逻辑
*/
import jwt from 'jsonwebtoken'
import { JWT_SECRET } from '../constants/secrets'
import { UserService } from '../services/user.service'
import type { Action } from 'routing-controllers'
export default function authChecker(action: Action, roles: string[]) {
// 请求头 Authorization 获取 token
const token = action.request.headers.authorization?.split(' ')[1]
// 若 token 不存在,不通过
if (!token) return false
// jwt 验证 token 是否有效
jwt.verify(token, JWT_SECRET, (err: any, payload: any) => {
// 若 error 存在,不通过
if (err) {
return false
}
// 验证用户名是否有效
const username = payload.username
const user = new UserService().queryByUsername(username)
// 若不存在对应用户,不通过
if (!user) return false
})
return true
}
核心逻辑分为以下几个部分:
- 从请求头 Authorization 标头中获取 token,若不存在,则未授权;
- jsonwebtoken 验证 token 是否有效,若报错,则未授权;
- 取 token 中的 username 信息,匹配数据库用户,若不存在,则未授权;
- 若全部检查通过,则已授权。
应用 authChecker 函数
使用 routing-controllers 应用 authChecker。
若不了解 routing-controllers,可查看 《Express 项目集成 routing-controllers,快速搞定 controller 层》
在 useExpressServer 第二个参数中添加 authorizationChecker 配置项,即完成 authChecker 配置:
useExpressServer(app, {
// ...
authorizationChecker: authChecker
})
后续在需要授权验证的请求上,添加 @Authorized 装饰器即可。
比如菜单模块 MenuController:
/**
* 菜单 controller
*/
import { Authorized, Controller, Get } from 'routing-controllers'
import { MenuService } from '../services/menu.service'
import { INTERFACE_PATH } from '../constants/path'
@Controller('/menu')
export class MenuController {
menuService = new MenuService()
@Get('/list')
@Authorized()
async queryList() {
const [rows, count] = await this.menuService.queryList()
return {
rows,
count
}
}
}
客户端请求 @Authorized() 装饰的接口时,会先走 authChecker 授权验证,若 authChecker 返回 false,则验证未通过,会将响将应码设置为 401(未授权),返回给客户端。
后续优化
无感刷新 token
按照现在的逻辑,若当前 token 过期后,则会要求用户输入用户名、密码重新登录。事实上,为保证 token 安全,有效期一般不会设置太长时间,这可能导致用户频繁登录。
无感刷新 token 的设计恰恰可以解决这个问题,若 token 过期,我们可以从应用层面做到自动刷新,对用户来说无感。
token 黑名单
若 token 不是过期失效,而是用户主动注销,则需要主动失效 token。
可以通过前端清空浏览器 token 缓存方式实现,然而前端控制安全性低,难免有心之人拿到 token 存起来,通过其他手段向服务端发送请求。
可以将用户注销但仍然可用的 token 加入到黑名单中,从服务端控制,提升安全性。
登录错误次数限制
目前登录时用户可以无限次的输入、试错,存在一定的安全隐患,攻击者可以通过不断尝试暴力破解。
限定错误次数,比如 5 次,错误 5 次后锁定账号,不允许持续性试错,可以一定程度避免系统被暴力攻破。
结语
本文重点介绍了 zimu-admin 中登录流程及授权验证的整体实现,核心逻辑多是按照项目当前情况设计,后续会持续迭代优化。望本文对您有所帮助。相关代码已上传至 GitHub,欢迎 star。
如您对文章内容有任何疑问或想深入讨论,欢迎评论区留下您的问题和见解。
技术简而不凡,创新生生不息。我是 嘟老板,咱们下期再会。