358 lines
9.4 KiB
TypeScript
358 lines
9.4 KiB
TypeScript
import type { LoginVerificationPayload } from '~/types/auth'
|
|
|
|
export type AuthStatus = 'checking' | 'authenticated' | 'unauthenticated'
|
|
|
|
export type UserRole = 'admin' | 'user'
|
|
|
|
export interface AuthUser {
|
|
id: number
|
|
username: string
|
|
role: UserRole
|
|
}
|
|
|
|
export interface LoginVerificationPublicConfig {
|
|
method: 'slider' | 'turnstile' | 'cap'
|
|
turnstileSiteKey?: string
|
|
capApiEndpoint?: string
|
|
}
|
|
|
|
export interface AuthStatusResponse {
|
|
initialized: boolean
|
|
allowRegistration: boolean
|
|
legacyMode: boolean
|
|
needsMigration: boolean
|
|
loginVerification: LoginVerificationPublicConfig
|
|
user: AuthUser | null
|
|
}
|
|
|
|
export function isUnauthorizedError(error: unknown): boolean {
|
|
return typeof error === 'object'
|
|
&& error !== null
|
|
&& 'statusCode' in error
|
|
&& (error as { statusCode: number }).statusCode === 401
|
|
}
|
|
|
|
export const AUTH_USERNAME_PATTERN = /^[a-zA-Z0-9_]{3,32}$/
|
|
export const AUTH_MIN_PASSWORD_LENGTH = 8
|
|
|
|
export type AuthValidationErrorKey = 'invalidUsername' | 'invalidPassword'
|
|
|
|
export function validateAuthInput(username: string, password: string): AuthValidationErrorKey | null {
|
|
const trimmed = username.trim()
|
|
if (!AUTH_USERNAME_PATTERN.test(trimmed)) {
|
|
return 'invalidUsername'
|
|
}
|
|
if (password.length < AUTH_MIN_PASSWORD_LENGTH) {
|
|
return 'invalidPassword'
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function getFetchErrorMessage(error: unknown, fallback: string): string {
|
|
if (!error || typeof error !== 'object') return fallback
|
|
|
|
const e = error as {
|
|
data?: {
|
|
message?: string
|
|
error?: { message?: string }
|
|
data?: { error?: { message?: string } }
|
|
}
|
|
statusMessage?: string
|
|
message?: string
|
|
}
|
|
|
|
const nested = e.data?.data?.error?.message
|
|
?? e.data?.error?.message
|
|
?? e.data?.message
|
|
if (nested) return nested
|
|
|
|
if (e.statusMessage && e.statusMessage !== 'Bad Request') {
|
|
return e.statusMessage
|
|
}
|
|
|
|
return fallback
|
|
}
|
|
|
|
export type AuthActionResult = { ok: true } | { ok: false, error: string }
|
|
|
|
export type { LoginVerificationPayload }
|
|
|
|
function buildVerificationBody(verification: LoginVerificationPayload): Record<string, unknown> {
|
|
if (verification.method === 'slider') {
|
|
return {
|
|
captchaId: verification.captchaId,
|
|
captchaPosition: verification.captchaPosition
|
|
}
|
|
}
|
|
if (verification.method === 'turnstile') {
|
|
return { turnstileToken: verification.turnstileToken }
|
|
}
|
|
return { capToken: verification.capToken }
|
|
}
|
|
|
|
export function useAuth() {
|
|
const { t } = useI18n()
|
|
const status = useState<AuthStatus>('auth-status', () => 'checking')
|
|
const user = useState<AuthUser | null>('auth-user', () => null)
|
|
const authStatus = useState<AuthStatusResponse | null>('auth-status-response', () => null)
|
|
/** 是否已完成至少一次会话校验(用于避免路由切换时反复全屏 loading) */
|
|
const hasValidatedOnce = useState('auth-validated-once', () => false)
|
|
|
|
const isChecking = computed(() => status.value === 'checking' && !hasValidatedOnce.value)
|
|
const isAuthenticated = computed(() => status.value === 'authenticated')
|
|
const isAdmin = computed(() => user.value?.role === 'admin')
|
|
|
|
async function fetchStatus(): Promise<AuthStatusResponse> {
|
|
const data = await $fetch<AuthStatusResponse>('/api/auth/status', {
|
|
credentials: 'include'
|
|
})
|
|
authStatus.value = data
|
|
return data
|
|
}
|
|
|
|
async function checkSession(options?: { force?: boolean }) {
|
|
const silent = !options?.force && hasValidatedOnce.value
|
|
if (!silent) {
|
|
status.value = 'checking'
|
|
}
|
|
try {
|
|
const data = await fetchStatus()
|
|
hasValidatedOnce.value = true
|
|
if (data.user) {
|
|
user.value = data.user
|
|
status.value = 'authenticated'
|
|
return true
|
|
}
|
|
user.value = null
|
|
status.value = 'unauthenticated'
|
|
return false
|
|
} catch {
|
|
hasValidatedOnce.value = true
|
|
user.value = null
|
|
status.value = 'unauthenticated'
|
|
return false
|
|
}
|
|
}
|
|
|
|
function markUnauthenticated() {
|
|
status.value = 'unauthenticated'
|
|
user.value = null
|
|
}
|
|
|
|
async function login(
|
|
credentials: { username: string, password: string }
|
|
| { secret: string },
|
|
verification: LoginVerificationPayload
|
|
): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> {
|
|
try {
|
|
const result = await $fetch<{
|
|
success: boolean
|
|
needsMigration?: boolean
|
|
user?: AuthUser
|
|
}>('/api/auth/login', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
body: {
|
|
...credentials,
|
|
...buildVerificationBody(verification)
|
|
}
|
|
})
|
|
if (result.needsMigration) {
|
|
return { ok: true, needsMigration: true }
|
|
}
|
|
if (result.user) {
|
|
user.value = result.user
|
|
}
|
|
status.value = 'authenticated'
|
|
hasValidatedOnce.value = true
|
|
await fetchStatus()
|
|
return { ok: true }
|
|
} catch (error: unknown) {
|
|
return {
|
|
ok: false,
|
|
error: getFetchErrorMessage(error, t('auth.loginFailed'))
|
|
}
|
|
}
|
|
}
|
|
|
|
async function register(
|
|
username: string,
|
|
password: string,
|
|
verification: LoginVerificationPayload
|
|
): Promise<AuthActionResult> {
|
|
const validationError = validateAuthInput(username, password)
|
|
if (validationError) {
|
|
return { ok: false, error: t(`auth.errors.${validationError}`) }
|
|
}
|
|
|
|
try {
|
|
const result = await $fetch<{ success: boolean, user: AuthUser }>(
|
|
'/api/auth/register',
|
|
{
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
body: {
|
|
username: username.trim(),
|
|
password,
|
|
...buildVerificationBody(verification)
|
|
}
|
|
}
|
|
)
|
|
user.value = result.user
|
|
status.value = 'authenticated'
|
|
hasValidatedOnce.value = true
|
|
await fetchStatus()
|
|
return { ok: true }
|
|
} catch (error: unknown) {
|
|
return {
|
|
ok: false,
|
|
error: getFetchErrorMessage(error, t('auth.registerFailed'))
|
|
}
|
|
}
|
|
}
|
|
|
|
async function setup(input: {
|
|
username: string
|
|
password: string
|
|
allowRegistration: boolean
|
|
domainSeparation?: boolean
|
|
siteBaseUrl?: string
|
|
imageBaseUrl?: string
|
|
}): Promise<AuthActionResult> {
|
|
const validationError = validateAuthInput(input.username, input.password)
|
|
if (validationError) {
|
|
return { ok: false, error: t(`auth.errors.${validationError}`) }
|
|
}
|
|
|
|
try {
|
|
const result = await $fetch<{ success: boolean, user: AuthUser }>(
|
|
'/api/setup',
|
|
{
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
body: {
|
|
username: input.username.trim(),
|
|
password: input.password,
|
|
allowRegistration: input.allowRegistration,
|
|
domainSeparation: input.domainSeparation,
|
|
siteBaseUrl: input.siteBaseUrl,
|
|
imageBaseUrl: input.imageBaseUrl
|
|
}
|
|
}
|
|
)
|
|
user.value = result.user
|
|
status.value = 'authenticated'
|
|
hasValidatedOnce.value = true
|
|
await fetchStatus()
|
|
return { ok: true }
|
|
} catch (error: unknown) {
|
|
return {
|
|
ok: false,
|
|
error: getFetchErrorMessage(error, t('auth.setupFailed'))
|
|
}
|
|
}
|
|
}
|
|
|
|
async function migrate(input: {
|
|
username: string
|
|
password: string
|
|
allowRegistration: boolean
|
|
}): Promise<AuthActionResult> {
|
|
const validationError = validateAuthInput(input.username, input.password)
|
|
if (validationError) {
|
|
return { ok: false, error: t(`auth.errors.${validationError}`) }
|
|
}
|
|
|
|
try {
|
|
const result = await $fetch<{ success: boolean, user: AuthUser }>(
|
|
'/api/auth/migrate',
|
|
{
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
body: {
|
|
username: input.username.trim(),
|
|
password: input.password,
|
|
allowRegistration: input.allowRegistration
|
|
}
|
|
}
|
|
)
|
|
user.value = result.user
|
|
status.value = 'authenticated'
|
|
hasValidatedOnce.value = true
|
|
await fetchStatus()
|
|
return { ok: true }
|
|
} catch (error: unknown) {
|
|
return {
|
|
ok: false,
|
|
error: getFetchErrorMessage(error, t('auth.migrateFailed'))
|
|
}
|
|
}
|
|
}
|
|
|
|
async function changePassword(
|
|
currentPassword: string,
|
|
newPassword: string
|
|
): Promise<AuthActionResult> {
|
|
if (newPassword.length < AUTH_MIN_PASSWORD_LENGTH) {
|
|
return { ok: false, error: t('password.tooShort') }
|
|
}
|
|
|
|
try {
|
|
await $fetch('/api/auth/password', {
|
|
method: 'PATCH',
|
|
credentials: 'include',
|
|
body: { currentPassword, newPassword }
|
|
})
|
|
return { ok: true }
|
|
} catch (error: unknown) {
|
|
return {
|
|
ok: false,
|
|
error: getFetchErrorMessage(error, t('password.changeFailed'))
|
|
}
|
|
}
|
|
}
|
|
|
|
async function logout() {
|
|
try {
|
|
await $fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
|
|
} catch {
|
|
// 忽略登出失败
|
|
} finally {
|
|
status.value = 'unauthenticated'
|
|
user.value = null
|
|
hasValidatedOnce.value = true
|
|
}
|
|
try {
|
|
await fetchStatus()
|
|
} catch {
|
|
// 保留既有公开配置(loginVerification 等)
|
|
}
|
|
}
|
|
|
|
function handleAuthError(error: unknown) {
|
|
if (isUnauthorizedError(error)) {
|
|
markUnauthenticated()
|
|
}
|
|
}
|
|
|
|
return {
|
|
status,
|
|
user,
|
|
authStatus,
|
|
isChecking,
|
|
isAuthenticated,
|
|
isAdmin,
|
|
checkSession,
|
|
fetchStatus,
|
|
markUnauthenticated,
|
|
login,
|
|
register,
|
|
setup,
|
|
migrate,
|
|
changePassword,
|
|
logout,
|
|
handleAuthError,
|
|
getFetchErrorMessage
|
|
}
|
|
}
|