diff --git a/backend/go-service/app/auth/controller/auth_controller.go b/backend/go-service/app/auth/controller/auth_controller.go index 7df4d73..de3e6ed 100644 --- a/backend/go-service/app/auth/controller/auth_controller.go +++ b/backend/go-service/app/auth/controller/auth_controller.go @@ -2,9 +2,16 @@ package controller import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/url" + "strings" + "github.com/echochat/backend/app/auth/service" "github.com/echochat/backend/app/constants" "github.com/echochat/backend/app/dto" + "github.com/echochat/backend/config" "github.com/echochat/backend/pkg/logs" "github.com/echochat/backend/pkg/middleware" "github.com/echochat/backend/pkg/utils" @@ -16,11 +23,13 @@ import ( // 处理用户注册、登录、Token 刷新、个人信息管理等接口 type AuthController struct { authService *service.AuthService + oauthService *service.OAuthService + oauthCfg *config.OAuthConfig } // NewAuthController 创建前台认证控制器实例 -func NewAuthController(authService *service.AuthService) *AuthController { - return &AuthController{authService: authService} +func NewAuthController(authService *service.AuthService, oauthService *service.OAuthService, oauthCfg *config.OAuthConfig) *AuthController { + return &AuthController{authService: authService, oauthService: oauthService, oauthCfg: oauthCfg} } // Register 用户注册 @@ -77,6 +86,53 @@ func (ctrl *AuthController) Login(c *gin.Context) { utils.ResponseOK(c, resp) } +func (ctrl *AuthController) OAuthAuthorize(c *gin.Context) { + provider := strings.ToLower(strings.TrimSpace(c.Param("provider"))) + authURL, err := ctrl.oauthService.BuildAuthorizeURL(c.Request.Context(), provider) + if err != nil { + handleAuthError(c, err, "第三方登录初始化失败") + return + } + utils.ResponseOK(c, gin.H{"url": authURL}) +} + +func (ctrl *AuthController) OAuthCallback(c *gin.Context) { + provider := strings.ToLower(strings.TrimSpace(c.Param("provider"))) + code := strings.TrimSpace(c.Query("code")) + state := strings.TrimSpace(c.Query("state")) + if code == "" { + ctrl.redirectOAuthFailure(c, "第三方登录授权失败") + return + } + resp, err := ctrl.oauthService.Callback(c.Request.Context(), provider, code, state, c.ClientIP()) + if err != nil { + ctrl.redirectOAuthFailure(c, err.Error()) + return + } + payload, err := json.Marshal(resp) + if err != nil { + ctrl.redirectOAuthFailure(c, "登录结果生成失败") + return + } + ctrl.redirectOAuthResult(c, "success", base64.RawURLEncoding.EncodeToString(payload)) +} + +func (ctrl *AuthController) redirectOAuthFailure(c *gin.Context, message string) { + ctrl.redirectOAuthResult(c, "error", base64.RawURLEncoding.EncodeToString([]byte(message))) +} + +func (ctrl *AuthController) redirectOAuthResult(c *gin.Context, status, payload string) { + callbackURL := "/echoChat-frontend/#/pages/auth/oauth-callback" + if ctrl.oauthCfg != nil && strings.TrimSpace(ctrl.oauthCfg.FrontendCallbackURL) != "" { + callbackURL = strings.TrimSpace(ctrl.oauthCfg.FrontendCallbackURL) + } + sep := "?" + if strings.Contains(callbackURL, "?") { + sep = "&" + } + c.Redirect(http.StatusFound, callbackURL+sep+"status="+url.QueryEscape(status)+"&payload="+url.QueryEscape(payload)) +} + // Logout 用户登出 // POST /api/v1/auth/logout(需认证) func (ctrl *AuthController) Logout(c *gin.Context) { @@ -226,6 +282,12 @@ func handleAuthError(c *gin.Context, err error, fallbackMsg ...string) { utils.ResponseForbidden(c, err.Error()) case service.ErrRefreshTokenType: utils.ResponseBadRequest(c, err.Error()) + case service.ErrOAuthProviderUnsupported: + utils.ResponseBadRequest(c, err.Error()) + case service.ErrOAuthProviderDisabled: + utils.ResponseBadRequest(c, err.Error()) + case service.ErrOAuthStateInvalid: + utils.ResponseBadRequest(c, err.Error()) default: msg := "服务器内部错误" if len(fallbackMsg) > 0 && fallbackMsg[0] != "" { diff --git a/backend/go-service/app/auth/dao/oauth_account_dao.go b/backend/go-service/app/auth/dao/oauth_account_dao.go new file mode 100644 index 0000000..1ba58e9 --- /dev/null +++ b/backend/go-service/app/auth/dao/oauth_account_dao.go @@ -0,0 +1,33 @@ +package dao + +import ( + "context" + + "github.com/echochat/backend/app/auth/model" + "gorm.io/gorm" +) + +type OAuthAccountDAO struct { + db *gorm.DB +} + +func NewOAuthAccountDAO(db *gorm.DB) *OAuthAccountDAO { + return &OAuthAccountDAO{db: db} +} + +func (d *OAuthAccountDAO) FindByProviderOpenID(ctx context.Context, provider, openID string) (*model.OAuthAccount, error) { + var account model.OAuthAccount + err := d.db.WithContext(ctx).Where("provider = ? AND open_id = ?", provider, openID).First(&account).Error + if err != nil { + return nil, err + } + return &account, nil +} + +func (d *OAuthAccountDAO) Create(ctx context.Context, account *model.OAuthAccount) error { + return d.db.WithContext(ctx).Create(account).Error +} + +func (d *OAuthAccountDAO) Update(ctx context.Context, account *model.OAuthAccount) error { + return d.db.WithContext(ctx).Save(account).Error +} diff --git a/backend/go-service/app/auth/model/oauth_account.go b/backend/go-service/app/auth/model/oauth_account.go new file mode 100644 index 0000000..af6ed8d --- /dev/null +++ b/backend/go-service/app/auth/model/oauth_account.go @@ -0,0 +1,22 @@ +package model + +import "time" + +type OAuthAccount struct { + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` + UserID int64 `json:"user_id" gorm:"not null;index"` + Provider string `json:"provider" gorm:"size:20;not null;uniqueIndex:idx_oauth_provider_openid"` + OpenID string `json:"openid" gorm:"size:128;not null;uniqueIndex:idx_oauth_provider_openid"` + UnionID string `json:"unionid" gorm:"size:128;not null;default:'';index"` + Nickname string `json:"nickname" gorm:"size:100;not null;default:''"` + Avatar string `json:"avatar" gorm:"size:500;not null;default:''"` + AccessToken string `json:"-" gorm:"column:access_token;size:1024;not null;default:''"` + RefreshToken string `json:"-" gorm:"column:refresh_token;size:1024;not null;default:''"` + ExpiresAt time.Time `json:"expires_at" gorm:"type:timestamp(0)"` + CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0)"` + UpdatedAt time.Time `json:"updated_at" gorm:"not null;autoUpdateTime;type:timestamp(0)"` +} + +func (OAuthAccount) TableName() string { + return "auth_oauth_accounts" +} diff --git a/backend/go-service/app/auth/provider.go b/backend/go-service/app/auth/provider.go index 7cdd5c2..ffee447 100644 --- a/backend/go-service/app/auth/provider.go +++ b/backend/go-service/app/auth/provider.go @@ -13,8 +13,10 @@ import ( var AuthSet = wire.NewSet( dao.NewUserDAO, dao.NewRoleDAO, + dao.NewOAuthAccountDAO, service.NewTokenStore, service.NewAuthService, + service.NewOAuthService, controller.NewAuthController, controller.NewAdminAuthController, ) diff --git a/backend/go-service/app/auth/router.go b/backend/go-service/app/auth/router.go index e53af2f..a56bb37 100644 --- a/backend/go-service/app/auth/router.go +++ b/backend/go-service/app/auth/router.go @@ -23,6 +23,8 @@ func RegisterRoutes( { public.POST("/register", ctrl.Register) public.POST("/login", ctrl.Login) + public.GET("/oauth/:provider/authorize", ctrl.OAuthAuthorize) + public.GET("/oauth/:provider/callback", ctrl.OAuthCallback) public.POST("/refresh-token", ctrl.RefreshToken) } diff --git a/backend/go-service/app/auth/service/oauth_service.go b/backend/go-service/app/auth/service/oauth_service.go new file mode 100644 index 0000000..47345b6 --- /dev/null +++ b/backend/go-service/app/auth/service/oauth_service.go @@ -0,0 +1,382 @@ +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 "" +} diff --git a/backend/go-service/app/provider/provider.go b/backend/go-service/app/provider/provider.go index 55d75c6..96b0fdb 100644 --- a/backend/go-service/app/provider/provider.go +++ b/backend/go-service/app/provider/provider.go @@ -191,6 +191,10 @@ func provideMeetingConfig(cfg *config.Config) *config.MeetingConfig { return &cfg.Meeting } +func provideOAuthConfig(cfg *config.Config) *config.OAuthConfig { + return &cfg.OAuth +} + // InfraSet 基础设施层 Provider Set var InfraSet = wire.NewSet( provideDBConfig, @@ -200,6 +204,7 @@ var InfraSet = wire.NewSet( provideServerConfig, provideLLMSourceConfig, provideMeetingConfig, + provideOAuthConfig, db.NewPostgres, db.NewRedis, db.NewLLMSourceDB, diff --git a/backend/go-service/app/provider/wire_gen.go b/backend/go-service/app/provider/wire_gen.go index da34d33..7619408 100644 --- a/backend/go-service/app/provider/wire_gen.go +++ b/backend/go-service/app/provider/wire_gen.go @@ -60,10 +60,13 @@ func InitializeApp(cfg *config.Config) (*App, error) { } userDAO := dao.NewUserDAO(gormDB) roleDAO := dao.NewRoleDAO(gormDB) + oauthAccountDAO := dao.NewOAuthAccountDAO(gormDB) jwtConfig := provideJWTConfig(cfg) tokenStore := service.NewTokenStore(client, jwtConfig) authService := service.NewAuthService(userDAO, roleDAO, jwtConfig, tokenStore) - authController := controller.NewAuthController(authService) + oauthConfig := provideOAuthConfig(cfg) + oauthService := service.NewOAuthService(userDAO, oauthAccountDAO, roleDAO, authService, oauthConfig, client) + authController := controller.NewAuthController(authService, oauthService, oauthConfig) adminAuthController := controller.NewAdminAuthController(authService) userManageDAO := dao2.NewUserManageDAO(gormDB) userManageService := service2.NewUserManageService(userManageDAO, userDAO, roleDAO) diff --git a/backend/go-service/cmd/server/main.go b/backend/go-service/cmd/server/main.go index b60947f..4b30b9f 100644 --- a/backend/go-service/cmd/server/main.go +++ b/backend/go-service/cmd/server/main.go @@ -10,6 +10,7 @@ import ( "syscall" "time" + authModel "github.com/echochat/backend/app/auth/model" "github.com/echochat/backend/app/im/model" meetingModel "github.com/echochat/backend/app/meeting/model" transcribeModel "github.com/echochat/backend/app/transcribe/model" @@ -58,6 +59,7 @@ func main() { &model.Conversation{}, &model.ConversationMember{}, &model.Message{}, + &authModel.OAuthAccount{}, // Phase B 新增:会议录制元数据表 &meetingModel.MeetingRecording{}, // Phase B 新增:会议录制语音转写结果表 diff --git a/backend/go-service/config/config.dev.yaml b/backend/go-service/config/config.dev.yaml index c6587a8..7d5e753 100644 --- a/backend/go-service/config/config.dev.yaml +++ b/backend/go-service/config/config.dev.yaml @@ -33,6 +33,20 @@ jwt: refresh_expire_day: 7 # Refresh Token 有效期(天),即 7 天 issuer: echochat # JWT 签发者标识 +oauth: + frontend_callback_url: "/echoChat-frontend/#/pages/auth/oauth-callback" + providers: + wechat: + enabled: false + client_id: "" + client_secret: "" + redirect_uri: "https://yl.z64.cn/api/v1/auth/oauth/wechat/callback" + qq: + enabled: false + client_id: "" + client_secret: "" + redirect_uri: "https://yl.z64.cn/api/v1/auth/oauth/qq/callback" + # 日志配置 log: level: debug # 最低输出级别:debug / info / warn / error @@ -95,4 +109,4 @@ meeting: cloud_law_internal_token: cloud-law-internal-xx14 recording_transcode_enabled: true recording_transcode_ffmpeg_path: /home/ykf/jenkins/yuyin/ffmpeg/ffmpeg-7.0.2-amd64-static/ffmpeg - recording_transcode_timeout_seconds: 600 \ No newline at end of file + recording_transcode_timeout_seconds: 600 diff --git a/backend/go-service/config/config.docker.yaml b/backend/go-service/config/config.docker.yaml index ab5569b..3d3b9e0 100644 --- a/backend/go-service/config/config.docker.yaml +++ b/backend/go-service/config/config.docker.yaml @@ -29,6 +29,20 @@ jwt: refresh_expire_day: 7 issuer: echochat +oauth: + frontend_callback_url: "/echoChat-frontend/#/pages/auth/oauth-callback" + providers: + wechat: + enabled: false + client_id: "" + client_secret: "" + redirect_uri: "https://yl.z64.cn/api/v1/auth/oauth/wechat/callback" + qq: + enabled: false + client_id: "" + client_secret: "" + redirect_uri: "https://yl.z64.cn/api/v1/auth/oauth/qq/callback" + log: level: debug format: text diff --git a/backend/go-service/config/config.go b/backend/go-service/config/config.go index 4d7c803..88b3b28 100644 --- a/backend/go-service/config/config.go +++ b/backend/go-service/config/config.go @@ -20,6 +20,19 @@ type Config struct { MediaServer MediaServerConfig `mapstructure:"media_server"` Meeting MeetingConfig `mapstructure:"meeting"` LLMSource LLMSourceConfig `mapstructure:"llm_source"` // Phase B:外部 LLM 配置库(MySQL,t_llm_provider/model/key) + OAuth OAuthConfig `mapstructure:"oauth"` +} + +type OAuthConfig struct { + FrontendCallbackURL string `mapstructure:"frontend_callback_url"` + Providers map[string]OAuthApp `mapstructure:"providers"` +} + +type OAuthApp struct { + ClientID string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` + RedirectURI string `mapstructure:"redirect_uri"` + Enabled bool `mapstructure:"enabled"` } // LLMSourceConfig 外部 LLM/STT 配置中心 MySQL 连接 diff --git a/deploy/docker/postgres/init.sql b/deploy/docker/postgres/init.sql index 5e550e2..2bb6ae5 100644 --- a/deploy/docker/postgres/init.sql +++ b/deploy/docker/postgres/init.sql @@ -81,6 +81,40 @@ COMMENT ON COLUMN auth_user_roles.user_id IS '关联的用户 ID'; COMMENT ON COLUMN auth_user_roles.role_id IS '关联的角色 ID'; COMMENT ON COLUMN auth_user_roles.created_at IS '角色分配时间'; +-- ============================================================ +-- auth_oauth_accounts: 第三方登录账号绑定表 +-- 存储微信/QQ 等 OAuth 账号与本地 auth_users 的绑定关系 +-- ============================================================ +CREATE TABLE auth_oauth_accounts ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES auth_users(id), + provider VARCHAR(20) NOT NULL, + open_id VARCHAR(128) NOT NULL, + union_id VARCHAR(128) NOT NULL DEFAULT '', + nickname VARCHAR(100) NOT NULL DEFAULT '', + avatar VARCHAR(500) NOT NULL DEFAULT '', + access_token VARCHAR(1024) NOT NULL DEFAULT '', + refresh_token VARCHAR(1024) NOT NULL DEFAULT '', + expires_at TIMESTAMP(0) DEFAULT NULL, + created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), + UNIQUE (provider, open_id) +); + +COMMENT ON TABLE auth_oauth_accounts IS '第三方登录账号绑定表'; +COMMENT ON COLUMN auth_oauth_accounts.user_id IS '关联本地用户 ID'; +COMMENT ON COLUMN auth_oauth_accounts.provider IS '第三方平台标识:wechat / qq'; +COMMENT ON COLUMN auth_oauth_accounts.open_id IS '第三方平台用户 OpenID'; +COMMENT ON COLUMN auth_oauth_accounts.union_id IS '第三方平台 UnionID'; +COMMENT ON COLUMN auth_oauth_accounts.nickname IS '第三方平台昵称快照'; +COMMENT ON COLUMN auth_oauth_accounts.avatar IS '第三方平台头像快照'; +COMMENT ON COLUMN auth_oauth_accounts.access_token IS '第三方平台 access_token'; +COMMENT ON COLUMN auth_oauth_accounts.refresh_token IS '第三方平台 refresh_token'; +COMMENT ON COLUMN auth_oauth_accounts.expires_at IS '第三方平台 token 过期时间'; + +CREATE INDEX idx_auth_oauth_accounts_user ON auth_oauth_accounts (user_id); +CREATE INDEX idx_auth_oauth_accounts_union ON auth_oauth_accounts (union_id); + -- ============================================================ -- 插入默认超级管理员(系统预置唯一账号) -- 用户名: super_admin 密码: admin123456 (bcrypt hash) diff --git a/deploy/docker/postgres/oauth_migration.sql b/deploy/docker/postgres/oauth_migration.sql new file mode 100644 index 0000000..e98ee16 --- /dev/null +++ b/deploy/docker/postgres/oauth_migration.sql @@ -0,0 +1,35 @@ +-- ============================================================ +-- OAuth 登录数据库迁移:auth_oauth_accounts +-- 执行环境:在已有 EchoChat 数据库基础上增量升级 +-- 全部语句使用 IF NOT EXISTS / 幂等形式,可重复执行 +-- ============================================================ + +CREATE TABLE IF NOT EXISTS auth_oauth_accounts ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES auth_users(id), + provider VARCHAR(20) NOT NULL, + open_id VARCHAR(128) NOT NULL, + union_id VARCHAR(128) NOT NULL DEFAULT '', + nickname VARCHAR(100) NOT NULL DEFAULT '', + avatar VARCHAR(500) NOT NULL DEFAULT '', + access_token VARCHAR(1024) NOT NULL DEFAULT '', + refresh_token VARCHAR(1024) NOT NULL DEFAULT '', + expires_at TIMESTAMP(0) DEFAULT NULL, + created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), + UNIQUE (provider, open_id) +); + +COMMENT ON TABLE auth_oauth_accounts IS '第三方登录账号绑定表'; +COMMENT ON COLUMN auth_oauth_accounts.user_id IS '关联本地用户 ID'; +COMMENT ON COLUMN auth_oauth_accounts.provider IS '第三方平台标识:wechat / qq'; +COMMENT ON COLUMN auth_oauth_accounts.open_id IS '第三方平台用户 OpenID'; +COMMENT ON COLUMN auth_oauth_accounts.union_id IS '第三方平台 UnionID'; +COMMENT ON COLUMN auth_oauth_accounts.nickname IS '第三方平台昵称快照'; +COMMENT ON COLUMN auth_oauth_accounts.avatar IS '第三方平台头像快照'; +COMMENT ON COLUMN auth_oauth_accounts.access_token IS '第三方平台 access_token'; +COMMENT ON COLUMN auth_oauth_accounts.refresh_token IS '第三方平台 refresh_token'; +COMMENT ON COLUMN auth_oauth_accounts.expires_at IS '第三方平台 token 过期时间'; + +CREATE INDEX IF NOT EXISTS idx_auth_oauth_accounts_user ON auth_oauth_accounts (user_id); +CREATE INDEX IF NOT EXISTS idx_auth_oauth_accounts_union ON auth_oauth_accounts (union_id); diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js index ee5134d..922e1fa 100644 --- a/frontend/src/api/auth.js +++ b/frontend/src/api/auth.js @@ -37,6 +37,10 @@ const login = (data) => { return post('/api/v1/auth/login', data, { needAuth: false }) } +const getOAuthAuthorizeUrl = (provider) => { + return get(`/api/v1/auth/oauth/${provider}/authorize`, {}, { needAuth: false }) +} + /** * 退出登录 * POST /api/v1/auth/logout @@ -107,6 +111,7 @@ const changePassword = (data) => { export default { register, login, + getOAuthAuthorizeUrl, logout, refreshToken, getProfile, diff --git a/frontend/src/pages.json b/frontend/src/pages.json index c8d1b49..cc0ea34 100644 --- a/frontend/src/pages.json +++ b/frontend/src/pages.json @@ -31,6 +31,12 @@ "navigationBarTitleText": "登录中" } }, + { + "path": "pages/auth/oauth-callback", + "style": { + "navigationBarTitleText": "登录中" + } + }, { "path": "pages/chat/index", "style": { diff --git a/frontend/src/pages/auth/login.vue b/frontend/src/pages/auth/login.vue index ba86553..d94d020 100644 --- a/frontend/src/pages/auth/login.vue +++ b/frontend/src/pages/auth/login.vue @@ -73,6 +73,18 @@ {{ loading ? '登录中…' : '登录' }} + + + + 其他登录方式 + + + + + + + + 还没有账号? @@ -94,6 +106,7 @@ import { useWebSocketStore } from '@/store/websocket' import { useChatStore } from '@/store/chat' import { useContactStore } from '@/store/contact' import { useNotifyStore } from '@/store/notify' +import authApi from '@/api/auth' function readSsoQueryFromLocation() { try { @@ -125,6 +138,7 @@ export default { form: { account: '', password: '' }, passwordVisible: false, loading: false, + oauthLoading: false, focusState: { account: false, password: false }, errors: { account: '', password: '' } } @@ -200,6 +214,29 @@ export default { goRegister() { uni.navigateTo({ url: '/pages/auth/register' }) + }, + + async oauthLogin(provider) { + if (this.oauthLoading) return + this.oauthLoading = true + try { + const res = await authApi.getOAuthAuthorizeUrl(provider) + const url = res && res.data && res.data.url + if (!url) { + uni.showToast({ title: '第三方登录未配置', icon: 'none' }) + return + } + // #ifdef H5 + window.location.href = url + // #endif + // #ifndef H5 + uni.showToast({ title: '当前端暂不支持第三方登录', icon: 'none' }) + // #endif + } catch (e) { + console.error('第三方登录失败:', e) + } finally { + this.oauthLoading = false + } } } } @@ -362,6 +399,54 @@ export default { letter-spacing: 2rpx; } +.oauth-section { + margin-top: 40rpx; +} + +.oauth-divider { + display: flex; + align-items: center; + gap: 20rpx; +} + +.oauth-line { + flex: 1; + height: 2rpx; + background-color: #E2E8F0; +} + +.oauth-text { + font-size: 24rpx; + color: #94A3B8; +} + +.oauth-buttons { + display: flex; + gap: 24rpx; + margin-top: 28rpx; +} + +.oauth-btn { + flex: 1; + height: 80rpx; + border-radius: 16rpx; + font-size: 28rpx; + color: #FFFFFF; + border: none; +} + +.oauth-btn::after { + border: none; +} + +.oauth-wechat { + background-color: #07C160; +} + +.oauth-qq { + background-color: #12B7F5; +} + /* ---- 底部链接 ---- */ .link-row { display: flex; diff --git a/frontend/src/pages/auth/oauth-callback.vue b/frontend/src/pages/auth/oauth-callback.vue new file mode 100644 index 0000000..1887b74 --- /dev/null +++ b/frontend/src/pages/auth/oauth-callback.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/frontend/src/store/user.js b/frontend/src/store/user.js index ec27eef..f7d2122 100644 --- a/frontend/src/store/user.js +++ b/frontend/src/store/user.js @@ -106,6 +106,11 @@ export const useUserStore = defineStore('user', () => { return res.data } + const applyOAuthLogin = (data) => { + _handleAuthSuccess(data) + return data + } + /** * 退出登录 * 1. 调用后端 API 从 Redis 删除 Token(使 Token 立即失效) @@ -207,6 +212,7 @@ export const useUserStore = defineStore('user', () => { // actions login, register, + applyOAuthLogin, logout, getProfile, updateProfile,