You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@answer.apache.org by li...@apache.org on 2026/07/03 03:06:38 UTC

(answer) branch fix/2.0.2/user created (now 1e329eb5)

This is an automated email from the ASF dual-hosted git repository.

LinkinStars pushed a change to branch fix/2.0.2/user
in repository https://gitbox.apache.org/repos/asf/answer.git


      at 1e329eb5 fix external login account binding

This branch includes the following new commits:

     new 1e329eb5 fix external login account binding

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(answer) 01/01: fix external login account binding

Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

LinkinStars pushed a commit to branch fix/2.0.2/user
in repository https://gitbox.apache.org/repos/asf/answer.git

commit 1e329eb50132bce7f808820db02ea1f7e6304e27
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Thu Jul 2 17:41:16 2026 +0800

    fix external login account binding
---
 internal/base/constant/cache_key.go                |   3 +
 internal/controller/connector_controller.go        |  66 +++++++++++-
 .../user_external_login_repo.go                    |  26 +++++
 internal/schema/user_external_login_schema.go      |  12 +++
 .../user_external_login_service.go                 | 113 +++++++++++++++++----
 5 files changed, 198 insertions(+), 22 deletions(-)

diff --git a/internal/base/constant/cache_key.go b/internal/base/constant/cache_key.go
index 987798d1..ab278cc0 100644
--- a/internal/base/constant/cache_key.go
+++ b/internal/base/constant/cache_key.go
@@ -42,6 +42,9 @@ const (
 	ConfigCacheTime                            = 1 * time.Hour
 	ConnectorUserExternalInfoCacheKey          = "answer:connector:"
 	ConnectorUserExternalInfoCacheTime         = 10 * time.Minute
+	ConnectorOAuthStateCacheKey                = "answer:connector:oauth-state:"
+	ConnectorOAuthStateCacheTime               = 10 * time.Minute
+	ConnectorOAuthBindStateCacheTime           = 5 * time.Minute
 	SiteMapQuestionCacheKeyPrefix              = "answer:sitemap:question:%d"
 	SiteMapQuestionCacheTime                   = time.Hour
 	SitemapMaxSize                             = 50000
diff --git a/internal/controller/connector_controller.go b/internal/controller/connector_controller.go
index 939a2a09..28aa5fc6 100644
--- a/internal/controller/connector_controller.go
+++ b/internal/controller/connector_controller.go
@@ -22,6 +22,7 @@ package controller
 import (
 	"fmt"
 	"net/http"
+	"net/url"
 
 	"github.com/apache/answer/internal/base/handler"
 	"github.com/apache/answer/internal/base/middleware"
@@ -106,6 +107,27 @@ func (cc *ConnectorController) ConnectorLogin(connector plugin.Connector) (fn fu
 			return
 		}
 
+		state := ctx.Query("state")
+		if len(state) > 0 {
+			stateInfo, err := cc.userExternalService.GetOAuthState(ctx, state)
+			if err != nil || stateInfo == nil || stateInfo.Provider != connector.ConnectorSlugName() {
+				log.Errorf("invalid connector oauth state for provider %s", connector.ConnectorSlugName())
+				ctx.Redirect(http.StatusFound, "/50x")
+				return
+			}
+		} else {
+			state, err = cc.userExternalService.GenerateOAuthState(ctx, connector.ConnectorSlugName(),
+				schema.ExternalLoginOAuthStateLoginIntent, "")
+			if err != nil {
+				log.Errorf("generate connector oauth state failed: %v", err)
+				ctx.Redirect(http.StatusFound, "/50x")
+				return
+			}
+			q := ctx.Request.URL.Query()
+			q.Set("state", state)
+			ctx.Request.URL.RawQuery = q.Encode()
+		}
+
 		receiverURL := fmt.Sprintf("%s%s%s%s", general.SiteUrl,
 			commonRouterPrefix, ConnectorRedirectRouterPrefix, connector.ConnectorSlugName())
 		redirectURL := connector.ConnectorSender(ctx, receiverURL)
@@ -141,6 +163,27 @@ func (cc *ConnectorController) ConnectorRedirect(connector plugin.Connector) (fn
 			Avatar:      userInfo.Avatar,
 			MetaInfo:    userInfo.MetaInfo,
 		}
+		stateInfo, err := cc.userExternalService.ConsumeOAuthState(ctx, ctx.Query("state"))
+		if err != nil {
+			log.Errorf("get connector oauth state failed: %v", err)
+			ctx.Redirect(http.StatusFound, "/50x")
+			return
+		}
+		if stateInfo != nil && stateInfo.Provider != connector.ConnectorSlugName() {
+			log.Errorf("connector oauth state provider mismatch: %s != %s",
+				stateInfo.Provider, connector.ConnectorSlugName())
+			ctx.Redirect(http.StatusFound, "/50x")
+			return
+		}
+		if stateInfo != nil && stateInfo.Intent == schema.ExternalLoginOAuthStateBindIntent {
+			if err = cc.userExternalService.BindExternalLoginToUser(ctx, stateInfo.UserID, u); err != nil {
+				log.Errorf("bind external login failed: %v", err)
+				ctx.Redirect(http.StatusFound, "/50x")
+				return
+			}
+			ctx.Redirect(http.StatusFound, fmt.Sprintf("%s/users/settings/account", siteGeneral.SiteUrl))
+			return
+		}
 		resp, err := cc.userExternalService.ExternalLogin(ctx, u)
 		if err != nil {
 			log.Errorf("external login failed: %v", err)
@@ -237,19 +280,32 @@ func (cc *ConnectorController) ConnectorsUserInfo(ctx *gin.Context) {
 	}
 
 	resp := make([]*schema.ConnectorUserInfoResp, 0)
-	_ = plugin.CallConnector(func(fn plugin.Connector) error {
+	err = plugin.CallConnector(func(fn plugin.Connector) error {
 		externalID := userExternalLoginMapping[fn.ConnectorSlugName()]
 		connectorName := fn.ConnectorName()
+		link := fmt.Sprintf("%s%s%s%s", general.SiteUrl,
+			commonRouterPrefix, ConnectorLoginRouterPrefix, fn.ConnectorSlugName())
+		if len(externalID) == 0 {
+			state, err := cc.userExternalService.GenerateOAuthState(ctx, fn.ConnectorSlugName(),
+				schema.ExternalLoginOAuthStateBindIntent, userID)
+			if err != nil {
+				return err
+			}
+			link = fmt.Sprintf("%s?state=%s", link, url.QueryEscape(state))
+		}
 		resp = append(resp, &schema.ConnectorUserInfoResp{
-			Name: connectorName.Translate(ctx),
-			Icon: fn.ConnectorLogoSVG(),
-			Link: fmt.Sprintf("%s%s%s%s", general.SiteUrl,
-				commonRouterPrefix, ConnectorLoginRouterPrefix, fn.ConnectorSlugName()),
+			Name:       connectorName.Translate(ctx),
+			Icon:       fn.ConnectorLogoSVG(),
+			Link:       link,
 			Binding:    len(externalID) > 0,
 			ExternalID: externalID,
 		})
 		return nil
 	})
+	if err != nil {
+		handler.HandleResponse(ctx, err, nil)
+		return
+	}
 	handler.HandleResponse(ctx, nil, resp)
 }
 
diff --git a/internal/repo/user_external_login/user_external_login_repo.go b/internal/repo/user_external_login/user_external_login_repo.go
index b5cf85e8..12d2a339 100644
--- a/internal/repo/user_external_login/user_external_login_repo.go
+++ b/internal/repo/user_external_login/user_external_login_repo.go
@@ -22,6 +22,7 @@ package user_external_login
 import (
 	"context"
 	"encoding/json"
+	"time"
 
 	"github.com/apache/answer/internal/base/constant"
 	"github.com/apache/answer/internal/base/data"
@@ -136,3 +137,28 @@ func (ur *userExternalLoginRepo) GetCacheUserExternalLoginInfo(
 	_ = json.Unmarshal([]byte(res), &info)
 	return info, nil
 }
+
+func (ur *userExternalLoginRepo) SetCacheOAuthState(
+	ctx context.Context, state string, info *schema.ExternalLoginOAuthState, duration time.Duration) (err error) {
+	cacheData, _ := json.Marshal(info)
+	return ur.data.Cache.SetString(ctx, constant.ConnectorOAuthStateCacheKey+state,
+		string(cacheData), duration)
+}
+
+func (ur *userExternalLoginRepo) GetCacheOAuthState(
+	ctx context.Context, state string) (info *schema.ExternalLoginOAuthState, err error) {
+	res, exist, err := ur.data.Cache.GetString(ctx, constant.ConnectorOAuthStateCacheKey+state)
+	if err != nil {
+		return info, err
+	}
+	if !exist {
+		return nil, nil
+	}
+	info = &schema.ExternalLoginOAuthState{}
+	_ = json.Unmarshal([]byte(res), &info)
+	return info, nil
+}
+
+func (ur *userExternalLoginRepo) DeleteCacheOAuthState(ctx context.Context, state string) (err error) {
+	return ur.data.Cache.Del(ctx, constant.ConnectorOAuthStateCacheKey+state)
+}
diff --git a/internal/schema/user_external_login_schema.go b/internal/schema/user_external_login_schema.go
index 21389e9c..5ec7de12 100644
--- a/internal/schema/user_external_login_schema.go
+++ b/internal/schema/user_external_login_schema.go
@@ -19,6 +19,11 @@
 
 package schema
 
+const (
+	ExternalLoginOAuthStateLoginIntent = "login"
+	ExternalLoginOAuthStateBindIntent  = "bind"
+)
+
 // UserExternalLoginResp user external login resp
 type UserExternalLoginResp struct {
 	BindingKey  string `json:"binding_key"`
@@ -75,6 +80,13 @@ type ExternalLoginUserInfoCache struct {
 	Bio string
 }
 
+// ExternalLoginOAuthState stores the local OAuth request state.
+type ExternalLoginOAuthState struct {
+	Provider string `json:"provider"`
+	Intent   string `json:"intent"`
+	UserID   string `json:"user_id,omitempty"`
+}
+
 // ExternalLoginUnbindingReq external login unbinding user
 type ExternalLoginUnbindingReq struct {
 	ExternalID string `validate:"required,gt=0,lte=128" json:"external_id"`
diff --git a/internal/service/user_external_login/user_external_login_service.go b/internal/service/user_external_login/user_external_login_service.go
index 3f82179b..2d209dbb 100644
--- a/internal/service/user_external_login/user_external_login_service.go
+++ b/internal/service/user_external_login/user_external_login_service.go
@@ -54,6 +54,10 @@ type UserExternalLoginRepo interface {
 	DeleteUserExternalLoginByUserID(ctx context.Context, userID string) (err error)
 	SetCacheUserExternalLoginInfo(ctx context.Context, key string, info *schema.ExternalLoginUserInfoCache) (err error)
 	GetCacheUserExternalLoginInfo(ctx context.Context, key string) (info *schema.ExternalLoginUserInfoCache, err error)
+	SetCacheOAuthState(ctx context.Context, state string, info *schema.ExternalLoginOAuthState,
+		duration time.Duration) (err error)
+	GetCacheOAuthState(ctx context.Context, state string) (info *schema.ExternalLoginOAuthState, err error)
+	DeleteCacheOAuthState(ctx context.Context, state string) (err error)
 }
 
 // UserExternalLoginService user external login service
@@ -88,6 +92,41 @@ func NewUserExternalLoginService(
 	}
 }
 
+func (us *UserExternalLoginService) GenerateOAuthState(
+	ctx context.Context, provider, intent, userID string) (state string, err error) {
+	state = token.GenerateToken()
+	duration := constant.ConnectorOAuthStateCacheTime
+	if intent == schema.ExternalLoginOAuthStateBindIntent {
+		duration = constant.ConnectorOAuthBindStateCacheTime
+	}
+	err = us.userExternalLoginRepo.SetCacheOAuthState(ctx, state, &schema.ExternalLoginOAuthState{
+		Provider: provider,
+		Intent:   intent,
+		UserID:   userID,
+	}, duration)
+	return state, err
+}
+
+func (us *UserExternalLoginService) GetOAuthState(
+	ctx context.Context, state string) (info *schema.ExternalLoginOAuthState, err error) {
+	if len(state) == 0 {
+		return nil, nil
+	}
+	return us.userExternalLoginRepo.GetCacheOAuthState(ctx, state)
+}
+
+func (us *UserExternalLoginService) ConsumeOAuthState(
+	ctx context.Context, state string) (info *schema.ExternalLoginOAuthState, err error) {
+	info, err = us.GetOAuthState(ctx, state)
+	if err != nil || info == nil {
+		return info, err
+	}
+	if err = us.userExternalLoginRepo.DeleteCacheOAuthState(ctx, state); err != nil {
+		log.Errorf("delete oauth state failed: %v", err)
+	}
+	return info, nil
+}
+
 // ExternalLogin if user is already a member logged in
 func (us *UserExternalLoginService) ExternalLogin(
 	ctx context.Context, externalUserInfo *schema.ExternalLoginUserInfoCache) (
@@ -151,12 +190,16 @@ func (us *UserExternalLoginService) ExternalLogin(
 	if err != nil {
 		return nil, err
 	}
+	if exist {
+		return &schema.UserExternalLoginResp{
+			ErrTitle: translator.Tr(handler.GetLangByCtx(ctx), reason.UserAccessDenied),
+			ErrMsg:   translator.Tr(handler.GetLangByCtx(ctx), reason.UserAccessDenied),
+		}, nil
+	}
 	// if user is not a member, register a new user
-	if !exist {
-		oldUserInfo, err = us.registerNewUser(ctx, externalUserInfo)
-		if err != nil {
-			return nil, err
-		}
+	oldUserInfo, err = us.registerNewUser(ctx, externalUserInfo)
+	if err != nil {
+		return nil, err
 	}
 	// bind external user info to user
 	err = us.bindOldUser(ctx, externalUserInfo, oldUserInfo)
@@ -176,10 +219,41 @@ func (us *UserExternalLoginService) ExternalLogin(
 	}
 
 	accessToken, _, err := us.userCommonService.CacheLoginUserInfo(
-		ctx, oldUserInfo.ID, newMailStatus, oldUserInfo.Status, oldExternalLoginUserInfo.ExternalID)
+		ctx, oldUserInfo.ID, newMailStatus, oldUserInfo.Status, externalUserInfo.ExternalID)
 	return &schema.UserExternalLoginResp{AccessToken: accessToken}, err
 }
 
+func (us *UserExternalLoginService) BindExternalLoginToUser(ctx context.Context,
+	userID string, externalUserInfo *schema.ExternalLoginUserInfoCache) error {
+	if len(userID) == 0 || len(externalUserInfo.ExternalID) == 0 {
+		return errors.BadRequest(reason.UserAccessDenied)
+	}
+	oldUserInfo, exist, err := us.userRepo.GetByUserID(ctx, userID)
+	if err != nil {
+		return err
+	}
+	if !exist || oldUserInfo.Status == entity.UserStatusDeleted {
+		return errors.BadRequest(reason.UserNotFound)
+	}
+	oldExternalLoginUserInfo, exist, err := us.userExternalLoginRepo.GetByExternalID(ctx,
+		externalUserInfo.Provider, externalUserInfo.ExternalID)
+	if err != nil {
+		return err
+	}
+	if exist && oldExternalLoginUserInfo.UserID != userID {
+		return errors.BadRequest(reason.UserAccessDenied)
+	}
+	currentExternalLoginUserInfo, exist, err := us.userExternalLoginRepo.GetByUserID(ctx,
+		externalUserInfo.Provider, userID)
+	if err != nil {
+		return err
+	}
+	if exist && currentExternalLoginUserInfo.ExternalID != externalUserInfo.ExternalID {
+		return errors.BadRequest(reason.UserAccessDenied)
+	}
+	return us.bindOldUser(ctx, externalUserInfo, oldUserInfo)
+}
+
 func (us *UserExternalLoginService) registerNewUser(ctx context.Context,
 	externalUserInfo *schema.ExternalLoginUserInfoCache) (userInfo *entity.User, err error) {
 	userInfo = &entity.User{}
@@ -297,18 +371,20 @@ func (us *UserExternalLoginService) ExternalLoginBindingUserSendEmail(
 		resp.EmailExistAndMustBeConfirmed = true
 		return resp, nil
 	}
+	if exist {
+		resp.EmailExistAndMustBeConfirmed = true
+		return resp, nil
+	}
 
-	if !exist {
-		externalLoginInfo.Email = req.Email
-		userInfo, err = us.registerNewUser(ctx, externalLoginInfo)
-		if err != nil {
-			return nil, err
-		}
-		resp.AccessToken, _, err = us.userCommonService.CacheLoginUserInfo(
-			ctx, userInfo.ID, userInfo.MailStatus, userInfo.Status, externalLoginInfo.ExternalID)
-		if err != nil {
-			log.Error(err)
-		}
+	externalLoginInfo.Email = req.Email
+	userInfo, err = us.registerNewUser(ctx, externalLoginInfo)
+	if err != nil {
+		return nil, err
+	}
+	resp.AccessToken, _, err = us.userCommonService.CacheLoginUserInfo(
+		ctx, userInfo.ID, userInfo.MailStatus, userInfo.Status, externalLoginInfo.ExternalID)
+	if err != nil {
+		log.Error(err)
 	}
 	err = us.userExternalLoginRepo.SetCacheUserExternalLoginInfo(ctx, req.BindingKey, externalLoginInfo)
 	if err != nil {
@@ -340,6 +416,9 @@ func (us *UserExternalLoginService) ExternalLoginBindingUser(
 	if err != nil || externalLoginInfo == nil {
 		return errors.BadRequest(reason.UserNotFound)
 	}
+	if len(externalLoginInfo.Email) == 0 || externalLoginInfo.Email != oldUserInfo.EMail {
+		return errors.BadRequest(reason.UserAccessDenied)
+	}
 	return us.bindOldUser(ctx, externalLoginInfo, oldUserInfo)
 }