Skip to content

扩展完善

swagger

bash
pnpm add koa-swagger-decorator@next reflect-metadata

TIP

注意: 带next标记的版本是v2版本,完善了v1版本中的参数校验功能不足的问题。返回v1版本

新建src/controllers/general.ctrl.ts,同时写入session的示例代码

ts
import { IRouterContext } from 'koa-router'
import { routeConfig, z } from 'koa-swagger-decorator'
import { Redis } from '../utils'
export default class GeneralController {
  @routeConfig({
    method: 'get',
    path: '/',
    summary: '欢迎页',
    tags: ['General'],
    request: {
      query: z.object({
        name: z.string().nullable().optional(),
      }),
    },
  })
  async hello(ctx: IRouterContext) {
    // 提取cookies中的session id
    const sid = ctx.cookies.get(process.env.COOKIE_KEY ?? 'koa.sid')
    console.log('sid', sid)
    // session prefix 拼接sid得到key
    const session_key = `${process.env.SESSION_PREFIX ?? 'koa:sess:'}${sid}`
    console.log('session_key', session_key)
    const data = await Redis.get(session_key)
    console.log('data', data)
    ctx.session.name = ctx.request.query.name
    if (ctx.session.viewCount === null || ctx.session.viewCount === undefined) {
      ctx.session.viewCount = 1
    } else {
      ctx.session.viewCount++
    }
    ctx.body = `Hello ${ctx.session.name}, you check this ${ctx.session.viewCount} times`
  }
}
export const generalController = new GeneralController()

新建src/routes/unprotected.ts,用来设置不需要鉴权的路由

ts
import Router from 'koa-router'
import { generalController } from '../controllers/general.ctrl'

const unprotectedRouter = new Router()
unprotectedRouter.get('/', generalController.hello)

export { unprotectedRouter }

新建src/routes/protected.ts,用来设置需要鉴权的路由

ts
import { SwaggerRouter, registry } from 'koa-swagger-decorator'

const protectedRouter = new SwaggerRouter(
  {
    spec: {
      info: {
        title: 'koa-starter',
        description: 'API Doc',
        version: '1.0.0',
      },
    },
  },
  { prefix: '/api' }
)
// 用来指定token存放的位置和key名
registry.registerComponent('securitySchemes', process.env.API_KEY ?? 'authorization', {
  type: 'apiKey',
  name: process.env.API_KEY ?? 'authorization',
  in: 'header',
})
// 开发环境才挂载swagger
if (process.env.NODE_ENV === 'development') {
  protectedRouter.swagger()
}

export { protectedRouter }

编辑src/routes/index.tssrc/app.tssrc/index.ts

ts
export * from './unprotected'
export * from './protected'
ts
import { unprotectedRouter, protectedRouter } from './routes'
// ...
app.use(helmet()) 
app
  .use(
    helmet.contentSecurityPolicy({
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", "'unsafe-inline'", 'unpkg.com'], 
      },
    })
  )
  .use(cors())
  .use(bodyParser())
  .use(koaStatic(path.resolve(__dirname, '../public')))

app
  .use(catchError) // 注意一定要放在路由的前面加载
  .use(unprotectedRouter.routes()) 
  .use(unprotectedRouter.allowedMethods()) 
  .use(protectedRouter.routes()) 
  .use(protectedRouter.allowedMethods()) 
// ...
ts
import './env'
import 'reflect-metadata'
import app from './app'
import { logger } from './utils'
const PORT = process.env.APP_PORT ?? 3000
app.listen(PORT, () => {
  logger.info(`
------------
Server Started!
App is running in ${app.env} mode
Logging initialized at ${process.env.LOG_LEVEL ?? 'debug'} level

Http: http://localhost:${PORT}

API Docs: http://localhost:${PORT}/api/swagger-html
API Spec: http://localhost:${PORT}/api/swagger-json
------------
  `)
})

参数校验

新建src/validators/auth.ts,用来编写接口的参数校验规则

ts
import { z } from 'koa-swagger-decorator'

const signUpReq = z.object({
  username: z
    .string({ required_error: '用户名不能为空' })
    .trim()
    .min(4, '用户名长度不能少于4位')
    .max(20, '用户名长度最多20位'),
  password: z.string({ required_error: '密码不能为空' }).min(6, '密码长度不能少于6位'),
  email: z.string({ required_error: '邮箱不能为空' }).trim().email('邮箱格式不正确'),
})

const signInReq = z.object({
  username: z
    .string({ required_error: '用户名不能为空' })
    .trim()
    .min(4, '用户名长度不能少于4位')
    .max(20, '用户名长度最多20位'),
  password: z.string({ required_error: '密码不能为空' }).min(6, '密码长度不能少于6位'),
})

const tokenReq = z.object({
  token: z.string({ required_error: 'token不能为空' }).trim(),
})

export { signUpReq, signInReq, tokenReq }
export type ISignUpReq = z.infer<typeof signUpReq>
export type ISignInReq = z.infer<typeof signInReq>
export type ITokenReq = z.infer<typeof tokenReq>

JWT

bash
pnpm add jsonwebtoken bcryptjs koa-unless
pnpm add @types/jsonwebtoken @types/bcryptjs -D

编辑src/utils/utils.ts,添加生成token的方法

ts
import jwt from 'jsonwebtoken'
// ...
export function genToken(
  payload: any,
  secretType: 'ACCESS' | 'REFRESH' = 'ACCESS',
  expiresIn: string | number | null = process.env.JWT_EXPIRED ?? '30s'
) {
  const secret =
    secretType === 'ACCESS'
      ? process.env.ACCESS_TOKEN_SECRET ?? 'secret'
      : process.env.REFRESH_TOKEN_SECRET ?? 'secret'
  if (expiresIn === null || expiresIn === '') {
    return jwt.sign(payload, secret)
  }
  return jwt.sign(payload, secret, { expiresIn })
}
ts
export { default as Utils, genToken } from './utils'

新建src/controllers/auth.ctrl.ts,用来写模拟的登录接口

TIP

因为目前还没接入数据库,所以先用模拟的数据来测试

ts
import { IRouterContext } from 'koa-router'
import { routeConfig, body, ParsedArgs } from 'koa-swagger-decorator'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { genToken, Redis, Success, HttpException } from '../utils'
import { signUpReq, signInReq, tokenReq, ISignUpReq, ISignInReq, ITokenReq } from '../validators'

export default class AuthController {
  // 模拟数据
  readonly username = 'admin'
  // 123456
  readonly password = '$2a$10$D46VTSW0Mpe6P96Sa1w8tebfeYfZf1s.97Dz84XFfpcUvjtSCvLMO'
  @routeConfig({
    method: 'post',
    path: '/signup',
    summary: '注册接口',
    tags: ['Auth'],
  })
  @body(signUpReq)
  async signUp(ctx: IRouterContext, args: ParsedArgs<ISignUpReq>) {
    ctx.body = 'signup'
  }

  @routeConfig({
    method: 'post',
    path: '/signin',
    summary: '登录接口',
    tags: ['Auth'],
  })
  @body(signInReq)
  async signIn(ctx: IRouterContext, args: ParsedArgs<ISignInReq>) {
    // 1.检查用户是否存在
    if (args.body.username !== this.username) {
      throw new HttpException('not_found', { msg: '用户不存在' })
    }
    // 2.校验用户密码
    if (!bcrypt.compareSync(args.body.password, this.password)) {
      throw new HttpException('auth_denied', { msg: '密码错误' })
    }
    // 3.生成token
    const accessToken = genToken({ username: this.username })
    const refreshToken = genToken({ username: this.username }, 'REFRESH', '1d')
    // 4.拿到redis中的token
    const refreshTokens = JSON.parse(await Redis.get(`${this.username}:token`)) ?? []
    // 5.将刷新token保存到redis中
    refreshTokens.push(refreshToken)
    await Redis.set(`${this.username}:token`, JSON.stringify(refreshTokens), 24 * 60 * 60)
    throw new Success({ msg: '登录成功', data: { accessToken, refreshToken } })
  }

  @routeConfig({
    method: 'put',
    path: '/token',
    summary: '刷新token',
    tags: ['Auth'],
  })
  @body(tokenReq)
  async token(ctx: IRouterContext, args: ParsedArgs<ITokenReq>) {
    // 1.先检查前端是否有提交token
    if (!args.body.token) {
      throw new HttpException('unauthorized')
    }
    // 2.解析token中的用户信息
    let user: any
    jwt.verify(args.body.token, process.env.REFRESH_TOKEN_SECRET ?? 'secret', (err, decode) => {
      if (err) {
        throw new HttpException('forbidden', { msg: '无效令牌,请重新登录' })
      }
      user = decode
    })
    // 3.拿到缓存中的token
    let refreshTokens: string[] = JSON.parse(await Redis.get(`${this.username}:token`)) ?? []
    // 4.再检查此用户在redis中是否有此token
    if (!refreshTokens.includes(args.body.token)) {
      throw new HttpException('forbidden', { msg: '无效令牌,请重新登录' })
    }
    // 5.生成新的token
    const { iat, exp, ...rest } = user
    const accessToken = genToken(rest)
    const refreshToken = genToken(rest, 'REFRESH', '1d')
    // 6.将新token保存到redis中
    refreshTokens = refreshTokens
      .filter((token) => token !== args.body.token)
      .concat([refreshToken])
    await Redis.set(`${rest.username}:token`, JSON.stringify(refreshTokens), 24 * 60 * 60)
    throw new Success({ msg: '刷新token成功', data: { accessToken, refreshToken } })
  }

  @routeConfig({
    method: 'delete',
    path: '/logout',
    summary: '退出',
    tags: ['Auth'],
    security: [{ [process.env.API_KEY ?? 'authorization']: [] }],
  })
  @body(tokenReq)
  async logout(ctx: IRouterContext, args: ParsedArgs<ITokenReq>) {
    // 1.先检查前端是否有提交token
    if (!args.body.token) {
      throw new HttpException('unauthorized')
    }
    // 2.解析token中的用户信息
    let user: any
    jwt.verify(args.body.token, process.env.REFRESH_TOKEN_SECRET ?? 'secret', (err, decode) => {
      if (err) {
        throw new HttpException('forbidden', { msg: '无效令牌,请重新登录' })
      }
      user = decode
    })
    // 3.拿到缓存中的token
    let refreshTokens: string[] = JSON.parse(await Redis.get(`${this.username}:token`)) ?? []
    // 4.再检查此用户在redis中是否有此token
    if (!refreshTokens.includes(args.body.token)) {
      throw new HttpException('forbidden', { msg: '无效令牌,请重新登录' })
    }
    // 5.移除redis中保存的此客户端token
    refreshTokens = refreshTokens.filter((token) => token !== args.body.token)
    await Redis.set(`${user.username}:token`, JSON.stringify(refreshTokens), 24 * 60 * 60)
    throw new Success({ status: 204, msg: '退出成功' })
  }
}
export const authController = new AuthController()

编辑src/routes/protected.ts,应用Auth路由模块

ts
import AuthController from '../controllers/auth.ctrl'
// ...
protectedRouter.applyRoute(AuthController) 

新建src/middlewares/auth.ts,用于校验token

ts
import { Context, Next } from 'koa'
import jwt from 'jsonwebtoken'
import { HttpException } from '../utils'
const unless = require('koa-unless')

export default function () {
  const verifyToken = async (ctx: Context, next: Next) => {
    const authzHeader = ctx.request.header.authorization
    const accessToken = authzHeader && authzHeader.split(' ')[1]
    if (!accessToken) {
      throw new HttpException('unauthorized')
    } else {
      jwt.verify(accessToken, process.env.ACCESS_TOKEN_SECRET ?? 'secret', (err, decode) => {
        if (err) {
          if (err.name === 'TokenExpiredError') {
            throw new HttpException('forbidden', { msg: '令牌过期' })
          } else if (err.name === 'JsonWebTokenError') {
            throw new HttpException('forbidden', { msg: '无效令牌' })
          }
        }
        ctx.state.user = decode
      })
      return next()
    }
  }
  verifyToken.unless = unless
  return verifyToken
}
ts
export { default as verifyToken } from './auth'

编辑src/app.ts,应用Auth中间件

ts
import { verifyToken, catchError } from './middlewares'
// ...
app
  .use(catchError) // 注意一定要放在路由的前面加载
  .use(unprotectedRouter.routes())
  .use(unprotectedRouter.allowedMethods())
  .use(
    verifyToken().unless({
      path: [
        /^\/public/,
        /^\/favicon.ico/,
        /^(?!\/api)/,
        /^\/api\/swagger-/,
        /^\/api\/signup/,
        /^\/api\/signin/,
        /^\/api\/token/,
      ],
    })
  )
  .use(protectedRouter.routes())
  .use(protectedRouter.allowedMethods())

编辑src/middlewares/error_handler.ts,适配swagger插件内置的参数校验

ts
import { BaseContext, Next } from 'koa'
import { z } from 'koa-swagger-decorator'
// ...

/** @description 错误处理中间件 */
export default async (ctx: BaseContext, next: Next) => {
  try {
    await next().catch((error) => {
      if (error instanceof z.ZodError) {
        throw new HttpException('parameters', {
          msg: error.issues.map((issue) => issue.message).join(';'),
        })
      }
      throw error
    })
  } catch (error: any) {
    // ...
  }
}

🎉 到这里,扩展部分就结束了,数据库的集成请看其他篇章

MIT License