434 lines
16 KiB
Go
434 lines
16 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"telesrv/internal/domain"
|
|
)
|
|
|
|
func telegramLoginPGHash(value string) []byte {
|
|
sum := sha256.Sum256([]byte(value))
|
|
return sum[:]
|
|
}
|
|
|
|
func TestTelegramLoginStorePostgresAtomicStateAndCodeConsumption(t *testing.T) {
|
|
pool := testPool(t)
|
|
ctx := context.Background()
|
|
now := time.Now().UTC().Truncate(time.Microsecond)
|
|
suffix := now.UnixNano() % 1_000_000_000
|
|
|
|
users := NewUserStore(pool)
|
|
bots := NewBotStore(pool)
|
|
owner, err := users.Create(ctx, domain.User{
|
|
AccessHash: suffix + 101,
|
|
Phone: fmt.Sprintf("1777%09d", suffix),
|
|
FirstName: "OIDC Owner",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create oidc owner: %v", err)
|
|
}
|
|
bot, _, err := bots.CreateBotAccount(ctx, domain.User{
|
|
AccessHash: suffix + 102,
|
|
FirstName: "OIDC Test Bot",
|
|
Username: fmt.Sprintf("oidc_%09d_bot", suffix),
|
|
}, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: "bot-secret"})
|
|
if err != nil {
|
|
t.Fatalf("create oidc bot: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id IN ($1,$2)", owner.ID, bot.ID)
|
|
})
|
|
|
|
store := NewTelegramLoginStore(pool)
|
|
client, err := store.UpsertTelegramLoginClient(ctx, domain.TelegramLoginClient{
|
|
BotUserID: bot.ID,
|
|
ClientID: fmt.Sprintf("%d", bot.ID),
|
|
SecretHash: telegramLoginPGHash("client-secret"),
|
|
SecretVersion: 1,
|
|
SigningAlgorithm: domain.TelegramLoginSigningRS256,
|
|
Enabled: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("upsert oidc client: %v", err)
|
|
}
|
|
redirectURI := fmt.Sprintf("https://rp-%d.example/callback", suffix)
|
|
origin := fmt.Sprintf("https://rp-%d.example", suffix)
|
|
if _, err := store.AddTelegramLoginAllowedURL(ctx, domain.TelegramLoginAllowedURL{
|
|
BotUserID: client.BotUserID, Kind: domain.TelegramLoginAllowedRedirectURI,
|
|
NormalizedURL: redirectURI, CreatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("add redirect: %v", err)
|
|
}
|
|
if _, err := store.AddTelegramLoginAllowedURL(ctx, domain.TelegramLoginAllowedURL{
|
|
BotUserID: client.BotUserID, Kind: domain.TelegramLoginAllowedWebOrigin,
|
|
NormalizedURL: origin, CreatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("add web origin: %v", err)
|
|
}
|
|
|
|
newRequest := func(label string) domain.TelegramLoginRequest {
|
|
request, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{
|
|
RequestTokenHash: telegramLoginPGHash("request-" + label),
|
|
BrowserTokenHash: telegramLoginPGHash("browser-" + label),
|
|
BotUserID: bot.ID,
|
|
ClientID: client.ClientID,
|
|
SigningAlgorithm: client.SigningAlgorithm,
|
|
Source: domain.TelegramLoginRequestWeb,
|
|
ResponseType: "code",
|
|
RedirectURI: redirectURI,
|
|
Origin: origin,
|
|
Domain: fmt.Sprintf("rp-%d.example", suffix),
|
|
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile, domain.TelegramLoginScopeBotAccess},
|
|
State: "state",
|
|
Nonce: "nonce",
|
|
CodeChallenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
CodeChallengeMethod: "S256",
|
|
Browser: "Firefox",
|
|
Platform: "Windows",
|
|
IP: "192.0.2.10",
|
|
Region: "Test Region",
|
|
MatchCodes: []string{"🟢", "🔵", "🟠"},
|
|
MatchCode: "🔵",
|
|
MatchCodesFirst: true,
|
|
Status: domain.TelegramLoginRequestPending,
|
|
CreatedAt: now,
|
|
ExpiresAt: now.Add(5 * time.Minute),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create request %s: %v", label, err)
|
|
}
|
|
return request
|
|
}
|
|
|
|
request := newRequest(fmt.Sprintf("race-%d", suffix))
|
|
start := make(chan struct{})
|
|
errs := make(chan error, 2)
|
|
go func() {
|
|
<-start
|
|
_, _, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
|
|
RequestID: request.ID,
|
|
Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName},
|
|
WriteAllowed: true,
|
|
MatchCode: request.MatchCode, ApprovedAt: now.Add(time.Second),
|
|
}, suffix+10_000)
|
|
errs <- err
|
|
}()
|
|
go func() {
|
|
<-start
|
|
_, err := store.DeclineTelegramLoginRequest(ctx, request.ID, owner.ID, now.Add(time.Second))
|
|
errs <- err
|
|
}()
|
|
close(start)
|
|
var success, conflict int
|
|
for range 2 {
|
|
err := <-errs
|
|
switch {
|
|
case err == nil:
|
|
success++
|
|
case errors.Is(err, domain.ErrTelegramLoginRequestConflict):
|
|
conflict++
|
|
default:
|
|
t.Fatalf("accept/decline race error: %v", err)
|
|
}
|
|
}
|
|
if success != 1 || conflict != 1 {
|
|
t.Fatalf("accept/decline success=%d conflict=%d, want 1/1", success, conflict)
|
|
}
|
|
|
|
codeRequest := newRequest(fmt.Sprintf("code-%d", suffix))
|
|
_, web, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
|
|
RequestID: codeRequest.ID,
|
|
Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName},
|
|
WriteAllowed: true,
|
|
MatchCode: codeRequest.MatchCode, ApprovedAt: now.Add(2 * time.Second),
|
|
}, suffix+20_000)
|
|
if err != nil {
|
|
t.Fatalf("approve code request: %v", err)
|
|
}
|
|
canSend, err := bots.CanBotSendMessage(ctx, bot.ID, owner.ID)
|
|
if err != nil || !canSend {
|
|
t.Fatalf("bot access after atomic approval = %v,%v", canSend, err)
|
|
}
|
|
code := domain.TelegramLoginAuthorizationCode{
|
|
RequestID: codeRequest.ID,
|
|
CodeHash: telegramLoginPGHash(fmt.Sprintf("code-%d", suffix)),
|
|
SealedCode: append(make([]byte, 32), 1),
|
|
SealNonce: make([]byte, 12),
|
|
SealKeyID: "integration-key",
|
|
IssuedAt: now.Add(3 * time.Second),
|
|
ExpiresAt: now.Add(time.Minute),
|
|
}
|
|
if _, err := store.PutTelegramLoginAuthorizationCode(ctx, code); err != nil {
|
|
t.Fatalf("put code: %v", err)
|
|
}
|
|
exchange := domain.TelegramLoginCodeExchange{
|
|
CodeHash: code.CodeHash, ClientID: client.ClientID, ClientSecretVersion: client.SecretVersion,
|
|
RedirectURI: codeRequest.RedirectURI, CodeChallenge: codeRequest.CodeChallenge, Now: now.Add(4 * time.Second),
|
|
}
|
|
start = make(chan struct{})
|
|
errs = make(chan error, 8)
|
|
var wg sync.WaitGroup
|
|
for range 8 {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
_, _, _, err := store.ConsumeTelegramLoginAuthorizationCode(ctx, exchange)
|
|
errs <- err
|
|
}()
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
close(errs)
|
|
success, conflict = 0, 0
|
|
for err := range errs {
|
|
switch {
|
|
case err == nil:
|
|
success++
|
|
case errors.Is(err, domain.ErrTelegramLoginCodeConsumed):
|
|
conflict++
|
|
default:
|
|
t.Fatalf("code consume race error: %v", err)
|
|
}
|
|
}
|
|
if success != 1 || conflict != 7 {
|
|
t.Fatalf("code consume success=%d consumed=%d, want 1/7", success, conflict)
|
|
}
|
|
|
|
miniRequest, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{
|
|
RequestTokenHash: telegramLoginPGHash(fmt.Sprintf("mini-request-%d", suffix)),
|
|
BrowserTokenHash: telegramLoginPGHash(fmt.Sprintf("mini-browser-%d", suffix)),
|
|
BotUserID: bot.ID, ClientID: client.ClientID, SigningAlgorithm: client.SigningAlgorithm,
|
|
Source: domain.TelegramLoginRequestMiniApp, ResponseType: "post_message",
|
|
RedirectURI: origin + "/", Origin: origin, InAppOrigin: origin, Domain: fmt.Sprintf("rp-%d.example", suffix),
|
|
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile},
|
|
Browser: "Telegram Mini App", Platform: "Telegram Mini App", IP: "192.0.2.11", Region: "Test Region",
|
|
MatchCodes: []string{"🟢", "🔵", "🟠"}, MatchCode: "🔵", MatchCodesFirst: true,
|
|
Status: domain.TelegramLoginRequestPending, CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create mini-app request: %v", err)
|
|
}
|
|
if _, _, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
|
|
RequestID: miniRequest.ID,
|
|
Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName},
|
|
MatchCode: miniRequest.MatchCode, ApprovedAt: now.Add(5 * time.Second),
|
|
}, suffix+25_000); err != nil {
|
|
t.Fatalf("approve mini-app request: %v", err)
|
|
}
|
|
directToken := domain.TelegramLoginAuthorizationCode{
|
|
RequestID: miniRequest.ID, CodeHash: telegramLoginPGHash(fmt.Sprintf("mini-token-%d", suffix)),
|
|
SealedCode: append(make([]byte, 32), 1), SealNonce: make([]byte, 12), SealKeyID: "integration-key",
|
|
IssuedAt: now.Add(6 * time.Second), ExpiresAt: now.Add(time.Minute),
|
|
}
|
|
if _, err := store.PutTelegramLoginAuthorizationCode(ctx, directToken); err != nil {
|
|
t.Fatalf("put mini-app token: %v", err)
|
|
}
|
|
start = make(chan struct{})
|
|
errs = make(chan error, 8)
|
|
for range 8 {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
_, _, _, err := store.ConsumeTelegramLoginDirectToken(ctx, directToken.CodeHash, origin, now.Add(7*time.Second))
|
|
errs <- err
|
|
}()
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
close(errs)
|
|
success, conflict = 0, 0
|
|
for err := range errs {
|
|
switch {
|
|
case err == nil:
|
|
success++
|
|
case errors.Is(err, domain.ErrTelegramLoginCodeConsumed):
|
|
conflict++
|
|
default:
|
|
t.Fatalf("mini-app token consume race error: %v", err)
|
|
}
|
|
}
|
|
if success != 1 || conflict != 7 {
|
|
t.Fatalf("mini-app token consume success=%d consumed=%d, want 1/7", success, conflict)
|
|
}
|
|
|
|
if revoked, err := store.RevokeTelegramLoginWebAuthorization(ctx, owner.ID, web.Hash, now.Add(5*time.Second)); err != nil || !revoked {
|
|
t.Fatalf("revoke web authorization = %v,%v", revoked, err)
|
|
}
|
|
if listed, err := store.ListTelegramLoginWebAuthorizations(ctx, owner.ID); err != nil {
|
|
t.Fatalf("list web authorizations: %v", err)
|
|
} else {
|
|
for _, got := range listed {
|
|
if got.Hash == web.Hash {
|
|
t.Fatalf("revoked web authorization still listed: %#v", got)
|
|
}
|
|
}
|
|
}
|
|
assertTelegramLoginConfigDeleteTakesClientLock(t, pool, client.BotUserID, func() (bool, error) {
|
|
return store.DeleteTelegramLoginAllowedURL(ctx, client.BotUserID, domain.TelegramLoginAllowedRedirectURI, redirectURI)
|
|
})
|
|
}
|
|
|
|
func TestTelegramLoginStorePostgresNativeCallbackAndRetention(t *testing.T) {
|
|
pool := testPool(t)
|
|
ctx := context.Background()
|
|
now := time.Now().UTC().Truncate(time.Microsecond)
|
|
suffix := now.UnixNano() % 1_000_000_000
|
|
|
|
users := NewUserStore(pool)
|
|
bots := NewBotStore(pool)
|
|
owner, err := users.Create(ctx, domain.User{
|
|
AccessHash: suffix + 301, Phone: fmt.Sprintf("1666%09d", suffix), FirstName: "Native Owner",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bot, _, err := bots.CreateBotAccount(ctx, domain.User{
|
|
AccessHash: suffix + 302, FirstName: "Native Login Bot", Username: fmt.Sprintf("native_%09d_bot", suffix),
|
|
}, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: "native-bot-secret"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id IN ($1,$2)", owner.ID, bot.ID) })
|
|
|
|
store := NewTelegramLoginStore(pool)
|
|
client, err := store.CreateTelegramLoginClient(ctx, domain.TelegramLoginClient{
|
|
BotUserID: bot.ID, ClientID: fmt.Sprintf("%d", bot.ID), SecretHash: telegramLoginPGHash("native-secret"),
|
|
SecretVersion: 1, SigningAlgorithm: domain.TelegramLoginSigningRS256, Enabled: true,
|
|
CreatedAt: now, UpdatedAt: now,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const callbackURI = "bedolaga://telegram-login"
|
|
nativeApp, err := store.UpsertTelegramLoginNativeApp(ctx, domain.TelegramLoginNativeApp{
|
|
BotUserID: bot.ID, Platform: domain.TelegramLoginNativeAndroid, ApplicationID: "dev.bedolaga.demo",
|
|
VerificationID: strings.Repeat("A", 64), CallbackURI: callbackURI, VerifiedDisplayName: "Bedolaga Demo",
|
|
Enabled: true, CreatedAt: now, UpdatedAt: now,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
createRequest := func(label string) domain.TelegramLoginRequest {
|
|
t.Helper()
|
|
request, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{
|
|
RequestTokenHash: telegramLoginPGHash("native-request-" + label), BrowserTokenHash: telegramLoginPGHash("native-browser-" + label),
|
|
BotUserID: bot.ID, ClientID: client.ClientID, SigningAlgorithm: client.SigningAlgorithm,
|
|
Source: domain.TelegramLoginRequestNative, ResponseType: "code", RedirectURI: callbackURI,
|
|
Domain: "dev.bedolaga.demo", Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile},
|
|
CodeChallenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", CodeChallengeMethod: "S256",
|
|
Browser: "TelegramLogin/Android", Platform: "Android", IP: "192.0.2.20", Region: "Test Region",
|
|
IsApp: true, VerifiedAppName: "Bedolaga Demo", MatchCodes: []string{}, Status: domain.TelegramLoginRequestPending,
|
|
CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create native request: %v", err)
|
|
}
|
|
return request
|
|
}
|
|
approve := func(request domain.TelegramLoginRequest, hash int64) domain.TelegramLoginWebAuthorization {
|
|
t.Helper()
|
|
_, web, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
|
|
RequestID: request.ID, Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: "Native Owner", GivenName: "Native"},
|
|
ApprovedAt: now.Add(time.Second),
|
|
}, hash)
|
|
if err != nil {
|
|
t.Fatalf("approve native request: %v", err)
|
|
}
|
|
return web
|
|
}
|
|
|
|
revokedRequest := createRequest(fmt.Sprintf("revoked-%d", suffix))
|
|
revokedWeb := approve(revokedRequest, suffix+30_000)
|
|
code := domain.TelegramLoginAuthorizationCode{
|
|
RequestID: revokedRequest.ID, CodeHash: telegramLoginPGHash(fmt.Sprintf("native-code-%d", suffix)),
|
|
SealedCode: append(make([]byte, 32), 1), SealNonce: make([]byte, 12), SealKeyID: "integration-key",
|
|
IssuedAt: now.Add(2 * time.Second), ExpiresAt: now.Add(time.Minute),
|
|
}
|
|
if _, err := store.PutTelegramLoginAuthorizationCode(ctx, code); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, _, err := store.ConsumeTelegramLoginAuthorizationCode(ctx, domain.TelegramLoginCodeExchange{
|
|
CodeHash: code.CodeHash, ClientID: client.ClientID, ClientSecretVersion: client.SecretVersion,
|
|
RedirectURI: callbackURI, CodeChallenge: revokedRequest.CodeChallenge, Now: now.Add(3 * time.Second),
|
|
}); err != nil {
|
|
t.Fatalf("consume native code: %v", err)
|
|
}
|
|
if ok, err := store.RevokeTelegramLoginWebAuthorization(ctx, owner.ID, revokedWeb.Hash, now.Add(4*time.Second)); err != nil || !ok {
|
|
t.Fatalf("revoke native authorization = %v,%v", ok, err)
|
|
}
|
|
|
|
activeRequest := createRequest(fmt.Sprintf("active-%d", suffix))
|
|
activeWeb := approve(activeRequest, suffix+40_000)
|
|
deleted, err := store.DeleteExpiredTelegramLoginArtifacts(ctx, now.Add(2*time.Hour), 100)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if deleted < 2 {
|
|
t.Fatalf("retention deleted=%d, want at least code and revoked request", deleted)
|
|
}
|
|
if _, found, _ := store.GetTelegramLoginRequest(ctx, revokedRequest.ID); found {
|
|
t.Fatal("revoked native request survived retention")
|
|
}
|
|
if _, found, _ := store.GetTelegramLoginRequest(ctx, activeRequest.ID); !found {
|
|
t.Fatal("active native request was deleted")
|
|
}
|
|
listed, err := store.ListTelegramLoginWebAuthorizations(ctx, owner.ID)
|
|
if err != nil || len(listed) != 1 || listed[0].Hash != activeWeb.Hash {
|
|
t.Fatalf("active authorization list=%#v err=%v", listed, err)
|
|
}
|
|
assertTelegramLoginConfigDeleteTakesClientLock(t, pool, client.BotUserID, func() (bool, error) {
|
|
return store.DeleteTelegramLoginNativeApp(ctx, client.BotUserID, nativeApp.ID)
|
|
})
|
|
}
|
|
|
|
func assertTelegramLoginConfigDeleteTakesClientLock(t *testing.T, pool *pgxpool.Pool, botUserID int64, remove func() (bool, error)) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
tx, err := pool.Begin(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
var lockedID int64
|
|
if err := tx.QueryRow(ctx, `SELECT bot_user_id FROM bot_login_clients WHERE bot_user_id = $1 FOR UPDATE`, botUserID).Scan(&lockedID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result := make(chan error, 1)
|
|
go func() {
|
|
deleted, err := remove()
|
|
if err == nil && !deleted {
|
|
err = errors.New("configuration row was not deleted")
|
|
}
|
|
result <- err
|
|
}()
|
|
select {
|
|
case err := <-result:
|
|
t.Fatalf("configuration delete bypassed client serialization lock: %v", err)
|
|
case <-time.After(150 * time.Millisecond):
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case err := <-result:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("configuration delete remained blocked after client lock committed")
|
|
}
|
|
}
|