mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 17:36:59 +08:00
Legacy GitHub bindings stored the login name, while current bindings store the numeric account ID. A legacy match no longer signs the user in on its own. When the account has two-factor or a passkey, the binding is rewritten only after that verification completes, inside the transaction that issues the session. Otherwise it is rewritten when one of the GitHub account's verified emails matches the account email. Without either, the login is declined with guidance to sign in another way and relink GitHub in account settings. All-digit login names never take part in the comparison, the bind flow no longer compares login names, and every migration or declined attempt is recorded as an account binding audit event.
645 lines
22 KiB
Go
645 lines
22 KiB
Go
package controller
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/i18n"
|
|
"github.com/QuantumNous/new-api/middleware"
|
|
"github.com/QuantumNous/new-api/model"
|
|
"github.com/QuantumNous/new-api/oauth"
|
|
"github.com/QuantumNous/new-api/service"
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const oauthAuthFlowTTL = 10 * time.Minute
|
|
|
|
type oauthStateRequest struct {
|
|
Provider string `json:"provider"`
|
|
Intent string `json:"intent"`
|
|
Aff string `json:"aff,omitempty"`
|
|
Scope string `json:"scope,omitempty"`
|
|
Context json.RawMessage `json:"context,omitempty"`
|
|
}
|
|
|
|
type oauthFlowPayload struct {
|
|
AffiliateCode string `json:"affiliate_code,omitempty"`
|
|
Verification *service.OAuthVerificationFlow `json:"verification,omitempty"`
|
|
Telegram *oauth.TelegramOAuthFlow `json:"telegram,omitempty"`
|
|
SessionIdentity *service.AuthIdentity `json:"session_identity,omitempty"`
|
|
Authorization *model.AuthFlowAuthorization `json:"authorization,omitempty"`
|
|
}
|
|
|
|
// providerParams returns map with Provider key for i18n templates
|
|
func providerParams(name string) map[string]any {
|
|
return map[string]any{"Provider": name}
|
|
}
|
|
|
|
// GenerateOAuthCode generates a state code for OAuth CSRF protection
|
|
func GenerateOAuthCode(c *gin.Context) {
|
|
var request oauthStateRequest
|
|
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
|
|
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
|
return
|
|
}
|
|
request.Provider = strings.TrimSpace(request.Provider)
|
|
request.Intent = strings.TrimSpace(request.Intent)
|
|
request.Aff = strings.TrimSpace(request.Aff)
|
|
if oauth.GetProvider(request.Provider) == nil ||
|
|
(request.Intent != model.AuthFlowIntentLogin && request.Intent != model.AuthFlowIntentBind && request.Intent != model.AuthFlowIntentVerify) ||
|
|
len(request.Aff) > 32 ||
|
|
(request.Intent != model.AuthFlowIntentLogin && request.Aff != "") ||
|
|
(request.Intent != model.AuthFlowIntentVerify && (request.Scope != "" || len(request.Context) != 0)) {
|
|
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
|
return
|
|
}
|
|
userID := 0
|
|
sessionID := ""
|
|
flowPayload := oauthFlowPayload{AffiliateCode: request.Aff}
|
|
bindingStarted := false
|
|
if request.Provider == "telegram" {
|
|
telegramFlow, err := oauth.NewTelegramOAuthFlow()
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
flowPayload.Telegram = telegramFlow
|
|
}
|
|
if request.Intent == model.AuthFlowIntentBind || request.Intent == model.AuthFlowIntentVerify {
|
|
identity, ok := middleware.GetSessionAuthIdentity(c)
|
|
if !ok {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "绑定操作需要登录"})
|
|
return
|
|
}
|
|
userID = identity.UserID
|
|
sessionID = identity.SessionID
|
|
if request.Intent == model.AuthFlowIntentBind {
|
|
defer func() {
|
|
recordUserSecurityAudit(c, userID, "user.binding_start", map[string]any{"provider": request.Provider, "success": bindingStarted})
|
|
}()
|
|
context, err := common.Marshal(service.AccountBindingContext{Provider: request.Provider})
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
flowPayload.Authorization = middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context})
|
|
if flowPayload.Authorization == nil {
|
|
return
|
|
}
|
|
flowPayload.SessionIdentity = &identity
|
|
}
|
|
if flowPayload.Telegram != nil {
|
|
if _, _, err := service.ValidateLoginSession(identity); err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
flowPayload.SessionIdentity = &identity
|
|
}
|
|
if request.Intent == model.AuthFlowIntentVerify {
|
|
verification, err := service.StartOAuthVerification(identity, service.VerificationOperation{Scope: request.Scope, Context: request.Context}, request.Provider)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
flowPayload.Verification = verification
|
|
}
|
|
}
|
|
payload, err := common.Marshal(flowPayload)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
expiresAt := time.Now().Add(oauthAuthFlowTTL)
|
|
state, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
|
|
Purpose: model.AuthFlowPurposeOAuth,
|
|
Provider: request.Provider,
|
|
Intent: request.Intent,
|
|
UserId: userID,
|
|
SessionId: sessionID,
|
|
Payload: string(payload),
|
|
ExpiresAt: expiresAt,
|
|
})
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
bindingStarted = request.Intent == model.AuthFlowIntentBind
|
|
data := gin.H{"flow_token": state, "expires_at": expiresAt.Unix()}
|
|
if flowPayload.Telegram != nil {
|
|
data["authorization_url"] = flowPayload.Telegram.AuthorizationURL(state)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "",
|
|
"data": data,
|
|
})
|
|
}
|
|
|
|
// HandleOAuth handles OAuth callback for all standard OAuth providers
|
|
func HandleOAuth(c *gin.Context) {
|
|
providerName := c.Param("provider")
|
|
provider := oauth.GetProvider(providerName)
|
|
if provider == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"success": false,
|
|
"message": i18n.T(c, i18n.MsgOAuthUnknownProvider),
|
|
})
|
|
return
|
|
}
|
|
|
|
// 1. Validate state (CSRF protection)
|
|
state := c.Query("state")
|
|
pendingFlow, err := model.GetAuthFlow(state, model.AuthFlowMatch{
|
|
Purpose: model.AuthFlowPurposeOAuth,
|
|
Provider: providerName,
|
|
})
|
|
if err != nil {
|
|
c.JSON(http.StatusForbidden, gin.H{
|
|
"success": false,
|
|
"message": i18n.T(c, i18n.MsgOAuthStateInvalid),
|
|
})
|
|
return
|
|
}
|
|
|
|
consumeMatch := model.AuthFlowMatch{
|
|
Purpose: model.AuthFlowPurposeOAuth,
|
|
Provider: providerName,
|
|
Intent: pendingFlow.Intent,
|
|
}
|
|
bindSucceeded, notificationFailed := false, false
|
|
if pendingFlow.Intent == model.AuthFlowIntentBind {
|
|
defer func() {
|
|
recordUserSecurityAudit(c, pendingFlow.UserId, "user.binding_bind", map[string]any{"provider": providerName, "success": bindSucceeded, "notification_failed": notificationFailed})
|
|
}()
|
|
}
|
|
// Bind and verification callbacks must use the dashboard session that started them.
|
|
if pendingFlow.Intent == model.AuthFlowIntentBind || pendingFlow.Intent == model.AuthFlowIntentVerify {
|
|
identity, ok := middleware.GetSessionAuthIdentity(c)
|
|
if !ok || identity.UserID != pendingFlow.UserId || identity.SessionID != pendingFlow.SessionId {
|
|
c.JSON(http.StatusForbidden, gin.H{
|
|
"success": false,
|
|
"message": i18n.T(c, i18n.MsgOAuthStateInvalid),
|
|
})
|
|
return
|
|
}
|
|
consumeMatch.UserId = identity.UserID
|
|
consumeMatch.SessionId = identity.SessionID
|
|
if pendingFlow.Intent == model.AuthFlowIntentBind {
|
|
var payload oauthFlowPayload
|
|
if err := common.UnmarshalJsonStr(pendingFlow.Payload, &payload); err != nil {
|
|
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
|
|
return
|
|
}
|
|
context, err := common.Marshal(service.AccountBindingContext{Provider: providerName})
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
if err := service.ValidateFlowAuthorization(identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context}, payload.Authorization); err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
}
|
|
} else if pendingFlow.Intent != model.AuthFlowIntentLogin {
|
|
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
|
return
|
|
}
|
|
|
|
// 3. Check if provider is enabled
|
|
var telegramPayload oauthFlowPayload
|
|
if providerName == "telegram" {
|
|
if err := oauth.TelegramConfigurationError(); err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
if err := common.UnmarshalJsonStr(pendingFlow.Payload, &telegramPayload); err != nil || telegramPayload.Telegram == nil {
|
|
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
|
|
return
|
|
}
|
|
if pendingFlow.Intent != model.AuthFlowIntentLogin {
|
|
identity, _ := middleware.GetSessionAuthIdentity(c)
|
|
if telegramPayload.SessionIdentity == nil || *telegramPayload.SessionIdentity != identity {
|
|
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
|
|
return
|
|
}
|
|
if _, _, err := service.ValidateLoginSession(identity); err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
}
|
|
c.Set(oauth.TelegramOAuthFlowContextKey, telegramPayload.Telegram)
|
|
}
|
|
if !provider.IsEnabled() {
|
|
common.ApiErrorI18n(c, i18n.MsgOAuthNotEnabled, providerParams(provider.GetName()))
|
|
return
|
|
}
|
|
|
|
// 4. Handle error from provider
|
|
errorCode := c.Query("error")
|
|
if errorCode != "" {
|
|
if _, err := model.ConsumeAuthFlow(state, consumeMatch); err != nil {
|
|
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)})
|
|
return
|
|
}
|
|
errorDescription := c.Query("error_description")
|
|
if errorDescription == "" {
|
|
errorDescription = errorCode
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": false,
|
|
"message": errorDescription,
|
|
})
|
|
return
|
|
}
|
|
// 5. Exchange code for token
|
|
code := c.Query("code")
|
|
token, err := provider.ExchangeToken(c.Request.Context(), code, c)
|
|
if err != nil {
|
|
if providerName == "telegram" {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
handleOAuthError(c, err)
|
|
return
|
|
}
|
|
|
|
// 6. Get user info
|
|
oauthUser, err := provider.GetUserInfo(c.Request.Context(), token)
|
|
if err != nil {
|
|
if providerName == "telegram" {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
handleOAuthError(c, err)
|
|
return
|
|
}
|
|
if pendingFlow.Intent == model.AuthFlowIntentBind {
|
|
bindSucceeded, notificationFailed = handleOAuthBind(c, providerName, provider, oauthUser, pendingFlow, state, consumeMatch)
|
|
return
|
|
}
|
|
flow, err := model.ConsumeAuthFlow(state, consumeMatch)
|
|
if err != nil {
|
|
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)})
|
|
return
|
|
}
|
|
|
|
switch flow.Intent {
|
|
case model.AuthFlowIntentLogin:
|
|
handleOAuthLogin(c, provider, oauthUser, token, flow)
|
|
case model.AuthFlowIntentVerify:
|
|
handleOAuthVerification(c, providerName, oauthUser, flow)
|
|
}
|
|
}
|
|
|
|
func handleOAuthVerification(c *gin.Context, provider string, oauthUser *oauth.OAuthUser, flow *model.AuthFlow) {
|
|
var payload oauthFlowPayload
|
|
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
identity, _ := middleware.GetSessionAuthIdentity(c)
|
|
proof, err := service.FinishOAuthVerification(identity, provider, oauthUser.ProviderUserID, payload.Verification)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
recordUserSecurityAudit(c, identity.UserID, "user.security_verify", map[string]any{"method": proof.Method, "scope": proof.Scope, "provider": provider})
|
|
common.ApiSuccess(c, proof)
|
|
}
|
|
|
|
func handleOAuthLogin(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, token *oauth.OAuthToken, flow *model.AuthFlow) {
|
|
// 7. Find or create user
|
|
var payload oauthFlowPayload
|
|
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
user, migration, err := findOrCreateOAuthUser(c, provider, oauthUser, token, payload.AffiliateCode)
|
|
if err != nil {
|
|
if errors.Is(err, model.ErrEmailAlreadyTaken) {
|
|
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
|
|
return
|
|
}
|
|
switch err.(type) {
|
|
case *OAuthUserDeletedError:
|
|
common.ApiErrorI18n(c, i18n.MsgOAuthUserDeleted)
|
|
case *OAuthRegistrationDisabledError:
|
|
common.ApiErrorI18n(c, i18n.MsgUserRegisterDisabled)
|
|
case *OAuthEmailAlreadyTakenError:
|
|
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
|
|
case *OAuthLegacyBindingNotConfirmedError:
|
|
common.ApiErrorI18n(c, i18n.MsgOAuthNotAutoLinked, providerParams(provider.GetName()))
|
|
default:
|
|
writeSecurityOperationError(c, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// 8. Check user status
|
|
if user.Status != common.UserStatusEnabled {
|
|
common.ApiErrorI18n(c, i18n.MsgOAuthUserBanned)
|
|
return
|
|
}
|
|
|
|
// 9. Setup login
|
|
setupLogin(user, migration, c)
|
|
}
|
|
|
|
// handleOAuthBind handles binding OAuth account to existing user
|
|
func handleOAuthBind(c *gin.Context, providerName string, provider oauth.Provider, oauthUser *oauth.OAuthUser, flow *model.AuthFlow, state string, match model.AuthFlowMatch) (bool, bool) {
|
|
identity, ok := middleware.GetSessionAuthIdentity(c)
|
|
if !ok {
|
|
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
|
|
return false, false
|
|
}
|
|
var payload oauthFlowPayload
|
|
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
|
|
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
|
|
return false, false
|
|
}
|
|
context, err := common.Marshal(service.AccountBindingContext{Provider: providerName})
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return false, false
|
|
}
|
|
// Recheck after the external provider round trip, then validate the session
|
|
// under the transaction's locks before consuming the flow and writing.
|
|
if err := service.ValidateFlowAuthorization(identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context}, payload.Authorization); err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return false, false
|
|
}
|
|
if provider.IsUserIDTaken(oauthUser.ProviderUserID) {
|
|
common.ApiErrorI18n(c, i18n.MsgOAuthAlreadyBound, providerParams(provider.GetName()))
|
|
return false, false
|
|
}
|
|
_, err = model.ConsumeAuthFlowWithAction(state, match, func(tx *gorm.DB, _ *model.AuthFlow) error {
|
|
if providerName == "telegram" {
|
|
return model.BindTelegramForSessionWithTx(tx, identity, oauthUser.ProviderUserID)
|
|
}
|
|
if custom, ok := provider.(*oauth.GenericOAuthProvider); ok {
|
|
return model.UpdateUserOAuthBindingForSessionWithTx(tx, identity, custom.GetProviderId(), oauthUser.ProviderUserID)
|
|
}
|
|
return model.UpdateUserBindColumnForSessionWithTx(tx, identity, provider.ProviderUserIDColumn(), oauthUser.ProviderUserID)
|
|
})
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return false, false
|
|
}
|
|
user, err := model.GetUserById(identity.UserID, false)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return true, true
|
|
}
|
|
notificationFailed := service.NotifyAccountSecurityChange(user.Email, "Login account linked: "+provider.GetName()) != nil
|
|
common.ApiSuccessI18n(c, i18n.MsgOAuthBindSuccess, gin.H{"action": "bind", "notification_warning": notificationFailed})
|
|
return true, notificationFailed
|
|
}
|
|
|
|
// findOrCreateOAuthUser finds the existing user or creates a new one. For a
|
|
// legacy GitHub binding that still waits for the login verification, it also
|
|
// returns the rewrite to carry into the challenge.
|
|
func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, token *oauth.OAuthToken, affiliateCode string) (*model.User, *service.LegacyGitHubMigration, error) {
|
|
user := &model.User{}
|
|
if provider.ProviderUserIDColumn() == "telegram_id" {
|
|
err := provider.FillUserByProviderID(user, oauthUser.ProviderUserID)
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil, oauth.ErrTelegramAccountNotBound
|
|
}
|
|
return user, nil, err
|
|
}
|
|
|
|
// Check if user already exists with new ID
|
|
if provider.IsUserIDTaken(oauthUser.ProviderUserID) {
|
|
err := provider.FillUserByProviderID(user, oauthUser.ProviderUserID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
// Check if user has been deleted
|
|
if user.Id == 0 {
|
|
return nil, nil, &OAuthUserDeletedError{}
|
|
}
|
|
return user, nil, nil
|
|
}
|
|
|
|
// Legacy GitHub bindings stored the login name, which only points at a
|
|
// candidate account. Values that are all digits are already numeric account
|
|
// IDs and never take part in the comparison.
|
|
legacyID, _ := oauthUser.Extra["legacy_id"].(string)
|
|
if strings.ContainsFunc(legacyID, func(r rune) bool { return r < '0' || r > '9' }) && provider.IsUserIDTaken(legacyID) {
|
|
if err := provider.FillUserByProviderID(user, legacyID); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if user.Id != 0 {
|
|
state, err := model.GetUserVerificationState(user.Id)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if state.HasTwoFA || state.HasPasskey {
|
|
// The rewrite is written in the transaction that issues the session
|
|
// once the login verification completes.
|
|
return user, &service.LegacyGitHubMigration{GitHubID: oauthUser.ProviderUserID, LegacyID: legacyID}, nil
|
|
}
|
|
// Without a second factor, one of the addresses the provider has
|
|
// confirmed must match the account email. The list is fetched only here
|
|
// and never recorded.
|
|
reason, matched := "no_matching_evidence", false
|
|
if emailProvider, ok := provider.(oauth.VerifiedEmailProvider); ok && user.Email != "" {
|
|
emails, err := emailProvider.GetVerifiedEmails(c.Request.Context(), token)
|
|
if err != nil {
|
|
common.SysError(fmt.Sprintf("[OAuth] Failed to load verified emails for user %d: %s", user.Id, err.Error()))
|
|
reason = "verified_emails_unavailable"
|
|
}
|
|
accountEmail := model.NormalizeEmail(user.Email)
|
|
matched = slices.ContainsFunc(emails, func(email string) bool { return model.NormalizeEmail(email) == accountEmail })
|
|
}
|
|
if !matched {
|
|
recordLegacyGitHubBindingAudit(c, user, false, map[string]any{"legacy_id": legacyID, "provider_user_id": oauthUser.ProviderUserID, "reason": reason})
|
|
return nil, nil, &OAuthLegacyBindingNotConfirmedError{}
|
|
}
|
|
written := false
|
|
err = model.DB.Transaction(func(tx *gorm.DB) error {
|
|
var err error
|
|
written, err = model.MigrateLegacyGitHubBindingWithTx(tx, user.Id, legacyID, oauthUser.ProviderUserID)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if written {
|
|
user.GitHubId = oauthUser.ProviderUserID
|
|
notificationFailed := service.NotifyAccountSecurityChange(user.Email, "Login account linked: "+provider.GetName()) != nil
|
|
recordLegacyGitHubBindingAudit(c, user, true, map[string]any{
|
|
"legacy_id": legacyID, "provider_user_id": oauthUser.ProviderUserID,
|
|
"verified_email_matched": true, "notification_failed": notificationFailed,
|
|
})
|
|
}
|
|
return user, nil, nil
|
|
}
|
|
}
|
|
|
|
// User doesn't exist, create new user if registration is enabled
|
|
if !common.RegisterEnabled {
|
|
return nil, nil, &OAuthRegistrationDisabledError{}
|
|
}
|
|
|
|
// Set up new user
|
|
user.Username = provider.GetProviderPrefix() + strconv.Itoa(model.GetMaxUserId()+1)
|
|
|
|
if oauthUser.Username != "" {
|
|
if exists, err := model.CheckUserExistOrDeleted(oauthUser.Username, ""); err == nil && !exists {
|
|
// 防止索引退化
|
|
if len(oauthUser.Username) <= model.UserNameMaxLength {
|
|
user.Username = oauthUser.Username
|
|
}
|
|
}
|
|
}
|
|
|
|
if oauthUser.DisplayName != "" {
|
|
user.DisplayName = oauthUser.DisplayName
|
|
} else if oauthUser.Username != "" {
|
|
user.DisplayName = oauthUser.Username
|
|
} else {
|
|
user.DisplayName = provider.GetName() + " User"
|
|
}
|
|
if oauthUser.Email != "" {
|
|
user.Email = model.NormalizeEmail(oauthUser.Email)
|
|
if err := model.EnsureEmailAvailable(user.Email, 0); err != nil {
|
|
if errors.Is(err, model.ErrEmailAlreadyTaken) {
|
|
return nil, nil, &OAuthEmailAlreadyTakenError{}
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
user.Role = common.RoleCommonUser
|
|
user.Status = common.UserStatusEnabled
|
|
|
|
// Handle affiliate code
|
|
inviterId := 0
|
|
if affiliateCode != "" {
|
|
inviterId, _ = model.GetUserIdByAffCode(affiliateCode)
|
|
}
|
|
|
|
// Use transaction to ensure user creation and OAuth binding are atomic
|
|
if genericProvider, ok := provider.(*oauth.GenericOAuthProvider); ok {
|
|
// Custom provider: create user and binding in a transaction
|
|
err := model.DB.Transaction(func(tx *gorm.DB) error {
|
|
// Create user
|
|
if err := user.InsertWithTx(tx, inviterId); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create OAuth binding
|
|
binding := &model.UserOAuthBinding{
|
|
UserId: user.Id,
|
|
ProviderId: genericProvider.GetProviderId(),
|
|
ProviderUserId: oauthUser.ProviderUserID,
|
|
}
|
|
if err := model.CreateUserOAuthBindingWithTx(tx, binding); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Perform post-transaction tasks (logs, sidebar config, inviter rewards)
|
|
user.FinalizeOAuthUserCreation(inviterId)
|
|
} else {
|
|
// Built-in provider: create user and update provider ID in a transaction
|
|
err := model.DB.Transaction(func(tx *gorm.DB) error {
|
|
// Create user
|
|
if err := user.InsertWithTx(tx, inviterId); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Set the provider user ID on the user model and update
|
|
provider.SetProviderUserID(user, oauthUser.ProviderUserID)
|
|
if err := tx.Model(user).Updates(map[string]any{
|
|
"github_id": user.GitHubId,
|
|
"discord_id": user.DiscordId,
|
|
"oidc_id": user.OidcId,
|
|
"linux_do_id": user.LinuxDOId,
|
|
"wechat_id": user.WeChatId,
|
|
"telegram_id": user.TelegramId,
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Perform post-transaction tasks
|
|
user.FinalizeOAuthUserCreation(inviterId)
|
|
}
|
|
|
|
return user, nil, nil
|
|
}
|
|
|
|
// recordLegacyGitHubBindingAudit records the outcome of a legacy GitHub binding
|
|
// rewrite as an account binding event. No session exists yet on the login path,
|
|
// so the row carries the user's own role instead of the request context.
|
|
func recordLegacyGitHubBindingAudit(c *gin.Context, user *model.User, success bool, params map[string]any) {
|
|
params["provider"], params["legacy_migration"], params["success"] = "github", true, success
|
|
model.RecordOperationAuditLog(user.Id, user.Role, auditContentEN("user.binding_bind", params), c.ClientIP(), "user.binding_bind", params, nil, &model.AuditRequestInfo{
|
|
Method: c.Request.Method, Route: c.FullPath(), Path: c.FullPath(), Status: c.Writer.Status(), Success: success,
|
|
}, c)
|
|
}
|
|
|
|
// Error types for OAuth
|
|
type OAuthUserDeletedError struct{}
|
|
|
|
func (e *OAuthUserDeletedError) Error() string {
|
|
return "user has been deleted"
|
|
}
|
|
|
|
type OAuthRegistrationDisabledError struct{}
|
|
|
|
func (e *OAuthRegistrationDisabledError) Error() string {
|
|
return "registration is disabled"
|
|
}
|
|
|
|
type OAuthEmailAlreadyTakenError struct{}
|
|
|
|
func (e *OAuthEmailAlreadyTakenError) Error() string {
|
|
return "email is already in use"
|
|
}
|
|
|
|
// OAuthLegacyBindingNotConfirmedError reports a legacy GitHub binding match that
|
|
// neither a second factor nor a confirmed provider email backed.
|
|
type OAuthLegacyBindingNotConfirmedError struct{}
|
|
|
|
func (e *OAuthLegacyBindingNotConfirmedError) Error() string {
|
|
return "legacy binding was not confirmed"
|
|
}
|
|
|
|
// handleOAuthError handles OAuth errors and returns translated message
|
|
func handleOAuthError(c *gin.Context, err error) {
|
|
switch e := err.(type) {
|
|
case *oauth.OAuthError:
|
|
if e.Params != nil {
|
|
common.ApiErrorI18n(c, e.MsgKey, e.Params)
|
|
} else {
|
|
common.ApiErrorI18n(c, e.MsgKey)
|
|
}
|
|
case *oauth.AccessDeniedError:
|
|
common.ApiErrorMsg(c, e.Message)
|
|
case *oauth.TrustLevelError:
|
|
common.ApiErrorI18n(c, i18n.MsgOAuthTrustLevelLow)
|
|
default:
|
|
writeSecurityOperationError(c, err)
|
|
}
|
|
}
|