Browse Source

feat(auth): add captcha functionality for registration and login

- Introduced captcha generation and validation in the registration and login processes.
- Created new database table `captcha_codes` to store captcha tokens and codes.
- Implemented functions to generate captcha codes and SVG images.
- Updated user registration and login logic to include captcha verification.
- Added new API endpoints for captcha generation and user authentication.
- Refactored authentication service to handle captcha-related operations.
- Updated package dependencies to include jsonwebtoken and its types.
auth
npmrun 4 weeks ago
parent
commit
a2af16fe81
  1. 3
      .env.example
  2. 5
      .vscode/settings.json
  3. 4
      AGENTS.md
  4. 199
      app/components/AuthForm.vue
  5. 89
      app/composables/useAuth.ts
  6. 14
      app/middleware/auth.global.ts
  7. 34
      app/pages/login.vue
  8. 32
      app/pages/register.vue
  9. 1106
      bun.lock
  10. 2
      package.json
  11. BIN
      packages/drizzle-pkg/db.sqlite
  12. 11
      packages/drizzle-pkg/lib/schema/auth.ts
  13. 19
      packages/drizzle-pkg/migrations/0001_naive_gabe_jones.sql
  14. 172
      packages/drizzle-pkg/migrations/meta/0001_snapshot.json
  15. 7
      packages/drizzle-pkg/migrations/meta/_journal.json
  16. 6
      server/api/auth/captcha.post.ts
  17. 36
      server/api/auth/login.post.ts
  18. 4
      server/api/auth/logout.post.ts
  19. 16
      server/api/auth/me.get.ts
  20. 36
      server/api/auth/register.post.ts
  21. 164
      server/service/auth/index.ts
  22. 11
      server/service/auth/legacy.ts
  23. 66
      server/utils/captcha.ts
  24. 41
      server/utils/jwt.ts

3
.env.example

@ -1,4 +1,5 @@
DATABASE_URL=file:./db.sqlite
STATIC_DIR=static
UPLOAD_SUBDIR=upload
NITRO_PORT=3399
NITRO_PORT=3399
JWT_SECRET=nuxt-app-super-secret-key-change-in-production-2026

5
.vscode/settings.json

@ -6,6 +6,9 @@
"strings": "on"
},
"tailwindCSS.classAttributes": ["class", "ui"],
"tailwindCSS.classFunctions": ["defineAppConfig"]
"tailwindCSS.classFunctions": [
"defineAppConfig"
],
"js/ts.tsdk.path": "node_modules/typescript/lib"
}

4
AGENTS.md

@ -1,6 +1,8 @@
## 必须
## 必须遵守
- 安装依赖时,必须写死依赖的版本,禁止任何升级的可能,如存在需要升级的场景,必须手动执行安装新版本命令
- 新建的mono包必须在根目录安装
- 使用nuxt时遇到不清楚的,必须先加载nuxt-remote查询文档再进行分析
## 设计哲学

199
app/components/AuthForm.vue

@ -0,0 +1,199 @@
<script setup lang="ts">
import { useAuth } from "~/composables/useAuth";
import type { CaptchaData } from "~/composables/useAuth";
const props = defineProps<{
mode: "login" | "register";
}>();
const emit = defineEmits<{
submit: [data: { username: string; password: string; captchaToken: string; captchaCode: string }];
}>();
const auth = useAuth();
const username = ref("");
const password = ref("");
const captchaCode = ref("");
const captcha = ref<CaptchaData | null>(null);
const captchaLoading = ref(false);
const errorMsg = ref("");
async function refreshCaptcha() {
captchaLoading.value = true;
try {
captcha.value = await auth.fetchCaptcha();
} finally {
captchaLoading.value = false;
}
}
function handleSubmit() {
errorMsg.value = "";
if (!username.value.trim()) {
errorMsg.value = "请输入用户名";
return;
}
if (!password.value) {
errorMsg.value = "请输入密码";
return;
}
if (props.mode === "register" && password.value.length < 8) {
errorMsg.value = "密码长度不能少于8位";
return;
}
if (!captchaCode.value.trim()) {
errorMsg.value = "请输入验证码";
return;
}
if (!captcha.value) {
errorMsg.value = "请先获取验证码";
return;
}
emit("submit", {
username: username.value.trim(),
password: password.value,
captchaToken: captcha.value.token,
captchaCode: captchaCode.value.trim(),
});
}
function setError(msg: string) {
errorMsg.value = msg;
}
defineExpose({ setError, refreshCaptcha });
onMounted(() => {
refreshCaptcha();
});
</script>
<template>
<div class="w-full max-w-[380px] mx-auto">
<!-- Header -->
<div class="text-center mb-10">
<h1 v-if="mode === 'login'" class="text-[34px] font-semibold tracking-[-0.374px] leading-[1.47]" style="font-family: 'SF Pro Text', system-ui, -apple-system, sans-serif; color: #1d1d1f;">
登录
</h1>
<h1 v-else class="text-[34px] font-semibold tracking-[-0.374px] leading-[1.47]" style="font-family: 'SF Pro Text', system-ui, -apple-system, sans-serif; color: #1d1d1f;">
创建账户
</h1>
<p class="mt-2 text-[17px] leading-[1.47] tracking-[-0.374px]" style="font-family: 'SF Pro Text', system-ui, -apple-system, sans-serif; color: #7a7a7a;">
{{ mode === 'login' ? '欢迎回来' : '加入我们' }}
</p>
</div>
<!-- Error -->
<div v-if="errorMsg" class="mb-5 px-4 py-3 rounded-[11px] text-[14px] leading-[1.43] tracking-[-0.224px]"
style="font-family: 'SF Pro Text', system-ui, -apple-system, sans-serif; background: #fff2f0; color: #cc3333; border: 1px solid #ffccc7;">
{{ errorMsg }}
</div>
<!-- Form -->
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- Username -->
<div>
<label class="block text-[14px] font-semibold leading-[1.29] tracking-[-0.224px] mb-1.5"
style="font-family: 'SF Pro Text', system-ui, -apple-system, sans-serif; color: #1d1d1f;">
用户名
</label>
<input
v-model="username"
type="text"
autocomplete="username"
class="w-full h-[44px] px-4 text-[17px] leading-[1.47] tracking-[-0.374px] rounded-[11px] outline-none transition-colors duration-150"
style="font-family: 'SF Pro Text', system-ui, -apple-system, sans-serif; background: #f5f5f7; color: #1d1d1f; border: 1px solid #e0e0e0;"
placeholder="请输入用户名"
/>
</div>
<!-- Password -->
<div>
<label class="block text-[14px] font-semibold leading-[1.29] tracking-[-0.224px] mb-1.5"
style="font-family: 'SF Pro Text', system-ui, -apple-system, sans-serif; color: #1d1d1f;">
密码
</label>
<input
v-model="password"
type="password"
autocomplete="current-password"
class="w-full h-[44px] px-4 text-[17px] leading-[1.47] tracking-[-0.374px] rounded-[11px] outline-none transition-colors duration-150"
style="font-family: 'SF Pro Text', system-ui, -apple-system, sans-serif; background: #f5f5f7; color: #1d1d1f; border: 1px solid #e0e0e0;"
placeholder="请输入密码"
/>
<p v-if="mode === 'register'" class="mt-1 text-[12px] leading-[1.0] tracking-[-0.12px]" style="font-family: 'SF Pro Text', system-ui, -apple-system, sans-serif; color: #7a7a7a;">
密码长度至少8位
</p>
</div>
<!-- Captcha -->
<div>
<label class="block text-[14px] font-semibold leading-[1.29] tracking-[-0.224px] mb-1.5"
style="font-family: 'SF Pro Text', system-ui, -apple-system, sans-serif; color: #1d1d1f;">
验证码
</label>
<div class="flex gap-3">
<input
v-model="captchaCode"
type="text"
maxlength="4"
autocomplete="off"
class="flex-1 h-[44px] px-4 text-[17px] leading-[1.47] tracking-[-0.374px] rounded-[11px] outline-none uppercase transition-colors duration-150"
style="font-family: 'SF Pro Text', system-ui, -apple-system, sans-serif; background: #f5f5f7; color: #1d1d1f; border: 1px solid #e0e0e0;"
placeholder="验证码"
/>
<button
type="button"
@click="refreshCaptcha"
:disabled="captchaLoading"
class="h-[44px] min-w-[120px] rounded-[11px] flex items-center justify-center overflow-hidden transition-opacity duration-150"
style="background: #fafafc; border: 1px solid #e0e0e0;"
:style="{ opacity: captchaLoading ? 0.5 : 1 }"
>
<img
v-if="captcha?.image"
:src="captcha.image"
alt="验证码"
class="h-full w-full object-contain"
/>
<span v-else class="text-[12px]" style="color: #7a7a7a;">加载中...</span>
</button>
</div>
</div>
<!-- Submit -->
<button
type="submit"
class="w-full h-[44px] rounded-full text-[18px] font-light leading-[1.0] tracking-[0] text-white transition-colors duration-150 mt-2"
style="font-family: 'SF Pro Text', system-ui, -apple-system, sans-serif; background: #0066cc;"
@mouseenter="(e) => { const t = e.currentTarget as HTMLElement; t.style.background = '#0071e3'; }"
@mouseleave="(e) => { const t = e.currentTarget as HTMLElement; t.style.background = '#0066cc'; }"
>
{{ mode === 'login' ? '登录' : '注册' }}
</button>
</form>
<!-- Footer link -->
<div class="mt-8 text-center">
<NuxtLink
v-if="mode === 'login'"
to="/register"
class="text-[17px] leading-[2.41] tracking-[0] transition-colors duration-150"
style="font-family: 'SF Pro Text', system-ui, -apple-system, sans-serif; color: #0066cc;"
>
还没有账户立即注册
</NuxtLink>
<NuxtLink
v-else
to="/login"
class="text-[17px] leading-[2.41] tracking-[0] transition-colors duration-150"
style="font-family: 'SF Pro Text', system-ui, -apple-system, sans-serif; color: #0066cc;"
>
已有账户前去登录
</NuxtLink>
</div>
</div>
</template>

89
app/composables/useAuth.ts

@ -0,0 +1,89 @@
interface User {
id: number;
username: string;
email: string | null;
nickname: string | null;
avatar: string | null;
role: string;
createdAt?: number;
}
interface CaptchaData {
token: string;
image: string;
}
interface ApiResponse<T> {
code: number;
message: string;
data: T | null;
}
export function useAuth() {
const user = useState<User | null>("auth-user", () => null);
const loading = ref(false);
async function loadUser() {
const res = await $fetch<ApiResponse<{ user: User }>>("/api/auth/me");
if (res.code === 0 && res.data?.user) {
user.value = res.data.user;
}
}
async function register(username: string, password: string, captchaToken: string, captchaCode: string) {
loading.value = true;
try {
const res = await $fetch<ApiResponse<{ user: User }>>("/api/auth/register", {
method: "POST",
body: { username, password, captchaToken, captchaCode },
});
if (res.code !== 0) {
return { success: false as const, message: res.message || "注册失败" };
}
await loadUser();
return { success: true as const };
} catch {
return { success: false as const, message: "网络错误,请重试" };
} finally {
loading.value = false;
}
}
async function login(username: string, password: string, captchaToken: string, captchaCode: string) {
loading.value = true;
try {
const res = await $fetch<ApiResponse<{ user: User }>>("/api/auth/login", {
method: "POST",
body: { username, password, captchaToken, captchaCode },
});
if (res.code !== 0) {
return { success: false as const, message: res.message || "登录失败" };
}
await loadUser();
return { success: true as const };
} catch {
return { success: false as const, message: "网络错误,请重试" };
} finally {
loading.value = false;
}
}
async function logout() {
await $fetch("/api/auth/logout", { method: "POST" });
user.value = null;
}
async function fetchCaptcha(): Promise<CaptchaData | null> {
const res = await $fetch<ApiResponse<CaptchaData>>("/api/auth/captcha", {
method: "POST",
});
if (res.code === 0 && res.data) {
return res.data;
}
return null;
}
const isLoggedIn = computed(() => !!user.value);
return { user, loading, isLoggedIn, loadUser, register, login, logout, fetchCaptcha };
}

14
app/middleware/auth.global.ts

@ -0,0 +1,14 @@
const PUBLIC_PATHS = ["/", "/index", "/login", "/register"];
export default defineNuxtRouteMiddleware(async (to) => {
if (PUBLIC_PATHS.includes(to.path)) return;
try {
const res = await $fetch<{ code: number }>("/api/auth/me");
if (res.code !== 0) {
return navigateTo("/login?redirect=" + encodeURIComponent(to.fullPath));
}
} catch {
return navigateTo("/login?redirect=" + encodeURIComponent(to.fullPath));
}
});

34
app/pages/login.vue

@ -0,0 +1,34 @@
<script setup lang="ts">
definePageMeta({ layout: false });
const auth = useAuth();
const router = useRouter();
const route = useRoute();
const authForm = ref<InstanceType<typeof AuthForm> | null>(null);
const loading = ref(false);
async function handleSubmit(data: { username: string; password: string; captchaToken: string; captchaCode: string }) {
loading.value = true;
try {
const result = await auth.login(data.username, data.password, data.captchaToken, data.captchaCode);
if (result.success) {
const redirect = (route.query.redirect as string) || "/";
router.push(redirect);
} else {
authForm.value?.setError(result.message || "登录失败");
authForm.value?.refreshCaptcha();
}
} catch {
authForm.value?.setError("网络错误,请重试");
authForm.value?.refreshCaptcha();
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="flex flex-col items-center justify-center min-h-screen px-5" style="background: #f5f5f7;">
<AuthForm ref="authForm" mode="login" @submit="handleSubmit" />
</div>
</template>

32
app/pages/register.vue

@ -0,0 +1,32 @@
<script setup lang="ts">
definePageMeta({ layout: false });
const auth = useAuth();
const router = useRouter();
const authForm = ref<InstanceType<typeof AuthForm> | null>(null);
const loading = ref(false);
async function handleSubmit(data: { username: string; password: string; captchaToken: string; captchaCode: string }) {
loading.value = true;
try {
const result = await auth.register(data.username, data.password, data.captchaToken, data.captchaCode);
if (result.success) {
router.push("/");
} else {
authForm.value?.setError(result.message || "注册失败");
authForm.value?.refreshCaptcha();
}
} catch {
authForm.value?.setError("网络错误,请重试");
authForm.value?.refreshCaptcha();
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="flex flex-col items-center justify-center min-h-screen px-5" style="background: #f5f5f7;">
<AuthForm ref="authForm" mode="register" @submit="handleSubmit" />
</div>
</template>

1106
bun.lock

File diff suppressed because it is too large

2
package.json

@ -27,6 +27,7 @@
"drizzle-pkg": "workspace:*",
"drizzle-seed": "0.3.1",
"drizzle-zod": "0.8.3",
"jsonwebtoken": "9.0.3",
"log4js": "6.9.1",
"logger": "workspace:*",
"mime": "4.1.0",
@ -40,6 +41,7 @@
},
"devDependencies": {
"@tailwindcss/vite": "4.3.0",
"@types/jsonwebtoken": "9.0.10",
"@types/multer": "2.1.0",
"drizzle-kit": "0.31.10",
"tsx": "4.21.0",

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

11
packages/drizzle-pkg/lib/schema/auth.ts

@ -1,4 +1,4 @@
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: integer().primaryKey(),
@ -15,3 +15,12 @@ export const users = sqliteTable("users", {
.$onUpdate(() => new Date())
.notNull(),
});
export const captchaCodes = sqliteTable("captcha_codes", {
id: integer().primaryKey(),
token: text().notNull().unique(),
code: text().notNull(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
used: integer({ mode: "boolean" }).notNull().default(false),
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
});

19
packages/drizzle-pkg/migrations/0001_naive_gabe_jones.sql

@ -0,0 +1,19 @@
CREATE TABLE `captcha_codes` (
`id` integer PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`code` text NOT NULL,
`expires_at` integer NOT NULL,
`used` integer DEFAULT false NOT NULL,
`created_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `captcha_codes_token_unique` ON `captcha_codes` (`token`);--> statement-breakpoint
DROP INDEX `users_public_slug_unique`;--> statement-breakpoint
ALTER TABLE `users` DROP COLUMN `public_slug`;--> statement-breakpoint
ALTER TABLE `users` DROP COLUMN `bio_markdown`;--> statement-breakpoint
ALTER TABLE `users` DROP COLUMN `bio_visibility`;--> statement-breakpoint
ALTER TABLE `users` DROP COLUMN `social_links_json`;--> statement-breakpoint
ALTER TABLE `users` DROP COLUMN `avatar_visibility`;--> statement-breakpoint
ALTER TABLE `users` DROP COLUMN `discover_visible`;--> statement-breakpoint
ALTER TABLE `users` DROP COLUMN `discover_location`;--> statement-breakpoint
ALTER TABLE `users` DROP COLUMN `discover_show_location`;

172
packages/drizzle-pkg/migrations/meta/0001_snapshot.json

@ -0,0 +1,172 @@
{
"version": "6",
"dialect": "sqlite",
"id": "33ed99cd-53f2-4200-ae9f-0283f282445f",
"prevId": "b0a44e8d-8950-4409-8ca7-97ef171c2ec8",
"tables": {
"captcha_codes": {
"name": "captcha_codes",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"code": {
"name": "code",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"used": {
"name": "used",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))"
}
},
"indexes": {
"captcha_codes_token_unique": {
"name": "captcha_codes_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"nickname": {
"name": "nickname",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"avatar": {
"name": "avatar",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

7
packages/drizzle-pkg/migrations/meta/_journal.json

@ -8,6 +8,13 @@
"when": 1778727736262,
"tag": "0000_huge_sage",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1778993818174,
"tag": "0001_naive_gabe_jones",
"breakpoints": true
}
]
}

6
server/api/auth/captcha.post.ts

@ -0,0 +1,6 @@
import { createCaptcha } from "#server/service/auth";
export default defineWrappedResponseHandler(async () => {
const result = await createCaptcha();
return R.success(result);
});

36
server/api/auth/login.post.ts

@ -0,0 +1,36 @@
import { loginUser } from "#server/service/auth";
import { z } from "zod";
const loginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
captchaToken: z.string().min(1),
captchaCode: z.string().min(1),
});
export default defineWrappedResponseHandler(async (event) => {
const body = await readBody(event);
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return R.error("参数校验失败", parsed.error.issues);
}
const { username, password, captchaToken, captchaCode } = parsed.data;
const result = await loginUser(username, password, captchaToken, captchaCode);
if (!result.success) {
return R.error(result.message!, null);
}
// Set token as httpOnly cookie
setCookie(event, "token", result.token!, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7,
path: "/",
});
return R.success({ user: result.user });
});

4
server/api/auth/logout.post.ts

@ -0,0 +1,4 @@
export default defineWrappedResponseHandler(async (event) => {
deleteCookie(event, "token", { path: "/" });
return R.success(null);
});

16
server/api/auth/me.get.ts

@ -0,0 +1,16 @@
import { getUserFromEvent } from "#server/utils/jwt";
import { getCurrentUser } from "#server/service/auth";
export default defineWrappedResponseHandler(async (event) => {
const payload = getUserFromEvent(event);
if (!payload) {
return R.error("未登录", null);
}
const user = await getCurrentUser(payload);
if (!user) {
return R.error("用户不存在", null);
}
return R.success({ user });
});

36
server/api/auth/register.post.ts

@ -0,0 +1,36 @@
import { registerUser } from "#server/service/auth";
import { z } from "zod";
const registerSchema = z.object({
username: z.string().min(3).max(20),
password: z.string().min(8).max(128),
captchaToken: z.string().min(1),
captchaCode: z.string().min(1),
});
export default defineWrappedResponseHandler(async (event) => {
const body = await readBody(event);
const parsed = registerSchema.safeParse(body);
if (!parsed.success) {
return R.error("参数校验失败", parsed.error.issues);
}
const { username, password, captchaToken, captchaCode } = parsed.data;
const result = await registerUser(username, password, captchaToken, captchaCode);
if (!result.success) {
return R.error(result.message!, null);
}
// Set token as httpOnly cookie
setCookie(event, "token", result.token!, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 7 days
path: "/",
});
return R.success({ user: result.user });
});

164
server/service/auth/index.ts

@ -1,12 +1,160 @@
import { dbGlobal } from "drizzle-pkg/lib/db";
import { users as usersTable } from "drizzle-pkg/lib/schema/auth";
import { eq } from "drizzle-orm";
import { users as usersTable, captchaCodes } from "drizzle-pkg/lib/schema/auth";
import { eq, and, lt, sql } from "drizzle-orm";
import { compare, hash } from "bcryptjs";
import log4js from "logger";
import { signToken, type JwtPayload } from "#server/utils/jwt";
import { generateCaptchaCode, generateCaptchaToken, generateCaptchaSvg, captchaSvgToDataUri } from "#server/utils/captcha";
const logger = log4js.getLogger("AUTH")
const logger = log4js.getLogger("AUTH");
const CAPTCHA_EXPIRY_MS = 5 * 60 * 1000;
const BCRYPT_ROUNDS = 12;
export async function getUsers() {
const users = await dbGlobal.select().from(usersTable)
logger.info("users (formatted): %s \n", JSON.stringify(users, null, 2));
return users;
}
// ========== Captcha ==========
export async function createCaptcha() {
const code = generateCaptchaCode();
const token = generateCaptchaToken();
const expiresAt = new Date(Date.now() + CAPTCHA_EXPIRY_MS);
await dbGlobal.insert(captchaCodes).values({
token,
code,
expiresAt,
});
const svg = generateCaptchaSvg(code);
const dataUri = captchaSvgToDataUri(svg);
return { token, image: dataUri };
}
async function verifyAndConsumeCaptcha(token: string, code: string): Promise<boolean> {
const now = new Date();
// Atomic: mark as used only if not already used and not expired, returning the code
const [record] = await dbGlobal
.update(captchaCodes)
.set({ used: true })
.where(
and(
eq(captchaCodes.token, token),
eq(captchaCodes.used, false),
sql`${captchaCodes.expiresAt} > ${now.getTime()}`,
),
)
.returning({ code: captchaCodes.code });
if (!record) return false;
return record.code.toUpperCase() === code.toUpperCase();
}
// ========== Register ==========
export async function registerUser(username: string, password: string, captchaToken: string, captchaCode: string) {
const validCaptcha = await verifyAndConsumeCaptcha(captchaToken, captchaCode);
if (!validCaptcha) {
return { success: false, message: "验证码错误或已过期" };
}
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
return { success: false, message: "用户名需为3-20位字母、数字或下划线" };
}
if (password.length < 8) {
return { success: false, message: "密码长度不能少于8位" };
}
const [existing] = await dbGlobal
.select({ id: usersTable.id })
.from(usersTable)
.where(eq(usersTable.username, username));
if (existing) {
return { success: false, message: "用户名已被注册" };
}
const hashedPassword = await hash(password, BCRYPT_ROUNDS);
const [newUser] = await dbGlobal
.insert(usersTable)
.values({
username,
password: hashedPassword,
})
.returning({ id: usersTable.id, username: usersTable.username, role: usersTable.role });
const token = signToken({ userId: newUser.id, username: newUser.username });
return { success: true, token, user: newUser };
}
// ========== Login ==========
export async function loginUser(username: string, password: string, captchaToken: string, captchaCode: string) {
const validCaptcha = await verifyAndConsumeCaptcha(captchaToken, captchaCode);
if (!validCaptcha) {
return { success: false, message: "验证码错误或已过期" };
}
const [user] = await dbGlobal
.select()
.from(usersTable)
.where(eq(usersTable.username, username));
// Constant-time check: always run bcrypt compare even if user doesn't exist
const dummyHash = "$2a$12$" + "0".repeat(53);
const hashToCheck = user ? user.password : dummyHash;
const validPassword = await compare(password, hashToCheck);
if (!user || !validPassword) {
return { success: false, message: "用户名或密码错误" };
}
const token = signToken({ userId: user.id, username: user.username });
return {
success: true,
token,
user: {
id: user.id,
username: user.username,
email: user.email,
nickname: user.nickname,
avatar: user.avatar,
role: user.role,
},
};
}
// ========== Get current user ==========
export async function getCurrentUser(payload: JwtPayload) {
const [user] = await dbGlobal
.select()
.from(usersTable)
.where(eq(usersTable.id, payload.userId));
if (!user) return null;
return {
id: user.id,
username: user.username,
email: user.email,
nickname: user.nickname,
avatar: user.avatar,
role: user.role,
createdAt: user.createdAt,
};
}
// ========== Cleanup expired captchas ==========
export async function cleanupExpiredCaptchas() {
await dbGlobal
.delete(captchaCodes)
.where(lt(captchaCodes.expiresAt, new Date()));
}
export { getUsers } from "./legacy";

11
server/service/auth/legacy.ts

@ -0,0 +1,11 @@
import { dbGlobal } from "drizzle-pkg/lib/db";
import { users as usersTable } from "drizzle-pkg/lib/schema/auth";
import log4js from "logger";
const logger = log4js.getLogger("AUTH");
export async function getUsers() {
const users = await dbGlobal.select().from(usersTable);
logger.info("users (formatted): %s \n", JSON.stringify(users, null, 2));
return users;
}

66
server/utils/captcha.ts

@ -0,0 +1,66 @@
import { randomBytes, createHash } from "node:crypto";
function randomInt(min: number, max: number): number {
const range = max - min + 1;
const maxRand = 256 - (256 % range);
let rand: number;
do {
rand = randomBytes(1)[0];
} while (rand >= maxRand);
return min + (rand % range);
}
function randomChar(charset: string): string {
return charset[randomInt(0, charset.length - 1)];
}
export function generateCaptchaCode(length: number = 4): string {
const charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let code = "";
for (let i = 0; i < length; i++) {
code += randomChar(charset);
}
return code;
}
export function generateCaptchaToken(): string {
return randomBytes(32).toString("hex");
}
export function generateCaptchaSvg(code: string): string {
const width = 120;
const height = 44;
const charWidth = width / code.length;
const colors = ["#0066cc", "#1d1d1f", "#0071e3", "#333333"];
let paths = "";
let texts = "";
for (let i = 0; i < 3; i++) {
const x1 = randomInt(0, width);
const y1 = randomInt(0, height);
const x2 = randomInt(0, width);
const y2 = randomInt(0, height);
paths += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="#e0e0e0" stroke-width="1" />`;
}
for (let i = 0; i < code.length; i++) {
const x = charWidth * i + randomInt(4, charWidth - 20);
const y = randomInt(26, 34);
const rotate = randomInt(-20, 20);
const color = colors[randomInt(0, colors.length - 1)];
const fontSize = randomInt(22, 28);
texts += `<text x="${x}" y="${y}" font-size="${fontSize}" fill="${color}" transform="rotate(${rotate}, ${x}, ${y})" font-family="Arial, sans-serif" font-weight="bold">${code[i]}</text>`;
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<rect width="${width}" height="${height}" fill="#fafafc" rx="8" />
${paths}
${texts}
</svg>`;
}
export function captchaSvgToDataUri(svg: string): string {
const base64 = Buffer.from(svg).toString("base64");
return `data:image/svg+xml;base64,${base64}`;
}

41
server/utils/jwt.ts

@ -0,0 +1,41 @@
import jwt from "jsonwebtoken";
import type { H3Event } from "h3";
const JWT_SECRET = process.env.JWT_SECRET as string;
if (!JWT_SECRET) {
throw new Error("JWT_SECRET is not defined in environment variables");
}
const JWT_EXPIRES_IN = "7d";
export interface JwtPayload {
userId: number;
username: string;
}
export function signToken(payload: JwtPayload): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
}
export function verifyToken(token: string): JwtPayload {
return jwt.verify(token, JWT_SECRET) as JwtPayload;
}
export function getTokenFromEvent(event: H3Event): string | null {
const header = getHeader(event, "Authorization");
if (header && header.startsWith("Bearer ")) {
return header.slice(7);
}
const cookie = getCookie(event, "token");
if (cookie) return cookie;
return null;
}
export function getUserFromEvent(event: H3Event): JwtPayload | null {
const token = getTokenFromEvent(event);
if (!token) return null;
try {
return verifyToken(token);
} catch {
return null;
}
}
Loading…
Cancel
Save