Skip to content

23.11-登录态联调

要点

  • 登录态管理是前后端联调的核心问题
  • 常见方案:JWT Token、Session Cookie、OAuth
  • 前端需要处理 token 存储、自动刷新、过期处理
  • 后端需要处理 token 验证、刷新、权限控制

内容

1. JWT 认证方案

1.1 后端生成和验证 Token

typescript
// 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
  }
}
typescript
// 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

typescript
// 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()
}
typescript
// 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. 登录页面

tsx
// 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 后端实现刷新接口

typescript
// 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 前端自动刷新

typescript
// 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. 受保护的路由

tsx
// 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}</>
}
tsx
// src/app/dashboard/page.tsx
import { AuthGuard } from '@/components/AuthGuard'

export default function DashboardPage() {
  return (
    <AuthGuard>
      <h1>仪表盘</h1>
      {/* 受保护的内容 */}
    </AuthGuard>
  )
}

5. 退出登录

typescript
// src/lib/auth-client.ts
export function logout(): void {
  removeToken()
  window.location.href = '/login'
}
tsx
// 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 刷新机制,实现无缝的用户体验。

基于 MIT 协议开源