package service import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "net/url" "os" "strings" "time" "github.com/echochat/backend/app/auth/dao" "github.com/echochat/backend/app/auth/model" "github.com/echochat/backend/app/constants" "github.com/echochat/backend/app/dto" "github.com/echochat/backend/config" "github.com/echochat/backend/pkg/utils" "github.com/redis/go-redis/v9" "gorm.io/gorm" ) const ( oauthStateKeyPrefix = "echo:auth:oauth_state:" oauthStateTTL = 10 * time.Minute ) var ( ErrOAuthProviderUnsupported = errors.New("不支持的第三方登录方式") ErrOAuthProviderDisabled = errors.New("第三方登录未配置或未启用") ErrOAuthStateInvalid = errors.New("第三方登录状态已失效") ) type OAuthService struct { userDAO *dao.UserDAO oauthAccountDAO *dao.OAuthAccountDAO roleDAO *dao.RoleDAO authService *AuthService cfg *config.OAuthConfig redis *redis.Client httpClient *http.Client } type OAuthProfile struct { Provider string OpenID string UnionID string Nickname string Avatar string AccessToken string RefreshToken string ExpiresIn int64 } type oauthTokenResponse struct { AccessToken string `json:"access_token"` ExpiresIn int64 `json:"expires_in"` RefreshToken string `json:"refresh_token"` OpenID string `json:"openid"` UnionID string `json:"unionid"` ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } type qqTokenResponse struct { AccessToken string `json:"access_token"` ExpiresIn int64 `json:"expires_in"` RefreshToken string `json:"refresh_token"` } type qqOpenIDResponse struct { ClientID string `json:"client_id"` OpenID string `json:"openid"` } type wxUserInfoResponse struct { OpenID string `json:"openid"` Nickname string `json:"nickname"` HeadImg string `json:"headimgurl"` UnionID string `json:"unionid"` ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } type qqUserInfoResponse struct { Ret int `json:"ret"` Msg string `json:"msg"` Nickname string `json:"nickname"` Avatar string `json:"figureurl_qq_2"` Avatar1 string `json:"figureurl_qq_1"` } func NewOAuthService(userDAO *dao.UserDAO, oauthAccountDAO *dao.OAuthAccountDAO, roleDAO *dao.RoleDAO, authService *AuthService, cfg *config.OAuthConfig, redisClient *redis.Client) *OAuthService { return &OAuthService{ userDAO: userDAO, oauthAccountDAO: oauthAccountDAO, roleDAO: roleDAO, authService: authService, cfg: cfg, redis: redisClient, httpClient: &http.Client{Timeout: 10 * time.Second}, } } func (s *OAuthService) BuildAuthorizeURL(ctx context.Context, provider string) (string, error) { provider = normalizeOAuthProvider(provider) app, err := s.providerConfig(provider) if err != nil { return "", err } state, err := randomState() if err != nil { return "", err } if err = s.saveOAuthState(ctx, provider, state); err != nil { return "", err } redirectURI := url.QueryEscape(app.RedirectURI) scope := "snsapi_login" if provider == "qq" { scope = "get_user_info" } if provider == "wechat" { return fmt.Sprintf("https://open.weixin.qq.com/connect/qrconnect?appid=%s&redirect_uri=%s&response_type=code&scope=%s&state=%s#wechat_redirect", url.QueryEscape(app.ClientID), redirectURI, scope, state), nil } return fmt.Sprintf("https://graph.qq.com/oauth2.0/authorize?response_type=code&client_id=%s&redirect_uri=%s&scope=%s&state=%s", url.QueryEscape(app.ClientID), redirectURI, url.QueryEscape(scope), state), nil } func (s *OAuthService) Callback(ctx context.Context, provider, code, state, clientIP string) (*dto.LoginResponse, error) { provider = normalizeOAuthProvider(provider) if err := s.validateOAuthState(ctx, provider, state); err != nil { return nil, err } profile, err := s.fetchProfile(ctx, provider, code) if err != nil { return nil, err } account, err := s.oauthAccountDAO.FindByProviderOpenID(ctx, profile.Provider, profile.OpenID) var user *model.User if err == nil { user, err = s.userDAO.FindByID(ctx, account.UserID) if err != nil { return nil, err } account.Nickname = profile.Nickname account.Avatar = profile.Avatar account.AccessToken = profile.AccessToken account.RefreshToken = profile.RefreshToken account.UnionID = profile.UnionID account.ExpiresAt = time.Now().Add(time.Duration(profile.ExpiresIn) * time.Second) _ = s.oauthAccountDAO.Update(ctx, account) } else if errors.Is(err, gorm.ErrRecordNotFound) { user, account, err = s.createOAuthUser(ctx, profile) if err != nil { return nil, err } } else { return nil, err } return s.authService.BuildLoginResponseForUser(ctx, user, clientIP, constants.ClientTypeFrontend) } func (s *OAuthService) createOAuthUser(ctx context.Context, profile *OAuthProfile) (*model.User, *model.OAuthAccount, error) { nickname := strings.TrimSpace(profile.Nickname) if nickname == "" { nickname = profile.Provider + "用户" } username := s.uniqueUsername(ctx, profile.Provider) passwordHash, err := utils.HashPassword(randomPassword()) if err != nil { return nil, nil, err } user := &model.User{ Username: username, Email: username + "@oauth.echochat.local", PasswordHash: passwordHash, Nickname: nickname, Avatar: profile.Avatar, Status: constants.UserStatusActive, } if err = s.userDAO.Create(ctx, user); err != nil { return nil, nil, err } defaultRole, roleErr := s.roleDAO.FindByCode(ctx, constants.RoleUser) if roleErr == nil { _ = s.roleDAO.AssignRole(ctx, user.ID, defaultRole.ID) } account := &model.OAuthAccount{ UserID: user.ID, Provider: profile.Provider, OpenID: profile.OpenID, UnionID: profile.UnionID, Nickname: profile.Nickname, Avatar: profile.Avatar, AccessToken: profile.AccessToken, RefreshToken: profile.RefreshToken, ExpiresAt: time.Now().Add(time.Duration(profile.ExpiresIn) * time.Second), } if err = s.oauthAccountDAO.Create(ctx, account); err != nil { return nil, nil, err } return user, account, nil } func (s *OAuthService) fetchProfile(ctx context.Context, provider, code string) (*OAuthProfile, error) { app, err := s.providerConfig(provider) if err != nil { return nil, err } if provider == "wechat" { return s.fetchWechatProfile(ctx, app, code) } return s.fetchQQProfile(ctx, app, code) } func (s *OAuthService) fetchWechatProfile(ctx context.Context, app config.OAuthApp, code string) (*OAuthProfile, error) { tokenURL := fmt.Sprintf("https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code", url.QueryEscape(app.ClientID), url.QueryEscape(app.ClientSecret), url.QueryEscape(code)) var token oauthTokenResponse if err := s.getJSON(ctx, tokenURL, &token); err != nil { return nil, err } if token.ErrCode != 0 || token.AccessToken == "" || token.OpenID == "" { return nil, fmt.Errorf("微信登录失败: %s", token.ErrMsg) } infoURL := fmt.Sprintf("https://api.weixin.qq.com/sns/userinfo?access_token=%s&openid=%s&lang=zh_CN", url.QueryEscape(token.AccessToken), url.QueryEscape(token.OpenID)) var info wxUserInfoResponse if err := s.getJSON(ctx, infoURL, &info); err != nil { return nil, err } if info.ErrCode != 0 { return nil, fmt.Errorf("微信用户信息获取失败: %s", info.ErrMsg) } return &OAuthProfile{Provider: "wechat", OpenID: token.OpenID, UnionID: firstNonEmpty(info.UnionID, token.UnionID), Nickname: info.Nickname, Avatar: info.HeadImg, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, ExpiresIn: token.ExpiresIn}, nil } func (s *OAuthService) fetchQQProfile(ctx context.Context, app config.OAuthApp, code string) (*OAuthProfile, error) { tokenURL := fmt.Sprintf("https://graph.qq.com/oauth2.0/token?grant_type=authorization_code&client_id=%s&client_secret=%s&code=%s&redirect_uri=%s&fmt=json", url.QueryEscape(app.ClientID), url.QueryEscape(app.ClientSecret), url.QueryEscape(code), url.QueryEscape(app.RedirectURI)) var token qqTokenResponse if err := s.getJSON(ctx, tokenURL, &token); err != nil { return nil, err } if token.AccessToken == "" { return nil, errors.New("QQ登录失败") } openidURL := fmt.Sprintf("https://graph.qq.com/oauth2.0/me?access_token=%s&fmt=json", url.QueryEscape(token.AccessToken)) var openid qqOpenIDResponse if err := s.getJSON(ctx, openidURL, &openid); err != nil { return nil, err } if openid.OpenID == "" { return nil, errors.New("QQ openid 获取失败") } infoURL := fmt.Sprintf("https://graph.qq.com/user/get_user_info?access_token=%s&oauth_consumer_key=%s&openid=%s&fmt=json", url.QueryEscape(token.AccessToken), url.QueryEscape(app.ClientID), url.QueryEscape(openid.OpenID)) var info qqUserInfoResponse if err := s.getJSON(ctx, infoURL, &info); err != nil { return nil, err } if info.Ret != 0 { return nil, fmt.Errorf("QQ用户信息获取失败: %s", info.Msg) } return &OAuthProfile{Provider: "qq", OpenID: openid.OpenID, Nickname: info.Nickname, Avatar: firstNonEmpty(info.Avatar, info.Avatar1), AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, ExpiresIn: token.ExpiresIn}, nil } func (s *OAuthService) providerConfig(provider string) (config.OAuthApp, error) { provider = normalizeOAuthProvider(provider) if provider != "wechat" && provider != "qq" { return config.OAuthApp{}, ErrOAuthProviderUnsupported } app := config.OAuthApp{} if s.cfg != nil && s.cfg.Providers != nil { app = s.cfg.Providers[provider] } prefix := "ECHOCHAT_OAUTH_" + strings.ToUpper(provider) + "_" if strings.TrimSpace(app.ClientID) == "" { app.ClientID = strings.TrimSpace(os.Getenv(prefix + "CLIENT_ID")) } if strings.TrimSpace(app.ClientSecret) == "" { app.ClientSecret = strings.TrimSpace(os.Getenv(prefix + "CLIENT_SECRET")) } if strings.TrimSpace(app.RedirectURI) == "" { app.RedirectURI = strings.TrimSpace(os.Getenv(prefix + "REDIRECT_URI")) } if !app.Enabled { app.Enabled = strings.EqualFold(strings.TrimSpace(os.Getenv(prefix+"ENABLED")), "true") } if !app.Enabled || app.ClientID == "" || app.ClientSecret == "" || app.RedirectURI == "" { return config.OAuthApp{}, ErrOAuthProviderDisabled } return app, nil } func (s *OAuthService) saveOAuthState(ctx context.Context, provider, state string) error { if s.redis == nil { return errors.New("OAuth state storage not initialized") } return s.redis.Set(ctx, oauthStateKey(provider, state), "1", oauthStateTTL).Err() } func (s *OAuthService) validateOAuthState(ctx context.Context, provider, state string) error { provider = normalizeOAuthProvider(provider) state = strings.TrimSpace(state) if provider != "wechat" && provider != "qq" || state == "" { return ErrOAuthStateInvalid } if s.redis == nil { return ErrOAuthStateInvalid } _, err := s.redis.Get(ctx, oauthStateKey(provider, state)).Result() if errors.Is(err, redis.Nil) { return ErrOAuthStateInvalid } if err != nil { return err } _ = s.redis.Del(ctx, oauthStateKey(provider, state)).Err() return nil } func oauthStateKey(provider, state string) string { return oauthStateKeyPrefix + normalizeOAuthProvider(provider) + ":" + strings.TrimSpace(state) } func normalizeOAuthProvider(provider string) string { return strings.ToLower(strings.TrimSpace(provider)) } func (s *OAuthService) getJSON(ctx context.Context, rawURL string, out interface{}) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return err } resp, err := s.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("OAuth HTTP %d", resp.StatusCode) } return json.NewDecoder(resp.Body).Decode(out) } func (s *OAuthService) uniqueUsername(ctx context.Context, provider string) string { for i := 0; i < 5; i++ { name := fmt.Sprintf("%s_%s", provider, randomString(10)) if existing, _ := s.userDAO.FindByUsername(ctx, name); existing == nil { return name } } return fmt.Sprintf("%s_%d", provider, time.Now().UnixNano()) } func randomState() (string, error) { buf := make([]byte, 16) _, err := rand.Read(buf) if err != nil { return "", err } return hex.EncodeToString(buf), nil } func randomString(n int) string { buf := make([]byte, n) _, _ = rand.Read(buf) return hex.EncodeToString(buf)[:n] } func randomPassword() string { return "oauth_" + randomString(32) } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return value } } return "" }