mirror of https://github.com/veypi/OneAuth.git
feat: oauth demo
parent
959e390126
commit
a5339aa589
@ -0,0 +1,134 @@
|
|||||||
|
//
|
||||||
|
// Copyright (C) 2024 veypi <i@veypi.com>
|
||||||
|
// 2025-07-24 15:27:31
|
||||||
|
// Distributed under terms of the MIT license.
|
||||||
|
//
|
||||||
|
|
||||||
|
package oauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/veypi/OneAuth/cfg"
|
||||||
|
"github.com/vyes/vigo"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
CodeChallenge string `form:"code_challenge"`
|
||||||
|
CodeChallengeMethod string `form:"code_challenge_method"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizeResponse 授权响应
|
||||||
|
type AuthorizeResponse struct {
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
State string `json:"state,omitempty"`
|
||||||
|
RedirectURI string `json:"redirect_uri"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
ErrorDesc string `json:"error_description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAuthorize 处理OAuth授权请求
|
||||||
|
func handleAuthorize(x *vigo.X) error {
|
||||||
|
args := &AuthorizeRequest{}
|
||||||
|
if err := x.Parse(args); err != nil {
|
||||||
|
return vigo.NewError("参数解析失败").WithError(err).WithCode(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
db := cfg.DB()
|
||||||
|
|
||||||
|
// 1. 验证响应类型
|
||||||
|
if args.ResponseType != ResponseTypeCode {
|
||||||
|
errorURI := BuildErrorRedirectURI(args.RedirectURI, ErrorUnsupportedResponseType, "不支持的响应类型", args.State)
|
||||||
|
return x.JSON(&AuthorizeResponse{
|
||||||
|
Error: "unsupported_response_type",
|
||||||
|
ErrorDesc: "不支持的响应类型",
|
||||||
|
RedirectURI: errorURI,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 验证客户端
|
||||||
|
var client OAuthClient
|
||||||
|
if err := db.Where("client_id = ? AND is_active = ?", args.ClientID, true).First(&client).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
errorURI := BuildErrorRedirectURI(args.RedirectURI, ErrorInvalidClient, "无效的客户端", args.State)
|
||||||
|
return x.JSON(&AuthorizeResponse{
|
||||||
|
Error: "invalid_client",
|
||||||
|
ErrorDesc: "无效的客户端",
|
||||||
|
RedirectURI: errorURI,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return vigo.NewError("数据库查询失败").WithError(err).WithCode(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 验证重定向URI
|
||||||
|
if !client.IsRedirectURIValid(args.RedirectURI) {
|
||||||
|
return vigo.NewError("无效的重定向URI").WithCode(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 验证作用域
|
||||||
|
if args.Scope != "" && !client.HasScope(args.Scope) {
|
||||||
|
errorURI := BuildErrorRedirectURI(args.RedirectURI, ErrorInvalidScope, "无效的授权范围", args.State)
|
||||||
|
return x.JSON(&AuthorizeResponse{
|
||||||
|
Error: "invalid_scope",
|
||||||
|
ErrorDesc: "无效的授权范围",
|
||||||
|
RedirectURI: errorURI,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 在实际应用中,这里应该:
|
||||||
|
// 1. 检查用户是否已登录
|
||||||
|
// 2. 显示授权同意页面
|
||||||
|
// 3. 用户同意后生成授权码
|
||||||
|
// 为了演示,这里假设用户已登录且同意授权
|
||||||
|
|
||||||
|
// 假设当前用户ID (实际应从session或JWT token中获取)
|
||||||
|
userID := "demo-user-id"
|
||||||
|
|
||||||
|
// 5. 生成授权码
|
||||||
|
code, err := generateRandomString(32)
|
||||||
|
if err != nil {
|
||||||
|
errorURI := BuildErrorRedirectURI(args.RedirectURI, ErrorServerError, "授权码生成失败", args.State)
|
||||||
|
return x.JSON(&AuthorizeResponse{
|
||||||
|
Error: "server_error",
|
||||||
|
ErrorDesc: "授权码生成失败",
|
||||||
|
RedirectURI: errorURI,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 创建授权码记录
|
||||||
|
authCode := &OAuthAuthorizationCode{
|
||||||
|
Code: code,
|
||||||
|
ClientID: client.ID,
|
||||||
|
UserID: userID,
|
||||||
|
RedirectURI: args.RedirectURI,
|
||||||
|
Scope: args.Scope,
|
||||||
|
CodeChallenge: args.CodeChallenge,
|
||||||
|
CodeChallengeMethod: args.CodeChallengeMethod,
|
||||||
|
ExpiresAt: time.Now().Add(DefaultAuthorizationCodeExpiry),
|
||||||
|
Used: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(authCode).Error; err != nil {
|
||||||
|
errorURI := BuildErrorRedirectURI(args.RedirectURI, ErrorServerError, "授权码保存失败", args.State)
|
||||||
|
return x.JSON(&AuthorizeResponse{
|
||||||
|
Error: "server_error",
|
||||||
|
ErrorDesc: "授权码保存失败",
|
||||||
|
RedirectURI: errorURI,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 构建成功重定向URI
|
||||||
|
redirectURI := BuildRedirectURI(args.RedirectURI, authCode.Code, args.State)
|
||||||
|
|
||||||
|
return x.JSON(&AuthorizeResponse{
|
||||||
|
Code: authCode.Code,
|
||||||
|
State: args.State,
|
||||||
|
RedirectURI: redirectURI,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,159 @@
|
|||||||
|
//
|
||||||
|
// Copyright (C) 2024 veypi <i@veypi.com>
|
||||||
|
// 2025-07-24 15:27:31
|
||||||
|
// Distributed under terms of the MIT license.
|
||||||
|
//
|
||||||
|
|
||||||
|
package oauth
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// OAuth 2.0 相关常量
|
||||||
|
const (
|
||||||
|
// Grant Types
|
||||||
|
GrantTypeAuthorizationCode = "authorization_code"
|
||||||
|
GrantTypeRefreshToken = "refresh_token"
|
||||||
|
GrantTypeClientCredentials = "client_credentials"
|
||||||
|
GrantTypePassword = "password"
|
||||||
|
GrantTypeImplicit = "implicit"
|
||||||
|
|
||||||
|
// Response Types
|
||||||
|
ResponseTypeCode = "code"
|
||||||
|
ResponseTypeToken = "token"
|
||||||
|
|
||||||
|
// Token Types
|
||||||
|
TokenTypeBearer = "Bearer"
|
||||||
|
|
||||||
|
// PKCE Challenge Methods
|
||||||
|
CodeChallengeMethodPlain = "plain"
|
||||||
|
CodeChallengeMethodS256 = "S256"
|
||||||
|
|
||||||
|
// Default Scopes
|
||||||
|
ScopeRead = "read"
|
||||||
|
ScopeWrite = "write"
|
||||||
|
ScopeProfile = "profile"
|
||||||
|
ScopeEmail = "email"
|
||||||
|
ScopePhone = "phone"
|
||||||
|
ScopeAdmin = "admin"
|
||||||
|
|
||||||
|
// Token 生存时间
|
||||||
|
DefaultAuthorizationCodeExpiry = 10 * time.Minute // 授权码10分钟过期
|
||||||
|
DefaultAccessTokenExpiry = 1 * time.Hour // 访问令牌1小时过期
|
||||||
|
DefaultRefreshTokenExpiry = 30 * 24 * time.Hour // 刷新令牌30天过期
|
||||||
|
DefaultSessionExpiry = 24 * time.Hour // 会话24小时过期
|
||||||
|
|
||||||
|
// Error Codes (RFC 6749)
|
||||||
|
ErrorInvalidRequest = "invalid_request"
|
||||||
|
ErrorInvalidClient = "invalid_client"
|
||||||
|
ErrorInvalidGrant = "invalid_grant"
|
||||||
|
ErrorUnauthorizedClient = "unauthorized_client"
|
||||||
|
ErrorUnsupportedGrantType = "unsupported_grant_type"
|
||||||
|
ErrorInvalidScope = "invalid_scope"
|
||||||
|
ErrorAccessDenied = "access_denied"
|
||||||
|
ErrorUnsupportedResponseType = "unsupported_response_type"
|
||||||
|
ErrorServerError = "server_error"
|
||||||
|
ErrorTemporarilyUnavailable = "temporarily_unavailable"
|
||||||
|
|
||||||
|
// PKCE Error Codes (RFC 7636)
|
||||||
|
ErrorInvalidGrant2 = "invalid_grant"
|
||||||
|
|
||||||
|
// Token 类型
|
||||||
|
UserTokenTypeAPI = "api" // API 令牌
|
||||||
|
UserTokenTypeSession = "session" // 会话令牌
|
||||||
|
UserTokenTypePersonal = "personal" // 个人访问令牌
|
||||||
|
)
|
||||||
|
|
||||||
|
// 默认作用域定义
|
||||||
|
var DefaultScopes = []struct {
|
||||||
|
Name string
|
||||||
|
DisplayName string
|
||||||
|
Description string
|
||||||
|
IsDefault bool
|
||||||
|
IsSystem bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: ScopeProfile,
|
||||||
|
DisplayName: "基本资料",
|
||||||
|
Description: "访问您的基本资料信息,如用户名、昵称等",
|
||||||
|
IsDefault: true,
|
||||||
|
IsSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: ScopeEmail,
|
||||||
|
DisplayName: "邮箱地址",
|
||||||
|
Description: "访问您的邮箱地址",
|
||||||
|
IsDefault: false,
|
||||||
|
IsSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: ScopePhone,
|
||||||
|
DisplayName: "手机号码",
|
||||||
|
Description: "访问您的手机号码",
|
||||||
|
IsDefault: false,
|
||||||
|
IsSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: ScopeRead,
|
||||||
|
DisplayName: "读取权限",
|
||||||
|
Description: "读取您的数据",
|
||||||
|
IsDefault: true,
|
||||||
|
IsSystem: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: ScopeWrite,
|
||||||
|
DisplayName: "写入权限",
|
||||||
|
Description: "修改您的数据",
|
||||||
|
IsDefault: false,
|
||||||
|
IsSystem: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: ScopeAdmin,
|
||||||
|
DisplayName: "管理员权限",
|
||||||
|
Description: "完全的管理员权限",
|
||||||
|
IsDefault: false,
|
||||||
|
IsSystem: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预定义的第三方OAuth提供商
|
||||||
|
var DefaultOAuthProviders = []struct {
|
||||||
|
Name string
|
||||||
|
DisplayName string
|
||||||
|
AuthURL string
|
||||||
|
TokenURL string
|
||||||
|
UserInfoURL string
|
||||||
|
Scope string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "github",
|
||||||
|
DisplayName: "GitHub",
|
||||||
|
AuthURL: "https://github.com/login/oauth/authorize",
|
||||||
|
TokenURL: "https://github.com/login/oauth/access_token",
|
||||||
|
UserInfoURL: "https://api.github.com/user",
|
||||||
|
Scope: "user:email",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "google",
|
||||||
|
DisplayName: "Google",
|
||||||
|
AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||||
|
TokenURL: "https://oauth2.googleapis.com/token",
|
||||||
|
UserInfoURL: "https://www.googleapis.com/oauth2/v2/userinfo",
|
||||||
|
Scope: "openid profile email",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "wechat",
|
||||||
|
DisplayName: "微信",
|
||||||
|
AuthURL: "https://open.weixin.qq.com/connect/oauth2/authorize",
|
||||||
|
TokenURL: "https://api.weixin.qq.com/sns/oauth2/access_token",
|
||||||
|
UserInfoURL: "https://api.weixin.qq.com/sns/userinfo",
|
||||||
|
Scope: "snsapi_userinfo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "dingtalk",
|
||||||
|
DisplayName: "钉钉",
|
||||||
|
AuthURL: "https://oapi.dingtalk.com/connect/oauth2/sns_authorize",
|
||||||
|
TokenURL: "https://oapi.dingtalk.com/sns/gettoken",
|
||||||
|
UserInfoURL: "https://oapi.dingtalk.com/sns/getuserinfo",
|
||||||
|
Scope: "snsapi_login",
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
//
|
||||||
|
// Copyright (C) 2024 veypi <i@veypi.com>
|
||||||
|
// 2025-07-24 15:27:31
|
||||||
|
// Distributed under terms of the MIT license.
|
||||||
|
//
|
||||||
|
|
||||||
|
package oauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/veypi/OneAuth/cfg"
|
||||||
|
"github.com/vyes/vigo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RevokeRequest 撤销令牌请求参数
|
||||||
|
type RevokeRequest struct {
|
||||||
|
Token string `form:"token" binding:"required"`
|
||||||
|
TokenTypeHint string `form:"token_type_hint"` // access_token 或 refresh_token
|
||||||
|
ClientID string `form:"client_id"`
|
||||||
|
ClientSecret string `form:"client_secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeResponse 撤销令牌响应
|
||||||
|
type RevokeResponse struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRevoke 处理OAuth撤销请求
|
||||||
|
func handleRevoke(x *vigo.X) error {
|
||||||
|
args := &RevokeRequest{}
|
||||||
|
if err := x.Parse(args); err != nil {
|
||||||
|
return vigo.NewError("参数解析失败").WithError(err).WithCode(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.Token == "" {
|
||||||
|
return vigo.NewError("令牌不能为空").WithCode(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
db := cfg.DB()
|
||||||
|
|
||||||
|
// 根据OAuth 2.0规范,撤销令牌应该是幂等操作
|
||||||
|
// 即使令牌不存在也应该返回成功,这是为了防止信息泄露
|
||||||
|
var revoked = false
|
||||||
|
|
||||||
|
// 尝试撤销访问令牌
|
||||||
|
result := db.Model(&OAuthAccessToken{}).Where("token = ?", args.Token).Update("revoked", true)
|
||||||
|
if result.Error == nil && result.RowsAffected > 0 {
|
||||||
|
revoked = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有找到访问令牌,尝试撤销刷新令牌
|
||||||
|
if !revoked {
|
||||||
|
result = db.Model(&OAuthRefreshToken{}).Where("token = ?", args.Token).Update("revoked", true)
|
||||||
|
if result.Error == nil && result.RowsAffected > 0 {
|
||||||
|
revoked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据OAuth 2.0规范,即使令牌不存在也返回成功
|
||||||
|
// 这样可以防止攻击者通过响应差异推断令牌是否存在
|
||||||
|
return x.JSON(&RevokeResponse{
|
||||||
|
Message: "令牌撤销成功",
|
||||||
|
Success: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// Copyright (C) 2024 veypi <i@veypi.com>
|
||||||
|
// 2025-07-24 15:27:31
|
||||||
|
// Distributed under terms of the MIT license.
|
||||||
|
//
|
||||||
|
|
||||||
|
package oauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generateRandomString 生成指定长度的随机字符串
|
||||||
|
func generateRandomString(length int) (string, error) {
|
||||||
|
bytes := make([]byte, length)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(bytes)[:length], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildRedirectURI 构建成功重定向URI
|
||||||
|
func BuildRedirectURI(baseURI, code, state string) string {
|
||||||
|
u, _ := url.Parse(baseURI)
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("code", code)
|
||||||
|
if state != "" {
|
||||||
|
q.Set("state", state)
|
||||||
|
}
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildErrorRedirectURI 构建错误重定向URI
|
||||||
|
func BuildErrorRedirectURI(baseURI, errorCode, errorDesc, state string) string {
|
||||||
|
u, _ := url.Parse(baseURI)
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("error", errorCode)
|
||||||
|
if errorDesc != "" {
|
||||||
|
q.Set("error_description", errorDesc)
|
||||||
|
}
|
||||||
|
if state != "" {
|
||||||
|
q.Set("state", state)
|
||||||
|
}
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue