23.11-登录态联调
要点
- 登录态管理是前后端联调的核心问题
- 常见方案:JWT Token、Session Cookie、OAuth
- 前端需要处理 token 存储、自动刷新、过期处理
- 后端需要处理 token 验证、刷新、权限控制
内容
1. JWT 认证方案
1.1 后端生成和验证 Token
// src/lib/auth.ts
import { sign, verify } from 'hono/jwt'
export async function generateToken(userId: string, env: Env): Promise<string> {
return sign(
{
userId,
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, // 7 天
},
env.JWT_SECRET
)
}
export async function verifyToken(token: string, env: Env) {
try {
const payload = await verify(token, env.JWT_SECRET)
return payload
} catch (error) {
return null
}
}// src/index.ts
import { Hono } from 'hono'
import { generateToken, verifyToken } from './lib/auth'
const app = new Hono()
// 登录
app.post('/api/auth/login', async (c) => {
const { email, password } = await c.req.json()
// 验证用户
const user = await verifyUser(email, password)
if (!user) {
return c.json({ error: 'Invalid credentials' }, 401)
}
// 生成 token
const token = await generateToken(user.id, c.env)
return c.json({
token,
user: {
id: user.id,
email: user.email,
name: user.name,
},
})
})
// 受保护的路由
app.get('/api/profile', async (c) => {
const authHeader = c.req.header('Authorization')
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized' }, 401)
}
const token = authHeader.slice(7)
const payload = await verifyToken(token, c.env)
if (!payload) {
return c.json({ error: 'Invalid token' }, 401)
}
const user = await getUser(payload.userId)
return c.json({ user })
})1.2 前端存储和使用 Token
// src/lib/auth-client.ts
const TOKEN_KEY = 'auth_token'
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token)
}
export function removeToken(): void {
localStorage.removeItem(TOKEN_KEY)
}
export function isAuthenticated(): boolean {
return !!getToken()
}// src/lib/api.ts
import { hc } from 'hono/client'
import type { AppType } from '@api/src/index'
import { getToken, removeToken } from './auth-client'
export const api = hc<AppType>(process.env.NEXT_PUBLIC_API_URL!, {
fetch: async (input, init) => {
const token = getToken()
const headers = new Headers(init?.headers)
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
const res = await fetch(input, { ...init, headers })
// 处理 401
if (res.status === 401) {
removeToken()
window.location.href = '/login'
}
return res
},
})2. 登录页面
// src/app/login/page.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { api } from '@/lib/api'
import { setToken } from '@/lib/auth-client'
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
const res = await api.api.auth.login.$post({
json: { email, password },
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Login failed')
}
const data = await res.json()
setToken(data.token)
// 跳转到首页
router.push('/')
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit}>
<h1>登录</h1>
{error && <div className="error">{error}</div>}
<input
type="email"
placeholder="邮箱"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button type="submit" disabled={loading}>
{loading ? '登录中...' : '登录'}
</button>
</form>
)
}3. Token 自动刷新
3.1 后端实现刷新接口
// src/index.ts
app.post('/api/auth/refresh', async (c) => {
const authHeader = c.req.header('Authorization')
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized' }, 401)
}
const token = authHeader.slice(7)
const payload = await verifyToken(token, c.env)
if (!payload) {
return c.json({ error: 'Invalid token' }, 401)
}
// 生成新 token
const newToken = await generateToken(payload.userId, c.env)
return c.json({ token: newToken })
})3.2 前端自动刷新
// src/lib/api.ts
import { getToken, setToken, removeToken } from './auth-client'
let isRefreshing = false
let refreshPromise: Promise<string> | null = null
async function refreshToken(): Promise<string> {
if (isRefreshing && refreshPromise) {
return refreshPromise
}
isRefreshing = true
refreshPromise = (async () => {
const currentToken = getToken()
if (!currentToken) {
throw new Error('No token')
}
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/auth/refresh`, {
headers: {
Authorization: `Bearer ${currentToken}`,
},
})
if (!res.ok) {
removeToken()
window.location.href = '/login'
throw new Error('Refresh failed')
}
const data = await res.json()
setToken(data.token)
isRefreshing = false
return data.token
})()
return refreshPromise
}
export const api = hc<AppType>(process.env.NEXT_PUBLIC_API_URL!, {
fetch: async (input, init) => {
let token = getToken()
if (!token) {
window.location.href = '/login'
throw new Error('Not authenticated')
}
const headers = new Headers(init?.headers)
headers.set('Authorization', `Bearer ${token}`)
let res = await fetch(input, { ...init, headers })
// 如果 401,尝试刷新 token
if (res.status === 401) {
try {
token = await refreshToken()
headers.set('Authorization', `Bearer ${token}`)
res = await fetch(input, { ...init, headers })
} catch (error) {
throw error
}
}
return res
},
})4. 受保护的路由
// src/components/AuthGuard.tsx
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { isAuthenticated } from '@/lib/auth-client'
export function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter()
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!isAuthenticated()) {
router.push('/login')
} else {
setLoading(false)
}
}, [router])
if (loading) {
return <div>加载中...</div>
}
return <>{children}</>
}// src/app/dashboard/page.tsx
import { AuthGuard } from '@/components/AuthGuard'
export default function DashboardPage() {
return (
<AuthGuard>
<h1>仪表盘</h1>
{/* 受保护的内容 */}
</AuthGuard>
)
}5. 退出登录
// src/lib/auth-client.ts
export function logout(): void {
removeToken()
window.location.href = '/login'
}// src/components/LogoutButton.tsx
'use client'
import { logout } from '@/lib/auth-client'
export function LogoutButton() {
return (
<button onClick={logout}>
退出登录
</button>
)
}6. 小结
登录态联调的关键点:
- 后端:生成 JWT token,验证 token,提供刷新接口
- 前端:存储 token,自动携带 token,处理 401
- Token 刷新:自动刷新过期 token,避免频繁登录
- 受保护路由:使用 AuthGuard 组件保护需要登录的页面
- 退出登录:清除 token,跳转到登录页
一句话带走:
登录态联调的核心是 token 的生成、存储、验证和刷新。前端自动携带 token,后端验证权限,配合 token 刷新机制,实现无缝的用户体验。