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
@ -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 { dbGlobal } from "drizzle-pkg/lib/db"; |
||||
import { users as usersTable } from "drizzle-pkg/lib/schema/auth"; |
import { users as usersTable, captchaCodes } from "drizzle-pkg/lib/schema/auth"; |
||||
import { eq } from "drizzle-orm"; |
import { eq, and, lt, sql } from "drizzle-orm"; |
||||
|
import { compare, hash } from "bcryptjs"; |
||||
import log4js from "logger"; |
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() { |
// ========== Captcha ==========
|
||||
const users = await dbGlobal.select().from(usersTable) |
|
||||
logger.info("users (formatted): %s \n", JSON.stringify(users, null, 2)); |
export async function createCaptcha() { |
||||
return users; |
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