CHAPTER 6 · AUTHENTICATION & PERMISSION
06
用户登录了,为什么还不能访问所有相册?
登录只解决“你是谁”,并不代表你可以访问系统中的全部数据。
一个看似能运行的接口
这段代码只检查了相册是否存在。
func GetAlbum(c *gin.Context) {
id := c.Param("id")
var album Album
result := db.First(&album, id)
if result.Error != nil {
c.JSON(http.StatusNotFound, APIResponse{
Code: "NOT_FOUND",
Message: "相册不存在",
})
return
}
c.JSON(http.StatusOK, APIResponse{
Code: "OK",
Message: "success",
Data: album,
})
}这段代码的问题在哪里?
用户 A 能否访问用户 B 的相册?
修改 URL 中的相册 ID 会发生什么?
私有相册会阻止其他用户访问吗?
前端不展示入口,是否就安全了?
结论:这段代码没有校验当前用户是谁,也没有校验相册属于谁。
能查询到数据 ≠ 有权限访问数据
三个概念
认证、授权、数据权限不是一回事。
认证 Authentication
你是谁?用户是否登录、Token 是否有效、Token 是否过期。
授权 Authorization
你能使用什么功能?普通用户能否访问管理员接口,审核员能否删除内容。
数据权限 Data Permission
你能操作哪一条数据?用户 A 能否修改用户 B 的相册,其他用户能否访问私有相册。
未登录访问“我的相册”
认证系统还不知道当前请求是谁发起的。
普通用户访问管理员接口
授权身份有效,但功能权限不足。
用户 A 删除用户 B 的相册
数据权限身份有效,但目标数据不属于他。
密码安全
数据库里不能保存用户明文密码。
CREATE TABLE users (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL,
password VARCHAR(100) NOT NULL
);username: zhangsan,password: 123456 一旦泄露,就会直接暴露用户密码。
注册时生成哈希
func HashPassword(password string) (string, error) {
passwordHash, err := bcrypt.GenerateFromPassword(
[]byte(password),
bcrypt.DefaultCost,
)
if err != nil {
return "", err
}
return string(passwordHash), nil
}登录时校验密码
func CheckPassword(passwordHash, password string) bool {
err := bcrypt.CompareHashAndPassword(
[]byte(passwordHash),
[]byte(password),
)
return err == nil
}服务端不需要解密用户密码。数据库中保存的应该是密码哈希,而不是原始密码。Base64 不是加密,不要自己设计密码算法。
登录接口
登录成功后返回 Token,而不是返回密码。
type User struct {
ID uint64 `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:50;uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
Role string `gorm:"size:20;not null" json:"role"`
}
func Login(db *gorm.DB, jwtSecret []byte) gin.HandlerFunc {
return func(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, APIResponse{Code: "INVALID_ARGUMENT", Message: "用户名和密码不能为空"})
return
}
var user User
result := db.Where("username = ?", req.Username).First(&user)
if result.Error != nil || !CheckPassword(user.PasswordHash, req.Password) {
c.JSON(http.StatusUnauthorized, APIResponse{Code: "INVALID_CREDENTIALS", Message: "用户名或密码错误"})
return
}
token, err := GenerateToken(user.ID, user.Role, jwtSecret)
if err != nil {
c.JSON(http.StatusInternalServerError, APIResponse{Code: "INTERNAL_ERROR", Message: "登录失败"})
return
}
c.JSON(http.StatusOK, APIResponse{Code: "OK", Message: "登录成功", Data: gin.H{"accessToken": token}})
}
}json:"-" 可以避免 PasswordHash 在 JSON 序列化时返回给前端。用户不存在和密码错误返回同一句话,可以减少账号探测风险。
JWT 基本原理
JWT 是由服务端签名的身份凭证。
type UserClaims struct {
UserID uint64 `json:"userId"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func GenerateToken(userID uint64, role string, secret []byte) (string, error) {
now := time.Now()
claims := UserClaims{
UserID: userID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(2 * time.Hour)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secret)
}JWT 的主要作用不是隐藏数据,而是验证数据由可信服务端签发且未被篡改。Payload 可以被读取,不能保存密码、密码哈希、数据库密码等敏感数据。
JWT 请求流程
后续请求通过 Authorization 携带身份凭证。
用户提交用户名和密码 → 服务端查询用户 → bcrypt 验证密码 → 服务端生成 JWT → 客户端保存 JWT → 后续请求携带 Authorization → 服务端验证 JWT → 获得当前用户身份 → 继续执行接口
GET /api/my/albums
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...JWT 解决身份传递问题,但不会自动解决 Token 撤销、续期和账号风险控制。
Gin 中间件
认证属于受保护接口的公共逻辑。
func AuthMiddleware(secret []byte) gin.HandlerFunc {
return func(c *gin.Context) {
if !ok || tokenString == "" {
return
}
claims := &UserClaims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (any, error) {
return secret, nil
})
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, APIResponse{Code: "UNAUTHORIZED", Message: "登录凭证无效或已过期"})
return
}
}
}登录接口不能放在认证中间件之后,因为用户登录前还没有 Token。
Gin Context
中间件把用户身份交给 Handler。
中间件写入
c.Set("currentUserID", claims.UserID)
c.Set("currentUserRole", claims.Role)Handler 读取
func CurrentUserID(c *gin.Context) (uint64, bool) {
value, exists := c.Get("currentUserID")
if !exists {
return 0, false
}
userID, ok := value.(uint64)
return userID, ok
}当前用户身份来自经过验证的 JWT ≠ 前端传入的 userId
URL 中的 userId
用户可以随意修改 URL。
请求体里的 userId
请求体同样来自客户端。
已验证 JWT 中的 userId
前提是签名、算法和过期时间都通过校验。
Context 中的 currentUserID
它由认证中间件写入。
JWT 之后仍需权限判断
知道你是谁,不等于你拥有这条数据。
错误删除
func DeleteAlbum(c *gin.Context) {
albumID := c.Param("id")
db.Delete(&Album{}, albumID)
c.JSON(http.StatusOK, APIResponse{Code: "OK", Message: "删除成功"})
}推荐删除
result := db.
WithContext(c.Request.Context()).
Where("id = ? AND user_id = ?", albumID, currentUserID).
Delete(&Album{})
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, APIResponse{Code: "NOT_FOUND", Message: "相册不存在或无权操作"})
}数据归属最好直接放进查询、更新或删除条件中,避免先查出数据后忘记校验权限。
公开相册与私有相册
403 更直接,404 泄露的信息更少,需要根据接口安全策略统一设计。
401、403、404
状态码要表达清楚身份和权限状态。
没有 Token、Token 无效、Token 过期。
普通用户访问管理员接口。
相册不存在或无权访问。
未携带 Token 访问“我的相册”
Token 已过期
普通用户访问管理员接口
用户 A 访问用户 B 的私有相册
越权代码查错
能更新成功,不代表权限正确。
func UpdateAlbum(c *gin.Context) {
albumID := c.Param("id")
c.JSON(http.StatusOK, gin.H{
"message": "success",
})
}前端权限不是安全控制
隐藏按钮只能改善体验,不能保护系统。
{album.userId === currentUser.id && (
<button onClick={deleteAlbum}>
删除相册
</button>
)}前端权限控制
隐藏不可用按钮
避免用户误操作
展示权限提示
后端权限控制
Token 校验
角色校验
数据归属校验
状态校验
最终操作授权
本章总结
- 用户密码不能明文保存,应使用 bcrypt 或 Argon2 等密码哈希算法。
- JWT 是服务端签名的身份凭证,不是敏感数据保险箱。
- JWT Payload 可以被读取,不能保存密码等敏感数据。
- Gin 中间件适合统一处理 Token 验证。
- 中间件可以将当前用户身份写入
gin.Context。 - Handler 应从 Context 获取当前用户,而不是相信前端传入的
userId。 - JWT 只解决认证问题,不会自动解决角色权限和数据权限。
- 查询、更新和删除数据时,应把用户归属条件放进数据库操作条件。
- 前端隐藏按钮只能改善体验,不能代替后端权限控制。
用户已经能安全访问自己的相册,但照片应该保存在哪里?
下一章:为什么需要对象存储,以及如何使用 OSS。