认证机制
约 2199 字大约 7 分钟
2025-08-16
概述
Evoliant 前端采用基于 Supabase 的完整认证系统,支持邮箱密码认证、角色权限管理、会话持久化等功能。认证系统与 Nuxt 4 深度集成,提供类型安全的用户状态管理。
技术栈
- @nuxtjs/supabase v1.6.0: Nuxt 官方 Supabase 集成
- Pinia: 状态管理和认证状态持久化
- TypeScript: 完整的类型安全
- Vue 3 Composition API: 响应式认证状态
认证架构
核心组件
认证系统架构
├── Supabase Auth Service # 后端认证服务
├── @nuxtjs/supabase Module # Nuxt 集成模块
├── Auth Store (Pinia) # 前端状态管理
├── Auth Middleware # 路由保护
├── Auth Composables # 认证组合式函数
└── Auth Pages # 登录注册页面认证流程
Supabase 配置
Nuxt 配置
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/supabase'],
supabase: {
url: process.env.SUPABASE_URL,
key: process.env.SUPABASE_ANON_KEY,
// 启用 SSR cookies 支持服务器端身份验证
useSsrCookies: true,
// 自动重定向配置
redirect: true,
redirectOptions: {
login: '/login',
callback: '/confirm',
exclude: ['/', '/login', '/register', '/confirm'],
saveRedirectToCookie: true
},
// Cookie 配置
cookieOptions: {
maxAge: 60 * 60 * 8, // 8 小时
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
httpOnly: false
},
// 类型定义
types: './app/types/database.types.ts'
}
})环境变量
# .env
SUPABASE_URL=http://127.0.0.1:54321
SUPABASE_ANON_KEY=your_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key状态管理
Auth Store (Pinia)
// stores/auth.ts
export const useAuthStore = defineStore('auth', () => {
const supabase = useSupabaseClient()
const user = useSupabaseUser()
// 状态
const userInfo = ref<UserInfo | null>(null)
const loading = ref(false)
// 计算属性
const isAuthenticated = computed(() => !!user.value && !!userInfo.value)
const userRole = computed(() => userInfo.value?.role || null)
// 登录
const signIn = async (credentials: LoginCredentials) => {
try {
loading.value = true
const { data: authData, error: authError } = await supabase.auth
.signInWithPassword(credentials)
if (authError) {
return { user: null, userInfo: null, error: authError.message }
}
// 获取用户详细信息
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('*')
.eq('id', authData.user.id)
.single()
if (profileError || !profile) {
return { user: null, userInfo: null, error: '获取用户信息失败' }
}
userInfo.value = profile
return {
user: authData.user,
userInfo: profile,
error: null
}
} catch (error) {
return {
user: null,
userInfo: null,
error: error instanceof Error ? error.message : '登录失败'
}
} finally {
loading.value = false
}
}
// 注册
const signUp = async (data: RegisterForm) => {
try {
loading.value = true
const { data: authData, error: authError } = await supabase.auth.signUp({
email: data.email,
password: data.password,
options: {
data: {
full_name: data.full_name,
role: data.role
}
}
})
if (authError) {
return { user: null, userInfo: null, error: authError.message }
}
// 创建用户档案
if (authData.user) {
const { data: profile, error: profileError } = await supabase
.from('profiles')
.insert({
id: authData.user.id,
email: data.email,
full_name: data.full_name,
role: data.role
})
.select()
.single()
if (!profileError && profile) {
userInfo.value = profile
}
}
return {
user: authData.user,
userInfo: userInfo.value,
error: null,
message: '注册成功!'
}
} catch (error) {
return {
user: null,
userInfo: null,
error: error instanceof Error ? error.message : '注册失败'
}
} finally {
loading.value = false
}
}
// 登出
const signOut = async () => {
try {
loading.value = true
const { error } = await supabase.auth.signOut()
if (!error) {
userInfo.value = null
await navigateTo('/login')
}
return { error: error?.message || null }
} catch (error) {
return { error: error instanceof Error ? error.message : '登出失败' }
} finally {
loading.value = false
}
}
// 刷新用户信息
const refreshUserInfo = async () => {
if (!user.value) return
const { data: profile } = await supabase
.from('profiles')
.select('*')
.eq('id', user.value.id)
.single()
if (profile) {
userInfo.value = profile
}
}
return {
// 状态
user: readonly(user),
userInfo: readonly(userInfo),
loading: readonly(loading),
// 计算属性
isAuthenticated,
userRole,
// 方法
signIn,
signUp,
signOut,
refreshUserInfo
}
})类型定义
用户相关类型
// types/auth.ts
export interface LoginCredentials {
email: string
password: string
}
export interface RegisterForm {
email: string
password: string
confirmPassword: string
full_name: string
role: 'student' | 'teacher' | 'admin'
}
export interface UserInfo {
id: string
email: string
full_name: string
role: 'student' | 'teacher' | 'admin'
avatar_url?: string
created_at: string
updated_at?: string
}
export interface AuthResponse {
user: User | null
userInfo: UserInfo | null
error: string | null
message?: string
}组合式函数
useAuth
// composables/useAuth.ts
export const useAuth = () => {
const authStore = useAuthStore()
const user = useSupabaseUser()
// 检查用户是否有特定角色
const hasRole = (role: string | string[]) => {
if (!authStore.userRole) return false
const roles = Array.isArray(role) ? role : [role]
return roles.includes(authStore.userRole)
}
// 检查用户是否为管理员
const isAdmin = computed(() => hasRole('admin'))
// 检查用户是否为教师
const isTeacher = computed(() => hasRole(['teacher', 'admin']))
// 检查用户是否为学生
const isStudent = computed(() => hasRole('student'))
// 获取用户显示名称
const displayName = computed(() => {
if (!authStore.userInfo) return '未知用户'
return authStore.userInfo.full_name || authStore.userInfo.email
})
// 获取用户头像URL
const avatarUrl = computed(() => {
return authStore.userInfo?.avatar_url || `/api/avatar/${authStore.userInfo?.id}`
})
return {
// 状态
user: authStore.user,
userInfo: authStore.userInfo,
loading: authStore.loading,
isAuthenticated: authStore.isAuthenticated,
// 角色检查
hasRole,
isAdmin,
isTeacher,
isStudent,
// 用户信息
displayName,
avatarUrl,
// 方法
signIn: authStore.signIn,
signUp: authStore.signUp,
signOut: authStore.signOut,
refreshUserInfo: authStore.refreshUserInfo
}
}useAuthGuard
// composables/useAuthGuard.ts
export const useAuthGuard = (requiredRole?: string | string[]) => {
const { isAuthenticated, hasRole } = useAuth()
const router = useRouter()
const canAccess = computed(() => {
if (!isAuthenticated.value) return false
if (!requiredRole) return true
return hasRole(requiredRole)
})
const guardRoute = () => {
if (!canAccess.value) {
if (!isAuthenticated.value) {
return navigateTo('/login')
} else {
throw createError({
statusCode: 403,
statusMessage: '权限不足'
})
}
}
}
return {
canAccess,
guardRoute
}
}路由保护
认证中间件
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
const user = useSupabaseUser()
// 如果用户未登录,重定向到登录页
if (!user.value) {
return navigateTo('/login')
}
})角色中间件
// middleware/role.ts
export default defineNuxtRouteMiddleware((to) => {
const { hasRole } = useAuth()
// 从路由 meta 中获取所需角色
const requiredRoles = to.meta.roles as string[] | undefined
if (requiredRoles && !hasRole(requiredRoles)) {
throw createError({
statusCode: 403,
statusMessage: '权限不足'
})
}
})页面中使用中间件
<!-- pages/admin/dashboard.vue -->
<script setup lang="ts">
// 页面级别的权限控制
definePageMeta({
middleware: ['auth', 'role'],
roles: ['admin']
})
</script>认证页面
登录页面
<!-- pages/login.vue -->
<template>
<div class="auth-container">
<Card class="auth-card">
<CardHeader>
<CardTitle>登录 Evoliant</CardTitle>
<CardDescription>请输入您的账号信息</CardDescription>
</CardHeader>
<CardContent>
<form @submit.prevent="handleSubmit">
<div class="space-y-4">
<div>
<Label for="email">邮箱</Label>
<Input
id="email"
v-model="form.email"
type="email"
required
/>
</div>
<div>
<Label for="password">密码</Label>
<Input
id="password"
v-model="form.password"
type="password"
required
/>
</div>
<Button type="submit" :disabled="loading" class="w-full">
<Loader2 v-if="loading" class="mr-2 h-4 w-4 animate-spin" />
{{ loading ? '登录中...' : '登录' }}
</Button>
<div v-if="error" class="error-message">
{{ error }}
</div>
</div>
</form>
</CardContent>
<CardFooter>
<p>还没有账号?
<NuxtLink to="/register" class="link">立即注册</NuxtLink>
</p>
</CardFooter>
</Card>
</div>
</template>
<script setup lang="ts">
import type { LoginCredentials } from '@/types'
definePageMeta({
layout: 'auth'
})
const { signIn, loading } = useAuth()
const redirectInfo = useSupabaseCookieRedirect()
const form = reactive<LoginCredentials>({
email: '',
password: ''
})
const error = ref('')
const handleSubmit = async () => {
error.value = ''
const { user, userInfo, error: authError } = await signIn(form)
if (authError) {
error.value = authError
return
}
if (user) {
// 获取保存的重定向路径
const savedPath = redirectInfo.pluck()
// 根据用户角色计算重定向路径
const roleRedirectMap = {
admin: '/admin/dashboard',
teacher: '/teacher/dashboard',
student: '/student/dashboard'
}
const redirectPath = savedPath ||
(userInfo?.role ? roleRedirectMap[userInfo.role] : null) ||
'/'
await navigateTo(redirectPath)
}
}
</script>注册页面
<!-- pages/register.vue -->
<template>
<div class="auth-container">
<Card class="auth-card">
<CardHeader>
<CardTitle>注册 Evoliant</CardTitle>
<CardDescription>创建您的新账号</CardDescription>
</CardHeader>
<CardContent>
<form @submit.prevent="handleSubmit">
<div class="space-y-4">
<div>
<Label for="full-name">姓名</Label>
<Input
id="full-name"
v-model="form.full_name"
required
/>
</div>
<div>
<Label for="email">邮箱</Label>
<Input
id="email"
v-model="form.email"
type="email"
required
/>
</div>
<div>
<Label for="role">用户角色</Label>
<Select v-model="form.role">
<SelectTrigger>
<SelectValue placeholder="请选择用户角色" />
</SelectTrigger>
<SelectContent>
<SelectItem value="student">学生</SelectItem>
<SelectItem value="teacher">教师</SelectItem>
<SelectItem value="admin">管理员</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label for="password">密码</Label>
<Input
id="password"
v-model="form.password"
type="password"
required
/>
</div>
<div>
<Label for="confirm-password">确认密码</Label>
<Input
id="confirm-password"
v-model="form.confirmPassword"
type="password"
required
/>
</div>
<Button type="submit" :disabled="loading" class="w-full">
<Loader2 v-if="loading" class="mr-2 h-4 w-4 animate-spin" />
{{ loading ? '注册中...' : '注册' }}
</Button>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div v-if="success" class="success-message">
{{ success }}
</div>
</div>
</form>
</CardContent>
<CardFooter>
<p>已有账号?
<NuxtLink to="/login" class="link">立即登录</NuxtLink>
</p>
</CardFooter>
</Card>
</div>
</template>
<script setup lang="ts">
import type { RegisterForm } from '@/types'
import { toast } from 'vue-sonner'
definePageMeta({
layout: 'auth'
})
const { signUp, loading } = useAuth()
const form = reactive<RegisterForm>({
email: '',
password: '',
full_name: '',
role: 'student',
confirmPassword: ''
})
const error = ref('')
const success = ref('')
const handleSubmit = async () => {
error.value = ''
success.value = ''
// 验证密码匹配
if (form.password !== form.confirmPassword) {
error.value = '两次输入的密码不一致'
return
}
// 验证密码长度
if (form.password.length < 6) {
error.value = '密码至少需要6位'
return
}
const { user, userInfo, error: authError, message } = await signUp(form)
if (authError) {
error.value = authError
return
}
if (user && userInfo) {
success.value = message || '注册成功!'
toast.success('注册成功!欢迎加入 Evoliant!', {
description: '您已成功创建账号并自动登录'
})
// 根据用户角色导航
const roleRedirectMap = {
admin: '/admin/dashboard',
teacher: '/teacher/dashboard',
student: '/student/dashboard'
}
const redirectPath = roleRedirectMap[userInfo.role] || '/'
setTimeout(() => {
navigateTo(redirectPath, { replace: true })
}, 200)
}
}
</script>客户端插件
认证状态初始化
// plugins/auth.client.ts
export default defineNuxtPlugin(async () => {
const authStore = useAuthStore()
const user = useSupabaseUser()
const supabase = useSupabaseClient()
// 监听认证状态变化
supabase.auth.onAuthStateChange(async (event, session) => {
if (event === 'SIGNED_IN' && session?.user) {
// 用户登录时获取详细信息
await authStore.refreshUserInfo()
} else if (event === 'SIGNED_OUT') {
// 用户登出时清理状态
authStore.userInfo = null
}
})
// 初始化时如果已登录则获取用户信息
if (user.value && !authStore.userInfo) {
await authStore.refreshUserInfo()
}
})最佳实践
1. 错误处理
// 统一的认证错误处理
const handleAuthError = (error: string) => {
const errorMessages: Record<string, string> = {
'Invalid login credentials': '邮箱或密码错误',
'Email already registered': '邮箱已被注册',
'Weak password': '密码强度不够',
'Invalid email': '邮箱格式不正确'
}
return errorMessages[error] || error
}2. 会话管理
// 检查会话有效性
const checkSession = async () => {
const { data: { session }, error } = await supabase.auth.getSession()
if (error || !session) {
// 会话无效,重定向到登录页
await navigateTo('/login')
return false
}
return true
}3. 角色权限检查
// 页面级别的权限检查
const checkPermission = (requiredRole: string[]) => {
const { hasRole } = useAuth()
if (!hasRole(requiredRole)) {
throw createError({
statusCode: 403,
statusMessage: '您没有权限访问此页面'
})
}
}4. 自动登出
// 处理 token 过期
watch(() => user.value, (newUser) => {
if (!newUser) {
// 用户被登出(可能是 token 过期)
authStore.userInfo = null
navigateTo('/login')
}
})安全注意事项
1. Cookie 安全
- 生产环境启用
secure标志 - 使用
sameSite: 'lax'防止 CSRF - 设置适当的过期时间
2. 敏感数据保护
- 不在客户端存储敏感信息
- 使用 HTTPS 传输认证信息
- 定期轮换 JWT Token
3. 路由保护
- 在服务端和客户端都进行权限验证
- 使用中间件统一处理路由保护
- 对敏感页面进行额外的权限检查
4. 输入验证
- 客户端和服务端都进行输入验证
- 使用强密码策略
- 防止常见的注入攻击
版权所有
版权归属:Evoliant
许可证:MIT