还在好奇 node 后端登录流程怎么做?进来聊聊吧

428 阅读8分钟

前言

哈喽大家好!我是 嘟老板。今天我们来梳理下 zimu-admin 的登录全流程,看看 nodejs 如何实现用户会话控制。

阅读本文您将收获:

  1. 了解登录流程设计及实现。
  2. 了解授权验证逻辑及实现。
  3. 后续优化点。

流程设计

以下是登录流程简图:

image.png

客户端首次向服务端发送请求时,由于没有登录,服务端在验证后会响应 401,提示没有授权;

前端接收到 401 状态,发现需要先登录才行,就重定向到登录页,要求用户输入用户名、密码进行登录;

服务端接收到客户端的登录请求,会先验证用户名和密码是否正确,验证通过后,则会生成加密的 token,返回给客户端,后续服务端会通过该 token 进行会话验证。

客户端接收到 token 后,需要将其缓存到浏览器(localStorage、sessionStorage、cookie 都可,按实际需求而定),后续每次向服务端发送请求时,都要在请求头 Authorization 标头中携带该 token。

服务端后续收到客户端的请求时,发现 Authorization 标头携带了 token,很好,然后对该 token 进行有效性验证,若验证通过,则可以执行后续的业务逻辑。

实现过程

相关技术

zimu-admin 服务端基于以下技术栈构建:

用户模块

要做登录,自然离不开用户,我们先来定义下用户模块。

实体类 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,查询数据库中的用户数据,返回用户详细信息

登录服务

登录流程图如下:

image.png

登录请求处理步骤如下:

  1. 服务端接收到客户端发送的登录请求,验证用户是否存在,若不存在,则报错“用户不存在”;
  2. 若用户存在,则比较客户端传递的密码和数据库中存储的用户密码是否匹配,若不匹配,则报错“密码不正确”;
  3. 若密码匹配,则生成一串 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 是授权验证的依据,那到底需要验证哪些呢?以下是大致内容:

  1. 客户端请求是否携带 token。
  2. token 是否有效,即 token 是否通过系统逻辑生成、是否在有效期内等。
  3. 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(未授权),返回给客户端。

image.png

后续优化

无感刷新 token

按照现在的逻辑,若当前 token 过期后,则会要求用户输入用户名、密码重新登录。事实上,为保证 token 安全,有效期一般不会设置太长时间,这可能导致用户频繁登录。

无感刷新 token 的设计恰恰可以解决这个问题,若 token 过期,我们可以从应用层面做到自动刷新,对用户来说无感。

token 黑名单

若 token 不是过期失效,而是用户主动注销,则需要主动失效 token。

可以通过前端清空浏览器 token 缓存方式实现,然而前端控制安全性低,难免有心之人拿到 token 存起来,通过其他手段向服务端发送请求。

可以将用户注销但仍然可用的 token 加入到黑名单中,从服务端控制,提升安全性。

登录错误次数限制

目前登录时用户可以无限次的输入、试错,存在一定的安全隐患,攻击者可以通过不断尝试暴力破解。

限定错误次数,比如 5 次,错误 5 次后锁定账号,不允许持续性试错,可以一定程度避免系统被暴力攻破。

结语

本文重点介绍了 zimu-admin 中登录流程及授权验证的整体实现,核心逻辑多是按照项目当前情况设计,后续会持续迭代优化。望本文对您有所帮助。相关代码已上传至 GitHub,欢迎 star。

如您对文章内容有任何疑问或想深入讨论,欢迎评论区留下您的问题和见解。

技术简而不凡,创新生生不息。我是 嘟老板,咱们下期再会。


往期推荐