package controller import ( "encoding/json" "errors" "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 the params of messages that name the OAuth provider. 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.ApiErrorT(c, "Invalid parameters") 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.ApiErrorT(c, "Invalid parameters") 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 { writeAccountStatusError(c, http.StatusUnauthorized, "Sign in to bind an account") 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 { writeAccountStatusError(c, http.StatusBadRequest, "Unknown OAuth provider") 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 { writeAccountStatusError(c, http.StatusForbidden, "State parameter is empty or mismatched") 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 { writeAccountStatusError(c, http.StatusForbidden, "State parameter is empty or mismatched") 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.ApiErrorT(c, "Invalid parameters") 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.ApiErrorT(c, "{{provider}} login and registration has not been enabled by administrator", providerParams(provider.GetName())) return } // 4. Handle error from provider errorCode := c.Query("error") if errorCode != "" { if _, err := model.ConsumeAuthFlow(state, consumeMatch); err != nil { writeAccountStatusError(c, http.StatusForbidden, "State parameter is empty or mismatched") 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 { writeAccountStatusError(c, http.StatusForbidden, "State parameter is empty or mismatched") 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.ApiErrorT(c, "Email address is already in use") return } switch err.(type) { case *OAuthUserDeletedError: common.ApiErrorT(c, "User has been deleted") case *OAuthRegistrationDisabledError: common.ApiErrorT(c, "New user registration has been disabled by administrator") case *OAuthEmailAlreadyTakenError: common.ApiErrorT(c, "Email address is already in use") case *OAuthLegacyBindingNotConfirmedError: common.ApiErrorT(c, "This {{provider}} account cannot be linked to an existing account automatically, please sign in or register another way and then link {{provider}} in account settings", providerParams(provider.GetName())) default: writeSecurityOperationError(c, err) } return } // 8. Check user status if user.Status != common.UserStatusEnabled { common.ApiErrorT(c, "User has been banned") 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.ApiErrorT(c, "This {{provider}} account has already been bound", 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(service.AccountMailLanguage(c, user.Id), user.Email, i18n.MsgSecurityEventLoginLinked, map[string]any{"Provider": provider.GetName()}) != nil common.ApiSuccessT(c, "Binding successful", 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(common.LogText("[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(service.AccountMailLanguage(c, user.Id), user.Email, i18n.MsgSecurityEventLoginLinked, map[string]any{"Provider": 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 a web console message func handleOAuthError(c *gin.Context, err error) { switch e := err.(type) { case *oauth.OAuthError: common.ApiError(c, e.Message) case *oauth.AccessDeniedError: common.ApiErrorMsg(c, e.Message) case *oauth.TrustLevelError: common.ApiErrorT(c, "Linux DO trust level does not meet the minimum required by administrator") default: writeSecurityOperationError(c, err) } }