owpengram-server/internal/store/postgres/telegram_login.go

1106 lines
58 KiB
Go

package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
type TelegramLoginStore struct {
db sqlcgen.DBTX
}
func NewTelegramLoginStore(db sqlcgen.DBTX) *TelegramLoginStore {
return &TelegramLoginStore{db: db}
}
type telegramLoginRowScanner interface {
Scan(dest ...any) error
}
const telegramLoginClientColumns = `bot_user_id, client_id, client_secret_hash, secret_version, signing_algorithm, enabled, created_at, updated_at`
func scanTelegramLoginClient(row telegramLoginRowScanner) (domain.TelegramLoginClient, error) {
var client domain.TelegramLoginClient
var algorithm string
if err := row.Scan(&client.BotUserID, &client.ClientID, &client.SecretHash, &client.SecretVersion, &algorithm, &client.Enabled, &client.CreatedAt, &client.UpdatedAt); err != nil {
return domain.TelegramLoginClient{}, err
}
client.SigningAlgorithm = domain.TelegramLoginSigningAlgorithm(algorithm)
return client, nil
}
func (s *TelegramLoginStore) CreateTelegramLoginClient(ctx context.Context, client domain.TelegramLoginClient) (domain.TelegramLoginClient, error) {
if err := client.Validate(); err != nil {
return domain.TelegramLoginClient{}, err
}
createdAt := client.CreatedAt
if createdAt.IsZero() {
createdAt = time.Now().UTC()
}
updatedAt := client.UpdatedAt
if updatedAt.IsZero() {
updatedAt = createdAt
}
client, err := scanTelegramLoginClient(s.db.QueryRow(ctx, `
INSERT INTO bot_login_clients (
bot_user_id, client_id, client_secret_hash, secret_version, signing_algorithm,
enabled, created_at, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING `+telegramLoginClientColumns,
client.BotUserID, client.ClientID, client.SecretHash, client.SecretVersion,
string(client.SigningAlgorithm), client.Enabled, createdAt, updatedAt))
if err != nil {
return domain.TelegramLoginClient{}, mapTelegramLoginWriteError("create telegram login client", err)
}
return client, nil
}
func (s *TelegramLoginStore) UpsertTelegramLoginClient(ctx context.Context, client domain.TelegramLoginClient) (domain.TelegramLoginClient, error) {
if err := client.Validate(); err != nil {
return domain.TelegramLoginClient{}, err
}
createdAt := client.CreatedAt
if createdAt.IsZero() {
createdAt = time.Now().UTC()
}
updatedAt := client.UpdatedAt
if updatedAt.IsZero() {
updatedAt = createdAt
}
row := s.db.QueryRow(ctx, `
INSERT INTO bot_login_clients (
bot_user_id, client_id, client_secret_hash, secret_version, signing_algorithm,
enabled, created_at, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (bot_user_id) DO UPDATE SET
client_id = EXCLUDED.client_id,
client_secret_hash = EXCLUDED.client_secret_hash,
secret_version = EXCLUDED.secret_version,
signing_algorithm = EXCLUDED.signing_algorithm,
enabled = EXCLUDED.enabled,
updated_at = EXCLUDED.updated_at
RETURNING `+telegramLoginClientColumns,
client.BotUserID, client.ClientID, client.SecretHash, client.SecretVersion,
string(client.SigningAlgorithm), client.Enabled, createdAt, updatedAt)
out, err := scanTelegramLoginClient(row)
if err != nil {
return domain.TelegramLoginClient{}, mapTelegramLoginWriteError("upsert telegram login client", err)
}
return out, nil
}
func (s *TelegramLoginStore) GetTelegramLoginClient(ctx context.Context, clientID string) (domain.TelegramLoginClient, bool, error) {
client, err := scanTelegramLoginClient(s.db.QueryRow(ctx, `SELECT `+telegramLoginClientColumns+` FROM bot_login_clients WHERE client_id = $1`, clientID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginClient{}, false, nil
}
if err != nil {
return domain.TelegramLoginClient{}, false, fmt.Errorf("get telegram login client: %w", err)
}
return client, true, nil
}
func (s *TelegramLoginStore) GetTelegramLoginClientByBot(ctx context.Context, botUserID int64) (domain.TelegramLoginClient, bool, error) {
client, err := scanTelegramLoginClient(s.db.QueryRow(ctx, `SELECT `+telegramLoginClientColumns+` FROM bot_login_clients WHERE bot_user_id = $1`, botUserID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginClient{}, false, nil
}
if err != nil {
return domain.TelegramLoginClient{}, false, fmt.Errorf("get telegram login client by bot: %w", err)
}
return client, true, nil
}
func (s *TelegramLoginStore) RotateTelegramLoginClientSecret(ctx context.Context, botUserID, expectedVersion int64, secretHash []byte, now time.Time) (domain.TelegramLoginClient, error) {
if botUserID <= 0 || expectedVersion <= 0 || len(secretHash) != 32 {
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
}
client, err := scanTelegramLoginClient(s.db.QueryRow(ctx, `
UPDATE bot_login_clients
SET client_secret_hash = $3, secret_version = secret_version + 1, updated_at = $4
WHERE bot_user_id = $1 AND secret_version = $2
RETURNING `+telegramLoginClientColumns, botUserID, expectedVersion, secretHash, now))
if errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginRequestConflict
}
if err != nil {
return domain.TelegramLoginClient{}, fmt.Errorf("rotate telegram login client secret: %w", err)
}
return client, nil
}
func (s *TelegramLoginStore) SetTelegramLoginClientSigningAlgorithm(ctx context.Context, botUserID int64, algorithm domain.TelegramLoginSigningAlgorithm, now time.Time) (domain.TelegramLoginClient, error) {
if botUserID <= 0 || !algorithm.Valid() {
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
}
client, err := scanTelegramLoginClient(s.db.QueryRow(ctx, `
UPDATE bot_login_clients SET signing_algorithm = $2, updated_at = $3
WHERE bot_user_id = $1
RETURNING `+telegramLoginClientColumns, botUserID, string(algorithm), now))
if errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
}
if err != nil {
return domain.TelegramLoginClient{}, fmt.Errorf("set telegram login client signing algorithm: %w", err)
}
return client, nil
}
func (s *TelegramLoginStore) SetTelegramLoginClientEnabled(ctx context.Context, botUserID int64, enabled bool, now time.Time) error {
tag, err := s.db.Exec(ctx, `UPDATE bot_login_clients SET enabled = $2, updated_at = $3 WHERE bot_user_id = $1`, botUserID, enabled, now)
if err != nil {
return fmt.Errorf("set telegram login client enabled: %w", err)
}
if tag.RowsAffected() == 0 {
return domain.ErrTelegramLoginClientInvalid
}
return nil
}
func scanTelegramLoginAllowedURL(row telegramLoginRowScanner) (domain.TelegramLoginAllowedURL, error) {
var allowed domain.TelegramLoginAllowedURL
var kind string
if err := row.Scan(&allowed.ID, &allowed.BotUserID, &kind, &allowed.NormalizedURL, &allowed.CreatedAt); err != nil {
return domain.TelegramLoginAllowedURL{}, err
}
allowed.Kind = domain.TelegramLoginAllowedURLKind(kind)
return allowed, nil
}
func (s *TelegramLoginStore) AddTelegramLoginAllowedURL(ctx context.Context, allowed domain.TelegramLoginAllowedURL) (domain.TelegramLoginAllowedURL, error) {
if allowed.BotUserID <= 0 || allowed.NormalizedURL == "" || (allowed.Kind != domain.TelegramLoginAllowedWebOrigin && allowed.Kind != domain.TelegramLoginAllowedRedirectURI) {
return domain.TelegramLoginAllowedURL{}, domain.ErrTelegramLoginURLInvalid
}
createdAt := allowed.CreatedAt
if createdAt.IsZero() {
createdAt = time.Now().UTC()
}
row := s.db.QueryRow(ctx, `
INSERT INTO bot_login_allowed_urls (bot_user_id, kind, normalized_url, created_at)
VALUES ($1,$2,$3,$4)
ON CONFLICT (bot_user_id, kind, normalized_url) DO UPDATE
SET normalized_url = EXCLUDED.normalized_url
RETURNING id, bot_user_id, kind, normalized_url, created_at`,
allowed.BotUserID, string(allowed.Kind), allowed.NormalizedURL, createdAt)
out, err := scanTelegramLoginAllowedURL(row)
if err != nil {
return domain.TelegramLoginAllowedURL{}, mapTelegramLoginWriteError("add telegram login allowed url", err)
}
return out, nil
}
func (s *TelegramLoginStore) DeleteTelegramLoginAllowedURL(ctx context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, normalizedURL string) (bool, error) {
beginner, ok := s.db.(txBeginner)
if !ok {
return false, fmt.Errorf("delete telegram login allowed url: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return false, fmt.Errorf("delete telegram login allowed url: begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
// Approval and code consumption lock this same client row before their
// final allow-list recheck. Configuration removal therefore serializes
// with those transitions across every server instance.
var exists bool
if err := tx.QueryRow(ctx, `SELECT true FROM bot_login_clients WHERE bot_user_id = $1 FOR UPDATE`, botUserID).Scan(&exists); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, domain.ErrTelegramLoginClientInvalid
}
return false, fmt.Errorf("delete telegram login allowed url: lock client: %w", err)
}
tag, err := tx.Exec(ctx, `DELETE FROM bot_login_allowed_urls WHERE bot_user_id = $1 AND kind = $2 AND normalized_url = $3`, botUserID, string(kind), normalizedURL)
if err != nil {
return false, fmt.Errorf("delete telegram login allowed url: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return false, fmt.Errorf("delete telegram login allowed url: commit: %w", err)
}
return tag.RowsAffected() > 0, nil
}
func (s *TelegramLoginStore) ListTelegramLoginAllowedURLs(ctx context.Context, botUserID int64) ([]domain.TelegramLoginAllowedURL, error) {
rows, err := s.db.Query(ctx, `SELECT id, bot_user_id, kind, normalized_url, created_at FROM bot_login_allowed_urls WHERE bot_user_id = $1 ORDER BY kind, id`, botUserID)
if err != nil {
return nil, fmt.Errorf("list telegram login allowed urls: %w", err)
}
defer rows.Close()
out := make([]domain.TelegramLoginAllowedURL, 0)
for rows.Next() {
allowed, err := scanTelegramLoginAllowedURL(rows)
if err != nil {
return nil, fmt.Errorf("scan telegram login allowed url: %w", err)
}
out = append(out, allowed)
}
return out, rows.Err()
}
func (s *TelegramLoginStore) IsTelegramLoginURLAllowed(ctx context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, normalizedURL string) (bool, error) {
var allowed bool
err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bot_login_allowed_urls WHERE bot_user_id = $1 AND kind = $2 AND normalized_url = $3)`, botUserID, string(kind), normalizedURL).Scan(&allowed)
if err != nil {
return false, fmt.Errorf("check telegram login allowed url: %w", err)
}
return allowed, nil
}
func scanTelegramLoginNativeApp(row telegramLoginRowScanner) (domain.TelegramLoginNativeApp, error) {
var app domain.TelegramLoginNativeApp
var platform string
if err := row.Scan(&app.ID, &app.BotUserID, &platform, &app.ApplicationID, &app.VerificationID, &app.CallbackURI, &app.VerifiedDisplayName, &app.Enabled, &app.CreatedAt, &app.UpdatedAt); err != nil {
return domain.TelegramLoginNativeApp{}, err
}
app.Platform = domain.TelegramLoginNativePlatform(platform)
return app, nil
}
const telegramLoginNativeAppColumns = `id, bot_user_id, platform, application_id, verification_id, callback_uri, verified_display_name, enabled, created_at, updated_at`
func (s *TelegramLoginStore) UpsertTelegramLoginNativeApp(ctx context.Context, app domain.TelegramLoginNativeApp) (domain.TelegramLoginNativeApp, error) {
if err := app.Validate(); err != nil {
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginClientInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.TelegramLoginNativeApp{}, fmt.Errorf("upsert telegram login native app: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.TelegramLoginNativeApp{}, fmt.Errorf("upsert telegram login native app: begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var exists bool
if err := tx.QueryRow(ctx, `SELECT true FROM bot_login_clients WHERE bot_user_id = $1 FOR UPDATE`, app.BotUserID).Scan(&exists); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginClientInvalid
}
return domain.TelegramLoginNativeApp{}, fmt.Errorf("upsert telegram login native app: lock client: %w", err)
}
if app.ID == 0 {
var current int
if err := tx.QueryRow(ctx, `SELECT count(*) FROM bot_login_native_apps WHERE bot_user_id = $1`, app.BotUserID).Scan(&current); err != nil {
return domain.TelegramLoginNativeApp{}, fmt.Errorf("upsert telegram login native app: capacity: %w", err)
}
if current >= domain.MaxTelegramLoginNativeApps {
// A duplicate configuration remains an idempotent update at capacity.
var duplicate bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bot_login_native_apps WHERE bot_user_id=$1 AND platform=$2 AND application_id=$3 AND verification_id=$4)`, app.BotUserID, string(app.Platform), app.ApplicationID, app.VerificationID).Scan(&duplicate); err != nil {
return domain.TelegramLoginNativeApp{}, fmt.Errorf("upsert telegram login native app: duplicate check: %w", err)
}
if !duplicate {
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginRequestInvalid
}
}
}
var row telegramLoginRowScanner
if app.ID == 0 {
row = tx.QueryRow(ctx, `
INSERT INTO bot_login_native_apps (
bot_user_id, platform, application_id, verification_id, callback_uri,
verified_display_name, enabled, created_at, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT (bot_user_id, platform, application_id, verification_id) DO UPDATE SET
callback_uri = EXCLUDED.callback_uri,
verified_display_name = EXCLUDED.verified_display_name,
enabled = EXCLUDED.enabled,
updated_at = EXCLUDED.updated_at
RETURNING `+telegramLoginNativeAppColumns,
app.BotUserID, string(app.Platform), app.ApplicationID, app.VerificationID,
app.CallbackURI, app.VerifiedDisplayName, app.Enabled, app.CreatedAt, app.UpdatedAt)
} else {
row = tx.QueryRow(ctx, `
UPDATE bot_login_native_apps SET
platform = $3, application_id = $4, verification_id = $5,
callback_uri = $6, verified_display_name = $7, enabled = $8, updated_at = $9
WHERE id = $1 AND bot_user_id = $2
RETURNING `+telegramLoginNativeAppColumns,
app.ID, app.BotUserID, string(app.Platform), app.ApplicationID, app.VerificationID,
app.CallbackURI, app.VerifiedDisplayName, app.Enabled, app.UpdatedAt)
}
out, err := scanTelegramLoginNativeApp(row)
if errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginClientInvalid
}
if err != nil {
return domain.TelegramLoginNativeApp{}, mapTelegramLoginWriteError("upsert telegram login native app", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.TelegramLoginNativeApp{}, fmt.Errorf("upsert telegram login native app: commit: %w", err)
}
return out, nil
}
func (s *TelegramLoginStore) DeleteTelegramLoginNativeApp(ctx context.Context, botUserID, appID int64) (bool, error) {
beginner, ok := s.db.(txBeginner)
if !ok {
return false, fmt.Errorf("delete telegram login native app: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return false, fmt.Errorf("delete telegram login native app: begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var exists bool
if err := tx.QueryRow(ctx, `SELECT true FROM bot_login_clients WHERE bot_user_id = $1 FOR UPDATE`, botUserID).Scan(&exists); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, domain.ErrTelegramLoginClientInvalid
}
return false, fmt.Errorf("delete telegram login native app: lock client: %w", err)
}
tag, err := tx.Exec(ctx, `DELETE FROM bot_login_native_apps WHERE id = $1 AND bot_user_id = $2`, appID, botUserID)
if err != nil {
return false, fmt.Errorf("delete telegram login native app: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return false, fmt.Errorf("delete telegram login native app: commit: %w", err)
}
return tag.RowsAffected() > 0, nil
}
func (s *TelegramLoginStore) ListTelegramLoginNativeApps(ctx context.Context, botUserID int64) ([]domain.TelegramLoginNativeApp, error) {
rows, err := s.db.Query(ctx, `SELECT `+telegramLoginNativeAppColumns+` FROM bot_login_native_apps WHERE bot_user_id = $1 ORDER BY id LIMIT $2`, botUserID, domain.MaxTelegramLoginNativeApps)
if err != nil {
return nil, fmt.Errorf("list telegram login native apps: %w", err)
}
defer rows.Close()
out := make([]domain.TelegramLoginNativeApp, 0)
for rows.Next() {
app, err := scanTelegramLoginNativeApp(rows)
if err != nil {
return nil, fmt.Errorf("scan telegram login native app: %w", err)
}
out = append(out, app)
}
return out, rows.Err()
}
const telegramLoginRequestColumns = `
id, request_token_hash, browser_token_hash, bot_user_id, client_id,
signing_algorithm, source, response_type, redirect_uri, origin, domain,
requested_scopes, oauth_state, nonce, code_challenge, code_challenge_method,
browser, platform, ip, region, in_app_origin, is_app, verified_app_name,
match_codes, match_code, match_codes_first, user_id_hint,
peer_type, peer_id, message_id, button_id,
status, authorized_user_id, profile_name, given_name, family_name,
preferred_username, picture, phone_number, write_allowed, phone_shared,
created_at, expires_at, approved_at, declined_at`
func telegramLoginScopeStrings(scopes []domain.TelegramLoginScope) []string {
out := make([]string, len(scopes))
for i, scope := range scopes {
out[i] = string(scope)
}
return out
}
func telegramLoginScopes(values []string) []domain.TelegramLoginScope {
out := make([]domain.TelegramLoginScope, len(values))
for i, value := range values {
out[i] = domain.TelegramLoginScope(value)
}
return out
}
func scanTelegramLoginRequest(row telegramLoginRowScanner) (domain.TelegramLoginRequest, error) {
var request domain.TelegramLoginRequest
var algorithm, source, status, peerType string
var scopes []string
var authorizedUserID sql.NullInt64
var approvedAt, declinedAt sql.NullTime
var messageID, buttonID int32
if err := row.Scan(
&request.ID, &request.RequestTokenHash, &request.BrowserTokenHash, &request.BotUserID, &request.ClientID,
&algorithm, &source, &request.ResponseType, &request.RedirectURI, &request.Origin, &request.Domain,
&scopes, &request.State, &request.Nonce, &request.CodeChallenge, &request.CodeChallengeMethod,
&request.Browser, &request.Platform, &request.IP, &request.Region, &request.InAppOrigin, &request.IsApp, &request.VerifiedAppName,
&request.MatchCodes, &request.MatchCode, &request.MatchCodesFirst, &request.UserIDHint,
&peerType, &request.PeerID, &messageID, &buttonID,
&status, &authorizedUserID, &request.ProfileName, &request.GivenName, &request.FamilyName,
&request.PreferredUsername, &request.Picture, &request.PhoneNumber, &request.WriteAllowed, &request.PhoneShared,
&request.CreatedAt, &request.ExpiresAt, &approvedAt, &declinedAt,
); err != nil {
return domain.TelegramLoginRequest{}, err
}
request.SigningAlgorithm = domain.TelegramLoginSigningAlgorithm(algorithm)
request.Source = domain.TelegramLoginRequestSource(source)
request.Scopes = telegramLoginScopes(scopes)
request.PeerType = domain.PeerType(peerType)
request.MessageID = int(messageID)
request.ButtonID = int(buttonID)
request.Status = domain.TelegramLoginRequestState(status)
if authorizedUserID.Valid {
request.AuthorizedUserID = authorizedUserID.Int64
}
if approvedAt.Valid {
request.ApprovedAt = approvedAt.Time
}
if declinedAt.Valid {
request.DeclinedAt = declinedAt.Time
}
return request, nil
}
func (s *TelegramLoginStore) CreateTelegramLoginRequest(ctx context.Context, request domain.TelegramLoginRequest) (domain.TelegramLoginRequest, error) {
if err := request.Validate(); err != nil {
return domain.TelegramLoginRequest{}, err
}
row := s.db.QueryRow(ctx, `
INSERT INTO telegram_login_requests (
request_token_hash, browser_token_hash, bot_user_id, client_id,
signing_algorithm, source, response_type, redirect_uri, origin, domain,
requested_scopes, oauth_state, nonce, code_challenge, code_challenge_method,
browser, platform, ip, region, in_app_origin, is_app, verified_app_name,
match_codes, match_code, match_codes_first, user_id_hint,
peer_type, peer_id, message_id, button_id, status, created_at, expires_at
)
SELECT
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,
$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33
FROM bot_login_clients c
WHERE c.bot_user_id = $3 AND c.client_id = $4 AND c.enabled
AND c.signing_algorithm = $5
RETURNING `+telegramLoginRequestColumns,
request.RequestTokenHash, request.BrowserTokenHash, request.BotUserID, request.ClientID,
string(request.SigningAlgorithm), string(request.Source), request.ResponseType, request.RedirectURI, request.Origin, request.Domain,
telegramLoginScopeStrings(request.Scopes), request.State, request.Nonce, request.CodeChallenge, request.CodeChallengeMethod,
request.Browser, request.Platform, request.IP, request.Region, request.InAppOrigin, request.IsApp, request.VerifiedAppName,
request.MatchCodes, request.MatchCode, request.MatchCodesFirst, request.UserIDHint,
string(request.PeerType), request.PeerID, request.MessageID, request.ButtonID, string(request.Status), request.CreatedAt, request.ExpiresAt)
out, err := scanTelegramLoginRequest(row)
if errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginClientDisabled
}
if err != nil {
return domain.TelegramLoginRequest{}, mapTelegramLoginWriteError("create telegram login request", err)
}
return out, nil
}
func (s *TelegramLoginStore) GetTelegramLoginRequest(ctx context.Context, requestID int64) (domain.TelegramLoginRequest, bool, error) {
request, err := scanTelegramLoginRequest(s.db.QueryRow(ctx, `SELECT `+telegramLoginRequestColumns+` FROM telegram_login_requests WHERE id = $1`, requestID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginRequest{}, false, nil
}
if err != nil {
return domain.TelegramLoginRequest{}, false, fmt.Errorf("get telegram login request: %w", err)
}
return request, true, nil
}
func (s *TelegramLoginStore) GetTelegramLoginRequestByTokenHash(ctx context.Context, tokenHash []byte) (domain.TelegramLoginRequest, bool, error) {
request, err := scanTelegramLoginRequest(s.db.QueryRow(ctx, `SELECT `+telegramLoginRequestColumns+` FROM telegram_login_requests WHERE request_token_hash = $1`, tokenHash))
if errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginRequest{}, false, nil
}
if err != nil {
return domain.TelegramLoginRequest{}, false, fmt.Errorf("get telegram login request by token: %w", err)
}
return request, true, nil
}
func (s *TelegramLoginStore) GetTelegramLoginRequestByBrowserTokenHash(ctx context.Context, tokenHash []byte) (domain.TelegramLoginRequest, bool, error) {
request, err := scanTelegramLoginRequest(s.db.QueryRow(ctx, `SELECT `+telegramLoginRequestColumns+` FROM telegram_login_requests WHERE browser_token_hash = $1`, tokenHash))
if errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginRequest{}, false, nil
}
if err != nil {
return domain.TelegramLoginRequest{}, false, fmt.Errorf("get telegram login request by browser token: %w", err)
}
return request, true, nil
}
func (s *TelegramLoginStore) ApproveTelegramLoginRequest(ctx context.Context, approval domain.TelegramLoginApproval, webAuthorizationHash int64) (domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) {
if approval.RequestID <= 0 || approval.Identity.UserID <= 0 || webAuthorizationHash == 0 || approval.ApprovedAt.IsZero() {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
request, err := scanTelegramLoginRequest(tx.QueryRow(ctx, `SELECT `+telegramLoginRequestColumns+` FROM telegram_login_requests WHERE id = $1 FOR UPDATE`, approval.RequestID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestInvalid
}
if err != nil {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: lock: %w", err)
}
if request.Status != domain.TelegramLoginRequestPending {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestConflict
}
if !approval.ApprovedAt.Before(request.ExpiresAt) {
if _, err := tx.Exec(ctx, `UPDATE telegram_login_requests SET status = 'expired' WHERE id = $1 AND status = 'pending'`, request.ID); err != nil {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: mark expired: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: commit expiry: %w", err)
}
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestExpired
}
var clientEnabled, redirectAllowed, originAllowed bool
if err := tx.QueryRow(ctx, `SELECT enabled FROM bot_login_clients WHERE bot_user_id = $1 AND client_id = $2 AND signing_algorithm = $3 FOR UPDATE`, request.BotUserID, request.ClientID, string(request.SigningAlgorithm)).Scan(&clientEnabled); err != nil || !clientEnabled {
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: client: %w", err)
}
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginClientDisabled
}
redirectAllowed = request.ResponseType != "code"
if request.ResponseType == "code" {
if err := tx.QueryRow(ctx, `SELECT
EXISTS(SELECT 1 FROM bot_login_allowed_urls WHERE bot_user_id = $1 AND kind = 'redirect_uri' AND normalized_url = $2)
OR ($3 = 'native' AND EXISTS(SELECT 1 FROM bot_login_native_apps WHERE bot_user_id = $1 AND callback_uri = $2 AND enabled))`, request.BotUserID, request.RedirectURI, string(request.Source)).Scan(&redirectAllowed); err != nil {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: redirect: %w", err)
}
if !redirectAllowed {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRedirectNotAllowed
}
} else if request.ResponseType == "post_message" || request.ResponseType == "legacy_url" {
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bot_login_allowed_urls WHERE bot_user_id = $1 AND kind = 'web_origin' AND normalized_url = $2)`, request.BotUserID, request.Origin).Scan(&originAllowed); err != nil {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: post-message origin: %w", err)
}
if !originAllowed {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed
}
} else {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestInvalid
}
originAllowed = request.InAppOrigin == ""
if !originAllowed {
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bot_login_allowed_urls WHERE bot_user_id = $1 AND kind = 'web_origin' AND normalized_url = $2)`, request.BotUserID, request.InAppOrigin).Scan(&originAllowed); err != nil {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: origin: %w", err)
}
}
if !originAllowed {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed
}
if len(request.MatchCodes) > 0 && approval.MatchCode != request.MatchCode {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginMatchCodeInvalid
}
scopes, err := grantedTelegramLoginScopesPG(request, approval)
if err != nil {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, err
}
identity, err := approval.Identity.Sanitized(request.Requests(domain.TelegramLoginScopeProfile), approval.PhoneShared)
if err != nil {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, err
}
// Serialize the per-user capacity check across requests and server
// instances. This prevents unbounded account.getWebAuthorizations payloads.
const telegramLoginAuthorizationLockNamespace int64 = 0x544c000000000000
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, identity.UserID^telegramLoginAuthorizationLockNamespace); err != nil {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: authorization capacity lock: %w", err)
}
var activeAuthorizations int
if err := tx.QueryRow(ctx, `SELECT count(*) FROM web_authorizations WHERE user_id = $1 AND revoked_at IS NULL`, identity.UserID).Scan(&activeAuthorizations); err != nil {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: authorization capacity: %w", err)
}
if activeAuthorizations >= domain.MaxTelegramLoginWebAuthorizations {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginAuthorizationsTooMany
}
if approval.WriteAllowed {
if _, err := tx.Exec(ctx, `
INSERT INTO bot_user_permissions (bot_user_id, user_id, from_request)
VALUES ($1,$2,true)
ON CONFLICT (bot_user_id, user_id) DO UPDATE SET
from_request = true, updated_at = now()`, request.BotUserID, identity.UserID); err != nil {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: grant bot access: %w", err)
}
}
if _, err := tx.Exec(ctx, `
UPDATE telegram_login_requests SET
status = 'approved', authorized_user_id = $2, write_allowed = $3,
phone_shared = $4, approved_at = $5, profile_name = $6, given_name = $7,
family_name = $8, preferred_username = $9, picture = $10, phone_number = $11
WHERE id = $1`, request.ID, identity.UserID, approval.WriteAllowed, approval.PhoneShared, approval.ApprovedAt,
identity.Name, identity.GivenName, identity.FamilyName, identity.PreferredUsername, identity.Picture, identity.PhoneNumber); err != nil {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: update request: %w", err)
}
web := domain.TelegramLoginWebAuthorization{
Hash: webAuthorizationHash, RequestID: request.ID, UserID: identity.UserID, BotUserID: request.BotUserID,
Domain: request.Domain, Browser: request.Browser, Platform: request.Platform, IP: request.IP, Region: request.Region,
Scopes: scopes, PhoneShared: approval.PhoneShared, BotAccessGranted: approval.WriteAllowed,
CreatedAt: approval.ApprovedAt, LastActiveAt: approval.ApprovedAt,
}
if _, err := tx.Exec(ctx, `
INSERT INTO web_authorizations (
hash, request_id, user_id, bot_user_id, domain, browser, platform, ip, region,
granted_scopes, phone_shared, bot_access_granted, created_at, last_active_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
web.Hash, web.RequestID, web.UserID, web.BotUserID, web.Domain, web.Browser, web.Platform, web.IP, web.Region,
telegramLoginScopeStrings(web.Scopes), web.PhoneShared, web.BotAccessGranted, web.CreatedAt, web.LastActiveAt); err != nil {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, mapTelegramLoginWriteError("approve telegram login request: insert web authorization", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: commit: %w", err)
}
request.Status = domain.TelegramLoginRequestApproved
request.AuthorizedUserID = identity.UserID
request.ProfileName = identity.Name
request.GivenName = identity.GivenName
request.FamilyName = identity.FamilyName
request.PreferredUsername = identity.PreferredUsername
request.Picture = identity.Picture
request.PhoneNumber = identity.PhoneNumber
request.WriteAllowed = approval.WriteAllowed
request.PhoneShared = approval.PhoneShared
request.ApprovedAt = approval.ApprovedAt
return request, web, nil
}
func grantedTelegramLoginScopesPG(request domain.TelegramLoginRequest, approval domain.TelegramLoginApproval) ([]domain.TelegramLoginScope, error) {
if approval.WriteAllowed && !request.Requests(domain.TelegramLoginScopeBotAccess) {
return nil, domain.ErrTelegramLoginScopeInvalid
}
if approval.PhoneShared && !request.Requests(domain.TelegramLoginScopePhone) {
return nil, domain.ErrTelegramLoginScopeInvalid
}
out := make([]domain.TelegramLoginScope, 0, len(request.Scopes))
for _, scope := range request.Scopes {
if scope == domain.TelegramLoginScopePhone && !approval.PhoneShared {
continue
}
if scope == domain.TelegramLoginScopeBotAccess && !approval.WriteAllowed {
continue
}
out = append(out, scope)
}
return out, nil
}
func (s *TelegramLoginStore) DeclineTelegramLoginRequest(ctx context.Context, requestID, userID int64, now time.Time) (domain.TelegramLoginRequest, error) {
if requestID <= 0 || userID <= 0 || now.IsZero() {
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.TelegramLoginRequest{}, fmt.Errorf("decline telegram login request: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.TelegramLoginRequest{}, fmt.Errorf("decline telegram login request: begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
request, err := scanTelegramLoginRequest(tx.QueryRow(ctx, `SELECT `+telegramLoginRequestColumns+` FROM telegram_login_requests WHERE id = $1 FOR UPDATE`, requestID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestInvalid
}
if err != nil {
return domain.TelegramLoginRequest{}, fmt.Errorf("decline telegram login request: lock: %w", err)
}
if request.Status != domain.TelegramLoginRequestPending {
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestConflict
}
if !now.Before(request.ExpiresAt) {
if _, err := tx.Exec(ctx, `UPDATE telegram_login_requests SET status = 'expired' WHERE id = $1`, request.ID); err != nil {
return domain.TelegramLoginRequest{}, fmt.Errorf("decline telegram login request: mark expired: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.TelegramLoginRequest{}, fmt.Errorf("decline telegram login request: commit expiry: %w", err)
}
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestExpired
}
if _, err := tx.Exec(ctx, `UPDATE telegram_login_requests SET status = 'declined', declined_at = $2 WHERE id = $1`, request.ID, now); err != nil {
return domain.TelegramLoginRequest{}, fmt.Errorf("decline telegram login request: update: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.TelegramLoginRequest{}, fmt.Errorf("decline telegram login request: commit: %w", err)
}
request.Status = domain.TelegramLoginRequestDeclined
request.DeclinedAt = now
return request, nil
}
const telegramLoginCodeColumns = `id, request_id, code_hash, sealed_code, seal_nonce, seal_key_id, issued_at, expires_at, consumed_at`
func scanTelegramLoginAuthorizationCode(row telegramLoginRowScanner) (domain.TelegramLoginAuthorizationCode, error) {
var code domain.TelegramLoginAuthorizationCode
var consumedAt sql.NullTime
if err := row.Scan(&code.ID, &code.RequestID, &code.CodeHash, &code.SealedCode, &code.SealNonce, &code.SealKeyID, &code.IssuedAt, &code.ExpiresAt, &consumedAt); err != nil {
return domain.TelegramLoginAuthorizationCode{}, err
}
if consumedAt.Valid {
code.ConsumedAt = consumedAt.Time
}
return code, nil
}
func (s *TelegramLoginStore) PutTelegramLoginAuthorizationCode(ctx context.Context, code domain.TelegramLoginAuthorizationCode) (domain.TelegramLoginAuthorizationCode, error) {
if code.RequestID <= 0 || len(code.CodeHash) != 32 || len(code.SealedCode) < 32 || len(code.SealNonce) < 12 || code.SealKeyID == "" || code.IssuedAt.IsZero() || !code.ExpiresAt.After(code.IssuedAt) {
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginCodeInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.TelegramLoginAuthorizationCode{}, fmt.Errorf("put telegram login code: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.TelegramLoginAuthorizationCode{}, fmt.Errorf("put telegram login code: begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
request, err := scanTelegramLoginRequest(tx.QueryRow(ctx, `SELECT `+telegramLoginRequestColumns+` FROM telegram_login_requests WHERE id = $1 FOR UPDATE`, code.RequestID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestInvalid
} else if err != nil {
return domain.TelegramLoginAuthorizationCode{}, fmt.Errorf("put telegram login code: lock request: %w", err)
}
if request.Status != domain.TelegramLoginRequestApproved {
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict
}
var clientEnabled bool
if err := tx.QueryRow(ctx, `SELECT enabled FROM bot_login_clients WHERE bot_user_id = $1 AND client_id = $2 AND signing_algorithm = $3 FOR UPDATE`,
request.BotUserID, request.ClientID, string(request.SigningAlgorithm)).Scan(&clientEnabled); err != nil || !clientEnabled {
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginAuthorizationCode{}, fmt.Errorf("put telegram login code: client: %w", err)
}
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginClientDisabled
}
switch request.ResponseType {
case "code":
var allowed bool
if err := tx.QueryRow(ctx, `SELECT
EXISTS(SELECT 1 FROM bot_login_allowed_urls WHERE bot_user_id = $1 AND kind = 'redirect_uri' AND normalized_url = $2)
OR ($3 = 'native' AND EXISTS(SELECT 1 FROM bot_login_native_apps WHERE bot_user_id = $1 AND callback_uri = $2 AND enabled))`,
request.BotUserID, request.RedirectURI, string(request.Source)).Scan(&allowed); err != nil {
return domain.TelegramLoginAuthorizationCode{}, fmt.Errorf("put telegram login code: redirect: %w", err)
}
if !allowed {
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRedirectNotAllowed
}
case "post_message":
var allowed bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bot_login_allowed_urls WHERE bot_user_id = $1 AND kind = 'web_origin' AND normalized_url = $2)`,
request.BotUserID, request.Origin).Scan(&allowed); err != nil {
return domain.TelegramLoginAuthorizationCode{}, fmt.Errorf("put telegram login code: origin: %w", err)
}
if !allowed {
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginOriginNotAllowed
}
default:
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict
}
web, err := scanTelegramLoginWebAuthorization(tx.QueryRow(ctx, `SELECT `+telegramLoginWebAuthorizationColumns+` FROM web_authorizations WHERE request_id = $1 FOR UPDATE`, code.RequestID))
if errors.Is(err, pgx.ErrNoRows) || !web.RevokedAt.IsZero() {
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict
}
if err != nil {
return domain.TelegramLoginAuthorizationCode{}, fmt.Errorf("put telegram login code: web authorization: %w", err)
}
existing, err := scanTelegramLoginAuthorizationCode(tx.QueryRow(ctx, `SELECT `+telegramLoginCodeColumns+` FROM telegram_login_codes WHERE request_id = $1`, code.RequestID))
if err == nil {
if err := tx.Commit(ctx); err != nil {
return domain.TelegramLoginAuthorizationCode{}, fmt.Errorf("put telegram login code: commit existing: %w", err)
}
return existing, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginAuthorizationCode{}, fmt.Errorf("put telegram login code: read existing: %w", err)
}
created, err := scanTelegramLoginAuthorizationCode(tx.QueryRow(ctx, `
INSERT INTO telegram_login_codes (
request_id, code_hash, sealed_code, seal_nonce, seal_key_id, issued_at, expires_at
) VALUES ($1,$2,$3,$4,$5,$6,$7)
RETURNING `+telegramLoginCodeColumns,
code.RequestID, code.CodeHash, code.SealedCode, code.SealNonce, code.SealKeyID, code.IssuedAt, code.ExpiresAt))
if err != nil {
return domain.TelegramLoginAuthorizationCode{}, mapTelegramLoginWriteError("put telegram login code", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.TelegramLoginAuthorizationCode{}, fmt.Errorf("put telegram login code: commit: %w", err)
}
return created, nil
}
func (s *TelegramLoginStore) GetTelegramLoginAuthorizationCodeByRequest(ctx context.Context, requestID int64) (domain.TelegramLoginAuthorizationCode, bool, error) {
code, err := scanTelegramLoginAuthorizationCode(s.db.QueryRow(ctx, `SELECT `+telegramLoginCodeColumns+` FROM telegram_login_codes WHERE request_id = $1`, requestID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginAuthorizationCode{}, false, nil
}
if err != nil {
return domain.TelegramLoginAuthorizationCode{}, false, fmt.Errorf("get telegram login code by request: %w", err)
}
return code, true, nil
}
func (s *TelegramLoginStore) GetTelegramLoginAuthorizationCodeByHash(ctx context.Context, codeHash []byte) (domain.TelegramLoginAuthorizationCode, bool, error) {
code, err := scanTelegramLoginAuthorizationCode(s.db.QueryRow(ctx, `SELECT `+telegramLoginCodeColumns+` FROM telegram_login_codes WHERE code_hash = $1`, codeHash))
if errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginAuthorizationCode{}, false, nil
}
if err != nil {
return domain.TelegramLoginAuthorizationCode{}, false, fmt.Errorf("get telegram login code by hash: %w", err)
}
return code, true, nil
}
const telegramLoginWebAuthorizationColumns = `hash, request_id, user_id, bot_user_id, domain, browser, platform, ip, region, granted_scopes, phone_shared, bot_access_granted, created_at, last_active_at, revoked_at`
func scanTelegramLoginWebAuthorization(row telegramLoginRowScanner) (domain.TelegramLoginWebAuthorization, error) {
var web domain.TelegramLoginWebAuthorization
var scopes []string
var revokedAt sql.NullTime
if err := row.Scan(&web.Hash, &web.RequestID, &web.UserID, &web.BotUserID, &web.Domain, &web.Browser, &web.Platform, &web.IP, &web.Region, &scopes, &web.PhoneShared, &web.BotAccessGranted, &web.CreatedAt, &web.LastActiveAt, &revokedAt); err != nil {
return domain.TelegramLoginWebAuthorization{}, err
}
web.Scopes = telegramLoginScopes(scopes)
if revokedAt.Valid {
web.RevokedAt = revokedAt.Time
}
return web, nil
}
func (s *TelegramLoginStore) ConsumeTelegramLoginAuthorizationCode(ctx context.Context, exchange domain.TelegramLoginCodeExchange) (domain.TelegramLoginAuthorizationCode, domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) {
if len(exchange.CodeHash) != 32 || exchange.ClientID == "" || exchange.ClientSecretVersion <= 0 || exchange.RedirectURI == "" || exchange.CodeChallenge == "" || exchange.Now.IsZero() {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login code: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login code: begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
code, err := scanTelegramLoginAuthorizationCode(tx.QueryRow(ctx, `SELECT `+telegramLoginCodeColumns+` FROM telegram_login_codes WHERE code_hash = $1 FOR UPDATE`, exchange.CodeHash))
if errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
if err != nil {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login code: lock code: %w", err)
}
if !code.ConsumedAt.IsZero() {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeConsumed
}
if !exchange.Now.Before(code.ExpiresAt) {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
request, err := scanTelegramLoginRequest(tx.QueryRow(ctx, `SELECT `+telegramLoginRequestColumns+` FROM telegram_login_requests WHERE id = $1 FOR UPDATE`, code.RequestID))
if err != nil {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login code: lock request: %w", err)
}
if request.Status != domain.TelegramLoginRequestApproved || request.ResponseType != "code" || request.ClientID != exchange.ClientID || request.RedirectURI != exchange.RedirectURI || request.CodeChallenge != exchange.CodeChallenge {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
var clientEnabled, redirectAllowed bool
var secretVersion int64
if err := tx.QueryRow(ctx, `SELECT enabled, secret_version FROM bot_login_clients WHERE client_id = $1 FOR UPDATE`, exchange.ClientID).Scan(&clientEnabled, &secretVersion); err != nil || !clientEnabled || secretVersion != exchange.ClientSecretVersion {
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login code: client: %w", err)
}
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
if err := tx.QueryRow(ctx, `SELECT
EXISTS(SELECT 1 FROM bot_login_allowed_urls WHERE bot_user_id = $1 AND kind = 'redirect_uri' AND normalized_url = $2)
OR ($3 = 'native' AND EXISTS(SELECT 1 FROM bot_login_native_apps WHERE bot_user_id = $1 AND callback_uri = $2 AND enabled))`, request.BotUserID, exchange.RedirectURI, string(request.Source)).Scan(&redirectAllowed); err != nil {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login code: redirect: %w", err)
}
if !redirectAllowed {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
web, err := scanTelegramLoginWebAuthorization(tx.QueryRow(ctx, `SELECT `+telegramLoginWebAuthorizationColumns+` FROM web_authorizations WHERE request_id = $1 FOR UPDATE`, request.ID))
if err != nil || !web.RevokedAt.IsZero() {
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login code: web authorization: %w", err)
}
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
if _, err := tx.Exec(ctx, `UPDATE telegram_login_codes SET consumed_at = $2 WHERE id = $1`, code.ID, exchange.Now); err != nil {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login code: mark consumed: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE web_authorizations SET last_active_at = $2 WHERE hash = $1`, web.Hash, exchange.Now); err != nil {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login code: touch web authorization: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login code: commit: %w", err)
}
code.ConsumedAt = exchange.Now
web.LastActiveAt = exchange.Now
return code, request, web, nil
}
func (s *TelegramLoginStore) ConsumeTelegramLoginDirectToken(ctx context.Context, tokenHash []byte, origin string, now time.Time) (domain.TelegramLoginAuthorizationCode, domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) {
if len(tokenHash) != 32 || origin == "" || now.IsZero() {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login direct token: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login direct token: begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
code, err := scanTelegramLoginAuthorizationCode(tx.QueryRow(ctx, `SELECT `+telegramLoginCodeColumns+` FROM telegram_login_codes WHERE code_hash = $1 FOR UPDATE`, tokenHash))
if errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
if err != nil {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login direct token: lock token: %w", err)
}
if !code.ConsumedAt.IsZero() {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeConsumed
}
if !now.Before(code.ExpiresAt) {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
request, err := scanTelegramLoginRequest(tx.QueryRow(ctx, `SELECT `+telegramLoginRequestColumns+` FROM telegram_login_requests WHERE id = $1 FOR UPDATE`, code.RequestID))
if err != nil {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login direct token: lock request: %w", err)
}
if request.Status != domain.TelegramLoginRequestApproved || request.Source != domain.TelegramLoginRequestMiniApp ||
request.ResponseType != "post_message" || request.Origin != origin || request.InAppOrigin != origin {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
var clientEnabled, originAllowed bool
if err := tx.QueryRow(ctx, `SELECT enabled FROM bot_login_clients WHERE bot_user_id = $1 AND client_id = $2 FOR UPDATE`, request.BotUserID, request.ClientID).Scan(&clientEnabled); err != nil || !clientEnabled {
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login direct token: client: %w", err)
}
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bot_login_allowed_urls WHERE bot_user_id = $1 AND kind = 'web_origin' AND normalized_url = $2)`, request.BotUserID, origin).Scan(&originAllowed); err != nil {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login direct token: origin: %w", err)
}
if !originAllowed {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
web, err := scanTelegramLoginWebAuthorization(tx.QueryRow(ctx, `SELECT `+telegramLoginWebAuthorizationColumns+` FROM web_authorizations WHERE request_id = $1 FOR UPDATE`, request.ID))
if err != nil || !web.RevokedAt.IsZero() {
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login direct token: web authorization: %w", err)
}
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
if _, err := tx.Exec(ctx, `UPDATE telegram_login_codes SET consumed_at = $2 WHERE id = $1`, code.ID, now); err != nil {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login direct token: mark consumed: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE web_authorizations SET last_active_at = $2 WHERE hash = $1`, web.Hash, now); err != nil {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login direct token: touch web authorization: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login direct token: commit: %w", err)
}
code.ConsumedAt = now
web.LastActiveAt = now
return code, request, web, nil
}
func (s *TelegramLoginStore) ListTelegramLoginWebAuthorizations(ctx context.Context, userID int64) ([]domain.TelegramLoginWebAuthorization, error) {
rows, err := s.db.Query(ctx, `SELECT `+telegramLoginWebAuthorizationColumns+` FROM web_authorizations WHERE user_id = $1 AND revoked_at IS NULL ORDER BY last_active_at DESC, hash DESC LIMIT $2`, userID, domain.MaxTelegramLoginWebAuthorizations)
if err != nil {
return nil, fmt.Errorf("list telegram login web authorizations: %w", err)
}
defer rows.Close()
out := make([]domain.TelegramLoginWebAuthorization, 0)
for rows.Next() {
web, err := scanTelegramLoginWebAuthorization(rows)
if err != nil {
return nil, fmt.Errorf("scan telegram login web authorization: %w", err)
}
out = append(out, web)
}
return out, rows.Err()
}
func (s *TelegramLoginStore) RevokeTelegramLoginWebAuthorization(ctx context.Context, userID, hash int64, now time.Time) (bool, error) {
tag, err := s.db.Exec(ctx, `UPDATE web_authorizations SET revoked_at = $3 WHERE user_id = $1 AND hash = $2 AND revoked_at IS NULL`, userID, hash, now)
if err != nil {
return false, fmt.Errorf("revoke telegram login web authorization: %w", err)
}
return tag.RowsAffected() > 0, nil
}
func (s *TelegramLoginStore) RevokeAllTelegramLoginWebAuthorizations(ctx context.Context, userID int64, now time.Time) (int64, error) {
tag, err := s.db.Exec(ctx, `UPDATE web_authorizations SET revoked_at = $2 WHERE user_id = $1 AND revoked_at IS NULL`, userID, now)
if err != nil {
return 0, fmt.Errorf("revoke all telegram login web authorizations: %w", err)
}
return tag.RowsAffected(), nil
}
func (s *TelegramLoginStore) DeleteExpiredTelegramLoginArtifacts(ctx context.Context, before time.Time, limit int) (int64, error) {
if limit <= 0 || limit > 1000 {
return 0, domain.ErrTelegramLoginRequestInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return 0, fmt.Errorf("delete expired telegram login artifacts: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("delete expired telegram login artifacts: begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var deletedCodes int64
if err := tx.QueryRow(ctx, `
WITH doomed AS (
SELECT id FROM telegram_login_codes
WHERE expires_at < $1 OR (consumed_at IS NOT NULL AND consumed_at < $1)
ORDER BY expires_at, id
LIMIT $2
FOR UPDATE SKIP LOCKED
), deleted AS (
DELETE FROM telegram_login_codes c USING doomed d WHERE c.id = d.id RETURNING c.id
)
SELECT count(*) FROM deleted`, before, limit).Scan(&deletedCodes); err != nil {
return 0, fmt.Errorf("delete expired telegram login codes: %w", err)
}
remaining := int64(limit) - deletedCodes
var deletedRequests int64
if remaining > 0 {
if err := tx.QueryRow(ctx, `
WITH doomed AS (
SELECT r.id FROM telegram_login_requests r
WHERE (r.status IN ('pending','declined','expired') AND r.expires_at < $1)
OR (r.status = 'approved' AND r.approved_at < $1
AND EXISTS (
SELECT 1 FROM web_authorizations w
WHERE w.request_id = r.id AND w.revoked_at < $1
)
AND NOT EXISTS (
SELECT 1 FROM telegram_login_codes c WHERE c.request_id = r.id
))
ORDER BY COALESCE(r.approved_at, r.expires_at), r.id
LIMIT $2
FOR UPDATE OF r SKIP LOCKED
), deleted AS (
DELETE FROM telegram_login_requests r USING doomed d WHERE r.id = d.id RETURNING r.id
)
SELECT count(*) FROM deleted`, before, remaining).Scan(&deletedRequests); err != nil {
return 0, fmt.Errorf("delete expired telegram login requests: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("delete expired telegram login artifacts: commit: %w", err)
}
return deletedCodes + deletedRequests, nil
}
func mapTelegramLoginWriteError(op string, err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case pgerrcode.UniqueViolation:
return fmt.Errorf("%s: %w", op, domain.ErrTelegramLoginRequestConflict)
case pgerrcode.ForeignKeyViolation, pgerrcode.CheckViolation:
return fmt.Errorf("%s: %w", op, domain.ErrTelegramLoginRequestInvalid)
}
}
return fmt.Errorf("%s: %w", op, err)
}