Files

465 lines
21 KiB
Go

package controller
import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"maps"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"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/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"gorm.io/gorm"
)
type telegramTestTransport struct{ target *url.URL }
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)
}
type telegramTestGrant struct {
challenge string
claims jwt.MapClaims
key *rsa.PrivateKey
}
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)
maps.Copy(common.OptionMap, previousOptions)
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
}
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)
}
}))
t.Cleanup(server.Close)
target, err := url.Parse(server.URL)
require.NoError(t, err)
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)
proof := ""
if intent == "bind" {
proof = issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: []byte(`{"provider":"telegram"}`)}, service.VerificationMethodPassword)
}
response := securityEnrollmentRequest("POST", "/api/oauth/state", string(request), proof, 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)
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", Password: fixture.user.Password, 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)
}