3.9.2 OAuth2.0 授权 #
OAuth 2.0 是一个开放标准,允许用户授权第三方应用访问他们在另一个服务上的资源,而无需分享他们的凭据。它广泛用于社交登录、API 访问控制等场景。
OAuth 2.0 基础概念 #
角色定义 #
OAuth 2.0 定义了四个角色:
- 资源所有者(Resource Owner):通常是用户,拥有受保护资源的实体
- 客户端(Client):代表资源所有者请求受保护资源的应用程序
- 资源服务器(Resource Server):托管受保护资源的服务器
- 授权服务器(Authorization Server):验证资源所有者身份并颁发访问令牌
授权流程类型 #
OAuth 2.0 定义了四种授权流程:
- 授权码流程(Authorization Code Flow):最安全的流程,适用于服务端应用
- 隐式流程(Implicit Flow):适用于客户端应用,已不推荐使用
- 资源所有者密码凭据流程(Resource Owner Password Credentials Flow):适用于高度信任的应用
- 客户端凭据流程(Client Credentials Flow):适用于服务间通信
Go 中实现 OAuth 2.0 #
安装依赖 #
go get golang.org/x/oauth2
go get github.com/gin-gonic/gin
go get github.com/google/uuid
OAuth 2.0 服务器实现 #
授权服务器 #
// pkg/oauth/server.go
package oauth
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/url"
"time"
"github.com/google/uuid"
)
// Client OAuth客户端
type Client struct {
ID string `json:"id"`
Secret string `json:"secret"`
Name string `json:"name"`
RedirectURIs []string `json:"redirect_uris"`
Scopes []string `json:"scopes"`
}
// AuthorizationCode 授权码
type AuthorizationCode struct {
Code string `json:"code"`
ClientID string `json:"client_id"`
UserID string `json:"user_id"`
RedirectURI string `json:"redirect_uri"`
Scope string `json:"scope"`
ExpiresAt time.Time `json:"expires_at"`
Used bool `json:"used"`
}
// AccessToken 访问令牌
type AccessToken struct {
Token string `json:"token"`
TokenType string `json:"token_type"`
ClientID string `json:"client_id"`
UserID string `json:"user_id"`
Scope string `json:"scope"`
ExpiresAt time.Time `json:"expires_at"`
}
// RefreshToken 刷新令牌
type RefreshToken struct {
Token string `json:"token"`
ClientID string `json:"client_id"`
UserID string `json:"user_id"`
Scope string `json:"scope"`
ExpiresAt time.Time `json:"expires_at"`
}
// OAuthServer OAuth服务器
type OAuthServer struct {
clients map[string]*Client
authCodes map[string]*AuthorizationCode
accessTokens map[string]*AccessToken
refreshTokens map[string]*RefreshToken
}
// NewOAuthServer 创建OAuth服务器
func NewOAuthServer() *OAuthServer {
return &OAuthServer{
clients: make(map[string]*Client),
authCodes: make(map[string]*AuthorizationCode),
accessTokens: make(map[string]*AccessToken),
refreshTokens: make(map[string]*RefreshToken),
}
}
// RegisterClient 注册客户端
func (s *OAuthServer) RegisterClient(client *Client) {
s.clients[client.ID] = client
}
// GetClient 获取客户端
func (s *OAuthServer) GetClient(clientID string) (*Client, error) {
client, exists := s.clients[clientID]
if !exists {
return nil, errors.New("客户端不存在")
}
return client, nil
}
// ValidateRedirectURI 验证重定向URI
func (s *OAuthServer) ValidateRedirectURI(clientID, redirectURI string) error {
client, err := s.GetClient(clientID)
if err != nil {
return err
}
for _, uri := range client.RedirectURIs {
if uri == redirectURI {
return nil
}
}
return errors.New("无效的重定向URI")
}
// GenerateAuthorizationCode 生成授权码
func (s *OAuthServer) GenerateAuthorizationCode(clientID, userID, redirectURI, scope string) (string, error) {
code := generateRandomString(32)
authCode := &AuthorizationCode{
Code: code,
ClientID: clientID,
UserID: userID,
RedirectURI: redirectURI,
Scope: scope,
ExpiresAt: time.Now().Add(10 * time.Minute), // 10分钟有效期
Used: false,
}
s.authCodes[code] = authCode
return code, nil
}
// ExchangeCodeForToken 用授权码换取访问令牌
func (s *OAuthServer) ExchangeCodeForToken(code, clientID, clientSecret, redirectURI string) (*AccessToken, *RefreshToken, error) {
// 验证客户端
client, err := s.GetClient(clientID)
if err != nil {
return nil, nil, err
}
if client.Secret != clientSecret {
return nil, nil, errors.New("客户端密钥错误")
}
// 验证授权码
authCode, exists := s.authCodes[code]
if !exists {
return nil, nil, errors.New("授权码不存在")
}
if authCode.Used {
return nil, nil, errors.New("授权码已使用")
}
if time.Now().After(authCode.ExpiresAt) {
return nil, nil, errors.New("授权码已过期")
}
if authCode.ClientID != clientID {
return nil, nil, errors.New("客户端ID不匹配")
}
if authCode.RedirectURI != redirectURI {
return nil, nil, errors.New("重定向URI不匹配")
}
// 标记授权码为已使用
authCode.Used = true
// 生成访问令牌
accessToken := &AccessToken{
Token: generateRandomString(32),
TokenType: "Bearer",
ClientID: clientID,
UserID: authCode.UserID,
Scope: authCode.Scope,
ExpiresAt: time.Now().Add(1 * time.Hour), // 1小时有效期
}
// 生成刷新令牌
refreshToken := &RefreshToken{
Token: generateRandomString(32),
ClientID: clientID,
UserID: authCode.UserID,
Scope: authCode.Scope,
ExpiresAt: time.Now().Add(30 * 24 * time.Hour), // 30天有效期
}
s.accessTokens[accessToken.Token] = accessToken
s.refreshTokens[refreshToken.Token] = refreshToken
return accessToken, refreshToken, nil
}
// ValidateAccessToken 验证访问令牌
func (s *OAuthServer) ValidateAccessToken(token string) (*AccessToken, error) {
accessToken, exists := s.accessTokens[token]
if !exists {
return nil, errors.New("访问令牌不存在")
}
if time.Now().After(accessToken.ExpiresAt) {
return nil, errors.New("访问令牌已过期")
}
return accessToken, nil
}
// RefreshAccessToken 刷新访问令牌
func (s *OAuthServer) RefreshAccessToken(refreshTokenStr, clientID, clientSecret string) (*AccessToken, *RefreshToken, error) {
// 验证客户端
client, err := s.GetClient(clientID)
if err != nil {
return nil, nil, err
}
if client.Secret != clientSecret {
return nil, nil, errors.New("客户端密钥错误")
}
// 验证刷新令牌
refreshToken, exists := s.refreshTokens[refreshTokenStr]
if !exists {
return nil, nil, errors.New("刷新令牌不存在")
}
if time.Now().After(refreshToken.ExpiresAt) {
return nil, nil, errors.New("刷新令牌已过期")
}
if refreshToken.ClientID != clientID {
return nil, nil, errors.New("客户端ID不匹配")
}
// 生成新的访问令牌
newAccessToken := &AccessToken{
Token: generateRandomString(32),
TokenType: "Bearer",
ClientID: clientID,
UserID: refreshToken.UserID,
Scope: refreshToken.Scope,
ExpiresAt: time.Now().Add(1 * time.Hour),
}
// 生成新的刷新令牌
newRefreshToken := &RefreshToken{
Token: generateRandomString(32),
ClientID: clientID,
UserID: refreshToken.UserID,
Scope: refreshToken.Scope,
ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
}
// 删除旧令牌
delete(s.refreshTokens, refreshTokenStr)
// 存储新令牌
s.accessTokens[newAccessToken.Token] = newAccessToken
s.refreshTokens[newRefreshToken.Token] = newRefreshToken
return newAccessToken, newRefreshToken, nil
}
// generateRandomString 生成随机字符串
func generateRandomString(length int) string {
bytes := make([]byte, length)
rand.Read(bytes)
return base64.URLEncoding.EncodeToString(bytes)[:length]
}
OAuth 控制器 #
// controllers/oauth.go
package controllers
import (
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"your-project/pkg/oauth"
)
type OAuthController struct {
oauthServer *oauth.OAuthServer
}
func NewOAuthController(oauthServer *oauth.OAuthServer) *OAuthController {
return &OAuthController{
oauthServer: oauthServer,
}
}
// AuthorizeRequest 授权请求参数
type AuthorizeRequest struct {
ResponseType string `form:"response_type" binding:"required"`
ClientID string `form:"client_id" binding:"required"`
RedirectURI string `form:"redirect_uri" binding:"required"`
Scope string `form:"scope"`
State string `form:"state"`
}
// TokenRequest 令牌请求参数
type TokenRequest struct {
GrantType string `form:"grant_type" binding:"required"`
Code string `form:"code"`
RedirectURI string `form:"redirect_uri"`
ClientID string `form:"client_id" binding:"required"`
ClientSecret string `form:"client_secret" binding:"required"`
RefreshToken string `form:"refresh_token"`
}
// Authorize 授权端点
func (ctrl *OAuthController) Authorize(c *gin.Context) {
var req AuthorizeRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_request",
"error_description": err.Error(),
})
return
}
// 验证响应类型
if req.ResponseType != "code" {
ctrl.redirectWithError(c, req.RedirectURI, req.State, "unsupported_response_type", "不支持的响应类型")
return
}
// 验证客户端
_, err := ctrl.oauthServer.GetClient(req.ClientID)
if err != nil {
ctrl.redirectWithError(c, req.RedirectURI, req.State, "invalid_client", "无效的客户端")
return
}
// 验证重定向URI
if err := ctrl.oauthServer.ValidateRedirectURI(req.ClientID, req.RedirectURI); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_request",
"error_description": "无效的重定向URI",
})
return
}
// 检查用户是否已登录
userID, exists := c.Get("user_id")
if !exists {
// 重定向到登录页面
loginURL := "/login?redirect=" + url.QueryEscape(c.Request.URL.String())
c.Redirect(http.StatusFound, loginURL)
return
}
// 显示授权页面或直接授权(这里简化为直接授权)
code, err := ctrl.oauthServer.GenerateAuthorizationCode(
req.ClientID,
userID.(string),
req.RedirectURI,
req.Scope,
)
if err != nil {
ctrl.redirectWithError(c, req.RedirectURI, req.State, "server_error", "服务器错误")
return
}
// 重定向到客户端
redirectURL, _ := url.Parse(req.RedirectURI)
query := redirectURL.Query()
query.Set("code", code)
if req.State != "" {
query.Set("state", req.State)
}
redirectURL.RawQuery = query.Encode()
c.Redirect(http.StatusFound, redirectURL.String())
}
// Token 令牌端点
func (ctrl *OAuthController) Token(c *gin.Context) {
var req TokenRequest
if err := c.ShouldBind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_request",
"error_description": err.Error(),
})
return
}
switch req.GrantType {
case "authorization_code":
ctrl.handleAuthorizationCodeGrant(c, &req)
case "refresh_token":
ctrl.handleRefreshTokenGrant(c, &req)
default:
c.JSON(http.StatusBadRequest, gin.H{
"error": "unsupported_grant_type",
"error_description": "不支持的授权类型",
})
}
}
// handleAuthorizationCodeGrant 处理授权码授权
func (ctrl *OAuthController) handleAuthorizationCodeGrant(c *gin.Context, req *TokenRequest) {
if req.Code == "" || req.RedirectURI == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_request",
"error_description": "缺少必需参数",
})
return
}
accessToken, refreshToken, err := ctrl.oauthServer.ExchangeCodeForToken(
req.Code,
req.ClientID,
req.ClientSecret,
req.RedirectURI,
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_grant",
"error_description": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"access_token": accessToken.Token,
"token_type": accessToken.TokenType,
"expires_in": int(accessToken.ExpiresAt.Sub(time.Now()).Seconds()),
"refresh_token": refreshToken.Token,
"scope": accessToken.Scope,
})
}
// handleRefreshTokenGrant 处理刷新令牌授权
func (ctrl *OAuthController) handleRefreshTokenGrant(c *gin.Context, req *TokenRequest) {
if req.RefreshToken == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_request",
"error_description": "缺少刷新令牌",
})
return
}
accessToken, refreshToken, err := ctrl.oauthServer.RefreshAccessToken(
req.RefreshToken,
req.ClientID,
req.ClientSecret,
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_grant",
"error_description": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"access_token": accessToken.Token,
"token_type": accessToken.TokenType,
"expires_in": int(accessToken.ExpiresAt.Sub(time.Now()).Seconds()),
"refresh_token": refreshToken.Token,
"scope": accessToken.Scope,
})
}
// redirectWithError 错误重定向
func (ctrl *OAuthController) redirectWithError(c *gin.Context, redirectURI, state, errorCode, errorDescription string) {
redirectURL, _ := url.Parse(redirectURI)
query := redirectURL.Query()
query.Set("error", errorCode)
query.Set("error_description", errorDescription)
if state != "" {
query.Set("state", state)
}
redirectURL.RawQuery = query.Encode()
c.Redirect(http.StatusFound, redirectURL.String())
}
OAuth 2.0 客户端实现 #
// pkg/oauth/client.go
package oauth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"golang.org/x/oauth2"
)
// OAuthClient OAuth客户端
type OAuthClient struct {
config *oauth2.Config
}
// NewOAuthClient 创建OAuth客户端
func NewOAuthClient(clientID, clientSecret, authURL, tokenURL, redirectURL string, scopes []string) *OAuthClient {
config := &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Scopes: scopes,
Endpoint: oauth2.Endpoint{
AuthURL: authURL,
TokenURL: tokenURL,
},
}
return &OAuthClient{
config: config,
}
}
// GetAuthURL 获取授权URL
func (c *OAuthClient) GetAuthURL(state string) string {
return c.config.AuthCodeURL(state, oauth2.AccessTypeOffline)
}
// ExchangeCode 用授权码换取令牌
func (c *OAuthClient) ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) {
return c.config.Exchange(ctx, code)
}
// RefreshToken 刷新令牌
func (c *OAuthClient) RefreshToken(ctx context.Context, token *oauth2.Token) (*oauth2.Token, error) {
tokenSource := c.config.TokenSource(ctx, token)
return tokenSource.Token()
}
// GetUserInfo 获取用户信息
func (c *OAuthClient) GetUserInfo(ctx context.Context, token *oauth2.Token) (map[string]interface{}, error) {
client := c.config.Client(ctx, token)
resp, err := client.Get("http://localhost:8080/api/v1/user")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取用户信息失败: %d", resp.StatusCode)
}
var userInfo map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
return nil, err
}
return userInfo, nil
}
第三方登录集成 #
GitHub OAuth 集成 #
// pkg/oauth/github.go
package oauth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"golang.org/x/oauth2"
"golang.org/x/oauth2/github"
)
// GitHubOAuth GitHub OAuth客户端
type GitHubOAuth struct {
config *oauth2.Config
}
// NewGitHubOAuth 创建GitHub OAuth客户端
func NewGitHubOAuth(clientID, clientSecret, redirectURL string) *GitHubOAuth {
config := &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Scopes: []string{"user:email"},
Endpoint: github.Endpoint,
}
return &GitHubOAuth{
config: config,
}
}
// GetAuthURL 获取GitHub授权URL
func (g *GitHubOAuth) GetAuthURL(state string) string {
return g.config.AuthCodeURL(state)
}
// ExchangeCode 用授权码换取令牌
func (g *GitHubOAuth) ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) {
return g.config.Exchange(ctx, code)
}
// GitHubUser GitHub用户信息
type GitHubUser struct {
ID int `json:"id"`
Login string `json:"login"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
}
// GetUserInfo 获取GitHub用户信息
func (g *GitHubOAuth) GetUserInfo(ctx context.Context, token *oauth2.Token) (*GitHubUser, error) {
client := g.config.Client(ctx, token)
resp, err := client.Get("https://api.github.com/user")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取用户信息失败: %d", resp.StatusCode)
}
var user GitHubUser
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, err
}
return &user, nil
}
社交登录控制器 #
// controllers/social.go
package controllers
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"your-project/pkg/oauth"
)
type SocialController struct {
githubOAuth *oauth.GitHubOAuth
}
func NewSocialController(githubOAuth *oauth.GitHubOAuth) *SocialController {
return &SocialController{
githubOAuth: githubOAuth,
}
}
// GitHubLogin GitHub登录
func (ctrl *SocialController) GitHubLogin(c *gin.Context) {
state := generateRandomString(32)
// 将state存储到session中(这里简化处理)
c.SetCookie("oauth_state", state, 600, "/", "", false, true)
authURL := ctrl.githubOAuth.GetAuthURL(state)
c.Redirect(http.StatusFound, authURL)
}
// GitHubCallback GitHub回调
func (ctrl *SocialController) GitHubCallback(c *gin.Context) {
// 验证state参数
state := c.Query("state")
cookieState, err := c.Cookie("oauth_state")
if err != nil || state != cookieState {
c.JSON(http.StatusBadRequest, gin.H{
"error": "无效的state参数",
})
return
}
// 清除state cookie
c.SetCookie("oauth_state", "", -1, "/", "", false, true)
// 获取授权码
code := c.Query("code")
if code == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "缺少授权码",
})
return
}
// 用授权码换取令牌
ctx := context.Background()
token, err := ctrl.githubOAuth.ExchangeCode(ctx, code)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "令牌交换失败",
})
return
}
// 获取用户信息
githubUser, err := ctrl.githubOAuth.GetUserInfo(ctx, token)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "获取用户信息失败",
})
return
}
// 这里可以将GitHub用户信息与本地用户关联
// 或创建新用户账户
c.JSON(http.StatusOK, gin.H{
"message": "登录成功",
"user": githubUser,
"token": token.AccessToken,
})
}
OAuth 2.0 中间件 #
// middleware/oauth.go
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"your-project/pkg/oauth"
)
// OAuthMiddleware OAuth认证中间件
func OAuthMiddleware(oauthServer *oauth.OAuthServer) gin.HandlerFunc {
return func(c *gin.Context) {
// 从Header中获取Authorization
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "unauthorized",
"error_description": "缺少访问令牌",
})
c.Abort()
return
}
// 检查Bearer前缀
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "invalid_token",
"error_description": "令牌格式错误",
})
c.Abort()
return
}
// 验证访问令牌
accessToken, err := oauthServer.ValidateAccessToken(parts[1])
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "invalid_token",
"error_description": err.Error(),
})
c.Abort()
return
}
// 将令牌信息存储到上下文
c.Set("client_id", accessToken.ClientID)
c.Set("user_id", accessToken.UserID)
c.Set("scope", accessToken.Scope)
c.Next()
}
}
// RequireScope 作用域验证中间件
func RequireScope(requiredScope string) gin.HandlerFunc {
return func(c *gin.Context) {
scope, exists := c.Get("scope")
if !exists {
c.JSON(http.StatusForbidden, gin.H{
"error": "insufficient_scope",
"error_description": "无法获取令牌作用域",
})
c.Abort()
return
}
scopes := strings.Split(scope.(string), " ")
for _, s := range scopes {
if s == requiredScope {
c.Next()
return
}
}
c.JSON(http.StatusForbidden, gin.H{
"error": "insufficient_scope",
"error_description": "权限不足",
})
c.Abort()
}
}
完整示例 #
// main.go
package main
import (
"log"
"github.com/gin-gonic/gin"
"your-project/controllers"
"your-project/middleware"
"your-project/pkg/oauth"
)
func main() {
// 初始化OAuth服务器
oauthServer := oauth.NewOAuthServer()
// 注册客户端
client := &oauth.Client{
ID: "test-client",
Secret: "test-secret",
Name: "测试客户端",
RedirectURIs: []string{"http://localhost:3000/callback"},
Scopes: []string{"read", "write"},
}
oauthServer.RegisterClient(client)
// 初始化GitHub OAuth
githubOAuth := oauth.NewGitHubOAuth(
"your-github-client-id",
"your-github-client-secret",
"http://localhost:8080/auth/github/callback",
)
// 初始化控制器
oauthController := controllers.NewOAuthController(oauthServer)
socialController := controllers.NewSocialController(githubOAuth)
// 初始化路由
r := gin.Default()
// OAuth 2.0 端点
r.GET("/oauth/authorize", oauthController.Authorize)
r.POST("/oauth/token", oauthController.Token)
// 社交登录
r.GET("/auth/github", socialController.GitHubLogin)
r.GET("/auth/github/callback", socialController.GitHubCallback)
// 受保护的API
api := r.Group("/api/v1")
api.Use(middleware.OAuthMiddleware(oauthServer))
{
api.GET("/user", func(c *gin.Context) {
userID, _ := c.Get("user_id")
c.JSON(200, gin.H{
"user_id": userID,
"message": "用户信息",
})
})
// 需要特定作用域的接口
writeAPI := api.Group("/write")
writeAPI.Use(middleware.RequireScope("write"))
{
writeAPI.POST("/data", func(c *gin.Context) {
c.JSON(200, gin.H{"message": "数据已写入"})
})
}
}
log.Println("OAuth服务器启动在 :8080")
r.Run(":8080")
}
OAuth 2.0 最佳实践 #
1. 安全考虑 #
- 使用 HTTPS:所有 OAuth 流程必须使用 HTTPS
- 状态参数:使用 state 参数防止 CSRF 攻击
- 短期令牌:访问令牌应该有较短的有效期
- 安全存储:客户端密钥必须安全存储
2. 令牌管理 #
- 令牌撤销:提供令牌撤销机制
- 作用域控制:实现细粒度的权限控制
- 令牌刷新:合理使用刷新令牌机制
3. 错误处理 #
- 标准错误码:使用 OAuth 2.0 标准错误码
- 详细日志:记录详细的操作日志
- 用户友好:提供用户友好的错误信息
OAuth 2.0 为现代应用提供了标准化的授权解决方案,通过合理的实现可以构建安全、可扩展的认证授权系统。