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. 小结

登录态联调的关键点:

  1. 后端:生成 JWT token,验证 token,提供刷新接口
  2. 前端:存储 token,自动携带 token,处理 401
  3. Token 刷新:自动刷新过期 token,避免频繁登录
  4. 受保护路由:使用 AuthGuard 组件保护需要登录的页面
  5. 退出登录:清除 token,跳转到登录页

一句话带走:

登录态联调的核心是 token 的生成、存储、验证和刷新。前端自动携带 token,后端验证权限,配合 token 刷新机制,实现无缝的用户体验。