返回首页

Golang横向:12 鉴权方案设计

在现代 Golang 服务中,鉴权设计不只是“能不能登录”的问题,更是“如何在安全、性能、扩展性和可维护性之间取得平衡”的工程问题。很多系统初期只做了简单的登录态校验,但随着业务发展,很快就会遇到一系列真实挑战:多终端登录、第三方身份接入、接口级权限隔离、内部服务调用、API 开放平台、细粒度授权、令牌泄露后的止损策略等。

本文从工程实践出发,系统梳理 Golang 服务里的常见鉴权方案,并给出完整可运行的代码示例。内容覆盖 JWT、OAuth2 / OIDC、RBAC + Casbin、API Key、Session vs Token,以及最终如何把这些方案落到统一的鉴权中间件中。

文中的示例都尽量保持可运行、可扩展、可迁移到真实项目。你可以先直接运行示例,再按业务场景拆分到自己的项目里。

一、鉴权设计的核心目标

在进入具体实现之前,先统一几个关键目标。一个好的鉴权方案通常需要同时满足以下几点:

  • 身份可信:系统要能确认“你是谁”
  • 权限可控:系统要能判断“你能做什么”
  • 边界清晰:不同客户端、不同服务、不同资源之间的访问规则明确
  • 便于扩展:后续能接入第三方登录、开放平台、管理后台、多租户等能力
  • 易于审计:关键授权动作、权限变更、令牌签发和调用行为可追踪
  • 可应对风险:泄露、重放、越权、伪造、长时间不失效等问题有应对方案

工程上,可以把鉴权链路粗略拆成四层:

  1. 认证 Authentication:确认调用者身份
  2. 授权 Authorization:判断该身份是否有权限访问目标资源
  3. 凭证管理 Credential Management:管理 Token、Session、API Key 等凭证生命周期
  4. 审计与风控 Audit & Risk Control:记录登录、授权、异常行为,支持封禁与追溯

后面的每一节,本质上都是在解决这四层中的一部分问题。

二、JWT 的正确使用:签名算法选择、过期处理、刷新策略

JWT(JSON Web Token)常用于无状态认证场景。它的优势是服务端不必保存大量会话状态,适合前后端分离、微服务和网关鉴权场景。但 JWT 用得不当,风险也很集中:算法混淆、永不过期、刷新失控、无法吊销、敏感信息裸奔等。

2.1 JWT 适合什么场景

JWT 更适合以下情况:

  • 前后端分离系统
  • 多服务共享认证结果
  • 网关统一鉴权
  • 移动端或多终端登录
  • 短生命周期访问令牌

JWT 不适合以下情况:

  • 需要强实时下线
  • 需要服务端立即吊销登录态
  • 权限变更必须立刻生效
  • 需要保存大量会话态数据

换句话说,JWT 适合做“短期、可验证、便于分发”的凭证,不适合做“永久、强控制、全状态”的会话载体。

2.2 签名算法如何选

JWT 的安全性依赖签名算法和密钥管理。常见算法选择如下:

算法 类型 是否推荐 适用场景 说明
HS256 HMAC 对称签名 推荐 单体服务、内部服务 简单高效,但签发和验签共用同一密钥
RS256 RSA 非对称签名 强烈推荐 多服务验签、第三方接入 私钥签发,公钥验签,更利于密钥隔离
ES256 ECDSA 非对称签名 推荐 对性能和密钥长度敏感的场景 更轻量,但实现和调试门槛略高
none 无签名 严禁 必须拒绝

核心建议:

  • 单体服务或内部小系统可用 HS256
  • 网关、多服务共享验签、公私钥分离场景优先 RS256
  • 必须显式校验算法,防止算法降级攻击
  • 不要把用户敏感信息直接塞进 JWT Payload,JWT 默认只是 Base64URL 编码,不是加密

2.3 JWT 设计原则

JWT 中建议包含以下最小声明:

  • sub:用户唯一标识
  • exp:过期时间
  • iat:签发时间
  • nbf:生效时间(可选)
  • iss:签发方
  • aud:受众
  • jti:令牌唯一 ID,用于吊销或审计
  • 自定义字段:如 roletenant_id,但应避免放敏感数据

不要直接把以下内容放进去:

  • 用户手机号、邮箱、身份证号等敏感信息
  • 大量权限列表
  • 经常变更的业务属性
  • 过大对象结构

JWT 最好只承载“身份最小闭环信息”,真正权限以服务端查询或缓存结果为准。

2.4 Golang 中实现安全 JWT

下面给出一个完整的 JWT 示例,包含:

  • 显式校验签名算法
  • Access Token 和 Refresh Token 分离
  • 过期处理
  • 基于 jti 的刷新与吊销思路

依赖安装:

go mod init jwt-demo
go get github.com/golang-jwt/jwt/v5
go get github.com/google/uuid

完整示例:

package main

import (
    "errors"
    "fmt"
    "log"
    "sync"
    "time"

    "github.com/golang-jwt/jwt/v5"
    "github.com/google/uuid"
)

type TokenType string

const (
    AccessToken  TokenType = "access"
    RefreshToken TokenType = "refresh"
)

type CustomClaims struct {
    UserID    string    `json:"user_id"`
    Role      string    `json:"role"`
    TokenType TokenType `json:"token_type"`
    jwt.RegisteredClaims
}

type TokenPair struct {
    AccessToken  string
    RefreshToken string
}

type RefreshStore struct {
    mu    sync.RWMutex
    items map[string]string
}

func NewRefreshStore() *RefreshStore {
    return &RefreshStore{items: make(map[string]string)}
}

func (s *RefreshStore) Save(jti, userID string) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.items[jti] = userID
}

func (s *RefreshStore) Exists(jti, userID string) bool {
    s.mu.RLock()
    defer s.mu.RUnlock()
    v, ok := s.items[jti]
    return ok && v == userID
}

func (s *RefreshStore) Delete(jti string) {
    s.mu.Lock()
    defer s.mu.Unlock()
    delete(s.items, jti)
}

var hmacSecret = []byte("replace-with-strong-random-secret-at-least-32-bytes")

func issueToken(userID, role string, tokenType TokenType, ttl time.Duration) (string, string, error) {
    now := time.Now()
    jti := uuid.NewString()

    claims := CustomClaims{
        UserID:    userID,
        Role:      role,
        TokenType: tokenType,
        RegisteredClaims: jwt.RegisteredClaims{
            ID:        jti,
            Subject:   userID,
            Issuer:    "auth-demo",
            Audience:  []string{"golang-blog-demo"},
            IssuedAt:  jwt.NewNumericDate(now),
            NotBefore: jwt.NewNumericDate(now),
            ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
        },
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    signed, err := token.SignedString(hmacSecret)
    if err != nil {
        return "", "", err
    }

    return signed, jti, nil
}

func issueTokenPair(store *RefreshStore, userID, role string) (*TokenPair, error) {
    accessToken, _, err := issueToken(userID, role, AccessToken, 15*time.Minute)
    if err != nil {
        return nil, err
    }

    refreshToken, refreshJTI, err := issueToken(userID, role, RefreshToken, 7*24*time.Hour)
    if err != nil {
        return nil, err
    }

    store.Save(refreshJTI, userID)

    return &TokenPair{
        AccessToken:  accessToken,
        RefreshToken: refreshToken,
    }, nil
}

func parseAndValidate(tokenString string) (*CustomClaims, error) {
    parser := jwt.NewParser(
        jwt.WithIssuer("auth-demo"),
        jwt.WithAudience("golang-blog-demo"),
    )

    token, err := parser.ParseWithClaims(tokenString, &CustomClaims{}, func(token *jwt.Token) (interface{}, error) {
        if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
            return nil, fmt.Errorf("unexpected signing method: %s", token.Method.Alg())
        }
        return hmacSecret, nil
    })
    if err != nil {
        return nil, err
    }

    claims, ok := token.Claims.(*CustomClaims)
    if !ok || !token.Valid {
        return nil, errors.New("invalid token claims")
    }

    return claims, nil
}

func refreshTokens(store *RefreshStore, refreshToken string) (*TokenPair, error) {
    claims, err := parseAndValidate(refreshToken)
    if err != nil {
        return nil, err
    }

    if claims.TokenType != RefreshToken {
        return nil, errors.New("token is not a refresh token")
    }

    if !store.Exists(claims.ID, claims.UserID) {
        return nil, errors.New("refresh token revoked or not found")
    }

    store.Delete(claims.ID)
    return issueTokenPair(store, claims.UserID, claims.Role)
}

func main() {
    store := NewRefreshStore()

    pair, err := issueTokenPair(store, "u1001", "admin")
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("access token:", pair.AccessToken)
    fmt.Println("refresh token:", pair.RefreshToken)

    accessClaims, err := parseAndValidate(pair.AccessToken)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("access claims: user=%s role=%s type=%s\n", accessClaims.UserID, accessClaims.Role, accessClaims.TokenType)

    newPair, err := refreshTokens(store, pair.RefreshToken)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("rotated access token:", newPair.AccessToken)
    fmt.Println("rotated refresh token:", newPair.RefreshToken)

    _, err = refreshTokens(store, pair.RefreshToken)
    if err != nil {
        fmt.Println("old refresh token rejected as expected:", err)
    }
}

2.5 过期处理与刷新策略

JWT 的核心原则不是“长期有效”,而是“短期 Access Token + 可控 Refresh Token”。推荐策略如下:

凭证类型 推荐时长 用途 处理建议
Access Token 10~30 分钟 接口访问 尽量短,泄露后影响更小
Refresh Token 7~30 天 换取新 Access Token 必须可吊销、可轮换

推荐实践:

  • Access Token 短期有效,尽量不落库
  • Refresh Token 落库或放 Redis,支持吊销
  • 刷新时启用 Refresh Token Rotation,即每次刷新后旧 Refresh Token 立即失效
  • 对刷新接口做频率限制,防止撞库或恶意刷令牌
  • 登出时删除 Refresh Token 记录,实现主动失效
  • 敏感操作可要求二次认证,而不是只依赖已有 JWT

2.6 JWT 常见误区

最常见的几个错误包括:

  • 直接相信 alg 字段,不做服务端强校验
  • Token 永不过期
  • Access Token 和 Refresh Token 混用
  • 在 JWT 里塞全量权限、菜单、组织树等大对象
  • 把密钥写死在代码仓库里
  • 不校验 issaudnbf
  • 刷新时不轮换,导致 Refresh Token 长期复用

三、OAuth2 / OIDC 集成:以 Google / GitHub 为例

当系统需要支持“使用 Google 登录”或“使用 GitHub 登录”时,通常并不是自己管理用户密码,而是接入第三方身份提供方。这时就会涉及 OAuth2 和 OIDC。

3.1 OAuth2 和 OIDC 的区别

很多文章把两者混用,但工程上要区分清楚:

  • OAuth2:解决“授权访问”问题,本质是让第三方应用拿到受限访问权限
  • OIDC:在 OAuth2 之上增加“身份认证”能力,返回标准化身份信息,例如 id_token

简单理解:

  • “允许应用访问我的 GitHub 资料”更偏 OAuth2
  • “确认当前登录用户是谁”更偏 OIDC

Google 对 OIDC 支持很完善,GitHub 则更常见 OAuth2 用户信息方式。

3.2 接入流程概览

典型登录流程如下:

  1. 前端引导用户跳转到第三方授权页
  2. 用户同意授权
  3. 第三方回调你的服务端,带回 code
  4. 服务端用 code 换取 access_token
  5. 再调用用户信息接口,或校验 id_token
  6. 建立本地用户映射,生成你自己的登录态(如 JWT / Session)

关键原则:

  • 第三方 Token 不直接作为你业务系统的长期凭证
  • 第三方身份只是“登录入口”,登录成功后应签发你自己的 Token 或 Session
  • state 参数必须校验,防 CSRF
  • 回调 URL 必须固定且受控

3.3 Google OIDC 登录示例

依赖安装:

go mod init google-oidc-demo
go get golang.org/x/oauth2
go get github.com/coreos/go-oidc/v3/oidc

完整示例:

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "os"

    "github.com/coreos/go-oidc/v3/oidc"
    "golang.org/x/oauth2"
)

var (
    googleClientID     = os.Getenv("GOOGLE_CLIENT_ID")
    googleClientSecret = os.Getenv("GOOGLE_CLIENT_SECRET")
    redirectURL        = "http://localhost:8080/auth/google/callback"
    oauthState         = "demo-state-123456"
)

type GoogleUser struct {
    Sub           string `json:"sub"`
    Email         string `json:"email"`
    EmailVerified bool   `json:"email_verified"`
    Name          string `json:"name"`
    Picture       string `json:"picture"`
}

func main() {
    ctx := context.Background()

    provider, err := oidc.NewProvider(ctx, "https://accounts.google.com")
    if err != nil {
        log.Fatal(err)
    }

    verifier := provider.Verifier(&oidc.Config{ClientID: googleClientID})

    config := oauth2.Config{
        ClientID:     googleClientID,
        ClientSecret: googleClientSecret,
        RedirectURL:  redirectURL,
        Endpoint:     provider.Endpoint(),
        Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
    }

    http.HandleFunc("/login/google", func(w http.ResponseWriter, r *http.Request) {
        url := config.AuthCodeURL(oauthState, oauth2.AccessTypeOffline)
        http.Redirect(w, r, url, http.StatusFound)
    })

    http.HandleFunc("/auth/google/callback", func(w http.ResponseWriter, r *http.Request) {
        if r.URL.Query().Get("state") != oauthState {
            http.Error(w, "invalid state", http.StatusBadRequest)
            return
        }

        code := r.URL.Query().Get("code")
        token, err := config.Exchange(r.Context(), code)
        if err != nil {
            http.Error(w, "failed to exchange token: "+err.Error(), http.StatusInternalServerError)
            return
        }

        rawIDToken, ok := token.Extra("id_token").(string)
        if !ok {
            http.Error(w, "missing id_token", http.StatusInternalServerError)
            return
        }

        idToken, err := verifier.Verify(r.Context(), rawIDToken)
        if err != nil {
            http.Error(w, "failed to verify id_token: "+err.Error(), http.StatusUnauthorized)
            return
        }

        var user GoogleUser
        if err := idToken.Claims(&user); err != nil {
            http.Error(w, "failed to parse claims: "+err.Error(), http.StatusInternalServerError)
            return
        }

        w.Header().Set("Content-Type", "application/json")
        _ = json.NewEncoder(w).Encode(map[string]interface{}{
            "provider": "google",
            "user":     user,
            "note":     "生产环境中,此处应绑定本地用户并签发你自己的 JWT 或 Session",
        })
    })

    fmt.Println("open http://localhost:8080/login/google")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

这个示例展示了一个很重要的工程原则:Google 的身份只负责证明“用户是谁”,真正的业务会话应由你的系统自己建立。

3.4 GitHub OAuth2 登录示例

GitHub 常见做法是使用 OAuth2 获取用户访问令牌,再调用 GitHub 用户信息接口获取身份资料。

依赖安装:

go mod init github-oauth-demo
go get golang.org/x/oauth2

完整示例:

package main

import (
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"

    "golang.org/x/oauth2"
    "golang.org/x/oauth2/github"
)

var (
    githubClientID     = os.Getenv("GITHUB_CLIENT_ID")
    githubClientSecret = os.Getenv("GITHUB_CLIENT_SECRET")
    githubRedirectURL  = "http://localhost:8080/auth/github/callback"
    githubState        = "github-demo-state"
)

type GitHubUser struct {
    ID        int64  `json:"id"`
    Login     string `json:"login"`
    Name      string `json:"name"`
    Email     string `json:"email"`
    AvatarURL string `json:"avatar_url"`
}

func main() {
    conf := &oauth2.Config{
        ClientID:     githubClientID,
        ClientSecret: githubClientSecret,
        RedirectURL:  githubRedirectURL,
        Endpoint:     github.Endpoint,
        Scopes:       []string{"read:user", "user:email"},
    }

    http.HandleFunc("/login/github", func(w http.ResponseWriter, r *http.Request) {
        url := conf.AuthCodeURL(githubState)
        http.Redirect(w, r, url, http.StatusFound)
    })

    http.HandleFunc("/auth/github/callback", func(w http.ResponseWriter, r *http.Request) {
        if r.URL.Query().Get("state") != githubState {
            http.Error(w, "invalid state", http.StatusBadRequest)
            return
        }

        code := r.URL.Query().Get("code")
        token, err := conf.Exchange(r.Context(), code)
        if err != nil {
            http.Error(w, "token exchange failed: "+err.Error(), http.StatusInternalServerError)
            return
        }

        req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://api.github.com/user", nil)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        req.Header.Set("Authorization", "Bearer "+token.AccessToken)
        req.Header.Set("Accept", "application/vnd.github+json")

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        defer resp.Body.Close()

        if resp.StatusCode != http.StatusOK {
            body, _ := io.ReadAll(resp.Body)
            http.Error(w, "github user api failed: "+string(body), http.StatusUnauthorized)
            return
        }

        var user GitHubUser
        if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        w.Header().Set("Content-Type", "application/json")
        _ = json.NewEncoder(w).Encode(map[string]interface{}{
            "provider": "github",
            "user":     user,
            "note":     "生产环境中,建议保存 provider + provider_user_id 的本地绑定关系",
        })
    })

    fmt.Println("open http://localhost:8080/login/github")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

3.5 第三方登录落地建议

接入 Google / GitHub 时,建议采用统一账户模型:

  • 本地用户表:保存系统内部用户 ID
  • 第三方账户表:保存 providerprovider_user_id、邮箱、昵称、头像等映射关系
  • 登录成功后签发自己的 Access Token / Session
  • 对未绑定账户场景支持首次注册或绑定已有账号

建议的账户绑定字段:

字段 含义
user_id 本地用户 ID
provider 第三方平台,如 google / github
provider_user_id 第三方用户唯一 ID
email 第三方返回邮箱
avatar 头像
raw_profile 原始资料快照,便于排查
created_at 绑定时间
updated_at 更新时间

同时要注意:

  • 邮箱不能盲目信任为唯一身份,尤其在不同平台间
  • 绑定时要防止抢绑
  • 解绑前需确认是否还有其他登录方式可用
  • 第三方 Token 不建议长期存储,确需存储时应加密保存

四、RBAC 权限模型设计与 Casbin 实战

认证解决的是“你是谁”,授权解决的是“你能做什么”。业务进入中后期后,单纯靠 role == admin 这样的逻辑会越来越难维护,这时就需要更清晰的权限模型。

4.1 RBAC 是什么

RBAC(Role-Based Access Control,基于角色的访问控制)是一种经典授权模型,它把“用户”和“权限”之间通过“角色”做中间抽象:

  • 用户属于一个或多个角色
  • 角色拥有一组权限
  • 权限可表示为资源 + 动作

例如:

  • admin 可对 order 执行 read/write/delete
  • auditor 只能对 order 执行 read
  • operator 可对 inventory 执行 read/write

RBAC 的好处是管理清晰,但如果业务需要按租户、部门、数据范围、对象属性做细粒度控制,纯 RBAC 会不够用。这时可以叠加 ABAC、ReBAC 或自定义策略。

4.2 Casbin 为什么常用

Casbin 是 Go 生态里非常成熟的授权库,适合做:

  • 基于角色的授权
  • 路径匹配授权
  • 多级角色继承
  • 多租户隔离
  • 自定义函数授权

Casbin 的核心是:

  • model:定义授权模型
  • policy:定义策略数据
  • enforcer:执行授权判断

4.3 Casbin 最小可运行示例

依赖安装:

go mod init casbin-demo
go get github.com/casbin/casbin/v2

项目文件如下:

.
├── main.go
├── model.conf
└── policy.csv

model.conf

[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && r.act == p.act

policy.csv

p, admin, /users, read
p, admin, /users, write
p, admin, /users/:id, read
p, admin, /users/:id, delete
p, operator, /orders, read
p, operator, /orders/:id, read
p, operator, /orders/:id, write
p, auditor, /orders, read
p, auditor, /orders/:id, read

g, alice, admin
g, bob, operator
g, carol, auditor

main.go

package main

import (
    "fmt"
    "log"

    "github.com/casbin/casbin/v2"
)

func main() {
    e, err := casbin.NewEnforcer("model.conf", "policy.csv")
    if err != nil {
        log.Fatal(err)
    }

    cases := []struct {
        user string
        obj  string
        act  string
    }{
        {"alice", "/users", "read"},
        {"alice", "/users/100", "delete"},
        {"bob", "/orders/1", "write"},
        {"bob", "/users", "read"},
        {"carol", "/orders/1", "read"},
        {"carol", "/orders/1", "write"},
    }

    for _, c := range cases {
        ok, err := e.Enforce(c.user, c.obj, c.act)
        if err != nil {
            log.Fatal(err)
        }
        fmt.Printf("user=%s obj=%s act=%s allowed=%v\n", c.user, c.obj, c.act, ok)
    }
}

运行后可以直观看到不同角色对不同资源的授权结果。

4.4 RBAC 落地设计建议

Casbin 示例解决了“怎么做授权判断”,但在真实项目中,还要进一步考虑权限模型如何设计。

推荐把权限抽象为三层:

  1. 主体 Subject:用户、服务账号、API Key、租户管理员等
  2. 资源 Object:订单、用户、配置项、项目、接口、菜单等
  3. 动作 Action:读、写、删除、审批、导出、发布等

例如可以统一为:

sub = user:1001
obj = project:42:deploy
act = execute

这样做的优势是:

  • 资源命名规范统一
  • 便于扩展到多租户、多环境、多项目
  • 便于日志审计和权限排查

4.5 Casbin 在 HTTP 服务里的实践

下面给出一个基于标准库 HTTP 的 Casbin 中间件示例:

package main

import (
    "context"
    "fmt"
    "log"
    "net/http"

    "github.com/casbin/casbin/v2"
)

type ctxKey string

const userKey ctxKey = "user"

func WithUser(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        user := r.Header.Get("X-User")
        if user == "" {
            http.Error(w, "missing user", http.StatusUnauthorized)
            return
        }
        ctx := context.WithValue(r.Context(), userKey, user)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func RBACMiddleware(e *casbin.Enforcer, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        user, _ := r.Context().Value(userKey).(string)
        ok, err := e.Enforce(user, r.URL.Path, r.Method)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        if !ok {
            http.Error(w, "forbidden", http.StatusForbidden)
            return
        }
        next.ServeHTTP(w, r)
    })
}

func main() {
    e, err := casbin.NewEnforcer("model.conf", "policy.csv")
    if err != nil {
        log.Fatal(err)
    }

    mux := http.NewServeMux()
    mux.HandleFunc("/orders", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "orders %s success\n", r.Method)
    })

    handler := WithUser(RBACMiddleware(e, mux))

    fmt.Println("listen on :8080")
    log.Fatal(http.ListenAndServe(":8080", handler))
}

如果你的策略中使用 HTTP 方法,那么 policy.csv 里的动作就可以写为 GETPOSTDELETE 之类,而不是抽象成 read/write

4.6 权限系统常见坑

实践中最容易踩坑的地方包括:

  • 把角色写死在代码里,导致扩展困难
  • 权限粒度过粗,后期无法满足审计和隔离需求
  • 权限粒度过细,管理成本暴涨
  • 鉴权只靠前端菜单隐藏,后端接口不校验
  • 用户角色变更后,旧缓存长期不失效

推荐原则是:

  • 粒度从“资源 + 动作”开始,不要一上来就做极细模型
  • 后端必须做最终鉴权
  • 权限变更后要有缓存失效策略
  • 超级管理员权限也要尽量留审计日志

五、API Key 管理与接口鉴权

除了面向用户的登录系统,很多开放平台、内部服务、Webhook、CLI 工具也会采用 API Key 作为调用凭证。API Key 的特点是简单、易集成、适合程序到程序调用,但如果管理不当,也很容易泄露和被滥用。

5.1 API Key 适用场景

API Key 通常适合:

  • 服务到服务调用
  • 开放平台调用
  • 第三方回调签名辅助
  • 脚本、机器人、定时任务
  • 开发者平台接口访问

API Key 不建议直接代替用户登录态,因为它通常表达的是“应用身份”或“调用方身份”,而不是最终用户身份。

5.2 API Key 设计建议

一个可用的 API Key 系统,建议至少具备以下能力:

  • Key 只在创建时明文展示一次
  • 数据库存储哈希值,而不是明文
  • 每个 Key 绑定调用方、环境、权限范围、过期时间
  • 支持禁用、轮换、吊销
  • 支持调用频控和 IP 白名单
  • 支持最小权限 Scope

建议把 Key 设计成两段:

  • key_id:公开标识,用于快速索引
  • key_secret:真正密钥,只展示一次

例如:

ak_live_01HXYZABCDEF.vERYsEcREtStRiNg

其中:

  • 点号前半段可作为查询索引
  • 点号后半段参与哈希校验

5.3 API Key 鉴权完整示例

下面给出一个完整可运行的 API Key 服务示例。

依赖安装:

go mod init apikey-demo

完整示例:

package main

import (
    "crypto/rand"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "strings"
    "sync"
    "time"
)

type APIKeyRecord struct {
    KeyID     string
    Hash      string
    Owner     string
    Scope     []string
    ExpiresAt time.Time
    Disabled  bool
}

type APIKeyStore struct {
    mu    sync.RWMutex
    items map[string]APIKeyRecord
}

func NewAPIKeyStore() *APIKeyStore {
    return &APIKeyStore{items: make(map[string]APIKeyRecord)}
}

func randomHex(n int) (string, error) {
    b := make([]byte, n)
    if _, err := rand.Read(b); err != nil {
        return "", err
    }
    return hex.EncodeToString(b), nil
}

func sha256Hex(s string) string {
    sum := sha256.Sum256([]byte(s))
    return hex.EncodeToString(sum[:])
}

func (s *APIKeyStore) Create(owner string, scope []string, ttl time.Duration) (string, error) {
    keyID, err := randomHex(8)
    if err != nil {
        return "", err
    }

    secret, err := randomHex(24)
    if err != nil {
        return "", err
    }

    plaintext := fmt.Sprintf("ak_%s.%s", keyID, secret)
    record := APIKeyRecord{
        KeyID:     "ak_" + keyID,
        Hash:      sha256Hex(secret),
        Owner:     owner,
        Scope:     scope,
        ExpiresAt: time.Now().Add(ttl),
        Disabled:  false,
    }

    s.mu.Lock()
    defer s.mu.Unlock()
    s.items[record.KeyID] = record

    return plaintext, nil
}

func (s *APIKeyStore) Verify(plaintext string, requiredScope string) (*APIKeyRecord, bool) {
    parts := strings.Split(plaintext, ".")
    if len(parts) != 2 {
        return nil, false
    }

    keyID := parts[0]
    secret := parts[1]

    s.mu.RLock()
    record, ok := s.items[keyID]
    s.mu.RUnlock()
    if !ok {
        return nil, false
    }

    if record.Disabled || time.Now().After(record.ExpiresAt) {
        return nil, false
    }

    if subtle.ConstantTimeCompare([]byte(record.Hash), []byte(sha256Hex(secret))) != 1 {
        return nil, false
    }

    for _, scope := range record.Scope {
        if scope == requiredScope {
            return &record, true
        }
    }

    return nil, false
}

func APIKeyMiddleware(store *APIKeyStore, requiredScope string, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        apiKey := r.Header.Get("X-API-Key")
        if apiKey == "" {
            http.Error(w, "missing api key", http.StatusUnauthorized)
            return
        }

        record, ok := store.Verify(apiKey, requiredScope)
        if !ok {
            http.Error(w, "invalid api key", http.StatusUnauthorized)
            return
        }

        w.Header().Set("X-API-Key-Owner", record.Owner)
        next.ServeHTTP(w, r)
    })
}

func main() {
    store := NewAPIKeyStore()
    key, err := store.Create("partner-service", []string{"metrics:read", "orders:read"}, 24*time.Hour)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("generated api key, copy now because it is only shown once:", key)

    mux := http.NewServeMux()
    mux.Handle("/metrics", APIKeyMiddleware(store, "metrics:read", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        _ = json.NewEncoder(w).Encode(map[string]any{
            "ok":    true,
            "owner": w.Header().Get("X-API-Key-Owner"),
            "time":  time.Now().Format(time.RFC3339),
        })
    })))

    log.Fatal(http.ListenAndServe(":8080", mux))
}

5.4 API Key 的进阶安全策略

在真实项目中,建议在 API Key 基础上叠加以下能力:

  • 签名机制:不仅校验 Key,还校验请求签名、时间戳、随机数,防止重放
  • 最小 Scope:例如 invoice:readinvoice:write
  • 环境隔离:测试环境和生产环境使用不同前缀和存储
  • 访问画像:记录 IP、UA、地域、调用频率、错误率
  • 异常检测:调用突增、跨区域漂移、连续失败自动告警

如果是高风险接口,只靠静态 API Key 仍然不够,最好再叠加签名和时效校验。

六、Session vs Token 对比与适用场景

这是一个被反复讨论的话题:到底该用 Session,还是 Token?正确答案通常不是“谁更先进”,而是“哪种方案更适合你的业务边界”。

6.1 两者的本质区别

  • Session:服务端保存会话状态,客户端只保存会话标识
  • Token:服务端签发凭证,客户端保存完整令牌,服务端依赖签名或存储做校验

Session 更像“服务端记住你”,Token 更像“服务端给你一张可验证通行证”。

6.2 对比表

维度 Session Token
状态存储 服务端保存 客户端保存,服务端可部分无状态
横向扩展 需要共享 Session 存储 更容易扩展
主动失效 容易 纯 JWT 较难,通常需配合黑名单或短期策略
Web 场景 非常适合 也适合
移动端 / API 不够灵活 更常用
CSRF 风险 Cookie 模式要重点考虑 Bearer Token 一般不受传统 CSRF 影响
XSS 风险 Cookie 可配 HttpOnly Token 若放前端可被脚本读取
多服务传递 不够自然 更方便

6.3 什么时候选 Session

优先考虑 Session 的场景:

  • 传统服务端渲染 Web 系统
  • 管理后台
  • 需要强控制登录态失效
  • 单域名或同站点为主
  • 安全策略以服务端集中控制为主

例如后台管理系统,往往更看重:

  • 一键踢下线
  • 角色调整立即生效
  • 登录风控集中控制

这些需求用 Session 通常更直接。

6.4 什么时候选 Token

优先考虑 Token 的场景:

  • 前后端分离
  • App、小程序、开放 API
  • 网关统一鉴权
  • 微服务传递身份
  • 多终端、多域调用

但要记住:Token 不是“无脑优选”,它只是更适合分布式和接口化架构。

6.5 Session + Token 混合方案

在很多系统里,最稳妥的做法其实是混合方案:

  • 浏览器后台:使用 Session Cookie
  • 开放接口 / App:使用 JWT Access Token
  • 高风险操作:再叠加一次性校验、二次确认或短时凭证

这类混合方案更符合真实业务,而不是“全站一种方案打到底”。

七、鉴权中间件实现

前面讲了 JWT、OAuth2 / OIDC、RBAC、API Key 和 Session / Token 的选择,最终都要落到服务端中间件中。一个成熟的鉴权中间件,通常要解决以下问题:

  • 从请求中提取凭证
  • 判断凭证类型
  • 验证身份合法性
  • 注入用户上下文
  • 执行权限判断
  • 输出统一错误响应
  • 打点日志和审计字段

7.1 中间件链路设计

一个典型链路可以设计为:

  1. RequestIDMiddleware
  2. LoggingMiddleware
  3. AuthenticationMiddleware
  4. AuthorizationMiddleware
  5. BusinessHandler

其中:

  • Authentication 负责确认主体身份
  • Authorization 负责确认是否有权访问资源
  • 上下文中应保存标准化身份对象,而不是到处散落字符串

7.2 统一身份主体模型

建议定义统一主体对象,兼容 JWT 用户、API Key 调用方、Session 用户等多种来源:

type Principal struct {
    SubjectType string
    SubjectID   string
    Name        string
    Roles       []string
    Scopes      []string
}

这样后续授权逻辑就不必关心“这个请求到底来自 JWT 还是 API Key”,而是统一消费 Principal

7.3 完整可运行的鉴权中间件示例

下面给出一个完整示例,整合了:

  • JWT Bearer Token 认证
  • API Key 认证
  • 统一主体注入
  • 路由级权限控制

依赖安装:

go mod init auth-middleware-demo
go get github.com/golang-jwt/jwt/v5

完整示例:

package main

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "log"
    "net/http"
    "strings"
    "time"

    "github.com/golang-jwt/jwt/v5"
)

type ctxKey string

const principalKey ctxKey = "principal"

var jwtSecret = []byte("replace-this-secret-with-production-grade-key")

type Principal struct {
    SubjectType string   `json:"subject_type"`
    SubjectID   string   `json:"subject_id"`
    Name        string   `json:"name"`
    Roles       []string `json:"roles"`
    Scopes      []string `json:"scopes"`
}

type Claims struct {
    UserID string   `json:"user_id"`
    Name   string   `json:"name"`
    Roles  []string `json:"roles"`
    Scopes []string `json:"scopes"`
    jwt.RegisteredClaims
}

func issueDemoJWT() string {
    claims := Claims{
        UserID: "u1001",
        Name:   "Alice",
        Roles:  []string{"admin"},
        Scopes: []string{"profile:read", "orders:read", "orders:write"},
        RegisteredClaims: jwt.RegisteredClaims{
            Subject:   "u1001",
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            Issuer:    "middleware-demo",
            Audience:  []string{"demo-api"},
        },
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    signed, err := token.SignedString(jwtSecret)
    if err != nil {
        panic(err)
    }
    return signed
}

func parseJWT(tokenString string) (*Principal, error) {
    token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
        if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
            return nil, errors.New("unexpected signing method")
        }
        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 &Principal{
        SubjectType: "user",
        SubjectID:   claims.UserID,
        Name:        claims.Name,
        Roles:       claims.Roles,
        Scopes:      claims.Scopes,
    }, nil
}

func parseAPIKey(apiKey string) (*Principal, error) {
    if apiKey == "demo-service-key" {
        return &Principal{
            SubjectType: "service",
            SubjectID:   "svc-001",
            Name:        "report-service",
            Roles:       []string{"service"},
            Scopes:      []string{"metrics:read"},
        }, nil
    }
    return nil, errors.New("invalid api key")
}

func writeJSON(w http.ResponseWriter, status int, v interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    _ = json.NewEncoder(w).Encode(v)
}

func AuthenticationMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        var principal *Principal
        auth := r.Header.Get("Authorization")
        apiKey := r.Header.Get("X-API-Key")

        switch {
        case strings.HasPrefix(auth, "Bearer "):
            token := strings.TrimPrefix(auth, "Bearer ")
            p, err := parseJWT(token)
            if err != nil {
                writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid bearer token"})
                return
            }
            principal = p
        case apiKey != "":
            p, err := parseAPIKey(apiKey)
            if err != nil {
                writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid api key"})
                return
            }
            principal = p
        default:
            writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing credentials"})
            return
        }

        ctx := context.WithValue(r.Context(), principalKey, principal)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func RequireScope(scope string, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        principal, _ := r.Context().Value(principalKey).(*Principal)
        if principal == nil {
            writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing principal"})
            return
        }

        for _, s := range principal.Scopes {
            if s == scope {
                next.ServeHTTP(w, r)
                return
            }
        }

        writeJSON(w, http.StatusForbidden, map[string]string{"error": "insufficient scope"})
    })
}

func RequireRole(role string, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        principal, _ := r.Context().Value(principalKey).(*Principal)
        if principal == nil {
            writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing principal"})
            return
        }

        for _, item := range principal.Roles {
            if item == role {
                next.ServeHTTP(w, r)
                return
            }
        }

        writeJSON(w, http.StatusForbidden, map[string]string{"error": "insufficient role"})
    })
}

func main() {
    mux := http.NewServeMux()

    mux.Handle("/profile", AuthenticationMiddleware(RequireScope("profile:read", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        principal, _ := r.Context().Value(principalKey).(*Principal)
        writeJSON(w, http.StatusOK, map[string]interface{}{
            "message":   "profile read success",
            "principal": principal,
        })
    }))))

    mux.Handle("/admin", AuthenticationMiddleware(RequireRole("admin", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        principal, _ := r.Context().Value(principalKey).(*Principal)
        writeJSON(w, http.StatusOK, map[string]interface{}{
            "message":   "admin access success",
            "principal": principal,
        })
    }))))

    mux.Handle("/metrics", AuthenticationMiddleware(RequireScope("metrics:read", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        principal, _ := r.Context().Value(principalKey).(*Principal)
        writeJSON(w, http.StatusOK, map[string]interface{}{
            "message":   "metrics access success",
            "principal": principal,
        })
    }))))

    token := issueDemoJWT()
    fmt.Println("demo jwt:", token)
    fmt.Println("try:")
    fmt.Println("curl -H 'Authorization: Bearer <demo-jwt>' http://localhost:8080/profile")
    fmt.Println("curl -H 'Authorization: Bearer <demo-jwt>' http://localhost:8080/admin")
    fmt.Println("curl -H 'X-API-Key: demo-service-key' http://localhost:8080/metrics")

    log.Fatal(http.ListenAndServe(":8080", mux))
}

7.4 中间件工程化建议

这个示例可以跑通,但在生产环境中还建议补充以下能力:

  • 统一错误码:区分未登录、登录失效、权限不足、签名错误、凭证过期
  • 审计日志:记录主体、资源、动作、结果、来源 IP、请求 ID
  • 灰度能力:新鉴权规则上线时支持观测模式,只记录不拦截
  • 缓存策略:用户权限、角色映射可做短缓存,但要支持主动失效
  • 多认证源并存:浏览器、开放 API、内部服务走不同认证器
  • 配置化策略:路由权限不要大量硬编码

7.5 一套推荐的组合方案

如果你正在设计一个典型的 Golang 中后台 + 开放接口系统,可以考虑如下组合:

  • 用户登录:账号密码 + 第三方登录(Google / GitHub)
  • 浏览器后台:Session 或短期 JWT + HttpOnly Cookie
  • App / 前后端分离:短期 Access Token + Refresh Token Rotation
  • 开放平台:API Key + Scope + 签名
  • 权限控制:RBAC 起步,复杂场景再叠加数据权限
  • 服务端实现:统一 Authentication / Authorization 中间件

这个组合的好处是:

  • 面向不同调用方使用最适合的凭证模型
  • 鉴权边界清晰
  • 易于审计和扩展
  • 不会因为“全站只迷信一种方案”而把系统做僵

八、总结

鉴权从来不是某个库、某个协议或者某个中间件单独能解决的问题,它本质上是一套系统设计。

回顾本文的核心结论:

  • JWT 适合分布式接口认证,但必须控制过期、刷新和吊销策略
  • OAuth2 / OIDC 适合做第三方身份接入,但业务系统应签发自己的登录态
  • RBAC + Casbin 适合做角色与资源动作的授权控制
  • API Key 适合开放平台和服务调用,但必须做好哈希存储、Scope 与轮换
  • Session vs Token 没有绝对优劣,关键在业务边界和控制诉求
  • 鉴权中间件 是把认证、授权、上下文和审计串起来的关键落点

真正成熟的鉴权方案,往往不是“只选一个技术”,而是按用户、设备、服务和风险等级组合使用多种机制。越是业务复杂,越需要在“安全性、可维护性、扩展性、接入成本”之间找到一个平衡点。


📝 版权声明:本文为原创技术博客,转载请注明出处。

如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!

上一篇

Golang横向: 11 Web 安全基础与常见攻击防御

下一篇

Golang横向:13 密码存储与加密实践