Files
new-api-custom/controller/oauth.go
T
cneicy ceddfd3d6b feat: sync deployed v1.0.0-rc.38-custom.42
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
2026-09-26 04:49:11 +08:00

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)
}
}