Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
f6542914
Commit
f6542914
authored
Apr 20, 2026
by
IanShaw027
Browse files
fix: route legacy linuxdo users to account binding
parent
5adefb46
Changes
2
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/auth_linuxdo_oauth.go
View file @
f6542914
...
@@ -15,6 +15,8 @@ import (
...
@@ -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) {
...
@@ -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) {
...
@@ -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) {
...
@@ -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) {
...
@@ -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"`
...
...
backend/internal/handler/auth_linuxdo_oauth_test.go
View file @
f6542914
...
@@ -300,6 +300,82 @@ func TestLinuxDoOAuthCallbackCreatesLoginPendingSessionForExistingUser(t *testin
...
@@ -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
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment