OAuth2与JWT:现代授权与身份验证实践
OAuth2与JWT:现代授权与身份验证实践
引言
OAuth2和JWT是现代Web应用和API安全的基石。OAuth2解决了"授权"问题,让用户可以授权第三方应用访问自己的资源,而无需分享密码。JWT则提供了一种紧凑、安全的信息传输方式。本文将深入解析OAuth2流程和JWT实现。
一、OAuth2核心概念
1.1 OAuth2角色
- Resource Owner:资源所有者,即最终用户
- Client:请求访问权限的第三方应用
- Authorization Server:授权服务器,负责验证用户身份并发放令牌
- Resource Server:资源服务器,托管受保护的资源
1.2 OAuth2授权类型
| 授权类型 | 场景 | 安全性 |
|---|---|---|
| Authorization Code | 有后端服务器的应用 | 最高 |
| PKCE | 移动端/SPA应用 | 高 |
| Client Credentials | 服务间通信 | 中 |
| Refresh Token | 令牌刷新 | 高 |
二、Authorization Code + PKCE实现
2.1 PKCE流程概述
PKCE(Proof Key for Code Exchange)防止授权码被拦截攻击:
1. Client 生成 code_verifier (随机字符串) 2. Client 计算 code_challenge = BASE64URL(SHA256(code_verifier)) 3. 用户被重定向到授权服务器 4. 授权服务器返回 authorization_code 5. Client 用 authorization_code + code_verifier 换取 token 6. 授权服务器验证 code_challenge2.2 授权服务器实现
package oauth2 import ( "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "net/http" "net/url" "strings" "sync" "time" "github.com/google/uuid" ) type AuthorizationServer struct { clients map[string]*Client codes map[string]*AuthorizationCode tokens map[string]*Token mu sync.RWMutex issuer string signingKey []byte } type Client struct { ID string Secret string RedirectURIs []string GrantTypes []string } type AuthorizationCode struct { Code string ClientID string RedirectURI string UserID string Scope string ExpiresAt time.Time CodeChallenge string CodeChallengeMethod string } type Token struct { AccessToken string RefreshToken string TokenType string ExpiresIn int ExpiresAt time.Time Scope string UserID string ClientID string } func NewAuthorizationServer(issuer string, signingKey []byte) *AuthorizationServer { return &AuthorizationServer{ clients: make(map[string]*Client), codes: make(map[string]*AuthorizationCode), tokens: make(map[string]*Token), issuer: issuer, signingKey: signingKey, } } func (as *AuthorizationServer) RegisterClient(client *Client) error { as.mu.Lock() defer as.mu.Unlock() if client.ID == "" { client.ID = uuid.New().String() } if client.Secret == "" { secret := make([]byte, 32) rand.Read(secret) client.Secret = base64.URLEncoding.EncodeToString(secret) } as.clients[client.ID] = client return nil } func (as *AuthorizationServer) HandleAuthorization(w http.ResponseWriter, r *http.Request) { clientID := r.URL.Query().Get("client_id") redirectURI := r.URL.Query().Get("redirect_uri") responseType := r.URL.Query().Get("response_type") scope := r.URL.Query().Get("scope") state := r.URL.Query().Get("state") codeChallenge := r.URL.Query().Get("code_challenge") codeChallengeMethod := r.URL.Query().Get("code_challenge_method") if responseType != "code" { as.redirectWithError(w, redirectURI, "unsupported_response_type", state) return } client, exists := as.clients[clientID] if !exists { as.redirectWithError(w, redirectURI, "invalid_client", state) return } if !as.validRedirectURI(redirectURI, client) { as.redirectWithError(w, redirectURI, "invalid_request", state) return } userID := as.authenticateUser(r) if userID == "" { as.renderLoginForm(w, r, clientID, redirectURI, scope, state) return } code := as.generateAuthorizationCode(clientID, redirectURI, userID, scope, codeChallenge, codeChallengeMethod) params := url.Values{} params.Set("code", code) params.Set("state", state) http.Redirect(w, r, redirectURI+"?"+params.Encode(), http.StatusFound) } func (as *AuthorizationServer) renderLoginForm(w http.ResponseWriter, r *http.Request, clientID, redirectURI, scope, state string) { w.Header().Set("Content-Type", "text/html") fmt.Fprintf(w, ` <html> <body> <h1>Login</h1> <form method="post"> <input type="hidden" name="client_id" value="%s"> <input type="hidden" name="redirect_uri" value="%s"> <input type="hidden" name="scope" value="%s"> <input type="hidden" name="state" value="%s"> <input type="hidden" name="action" value="login"> <input type="hidden" name="return_to" value="%s"> <p>Email: <input type="text" name="email"></p> <p>Password: <input type="password" name="password"></p> <button type="submit">Login</button> </form> </body> </html> `, clientID, redirectURI, scope, state, r.URL.RequestURI()) } func (as *AuthorizationServer) authenticateUser(r *http.Request) string { return r.Header.Get("X-User-ID") } func (as *AuthorizationServer) HandleToken(w http.ResponseWriter, r *http.Request) { r.ParseForm() grantType := r.PostForm.Get("grant_type") switch grantType { case "authorization_code": as.handleAuthorizationCodeGrant(w, r) case "refresh_token": as.handleRefreshTokenGrant(w, r) case "client_credentials": as.handleClientCredentialsGrant(w, r) default: http.Error(w, `{"error": "unsupported_grant_type"}`, http.StatusBadRequest) } } func (as *AuthorizationServer) handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Request) { clientID := r.PostForm.Get("client_id") clientSecret := r.PostForm.Get("client_secret") code := r.PostForm.Get("code") redirectURI := r.PostForm.Get("redirect_uri") codeVerifier := r.PostForm.Get("code_verifier") client, exists := as.clients[clientID] if !exists || client.Secret != clientSecret { as.tokenError(w, "invalid_client") return } as.mu.Lock() authCode, ok := as.codes[code] if ok { delete(as.codes, code) } as.mu.Unlock() if !ok || authCode.Expired() { as.tokenError(w, "invalid_grant") return } if authCode.RedirectURI != redirectURI { as.tokenError(w, "invalid_grant") return } if authCode.CodeChallenge != "" { if !as.verifyCodeChallenge(codeVerifier, authCode.CodeChallenge, authCode.CodeChallengeMethod) { as.tokenError(w, "invalid_grant") return } } token := as.generateToken(authCode.UserID, clientID, authCode.Scope) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(token) } func (as *AuthorizationServer) handleRefreshTokenGrant(w http.ResponseWriter, r *http.Request) { refreshToken := r.PostForm.Get("refresh_token") as.mu.RLock() token, exists := as.tokens[refreshToken] as.mu.RUnlock() if !exists || token.Expired() { as.tokenError(w, "invalid_grant") return } newToken := as.generateToken(token.UserID, token.ClientID, token.Scope) as.mu.Lock() delete(as.tokens, refreshToken) as.mu.Unlock() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(newToken) } func (as *AuthorizationServer) handleClientCredentialsGrant(w http.ResponseWriter, r *http.Request) { clientID := r.PostForm.Get("client_id") clientSecret := r.PostForm.Get("client_secret") scope := r.PostForm.Get("scope") client, exists := as.clients[clientID] if !exists || client.Secret != clientSecret { as.tokenError(w, "invalid_client") return } token := as.generateToken("", clientID, scope) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(token) } func (as *AuthorizationServer) generateAuthorizationCode(clientID, redirectURI, userID, scope, codeChallenge, codeChallengeMethod string) string { code := generateRandomString(32) as.mu.Lock() as.codes[code] = &AuthorizationCode{ Code: code, ClientID: clientID, RedirectURI: redirectURI, UserID: userID, Scope: scope, ExpiresAt: time.Now().Add(10 * time.Minute), CodeChallenge: codeChallenge, CodeChallengeMethod: codeChallengeMethod, } as.mu.Unlock() return code } func (as *AuthorizationServer) generateToken(userID, clientID, scope string) *Token { accessToken := generateRandomString(32) refreshToken := generateRandomString(32) expiresIn := 3600 token := &Token{ AccessToken: accessToken, RefreshToken: refreshToken, TokenType: "Bearer", ExpiresIn: expiresIn, ExpiresAt: time.Now().Add(time.Duration(expiresIn) * time.Second), Scope: scope, UserID: userID, ClientID: clientID, } as.mu.Lock() as.tokens[accessToken] = token as.tokens[refreshToken] = token as.mu.Unlock() return token } func (as *AuthorizationServer) verifyCodeChallenge(verifier, challenge, method string) bool { switch method { case "S256": hash := sha256.Sum256([]byte(verifier)) computed := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:]) return computed == challenge case "plain": return verifier == challenge default: return false } } func (as *AuthorizationServer) validRedirectURI(uri string, client *Client) bool { for _, validURI := range client.RedirectURIs { if validURI == uri { return true } } return false } func (as *AuthorizationServer) redirectWithError(w http.ResponseWriter, redirectURI, error, state string) { if redirectURI == "" { http.Error(w, error, http.StatusBadRequest) return } params := url.Values{} params.Set("error", error) if state != "" { params.Set("state", state) } http.Redirect(w, &http.Request{}, redirectURI+"?"+params.Encode(), http.StatusFound) } func (as *AuthorizationServer) tokenError(w http.ResponseWriter, error string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": error}) } func (as *AuthorizationServer) ValidateToken(tokenString string) (*Token, error) { as.mu.RLock() defer as.mu.RUnlock() token, exists := as.tokens[tokenString] if !exists || token.Expired() { return nil, fmt.Errorf("invalid or expired token") } return token, nil } func (c *AuthorizationCode) Expired() bool { return time.Now().After(c.ExpiresAt) } func (t *Token) Expired() bool { return time.Now().After(t.ExpiresAt) } func generateRandomString(length int) string { bytes := make([]byte, length) rand.Read(bytes) return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes) }三、JWT实现与使用
3.1 JWT结构
JWT由三部分组成:Header.Payload.Signature
package jwt import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "strings" "time" ) var ( ErrInvalidToken = errors.New("invalid token") ErrExpiredToken = errors.New("token has expired") ErrInvalidClaims = errors.New("invalid claims") ) type Header struct { Alg string `json:"alg"` Typ string `json:"typ"` } type Claims struct { Issuer string `json:"iss,omitempty"` Subject string `json:"sub,omitempty"` Audience string `json:"aud,omitempty"` ExpiresAt int64 `json:"exp,omitempty"` NotBefore int64 `json:"nbf,omitempty"` IssuedAt int64 `json:"iat,omitempty"` JWTID string `json:"jti,omitempty"` Custom map[string]interface{} `json:"-"` } type Token struct { Raw string Header Header Claims Claims Signature []byte } func (c *Claims) Validate(issuer, audience string, expiry time.Duration) error { now := time.Now().Unix() if c.ExpiresAt > 0 && c.ExpiresAt < now { return ErrExpiredToken } if c.NotBefore > 0 && c.NotBefore > now { return ErrInvalidClaims } if issuer != "" && c.Issuer != issuer { return ErrInvalidClaims } if audience != "" && c.Audience != audience { return ErrInvalidClaims } return nil } type Encoder struct { secretKey []byte algorithm string } func NewEncoder(secretKey []byte) *Encoder { return &Encoder{ secretKey: secretKey, algorithm: "HS256", } } func (e *Encoder) Encode(claims *Claims) (string, error) { if claims.IssuedAt == 0 { claims.IssuedAt = time.Now().Unix() } header := Header{ Alg: e.algorithm, Typ: "JWT", } headerBytes, err := json.Marshal(header) if err != nil { return "", fmt.Errorf("failed to marshal header: %w", err) } claimsBytes, err := json.Marshal(claims) if err != nil { return "", fmt.Errorf("failed to marshal claims: %w", err) } headerB64 := base64.RawURLEncoding.EncodeToString(headerBytes) claimsB64 := base64.RawURLEncoding.EncodeToString(claimsBytes) signingInput := headerB64 + "." + claimsB64 signature := e.sign([]byte(signingInput)) return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil } func (e *Encoder) sign(message []byte) []byte { h := hmac.New(sha256.New, e.secretKey) h.Write(message) return h.Sum(nil) } type Decoder struct { secretKey []byte algorithm string } func NewDecoder(secretKey []byte) *Decoder { return &Decoder{ secretKey: secretKey, algorithm: "HS256", } } func (d *Decoder) Decode(tokenString string) (*Token, error) { parts := strings.Split(tokenString, ".") if len(parts) != 3 { return nil, ErrInvalidToken } headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return nil, ErrInvalidToken } var header Header if err := json.Unmarshal(headerBytes, &header); err != nil { return nil, ErrInvalidToken } claimsBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return nil, ErrInvalidToken } var claims Claims if err := json.Unmarshal(claimsBytes, &claims); err != nil { return nil, ErrInvalidToken } signature, err := base64.RawURLEncoding.DecodeString(parts[2]) if err != nil { return nil, ErrInvalidToken } signingInput := parts[0] + "." + parts[1] expectedSig := d.sign([]byte(signingInput)) if !hmac.Equal(signature, expectedSig) { return nil, ErrInvalidToken } return &Token{ Raw: tokenString, Header: header, Claims: claims, Signature: signature, }, nil } func (d *Decoder) sign(message []byte) []byte { h := hmac.New(sha256.New, d.secretKey) h.Write(message) return h.Sum(nil) }3.2 JWT刷新令牌机制
package jwt import ( "sync" "time" ) type TokenStore struct { mu sync.RWMutex refreshTokens map[string]*RefreshTokenEntry accessTokens map[string]*AccessTokenEntry encoder *Encoder decoder *Decoder } type RefreshTokenEntry struct { TokenID string UserID string ClientID string Scope string IssuedAt time.Time ExpiresAt time.Time UsedCount int Revoked bool } type AccessTokenEntry struct { TokenID string UserID string ClientID string Scope string IssuedAt time.Time ExpiresAt time.Time Revoked bool } func NewTokenStore(secretKey []byte) *TokenStore { return &TokenStore{ refreshTokens: make(map[string]*RefreshTokenEntry), accessTokens: make(map[string]*AccessTokenEntry), encoder: NewEncoder(secretKey), decoder: NewDecoder(secretKey), } } func (ts *TokenStore) GenerateAccessToken(userID, clientID, scope string, expiry time.Duration) (*Token, error) { claims := &Claims{ Subject: userID, Audience: clientID, IssuedAt: time.Now().Unix(), ExpiresAt: time.Now().Add(expiry).Unix(), JWTID: generateTokenID(), } tokenString, err := ts.encoder.Encode(claims) if err != nil { return nil, err } entry := &AccessTokenEntry{ TokenID: claims.JWTID, UserID: userID, ClientID: clientID, Scope: scope, IssuedAt: time.Now(), ExpiresAt: time.Now().Add(expiry), } ts.mu.Lock() ts.accessTokens[claims.JWTID] = entry ts.mu.Unlock() return &Token{Raw: tokenString, Claims: *claims}, nil } func (ts *TokenStore) GenerateRefreshToken(userID, clientID, scope string) (string, time.Time, error) { claims := &Claims{ Subject: userID, Audience: clientID, IssuedAt: time.Now().Unix(), ExpiresAt: time.Now().Add(30 * 24 * time.Hour).Unix(), JWTID: generateTokenID(), } tokenString, err := ts.encoder.Encode(claims) if err != nil { return "", time.Time{}, err } expiresAt := time.Until(time.Now().Add(30 * 24 * time.Hour)) entry := &RefreshTokenEntry{ TokenID: claims.JWTID, UserID: userID, ClientID: clientID, Scope: scope, IssuedAt: time.Now(), ExpiresAt: time.Now().Add(30 * 24 * time.Hour), } ts.mu.Lock() ts.refreshTokens[claims.JWTID] = entry ts.mu.Unlock() return tokenString, expiresAt, nil } func (ts *TokenStore) RefreshAccessToken(refreshTokenString string) (*Token, error) { token, err := ts.decoder.Decode(refreshTokenString) if err != nil { return nil, err } ts.mu.Lock() entry, exists := ts.refreshTokens[token.Claims.JWTID] if !exists || entry.Revoked { ts.mu.Unlock() return nil, ErrInvalidToken } if entry.UsedCount >= 1 { for id := range ts.refreshTokens { if ts.refreshTokens[id].UserID == entry.UserID { ts.refreshTokens[id].Revoked = true } } ts.mu.Unlock() return nil, ErrInvalidToken } entry.UsedCount++ ts.mu.Unlock() return ts.GenerateAccessToken(entry.UserID, entry.ClientID, entry.Scope, time.Hour) } func (ts *TokenStore) RevokeRefreshToken(tokenString string) error { token, err := ts.decoder.Decode(tokenString) if err != nil { return err } ts.mu.Lock() if entry, exists := ts.refreshTokens[token.Claims.JWTID]; exists { entry.Revoked = true } ts.mu.Unlock() return nil } func (ts *TokenStore) ValidateAccessToken(tokenString string) (*AccessTokenEntry, error) { token, err := ts.decoder.Decode(tokenString) if err != nil { return nil, err } ts.mu.RLock() entry, exists := ts.accessTokens[token.Claims.JWTID] ts.mu.RUnlock() if !exists || entry.Revoked { return nil, ErrInvalidToken } if time.Now().After(entry.ExpiresAt) { return nil, ErrExpiredToken } return entry, nil } func (ts *TokenStore) Cleanup() { ticker := time.NewTicker(1 * time.Hour) go func() { for range ticker.C { ts.cleanupExpired() } }() } func (ts *TokenStore) cleanupExpired() { now := time.Now() ts.mu.Lock() for id, entry := range ts.refreshTokens { if now.After(entry.ExpiresAt) { delete(ts.refreshTokens, id) } } for id, entry := range ts.accessTokens { if now.After(entry.ExpiresAt) { delete(ts.accessTokens, id) } } ts.mu.Unlock() } func generateTokenID() string { b := make([]byte, 16) for i := range b { b[i] = byte(time.Now().UnixNano() >> uint(i*8) & 0xff) } return base64.RawURLEncoding.EncodeToString(b) }四、实战:API网关集成
package gateway import ( "net/http" "strings" "github.com/4jiang_style/csdn-users/jwt" ) type AuthMiddleware struct { decoder *jwt.Decoder skipPaths []string } func NewAuthMiddleware(secretKey []byte) *AuthMiddleware { return &AuthMiddleware{ decoder: jwt.NewDecoder(secretKey), skipPaths: []string{"/health", "/metrics", "/oauth/token"}, } } func (m *AuthMiddleware) Handler(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if m.shouldSkip(r.URL.Path) { next.ServeHTTP(w, r) return } authHeader := r.Header.Get("Authorization") if authHeader == "" { http.Error(w, "missing authorization header", http.StatusUnauthorized) return } parts := strings.SplitN(authHeader, " ", 2) if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { http.Error(w, "invalid authorization header format", http.StatusUnauthorized) return } token, err := m.decoder.Decode(parts[1]) if err != nil { http.Error(w, "invalid token", http.StatusUnauthorized) return } if err := token.Claims.Validate("", "", 0); err != nil { http.Error(w, "token expired or invalid", http.StatusUnauthorized) return } r.Header.Set("X-User-ID", token.Claims.Subject) r.Header.Set("X-Token-ID", token.Claims.JWTID) next.ServeHTTP(w, r) }) } func (m *AuthMiddleware) shouldSkip(path string) bool { for _, skipPath := range m.skipPaths { if strings.HasPrefix(path, skipPath) { return true } } return false }五、总结
OAuth2和JWT是现代API安全的核心标准:
OAuth2授权类型选择:
- 有后端服务:Authorization Code + PKCE
- SPA/移动端:PKCE是必需的
- 服务间:Client Credentials
JWT安全要点:
- 始终验证签名
- 检查令牌过期时间
- 验证Issuer和Audience
- 使用HTTPS传输
刷新令牌策略:
- 限制刷新令牌使用次数
- 检测令牌重用攻击
- 定期轮换刷新令牌
安全最佳实践:
- 令牌存放在安全位置,避免XSS泄露
- 实现令牌撤销机制
- 记录审计日志
掌握这些核心技术,能够构建安全可靠的现代认证授权系统。
