全栈工程课 · 一个相册系统的上线之路
课程平台Chapter 6 · JWT 与权限

CHAPTER 6 · AUTHENTICATION & PERMISSION

06

用户登录了,为什么还不能访问所有相册?

登录只解决“你是谁”,并不代表你可以访问系统中的全部数据。

一个看似能运行的接口

这段代码只检查了相册是否存在。

get_album.go
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: zhangsanpassword: 123456 一旦泄露,就会直接暴露用户密码。

注册时生成哈希

bcrypt.go
func HashPassword(password string) (string, error) {
    passwordHash, err := bcrypt.GenerateFromPassword(
        []byte(password),
        bcrypt.DefaultCost,
    )
    if err != nil {
        return "", err
    }

    return string(passwordHash), nil
}

登录时校验密码

bcrypt.go
func CheckPassword(passwordHash, password string) bool {
    err := bcrypt.CompareHashAndPassword(
        []byte(passwordHash),
        []byte(password),
    )

    return err == nil
}

服务端不需要解密用户密码。数据库中保存的应该是密码哈希,而不是原始密码。Base64 不是加密,不要自己设计密码算法。

登录接口

登录成功后返回 Token,而不是返回密码。

login.go
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 是由服务端签名的身份凭证。

..
claims.go
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 → 获得当前用户身份 → 继续执行接口

request.http
GET /api/my/albums
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

JWT 解决身份传递问题,但不会自动解决 Token 撤销、续期和账号风险控制。

Gin 中间件

认证属于受保护接口的公共逻辑。

auth_middleware.go点击高亮查看作用
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。

中间件写入

middleware.go
c.Set("currentUserID", claims.UserID)
c.Set("currentUserRole", claims.Role)

Handler 读取

context.go
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 之后仍需权限判断

知道你是谁,不等于你拥有这条数据。

错误删除

bad.go
func DeleteAlbum(c *gin.Context) {
    albumID := c.Param("id")
    db.Delete(&Album{}, albumID)
    c.JSON(http.StatusOK, APIResponse{Code: "OK", Message: "删除成功"})
}

推荐删除

good.go
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

状态码要表达清楚身份和权限状态。

401 Unauthorized没有有效身份

没有 Token、Token 无效、Token 过期。

403 Forbidden身份有效但权限不足

普通用户访问管理员接口。

404 Not Found不存在或隐藏存在性

相册不存在或无权访问。

未携带 Token 访问“我的相册”

Token 已过期

普通用户访问管理员接口

用户 A 访问用户 B 的私有相册

越权代码查错

能更新成功,不代表权限正确。

bad_update.go点击问题行
func UpdateAlbum(c *gin.Context) {
    albumID := c.Param("id")





    c.JSON(http.StatusOK, gin.H{
        "message": "success",
    })
}

前端权限不是安全控制

隐藏按钮只能改善体验,不能保护系统。

AlbumActions.jsx
{album.userId === currentUser.id && (
  <button onClick={deleteAlbum}>
    删除相册
  </button>
)}

前端权限控制

隐藏不可用按钮
避免用户误操作
展示权限提示

后端权限控制

Token 校验
角色校验
数据归属校验
状态校验
最终操作授权

本章总结

  1. 用户密码不能明文保存,应使用 bcrypt 或 Argon2 等密码哈希算法。
  2. JWT 是服务端签名的身份凭证,不是敏感数据保险箱。
  3. JWT Payload 可以被读取,不能保存密码等敏感数据。
  4. Gin 中间件适合统一处理 Token 验证。
  5. 中间件可以将当前用户身份写入 gin.Context
  6. Handler 应从 Context 获取当前用户,而不是相信前端传入的 userId
  7. JWT 只解决认证问题,不会自动解决角色权限和数据权限。
  8. 查询、更新和删除数据时,应把用户归属条件放进数据库操作条件。
  9. 前端隐藏按钮只能改善体验,不能代替后端权限控制。
用户已经能安全访问自己的相册,但照片应该保存在哪里?
下一章:为什么需要对象存储,以及如何使用 OSS。