阶段4: 用户系统
实现完整的用户认证和授权体系:JWT 认证(Access Token + Refresh Token)、RBAC 权限控制(guest / hotel_admin / admin)、GitHub OAuth 第三方登录。前端集成登录流程,所有需要认证的 API 都受保护。
| 模块 | 核心知识点 | 在本阶段的作用 |
|---|---|---|
| Module 12 | JWT / OAuth / RBAC | 认证方案设计、令牌管理、角色权限 |
| Module 14 | 密码存储 / CSRF | bcrypt 哈希、安全头 |
| Module 13 | CORS | 跨域认证头配置 |
步骤1: 注册与登录
Section titled “步骤1: 注册与登录”实现邮箱密码注册、登录,签发 JWT Access Token 和 Refresh Token。
你是一名 Go+Gin 后端工程师。请为酒店预订系统实现注册和登录功能。
## 文件结构
### 1. server/auth/jwt.go — JWT 工具函数
```gopackage auth
import ( "time" "github.com/golang-jwt/jwt/v5")
var jwtSecret []byte // 从环境变量加载
type Claims struct { UserID uint `json:"user_id"` Email string `json:"email"` Role string `json:"role"` jwt.RegisteredClaims}
// GenerateAccessToken 生成访问令牌(15分钟有效期)func GenerateAccessToken(userID uint, email, role string) (string, error) { claims := Claims{ UserID: userID, Email: email, Role: role, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), IssuedAt: jwt.NewNumericDate(time.Now()), Issuer: "hotel-reservation", }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(jwtSecret)}
// GenerateRefreshToken 生成刷新令牌(7天有效期)func GenerateRefreshToken(userID uint, email, role string) (string, error) { claims := Claims{ UserID: userID, Email: email, Role: role, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)), IssuedAt: jwt.NewNumericDate(time.Now()), Issuer: "hotel-reservation", }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(jwtSecret)}
// ParseToken 解析并验证令牌func ParseToken(tokenString string) (*Claims, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { return jwtSecret, nil }) if err != nil { return nil, err } claims, ok := token.Claims.(*Claims) if !ok || !token.Valid { return nil, errors.New("invalid token") } return claims, nil}
// InitJWT 从环境变量加载密钥func InitJWT(secret string) { jwtSecret = []byte(secret)}2. server/handlers/auth.go — 注册和登录
Section titled “2. server/handlers/auth.go — 注册和登录”POST /api/v1/auth/register
Section titled “POST /api/v1/auth/register”请求体:
{ "email": "user@example.com", "password": "123456", "name": "张三", "phone": "13800138000"}逻辑:
- 验证参数(email 格式、password 长度 >= 6)
- 检查 email 是否已注册(唯一索引会报错,但先查一次给友好提示)
- 用 bcrypt 哈希密码(cost = 10)
- 创建用户(role = “guest”)
- 生成 access_token 和 refresh_token
- 返回用户信息 + 两个 token
响应:
{ "data": { "user": { "id": 1, "email": "user@example.com", "name": "张三", "role": "guest" }, "access_token": "eyJhbGci...", "refresh_token": "eyJhbGci...", "expires_in": 900 }}POST /api/v1/auth/login
Section titled “POST /api/v1/auth/login”请求体:
{ "email": "user@example.com", "password": "123456"}逻辑:
- 根据 email 查找用户
- 用 bcrypt.CompareHashAndPassword 验证密码
- 生成 token 对
- 返回同上格式
错误:
- 401: “邮箱或密码错误”(不要区分”邮箱不存在”和”密码错误”,避免信息泄露)
POST /api/v1/auth/refresh
Section titled “POST /api/v1/auth/refresh”请求体:
{ "refresh_token": "eyJhbGci..."}逻辑:
- 解析 refresh_token
- 检查是否过期
- 生成新的 access_token(不换 refresh_token)
- 返回新 access_token
请输出所有文件的完整 Go 代码。包含完整的参数验证和错误处理。
### 步骤2: 认证中间件你是一名 Go+Gin 后端工程师。
请创建 server/middleware/auth.go,实现 JWT 认证中间件。
AuthRequired 中间件
Section titled “AuthRequired 中间件”func AuthRequired() gin.HandlerFunc { return func(c *gin.Context) { // 1. 从 Authorization header 获取 token // 格式:Bearer <token> authHeader := c.GetHeader("Authorization") if authHeader == "" { c.JSON(401, gin.H{"error": "未登录,请先登录"}) c.Abort() return }
// 2. 提取 token(去掉 "Bearer " 前缀) parts := strings.SplitN(authHeader, " ", 2) if len(parts) != 2 || parts[0] != "Bearer" { c.JSON(401, gin.H{"error": "Authorization 格式错误"}) c.Abort() return }
// 3. 解析并验证 token claims, err := auth.ParseToken(parts[1]) if err != nil { c.JSON(401, gin.H{"error": "令牌无效或已过期"}) c.Abort() return }
// 4. 将用户信息注入到 Gin context 中 c.Set("user_id", claims.UserID) c.Set("user_email", claims.Email) c.Set("user_role", claims.Role)
c.Next() }}
// GetUserID 从 context 中获取当前用户 ID(在 handler 中使用)func GetUserID(c *gin.Context) uint { userID, _ := c.Get("user_id") return userID.(uint)}
// GetUserRole 从 context 中获取当前用户角色func GetUserRole(c *gin.Context) string { role, _ := c.Get("user_role") return role.(string)}更新 server/routes.go:
- 公开路由(不需要认证):health, search, hotel detail, auth/register, auth/login, auth/refresh, webhooks
- 需要认证的路由:bookings(创建/列表/详情/取消/支付)
- 需要认证的路由组用 AuthRequired() 中间件
同时更新 booking handler,从 context 获取 user_id,不再从请求体传入。
请输出完整的 middleware/auth.go 和更新后的 routes.go。
### 步骤3: RBAC 权限控制你是一名 Go+Gin 后端工程师。
请实现 RBAC 权限控制中间件。
| 角色 | 权限 |
|---|---|
| guest | 搜索、查看酒店、预订、管理自己的订单 |
| hotel_admin | guest 的所有权限 + 管理自己的酒店和房型 |
| admin | 所有权限 + 平台管理 |
创建 server/middleware/rbac.go
Section titled “创建 server/middleware/rbac.go”package middleware
// RoleRequired 检查用户角色是否在允许列表中func RoleRequired(allowedRoles ...string) gin.HandlerFunc { return func(c *gin.Context) { role := GetUserRole(c)
allowed := false for _, r := range allowedRoles { if role == r { allowed = true break } }
if !allowed { c.JSON(403, gin.H{"error": "权限不足"}) c.Abort() return }
c.Next() }}
// ResourceOwnerOrAdmin 检查是否是资源所有者或管理员// 用于"用户只能操作自己的订单"这种场景func ResourceOwnerOrAdmin(getOwnerID func(*gin.Context) uint) gin.HandlerFunc { return func(c *gin.Context) { currentUserID := GetUserID(c) currentRole := GetUserRole(c)
// admin 可以操作任何资源 if currentRole == "admin" { c.Next() return }
// 非 admin 只能操作自己的资源 ownerID := getOwnerID(c) if ownerID != currentUserID { c.JSON(403, gin.H{"error": "无权操作此资源"}) c.Abort() return }
c.Next() }}// 公开路由v1.GET("/health", handlers.Health)v1.GET("/hotels/search", handlers.SearchHotels)v1.GET("/hotels/:id", handlers.GetHotel)v1.POST("/auth/register", handlers.Register)v1.POST("/auth/login", handlers.Login)v1.POST("/auth/refresh", handlers.RefreshToken)
// 需要登录authenticated := v1.Group("")authenticated.Use(middleware.AuthRequired()){ authenticated.POST("/bookings", handlers.CreateBooking) authenticated.GET("/bookings", handlers.ListBookings) authenticated.GET("/bookings/:id", handlers.GetBooking) authenticated.POST("/bookings/:id/cancel", handlers.CancelBooking) authenticated.POST("/bookings/:id/pay", handlers.CreatePaymentIntent)}
// 酒店管理(需要 hotel_admin 或 admin)hotelAdmin := v1.Group("/admin")hotelAdmin.Use(middleware.AuthRequired())hotelAdmin.Use(middleware.RoleRequired("hotel_admin", "admin")){ hotelAdmin.GET("/hotels/:id/room-types", handlers.ListRoomTypes) hotelAdmin.POST("/hotels/:id/room-types", handlers.CreateRoomType) hotelAdmin.PUT("/room-types/:id", handlers.UpdateRoomType) hotelAdmin.PUT("/room-types/:id/inventory", handlers.UpdateInventory)}
// 平台管理(仅 admin)platform := v1.Group("/platform")platform.Use(middleware.AuthRequired())platform.Use(middleware.RoleRequired("admin")){ platform.GET("/hotels", handlers.ListAllHotels) platform.PUT("/hotels/:id/status", handlers.UpdateHotelStatus)}请输出完整的 middleware/rbac.go 和更新后的 routes.go。
### 步骤4: GitHub OAuth 登录你是一名 Go+Gin 后端工程师。
请实现 GitHub OAuth 登录(授权码流程)。
- 前端点击”GitHub 登录”→ 跳转到 GitHub 授权页面
- 用户授权后,GitHub 重定向到回调 URL,带上 code
- 后端用 code 换取 access_token
- 后端用 access_token 获取 GitHub 用户信息(email, name, avatar)
- 查找或创建用户
- 生成 JWT,重定向到前端并传递 token
创建 server/handlers/oauth.go
Section titled “创建 server/handlers/oauth.go”GET /api/v1/auth/github
Section titled “GET /api/v1/auth/github”重定向到 GitHub 授权页面:
https://github.com/login/oauth/authorize? client_id=xxx& redirect_uri=http://localhost:8080/api/v1/auth/github/callback& scope=user:email& state=随机字符串state 参数存到 cookie(防 CSRF)。
GET /api/v1/auth/github/callback
Section titled “GET /api/v1/auth/github/callback”func GitHubCallback(c *gin.Context) { code := c.Query("code") state := c.Query("state")
// 1. 验证 state(和 cookie 中的比较) // 2. 用 code 换 access_token // POST https://github.com/login/oauth/access_token // 参数:client_id, client_secret, code // 3. 用 access_token 获取用户信息 // GET https://api.github.com/user // Header: Authorization: Bearer <access_token> // 4. 获取用户 email(如果 user.email 为空,调用 /user/emails) // 5. 查找或创建用户 // 如果 email 已存在 → 关联 GitHub(更新 github_id) // 如果不存在 → 创建新用户(随机密码,role = guest) // 6. 生成 JWT // 7. 重定向到前端,query string 带 token // http://localhost:5173/auth/callback?access_token=xxx&refresh_token=xxx}更新 User 模型
Section titled “更新 User 模型”添加字段:
- GitHubID string
gorm:"size:50;index" json:"-" - AvatarURL string
gorm:"size:500" json:"avatar_url"
环境变量(server/.env.example 追加)
Section titled “环境变量(server/.env.example 追加)”GITHUB_CLIENT_ID=xxx GITHUB_CLIENT_SECRET=xxx
- GitHub API 需要设置 User-Agent header
- access_token 请求需要设置 Accept: application/json
- 生产环境 redirect_uri 要改成 HTTPS
- state 用 crypto/rand 生成 32 字节随机字符串
请输出完整的 oauth.go 代码。包含 HTTP 请求的错误处理。
### 步骤5: 前端集成你是一名 React + Tailwind CSS 前端工程师。
请实现酒店预订系统的前端登录流程。
1. 更新 web/src/api/client.js
Section titled “1. 更新 web/src/api/client.js”- 从 localStorage 读取 access_token,添加到请求头
- 401 响应时,尝试用 refresh_token 刷新
- 刷新成功 → 重试原请求
- 刷新失败 → 清除 token,跳转到 /login
class ApiClient { constructor() { this.baseURL = ''; }
async request(method, url, data = null) { const headers = { 'Content-Type': 'application/json' }; const token = localStorage.getItem('access_token'); if (token) { headers['Authorization'] = `Bearer ${token}`; }
let response = await fetch(this.baseURL + url, { method, headers, body: data ? JSON.stringify(data) : null, });
// 401 时尝试刷新 token if (response.status === 401) { const refreshed = await this.refreshToken(); if (refreshed) { // 用新 token 重试 headers['Authorization'] = `Bearer ${localStorage.getItem('access_token')}`; response = await fetch(this.baseURL + url, { method, headers, body: data ? JSON.stringify(data) : null, }); } else { localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); window.location.href = '/login'; return; } }
if (!response.ok) { const err = await response.json(); throw new Error(err.error || '请求失败'); }
return response.json(); }
async refreshToken() { const refreshToken = localStorage.getItem('refresh_token'); if (!refreshToken) return false; try { const res = await fetch('/api/v1/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refreshToken }), }); if (!res.ok) return false; const data = await res.json(); localStorage.setItem('access_token', data.data.access_token); return true; } catch { return false; } }
get(url) { return this.request('GET', url); } post(url, data) { return this.request('POST', url, data); } put(url, data) { return this.request('PUT', url, data); } delete(url) { return this.request('DELETE', url); }}
export const api = new ApiClient();2. 创建 web/src/pages/LoginPage.jsx
Section titled “2. 创建 web/src/pages/LoginPage.jsx”登录页面:
- 邮箱 + 密码登录表单
- “没有账号?去注册” 链接
- “GitHub 登录” 按钮(跳转到 /api/v1/auth/github)
- 登录成功后存储 token,跳转到首页
- 显示错误信息
3. 创建 web/src/pages/RegisterPage.jsx
Section titled “3. 创建 web/src/pages/RegisterPage.jsx”注册页面:
- 邮箱、密码、确认密码、姓名、手机号
- 密码强度提示(至少6位)
- 注册成功后自动登录并跳转
4. 创建 web/src/pages/AuthCallbackPage.jsx
Section titled “4. 创建 web/src/pages/AuthCallbackPage.jsx”OAuth 回调页面(路由:/auth/callback):
- 从 URL query string 获取 access_token 和 refresh_token
- 存储到 localStorage
- 跳转到首页
- 显示”登录中…“
5. 创建 web/src/hooks/useAuth.js
Section titled “5. 创建 web/src/hooks/useAuth.js”认证状态 Hook:
export function useAuth() { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true);
useEffect(() => { const token = localStorage.getItem('access_token'); if (token) { // 解析 JWT payload(不验签,仅读取) const payload = JSON.parse(atob(token.split('.')[1])); setUser({ id: payload.user_id, email: payload.email, role: payload.role, }); } setLoading(false); }, []);
const login = (accessToken, refreshToken) => { localStorage.setItem('access_token', accessToken); localStorage.setItem('refresh_token', refreshToken); const payload = JSON.parse(atob(accessToken.split('.')[1])); setUser({ id: payload.user_id, email: payload.email, role: payload.role }); };
const logout = () => { localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); setUser(null); window.location.href = '/login'; };
const isAdmin = () => user?.role === 'admin'; const isHotelAdmin = () => user?.role === 'hotel_admin' || user?.role === 'admin';
return { user, loading, login, logout, isAdmin, isHotelAdmin };}6. 更新 web/src/components/Navbar.jsx
Section titled “6. 更新 web/src/components/Navbar.jsx”- 未登录时:显示”登录”和”注册”按钮
- 已登录时:显示用户名、“我的订单”、“退出”按钮
- admin 用户显示”管理后台”入口
7. 更新路由 App.jsx
Section titled “7. 更新路由 App.jsx”添加路由:/login, /register, /auth/callback 保护路由:/bookings 需要登录,未登录跳转 /login
请输出所有文件的完整代码。
---
## 检查清单
- [ ] 注册新用户后返回 JWT,前端能正常存储- [ ] 登录成功返回 access_token 和 refresh_token- [ ] 错误密码返回 401,且不泄露"邮箱不存在"信息- [ ] 带 token 访问 /bookings 正常,不带 token 返回 401- [ ] access_token 过期后,自动用 refresh_token 刷新- [ ] guest 用户访问 /admin 路由返回 403- [ ] GitHub OAuth 登录完整流程通畅(需配置 GitHub App)- [ ] 前端未登录时自动跳转到登录页
---
## 常见踩坑
1. **JWT 密钥写死在代码里** — 密钥必须从环境变量读取,不要写在代码中。即使是开发环境也不要提交 `.env` 到 Git。`.env.example` 中只放模板,不放真实密钥。
2. **bcrypt 的 cost 设太高** — cost 每增加 1,耗时翻倍。cost=10 约需 100ms,cost=14 约需 1.6s。开发环境用 10 就够了。生产环境根据服务器性能选 12-14。
3. **前端解析 JWT 当作信任来源** — `atob(token.split('.')[1])` 只是解码,不验证签名。前端从 JWT 读取 user_id/role 仅用于 UI 展示(决定显示哪些按钮),真正的权限校验必须在后端。攻击者可以篡改前端的 localStorage。
4. **refresh_token 没有存储到数据库** — 当前方案的 refresh_token 是无状态的(和 access_token 一样只靠签名验证)。缺点是无法主动吊销。进阶方案:refresh_token 存数据库,刷新时验证是否存在,退出时删除。
5. **OAuth callback 的 state 参数没校验** — state 参数防止 CSRF 攻击。流程:生成随机 state → 存 cookie → 发给 GitHub → 回调时比对 cookie 中的 state 和 URL 中的 state。如果不校验,攻击者可以让受害者登录攻击者的账号。