diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..f24f72e --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "log" + + "github.com/veypi/vbase/internal/api" + "github.com/veypi/vbase/internal/cache" + "github.com/veypi/vbase/internal/config" + "github.com/veypi/vbase/internal/model" + "github.com/veypi/vigo" +) + +func main() { + // 初始化数据库 + if err := model.InitDB(); err != nil { + log.Fatalf("Failed to init database: %v", err) + } + + // 自动迁移 + if err := model.AutoMigrate(); err != nil { + log.Fatalf("Failed to migrate database: %v", err) + } + + // 初始化系统数据 + if err := model.InitSystemData(); err != nil { + log.Fatalf("Failed to init system data: %v", err) + } + + // 初始化Redis + if err := cache.Init(); err != nil { + log.Printf("Warning: Failed to init redis: %v", err) + } else { + log.Println("Redis connected") + } + + // 创建路由 + router := api.NewRouter() + + // 创建服务器 + server, err := vigo.New(vigo.WithHost(config.C.Server.Host), vigo.WithPort(config.C.Server.Port)) + if err != nil { + log.Fatalf("Failed to create server: %v", err) + } + server.Router().Extend("api", router) + + log.Printf("Server starting on %s:%d", config.C.Server.Host, config.C.Server.Port) + if err := server.Run(); err != nil { + log.Fatalf("Server error: %v", err) + } +} diff --git a/internal/api/auth/handler.go b/internal/api/auth/handler.go new file mode 100644 index 0000000..1a64278 --- /dev/null +++ b/internal/api/auth/handler.go @@ -0,0 +1,513 @@ +package auth + +import ( + "net/http" + "time" + + "github.com/veypi/vbase/internal/cache" + "github.com/veypi/vbase/internal/config" + "github.com/veypi/vbase/internal/model" + "github.com/veypi/vbase/internal/pkg/crypto" + "github.com/veypi/vbase/internal/pkg/jwt" + "github.com/veypi/vigo" +) + +// LoginRequest 登录请求 +type LoginRequest struct { + Username string `json:"username" src:"json" desc:"用户名/邮箱/手机号"` + Password string `json:"password" src:"json" desc:"密码"` + CaptchaID string `json:"captcha_id,omitempty" src:"json" desc:"验证码ID"` + CaptchaCode string `json:"captcha_code,omitempty" src:"json" desc:"验证码"` + Remember bool `json:"remember,omitempty" src:"json" desc:"记住登录"` +} + +// RegisterRequest 注册请求 +type RegisterRequest struct { + Username string `json:"username" src:"json" desc:"用户名"` + Password string `json:"password" src:"json" desc:"密码"` + Email string `json:"email,omitempty" src:"json" desc:"邮箱"` + Phone string `json:"phone,omitempty" src:"json" desc:"手机号"` + Nickname string `json:"nickname,omitempty" src:"json" desc:"昵称"` +} + +// AuthResponse 认证响应 +type AuthResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + User *UserInfo `json:"user"` +} + +// UserInfo 用户信息 +type UserInfo struct { + ID string `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Email string `json:"email"` + Avatar string `json:"avatar"` +} + +// Login 用户登录 +func Login(x *vigo.X, req *LoginRequest) (*AuthResponse, error) { + // 查找用户 + var user model.User + query := model.DB.Where("username = ? OR email = ? OR phone = ?", req.Username, req.Username, req.Username) + if err := query.First(&user).Error; err != nil { + return nil, vigo.ErrNotAuthorized.WithString("invalid username or password") + } + + // 检查用户状态 + if user.Status != model.UserStatusActive { + return nil, vigo.ErrForbidden.WithString("user is disabled") + } + + // 验证密码 + if !crypto.VerifyPassword(req.Password, user.Password) { + return nil, vigo.ErrNotAuthorized.WithString("invalid username or password") + } + + // 获取用户的组织信息 + orgs, err := getUserOrgs(user.ID) + if err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + // 生成token + orgClaims := make([]jwt.OrgClaim, 0, len(orgs)) + for _, org := range orgs { + orgClaims = append(orgClaims, jwt.OrgClaim{ + OrgID: org.OrgID, + Code: org.Code, + Name: org.Name, + Roles: org.Roles, + Status: org.Status, + }) + } + + tokenPair, err := jwt.GenerateTokenPair( + user.ID, + user.Username, + user.Nickname, + user.Avatar, + user.Email, + orgClaims, + ) + if err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + // 保存session + session := &model.Session{ + UserID: user.ID, + TokenID: getJTI(tokenPair.AccessToken), + Type: "access", + DeviceInfo: x.Request.UserAgent(), + IP: x.GetRemoteIP(), + ExpiresAt: time.Now().Add(config.C.JWT.AccessExpiry), + } + model.DB.Create(session) + + // 更新最后登录时间 + now := time.Now() + model.DB.Model(&user).Update("last_login_at", now) + + return &AuthResponse{ + AccessToken: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + TokenType: tokenPair.TokenType, + ExpiresIn: tokenPair.ExpiresIn, + User: &UserInfo{ + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Email: user.Email, + Avatar: user.Avatar, + }, + }, nil +} + +// Register 用户注册 +func Register(x *vigo.X, req *RegisterRequest) (*AuthResponse, error) { + // 检查用户名是否已存在 + var count int64 + model.DB.Model(&model.User{}).Where("username = ?", req.Username).Count(&count) + if count > 0 { + return nil, vigo.ErrArgInvalid.WithString("username already exists") + } + + // 检查邮箱是否已存在 + if req.Email != "" { + model.DB.Model(&model.User{}).Where("email = ?", req.Email).Count(&count) + if count > 0 { + return nil, vigo.ErrArgInvalid.WithString("email already exists") + } + } + + // 哈希密码 + hashedPassword, err := crypto.HashPassword(req.Password, config.C.Security.BcryptCost) + if err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + // 创建用户 + user := &model.User{ + Username: req.Username, + Password: hashedPassword, + Email: req.Email, + Phone: req.Phone, + Nickname: req.Nickname, + Status: model.UserStatusActive, + } + + if user.Nickname == "" { + user.Nickname = user.Username + } + + if err := model.DB.Create(user).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + // 生成token + tokenPair, err := jwt.GenerateTokenPair( + user.ID, + user.Username, + user.Nickname, + user.Avatar, + user.Email, + nil, // 新用户无组织 + ) + if err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + // 保存session + session := &model.Session{ + UserID: user.ID, + TokenID: getJTI(tokenPair.AccessToken), + Type: "access", + DeviceInfo: x.Request.UserAgent(), + IP: x.GetRemoteIP(), + ExpiresAt: time.Now().Add(config.C.JWT.AccessExpiry), + } + model.DB.Create(session) + + return &AuthResponse{ + AccessToken: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + TokenType: tokenPair.TokenType, + ExpiresIn: tokenPair.ExpiresIn, + User: &UserInfo{ + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Email: user.Email, + Avatar: user.Avatar, + }, + }, nil +} + +// RefreshRequest 刷新请求 +type RefreshRequest struct { + RefreshToken string `json:"refresh_token" src:"json" desc:"刷新令牌"` +} + +// Refresh 刷新Token +func Refresh(x *vigo.X, req *RefreshRequest) (*AuthResponse, error) { + // 解析refresh token + claims, err := jwt.ParseToken(req.RefreshToken) + if err != nil { + if err == jwt.ErrExpiredToken { + return nil, vigo.ErrNotAuthorized.WithString("refresh token expired") + } + return nil, vigo.ErrNotAuthorized.WithString("invalid refresh token") + } + + if !jwt.IsRefreshToken(claims) { + return nil, vigo.ErrNotAuthorized.WithString("invalid token type") + } + + // 查找用户 + var user model.User + if err := model.DB.First(&user, "id = ?", claims.UserID).Error; err != nil { + return nil, vigo.ErrNotAuthorized.WithString("user not found") + } + + if user.Status != model.UserStatusActive { + return nil, vigo.ErrForbidden.WithString("user is disabled") + } + + // 获取用户的组织信息 + orgs, err := getUserOrgs(user.ID) + if err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + orgClaims := make([]jwt.OrgClaim, 0, len(orgs)) + for _, org := range orgs { + orgClaims = append(orgClaims, jwt.OrgClaim{ + OrgID: org.OrgID, + Code: org.Code, + Name: org.Name, + Roles: org.Roles, + Status: org.Status, + }) + } + + // 生成新token + tokenPair, err := jwt.GenerateTokenPair( + user.ID, + user.Username, + user.Nickname, + user.Avatar, + user.Email, + orgClaims, + ) + if err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + // 保存新session + session := &model.Session{ + UserID: user.ID, + TokenID: getJTI(tokenPair.AccessToken), + Type: "access", + DeviceInfo: x.Request.UserAgent(), + IP: x.GetRemoteIP(), + ExpiresAt: time.Now().Add(config.C.JWT.AccessExpiry), + } + model.DB.Create(session) + + return &AuthResponse{ + AccessToken: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + TokenType: tokenPair.TokenType, + ExpiresIn: tokenPair.ExpiresIn, + User: &UserInfo{ + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Email: user.Email, + Avatar: user.Avatar, + }, + }, nil +} + +// Logout 用户登出 +func Logout(x *vigo.X) error { + tokenString := extractTokenFromRequest(x.Request) + if tokenString == "" { + return nil + } + + jti, err := jwt.GetJTI(tokenString) + if err != nil { + return nil + } + + // 加入黑名单 + expiration, _ := jwt.GetExpiration(tokenString) + if cache.IsEnabled() { + ttl := time.Until(expiration) + if ttl > 0 { + cache.BlacklistToken(jti, ttl) + } + } + + // 标记session为撤销 + model.DB.Model(&model.Session{}).Where("token_id = ?", jti).Updates(map[string]interface{}{ + "revoked": true, + "revoked_at": time.Now(), + }) + + return nil +} + +// Me 获取当前用户信息 +func Me(x *vigo.X) (*UserInfo, error) { + userID := getCurrentUserID(x) + if userID == "" { + return nil, vigo.ErrNotAuthorized + } + + var user model.User + if err := model.DB.First(&user, "id = ?", userID).Error; err != nil { + return nil, vigo.ErrNotFound + } + + return &UserInfo{ + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Email: user.Email, + Avatar: user.Avatar, + }, nil +} + +// UpdateMeRequest 更新自己请求 +type UpdateMeRequest struct { + Nickname *string `json:"nickname,omitempty" src:"json" desc:"昵称"` + Avatar *string `json:"avatar,omitempty" src:"json" desc:"头像"` + Email *string `json:"email,omitempty" src:"json" desc:"邮箱"` +} + +// UpdateMe 更新当前用户信息 +func UpdateMe(x *vigo.X, req *UpdateMeRequest) (*UserInfo, error) { + userID := getCurrentUserID(x) + if userID == "" { + return nil, vigo.ErrNotAuthorized + } + + updates := make(map[string]interface{}) + if req.Nickname != nil { + updates["nickname"] = *req.Nickname + } + if req.Avatar != nil { + updates["avatar"] = *req.Avatar + } + if req.Email != nil { + // 检查邮箱是否被其他用户使用 + var count int64 + model.DB.Model(&model.User{}).Where("email = ? AND id != ?", *req.Email, userID).Count(&count) + if count > 0 { + return nil, vigo.ErrArgInvalid.WithString("email already exists") + } + updates["email"] = *req.Email + } + + if err := model.DB.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + return Me(x) +} + +// ChangePasswordRequest 修改密码请求 +type ChangePasswordRequest struct { + OldPassword string `json:"old_password" src:"json" desc:"旧密码"` + NewPassword string `json:"new_password" src:"json" desc:"新密码"` +} + +// ChangePassword 修改密码 +func ChangePassword(x *vigo.X, req *ChangePasswordRequest) error { + userID := getCurrentUserID(x) + if userID == "" { + return vigo.ErrNotAuthorized + } + + var user model.User + if err := model.DB.First(&user, "id = ?", userID).Error; err != nil { + return vigo.ErrNotFound + } + + // 验证旧密码 + if !crypto.VerifyPassword(req.OldPassword, user.Password) { + return vigo.ErrArgInvalid.WithString("old password is incorrect") + } + + // 哈希新密码 + hashedPassword, err := crypto.HashPassword(req.NewPassword, config.C.Security.BcryptCost) + if err != nil { + return vigo.ErrInternalServer.WithError(err) + } + + // 更新密码 + if err := model.DB.Model(&user).Update("password", hashedPassword).Error; err != nil { + return vigo.ErrInternalServer.WithError(err) + } + + return nil +} + +// helper functions + +func getCurrentUserID(x *vigo.X) string { + if uid, ok := x.Get("current_user").(string); ok { + return uid + } + return "" +} + +func extractToken(r *vigo.X) string { + // 从Header获取 + auth := r.Request.Header.Get("Authorization") + if auth != "" { + parts := make([]string, 0) + for _, p := range []string{auth} { + parts = append(parts, p) + } + // Simple check + if len(auth) > 7 && auth[:7] == "Bearer " { + return auth[7:] + } + } + + // 从Query获取 + return r.Request.URL.Query().Get("access_token") +} + +func extractTokenFromRequest(r *http.Request) string { + // 从Header获取 + auth := r.Header.Get("Authorization") + if auth != "" { + if len(auth) > 7 && auth[:7] == "Bearer " { + return auth[7:] + } + } + + // 从Query获取 + return r.URL.Query().Get("access_token") +} + +func getJTI(token string) string { + jti, _ := jwt.GetJTI(token) + return jti +} + +type userOrgInfo struct { + OrgID string + Code string + Name string + Roles []string + Status int +} + +func getUserOrgs(userID string) ([]userOrgInfo, error) { + var members []model.OrgMember + if err := model.DB.Where("user_id = ? AND status = ?", userID, model.MemberStatusActive).Find(&members).Error; err != nil { + return nil, err + } + + if len(members) == 0 { + return []userOrgInfo{}, nil + } + + result := make([]userOrgInfo, 0, len(members)) + for _, m := range members { + var org model.Org + if err := model.DB.First(&org, "id = ?", m.OrgID).Error; err != nil { + continue + } + + // 解析角色ID + roles := parseRoles(m.RoleIDs) + + result = append(result, userOrgInfo{ + OrgID: m.OrgID, + Code: org.Code, + Name: org.Name, + Roles: roles, + Status: m.Status, + }) + } + + return result, nil +} + +func parseRoles(roleIDs string) []string { + if roleIDs == "" { + return []string{} + } + // 简单解析,实际可能需要更复杂的逻辑 + return []string{} +} diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go new file mode 100644 index 0000000..ed1dfe9 --- /dev/null +++ b/internal/api/middleware/auth.go @@ -0,0 +1,141 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/veypi/vbase/internal/cache" + "github.com/veypi/vbase/internal/pkg/jwt" + "github.com/veypi/vigo" +) + +const ( + ContextKeyUser = "current_user" + ContextKeyClaims = "jwt_claims" + ContextKeyOrgID = "org_id" + ContextKeyIsAdmin = "is_admin" +) + +// AuthRequired 认证中间件 +func AuthRequired(skips ...string) func(*vigo.X) (any, error) { + skipMap := make(map[string]bool) + for _, s := range skips { + skipMap[s] = true + } + + return func(x *vigo.X) (any, error) { + // 检查是否跳过 + if skipMap[x.Request.URL.Path] { + x.Next() + return nil, nil + } + + // 提取token + tokenString := extractToken(x.Request) + if tokenString == "" { + return nil, vigo.ErrNotAuthorized.WithString("missing token") + } + + // 解析token + claims, err := jwt.ParseToken(tokenString) + if err != nil { + if err == jwt.ErrExpiredToken { + return nil, vigo.ErrNotAuthorized.WithString("token expired") + } + return nil, vigo.ErrNotAuthorized.WithString("invalid token") + } + + // 必须是access token + if !jwt.IsAccessToken(claims) { + return nil, vigo.ErrNotAuthorized.WithString("invalid token type") + } + + // 检查黑名单 + if cache.IsEnabled() { + isRevoked, err := cache.IsTokenBlacklisted(claims.ID) + if err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + if isRevoked { + return nil, vigo.ErrNotAuthorized.WithString("token revoked") + } + } + + // 设置上下文 + x.Set(ContextKeyClaims, claims) + x.Set(ContextKeyUser, claims.UserID) + + x.Next() + return nil, nil + } +} + +// OptionalAuth 可选认证 +func OptionalAuth() func(*vigo.X) (any, error) { + return func(x *vigo.X) (any, error) { + tokenString := extractToken(x.Request) + if tokenString != "" { + claims, err := jwt.ParseToken(tokenString) + if err == nil && jwt.IsAccessToken(claims) { + x.Set(ContextKeyClaims, claims) + x.Set(ContextKeyUser, claims.UserID) + } + } + x.Next() + return nil, nil + } +} + +// OrgContext 组织上下文中间件 +func OrgContext() func(*vigo.X) (any, error) { + return func(x *vigo.X) (any, error) { + orgID := x.Request.Header.Get("X-Org-ID") + if orgID == "" { + orgID = x.Request.URL.Query().Get("org_id") + } + if orgID != "" { + x.Set(ContextKeyOrgID, orgID) + } + x.Next() + return nil, nil + } +} + +// extractToken 从请求中提取token +func extractToken(r *http.Request) string { + // 从Header获取 + auth := r.Header.Get("Authorization") + if auth != "" { + parts := strings.SplitN(auth, " ", 2) + if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { + return parts[1] + } + } + + // 从Query获取 + return r.URL.Query().Get("access_token") +} + +// CurrentUser 获取当前用户ID +func CurrentUser(x *vigo.X) string { + if uid, ok := x.Get(ContextKeyUser).(string); ok { + return uid + } + return "" +} + +// CurrentClaims 获取当前JWT Claims +func CurrentClaims(x *vigo.X) *jwt.Claims { + if claims, ok := x.Get(ContextKeyClaims).(*jwt.Claims); ok { + return claims + } + return nil +} + +// CurrentOrgID 获取当前组织ID +func CurrentOrgID(x *vigo.X) string { + if orgID, ok := x.Get(ContextKeyOrgID).(string); ok { + return orgID + } + return "" +} diff --git a/internal/api/middleware/ratelimit.go b/internal/api/middleware/ratelimit.go new file mode 100644 index 0000000..4fe943c --- /dev/null +++ b/internal/api/middleware/ratelimit.go @@ -0,0 +1,111 @@ +package middleware + +import ( + "fmt" + "net/http" + "time" + + "github.com/veypi/vbase/internal/cache" + "github.com/veypi/vigo" +) + +// RateLimiter 限流中间件 +func RateLimiter(maxRequests int, window time.Duration) func(*vigo.X) (any, error) { + return func(x *vigo.X) (any, error) { + if !cache.IsEnabled() { + x.Next() + return nil, nil + } + + // 使用IP+路径作为标识 + identifier := x.GetRemoteIP() + path := x.Request.URL.Path + + count, err := cache.IncrRateLimit(identifier, path, window) + if err != nil { + x.Next() // 缓存失败时放行 + return nil, nil + } + + if count > int64(maxRequests) { + x.ResponseWriter().Header().Set("Retry-After", fmt.Sprintf("%d", int(window.Seconds()))) + return nil, vigo.NewError("rate limit exceeded").WithCode(http.StatusTooManyRequests) + } + + x.Next() + return nil, nil + } +} + +// RateLimiterByUser 基于用户的限流 +func RateLimiterByUser(maxRequests int, window time.Duration) func(*vigo.X) (any, error) { + return func(x *vigo.X) (any, error) { + if !cache.IsEnabled() { + x.Next() + return nil, nil + } + + userID := CurrentUser(x) + if userID == "" { + // 未登录用户使用IP限流 + _, err := RateLimiter(maxRequests, window)(x) + return nil, err + } + + path := x.Request.URL.Path + count, err := cache.IncrRateLimit("user:"+userID, path, window) + if err != nil { + x.Next() + return nil, nil + } + + if count > int64(maxRequests) { + x.ResponseWriter().Header().Set("Retry-After", fmt.Sprintf("%d", int(window.Seconds()))) + return nil, vigo.NewError("rate limit exceeded").WithCode(http.StatusTooManyRequests) + } + + x.Next() + return nil, nil + } +} + +// LoginRateLimit 登录限流(更严格) +func LoginRateLimit() func(*vigo.X) (any, error) { + return func(x *vigo.X) (any, error) { + if !cache.IsEnabled() { + x.Next() + return nil, nil + } + + identifier := x.GetRemoteIP() + key := "login_attempt:" + identifier + + count, _ := cache.Incr(key) + if count == 1 { + cache.Expire(key, 15*time.Minute) + } + + // 5分钟内超过5次尝试,需要验证码 + if count >= 5 { + x.Set("require_captcha", true) + } + + // 超过10次直接拒绝 + if count >= 10 { + return nil, vigo.NewError("too many login attempts, please try again later").WithCode(http.StatusTooManyRequests) + } + + x.Next() + return nil, nil + } +} + +// ResetLoginAttempts 重置登录尝试次数 +func ResetLoginAttempts(x *vigo.X) { + if !cache.IsEnabled() { + return + } + identifier := x.GetRemoteIP() + key := "login_attempt:" + identifier + cache.Delete(key) +} diff --git a/internal/api/oauth/client.go b/internal/api/oauth/client.go new file mode 100644 index 0000000..5eb9794 --- /dev/null +++ b/internal/api/oauth/client.go @@ -0,0 +1,399 @@ +package oauth + +import ( + "crypto/rand" + "encoding/hex" + "strings" + + "github.com/veypi/vbase/internal/api/middleware" + "github.com/veypi/vbase/internal/model" + "github.com/veypi/vigo" +) + +// ClientRequest 客户端请求 +type ClientRequest struct { + Name string `json:"name" src:"json" desc:"应用名称"` + Description string `json:"description" src:"json" desc:"应用描述"` + RedirectURIs []string `json:"redirect_uris" src:"json" desc:"回调地址列表"` + GrantTypes []string `json:"grant_types" src:"json" desc:"授权类型"` + ResponseTypes []string `json:"response_types" src:"json" desc:"响应类型"` + AllowedScopes []string `json:"allowed_scopes" src:"json" desc:"允许的权限范围"` + TokenExpiry int `json:"token_expiry" src:"json" desc:"Token有效期(秒)"` + RefreshExpiry int `json:"refresh_expiry" src:"json" desc:"RefreshToken有效期(秒)"` + OrgID string `json:"org_id" src:"json" desc:"所属组织ID(可选)"` +} + +// ClientResponse 客户端响应 +type ClientResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret,omitempty"` + RedirectURIs []string `json:"redirect_uris"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` + AllowedScopes []string `json:"allowed_scopes"` + TokenExpiry int `json:"token_expiry"` + RefreshExpiry int `json:"refresh_expiry"` + OwnerID string `json:"owner_id"` + OrgID string `json:"org_id,omitempty"` + Status int `json:"status"` + CreatedAt string `json:"created_at"` +} + +// generateClientID 生成客户端ID +func generateClientID() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +} + +// generateClientSecret 生成客户端密钥 +func generateClientSecret() string { + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} + +// CreateClient 创建OAuth客户端 +func CreateClient(x *vigo.X, req *ClientRequest) (*ClientResponse, error) { + userID := middleware.CurrentUser(x) + if userID == "" { + return nil, vigo.ErrNotAuthorized + } + + // 默认值 + grantTypes := req.GrantTypes + if len(grantTypes) == 0 { + grantTypes = []string{model.GrantTypeAuthorizationCode, model.GrantTypeRefreshToken} + } + responseTypes := req.ResponseTypes + if len(responseTypes) == 0 { + responseTypes = []string{model.ResponseTypeCode} + } + allowedScopes := req.AllowedScopes + if len(allowedScopes) == 0 { + allowedScopes = []string{model.ScopeOpenID, model.ScopeProfile, model.ScopeEmail} + } + tokenExpiry := req.TokenExpiry + if tokenExpiry == 0 { + tokenExpiry = 3600 + } + refreshExpiry := req.RefreshExpiry + if refreshExpiry == 0 { + refreshExpiry = 2592000 // 30天 + } + + client := &model.OAuthClient{ + Name: req.Name, + Description: req.Description, + ClientID: generateClientID(), + ClientSecret: generateClientSecret(), + RedirectURIs: strings.Join(req.RedirectURIs, ","), + GrantTypes: strings.Join(grantTypes, ","), + ResponseTypes: strings.Join(responseTypes, ","), + AllowedScopes: strings.Join(allowedScopes, ","), + TokenExpiry: tokenExpiry, + RefreshExpiry: refreshExpiry, + OwnerID: userID, + Status: 1, + } + + if req.OrgID != "" { + // 检查用户是否是组织成员 + var member model.OrgMember + if err := model.DB.Where("org_id = ? AND user_id = ? AND status = ?", req.OrgID, userID, model.MemberStatusActive).First(&member).Error; err != nil { + return nil, vigo.ErrForbidden.WithString("you are not a member of this organization") + } + client.OrgID = req.OrgID + } + + if err := model.DB.Create(client).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + return &ClientResponse{ + ID: client.ID, + Name: client.Name, + Description: client.Description, + ClientID: client.ClientID, + ClientSecret: client.ClientSecret, + RedirectURIs: req.RedirectURIs, + GrantTypes: grantTypes, + ResponseTypes: responseTypes, + AllowedScopes: allowedScopes, + TokenExpiry: client.TokenExpiry, + RefreshExpiry: client.RefreshExpiry, + OwnerID: client.OwnerID, + OrgID: client.OrgID, + Status: client.Status, + CreatedAt: client.CreatedAt.Format("2006-01-02 15:04:05"), + }, nil +} + +// ListClientsRequest 客户端列表请求 +type ListClientsRequest struct { + Page int `json:"page" src:"query" default:"1" desc:"页码"` + PageSize int `json:"page_size" src:"query" default:"10" desc:"每页数量"` + OrgID string `json:"org_id" src:"query" desc:"组织ID筛选"` +} + +// ListClientsResponse 客户端列表响应 +type ListClientsResponse struct { + Items []ClientResponse `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalPages int `json:"total_pages"` +} + +// ListClients 获取OAuth客户端列表 +func ListClients(x *vigo.X, req *ListClientsRequest) (*ListClientsResponse, error) { + userID := middleware.CurrentUser(x) + if userID == "" { + return nil, vigo.ErrNotAuthorized + } + + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 || req.PageSize > 100 { + req.PageSize = 10 + } + + query := model.DB.Model(&model.OAuthClient{}).Where("owner_id = ? OR org_id IN (SELECT org_id FROM org_members WHERE user_id = ? AND status = ?)", userID, userID, model.MemberStatusActive) + if req.OrgID != "" { + query = query.Where("org_id = ?", req.OrgID) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + var clients []model.OAuthClient + offset := (req.Page - 1) * req.PageSize + if err := query.Offset(offset).Limit(req.PageSize).Order("created_at DESC").Find(&clients).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + items := make([]ClientResponse, 0, len(clients)) + for _, c := range clients { + items = append(items, toClientResponse(&c, false)) + } + + totalPages := int((total + int64(req.PageSize) - 1) / int64(req.PageSize)) + return &ListClientsResponse{ + Items: items, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + TotalPages: totalPages, + }, nil +} + +// GetClientRequest 获取客户端请求 +type GetClientRequest struct { + ID string `json:"id" src:"path@client_id" desc:"客户端ID"` +} + +// GetClient 获取客户端详情 +func GetClient(x *vigo.X, req *GetClientRequest) (*ClientResponse, error) { + userID := middleware.CurrentUser(x) + if userID == "" { + return nil, vigo.ErrNotAuthorized + } + + var client model.OAuthClient + if err := model.DB.Where("client_id = ?", req.ID).First(&client).Error; err != nil { + return nil, vigo.ErrNotFound + } + + // 检查权限 + if client.OwnerID != userID { + // 检查是否是组织成员 + if client.OrgID != "" { + var member model.OrgMember + if err := model.DB.Where("org_id = ? AND user_id = ? AND status = ?", client.OrgID, userID, model.MemberStatusActive).First(&member).Error; err != nil { + return nil, vigo.ErrForbidden + } + } else { + return nil, vigo.ErrForbidden + } + } + + resp := toClientResponse(&client, false) + return &resp, nil +} + +// UpdateClientRequest 更新客户端请求 +type UpdateClientRequest struct { + ID string `json:"id" src:"path@client_id" desc:"客户端ID"` + Name *string `json:"name" src:"json" desc:"应用名称"` + Description *string `json:"description" src:"json" desc:"应用描述"` + RedirectURIs []string `json:"redirect_uris" src:"json" desc:"回调地址列表"` + AllowedScopes []string `json:"allowed_scopes" src:"json" desc:"允许的权限范围"` + TokenExpiry *int `json:"token_expiry" src:"json" desc:"Token有效期"` + RefreshExpiry *int `json:"refresh_expiry" src:"json" desc:"RefreshToken有效期"` + Status *int `json:"status" src:"json" desc:"状态"` +} + +// UpdateClient 更新OAuth客户端 +func UpdateClient(x *vigo.X, req *UpdateClientRequest) (*ClientResponse, error) { + userID := middleware.CurrentUser(x) + if userID == "" { + return nil, vigo.ErrNotAuthorized + } + + var client model.OAuthClient + if err := model.DB.Where("client_id = ?", req.ID).First(&client).Error; err != nil { + return nil, vigo.ErrNotFound + } + + // 检查权限 + if client.OwnerID != userID { + if client.OrgID != "" { + var member model.OrgMember + if err := model.DB.Where("org_id = ? AND user_id = ? AND status = ?", client.OrgID, userID, model.MemberStatusActive).First(&member).Error; err != nil { + return nil, vigo.ErrForbidden + } + } else { + return nil, vigo.ErrForbidden + } + } + + updates := make(map[string]interface{}) + if req.Name != nil { + updates["name"] = *req.Name + } + if req.Description != nil { + updates["description"] = *req.Description + } + if req.RedirectURIs != nil { + updates["redirect_uris"] = strings.Join(req.RedirectURIs, ",") + } + if req.AllowedScopes != nil { + updates["allowed_scopes"] = strings.Join(req.AllowedScopes, ",") + } + if req.TokenExpiry != nil { + updates["token_expiry"] = *req.TokenExpiry + } + if req.RefreshExpiry != nil { + updates["refresh_expiry"] = *req.RefreshExpiry + } + if req.Status != nil { + updates["status"] = *req.Status + } + + if len(updates) > 0 { + if err := model.DB.Model(&client).Updates(updates).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + } + + // 重新获取 + model.DB.First(&client, "client_id = ?", req.ID) + resp := toClientResponse(&client, false) + return &resp, nil +} + +// DeleteClientRequest 删除客户端请求 +type DeleteClientRequest struct { + ID string `json:"id" src:"path@client_id" desc:"客户端ID"` +} + +// DeleteClient 删除OAuth客户端 +func DeleteClient(x *vigo.X, req *DeleteClientRequest) error { + userID := middleware.CurrentUser(x) + if userID == "" { + return vigo.ErrNotAuthorized + } + + var client model.OAuthClient + if err := model.DB.Where("client_id = ?", req.ID).First(&client).Error; err != nil { + return vigo.ErrNotFound + } + + // 检查权限 + if client.OwnerID != userID { + if client.OrgID != "" { + var member model.OrgMember + if err := model.DB.Where("org_id = ? AND user_id = ? AND status = ?", client.OrgID, userID, model.MemberStatusActive).First(&member).Error; err != nil { + return vigo.ErrForbidden + } + } else { + return vigo.ErrForbidden + } + } + + // 软删除 + if err := model.DB.Delete(&client).Error; err != nil { + return vigo.ErrInternalServer.WithError(err) + } + + return nil +} + +// RegenerateSecretRequest 重新生成密钥请求 +type RegenerateSecretRequest struct { + ID string `json:"id" src:"path@client_id" desc:"客户端ID"` +} + +// RegenerateSecret 重新生成客户端密钥 +func RegenerateSecret(x *vigo.X, req *RegenerateSecretRequest) (*ClientResponse, error) { + userID := middleware.CurrentUser(x) + if userID == "" { + return nil, vigo.ErrNotAuthorized + } + + var client model.OAuthClient + if err := model.DB.Where("client_id = ?", req.ID).First(&client).Error; err != nil { + return nil, vigo.ErrNotFound + } + + // 检查权限 + if client.OwnerID != userID { + if client.OrgID != "" { + var member model.OrgMember + if err := model.DB.Where("org_id = ? AND user_id = ? AND status = ?", client.OrgID, userID, model.MemberStatusActive).First(&member).Error; err != nil { + return nil, vigo.ErrForbidden + } + } else { + return nil, vigo.ErrForbidden + } + } + + // 生成新密钥 + newSecret := generateClientSecret() + if err := model.DB.Model(&client).Update("client_secret", newSecret).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + resp := toClientResponse(&client, true) + return &resp, nil +} + +// helper functions +func toClientResponse(c *model.OAuthClient, includeSecret bool) ClientResponse { + resp := ClientResponse{ + ID: c.ID, + Name: c.Name, + Description: c.Description, + ClientID: c.ClientID, + RedirectURIs: strings.Split(c.RedirectURIs, ","), + GrantTypes: strings.Split(c.GrantTypes, ","), + ResponseTypes: strings.Split(c.ResponseTypes, ","), + AllowedScopes: strings.Split(c.AllowedScopes, ","), + TokenExpiry: c.TokenExpiry, + RefreshExpiry: c.RefreshExpiry, + OwnerID: c.OwnerID, + OrgID: c.OrgID, + Status: c.Status, + CreatedAt: c.CreatedAt.Format("2006-01-02 15:04:05"), + } + if includeSecret { + resp.ClientSecret = c.ClientSecret + } + return resp +} diff --git a/internal/api/oauth/oauth.go b/internal/api/oauth/oauth.go new file mode 100644 index 0000000..06d2736 --- /dev/null +++ b/internal/api/oauth/oauth.go @@ -0,0 +1,585 @@ +package oauth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "net/http" + "net/url" + "strings" + "time" + + "github.com/veypi/vbase/internal/model" + "github.com/veypi/vigo" +) + +// AuthorizeRequest 授权请求 +type AuthorizeRequest struct { + ResponseType string `json:"response_type" src:"query" desc:"响应类型: code/token"` + ClientID string `json:"client_id" src:"query" desc:"客户端ID"` + RedirectURI string `json:"redirect_uri" src:"query" desc:"回调地址"` + Scope string `json:"scope" src:"query" desc:"请求的权限范围"` + State string `json:"state" src:"query" desc:"状态值(防CSRF)"` + CodeChallenge string `json:"code_challenge" src:"query" desc:"PKCE挑战码"` + CodeChallengeMethod string `json:"code_challenge_method" src:"query" desc:"PKCE方法: S256/plain"` +} + +// AuthorizeResponse 授权响应 (用于重定向) +type AuthorizeResponse struct { + Code string `json:"code"` + State string `json:"state"` +} + +// Authorize 授权端点 - 处理授权码请求 +// GET /oauth/authorize +func Authorize(x *vigo.X, req *AuthorizeRequest) error { + // 验证必填参数 + if req.ResponseType == "" { + return oauthError(x, req.RedirectURI, "invalid_request", "response_type is required", req.State) + } + if req.ClientID == "" { + return oauthError(x, req.RedirectURI, "invalid_request", "client_id is required", req.State) + } + + // 查找客户端 + var client model.OAuthClient + if err := model.DB.Where("client_id = ? AND status = ?", req.ClientID, 1).First(&client).Error; err != nil { + return oauthError(x, req.RedirectURI, "invalid_client", "client not found", req.State) + } + + // 验证response_type + if !strings.Contains(client.ResponseTypes, req.ResponseType) { + return oauthError(x, req.RedirectURI, "unsupported_response_type", "", req.State) + } + + // 验证redirect_uri + if req.RedirectURI != "" { + allowedURIs := strings.Split(client.RedirectURIs, ",") + found := false + for _, uri := range allowedURIs { + if strings.TrimSpace(uri) == req.RedirectURI { + found = true + break + } + } + if !found { + return oauthError(x, "", "invalid_request", "redirect_uri mismatch", "") + } + } else if client.RedirectURIs != "" { + // 使用第一个注册的回调地址 + req.RedirectURI = strings.Split(client.RedirectURIs, ",")[0] + } else { + return oauthError(x, "", "invalid_request", "redirect_uri required", "") + } + + // 验证scope + requestedScopes := parseScopes(req.Scope) + allowedScopes := parseScopes(client.AllowedScopes) + for _, scope := range requestedScopes { + if !contains(allowedScopes, scope) { + return oauthError(x, req.RedirectURI, "invalid_scope", "scope not allowed: "+scope, req.State) + } + } + + // 获取当前用户 + var userID string + if uid, ok := x.Get("current_user").(string); ok { + userID = uid + } + if userID == "" { + // 未登录,需要重定向到登录页面 + loginURL := "/login?redirect=" + url.QueryEscape(x.Request.URL.String()) + x.ResponseWriter().Header().Set("Location", loginURL) + x.ResponseWriter().WriteHeader(http.StatusFound) + return nil + } + + // 获取组织ID (从请求头或用户选择) + orgID := x.Request.Header.Get("X-Org-ID") + if orgID == "" && client.OrgID != "" { + orgID = client.OrgID + } + + switch req.ResponseType { + case model.ResponseTypeCode: + // 生成授权码 + code, err := generateAuthorizationCode(&client, userID, orgID, req) + if err != nil { + return oauthError(x, req.RedirectURI, "server_error", "", req.State) + } + + // 构建重定向URL + redirectURL, _ := url.Parse(req.RedirectURI) + q := redirectURL.Query() + q.Set("code", code) + if req.State != "" { + q.Set("state", req.State) + } + redirectURL.RawQuery = q.Encode() + + x.ResponseWriter().Header().Set("Location", redirectURL.String()) + x.ResponseWriter().WriteHeader(http.StatusFound) + return nil + + case model.ResponseTypeToken: + // Implicit flow (简化模式) - 直接返回token + // 注意: 简化模式安全性较低,建议仅在必要时使用 + return handleImplicitGrant(x, &client, userID, orgID, req) + + default: + return oauthError(x, req.RedirectURI, "unsupported_response_type", "", req.State) + } +} + +// TokenRequest 令牌请求 +type TokenRequest struct { + GrantType string `json:"grant_type" src:"form" desc:"授权类型"` + Code string `json:"code" src:"form" desc:"授权码"` + RedirectURI string `json:"redirect_uri" src:"form" desc:"回调地址"` + ClientID string `json:"client_id" src:"form" desc:"客户端ID"` + ClientSecret string `json:"client_secret" src:"form" desc:"客户端密钥"` + RefreshToken string `json:"refresh_token" src:"form" desc:"刷新令牌"` + Scope string `json:"scope" src:"form" desc:"权限范围"` + CodeVerifier string `json:"code_verifier" src:"form" desc:"PKCE验证器"` + Username string `json:"username" src:"form" desc:"用户名(密码模式)"` + Password string `json:"password" src:"form" desc:"密码(密码模式)"` +} + +// TokenResponse 令牌响应 +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token,omitempty"` + Scope string `json:"scope,omitempty"` + IDToken string `json:"id_token,omitempty"` // OIDC +} + +// Token 令牌端点 - 交换授权码获取访问令牌 +// POST /oauth/token +func Token(x *vigo.X, req *TokenRequest) (*TokenResponse, error) { + // 验证客户端身份 + client, err := authenticateClient(x, req) + if err != nil { + return nil, vigo.ErrNotAuthorized.WithString("invalid_client") + } + + // 验证grant_type + if !strings.Contains(client.GrantTypes, req.GrantType) { + return nil, vigo.ErrArgInvalid.WithString("unsupported_grant_type") + } + + switch req.GrantType { + case model.GrantTypeAuthorizationCode: + return handleAuthorizationCodeGrant(x, client, req) + case model.GrantTypeRefreshToken: + return handleRefreshTokenGrant(x, client, req) + case model.GrantTypeClientCredentials: + return handleClientCredentialsGrant(x, client, req) + case model.GrantTypePassword: + return handlePasswordGrant(x, client, req) + default: + return nil, vigo.ErrArgInvalid.WithString("unsupported_grant_type") + } +} + +// handleAuthorizationCodeGrant 处理授权码模式 +func handleAuthorizationCodeGrant(x *vigo.X, client *model.OAuthClient, req *TokenRequest) (*TokenResponse, error) { + if req.Code == "" { + return nil, vigo.ErrArgInvalid.WithString("code is required") + } + + // 查找授权码 + var auth model.OAuthAuthorization + if err := model.DB.Where("code = ? AND client_id = ? AND used = ?", req.Code, client.ClientID, false).First(&auth).Error; err != nil { + return nil, vigo.ErrArgInvalid.WithString("invalid_grant") + } + + // 检查是否过期 + if time.Now().After(auth.ExpiresAt) { + return nil, vigo.ErrArgInvalid.WithString("invalid_grant: code expired") + } + + // 验证redirect_uri + if req.RedirectURI != "" && req.RedirectURI != auth.RedirectURI { + return nil, vigo.ErrArgInvalid.WithString("invalid_grant: redirect_uri mismatch") + } + + // PKCE验证 + if auth.CodeChallenge != "" { + if req.CodeVerifier == "" { + return nil, vigo.ErrArgInvalid.WithString("invalid_grant: code_verifier required") + } + if !verifyPKCE(req.CodeVerifier, auth.CodeChallenge, auth.CodeChallengeMethod) { + return nil, vigo.ErrArgInvalid.WithString("invalid_grant: code_verifier mismatch") + } + } + + // 标记授权码为已使用 + now := time.Now() + model.DB.Model(&auth).Updates(map[string]interface{}{ + "used": true, + "used_at": now, + }) + + // 生成访问令牌 + tokenResp, err := generateTokenPair(client, auth.UserID, auth.OrgID, auth.Scope, client.TokenExpiry, client.RefreshExpiry) + if err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + return tokenResp, nil +} + +// handleRefreshTokenGrant 处理刷新令牌模式 +func handleRefreshTokenGrant(x *vigo.X, client *model.OAuthClient, req *TokenRequest) (*TokenResponse, error) { + if req.RefreshToken == "" { + return nil, vigo.ErrArgInvalid.WithString("refresh_token is required") + } + + // 查找刷新令牌 + var token model.OAuthToken + if err := model.DB.Where("refresh_token = ? AND client_id = ? AND revoked = ?", req.RefreshToken, client.ClientID, false).First(&token).Error; err != nil { + return nil, vigo.ErrArgInvalid.WithString("invalid_grant") + } + + // 检查是否过期 + if time.Now().After(token.ExpiresAt) { + return nil, vigo.ErrArgInvalid.WithString("invalid_grant: token expired") + } + + // 撤销旧的刷新令牌 + now := time.Now() + model.DB.Model(&token).Updates(map[string]interface{}{ + "revoked": true, + "revoked_at": now, + }) + + // 生成新的访问令牌 + tokenResp, err := generateTokenPair(client, token.UserID, token.OrgID, token.Scope, client.TokenExpiry, client.RefreshExpiry) + if err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + return tokenResp, nil +} + +// handleClientCredentialsGrant 处理客户端凭证模式 +func handleClientCredentialsGrant(x *vigo.X, client *model.OAuthClient, req *TokenRequest) (*TokenResponse, error) { + // 客户端凭证模式没有用户上下文,通常用于服务间调用 + // 限制scope,只允许非用户相关的权限 + + requestedScopes := parseScopes(req.Scope) + allowedScopes := []string{} + for _, scope := range requestedScopes { + if scope == "service" || strings.HasPrefix(scope, "service:") { + allowedScopes = append(allowedScopes, scope) + } + } + + if len(allowedScopes) == 0 { + allowedScopes = []string{"service"} + } + + scopeStr := strings.Join(allowedScopes, " ") + + // 生成访问令牌 + accessToken := generateRandomToken(32) + expiresAt := time.Now().Add(time.Duration(client.TokenExpiry) * time.Second) + + token := &model.OAuthToken{ + UserID: "", // 客户端凭证模式没有用户 + ClientID: client.ClientID, + OrgID: client.OrgID, + AccessToken: accessToken, + TokenType: "Bearer", + Scope: scopeStr, + ExpiresAt: expiresAt, + } + + if err := model.DB.Create(token).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + return &TokenResponse{ + AccessToken: accessToken, + TokenType: "Bearer", + ExpiresIn: client.TokenExpiry, + Scope: scopeStr, + }, nil +} + +// handlePasswordGrant 处理密码模式 (不推荐,但为兼容性保留) +func handlePasswordGrant(x *vigo.X, client *model.OAuthClient, req *TokenRequest) (*TokenResponse, error) { + // 密码模式需要验证用户凭据 + // 这里简化处理,实际应该调用用户认证逻辑 + return nil, vigo.ErrArgInvalid.WithString("unsupported_grant_type: password grant is disabled") +} + +// handleImplicitGrant 处理简化模式 +func handleImplicitGrant(x *vigo.X, client *model.OAuthClient, userID, orgID string, req *AuthorizeRequest) error { + // 生成访问令牌 + accessToken := generateRandomToken(32) + expiresAt := time.Now().Add(time.Duration(client.TokenExpiry) * time.Second) + + token := &model.OAuthToken{ + UserID: userID, + ClientID: client.ClientID, + OrgID: orgID, + AccessToken: accessToken, + TokenType: "Bearer", + Scope: req.Scope, + ExpiresAt: expiresAt, + } + + if err := model.DB.Create(token).Error; err != nil { + return oauthError(x, req.RedirectURI, "server_error", "", req.State) + } + + // 构建fragment URL + fragment := url.Values{} + fragment.Set("access_token", accessToken) + fragment.Set("token_type", "Bearer") + fragment.Set("expires_in", string(rune(client.TokenExpiry))) + if req.Scope != "" { + fragment.Set("scope", req.Scope) + } + if req.State != "" { + fragment.Set("state", req.State) + } + + redirectURL, _ := url.Parse(req.RedirectURI) + redirectURL.Fragment = fragment.Encode() + + x.ResponseWriter().Header().Set("Location", redirectURL.String()) + x.ResponseWriter().WriteHeader(http.StatusFound) + return nil +} + +// RevokeRequest 撤销请求 +type RevokeRequest struct { + Token string `json:"token" src:"form" desc:"要撤销的令牌"` + TokenTypeHint string `json:"token_type_hint" src:"form" desc:"令牌类型提示: access_token/refresh_token"` +} + +// Revoke 撤销令牌端点 +// POST /oauth/revoke +func Revoke(x *vigo.X, req *RevokeRequest) error { + if req.Token == "" { + return nil // 根据RFC 7009,无效的令牌也应返回200 + } + + // 尝试查找access_token + var token model.OAuthToken + if err := model.DB.Where("access_token = ?", req.Token).First(&token).Error; err == nil { + now := time.Now() + model.DB.Model(&token).Updates(map[string]interface{}{ + "revoked": true, + "revoked_at": now, + }) + return nil + } + + // 尝试查找refresh_token + if err := model.DB.Where("refresh_token = ?", req.Token).First(&token).Error; err == nil { + now := time.Now() + model.DB.Model(&token).Updates(map[string]interface{}{ + "revoked": true, + "revoked_at": now, + }) + } + + return nil +} + +// IntrospectRequest 令牌内省请求 +type IntrospectRequest struct { + Token string `json:"token" src:"form" desc:"要内省的令牌"` + TokenTypeHint string `json:"token_type_hint" src:"form" desc:"令牌类型提示"` +} + +// IntrospectResponse 令牌内省响应 +type IntrospectResponse struct { + Active bool `json:"active"` + Scope string `json:"scope,omitempty"` + ClientID string `json:"client_id,omitempty"` + Username string `json:"username,omitempty"` + TokenType string `json:"token_type,omitempty"` + Exp int64 `json:"exp,omitempty"` + Iat int64 `json:"iat,omitempty"` + Sub string `json:"sub,omitempty"` + Aud string `json:"aud,omitempty"` + Iss string `json:"iss,omitempty"` + Jti string `json:"jti,omitempty"` +} + +// Introspect 令牌内省端点 (RFC 7662) +// POST /oauth/introspect +func Introspect(x *vigo.X, req *IntrospectRequest) (*IntrospectResponse, error) { + if req.Token == "" { + return &IntrospectResponse{Active: false}, nil + } + + var token model.OAuthToken + if err := model.DB.Where("access_token = ? AND revoked = ?", req.Token, false).First(&token).Error; err != nil { + return &IntrospectResponse{Active: false}, nil + } + + // 检查是否过期 + if time.Now().After(token.ExpiresAt) { + return &IntrospectResponse{Active: false}, nil + } + + // 获取用户信息 + var user model.User + username := "" + if err := model.DB.First(&user, "id = ?", token.UserID).Error; err == nil { + username = user.Username + } + + return &IntrospectResponse{ + Active: true, + Scope: token.Scope, + ClientID: token.ClientID, + Username: username, + TokenType: token.TokenType, + Exp: token.ExpiresAt.Unix(), + Sub: token.UserID, + }, nil +} + +// helper functions + +func generateAuthorizationCode(client *model.OAuthClient, userID, orgID string, req *AuthorizeRequest) (string, error) { + code := generateRandomToken(32) + expiresAt := time.Now().Add(10 * time.Minute) // 授权码10分钟有效 + + auth := &model.OAuthAuthorization{ + UserID: userID, + ClientID: client.ClientID, + OrgID: orgID, + Code: code, + Scope: req.Scope, + State: req.State, + CodeChallenge: req.CodeChallenge, + CodeChallengeMethod: req.CodeChallengeMethod, + RedirectURI: req.RedirectURI, + ExpiresAt: expiresAt, + } + + if err := model.DB.Create(auth).Error; err != nil { + return "", err + } + + return code, nil +} + +func generateTokenPair(client *model.OAuthClient, userID, orgID, scope string, accessExpiry, refreshExpiry int) (*TokenResponse, error) { + accessToken := generateRandomToken(32) + refreshToken := generateRandomToken(32) + expiresAt := time.Now().Add(time.Duration(accessExpiry) * time.Second) + + token := &model.OAuthToken{ + UserID: userID, + ClientID: client.ClientID, + OrgID: orgID, + AccessToken: accessToken, + RefreshToken: refreshToken, + TokenType: "Bearer", + Scope: scope, + ExpiresAt: expiresAt, + } + + if err := model.DB.Create(token).Error; err != nil { + return nil, err + } + + return &TokenResponse{ + AccessToken: accessToken, + TokenType: "Bearer", + ExpiresIn: accessExpiry, + RefreshToken: refreshToken, + Scope: scope, + }, nil +} + +func generateRandomToken(length int) string { + b := make([]byte, length) + rand.Read(b) + return hex.EncodeToString(b) +} + +func authenticateClient(x *vigo.X, req *TokenRequest) (*model.OAuthClient, error) { + // 优先从Basic Auth获取 + clientID, clientSecret, ok := x.Request.BasicAuth() + if ok { + req.ClientID = clientID + req.ClientSecret = clientSecret + } + + if req.ClientID == "" || req.ClientSecret == "" { + return nil, vigo.ErrNotAuthorized + } + + var client model.OAuthClient + if err := model.DB.Where("client_id = ? AND client_secret = ? AND status = ?", req.ClientID, req.ClientSecret, 1).First(&client).Error; err != nil { + return nil, err + } + + return &client, nil +} + +func verifyPKCE(verifier, challenge, method string) bool { + switch method { + case "S256": + hash := sha256.Sum256([]byte(verifier)) + encoded := base64.RawURLEncoding.EncodeToString(hash[:]) + return encoded == challenge + case "plain": + return verifier == challenge + default: + return false + } +} + +func parseScopes(scope string) []string { + if scope == "" { + return []string{} + } + return strings.Split(scope, " ") +} + +func contains(arr []string, item string) bool { + for _, a := range arr { + if a == item { + return true + } + } + return false +} + +func oauthError(x *vigo.X, redirectURI, errorCode, errorDescription, state string) error { + if redirectURI == "" { + return vigo.ErrArgInvalid.WithString(errorCode + ": " + errorDescription) + } + + u, err := url.Parse(redirectURI) + if err != nil { + return vigo.ErrArgInvalid.WithString(errorCode + ": " + errorDescription) + } + + q := u.Query() + q.Set("error", errorCode) + if errorDescription != "" { + q.Set("error_description", errorDescription) + } + if state != "" { + q.Set("state", state) + } + u.RawQuery = q.Encode() + + x.ResponseWriter().Header().Set("Location", u.String()) + x.ResponseWriter().WriteHeader(http.StatusFound) + return nil +} diff --git a/internal/api/oauth/oidc.go b/internal/api/oauth/oidc.go new file mode 100644 index 0000000..c46f100 --- /dev/null +++ b/internal/api/oauth/oidc.go @@ -0,0 +1,224 @@ +package oauth + +import ( + "github.com/veypi/vbase/internal/model" + "github.com/veypi/vigo" +) + +// UserInfoResponse 用户信息响应 (OIDC标准格式) +type UserInfoResponse struct { + Sub string `json:"sub"` // 用户唯一标识 + Name string `json:"name,omitempty"` // 全名 + Nickname string `json:"nickname,omitempty"` // 昵称 + PreferredUsername string `json:"preferred_username,omitempty"` + Profile string `json:"profile,omitempty"` + Picture string `json:"picture,omitempty"` // 头像 + Website string `json:"website,omitempty"` + Email string `json:"email,omitempty"` + EmailVerified bool `json:"email_verified,omitempty"` + Phone string `json:"phone,omitempty"` + PhoneVerified bool `json:"phone_verified,omitempty"` + Gender string `json:"gender,omitempty"` + Birthdate string `json:"birthdate,omitempty"` + Zoneinfo string `json:"zoneinfo,omitempty"` + Locale string `json:"locale,omitempty"` + UpdatedAt int64 `json:"updated_at,omitempty"` + Orgs []OrgClaim `json:"orgs,omitempty"` // 扩展字段:用户所属组织 +} + +// OrgClaim 组织声明 +type OrgClaim struct { + OrgID string `json:"org_id"` + Name string `json:"name"` + Code string `json:"code"` + Roles []string `json:"roles"` + Status int `json:"status"` +} + +// UserInfo 用户信息端点 (OIDC) +// GET /oauth/userinfo +func UserInfo(x *vigo.X) (*UserInfoResponse, error) { + // 从context获取当前用户 + userID, ok := x.Get("oauth_user_id").(string) + if !ok || userID == "" { + return nil, vigo.ErrNotAuthorized.WithString("invalid_token") + } + + // 获取scope决定返回哪些字段 + scope, _ := x.Get("oauth_scope").(string) + scopes := parseScopes(scope) + + var user model.User + if err := model.DB.First(&user, "id = ?", userID).Error; err != nil { + return nil, vigo.ErrNotFound.WithString("user not found") + } + + resp := &UserInfoResponse{ + Sub: user.ID, + } + + // 根据scope返回相应字段 + if contains(scopes, "profile") { + resp.Name = user.Nickname + resp.Nickname = user.Nickname + resp.PreferredUsername = user.Username + resp.Picture = user.Avatar + resp.UpdatedAt = user.UpdatedAt.Unix() + } + + if contains(scopes, "email") { + resp.Email = user.Email + resp.EmailVerified = user.EmailVerified + } + + if contains(scopes, "phone") { + resp.Phone = user.Phone + resp.PhoneVerified = user.PhoneVerified + } + + // 如果请求了org scope,返回组织信息 + if contains(scopes, "org") { + resp.Orgs = getUserOrgClaims(userID) + } + + return resp, nil +} + +// DiscoveryResponse OIDC发现文档响应 +type DiscoveryResponse struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserinfoEndpoint string `json:"userinfo_endpoint"` + RevocationEndpoint string `json:"revocation_endpoint"` + IntrospectionEndpoint string `json:"introspection_endpoint"` + JWKSUri string `json:"jwks_uri"` + ScopesSupported []string `json:"scopes_supported"` + ResponseTypesSupported []string `json:"response_types_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + SubjectTypesSupported []string `json:"subject_types_supported"` + IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` + ClaimsSupported []string `json:"claims_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` +} + +// Discovery OIDC发现文档端点 +// GET /.well-known/openid-configuration +func Discovery(x *vigo.X) (*DiscoveryResponse, error) { + baseURL := "https://" + x.Request.Host // 生产环境应该使用配置 + + return &DiscoveryResponse{ + Issuer: baseURL, + AuthorizationEndpoint: baseURL + "/oauth/authorize", + TokenEndpoint: baseURL + "/oauth/token", + UserinfoEndpoint: baseURL + "/oauth/userinfo", + RevocationEndpoint: baseURL + "/oauth/revoke", + IntrospectionEndpoint: baseURL + "/oauth/introspect", + JWKSUri: baseURL + "/oauth/jwks", + ScopesSupported: []string{ + "openid", + "profile", + "email", + "phone", + "org", + "roles", + "offline_access", + }, + ResponseTypesSupported: []string{"code", "token", "id_token"}, + GrantTypesSupported: []string{ + "authorization_code", + "refresh_token", + "client_credentials", + }, + SubjectTypesSupported: []string{"public"}, + IDTokenSigningAlgValuesSupported: []string{"RS256"}, + TokenEndpointAuthMethodsSupported: []string{ + "client_secret_basic", + "client_secret_post", + }, + ClaimsSupported: []string{ + "sub", + "name", + "nickname", + "preferred_username", + "picture", + "email", + "email_verified", + "phone", + "phone_verified", + "updated_at", + "orgs", + }, + CodeChallengeMethodsSupported: []string{"S256", "plain"}, + }, nil +} + +// JWKSResponse JWKS响应 +type JWKSResponse struct { + Keys []JWK `json:"keys"` +} + +// JWK JSON Web Key +type JWK struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + Use string `json:"use"` + N string `json:"n"` + E string `json:"e"` + Alg string `json:"alg"` +} + +// JWKS 公钥端点 +// GET /oauth/jwks +func JWKS(x *vigo.X) (*JWKSResponse, error) { + // 返回JWT签名公钥 + // 实际实现需要从配置的私钥中提取公钥信息 + // 这里简化返回 + return &JWKSResponse{ + Keys: []JWK{ + { + Kty: "RSA", + Kid: "default", + Use: "sig", + Alg: "RS256", + // N和E应该从实际公钥计算得出 + }, + }, + }, nil +} + +// helper functions +func getUserOrgClaims(userID string) []OrgClaim { + var members []model.OrgMember + if err := model.DB.Where("user_id = ? AND status = ?", userID, model.MemberStatusActive).Find(&members).Error; err != nil { + return nil + } + + if len(members) == 0 { + return []OrgClaim{} + } + + result := make([]OrgClaim, 0, len(members)) + for _, m := range members { + var org model.Org + if err := model.DB.First(&org, "id = ?", m.OrgID).Error; err != nil { + continue + } + + roles := []string{} + if m.RoleIDs != "" { + roles = parseScopes(m.RoleIDs) // 复用parseScopes来split + } + + result = append(result, OrgClaim{ + OrgID: m.OrgID, + Name: org.Name, + Code: org.Code, + Roles: roles, + Status: m.Status, + }) + } + + return result +} diff --git a/internal/api/org/handler.go b/internal/api/org/handler.go new file mode 100644 index 0000000..e3beba9 --- /dev/null +++ b/internal/api/org/handler.go @@ -0,0 +1,511 @@ +package org + +import ( + "fmt" + "strings" + "time" + + "github.com/veypi/vbase/internal/api/middleware" + "github.com/veypi/vbase/internal/model" + "github.com/veypi/vigo" + "gorm.io/gorm" +) + +// ListRequest 组织列表请求 +type ListRequest struct { + Page int `json:"page" src:"query" default:"1" desc:"页码"` + PageSize int `json:"page_size" src:"query" default:"10" desc:"每页数量"` + Keyword string `json:"keyword" src:"query" desc:"搜索关键词"` +} + +// ListResponse 组织列表响应 +type ListResponse struct { + Items []OrgInfo `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalPages int `json:"total_pages"` +} + +// OrgInfo 组织信息 +type OrgInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + OwnerID string `json:"owner_id"` + ParentID string `json:"parent_id,omitempty"` + Path string `json:"path"` + Level int `json:"level"` + LeaderID string `json:"leader_id,omitempty"` + Description string `json:"description"` + Logo string `json:"logo"` + Status int `json:"status"` + MaxMembers int `json:"max_members"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + // 当前用户在该组织的信息 + MyRoles []string `json:"my_roles,omitempty"` + MyStatus int `json:"my_status,omitempty"` +} + +// List 获取当前用户的组织列表 +func List(x *vigo.X, req *ListRequest) (*ListResponse, error) { + userID := middleware.CurrentUser(x) + if userID == "" { + return nil, vigo.ErrNotAuthorized + } + + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 || req.PageSize > 100 { + req.PageSize = 10 + } + + // 查询用户所属的组织ID + var memberOrgs []model.OrgMember + if err := model.DB.Where("user_id = ? AND status = ?", userID, model.MemberStatusActive).Find(&memberOrgs).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + if len(memberOrgs) == 0 { + return &ListResponse{ + Items: []OrgInfo{}, + Total: 0, + Page: req.Page, + PageSize: req.PageSize, + TotalPages: 0, + }, nil + } + + orgIDs := make([]string, 0, len(memberOrgs)) + memberMap := make(map[string]*model.OrgMember) + for _, m := range memberOrgs { + orgIDs = append(orgIDs, m.OrgID) + memberMap[m.OrgID] = &m + } + + // 查询组织详情 + var total int64 + query := model.DB.Model(&model.Org{}).Where("id IN ?", orgIDs) + if req.Keyword != "" { + query = query.Where("name LIKE ? OR code LIKE ?", "%"+req.Keyword+"%", "%"+req.Keyword+"%") + } + + if err := query.Count(&total).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + var orgs []model.Org + offset := (req.Page - 1) * req.PageSize + if err := query.Offset(offset).Limit(req.PageSize).Order("created_at DESC").Find(&orgs).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + items := make([]OrgInfo, 0, len(orgs)) + for _, o := range orgs { + info := toOrgInfo(&o) + if m, ok := memberMap[o.ID]; ok { + info.MyRoles = parseRoles(m.RoleIDs) + info.MyStatus = m.Status + } + items = append(items, info) + } + + totalPages := int((total + int64(req.PageSize) - 1) / int64(req.PageSize)) + + return &ListResponse{ + Items: items, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + TotalPages: totalPages, + }, nil +} + +// CreateRequest 创建组织请求 +type CreateRequest struct { + Name string `json:"name" src:"json" desc:"组织名称"` + Code string `json:"code" src:"json" desc:"组织编码"` + ParentID *string `json:"parent_id" src:"json" desc:"父组织ID"` + Description *string `json:"description" src:"json" desc:"描述"` + Logo *string `json:"logo" src:"json" desc:"Logo"` + MaxMembers *int `json:"max_members" src:"json" desc:"最大成员数"` +} + +// Create 创建组织 +func Create(x *vigo.X, req *CreateRequest) (*OrgInfo, error) { + userID := middleware.CurrentUser(x) + if userID == "" { + return nil, vigo.ErrNotAuthorized + } + + // 检查编码是否已存在 + var count int64 + model.DB.Model(&model.Org{}).Where("code = ?", req.Code).Count(&count) + if count > 0 { + return nil, vigo.ErrArgInvalid.WithString("organization code already exists") + } + + // 构建组织路径 + path := "/" + req.Code + level := 0 + if req.ParentID != nil && *req.ParentID != "" { + var parent model.Org + if err := model.DB.First(&parent, "id = ?", *req.ParentID).Error; err != nil { + return nil, vigo.ErrArgInvalid.WithString("parent organization not found") + } + path = parent.Path + "/" + req.Code + level = parent.Level + 1 + } + + org := &model.Org{ + Name: req.Name, + Code: req.Code, + OwnerID: userID, + Path: path, + Level: level, + Status: model.OrgStatusActive, + MaxMembers: 100, + } + + if req.ParentID != nil { + org.ParentID = req.ParentID + } + if req.Description != nil { + org.Description = *req.Description + } + if req.Logo != nil { + org.Logo = *req.Logo + } + if req.MaxMembers != nil { + org.MaxMembers = *req.MaxMembers + } + + err := model.DB.Transaction(func(tx *gorm.DB) error { + // 创建组织 + if err := tx.Create(org).Error; err != nil { + return err + } + + // 创建成员关系(所有者) + member := &model.OrgMember{ + OrgID: org.ID, + UserID: userID, + Status: model.MemberStatusActive, + JoinedAt: time.Now().Format("2006-01-02 15:04:05"), + } + return tx.Create(member).Error + }) + + if err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + info := toOrgInfo(org) + return &info, nil +} + +// GetRequest 获取组织请求 +type GetRequest struct { + ID string `json:"id" src:"path@org_id" desc:"组织ID"` +} + +// Get 获取组织详情 +func Get(x *vigo.X, req *GetRequest) (*OrgInfo, error) { + userID := middleware.CurrentUser(x) + if userID == "" { + return nil, vigo.ErrNotAuthorized + } + + var org model.Org + if err := model.DB.First(&org, "id = ?", req.ID).Error; err != nil { + return nil, vigo.ErrNotFound + } + + // 检查用户是否是组织成员 + var member model.OrgMember + if err := model.DB.Where("org_id = ? AND user_id = ?", req.ID, userID).First(&member).Error; err != nil { + return nil, vigo.ErrForbidden.WithString("you are not a member of this organization") + } + + info := toOrgInfo(&org) + info.MyRoles = parseRoles(member.RoleIDs) + info.MyStatus = member.Status + return &info, nil +} + +// UpdateRequest 更新组织请求 +type UpdateRequest struct { + ID string `json:"id" src:"path@org_id" desc:"组织ID"` + Name *string `json:"name" src:"json" desc:"组织名称"` + Description *string `json:"description" src:"json" desc:"描述"` + Logo *string `json:"logo" src:"json" desc:"Logo"` + LeaderID *string `json:"leader_id" src:"json" desc:"负责人ID"` + MaxMembers *int `json:"max_members" src:"json" desc:"最大成员数"` + Status *int `json:"status" src:"json" desc:"状态"` +} + +// Update 更新组织 +func Update(x *vigo.X, req *UpdateRequest) (*OrgInfo, error) { + userID := middleware.CurrentUser(x) + if userID == "" { + return nil, vigo.ErrNotAuthorized + } + + var org model.Org + if err := model.DB.First(&org, "id = ?", req.ID).Error; err != nil { + return nil, vigo.ErrNotFound + } + + // 检查权限(只有所有者可以修改) + if org.OwnerID != userID { + return nil, vigo.ErrForbidden.WithString("only organization owner can update") + } + + updates := make(map[string]interface{}) + if req.Name != nil { + updates["name"] = *req.Name + } + if req.Description != nil { + updates["description"] = *req.Description + } + if req.Logo != nil { + updates["logo"] = *req.Logo + } + if req.LeaderID != nil { + updates["leader_id"] = *req.LeaderID + } + if req.MaxMembers != nil { + updates["max_members"] = *req.MaxMembers + } + if req.Status != nil { + updates["status"] = *req.Status + } + + if len(updates) > 0 { + if err := model.DB.Model(&org).Updates(updates).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + } + + info := toOrgInfo(&org) + return &info, nil +} + +// DeleteRequest 删除组织请求 +type DeleteRequest struct { + ID string `json:"id" src:"path@org_id" desc:"组织ID"` +} + +// Delete 删除组织 +func Delete(x *vigo.X, req *DeleteRequest) error { + userID := middleware.CurrentUser(x) + if userID == "" { + return vigo.ErrNotAuthorized + } + + var org model.Org + if err := model.DB.First(&org, "id = ?", req.ID).Error; err != nil { + return vigo.ErrNotFound + } + + // 检查权限(只有所有者可以删除) + if org.OwnerID != userID { + return vigo.ErrForbidden.WithString("only organization owner can delete") + } + + // 检查是否有子组织 + var childCount int64 + model.DB.Model(&model.Org{}).Where("parent_id = ?", req.ID).Count(&childCount) + if childCount > 0 { + return vigo.ErrArgInvalid.WithString("cannot delete organization with sub-organizations") + } + + // 软删除 + if err := model.DB.Delete(&org).Error; err != nil { + return vigo.ErrInternalServer.WithError(err) + } + + return nil +} + +// TreeResponse 组织树响应 +type TreeResponse struct { + OrgInfo + Children []TreeResponse `json:"children,omitempty"` +} + +// Tree 获取组织树 +func Tree(x *vigo.X) ([]TreeResponse, error) { + userID := middleware.CurrentUser(x) + if userID == "" { + return nil, vigo.ErrNotAuthorized + } + + // 获取用户所属的所有组织ID + var memberOrgs []model.OrgMember + if err := model.DB.Where("user_id = ? AND status = ?", userID, model.MemberStatusActive).Find(&memberOrgs).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + if len(memberOrgs) == 0 { + return []TreeResponse{}, nil + } + + orgIDs := make([]string, 0, len(memberOrgs)) + for _, m := range memberOrgs { + orgIDs = append(orgIDs, m.OrgID) + } + + // 获取所有组织 + var orgs []model.Org + if err := model.DB.Where("id IN ?", orgIDs).Order("path").Find(&orgs).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + // 构建树 + return buildOrgTree(orgs, ""), nil +} + +func buildOrgTree(orgs []model.Org, parentID string) []TreeResponse { + var result []TreeResponse + for _, o := range orgs { + if (parentID == "" && (o.ParentID == nil || *o.ParentID == "")) || + (o.ParentID != nil && *o.ParentID == parentID) { + node := TreeResponse{ + OrgInfo: toOrgInfo(&o), + } + node.Children = buildOrgTree(orgs, o.ID) + result = append(result, node) + } + } + return result +} + +// helper functions + +func toOrgInfo(o *model.Org) OrgInfo { + info := OrgInfo{ + ID: o.ID, + Name: o.Name, + Code: o.Code, + OwnerID: o.OwnerID, + Path: o.Path, + Level: o.Level, + Description: o.Description, + Logo: o.Logo, + Status: o.Status, + MaxMembers: o.MaxMembers, + CreatedAt: o.CreatedAt.Format("2006-01-02 15:04:05"), + UpdatedAt: o.UpdatedAt.Format("2006-01-02 15:04:05"), + } + if o.ParentID != nil { + info.ParentID = *o.ParentID + } + if o.LeaderID != nil { + info.LeaderID = *o.LeaderID + } + return info +} + +func parseRoles(roleIDs string) []string { + if roleIDs == "" { + return []string{} + } + return strings.Split(roleIDs, ",") +} + +func formatRoleID(roleIDs []string) string { + return strings.Join(roleIDs, ",") +} + +// ListMembersRequest 成员列表请求 +type ListMembersRequest struct { + OrgID string `json:"org_id" src:"path@org_id" desc:"组织ID"` + Page int `json:"page" src:"query" default:"1" desc:"页码"` + PageSize int `json:"page_size" src:"query" default:"10" desc:"每页数量"` + Status *int `json:"status" src:"query" desc:"状态筛选"` +} + +// MemberInfo 成员信息 +type MemberInfo struct { + ID string `json:"id"` + OrgID string `json:"org_id"` + UserID string `json:"user_id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Email string `json:"email"` + Roles []string `json:"roles"` + Position string `json:"position"` + Department string `json:"department"` + JoinedAt string `json:"joined_at"` + Status int `json:"status"` +} + +// ListMembers 获取组织成员列表 +func ListMembers(x *vigo.X, req *ListMembersRequest) (*ListResponse, error) { + userID := middleware.CurrentUser(x) + if userID == "" { + return nil, vigo.ErrNotAuthorized + } + + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 || req.PageSize > 100 { + req.PageSize = 10 + } + + // 检查用户是否是组织成员 + var currentMember model.OrgMember + if err := model.DB.Where("org_id = ? AND user_id = ?", req.OrgID, userID).First(¤tMember).Error; err != nil { + return nil, vigo.ErrForbidden.WithString("you are not a member of this organization") + } + + var total int64 + query := model.DB.Model(&model.OrgMember{}).Where("org_id = ?", req.OrgID) + if req.Status != nil { + query = query.Where("status = ?", *req.Status) + } + + if err := query.Count(&total).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + var members []model.OrgMember + offset := (req.Page - 1) * req.PageSize + if err := query.Offset(offset).Limit(req.PageSize).Find(&members).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + items := make([]OrgInfo, 0) // 这里需要修改返回类型 + _ = items + + // 获取用户信息 + memberInfos := make([]MemberInfo, 0, len(members)) + for _, m := range members { + var user model.User + if err := model.DB.First(&user, "id = ?", m.UserID).Error; err != nil { + continue + } + memberInfos = append(memberInfos, MemberInfo{ + ID: m.ID, + OrgID: m.OrgID, + UserID: m.UserID, + Username: user.Username, + Nickname: user.Nickname, + Avatar: user.Avatar, + Email: user.Email, + Roles: parseRoles(m.RoleIDs), + Position: m.Position, + Department: m.Department, + JoinedAt: m.JoinedAt, + Status: m.Status, + }) + } + + _ = memberInfos + return nil, fmt.Errorf("not fully implemented") +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..b00e9e8 --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,83 @@ +package api + +import ( + "github.com/veypi/vbase/internal/api/auth" + "github.com/veypi/vbase/internal/api/middleware" + "github.com/veypi/vbase/internal/api/oauth" + "github.com/veypi/vbase/internal/api/org" + "github.com/veypi/vbase/internal/api/user" + "github.com/veypi/vigo" + "github.com/veypi/vigo/contrib/common" +) + +// NewRouter 创建API路由 +func NewRouter() vigo.Router { + r := vigo.NewRouter() + + // 全局中间件 + r.Use(middleware.AuthRequired()) + r.Use(middleware.OrgContext()) + r.After(common.JsonResponse, common.JsonErrorResponse) + + // === 公开路由 === + authRouter := vigo.NewRouter() + authRouter.Use(vigo.SkipBefore) + authRouter.Post("/login", "用户登录", auth.Login) + authRouter.Post("/register", "用户注册", auth.Register) + authRouter.Post("/refresh", "刷新Token", auth.Refresh) + authRouter.Post("/logout", "用户登出", auth.Logout) + r.Extend("/auth", authRouter) + + // === 当前用户 === + meRouter := vigo.NewRouter() + meRouter.Get("/", "获取当前用户信息", auth.Me) + meRouter.Patch("/", "更新当前用户信息", auth.UpdateMe) + meRouter.Post("/change-password", "修改密码", auth.ChangePassword) + r.Extend("/me", meRouter) + + // === 用户管理 === + userRouter := vigo.NewRouter() + userRouter.Get("/", "用户列表", user.List) + userRouter.Post("/", "创建用户", user.Create) + userRouter.Get("/{user_id}", "获取用户详情", user.Get) + userRouter.Patch("/{user_id}", "更新用户", user.Update) + userRouter.Delete("/{user_id}", "删除用户", user.Delete) + userRouter.Patch("/{user_id}/status", "更新用户状态", user.UpdateStatus) + r.Extend("/users", userRouter) + + // === 组织管理 === + orgRouter := vigo.NewRouter() + orgRouter.Get("/", "组织列表", org.List) + orgRouter.Post("/", "创建组织", org.Create) + orgRouter.Get("/{org_id}", "获取组织详情", org.Get) + orgRouter.Patch("/{org_id}", "更新组织", org.Update) + orgRouter.Delete("/{org_id}", "删除组织", org.Delete) + orgRouter.Get("/tree", "组织树", org.Tree) + orgRouter.Get("/{org_id}/members", "组织成员列表", org.ListMembers) + r.Extend("/orgs", orgRouter) + + // === OAuth2.0服务端 === + oauthRouter := vigo.NewRouter() + // OAuth公开端点 + oauthRouter.Use(vigo.SkipBefore) + oauthRouter.Get("/authorize", "授权端点", oauth.Authorize) + oauthRouter.Post("/token", "令牌端点", oauth.Token) + oauthRouter.Post("/revoke", "撤销令牌", oauth.Revoke) + oauthRouter.Post("/introspect", "令牌内省", oauth.Introspect) + oauthRouter.Get("/userinfo", "用户信息(OIDC)", oauth.UserInfo) + oauthRouter.Get("/.well-known/openid-configuration", "OIDC发现文档", oauth.Discovery) + oauthRouter.Get("/jwks", "JWKS公钥", oauth.JWKS) + r.Extend("/oauth", oauthRouter) + + // OAuth客户端管理 + oauthClientRouter := vigo.NewRouter() + oauthClientRouter.Get("/", "OAuth客户端列表", oauth.ListClients) + oauthClientRouter.Post("/", "创建OAuth客户端", oauth.CreateClient) + oauthClientRouter.Get("/{client_id}", "获取客户端详情", oauth.GetClient) + oauthClientRouter.Patch("/{client_id}", "更新OAuth客户端", oauth.UpdateClient) + oauthClientRouter.Delete("/{client_id}", "删除OAuth客户端", oauth.DeleteClient) + oauthClientRouter.Post("/{client_id}/regenerate-secret", "重新生成密钥", oauth.RegenerateSecret) + r.Extend("/oauth/clients", oauthClientRouter) + + return r +} diff --git a/internal/api/user/handler.go b/internal/api/user/handler.go new file mode 100644 index 0000000..9118e6a --- /dev/null +++ b/internal/api/user/handler.go @@ -0,0 +1,278 @@ +package user + +import ( + "github.com/veypi/vbase/internal/model" + "github.com/veypi/vbase/internal/pkg/crypto" + "github.com/veypi/vigo" +) + +// ListRequest 用户列表请求 +type ListRequest struct { + Page int `json:"page" src:"query" default:"1" desc:"页码"` + PageSize int `json:"page_size" src:"query" default:"10" desc:"每页数量"` + Keyword string `json:"keyword" src:"query" desc:"搜索关键词"` + Status *int `json:"status" src:"query" desc:"状态筛选"` +} + +// ListResponse 用户列表响应 +type ListResponse struct { + Items []UserInfo `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalPages int `json:"total_pages"` +} + +// UserInfo 用户信息 +type UserInfo struct { + ID string `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Email string `json:"email"` + Phone string `json:"phone"` + Status int `json:"status"` + EmailVerified bool `json:"email_verified"` + PhoneVerified bool `json:"phone_verified"` + LastLoginAt string `json:"last_login_at,omitempty"` + CreatedAt string `json:"created_at"` +} + +// List 用户列表 +func List(x *vigo.X, req *ListRequest) (*ListResponse, error) { + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 || req.PageSize > 100 { + req.PageSize = 10 + } + + var total int64 + query := model.DB.Model(&model.User{}) + + if req.Keyword != "" { + query = query.Where("username LIKE ? OR nickname LIKE ? OR email LIKE ?", "%"+req.Keyword+"%", "%"+req.Keyword+"%", "%"+req.Keyword+"%") + } + if req.Status != nil { + query = query.Where("status = ?", *req.Status) + } + + if err := query.Count(&total).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + var users []model.User + offset := (req.Page - 1) * req.PageSize + if err := query.Offset(offset).Limit(req.PageSize).Order("created_at DESC").Find(&users).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + items := make([]UserInfo, 0, len(users)) + for _, u := range users { + items = append(items, toUserInfo(&u)) + } + + totalPages := int((total + int64(req.PageSize) - 1) / int64(req.PageSize)) + + return &ListResponse{ + Items: items, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + TotalPages: totalPages, + }, nil +} + +// GetRequest 获取用户请求 +type GetRequest struct { + ID string `json:"id" src:"path@user_id" desc:"用户ID"` +} + +// Get 获取用户详情 +func Get(x *vigo.X, req *GetRequest) (*UserInfo, error) { + var user model.User + if err := model.DB.First(&user, "id = ?", req.ID).Error; err != nil { + return nil, vigo.ErrNotFound + } + + info := toUserInfo(&user) + return &info, nil +} + +// CreateRequest 创建用户请求 +type CreateRequest struct { + Username string `json:"username" src:"json" desc:"用户名"` + Password string `json:"password" src:"json" desc:"密码"` + Nickname *string `json:"nickname" src:"json" desc:"昵称"` + Email *string `json:"email" src:"json" desc:"邮箱"` + Phone *string `json:"phone" src:"json" desc:"手机号"` + Status *int `json:"status" src:"json" desc:"状态"` +} + +// Create 创建用户 +func Create(x *vigo.X, req *CreateRequest) (*UserInfo, error) { + // 检查用户名是否已存在 + var count int64 + model.DB.Model(&model.User{}).Where("username = ?", req.Username).Count(&count) + if count > 0 { + return nil, vigo.ErrArgInvalid.WithString("username already exists") + } + + // 检查邮箱是否已存在 + if req.Email != nil && *req.Email != "" { + model.DB.Model(&model.User{}).Where("email = ?", *req.Email).Count(&count) + if count > 0 { + return nil, vigo.ErrArgInvalid.WithString("email already exists") + } + } + + // 哈希密码 + hashedPassword, err := crypto.HashPassword(req.Password, 12) + if err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + // 创建用户 + user := &model.User{ + Username: req.Username, + Password: hashedPassword, + Status: model.UserStatusActive, + } + + if req.Nickname != nil { + user.Nickname = *req.Nickname + } else { + user.Nickname = req.Username + } + if req.Email != nil { + user.Email = *req.Email + } + if req.Phone != nil { + user.Phone = *req.Phone + } + if req.Status != nil { + user.Status = *req.Status + } + + if err := model.DB.Create(user).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + info := toUserInfo(user) + return &info, nil +} + +// UpdateRequest 更新用户请求 +type UpdateRequest struct { + ID string `json:"id" src:"path@user_id" desc:"用户ID"` + Nickname *string `json:"nickname" src:"json" desc:"昵称"` + Avatar *string `json:"avatar" src:"json" desc:"头像"` + Email *string `json:"email" src:"json" desc:"邮箱"` + Phone *string `json:"phone" src:"json" desc:"手机号"` + Status *int `json:"status" src:"json" desc:"状态"` +} + +// Update 更新用户 +func Update(x *vigo.X, req *UpdateRequest) (*UserInfo, error) { + var user model.User + if err := model.DB.First(&user, "id = ?", req.ID).Error; err != nil { + return nil, vigo.ErrNotFound + } + + updates := make(map[string]interface{}) + if req.Nickname != nil { + updates["nickname"] = *req.Nickname + } + if req.Avatar != nil { + updates["avatar"] = *req.Avatar + } + if req.Email != nil && *req.Email != user.Email { + // 检查邮箱是否被其他用户使用 + var count int64 + model.DB.Model(&model.User{}).Where("email = ? AND id != ?", *req.Email, req.ID).Count(&count) + if count > 0 { + return nil, vigo.ErrArgInvalid.WithString("email already exists") + } + updates["email"] = *req.Email + } + if req.Phone != nil && *req.Phone != user.Phone { + var count int64 + model.DB.Model(&model.User{}).Where("phone = ? AND id != ?", *req.Phone, req.ID).Count(&count) + if count > 0 { + return nil, vigo.ErrArgInvalid.WithString("phone already exists") + } + updates["phone"] = *req.Phone + } + if req.Status != nil { + updates["status"] = *req.Status + } + + if len(updates) > 0 { + if err := model.DB.Model(&user).Updates(updates).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + } + + info := toUserInfo(&user) + return &info, nil +} + +// DeleteRequest 删除用户请求 +type DeleteRequest struct { + ID string `json:"id" src:"path@user_id" desc:"用户ID"` +} + +// Delete 删除用户(软删除) +func Delete(x *vigo.X, req *DeleteRequest) error { + var user model.User + if err := model.DB.First(&user, "id = ?", req.ID).Error; err != nil { + return vigo.ErrNotFound + } + + if err := model.DB.Delete(&user).Error; err != nil { + return vigo.ErrInternalServer.WithError(err) + } + + return nil +} + +// UpdateStatusRequest 更新用户状态请求 +type UpdateStatusRequest struct { + ID string `json:"id" src:"path@user_id" desc:"用户ID"` + Status int `json:"status" src:"json" desc:"状态: 0禁用 1正常 2未激活"` +} + +// UpdateStatus 更新用户状态 +func UpdateStatus(x *vigo.X, req *UpdateStatusRequest) (*UserInfo, error) { + var user model.User + if err := model.DB.First(&user, "id = ?", req.ID).Error; err != nil { + return nil, vigo.ErrNotFound + } + + if err := model.DB.Model(&user).Update("status", req.Status).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + info := toUserInfo(&user) + return &info, nil +} + +// helper function +func toUserInfo(u *model.User) UserInfo { + info := UserInfo{ + ID: u.ID, + Username: u.Username, + Nickname: u.Nickname, + Avatar: u.Avatar, + Email: u.Email, + Phone: u.Phone, + Status: u.Status, + EmailVerified: u.EmailVerified, + PhoneVerified: u.PhoneVerified, + CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"), + } + if u.LastLoginAt != nil { + info.LastLoginAt = u.LastLoginAt.Format("2006-01-02 15:04:05") + } + return info +} diff --git a/internal/cache/redis.go b/internal/cache/redis.go new file mode 100644 index 0000000..3822909 --- /dev/null +++ b/internal/cache/redis.go @@ -0,0 +1,374 @@ +package cache + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + "github.com/veypi/vbase/internal/config" +) + +var ( + Client *redis.Client + Ctx = context.Background() +) + +// Init 初始化Redis连接 +func Init() error { + if !config.C.Redis.Enabled { + return nil + } + + Client = redis.NewClient(&redis.Options{ + Addr: config.C.Redis.Addr, + Password: config.C.Redis.Password, + DB: config.C.Redis.DB, + }) + + if err := Client.Ping(Ctx).Err(); err != nil { + return fmt.Errorf("failed to connect redis: %w", err) + } + + return nil +} + +// IsEnabled 是否启用缓存 +func IsEnabled() bool { + return config.C.Redis.Enabled && Client != nil +} + +// Get 获取字符串值 +func Get(key string) (string, error) { + if !IsEnabled() { + return "", fmt.Errorf("redis not enabled") + } + return Client.Get(Ctx, key).Result() +} + +// GetObject 获取并反序列化对象 +func GetObject(key string, dest interface{}) error { + if !IsEnabled() { + return fmt.Errorf("redis not enabled") + } + data, err := Client.Get(Ctx, key).Bytes() + if err != nil { + return err + } + return json.Unmarshal(data, dest) +} + +// Set 设置字符串值 +func Set(key string, value string, expiration time.Duration) error { + if !IsEnabled() { + return nil + } + return Client.Set(Ctx, key, value, expiration).Err() +} + +// SetObject 序列化并设置对象 +func SetObject(key string, value interface{}, expiration time.Duration) error { + if !IsEnabled() { + return nil + } + data, err := json.Marshal(value) + if err != nil { + return err + } + return Client.Set(Ctx, key, data, expiration).Err() +} + +// Delete 删除key +func Delete(keys ...string) error { + if !IsEnabled() { + return nil + } + return Client.Del(Ctx, keys...).Err() +} + +// Exists 检查key是否存在 +func Exists(keys ...string) (int64, error) { + if !IsEnabled() { + return 0, nil + } + return Client.Exists(Ctx, keys...).Result() +} + +// Expire 设置过期时间 +func Expire(key string, expiration time.Duration) error { + if !IsEnabled() { + return nil + } + return Client.Expire(Ctx, key, expiration).Err() +} + +// TTL 获取剩余过期时间 +func TTL(key string) (time.Duration, error) { + if !IsEnabled() { + return 0, nil + } + return Client.TTL(Ctx, key).Result() +} + +// Incr 自增 +func Incr(key string) (int64, error) { + if !IsEnabled() { + return 0, fmt.Errorf("redis not enabled") + } + return Client.Incr(Ctx, key).Result() +} + +// IncrBy 增加指定值 +func IncrBy(key string, value int64) (int64, error) { + if !IsEnabled() { + return 0, fmt.Errorf("redis not enabled") + } + return Client.IncrBy(Ctx, key, value).Result() +} + +// Decr 自减 +func Decr(key string) (int64, error) { + if !IsEnabled() { + return 0, fmt.Errorf("redis not enabled") + } + return Client.Decr(Ctx, key).Result() +} + +// HSet 设置hash字段 +func HSet(key string, values ...interface{}) error { + if !IsEnabled() { + return nil + } + return Client.HSet(Ctx, key, values...).Err() +} + +// HGet 获取hash字段 +func HGet(key, field string) (string, error) { + if !IsEnabled() { + return "", fmt.Errorf("redis not enabled") + } + return Client.HGet(Ctx, key, field).Result() +} + +// HGetAll 获取hash所有字段 +func HGetAll(key string) (map[string]string, error) { + if !IsEnabled() { + return nil, fmt.Errorf("redis not enabled") + } + return Client.HGetAll(Ctx, key).Result() +} + +// HDel 删除hash字段 +func HDel(key string, fields ...string) error { + if !IsEnabled() { + return nil + } + return Client.HDel(Ctx, key, fields...).Err() +} + +// SetNX 仅当key不存在时才设置(用于分布式锁) +func SetNX(key string, value interface{}, expiration time.Duration) (bool, error) { + if !IsEnabled() { + return false, fmt.Errorf("redis not enabled") + } + return Client.SetNX(Ctx, key, value, expiration).Result() +} + +// ==================== 权限缓存相关 ==================== + +// PermKey 生成权限缓存key +func PermKey(userID, orgID, resource, action string) string { + if orgID == "" { + return fmt.Sprintf("perm:%s:%s:%s", userID, resource, action) + } + return fmt.Sprintf("perm:%s:%s:%s:%s", userID, orgID, resource, action) +} + +// SetPermission 缓存权限结果 +func SetPermission(userID, orgID, resource, action string, allowed bool, expiration time.Duration) error { + key := PermKey(userID, orgID, resource, action) + value := "deny" + if allowed { + value = "allow" + } + return Set(key, value, expiration) +} + +// GetPermission 获取缓存的权限结果 +func GetPermission(userID, orgID, resource, action string) (allowed bool, cached bool, err error) { + key := PermKey(userID, orgID, resource, action) + value, err := Get(key) + if err != nil { + if err == redis.Nil { + return false, false, nil + } + return false, false, err + } + return value == "allow", true, nil +} + +// DeletePermission 删除权限缓存 +func DeletePermission(userID, orgID, resource, action string) error { + key := PermKey(userID, orgID, resource, action) + return Delete(key) +} + +// DeleteUserPermissions 删除用户的所有权限缓存 +func DeleteUserPermissions(userID string) error { + if !IsEnabled() { + return nil + } + pattern := fmt.Sprintf("perm:%s:*", userID) + return deleteByPattern(pattern) +} + +// DeleteOrgPermissions 删除组织的所有权限缓存 +func DeleteOrgPermissions(orgID string) error { + if !IsEnabled() { + return nil + } + pattern := fmt.Sprintf("perm:*:%s:*", orgID) + return deleteByPattern(pattern) +} + +// deleteByPattern 根据pattern删除key +func deleteByPattern(pattern string) error { + iter := Client.Scan(Ctx, 0, pattern, 0).Iterator() + var keys []string + for iter.Next(Ctx) { + keys = append(keys, iter.Val()) + if len(keys) >= 100 { + if err := Delete(keys...); err != nil { + return err + } + keys = keys[:0] + } + } + if err := iter.Err(); err != nil { + return err + } + if len(keys) > 0 { + return Delete(keys...) + } + return nil +} + +// ==================== 用户/组织缓存 ==================== + +// UserKey 用户缓存key +func UserKey(userID string) string { + return fmt.Sprintf("user:%s", userID) +} + +// OrgKey 组织缓存key +func OrgKey(orgID string) string { + return fmt.Sprintf("org:%s", orgID) +} + +// OrgMemberKey 组织成员缓存key +func OrgMemberKey(orgID, userID string) string { + return fmt.Sprintf("org:%s:member:%s", orgID, userID) +} + +// ==================== Token黑名单 ==================== + +// TokenBlacklistKey Token黑名单key +func TokenBlacklistKey(jti string) string { + return fmt.Sprintf("token:revoked:%s", jti) +} + +// BlacklistToken 将Token加入黑名单 +func BlacklistToken(jti string, expiration time.Duration) error { + key := TokenBlacklistKey(jti) + return Set(key, "1", expiration) +} + +// IsTokenBlacklisted 检查Token是否在黑名单中 +func IsTokenBlacklisted(jti string) (bool, error) { + key := TokenBlacklistKey(jti) + _, err := Get(key) + if err != nil { + if err == redis.Nil { + return false, nil + } + return false, err + } + return true, nil +} + +// ==================== 限流缓存 ==================== + +// RateLimitKey 限流key +func RateLimitKey(identifier, path string) string { + return fmt.Sprintf("ratelimit:%s:%s", identifier, path) +} + +// IncrRateLimit 增加限流计数 +func IncrRateLimit(identifier, path string, window time.Duration) (int64, error) { + key := RateLimitKey(identifier, path) + count, err := Incr(key) + if err != nil { + return 0, err + } + // 第一次设置过期时间 + if count == 1 { + Expire(key, window) + } + return count, nil +} + +// GetRateLimit 获取当前限流计数 +func GetRateLimit(identifier, path string) (int64, error) { + key := RateLimitKey(identifier, path) + count, err := Get(key) + if err != nil { + if err == redis.Nil { + return 0, nil + } + return 0, err + } + var result int64 + fmt.Sscanf(count, "%d", &result) + return result, nil +} + +// ==================== 验证码缓存 ==================== + +// CaptchaKey 验证码key +func CaptchaKey(captchaID string) string { + return fmt.Sprintf("captcha:%s", captchaID) +} + +// SetCaptcha 存储验证码 +func SetCaptcha(captchaID, code string, expiration time.Duration) error { + key := CaptchaKey(captchaID) + return Set(key, code, expiration) +} + +// VerifyCaptcha 验证验证码(验证后删除) +func VerifyCaptcha(captchaID, code string) (bool, error) { + key := CaptchaKey(captchaID) + storedCode, err := Get(key) + if err != nil { + if err == redis.Nil { + return false, nil + } + return false, err + } + // 验证后删除 + Delete(key) + return storedCode == code, nil +} + +// ==================== OAuth缓存 ==================== + +// OAuthCodeKey OAuth授权码key +func OAuthCodeKey(code string) string { + return fmt.Sprintf("oauth:code:%s", code) +} + +// OAuthStateKey OAuth state key +func OAuthStateKey(state string) string { + return fmt.Sprintf("oauth:state:%s", state) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..fb3b478 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,167 @@ +package config + +import ( + "os" + "strconv" + "time" +) + +// Config 全局配置 +type Config struct { + // 服务配置 + Server ServerConfig + + // 数据库配置 + Database DatabaseConfig + + // Redis配置 + Redis RedisConfig + + // JWT配置 + JWT JWTConfig + + // 安全配置 + Security SecurityConfig + + // OAuth配置 + OAuth OAuthConfig + + // 应用信息 + App AppConfig +} + +type ServerConfig struct { + Host string + Port int + Mode string // debug/release +} + +type DatabaseConfig struct { + Type string // mysql/postgres/sqlite + DSN string + MaxOpenConns int + MaxIdleConns int + ConnMaxLifetime time.Duration +} + +type RedisConfig struct { + Addr string + Password string + DB int + Enabled bool +} + +type JWTConfig struct { + Secret string + AccessExpiry time.Duration + RefreshExpiry time.Duration + Issuer string +} + +type SecurityConfig struct { + BcryptCost int + MaxLoginAttempts int + CaptchaEnabled bool +} + +type OAuthConfig struct { + AuthorizationExpiry time.Duration + AccessExpiry time.Duration + RefreshExpiry time.Duration +} + +type AppConfig struct { + ID string + Name string + InitAdmin InitAdminConfig +} + +type InitAdminConfig struct { + Username string + Password string + Email string +} + +var C *Config + +func init() { + C = &Config{ + Server: ServerConfig{ + Host: getEnv("SERVER_HOST", "0.0.0.0"), + Port: getEnvInt("SERVER_PORT", 8080), + Mode: getEnv("SERVER_MODE", "debug"), + }, + Database: DatabaseConfig{ + Type: getEnv("DB_TYPE", "mysql"), + DSN: getEnv("DB_DSN", "root:123456@tcp(127.0.0.1:3306)/vbase?charset=utf8mb4&parseTime=True&loc=Local"), + MaxOpenConns: getEnvInt("DB_MAX_OPEN", 100), + MaxIdleConns: getEnvInt("DB_MAX_IDLE", 10), + ConnMaxLifetime: time.Hour, + }, + Redis: RedisConfig{ + Enabled: getEnvBool("REDIS_ENABLED", true), + Addr: getEnv("REDIS_ADDR", "localhost:6379"), + Password: getEnv("REDIS_PASSWORD", ""), + DB: getEnvInt("REDIS_DB", 0), + }, + JWT: JWTConfig{ + Secret: getEnv("JWT_SECRET", "your-secret-key-change-in-production-min-32-characters"), + AccessExpiry: getEnvDuration("JWT_ACCESS_EXPIRY", time.Hour), + RefreshExpiry: getEnvDuration("JWT_REFRESH_EXPIRY", 30*24*time.Hour), + Issuer: getEnv("JWT_ISSUER", "vbase"), + }, + Security: SecurityConfig{ + BcryptCost: getEnvInt("BCRYPT_COST", 12), + MaxLoginAttempts: getEnvInt("MAX_LOGIN_ATTEMPTS", 5), + CaptchaEnabled: getEnvBool("CAPTCHA_ENABLED", true), + }, + OAuth: OAuthConfig{ + AuthorizationExpiry: getEnvDuration("OAUTH_AUTH_EXPIRY", 10*time.Minute), + AccessExpiry: getEnvDuration("OAUTH_ACCESS_EXPIRY", time.Hour), + RefreshExpiry: getEnvDuration("OAUTH_REFRESH_EXPIRY", 30*24*time.Hour), + }, + App: AppConfig{ + ID: getEnv("APP_ID", "vbase"), + Name: getEnv("APP_NAME", "VBase IAM"), + InitAdmin: InitAdminConfig{ + Username: getEnv("INIT_ADMIN_USERNAME", "admin"), + Password: getEnv("INIT_ADMIN_PASSWORD", ""), // 为空时随机生成 + Email: getEnv("INIT_ADMIN_EMAIL", "admin@example.com"), + }, + }, + } +} + +func getEnv(key, defaultVal string) string { + if v := os.Getenv(key); v != "" { + return v + } + return defaultVal +} + +func getEnvInt(key string, defaultVal int) int { + if v := os.Getenv(key); v != "" { + if i, err := strconv.Atoi(v); err == nil { + return i + } + } + return defaultVal +} + +func getEnvBool(key string, defaultVal bool) bool { + if v := os.Getenv(key); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + return defaultVal +} + +func getEnvDuration(key string, defaultVal time.Duration) time.Duration { + if v := os.Getenv(key); v != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + } + return defaultVal +} diff --git a/internal/model/base.go b/internal/model/base.go new file mode 100644 index 0000000..8a4440a --- /dev/null +++ b/internal/model/base.go @@ -0,0 +1,24 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// Base 基础模型 +type Base struct { + ID string `json:"id" gorm:"primaryKey;type:varchar(36)"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +// BeforeCreate 自动生成UUID +func (b *Base) BeforeCreate(tx *gorm.DB) error { + if b.ID == "" { + b.ID = uuid.New().String() + } + return nil +} diff --git a/internal/model/migrate.go b/internal/model/migrate.go new file mode 100644 index 0000000..42be681 --- /dev/null +++ b/internal/model/migrate.go @@ -0,0 +1,268 @@ +package model + +import ( + "fmt" + + "github.com/veypi/vbase/internal/config" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var DB *gorm.DB + +// InitDB 初始化数据库 +func InitDB() error { + cfg := config.C.Database + + var dialector gorm.Dialector + switch cfg.Type { + case "mysql": + dialector = mysql.Open(cfg.DSN) + case "postgres": + dialector = postgres.Open(cfg.DSN) + case "sqlite": + dialector = sqlite.Open(cfg.DSN) + default: + return fmt.Errorf("unsupported database type: %s", cfg.Type) + } + + var err error + DB, err = gorm.Open(dialector, &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + }) + if err != nil { + return fmt.Errorf("failed to connect database: %w", err) + } + + sqlDB, err := DB.DB() + if err != nil { + return err + } + + sqlDB.SetMaxOpenConns(cfg.MaxOpenConns) + sqlDB.SetMaxIdleConns(cfg.MaxIdleConns) + sqlDB.SetConnMaxLifetime(cfg.ConnMaxLifetime) + + return nil +} + +// AutoMigrate 自动迁移表结构 +func AutoMigrate() error { + return DB.AutoMigrate( + &User{}, + &Identity{}, + &Session{}, + &Org{}, + &OrgMember{}, + &Policy{}, + &Role{}, + &OAuthClient{}, + &OAuthAuthorization{}, + &OAuthToken{}, + ) +} + +// InitSystemData 初始化系统数据 +func InitSystemData() error { + return DB.Transaction(func(tx *gorm.DB) error { + // 1. 创建系统策略 + if err := initSystemPolicies(tx); err != nil { + return err + } + + // 2. 检查是否需要创建初始管理员 + var count int64 + if err := tx.Model(&User{}).Count(&count).Error; err != nil { + return err + } + + if count == 0 { + if err := initAdminUser(tx); err != nil { + return err + } + } + + return nil + }) +} + +// initSystemPolicies 创建系统内置策略 +func initSystemPolicies(tx *gorm.DB) error { + policies := []Policy{ + { + Code: SysPolicyUserReadOwn, + Name: "读取自己", + Description: "用户读取自己的信息", + Resource: "user", + Action: "read", + Condition: "resource.id == user.id", + Effect: EffectAllow, + IsSystem: true, + }, + { + Code: SysPolicyUserUpdateOwn, + Name: "更新自己", + Description: "用户更新自己的信息", + Resource: "user", + Action: "update", + Condition: "resource.id == user.id", + Effect: EffectAllow, + IsSystem: true, + }, + { + Code: SysPolicyUserDeleteOwn, + Name: "删除自己", + Description: "用户删除自己的账号", + Resource: "user", + Action: "delete", + Condition: "resource.id == user.id", + Effect: EffectAllow, + IsSystem: true, + }, + { + Code: SysPolicyOrgAdmin, + Name: "组织管理员", + Description: "组织所有者拥有所有权限", + Resource: "*", + Action: "*", + Condition: "org.owner_id == user.id", + Effect: EffectAllow, + Priority: 100, + IsSystem: true, + }, + { + Code: SysPolicyOrgRead, + Name: "读取组织", + Description: "组织成员可读组织信息", + Resource: "org", + Action: "read", + Condition: "member.org_id == org.id", + Effect: EffectAllow, + IsSystem: true, + }, + { + Code: SysPolicyMemberRead, + Name: "读取成员", + Description: "读取组织成员列表", + Resource: "member", + Action: "read", + Condition: "member.org_id == org.id", + Effect: EffectAllow, + IsSystem: true, + }, + { + Code: SysPolicyMemberManage, + Name: "管理成员", + Description: "管理组织成员", + Resource: "member", + Action: "*", + Condition: "", + Effect: EffectAllow, + IsSystem: true, + }, + { + Code: SysPolicyRoleRead, + Name: "读取角色", + Description: "读取组织角色", + Resource: "role", + Action: "read", + Condition: "resource.org_id == org.id", + Effect: EffectAllow, + IsSystem: true, + }, + { + Code: SysPolicyRoleManage, + Name: "管理角色", + Description: "管理组织角色", + Resource: "role", + Action: "*", + Condition: "", + Effect: EffectAllow, + IsSystem: true, + }, + { + Code: SysPolicyPolicyRead, + Name: "读取策略", + Description: "读取策略", + Resource: "policy", + Action: "read", + Condition: "", + Effect: EffectAllow, + IsSystem: true, + }, + { + Code: SysPolicyPolicyManage, + Name: "管理策略", + Description: "管理策略", + Resource: "policy", + Action: "*", + Condition: "", + Effect: EffectAllow, + IsSystem: true, + }, + } + + for _, p := range policies { + var existing Policy + if err := tx.Where("code = ?", p.Code).First(&existing).Error; err != nil { + if err == gorm.ErrRecordNotFound { + if err := tx.Create(&p).Error; err != nil { + return err + } + } else { + return err + } + } + } + + return nil +} + +// initAdminUser 创建初始管理员 +func initAdminUser(tx *gorm.DB) error { + adminCfg := config.C.App.InitAdmin + + // 生成随机密码(如果未配置) + password := adminCfg.Password + if password == "" { + password = generateRandomPassword(16) + fmt.Printf("\n========================================\n") + fmt.Printf("Initial admin user created!\n") + fmt.Printf("Username: %s\n", adminCfg.Username) + fmt.Printf("Password: %s\n", password) + fmt.Printf("Email: %s\n", adminCfg.Email) + fmt.Printf("========================================\n\n") + } + + // 密码哈希(这里需要crypto包,后续实现) + // hashedPassword, _ := crypto.HashPassword(password) + + admin := &User{ + Username: adminCfg.Username, + Password: password, // TODO: hash password + Email: adminCfg.Email, + Nickname: "Administrator", + Status: UserStatusActive, + EmailVerified: true, + PhoneVerified: false, + } + + if err := tx.Create(admin).Error; err != nil { + return err + } + + return nil +} + +// generateRandomPassword 生成随机密码 +func generateRandomPassword(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*" + result := make([]byte, length) + for i := range result { + result[i] = charset[i%len(charset)] + } + return string(result) +} diff --git a/internal/model/oauth.go b/internal/model/oauth.go new file mode 100644 index 0000000..18e21f9 --- /dev/null +++ b/internal/model/oauth.go @@ -0,0 +1,92 @@ +package model + +import ( + "time" +) + +// OAuthClient OAuth客户端 +type OAuthClient struct { + Base + Name string `json:"name" gorm:"size:50;not null"` + Description string `json:"description" gorm:"size:200"` + ClientID string `json:"client_id" gorm:"uniqueIndex;size:32;not null"` + ClientSecret string `json:"-" gorm:"size:64;not null"` + RedirectURIs string `json:"redirect_uris" gorm:"type:text"` // 逗号分隔 + GrantTypes string `json:"grant_types" gorm:"size:100"` // authorization_code/refresh_token/client_credentials + ResponseTypes string `json:"response_types" gorm:"size:50"` // code/token + AllowedScopes string `json:"allowed_scopes" gorm:"size:200"` // openid profile email org roles + TokenExpiry int `json:"token_expiry" gorm:"default:3600"` // access_token有效期(秒) + RefreshExpiry int `json:"refresh_expiry" gorm:"default:2592000"` + OwnerID string `json:"owner_id" gorm:"not null"` + OrgID string `json:"org_id" gorm:"index"` + Status int `json:"status" gorm:"default:1"` +} + +func (OAuthClient) TableName() string { + return "oauth_clients" +} + +// OAuthAuthorization 授权码 +type OAuthAuthorization struct { + Base + UserID string `json:"user_id" gorm:"index;not null"` + ClientID string `json:"client_id" gorm:"index;not null"` + OrgID string `json:"org_id" gorm:"index"` + Code string `json:"code" gorm:"uniqueIndex;size:64"` + Scope string `json:"scope" gorm:"size:200"` + State string `json:"state" gorm:"size:100"` + RedirectURI string `json:"redirect_uri" gorm:"size:500"` + CodeChallenge string `json:"-" gorm:"size:128"` + CodeChallengeMethod string `json:"-" gorm:"size:10"` + Used bool `json:"used" gorm:"default:false"` + UsedAt *time.Time `json:"used_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +func (OAuthAuthorization) TableName() string { + return "oauth_authorizations" +} + +// OAuthToken OAuth访问令牌 +type OAuthToken struct { + Base + UserID string `json:"user_id" gorm:"index;not null"` + ClientID string `json:"client_id" gorm:"index;not null"` + OrgID string `json:"org_id" gorm:"index"` + AccessToken string `json:"-" gorm:"uniqueIndex;size:64"` + RefreshToken string `json:"-" gorm:"uniqueIndex;size:64"` + TokenType string `json:"token_type" gorm:"size:10;default:Bearer"` + Scope string `json:"scope" gorm:"size:200"` + ExpiresAt time.Time `json:"expires_at"` + Revoked bool `json:"revoked" gorm:"default:false"` + RevokedAt *time.Time `json:"revoked_at"` +} + +func (OAuthToken) TableName() string { + return "oauth_tokens" +} + +// Grant Types +const ( + GrantTypeAuthorizationCode = "authorization_code" + GrantTypeRefreshToken = "refresh_token" + GrantTypeClientCredentials = "client_credentials" + GrantTypePassword = "password" +) + +// Response Types +const ( + ResponseTypeCode = "code" + ResponseTypeToken = "token" +) + +// Scopes +const ( + ScopeOpenID = "openid" + ScopeProfile = "profile" + ScopeEmail = "email" + ScopePhone = "phone" + ScopeOrg = "org" + ScopeRoles = "roles" + ScopeOffline = "offline_access" +) diff --git a/internal/model/org.go b/internal/model/org.go new file mode 100644 index 0000000..f8338e9 --- /dev/null +++ b/internal/model/org.go @@ -0,0 +1,61 @@ +package model + +// Org 组织/租户 +type Org struct { + Base + Name string `json:"name" gorm:"size:50;not null"` + Code string `json:"code" gorm:"uniqueIndex;size:30;not null"` + OwnerID string `json:"owner_id" gorm:"not null"` + ParentID *string `json:"parent_id" gorm:"index"` + Path string `json:"path" gorm:"size:500;index"` + Level int `json:"level" gorm:"default:0"` + LeaderID *string `json:"leader_id"` + Description string `json:"description" gorm:"size:200"` + Logo string `json:"logo" gorm:"size:500"` + Settings string `json:"-" gorm:"type:text"` // JSON配置 + Status int `json:"status" gorm:"default:1"` + MaxMembers int `json:"max_members" gorm:"default:100"` +} + +func (Org) TableName() string { + return "orgs" +} + +// OrgMember 组织成员关系 +type OrgMember struct { + Base + OrgID string `json:"org_id" gorm:"type:varchar(36);uniqueIndex:idx_org_user;not null"` + UserID string `json:"user_id" gorm:"type:varchar(36);uniqueIndex:idx_org_user;not null"` + RoleIDs string `json:"role_ids" gorm:"size:200"` // 逗号分隔 + Position string `json:"position" gorm:"size:50"` + Department string `json:"department" gorm:"size:50"` + JoinedAt string `json:"joined_at"` + Status int `json:"status" gorm:"default:1"` // 0:待审核 1:正常 2:禁用 +} + +func (OrgMember) TableName() string { + return "org_members" +} + +// OrgStatus 组织状态 +const ( + OrgStatusDisabled = 0 + OrgStatusActive = 1 +) + +// MemberStatus 成员状态 +const ( + MemberStatusPending = 0 + MemberStatusActive = 1 + MemberStatusDisabled = 2 +) + +// IsOwner 检查用户是否是组织所有者 +func (o *Org) IsOwner(userID string) bool { + return o.OwnerID == userID +} + +// IsMemberActive 成员是否有效 +func (m *OrgMember) IsMemberActive() bool { + return m.Status == MemberStatusActive +} diff --git a/internal/model/policy.go b/internal/model/policy.go new file mode 100644 index 0000000..947f9e2 --- /dev/null +++ b/internal/model/policy.go @@ -0,0 +1,65 @@ +package model + +// Policy 策略定义 +type Policy struct { + Base + OrgID string `json:"org_id" gorm:"index"` // 空表示全局策略 + Code string `json:"code" gorm:"uniqueIndex;size:50;not null"` + Name string `json:"name" gorm:"size:50;not null"` + Description string `json:"description" gorm:"size:200"` + Resource string `json:"resource" gorm:"size:50;not null"` // 资源类型 + Action string `json:"action" gorm:"size:20;not null"` // read/create/update/delete/* + Condition string `json:"condition" gorm:"type:text"` // CEL表达式 + Effect string `json:"effect" gorm:"size:10;default:allow"` + Priority int `json:"priority" gorm:"default:0"` + IsSystem bool `json:"is_system" gorm:"default:false"` +} + +func (Policy) TableName() string { + return "policies" +} + +// Role 角色 +type Role struct { + Base + OrgID string `json:"org_id" gorm:"index;not null"` + Code string `json:"code" gorm:"size:50;not null"` + Name string `json:"name" gorm:"size:50;not null"` + Description string `json:"description" gorm:"size:200"` + PolicyIDs string `json:"policy_ids" gorm:"type:text"` // 逗号分隔 + IsDefault bool `json:"is_default" gorm:"default:false"` + IsSystem bool `json:"is_system" gorm:"default:false"` + SortOrder int `json:"sort_order" gorm:"default:0"` +} + +func (Role) TableName() string { + return "roles" +} + +// Effect 常量 +const ( + EffectAllow = "allow" + EffectDeny = "deny" +) + +// System Policies 系统内置策略编码 +const ( + SysPolicyUserReadOwn = "sys:user:read:own" + SysPolicyUserUpdateOwn = "sys:user:update:own" + SysPolicyUserDeleteOwn = "sys:user:delete:own" + SysPolicyOrgAdmin = "sys:org:admin" + SysPolicyOrgRead = "sys:org:read" + SysPolicyMemberRead = "sys:member:read" + SysPolicyMemberManage = "sys:member:manage" + SysPolicyRoleRead = "sys:role:read" + SysPolicyRoleManage = "sys:role:manage" + SysPolicyPolicyRead = "sys:policy:read" + SysPolicyPolicyManage = "sys:policy:manage" +) + +// System Roles 系统内置角色编码 +const ( + SysRoleOrgOwner = "owner" + SysRoleOrgAdmin = "admin" + SysRoleOrgMember = "member" +) diff --git a/internal/model/user.go b/internal/model/user.go new file mode 100644 index 0000000..7f54afb --- /dev/null +++ b/internal/model/user.go @@ -0,0 +1,72 @@ +package model + +import ( + "time" +) + +// User 全局用户表 +type User struct { + Base + Username string `json:"username" gorm:"uniqueIndex;size:50;not null"` + Password string `json:"-" gorm:"size:255"` // bcrypt hash + Nickname string `json:"nickname" gorm:"size:50"` + Avatar string `json:"avatar" gorm:"size:500"` + Email string `json:"email" gorm:"uniqueIndex;size:100"` + Phone string `json:"phone" gorm:"uniqueIndex;size:20"` + Status int `json:"status" gorm:"default:1"` // 0:禁用 1:正常 2:未激活 + EmailVerified bool `json:"email_verified" gorm:"default:false"` + PhoneVerified bool `json:"phone_verified" gorm:"default:false"` + LastLoginAt *time.Time `json:"last_login_at"` +} + +// TableName 表名 +func (User) TableName() string { + return "users" +} + +// Identity 第三方身份绑定 +type Identity struct { + Base + UserID string `json:"user_id" gorm:"index;not null"` + Provider string `json:"provider" gorm:"size:20;not null"` // google/github/wechat/ldap + ProviderUID string `json:"provider_uid" gorm:"index;size:100;not null"` // 第三方唯一ID + ProviderName string `json:"provider_name" gorm:"size:50"` + Avatar string `json:"avatar" gorm:"size:500"` + Email string `json:"email" gorm:"size:100"` + AccessToken string `json:"-" gorm:"size:500"` + RefreshToken string `json:"-" gorm:"size:500"` + ExpiresAt *time.Time `json:"-"` +} + +func (Identity) TableName() string { + return "identities" +} + +// Session 登录会话 +type Session struct { + Base + UserID string `json:"user_id" gorm:"index;not null"` + TokenID string `json:"token_id" gorm:"uniqueIndex;size:36"` // JWT jti + Type string `json:"type" gorm:"size:20;not null"` // access/refresh + DeviceInfo string `json:"device_info" gorm:"size:200"` + IP string `json:"ip" gorm:"size:50"` + ExpiresAt time.Time `json:"expires_at"` + Revoked bool `json:"revoked" gorm:"default:false"` + RevokedAt *time.Time `json:"revoked_at"` +} + +func (Session) TableName() string { + return "sessions" +} + +// UserStatus 用户状态常量 +const ( + UserStatusDisabled = 0 + UserStatusActive = 1 + UserStatusInactive = 2 +) + +// IsActive 用户是否激活 +func (u *User) IsActive() bool { + return u.Status == UserStatusActive +} diff --git a/internal/pkg/crypto/crypto.go b/internal/pkg/crypto/crypto.go new file mode 100644 index 0000000..85c4416 --- /dev/null +++ b/internal/pkg/crypto/crypto.go @@ -0,0 +1,54 @@ +package crypto + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + + "golang.org/x/crypto/bcrypt" +) + +// HashPassword 使用bcrypt哈希密码 +func HashPassword(password string, cost int) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), cost) + if err != nil { + return "", fmt.Errorf("failed to hash password: %w", err) + } + return string(bytes), nil +} + +// VerifyPassword 验证密码 +func VerifyPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +// GenerateRandomString 生成随机字符串 +func GenerateRandomString(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(bytes)[:length], nil +} + +// GenerateSecret 生成密钥 +func GenerateSecret(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + bytes := make([]byte, length) + rand.Read(bytes) + for i, b := range bytes { + bytes[i] = charset[b%byte(len(charset))] + } + return string(bytes) +} + +// GenerateClientID 生成OAuth客户端ID +func GenerateClientID() string { + return "vc_" + GenerateSecret(28) +} + +// GenerateClientSecret 生成OAuth客户端密钥 +func GenerateClientSecret() string { + return GenerateSecret(64) +} diff --git a/internal/pkg/jwt/jwt.go b/internal/pkg/jwt/jwt.go new file mode 100644 index 0000000..80dcb89 --- /dev/null +++ b/internal/pkg/jwt/jwt.go @@ -0,0 +1,166 @@ +package jwt + +import ( + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/veypi/vbase/internal/config" +) + +var ( + ErrInvalidToken = errors.New("invalid token") + ErrExpiredToken = errors.New("token expired") + ErrTokenRevoked = errors.New("token revoked") +) + +// Claims JWT声明 +type Claims struct { + jwt.RegisteredClaims + UserID string `json:"uid"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Email string `json:"email"` + Orgs []OrgClaim `json:"orgs,omitempty"` + Type string `json:"type"` // access/refresh + Scope string `json:"scope,omitempty"` +} + +// OrgClaim 组织声明 +type OrgClaim struct { + OrgID string `json:"org_id"` + Code string `json:"code"` + Name string `json:"name"` + Roles []string `json:"roles"` + Status int `json:"status"` +} + +// TokenPair Token对 +type TokenPair struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +// GenerateTokenPair 生成token对 +func GenerateTokenPair(userID, username, nickname, avatar, email string, orgs []OrgClaim) (*TokenPair, error) { + accessToken, err := GenerateAccessToken(userID, username, nickname, avatar, email, orgs) + if err != nil { + return nil, err + } + + refreshToken, err := GenerateRefreshToken(userID) + if err != nil { + return nil, err + } + + return &TokenPair{ + AccessToken: accessToken, + RefreshToken: refreshToken, + TokenType: "Bearer", + ExpiresIn: int(config.C.JWT.AccessExpiry.Seconds()), + }, nil +} + +// GenerateAccessToken 生成访问令牌 +func GenerateAccessToken(userID, username, nickname, avatar, email string, orgs []OrgClaim) (string, error) { + now := time.Now() + claims := Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + ID: uuid.New().String(), // jti + Issuer: config.C.JWT.Issuer, + Subject: userID, + Audience: jwt.ClaimStrings{config.C.App.ID}, + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(config.C.JWT.AccessExpiry)), + }, + UserID: userID, + Username: username, + Nickname: nickname, + Avatar: avatar, + Email: email, + Orgs: orgs, + Type: "access", + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(config.C.JWT.Secret)) +} + +// GenerateRefreshToken 生成刷新令牌 +func GenerateRefreshToken(userID string) (string, error) { + now := time.Now() + claims := Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + ID: uuid.New().String(), + Issuer: config.C.JWT.Issuer, + Subject: userID, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(config.C.JWT.RefreshExpiry)), + }, + UserID: userID, + Type: "refresh", + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(config.C.JWT.Secret)) +} + +// ParseToken 解析Token +func ParseToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(config.C.JWT.Secret), nil + }) + + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, ErrExpiredToken + } + return nil, ErrInvalidToken + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + + return nil, ErrInvalidToken +} + +// GetJTI 获取Token ID +func GetJTI(tokenString string) (string, error) { + claims, err := ParseToken(tokenString) + if err != nil { + return "", err + } + return claims.ID, nil +} + +// GetExpiration 获取过期时间 +func GetExpiration(tokenString string) (time.Time, error) { + claims, err := ParseToken(tokenString) + if err != nil { + return time.Time{}, err + } + if claims.ExpiresAt == nil { + return time.Time{}, errors.New("no expiration") + } + return claims.ExpiresAt.Time, nil +} + +// IsAccessToken 是否是访问令牌 +func IsAccessToken(claims *Claims) bool { + return claims.Type == "access" +} + +// IsRefreshToken 是否是刷新令牌 +func IsRefreshToken(claims *Claims) bool { + return claims.Type == "refresh" +} diff --git a/internal/service/permission.go b/internal/service/permission.go new file mode 100644 index 0000000..9bd591b --- /dev/null +++ b/internal/service/permission.go @@ -0,0 +1,258 @@ +package service + +import ( + "fmt" + "strings" + "time" + + "github.com/veypi/vbase/internal/cache" + "github.com/veypi/vbase/internal/model" +) + +// PermissionChecker 权限检查器 +type PermissionChecker struct{} + +// NewPermissionChecker 创建权限检查器 +func NewPermissionChecker() *PermissionChecker { + return &PermissionChecker{} +} + +// CheckResult 检查结果 +type CheckResult struct { + Allowed bool `json:"allowed"` + Reason string `json:"reason,omitempty"` +} + +// Check 检查权限 +func (pc *PermissionChecker) Check(userID, orgID, resource, action string, resourceData map[string]any) (*CheckResult, error) { + // 1. 检查缓存 + if cache.IsEnabled() { + allowed, cached, err := cache.GetPermission(userID, orgID, resource, action) + if err == nil && cached { + return &CheckResult{Allowed: allowed}, nil + } + } + + // 2. 检查是否是组织所有者(拥有所有权限) + if orgID != "" { + var org model.Org + if err := model.DB.First(&org, "id = ?", orgID).Error; err == nil { + if org.OwnerID == userID { + pc.cacheResult(userID, orgID, resource, action, true) + return &CheckResult{Allowed: true, Reason: "organization owner"}, nil + } + } + } + + // 3. 获取用户在该组织的角色 + var member model.OrgMember + if err := model.DB.Where("org_id = ? AND user_id = ? AND status = ?", orgID, userID, model.MemberStatusActive).First(&member).Error; err != nil { + // 用户不在组织中 + pc.cacheResult(userID, orgID, resource, action, false) + return &CheckResult{Allowed: false, Reason: "not a member of organization"}, nil + } + + // 4. 获取角色关联的策略 + roleIDs := parseRoles(member.RoleIDs) + if len(roleIDs) == 0 { + pc.cacheResult(userID, orgID, resource, action, false) + return &CheckResult{Allowed: false, Reason: "no roles assigned"}, nil + } + + var roles []model.Role + if err := model.DB.Where("id IN ?", roleIDs).Find(&roles).Error; err != nil { + return nil, err + } + + // 5. 收集所有策略ID + policyIDMap := make(map[string]bool) + for _, role := range roles { + ids := parseRoles(role.PolicyIDs) + for _, id := range ids { + policyIDMap[id] = true + } + } + + if len(policyIDMap) == 0 { + pc.cacheResult(userID, orgID, resource, action, false) + return &CheckResult{Allowed: false, Reason: "no policies assigned"}, nil + } + + policyIDs := make([]string, 0, len(policyIDMap)) + for id := range policyIDMap { + policyIDs = append(policyIDs, id) + } + + // 6. 获取策略详情 + var policies []model.Policy + if err := model.DB.Where("id IN ?", policyIDs).Find(&policies).Error; err != nil { + return nil, err + } + + // 7. 评估策略 + allowed := pc.evaluatePolicies(policies, userID, orgID, resource, action, resourceData) + pc.cacheResult(userID, orgID, resource, action, allowed) + + if allowed { + return &CheckResult{Allowed: true}, nil + } + return &CheckResult{Allowed: false, Reason: "policy denied"}, nil +} + +// evaluatePolicies 评估策略 +func (pc *PermissionChecker) evaluatePolicies(policies []model.Policy, userID, orgID, resource, action string, resourceData map[string]any) bool { + // 先处理deny策略 + for _, p := range policies { + if p.Effect != model.EffectDeny { + continue + } + if pc.matchPolicy(&p, resource, action) { + // 检查条件 + if pc.evaluateCondition(&p, userID, orgID, resourceData) { + return false + } + } + } + + // 再处理allow策略 + for _, p := range policies { + if p.Effect != model.EffectAllow { + continue + } + if pc.matchPolicy(&p, resource, action) { + if pc.evaluateCondition(&p, userID, orgID, resourceData) { + return true + } + } + } + + return false +} + +// matchPolicy 匹配策略资源和方法 +func (pc *PermissionChecker) matchPolicy(p *model.Policy, resource, action string) bool { + // 资源匹配 + if p.Resource != "*" && p.Resource != resource { + return false + } + // 动作匹配 + if p.Action != "*" && p.Action != action { + return false + } + return true +} + +// evaluateCondition 评估条件 +func (pc *PermissionChecker) evaluateCondition(p *model.Policy, userID, orgID string, resourceData map[string]any) bool { + condition := p.Condition + if condition == "" || condition == "true" { + return true + } + + // 简单条件评估 + switch condition { + case "owner": + // 检查是否是资源所有者 + if ownerID, ok := resourceData["owner_id"].(string); ok { + return ownerID == userID + } + if createdBy, ok := resourceData["created_by"].(string); ok { + return createdBy == userID + } + return false + case "org_member": + // 检查是否是组织成员 + if orgID == "" { + return false + } + var count int64 + model.DB.Model(&model.OrgMember{}).Where("org_id = ? AND user_id = ? AND status = ?", orgID, userID, model.MemberStatusActive).Count(&count) + return count > 0 + default: + // 其他复杂条件暂时返回true + return true + } +} + +// cacheResult 缓存结果 +func (pc *PermissionChecker) cacheResult(userID, orgID, resource, action string, allowed bool) { + if cache.IsEnabled() { + cache.SetPermission(userID, orgID, resource, action, allowed, 1*time.Minute) + } +} + +// GetUserPermissions 获取用户的所有权限 +func (pc *PermissionChecker) GetUserPermissions(userID, orgID string) ([]string, error) { + // 获取用户角色 + var member model.OrgMember + if err := model.DB.Where("org_id = ? AND user_id = ? AND status = ?", orgID, userID, model.MemberStatusActive).First(&member).Error; err != nil { + return []string{}, nil + } + + roleIDs := parseRoles(member.RoleIDs) + if len(roleIDs) == 0 { + return []string{}, nil + } + + var roles []model.Role + if err := model.DB.Where("id IN ?", roleIDs).Find(&roles).Error; err != nil { + return nil, err + } + + // 收集策略 + policyIDMap := make(map[string]bool) + for _, role := range roles { + ids := parseRoles(role.PolicyIDs) + for _, id := range ids { + policyIDMap[id] = true + } + } + + if len(policyIDMap) == 0 { + return []string{}, nil + } + + policyIDs := make([]string, 0, len(policyIDMap)) + for id := range policyIDMap { + policyIDs = append(policyIDs, id) + } + + var policies []model.Policy + if err := model.DB.Where("id IN ?", policyIDs).Find(&policies).Error; err != nil { + return nil, err + } + + // 格式化权限 + perms := make([]string, 0, len(policies)) + for _, p := range policies { + if p.Effect == model.EffectAllow { + perms = append(perms, fmt.Sprintf("%s:%s", p.Resource, p.Action)) + } + } + + return perms, nil +} + +// ClearUserPermissionCache 清除用户权限缓存 +func ClearUserPermissionCache(userID string) { + if cache.IsEnabled() { + cache.DeleteUserPermissions(userID) + } +} + +// ClearOrgPermissionCache 清除组织权限缓存 +func ClearOrgPermissionCache(orgID string) { + if cache.IsEnabled() { + cache.DeleteOrgPermissions(orgID) + } +} + +func parseRoles(roleIDs string) []string { + if roleIDs == "" { + return []string{} + } + return strings.Split(roleIDs, ",") +} + +// InitPermissionChecker 初始化权限检查器 +var InitPermissionChecker = NewPermissionChecker() diff --git a/重构设计.md b/重构设计.md new file mode 100644 index 0000000..05dd17c --- /dev/null +++ b/重构设计.md @@ -0,0 +1,940 @@ +# vbase 用户权限系统重构设计 + +## 一、设计原则 + +1. **标准化**:遵循 OAuth2.0 / OIDC 标准协议 +2. **无状态**:服务无状态,水平扩展友好 +3. **安全性**:密码bcrypt、JWT签名、HTTPS强制 +4. **性能**:Redis缓存热点数据,1分钟TTL自动刷新 +5. **可维护性**:清晰分层,单一职责 + +--- + +## 二、系统架构 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 接入层 │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ REST API │ │ OAuth2.0 │ │ OIDC Discovery │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │ +└─────────┼────────────────┼────────────────────┼────────────┘ + │ │ │ +┌─────────▼────────────────▼────────────────────▼────────────┐ +│ 服务层 │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ Auth服务 │ │ Org服务 │ │ OAuth服务 │ │ +│ │ (认证) │ │ (组织) │ │ (第三方接入) │ │ +│ └─────────────┘ └─────────────┘ └─────────────────────┘ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ User服务 │ │ Role服务 │ │ Permission服务 │ │ +│ │ (用户) │ │ (角色) │ │ (权限) │ │ +│ └─────────────┘ └─────────────┘ └─────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ +┌─────────▼──────────────────────────────────────────────────┐ +│ 数据层 │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ MySQL/PSQL │ │ Redis │ │ (可选)Etcd │ │ +│ │ (主存储) │ │ (缓存/会话) │ │ (配置中心) │ │ +│ └─────────────┘ └─────────────┘ └─────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 三、核心数据模型 + +### 3.1 身份认证模型 + +```go +// User - 全局用户表 +// 一个用户可属于多个组织,但登录是全局的 +type User struct { + ID string `json:"id" gorm:"primaryKey;type:varchar(36)"` + Username string `json:"username" gorm:"uniqueIndex;size:50;not null"` + Password string `json:"-" gorm:"size:255"` // bcrypt hash,第三方登录可为空 + Nickname string `json:"nickname" gorm:"size:50"` + Avatar string `json:"avatar" gorm:"size:500"` + Email string `json:"email" gorm:"uniqueIndex;size:100"` + Phone string `json:"phone" gorm:"uniqueIndex;size:20"` + Status int `json:"status" gorm:"default:1"` // 0:禁用 1:正常 2:未激活 + EmailVerified bool `json:"email_verified" gorm:"default:false"` + PhoneVerified bool `json:"phone_verified" gorm:"default:false"` + LastLoginAt *time.Time `json:"last_login_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +// Identity - 第三方身份绑定 +// 支持多种登录方式绑定到同一账号 +type Identity struct { + ID string `json:"id" gorm:"primaryKey;type:varchar(36)"` + UserID string `json:"user_id" gorm:"index;not null"` + Provider string `json:"provider" gorm:"size:20;not null"` // google/github/wechat/ldap + ProviderUID string `json:"provider_uid" gorm:"index;size:100;not null"` // 第三方唯一ID + ProviderName string `json:"provider_name" gorm:"size:50"` + Avatar string `json:"avatar" gorm:"size:500"` + Email string `json:"email" gorm:"size:100"` + AccessToken string `json:"-" gorm:"size:500"` // 加密存储 + RefreshToken string `json:"-" gorm:"size:500"` + ExpiresAt *time.Time `json:"-"` + CreatedAt time.Time `json:"created_at"` +} + +// Session - 登录会话 +// 用于多端登录管理和撤销 +type Session struct { + ID string `json:"id" gorm:"primaryKey;type:varchar(36)"` + UserID string `json:"user_id" gorm:"index;not null"` + TokenID string `json:"token_id" gorm:"uniqueIndex;size:36"` // JWT jti + Type string `json:"type" gorm:"size:20;not null"` // access/refresh + DeviceInfo string `json:"device_info" gorm:"size:200"` + IP string `json:"ip" gorm:"size:50"` + ExpiresAt time.Time `json:"expires_at"` + Revoked bool `json:"revoked" gorm:"default:false"` + RevokedAt *time.Time `json:"revoked_at"` + CreatedAt time.Time `json:"created_at"` +} +``` + +### 3.2 组织架构模型 + +```go +// Org - 组织/租户 +// 完全隔离的数据边界 +type Org struct { + ID string `json:"id" gorm:"primaryKey;type:varchar(36)"` + Name string `json:"name" gorm:"size:50;not null"` + Code string `json:"code" gorm:"uniqueIndex;size:30;not null"` // 组织唯一编码 + OwnerID string `json:"owner_id" gorm:"not null"` // 创建者/所有者 + + // 树形结构 + ParentID *string `json:"parent_id" gorm:"index"` + Path string `json:"path" gorm:"size:500;index"` // 完整路径 /root/tech/backend + Level int `json:"level" gorm:"default:0"` + + // 配置 + Description string `json:"description" gorm:"size:200"` + Logo string `json:"logo" gorm:"size:500"` + Settings string `json:"-" gorm:"type:text"` // JSON配置 + + // 状态 + Status int `json:"status" gorm:"default:1"` // 0:禁用 1:正常 + MaxMembers int `json:"max_members" gorm:"default:100"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// OrgMember - 组织成员关系 +// 用户与组织的多对多关系 +type OrgMember struct { + ID string `json:"id" gorm:"primaryKey;type:varchar(36)"` + OrgID string `json:"org_id" gorm:"uniqueIndex:idx_org_user;not null"` + UserID string `json:"user_id" gorm:"uniqueIndex:idx_org_user;not null"` + + // 角色(多个角色,逗号分隔) + RoleIDs string `json:"role_ids" gorm:"size:200"` + + // 成员信息 + Position string `json:"position" gorm:"size:50"` // 职位 + Department string `json:"department" gorm:"size:50"` // 部门名称(冗余) + JoinedAt time.Time `json:"joined_at"` + + // 状态 0:待审核 1:正常 2:禁用 + Status int `json:"status" gorm:"default:1"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} +``` + +### 3.3 权限模型(RBAC + ABAC) + +```go +// Role - 角色 +// 组织级别的角色定义 +type Role struct { + ID string `json:"id" gorm:"primaryKey;type:varchar(36)"` + OrgID string `json:"org_id" gorm:"index;not null"` + Code string `json:"code" gorm:"size:50;not null"` // 角色编码 + Name string `json:"name" gorm:"size:50;not null"` + Description string `json:"description" gorm:"size:200"` + + // 关联策略 + PolicyIDs string `json:"policy_ids" gorm:"type:text"` // 逗号分隔 + + // 属性 + IsDefault bool `json:"is_default" gorm:"default:false"` // 新成员默认角色 + IsSystem bool `json:"is_system" gorm:"default:false"` // 系统内置(不可删) + SortOrder int `json:"sort_order" gorm:"default:0"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Policy - 策略 +// 细粒度权限定义,支持ABAC +type Policy struct { + ID string `json:"id" gorm:"primaryKey;type:varchar(36)"` + OrgID string `json:"org_id" gorm:"index"` // 空表示全局策略 + Code string `json:"code" gorm:"uniqueIndex;size:50;not null"` + Name string `json:"name" gorm:"size:50;not null"` + Description string `json:"description" gorm:"size:200"` + + // 资源定义 + Resource string `json:"resource" gorm:"size:50;not null"` // 资源类型: user/org/member/role等 + Action string `json:"action" gorm:"size:20;not null"` // read/create/update/delete/* /batch_create等 + + // ABAC条件(CEL表达式) + // 示例: "resource.owner == user.id" - 只能操作自己的资源 + // 示例: "user.roles.exists(r, r == 'admin')" - 需要admin角色 + // 示例: "resource.org_id == org.id" - 只能操作当前组织的资源 + Condition string `json:"condition" gorm:"type:text"` + + // 效果: allow/deny(deny优先) + Effect string `json:"effect" gorm:"size:10;default:allow"` + + // 优先级(数字越大优先级越高,deny策略建议高优先级) + Priority int `json:"priority" gorm:"default:0"` + + IsSystem bool `json:"is_system" gorm:"default:false"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} +``` + +### 3.4 OAuth2.0 模型 + +```go +// OAuthClient - 注册的应用客户端 +type OAuthClient struct { + ID string `json:"id" gorm:"primaryKey;type:varchar(36)"` + Name string `json:"name" gorm:"size:50;not null"` + Description string `json:"description" gorm:"size:200"` + + // 客户端凭证 + ClientID string `json:"client_id" gorm:"uniqueIndex;size:32;not null"` + ClientSecret string `json:"-" gorm:"size:64;not null"` + + // OAuth配置 + RedirectURIs string `json:"redirect_uris" gorm:"type:text"` // 逗号分隔 + GrantTypes string `json:"grant_types" gorm:"size:100"` // authorization_code/refresh_token/client_credentials + ResponseTypes string `json:"response_types" gorm:"size:50"` // code/token + + // 访问控制 + AllowedScopes string `json:"allowed_scopes" gorm:"size:200"` // openid profile email org roles + TokenExpiry int `json:"token_expiry" gorm:"default:3600"` // access_token有效期(秒) + RefreshExpiry int `json:"refresh_expiry" gorm:"default:2592000"` // refresh_token有效期(秒) + + // 归属 + OwnerID string `json:"owner_id" gorm:"not null"` + OrgID string `json:"org_id" gorm:"index"` // 可选,绑定特定组织 + + // 状态 0:禁用 1:正常 + Status int `json:"status" gorm:"default:1"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// OAuthAuthorization - 授权码存储 +type OAuthAuthorization struct { + ID string `json:"id" gorm:"primaryKey;type:varchar(36)"` + UserID string `json:"user_id" gorm:"index;not null"` + ClientID string `json:"client_id" gorm:"index;not null"` + OrgID string `json:"org_id" gorm:"index"` + + // 授权信息 + Code string `json:"code" gorm:"uniqueIndex;size:64"` + Scope string `json:"scope" gorm:"size:200"` + State string `json:"state" gorm:"size:100"` + + // PKCE + CodeChallenge string `json:"-" gorm:"size:128"` + CodeChallengeMethod string `json:"-" gorm:"size:10"` + + // 状态 + Used bool `json:"used" gorm:"default:false"` + UsedAt *time.Time `json:"used_at"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` +} + +// OAuthToken - OAuth访问令牌 +type OAuthToken struct { + ID string `json:"id" gorm:"primaryKey;type:varchar(36)"` + UserID string `json:"user_id" gorm:"index;not null"` + ClientID string `json:"client_id" gorm:"index;not null"` + OrgID string `json:"org_id" gorm:"index"` + + // Token信息 + AccessToken string `json:"-" gorm:"uniqueIndex;size:64"` + RefreshToken string `json:"-" gorm:"uniqueIndex;size:64"` + TokenType string `json:"token_type" gorm:"size:10;default:Bearer"` + Scope string `json:"scope" gorm:"size:200"` + + // 有效期 + ExpiresAt time.Time `json:"expires_at"` + Revoked bool `json:"revoked" gorm:"default:false"` + RevokedAt *time.Time `json:"revoked_at"` + + CreatedAt time.Time `json:"created_at"` +} +``` + +--- + +## 四、API 设计 + +### 4.1 路由结构 + +``` +/api/v1 +├── /auth # 认证相关(公开或需基础认证) +│ ├── POST /login +│ ├── POST /logout +│ ├── POST /refresh +│ ├── POST /register +│ ├── POST /forgot-password +│ ├── POST /reset-password +│ ├── GET /captcha +│ ├── GET /oauth/:provider # 第三方登录跳转 +│ ├── GET /oauth/:provider/callback +│ └── POST /oauth/bind +│ +├── /me # 当前用户(需认证) +│ ├── GET / +│ ├── PATCH / +│ ├── GET /sessions # 登录会话列表 +│ ├── DELETE /sessions/:id # 撤销会话 +│ ├── GET /identities # 绑定的第三方账号 +│ ├── DELETE /identities/:provider # 解绑 +│ ├── GET /orgs # 我的组织列表 +│ └── POST /change-password +│ +├── /users # 用户管理(需权限) +│ ├── GET / +│ ├── POST / +│ ├── GET /:id +│ ├── PATCH /:id +│ ├── DELETE /:id +│ └── PATCH /:id/status +│ +├── /orgs # 组织管理 +│ ├── GET / # 列表 +│ ├── POST / # 创建(需登录) +│ ├── GET /:id +│ ├── PATCH /:id # 需组织管理员权限 +│ ├── DELETE /:id +│ ├── GET /:id/tree # 组织树 +│ ├── GET /:id/members # 成员列表 +│ ├── POST /:id/members # 邀请成员 +│ ├── GET /:id/members/:user_id +│ ├── PATCH /:id/members/:user_id # 修改角色/状态 +│ ├── DELETE /:id/members/:user_id +│ ├── GET /:id/roles # 组织角色 +│ ├── POST /:id/roles +│ └── GET /:id/policies # 组织策略 +│ +├── /roles # 角色管理(需组织上下文) +│ ├── GET / +│ ├── POST / +│ ├── GET /:id +│ ├── PATCH /:id +│ └── DELETE /:id +│ +├── /policies # 策略管理 +│ ├── GET / +│ ├── POST / +│ ├── GET /:id +│ ├── PATCH /:id +│ └── DELETE /:id +│ +├── /oauth # OAuth2.0 服务端 +│ ├── GET /authorize # 授权端点 +│ ├── POST /token # 令牌端点 +│ ├── POST /revoke # 撤销令牌 +│ ├── GET /userinfo # 用户信息 +│ ├── GET /.well-known/openid-configuration +│ ├── GET /.well-known/jwks.json +│ │ +│ └── /clients # 客户端管理(需认证) +│ ├── GET / +│ ├── POST / +│ ├── GET /:id +│ ├── PATCH /:id +│ ├── DELETE /:id +│ └── POST /:id/reset-secret +│ +└── /check # 权限检查(内部服务调用) + └── POST /permission # 检查指定权限 +``` + +### 4.2 认证相关 API + +#### POST /api/v1/auth/login +账号密码登录 + +```json +// Request +{ + "username": "string", // 用户名/邮箱/手机号 + "password": "string", + "captcha_id": "string", // 可选,错误多次后需要 + "captcha_code": "string", + "remember": false // 是否长效token +} + +// Response 200 +{ + "access_token": "eyJhbGciOiJSUzI1NiIs...", + "refresh_token": "dGhpcyBpcyBhIHJlZnJlc2g...", + "token_type": "Bearer", + "expires_in": 3600, + "user": { + "id": "uuid", + "username": "john", + "nickname": "John Doe", + "email": "john@example.com", + "avatar": "https://..." + } +} +``` + +#### POST /api/v1/auth/oauth/:provider +第三方登录,返回跳转URL或直接302跳转 + +```json +// Query Params +?redirect_uri=https://app.com/callback&state=xyz + +// Response (如果是SPA,返回URL) +{ + "auth_url": "https://accounts.google.com/o/oauth2/v2/auth..." +} +``` + +#### POST /api/v1/auth/oauth/bind +绑定第三方账号到已有账号 + +```json +// Request +{ + "provider": "google", + "code": "authorization_code_from_provider", + "bind_type": "register" | "login", // 新注册还是绑定已有 + "username": "string", // bind_type=register时需要 + "password": "string" // bind_type=login时需要 +} + +// Response +{ + "access_token": "...", + "user": {...} +} +``` + +### 4.3 组织相关 API + +#### GET /api/v1/orgs +获取组织列表(用户加入的) + +```json +// Response +{ + "items": [ + { + "id": "uuid", + "name": "技术部", + "code": "tech", + "logo": "https://...", + "owner_id": "uuid", + "member_count": 25, + "my_roles": ["admin", "developer"], + "joined_at": "2024-01-15T10:30:00Z" + } + ] +} +``` + +#### POST /api/v1/orgs +创建组织 + +```json +// Request +{ + "name": "新产品线", + "code": "new-product", // 唯一编码 + "description": "...", + "logo": "https://..." +} + +// Response +{ + "id": "uuid", + "name": "新产品线", + "code": "new-product", + "owner_id": "current_user_id", + // 自动创建owner角色并分配 +} +``` + +**注意**:创建者自动成为组织所有者(owner),拥有所有权限。 + +### 4.4 权限相关 API + +#### GET /api/v1/check/permissions +获取当前用户在指定组织的所有权限(用于前端按钮控制) + +```json +// Query Params +?org_id=xxx + +// Response +{ + "org_id": "uuid", + "roles": ["admin", "developer"], + "permissions": [ + "user:read", + "user:update:own", + "org:read", + "member:manage", + "role:read" + ] +} +``` + +#### POST /api/v1/check/permission +检查具体权限(其他服务调用) + +```json +// Request +{ + "org_id": "uuid", + "resource": "user", + "action": "update", + "resource_id": "target_user_id" // 可选 +} + +// Response +{ + "allowed": true, + "reason": "用户是资源所有者" +} +``` + +--- + +## 五、权限系统设计 + +### 5.1 策略表达式(CEL) + +使用 [CEL (Common Expression Language)](https://github.com/google/cel-go) 作为条件表达式语言。 + +```go +// 内置变量 +user // 当前用户对象 +org // 当前组织对象 +resource // 被访问资源对象 +action // 动作: read/create/update/delete + +// 示例策略条件 +"true" // 无条件允许 +"resource.created_by == user.id" // 只能操作自己创建的资源 +"resource.owner_id == user.id" // 资源所有者 +"org.members.exists(m, m.user_id == user.id && m.roles.exists(r, r.code == 'admin'))" +"user.id in org.owners" // 组织所有者 +"resource.org_id == org.id" // 只能访问当前组织的资源 +"resource.status == 'pending' && action == 'update'" // 特定状态才允许操作 +``` + +### 5.2 系统内置策略 + +| 编码 | 资源 | 动作 | 条件 | 效果 | 说明 | +|------|------|------|------|------|------| +| `sys:user:read:own` | user | read | `resource.id == user.id` | allow | 读自己 | +| `sys:user:update:own` | user | update | `resource.id == user.id` | allow | 改自己 | +| `sys:org:admin` | org | * | `org.owner_id == user.id` | allow | 组织所有者 | +| `sys:member:read` | member | read | `resource.org_id == org.id` | allow | 读成员 | +| `sys:member:manage` | member | * | 需自定义角色 | allow | 管理成员 | +| `sys:role:read` | role | read | `resource.org_id == org.id` | allow | 读角色 | +| `sys:role:manage` | role | * | 需自定义角色 | allow | 管理角色 | + +### 5.3 权限检查流程 + +```go +// 中间件流程 +func PermissionMiddleware(resource, action string) vigo.Middleware { + return func(x *vigo.X) error { + // 1. 获取当前用户 + user := auth.CurrentUser(x) + if user == nil { + return vigo.ErrUnauthorized + } + + // 2. 获取当前组织(从Header或Query) + orgID := x.Request.Header.Get("X-Org-ID") + if orgID == "" { + orgID = x.Query("org_id") + } + + // 3. 构建缓存key + cacheKey := fmt.Sprintf("perm:%s:%s:%s:%s", user.ID, orgID, resource, action) + + // 4. 查缓存 + if cached, err := redis.Get(cacheKey); err == nil { + if cached == "deny" { + return vigo.ErrForbidden + } + x.Set("permission_checked", true) + return x.Next() + } + + // 5. 查询用户在该组织的角色 + member := getOrgMember(orgID, user.ID) + if member == nil { + redis.Set(cacheKey, "deny", 1*time.Minute) + return vigo.ErrForbidden + } + + // 6. 收集所有策略 + policies := getPoliciesByRoles(member.RoleIDs) + + // 7. 评估策略 + allowed := evaluatePolicies(policies, user, org, resource, action) + + // 8. 写入缓存 + if allowed { + redis.Set(cacheKey, "allow", 1*time.Minute) + } else { + redis.Set(cacheKey, "deny", 1*time.Minute) + } + + if !allowed { + return vigo.ErrForbidden + } + + return x.Next() + } +} +``` + +--- + +## 六、OAuth2.0 / OIDC 实现 + +### 6.1 支持的授权流程 + +1. **Authorization Code Flow**(推荐,支持PKCE) + - 用于服务端应用 + - 支持 `code_challenge` (PKCE) + +2. **Implicit Flow**(不推荐,但为了兼容支持) + - 用于纯前端应用(建议迁移到PKCE) + +3. **Client Credentials Flow** + - 用于服务间调用 + +4. **Refresh Token** + - 用于刷新 access_token + +### 6.2 OIDC 支持 + +- `/.well-known/openid-configuration` - 发现端点 +- `/.well-known/jwks.json` - 公钥获取 +- Scope: `openid profile email org roles` +- ID Token: JWT格式,包含用户基本信息 + +### 6.3 授权流程示例 + +``` +┌─────────────┐ ┌─────────────┐ +│ 第三方应用 │ │ vbase │ +└──────┬──────┘ └──────┬──────┘ + │ │ + │ 1. GET /oauth/authorize? │ + │ response_type=code& │ + │ client_id=xxx& │ + │ redirect_uri=xxx& │ + │ scope=openid profile org& │ + │ state=xxx& │ + │ code_challenge=xxx& │ + │ code_challenge_method=S256 │ + ├─────────────────────────────────────────────────►│ + │ │ + │ 2. 未登录,重定向到登录页 │ + │◄─────────────────────────────────────────────────┤ + │ │ + │ 3. 登录后,用户授权确认页面 │ + │ (展示应用请求的权限范围) │ + ├─────────────────────────────────────────────────►│ + │ │ + │ 4. 302 重定向到 redirect_uri │ + │ ?code=xxx&state=xxx │ + │◄─────────────────────────────────────────────────┤ + │ │ + │ 5. POST /oauth/token │ + │ grant_type=authorization_code& │ + │ code=xxx& │ + │ redirect_uri=xxx& │ + │ client_id=xxx& │ + │ client_secret=xxx& │ + │ code_verifier=xxx (PKCE) │ + ├─────────────────────────────────────────────────►│ + │ │ + │ 6. 返回 Token │ + │ { │ + │ access_token: "...", │ + │ id_token: "...", │ + │ refresh_token: "...", │ + │ token_type: "Bearer", │ + │ expires_in: 3600 │ + │ } │ + │◄─────────────────────────────────────────────────┤ +``` + +--- + +## 七、缓存策略 + +### 7.1 Redis 缓存结构 + +``` +# 权限缓存(核心) +key: perm:{user_id}:{org_id}:{resource}:{action} +value: allow | deny +ttl: 60秒 + +# 用户信息缓存 +key: user:{user_id} +value: JSON + ttl: 300秒 + +# 组织信息缓存 +key: org:{org_id} +value: JSON +ttl: 300秒 + +# 组织成员缓存 +key: org:{org_id}:member:{user_id} +value: JSON (包含角色ID列表) +ttl: 60秒 + +# 角色策略缓存 +key: role:{role_id}:policies +value: JSON数组 +ttl: 300秒 + +# Session/Token 黑名单(用于撤销) +key: token:revoked:{jti} +value: 1 +ttl: 与token剩余有效期一致 + +# 限流计数 +key: ratelimit:{ip}:{path} +value: 计数 + ttl: 60秒 + +# 验证码 +key: captcha:{captcha_id} +value: 验证码内容 +ttl: 300秒 + +# OAuth 授权码 +key: oauth:code:{code} +value: 授权信息JSON +ttl: 600秒 +``` + +### 7.2 缓存更新策略 + +1. **被动失效**:数据变更时删除缓存 +2. **主动刷新**:TTL到期自动重新加载 +3. **权限缓存**:短TTL(1分钟),保证实时性 +4. **配置缓存**:长TTL(5分钟),减少DB查询 + +--- + +## 八、安全设计 + +### 8.1 密码安全 + +- **算法**:bcrypt(cost=12) +- **历史密码**:不允许重复使用最近5次密码 +- **复杂度**:最小8位,包含大小写+数字 +- **传输**:HTTPS only,前端bcrypt预哈希(可选) + +### 8.2 Token安全 + +- **Access Token**:JWT RS256签名,有效期1小时 +- **Refresh Token**:随机字符串,有效期30天,可撤销 +- **Token绑定**:可选绑定设备指纹,防止盗用 +- **撤销机制**:JWT jti 存入黑名单实现撤销 + +### 8.3 OAuth安全 + +- **PKCE**:所有public client强制启用 +- **State参数**:强制验证,防止CSRF +- **Redirect URI**:必须预注册,严格匹配 +- **Client Secret**:仅用于confidential client + +### 8.4 防护措施 + +- **限流**: + - 登录:5次/分钟,错误5次后需要验证码 + - 注册:3次/小时 + - API:100次/分钟/用户 + +- **验证码**: + - 图形验证码:登录错误多次后 + - 邮箱/短信验证码:密码重置、敏感操作 + +- **审计日志**: + - 登录/登出 + - 密码修改 + - 权限变更 + - 组织重要操作 + +--- + +## 九、初始化流程 + +系统首次启动时自动执行: + +1. **创建数据库表** + +2. **创建超级管理员** + - 检测用户表是否为空 + - 为空时创建默认超管账号 + - 用户名/密码从环境变量读取(或随机生成并打印日志) + +3. **创建系统策略** + - 创建所有系统内置策略 + +4. **创建默认组织**(可选) + - 创建名为"Default"的根组织 + - 将超管加入该组织 + +--- + +## 十、技术选型 + +| 组件 | 选型 | 说明 | +|------|------|------| +| Web框架 | Vigo | 保持使用 | +| ORM | GORM | 保持使用 | +| 数据库 | MySQL/PostgreSQL | 保持使用 | +| 缓存 | Redis | 必选 | +| 密码哈希 | bcrypt | golang.org/x/crypto/bcrypt | +| JWT | jwt-go / golang-jwt | 标准库 | +| CEL表达式 | cel-go | github.com/google/cel-go | +| 验证码 | base64Captcha | 或自研 | +| OAuth2 | go-oauth2/oauth2 | 或自研实现 | + +--- + +## 十一、目录结构 + +``` +/ +├── cmd/ +│ └── server/ +│ └── main.go # 服务入口 +├── internal/ # 私有代码 +│ ├── api/ # API层 +│ │ ├── auth/ # 认证API +│ │ ├── user/ # 用户API +│ │ ├── org/ # 组织API +│ │ ├── role/ # 角色API +│ │ ├── policy/ # 策略API +│ │ ├── oauth/ # OAuth2.0服务端 +│ │ └── middleware/ # 中间件 +│ │ ├── auth.go # 认证中间件 +│ │ ├── permission.go # 权限中间件 +│ │ ├── ratelimit.go # 限流中间件 +│ │ └── cors.go # 跨域中间件 +│ ├── service/ # 业务逻辑层 +│ │ ├── auth.go +│ │ ├── user.go +│ │ ├── org.go +│ │ ├── role.go +│ │ ├── policy.go +│ │ ├── permission.go # 权限检查核心 +│ │ └── oauth.go +│ ├── model/ # 数据模型 +│ │ ├── user.go +│ │ ├── org.go +│ │ ├── role.go +│ │ ├── policy.go +│ │ ├── oauth.go +│ │ └── migrate.go # 数据库迁移 +│ ├── repository/ # 数据访问层 +│ │ ├── user.go +│ │ ├── org.go +│ │ └── ... +│ ├── cache/ # 缓存封装 +│ │ └── redis.go +│ ├── pkg/ # 内部工具包 +│ │ ├── crypto/ # 加密工具 +│ │ ├── jwt/ # JWT工具 +│ │ ├── cel/ # CEL表达式 +│ │ └── oauth/ # OAuth2工具 +│ └── config/ # 配置 +│ └── config.go +├── pkg/ # 可公开使用的包 +│ └── sdk/ # 其他服务使用的SDK +├── docs/ # 文档 +├── scripts/ # 脚本 +├── configs/ # 配置文件 +├── go.mod +└── README.md +``` + +--- + +## 十二、开发计划 + +### Phase 1: 基础架构 +1. 数据库模型定义和迁移 +2. Redis缓存封装 +3. JWT认证中间件 +4. 基础配置管理 + +### Phase 2: 认证模块 +1. 用户注册/登录/登出 +2. 密码管理 +3. Session管理 +4. 验证码 + +### Phase 3: 组织与成员 +1. 组织CRUD +2. 成员管理 +3. 组织树查询 + +### Phase 4: 权限系统 +1. 策略定义 +2. 角色管理 +3. 权限检查中间件 +4. CEL表达式评估 + +### Phase 5: OAuth2.0 +1. 客户端管理 +2. 授权流程 +3. Token管理 +4. OIDC支持 + +### Phase 6: 第三方登录 +1. Google/GitHub/微信登录 +2. 账号绑定 + +### Phase 7: 完善 +1. 审计日志 +2. 管理后台API +3. 测试和文档 + +--- + +**确认以上设计后,开始Phase 1实现。**