跳转到内容

阶段4: 用户系统

实现完整的用户认证和授权体系:JWT 认证(Access Token + Refresh Token)、RBAC 权限控制(guest / hotel_admin / admin)、GitHub OAuth 第三方登录。前端集成登录流程,所有需要认证的 API 都受保护。


模块核心知识点在本阶段的作用
Module 12JWT / OAuth / RBAC认证方案设计、令牌管理、角色权限
Module 14密码存储 / CSRFbcrypt 哈希、安全头
Module 13CORS跨域认证头配置

实现邮箱密码注册、登录,签发 JWT Access Token 和 Refresh Token。

你是一名 Go+Gin 后端工程师。请为酒店预订系统实现注册和登录功能。
## 文件结构
### 1. server/auth/jwt.go — JWT 工具函数
```go
package 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 — 注册和登录”

请求体:

{
"email": "[email protected]",
"password": "123456",
"name": "张三",
"phone": "13800138000"
}

逻辑:

  1. 验证参数(email 格式、password 长度 >= 6)
  2. 检查 email 是否已注册(唯一索引会报错,但先查一次给友好提示)
  3. 用 bcrypt 哈希密码(cost = 10)
  4. 创建用户(role = “guest”)
  5. 生成 access_token 和 refresh_token
  6. 返回用户信息 + 两个 token

响应:

{
"data": {
"user": {
"id": 1,
"email": "[email protected]",
"name": "张三",
"role": "guest"
},
"access_token": "eyJhbGci...",
"refresh_token": "eyJhbGci...",
"expires_in": 900
}
}

请求体:

{
"email": "[email protected]",
"password": "123456"
}

逻辑:

  1. 根据 email 查找用户
  2. 用 bcrypt.CompareHashAndPassword 验证密码
  3. 生成 token 对
  4. 返回同上格式

错误:

  • 401: “邮箱或密码错误”(不要区分”邮箱不存在”和”密码错误”,避免信息泄露)

请求体:

{
"refresh_token": "eyJhbGci..."
}

逻辑:

  1. 解析 refresh_token
  2. 检查是否过期
  3. 生成新的 access_token(不换 refresh_token)
  4. 返回新 access_token

请输出所有文件的完整 Go 代码。包含完整的参数验证和错误处理。

### 步骤2: 认证中间件

你是一名 Go+Gin 后端工程师。

请创建 server/middleware/auth.go,实现 JWT 认证中间件。

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_adminguest 的所有权限 + 管理自己的酒店和房型
admin所有权限 + 平台管理
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 登录(授权码流程)。

  1. 前端点击”GitHub 登录”→ 跳转到 GitHub 授权页面
  2. 用户授权后,GitHub 重定向到回调 URL,带上 code
  3. 后端用 code 换取 access_token
  4. 后端用 access_token 获取 GitHub 用户信息(email, name, avatar)
  5. 查找或创建用户
  6. 生成 JWT,重定向到前端并传递 token

重定向到 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)。

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
}

添加字段:

  • 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 前端工程师。

请实现酒店预订系统的前端登录流程。

  • 从 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();

登录页面:

  • 邮箱 + 密码登录表单
  • “没有账号?去注册” 链接
  • “GitHub 登录” 按钮(跳转到 /api/v1/auth/github)
  • 登录成功后存储 token,跳转到首页
  • 显示错误信息

注册页面:

  • 邮箱、密码、确认密码、姓名、手机号
  • 密码强度提示(至少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
  • 跳转到首页
  • 显示”登录中…“

认证状态 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 };
}
  • 未登录时:显示”登录”和”注册”按钮
  • 已登录时:显示用户名、“我的订单”、“退出”按钮
  • admin 用户显示”管理后台”入口

添加路由:/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。如果不校验,攻击者可以让受害者登录攻击者的账号。