From 38469739dea676a6e27ced9d0470af2038eef4f4 Mon Sep 17 00:00:00 2001 From: 12975 <1297598740@qq.com> Date: Thu, 29 Jan 2026 19:06:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Gitee=20OAuth=20?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E6=94=AF=E6=8C=81=E5=B9=B6=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E9=85=8D=E7=BD=AE=E3=80=81=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E5=92=8C=E6=9C=8D=E5=8A=A1=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.prod | 7 + internal/config/config.go | 10 + internal/handler/auth_handler.go | 56 ++++- internal/router/router.go | 12 +- internal/service/gitee_oauth_service.go | 294 ++++++++++++++++++++++++ 5 files changed, 373 insertions(+), 6 deletions(-) create mode 100644 internal/service/gitee_oauth_service.go diff --git a/.env.prod b/.env.prod index d964eff..ddb7265 100644 --- a/.env.prod +++ b/.env.prod @@ -58,6 +58,13 @@ GITHUB_CLIENT_SECRET=7e154e464dccd913a92cf580021f2a5dc51aac93 GITHUB_REDIRECT_URL=https://bk.swalktech.top/api/v1/auth/github/callback FRONTEND_URL=https://bk.swalktech.top +# ============================================ +# Gitee OAuth 配置(可选) +# ============================================ +GITEE_CLIENT_ID=ccc286f08aac25a6304c61a1a7a5a4418e0fd73948d8f8339ca941bfb5379280 +GITEE_CLIENT_SECRET=b7832bdfc3cadf2e00dba9e2b694345f88afb591603a2edf3af19484b68efe9a +GITEE_REDIRECT_URL=https://bk.swalktech.top/api/v1/auth/gitee/callback + # ============================================ # 网络配置 # ============================================ diff --git a/internal/config/config.go b/internal/config/config.go index 869631f..6115470 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,6 +46,11 @@ type Config struct { GitHubRedirectURL string FrontendURL string + // Gitee OAuth configuration + GiteeClientID string + GiteeClientSecret string + GiteeRedirectURL string + // AI configuration (OpenAI compatible) OpenAIAPIKey string OpenAIBaseURL string @@ -105,6 +110,11 @@ func Load() *Config { GitHubRedirectURL: getEnv("GITHUB_REDIRECT_URL", ""), FrontendURL: getEnv("FRONTEND_URL", "http://localhost:2613"), + // Gitee OAuth + GiteeClientID: getEnv("GITEE_CLIENT_ID", ""), + GiteeClientSecret: getEnv("GITEE_CLIENT_SECRET", ""), + GiteeRedirectURL: getEnv("GITEE_REDIRECT_URL", ""), + // AI (OpenAI compatible) OpenAIAPIKey: getEnv("OPENAI_API_KEY", ""), OpenAIBaseURL: getEnv("OPENAI_BASE_URL", ""), diff --git a/internal/handler/auth_handler.go b/internal/handler/auth_handler.go index d17a1f6..3aaa1a4 100644 --- a/internal/handler/auth_handler.go +++ b/internal/handler/auth_handler.go @@ -15,15 +15,16 @@ import ( type AuthHandler struct { authService *service.AuthService gitHubOAuthService *service.GitHubOAuthService + giteeOAuthService *service.GiteeOAuthService cfg *config.Config } -func NewAuthHandler(authService *service.AuthService, gitHubOAuthService *service.GitHubOAuthService) *AuthHandler { - return &AuthHandler{authService: authService, gitHubOAuthService: gitHubOAuthService} +func NewAuthHandler(authService *service.AuthService, gitHubOAuthService *service.GitHubOAuthService, giteeOAuthService *service.GiteeOAuthService) *AuthHandler { + return &AuthHandler{authService: authService, gitHubOAuthService: gitHubOAuthService, giteeOAuthService: giteeOAuthService} } -func NewAuthHandlerWithConfig(authService *service.AuthService, gitHubOAuthService *service.GitHubOAuthService, cfg *config.Config) *AuthHandler { - return &AuthHandler{authService: authService, gitHubOAuthService: gitHubOAuthService, cfg: cfg} +func NewAuthHandlerWithConfig(authService *service.AuthService, gitHubOAuthService *service.GitHubOAuthService, giteeOAuthService *service.GiteeOAuthService, cfg *config.Config) *AuthHandler { + return &AuthHandler{authService: authService, gitHubOAuthService: gitHubOAuthService, giteeOAuthService: giteeOAuthService, cfg: cfg} } type RegisterRequest struct { @@ -154,6 +155,8 @@ func (h *AuthHandler) RegisterRoutes(rg *gin.RouterGroup) { auth.POST("/refresh", h.RefreshToken) auth.GET("/github", h.GitHubLogin) auth.GET("/github/callback", h.GitHubCallback) + auth.GET("/gitee", h.GiteeLogin) + auth.GET("/gitee/callback", h.GiteeCallback) } func (h *AuthHandler) RegisterProtectedRoutes(rg *gin.RouterGroup) { @@ -206,3 +209,48 @@ func (h *AuthHandler) GitHubCallback(c *gin.Context) { frontendURL, url.QueryEscape(tokens.AccessToken), url.QueryEscape(tokens.RefreshToken), user.ID) c.Redirect(302, redirectURL) } + +func (h *AuthHandler) GiteeLogin(c *gin.Context) { + if h.giteeOAuthService == nil { + api.BadRequest(c, "Gitee OAuth is not configured") + return + } + state := c.Query("state") + if state == "" { + state = "default" + } + authURL := h.giteeOAuthService.GetAuthorizationURL(state) + c.Redirect(302, authURL) +} + +func (h *AuthHandler) GiteeCallback(c *gin.Context) { + if h.giteeOAuthService == nil { + api.BadRequest(c, "Gitee OAuth is not configured") + return + } + + frontendURL := "http://localhost:2613" + if h.cfg != nil && h.cfg.FrontendURL != "" { + frontendURL = h.cfg.FrontendURL + } + + code := c.Query("code") + if code == "" { + redirectURL := fmt.Sprintf("%s/login?error=missing_code", frontendURL) + c.Redirect(302, redirectURL) + return + } + + user, tokens, err := h.giteeOAuthService.HandleCallback(code) + if err != nil { + fmt.Printf("[Auth] Gitee callback failed: %v\n", err) + redirectURL := fmt.Sprintf("%s/login?error=%s", frontendURL, url.QueryEscape(err.Error())) + c.Redirect(302, redirectURL) + return + } + + // 重定向到前端回调页面,带上token信息 + redirectURL := fmt.Sprintf("%s/auth/gitee/callback?access_token=%s&refresh_token=%s&user_id=%d", + frontendURL, url.QueryEscape(tokens.AccessToken), url.QueryEscape(tokens.RefreshToken), user.ID) + c.Redirect(302, redirectURL) +} diff --git a/internal/router/router.go b/internal/router/router.go index 5e12541..f4fdaa8 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -58,7 +58,11 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config) if cfg.GitHubClientID != "" && cfg.GitHubClientSecret != "" { gitHubOAuthService = service.NewGitHubOAuthService(userRepo, authService, cfg) } - authHandler := handler.NewAuthHandlerWithConfig(authService, gitHubOAuthService, cfg) + var giteeOAuthService *service.GiteeOAuthService + if cfg.GiteeClientID != "" && cfg.GiteeClientSecret != "" { + giteeOAuthService = service.NewGiteeOAuthService(userRepo, authService, cfg) + } + authHandler := handler.NewAuthHandlerWithConfig(authService, gitHubOAuthService, giteeOAuthService, cfg) authMiddleware := middleware.NewAuthMiddleware(authService) // Initialize services @@ -331,7 +335,11 @@ func SetupWithRedis(db *gorm.DB, yunAPIClient *service.YunAPIClient, redisClient if cfg.GitHubClientID != "" && cfg.GitHubClientSecret != "" { gitHubOAuthService = service.NewGitHubOAuthService(userRepo, authService, cfg) } - authHandler := handler.NewAuthHandlerWithConfig(authService, gitHubOAuthService, cfg) + var giteeOAuthService *service.GiteeOAuthService + if cfg.GiteeClientID != "" && cfg.GiteeClientSecret != "" { + giteeOAuthService = service.NewGiteeOAuthService(userRepo, authService, cfg) + } + authHandler := handler.NewAuthHandlerWithConfig(authService, gitHubOAuthService, giteeOAuthService, cfg) authMiddleware := middleware.NewAuthMiddleware(authService) // Initialize services diff --git a/internal/service/gitee_oauth_service.go b/internal/service/gitee_oauth_service.go new file mode 100644 index 0000000..225386e --- /dev/null +++ b/internal/service/gitee_oauth_service.go @@ -0,0 +1,294 @@ +// Package service provides business logic for the application +package service + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "accounting-app/internal/config" + "accounting-app/internal/models" + "accounting-app/internal/repository" +) + +// Gitee OAuth errors +var ( + ErrGiteeOAuthFailed = errors.New("gitee oauth authentication failed") + ErrGiteeUserInfoFailed = errors.New("failed to get gitee user info") +) + +// GiteeUser represents Gitee user information +type GiteeUser struct { + ID int64 `json:"id"` + Login string `json:"login"` + Email string `json:"email"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` +} + +// GiteeTokenResponse represents Gitee OAuth token response +type GiteeTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` + CreatedAt int64 `json:"created_at"` +} + +// GiteeOAuthService handles Gitee OAuth operations +type GiteeOAuthService struct { + userRepo *repository.UserRepository + authService *AuthService + cfg *config.Config + httpClient *http.Client +} + +// NewGiteeOAuthService creates a new GiteeOAuthService instance +func NewGiteeOAuthService(userRepo *repository.UserRepository, authService *AuthService, cfg *config.Config) *GiteeOAuthService { + return &GiteeOAuthService{ + userRepo: userRepo, + authService: authService, + cfg: cfg, + httpClient: &http.Client{ + Timeout: 60 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + }, + }, + } +} + +// GetAuthorizationURL returns the Gitee OAuth authorization URL +func (s *GiteeOAuthService) GetAuthorizationURL(state string) string { + params := url.Values{} + params.Set("client_id", s.cfg.GiteeClientID) + params.Set("redirect_uri", s.cfg.GiteeRedirectURL) + params.Set("response_type", "code") + params.Set("scope", "user_info emails") + if state != "" { + params.Set("state", state) + } + + return fmt.Sprintf("https://gitee.com/oauth/authorize?%s", params.Encode()) +} + +// ExchangeCodeForToken exchanges authorization code for access token +func (s *GiteeOAuthService) ExchangeCodeForToken(code string) (*GiteeTokenResponse, error) { + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("client_id", s.cfg.GiteeClientID) + data.Set("client_secret", s.cfg.GiteeClientSecret) + data.Set("code", code) + data.Set("redirect_uri", s.cfg.GiteeRedirectURL) + + req, err := http.NewRequest("POST", "https://gitee.com/oauth/token", strings.NewReader(data.Encode())) + if err != nil { + fmt.Printf("[Gitee] Failed to create request: %v\n", err) + return nil, err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + fmt.Printf("[Gitee] Exchanging code for token...\n") + resp, err := s.httpClient.Do(req) + if err != nil { + fmt.Printf("[Gitee] Request failed: %v\n", err) + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + fmt.Printf("[Gitee] Token exchange failed with status: %d\n", resp.StatusCode) + return nil, ErrGiteeOAuthFailed + } + + var tokenResp GiteeTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + fmt.Printf("[Gitee] Failed to decode response: %v\n", err) + return nil, err + } + + if tokenResp.AccessToken == "" { + fmt.Printf("[Gitee] No access token in response\n") + return nil, ErrGiteeOAuthFailed + } + + return &tokenResp, nil +} + +// GetUserInfo retrieves Gitee user information using access token +func (s *GiteeOAuthService) GetUserInfo(accessToken string) (*GiteeUser, error) { + reqURL := fmt.Sprintf("https://gitee.com/api/v5/user?access_token=%s", url.QueryEscape(accessToken)) + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + fmt.Printf("[Gitee] Get user info failed with status: %d\n", resp.StatusCode) + return nil, ErrGiteeUserInfoFailed + } + + var user GiteeUser + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return nil, err + } + + // If email is empty, try to get from emails endpoint + if user.Email == "" { + email, err := s.getUserEmail(accessToken) + if err == nil && email != "" { + user.Email = email + } + } + + return &user, nil +} + +// getUserEmail retrieves user's primary email from Gitee +func (s *GiteeOAuthService) getUserEmail(accessToken string) (string, error) { + reqURL := fmt.Sprintf("https://gitee.com/api/v5/emails?access_token=%s", url.QueryEscape(accessToken)) + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return "", err + } + + req.Header.Set("Accept", "application/json") + + resp, err := s.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", ErrGiteeUserInfoFailed + } + + var emails []struct { + Email string `json:"email"` + State string `json:"state"` + Scope []string `json:"scope"` + } + + if err := json.NewDecoder(resp.Body).Decode(&emails); err != nil { + return "", err + } + + // Find primary/confirmed email + for _, e := range emails { + if e.State == "confirmed" { + return e.Email, nil + } + } + + // Fallback to first email + if len(emails) > 0 { + return emails[0].Email, nil + } + + return "", nil +} + +// HandleCallback processes Gitee OAuth callback +func (s *GiteeOAuthService) HandleCallback(code string) (*models.User, *TokenPair, error) { + // Exchange code for token + tokenResp, err := s.ExchangeCodeForToken(code) + if err != nil { + return nil, nil, err + } + + // Get Gitee user info + giteeUser, err := s.GetUserInfo(tokenResp.AccessToken) + if err != nil { + return nil, nil, err + } + + // Check if user already exists with this Gitee account + user, err := s.userRepo.GetByOAuthProvider("gitee", fmt.Sprintf("%d", giteeUser.ID)) + if err == nil { + // User exists, update token and return + _ = s.userRepo.UpdateOAuthToken("gitee", fmt.Sprintf("%d", giteeUser.ID), tokenResp.AccessToken) + tokens, err := s.authService.generateTokenPair(user) + if err != nil { + return nil, nil, err + } + return user, tokens, nil + } + + // Check if user exists with same email + if giteeUser.Email != "" { + existingUser, err := s.userRepo.GetByEmail(giteeUser.Email) + if err == nil { + // Link Gitee account to existing user + oauth := &models.OAuthAccount{ + UserID: existingUser.ID, + Provider: "gitee", + ProviderID: fmt.Sprintf("%d", giteeUser.ID), + AccessToken: tokenResp.AccessToken, + } + if err := s.userRepo.CreateOAuthAccount(oauth); err != nil { + return nil, nil, err + } + tokens, err := s.authService.generateTokenPair(existingUser) + if err != nil { + return nil, nil, err + } + return existingUser, tokens, nil + } + } + + // Create new user + username := giteeUser.Login + if giteeUser.Name != "" { + username = giteeUser.Name + } + + email := giteeUser.Email + if email == "" { + email = fmt.Sprintf("%d@gitee.user", giteeUser.ID) + } + + newUser := &models.User{ + Email: email, + Username: username, + Avatar: giteeUser.AvatarURL, + IsActive: true, + } + + if err := s.userRepo.Create(newUser); err != nil { + return nil, nil, err + } + + // Create OAuth account link + oauth := &models.OAuthAccount{ + UserID: newUser.ID, + Provider: "gitee", + ProviderID: fmt.Sprintf("%d", giteeUser.ID), + AccessToken: tokenResp.AccessToken, + } + if err := s.userRepo.CreateOAuthAccount(oauth); err != nil { + return nil, nil, err + } + + tokens, err := s.authService.generateTokenPair(newUser) + if err != nil { + return nil, nil, err + } + + return newUser, tokens, nil +}