Clerk 用户认证系统集成文档
目录
概述
Clerk是一个现代化的用户认证和用户管理平台,提供了完整的身份验证解决方案。本项目已集成Clerk,支持多种登录方式:
- ✅ 邮箱密码登录
- ✅ 社交登录 (Google, GitHub, Facebook等)
- ✅ 手机号验证码登录
- ✅ 邮箱验证码登录
- ✅ 多因素认证(MFA)
- ✅ 组织管理
- ✅ 用户资料管理
📦 安装与配置
1. 依赖包安装
项目已安装以下Clerk相关依赖:
json
{
"dependencies": {
"@clerk/themes": "^2.4.13",
"@clerk/nuxt": "^1.8.10",
"@clerk/vue": "^1.11.4"
}
}
2. 安装命令
bash
# 使用 pnpm
pnpm add @clerk/themes @clerk/nuxt @clerk/vue
# 或使用 npm
npm install @clerk/themes @clerk/nuxt @clerk/vue
# 或使用 yarn
yarn add @clerk/themes @clerk/nuxt @clerk/vue
🔧 环境变量配置
1. 创建环境变量文件
创建 .env
文件:
env
# Clerk 配置
NUXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
NUXT_CLERK_SECRET_KEY=sk_test_your_secret_key_here
# 可选:自定义域名
NUXT_PUBLIC_CLERK_SIGN_IN_URL=/login
NUXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NUXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/
NUXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/
2. 获取Clerk密钥
- 访问 Clerk Dashboard
- 创建新应用或选择现有应用
- 在 API Keys 页面获取:
- Publishable Key (前端使用)
- Secret Key (后端使用)
3. 环境变量说明
变量名 | 说明 | 必需 |
---|---|---|
NUXT_PUBLIC_CLERK_PUBLISHABLE_KEY |
前端公钥 | ✅ |
NUXT_CLERK_SECRET_KEY |
后端密钥 | ✅ |
NUXT_PUBLIC_CLERK_SIGN_IN_URL |
登录页面路径 | ❌ |
NUXT_PUBLIC_CLERK_SIGN_UP_URL |
注册页面路径 | ❌ |
NUXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL |
登录后跳转路径 | ❌ |
NUXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL |
注册后跳转路径 | ❌ |
⚙️ Nuxt配置
1. 基础配置
typescript
// nuxt.config.ts
import { defineNuxtConfig } from 'nuxt/config'
import { dark } from '@clerk/themes'
export default defineNuxtConfig({
modules: ['@nuxtjs/tailwindcss', '@clerk/nuxt'],
clerk: {
appearance: {
baseTheme: dark,
},
// 禁用自动服务端中间件,避免全局拦截
skipServerMiddleware: true,
},
runtimeConfig: {
public: {
clerkPublishableKey: process.env.NUXT_PUBLIC_CLERK_PUBLISHABLE_KEY || '',
},
// 服务端配置
clerkSecretKey: process.env.NUXT_CLERK_SECRET_KEY || '',
},
nitro: {
routeRules: {
// 首页和其他页面不使用 Clerk 自动认证
'/': { ssr: true, prerender: false },
'/chat/**': { ssr: true, prerender: false },
// 确保登录页面支持OAuth回调参数
'/login': { ssr: false, prerender: false },
},
},
})
2. 高级配置选项
typescript
clerk: {
appearance: {
baseTheme: dark,
variables: {
colorPrimary: '#00d084',
colorBackground: 'transparent',
colorInputBackground: '#2a2a2a',
colorInputText: '#ffffff',
colorText: '#ffffff',
colorTextSecondary: '#9ca3af',
borderRadius: '8px',
fontSize: '16px',
},
},
// 自定义路由
signInUrl: '/login',
signUpUrl: '/sign-up',
afterSignInUrl: '/',
afterSignUpUrl: '/',
// 禁用自动重定向
skipServerMiddleware: true,
}
🧩 组件集成
1. 登录页面集成
vue
<!-- src/pages/login/index.tsx -->
<template>
<div class="login-container">
<h1>Sign in</h1>
<SignIn
transferable={true}
withSignUp={true}
oauthFlow="redirect"
appearance={{
baseTheme: dark,
variables: {
colorPrimary: '#00d084',
colorBackground: 'transparent',
colorInputBackground: '#2a2a2a',
colorInputText: '#ffffff',
colorText: '#ffffff',
colorTextSecondary: '#9ca3af',
borderRadius: '8px',
fontSize: '16px',
},
elements: {
card: {
backgroundColor: 'transparent',
boxShadow: 'none',
border: 'none',
padding: '0',
},
socialButtonsBlock: {
display: 'flex',
flexDirection: 'column',
gap: '12px',
width: '100%',
},
formButtonPrimary: {
backgroundColor: '#00d084',
color: '#ffffff',
borderRadius: '8px',
height: '48px',
},
},
}}
/>
</div>
</template>
<script setup>
import { SignIn } from '@clerk/vue'
import { dark } from '@clerk/themes'
</script>
2. 注册页面
vue
<!-- src/pages/sign-up/index.tsx -->
<template>
<div class="signup-container">
<h1>Create Account</h1>
<SignUp
transferable={true}
withSignIn={true}
oauthFlow="redirect"
appearance={{
baseTheme: dark,
// 与登录页面相同的样式配置
}}
/>
</div>
</template>
<script setup>
import { SignUp } from '@clerk/vue'
import { dark } from '@clerk/themes'
</script>
3. 用户按钮组件
vue
<!-- src/components/UserButton.vue -->
<template>
<UserButton
appearance={{
baseTheme: dark,
elements: {
userButtonAvatarBox: {
width: '40px',
height: '40px',
},
userButtonPopoverCard: {
backgroundColor: '#2a2a2a',
borderColor: '#404040',
},
},
}}
/>
</template>
<script setup>
import { UserButton } from '@clerk/vue'
import { dark } from '@clerk/themes'
</script>
4. 组织管理组件
vue
<!-- src/components/OrganizationSwitcher.vue -->
<template>
<OrganizationSwitcher
appearance={{
baseTheme: dark,
elements: {
organizationSwitcherTrigger: {
backgroundColor: '#2a2a2a',
borderColor: '#404040',
},
},
}}
/>
</template>
<script setup>
import { OrganizationSwitcher } from '@clerk/vue'
import { dark } from '@clerk/themes'
</script>
🗃️ 状态管理
1. Pinia Store集成
typescript
// src/stores/app/methods.ts
import { defineStore } from 'pinia'
import { onMounted, reactive } from 'vue'
import { useCookie, useRouter } from '#app'
import { useRuntimeConfig } from '#imports'
import { useAuth, useClerk } from '@clerk/vue'
import type { LoginType } from './types'
export const useAppStore = defineStore('app', () => {
// Clerk hooks 引用
let auth: any = null
let clerk: any = null
let clerkInitialized = false
const router = useRouter()
// 安全地初始化 Clerk composables
const initClerkHooks = () => {
try {
if (
import.meta.client &&
typeof window !== 'undefined' &&
!clerkInitialized
) {
const config = useRuntimeConfig()
if (config.public.clerkPublishableKey) {
auth = useAuth()
clerk = useClerk()
clerkInitialized = true
console.log('✅ Clerk hooks initialized in store')
return true
}
}
return clerkInitialized
} catch (error) {
console.warn('Failed to initialize Clerk hooks in store:', error)
return false
}
}
const state = reactive({
loginType: 'normal',
agentModel: {
network: false,
value: '这是测试agent消息',
knowledgeBase: false,
},
token: useCookie('token').value || '',
})
onMounted(() => {
const localToken = localStorage.getItem('token')
const cookieToken = useCookie('token').value
state.token = localToken || cookieToken || ''
state.loginType = (localStorage.getItem('loginType') as LoginType) || 'normal'
// 如果是 Clerk 登录类型,尝试初始化 Clerk hooks
if (state.loginType === 'clerk') {
const initialized = initClerkHooks()
if (initialized) {
console.log('✅ Clerk hooks initialized on mount')
}
}
})
const setToken = (token: string, loginType: LoginType = 'normal') => {
state.token = token
state.loginType = loginType
localStorage.setItem('token', token)
localStorage.setItem('loginType', loginType)
useCookie('token').value = token
router.push('/')
}
const logout = async () => {
try {
// 如果是 Clerk 登录类型,必须确保 Clerk 登出
if (state.loginType === 'clerk') {
console.log('�� 执行 Clerk 登出...')
// 如果还没初始化,尝试初始化
if (!clerkInitialized) {
console.log('📡 Clerk 未初始化,尝试初始化...')
initClerkHooks()
}
// 确保在客户端环境下执行 Clerk 登出
if (import.meta.client && typeof window !== 'undefined') {
let clerkLogoutSuccess = false
// 尝试使用 auth.signOut
if (auth?.signOut?.value) {
try {
await auth.signOut.value()
clerkLogoutSuccess = true
console.log('✅ auth.signOut 执行成功')
} catch (error) {
console.warn('⚠️ auth.signOut 失败:', error)
}
}
// 尝试使用 clerk.signOut(备用方案)
if (clerk?.value?.signOut && !clerkLogoutSuccess) {
try {
await clerk.value.signOut()
clerkLogoutSuccess = true
console.log('✅ clerk.signOut 执行成功')
} catch (error) {
console.warn('⚠️ clerk.signOut 失败:', error)
}
}
// 如果 Clerk 登出都失败了,至少清理 Clerk cookies
if (!clerkLogoutSuccess) {
console.warn('🚨 Clerk 登出失败,手动清理 Clerk cookies...')
const clerkCookies = [
'__client',
'__client_uat',
'__session',
'__clerk_db_jwt',
'__clerk_hs_db_jwt',
]
clerkCookies.forEach(cookieName => {
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`
})
}
} else {
console.warn('⚠️ 不在客户端环境,跳过 Clerk 登出')
}
}
} catch (e) {
console.error('❌ Clerk 登出过程出错:', e)
}
// 清理本地状态(无论 Clerk 登出是否成功)
console.log('🧹 清理本地状态...')
state.token = ''
state.loginType = 'normal'
localStorage.removeItem('token')
localStorage.removeItem('loginType')
const tokenCookie = useCookie('token')
tokenCookie.value = null
console.log('✅ 登出完成')
// 智能跳转:只有在需要认证的页面才跳转
if (import.meta.client && typeof window !== 'undefined') {
const currentPath = window.location.pathname
const protectedRoutes = ['/chat', '/dashboard', '/profile']
const isProtectedRoute = protectedRoutes.some(route =>
currentPath.startsWith(route)
)
if (isProtectedRoute) {
console.log(`�� 当前在受保护页面 ${currentPath},跳转到首页`)
router.push('/')
} else {
console.log(`📍 当前在公开页面 ${currentPath},无需跳转`)
}
}
}
return {
value: state,
setToken,
logout,
}
})
2. 类型定义
typescript
// src/stores/app/types.ts
// 登录类型
export type LoginType = 'normal' | 'google' | 'github' | 'clerk'
export interface AppState {
isLoading: boolean
user: any | null
isAuthenticated: boolean
}
样式定制
1. CSS样式覆盖
css
/* src/assets/css/main.css */
/* Clerk 样式覆盖 */
.cl-card.cl-signIn-start {
gap: 0 !important;
}
.cl-main {
gap: 0 !important;
}
.cl-cardBox {
box-shadow: none !important;
border-radius: unset !important;
}
.cl-formButtonPrimary {
background-color: #32f08b !important;
border: none !important;
box-shadow: none !important;
color: #1a1b1d !important;
border-radius: 6px !important;
height: 44px !important;
}
.cl-socialButtons {
display: flex !important;
flex-direction: column !important;
gap: 12px !important;
width: 100% !important;
}
.cl-socialButtonsButton {
height: 48px !important;
}
.cl-socialButtonsButtonText {
font-size: 16px !important;
font-weight: 400 !important;
}
.cl-dividerRow {
margin: 18px 0 !important;
}
.cl-dividerText {
background-color: transparent !important;
}
.cl-dividerLine {
background-color: rgba(255, 255, 255, 0.1) !important;
}
.cl-formFieldInput {
max-height: fit-content !important;
height: 48px !important;
}
.cl-otpCodeFieldInput {
border-color: rgba(255, 255, 255, 0.2) !important;
}
2. 主题配置
typescript
// 深色主题配置
const darkTheme = {
baseTheme: dark,
variables: {
colorPrimary: '#00d084',
colorBackground: 'transparent',
colorInputBackground: '#2a2a2a',
colorInputText: '#ffffff',
colorText: '#ffffff',
colorTextSecondary: '#9ca3af',
colorTextOnPrimaryBackground: '#ffffff',
colorNeutral: '#404040',
borderRadius: '8px',
fontSize: '16px',
spacingUnit: '1rem',
},
elements: {
card: {
backgroundColor: 'transparent',
boxShadow: 'none',
border: 'none',
padding: '0',
},
headerTitle: {
display: 'none',
},
socialButtonsBlock: {
display: 'flex',
flexDirection: 'column',
gap: '12px',
width: '100%',
},
socialButtonsBlockButton: {
height: '48px',
backgroundColor: '#2a2a2a',
borderColor: '#404040',
color: '#ffffff',
borderRadius: '8px',
border: '1px solid #404040',
fontSize: '16px',
fontWeight: '400',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '0',
'&:hover': {
backgroundColor: '#333333',
borderColor: '#404040',
},
'&:focus': {
backgroundColor: '#333333',
borderColor: '#404040',
boxShadow: 'none',
},
},
formButtonPrimary: {
backgroundColor: '#00d084',
color: '#ffffff',
borderRadius: '8px',
height: '48px',
fontSize: '16px',
fontWeight: '500',
'&:hover': {
backgroundColor: '#00b876',
},
},
},
}
🛡️ 路由保护
1. 中间件配置
typescript
// src/middleware/auth.global.ts
export default defineNuxtRouteMiddleware((to) => {
const { isSignedIn } = useAuth()
// 需要认证的路由
const protectedRoutes = ['/chat', '/dashboard', '/profile']
const isProtectedRoute = protectedRoutes.some(route =>
to.path.startsWith(route)
)
if (isProtectedRoute && !isSignedIn.value) {
return navigateTo('/login')
}
})
2. 页面级保护
vue
<!-- src/pages/chat/index.vue -->
<template>
<div v-if="isSignedIn">
<!-- 受保护的内容 -->
<ChatInterface />
</div>
<div v-else>
<p>请先登录</p>
<NuxtLink to="/login">去登录</NuxtLink>
</div>
</template>
<script setup>
import { useAuth } from '@clerk/vue'
const { isSignedIn } = useAuth()
</script>
3. 组件级保护
vue
<!-- src/components/ProtectedComponent.vue -->
<template>
<div v-if="isSignedIn">
<slot />
</div>
<div v-else>
<slot name="fallback">
<p>需要登录才能查看此内容</p>
</slot>
</div>
</template>
<script setup>
import { useAuth } from '@clerk/vue'
const { isSignedIn } = useAuth()
</script>
🔌 API集成
1. 服务端API
typescript
// server/api/user.ts
import { clerkClient } from '@clerk/clerk-sdk-node'
export default defineEventHandler(async (event) => {
try {
// 获取当前用户
const { userId } = event.context.auth
if (!userId) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized'
})
}
// 获取用户信息
const user = await clerkClient.users.getUser(userId)
return {
id: user.id,
email: user.emailAddresses[0]?.emailAddress,
firstName: user.firstName,
lastName: user.lastName,
imageUrl: user.imageUrl,
}
} catch (error) {
console.error('Error fetching user:', error)
throw createError({
statusCode: 500,
statusMessage: 'Internal Server Error'
})
}
})
2. 客户端API调用
typescript
// src/composables/useUser.ts
import { useAuth } from '@clerk/vue'
export const useUser = () => {
const { isSignedIn, user } = useAuth()
const fetchUserData = async () => {
if (!isSignedIn.value) {
throw new Error('User not authenticated')
}
try {
const response = await $fetch('/api/user')
return response
} catch (error) {
console.error('Error fetching user data:', error)
throw error
}
}
return {
isSignedIn,
user,
fetchUserData,
}
}
3. 组织API
typescript
// server/api/organization.ts
import { clerkClient } from '@clerk/clerk-sdk-node'
export default defineEventHandler(async (event) => {
try {
const { userId } = event.context.auth
if (!userId) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized'
})
}
// 获取用户所属组织
const memberships = await clerkClient.users.getOrganizationMembershipList({
userId
})
return memberships.data
} catch (error) {
console.error('Error fetching organizations:', error)
throw createError({
statusCode: 500,
statusMessage: 'Internal Server Error'
})
}
})
🚀 最佳实践
1. 错误处理
typescript
// src/composables/useClerkError.ts
export const useClerkError = () => {
const handleClerkError = (error: any) => {
console.error('Clerk Error:', error)
switch (error.code) {
case 'form_identifier_not_found':
return '邮箱地址不存在'
case 'form_password_incorrect':
return '密码错误'
case 'form_code_incorrect':
return '验证码错误'
case 'oauth_callback_error':
return '社交登录失败,请重试'
default:
return '登录失败,请重试'
}
}
return {
handleClerkError,
}
}
2. 加载状态管理
typescript
// src/composables/useClerkLoading.ts
export const useClerkLoading = () => {
const isLoading = ref(false)
const loadingMessage = ref('')
const setLoading = (loading: boolean, message?: string) => {
isLoading.value = loading
loadingMessage.value = message || ''
}
return {
isLoading: readonly(isLoading),
loadingMessage: readonly(loadingMessage),
setLoading,
}
}
3. 用户状态同步
typescript
// src/composables/useUserSync.ts
export const useUserSync = () => {
const { user, isSignedIn } = useAuth()
const appStore = useAppStore()
// 监听用户状态变化
watch(isSignedIn, (signedIn) => {
if (signedIn && user.value) {
// 同步用户信息到本地状态
appStore.setUser({
id: user.value.id,
email: user.value.emailAddresses[0]?.emailAddress,
firstName: user.value.firstName,
lastName: user.value.lastName,
imageUrl: user.value.imageUrl,
})
} else {
// 清除本地用户信息
appStore.clearUser()
}
})
return {
user,
isSignedIn,
}
}
🔧 故障排除
1. 常见问题
问题1: Clerk未初始化
typescript
// 解决方案:检查环境变量
console.log('Clerk Key:', process.env.NUXT_PUBLIC_CLERK_PUBLISHABLE_KEY)
问题2: 样式不生效
css
/* 解决方案:增加CSS优先级 */
.cl-formButtonPrimary {
background-color: #32f08b !important;
}
问题3: 路由重定向循环
typescript
// 解决方案:检查中间件配置
export default defineNuxtRouteMiddleware((to) => {
const { isSignedIn } = useAuth()
// 避免在登录页面检查认证状态
if (to.path === '/login' && isSignedIn.value) {
return navigateTo('/')
}
})
2. 调试技巧
typescript
// 启用Clerk调试模式
clerk: {
debug: true,
// ...其他配置
}
3. 性能优化
typescript
// 懒加载Clerk组件
const SignIn = defineAsyncComponent(() => import('@clerk/vue').then(m => m.SignIn))
const SignUp = defineAsyncComponent(() => import('@clerk/vue').then(m => m.SignUp))
参考资源
这个文档提供了完整的Clerk集成指南,涵盖了从安装配置到高级功能的各个方面。您可以根据项目需求选择相应的功能进行实现。