fix: route legacy linuxdo users to account binding
This commit is contained in:
@@ -15,6 +15,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||||
|
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
|
"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", "")
|
redirectOAuthError(c, frontendCallback, "userinfo_failed", "failed to fetch user info", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
compatEmail := strings.TrimSpace(email)
|
||||||
|
|
||||||
// 安全考虑:不要把第三方返回的 email 直接映射到本地账号(可能与本地邮箱用户冲突导致账号被接管)。
|
// 安全考虑:不要把第三方返回的 email 直接映射到本地账号(可能与本地邮箱用户冲突导致账号被接管)。
|
||||||
// 统一使用基于 subject 的稳定合成邮箱来做账号绑定。
|
// 统一使用基于 subject 的稳定合成邮箱来做账号绑定。
|
||||||
@@ -255,6 +258,9 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
|||||||
"suggested_display_name": displayName,
|
"suggested_display_name": displayName,
|
||||||
"suggested_avatar_url": avatarURL,
|
"suggested_avatar_url": avatarURL,
|
||||||
}
|
}
|
||||||
|
if compatEmail != "" && !strings.EqualFold(strings.TrimSpace(compatEmail), strings.TrimSpace(email)) {
|
||||||
|
upstreamClaims["compat_email"] = compatEmail
|
||||||
|
}
|
||||||
if intent == oauthIntentBindCurrentUser {
|
if intent == oauthIntentBindCurrentUser {
|
||||||
targetUserID, err := h.readOAuthBindUserIDFromCookie(c, linuxDoOAuthBindUserCookieName)
|
targetUserID, err := h.readOAuthBindUserIDFromCookie(c, linuxDoOAuthBindUserCookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -314,6 +320,33 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
|||||||
return
|
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 h.isForceEmailOnThirdPartySignup(c.Request.Context()) {
|
||||||
if err := h.createOAuthEmailRequiredPendingSession(c, identityKey, redirectTo, browserSessionKey, upstreamClaims); err != nil {
|
if err := h.createOAuthEmailRequiredPendingSession(c, identityKey, redirectTo, browserSessionKey, upstreamClaims); err != nil {
|
||||||
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
||||||
@@ -372,6 +405,32 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
|||||||
redirectToFrontendCallback(c, frontendCallback)
|
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 {
|
type completeLinuxDoOAuthRequest struct {
|
||||||
InvitationCode string `json:"invitation_code" binding:"required"`
|
InvitationCode string `json:"invitation_code" binding:"required"`
|
||||||
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
|
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
|
||||||
|
|||||||
@@ -300,6 +300,82 @@ func TestLinuxDoOAuthCallbackCreatesLoginPendingSessionForExistingUser(t *testin
|
|||||||
require.Nil(t, completion["error"])
|
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) {
|
func TestLinuxDoOAuthCallbackCreatesInvitationPendingSessionWhenSignupRequiresInvite(t *testing.T) {
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
|
|||||||
Reference in New Issue
Block a user