From 3e84ec0ab8239cf2277f8a10d45566630a9c10fa Mon Sep 17 00:00:00 2001 From: CaIon Date: Sun, 6 Sep 2026 15:26:08 +0800 Subject: [PATCH] feat(auth): migrate Telegram to unified OAuth Use authorization code flow with PKCE and verified ID tokens for Telegram login, binding, and security verification. Preserve existing bindings and require administrator OAuth configuration. Keep the restricted WeChat first-enrollment session proof, fix missing-target authentication errors, and preserve callback requests after OAuth popups close. --- controller/channel.go | 12 +- controller/misc.go | 1 + controller/oauth.go | 79 +- controller/option.go | 5 +- controller/secure_verification.go | 11 + controller/security_enrollment_test.go | 257 ++++++ controller/telegram.go | 337 +------- controller/telegram_test.go | 810 ++++++++++-------- controller/twofa.go | 8 +- go.mod | 3 + go.sum | 6 + model/custom_oauth_provider.go | 3 + model/external_identity_claim.go | 32 + oauth/registry.go | 41 +- oauth/telegram.go | 202 +++++ oauth/types.go | 1 + router/api-router.go | 10 +- service/security_verification.go | 34 +- setting/system_setting/telegram.go | 26 + web/src/features/auth/api.test.ts | 67 +- web/src/features/auth/api.ts | 26 +- .../auth/components/oauth-providers.tsx | 67 +- .../features/auth/hooks/use-oauth-login.ts | 62 +- .../auth/lib/__tests__/oauth-popup.test.ts | 55 +- .../features/auth/lib/oauth-callback-mode.ts | 23 + web/src/features/auth/lib/oauth-popup.ts | 3 + .../__tests__/verification-api.test.ts | 91 ++ .../__tests__/verification-flow.test.tsx | 61 ++ .../features/auth/secure-verification/api.ts | 25 +- .../components/secure-verification-dialog.tsx | 1 + .../hooks/use-secure-verification.ts | 18 + .../auth/secure-verification/types.ts | 8 +- web/src/features/auth/types.ts | 2 + .../security/__tests__/enrollment.test.tsx | 140 ++- .../security/components/account-bindings.tsx | 31 +- .../features/system-settings/auth/index.tsx | 2 + .../system-settings/auth/oauth-section.tsx | 51 +- .../system-settings/auth/section-registry.tsx | 4 +- web/src/features/system-settings/types.ts | 2 + web/src/i18n/locales/en.json | 9 + web/src/i18n/locales/fr.json | 9 + web/src/i18n/locales/ja.json | 9 + web/src/i18n/locales/ru.json | 9 + web/src/i18n/locales/vi.json | 9 + web/src/i18n/locales/zh-TW.json | 9 + web/src/i18n/locales/zh.json | 9 + web/src/i18n/static-keys.ts | 5 + web/src/lib/server-error-message.ts | 9 + web/src/routes/oauth/$provider.tsx | 3 +- 49 files changed, 1815 insertions(+), 882 deletions(-) create mode 100644 oauth/telegram.go create mode 100644 setting/system_setting/telegram.go diff --git a/controller/channel.go b/controller/channel.go index f26ff6a07..3187123a0 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -3,6 +3,7 @@ package controller import ( "context" "encoding/json" + "errors" "fmt" "net/http" "strconv" @@ -421,20 +422,19 @@ func GetChannel(c *gin.Context) { // 此函数依赖 SecureVerificationRequired 中间件,确保用户已通过安全验证 func GetChannelKey(c *gin.Context) { channelId, err := strconv.Atoi(c.Param("id")) - if err != nil { + if err != nil || channelId <= 0 { common.ApiErrorMsg(c, "渠道ID格式错误") return } // 获取渠道信息(包含密钥) channel, err := model.GetChannelById(channelId, true) - if err != nil { - writeSecurityOperationError(c, err) + if errors.Is(err, gorm.ErrRecordNotFound) { + common.ApiErrorI18n(c, i18n.MsgChannelNotExists) return } - - if channel == nil { - common.ApiErrorMsg(c, "渠道不存在") + if err != nil { + writeSecurityOperationError(c, err) return } diff --git a/controller/misc.go b/controller/misc.go index 9f7480ef9..35f14c8ee 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -62,6 +62,7 @@ func GetStatus(c *gin.Context) { "linuxdo_client_id": common.LinuxDOClientId, "linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel, "telegram_oauth": common.TelegramOAuthEnabled, + "telegram_oauth_configured": oauth.TelegramConfigurationError() == nil, "telegram_bot_name": common.TelegramBotName, "theme": "default", "system_name": common.SystemName, diff --git a/controller/oauth.go b/controller/oauth.go index 2e1e65c46..a6e964b08 100644 --- a/controller/oauth.go +++ b/controller/oauth.go @@ -30,8 +30,10 @@ type oauthStateRequest struct { } type oauthFlowPayload struct { - AffiliateCode string `json:"affiliate_code,omitempty"` - Verification *service.OAuthVerificationFlow `json:"verification,omitempty"` + 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"` } // providerParams returns map with Provider key for i18n templates @@ -60,6 +62,14 @@ func GenerateOAuthCode(c *gin.Context) { userID := 0 sessionID := "" flowPayload := oauthFlowPayload{AffiliateCode: request.Aff} + 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 { @@ -68,6 +78,13 @@ func GenerateOAuthCode(c *gin.Context) { } userID = identity.UserID sessionID = identity.SessionID + 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 { @@ -96,13 +113,14 @@ func GenerateOAuthCode(c *gin.Context) { writeSecurityOperationError(c, err) return } + 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": gin.H{ - "flow_token": state, - "expires_at": expiresAt.Unix(), - }, + "data": data, }) } @@ -155,6 +173,29 @@ func HandleOAuth(c *gin.Context) { } // 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 @@ -181,6 +222,10 @@ func HandleOAuth(c *gin.Context) { 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 } @@ -188,9 +233,24 @@ func HandleOAuth(c *gin.Context) { // 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 providerName == "telegram" && pendingFlow.Intent == model.AuthFlowIntentBind { + _, err := model.ConsumeAuthFlowWithAction(state, consumeMatch, func(tx *gorm.DB, _ *model.AuthFlow) error { + return model.BindTelegramForSessionWithTx(tx, *telegramPayload.SessionIdentity, oauthUser.ProviderUserID) + }) + if err != nil { + writeSecurityOperationError(c, err) + return + } + common.ApiSuccessI18n(c, i18n.MsgOAuthBindSuccess, gin.H{"action": "bind"}) + return + } flow, err := model.ConsumeAuthFlow(state, consumeMatch) if err != nil { c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)}) @@ -303,6 +363,13 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider, oauthUser *oauth.O // findOrCreateOAuthUser finds existing user or creates new user func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, affiliateCode string) (*model.User, error) { user := &model.User{} + if provider.ProviderUserIDColumn() == "telegram_id" { + err := provider.FillUserByProviderID(user, oauthUser.ProviderUserID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, oauth.ErrTelegramAccountNotBound + } + return user, err + } // Check if user already exists with new ID if provider.IsUserIDTaken(oauthUser.ProviderUserID) { diff --git a/controller/option.go b/controller/option.go index 20ce18b2f..fa6f731e1 100644 --- a/controller/option.go +++ b/controller/option.go @@ -233,10 +233,11 @@ func UpdateOption(c *gin.Context) { return } case "TelegramOAuthEnabled": - if option.Value == "true" && common.TelegramBotToken == "" { + if option.Value == "true" && !system_setting.GetTelegramSettings().IsConfigured() { c.JSON(http.StatusOK, gin.H{ "success": false, - "message": "无法启用 Telegram OAuth,请先填入 Telegram Bot Token!", + "code": "TELEGRAM_OAUTH_NOT_CONFIGURED", + "message": "Telegram OAuth is not configured or enabled. Please contact your administrator.", }) return } diff --git a/controller/secure_verification.go b/controller/secure_verification.go index 54a139682..6a4cc3bbe 100644 --- a/controller/secure_verification.go +++ b/controller/secure_verification.go @@ -7,6 +7,7 @@ import ( "github.com/QuantumNous/new-api/common" "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" "github.com/go-webauthn/webauthn/protocol" @@ -33,6 +34,16 @@ func writeSecurityOperationError(c *gin.Context, err error) { var code, message string var protocolError *protocol.Error switch { + case errors.Is(err, oauth.ErrTelegramOAuthNotConfigured): + code, message = "TELEGRAM_OAUTH_NOT_CONFIGURED", oauth.ErrTelegramOAuthNotConfigured.Error() + case errors.Is(err, oauth.ErrTelegramOAuthConflict): + code, message = "TELEGRAM_OAUTH_CONFLICT", oauth.ErrTelegramOAuthConflict.Error() + case errors.Is(err, oauth.ErrTelegramOAuthFailed): + code, message = "TELEGRAM_OAUTH_FAILED", oauth.ErrTelegramOAuthFailed.Error() + case errors.Is(err, oauth.ErrTelegramAccountNotBound): + code, message = "TELEGRAM_ACCOUNT_NOT_BOUND", oauth.ErrTelegramAccountNotBound.Error() + case errors.Is(err, model.ErrExternalIdentityAlreadyClaimed): + code, message = "TELEGRAM_BIND_ALREADY_BOUND", "This Telegram account is already bound." case errors.Is(err, service.ErrVerificationContextInvalid): status = http.StatusBadRequest code, message = "SECURITY_CONTEXT_INVALID", service.ErrVerificationContextInvalid.Error() diff --git a/controller/security_enrollment_test.go b/controller/security_enrollment_test.go index 063989914..ed6ea34d9 100644 --- a/controller/security_enrollment_test.go +++ b/controller/security_enrollment_test.go @@ -1130,3 +1130,260 @@ func TestSecurityEnrollmentCustomOAuthUsesExistingBinding(t *testing.T) { require.Len(t, bindings, 1) assert.Equal(t, "custom-user", bindings[0].ProviderUserId) } + +func completeFirstSecurityFactor(t *testing.T, identity service.AuthIdentity, proof service.SecurityProof) { + t.Helper() + var response *httptest.ResponseRecorder + if proof.Scope == service.VerificationScopeTwoFASetup { + response = securityEnrollmentRequest("POST", "/api/user/2fa/setup", "", proof.ProofToken, identity, Setup2FA) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, body.Message) + var setup service.TwoFASetup + require.NoError(t, common.Unmarshal(body.Data, &setup)) + code, err := totp.GenerateCode(setup.Secret, time.Now()) + require.NoError(t, err) + request, err := common.Marshal(Verify2FARequest{FlowToken: setup.FlowToken, Code: code}) + require.NoError(t, err) + response = securityEnrollmentRequest("POST", "/api/user/2fa/enable", string(request), "", identity, Enable2FA) + } else { + response = securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof.ProofToken, identity, PasskeyRegisterBegin) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, body.Message) + var begin struct { + FlowToken string `json:"flow_token"` + Options struct { + PublicKey struct { + Challenge string `json:"challenge"` + } `json:"publicKey"` + } `json:"options"` + } + require.NoError(t, common.Unmarshal(body.Data, &begin)) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + request, err := common.Marshal(passkeyFinishRequest{ + FlowToken: begin.FlowToken, + Credential: securityPasskeyResponse(t, key, begin.Options.PublicKey.Challenge, true, 0), + }) + require.NoError(t, err) + response = securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(request), "", identity, PasskeyRegisterFinish) + } + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, response.Body.String()) + var rotation struct { + AccessToken string `json:"access_token"` + } + require.NoError(t, common.Unmarshal(body.Data, &rotation)) + updated, err := service.ParseAccessToken(rotation.AccessToken) + require.NoError(t, err) + assert.Equal(t, identity.UserID, updated.UserID) + assert.Equal(t, identity.UserAuthVersion+1, updated.UserAuthVersion) + _, err = service.ConsumeOperationProof(proof.ProofToken, updated, service.VerificationOperation{Scope: proof.Scope}) + assert.Error(t, err) +} + +func TestSecurityEnrollmentTelegramAndWeChatFirstFactor(t *testing.T) { + for _, provider := range []string{"telegram", "wechat"} { + for _, scope := range []string{service.VerificationScopeTwoFASetup, service.VerificationScopePasskeyRegister} { + t.Run(provider+"/"+scope, func(t *testing.T) { + var user *model.User + var identity service.AuthIdentity + var response *httptest.ResponseRecorder + method := service.VerificationMethodSession + if provider == "telegram" { + fixture := setupTelegramOAuthTest(t) + user, identity = fixture.user, fixture.identity + require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "telegram_id": "42"}).Error) + state, code := fixture.authorization(t, "verify", identity, scope, telegramIdentityClaims(99)) + mismatch := telegramOAuthCallback(state, code, identity) + assert.Contains(t, mismatch.Body.String(), "OAUTH_ACCOUNT_MISMATCH") + assert.NotContains(t, mismatch.Body.String(), "proof_token") + state, code = fixture.authorization(t, "verify", identity, scope, telegramIdentityClaims(42)) + response = telegramOAuthCallback(state, code, identity) + method = service.VerificationMethodOAuth + } else { + user, identity = setupSecurityEnrollmentTest(t) + require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "wechat_id": "wechat-user"}).Error) + request, err := common.Marshal(service.VerificationInput{Scope: scope, Method: method}) + require.NoError(t, err) + response = securityEnrollmentRequest("POST", "/api/verify", string(request), "", identity, UniversalVerify) + } + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, response.Body.String()) + var proof service.SecurityProof + require.NoError(t, common.Unmarshal(body.Data, &proof)) + assert.Equal(t, scope, proof.Scope) + assert.Equal(t, method, proof.Method) + before, err := model.GetUserById(user.Id, true) + require.NoError(t, err) + assert.Empty(t, before.Password) + assert.Equal(t, identity.UserAuthVersion, before.AuthVersion) + completeFirstSecurityFactor(t, identity, proof) + after, err := model.GetUserById(user.Id, true) + require.NoError(t, err) + assert.Equal(t, before.TelegramId, after.TelegramId) + assert.Equal(t, before.WeChatId, after.WeChatId) + }) + } + } +} + +func TestSecurityEnrollmentWeChatExceptionRemainsNarrow(t *testing.T) { + for _, scenario := range []string{"password", "passkey", "locked 2fa", "telegram", "github", "disabled custom binding", "no binding", "binding storage failure", "revoked session"} { + t.Run(scenario, func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "wechat_id": "wechat-user"}).Error) + switch scenario { + case "password": + require.NoError(t, model.DB.Model(user).Update("password", "stored-hash").Error) + case "passkey": + require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "credential", PublicKey: "key"}).Error) + case "locked 2fa": + until := time.Now().Add(time.Hour) + require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: true, LockedUntil: &until}).Error) + case "telegram": + require.NoError(t, model.DB.Model(user).Update("telegram_id", "42").Error) + case "github": + require.NoError(t, model.DB.Model(user).Update("github_id", "42").Error) + case "disabled custom binding": + require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: user.Id, ProviderId: 42, ProviderUserId: "linked"}).Error) + case "no binding": + require.NoError(t, model.DB.Model(user).Update("wechat_id", "").Error) + case "binding storage failure": + require.NoError(t, model.DB.Callback().Query().Before("gorm:query").Register("wechat_bindings_failure", func(tx *gorm.DB) { + if tx.Statement.Table == "user_oauth_bindings" { + tx.AddError(errors.New("private binding failure")) + } + })) + case "revoked session": + _, err := model.RevokeAllUserSessions(user.Id, "test") + require.NoError(t, err) + } + for _, scope := range []string{"2fa.setup", "passkey.register", "passkey.delete", "channel.key.read"} { + context := json.RawMessage(nil) + if scope == "channel.key.read" { + context = json.RawMessage(`{"channel_id":1}`) + } + _, err := service.VerifySecurityInput(identity, service.VerificationInput{Method: "session", Scope: scope, Context: context}) + assert.Error(t, err, scope) + } + if scenario == "binding storage failure" { + require.NoError(t, model.DB.Callback().Query().Remove("wechat_bindings_failure")) + } + var count int64 + require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Count(&count).Error) + assert.Zero(t, count) + }) + } +} + +func TestSecurityEnrollmentMissingTargetsAreBusinessErrors(t *testing.T) { + _, identity := setupSecurityEnrollmentTest(t) + require.NoError(t, model.DB.AutoMigrate(&model.Channel{})) + for _, target := range []struct { + path, key string + handler gin.HandlerFunc + }{ + {"/api/channel/999/key", i18n.MsgChannelNotExists, GetChannelKey}, + {"/api/user/999/2fa", i18n.MsgUserNotExists, AdminDisable2FA}, + } { + var expectedMessage string + response := securityEnrollmentRequest("POST", target.path, "", "", identity, func(c *gin.Context) { + c.Params = gin.Params{{Key: "id", Value: "999"}} + expectedMessage = i18n.T(c, target.key) + target.handler(c) + }) + assert.Equal(t, http.StatusOK, response.Code) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + assert.False(t, body.Success) + assert.Equal(t, expectedMessage, body.Message) + assert.NotEqual(t, "AUTH_UNAUTHORIZED", body.Code) + for _, id := range []string{"invalid", "0", "-1"} { + invalid := securityEnrollmentRequest("POST", target.path, "", "", identity, func(c *gin.Context) { + c.Params = gin.Params{{Key: "id", Value: id}} + target.handler(c) + }) + assert.Equal(t, http.StatusOK, invalid.Code) + assert.Contains(t, invalid.Body.String(), `"success":false`) + } + require.NoError(t, model.DB.Callback().Query().Before("gorm:query").Register("target_query_failure", func(tx *gorm.DB) { + if tx.Statement.Table == "users" || tx.Statement.Table == "channels" { + tx.AddError(errors.New("private database failure")) + } + })) + failed := securityEnrollmentRequest("POST", target.path, "", "", identity, func(c *gin.Context) { + c.Params = gin.Params{{Key: "id", Value: "999"}} + target.handler(c) + }) + require.NoError(t, model.DB.Callback().Query().Remove("target_query_failure")) + assert.Equal(t, http.StatusInternalServerError, failed.Code) + assert.NotContains(t, failed.Body.String(), "private database failure") + } + _, _, err := service.ValidateLoginSession(identity) + require.NoError(t, err) +} + +func TestSecurityEnrollmentRejectsChangedFirstFactorPolicy(t *testing.T) { + for _, provider := range []string{"telegram", "wechat"} { + for _, stage := range []string{"proof", "setup"} { + t.Run(provider+"/"+stage, func(t *testing.T) { + fixture := setupTelegramOAuthTest(t) + user, identity := fixture.user, fixture.identity + require.NoError(t, model.DB.Model(user).Update("password", "").Error) + var proof *service.SecurityProof + var err error + if provider == "telegram" { + require.NoError(t, model.DB.Model(user).Update("telegram_id", "42").Error) + state, code := fixture.authorization(t, "verify", identity, "2fa.setup", telegramIdentityClaims(42)) + response := telegramOAuthCallback(state, code, identity) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, response.Body.String()) + require.NoError(t, common.Unmarshal(body.Data, &proof)) + } else { + require.NoError(t, model.DB.Model(user).Update("wechat_id", "wechat-user").Error) + proof, err = service.VerifySecurityInput(identity, service.VerificationInput{Scope: "2fa.setup", Method: "session"}) + require.NoError(t, err) + } + _, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "passkey.register"}) + assert.Error(t, err) + var setupToken string + if stage == "setup" { + response := securityEnrollmentRequest("POST", "/api/user/2fa/setup", `{}`, proof.ProofToken, identity, Setup2FA) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, response.Body.String()) + var setup struct { + FlowToken string `json:"flow_token"` + } + require.NoError(t, common.Unmarshal(body.Data, &setup)) + setupToken = setup.FlowToken + _, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "2fa.setup"}) + assert.ErrorIs(t, err, service.ErrProofConsumed) + } + if provider == "telegram" { + common.TelegramOAuthEnabled = false + } else { + require.NoError(t, model.DB.Model(user).Update("telegram_id", "42").Error) + } + if stage == "proof" { + response := securityEnrollmentRequest("POST", "/api/user/2fa/setup", `{}`, proof.ProofToken, identity, Setup2FA) + assert.Contains(t, response.Body.String(), `"success":false`) + assert.NotContains(t, response.Body.String(), "flow_token") + } else { + request, err := common.Marshal(map[string]string{"flow_token": setupToken, "code": "123456"}) + require.NoError(t, err) + response := securityEnrollmentRequest("POST", "/api/user/2fa/enable", string(request), "", identity, Enable2FA) + assert.Contains(t, response.Body.String(), `"success":false`) + } + factor, err := model.GetTwoFAByUserId(user.Id) + require.NoError(t, err) + assert.True(t, factor == nil || !factor.IsEnabled) + }) + } + } +} diff --git a/controller/telegram.go b/controller/telegram.go index e738307ff..cf1c4d871 100644 --- a/controller/telegram.go +++ b/controller/telegram.go @@ -1,340 +1,17 @@ package controller import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "errors" "net/http" - "net/url" - "sort" - "strconv" - "strings" - "time" - - "github.com/QuantumNous/new-api/common" - "github.com/QuantumNous/new-api/middleware" - "github.com/QuantumNous/new-api/model" - "github.com/QuantumNous/new-api/service" "github.com/gin-gonic/gin" - "gorm.io/gorm" ) -const ( - // The legacy Telegram widget has no nonce. Keep its signed assertion short-lived - // so captured callbacks cannot be reused indefinitely. - telegramAuthorizationMaxAge = 5 * time.Minute - telegramAuthorizationFutureSkew = 2 * time.Minute - telegramBindFlowTTL = 5 * time.Minute - - telegramBindErrorDisabled = "TELEGRAM_BIND_DISABLED" - telegramBindErrorInvalidRequest = "TELEGRAM_BIND_INVALID_REQUEST" - telegramBindErrorFlowInvalid = "TELEGRAM_BIND_FLOW_INVALID" - telegramBindErrorSessionInvalid = "TELEGRAM_BIND_SESSION_INVALID" - telegramBindErrorAlreadyBound = "TELEGRAM_BIND_ALREADY_BOUND" - telegramBindErrorUserDeleted = "TELEGRAM_BIND_USER_DELETED" - telegramBindErrorUserDisabled = "TELEGRAM_BIND_USER_DISABLED" - telegramBindErrorInternal = "TELEGRAM_BIND_INTERNAL_ERROR" -) - -var ( - errTelegramAccountAlreadyBound = errors.New("telegram account is already bound") - errTelegramBindAssertionInvalid = errors.New("telegram bind assertion is invalid") - errTelegramBindUserDeleted = errors.New("telegram bind user was deleted") - errTelegramBindUserDisabled = errors.New("telegram bind user is disabled") -) - -func TelegramBindStart(c *gin.Context) { - if !common.TelegramOAuthEnabled { - c.JSON(http.StatusOK, gin.H{ - "message": "管理员未开启通过 Telegram 登录以及注册", - "success": false, - }) - return - } - identity, ok := middleware.GetSessionAuthIdentity(c) - if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"}) - return - } - expiresAt := time.Now().Add(telegramBindFlowTTL) - flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ - Purpose: model.AuthFlowPurposeTelegramBind, - UserId: identity.UserID, - SessionId: identity.SessionID, - ExpiresAt: expiresAt, - }) - if err != nil { - common.ApiError(c, err) - return - } - callbackURL := "/api/oauth/telegram/bind/" + flowToken - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "", - "data": gin.H{ - "flow_token": flowToken, - "callback_url": callbackURL, - "expires_at": expiresAt.Unix(), - }, +// TelegramLegacyAuth retires the unsigned-flow widget endpoints. Existing +// Telegram bindings are used by the unified OAuth provider instead. +func TelegramLegacyAuth(c *gin.Context) { + c.JSON(http.StatusGone, gin.H{ + "success": false, + "code": "TELEGRAM_LEGACY_AUTH_REMOVED", + "message": "Telegram login has changed. Reload the page and start Telegram OAuth again.", }) } - -func TelegramBind(c *gin.Context) { - if !common.TelegramOAuthEnabled { - telegramBindFailure(c, telegramBindErrorDisabled) - return - } - params := c.Request.URL.Query() - telegramId, err := verifyTelegramAuthorization(params, common.TelegramBotToken, time.Now()) - if err != nil { - common.SysLog("TelegramBind authorization failed: " + err.Error()) - telegramBindFailure(c, telegramBindErrorInvalidRequest) - return - } - pendingFlow, err := model.GetAuthFlow(c.Param("flow_token"), model.AuthFlowMatch{ - Purpose: model.AuthFlowPurposeTelegramBind, - }) - if err != nil { - if !errors.Is(err, model.ErrAuthFlowInvalid) && - !errors.Is(err, model.ErrAuthFlowExpired) && - !errors.Is(err, model.ErrAuthFlowConsumed) { - common.SysError("TelegramBind flow lookup failed: " + err.Error()) - telegramBindFailure(c, telegramBindErrorInternal) - return - } - telegramBindFailure(c, telegramBindErrorFlowInvalid) - return - } - if _, err := service.ValidateSessionReference(pendingFlow.UserId, pendingFlow.SessionId); err != nil { - if !errors.Is(err, service.ErrLoginSessionInvalid) && - !errors.Is(err, service.ErrLoginSessionRevoked) && - !errors.Is(err, model.ErrUserSessionInactive) && - !errors.Is(err, gorm.ErrRecordNotFound) { - common.SysError("TelegramBind session validation failed: " + err.Error()) - telegramBindFailure(c, telegramBindErrorInternal) - return - } - - var user model.User - userErr := model.DB.First(&user, pendingFlow.UserId).Error - switch { - case errors.Is(userErr, gorm.ErrRecordNotFound): - telegramBindFailure(c, telegramBindErrorUserDeleted) - case userErr != nil: - common.SysError("TelegramBind user status lookup failed: " + userErr.Error()) - telegramBindFailure(c, telegramBindErrorInternal) - case user.Status != common.UserStatusEnabled: - telegramBindFailure(c, telegramBindErrorUserDisabled) - default: - telegramBindFailure(c, telegramBindErrorSessionInvalid) - } - return - } - assertion, assertionExpiresAt, err := telegramAuthorizationClaim(params, time.Now()) - if err != nil { - common.SysLog("TelegramBind authorization claim failed: " + err.Error()) - telegramBindFailure(c, telegramBindErrorInvalidRequest) - return - } - _, err = model.ConsumeAuthFlowWithAction(c.Param("flow_token"), model.AuthFlowMatch{ - Purpose: model.AuthFlowPurposeTelegramBind, - UserId: pendingFlow.UserId, - SessionId: pendingFlow.SessionId, - }, func(tx *gorm.DB, flow *model.AuthFlow) error { - if err := model.ClaimExternalAuthAssertionWithTx(tx, model.AuthFlowPurposeTelegramAssertion, assertion, assertionExpiresAt); err != nil { - if errors.Is(err, model.ErrAuthFlowInvalid) || errors.Is(err, model.ErrAuthFlowConsumed) { - return errors.Join(errTelegramBindAssertionInvalid, err) - } - return err - } - - var user model.User - if err := tx.First(&user, flow.UserId).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errTelegramBindUserDeleted - } - return err - } - if user.Status != common.UserStatusEnabled { - return errTelegramBindUserDisabled - } - - var session model.UserSession - if err := tx.Where("sid = ? AND user_id = ?", flow.SessionId, flow.UserId).First(&session).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return service.ErrLoginSessionRevoked - } - return err - } - if session.Status != model.UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= time.Now().Unix() { - return service.ErrLoginSessionRevoked - } - if session.UserAuthVersion != user.AuthVersion { - return service.ErrLoginSessionRevoked - } - if user.TelegramId != "" { - return errTelegramAccountAlreadyBound - } - if err := model.ClaimExternalIdentityWithTx( - tx, - model.ExternalIdentityProviderTelegram, - telegramId, - user.Id, - ); err != nil { - if errors.Is(err, model.ErrExternalIdentityAlreadyClaimed) { - return errTelegramAccountAlreadyBound - } - return err - } - result := tx.Model(&model.User{}). - Where("id = ? AND status = ? AND auth_version = ? AND telegram_id = ?", user.Id, common.UserStatusEnabled, user.AuthVersion, ""). - Update("telegram_id", telegramId) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return errTelegramAccountAlreadyBound - } - return nil - }) - if err != nil { - switch { - case errors.Is(err, errTelegramBindAssertionInvalid): - telegramBindFailure(c, telegramBindErrorInvalidRequest) - case errors.Is(err, errTelegramAccountAlreadyBound): - telegramBindFailure(c, telegramBindErrorAlreadyBound) - case errors.Is(err, errTelegramBindUserDeleted): - telegramBindFailure(c, telegramBindErrorUserDeleted) - case errors.Is(err, errTelegramBindUserDisabled): - telegramBindFailure(c, telegramBindErrorUserDisabled) - case errors.Is(err, service.ErrLoginSessionRevoked): - telegramBindFailure(c, telegramBindErrorSessionInvalid) - case errors.Is(err, model.ErrAuthFlowInvalid), errors.Is(err, model.ErrAuthFlowExpired), errors.Is(err, model.ErrAuthFlowConsumed): - telegramBindFailure(c, telegramBindErrorFlowInvalid) - default: - common.SysError("TelegramBind failed: " + err.Error()) - telegramBindFailure(c, telegramBindErrorInternal) - } - return - } - - callback := "/oauth/telegram?telegram_bind=success&flow_token=" + url.QueryEscape(c.Param("flow_token")) - c.Redirect(http.StatusFound, callback) -} - -func telegramBindFailure(c *gin.Context, errorCode string) { - query := url.Values{ - "telegram_bind": {"error"}, - "flow_token": {c.Param("flow_token")}, - "error_code": {errorCode}, - } - c.Redirect(http.StatusFound, "/oauth/telegram?"+query.Encode()) -} - -func TelegramLogin(c *gin.Context) { - if !common.TelegramOAuthEnabled { - c.JSON(200, gin.H{ - "message": "管理员未开启通过 Telegram 登录以及注册", - "success": false, - }) - return - } - params := c.Request.URL.Query() - telegramId, err := verifyTelegramAuthorization(params, common.TelegramBotToken, time.Now()) - if err != nil { - common.SysLog("TelegramLogin authorization failed: " + err.Error()) - c.JSON(200, gin.H{ - "message": "无效的请求", - "success": false, - }) - return - } - - user := model.User{TelegramId: telegramId} - if err := user.FillUserByTelegramId(); err != nil { - c.JSON(200, gin.H{ - "message": err.Error(), - "success": false, - }) - return - } - if err := claimTelegramAuthorization(params, time.Now()); err != nil { - common.SysLog("TelegramLogin assertion replay rejected: " + err.Error()) - c.JSON(http.StatusForbidden, gin.H{ - "message": "该登录凭据已被使用", - "success": false, - }) - return - } - setupLogin(&user, c) -} - -func claimTelegramAuthorization(params url.Values, now time.Time) error { - assertion, expiresAt, err := telegramAuthorizationClaim(params, now) - if err != nil { - return err - } - return model.ClaimExternalAuthAssertion(model.AuthFlowPurposeTelegramAssertion, assertion, expiresAt) -} - -func telegramAuthorizationClaim(params url.Values, now time.Time) (string, time.Time, error) { - authDate, err := strconv.ParseInt(params.Get("auth_date"), 10, 64) - if err != nil { - return "", time.Time{}, errors.New("telegram authorization date is invalid") - } - hashBytes, err := hex.DecodeString(params.Get("hash")) - if err != nil { - return "", time.Time{}, errors.New("telegram authorization signature is invalid") - } - expiresAt := time.Unix(authDate, 0).Add(telegramAuthorizationMaxAge) - if !expiresAt.After(now) { - return "", time.Time{}, errors.New("telegram authorization has expired") - } - return hex.EncodeToString(hashBytes), expiresAt, nil -} - -func verifyTelegramAuthorization(params url.Values, token string, now time.Time) (string, error) { - if token == "" { - return "", errors.New("telegram bot token is empty") - } - for _, values := range params { - if len(values) != 1 { - return "", errors.New("telegram authorization contains duplicate parameters") - } - } - - telegramID := params.Get("id") - hash := params.Get("hash") - authDateText := params.Get("auth_date") - if telegramID == "" || hash == "" || authDateText == "" { - return "", errors.New("telegram authorization is incomplete") - } - authDate, err := strconv.ParseInt(authDateText, 10, 64) - if err != nil { - return "", errors.New("telegram authorization date is invalid") - } - if authDate < now.Add(-telegramAuthorizationMaxAge).Unix() || - authDate > now.Add(telegramAuthorizationFutureSkew).Unix() { - return "", errors.New("telegram authorization has expired") - } - - strs := make([]string, 0, len(params)-1) - for k, v := range params { - if k == "hash" { - continue - } - strs = append(strs, k+"="+v[0]) - } - sort.Strings(strs) - secret := sha256.Sum256([]byte(token)) - mac := hmac.New(sha256.New, secret[:]) - _, _ = mac.Write([]byte(strings.Join(strs, "\n"))) - providedHash, err := hex.DecodeString(hash) - if err != nil || !hmac.Equal(providedHash, mac.Sum(nil)) { - return "", errors.New("telegram authorization signature is invalid") - } - - return telegramID, nil -} diff --git a/controller/telegram_test.go b/controller/telegram_test.go index 506447241..4418e9b4b 100644 --- a/controller/telegram_test.go +++ b/controller/telegram_test.go @@ -1,409 +1,461 @@ package controller import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" "errors" + "fmt" + "math/big" "net/http" "net/http/httptest" "net/url" - "sort" - "strconv" - "strings" + "sync" "testing" "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/oauth" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" - "github.com/glebarez/sqlite" + "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" "gorm.io/gorm" ) -func TestVerifyTelegramAuthorization(t *testing.T) { - const token = "telegram-test-token" - now := time.Unix(1_700_000_000, 0) +type telegramTestTransport struct{ target *url.URL } - tests := []struct { - name string - authDate time.Time - mutate func(url.Values) - wantID string - wantErr string - }{ - {name: "valid", authDate: now, wantID: "123456"}, - {name: "small future clock skew", authDate: now.Add(90 * time.Second), wantID: "123456"}, - {name: "expired", authDate: now.Add(-telegramAuthorizationMaxAge - time.Second), wantErr: "expired"}, - {name: "too far in future", authDate: now.Add(telegramAuthorizationFutureSkew + time.Second), wantErr: "expired"}, - {name: "invalid signature", authDate: now, mutate: func(values url.Values) { values.Set("hash", "00") }, wantErr: "signature"}, - {name: "unsigned flow token query is rejected", authDate: now, mutate: func(values url.Values) { values.Set("flow_token", "must-be-in-path") }, wantErr: "signature"}, - {name: "duplicate parameter", authDate: now, mutate: func(values url.Values) { values["id"] = append(values["id"], "654321") }, wantErr: "duplicate"}, +func (transport telegramTestTransport) RoundTrip(request *http.Request) (*http.Response, error) { + if request.URL.Host != "oauth.telegram.org" { + return nil, fmt.Errorf("unexpected OAuth host: %s", request.URL.Host) } + clone := request.Clone(request.Context()) + clone.URL.Scheme, clone.URL.Host = transport.target.Scheme, transport.target.Host + return http.DefaultTransport.RoundTrip(clone) +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - params := signedTelegramAuthorization(token, tt.authDate) - if tt.mutate != nil { - tt.mutate(params) - } +type telegramTestGrant struct { + challenge string + claims jwt.MapClaims + key *rsa.PrivateKey +} - telegramID, err := verifyTelegramAuthorization(params, token, now) - if tt.wantErr != "" { - require.Error(t, err) - assert.ErrorContains(t, err, tt.wantErr) - assert.Empty(t, telegramID) +type telegramOAuthFixture struct { + user *model.User + identity service.AuthIdentity + client *http.Client + key *rsa.PrivateKey + mutex sync.Mutex + grants map[string]telegramTestGrant + tokenStatus int + jwksStatus int +} + +func setupTelegramOAuthTest(t *testing.T) *telegramOAuthFixture { + t.Helper() + user, identity := setupSecurityEnrollmentTest(t) + require.NoError(t, model.DB.AutoMigrate(&model.ExternalIdentityClaim{}, &model.Option{})) + previousEnabled := common.TelegramOAuthEnabled + previousSettings := *system_setting.GetTelegramSettings() + previousAddress := system_setting.ServerAddress + previousProvider := oauth.GetProvider("telegram") + common.OptionMapRWMutex.Lock() + previousOptions := common.OptionMap + common.OptionMap = make(map[string]string) + for key, value := range previousOptions { + common.OptionMap[key] = value + } + common.OptionMapRWMutex.Unlock() + common.TelegramOAuthEnabled = true + *system_setting.GetTelegramSettings() = system_setting.TelegramSettings{ClientID: "12345", ClientSecret: "telegram-client-secret"} + system_setting.ServerAddress = "https://example.com" + t.Cleanup(func() { + common.OptionMapRWMutex.Lock() + common.OptionMap = previousOptions + common.OptionMapRWMutex.Unlock() + common.TelegramOAuthEnabled = previousEnabled + *system_setting.GetTelegramSettings() = previousSettings + system_setting.ServerAddress = previousAddress + oauth.Register("telegram", previousProvider) + oauth.UnregisterCustomProvider("telegram") + }) + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + fixture := &telegramOAuthFixture{user: user, identity: identity, key: key, grants: map[string]telegramTestGrant{}} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + fixture.mutex.Lock() + defer fixture.mutex.Unlock() + w.Header().Set("Content-Type", "application/json") + switch request.URL.Path { + case "/.well-known/jwks.json": + if fixture.jwksStatus != 0 { + w.WriteHeader(fixture.jwksStatus) return } - - require.NoError(t, err) - assert.Equal(t, tt.wantID, telegramID) - }) - } -} - -func signedTelegramAuthorization(token string, authDate time.Time) url.Values { - params := url.Values{ - "auth_date": {strconv.FormatInt(authDate.Unix(), 10)}, - "first_name": {"Test"}, - "id": {"123456"}, - } - signTelegramAuthorization(token, params) - return params -} - -func signTelegramAuthorization(token string, params url.Values) { - keys := make([]string, 0, len(params)) - for key := range params { - if key == "hash" { - continue - } - keys = append(keys, key) - } - sort.Strings(keys) - dataCheck := make([]string, 0, len(keys)) - for _, key := range keys { - dataCheck = append(dataCheck, key+"="+params.Get(key)) - } - secret := sha256.Sum256([]byte(token)) - mac := hmac.New(sha256.New, secret[:]) - _, _ = mac.Write([]byte(strings.Join(dataCheck, "\n"))) - params.Set("hash", hex.EncodeToString(mac.Sum(nil))) -} - -func createTelegramBindTestFlow(t *testing.T, db *gorm.DB, name string, status int, now time.Time) (*model.User, string) { - t.Helper() - user := &model.User{ - Username: name, Password: "password-placeholder", Role: common.RoleCommonUser, - Status: status, Group: "default", AuthVersion: 1, AffCode: name, - } - require.NoError(t, db.Create(user).Error) - session := &model.UserSession{ - SID: name + "-session", UserID: user.Id, Version: 1, UserAuthVersion: user.AuthVersion, - Status: model.UserSessionStatusActive, RefreshHash: name + "-refresh-hash", LoginMethod: "password", - CreatedAt: now.Unix(), LastActiveAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), - } - require.NoError(t, model.CreateUserSession(session)) - flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ - Purpose: model.AuthFlowPurposeTelegramBind, UserId: user.Id, SessionId: session.SID, - ExpiresAt: now.Add(time.Minute), - }) - require.NoError(t, err) - return user, flowToken -} - -func assertTelegramBindRedirect(t *testing.T, response *httptest.ResponseRecorder, flowToken, errorCode string) { - t.Helper() - require.Equal(t, http.StatusFound, response.Code) - location, err := url.Parse(response.Header().Get("Location")) - require.NoError(t, err) - assert.Equal(t, "/oauth/telegram", location.Path) - assert.Equal(t, "error", location.Query().Get("telegram_bind")) - assert.Equal(t, flowToken, location.Query().Get("flow_token")) - assert.Equal(t, errorCode, location.Query().Get("error_code")) - assert.Empty(t, location.Query().Get("error_description")) - assert.Empty(t, location.Query().Get("message")) -} - -func TestTelegramBindFailureResponseContract(t *testing.T) { - failures := []struct { - name string - errorCode string - }{ - {name: "disabled", errorCode: telegramBindErrorDisabled}, - {name: "invalid request", errorCode: telegramBindErrorInvalidRequest}, - {name: "invalid flow", errorCode: telegramBindErrorFlowInvalid}, - {name: "invalid session", errorCode: telegramBindErrorSessionInvalid}, - {name: "already bound", errorCode: telegramBindErrorAlreadyBound}, - {name: "deleted user", errorCode: telegramBindErrorUserDeleted}, - {name: "disabled user", errorCode: telegramBindErrorUserDisabled}, - {name: "internal error", errorCode: telegramBindErrorInternal}, - } - - for _, failure := range failures { - t.Run(failure.name, func(t *testing.T) { - response := httptest.NewRecorder() - context, _ := gin.CreateTestContext(response) - context.Params = gin.Params{{Key: "flow_token", Value: "flow token"}} - context.Request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/flow-token", nil) - - telegramBindFailure(context, failure.errorCode) - - assertTelegramBindRedirect(t, response, "flow token", failure.errorCode) - }) - } -} - -func TestTelegramBindCommitsFlowAssertionAndBindingAtomically(t *testing.T) { - previousDB := model.DB - previousType := common.MainDatabaseType() - previousRedis := common.RedisEnabled - previousEnabled := common.TelegramOAuthEnabled - previousToken := common.TelegramBotToken - previousSecret := common.SessionSecret - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - require.NoError(t, err) - require.NoError(t, db.AutoMigrate( - &model.User{}, - &model.UserSession{}, - &model.AuthFlow{}, - &model.ExternalIdentityClaim{}, - )) - model.DB = db - common.SetMainDatabaseType(common.DatabaseTypeSQLite) - common.RedisEnabled = false - common.TelegramOAuthEnabled = true - common.TelegramBotToken = "telegram-bind-test-token" - common.SessionSecret = "telegram-bind-session-secret" - t.Cleanup(func() { - model.DB = previousDB - common.SetMainDatabaseType(previousType) - common.RedisEnabled = previousRedis - common.TelegramOAuthEnabled = previousEnabled - common.TelegramBotToken = previousToken - common.SessionSecret = previousSecret - }) - - user := &model.User{ - Username: "telegram-bind-user", Password: "password-placeholder", Role: common.RoleCommonUser, - Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "telegram-bind-user", - } - require.NoError(t, db.Create(user).Error) - now := time.Now() - session := &model.UserSession{ - SID: "telegram-bind-session", UserID: user.Id, Version: 1, UserAuthVersion: user.AuthVersion, - Status: model.UserSessionStatusActive, RefreshHash: "refresh-hash", LoginMethod: "password", - CreatedAt: now.Unix(), LastActiveAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), - } - require.NoError(t, model.CreateUserSession(session)) - flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ - Purpose: model.AuthFlowPurposeTelegramBind, UserId: user.Id, SessionId: session.SID, - ExpiresAt: now.Add(time.Minute), - }) - require.NoError(t, err) - params := signedTelegramAuthorization(common.TelegramBotToken, now) - router := gin.New() - router.GET("/api/oauth/telegram/bind/:flow_token", TelegramBind) - - common.TelegramOAuthEnabled = false - request := httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/disabled-flow", nil) - response := httptest.NewRecorder() - router.ServeHTTP(response, request) - assertTelegramBindRedirect(t, response, "disabled-flow", telegramBindErrorDisabled) - common.TelegramOAuthEnabled = true - - request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/invalid-request", nil) - response = httptest.NewRecorder() - router.ServeHTTP(response, request) - assertTelegramBindRedirect(t, response, "invalid-request", telegramBindErrorInvalidRequest) - - request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/missing-flow?"+params.Encode(), nil) - response = httptest.NewRecorder() - router.ServeHTTP(response, request) - assertTelegramBindRedirect(t, response, "missing-flow", telegramBindErrorFlowInvalid) - - invalidSessionFlowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ - Purpose: model.AuthFlowPurposeTelegramBind, UserId: user.Id, SessionId: "missing-session", - ExpiresAt: now.Add(time.Minute), - }) - require.NoError(t, err) - request = httptest.NewRequest( - http.MethodGet, - "/api/oauth/telegram/bind/"+invalidSessionFlowToken+"?"+params.Encode(), - nil, - ) - response = httptest.NewRecorder() - router.ServeHTTP(response, request) - assertTelegramBindRedirect(t, response, invalidSessionFlowToken, telegramBindErrorSessionInvalid) - invalidSessionFlow, err := model.GetAuthFlow(invalidSessionFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind}) - require.NoError(t, err) - assert.Nil(t, invalidSessionFlow.ConsumedAt) - - request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/"+flowToken+"?"+params.Encode(), nil) - response = httptest.NewRecorder() - router.ServeHTTP(response, request) - - assert.Equal(t, http.StatusFound, response.Code) - assert.Equal(t, "/oauth/telegram?telegram_bind=success&flow_token="+url.QueryEscape(flowToken), response.Header().Get("Location")) - var storedUser model.User - require.NoError(t, db.First(&storedUser, user.Id).Error) - assert.Equal(t, "123456", storedUser.TelegramId) - var identityClaim model.ExternalIdentityClaim - require.NoError(t, db.Where("provider = ? AND subject = ?", model.ExternalIdentityProviderTelegram, "123456"). - First(&identityClaim).Error) - assert.Equal(t, user.Id, identityClaim.UserId) - _, err = model.GetAuthFlow(flowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind}) - assert.ErrorIs(t, err, model.ErrAuthFlowConsumed) - - replayFlowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ - Purpose: model.AuthFlowPurposeTelegramBind, UserId: user.Id, SessionId: session.SID, - ExpiresAt: now.Add(time.Minute), - }) - require.NoError(t, err) - request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/"+replayFlowToken+"?"+params.Encode(), nil) - response = httptest.NewRecorder() - router.ServeHTTP(response, request) - assertTelegramBindRedirect(t, response, replayFlowToken, telegramBindErrorInvalidRequest) - replayFlow, err := model.GetAuthFlow(replayFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind}) - require.NoError(t, err) - assert.Nil(t, replayFlow.ConsumedAt) - - competingUser := &model.User{ - Username: "telegram-bind-competing-user", Password: "password-placeholder", Role: common.RoleCommonUser, - Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "telegram-bind-competing-user", - } - require.NoError(t, db.Create(competingUser).Error) - competingSession := &model.UserSession{ - SID: "telegram-bind-competing-session", UserID: competingUser.Id, Version: 1, - UserAuthVersion: competingUser.AuthVersion, Status: model.UserSessionStatusActive, - RefreshHash: "competing-refresh-hash", LoginMethod: "password", - CreatedAt: now.Unix(), LastActiveAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), - } - require.NoError(t, model.CreateUserSession(competingSession)) - competingFlowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ - Purpose: model.AuthFlowPurposeTelegramBind, UserId: competingUser.Id, SessionId: competingSession.SID, - ExpiresAt: now.Add(time.Minute), - }) - require.NoError(t, err) - competingParams := signedTelegramAuthorization(common.TelegramBotToken, now) - competingParams.Set("first_name", "Competing") - signTelegramAuthorization(common.TelegramBotToken, competingParams) - request = httptest.NewRequest( - http.MethodGet, - "/api/oauth/telegram/bind/"+competingFlowToken+"?"+competingParams.Encode(), - nil, - ) - response = httptest.NewRecorder() - router.ServeHTTP(response, request) - assertTelegramBindRedirect(t, response, competingFlowToken, telegramBindErrorAlreadyBound) - - require.NoError(t, db.First(competingUser, competingUser.Id).Error) - assert.Empty(t, competingUser.TelegramId) - competingFlow, err := model.GetAuthFlow(competingFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind}) - require.NoError(t, err) - assert.Nil(t, competingFlow.ConsumedAt) - competingAssertion, competingAssertionExpiry, err := telegramAuthorizationClaim(competingParams, time.Now()) - require.NoError(t, err) - require.NoError(t, model.ClaimExternalAuthAssertion( - model.AuthFlowPurposeTelegramAssertion, - competingAssertion, - competingAssertionExpiry, - )) - - disabledUser, disabledFlowToken := createTelegramBindTestFlow( - t, db, "telegram-bind-disabled-user", common.UserStatusDisabled, now, - ) - disabledParams := signedTelegramAuthorization(common.TelegramBotToken, now) - disabledParams.Set("id", "disabled-telegram-id") - disabledParams.Set("first_name", "Disabled") - signTelegramAuthorization(common.TelegramBotToken, disabledParams) - request = httptest.NewRequest( - http.MethodGet, - "/api/oauth/telegram/bind/"+disabledFlowToken+"?"+disabledParams.Encode(), - nil, - ) - response = httptest.NewRecorder() - router.ServeHTTP(response, request) - assertTelegramBindRedirect(t, response, disabledFlowToken, telegramBindErrorUserDisabled) - var storedDisabledUser model.User - require.NoError(t, db.First(&storedDisabledUser, disabledUser.Id).Error) - assert.Empty(t, storedDisabledUser.TelegramId) - disabledFlow, err := model.GetAuthFlow(disabledFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind}) - require.NoError(t, err) - assert.Nil(t, disabledFlow.ConsumedAt) - disabledAssertion, disabledAssertionExpiry, err := telegramAuthorizationClaim(disabledParams, time.Now()) - require.NoError(t, err) - require.NoError(t, model.ClaimExternalAuthAssertion( - model.AuthFlowPurposeTelegramAssertion, - disabledAssertion, - disabledAssertionExpiry, - )) - - deletedUser, deletedFlowToken := createTelegramBindTestFlow( - t, db, "telegram-bind-deleted-user", common.UserStatusEnabled, now, - ) - require.NoError(t, db.Delete(deletedUser).Error) - deletedParams := signedTelegramAuthorization(common.TelegramBotToken, now) - deletedParams.Set("id", "deleted-telegram-id") - deletedParams.Set("first_name", "Deleted") - signTelegramAuthorization(common.TelegramBotToken, deletedParams) - request = httptest.NewRequest( - http.MethodGet, - "/api/oauth/telegram/bind/"+deletedFlowToken+"?"+deletedParams.Encode(), - nil, - ) - response = httptest.NewRecorder() - router.ServeHTTP(response, request) - assertTelegramBindRedirect(t, response, deletedFlowToken, telegramBindErrorUserDeleted) - deletedFlow, err := model.GetAuthFlow(deletedFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind}) - require.NoError(t, err) - assert.Nil(t, deletedFlow.ConsumedAt) - deletedAssertion, deletedAssertionExpiry, err := telegramAuthorizationClaim(deletedParams, time.Now()) - require.NoError(t, err) - require.NoError(t, model.ClaimExternalAuthAssertion( - model.AuthFlowPurposeTelegramAssertion, - deletedAssertion, - deletedAssertionExpiry, - )) - - _, internalFlowToken := createTelegramBindTestFlow( - t, db, "telegram-bind-internal-error", common.UserStatusEnabled, now, - ) - internalParams := signedTelegramAuthorization(common.TelegramBotToken, now) - internalParams.Set("id", "internal-error-telegram-id") - internalParams.Set("first_name", "Internal") - signTelegramAuthorization(common.TelegramBotToken, internalParams) - forcedError := errors.New("forced telegram session query failure") - const callbackName = "test:telegram-bind-session-query-failure" - require.NoError(t, db.Callback().Query().Before("gorm:query").Register(callbackName, func(tx *gorm.DB) { - if tx.Statement.Table != "user_sessions" { - return - } - if _, inTransaction := tx.Statement.ConnPool.(gorm.TxCommitter); inTransaction { - tx.AddError(forcedError) + payload, err := common.Marshal(map[string]any{"keys": []any{map[string]any{ + "kty": "RSA", "kid": "telegram-test-key", "alg": "RS256", "use": "sig", + "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()), + }}}) + if assert.NoError(t, err) { + _, _ = w.Write(payload) + } + case "/token": + if fixture.tokenStatus != 0 { + w.WriteHeader(fixture.tokenStatus) + return + } + clientID, secret, ok := request.BasicAuth() + if !ok || clientID != "12345" || secret != "telegram-client-secret" { + w.WriteHeader(http.StatusUnauthorized) + return + } + if request.ParseForm() != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + code := request.Form.Get("code") + grant, ok := fixture.grants[code] + if !ok || request.Form.Get("grant_type") != "authorization_code" || + request.Form.Get("client_id") != "12345" || request.Form.Get("redirect_uri") != "https://example.com/oauth/telegram" || + oauth2.S256ChallengeFromVerifier(request.Form.Get("code_verifier")) != grant.challenge { + w.WriteHeader(http.StatusBadRequest) + return + } + delete(fixture.grants, code) + token := jwt.NewWithClaims(jwt.SigningMethodRS256, grant.claims) + token.Header["kid"] = "telegram-test-key" + signed, err := token.SignedString(grant.key) + if !assert.NoError(t, err) { + w.WriteHeader(http.StatusInternalServerError) + return + } + payload, err := common.Marshal(map[string]any{"access_token": "test-access-token", "id_token": signed, "token_type": "Bearer"}) + if assert.NoError(t, err) { + _, _ = w.Write(payload) + } + default: + t.Errorf("unexpected Telegram endpoint: %s", request.URL.Path) + w.WriteHeader(http.StatusNotFound) } })) - request = httptest.NewRequest( - http.MethodGet, - "/api/oauth/telegram/bind/"+internalFlowToken+"?"+internalParams.Encode(), - nil, - ) - response = httptest.NewRecorder() - router.ServeHTTP(response, request) - db.Callback().Query().Remove(callbackName) - assertTelegramBindRedirect(t, response, internalFlowToken, telegramBindErrorInternal) - assert.NotContains(t, response.Header().Get("Location"), forcedError.Error()) - internalFlow, err := model.GetAuthFlow(internalFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind}) + t.Cleanup(server.Close) + target, err := url.Parse(server.URL) require.NoError(t, err) - assert.Nil(t, internalFlow.ConsumedAt) - internalAssertion, internalAssertionExpiry, err := telegramAuthorizationClaim(internalParams, time.Now()) + fixture.client = &http.Client{Transport: telegramTestTransport{target: target}, Timeout: 5 * time.Second} + oauth.Register("telegram", oauth.NewTelegramProvider(fixture.client)) + return fixture +} + +func (fixture *telegramOAuthFixture) authorization(t *testing.T, intent string, identity service.AuthIdentity, scope string, claims jwt.MapClaims) (string, string) { + t.Helper() + request, err := common.Marshal(oauthStateRequest{Provider: "telegram", Intent: intent, Scope: scope}) + require.NoError(t, err) + response := securityEnrollmentRequest("POST", "/api/oauth/state", string(request), "", identity, GenerateOAuthCode) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, response.Body.String()) + var data struct { + State string `json:"flow_token"` + URL string `json:"authorization_url"` + } + require.NoError(t, common.Unmarshal(body.Data, &data)) + authorizationURL, err := url.Parse(data.URL) + require.NoError(t, err) + assert.Equal(t, "https://oauth.telegram.org/auth", authorizationURL.Scheme+"://"+authorizationURL.Host+authorizationURL.Path) + assert.Equal(t, "openid profile", authorizationURL.Query().Get("scope")) + assert.Equal(t, "S256", authorizationURL.Query().Get("code_challenge_method")) + assert.Equal(t, data.State, authorizationURL.Query().Get("state")) + assert.Empty(t, authorizationURL.Query().Get("code_verifier")) + assert.NotContains(t, response.Body.String(), "telegram-client-secret") + code := "code-" + data.State + fixture.mutex.Lock() + fixture.grants[code] = telegramTestGrant{challenge: authorizationURL.Query().Get("code_challenge"), claims: claims, key: fixture.key} + fixture.mutex.Unlock() + return data.State, code +} + +func telegramIdentityClaims(id any) jwt.MapClaims { + return jwt.MapClaims{ + "iss": oauth.TelegramIssuer, "aud": "12345", "sub": "different-oidc-subject", + "iat": time.Now().Unix(), "exp": time.Now().Add(time.Minute).Unix(), + "id": id, "name": "Telegram User", "preferred_username": "telegram-user", + } +} + +func telegramOAuthCallback(state, code string, identity service.AuthIdentity) *httptest.ResponseRecorder { + path := "/api/oauth/telegram?" + url.Values{"state": {state}, "code": {code}}.Encode() + return securityEnrollmentRequest("GET", path, "", "", identity, func(c *gin.Context) { + c.Params = gin.Params{{Key: "provider", Value: "telegram"}} + HandleOAuth(c) + }) +} + +func TestTelegramOAuthPreservesExistingAccountAndBinding(t *testing.T) { + fixture := setupTelegramOAuthTest(t) + const telegramID = "1234567890123456" + require.NoError(t, model.DB.Model(fixture.user).Update("telegram_id", telegramID).Error) + require.NoError(t, model.InitializeExternalIdentityClaims()) + require.NoError(t, model.InitializeExternalIdentityClaims()) + state, code := fixture.authorization(t, "login", service.AuthIdentity{}, "", telegramIdentityClaims(json.Number(telegramID))) + response := telegramOAuthCallback(state, code, service.AuthIdentity{}) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, response.Body.String()) + var login struct { + User struct { + ID int `json:"id"` + } `json:"user"` + } + require.NoError(t, common.Unmarshal(body.Data, &login)) + assert.Equal(t, fixture.user.Id, login.User.ID) + stored, err := model.GetUserByTelegramID(telegramID) + require.NoError(t, err) + assert.Equal(t, telegramID, stored.TelegramId) + var claim model.ExternalIdentityClaim + require.NoError(t, model.DB.Where("provider = ? AND subject = ?", "telegram", telegramID).First(&claim).Error) + assert.Equal(t, stored.Id, claim.UserId) + replay := telegramOAuthCallback(state, code, service.AuthIdentity{}) + assert.Equal(t, http.StatusForbidden, replay.Code) + state, code = fixture.authorization(t, "login", service.AuthIdentity{}, "", telegramIdentityClaims(999)) + response = telegramOAuthCallback(state, code, service.AuthIdentity{}) + assert.Contains(t, response.Body.String(), "TELEGRAM_ACCOUNT_NOT_BOUND") + var count int64 + require.NoError(t, model.DB.Model(&model.User{}).Count(&count).Error) + assert.EqualValues(t, 1, count) +} + +func TestTelegramOAuthRejectsInvalidTokens(t *testing.T) { + fixture := setupTelegramOAuthTest(t) + require.NoError(t, model.DB.Model(fixture.user).Update("telegram_id", "42").Error) + for _, test := range []struct { + name string + change func(jwt.MapClaims) + }{ + {"issuer", func(c jwt.MapClaims) { c["iss"] = "https://other.example" }}, + {"audience", func(c jwt.MapClaims) { c["aud"] = "other-client" }}, + {"expired", func(c jwt.MapClaims) { c["exp"] = time.Now().Add(-time.Minute).Unix() }}, + {"missing id", func(c jwt.MapClaims) { delete(c, "id") }}, + {"negative id", func(c jwt.MapClaims) { c["id"] = -1 }}, + {"fractional id", func(c jwt.MapClaims) { c["id"] = 1.5 }}, + {"overflow id", func(c jwt.MapClaims) { c["id"] = "18446744073709551616" }}, + {"missing subject", func(c jwt.MapClaims) { delete(c, "sub") }}, + } { + t.Run(test.name, func(t *testing.T) { + claims := telegramIdentityClaims(42) + test.change(claims) + state, code := fixture.authorization(t, "login", service.AuthIdentity{}, "", claims) + response := telegramOAuthCallback(state, code, service.AuthIdentity{}) + assert.Contains(t, response.Body.String(), "TELEGRAM_OAUTH_FAILED") + assert.NotContains(t, response.Body.String(), "access_token") + }) + } + t.Run("signature", func(t *testing.T) { + state, code := fixture.authorization(t, "login", service.AuthIdentity{}, "", telegramIdentityClaims(42)) + otherKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + grant := fixture.grants[code] + grant.key = otherKey + fixture.grants[code] = grant + assert.Contains(t, telegramOAuthCallback(state, code, service.AuthIdentity{}).Body.String(), "TELEGRAM_OAUTH_FAILED") + }) + t.Run("PKCE", func(t *testing.T) { + state, code := fixture.authorization(t, "login", service.AuthIdentity{}, "", telegramIdentityClaims(42)) + grant := fixture.grants[code] + grant.challenge = "wrong-challenge" + fixture.grants[code] = grant + assert.Contains(t, telegramOAuthCallback(state, code, service.AuthIdentity{}).Body.String(), "TELEGRAM_OAUTH_FAILED") + }) + for _, endpoint := range []string{"token", "jwks"} { + t.Run(endpoint+" unavailable", func(t *testing.T) { + state, code := fixture.authorization(t, "login", service.AuthIdentity{}, "", telegramIdentityClaims(42)) + oauth.Register("telegram", oauth.NewTelegramProvider(fixture.client)) + if endpoint == "token" { + fixture.tokenStatus = 503 + } else { + fixture.jwksStatus = 503 + } + response := telegramOAuthCallback(state, code, service.AuthIdentity{}) + assert.Contains(t, response.Body.String(), "TELEGRAM_OAUTH_FAILED") + fixture.tokenStatus, fixture.jwksStatus = 0, 0 + }) + } +} + +func TestTelegramOAuthBindingIsAtomicAndSessionBound(t *testing.T) { + fixture := setupTelegramOAuthTest(t) + // Older rows may predate the Telegram column and have a NULL binding. + require.NoError(t, model.DB.Model(fixture.user).Update("telegram_id", nil).Error) + state, code := fixture.authorization(t, "bind", fixture.identity, "", telegramIdentityClaims(42)) + otherBundle, err := service.CreateLoginSession(fixture.user.Id, "password", "127.0.0.1", "other") + require.NoError(t, err) + otherIdentity, err := service.ParseAccessToken(otherBundle.AccessToken) + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, telegramOAuthCallback(state, code, otherIdentity).Code) + failure := errors.New("private Telegram bind storage failure") + require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("telegram_bind_failure", func(tx *gorm.DB) { + if tx.Statement.Table == "users" { + tx.AddError(failure) + } + })) + response := telegramOAuthCallback(state, code, fixture.identity) + assert.Equal(t, http.StatusInternalServerError, response.Code) + assert.NotContains(t, response.Body.String(), "private") + require.NoError(t, model.DB.Callback().Update().Remove("telegram_bind_failure")) + _, err = model.GetAuthFlow(state, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth, Provider: "telegram"}) + require.NoError(t, err) + var claimCount int64 + require.NoError(t, model.DB.Model(&model.ExternalIdentityClaim{}).Count(&claimCount).Error) + assert.Zero(t, claimCount) + state, code = fixture.authorization(t, "bind", fixture.identity, "", telegramIdentityClaims(42)) + response = telegramOAuthCallback(state, code, fixture.identity) + assert.Contains(t, response.Body.String(), `"success":true`) + stored, err := model.GetUserById(fixture.user.Id, false) require.NoError(t, err) - require.NoError(t, model.ClaimExternalAuthAssertion( - model.AuthFlowPurposeTelegramAssertion, - internalAssertion, - internalAssertionExpiry, - )) + assert.Equal(t, "42", stored.TelegramId) + assert.Equal(t, fixture.user.Role, stored.Role) + assert.Equal(t, fixture.user.Status, stored.Status) + assert.Equal(t, fixture.user.AuthVersion, stored.AuthVersion) + assert.Equal(t, http.StatusForbidden, telegramOAuthCallback(state, code, fixture.identity).Code) +} +func TestTelegramOAuthBindingRejectsChangedAccountsAndDuplicateOwnership(t *testing.T) { + for _, change := range []string{"revoked", "disabled", "deleted", "already bound", "owned by another user"} { + t.Run(change, func(t *testing.T) { + fixture := setupTelegramOAuthTest(t) + state, code := fixture.authorization(t, "bind", fixture.identity, "", telegramIdentityClaims(42)) + switch change { + case "revoked": + _, err := model.RevokeAllUserSessions(fixture.user.Id, "test") + require.NoError(t, err) + case "disabled": + require.NoError(t, model.DB.Model(fixture.user).Update("status", common.UserStatusDisabled).Error) + case "deleted": + require.NoError(t, model.DB.Delete(fixture.user).Error) + case "already bound": + require.NoError(t, model.DB.Model(fixture.user).Update("telegram_id", "99").Error) + require.NoError(t, model.InitializeExternalIdentityClaims()) + case "owned by another user": + owner := &model.User{Username: "owner", AffCode: "owner", Status: common.UserStatusEnabled, AuthVersion: 1, TelegramId: "42"} + require.NoError(t, model.DB.Create(owner).Error) + require.NoError(t, model.InitializeExternalIdentityClaims()) + } + response := telegramOAuthCallback(state, code, fixture.identity) + assert.Contains(t, response.Body.String(), `"success":false`) + var claims int64 + require.NoError(t, model.DB.Model(&model.ExternalIdentityClaim{}).Where("user_id = ? AND subject = ?", fixture.user.Id, "42").Count(&claims).Error) + assert.Zero(t, claims) + }) + } +} + +func TestTelegramOAuthConfigurationAndLegacyEndpoints(t *testing.T) { + fixture := setupTelegramOAuthTest(t) + previousBotToken := common.TelegramBotToken + t.Cleanup(func() { common.TelegramBotToken = previousBotToken }) + require.NoError(t, model.UpdateOptionsBulk(map[string]string{ + "telegram.client_id": "12345", "telegram.client_secret": "telegram-client-secret", + "TelegramBotToken": "stored-legacy-token", "TelegramOAuthEnabled": "true", + })) + for range 2 { + *system_setting.GetTelegramSettings() = system_setting.TelegramSettings{} + common.TelegramOAuthEnabled = false + model.InitOptionMap() + assert.True(t, common.TelegramOAuthEnabled) + assert.True(t, system_setting.GetTelegramSettings().IsConfigured()) + assert.Equal(t, "telegram-client-secret", system_setting.GetTelegramSettings().ClientSecret) + assert.Equal(t, "stored-legacy-token", common.TelegramBotToken) + } + var stored model.Option + require.NoError(t, model.DB.Where(&model.Option{Key: "telegram.client_id"}).First(&stored).Error) + assert.Equal(t, "12345", stored.Value) + status := securityEnrollmentRequest("GET", "/api/status", "", "", fixture.identity, GetStatus) + assert.Contains(t, status.Body.String(), `"telegram_oauth_configured":true`) + assert.NotContains(t, status.Body.String(), "telegram-client-secret") + options := securityEnrollmentRequest("GET", "/api/option/", "", "", fixture.identity, GetOptions) + assert.NotContains(t, options.Body.String(), "telegram-client-secret") + assert.NotContains(t, options.Body.String(), "stored-legacy-token") + require.NoError(t, model.DB.Model(fixture.user).Updates(map[string]any{"password": "", "telegram_id": "42"}).Error) + for _, unavailable := range []string{"missing secret", "disabled", "conflict"} { + t.Run(unavailable, func(t *testing.T) { + switch unavailable { + case "missing secret": + system_setting.GetTelegramSettings().ClientSecret = "" + case "disabled": + common.TelegramOAuthEnabled = false + case "conflict": + require.Error(t, oauth.RegisterCustom("telegram", oauth.NewGenericOAuthProvider(&model.CustomOAuthProvider{Slug: "telegram"}))) + } + response := securityEnrollmentRequest("POST", "/api/oauth/state", `{"provider":"telegram","intent":"login"}`, "", service.AuthIdentity{}, GenerateOAuthCode) + assert.Contains(t, response.Body.String(), `"success":false`) + assert.NotContains(t, response.Body.String(), "flow_token") + for _, scope := range []string{"2fa.setup", "passkey.register"} { + requirements, err := service.GetVerificationRequirements(fixture.identity, scope) + require.NoError(t, err) + require.Len(t, requirements.Methods, 1) + assert.False(t, requirements.Methods[0].Available) + assert.Contains(t, requirements.Methods[0].Reason, "administrator") + _, err = service.VerifySecurityInput(fixture.identity, service.VerificationInput{Scope: scope, Method: "session"}) + assert.Error(t, err) + _, err = service.StartOAuthVerification(fixture.identity, service.VerificationOperation{Scope: scope}, "telegram") + assert.Error(t, err) + } + for _, handler := range []gin.HandlerFunc{Setup2FA, PasskeyRegisterBegin} { + response := securityEnrollmentRequest("POST", "/setup", `{}`, "", fixture.identity, handler) + assert.Contains(t, response.Body.String(), `"success":false`) + assert.NotContains(t, response.Body.String(), "flow_token") + } + common.TelegramOAuthEnabled = true + system_setting.GetTelegramSettings().ClientSecret = "telegram-client-secret" + oauth.UnregisterCustomProvider("telegram") + }) + } + for _, endpoint := range []struct{ method, path string }{ + {"GET", "/api/oauth/telegram/login"}, + {"POST", "/api/oauth/telegram/bind/start"}, + {"GET", "/api/oauth/telegram/bind/old-flow"}, + } { + response := securityEnrollmentRequest(endpoint.method, endpoint.path, "", "", fixture.identity, TelegramLegacyAuth) + assert.Equal(t, http.StatusGone, response.Code) + assert.Contains(t, response.Body.String(), "TELEGRAM_LEGACY_AUTH_REMOVED") + } +} + +func TestTelegramOAuthConcurrentBindingHasSingleOwner(t *testing.T) { + fixture := setupTelegramOAuthTest(t) + other := &model.User{Username: "other-owner", AffCode: "other-owner", Status: common.UserStatusEnabled, AuthVersion: 1} + require.NoError(t, model.DB.Create(other).Error) + bundle, err := service.CreateLoginSession(other.Id, "password", "127.0.0.1", "test") + require.NoError(t, err) + otherIdentity, err := service.ParseAccessToken(bundle.AccessToken) + require.NoError(t, err) + state, code := fixture.authorization(t, "bind", fixture.identity, "", telegramIdentityClaims(42)) + otherState, otherCode := fixture.authorization(t, "bind", otherIdentity, "", telegramIdentityClaims(42)) + start := make(chan struct{}) + responses := make(chan *httptest.ResponseRecorder, 2) + go func() { <-start; responses <- telegramOAuthCallback(state, code, fixture.identity) }() + go func() { <-start; responses <- telegramOAuthCallback(otherState, otherCode, otherIdentity) }() + close(start) + successes := 0 + for range 2 { + response := <-responses + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + if body.Success { + successes++ + } + } + assert.Equal(t, 1, successes) + var owners []model.User + require.NoError(t, model.DB.Where("telegram_id = ?", "42").Find(&owners).Error) + require.Len(t, owners, 1) + var claims []model.ExternalIdentityClaim + require.NoError(t, model.DB.Where("provider = ? AND subject = ?", "telegram", "42").Find(&claims).Error) + require.Len(t, claims, 1) + assert.Equal(t, owners[0].Id, claims[0].UserId) } diff --git a/controller/twofa.go b/controller/twofa.go index 1c0d674d1..fba31d185 100644 --- a/controller/twofa.go +++ b/controller/twofa.go @@ -6,11 +6,13 @@ import ( "strconv" "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/service" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) type Verify2FARequest struct { @@ -333,7 +335,7 @@ func Admin2FAStats(c *gin.Context) { func AdminDisable2FA(c *gin.Context) { userIdStr := c.Param("id") userId, err := strconv.Atoi(userIdStr) - if err != nil { + if err != nil || userId <= 0 { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "用户ID格式错误", @@ -343,6 +345,10 @@ func AdminDisable2FA(c *gin.Context) { // 检查目标用户权限 targetUser, err := model.GetUserById(userId, false) + if errors.Is(err, gorm.ErrRecordNotFound) { + common.ApiErrorI18n(c, i18n.MsgUserNotExists) + return + } if err != nil { writeSecurityOperationError(c, err) return diff --git a/go.mod b/go.mod index 72a9e8e31..05e01799f 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/aws/smithy-go v1.24.2 github.com/bytedance/gopkg v0.1.3 github.com/casbin/casbin/v2 v2.135.0 + github.com/coreos/go-oidc/v3 v3.21.0 github.com/gin-contrib/cors v1.7.2 github.com/gin-contrib/gzip v0.0.6 github.com/gin-contrib/static v0.0.1 @@ -52,6 +53,7 @@ require ( golang.org/x/crypto v0.52.0 golang.org/x/image v0.41.0 golang.org/x/net v0.55.0 + golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 golang.org/x/sys v0.45.0 golang.org/x/text v0.37.0 @@ -73,6 +75,7 @@ require ( github.com/dlclark/regexp2/v2 v2.2.2 // indirect github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect github.com/hashicorp/go-version v1.8.0 // indirect diff --git a/go.sum b/go.sum index 6f8972a56..cb4c676d0 100644 --- a/go.sum +++ b/go.sum @@ -964,6 +964,8 @@ github.com/coreos/go-iptables v0.4.5/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmeka github.com/coreos/go-iptables v0.5.0/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmekav8Dbxlm1MU= github.com/coreos/go-iptables v0.6.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= +github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM= +github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd v0.0.0-20161114122254-48702e0da86b/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= @@ -1144,6 +1146,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-ini/ini v1.25.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-ini/ini v1.66.6/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -2389,6 +2393,8 @@ golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I= golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/model/custom_oauth_provider.go b/model/custom_oauth_provider.go index 12b4d1111..583ed07dd 100644 --- a/model/custom_oauth_provider.go +++ b/model/custom_oauth_provider.go @@ -161,6 +161,9 @@ func validateCustomOAuthProvider(provider *CustomOAuthProvider) error { } } provider.Slug = slug + if slug == "telegram" { + return errors.New("the telegram slug is reserved for built-in Telegram OAuth; rename this custom provider") + } if provider.ClientId == "" { return errors.New("client ID is required") diff --git a/model/external_identity_claim.go b/model/external_identity_claim.go index 7c5ee1f49..2bf1cf95d 100644 --- a/model/external_identity_claim.go +++ b/model/external_identity_claim.go @@ -76,6 +76,38 @@ func ReleaseExternalIdentityWithTx(tx *gorm.DB, provider string, userId int) err Delete(&ExternalIdentityClaim{}).Error } +func GetUserByTelegramID(telegramID string) (*User, error) { + var user User + err := DB.Where("telegram_id = ?", telegramID).First(&user).Error + return &user, err +} + +// BindTelegramForSessionWithTx preserves single ownership and the session that +// started the binding. The caller consumes its OAuth flow in this transaction. +func BindTelegramForSessionWithTx(tx *gorm.DB, identity AuthSessionIdentity, telegramID string) error { + if err := ValidateAuthSessionWithTx(tx, identity); err != nil { + return err + } + var user User + if err := tx.Select("id", "telegram_id").First(&user, identity.UserID).Error; err != nil { + return err + } + if user.TelegramId != "" { + return ErrExternalIdentityAlreadyClaimed + } + if err := ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, telegramID, user.Id); err != nil { + return err + } + result := tx.Model(&User{}).Where("id = ? AND (telegram_id = ? OR telegram_id IS NULL)", user.Id, "").Update("telegram_id", telegramID) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrExternalIdentityAlreadyClaimed + } + return nil +} + func releaseAllExternalIdentitiesWithTx(tx *gorm.DB, userId int) error { if tx == nil || userId == 0 { return errors.New("external identity release is invalid") diff --git a/oauth/registry.go b/oauth/registry.go index 91d196364..d5e5b4d79 100644 --- a/oauth/registry.go +++ b/oauth/registry.go @@ -12,7 +12,8 @@ var ( providers = make(map[string]Provider) mu sync.RWMutex // customProviderSlugs tracks which providers are custom (can be unregistered) - customProviderSlugs = make(map[string]bool) + customProviderSlugs = make(map[string]bool) + customProviderConflicts = make(map[string]bool) ) // Register registers an OAuth provider with the given name @@ -23,11 +24,22 @@ func Register(name string, provider Provider) { } // RegisterCustom registers a custom OAuth provider (can be unregistered later) -func RegisterCustom(name string, provider Provider) { +func RegisterCustom(name string, provider Provider) error { mu.Lock() defer mu.Unlock() + if providers[name] != nil && !customProviderSlugs[name] { + customProviderConflicts[name] = true + return fmt.Errorf("custom OAuth provider %q conflicts with a built-in provider; rename the custom provider", name) + } providers[name] = provider customProviderSlugs[name] = true + return nil +} + +func HasCustomProviderConflict(name string) bool { + mu.RLock() + defer mu.RUnlock() + return customProviderConflicts[name] } // Unregister removes a provider from the registry @@ -94,6 +106,7 @@ func LoadCustomProviders() error { delete(providers, name) } customProviderSlugs = make(map[string]bool) + customProviderConflicts = make(map[string]bool) mu.Unlock() // Load all custom providers from database @@ -104,14 +117,19 @@ func LoadCustomProviders() error { } // Register each custom provider + var conflict error for _, config := range customProviders { provider := NewGenericOAuthProvider(config) - RegisterCustom(config.Slug, provider) + if err := RegisterCustom(config.Slug, provider); err != nil { + common.SysError(err.Error()) + conflict = err + continue + } common.SysLog("Loaded custom OAuth provider: " + config.Name + " (" + config.Slug + ")") } common.SysLog(fmt.Sprintf("Loaded %d custom OAuth providers", len(customProviders))) - return nil + return conflict } // ReloadCustomProviders reloads all custom OAuth providers from the database @@ -122,13 +140,18 @@ func ReloadCustomProviders() error { // RegisterOrUpdateCustomProvider registers or updates a single custom provider func RegisterOrUpdateCustomProvider(config *model.CustomOAuthProvider) { provider := NewGenericOAuthProvider(config) - mu.Lock() - defer mu.Unlock() - providers[config.Slug] = provider - customProviderSlugs[config.Slug] = true + if err := RegisterCustom(config.Slug, provider); err != nil { + common.SysError(err.Error()) + } } // UnregisterCustomProvider unregisters a custom provider by slug func UnregisterCustomProvider(slug string) { - Unregister(slug) + mu.Lock() + defer mu.Unlock() + if customProviderSlugs[slug] { + delete(providers, slug) + delete(customProviderSlugs, slug) + } + delete(customProviderConflicts, slug) } diff --git a/oauth/telegram.go b/oauth/telegram.go new file mode 100644 index 000000000..a84cccdec --- /dev/null +++ b/oauth/telegram.go @@ -0,0 +1,202 @@ +package oauth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gin-gonic/gin" + "golang.org/x/oauth2" +) + +const ( + TelegramIssuer = "https://oauth.telegram.org" + TelegramOAuthFlowContextKey = "telegram_oauth_flow" +) + +var ( + ErrTelegramOAuthNotConfigured = errors.New("Telegram OAuth is not configured or enabled. Please contact your administrator.") + ErrTelegramOAuthConflict = errors.New("The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.") + ErrTelegramOAuthFailed = errors.New("Telegram authorization failed. Please try again.") + ErrTelegramAccountNotBound = errors.New("This Telegram account is not linked. Sign in using another method and link it first.") +) + +// TelegramOAuthFlow keeps the PKCE secret and original client configuration on +// the server. Only AuthorizationURL is sent to the browser. +type TelegramOAuthFlow struct { + CodeVerifier string `json:"code_verifier"` + ClientID string `json:"client_id"` + RedirectURI string `json:"redirect_uri"` +} + +func TelegramConfigurationError() error { + if HasCustomProviderConflict("telegram") { + return ErrTelegramOAuthConflict + } + if !common.TelegramOAuthEnabled || !system_setting.GetTelegramSettings().IsConfigured() { + return ErrTelegramOAuthNotConfigured + } + return nil +} + +func NewTelegramOAuthFlow() (*TelegramOAuthFlow, error) { + if err := TelegramConfigurationError(); err != nil { + return nil, err + } + redirectURI := strings.TrimRight(system_setting.ServerAddress, "/") + "/oauth/telegram" + parsed, err := url.Parse(redirectURI) + if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") { + return nil, ErrTelegramOAuthNotConfigured + } + return &TelegramOAuthFlow{ + CodeVerifier: oauth2.GenerateVerifier(), + ClientID: strings.TrimSpace(system_setting.GetTelegramSettings().ClientID), + RedirectURI: redirectURI, + }, nil +} + +func (flow *TelegramOAuthFlow) AuthorizationURL(state string) string { + values := url.Values{ + "client_id": {flow.ClientID}, + "redirect_uri": {flow.RedirectURI}, + "response_type": {"code"}, + "scope": {"openid profile"}, + "state": {state}, + "code_challenge": {oauth2.S256ChallengeFromVerifier(flow.CodeVerifier)}, + "code_challenge_method": {"S256"}, + } + return TelegramIssuer + "/auth?" + values.Encode() +} + +type TelegramProvider struct { + client *http.Client + keys oidc.KeySet +} + +func init() { + Register("telegram", NewTelegramProvider(&http.Client{Timeout: 20 * time.Second})) +} + +// NewTelegramProvider shares the HTTP client with a long-lived, cached JWKS +// verifier. Tests can exercise the real protocol using a local HTTP transport. +func NewTelegramProvider(client *http.Client) *TelegramProvider { + return &TelegramProvider{ + client: client, + keys: oidc.NewRemoteKeySet( + oidc.ClientContext(context.Background(), client), + TelegramIssuer+"/.well-known/jwks.json", + ), + } +} + +func (p *TelegramProvider) GetName() string { return "Telegram" } + +func (p *TelegramProvider) IsEnabled() bool { return TelegramConfigurationError() == nil } + +func (p *TelegramProvider) ExchangeToken(ctx context.Context, code string, c *gin.Context) (*OAuthToken, error) { + if err := TelegramConfigurationError(); err != nil { + return nil, err + } + value, _ := c.Get(TelegramOAuthFlowContextKey) + flow, ok := value.(*TelegramOAuthFlow) + settings := system_setting.GetTelegramSettings() + if !ok || flow == nil || flow.CodeVerifier == "" || code == "" || + flow.ClientID != strings.TrimSpace(settings.ClientID) || + flow.RedirectURI != strings.TrimRight(system_setting.ServerAddress, "/")+"/oauth/telegram" { + return nil, ErrTelegramOAuthFailed + } + values := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "client_id": {flow.ClientID}, + "redirect_uri": {flow.RedirectURI}, + "code_verifier": {flow.CodeVerifier}, + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, TelegramIssuer+"/token", strings.NewReader(values.Encode())) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrTelegramOAuthFailed, err) + } + request.SetBasicAuth(flow.ClientID, strings.TrimSpace(settings.ClientSecret)) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response, err := p.client.Do(request) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrTelegramOAuthFailed, err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: token endpoint status %d", ErrTelegramOAuthFailed, response.StatusCode) + } + var token OAuthToken + if err := common.DecodeJson(io.LimitReader(response.Body, 1<<20), &token); err != nil { + return nil, fmt.Errorf("%w: %v", ErrTelegramOAuthFailed, err) + } + if token.IDToken == "" { + return nil, ErrTelegramOAuthFailed + } + token.ClientID = flow.ClientID + return &token, nil +} + +func (p *TelegramProvider) GetUserInfo(ctx context.Context, token *OAuthToken) (*OAuthUser, error) { + if err := TelegramConfigurationError(); err != nil { + return nil, err + } + if token == nil || token.ClientID != strings.TrimSpace(system_setting.GetTelegramSettings().ClientID) { + return nil, ErrTelegramOAuthFailed + } + verifier := oidc.NewVerifier(TelegramIssuer, p.keys, &oidc.Config{ + ClientID: token.ClientID, SupportedSigningAlgs: []string{oidc.RS256, oidc.ES256}, + }) + verified, err := verifier.Verify(ctx, token.IDToken) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrTelegramOAuthFailed, err) + } + var rawClaims json.RawMessage + if err := verified.Claims(&rawClaims); err != nil { + return nil, fmt.Errorf("%w: %v", ErrTelegramOAuthFailed, err) + } + var claims struct { + ID json.Number `json:"id"` + Name string `json:"name"` + Username string `json:"preferred_username"` + } + if err := common.Unmarshal(rawClaims, &claims); err != nil { + return nil, fmt.Errorf("%w: %v", ErrTelegramOAuthFailed, err) + } + id, err := strconv.ParseUint(claims.ID.String(), 10, 64) + if err != nil || id == 0 || verified.Subject == "" { + return nil, ErrTelegramOAuthFailed + } + return &OAuthUser{ + ProviderUserID: strconv.FormatUint(id, 10), + Username: claims.Username, DisplayName: claims.Name, + }, nil +} + +func (p *TelegramProvider) IsUserIDTaken(id string) bool { return model.IsTelegramIdAlreadyTaken(id) } + +func (p *TelegramProvider) FillUserByProviderID(user *model.User, id string) error { + stored, err := model.GetUserByTelegramID(id) + if err != nil { + return err + } + *user = *stored + return nil +} + +func (p *TelegramProvider) SetProviderUserID(user *model.User, id string) { user.TelegramId = id } + +func (p *TelegramProvider) GetProviderPrefix() string { return "telegram_" } + +func (p *TelegramProvider) ProviderUserIDColumn() string { return "telegram_id" } diff --git a/oauth/types.go b/oauth/types.go index 383e6f351..f59848d58 100644 --- a/oauth/types.go +++ b/oauth/types.go @@ -8,6 +8,7 @@ type OAuthToken struct { ExpiresIn int `json:"expires_in,omitempty"` Scope string `json:"scope,omitempty"` IDToken string `json:"id_token,omitempty"` + ClientID string `json:"-"` // Expected OIDC audience from the server-owned flow. } // OAuthUser represents the user info from OAuth provider diff --git a/router/api-router.go b/router/api-router.go index 120ab856b..26b733226 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -47,13 +47,13 @@ func SetApiRouter(router *gin.Engine) { // OAuth routes - specific routes must come before :provider wildcard apiRouter.POST("/oauth/state", middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.TryUserAuth(), anonymousRequestBodyLimit, controller.GenerateOAuthCode) apiRouter.POST("/oauth/email/bind", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.EmailBind) - // Non-standard OAuth (WeChat, Telegram) - keep original routes + // WeChat uses its existing authorization-code service. apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.WeChatAuth) apiRouter.POST("/oauth/wechat/bind", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.WeChatBind) - apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.TelegramLogin) - apiRouter.POST("/oauth/telegram/bind/start", middleware.UserAuth(), middleware.CriticalRateLimit(), middleware.DisableCache(), controller.TelegramBindStart) - apiRouter.GET("/oauth/telegram/bind/:flow_token", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.TelegramBind) - // Standard OAuth providers (GitHub, Discord, OIDC, LinuxDO) - unified route + apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.TelegramLegacyAuth) + apiRouter.POST("/oauth/telegram/bind/start", middleware.UserAuth(), middleware.CriticalRateLimit(), middleware.DisableCache(), controller.TelegramLegacyAuth) + apiRouter.GET("/oauth/telegram/bind/:flow_token", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.TelegramLegacyAuth) + // Standard OAuth providers (GitHub, Discord, OIDC, LinuxDO, Telegram) - unified route apiRouter.GET("/oauth/:provider", middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.TryUserAuth(), controller.HandleOAuth) apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig) diff --git a/service/security_verification.go b/service/security_verification.go index 4c880f0e2..59d85ba8f 100644 --- a/service/security_verification.go +++ b/service/security_verification.go @@ -19,6 +19,7 @@ const ( VerificationMethodPasskey = "passkey" VerificationMethodPassword = "password" VerificationMethodOAuth = "oauth" + VerificationMethodSession = "session" VerificationScopeChannelKeyRead = "channel.key.read" VerificationScopePasskeyRegister = "passkey.register" VerificationScopePasskeyDelete = "passkey.delete" @@ -115,11 +116,12 @@ type VerificationRequirements struct { } type verificationAccountState struct { - HasPassword bool - HasTwoFA bool - TwoFALocked bool - HasPasskey bool - PasskeyEnabled bool + HasPassword bool + HasTwoFA bool + TwoFALocked bool + HasPasskey bool + PasskeyEnabled bool + WeChatEnrollment bool } // securityVerificationPolicy is the only operation-to-method policy. Device @@ -151,6 +153,8 @@ func securityVerificationPolicy(scope string, state verificationAccountState) ([ methods = []string{VerificationMethodPasskey} case state.HasPassword: methods = []string{VerificationMethodPassword} + case state.WeChatEnrollment: + methods = []string{VerificationMethodSession} default: methods = []string{VerificationMethodOAuth} } @@ -195,6 +199,16 @@ func GetVerificationRequirements(identity AuthIdentity, scope string) (*Verifica TwoFALocked: twoFA != nil && twoFA.IsLocked(), HasPasskey: err == nil, PasskeyEnabled: system_setting.GetPasskeySettings().Enabled, } + if (scope == VerificationScopeTwoFASetup || scope == VerificationScopePasskeyRegister) && + !state.HasPassword && !state.HasTwoFA && !state.HasPasskey && + user.WeChatId != "" && user.TelegramId == "" && user.GitHubId == "" && + user.DiscordId == "" && user.OidcId == "" && user.LinuxDOId == "" { + bindings, err := model.GetUserOAuthBindingsByUserId(user.Id) + if err != nil { + return nil, err + } + state.WeChatEnrollment = len(bindings) == 0 + } methods, err := securityVerificationPolicy(scope, state) if err != nil { return nil, err @@ -210,6 +224,11 @@ func GetVerificationRequirements(identity AuthIdentity, scope string) (*Verifica } if len(requirements.OAuthProviders) == 0 { methods[i].Available, methods[i].Reason = false, "No linked OAuth provider is available." + if user.TelegramId != "" { + if err := oauth.TelegramConfigurationError(); err != nil { + methods[i].Reason = err.Error() + } + } } } return requirements, nil @@ -244,6 +263,8 @@ func verificationOAuthProviders(user *model.User) ([]VerificationOAuthProvider, userID = user.OidcId case "linux_do_id": userID = user.LinuxDOId + case "telegram_id": + userID = user.TelegramId } } if userID != "" { @@ -364,6 +385,9 @@ func VerifySecurityInput(identity AuthIdentity, input VerificationInput) (*Secur return nil, err } switch input.Method { + case VerificationMethodSession: + // The policy above permits only first enrollment for a WeChat-only + // account. CompleteSecurityVerification rechecks its live session. case VerificationMethodPassword: password := input.Password if common.PasswordLoginEncryptionEnabled { diff --git a/setting/system_setting/telegram.go b/setting/system_setting/telegram.go new file mode 100644 index 000000000..7a49ab0b0 --- /dev/null +++ b/setting/system_setting/telegram.go @@ -0,0 +1,26 @@ +package system_setting + +import ( + "strings" + + "github.com/QuantumNous/new-api/setting/config" +) + +type TelegramSettings struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` +} + +var telegramSettings TelegramSettings + +func init() { + config.GlobalConfig.Register("telegram", &telegramSettings) +} + +func GetTelegramSettings() *TelegramSettings { + return &telegramSettings +} + +func (s *TelegramSettings) IsConfigured() bool { + return strings.TrimSpace(s.ClientID) != "" && strings.TrimSpace(s.ClientSecret) != "" +} diff --git a/web/src/features/auth/api.test.ts b/web/src/features/auth/api.test.ts index c3fcb8943..4495c03e4 100644 --- a/web/src/features/auth/api.test.ts +++ b/web/src/features/auth/api.test.ts @@ -16,12 +16,75 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { describe, expect, test } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import { toast } from 'sonner' +import { afterEach, describe, expect, test, vi } from 'vitest' -import type { RefreshOutcome } from '@/lib/api' +import { api, type RefreshOutcome } from '@/lib/api' import type { AuthBundle } from '@/stores/auth-store' import { executeLogout } from './api' +import { useOAuthLogin } from './hooks/use-oauth-login' +import { consumeOAuthLoginRedirect } from './lib/oauth-callback-mode' + +afterEach(() => vi.restoreAllMocks()) + +test.each([true, false])( + 'starts Telegram OAuth only when configuration is ready: %s', + async (configured) => { + vi.spyOn(window, 'localStorage', 'get').mockReturnValue( + window.sessionStorage + ) + const post = vi.spyOn(api, 'post').mockImplementation(async (url) => { + if (url === '/api/oauth/state') { + return { + data: { + success: true, + data: { + flow_token: 'telegram-state', + authorization_url: 'https://oauth.telegram.org/auth?server=pkce', + }, + }, + } + } + if (url === '/api/user/auth/logout') return { data: { success: true } } + throw new Error(`Unexpected POST ${url}`) + }) + const open = vi.spyOn(window, 'open').mockReturnValue(null) + const error = vi.spyOn(toast, 'error') + const { result } = renderHook(() => + useOAuthLogin( + { telegram_oauth: true, telegram_oauth_configured: configured }, + '/console/personal' + ) + ) + await act(() => result.current.handleTelegramLogin()) + if (configured) { + expect(post.mock.calls.map(([url]) => url)).toEqual([ + '/api/oauth/state', + '/api/user/auth/logout', + ]) + expect(post).toHaveBeenCalledWith( + '/api/oauth/state', + expect.objectContaining({ provider: 'telegram', intent: 'login' }), + expect.anything() + ) + expect(open).toHaveBeenCalledWith( + 'https://oauth.telegram.org/auth?server=pkce', + '_self' + ) + expect(consumeOAuthLoginRedirect('telegram-state')).toBe( + '/console/personal' + ) + } else { + expect(post).not.toHaveBeenCalled() + expect(open).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith( + 'Telegram OAuth is not configured or enabled. Please contact your administrator.' + ) + } + } +) const bundle: AuthBundle = { access_token: 'access-token', diff --git a/web/src/features/auth/api.ts b/web/src/features/auth/api.ts index a83ce78f5..1977bf291 100644 --- a/web/src/features/auth/api.ts +++ b/web/src/features/auth/api.ts @@ -20,6 +20,7 @@ import axios from 'axios' import { api, refreshAuthentication, type RefreshOutcome } from '@/lib/api' import { AuthOperationError } from '@/lib/secure-verification' +import { getServerErrorMessageKey } from '@/lib/server-error-message' import { useAuthStore } from '@/stores/auth-store' import { @@ -166,12 +167,12 @@ export async function githubOAuthStart(clientId: string, state: string) { } // Get OAuth state for CSRF protection -export async function createOAuthFlow( +export async function createOAuthAuthorization( provider: string, intent: 'login' | 'bind' | 'verify', operation?: VerificationOperation, signal?: AbortSignal -): Promise { +): Promise<{ state: string; authorizationUrl?: string }> { const aff = intent === 'login' ? getAffiliateCode() : '' const res = await api.post( '/api/oauth/state', @@ -190,17 +191,32 @@ export async function createOAuthFlow( } ) if (res.data?.success) { - if (typeof res.data.data === 'string') return res.data.data + if (typeof res.data.data === 'string') return { state: res.data.data } if (typeof res.data.data?.flow_token === 'string') { - return res.data.data.flow_token + return { + state: res.data.data.flow_token, + authorizationUrl: res.data.data.authorization_url, + } } } throw new AuthOperationError( - res.data?.message || 'Failed to initialize OAuth', + getServerErrorMessageKey(res.data) || + res.data?.message || + 'Failed to initialize OAuth', res.data?.code ) } +export async function createOAuthFlow( + provider: string, + intent: 'login' | 'bind' | 'verify', + operation?: VerificationOperation, + signal?: AbortSignal +): Promise { + return (await createOAuthAuthorization(provider, intent, operation, signal)) + .state +} + // WeChat login by authorization code export async function wechatLoginByCode(code: string): Promise { const res = await api.get('/api/oauth/wechat', { params: { code } }) diff --git a/web/src/features/auth/components/oauth-providers.tsx b/web/src/features/auth/components/oauth-providers.tsx index a686d356b..3a3559c56 100644 --- a/web/src/features/auth/components/oauth-providers.tsx +++ b/web/src/features/auth/components/oauth-providers.tsx @@ -31,7 +31,6 @@ import { cn } from '@/lib/utils' import { useOAuthLogin } from '../hooks/use-oauth-login' import type { SystemStatus } from '../types' -import { TelegramLoginDialog } from './telegram-login-dialog' type OAuthProvidersProps = { status: SystemStatus | null @@ -69,10 +68,6 @@ export function OAuthProviders({ handleLinuxDOLogin, handleTelegramLogin, handleCustomOAuthLogin, - isTelegramDialogOpen, - isTelegramPending, - handleTelegramAuthorization, - setIsTelegramDialogOpen, } = useOAuthLogin(status, redirectTo) const providerButtons: ProviderButton[] = [] @@ -150,45 +145,35 @@ export function OAuthProviders({ if (providerButtons.length === 0) return null return ( - <> -
-
-
- -
-
- - {t('Or continue with')} - -
+
+
+
+
- -
- {providerButtons.map( - ({ key, label, onClick, icon, disabled: extraDisabled }) => ( - - ) - )} +
+ + {t('Or continue with')} +
- - +
+ {providerButtons.map( + ({ key, label, onClick, icon, disabled: extraDisabled }) => ( + + ) + )} +
+
) } diff --git a/web/src/features/auth/hooks/use-oauth-login.ts b/web/src/features/auth/hooks/use-oauth-login.ts index d77047618..9510c2bee 100644 --- a/web/src/features/auth/hooks/use-oauth-login.ts +++ b/web/src/features/auth/hooks/use-oauth-login.ts @@ -20,18 +20,18 @@ import { useState, useRef, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { clearAuthentication, isAuthBundle } from '@/lib/api' +import { clearAuthentication } from '@/lib/api' +import { AuthOperationError } from '@/lib/secure-verification' -import { createOAuthFlow, logout, telegramLogin } from '../api' +import { createOAuthAuthorization, createOAuthFlow, logout } from '../api' import { buildGitHubOAuthUrl, buildDiscordOAuthUrl, buildOIDCOAuthUrl, buildLinuxDOOAuthUrl, } from '../lib/oauth' -import { pickTelegramAuthorization } from '../lib/telegram-login' +import { rememberOAuthLoginRedirect } from '../lib/oauth-callback-mode' import type { SystemStatus, CustomOAuthProviderInfo } from '../types' -import { useAuthRedirect } from './use-auth-redirect' /** * Hook for managing OAuth login @@ -41,10 +41,7 @@ export function useOAuthLogin( redirectTo?: string ) { const { t } = useTranslation() - const { handleLoginSuccess } = useAuthRedirect() const [isLoading, setIsLoading] = useState(false) - const [isTelegramDialogOpen, setIsTelegramDialogOpen] = useState(false) - const [isTelegramPending, setIsTelegramPending] = useState(false) const [githubButtonText, setGithubButtonText] = useState('') const [githubButtonDisabled, setGithubButtonDisabled] = useState(false) const githubTimeoutRef = useRef(null) @@ -160,49 +157,30 @@ export function useOAuthLogin( } const handleTelegramLogin = async () => { - if (!status?.telegram_bot_name?.trim()) { - toast.error(t('Login failed')) + if (!status?.telegram_oauth_configured) { + toast.error( + t( + 'Telegram OAuth is not configured or enabled. Please contact your administrator.' + ) + ) return } - setIsLoading(true) try { + const authorization = await createOAuthAuthorization('telegram', 'login') + if (!authorization.authorizationUrl) { + throw new AuthOperationError('Failed to initialize OAuth') + } await resetSession() - setIsTelegramDialogOpen(true) - } catch { - toast.error( - t('Failed to start {{provider}} login', { provider: 'Telegram' }) - ) + rememberOAuthLoginRedirect(authorization.state, redirectTo) + window.open(authorization.authorizationUrl, '_self') + } catch (error) { + toast.error(t(AuthOperationError.from(error).message)) } finally { setIsLoading(false) } } - const handleTelegramAuthorization = async (value: unknown) => { - const authorization = pickTelegramAuthorization(value) - if (!authorization) { - toast.error(t('Login failed')) - return - } - - setIsTelegramPending(true) - try { - const response = await telegramLogin(authorization) - if (!response.success || !isAuthBundle(response.data)) { - toast.error(t('Login failed')) - return - } - - setIsTelegramDialogOpen(false) - await handleLoginSuccess(response.data, redirectTo) - toast.success(t('Welcome back!')) - } catch { - toast.error(t('Login failed')) - } finally { - setIsTelegramPending(false) - } - } - const handleCustomOAuthLogin = async (provider: CustomOAuthProviderInfo) => { if (!provider.authorization_endpoint || !provider.client_id) return @@ -235,15 +213,11 @@ export function useOAuthLogin( isLoading, githubButtonText, githubButtonDisabled, - isTelegramDialogOpen, - isTelegramPending, handleGitHubLogin, handleDiscordLogin, handleOIDCLogin, handleLinuxDOLogin, handleTelegramLogin, - handleTelegramAuthorization, - setIsTelegramDialogOpen, handleCustomOAuthLogin, } } diff --git a/web/src/features/auth/lib/__tests__/oauth-popup.test.ts b/web/src/features/auth/lib/__tests__/oauth-popup.test.ts index ac0230958..5ca19f619 100644 --- a/web/src/features/auth/lib/__tests__/oauth-popup.test.ts +++ b/web/src/features/auth/lib/__tests__/oauth-popup.test.ts @@ -131,9 +131,35 @@ it('closes an aborted popup and ignores a late authorization response', async () expect(popup.location.replace).not.toHaveBeenCalled() }) -it('aborts the callback request if the user closes the popup before it finishes', async () => { - vi.useFakeTimers() +it.each(['bind', 'verify'] as const)( + 'keeps the %s callback request alive after the popup closes', + async (intent) => { + vi.useFakeTimers() + const popup = popupWindow() + const prepared = Promise.resolve({ + state: 'state', + url: 'https://example.com/authorize', + }) + const result = openOAuthPopup({ + provider: 'github', + intent, + signal: new AbortController().signal, + prepare: () => prepared, + }) + await prepared + callbackMessage(popup, { intent }) + const exchange = await result + popup.closed = true + await vi.advanceTimersByTimeAsync(11 * 60_000) + expect(exchange.signal.aborted).toBe(false) + expect(vi.getTimerCount()).toBe(0) + exchange.finish({ success: true }) + } +) + +it('still cancels when the caller aborts after receiving a callback', async () => { const popup = popupWindow() + const controller = new AbortController() const prepared = Promise.resolve({ state: 'state', url: 'https://example.com/authorize', @@ -141,16 +167,35 @@ it('aborts the callback request if the user closes the popup before it finishes' const result = openOAuthPopup({ provider: 'github', intent: 'verify', - signal: new AbortController().signal, + signal: controller.signal, prepare: () => prepared, }) await prepared callbackMessage(popup) const exchange = await result + controller.abort(new AuthOperationError('Cancelled', 'AUTH_CANCELLED')) + expect(exchange.signal.aborted).toBe(true) + expect(popup.closed).toBe(true) +}) + +it('cancels when the popup closes before receiving a callback', async () => { + vi.useFakeTimers() + const popup = popupWindow() + const result = openOAuthPopup({ + provider: 'github', + intent: 'verify', + signal: new AbortController().signal, + prepare: async () => ({ + state: 'state', + url: 'https://example.com/authorize', + }), + }) + const rejected = expect(result).rejects.toMatchObject({ + code: 'AUTH_CANCELLED', + }) popup.closed = true await vi.advanceTimersByTimeAsync(500) - expect(exchange.signal.aborted).toBe(true) - expect(exchange.signal.reason).toMatchObject({ code: 'AUTH_CANCELLED' }) + await rejected expect(vi.getTimerCount()).toBe(0) }) diff --git a/web/src/features/auth/lib/oauth-callback-mode.ts b/web/src/features/auth/lib/oauth-callback-mode.ts index 4a830ed88..0eaa2628f 100644 --- a/web/src/features/auth/lib/oauth-callback-mode.ts +++ b/web/src/features/auth/lib/oauth-callback-mode.ts @@ -19,6 +19,29 @@ For commercial licensing, please contact support@quantumnous.com const OAUTH_POPUP_FLOW_KEY_PREFIX = 'oauth_popup_flow:' +export function rememberOAuthLoginRedirect( + state: string, + redirect?: string +): void { + if (!redirect) return + try { + window.sessionStorage.setItem(`oauth_login_redirect:${state}`, redirect) + } catch { + // Login can still complete using the default destination. + } +} + +export function consumeOAuthLoginRedirect(state: string): string | null { + try { + const key = `oauth_login_redirect:${state}` + const redirect = window.sessionStorage.getItem(key) + window.sessionStorage.removeItem(key) + return redirect + } catch { + return null + } +} + /** Minimal shape of `sessionStorage`, kept structural so tests can fake it. */ export interface OAuthModeStorage { getItem: (key: string) => string | null diff --git a/web/src/features/auth/lib/oauth-popup.ts b/web/src/features/auth/lib/oauth-popup.ts index 718050383..b7ad8a343 100644 --- a/web/src/features/auth/lib/oauth-popup.ts +++ b/web/src/features/auth/lib/oauth-popup.ts @@ -126,6 +126,9 @@ export function openOAuthPopup( return } received = true + stopCloseWatcher() + clearTimeout(deadline) + window.removeEventListener('message', onMessage) resolve({ callback: { provider: options.provider, diff --git a/web/src/features/auth/secure-verification/__tests__/verification-api.test.ts b/web/src/features/auth/secure-verification/__tests__/verification-api.test.ts index 267d45c01..463475d71 100644 --- a/web/src/features/auth/secure-verification/__tests__/verification-api.test.ts +++ b/web/src/features/auth/secure-verification/__tests__/verification-api.test.ts @@ -16,6 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { waitFor } from '@testing-library/react' import { AxiosError, type InternalAxiosRequestConfig } from 'axios' import { afterEach, expect, it, vi } from 'vitest' @@ -24,10 +25,100 @@ import { AuthOperationError, authResult } from '@/lib/secure-verification' import { useAuthStore, type AuthBundle } from '@/stores/auth-store' import { createOAuthFlow } from '../../api' +import { OAUTH_POPUP_CALLBACK_MESSAGE } from '../../constants' import { checkVerificationMethods, verify } from '../api' +import type { SecurityProof } from '../types' const originalAdapter = api.defaults.adapter const originalLocation = window.location.href + +it.each([false, true])( + 'retains the Telegram verification request after popup close and honors caller cancellation: %s', + async (cancel) => { + const popup = { + closed: false, + location: { replace: vi.fn() }, + sessionStorage: window.sessionStorage, + close: vi.fn(), + postMessage: vi.fn(), + } + vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window) + vi.spyOn(api, 'post').mockResolvedValue({ + data: { + success: true, + data: { + flow_token: 'verification-state', + authorization_url: 'https://oauth.telegram.org/auth?server=pkce', + }, + }, + }) + const proof: SecurityProof = { + proof_token: 'telegram-proof', + method: 'oauth', + scope: '2fa.setup', + expires_at: Math.floor(Date.now() / 1000) + 300, + } + let resolve!: (response: { + data: { success: boolean; data: SecurityProof } + }) => void + const response = new Promise<{ + data: { success: boolean; data: SecurityProof } + }>((done) => { + resolve = done + }) + const get = vi + .spyOn(api, 'get') + .mockImplementation((url) => + url === '/api/status' + ? Promise.resolve({ data: { success: true, data: {} } }) + : response + ) + const controller = new AbortController() + const result = verify( + { method: 'oauth', provider: 'telegram' }, + { scope: '2fa.setup' }, + false, + controller.signal + ) + const outcome = cancel + ? expect(result).rejects.toMatchObject({ code: 'AUTH_CANCELLED' }) + : expect(result).resolves.toEqual(proof) + await waitFor(() => + expect(popup.location.replace).toHaveBeenCalledWith( + 'https://oauth.telegram.org/auth?server=pkce' + ) + ) + const event = new MessageEvent('message', { + origin: window.location.origin, + data: { + type: OAUTH_POPUP_CALLBACK_MESSAGE, + intent: 'verify', + provider: 'telegram', + state: 'verification-state', + code: 'code', + }, + }) + Object.defineProperty(event, 'source', { value: popup }) + window.dispatchEvent(event) + popup.closed = true + await waitFor(() => + expect(get).toHaveBeenCalledWith( + '/api/oauth/telegram', + expect.objectContaining({ singleUseAuthorization: true }) + ) + ) + const signal = get.mock.calls.find( + ([url]) => url === '/api/oauth/telegram' + )?.[1]?.signal + expect(signal?.aborted).toBe(false) + if (cancel) { + controller.abort(new AuthOperationError('Cancelled', 'AUTH_CANCELLED')) + } + expect(signal?.aborted).toBe(cancel) + resolve({ data: { success: true, data: proof } }) + await outcome + } +) const sessionBundle = { access_token: 'access-token', token_type: 'Bearer' as const, diff --git a/web/src/features/auth/secure-verification/__tests__/verification-flow.test.tsx b/web/src/features/auth/secure-verification/__tests__/verification-flow.test.tsx index 721fc78a8..61f79e52a 100644 --- a/web/src/features/auth/secure-verification/__tests__/verification-flow.test.tsx +++ b/web/src/features/auth/secure-verification/__tests__/verification-flow.test.tsx @@ -76,6 +76,67 @@ afterEach(() => { vi.unstubAllGlobals() }) +it.each(['success', 'cancel', 'retry'] as const)( + 'automatically obtains the first-enrollment session proof and handles %s', + async (outcome) => { + vi.spyOn(api, 'get').mockResolvedValue({ + data: { + success: true, + data: { + ...passwordRequirements, + methods: [{ method: 'session', available: true }], + }, + }, + }) + const proof: SecurityProof = { + proof_token: 'session-proof', + method: 'session', + scope: 'passkey.register', + expires_at: Math.floor(Date.now() / 1000) + 300, + } + const reply = pendingResponse<{ + data: { success: boolean; data: SecurityProof } + }>() + const post = vi.spyOn(api, 'post').mockReturnValue(reply.promise) + if (outcome === 'retry') { + post.mockRejectedValueOnce(new Error('Session check failed')) + } + const result = vi.fn() + const user = userEvent.setup() + render() + await user.click(screen.getByText('Protected action')) + if (outcome === 'retry') { + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Session check failed' + ) + await user.click(screen.getByRole('button', { name: 'Retry' })) + } + await waitFor(() => + expect(post).toHaveBeenCalledTimes(outcome === 'retry' ? 2 : 1) + ) + expect(post).toHaveBeenLastCalledWith( + '/api/verify', + { method: 'session', scope: 'passkey.register' }, + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) + expect(screen.getByRole('button', { name: 'Verify' })).toBeDisabled() + if (outcome === 'cancel') { + await user.click(screen.getByRole('button', { name: 'Cancel' })) + expect(post.mock.calls[0][2]?.signal?.aborted).toBe(true) + } + await act(async () => { + reply.resolve({ data: { success: true, data: proof } }) + await reply.promise + }) + await waitFor(() => + expect(result).toHaveBeenCalledExactlyOnceWith( + outcome === 'cancel' ? null : proof + ) + ) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + } +) + it('keeps the requested channel context fixed while verification is open', async () => { vi.spyOn(api, 'get').mockResolvedValue({ data: { diff --git a/web/src/features/auth/secure-verification/api.ts b/web/src/features/auth/secure-verification/api.ts index c0d93a0cc..86c4461aa 100644 --- a/web/src/features/auth/secure-verification/api.ts +++ b/web/src/features/auth/secure-verification/api.ts @@ -29,7 +29,7 @@ import { authResult, } from '@/lib/secure-verification' -import { createOAuthFlow } from '../api' +import { createOAuthAuthorization } from '../api' import { openOAuthPopup } from '../lib/oauth-popup' import { encryptPassword } from '../lib/password-encryption' import { @@ -92,6 +92,18 @@ export async function verify( } let proof: SecurityProof switch (input.method) { + case 'session': + proof = await authResult( + api.post( + '/api/verify', + { method: 'session', ...operationFields }, + { + ...authRequestOptions, + signal, + } + ) + ) + break case '2fa': proof = await authResult( api.post( @@ -188,8 +200,8 @@ async function verifyOAuth( intent: 'verify', signal, prepare: async (popupSignal) => { - const [state, status] = await Promise.all([ - createOAuthFlow(provider, 'verify', operation, popupSignal), + const [authorization, status] = await Promise.all([ + createOAuthAuthorization(provider, 'verify', operation, popupSignal), authResult( api.get('/api/status', { ...authRequestOptions, @@ -198,7 +210,12 @@ async function verifyOAuth( }) ), ]) - return { state, url: buildOAuthAuthorizationUrl(provider, state, status) } + return { + state: authorization.state, + url: + authorization.authorizationUrl ?? + buildOAuthAuthorizationUrl(provider, authorization.state, status), + } }, }) try { diff --git a/web/src/features/auth/secure-verification/components/secure-verification-dialog.tsx b/web/src/features/auth/secure-verification/components/secure-verification-dialog.tsx index c258ce746..0743842db 100644 --- a/web/src/features/auth/secure-verification/components/secure-verification-dialog.tsx +++ b/web/src/features/auth/secure-verification/components/secure-verification-dialog.tsx @@ -45,6 +45,7 @@ const methodLabels: Record = { passkey: 'Passkey', password: 'Password', oauth: 'Linked account', + session: 'Login session', } export function SecureVerificationDialog(props: SecureVerificationDialogProps) { diff --git a/web/src/features/auth/secure-verification/hooks/use-secure-verification.ts b/web/src/features/auth/secure-verification/hooks/use-secure-verification.ts index fee1d629d..c9576c607 100644 --- a/web/src/features/auth/secure-verification/hooks/use-secure-verification.ts +++ b/web/src/features/auth/secure-verification/hooks/use-secure-verification.ts @@ -134,6 +134,24 @@ export function useSecureVerification() { current.request.scope, current.controller.signal ) + if (pending.current !== current) return + if ( + requirements.methods.length === 1 && + requirements.methods[0].method === 'session' && + requirements.methods[0].available + ) { + const proof = await verify( + { method: 'session' }, + current.request, + requirements.password_encryption_enabled, + current.controller.signal + ) + if (pending.current !== current) return + pending.current = null + dispatch({ type: 'reset' }) + current.resolve(proof) + return + } if (pending.current === current) { dispatch({ type: 'loaded', requirements }) } diff --git a/web/src/features/auth/secure-verification/types.ts b/web/src/features/auth/secure-verification/types.ts index 83ae5e6d1..d4b0c0b28 100644 --- a/web/src/features/auth/secure-verification/types.ts +++ b/web/src/features/auth/secure-verification/types.ts @@ -16,7 +16,12 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -export type VerificationMethod = '2fa' | 'passkey' | 'password' | 'oauth' +export type VerificationMethod = + | '2fa' + | 'passkey' + | 'password' + | 'oauth' + | 'session' export type SecurityProofScope = | 'channel.key.read' | 'passkey.register' @@ -49,6 +54,7 @@ export type VerificationInput = | { method: 'password'; password: string } | { method: 'passkey' } | { method: 'oauth'; provider: string } + | { method: 'session' } export type RequestVerificationOptions = VerificationOperation & { title?: string diff --git a/web/src/features/auth/types.ts b/web/src/features/auth/types.ts index 1d6b89c32..5b9e5719c 100644 --- a/web/src/features/auth/types.ts +++ b/web/src/features/auth/types.ts @@ -108,6 +108,7 @@ export interface SystemStatus { linuxdo_oauth?: boolean linuxdo_client_id?: string telegram_oauth?: boolean + telegram_oauth_configured?: boolean telegram_bot_name?: string passkey_login?: boolean wechat_login?: boolean @@ -154,6 +155,7 @@ export interface SystemStatus { linuxdo_oauth?: boolean linuxdo_client_id?: string telegram_oauth?: boolean + telegram_oauth_configured?: boolean telegram_bot_name?: string passkey_login?: boolean wechat_login?: boolean diff --git a/web/src/features/security/__tests__/enrollment.test.tsx b/web/src/features/security/__tests__/enrollment.test.tsx index ae4d89412..789fd2dab 100644 --- a/web/src/features/security/__tests__/enrollment.test.tsx +++ b/web/src/features/security/__tests__/enrollment.test.tsx @@ -16,13 +16,17 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { toast } from 'sonner' import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { OAUTH_POPUP_CALLBACK_MESSAGE } from '@/features/auth/constants' +import type { UserProfile } from '@/features/profile/types' import { api } from '@/lib/api' +import { AccountBindings } from '../components/account-bindings' import { PasskeyCard } from '../components/passkey-card' import { TwoFACard } from '../components/two-fa-card' @@ -71,6 +75,140 @@ afterEach(() => { } else Reflect.deleteProperty(navigator, 'credentials') }) +it.each(['2fa', 'passkey'] as const)( + 'blocks %s enrollment and explains missing Telegram configuration', + async (factor) => { + const reason = + 'Telegram OAuth is not configured or enabled. Please contact your administrator.' + vi.spyOn(api, 'get').mockImplementation(async (url) => ({ + data: { + success: true, + data: + url === '/api/verify/methods' + ? { + scope: factor === '2fa' ? '2fa.setup' : 'passkey.register', + methods: [{ method: 'oauth', available: false, reason }], + oauth_providers: [], + password_encryption_enabled: false, + } + : { enabled: false, locked: false }, + }, + })) + const post = vi.spyOn(api, 'post') + const user = userEvent.setup() + render( + factor === '2fa' ? ( + + ) : ( + + ) + ) + const enable = await screen.findByRole('button', { + name: factor === '2fa' ? 'Enable' : 'Enable Passkey', + }) + await waitFor(() => expect(enable).toBeEnabled()) + await user.click(enable) + expect(await screen.findByText(reason)).toBeVisible() + expect(post).not.toHaveBeenCalled() + expect(navigator.credentials.create).not.toHaveBeenCalled() + } +) + +it('refreshes Telegram bindings from the server result after the callback popup has closed', async () => { + const popup = { + closed: false, + location: { replace: vi.fn() }, + sessionStorage: window.sessionStorage, + close: vi.fn(), + postMessage: vi.fn(), + } + vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window) + vi.spyOn(api, 'post').mockResolvedValue({ + data: { + success: true, + data: { + flow_token: 'binding-state', + authorization_url: 'https://oauth.telegram.org/auth?server=pkce', + }, + }, + }) + let resolve!: (response: { + data: { success: boolean; data: { action: string } } + }) => void + const response = new Promise<{ + data: { success: boolean; data: { action: string } } + }>((done) => { + resolve = done + }) + const get = vi.spyOn(api, 'get').mockImplementation((url) => + url === '/api/status' + ? Promise.resolve({ + data: { + success: true, + data: { telegram_oauth: true, telegram_oauth_configured: true }, + }, + }) + : response + ) + const onUpdate = vi.fn() + const user = userEvent.setup() + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + render( + + + + ) + const telegram = (await screen.findByText('Telegram')).closest('li') + if (!telegram) throw new Error('Telegram binding entry is missing') + await user.click(within(telegram).getByRole('button', { name: 'Bind' })) + await waitFor(() => + expect(popup.location.replace).toHaveBeenCalledWith( + 'https://oauth.telegram.org/auth?server=pkce' + ) + ) + const message = new MessageEvent('message', { + origin: window.location.origin, + data: { + type: OAUTH_POPUP_CALLBACK_MESSAGE, + provider: 'telegram', + intent: 'bind', + state: 'binding-state', + code: 'code', + }, + }) + Object.defineProperty(message, 'source', { value: popup }) + act(() => { + window.dispatchEvent(message) + popup.closed = true + }) + await waitFor(() => + expect(get).toHaveBeenCalledWith( + '/api/oauth/telegram', + expect.objectContaining({ + params: expect.objectContaining({ + state: 'binding-state', + code: 'code', + }), + }) + ) + ) + expect(onUpdate).not.toHaveBeenCalled() + expect( + get.mock.calls.find(([url]) => url === '/api/oauth/telegram')?.[1]?.signal + ?.aborted + ).toBe(false) + await act(async () => { + resolve({ data: { success: true, data: { action: 'bind' } } }) + await response + }) + await waitFor(() => expect(onUpdate).toHaveBeenCalledTimes(1)) +}) + it('shows a retry when the 2FA status query fails instead of offering enrollment', async () => { const get = vi .spyOn(api, 'get') diff --git a/web/src/features/security/components/account-bindings.tsx b/web/src/features/security/components/account-bindings.tsx index e6615e3c5..19271085d 100644 --- a/web/src/features/security/components/account-bindings.tsx +++ b/web/src/features/security/components/account-bindings.tsx @@ -26,7 +26,7 @@ import { IconDiscord } from '@/assets/brand-icons' import { ConfirmDialog } from '@/components/confirm-dialog' import { StatusBadge } from '@/components/status-badge' import { Button } from '@/components/ui/button' -import { createOAuthFlow } from '@/features/auth/api' +import { createOAuthAuthorization } from '@/features/auth/api' import { openOAuthPopup, type OAuthPopupExchange, @@ -49,7 +49,6 @@ import { } from '@/lib/secure-verification' import { EmailBindDialog } from './dialogs/email-bind-dialog' -import { TelegramBindDialog } from './dialogs/telegram-bind-dialog' import { WeChatBindDialog } from './dialogs/wechat-bind-dialog' // ============================================================================ @@ -61,7 +60,7 @@ interface AccountBindingsProps { onUpdate: () => void } -type DialogKey = 'email' | 'wechat' | 'telegram' +type DialogKey = 'email' | 'wechat' export function AccountBindings({ profile, onUpdate }: AccountBindingsProps) { const { t } = useTranslation() @@ -134,15 +133,21 @@ export function AccountBindings({ profile, onUpdate }: AccountBindingsProps) { intent: 'bind', signal: controller.signal, prepare: async (signal) => { - const state = await createOAuthFlow( + const authorization = await createOAuthAuthorization( provider, 'bind', undefined, signal ) return { - state, - url: buildOAuthAuthorizationUrl(provider, state, status ?? {}), + state: authorization.state, + url: + authorization.authorizationUrl ?? + buildOAuthAuthorizationUrl( + provider, + authorization.state, + status ?? {} + ), } }, }) @@ -267,7 +272,7 @@ export function AccountBindings({ profile, onUpdate }: AccountBindingsProps) { (profile as unknown as Record).telegram_id ), isEnabled: status?.telegram_oauth || false, - onBind: () => dialogs.open('telegram'), + onBind: () => void startOAuthBinding('telegram'), }, { id: 'linuxdo', @@ -441,18 +446,6 @@ export function AccountBindings({ profile, onUpdate }: AccountBindingsProps) { } onSuccess={onUpdate} /> - - {/* Telegram Bind Dialog */} - {status?.telegram_bot_name && ( - - open ? dialogs.open('telegram') : dialogs.close('telegram') - } - botName={status.telegram_bot_name as string} - onSuccess={onUpdate} - /> - )} ) } diff --git a/web/src/features/system-settings/auth/index.tsx b/web/src/features/system-settings/auth/index.tsx index 915801fd2..1144a1d84 100644 --- a/web/src/features/system-settings/auth/index.tsx +++ b/web/src/features/system-settings/auth/index.tsx @@ -48,6 +48,8 @@ const defaultAuthSettings: AuthSettings = { 'oidc.token_endpoint': '', 'oidc.user_info_endpoint': '', TelegramOAuthEnabled: false, + 'telegram.client_id': '', + 'telegram.client_secret': '', TelegramBotToken: '', TelegramBotName: '', LinuxDOOAuthEnabled: false, diff --git a/web/src/features/system-settings/auth/oauth-section.tsx b/web/src/features/system-settings/auth/oauth-section.tsx index 73bbf2649..a27954803 100644 --- a/web/src/features/system-settings/auth/oauth-section.tsx +++ b/web/src/features/system-settings/auth/oauth-section.tsx @@ -81,8 +81,7 @@ const oauthSchema = z.object({ user_info_endpoint: z.string(), }), TelegramOAuthEnabled: z.boolean(), - TelegramBotToken: z.string(), - TelegramBotName: z.string(), + telegram: z.object({ client_id: z.string(), client_secret: z.string() }), LinuxDOOAuthEnabled: z.boolean(), LinuxDOClientId: z.string(), LinuxDOClientSecret: z.string(), @@ -111,8 +110,8 @@ type FlatOAuthDefaults = { 'oidc.token_endpoint': string 'oidc.user_info_endpoint': string TelegramOAuthEnabled: boolean - TelegramBotToken: string - TelegramBotName: string + 'telegram.client_id': string + 'telegram.client_secret': string LinuxDOOAuthEnabled: boolean LinuxDOClientId: string LinuxDOClientSecret: string @@ -195,8 +194,10 @@ const buildFormDefaults = (defaults: FlatOAuthDefaults): OAuthFormValues => ({ user_info_endpoint: defaults['oidc.user_info_endpoint'] ?? '', }, TelegramOAuthEnabled: defaults.TelegramOAuthEnabled, - TelegramBotToken: defaults.TelegramBotToken ?? '', - TelegramBotName: defaults.TelegramBotName ?? '', + telegram: { + client_id: defaults['telegram.client_id'] ?? '', + client_secret: defaults['telegram.client_secret'] ?? '', + }, LinuxDOOAuthEnabled: defaults.LinuxDOOAuthEnabled, LinuxDOClientId: defaults.LinuxDOClientId ?? '', LinuxDOClientSecret: defaults.LinuxDOClientSecret ?? '', @@ -222,9 +223,9 @@ const normalizeFormValues = (values: OAuthFormValues): FlatOAuthDefaults => ({ 'oidc.authorization_endpoint': values.oidc.authorization_endpoint, 'oidc.token_endpoint': values.oidc.token_endpoint, 'oidc.user_info_endpoint': values.oidc.user_info_endpoint, + 'telegram.client_id': values.telegram.client_id, + 'telegram.client_secret': values.telegram.client_secret, TelegramOAuthEnabled: values.TelegramOAuthEnabled, - TelegramBotToken: values.TelegramBotToken, - TelegramBotName: values.TelegramBotName, LinuxDOOAuthEnabled: values.LinuxDOOAuthEnabled, LinuxDOClientId: values.LinuxDOClientId, LinuxDOClientSecret: values.LinuxDOClientSecret, @@ -260,6 +261,11 @@ export function OAuthSection(props: OAuthSectionProps) { 'oidc', t('Site URL') ) + const telegramCallbackUrl = buildOAuthCallbackUrl( + props.serverAddress, + 'telegram', + t('Site URL') + ) const linuxDOCallbackUrl = buildOAuthCallbackUrl( props.serverAddress, 'linuxdo', @@ -807,6 +813,19 @@ export function OAuthSection(props: OAuthSectionProps) { value='telegram' className={oauthTabContentClassName} > + ( - {t('Bot Token')} + {t('Client Secret')} @@ -855,13 +876,15 @@ export function OAuthSection(props: OAuthSectionProps) { ( - {t('Bot Name')} + {t('Client ID')} diff --git a/web/src/features/system-settings/auth/section-registry.tsx b/web/src/features/system-settings/auth/section-registry.tsx index eeaeda8d5..f30f959fc 100644 --- a/web/src/features/system-settings/auth/section-registry.tsx +++ b/web/src/features/system-settings/auth/section-registry.tsx @@ -65,8 +65,8 @@ const AUTH_SECTIONS = [ 'oidc.token_endpoint': settings['oidc.token_endpoint'], 'oidc.user_info_endpoint': settings['oidc.user_info_endpoint'], TelegramOAuthEnabled: settings.TelegramOAuthEnabled, - TelegramBotToken: settings.TelegramBotToken, - TelegramBotName: settings.TelegramBotName, + 'telegram.client_id': settings['telegram.client_id'], + 'telegram.client_secret': settings['telegram.client_secret'], LinuxDOOAuthEnabled: settings.LinuxDOOAuthEnabled, LinuxDOClientId: settings.LinuxDOClientId, LinuxDOClientSecret: settings.LinuxDOClientSecret, diff --git a/web/src/features/system-settings/types.ts b/web/src/features/system-settings/types.ts index 437b1dc63..cce465c8e 100644 --- a/web/src/features/system-settings/types.ts +++ b/web/src/features/system-settings/types.ts @@ -145,6 +145,8 @@ export type AuthSettings = { 'oidc.token_endpoint': string 'oidc.user_info_endpoint': string TelegramOAuthEnabled: boolean + 'telegram.client_id': string + 'telegram.client_secret': string TelegramBotToken: string TelegramBotName: string LinuxDOOAuthEnabled: boolean diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 52ed52cc4..7c062b914 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2453,6 +2453,7 @@ "Import from URL": "Import from URL", "Import to CC Switch": "Import to CC Switch", "Important": "Important", + "In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.", "In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.", "In Progress": "In Progress", "In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.", @@ -2730,6 +2731,7 @@ "Login flow expired. Please sign in again.": "Login flow expired. Please sign in again.", "Login Info": "Login Info", "Login Method": "Login Method", + "Login session": "Login session", "Login sessions": "Login sessions", "Login, security and access records": "Login, security and access records", "Logo": "Logo", @@ -4772,9 +4774,14 @@ "Team Collaboration": "Team Collaboration", "Technical Support": "Technical Support", "Telegram": "Telegram", + "Telegram authorization failed. Please try again.": "Telegram authorization failed. Please try again.", "Telegram binding failed. Please try again.": "Telegram binding failed. Please try again.", "Telegram binding is disabled.": "Telegram binding is disabled.", + "Telegram login has changed. Reload the page and start Telegram OAuth again.": "Telegram login has changed. Reload the page and start Telegram OAuth again.", "Telegram Login Widget": "Telegram Login Widget", + "Telegram OAuth Client ID from BotFather": "Telegram OAuth Client ID from BotFather", + "Telegram OAuth Client Secret from BotFather": "Telegram OAuth Client Secret from BotFather", + "Telegram OAuth is not configured or enabled. Please contact your administrator.": "Telegram OAuth is not configured or enabled. Please contact your administrator.", "Temperature": "Temperature", "Template": "Template", "Template variables:": "Template variables:", @@ -4846,6 +4853,7 @@ "The slug is appended to the URL:": "The slug is appended to the URL:", "The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.", "The Telegram authorization request is invalid or expired.": "The Telegram authorization request is invalid or expired.", + "The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.": "The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.", "The token group that will have a custom ratio": "The token group that will have a custom ratio", "The token has no group, so it is billed as the user group vip, using the base ratio of vip.": "The token has no group, so it is billed as the user group vip, using the base ratio of vip.", "The two roles of a group": "The two roles of a group", @@ -4925,6 +4933,7 @@ "This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.": "This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.", "This source lists no installable task plugins.": "This source lists no installable task plugins.", "This Telegram account is already bound.": "This Telegram account is already bound.", + "This Telegram account is not linked. Sign in using another method and link it first.": "This Telegram account is not linked. Sign in using another method and link it first.", "This Telegram binding request has expired or has already been used.": "This Telegram binding request has expired or has already been used.", "This tier catches any request that did not match earlier tiers.": "This tier catches any request that did not match earlier tiers.", "this token group": "this token group", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index cb1dcbe78..e446f6b1e 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -2453,6 +2453,7 @@ "Import from URL": "Importer depuis une URL", "Import to CC Switch": "Importer vers CC Switch", "Important": "Important", + "In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "Dans BotFather, ouvrez Login Widget, enregistrez cette URL de rappel et copiez le Client ID et le Client Secret. Les comptes Telegram déjà liés fonctionneront après la configuration.", "In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "En JSON, le groupe d’utilisateurs est la clé externe et le groupe de facturation la clé interne. L’exemple ci-dessous signifie : les utilisateurs vip paient 0,8 sous standard et 0,3 sous premium.", "In Progress": "En cours", "In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "Dans l’éditeur visuel, ces règles apparaissent comme « Visible en plus » et « Masqué ». En JSON, +: (ou aucun préfixe) ajoute un groupe et -: en retire un.", @@ -2730,6 +2731,7 @@ "Login flow expired. Please sign in again.": "Le processus de connexion a expiré. Veuillez vous reconnecter.", "Login Info": "Informations de connexion", "Login Method": "Méthode de connexion", + "Login session": "Session de connexion", "Login sessions": "Sessions de connexion", "Login, security and access records": "Historique des connexions, de la sécurité et des accès", "Logo": "Logo", @@ -4772,9 +4774,14 @@ "Team Collaboration": "Collaboration d'équipe", "Technical Support": "Support technique", "Telegram": "Telegram", + "Telegram authorization failed. Please try again.": "L’autorisation Telegram a échoué. Réessayez.", "Telegram binding failed. Please try again.": "Échec de la liaison Telegram. Veuillez réessayer.", "Telegram binding is disabled.": "La liaison Telegram est désactivée.", + "Telegram login has changed. Reload the page and start Telegram OAuth again.": "La connexion Telegram a changé. Rechargez la page et relancez Telegram OAuth.", "Telegram Login Widget": "Widget de connexion Telegram", + "Telegram OAuth Client ID from BotFather": "Client ID Telegram OAuth fourni par BotFather", + "Telegram OAuth Client Secret from BotFather": "Client Secret Telegram OAuth fourni par BotFather", + "Telegram OAuth is not configured or enabled. Please contact your administrator.": "Telegram OAuth n’est pas configuré ou activé. Contactez votre administrateur.", "Temperature": "Température", "Template": "Modèle", "Template variables:": "Variables de modèle :", @@ -4846,6 +4853,7 @@ "The slug is appended to the URL:": "Le slug est ajouté à l'URL :", "The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "La synchronisation récupérera les modèles et fournisseurs manquants à partir de la source sélectionnée. Les enregistrements existants ne sont mis à jour que lorsque vous approuvez les conflits.", "The Telegram authorization request is invalid or expired.": "La demande d’autorisation Telegram est invalide ou a expiré.", + "The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.": "Le nom de fournisseur OAuth telegram est réservé. Demandez à votre administrateur de renommer le fournisseur personnalisé en conflit.", "The token group that will have a custom ratio": "Le groupe de jetons qui aura un ratio personnalisé", "The token has no group, so it is billed as the user group vip, using the base ratio of vip.": "Le jeton n’a pas de groupe, il est donc facturé sous le groupe d’utilisateurs vip, avec le taux de base de vip.", "The two roles of a group": "Les deux rôles d’un groupe", @@ -4925,6 +4933,7 @@ "This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.": "Cette source ne publie pas de sha256 pour cette version : impossible de garantir que le code téléchargé est bien celui qu’elle a publié.", "This source lists no installable task plugins.": "Cette source ne liste aucun plugin de tâches installable.", "This Telegram account is already bound.": "Ce compte Telegram est déjà lié.", + "This Telegram account is not linked. Sign in using another method and link it first.": "Ce compte Telegram n’est pas lié. Connectez-vous autrement pour le lier.", "This Telegram binding request has expired or has already been used.": "Cette demande de liaison Telegram a expiré ou a déjà été utilisée.", "This tier catches any request that did not match earlier tiers.": "Ce palier récupère toute requête qui ne correspond à aucun palier précédent.", "this token group": "ce groupe de jetons", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index c9d864732..ec0d1f586 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -2453,6 +2453,7 @@ "Import from URL": "URL からインポート", "Import to CC Switch": "CC Switch にインポート", "Important": "重要", + "In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "BotFather で Login Widget を開き、このコールバック URL を登録して Client ID と Client Secret をコピーしてください。設定後は既存の Telegram 連携を引き続き利用できます。", "In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "JSONでは外側のキーがユーザーグループ、内側のキーが課金グループです。以下の例は、vip ユーザーが standard として課金されると 0.8、premium として課金されると 0.3 を意味します。", "In Progress": "処理中", "In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "ビジュアルエディタでは「追加表示」と「非表示」として表示されます。JSONでは +:(または接頭辞なし)でグループを追加し、-: で削除します。", @@ -2730,6 +2731,7 @@ "Login flow expired. Please sign in again.": "ログイン手続きの有効期限が切れました。もう一度サインインしてください。", "Login Info": "ログイン情報", "Login Method": "ログイン方法", + "Login session": "ログインセッション", "Login sessions": "ログインセッション", "Login, security and access records": "ログイン、セキュリティ、アクセスの記録", "Logo": "ロゴ", @@ -4772,9 +4774,14 @@ "Team Collaboration": "チームコラボレーション", "Technical Support": "テクニカルサポート", "Telegram": "Telegram", + "Telegram authorization failed. Please try again.": "Telegram の認証に失敗しました。もう一度お試しください。", "Telegram binding failed. Please try again.": "Telegram の連携に失敗しました。もう一度お試しください。", "Telegram binding is disabled.": "Telegram 連携は無効です。", + "Telegram login has changed. Reload the page and start Telegram OAuth again.": "Telegram のログイン方法が更新されました。ページを再読み込みし、Telegram OAuth をやり直してください。", "Telegram Login Widget": "Telegramログインウィジェット", + "Telegram OAuth Client ID from BotFather": "BotFather が発行した Telegram OAuth Client ID", + "Telegram OAuth Client Secret from BotFather": "BotFather が発行した Telegram OAuth Client Secret", + "Telegram OAuth is not configured or enabled. Please contact your administrator.": "Telegram OAuth が設定されていないか、無効になっています。管理者にお問い合わせください。", "Temperature": "温度", "Template": "テンプレート", "Template variables:": "テンプレート変数:", @@ -4846,6 +4853,7 @@ "The slug is appended to the URL:": "スラッグがURLに追加されます:", "The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "同期により、選択されたソースから不足しているモデルとベンダーが取得されます。既存のレコードは、競合を承認した場合にのみ更新されます。", "The Telegram authorization request is invalid or expired.": "Telegram の認証リクエストが無効か、有効期限が切れています。", + "The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.": "OAuth プロバイダー名 telegram は予約されています。管理者に競合するカスタムプロバイダーの名前変更を依頼してください。", "The token group that will have a custom ratio": "カスタム比率を持つトークングループ", "The token has no group, so it is billed as the user group vip, using the base ratio of vip.": "トークンにグループがないため、ユーザーグループ vip として課金され、vip の基本倍率が使われます。", "The two roles of a group": "グループの2つの役割", @@ -4925,6 +4933,7 @@ "This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.": "このソースはこのバージョンの sha256 を公開していないため、ダウンロードしたソースがソース側の意図した内容と一致するか確認できません。", "This source lists no installable task plugins.": "このソースにはインストール可能なタスクプラグインがありません。", "This Telegram account is already bound.": "この Telegram アカウントはすでに連携されています。", + "This Telegram account is not linked. Sign in using another method and link it first.": "この Telegram アカウントは連携されていません。別の方法でログインしてから連携してください。", "This Telegram binding request has expired or has already been used.": "この Telegram 連携リクエストは期限切れか、すでに使用されています。", "This tier catches any request that did not match earlier tiers.": "この段階は、前の段階に一致しなかったすべてのリクエストを受け取ります。", "this token group": "このトークングループ", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 23e776171..f37ab8c6a 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -2453,6 +2453,7 @@ "Import from URL": "Импорт по URL", "Import to CC Switch": "Импорт в CC Switch", "Important": "Важно", + "In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "Откройте Login Widget в BotFather, зарегистрируйте этот URL обратного вызова и скопируйте Client ID и Client Secret. После настройки существующие привязки Telegram продолжат работать.", "In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "В JSON внешний ключ — группа пользователя, внутренний — тарифная группа. Пример ниже означает: пользователи vip платят 0,8 по standard и 0,3 по premium.", "In Progress": "Выполняется", "In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "В визуальном редакторе это отображается как «Дополнительно видимая» и «Скрыта». В JSON префикс +: (или его отсутствие) добавляет группу, а -: удаляет её.", @@ -2730,6 +2731,7 @@ "Login flow expired. Please sign in again.": "Процесс входа истёк. Войдите снова.", "Login Info": "Информация о входе", "Login Method": "Способ входа", + "Login session": "Сеанс входа", "Login sessions": "Сеансы входа", "Login, security and access records": "Записи о входах, безопасности и доступе", "Logo": "Логотип", @@ -4772,9 +4774,14 @@ "Team Collaboration": "Совместная работа в команде", "Technical Support": "Техническая поддержка", "Telegram": "Telegram", + "Telegram authorization failed. Please try again.": "Не удалось выполнить авторизацию Telegram. Повторите попытку.", "Telegram binding failed. Please try again.": "Не удалось привязать Telegram. Повторите попытку.", "Telegram binding is disabled.": "Привязка Telegram отключена.", + "Telegram login has changed. Reload the page and start Telegram OAuth again.": "Способ входа через Telegram изменился. Обновите страницу и снова начните вход через Telegram OAuth.", "Telegram Login Widget": "Виджет входа Telegram", + "Telegram OAuth Client ID from BotFather": "Client ID Telegram OAuth из BotFather", + "Telegram OAuth Client Secret from BotFather": "Client Secret Telegram OAuth из BotFather", + "Telegram OAuth is not configured or enabled. Please contact your administrator.": "Telegram OAuth не настроен или отключён. Обратитесь к администратору.", "Temperature": "Температура", "Template": "Шаблон", "Template variables:": "Переменные шаблона:", @@ -4846,6 +4853,7 @@ "The slug is appended to the URL:": "Слаг добавляется к URL:", "The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "Синхронизация получит отсутствующие модели и поставщиков из выбранного источника. Существующие записи обновляются только после подтверждения конфликтов.", "The Telegram authorization request is invalid or expired.": "Запрос авторизации Telegram недействителен или истёк.", + "The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.": "Имя OAuth-провайдера telegram зарезервировано. Попросите администратора переименовать конфликтующего пользовательского провайдера.", "The token group that will have a custom ratio": "Группа токенов, которая будет иметь пользовательское соотношение", "The token has no group, so it is billed as the user group vip, using the base ratio of vip.": "У токена нет группы, поэтому вызов тарифицируется по группе пользователя vip с базовым коэффициентом vip.", "The two roles of a group": "Две роли группы", @@ -4925,6 +4933,7 @@ "This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.": "Источник не публикует sha256 для этой версии, поэтому нельзя убедиться, что загруженный код совпадает с опубликованным.", "This source lists no installable task plugins.": "В этом источнике нет плагинов задач, доступных для установки.", "This Telegram account is already bound.": "Эта учётная запись Telegram уже привязана.", + "This Telegram account is not linked. Sign in using another method and link it first.": "Этот аккаунт Telegram не привязан. Войдите другим способом и привяжите его.", "This Telegram binding request has expired or has already been used.": "Этот запрос на привязку Telegram истёк или уже был использован.", "This tier catches any request that did not match earlier tiers.": "Этот уровень обрабатывает все запросы, которые не совпали с предыдущими уровнями.", "this token group": "эта группа токенов", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 3705b723c..aca778f5f 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -2453,6 +2453,7 @@ "Import from URL": "Nhập từ URL", "Import to CC Switch": "Nhập vào CC Switch", "Important": "Quan trọng", + "In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "Trong BotFather, mở Login Widget, đăng ký URL gọi lại này và sao chép Client ID cùng Client Secret. Các liên kết Telegram hiện có sẽ tiếp tục hoạt động sau khi cấu hình.", "In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "Trong JSON, khóa ngoài là nhóm người dùng, khóa trong là nhóm tính phí. Ví dụ dưới đây nghĩa là: người dùng vip trả 0.8 khi tính phí theo standard và 0.3 khi theo premium.", "In Progress": "Đang xử lý", "In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "Trong trình chỉnh sửa trực quan, các quy tắc này hiển thị là «Hiển thị thêm» và «Ẩn». Trong JSON, +: (hoặc không có tiền tố) thêm nhóm và -: xóa nhóm.", @@ -2730,6 +2731,7 @@ "Login flow expired. Please sign in again.": "Quy trình đăng nhập đã hết hạn. Vui lòng đăng nhập lại.", "Login Info": "Thông tin đăng nhập", "Login Method": "Phương thức đăng nhập", + "Login session": "Phiên đăng nhập", "Login sessions": "Phiên đăng nhập", "Login, security and access records": "Nhật ký đăng nhập, bảo mật và truy cập", "Logo": "Logo", @@ -4772,9 +4774,14 @@ "Team Collaboration": "Teamwork", "Technical Support": "Hỗ trợ kỹ thuật", "Telegram": "Telegram", + "Telegram authorization failed. Please try again.": "Ủy quyền Telegram thất bại. Vui lòng thử lại.", "Telegram binding failed. Please try again.": "Liên kết Telegram không thành công. Vui lòng thử lại.", "Telegram binding is disabled.": "Tính năng liên kết Telegram đã bị tắt.", + "Telegram login has changed. Reload the page and start Telegram OAuth again.": "Cách đăng nhập Telegram đã thay đổi. Hãy tải lại trang và bắt đầu lại Telegram OAuth.", "Telegram Login Widget": "Tiện ích đăng nhập Telegram", + "Telegram OAuth Client ID from BotFather": "Client ID Telegram OAuth từ BotFather", + "Telegram OAuth Client Secret from BotFather": "Client Secret Telegram OAuth từ BotFather", + "Telegram OAuth is not configured or enabled. Please contact your administrator.": "Telegram OAuth chưa được cấu hình hoặc bật. Vui lòng liên hệ quản trị viên.", "Temperature": "Nhiệt độ", "Template": "Mẫu", "Template variables:": "Biến mẫu:", @@ -4846,6 +4853,7 @@ "The slug is appended to the URL:": "Slug được gắn vào URL:", "The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "Đồng bộ hóa sẽ tìm nạp các mẫu và nhà cung cấp còn thiếu từ nguồn đã chọn. Các bản ghi hiện có chỉ được cập nhật khi bạn chấp thuận các xung đột.", "The Telegram authorization request is invalid or expired.": "Yêu cầu ủy quyền Telegram không hợp lệ hoặc đã hết hạn.", + "The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.": "Tên nhà cung cấp OAuth telegram đã được dành riêng. Hãy nhờ quản trị viên đổi tên nhà cung cấp tùy chỉnh bị trùng.", "The token group that will have a custom ratio": "The token group will have a custom ratio.", "The token has no group, so it is billed as the user group vip, using the base ratio of vip.": "Token không có nhóm, nên được tính phí theo nhóm người dùng vip, dùng hệ số cơ bản của vip.", "The two roles of a group": "Hai vai trò của một nhóm", @@ -4925,6 +4933,7 @@ "This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.": "Nguồn này không công bố sha256 cho phiên bản đó, nên không thể xác nhận mã nguồn đã tải đúng với nội dung nguồn phát hành.", "This source lists no installable task plugins.": "Nguồn này không liệt kê plugin tác vụ nào có thể cài đặt.", "This Telegram account is already bound.": "Tài khoản Telegram này đã được liên kết.", + "This Telegram account is not linked. Sign in using another method and link it first.": "Tài khoản Telegram này chưa được liên kết. Hãy đăng nhập bằng cách khác rồi liên kết tài khoản.", "This Telegram binding request has expired or has already been used.": "Yêu cầu liên kết Telegram này đã hết hạn hoặc đã được sử dụng.", "This tier catches any request that did not match earlier tiers.": "Tầng này bắt mọi yêu cầu không khớp với các tầng trước.", "this token group": "nhóm token này", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index 78cb92b20..f3ec9c93d 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -2453,6 +2453,7 @@ "Import from URL": "從 URL 匯入", "Import to CC Switch": "填入 CC Switch", "Important": "重要", + "In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "在 BotFather 中開啟 Login Widget,登記此回呼網址並複製 Client ID 和 Client Secret。完成設定後,現有 Telegram 綁定可繼續使用。", "In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "在 JSON 中,外層鍵是用戶分組,內層鍵是收費分組。下面的示例表示:vip 用戶按 standard 收費時用 0.8,按 premium 收費時用 0.3。", "In Progress": "進行中", "In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "在可視化編輯器中顯示為「額外可見」和「屏蔽」。在 JSON 中,+:(或無前綴)表示新增分組,-: 表示移除分組。", @@ -2730,6 +2731,7 @@ "Login flow expired. Please sign in again.": "登入流程已過期,請重新登入。", "Login Info": "登入資訊", "Login Method": "登入方式", + "Login session": "登入工作階段", "Login sessions": "登入工作階段", "Login, security and access records": "登入、安全與存取記錄", "Logo": "徽標", @@ -4772,9 +4774,14 @@ "Team Collaboration": "團隊協作", "Technical Support": "技術支援", "Telegram": "Telegram", + "Telegram authorization failed. Please try again.": "Telegram 授權失敗,請重試。", "Telegram binding failed. Please try again.": "Telegram 綁定失敗,請再試一次。", "Telegram binding is disabled.": "Telegram 綁定已停用。", + "Telegram login has changed. Reload the page and start Telegram OAuth again.": "Telegram 登入方式已更新,請重新載入頁面並重新發起 Telegram OAuth 登入。", "Telegram Login Widget": "Telegram 登入小部件", + "Telegram OAuth Client ID from BotFather": "BotFather 提供的 Telegram OAuth Client ID", + "Telegram OAuth Client Secret from BotFather": "BotFather 提供的 Telegram OAuth Client Secret", + "Telegram OAuth is not configured or enabled. Please contact your administrator.": "Telegram OAuth 尚未設定或啟用,請聯絡管理員。", "Temperature": "溫度", "Template": "模板", "Template variables:": "模板變數:", @@ -4846,6 +4853,7 @@ "The slug is appended to the URL:": "別名將附加到 URL:", "The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "同步將從選定的源獲取缺失的模型和供應商。僅在您批准衝突時才會更新現有記錄。", "The Telegram authorization request is invalid or expired.": "Telegram 授權要求無效或已過期。", + "The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.": "telegram 是保留的 OAuth 提供方名稱,請聯絡管理員重新命名衝突的自訂提供方。", "The token group that will have a custom ratio": "將具有自訂比例的令牌分組", "The token has no group, so it is billed as the user group vip, using the base ratio of vip.": "令牌未設定分組,因此按用戶分組 vip 收費,使用 vip 的基礎倍率。", "The two roles of a group": "分組的兩種角色", @@ -4925,6 +4933,7 @@ "This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.": "該來源未為此版本提供 sha256,因此無法確認下載到的原始碼與該來源所發布的內容一致。", "This source lists no installable task plugins.": "該來源未列出任何可安裝的任務外掛。", "This Telegram account is already bound.": "此 Telegram 帳號已綁定。", + "This Telegram account is not linked. Sign in using another method and link it first.": "此 Telegram 帳號尚未綁定,請先透過其他方式登入並綁定。", "This Telegram binding request has expired or has already been used.": "此 Telegram 綁定要求已過期或已使用。", "This tier catches any request that did not match earlier tiers.": "此階梯會兜底處理未匹配前面階梯的請求。", "this token group": "此令牌分組", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 257440fd6..9bf36184a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -2453,6 +2453,7 @@ "Import from URL": "从 URL 导入", "Import to CC Switch": "填入 CC Switch", "Important": "重要", + "In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "在 BotFather 中打开 Login Widget,登记此回调地址并复制 Client ID 和 Client Secret。完成配置后,现有 Telegram 绑定可继续使用。", "In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "在 JSON 中,外层键是用户分组,内层键是计费分组。下面的示例表示:vip 用户按 standard 计费时用 0.8,按 premium 计费时用 0.3。", "In Progress": "进行中", "In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "在可视化编辑器中显示为「额外可见」和「屏蔽」。在 JSON 中,+:(或无前缀)表示添加分组,-: 表示移除分组。", @@ -2730,6 +2731,7 @@ "Login flow expired. Please sign in again.": "登录流程已过期,请重新登录。", "Login Info": "登录信息", "Login Method": "登录方式", + "Login session": "登录会话", "Login sessions": "登录会话", "Login, security and access records": "登录、安全与访问记录", "Logo": "徽标", @@ -4772,9 +4774,14 @@ "Team Collaboration": "团队协作", "Technical Support": "技术支持", "Telegram": "Telegram", + "Telegram authorization failed. Please try again.": "Telegram 授权失败,请重试。", "Telegram binding failed. Please try again.": "Telegram 绑定失败,请重试。", "Telegram binding is disabled.": "Telegram 绑定已禁用。", + "Telegram login has changed. Reload the page and start Telegram OAuth again.": "Telegram 登录方式已更新,请刷新页面并重新发起 Telegram OAuth 登录。", "Telegram Login Widget": "Telegram 登录小部件", + "Telegram OAuth Client ID from BotFather": "BotFather 提供的 Telegram OAuth Client ID", + "Telegram OAuth Client Secret from BotFather": "BotFather 提供的 Telegram OAuth Client Secret", + "Telegram OAuth is not configured or enabled. Please contact your administrator.": "Telegram OAuth 尚未配置或启用,请联系管理员。", "Temperature": "温度", "Template": "模板", "Template variables:": "模板变量:", @@ -4846,6 +4853,7 @@ "The slug is appended to the URL:": "别名将附加到 URL:", "The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "同步将从选定的源获取缺失的模型和供应商。仅在您批准冲突时才会更新现有记录。", "The Telegram authorization request is invalid or expired.": "Telegram 授权请求无效或已过期。", + "The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.": "telegram 是保留的 OAuth 提供方名称,请联系管理员重命名冲突的自定义提供方。", "The token group that will have a custom ratio": "将具有自定义比例的令牌分组", "The token has no group, so it is billed as the user group vip, using the base ratio of vip.": "令牌未设置分组,因此按用户分组 vip 计费,使用 vip 的基础倍率。", "The two roles of a group": "分组的两种角色", @@ -4925,6 +4933,7 @@ "This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.": "该源未为此版本提供 sha256,因此无法确认下载到的源码与该源所发布的内容一致。", "This source lists no installable task plugins.": "该源未列出任何可安装的任务插件。", "This Telegram account is already bound.": "此 Telegram 账户已被绑定。", + "This Telegram account is not linked. Sign in using another method and link it first.": "此 Telegram 账号尚未绑定,请先通过其他方式登录并绑定。", "This Telegram binding request has expired or has already been used.": "此 Telegram 绑定请求已过期或已使用。", "This tier catches any request that did not match earlier tiers.": "此阶梯会兜底处理未匹配前面阶梯的请求。", "this token group": "此令牌分组", diff --git a/web/src/i18n/static-keys.ts b/web/src/i18n/static-keys.ts index c444645ab..be77af393 100644 --- a/web/src/i18n/static-keys.ts +++ b/web/src/i18n/static-keys.ts @@ -578,6 +578,11 @@ export const STATIC_I18N_KEYS = [ 'This user account no longer exists.', 'This user account is disabled.', 'Telegram binding failed. Please try again.', + 'Telegram authorization failed. Please try again.', + 'This Telegram account is not linked. Sign in using another method and link it first.', + 'The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.', + 'Telegram login has changed. Reload the page and start Telegram OAuth again.', + 'Login session', 'Verification scope is missing', 'This verification has already been used. Please verify again.', "Verification does not match this action's details. Please verify again.", diff --git a/web/src/lib/server-error-message.ts b/web/src/lib/server-error-message.ts index 662df0a1d..bc29f163b 100644 --- a/web/src/lib/server-error-message.ts +++ b/web/src/lib/server-error-message.ts @@ -17,6 +17,15 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ const serverErrorMessageKeys = { + TELEGRAM_OAUTH_NOT_CONFIGURED: + 'Telegram OAuth is not configured or enabled. Please contact your administrator.', + TELEGRAM_OAUTH_CONFLICT: + 'The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.', + TELEGRAM_OAUTH_FAILED: 'Telegram authorization failed. Please try again.', + TELEGRAM_ACCOUNT_NOT_BOUND: + 'This Telegram account is not linked. Sign in using another method and link it first.', + TELEGRAM_LEGACY_AUTH_REMOVED: + 'Telegram login has changed. Reload the page and start Telegram OAuth again.', AUTH_INTERNAL_ERROR: 'Please try again later.', SECURITY_VERIFICATION_FAILED: 'Verification failed. Please try again.', SECURITY_VERIFICATION_FLOW_REQUIRED: diff --git a/web/src/routes/oauth/$provider.tsx b/web/src/routes/oauth/$provider.tsx index 660032233..22f3d6350 100644 --- a/web/src/routes/oauth/$provider.tsx +++ b/web/src/routes/oauth/$provider.tsx @@ -40,6 +40,7 @@ import { } from '@/features/auth/lib/oauth-bind-window' import { getOAuthSessionStorage, + consumeOAuthLoginRedirect, resolveOAuthCallbackMode, } from '@/features/auth/lib/oauth-callback-mode' import { api, applyAuthBundle, isAuthBundle } from '@/lib/api' @@ -205,7 +206,7 @@ function OAuthCallback() { const response = await api.get(`/api/oauth/${provider}`, config) if (response.data?.success && isAuthBundle(response.data?.data)) { applyAuthBundle(response.data.data) - safeNavigate(search.redirect) + safeNavigate(search.redirect ?? consumeOAuthLoginRedirect(state)) toast.success(i18next.t('Signed in successfully!')) return }