diff --git a/tools/telegram-bridge/internal/api/handlers.go b/tools/telegram-bridge/internal/api/handlers.go index dd2b9cda06..67db82b9fa 100644 --- a/tools/telegram-bridge/internal/api/handlers.go +++ b/tools/telegram-bridge/internal/api/handlers.go @@ -53,6 +53,8 @@ func NewRouter(cfg *config.Config, db *sql.DB) *Router { wsHub: NewWSHub(), } + r.initAuthPersistence() + // Initialize Bot API client if token is configured log.Printf("[INIT] BotToken set: %v (len=%d)", cfg.BotToken != "", len(cfg.BotToken)) log.Printf("[INIT] BotWebhookURL: %s", cfg.BotWebhookURL) @@ -213,6 +215,9 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/v1/auth/phone", r.handleAuthPhone) r.mux.HandleFunc("/api/v1/auth/code", r.handleAuthCode) r.mux.HandleFunc("/api/v1/auth/2fa", r.handleAuth2FA) + r.mux.HandleFunc("/api/v1/auth/resend", r.handleAuthResend) + r.mux.HandleFunc("/api/v1/auth/qr", r.handleAuthQR) + r.mux.HandleFunc("/api/v1/auth/qr/status", r.handleAuthQRStatus) r.mux.HandleFunc("/api/v1/me", r.handleGetMe) r.mux.HandleFunc("/api/v1/dialogs", r.handleGetDialogs) r.mux.HandleFunc("/api/v1/history/", r.handleGetHistory) @@ -368,8 +373,16 @@ func (r *Router) handleConnect(w http.ResponseWriter, req *http.Request) { // Generate session ID sessionID := generateSessionID() - // Create session storage - use gotd's FileStorage directly for proper persistence - sessionStorage := &session.FileStorage{Path: r.cfg.SessionDir + "/" + sessionID + ".session"} + // Session storage. On a container filesystem FileStorage is not + // persistence -- it is a file that dies with the container, so a redeploy + // mid-login took the session with it. Use the database when we have one + // and keep the file only as the local-development fallback. + var sessionStorage session.Storage + if r.db != nil { + sessionStorage = telegram.NewPostgresSessionStorage(r.db, sessionID) + } else { + sessionStorage = &session.FileStorage{Path: r.cfg.SessionDir + "/" + sessionID + ".session"} + } // Create Telegram client client, err := telegram.NewClient(body.AppID, body.AppHash, sessionStorage) @@ -441,14 +454,14 @@ func (r *Router) handleAuthPhone(w http.ResponseWriter, req *http.Request) { log.Printf("Sending code to phone: %s", body.Phone) // Retry logic for DC migration - Telegram may require reconnection to different data center - var codeHash string + var sent telegram.SentCode var err error maxRetries := 2 for attempt := 1; attempt <= maxRetries; attempt++ { // Use longer timeout per attempt to allow DC migration to complete ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) - codeHash, err = client.SendCode(ctx, body.Phone) + sent, err = client.SendCode(ctx, body.Phone) cancel() if err == nil { @@ -474,15 +487,243 @@ func (r *Router) handleAuthPhone(w http.ResponseWriter, req *http.Request) { return } - log.Printf("Auth phone request success: phone=%s", body.Phone) + if sent.AlreadyAuthorized { + respondJSON(w, http.StatusOK, map[string]interface{}{ + "status": "authorized", + "message": "This session is already authorized; no code was sent.", + }) + return + } + + r.savePendingAuth(sessionID, client) + + log.Printf("Auth phone success: phone=%s delivery=%s timeout=%ds next=%q", + body.Phone, sent.Type, sent.TimeoutSeconds, sent.NextType) + + // The delivery channel is reported as Telegram stated it, never guessed. + // "app" in particular is not a synonym for "check your SMS": it means the + // code went to other logged-in sessions of this number, and if there are + // none, it went nowhere the user can reach. Saying so is the difference + // between a user who waits for an SMS that will never arrive and a user + // who scans a QR code. + resp := map[string]interface{}{ + "status": "code_sent", + "code_hash": sent.CodeHash, + "delivery": sent.Type, + "can_resend": sent.CanResend(), + "message": describeDelivery(sent), + } + if sent.Length > 0 { + resp["code_length"] = sent.Length + } + if sent.TimeoutSeconds > 0 { + resp["resend_after_seconds"] = sent.TimeoutSeconds + } + if sent.NextType != "" { + resp["next_delivery"] = sent.NextType + } + if sent.Detail != "" { + resp["delivery_detail"] = sent.Detail + } + respondJSON(w, http.StatusOK, resp) +} + +// describeDelivery states, in the user's own terms, where the code went. +func describeDelivery(s telegram.SentCode) string { + switch s.Type { + case "app": + return "The code was sent to Telegram itself, in the chat named \"Telegram\", " + + "on your other logged-in devices for this number. If this number is not " + + "logged in anywhere else, no code will arrive on any channel -- use QR login instead." + case "sms": + return "The code was sent by SMS." + case "call": + return "The code will be dictated by an automated phone call." + case "missed_call": + return "You will receive a missed call; the code is the last digits of the calling number." + case "flash_call": + return "You will receive a dropped call; the code is taken from the calling number." + case "email": + return "The code was sent to the login email " + s.Detail + "." + case "fragment": + return "The code was delivered through Fragment: " + s.Detail + case "firebase_sms": + return "The code was sent by SMS via Firebase." + case "setup_email_required": + return "This account must set up a login email before a code can be sent." + default: + return "Telegram used a delivery channel this bridge does not recognise (" + s.Detail + "). " + + "Rather than guess, we are telling you so: QR login does not depend on code delivery." + } +} + +// AuthResendRequest is the request for /auth/resend +type AuthResendRequest struct { + SessionID string `json:"session_id,omitempty"` +} + +// handleAuthResend advances the code to Telegram's next delivery channel. +// +// The old "Request a new code" button called /auth/phone again. That restarts +// auth.sendCode, which is free to choose the same channel it already chose, so +// for the user who cannot receive an app code it repeated the failure with a +// fresh timer. auth.resendCode moves to the next_type Telegram itself +// nominated, and when it nominates none we say so instead of pretending. +func (r *Router) handleAuthResend(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + respondError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + var body AuthResendRequest + _ = json.NewDecoder(req.Body).Decode(&body) + + sessionID := req.Header.Get("X-Session-ID") + if sessionID == "" { + sessionID = body.SessionID + } + client := r.getClient(sessionID) + if client == nil { + respondError(w, http.StatusUnauthorized, "Invalid or missing session") + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + sent, err := client.ResendCode(ctx) + if err != nil { + log.Printf("ResendCode error for session %s: %v", sessionID, err) + respondError(w, http.StatusBadRequest, err.Error()) + return + } + + r.savePendingAuth(sessionID, client) + + resp := map[string]interface{}{ + "status": "code_resent", + "code_hash": sent.CodeHash, + "delivery": sent.Type, + "can_resend": sent.CanResend(), + "message": describeDelivery(sent), + } + if sent.TimeoutSeconds > 0 { + resp["resend_after_seconds"] = sent.TimeoutSeconds + } + if sent.NextType != "" { + resp["next_delivery"] = sent.NextType + } + respondJSON(w, http.StatusOK, resp) +} + +// AuthQRRequest is the request for the QR endpoints. +type AuthQRRequest struct { + SessionID string `json:"session_id,omitempty"` +} + +// handleAuthQR issues a tg://login token for the user to scan. +func (r *Router) handleAuthQR(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + respondError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + var body AuthQRRequest + _ = json.NewDecoder(req.Body).Decode(&body) + + sessionID := req.Header.Get("X-Session-ID") + if sessionID == "" { + sessionID = body.SessionID + } + client := r.getClient(sessionID) + if client == nil { + respondError(w, http.StatusUnauthorized, "Invalid or missing session") + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + code, err := client.ExportQR(ctx) + if err != nil { + log.Printf("ExportQR error for session %s: %v", sessionID, err) + respondError(w, http.StatusInternalServerError, "Failed to export login token: "+err.Error()) + return + } + if code == nil { + respondJSON(w, http.StatusOK, map[string]interface{}{ + "status": "authorized", + "message": "This session is already authorized.", + }) + return + } + + r.savePendingAuth(sessionID, client) respondJSON(w, http.StatusOK, map[string]interface{}{ - "status": "code_sent", - "code_hash": codeHash, - "message": "Verification code sent to phone", + "status": "qr_issued", + "url": code.URL, + "expires_at": code.ExpiresAt, + "message": "Open Telegram on the phone that holds this number, go to " + + "Settings -> Devices -> Link Desktop Device, and scan this code. " + + "Poll /api/v1/auth/qr/status until it reports authorized.", }) } +// handleAuthQRStatus reports whether the token has been scanned yet. +func (r *Router) handleAuthQRStatus(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet && req.Method != http.MethodPost { + respondError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + sessionID := req.Header.Get("X-Session-ID") + if sessionID == "" { + sessionID = req.URL.Query().Get("session_id") + } + client := r.getClient(sessionID) + if client == nil { + respondError(w, http.StatusUnauthorized, "Invalid or missing session") + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + status, err := client.PollQR(ctx) + if err != nil { + log.Printf("PollQR error for session %s: %v", sessionID, err) + respondError(w, http.StatusInternalServerError, "Failed to poll login token: "+err.Error()) + return + } + + resp := map[string]interface{}{"state": string(status.State)} + switch status.State { + case telegram.QRPending: + resp["message"] = "Not scanned yet." + case telegram.QRExpired: + resp["message"] = "The previous token expired; a fresh one is attached. The login itself is not lost." + case telegram.QRNeedsPassword: + // Identical second factor to the code path, so the client can reuse + // the same screen it already has. + resp["message"] = "Scanned. This account has two-step verification; POST the cloud password to /api/v1/auth/2fa." + case telegram.QRAuthorized: + resp["message"] = "Authorized." + if err := r.clearPendingAuth(sessionID); err != nil { + log.Printf("clear pending auth for session %s: %v", sessionID, err) + } + } + if status.Token != nil { + resp["url"] = status.Token.URL + resp["expires_at"] = status.Token.ExpiresAt + r.savePendingAuth(sessionID, client) + } + if status.User != nil { + resp["user"] = status.User + } + respondJSON(w, http.StatusOK, resp) +} + // AuthCodeRequest is the request for /auth/code type AuthCodeRequest struct { SessionID string `json:"session_id,omitempty"` @@ -537,6 +778,11 @@ func (r *Router) handleAuthCode(w http.ResponseWriter, req *http.Request) { log.Printf("Auth code request: user=%s", user.Username) + // The login is complete; the in-flight record has nothing left to protect. + if err := r.clearPendingAuth(sessionID); err != nil { + log.Printf("clear pending auth for session %s: %v", sessionID, err) + } + // Explicitly save session to disk if err := client.SaveSession(); err != nil { log.Printf("⚠️ Failed to save session after SignIn: %v", err) @@ -595,6 +841,10 @@ func (r *Router) handleAuth2FA(w http.ResponseWriter, req *http.Request) { log.Printf("Auth 2FA request: user=%s", user.Username) + if err := r.clearPendingAuth(sessionID); err != nil { + log.Printf("clear pending auth for session %s: %v", sessionID, err) + } + // Explicitly save session to disk if err := client.SaveSession(); err != nil { log.Printf("⚠️ Failed to save session after 2FA: %v", err) @@ -898,8 +1148,15 @@ func (r *Router) handleWebSocket(w http.ResponseWriter, req *http.Request) { func (r *Router) getClient(sessionID string) *telegram.Client { r.mu.RLock() - defer r.mu.RUnlock() - return r.clients[sessionID] + client := r.clients[sessionID] + r.mu.RUnlock() + if client != nil { + return client + } + // Not in memory. After a restart that is true of every session, so before + // telling the caller their session is invalid, look for a persisted + // in-flight login. This is the whole point of persisting it. + return r.restoreClient(sessionID) } func (r *Router) forwardUpdates(sessionID string, client *telegram.Client) { diff --git a/tools/telegram-bridge/internal/api/pending_auth.go b/tools/telegram-bridge/internal/api/pending_auth.go new file mode 100644 index 0000000000..520eb59ee8 --- /dev/null +++ b/tools/telegram-bridge/internal/api/pending_auth.go @@ -0,0 +1,163 @@ +package api + +import ( + "context" + "database/sql" + "encoding/json" + "log" + "time" + + "github.com/vibee/telegram-bridge/internal/telegram" +) + +// An unfinished login used to live entirely in this process: the *telegram.Client +// in r.clients, and inside it the phone and code hash. A redeploy between "enter +// your phone" and "enter the code" -- which on Railway is any push to main -- +// dropped both. The user then typed a perfectly valid code into a bridge that no +// longer knew which login it belonged to, and got "Invalid or missing session" +// with no explanation, or worse, a generic failure that looked like Telegram's +// fault. +// +// What follows moves the in-flight part out of memory. The session blob itself +// already had a home in telegram_sessions; it simply was not being used, because +// handleConnect hard-coded gotd's FileStorage against an ephemeral container +// filesystem. + +const pendingAuthSchema = ` +CREATE TABLE IF NOT EXISTS telegram_pending_auth ( + session_id TEXT PRIMARY KEY, + app_id INTEGER NOT NULL, + app_hash TEXT NOT NULL, + flow JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE TABLE IF NOT EXISTS telegram_sessions ( + phone TEXT PRIMARY KEY, + session_data BYTEA, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +);` + +// initAuthPersistence creates the tables the login flow needs. +// +// telegram_sessions is included because PostgresSessionStorage referenced it +// while nothing in the repository ever created it -- it was dead code aimed at +// a table that did not exist. +func (r *Router) initAuthPersistence() { + if r.db == nil { + log.Printf("[AUTH] No database configured: in-flight logins will NOT survive a restart") + return + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if _, err := r.db.ExecContext(ctx, pendingAuthSchema); err != nil { + log.Printf("[AUTH] Failed to create auth persistence tables: %v", err) + } +} + +// savePendingAuth records the unfinished login. Best effort by design: a +// database hiccup must not fail a login that is otherwise proceeding, so it is +// logged rather than returned. +func (r *Router) savePendingAuth(sessionID string, client *telegram.Client) { + if r.db == nil || sessionID == "" { + return + } + + appID, appHash := client.Credentials() + flow, err := json.Marshal(client.ExportPendingAuth()) + if err != nil { + log.Printf("[AUTH] marshal pending auth for %s: %v", sessionID, err) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err = r.db.ExecContext(ctx, ` + INSERT INTO telegram_pending_auth (session_id, app_id, app_hash, flow) + VALUES ($1, $2, $3, $4) + ON CONFLICT (session_id) DO UPDATE SET + app_id = EXCLUDED.app_id, + app_hash = EXCLUDED.app_hash, + flow = EXCLUDED.flow, + updated_at = NOW() + `, sessionID, appID, appHash, flow) + if err != nil { + log.Printf("[AUTH] save pending auth for %s: %v", sessionID, err) + } +} + +// clearPendingAuth drops the record once the login has completed. +func (r *Router) clearPendingAuth(sessionID string) error { + if r.db == nil || sessionID == "" { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := r.db.ExecContext(ctx, + "DELETE FROM telegram_pending_auth WHERE session_id = $1", sessionID) + return err +} + +// restoreClient rebuilds a client for a session this process has never seen, +// which after a restart is every session. +// +// It returns nil when there is nothing to restore -- an unknown session id is +// still an unknown session id, and this must not manufacture one. +func (r *Router) restoreClient(sessionID string) *telegram.Client { + if r.db == nil || sessionID == "" { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var appID int + var appHash string + var flowJSON []byte + err := r.db.QueryRowContext(ctx, ` + SELECT app_id, app_hash, flow FROM telegram_pending_auth WHERE session_id = $1 + `, sessionID).Scan(&appID, &appHash, &flowJSON) + if err == sql.ErrNoRows { + return nil + } + if err != nil { + log.Printf("[AUTH] restore pending auth for %s: %v", sessionID, err) + return nil + } + + var pending telegram.PendingAuth + if err := json.Unmarshal(flowJSON, &pending); err != nil { + log.Printf("[AUTH] unmarshal pending auth for %s: %v", sessionID, err) + return nil + } + + storage := telegram.NewPostgresSessionStorage(r.db, sessionID) + client, err := telegram.NewClient(appID, appHash, storage) + if err != nil { + log.Printf("[AUTH] rebuild client for %s: %v", sessionID, err) + return nil + } + if err := client.Connect(context.Background()); err != nil { + log.Printf("[AUTH] reconnect restored client for %s: %v", sessionID, err) + return nil + } + client.RestorePendingAuth(pending) + + r.mu.Lock() + // Another request may have restored the same session while this one was + // connecting; the first one to register wins and this copy is discarded. + if existing, ok := r.clients[sessionID]; ok { + r.mu.Unlock() + return existing + } + r.clients[sessionID] = client + r.mu.Unlock() + + go r.forwardUpdates(sessionID, client) + + log.Printf("[AUTH] Restored in-flight login for session %s after restart (phone=%s, delivery=%s)", + sessionID, pending.Phone, pending.CodeType) + return client +} diff --git a/tools/telegram-bridge/internal/telegram/auth_code.go b/tools/telegram-bridge/internal/telegram/auth_code.go new file mode 100644 index 0000000000..8204df3b00 --- /dev/null +++ b/tools/telegram-bridge/internal/telegram/auth_code.go @@ -0,0 +1,211 @@ +package telegram + +import ( + "context" + "fmt" + "time" + + "github.com/gotd/td/tg" +) + +// SentCode describes how Telegram actually delivered a login code. +// +// The UI used to guess between "app" and "SMS". It has no business guessing: +// auth.sendCode says which channel it used, how long the code is, how long the +// caller must wait before asking for another, and what the next channel would +// be. All four are reported here so the screen can state a fact instead of a +// hopeful default. +type SentCode struct { + CodeHash string `json:"code_hash"` + // Type is the channel the code went to: app, sms, call, missed_call, + // flash_call, email, fragment, firebase_sms, or setup_email_required. + Type string `json:"type"` + // Length is the number of digits, where the channel defines one. + Length int `json:"length,omitempty"` + // TimeoutSeconds is Telegram's own timeout before a resend is allowed. + // Zero means Telegram sent none, and the caller must not invent one. + TimeoutSeconds int `json:"timeout_seconds,omitempty"` + // NextType is the channel a resend would use, empty if Telegram did not + // offer one -- in which case there is nothing to resend to. + NextType string `json:"next_type,omitempty"` + // Detail carries channel-specific information the user needs: the + // Fragment URL, the flash-call pattern, the missed-call prefix, the + // email pattern. + Detail string `json:"detail,omitempty"` + // AlreadyAuthorized is set when Telegram short-circuited the flow. + AlreadyAuthorized bool `json:"already_authorized,omitempty"` +} + +// CanResend reports whether Telegram offered a next delivery channel. +func (s SentCode) CanResend() bool { return s.NextType != "" } + +// describeSentCodeType maps a tg.AuthSentCodeTypeClass onto the wire names and +// the human-visible detail. Every branch is named; an unknown type is reported +// as unknown rather than silently rendered as "app". +func describeSentCodeType(t tg.AuthSentCodeTypeClass) (kind string, length int, detail string) { + switch v := t.(type) { + case *tg.AuthSentCodeTypeApp: + return "app", v.Length, "sent to another logged-in Telegram session on this number" + case *tg.AuthSentCodeTypeSMS: + return "sms", v.Length, "" + case *tg.AuthSentCodeTypeCall: + return "call", v.Length, "dictated by an automated call" + case *tg.AuthSentCodeTypeFlashCall: + return "flash_call", 0, v.Pattern + case *tg.AuthSentCodeTypeMissedCall: + return "missed_call", v.Length, "last digits of the calling number, prefix " + v.Prefix + case *tg.AuthSentCodeTypeEmailCode: + return "email", v.Length, v.EmailPattern + case *tg.AuthSentCodeTypeSetUpEmailRequired: + return "setup_email_required", 0, "the account must set up a login email before a code can be sent" + case *tg.AuthSentCodeTypeFragmentSMS: + return "fragment", v.Length, v.URL + case *tg.AuthSentCodeTypeFirebaseSMS: + return "firebase_sms", v.Length, "" + default: + return "unknown", 0, fmt.Sprintf("%T", t) + } +} + +// describeNextType maps the optional next-delivery hint. +func describeNextType(t tg.AuthCodeTypeClass) string { + switch t.(type) { + case *tg.AuthCodeTypeSMS: + return "sms" + case *tg.AuthCodeTypeCall: + return "call" + case *tg.AuthCodeTypeFlashCall: + return "flash_call" + case *tg.AuthCodeTypeMissedCall: + return "missed_call" + case *tg.AuthCodeTypeFragmentSMS: + return "fragment" + default: + return "" + } +} + +// readSentCode turns the raw auth.sentCode into a SentCode and records the +// parts of it the resend path needs. +func (c *Client) readSentCode(sentCode tg.AuthSentCodeClass) SentCode { + switch v := sentCode.(type) { + case *tg.AuthSentCode: + kind, length, detail := describeSentCodeType(v.Type) + + next := "" + if nt, ok := v.GetNextType(); ok { + next = describeNextType(nt) + } + timeout, _ := v.GetTimeout() + + c.mu.Lock() + c.authFlow.codeHash = v.PhoneCodeHash + c.authFlow.codeType = kind + c.authFlow.nextType = next + c.authFlow.timeout = timeout + c.authFlow.sentAt = time.Now() + c.mu.Unlock() + + return SentCode{ + CodeHash: v.PhoneCodeHash, + Type: kind, + Length: length, + TimeoutSeconds: timeout, + NextType: next, + Detail: detail, + } + + case *tg.AuthSentCodeSuccess: + c.mu.Lock() + c.isAuthed = true + c.mu.Unlock() + return SentCode{AlreadyAuthorized: true} + } + + return SentCode{Type: "unknown"} +} + +// ResendCode asks Telegram to deliver the code over its next channel. +// +// This is not the same call as repeating SendCode. auth.sendCode starts the +// flow again and Telegram is free to pick the same channel it already used, so +// a "request a new code" button wired to it changes nothing for a user who has +// no other session to receive an app code. auth.resendCode advances to the +// next_type Telegram itself nominated, which is the only way the channel +// actually changes. +func (c *Client) ResendCode(ctx context.Context) (SentCode, error) { + c.mu.RLock() + phone := c.authFlow.phone + codeHash := c.authFlow.codeHash + next := c.authFlow.nextType + timeout := c.authFlow.timeout + sentAt := c.authFlow.sentAt + c.mu.RUnlock() + + if phone == "" || codeHash == "" { + return SentCode{}, fmt.Errorf("send code first") + } + if next == "" { + return SentCode{}, fmt.Errorf("telegram offered no next delivery channel for this number; a resend would change nothing -- use QR login instead") + } + if timeout > 0 { + if wait := time.Duration(timeout)*time.Second - time.Since(sentAt); wait > 0 { + return SentCode{}, fmt.Errorf("resend not permitted for another %d seconds", int(wait.Seconds())+1) + } + } + + sentCode, err := c.api.API().AuthResendCode(ctx, &tg.AuthResendCodeRequest{ + PhoneNumber: phone, + PhoneCodeHash: codeHash, + }) + if err != nil { + return SentCode{}, fmt.Errorf("resend code: %w", err) + } + + return c.readSentCode(sentCode), nil +} + +// PendingAuth is the part of an unfinished login that must outlive the +// process. Keeping it only in memory meant a redeploy between "enter phone" +// and "enter code" silently lost the flow and the user's code stopped working +// with no explanation. +type PendingAuth struct { + Phone string `json:"phone"` + CodeHash string `json:"code_hash"` + CodeType string `json:"code_type"` + NextType string `json:"next_type"` + Timeout int `json:"timeout"` + SentAt time.Time `json:"sent_at"` + QRToken string `json:"qr_token,omitempty"` + QRExpires time.Time `json:"qr_expires,omitempty"` +} + +// ExportPendingAuth snapshots the unfinished login for persistence. +func (c *Client) ExportPendingAuth() PendingAuth { + c.mu.RLock() + defer c.mu.RUnlock() + return PendingAuth{ + Phone: c.authFlow.phone, + CodeHash: c.authFlow.codeHash, + CodeType: c.authFlow.codeType, + NextType: c.authFlow.nextType, + Timeout: c.authFlow.timeout, + SentAt: c.authFlow.sentAt, + QRToken: c.authFlow.qrToken, + QRExpires: c.authFlow.qrExpires, + } +} + +// RestorePendingAuth reinstates an unfinished login after a restart. +func (c *Client) RestorePendingAuth(p PendingAuth) { + c.mu.Lock() + defer c.mu.Unlock() + c.authFlow.phone = p.Phone + c.authFlow.codeHash = p.CodeHash + c.authFlow.codeType = p.CodeType + c.authFlow.nextType = p.NextType + c.authFlow.timeout = p.Timeout + c.authFlow.sentAt = p.SentAt + c.authFlow.qrToken = p.QRToken + c.authFlow.qrExpires = p.QRExpires +} diff --git a/tools/telegram-bridge/internal/telegram/auth_code_test.go b/tools/telegram-bridge/internal/telegram/auth_code_test.go new file mode 100644 index 0000000000..efa4964141 --- /dev/null +++ b/tools/telegram-bridge/internal/telegram/auth_code_test.go @@ -0,0 +1,113 @@ +package telegram + +import ( + "testing" + + "github.com/gotd/td/tg" +) + +// The defect these guard: the bridge told every user "app/SMS" regardless of +// what Telegram said, so a user whose code went to a set of zero logged-in +// sessions was advised to wait for an SMS that could never arrive. +func TestDescribeSentCodeType(t *testing.T) { + cases := []struct { + name string + in tg.AuthSentCodeTypeClass + wantKind string + wantLen int + }{ + {"app", &tg.AuthSentCodeTypeApp{Length: 5}, "app", 5}, + {"sms", &tg.AuthSentCodeTypeSMS{Length: 5}, "sms", 5}, + {"call", &tg.AuthSentCodeTypeCall{Length: 6}, "call", 6}, + {"missed", &tg.AuthSentCodeTypeMissedCall{Prefix: "+79", Length: 4}, "missed_call", 4}, + {"email", &tg.AuthSentCodeTypeEmailCode{EmailPattern: "d*@e*.com", Length: 6}, "email", 6}, + {"fragment", &tg.AuthSentCodeTypeFragmentSMS{URL: "https://fragment.com/x", Length: 6}, "fragment", 6}, + {"firebase", &tg.AuthSentCodeTypeFirebaseSMS{Length: 6}, "firebase_sms", 6}, + {"setup_email", &tg.AuthSentCodeTypeSetUpEmailRequired{}, "setup_email_required", 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + kind, length, _ := describeSentCodeType(c.in) + if kind != c.wantKind { + t.Errorf("kind = %q, want %q", kind, c.wantKind) + } + if length != c.wantLen { + t.Errorf("length = %d, want %d", length, c.wantLen) + } + }) + } +} + +// An unrecognised type must say so rather than fall back to "app". Reporting +// an unknown channel as the one channel that silently delivers nowhere is the +// exact failure being fixed. +func TestDescribeSentCodeTypeUnknownIsNotApp(t *testing.T) { + kind, _, detail := describeSentCodeType(&tg.AuthSentCodeTypeSMSWord{}) + if kind != "unknown" { + t.Fatalf("kind = %q, want %q", kind, "unknown") + } + if detail == "" { + t.Error("unknown type reported no detail; the caller has nothing to show") + } +} + +func TestDescribeNextType(t *testing.T) { + if got := describeNextType(&tg.AuthCodeTypeSMS{}); got != "sms" { + t.Errorf("sms next type = %q", got) + } + if got := describeNextType(&tg.AuthCodeTypeCall{}); got != "call" { + t.Errorf("call next type = %q", got) + } +} + +// CanResend is what the UI uses to decide whether to offer the button at all. +// Offering it when Telegram nominated no next channel is how the old flow +// promised the user something it could not deliver. +func TestCanResend(t *testing.T) { + if (SentCode{NextType: "sms"}).CanResend() != true { + t.Error("a nominated next channel should be resendable") + } + if (SentCode{}).CanResend() != false { + t.Error("no next channel means no resend; the button must not be offered") + } +} + +// A resend with no next channel must fail loudly instead of repeating +// auth.sendCode and appearing to work. +func TestResendCodeRefusesWithoutNextChannel(t *testing.T) { + c := &Client{authFlow: &authFlow{}} + c.authFlow.phone = "+79990000000" + c.authFlow.codeHash = "hash" + // nextType deliberately empty. + + if _, err := c.ResendCode(t.Context()); err == nil { + t.Fatal("expected refusal when Telegram nominated no next channel") + } +} + +func TestResendCodeRequiresSendCodeFirst(t *testing.T) { + c := &Client{authFlow: &authFlow{}} + if _, err := c.ResendCode(t.Context()); err == nil { + t.Fatal("expected refusal when no code has been sent") + } +} + +func TestPendingAuthRoundTrip(t *testing.T) { + c := &Client{authFlow: &authFlow{}} + in := PendingAuth{ + Phone: "+79990000000", + CodeHash: "hash", + CodeType: "app", + NextType: "sms", + Timeout: 60, + QRToken: "tok", + } + c.RestorePendingAuth(in) + out := c.ExportPendingAuth() + + if out.Phone != in.Phone || out.CodeHash != in.CodeHash || + out.CodeType != in.CodeType || out.NextType != in.NextType || + out.Timeout != in.Timeout || out.QRToken != in.QRToken { + t.Errorf("round trip lost data: got %+v, want %+v", out, in) + } +} diff --git a/tools/telegram-bridge/internal/telegram/auth_qr.go b/tools/telegram-bridge/internal/telegram/auth_qr.go new file mode 100644 index 0000000000..03dd2a4e2f --- /dev/null +++ b/tools/telegram-bridge/internal/telegram/auth_qr.go @@ -0,0 +1,170 @@ +package telegram + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/gotd/td/telegram/auth/qrlogin" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" +) + +// QR login is the second route into an account, and for some users it is the +// only one. +// +// sentCodeTypeApp delivers the login code to other logged-in sessions of the +// same number. A user whose only session is the one they are trying to create +// has nowhere to receive it, and no amount of resending changes that: the code +// is not lost, it was delivered to a set of zero devices. auth.exportLoginToken +// does not depend on code delivery at all -- the already-logged-in phone scans +// a token and confirms. That is why this path exists. + +// QRCode is the token a client turns into a scannable image. +type QRCode struct { + // URL is the tg://login?token=... link. Render it as a QR code; the + // Telegram app's "Link Desktop Device" scanner reads it. + URL string `json:"url"` + // ExpiresAt is when Telegram stops accepting this token. Past it, call + // ExportQR again -- the login itself is not lost, only the token. + ExpiresAt time.Time `json:"expires_at"` +} + +// QRState is the outcome of one poll of the QR login. +type QRState string + +const ( + // QRPending means the token has not been scanned yet. + QRPending QRState = "pending" + // QRExpired means the token timed out; export a fresh one. + QRExpired QRState = "expired" + // QRNeedsPassword means the token was accepted and the account has 2FA. + // The cloud password must now be supplied via Check2FA -- exactly the + // same second factor as the code path. + QRNeedsPassword QRState = "needs_password" + // QRAuthorized means the login is complete. + QRAuthorized QRState = "authorized" +) + +// QRStatus is a poll result. +type QRStatus struct { + State QRState `json:"state"` + // Token is set when a fresh token was issued during this poll, either + // because the previous one expired or because Telegram rotated it. + Token *QRCode `json:"token,omitempty"` + User *User `json:"user,omitempty"` +} + +// qr builds the login helper, with DC migration wired to the client's own +// connection so a token issued on the wrong DC does not dead-end. +func (c *Client) qr() qrlogin.QR { + return qrlogin.NewQR(c.api.API(), c.appID, c.appHash, qrlogin.Options{ + Migrate: c.api.MigrateTo, + }) +} + +// ExportQR issues a login token and records it against the pending flow. +func (c *Client) ExportQR(ctx context.Context) (*QRCode, error) { + token, err := c.qr().Export(ctx) + if err != nil { + return nil, fmt.Errorf("export login token: %w", err) + } + if token.Empty() { + // AuthLoginTokenSuccess: a previous token was already accepted. + c.mu.Lock() + c.isAuthed = true + c.mu.Unlock() + return nil, nil + } + + code := &QRCode{URL: token.URL(), ExpiresAt: token.Expires()} + + c.mu.Lock() + c.authFlow.qrToken = token.String() + c.authFlow.qrExpires = token.Expires() + c.mu.Unlock() + + return code, nil +} + +// PollQR asks Telegram whether the token has been scanned. +// +// One call, one honest answer: still waiting, expired and here is a new one, +// scanned but the account wants its cloud password, or done. The caller polls +// this rather than holding a socket open, so a redeploy between the scan and +// the confirmation does not lose the flow -- the token lives in the persisted +// pending-auth record, not in a goroutine. +func (c *Client) PollQR(ctx context.Context) (*QRStatus, error) { + auth, err := c.qr().Import(ctx) + if err == nil { + user, uerr := c.acceptAuthorization(auth) + if uerr != nil { + return nil, uerr + } + return &QRStatus{State: QRAuthorized, User: user}, nil + } + + // The account has 2FA. The token was accepted; only the second factor + // is outstanding, and it is the same Check2FA the code path uses. + if tgerr.Is(err, "SESSION_PASSWORD_NEEDED") { + return &QRStatus{State: QRNeedsPassword}, nil + } + + // Import re-exports the token to learn its state. An unscanned token + // comes back as auth.loginToken, which Import cannot use and reports as + // an unexpected type -- that is "not scanned yet", not a failure. + if isUnscannedToken(err) { + c.mu.RLock() + expires := c.authFlow.qrExpires + c.mu.RUnlock() + if !expires.IsZero() && time.Now().After(expires) { + fresh, ferr := c.ExportQR(ctx) + if ferr != nil { + return nil, ferr + } + return &QRStatus{State: QRExpired, Token: fresh}, nil + } + return &QRStatus{State: QRPending}, nil + } + + if tgerr.Is(err, "AUTH_TOKEN_EXPIRED") { + fresh, ferr := c.ExportQR(ctx) + if ferr != nil { + return nil, ferr + } + return &QRStatus{State: QRExpired, Token: fresh}, nil + } + + return nil, fmt.Errorf("poll qr login: %w", err) +} + +// isUnscannedToken reports whether the error is Import's complaint about being +// handed a still-pending auth.loginToken. +func isUnscannedToken(err error) bool { + return err != nil && strings.Contains(err.Error(), "unexpected type *tg.AuthLoginToken") +} + +// acceptAuthorization records a completed login and returns the user. +func (c *Client) acceptAuthorization(auth *tg.AuthAuthorization) (*User, error) { + user, ok := auth.User.(*tg.User) + if !ok { + return nil, fmt.Errorf("unexpected user type %T", auth.User) + } + + c.mu.Lock() + c.isAuthed = true + c.mu.Unlock() + + if err := c.SaveSession(); err != nil { + return nil, fmt.Errorf("save session after qr login: %w", err) + } + + return &User{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Username: user.Username, + Phone: user.Phone, + }, nil +} diff --git a/tools/telegram-bridge/internal/telegram/client.go b/tools/telegram-bridge/internal/telegram/client.go index 8dd140dcfa..03d89d86f6 100644 --- a/tools/telegram-bridge/internal/telegram/client.go +++ b/tools/telegram-bridge/internal/telegram/client.go @@ -215,7 +215,18 @@ func (c *Client) Connect(ctx context.Context) error { } // SendCode sends authentication code to phone -func (c *Client) SendCode(ctx context.Context, phone string) (string, error) { +// Credentials returns the API credentials this client was built with, so a +// restored session can be rebuilt with the same ones. +func (c *Client) Credentials() (int, string) { + return c.appID, c.appHash +} + +// SendCode starts the login flow and reports how Telegram delivered the code. +// +// The whole auth.sentCode response is kept, not just the hash: the delivery +// channel, the code length, Telegram's own resend timeout and the channel a +// resend would use. Discarding those is what forced the UI to guess. +func (c *Client) SendCode(ctx context.Context, phone string) (SentCode, error) { c.mu.Lock() c.authFlow.phone = phone c.mu.Unlock() @@ -228,27 +239,10 @@ func (c *Client) SendCode(ctx context.Context, phone string) (string, error) { Settings: tg.CodeSettings{}, }) if err != nil { - return "", fmt.Errorf("send code: %w", err) - } - - // Extract phone code hash based on type - var codeHash string - switch v := sentCode.(type) { - case *tg.AuthSentCode: - codeHash = v.PhoneCodeHash - case *tg.AuthSentCodeSuccess: - // Already authorized - c.mu.Lock() - c.isAuthed = true - c.mu.Unlock() - return "", nil + return SentCode{}, fmt.Errorf("send code: %w", err) } - c.mu.Lock() - c.authFlow.codeHash = codeHash - c.mu.Unlock() - - return codeHash, nil + return c.readSentCode(sentCode), nil } // SignIn verifies code and signs in @@ -1189,6 +1183,18 @@ func (c *Client) extractChatID(peer tg.PeerClass) int64 { type authFlow struct { phone string codeHash string + // codeType, nextType and timeout are what Telegram actually said about + // the delivery. They used to be discarded, which is why the UI guessed + // "app/SMS" and why the resend timer was invented locally instead of + // being read from the response. + codeType string + nextType string + timeout int + sentAt time.Time + // qrToken is the exported login token for the QR path, base64url as it + // appears in the tg://login?token=... URL. + qrToken string + qrExpires time.Time } // computeSRP computes SRP parameters for 2FA using gotd/td crypto/srp package