视频连线
This commit is contained in:
@@ -2,9 +2,16 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/echochat/backend/app/auth/service"
|
"github.com/echochat/backend/app/auth/service"
|
||||||
"github.com/echochat/backend/app/constants"
|
"github.com/echochat/backend/app/constants"
|
||||||
"github.com/echochat/backend/app/dto"
|
"github.com/echochat/backend/app/dto"
|
||||||
|
"github.com/echochat/backend/config"
|
||||||
"github.com/echochat/backend/pkg/logs"
|
"github.com/echochat/backend/pkg/logs"
|
||||||
"github.com/echochat/backend/pkg/middleware"
|
"github.com/echochat/backend/pkg/middleware"
|
||||||
"github.com/echochat/backend/pkg/utils"
|
"github.com/echochat/backend/pkg/utils"
|
||||||
@@ -16,11 +23,13 @@ import (
|
|||||||
// 处理用户注册、登录、Token 刷新、个人信息管理等接口
|
// 处理用户注册、登录、Token 刷新、个人信息管理等接口
|
||||||
type AuthController struct {
|
type AuthController struct {
|
||||||
authService *service.AuthService
|
authService *service.AuthService
|
||||||
|
oauthService *service.OAuthService
|
||||||
|
oauthCfg *config.OAuthConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthController 创建前台认证控制器实例
|
// NewAuthController 创建前台认证控制器实例
|
||||||
func NewAuthController(authService *service.AuthService) *AuthController {
|
func NewAuthController(authService *service.AuthService, oauthService *service.OAuthService, oauthCfg *config.OAuthConfig) *AuthController {
|
||||||
return &AuthController{authService: authService}
|
return &AuthController{authService: authService, oauthService: oauthService, oauthCfg: oauthCfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register 用户注册
|
// Register 用户注册
|
||||||
@@ -77,6 +86,53 @@ func (ctrl *AuthController) Login(c *gin.Context) {
|
|||||||
utils.ResponseOK(c, resp)
|
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 用户登出
|
// Logout 用户登出
|
||||||
// POST /api/v1/auth/logout(需认证)
|
// POST /api/v1/auth/logout(需认证)
|
||||||
func (ctrl *AuthController) Logout(c *gin.Context) {
|
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())
|
utils.ResponseForbidden(c, err.Error())
|
||||||
case service.ErrRefreshTokenType:
|
case service.ErrRefreshTokenType:
|
||||||
utils.ResponseBadRequest(c, err.Error())
|
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:
|
default:
|
||||||
msg := "服务器内部错误"
|
msg := "服务器内部错误"
|
||||||
if len(fallbackMsg) > 0 && fallbackMsg[0] != "" {
|
if len(fallbackMsg) > 0 && fallbackMsg[0] != "" {
|
||||||
|
|||||||
33
backend/go-service/app/auth/dao/oauth_account_dao.go
Normal file
33
backend/go-service/app/auth/dao/oauth_account_dao.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
22
backend/go-service/app/auth/model/oauth_account.go
Normal file
22
backend/go-service/app/auth/model/oauth_account.go
Normal file
@@ -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"
|
||||||
|
}
|
||||||
@@ -13,8 +13,10 @@ import (
|
|||||||
var AuthSet = wire.NewSet(
|
var AuthSet = wire.NewSet(
|
||||||
dao.NewUserDAO,
|
dao.NewUserDAO,
|
||||||
dao.NewRoleDAO,
|
dao.NewRoleDAO,
|
||||||
|
dao.NewOAuthAccountDAO,
|
||||||
service.NewTokenStore,
|
service.NewTokenStore,
|
||||||
service.NewAuthService,
|
service.NewAuthService,
|
||||||
|
service.NewOAuthService,
|
||||||
controller.NewAuthController,
|
controller.NewAuthController,
|
||||||
controller.NewAdminAuthController,
|
controller.NewAdminAuthController,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ func RegisterRoutes(
|
|||||||
{
|
{
|
||||||
public.POST("/register", ctrl.Register)
|
public.POST("/register", ctrl.Register)
|
||||||
public.POST("/login", ctrl.Login)
|
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)
|
public.POST("/refresh-token", ctrl.RefreshToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
382
backend/go-service/app/auth/service/oauth_service.go
Normal file
382
backend/go-service/app/auth/service/oauth_service.go
Normal file
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -191,6 +191,10 @@ func provideMeetingConfig(cfg *config.Config) *config.MeetingConfig {
|
|||||||
return &cfg.Meeting
|
return &cfg.Meeting
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func provideOAuthConfig(cfg *config.Config) *config.OAuthConfig {
|
||||||
|
return &cfg.OAuth
|
||||||
|
}
|
||||||
|
|
||||||
// InfraSet 基础设施层 Provider Set
|
// InfraSet 基础设施层 Provider Set
|
||||||
var InfraSet = wire.NewSet(
|
var InfraSet = wire.NewSet(
|
||||||
provideDBConfig,
|
provideDBConfig,
|
||||||
@@ -200,6 +204,7 @@ var InfraSet = wire.NewSet(
|
|||||||
provideServerConfig,
|
provideServerConfig,
|
||||||
provideLLMSourceConfig,
|
provideLLMSourceConfig,
|
||||||
provideMeetingConfig,
|
provideMeetingConfig,
|
||||||
|
provideOAuthConfig,
|
||||||
db.NewPostgres,
|
db.NewPostgres,
|
||||||
db.NewRedis,
|
db.NewRedis,
|
||||||
db.NewLLMSourceDB,
|
db.NewLLMSourceDB,
|
||||||
|
|||||||
@@ -60,10 +60,13 @@ func InitializeApp(cfg *config.Config) (*App, error) {
|
|||||||
}
|
}
|
||||||
userDAO := dao.NewUserDAO(gormDB)
|
userDAO := dao.NewUserDAO(gormDB)
|
||||||
roleDAO := dao.NewRoleDAO(gormDB)
|
roleDAO := dao.NewRoleDAO(gormDB)
|
||||||
|
oauthAccountDAO := dao.NewOAuthAccountDAO(gormDB)
|
||||||
jwtConfig := provideJWTConfig(cfg)
|
jwtConfig := provideJWTConfig(cfg)
|
||||||
tokenStore := service.NewTokenStore(client, jwtConfig)
|
tokenStore := service.NewTokenStore(client, jwtConfig)
|
||||||
authService := service.NewAuthService(userDAO, roleDAO, jwtConfig, tokenStore)
|
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)
|
adminAuthController := controller.NewAdminAuthController(authService)
|
||||||
userManageDAO := dao2.NewUserManageDAO(gormDB)
|
userManageDAO := dao2.NewUserManageDAO(gormDB)
|
||||||
userManageService := service2.NewUserManageService(userManageDAO, userDAO, roleDAO)
|
userManageService := service2.NewUserManageService(userManageDAO, userDAO, roleDAO)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
authModel "github.com/echochat/backend/app/auth/model"
|
||||||
"github.com/echochat/backend/app/im/model"
|
"github.com/echochat/backend/app/im/model"
|
||||||
meetingModel "github.com/echochat/backend/app/meeting/model"
|
meetingModel "github.com/echochat/backend/app/meeting/model"
|
||||||
transcribeModel "github.com/echochat/backend/app/transcribe/model"
|
transcribeModel "github.com/echochat/backend/app/transcribe/model"
|
||||||
@@ -58,6 +59,7 @@ func main() {
|
|||||||
&model.Conversation{},
|
&model.Conversation{},
|
||||||
&model.ConversationMember{},
|
&model.ConversationMember{},
|
||||||
&model.Message{},
|
&model.Message{},
|
||||||
|
&authModel.OAuthAccount{},
|
||||||
// Phase B 新增:会议录制元数据表
|
// Phase B 新增:会议录制元数据表
|
||||||
&meetingModel.MeetingRecording{},
|
&meetingModel.MeetingRecording{},
|
||||||
// Phase B 新增:会议录制语音转写结果表
|
// Phase B 新增:会议录制语音转写结果表
|
||||||
|
|||||||
@@ -33,6 +33,20 @@ jwt:
|
|||||||
refresh_expire_day: 7 # Refresh Token 有效期(天),即 7 天
|
refresh_expire_day: 7 # Refresh Token 有效期(天),即 7 天
|
||||||
issuer: echochat # JWT 签发者标识
|
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:
|
log:
|
||||||
level: debug # 最低输出级别:debug / info / warn / error
|
level: debug # 最低输出级别:debug / info / warn / error
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ jwt:
|
|||||||
refresh_expire_day: 7
|
refresh_expire_day: 7
|
||||||
issuer: echochat
|
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:
|
log:
|
||||||
level: debug
|
level: debug
|
||||||
format: text
|
format: text
|
||||||
|
|||||||
@@ -20,6 +20,19 @@ type Config struct {
|
|||||||
MediaServer MediaServerConfig `mapstructure:"media_server"`
|
MediaServer MediaServerConfig `mapstructure:"media_server"`
|
||||||
Meeting MeetingConfig `mapstructure:"meeting"`
|
Meeting MeetingConfig `mapstructure:"meeting"`
|
||||||
LLMSource LLMSourceConfig `mapstructure:"llm_source"` // Phase B:外部 LLM 配置库(MySQL,t_llm_provider/model/key)
|
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 连接
|
// LLMSourceConfig 外部 LLM/STT 配置中心 MySQL 连接
|
||||||
|
|||||||
@@ -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.role_id IS '关联的角色 ID';
|
||||||
COMMENT ON COLUMN auth_user_roles.created_at IS '角色分配时间';
|
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)
|
-- 用户名: super_admin 密码: admin123456 (bcrypt hash)
|
||||||
|
|||||||
35
deploy/docker/postgres/oauth_migration.sql
Normal file
35
deploy/docker/postgres/oauth_migration.sql
Normal file
@@ -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);
|
||||||
@@ -37,6 +37,10 @@ const login = (data) => {
|
|||||||
return post('/api/v1/auth/login', data, { needAuth: false })
|
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
|
* POST /api/v1/auth/logout
|
||||||
@@ -107,6 +111,7 @@ const changePassword = (data) => {
|
|||||||
export default {
|
export default {
|
||||||
register,
|
register,
|
||||||
login,
|
login,
|
||||||
|
getOAuthAuthorizeUrl,
|
||||||
logout,
|
logout,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
getProfile,
|
getProfile,
|
||||||
|
|||||||
@@ -31,6 +31,12 @@
|
|||||||
"navigationBarTitleText": "登录中"
|
"navigationBarTitleText": "登录中"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/auth/oauth-callback",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "登录中"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/chat/index",
|
"path": "pages/chat/index",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
@@ -73,6 +73,18 @@
|
|||||||
<text class="btn-label">{{ loading ? '登录中…' : '登录' }}</text>
|
<text class="btn-label">{{ loading ? '登录中…' : '登录' }}</text>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<view class="oauth-section">
|
||||||
|
<view class="oauth-divider">
|
||||||
|
<view class="oauth-line"></view>
|
||||||
|
<text class="oauth-text">其他登录方式</text>
|
||||||
|
<view class="oauth-line"></view>
|
||||||
|
</view>
|
||||||
|
<view class="oauth-buttons">
|
||||||
|
<button class="oauth-btn oauth-wechat" :disabled="oauthLoading" @tap="oauthLogin('wechat')">微信登录</button>
|
||||||
|
<button class="oauth-btn oauth-qq" :disabled="oauthLoading" @tap="oauthLogin('qq')">QQ登录</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 底部链接 -->
|
<!-- 底部链接 -->
|
||||||
<view class="link-row">
|
<view class="link-row">
|
||||||
<text class="link-hint">还没有账号?</text>
|
<text class="link-hint">还没有账号?</text>
|
||||||
@@ -94,6 +106,7 @@ import { useWebSocketStore } from '@/store/websocket'
|
|||||||
import { useChatStore } from '@/store/chat'
|
import { useChatStore } from '@/store/chat'
|
||||||
import { useContactStore } from '@/store/contact'
|
import { useContactStore } from '@/store/contact'
|
||||||
import { useNotifyStore } from '@/store/notify'
|
import { useNotifyStore } from '@/store/notify'
|
||||||
|
import authApi from '@/api/auth'
|
||||||
|
|
||||||
function readSsoQueryFromLocation() {
|
function readSsoQueryFromLocation() {
|
||||||
try {
|
try {
|
||||||
@@ -125,6 +138,7 @@ export default {
|
|||||||
form: { account: '', password: '' },
|
form: { account: '', password: '' },
|
||||||
passwordVisible: false,
|
passwordVisible: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
oauthLoading: false,
|
||||||
focusState: { account: false, password: false },
|
focusState: { account: false, password: false },
|
||||||
errors: { account: '', password: '' }
|
errors: { account: '', password: '' }
|
||||||
}
|
}
|
||||||
@@ -200,6 +214,29 @@ export default {
|
|||||||
|
|
||||||
goRegister() {
|
goRegister() {
|
||||||
uni.navigateTo({ url: '/pages/auth/register' })
|
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;
|
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 {
|
.link-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
131
frontend/src/pages/auth/oauth-callback.vue
Normal file
131
frontend/src/pages/auth/oauth-callback.vue
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<template>
|
||||||
|
<view class="callback-page">
|
||||||
|
<view class="callback-card">
|
||||||
|
<view class="loading-dot"></view>
|
||||||
|
<text class="callback-title">正在完成登录</text>
|
||||||
|
<text class="callback-desc">请稍候,正在同步账号信息…</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { useUserStore } from '@/store/user'
|
||||||
|
import { useWebSocketStore } from '@/store/websocket'
|
||||||
|
import { useChatStore } from '@/store/chat'
|
||||||
|
import { useContactStore } from '@/store/contact'
|
||||||
|
import { useNotifyStore } from '@/store/notify'
|
||||||
|
|
||||||
|
function decodePayload(payload) {
|
||||||
|
const normalized = String(payload || '').replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4)
|
||||||
|
const binary = atob(padded)
|
||||||
|
return decodeURIComponent(Array.prototype.map.call(binary, c => {
|
||||||
|
return `%${(`00${c.charCodeAt(0).toString(16)}`).slice(-2)}`
|
||||||
|
}).join(''))
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRawQueryParams() {
|
||||||
|
const result = {}
|
||||||
|
try {
|
||||||
|
if (typeof window === 'undefined') return result
|
||||||
|
const search = String(window.location.search || '').replace(/^\?/, '')
|
||||||
|
const hash = String(window.location.hash || '')
|
||||||
|
const hashQuery = hash.indexOf('?') >= 0 ? hash.slice(hash.indexOf('?') + 1) : ''
|
||||||
|
;[search, hashQuery].forEach(raw => {
|
||||||
|
if (!raw) return
|
||||||
|
const params = new URLSearchParams(raw)
|
||||||
|
params.forEach((value, key) => {
|
||||||
|
if (result[key] === undefined) {
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (e) {}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'OAuthCallbackPage',
|
||||||
|
onLoad(query) {
|
||||||
|
this.handleCallback({ ...readRawQueryParams(), ...(query || {}) })
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async handleCallback(query) {
|
||||||
|
try {
|
||||||
|
if (query.status !== 'success') {
|
||||||
|
const message = query.payload ? decodePayload(query.payload) : '第三方登录失败'
|
||||||
|
uni.showToast({ title: message || '第三方登录失败', icon: 'none' })
|
||||||
|
setTimeout(() => uni.redirectTo({ url: '/pages/auth/login' }), 1200)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const loginData = JSON.parse(decodePayload(query.payload))
|
||||||
|
const store = useUserStore()
|
||||||
|
store.applyOAuthLogin(loginData)
|
||||||
|
const wsStore = useWebSocketStore()
|
||||||
|
wsStore.connect()
|
||||||
|
useChatStore().initWsListeners()
|
||||||
|
useContactStore().initWsListeners()
|
||||||
|
const notifyStore = useNotifyStore()
|
||||||
|
notifyStore.initWsListeners()
|
||||||
|
notifyStore.fetchUnreadCount().catch(() => {})
|
||||||
|
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||||
|
setTimeout(() => uni.reLaunch({ url: '/pages/index/index' }), 600)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('OAuth 回调处理失败:', e)
|
||||||
|
uni.showToast({ title: '登录失败', icon: 'none' })
|
||||||
|
setTimeout(() => uni.redirectTo({ url: '/pages/auth/login' }), 1200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.callback-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: #F8FAFC;
|
||||||
|
padding: 48rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callback-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640rpx;
|
||||||
|
padding: 64rpx 40rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dot {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 6rpx solid #DBEAFE;
|
||||||
|
border-top-color: #2563EB;
|
||||||
|
animation: spin 800ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callback-title {
|
||||||
|
margin-top: 32rpx;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1E293B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callback-desc {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #64748B;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -106,6 +106,11 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const applyOAuthLogin = (data) => {
|
||||||
|
_handleAuthSuccess(data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 退出登录
|
* 退出登录
|
||||||
* 1. 调用后端 API 从 Redis 删除 Token(使 Token 立即失效)
|
* 1. 调用后端 API 从 Redis 删除 Token(使 Token 立即失效)
|
||||||
@@ -207,6 +212,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
// actions
|
// actions
|
||||||
login,
|
login,
|
||||||
register,
|
register,
|
||||||
|
applyOAuthLogin,
|
||||||
logout,
|
logout,
|
||||||
getProfile,
|
getProfile,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
|
|||||||
Reference in New Issue
Block a user