-
-
Notifications
You must be signed in to change notification settings - Fork 2.2k
fix: improve OAuth user binding and registration logic #2039
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Refactored GitHub and OIDC OAuth handlers to better handle existing users by email, prevent conflicting account bindings, and only allow new user registration if enabled. This improves account linking consistency and provides clearer error messages for users.
WalkthroughAdd email-based account linking to GitHub and OIDC login flows, defer registration gating to the point of creating new users, and update frontend account-binding buttons to show enabled / bound states and disable appropriately. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant U as User
participant GH as GitHub OAuth
participant C as Controller (github.go)
participant DB as Database
U->>GH: OAuth login
GH-->>C: Callback (github_id, email, name)
C->>DB: Find user by GitHubId
alt Found by GitHubId
DB-->>C: user
C-->>U: Login existing user
else Not found by GitHubId
alt Email present
C->>DB: Find user by email
alt Found by email
DB-->>C: user
alt GitHubId empty or matches
C->>DB: Update user.GitHubId (+ DisplayName if missing)
DB-->>C: updated user
C-->>U: Login linked user
else Conflict (different GitHubId)
C-->>U: Error (email bound to different GitHub)
end
else Email not found
alt RegisterEnabled
C->>DB: Create user
DB-->>C: new user
C-->>U: Login new user
else Registration disabled
C-->>U: Error (registration closed)
end
end
else No email
alt RegisterEnabled
C->>DB: Create user
DB-->>C: new user
C-->>U: Login new user
else Registration disabled
C-->>U: Error (registration closed)
end
end
end
sequenceDiagram
autonumber
participant U as User
participant OP as OIDC Provider
participant C as Controller (oidc.go)
participant DB as Database
U->>OP: OIDC login
OP-->>C: Callback (oidc_id, email, email_verified, name, preferred_username)
C->>DB: Find user by OidcId
alt Found by OidcId
DB-->>C: user
C-->>U: Login existing user
else Not found by OidcId
alt Email present and verified
C->>DB: Find user by email
alt Found by email
DB-->>C: user
alt OidcId empty or matches
C->>DB: Update user.OidcId (+ DisplayName if missing)
DB-->>C: updated user
C-->>U: Login linked user
else Conflict (different OidcId)
C-->>U: Error (email bound to another OIDC account)
end
else Email not found
alt RegisterEnabled
C->>DB: Create user (username from preferred_username or generated)
DB-->>C: new user
C-->>U: Login new user
else Registration disabled
C-->>U: Error (registration closed)
end
end
else Email missing or not verified
C-->>U: Error (email required/verified)
end
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (3 warnings)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (1)
🧰 Additional context used🧬 Code graph analysis (1)controller/oidc.go (4)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
🧹 Nitpick comments (1)
controller/oidc.go (1)
175-181
: Registration gating at creation point is correct; consider adding inviter support.The gating check is good. To keep parity with GitHub, pass inviter via session "aff" when inserting:
- err := user.Insert(0) + affCode := session.Get("aff") + inviterId := 0 + if affCode != nil { + inviterId, _ = model.GetUserIdByAffCode(affCode.(string)) + } + err := user.Insert(inviterId)
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
web/bun.lock
is excluded by!**/*.lock
📒 Files selected for processing (3)
controller/github.go
(2 hunks)controller/oidc.go
(2 hunks)web/src/components/settings/personal/cards/AccountManagement.jsx
(4 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
web/src/components/settings/personal/cards/AccountManagement.jsx (2)
web/src/components/auth/LoginForm.jsx (2)
status
(99-102)userState
(68-68)web/src/components/settings/PersonalSetting.jsx (2)
status
(61-61)userState
(48-48)
controller/oidc.go (4)
model/user.go (1)
User
(20-49)model/main.go (1)
DB
(64-64)common/gin.go (1)
ApiError
(97-102)common/constants.go (1)
RegisterEnabled
(51-51)
controller/github.go (4)
model/user.go (1)
User
(20-49)model/main.go (1)
DB
(64-64)common/gin.go (1)
ApiError
(97-102)common/constants.go (1)
RegisterEnabled
(51-51)
🔇 Additional comments (7)
web/src/components/settings/personal/cards/AccountManagement.jsx (4)
196-205
: WeChat binding state UX is correct.Disabled + label logic aligns with status and binding state. LGTM.
246-250
: GitHub binding label logic LGTM; verify config presence.Looks good. Ensure status.github_client_id is set whenever status.github_oauth is true to avoid a disabled-state mismatch.
293-297
: OIDC binding label logic LGTM.States are consistent with disabled condition.
413-417
: LinuxDO binding label logic LGTM.Consistent with disabled condition and bound state.
controller/github.go (2)
17-17
: Import gorm is appropriate for ErrRecordNotFound checks.
165-173
: Registration gating at creation point is good.This defers the “registration disabled” check until you know a new user is needed. LGTM.
controller/oidc.go (1)
19-19
: Import gorm is appropriate for DB not-found checks.
if githubUser.Email != "" { | ||
var existingUser model.User | ||
err := model.DB.Where("email = ?", githubUser.Email).First(&existingUser).Error | ||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { | ||
common.ApiError(c, err) | ||
return | ||
} | ||
if err == nil { | ||
if existingUser.GitHubId != "" && existingUser.GitHubId != githubUser.Login { | ||
c.JSON(http.StatusOK, gin.H{ | ||
"success": false, | ||
"message": "该邮箱对应的账户已绑定其他 GitHub 账户", | ||
}) | ||
return | ||
} | ||
existingUser.GitHubId = githubUser.Login | ||
if existingUser.DisplayName == "" { | ||
if githubUser.Name != "" { | ||
existingUser.DisplayName = githubUser.Name | ||
} else { | ||
existingUser.DisplayName = "GitHub User" | ||
} | ||
} | ||
if err := existingUser.Update(false); err != nil { | ||
common.ApiError(c, err) | ||
return | ||
} | ||
user = existingUser | ||
} | ||
} | ||
if user.Id == 0 { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
Do not link accounts using unverified GitHub emails (account takeover risk).
GitHub’s user.email may be empty or unverified and can be set to arbitrary addresses. Linking by this field enables hijacking existing accounts. Fetch the verified primary email via the /user/emails endpoint (requires scope user:email) and only link when verified.
Apply these changes:
- Return the access token from getGitHubUserInfoByCode so you can call /user/emails:
- func getGitHubUserInfoByCode(code string) (*GitHubUser, error) {
+ func getGitHubUserInfoByCode(code string) (*GitHubUser, string, error) {
@@
- if code == "" {
- return nil, errors.New("无效的参数")
+ if code == "" {
+ return nil, "", errors.New("无效的参数")
}
@@
- if err != nil {
- return nil, err
+ if err != nil {
+ return nil, "", err
}
@@
- if err != nil {
- return nil, err
+ if err != nil {
+ return nil, "", err
}
@@
- if githubUser.Login == "" {
- return nil, errors.New("返回值非法,用户字段为空,请稍后重试!")
+ if githubUser.Login == "" {
+ return nil, "", errors.New("返回值非法,用户字段为空,请稍后重试!")
}
- return &githubUser, nil
+ return &githubUser, oAuthResponse.AccessToken, nil
}
- Add a helper to fetch the verified primary email:
type GitHubEmail struct {
Email string `json:"email"`
Primary bool `json:"primary"`
Verified bool `json:"verified"`
}
func fetchGitHubVerifiedPrimaryEmail(token string) (string, error) {
req, _ := http.NewRequest("GET", "https://api.github.com/user/emails", nil)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
req.Header.Set("Accept", "application/vnd.github+json")
client := http.Client{Timeout: 5 * time.Second}
res, err := client.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
var emails []GitHubEmail
if err := json.NewDecoder(res.Body).Decode(&emails); err != nil {
return "", err
}
for _, e := range emails {
if e.Primary && e.Verified && e.Email != "" {
return strings.ToLower(e.Email), nil
}
}
return "", errors.New("未找到已验证的主邮箱")
}
- Update call site and linking logic:
- githubUser, err := getGitHubUserInfoByCode(code)
+ githubUser, accessToken, err := getGitHubUserInfoByCode(code)
@@
- if githubUser.Email != "" {
+ verifiedEmail := ""
+ if accessToken != "" {
+ if e, ferr := fetchGitHubVerifiedPrimaryEmail(accessToken); ferr == nil {
+ verifiedEmail = e
+ }
+ }
+ if verifiedEmail != "" {
var existingUser model.User
- err := model.DB.Where("email = ?", githubUser.Email).First(&existingUser).Error
+ err := model.DB.Where("LOWER(email) = ?", verifiedEmail).First(&existingUser).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
common.ApiError(c, err)
return
}
if err == nil {
if existingUser.GitHubId != "" && existingUser.GitHubId != githubUser.Login {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该邮箱对应的账户已绑定其他 GitHub 账户",
})
return
}
existingUser.GitHubId = githubUser.Login
@@
if err := existingUser.Update(false); err != nil {
common.ApiError(c, err)
return
}
user = existingUser
}
}
Additionally:
- Normalize stored emails to lowercase whenever assigning (e.g., user.Email = strings.ToLower(verifiedEmail)).
- Ensure github_id has a unique DB index to prevent races/double-binding.
🏁 Script executed:
#!/bin/bash
# Locate getGitHubUserInfoByCode definition and GitHubUser struct
rg -n "func getGitHubUserInfoByCode" -C5
rg -n "type GitHubUser" -C5 controller/github.go
Length of output: 900
🏁 Script executed:
#!/bin/bash
# Display full implementation of getGitHubUserInfoByCode
sed -n '1,200p' controller/github.go | sed -n '1,200!d'
Length of output: 0
🏁 Script executed:
#!/bin/bash
# Show implementation of getGitHubUserInfoByCode from lines 30–120
sed -n '30,120p' controller/github.go
Length of output: 2425
Secure GitHub email linking by verifying the primary email
Fetch and use only the user’s verified primary email instead of the public email
field to prevent account takeover:
- Change
func getGitHubUserInfoByCode(code string) (*GitHubUser, error)
to return(githubUser *GitHubUser, accessToken string, err error)
so you can call/user/emails
. - Add
fetchGitHubVerifiedPrimaryEmail(token string) (string, error)
that GETshttps://api.github.com/user/emails
, decodes[]{Email,Primary,Verified}
, and returns the primary verified email (lowercased). - In
GitHubOAuth
, use that verified email formodel.DB.Where("LOWER(email)=?", verifiedEmail).First(&existingUser)
and normalize all stored emails viastrings.ToLower
. - Add a unique index on
github_id
in your database to prevent race conditions or double-binding.
var existingUser model.User | ||
err := model.DB.Where("email = ?", oidcUser.Email).First(&existingUser).Error | ||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { | ||
common.ApiError(c, err) | ||
return | ||
} | ||
if err == nil { | ||
if existingUser.OidcId != "" && existingUser.OidcId != oidcUser.OpenID { | ||
c.JSON(http.StatusOK, gin.H{ | ||
"success": false, | ||
"message": "该邮箱对应的账户已绑定其他 OIDC 账户", | ||
}) | ||
return | ||
} | ||
existingUser.OidcId = oidcUser.OpenID | ||
if existingUser.DisplayName == "" { | ||
if oidcUser.Name != "" { | ||
existingUser.DisplayName = oidcUser.Name | ||
} else { | ||
existingUser.DisplayName = "OIDC User" | ||
} | ||
} | ||
if err := existingUser.Update(false); err != nil { | ||
common.ApiError(c, err) | ||
return | ||
} | ||
user = existingUser | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
Require OIDC email_verified before email-based linking.
Linking on an unverified email can hijack existing accounts. Include and enforce the email_verified claim from userinfo.
Apply these changes:
- Capture email_verified in OidcUser:
type OidcUser struct {
OpenID string `json:"sub"`
Email string `json:"email"`
+ EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Picture string `json:"picture"`
}
- Refuse linking if not verified; normalize email on lookup:
- var existingUser model.User
- err := model.DB.Where("email = ?", oidcUser.Email).First(&existingUser).Error
+ if !oidcUser.EmailVerified {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "OIDC 邮箱未验证,无法通过邮箱自动绑定",
+ })
+ return
+ }
+ var existingUser model.User
+ err := model.DB.Where("LOWER(email) = ?", strings.ToLower(oidcUser.Email)).First(&existingUser).Error
@@
- existingUser.OidcId = oidcUser.OpenID
+ existingUser.OidcId = oidcUser.OpenID
Also, when creating a new user, store email lowercased:
- user.Email = oidcUser.Email
+ user.Email = strings.ToLower(oidcUser.Email)
🏁 Script executed:
#!/bin/bash
file=$(fd -t f oidc.go | head -n1)
echo "File: $file"
echo "=== OidcUser struct ==="
sed -n '/type OidcUser/,/}/p' "$file"
echo
echo "=== Imports ==="
sed -n '1,50p' "$file" | grep -E '^import'
echo
echo "=== user.Email assignments ==="
grep -R "user\.Email" -n "$file"
Length of output: 383
🏁 Script executed:
#!/bin/bash
echo "=== Import block ==="
sed -n '1,200p' controller/oidc.go | sed -n '/import (/,/)/p'
echo
echo "=== strings usage/import ==="
grep -R "strings" -n controller/oidc.go
Length of output: 498
Require verified email before OIDC account linking
- Add
EmailVerified bool json:"email_verified"
toOidcUser
. - Abort linking with a failure response if
!oidcUser.EmailVerified
. - Lookup existing users case-insensitively:
model.DB.Where("LOWER(email)=?", strings.ToLower(oidcUser.Email))…
- On new user creation, store
user.Email = strings.ToLower(oidcUser.Email)
.
🤖 Prompt for AI Agents
In controller/oidc.go around lines 146-173: enforce verified email and
case-insensitive email handling when linking/creating users. Add EmailVerified
bool json:"email_verified" to the OidcUser struct, immediately abort linking
with a failure response if oidcUser.EmailVerified is false, change the lookup to
use case-insensitive matching (e.g. WHERE LOWER(email)=? with
strings.ToLower(oidcUser.Email)), and when creating a new user set user.Email =
strings.ToLower(oidcUser.Email). Ensure imports/use of strings as needed.
考虑到Github和OIDC的邮箱有未验证的情况,这个暂时还不能合并 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
controller/oidc.go (1)
198-206
: Extend password-reset flow for OAuth-only users
user.Insert(0)
inserts an empty-string (""
) password—which satisfies theNOT NULL
constraint—but the/user/reset
endpoint only acceptstoken
. Per issue #1350, add apassword
field toPasswordResetRequest
and updateResetPassword
to set an initial password without requiring the old one.
♻️ Duplicate comments (1)
controller/oidc.go (1)
151-158
: Normalize and match email case-insensitively; handle duplicate-email ambiguity.Current lookup is case-sensitive and stores raw case. This can miss existing accounts (leading to duplicate accounts) and can bind unpredictably if duplicates exist. Normalize to lowercase on read/write and reject ambiguous matches. Also avoid shadowing err with :=.
Apply this diff:
@@ - var existingUser model.User - err := model.DB.Where("email = ?", oidcUser.Email).First(&existingUser).Error - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - common.ApiError(c, err) - return - } - if err == nil { - if existingUser.OidcId != "" && existingUser.OidcId != oidcUser.OpenID { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "该邮箱对应的账户已绑定其他 OIDC 账户", - }) - return - } - existingUser.OidcId = oidcUser.OpenID - if existingUser.DisplayName == "" { - if oidcUser.Name != "" { - existingUser.DisplayName = oidcUser.Name - } else { - existingUser.DisplayName = "OIDC User" - } - } - if err := existingUser.Update(false); err != nil { - common.ApiError(c, err) - return - } - user = existingUser - } + normalizedEmail := strings.ToLower(strings.TrimSpace(oidcUser.Email)) + var matches []model.User + err = model.DB.Where("LOWER(email) = ?", normalizedEmail).Limit(2).Find(&matches).Error + if err != nil { + common.ApiError(c, err) + return + } + if len(matches) > 1 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该邮箱对应多个账户,请联系管理员处理", + }) + return + } + if len(matches) == 1 { + existingUser := matches[0] + if existingUser.OidcId != "" && existingUser.OidcId != oidcUser.OpenID { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该邮箱对应的账户已绑定其他 OIDC 账户", + }) + return + } + existingUser.OidcId = oidcUser.OpenID + if existingUser.DisplayName == "" { + if oidcUser.Name != "" { + existingUser.DisplayName = oidcUser.Name + } else { + existingUser.DisplayName = "OIDC User" + } + } + if err = existingUser.Update(false); err != nil { + common.ApiError(c, err) + return + } + user = existingUser + } @@ - user.Email = oidcUser.Email + user.Email = normalizedEmailBased on learnings (GORM).
Also applies to: 187-187
🧹 Nitpick comments (4)
controller/oidc.go (4)
31-38
: Adding EmailVerified to OidcUser is good. Consider pointer if some IdPs omit the claim.If you encounter IdPs that don’t include email_verified in userinfo, switching to a pointer can distinguish “absent” from “false”:
- Change to: EmailVerified *bool
json:"email_verified"
- Then check: if oidcUser.EmailVerified == nil || !*oidcUser.EmailVerified { … }
110-119
: Invalidate oauth_state after successful validation to prevent replay.Delete the state from the session once checked.
Apply this diff:
if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) { c.JSON(http.StatusForbidden, gin.H{ "success": false, "message": "state is empty or not same", }) return } + // Invalidate state to prevent replay + session.Delete("oauth_state") + _ = session.Save()
188-193
: Ensure username is valid, unique, and collision-safe.PreferredUsername may exceed limits or collide with existing usernames. Consider:
- sanitize/trim to allowed charset and length,
- check for uniqueness; on conflict, fall back to a generated slug (e.g., oidc_) with retry on unique violation.
243-247
: Avoid panic on session id type assertion.session.Get("id") may not be int (e.g., float64/string depending on store). Safely handle types.
Apply this diff:
- id := session.Get("id") - // id := c.GetInt("id") // critical bug! - user.Id = id.(int) + sid := session.Get("id") + switch v := sid.(type) { + case int: + user.Id = v + case int64: + user.Id = int(v) + case float64: + user.Id = int(v) + default: + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "session 无效,请重新登录", + }) + return + }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
controller/oidc.go
(4 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
controller/oidc.go (5)
common/sys_log.go (1)
SysLog
(11-14)model/user.go (1)
User
(20-49)model/main.go (1)
DB
(64-64)common/gin.go (1)
ApiError
(97-102)common/constants.go (1)
RegisterEnabled
(51-51)
🔇 Additional comments (3)
controller/oidc.go (3)
19-19
: LGTM: gorm import is appropriate for ErrRecordNotFound checks.
103-106
: Requiring verified email before linking is correct.Prevents hijacking via unverified addresses. Confirm your supported IdPs reliably populate this claim in userinfo.
Can you confirm all supported OIDC providers return email_verified in the userinfo response for accounts with an email?
179-186
: LGTM: Gate new-user creation behind RegisterEnabled.Prevents unintended signups while still allowing linking for existing users.
This reverts commit eac0570.
#1350 这个问题确实存在 通过第三方登录会绕过邮箱唯一性约束 |
Fix #1350
Refactored GitHub and OIDC OAuth handlers to better handle existing users by email, prevent conflicting account bindings, and only allow new user registration if enabled. This improves account linking consistency and provides clearer error messages for users.
Summary by CodeRabbit
New Features
Bug Fixes