diff --git a/api/init.go b/api/init.go index ab5d11b..1832fce 100644 --- a/api/init.go +++ b/api/init.go @@ -14,7 +14,7 @@ import ( "github.com/veypi/OneAuth/api/token" "github.com/veypi/OneAuth/api/user" "github.com/vyes/vigo" - "github.com/vyes/vigo/middlewares/common" + "github.com/vyes/vigo/contrib/common" ) var Router = vigo.NewRouter().UseAfter(common.JsonResponse, common.JsonErrorResponse) diff --git a/init.go b/init.go index 30845ad..8c6cc26 100644 --- a/init.go +++ b/init.go @@ -9,9 +9,9 @@ package OneAuth import ( "embed" "github.com/veypi/OneAuth/api" - "github.com/vyes/vigo" - "github.com/vyes/vigo/middlewares/vyes" "github.com/veypi/vyes-ui" + "github.com/vyes/vigo" + "github.com/vyes/vigo/contrib/vyes" ) var Router = vigo.NewRouter() diff --git a/models/init.go b/models/init.go index cf4e3f0..92bfb27 100644 --- a/models/init.go +++ b/models/init.go @@ -12,8 +12,8 @@ import ( "time" "github.com/google/uuid" + "github.com/vyes/vigo/contrib/dbmodels" "github.com/vyes/vigo/logv" - "github.com/vyes/vigo/middlewares/dbmodels" "gorm.io/gorm" ) diff --git a/oauth/OAuth_Database_Design.md b/oauth/OAuth_Database_Design.md new file mode 100644 index 0000000..60371b6 --- /dev/null +++ b/oauth/OAuth_Database_Design.md @@ -0,0 +1,242 @@ +# OAuth 服务器数据库设计 + +本文档介绍了基于您现有用户管理系统的OAuth 2.0服务器数据库设计。 + +## 核心表结构 + +### 1. OAuth 客户端相关 + +#### `oauth_clients` - OAuth客户端表 +存储注册到系统的OAuth客户端应用信息。 + +| 字段 | 类型 | 说明 | +|-----|------|------| +| id | varchar(32) | 主键ID | +| client_id | varchar(255) | 客户端ID(唯一) | +| client_secret | varchar(255) | 客户端密钥(加密存储) | +| client_name | varchar(255) | 客户端应用名称 | +| client_uri | varchar(500) | 客户端主页 | +| logo_uri | varchar(500) | 客户端Logo | +| redirect_uris | text | 重定向URI列表(JSON格式) | +| response_types | varchar(255) | 支持的响应类型 | +| grant_types | varchar(255) | 支持的授权类型 | +| scope | text | 授权范围 | +| is_public | boolean | 是否为公开客户端 | +| is_active | boolean | 是否激活 | +| owner_id | varchar(32) | 客户端拥有者ID | + +#### `oauth_scopes` - OAuth授权范围表 +定义可用的授权范围。 + +| 字段 | 类型 | 说明 | +|-----|------|------| +| id | varchar(32) | 主键ID | +| name | varchar(100) | 范围名称(唯一) | +| display_name | varchar(100) | 显示名称 | +| description | text | 范围描述 | +| is_default | boolean | 是否为默认范围 | +| is_system | boolean | 是否为系统范围 | + +#### `oauth_client_scopes` - 客户端授权范围关联表 +定义客户端可以请求的授权范围。 + +| 字段 | 类型 | 说明 | +|-----|------|------| +| client_id | varchar(32) | 客户端ID | +| scope_id | varchar(32) | 范围ID | + +### 2. OAuth 授权流程相关 + +#### `oauth_authorization_codes` - 授权码表 +存储授权码流程中的临时授权码。 + +| 字段 | 类型 | 说明 | +|-----|------|------| +| id | varchar(32) | 主键ID | +| code | varchar(255) | 授权码(唯一) | +| client_id | varchar(32) | 客户端ID | +| user_id | varchar(32) | 用户ID | +| redirect_uri | varchar(500) | 重定向URI | +| scope | text | 授权范围 | +| code_challenge | varchar(255) | PKCE代码挑战 | +| code_challenge_method | varchar(50) | PKCE挑战方法 | +| expires_at | timestamp | 过期时间 | +| used | boolean | 是否已使用 | + +#### `oauth_access_tokens` - 访问令牌表 +存储颁发的访问令牌。 + +| 字段 | 类型 | 说明 | +|-----|------|------| +| id | varchar(32) | 主键ID | +| token | varchar(500) | 访问令牌(唯一) | +| client_id | varchar(32) | 客户端ID | +| user_id | varchar(32) | 用户ID | +| scope | text | 授权范围 | +| expires_at | timestamp | 过期时间 | +| revoked | boolean | 是否已撤销 | + +#### `oauth_refresh_tokens` - 刷新令牌表 +存储刷新令牌,用于获取新的访问令牌。 + +| 字段 | 类型 | 说明 | +|-----|------|------| +| id | varchar(32) | 主键ID | +| token | varchar(500) | 刷新令牌(唯一) | +| access_token_id | varchar(32) | 关联的访问令牌ID | +| client_id | varchar(32) | 客户端ID | +| user_id | varchar(32) | 用户ID | +| scope | text | 授权范围 | +| expires_at | timestamp | 过期时间 | +| revoked | boolean | 是否已撤销 | + +#### `oauth_user_consents` - 用户授权同意表 +记录用户对客户端的授权同意。 + +| 字段 | 类型 | 说明 | +|-----|------|------| +| id | varchar(32) | 主键ID | +| user_id | varchar(32) | 用户ID | +| client_id | varchar(32) | 客户端ID | +| scope | text | 授权范围 | +| consent_at | timestamp | 同意时间 | +| expires_at | timestamp | 同意过期时间 | + +### 3. 第三方OAuth登录相关 + +#### `oauth_providers` - 第三方OAuth提供商表 +配置第三方OAuth提供商(如GitHub, Google等)。 + +| 字段 | 类型 | 说明 | +|-----|------|------| +| id | varchar(32) | 主键ID | +| name | varchar(100) | 提供商名称(唯一) | +| display_name | varchar(100) | 显示名称 | +| client_id | varchar(255) | 客户端ID | +| client_secret | varchar(255) | 客户端密钥 | +| auth_url | varchar(500) | 授权URL | +| token_url | varchar(500) | 令牌URL | +| user_info_url | varchar(500) | 用户信息URL | +| scope | text | 默认授权范围 | +| is_active | boolean | 是否激活 | + +#### `oauth_accounts` - 用户OAuth账户表 +存储用户通过第三方OAuth登录的账户信息。 + +| 字段 | 类型 | 说明 | +|-----|------|------| +| id | varchar(32) | 主键ID | +| user_id | varchar(32) | 本系统用户ID | +| provider_id | varchar(32) | 提供商ID | +| provider_user_id | varchar(255) | 提供商用户ID | +| email | varchar(255) | 邮箱 | +| username | varchar(255) | 用户名 | +| nickname | varchar(255) | 昵称 | +| avatar | varchar(500) | 头像URL | +| access_token | text | 访问令牌 | +| refresh_token | text | 刷新令牌 | +| expires_at | timestamp | 令牌过期时间 | + +### 4. 用户会话和令牌管理 + +#### `user_sessions` - 用户会话表 +管理用户登录会话。 + +| 字段 | 类型 | 说明 | +|-----|------|------| +| id | varchar(32) | 主键ID | +| user_id | varchar(32) | 用户ID | +| session_id | varchar(255) | 会话ID(唯一) | +| ip_address | varchar(45) | IP地址 | +| user_agent | text | 用户代理 | +| expires_at | timestamp | 过期时间 | +| is_active | boolean | 是否激活 | +| last_activity | timestamp | 最后活动时间 | + +#### `user_tokens` - 用户令牌表 +管理用户的API令牌等。 + +| 字段 | 类型 | 说明 | +|-----|------|------| +| id | varchar(32) | 主键ID | +| user_id | varchar(32) | 用户ID | +| token_type | varchar(50) | 令牌类型 | +| token | varchar(500) | 令牌值(唯一) | +| name | varchar(100) | 令牌名称 | +| description | text | 令牌描述 | +| scope | text | 授权范围 | +| expires_at | timestamp | 过期时间 | +| last_used_at | timestamp | 最后使用时间 | +| is_active | boolean | 是否激活 | + +## 关系说明 + +### 用户与OAuth的关系 +- 一个用户可以拥有多个OAuth客户端(`users.id` -> `oauth_clients.owner_id`) +- 一个用户可以授权多个客户端(`users.id` -> `oauth_user_consents.user_id`) +- 一个用户可以关联多个第三方账户(`users.id` -> `oauth_accounts.user_id`) + +### OAuth授权流程关系 +- 授权码关联客户端和用户(`oauth_authorization_codes.client_id` -> `oauth_clients.id`) +- 访问令牌关联刷新令牌(`oauth_refresh_tokens.access_token_id` -> `oauth_access_tokens.id`) + +### 权限控制 +- 基于现有的RBAC系统,为OAuth相关操作定义权限 +- 管理员可以管理所有OAuth客户端 +- 普通用户只能管理自己的OAuth客户端 + +## 预定义数据 + +### 默认授权范围 +- `profile`: 基本资料访问 +- `email`: 邮箱地址访问 +- `phone`: 手机号码访问 +- `read`: 读取权限 +- `write`: 写入权限 +- `admin`: 管理员权限 + +### 预配置的第三方提供商 +- GitHub +- Google +- 微信 +- 钉钉 + +### OAuth相关权限 +- `oauth.client.create`: 创建OAuth客户端 +- `oauth.client.read`: 查看OAuth客户端 +- `oauth.client.update`: 更新OAuth客户端 +- `oauth.client.delete`: 删除OAuth客户端 +- `oauth.token.manage`: 管理OAuth令牌 +- `oauth.scope.manage`: 管理OAuth作用域 +- `oauth.provider.manage`: 管理OAuth提供商 + +## 安全考虑 + +1. **令牌存储**: 所有敏感令牌都应该进行哈希或加密存储 +2. **HTTPS强制**: 所有OAuth端点必须使用HTTPS +3. **PKCE支持**: 支持PKCE以增强安全性 +4. **令牌过期**: 设置合理的令牌过期时间 +5. **审计日志**: 记录所有OAuth相关操作 + +## 使用示例 + +### 初始化OAuth数据 +```go +import "vyes_cli/oauth" + +// 在数据库迁移后调用 +err := oauth.InitializeOAuthData(db) +if err != nil { + log.Fatal("Failed to initialize OAuth data:", err) +} +``` + +### 创建测试客户端 +```go +client, err := oauth.CreateDefaultOAuthClient(db, adminUserID) +if err != nil { + log.Fatal("Failed to create default OAuth client:", err) +} +``` + +这个设计基于OAuth 2.0 RFC标准,同时考虑了您现有的用户管理系统架构,可以无缝集成到您的项目中。 diff --git a/oauth/authorize.go b/oauth/authorize.go new file mode 100644 index 0000000..72a58da --- /dev/null +++ b/oauth/authorize.go @@ -0,0 +1,134 @@ +// +// Copyright (C) 2024 veypi +// 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, + }) +} diff --git a/oauth/constants.go b/oauth/constants.go new file mode 100644 index 0000000..8d9b10d --- /dev/null +++ b/oauth/constants.go @@ -0,0 +1,159 @@ +// +// Copyright (C) 2024 veypi +// 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", + }, +} diff --git a/oauth/init.go b/oauth/init.go new file mode 100644 index 0000000..bf70863 --- /dev/null +++ b/oauth/init.go @@ -0,0 +1,210 @@ +// +// Copyright (C) 2024 veypi +// 2025-07-24 15:27:31 +// Distributed under terms of the MIT license. +// + +package oauth + +import ( + "github.com/vyes/vigo" + "gorm.io/gorm" +) + +var Router = vigo.NewRouter() + +func init() { + // OAuth 授权端点 + var _ = Router.Get("/authorize", `OAuth授权端点 - 获取授权码`, AuthorizeRequest{}, handleAuthorize) + + // OAuth 令牌端点 + var _ = Router.Post("/token", `OAuth令牌端点 - 用授权码换取令牌或刷新令牌`, TokenRequest{}, handleToken) + + // OAuth 撤销端点 + var _ = Router.Post("/revoke", `OAuth撤销端点 - 撤销访问令牌或刷新令牌`, RevokeRequest{}, handleRevoke) +} + +// InitializeOAuthData 初始化OAuth相关的基础数据 +func InitializeOAuthData(db *gorm.DB) error { + // 1. 创建默认的OAuth作用域 + if err := createDefaultScopes(db); err != nil { + return err + } + + // 2. 创建默认的第三方OAuth提供商 + if err := createDefaultProviders(db); err != nil { + return err + } + + // 3. 创建默认权限 + if err := createDefaultPermissions(db); err != nil { + return err + } + + return nil +} + +func createDefaultScopes(db *gorm.DB) error { + for _, scopeData := range DefaultScopes { + scope := &OAuthScope{ + Name: scopeData.Name, + DisplayName: scopeData.DisplayName, + Description: scopeData.Description, + IsDefault: scopeData.IsDefault, + IsSystem: scopeData.IsSystem, + } + + // 如果不存在则创建 + var existingScope OAuthScope + result := db.Where("name = ?", scope.Name).First(&existingScope) + if result.Error == gorm.ErrRecordNotFound { + if err := db.Create(scope).Error; err != nil { + return err + } + } + } + return nil +} + +func createDefaultProviders(db *gorm.DB) error { + for _, providerData := range DefaultOAuthProviders { + provider := &OAuthProvider{ + Name: providerData.Name, + DisplayName: providerData.DisplayName, + AuthURL: providerData.AuthURL, + TokenURL: providerData.TokenURL, + UserInfoURL: providerData.UserInfoURL, + Scope: providerData.Scope, + IsActive: false, // 默认不激活,需要配置ClientID和ClientSecret后激活 + } + + // 如果不存在则创建 + var existingProvider OAuthProvider + result := db.Where("name = ?", provider.Name).First(&existingProvider) + if result.Error == gorm.ErrRecordNotFound { + if err := db.Create(provider).Error; err != nil { + return err + } + } + } + return nil +} + +func createDefaultPermissions(db *gorm.DB) error { + oauthPermissions := []struct { + Name string + DisplayName string + Description string + Resource string + Action string + IsSystem bool + }{ + { + Name: "oauth.client.create", + DisplayName: "创建OAuth客户端", + Description: "允许创建新的OAuth客户端应用", + Resource: "oauth_client", + Action: "create", + IsSystem: true, + }, + { + Name: "oauth.client.read", + DisplayName: "查看OAuth客户端", + Description: "允许查看OAuth客户端信息", + Resource: "oauth_client", + Action: "read", + IsSystem: true, + }, + { + Name: "oauth.client.update", + DisplayName: "更新OAuth客户端", + Description: "允许更新OAuth客户端信息", + Resource: "oauth_client", + Action: "update", + IsSystem: true, + }, + { + Name: "oauth.client.delete", + DisplayName: "删除OAuth客户端", + Description: "允许删除OAuth客户端", + Resource: "oauth_client", + Action: "delete", + IsSystem: true, + }, + { + Name: "oauth.token.manage", + DisplayName: "管理OAuth令牌", + Description: "允许管理用户的OAuth令牌", + Resource: "oauth_token", + Action: "manage", + IsSystem: true, + }, + { + Name: "oauth.scope.manage", + DisplayName: "管理OAuth作用域", + Description: "允许管理OAuth作用域", + Resource: "oauth_scope", + Action: "manage", + IsSystem: true, + }, + { + Name: "oauth.provider.manage", + DisplayName: "管理OAuth提供商", + Description: "允许管理第三方OAuth提供商", + Resource: "oauth_provider", + Action: "manage", + IsSystem: true, + }, + } + + for _, permData := range oauthPermissions { + permission := &Permission{ + Name: permData.Name, + DisplayName: permData.DisplayName, + Description: permData.Description, + Resource: permData.Resource, + Action: permData.Action, + IsSystem: permData.IsSystem, + } + + // 如果不存在则创建 + var existingPerm Permission + result := db.Where("name = ?", permission.Name).First(&existingPerm) + if result.Error == gorm.ErrRecordNotFound { + if err := db.Create(permission).Error; err != nil { + return err + } + } + } + + return nil +} + +// CreateDefaultOAuthClient 创建默认的OAuth客户端(用于测试) +func CreateDefaultOAuthClient(db *gorm.DB, ownerID string) (*OAuthClient, error) { + client := &OAuthClient{ + ClientID: "default-client-id", + ClientSecret: "default-client-secret", // 实际使用时应该使用加密存储 + ClientName: "Default Test Client", + ClientURI: "http://localhost:3000", + RedirectURIs: `["http://localhost:3000/callback", "http://localhost:8080/callback"]`, + ResponseTypes: "code", + GrantTypes: "authorization_code,refresh_token", + Scope: "profile read write", + IsPublic: false, + IsActive: true, + OwnerID: ownerID, + } + + // 检查是否已存在 + var existingClient OAuthClient + result := db.Where("client_id = ?", client.ClientID).First(&existingClient) + if result.Error == gorm.ErrRecordNotFound { + if err := db.Create(client).Error; err != nil { + return nil, err + } + return client, nil + } + + return &existingClient, nil +} diff --git a/oauth/revoke.go b/oauth/revoke.go new file mode 100644 index 0000000..7b53340 --- /dev/null +++ b/oauth/revoke.go @@ -0,0 +1,65 @@ +// +// Copyright (C) 2024 veypi +// 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, + }) +} diff --git a/oauth/token.go b/oauth/token.go new file mode 100644 index 0000000..44b875b --- /dev/null +++ b/oauth/token.go @@ -0,0 +1,294 @@ +// +// Copyright (C) 2024 veypi +// 2025-07-24 15:27:31 +// Distributed under terms of the MIT license. +// + +package oauth + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "github.com/veypi/OneAuth/cfg" + "github.com/vyes/vigo" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + "time" +) + +// 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"` + ClientSecret string `form:"client_secret"` + RefreshToken string `form:"refresh_token"` + CodeVerifier string `form:"code_verifier"` + Username string `form:"username"` // for password grant + Password string `form:"password"` // for password grant + Scope string `form:"scope"` // for password grant +} + +// TokenResponse 令牌响应 +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token,omitempty"` + Scope string `json:"scope,omitempty"` +} + +// handleToken 处理OAuth令牌请求 +func handleToken(x *vigo.X) error { + args := &TokenRequest{} + if err := x.Parse(args); err != nil { + return vigo.NewError("参数解析失败").WithError(err).WithCode(400) + } + + db := cfg.DB() + + switch args.GrantType { + case GrantTypeAuthorizationCode: + return handleAuthorizationCodeGrant(db, x, args) + case GrantTypeRefreshToken: + return handleRefreshTokenGrant(db, x, args) + case GrantTypePassword: + return handlePasswordGrant(db, x, args) + default: + return vigo.NewError("不支持的授权类型").WithCode(400) + } +} + +// handleAuthorizationCodeGrant 处理授权码授权类型 +func handleAuthorizationCodeGrant(db *gorm.DB, x *vigo.X, args *TokenRequest) error { + // 1. 验证授权码 + var authCode OAuthAuthorizationCode + if err := db.Where("code = ? AND used = ?", args.Code, false).First(&authCode).Error; err != nil { + return vigo.NewError("无效的授权码").WithCode(400) + } + + // 2. 检查授权码是否过期 + if authCode.IsExpired() { + return vigo.NewError("授权码已过期").WithCode(400) + } + + // 3. 验证客户端 + var client OAuthClient + if err := db.Where("id = ? AND client_id = ?", authCode.ClientID, args.ClientID).First(&client).Error; err != nil { + return vigo.NewError("无效的客户端").WithCode(400) + } + + // 4. 验证客户端密钥(对于机密客户端) + if !client.IsPublic && client.ClientSecret != args.ClientSecret { + return vigo.NewError("无效的客户端凭据").WithCode(400) + } + + // 5. 验证重定向URI + if authCode.RedirectURI != args.RedirectURI { + return vigo.NewError("重定向URI不匹配").WithCode(400) + } + + // 6. 验证PKCE(如果使用) + if authCode.CodeChallenge != "" { + if err := validatePKCE(authCode.CodeChallenge, authCode.CodeChallengeMethod, args.CodeVerifier); err != nil { + return vigo.NewError("PKCE验证失败").WithError(err).WithCode(400) + } + } + + // 7. 标记授权码为已使用 + if err := db.Model(&authCode).Update("used", true).Error; err != nil { + return vigo.NewError("授权码更新失败").WithError(err).WithCode(500) + } + + // 8. 生成访问令牌 + accessToken, err := generateAccessToken(db, &client, authCode.UserID, authCode.Scope) + if err != nil { + return vigo.NewError("访问令牌生成失败").WithError(err).WithCode(500) + } + + // 9. 生成刷新令牌 + refreshToken, err := generateRefreshToken(db, accessToken, &client, authCode.UserID, authCode.Scope) + if err != nil { + return vigo.NewError("刷新令牌生成失败").WithError(err).WithCode(500) + } + + return x.JSON(&TokenResponse{ + AccessToken: accessToken.Token, + TokenType: TokenTypeBearer, + ExpiresIn: int64(DefaultAccessTokenExpiry.Seconds()), + RefreshToken: refreshToken.Token, + Scope: authCode.Scope, + }) +} + +// handleRefreshTokenGrant 处理刷新令牌授权类型 +func handleRefreshTokenGrant(db *gorm.DB, x *vigo.X, args *TokenRequest) error { + // 1. 验证刷新令牌 + var refreshToken OAuthRefreshToken + if err := db.Where("token = ? AND revoked = ?", args.RefreshToken, false).First(&refreshToken).Error; err != nil { + return vigo.NewError("无效的刷新令牌").WithCode(400) + } + + // 2. 检查刷新令牌是否过期 + if refreshToken.IsExpired() { + return vigo.NewError("刷新令牌已过期").WithCode(400) + } + + // 3. 验证客户端 + var client OAuthClient + if err := db.Where("id = ? AND client_id = ?", refreshToken.ClientID, args.ClientID).First(&client).Error; err != nil { + return vigo.NewError("无效的客户端").WithCode(400) + } + + // 4. 撤销旧的访问令牌 + if err := db.Model(&OAuthAccessToken{}).Where("id = ?", refreshToken.AccessTokenID).Update("revoked", true).Error; err != nil { + return vigo.NewError("旧令牌撤销失败").WithError(err).WithCode(500) + } + + // 5. 生成新的访问令牌 + accessToken, err := generateAccessToken(db, &client, refreshToken.UserID, refreshToken.Scope) + if err != nil { + return vigo.NewError("访问令牌生成失败").WithError(err).WithCode(500) + } + + // 6. 更新刷新令牌关联 + if err := db.Model(&refreshToken).Update("access_token_id", accessToken.ID).Error; err != nil { + return vigo.NewError("刷新令牌更新失败").WithError(err).WithCode(500) + } + + return x.JSON(&TokenResponse{ + AccessToken: accessToken.Token, + TokenType: TokenTypeBearer, + ExpiresIn: int64(DefaultAccessTokenExpiry.Seconds()), + RefreshToken: refreshToken.Token, + Scope: refreshToken.Scope, + }) +} + +// handlePasswordGrant 处理密码授权类型 +func handlePasswordGrant(db *gorm.DB, x *vigo.X, args *TokenRequest) error { + // 1. 验证必要参数 + if args.Username == "" || args.Password == "" { + return vigo.NewError("用户名和密码不能为空").WithCode(400) + } + + // 2. 验证客户端 + var client OAuthClient + if err := db.Where("client_id = ?", args.ClientID).First(&client).Error; err != nil { + return vigo.NewError("无效的客户端").WithCode(400) + } + + // 3. 验证客户端密钥(对于机密客户端) + if !client.IsPublic && client.ClientSecret != args.ClientSecret { + return vigo.NewError("无效的客户端凭据").WithCode(400) + } + + // 4. 验证用户凭据 + var user User + if err := db.Where("username = ?", args.Username).First(&user).Error; err != nil { + return vigo.NewError("用户名或密码错误").WithCode(400) + } + + // 5. 验证密码 + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(args.Password)); err != nil { + return vigo.NewError("用户名或密码错误").WithCode(400) + } + + // 6. 处理权限范围 + scope := args.Scope + if scope == "" { + // scope = DefaultScope // 默认权限范围 + } + + // 7. 生成访问令牌 + accessToken, err := generateAccessToken(db, &client, user.ID, scope) + if err != nil { + return vigo.NewError("访问令牌生成失败").WithError(err).WithCode(500) + } + + // 8. 生成刷新令牌 + refreshToken, err := generateRefreshToken(db, accessToken, &client, user.ID, scope) + if err != nil { + return vigo.NewError("刷新令牌生成失败").WithError(err).WithCode(500) + } + + return x.JSON(&TokenResponse{ + AccessToken: accessToken.Token, + TokenType: TokenTypeBearer, + ExpiresIn: int64(DefaultAccessTokenExpiry.Seconds()), + RefreshToken: refreshToken.Token, + Scope: scope, + }) +} + +// 辅助函数 + +func generateAccessToken(db *gorm.DB, client *OAuthClient, userID, scope string) (*OAuthAccessToken, error) { + token, err := generateRandomString(64) + if err != nil { + return nil, err + } + + accessToken := &OAuthAccessToken{ + Token: token, + ClientID: client.ID, + UserID: userID, + Scope: scope, + ExpiresAt: time.Now().Add(DefaultAccessTokenExpiry), + Revoked: false, + } + + if err := db.Create(accessToken).Error; err != nil { + return nil, err + } + + return accessToken, nil +} + +func generateRefreshToken(db *gorm.DB, accessToken *OAuthAccessToken, client *OAuthClient, userID, scope string) (*OAuthRefreshToken, error) { + token, err := generateRandomString(64) + if err != nil { + return nil, err + } + + refreshToken := &OAuthRefreshToken{ + Token: token, + AccessTokenID: accessToken.ID, + ClientID: client.ID, + UserID: userID, + Scope: scope, + ExpiresAt: time.Now().Add(DefaultRefreshTokenExpiry), + Revoked: false, + } + + if err := db.Create(refreshToken).Error; err != nil { + return nil, err + } + + return refreshToken, nil +} + +func validatePKCE(codeChallenge, method, codeVerifier string) error { + if codeVerifier == "" { + return fmt.Errorf("code verifier required") + } + + switch method { + case CodeChallengeMethodPlain: + if codeChallenge != codeVerifier { + return fmt.Errorf("invalid code verifier") + } + case CodeChallengeMethodS256: + h := sha256.Sum256([]byte(codeVerifier)) + expected := base64.RawURLEncoding.EncodeToString(h[:]) + if codeChallenge != expected { + return fmt.Errorf("invalid code verifier") + } + default: + return fmt.Errorf("unsupported code challenge method") + } + + return nil +} diff --git a/oauth/user.go b/oauth/user.go new file mode 100644 index 0000000..1bcc91a --- /dev/null +++ b/oauth/user.go @@ -0,0 +1,400 @@ +// +// Copyright (C) 2024 veypi +// 2025-07-24 15:27:31 +// Distributed under terms of the MIT license. +// + +package oauth + +import ( + "time" + + "github.com/veypi/OneAuth/models" + "gorm.io/gorm" +) + +// User 用户表 +type User struct { + models.BaseModel + Username string `json:"username" gorm:"uniqueIndex;not null;size:50;comment:用户名""` + Email string `json:"email" gorm:"uniqueIndex;size:100;comment:邮箱地址"` + Phone string `json:"phone" gorm:"uniqueIndex;size:20;comment:手机号码"` + PasswordHash string `json:"-" gorm:"not null;size:255;comment:密码哈希"` + Nickname string `json:"nickname" gorm:"size:50;comment:昵称"` + Avatar string `json:"avatar" gorm:"size:255;comment:头像URL"` + IsActive bool `json:"is_active" gorm:"default:true;comment:是否激活"` + IsSuperuser bool `json:"is_superuser" gorm:"default:false;comment:是否为超级用户"` + LastLoginAt *time.Time `json:"last_login_at" gorm:"comment:最后登录时间"` + EmailVerified bool `json:"email_verified" gorm:"default:false;comment:邮箱是否已验证"` + PhoneVerified bool `json:"phone_verified" gorm:"default:false;comment:手机是否已验证"` + TwoFactorAuth bool `json:"two_factor_auth" gorm:"default:false;comment:是否启用双因素认证"` + Locale string `json:"locale" gorm:"size:10;default:zh-CN;comment:语言偏好"` + Timezone string `json:"timezone" gorm:"size:50;default:Asia/Shanghai;comment:时区"` + Bio string `json:"bio" gorm:"type:text;comment:个人简介"` + + // 关联关系 + Roles []Role `json:"roles" gorm:"many2many:user_roles;"` + UserRoles []UserRole `json:"-"` + OAuthAccounts []OAuthAccount `json:"oauth_accounts"` + Tokens []UserToken `json:"-"` + MetaData []byte `json:"meta_data" gorm:"type:jsonb;comment:用户元数据"` // 存储用户自定义的元数据 +} + +// Role 角色表 +type Role struct { + models.BaseModel + Name string `json:"name" gorm:"uniqueIndex;not null;size:50;comment:角色名称" validate:"required"` + DisplayName string `json:"display_name" gorm:"size:100;comment:显示名称"` + Description string `json:"description" gorm:"type:text;comment:角色描述"` + IsSystem bool `json:"is_system" gorm:"default:false;comment:是否为系统角色"` + IsActive bool `json:"is_active" gorm:"default:true;comment:是否激活"` + + // 关联关系 + Users []User `json:"-" gorm:"many2many:user_roles;"` + UserRoles []UserRole `json:"-"` + Permissions []Permission `json:"permissions" gorm:"many2many:role_permissions;"` +} + +// Permission 权限表 +type Permission struct { + models.BaseModel + Name string `json:"name" gorm:"uniqueIndex;not null;size:100;comment:权限名称" validate:"required"` + DisplayName string `json:"display_name" gorm:"size:100;comment:显示名称"` + Description string `json:"description" gorm:"type:text;comment:权限描述"` + Resource string `json:"resource" gorm:"not null;size:50;comment:资源名称" validate:"required"` + Action string `json:"action" gorm:"not null;size:50;comment:操作类型" validate:"required"` + IsSystem bool `json:"is_system" gorm:"default:false;comment:是否为系统权限"` + + // 关联关系 + Roles []Role `json:"-" gorm:"many2many:role_permissions;"` +} + +// UserRole 用户角色关联表 +type UserRole struct { + UserID string `json:"user_id" gorm:"primaryKey;type:varchar(32);comment:用户ID"` + RoleID string `json:"role_id" gorm:"primaryKey;type:varchar(32);comment:角色ID"` + GrantedBy string `json:"granted_by" gorm:"type:varchar(32);comment:授权人ID"` + GrantedAt time.Time `json:"granted_at" gorm:"autoCreateTime;comment:授权时间"` + ExpiresAt *time.Time `json:"expires_at" gorm:"comment:过期时间"` + + // 关联关系 + User *User `json:"user" gorm:"foreignKey:UserID"` + Role *Role `json:"role" gorm:"foreignKey:RoleID"` + GrantedByUser *User `json:"granted_by_user" gorm:"foreignKey:GrantedBy"` +} + +// RolePermission 角色权限关联表 +type RolePermission struct { + RoleID string `json:"role_id" gorm:"primaryKey;type:varchar(32);comment:角色ID"` + PermissionID string `json:"permission_id" gorm:"primaryKey;type:varchar(32);comment:权限ID"` + GrantedBy string `json:"granted_by" gorm:"type:varchar(32);comment:授权人ID"` + GrantedAt time.Time `json:"granted_at" gorm:"autoCreateTime;comment:授权时间"` + + // 关联关系 + Role *Role `json:"role" gorm:"foreignKey:RoleID"` + Permission *Permission `json:"permission" gorm:"foreignKey:PermissionID"` + GrantedByUser *User `json:"granted_by_user" gorm:"foreignKey:GrantedBy"` +} + +// UserLoginLog 用户登录日志表 +type UserLoginLog struct { + models.BaseModel + UserID string `json:"user_id" gorm:"not null;type:varchar(32);index;comment:用户ID"` + IPAddress string `json:"ip_address" gorm:"size:45;comment:IP地址"` + UserAgent string `json:"user_agent" gorm:"type:text;comment:用户代理"` + LoginAt time.Time `json:"login_at" gorm:"autoCreateTime;comment:登录时间"` + Success bool `json:"success" gorm:"default:true;comment:是否成功"` + FailReason string `json:"fail_reason" gorm:"size:255;comment:失败原因"` + Location string `json:"location" gorm:"size:100;comment:地理位置"` // 地理位置 + + // 关联关系 + User User `json:"user" gorm:"foreignKey:UserID"` +} + +// GORM Hooks +func (u *User) BeforeCreate(tx *gorm.DB) error { + if err := u.BaseModel.BeforeCreate(tx); err != nil { + return err + } + if u.Locale == "" { + u.Locale = "zh-CN" + } + if u.Timezone == "" { + u.Timezone = "Asia/Shanghai" + } + return nil +} + +// 用户方法 +func (u *User) HasRole(roleName string) bool { + for _, role := range u.Roles { + if role.Name == roleName { + return true + } + } + return false +} + +func (u *User) HasPermission(resource, action string) bool { + for _, role := range u.Roles { + for _, permission := range role.Permissions { + if permission.Resource == resource && permission.Action == action { + return true + } + } + } + return false +} + +func (u *User) GetPermissions() []Permission { + var permissions []Permission + permissionMap := make(map[string]bool) + + for _, role := range u.Roles { + for _, permission := range role.Permissions { + if !permissionMap[permission.ID] { + permissions = append(permissions, permission) + permissionMap[permission.ID] = true + } + } + } + return permissions +} + +// 角色方法 +func (r *Role) HasPermission(resource, action string) bool { + for _, permission := range r.Permissions { + if permission.Resource == resource && permission.Action == action { + return true + } + } + return false +} + +// ===== OAuth 服务器相关模型 ===== + +// OAuthClient OAuth客户端表 +type OAuthClient struct { + models.BaseModel + ClientID string `json:"client_id" gorm:"uniqueIndex;not null;size:255;comment:客户端ID"` + ClientSecret string `json:"-" gorm:"not null;size:255;comment:客户端密钥"` + ClientName string `json:"client_name" gorm:"not null;size:255;comment:客户端名称"` + ClientURI string `json:"client_uri" gorm:"size:500;comment:客户端主页"` + LogoURI string `json:"logo_uri" gorm:"size:500;comment:客户端Logo"` + TermsOfServiceURI string `json:"tos_uri" gorm:"size:500;comment:服务条款URL"` + PolicyURI string `json:"policy_uri" gorm:"size:500;comment:隐私政策URL"` + RedirectURIs string `json:"redirect_uris" gorm:"type:text;comment:重定向URI列表,JSON格式"` + ResponseTypes string `json:"response_types" gorm:"size:255;default:'code';comment:响应类型"` + GrantTypes string `json:"grant_types" gorm:"size:255;default:'authorization_code,refresh_token';comment:授权类型"` + Scope string `json:"scope" gorm:"type:text;comment:授权范围"` + Contacts string `json:"contacts" gorm:"type:text;comment:联系人邮箱,JSON格式"` + IsPublic bool `json:"is_public" gorm:"default:false;comment:是否为公开客户端"` + IsActive bool `json:"is_active" gorm:"default:true;comment:是否激活"` + OwnerID string `json:"owner_id" gorm:"type:varchar(32);comment:客户端拥有者ID"` + + // 关联关系 + Owner *User `json:"owner" gorm:"foreignKey:OwnerID"` + AuthorizationCodes []OAuthAuthorizationCode `json:"-"` + AccessTokens []OAuthAccessToken `json:"-"` + RefreshTokens []OAuthRefreshToken `json:"-"` +} + +// OAuthAuthorizationCode 授权码表 +type OAuthAuthorizationCode struct { + models.BaseModel + Code string `json:"code" gorm:"uniqueIndex;not null;size:255;comment:授权码"` + ClientID string `json:"client_id" gorm:"not null;type:varchar(32);index;comment:客户端ID"` + UserID string `json:"user_id" gorm:"not null;type:varchar(32);index;comment:用户ID"` + RedirectURI string `json:"redirect_uri" gorm:"not null;size:500;comment:重定向URI"` + Scope string `json:"scope" gorm:"type:text;comment:授权范围"` + CodeChallenge string `json:"code_challenge" gorm:"size:255;comment:PKCE代码挑战"` + CodeChallengeMethod string `json:"code_challenge_method" gorm:"size:50;comment:PKCE挑战方法"` + ExpiresAt time.Time `json:"expires_at" gorm:"not null;comment:过期时间"` + Used bool `json:"used" gorm:"default:false;comment:是否已使用"` + + // 关联关系 + Client *OAuthClient `json:"client" gorm:"foreignKey:ClientID"` + User *User `json:"user" gorm:"foreignKey:UserID"` +} + +// OAuthAccessToken 访问令牌表 +type OAuthAccessToken struct { + models.BaseModel + Token string `json:"token" gorm:"uniqueIndex;not null;size:500;comment:访问令牌"` + ClientID string `json:"client_id" gorm:"not null;type:varchar(32);index;comment:客户端ID"` + UserID string `json:"user_id" gorm:"not null;type:varchar(32);index;comment:用户ID"` + Scope string `json:"scope" gorm:"type:text;comment:授权范围"` + ExpiresAt time.Time `json:"expires_at" gorm:"not null;comment:过期时间"` + Revoked bool `json:"revoked" gorm:"default:false;comment:是否已撤销"` + + // 关联关系 + Client *OAuthClient `json:"client" gorm:"foreignKey:ClientID"` + User *User `json:"user" gorm:"foreignKey:UserID"` + RefreshToken *OAuthRefreshToken `json:"refresh_token"` +} + +// OAuthRefreshToken 刷新令牌表 +type OAuthRefreshToken struct { + models.BaseModel + Token string `json:"token" gorm:"uniqueIndex;not null;size:500;comment:刷新令牌"` + AccessTokenID string `json:"access_token_id" gorm:"type:varchar(32);uniqueIndex;comment:访问令牌ID"` + ClientID string `json:"client_id" gorm:"not null;type:varchar(32);index;comment:客户端ID"` + UserID string `json:"user_id" gorm:"not null;type:varchar(32);index;comment:用户ID"` + Scope string `json:"scope" gorm:"type:text;comment:授权范围"` + ExpiresAt time.Time `json:"expires_at" gorm:"not null;comment:过期时间"` + Revoked bool `json:"revoked" gorm:"default:false;comment:是否已撤销"` + + // 关联关系 + Client *OAuthClient `json:"client" gorm:"foreignKey:ClientID"` + User *User `json:"user" gorm:"foreignKey:UserID"` + AccessToken *OAuthAccessToken `json:"access_token" gorm:"foreignKey:AccessTokenID"` +} + +// OAuthScope OAuth授权范围表 +type OAuthScope struct { + models.BaseModel + Name string `json:"name" gorm:"uniqueIndex;not null;size:100;comment:范围名称"` + DisplayName string `json:"display_name" gorm:"size:100;comment:显示名称"` + Description string `json:"description" gorm:"type:text;comment:范围描述"` + IsDefault bool `json:"is_default" gorm:"default:false;comment:是否为默认范围"` + IsSystem bool `json:"is_system" gorm:"default:false;comment:是否为系统范围"` +} + +// OAuthClientScope 客户端授权范围关联表 +type OAuthClientScope struct { + ClientID string `json:"client_id" gorm:"primaryKey;type:varchar(32);comment:客户端ID"` + ScopeID string `json:"scope_id" gorm:"primaryKey;type:varchar(32);comment:范围ID"` + + // 关联关系 + Client *OAuthClient `json:"client" gorm:"foreignKey:ClientID"` + Scope *OAuthScope `json:"scope" gorm:"foreignKey:ScopeID"` +} + +// OAuthProvider 第三方OAuth提供商表(用于OAuth客户端模式) +type OAuthProvider struct { + models.BaseModel + Name string `json:"name" gorm:"uniqueIndex;not null;size:100;comment:提供商名称"` + DisplayName string `json:"display_name" gorm:"size:100;comment:显示名称"` + ClientID string `json:"client_id" gorm:"not null;size:255;comment:客户端ID"` + ClientSecret string `json:"-" gorm:"not null;size:255;comment:客户端密钥"` + AuthURL string `json:"auth_url" gorm:"not null;size:500;comment:授权URL"` + TokenURL string `json:"token_url" gorm:"not null;size:500;comment:令牌URL"` + UserInfoURL string `json:"user_info_url" gorm:"size:500;comment:用户信息URL"` + Scope string `json:"scope" gorm:"type:text;comment:默认授权范围"` + IsActive bool `json:"is_active" gorm:"default:true;comment:是否激活"` + + // 关联关系 + OAuthAccounts []OAuthAccount `json:"oauth_accounts"` +} + +// OAuthAccount 用户OAuth账户表(第三方登录) +type OAuthAccount struct { + models.BaseModel + UserID string `json:"user_id" gorm:"not null;type:varchar(32);index;comment:用户ID"` + ProviderID string `json:"provider_id" gorm:"not null;type:varchar(32);index;comment:提供商ID"` + ProviderUserID string `json:"provider_user_id" gorm:"not null;size:255;comment:提供商用户ID"` + Email string `json:"email" gorm:"size:255;comment:邮箱"` + Username string `json:"username" gorm:"size:255;comment:用户名"` + Nickname string `json:"nickname" gorm:"size:255;comment:昵称"` + Avatar string `json:"avatar" gorm:"size:500;comment:头像URL"` + AccessToken string `json:"-" gorm:"type:text;comment:访问令牌"` + RefreshToken string `json:"-" gorm:"type:text;comment:刷新令牌"` + ExpiresAt *time.Time `json:"expires_at" gorm:"comment:令牌过期时间"` + + // 关联关系 + User *User `json:"user" gorm:"foreignKey:UserID"` + Provider *OAuthProvider `json:"provider" gorm:"foreignKey:ProviderID"` +} + +// UserToken 用户令牌表(API令牌等) +type UserToken struct { + models.BaseModel + UserID string `json:"user_id" gorm:"not null;type:varchar(32);index;comment:用户ID"` + TokenType string `json:"token_type" gorm:"not null;size:50;comment:令牌类型"` // api, session, etc. + Token string `json:"token" gorm:"uniqueIndex;not null;size:500;comment:令牌值"` + Name string `json:"name" gorm:"size:100;comment:令牌名称"` + Description string `json:"description" gorm:"type:text;comment:令牌描述"` + Scope string `json:"scope" gorm:"type:text;comment:授权范围"` + ExpiresAt *time.Time `json:"expires_at" gorm:"comment:过期时间"` + LastUsedAt *time.Time `json:"last_used_at" gorm:"comment:最后使用时间"` + IsActive bool `json:"is_active" gorm:"default:true;comment:是否激活"` + + // 关联关系 + User *User `json:"user" gorm:"foreignKey:UserID"` +} + +// UserSession 用户会话表 +type UserSession struct { + models.BaseModel + UserID string `json:"user_id" gorm:"not null;type:varchar(32);index;comment:用户ID"` + SessionID string `json:"session_id" gorm:"uniqueIndex;not null;size:255;comment:会话ID"` + IPAddress string `json:"ip_address" gorm:"size:45;comment:IP地址"` + UserAgent string `json:"user_agent" gorm:"type:text;comment:用户代理"` + ExpiresAt time.Time `json:"expires_at" gorm:"not null;comment:过期时间"` + IsActive bool `json:"is_active" gorm:"default:true;comment:是否激活"` + LastActivity time.Time `json:"last_activity" gorm:"comment:最后活动时间"` + + // 关联关系 + User *User `json:"user" gorm:"foreignKey:UserID"` +} + +// OAuthUserConsent 用户授权同意表 +type OAuthUserConsent struct { + models.BaseModel + UserID string `json:"user_id" gorm:"not null;type:varchar(32);index;comment:用户ID"` + ClientID string `json:"client_id" gorm:"not null;type:varchar(32);index;comment:客户端ID"` + Scope string `json:"scope" gorm:"type:text;comment:授权范围"` + ConsentAt time.Time `json:"consent_at" gorm:"autoCreateTime;comment:同意时间"` + ExpiresAt *time.Time `json:"expires_at" gorm:"comment:同意过期时间"` + + // 关联关系 + User *User `json:"user" gorm:"foreignKey:UserID"` + Client *OAuthClient `json:"client" gorm:"foreignKey:ClientID"` +} + +// ===== OAuth 模型方法 ===== + +// OAuthClient 方法 +func (c *OAuthClient) IsRedirectURIValid(uri string) bool { + // 这里应该解析 RedirectURIs JSON 并验证 + // 简化实现,实际使用时需要完善 + return true +} + +func (c *OAuthClient) HasScope(scope string) bool { + // 这里应该解析 Scope 并检查 + // 简化实现,实际使用时需要完善 + return true +} + +// OAuthAuthorizationCode 方法 +func (code *OAuthAuthorizationCode) IsExpired() bool { + return time.Now().After(code.ExpiresAt) +} + +// OAuthAccessToken 方法 +func (token *OAuthAccessToken) IsExpired() bool { + return time.Now().After(token.ExpiresAt) +} + +func (token *OAuthAccessToken) IsValid() bool { + return !token.Revoked && !token.IsExpired() +} + +// OAuthRefreshToken 方法 +func (token *OAuthRefreshToken) IsExpired() bool { + return time.Now().After(token.ExpiresAt) +} + +func (token *OAuthRefreshToken) IsValid() bool { + return !token.Revoked && !token.IsExpired() +} + +// UserSession 方法 +func (s *UserSession) IsExpired() bool { + return time.Now().After(s.ExpiresAt) +} + +func (s *UserSession) IsValid() bool { + return s.IsActive && !s.IsExpired() +} diff --git a/oauth/utils.go b/oauth/utils.go new file mode 100644 index 0000000..619f08f --- /dev/null +++ b/oauth/utils.go @@ -0,0 +1,49 @@ +// +// Copyright (C) 2024 veypi +// 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() +}