fix: route legacy linuxdo users to account binding

This commit is contained in:
IanShaw027
2026-04-20 21:31:05 +08:00
parent 5adefb466b
commit f65429145e
2 changed files with 135 additions and 0 deletions

View File

@@ -15,6 +15,8 @@ import (
"time"
"unicode/utf8"
dbent "github.com/Wei-Shaw/sub2api/ent"
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
@@ -237,6 +239,7 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
redirectOAuthError(c, frontendCallback, "userinfo_failed", "failed to fetch user info", "")
return
}
compatEmail := strings.TrimSpace(email)
// 安全考虑:不要把第三方返回的 email 直接映射到本地账号(可能与本地邮箱用户冲突导致账号被接管)。
// 统一使用基于 subject 的稳定合成邮箱来做账号绑定。
@@ -255,6 +258,9 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
"suggested_display_name": displayName,
"suggested_avatar_url": avatarURL,
}
if compatEmail != "" && !strings.EqualFold(strings.TrimSpace(compatEmail), strings.TrimSpace(email)) {
upstreamClaims["compat_email"] = compatEmail
}
if intent == oauthIntentBindCurrentUser {
targetUserID, err := h.readOAuthBindUserIDFromCookie(c, linuxDoOAuthBindUserCookieName)
if err != nil {
@@ -314,6 +320,33 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
return
}
compatEmailUser, err := h.findLinuxDoCompatEmailUser(c.Request.Context(), compatEmail)
if err != nil {
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
return
}
if compatEmailUser != nil {
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
Intent: "adopt_existing_user_by_email",
Identity: identityKey,
TargetUserID: &compatEmailUser.ID,
ResolvedEmail: compatEmailUser.Email,
RedirectTo: redirectTo,
BrowserSessionKey: browserSessionKey,
UpstreamIdentityClaims: upstreamClaims,
CompletionResponse: map[string]any{
"redirect": redirectTo,
"step": "bind_login_required",
"email": compatEmailUser.Email,
},
}); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
return
}
redirectToFrontendCallback(c, frontendCallback)
return
}
if h.isForceEmailOnThirdPartySignup(c.Request.Context()) {
if err := h.createOAuthEmailRequiredPendingSession(c, identityKey, redirectTo, browserSessionKey, upstreamClaims); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
@@ -372,6 +405,32 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
redirectToFrontendCallback(c, frontendCallback)
}
func (h *AuthHandler) findLinuxDoCompatEmailUser(ctx context.Context, email string) (*dbent.User, error) {
client := h.entClient()
if client == nil {
return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
}
email = strings.TrimSpace(strings.ToLower(email))
if email == "" ||
strings.HasSuffix(email, service.LinuxDoConnectSyntheticEmailDomain) ||
strings.HasSuffix(email, service.OIDCConnectSyntheticEmailDomain) ||
strings.HasSuffix(email, service.WeChatConnectSyntheticEmailDomain) {
return nil, nil
}
userEntity, err := client.User.Query().
Where(dbuser.EmailEqualFold(email)).
Only(ctx)
if err != nil {
if dbent.IsNotFound(err) {
return nil, nil
}
return nil, infraerrors.InternalServer("COMPAT_EMAIL_LOOKUP_FAILED", "failed to look up compat email user").WithCause(err)
}
return userEntity, nil
}
type completeLinuxDoOAuthRequest struct {
InvitationCode string `json:"invitation_code" binding:"required"`
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`

View File

@@ -300,6 +300,82 @@ func TestLinuxDoOAuthCallbackCreatesLoginPendingSessionForExistingUser(t *testin
require.Nil(t, completion["error"])
}
func TestLinuxDoOAuthCallbackCreatesBindPendingSessionForCompatEmailUser(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`))
case "/userinfo":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"321","email":"legacy@example.com","username":"linuxdo_user","name":"LinuxDo Display","avatar_url":"https://cdn.example/linuxdo.png"}`))
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{
Enabled: true,
ClientID: "linuxdo-client",
ClientSecret: "linuxdo-secret",
AuthorizeURL: upstream.URL + "/authorize",
TokenURL: upstream.URL + "/token",
UserInfoURL: upstream.URL + "/userinfo",
Scopes: "read",
RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
FrontendRedirectURL: "/auth/linuxdo/callback",
TokenAuthMethod: "client_secret_post",
UsePKCE: true,
})
defer client.Close()
ctx := context.Background()
existingUser, err := client.User.Create().
SetEmail("legacy@example.com").
SetUsername("legacy-user").
SetPasswordHash("hash").
SetRole(service.RoleUser).
SetStatus(service.StatusActive).
Save(ctx)
require.NoError(t, err)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=code-compat&state=state-compat", nil)
req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-compat"))
req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/dashboard"))
req.AddCookie(encodedCookie(linuxDoOAuthVerifierCookie, "verifier-compat"))
req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentLogin))
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-compat"))
c.Request = req
handler.LinuxDoOAuthCallback(c)
require.Equal(t, http.StatusFound, recorder.Code)
require.Equal(t, "/auth/linuxdo/callback", recorder.Header().Get("Location"))
sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
require.NotNil(t, sessionCookie)
session, err := client.PendingAuthSession.Query().
Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
Only(ctx)
require.NoError(t, err)
require.Equal(t, "adopt_existing_user_by_email", session.Intent)
require.NotNil(t, session.TargetUserID)
require.Equal(t, existingUser.ID, *session.TargetUserID)
require.Equal(t, existingUser.Email, session.ResolvedEmail)
require.Equal(t, "legacy@example.com", session.UpstreamIdentityClaims["compat_email"])
completion := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
require.Equal(t, "/dashboard", completion["redirect"])
require.Equal(t, "bind_login_required", completion["step"])
require.Equal(t, existingUser.Email, completion["email"])
_, hasAccessToken := completion["access_token"]
require.False(t, hasAccessToken)
}
func TestLinuxDoOAuthCallbackCreatesInvitationPendingSessionWhenSignupRequiresInvite(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {