在现代 Golang 服务中,鉴权设计不只是“能不能登录”的问题,更是“如何在安全、性能、扩展性和可维护性之间取得平衡”的工程问题。很多系统初期只做了简单的登录态校验,但随着业务发展,很快就会遇到一系列真实挑战:多终端登录、第三方身份接入、接口级权限隔离、内部服务调用、API 开放平台、细粒度授权、令牌泄露后的止损策略等。
本文从工程实践出发,系统梳理 Golang 服务里的常见鉴权方案,并给出完整可运行的代码示例。内容覆盖 JWT、OAuth2 / OIDC、RBAC + Casbin、API Key、Session vs Token,以及最终如何把这些方案落到统一的鉴权中间件中。
文中的示例都尽量保持可运行、可扩展、可迁移到真实项目。你可以先直接运行示例,再按业务场景拆分到自己的项目里。
一、鉴权设计的核心目标
在进入具体实现之前,先统一几个关键目标。一个好的鉴权方案通常需要同时满足以下几点:
- 身份可信:系统要能确认“你是谁”
- 权限可控:系统要能判断“你能做什么”
- 边界清晰:不同客户端、不同服务、不同资源之间的访问规则明确
- 便于扩展:后续能接入第三方登录、开放平台、管理后台、多租户等能力
- 易于审计:关键授权动作、权限变更、令牌签发和调用行为可追踪
- 可应对风险:泄露、重放、越权、伪造、长时间不失效等问题有应对方案
工程上,可以把鉴权链路粗略拆成四层:
- 认证 Authentication:确认调用者身份
- 授权 Authorization:判断该身份是否有权限访问目标资源
- 凭证管理 Credential Management:管理 Token、Session、API Key 等凭证生命周期
- 审计与风控 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,用于吊销或审计- 自定义字段:如
role、tenant_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 里塞全量权限、菜单、组织树等大对象
- 把密钥写死在代码仓库里
- 不校验
iss、aud、nbf - 刷新时不轮换,导致 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 接入流程概览
典型登录流程如下:
- 前端引导用户跳转到第三方授权页
- 用户同意授权
- 第三方回调你的服务端,带回
code - 服务端用
code换取access_token - 再调用用户信息接口,或校验
id_token - 建立本地用户映射,生成你自己的登录态(如 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
- 第三方账户表:保存
provider、provider_user_id、邮箱、昵称、头像等映射关系 - 登录成功后签发自己的 Access Token / Session
- 对未绑定账户场景支持首次注册或绑定已有账号
建议的账户绑定字段:
| 字段 | 含义 |
|---|---|
| user_id | 本地用户 ID |
| provider | 第三方平台,如 google / github |
| provider_user_id | 第三方用户唯一 ID |
| 第三方返回邮箱 | |
| avatar | 头像 |
| raw_profile | 原始资料快照,便于排查 |
| created_at | 绑定时间 |
| updated_at | 更新时间 |
同时要注意:
- 邮箱不能盲目信任为唯一身份,尤其在不同平台间
- 绑定时要防止抢绑
- 解绑前需确认是否还有其他登录方式可用
- 第三方 Token 不建议长期存储,确需存储时应加密保存
四、RBAC 权限模型设计与 Casbin 实战
认证解决的是“你是谁”,授权解决的是“你能做什么”。业务进入中后期后,单纯靠 role == admin 这样的逻辑会越来越难维护,这时就需要更清晰的权限模型。
4.1 RBAC 是什么
RBAC(Role-Based Access Control,基于角色的访问控制)是一种经典授权模型,它把“用户”和“权限”之间通过“角色”做中间抽象:
- 用户属于一个或多个角色
- 角色拥有一组权限
- 权限可表示为资源 + 动作
例如:
admin可对order执行read/write/deleteauditor只能对order执行readoperator可对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 示例解决了“怎么做授权判断”,但在真实项目中,还要进一步考虑权限模型如何设计。
推荐把权限抽象为三层:
- 主体 Subject:用户、服务账号、API Key、租户管理员等
- 资源 Object:订单、用户、配置项、项目、接口、菜单等
- 动作 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 里的动作就可以写为 GET、POST、DELETE 之类,而不是抽象成 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:read、invoice: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 中间件链路设计
一个典型链路可以设计为:
RequestIDMiddlewareLoggingMiddlewareAuthenticationMiddlewareAuthorizationMiddlewareBusinessHandler
其中:
- 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 没有绝对优劣,关键在业务边界和控制诉求
- 鉴权中间件 是把认证、授权、上下文和审计串起来的关键落点
真正成熟的鉴权方案,往往不是“只选一个技术”,而是按用户、设备、服务和风险等级组合使用多种机制。越是业务复杂,越需要在“安全性、可维护性、扩展性、接入成本”之间找到一个平衡点。
📝 版权声明:本文为原创技术博客,转载请注明出处。
如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!