Browse Source

为注册功能添加验证码机制和安全增强

- 新增 `svg-captcha` 依赖包用于生成图形验证码
- 在 `PageController.js` 中实现验证码生成接口 `/captcha`,并添加5分钟过期时间控制
- 修改注册逻辑,增加验证码校验、随机数防重复提交等安全措施
- 优化注册页面模板,添加验证码输入框和隐藏的随机数字段
- 简化日志配置,移除部分未使用的日志记录器
- 修复示例任务的引号格式问题,保持代码风格统一
- 新增 Toast 中间件,提供统一的提示消息设置方法
- 优化错误处理中间件,将错误信息重定向到当前页面并显示
route
谢亚昕 1 week ago
parent
commit
43d2f4a765
  1. BIN
      bun.lockb
  2. 1
      package.json
  3. 62
      src/controllers/Page/PageController.js
  4. 12
      src/jobs/exampleJob.js
  5. 88
      src/logger.js
  6. 10
      src/main.js
  7. 4
      src/middlewares/ResponseTime/index.js
  8. 14
      src/middlewares/Toast/index.js
  9. 5
      src/middlewares/errorHandler/index.js
  10. 2
      src/middlewares/install.js
  11. 5
      src/views/page/register/index.pug

BIN
bun.lockb

Binary file not shown.

1
package.json

@ -34,6 +34,7 @@
"path-to-regexp": "^8.2.0",
"pug": "^3.0.3",
"sqlite3": "^5.1.7",
"svg-captcha": "^1.4.0",
"vite-plugin-static-copy": "^3.1.0"
},
"_moduleAliases": {

62
src/controllers/Page/PageController.js

@ -1,6 +1,8 @@
import Router from "utils/router.js"
import UserService from "services/UserService.js"
import SiteConfigService from "services/SiteConfigService.js"
import svgCaptcha from "svg-captcha"
import CommonError from "@/utils/error/CommonError"
class PageController {
constructor() {
@ -33,6 +35,26 @@ class PageController {
ctx.body = { success: true, message: "登录成功" }
}
async captchaGet(ctx) {
var captcha = svgCaptcha.create({
size: 4, // 个数
width: 100, // 宽
height: 30, // 高
fontSize: 38, // 字体大小
color: true, // 字体颜色是否多变
noise: 2, // 干扰线几条
})
// 记录验证码信息(文本+过期时间)
// 这里设置5分钟后过期
const expireTime = Date.now() + 5 * 60 * 1000
ctx.session.captcha = {
text: captcha.text.toLowerCase(), // 转小写,忽略大小写验证
expireTime: expireTime,
}
ctx.type = "image/svg+xml"
ctx.body = captcha.data
}
async registerGet(ctx) {
if (ctx.state.user) {
ctx.cookies.set("toast", JSON.stringify({ type: "error", message: encodeURIComponent("用户已登录") }), {
@ -42,15 +64,48 @@ class PageController {
})
return ctx.redirect("/?msg=用户已登录")
}
return await ctx.render("page/register/index", { site_title: "注册" })
// TODO 多个
ctx.session.registerRandomStr = Math.ceil(Math.random() * 100000000000000)
return await ctx.render("page/register/index", { site_title: "注册", randomStr: ctx.session.registerRandomStr })
}
async registerPost(ctx) {
const { username, password } = ctx.request.body
const { username, password, code, randomStr } = ctx.request.body
if (!ctx.session.registerRandomStr) {
throw new CommonError("缺少随机数")
}
if (ctx.session.registerRandomStr + "" !== randomStr + "") {
throw new CommonError("随机数不匹配")
}
delete ctx.session.registerRandomStr
// 检查Session中是否存在验证码
if (!ctx.session.captcha) {
throw new CommonError("验证码不存在,请重新获取")
}
const { text, expireTime } = ctx.session.captcha
// 检查是否过期
if (Date.now() > expireTime) {
// 过期后清除Session中的验证码
delete ctx.session.captcha
throw new CommonError("验证码已过期,请重新获取")
}
if (!code) {
throw new CommonError("请输入验证码")
}
if (code.toLowerCase() !== text) {
throw new CommonError("验证码错误")
}
delete ctx.session.captcha
// try {
await this.userService.register({ username, password, role: "user" })
// ctx.cookies.set("toast", JSON.stringify({ type: "success", message: "注册成功" }), {
// maxAge: 1,
// httpOnly: false,
@ -97,6 +152,7 @@ class PageController {
router.get("/about", controller.pageGet("page/about/index"), { auth: false })
router.get("/login", controller.loginGet.bind(controller), { auth: "try" })
router.post("/login", controller.loginPost.bind(controller), { auth: false })
router.get("/captcha", controller.captchaGet.bind(controller), { auth: false })
router.get("/register", controller.registerGet.bind(controller), { auth: "try" })
router.post("/register", controller.registerPost.bind(controller), { auth: false })
router.post("/logout", controller.logout.bind(controller), { auth: true })

12
src/jobs/exampleJob.js

@ -1,11 +1,11 @@
import { jobLogger } from "@/logger";
import { jobLogger } from "@/logger"
export default {
id: 'example',
cronTime: '*/10 * * * * *', // 每10秒执行一次
id: "example",
cronTime: "*/10 * * * * *", // 每10秒执行一次
task: () => {
jobLogger.info('Example Job 执行了');
jobLogger.info("Example Job 执行了")
},
options: {},
autoStart: false
};
autoStart: false,
}

88
src/logger.js

@ -2,18 +2,18 @@ import log4js from "log4js"
log4js.configure({
appenders: {
debug: {
type: "file",
filename: "logs/debug.log",
maxLogSize: 102400,
pattern: "-yyyy-MM-dd.log",
alwaysIncludePattern: true,
backups: 3,
layout: {
type: 'pattern',
pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m',
},
},
// debug: {
// type: "file",
// filename: "logs/debug.log",
// maxLogSize: 102400,
// pattern: "-yyyy-MM-dd.log",
// alwaysIncludePattern: true,
// backups: 3,
// layout: {
// type: 'pattern',
// pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m',
// },
// },
all: {
type: "file",
filename: "logs/all.log",
@ -26,18 +26,18 @@ log4js.configure({
pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m',
},
},
error: {
type: "file",
filename: "logs/error.log",
maxLogSize: 102400,
pattern: "-yyyy-MM-dd.log",
alwaysIncludePattern: true,
backups: 3,
layout: {
type: 'pattern',
pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m',
},
},
// error: {
// type: "file",
// filename: "logs/error.log",
// maxLogSize: 102400,
// pattern: "-yyyy-MM-dd.log",
// alwaysIncludePattern: true,
// backups: 3,
// layout: {
// type: 'pattern',
// pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m',
// },
// },
jobs: {
type: "file",
filename: "logs/jobs.log",
@ -50,18 +50,18 @@ log4js.configure({
pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m',
},
},
site: {
type: "file",
filename: "logs/site.log",
maxLogSize: 102400,
pattern: "-yyyy-MM-dd.log",
alwaysIncludePattern: true,
backups: 3,
layout: {
type: 'pattern',
pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m',
},
},
// site: {
// type: "file",
// filename: "logs/site.log",
// maxLogSize: 102400,
// pattern: "-yyyy-MM-dd.log",
// alwaysIncludePattern: true,
// backups: 3,
// layout: {
// type: 'pattern',
// pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m',
// },
// },
console: {
type: "console",
layout: {
@ -72,18 +72,18 @@ log4js.configure({
},
categories: {
jobs: { appenders: ["console", "jobs"], level: "ALL" },
site: { appenders: ["site"], level: "ALL" },
console: { appenders: ["console"], level: "ALL" },
error: { appenders: ["console", "error"], level: "error" },
// site: { appenders: ["site"], level: "ALL" },
// console: { appenders: ["console"], level: "ALL" },
// error: { appenders: ["console", "error"], level: "error" },
default: { appenders: ["console", "all"], level: "ALL" },
debug: { appenders: ["debug"], level: "debug" },
// debug: { appenders: ["debug"], level: "debug" },
},
})
// 导出常用logger实例,便于直接引用
export const logger = log4js.getLogger();
export const debugLogger = log4js.getLogger('debug');
// export const debugLogger = log4js.getLogger('debug');
export const jobLogger = log4js.getLogger('jobs');
export const errorLogger = log4js.getLogger('error');
export const siteLogger = log4js.getLogger('site');
export const consoleLogger = log4js.getLogger('console');
// export const errorLogger = log4js.getLogger('error');
// export const siteLogger = log4js.getLogger('site');
// export const consoleLogger = log4js.getLogger('console');

10
src/main.js

@ -1,5 +1,5 @@
// 日志、全局插件、定时任务等基础设施
import { consoleLogger } from "./logger.js"
import { logger } from "./logger.js"
import "./jobs/index.js"
// 第三方依赖
@ -31,10 +31,10 @@ const server = app.listen(PORT, () => {
return "localhost"
}
const localIP = getLocalIP()
consoleLogger.trace(`===================【服务器地址】====================`)
consoleLogger.trace(` http://localhost:${port} (本地地址) `)
consoleLogger.trace(` http://${localIP}:${port} (本地地址) `)
consoleLogger.trace(`===================【服务器地址】====================`)
logger.trace(`===================【服务器地址】====================`)
logger.trace(` http://localhost:${port} (本地地址) `)
logger.trace(` http://${localIP}:${port} (本地地址) `)
logger.trace(`===================【服务器地址】====================`)
})
export default app

4
src/middlewares/ResponseTime/index.js

@ -1,4 +1,4 @@
import { siteLogger, logger } from "@/logger"
import { logger } from "@/logger"
// 静态资源扩展名列表
const staticExts = [".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".map", ".woff", ".woff2", ".ttf", ".eot"]
@ -23,7 +23,7 @@ export default async (ctx, next) => {
const ms = Date.now() - start
ctx.set("X-Response-Time", `${ms}ms`)
if (ms > 500) {
siteLogger.info(`${ctx.path} | ⏱️ ${ms}ms`)
logger.info(`${ctx.path} | ⏱️ ${ms}ms`)
}
return
}

14
src/middlewares/Toast/index.js

@ -0,0 +1,14 @@
export default function ToastMiddlewares() {
return function toast(ctx, next) {
if (ctx.toast) return next()
// error success info
ctx.toast = function (type, message) {
ctx.cookies.set("toast", JSON.stringify({ type: type, message: encodeURIComponent(message) }), {
maxAge: 1,
httpOnly: false,
path: "/",
})
}
return next()
}
}

5
src/middlewares/errorHandler/index.js

@ -1,3 +1,4 @@
import { logger } from "@/logger"
import CommonError from "utils/error/CommonError"
// src/plugins/errorHandler.js
// 错误处理中间件插件
@ -32,7 +33,7 @@ export default function errorHandler() {
await formatError(ctx, 404, "Resource not found")
}
} catch (err) {
console.error(err);
logger.error(err)
const isDev = process.env.NODE_ENV === "development"
if (isDev && err.stack) {
console.error(err.stack)
@ -43,7 +44,7 @@ export default function errorHandler() {
httpOnly: false,
path: "/",
})
ctx.redirect(ctx.path)
ctx.redirect(ctx.path+"?msg="+err.message)
return
}
await formatError(ctx, err.statusCode || 500, err.message || err || "Internal server error", isDev ? err.stack : undefined)

2
src/middlewares/install.js

@ -8,12 +8,14 @@ import { auth } from "./Auth"
import bodyParser from "koa-bodyparser"
import Views from "./Views"
import Session from "./Session"
import Toast from "./Toast"
import { autoRegisterControllers } from "@/utils/ForRegister.js"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const publicPath = resolve(__dirname, "../../public")
export default app => {
app.use(Toast())
app.use(ErrorHandler())
app.use(ResponseTime)
app.use(Session(app));

5
src/views/page/register/index.pug

@ -77,6 +77,7 @@ block pageContent
.register-container
.register-title 注册账号
form(action="/register" method="post")
input(type="text" name="randomStr" value=randomStr style="display:none")
.form-group
label(for="username") 用户名
input(type="text" id="username" name="username" required placeholder="请输入用户名")
@ -86,5 +87,9 @@ block pageContent
.form-group
label(for="confirm_password") 确认密码
input(type="password" id="confirm_password" name="confirm_password" required placeholder="请再次输入密码")
img(src="/captcha", alt="")
.form-group
label(for="code") 验证码
input(type="text" id="code" name="code" required placeholder="请输入验证码")
button.register-btn(type="submit") 注册
a.login-link(href="/login") 已有账号?去登录
Loading…
Cancel
Save