Browse Source
- 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
24 changed files with 1537 additions and 540 deletions
@ -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 |
|||
@ -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> |
|||
@ -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 }; |
|||
} |
|||
@ -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)); |
|||
} |
|||
}); |
|||
@ -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> |
|||
@ -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> |
|||
File diff suppressed because it is too large
Binary file not shown.
@ -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`; |
|||
@ -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": {} |
|||
} |
|||
} |
|||
@ -0,0 +1,6 @@ |
|||
import { createCaptcha } from "#server/service/auth"; |
|||
|
|||
export default defineWrappedResponseHandler(async () => { |
|||
const result = await createCaptcha(); |
|||
return R.success(result); |
|||
}); |
|||
@ -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 }); |
|||
}); |
|||
@ -0,0 +1,4 @@ |
|||
export default defineWrappedResponseHandler(async (event) => { |
|||
deleteCookie(event, "token", { path: "/" }); |
|||
return R.success(null); |
|||
}); |
|||
@ -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 }); |
|||
}); |
|||
@ -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 }); |
|||
}); |
|||
@ -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"; |
|||
|
|||
@ -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; |
|||
} |
|||
@ -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}`; |
|||
} |
|||
@ -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…
Reference in new issue