diff --git a/.env.example b/.env.example index c706c601..35abb6c8 100644 --- a/.env.example +++ b/.env.example @@ -164,7 +164,9 @@ TELESRV_PUBLIC_LINK_WEB_ADDR=127.0.0.1:2401 # have been generated with `go run ./cmd/telegramloginkeygen -mode init`. TELESRV_TELEGRAM_LOGIN_ENABLE=false TELESRV_TELEGRAM_LOGIN_ISSUER=https://telesrv.net -TELESRV_TELEGRAM_LOGIN_ALLOW_LOOPBACK_HTTP=false +# Set true to permit an HTTP issuer and HTTP registered origins/redirect URIs +# on any hostname or IP address. HTTPS remains the default when false. +TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=false TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE=data/telegram-login/signing-keys.json TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE=data/telegram-login/code-keys.json TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE=data/telegram-login/client-secret-pepper diff --git a/cmd/bots/aiogramecho/README.md b/cmd/bots/aiogramecho/README.md index 0febb3a7..5c06a384 100644 --- a/cmd/bots/aiogramecho/README.md +++ b/cmd/bots/aiogramecho/README.md @@ -56,10 +56,10 @@ python .\cmd\bots\aiogramecho\echo.py ` ## Webhook 模式 telesrv 现在会持久化 webhook 配置,通过跨实例租约投递,并且只在目标返回 2xx -后推进 `update_id`。aiogram 可监听本机 HTTP,由 Caddy/Nginx/Tunnel 提供公网 HTTPS: +后推进 `update_id`。aiogram 可直接登记 HTTP/HTTPS 域名或 IP,也可以由 Caddy/Nginx/Tunnel 提供公网 HTTPS: ```powershell -$env:TELESRV_BOT_WEBHOOK_URL = "https://bot.example.com/webhook" +$env:TELESRV_BOT_WEBHOOK_URL = "http://192.0.2.25:8080/webhook" $env:TELESRV_BOT_WEBHOOK_SECRET = "replace_with_a_random_secret" python .\cmd\bots\aiogramecho\echo.py ` --mode webhook ` @@ -69,8 +69,8 @@ python .\cmd\bots\aiogramecho\echo.py ` --drop-pending ``` -公网 URL 必须是 HTTPS,端口限 Telegram 标准的 443/80/88/8443;本机监听地址 -可以是 HTTP,因为 TLS 通常在反向代理终止。`secret_token` 会由 telesrv 放入 +Webhook URL 可使用任意合法 HTTP/HTTPS 域名或 IP 及 `1..65535` 端口;本机监听地址 +也可以直接使用 HTTP。`secret_token` 会由 telesrv 放入 `X-Telegram-Bot-Api-Secret-Token`,aiogram 会自动校验。若希望进程退出时删除配置, 再加 `--delete-webhook-on-exit`;默认保留配置,以免普通重启造成更新丢窗。 diff --git a/cmd/bots/aiogramecho/echo.py b/cmd/bots/aiogramecho/echo.py index 78b0d172..400786cc 100644 --- a/cmd/bots/aiogramecho/echo.py +++ b/cmd/bots/aiogramecho/echo.py @@ -52,7 +52,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--webhook-url", default=os.getenv("TELESRV_BOT_WEBHOOK_URL", ""), - help="Public HTTPS URL including the webhook path", + help="Public HTTP(S) URL including the webhook path", ) parser.add_argument( "--webhook-path", diff --git a/cmd/bots/bedolagaformat/README.md b/cmd/bots/bedolagaformat/README.md index f7163360..ef46c504 100644 --- a/cmd/bots/bedolagaformat/README.md +++ b/cmd/bots/bedolagaformat/README.md @@ -89,8 +89,8 @@ enable `/setlogin` 首次创建 client 时只展示一次 OIDC Client Secret;不要写进仓库。可用 `/logininfo` 查看 Client ID 和登记结果,或用 `/resetloginsecret` 轮换 secret。 -loopback HTTP 仅应配合 telesrv 的显式开发开关使用;测试部署/生产必须换成精确 -HTTPS origin。 +使用 HTTP 域名/IP 时,在 telesrv 配置 `TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=true`; +demo 会接受任意合法 HTTP(S) issuer/public origin,不再限制为 loopback。 把一次性 secret 和 Client ID 放入进程环境,再启动: diff --git a/cmd/bots/bedolagaformat/login_demo.py b/cmd/bots/bedolagaformat/login_demo.py index c0cae522..185cd165 100644 --- a/cmd/bots/bedolagaformat/login_demo.py +++ b/cmd/bots/bedolagaformat/login_demo.py @@ -68,10 +68,6 @@ class PendingFlow: code_verifier: str = "" -def _is_loopback(host: str | None) -> bool: - return host in {"127.0.0.1", "::1", "localhost"} - - def normalize_web_base(value: str, *, name: str) -> str: raw = value.strip().rstrip("/") parsed = urlsplit(raw) @@ -85,8 +81,6 @@ def normalize_web_base(value: str, *, name: str) -> str: or parsed.path not in {"", "/"} ): raise ValueError(f"{name} must be an absolute origin without path, query, or fragment") - if parsed.scheme != "https" and not _is_loopback(parsed.hostname): - raise ValueError(f"{name} must use HTTPS except on loopback") return f"{parsed.scheme}://{parsed.netloc}" diff --git a/cmd/bots/bedolagaformat/test_login_demo.py b/cmd/bots/bedolagaformat/test_login_demo.py index 7ba9bb54..cd136f70 100644 --- a/cmd/bots/bedolagaformat/test_login_demo.py +++ b/cmd/bots/bedolagaformat/test_login_demo.py @@ -49,8 +49,14 @@ class LoginDemoHelpersTest(unittest.TestCase): demo.normalize_web_base("http://127.0.0.1:3000", name="RP"), "http://127.0.0.1:3000", ) - with self.assertRaises(ValueError): - demo.normalize_web_base("http://rp.example", name="RP") + self.assertEqual( + demo.normalize_web_base("http://192.0.2.25:3000", name="RP"), + "http://192.0.2.25:3000", + ) + self.assertEqual( + demo.normalize_web_base("http://rp.example:18080", name="RP"), + "http://rp.example:18080", + ) with self.assertRaises(ValueError): demo.normalize_web_base("https://rp.example/callback", name="RP") self.assertEqual(demo.parse_listen("127.0.0.1:3000"), ("127.0.0.1", 3000)) diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index b078525d..d5efb734 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -366,7 +366,7 @@ func run(logger *zap.Logger) error { } telegramLoginService, err = telegramloginapp.NewService(postgres.NewTelegramLoginStore(pool), codeSealer, telegramloginapp.Config{ Issuer: cfg.TelegramLoginIssuer, AppScheme: cfg.PublicAppScheme, - AllowLoopbackHTTP: cfg.TelegramLoginAllowLoopbackHTTP, + AllowHTTP: cfg.TelegramLoginAllowHTTP, ClientSecretPepper: clientSecretPepper, SupportedSigningAlgorithms: signingKeys.ActiveAlgorithms(), RequestTTL: cfg.TelegramLoginRequestTTL, CodeTTL: cfg.TelegramLoginCodeTTL, @@ -375,7 +375,7 @@ func run(logger *zap.Logger) error { return fmt.Errorf("initialize telegram login service: %w", err) } telegramLoginIDTokens, err = telegramloginapp.NewIDTokenIssuer(signingKeys, telegramloginapp.IDTokenIssuerConfig{ - Issuer: cfg.TelegramLoginIssuer, TTL: cfg.TelegramLoginIDTokenTTL, + Issuer: cfg.TelegramLoginIssuer, TTL: cfg.TelegramLoginIDTokenTTL, AllowHTTP: cfg.TelegramLoginAllowHTTP, }) if err != nil { return fmt.Errorf("initialize telegram login ID-token issuer: %w", err) @@ -393,7 +393,7 @@ func run(logger *zap.Logger) error { Service: telegramLoginService, Tokens: telegramLoginIDTokens, Limiter: redisstore.NewRateLimiter(rdb), AppName: cfg.PublicAppName, Logger: logger.Named("telegram-login-http"), TrustedProxyCIDRs: cfg.TelegramLoginTrustedProxyCIDRs, - AllowLoopbackHTTP: cfg.TelegramLoginAllowLoopbackHTTP, + AllowHTTP: cfg.TelegramLoginAllowHTTP, }) if err != nil { return fmt.Errorf("initialize telegram login HTTP provider: %w", err) diff --git a/docs/configuration.en.md b/docs/configuration.en.md index 20f35c0b..aaa23531 100644 --- a/docs/configuration.en.md +++ b/docs/configuration.en.md @@ -51,7 +51,7 @@ This document describes every setting loaded by `internal/config`. Defaults and | Setting | Type / code default | Description and constraints | |---|---|---| | `TELESRV_DEBUG_ADDR` | nullable address / `127.0.0.1:6060` | pprof/debug listener. Empty disables it. Keep loopback-only; use an SSH tunnel for production profiling. | -| `TELESRV_BOT_API_ADDR` | nullable address / empty | Minimal HTTP Bot API listener. Empty disables it. It shares MTProto app/store facts. | +| `TELESRV_BOT_API_ADDR` | nullable address / empty | Minimal HTTP Bot API listener. Empty disables it. It shares MTProto app/store facts. `setWebhook` accepts any valid `http://` or `https://` host/IP and port in `1..65535`. | | `TELESRV_ADMIN_API_ADDR` | nullable address / empty | In-process Admin write API listener. Empty disables it; production should bind loopback. | | `TELESRV_ADMIN_API_TOKEN` | secret string / empty | Admin API bearer token. Required when the Admin API is enabled and must match the Admin UI token configuration. | | `TELESRV_ADMIN_UI_ADDR` | address / `127.0.0.1:2600` | Standalone `cmd/telesrv-admin` listen address. | @@ -62,7 +62,189 @@ This document describes every setting loaded by `internal/config`. Defaults and | `TELESRV_PUBLIC_APP_SCHEME` | URL scheme / `telesrv` | Automatic app-open scheme on landing pages. Must match patched client registration. `tg`, `http`, and `https` are rejected. | | `TELESRV_PUBLIC_WEB_BASE_URL` | HTTP(S) URL / `https://web.telesrv.net` | Web-client root used by public username pages. Same URL validation as `TELESRV_PUBLIC_BASE_URL`. | | `TELESRV_PUBLIC_APP_NAME` | string / `telesrv` | Public landing-page product name; trimmed, non-empty, no control characters, maximum 64 Unicode characters. | -| `TELESRV_PUBLIC_LINK_WEB_ADDR` | nullable address / empty | Read-only username/avatar/sticker/emoji/chatlist landing-page listener. Empty disables it. Production should bind loopback behind exact nginx routes. `.env.example` enables `127.0.0.1:2401` for development. | +| `TELESRV_PUBLIC_LINK_WEB_ADDR` | nullable address / empty | Read-only username/avatar/sticker/emoji/chatlist/collectible-gift landing-page listener. Empty disables it. Production should bind loopback behind exact nginx routes. `.env.example` enables `127.0.0.1:2401` for development. | +| `TELESRV_TELEGRAM_LOGIN_ENABLE` | bool / `false` | Mount the self-hosted Telegram Login/OIDC provider on `TELESRV_PUBLIC_LINK_WEB_ADDR`. Enabling it requires that listener and all key files below. | +| `TELESRV_TELEGRAM_LOGIN_ISSUER` | absolute origin URL / `TELESRV_PUBLIC_BASE_URL` | Exact public issuer used in discovery and tokens. HTTPS is required by default; paths, credentials, query, and fragment are rejected. The next setting permits any HTTP host/IP. | +| `TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP` | bool / `false` | When enabled, permits any valid HTTP issuer, BotFather Web origin, redirect URI, and native HTTP callback, without loopback, subnet, or port restrictions. When disabled, those Web URLs still require HTTPS. | +| `TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE` | path / `data/telegram-login/signing-keys.json` | JOSE private-key ring generated by `cmd/telegramloginkeygen`; active plus retiring public keys are published through JWKS. | +| `TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE` | path / `data/telegram-login/code-keys.json` | AES-256-GCM envelope-key ring for recoverable, one-time authorization codes. | +| `TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE` | path / `data/telegram-login/client-secret-pepper` | Deployment pepper for HMAC-SHA-256 client-secret hashes. The file must contain a base64 encoding of exactly 32 random bytes. | +| `TELESRV_TELEGRAM_LOGIN_REQUEST_TTL` | duration / `5m` | Pending authorization lifetime; bounded to `1m..15m`. | +| `TELESRV_TELEGRAM_LOGIN_CODE_TTL` | duration / `2m` | One-time code lifetime; bounded to `30s..10m`. | +| `TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL` | duration / `1h` | Signed ID-token lifetime; bounded to `1m..24h`. Retiring signing keys must cover this window. | +| `TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS` | comma-separated CIDRs / empty | Only requests whose direct peer is in this list may supply `Forwarded`/`X-Forwarded-*` client metadata. The documented nginx deployment uses `127.0.0.1/32,::1/128`. | +| `TELESRV_TELEGRAM_LOGIN_RETENTION` | duration / `168h` | Retention after terminal request/code/revocation state; bounded to `1h..90d`. | +| `TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL` | duration / `5m` | Retention worker interval; bounded to `10s..1h`. | +| `TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH` | int / `500` | Maximum rows per retention pass; bounded to `1..1000`. | + +### 3.1 Complete Telegram Login / OIDC setup + +#### 1. Generate `data/telegram-login` once + +Run this from the `telesrv` repository root: + +```powershell +go run ./cmd/telegramloginkeygen -mode init -dir data/telegram-login +Get-ChildItem .\data\telegram-login +``` + +The same command works on Linux; restrict the generated directory afterward: + +```bash +go run ./cmd/telegramloginkeygen -mode init -dir data/telegram-login +chmod 0700 data/telegram-login +chmod 0600 data/telegram-login/* +``` + +Initialization creates the following private files. It never prints key material and refuses to +overwrite an existing `signing-keys.json`, `code-keys.json`, or `client-secret-pepper`: + +- `signing-keys.json` plus three `signing-*.pem` files: the manifest and private keys for RS256, + ES256, and EdDSA ID-token signatures; +- `code-keys.json`: the AES-256-GCM envelope-key ring for one-time authorization codes; +- `client-secret-pepper`: a 32-byte deployment pepper used to store and verify OIDC Client Secret + digests. + +The repository ignores `data/*` by default. Never put this directory in Git, release archives, +logs, or ordinary backups. All instances must mount the same protected files and restart together +after rotation. Losing the pepper invalidates existing Client Secret verification. Losing a signing +key that is still in its publication window invalidates otherwise-live ID tokens against JWKS. + +#### 2. Configure and start the Provider + +This example exposes OIDC directly at `http://192.0.2.25:2401`; replace it with the server address +that clients can actually reach. Bind `0.0.0.0:2401` for direct LAN/public access, or keep +`127.0.0.1:2401` when an on-host reverse proxy is the only caller: + +```env +TELESRV_PUBLIC_BASE_URL=http://192.0.2.25:2401 +TELESRV_PUBLIC_LINK_WEB_ADDR=0.0.0.0:2401 +TELESRV_PUBLIC_APP_SCHEME=telesrv + +TELESRV_TELEGRAM_LOGIN_ENABLE=true +TELESRV_TELEGRAM_LOGIN_ISSUER=http://192.0.2.25:2401 +TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=true +TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE=data/telegram-login/signing-keys.json +TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE=data/telegram-login/code-keys.json +TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE=data/telegram-login/client-secret-pepper +``` + +For HTTPS, set the issuer and public base to the exact HTTPS origin and leave +`TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=false`. The issuer becomes the token `iss` and the root of all +discovery endpoints, so its scheme, host, and port must exactly match the address used by relying +parties. Start or restart `telesrv`, then verify the public endpoints: + +```powershell +curl.exe http://192.0.2.25:2401/.well-known/openid-configuration +curl.exe http://192.0.2.25:2401/.well-known/jwks.json +curl.exe -I http://192.0.2.25:2401/js/telegram-login.js +``` + +The discovery `issuer` must equal the configured value, and its `authorization_endpoint`, +`token_endpoint`, and `jwks_uri` must be reachable by the relying party. A reverse proxy must pass +through `/.well-known/openid-configuration`, `/.well-known/jwks.json`, `/auth`, `/auth/status`, +`/token`, `/crossapp`, `/inapp`, `/telegram-login.js`, and `/js/telegram-login.js` unchanged. + +#### 3. Create an OIDC Client with the local `@BotFather` + +Create a bot with `/newbot` or select an existing bot. In the local `@BotFather`, run `/setlogin` +and choose that bot. Initial setup returns: + +- `Client ID`: the bot user ID as a decimal string; +- `Client Secret`: shown once, separate from the Bot API token, and meant to be saved immediately + in a secret manager. + +Send each configuration command separately. This example runs the relying party at +`http://192.0.2.30:3000`: + +```text +add origin http://192.0.2.30:3000 +add redirect http://192.0.2.30:3000/oauth/callback +algorithm RS256 +enable +``` + +An `origin` is an exact Web origin without a path, query, or fragment; it authorizes the JS SDK, +popup CORS, and legacy `login_url`. A `redirect` is the exact full URI that receives an +Authorization Code. Wildcards and prefix matching are not supported. Use `/logininfo` to inspect +status and registrations; use `/setlogin` to add/remove URLs, change the algorithm, or disable the +client; use `/resetloginsecret` to rotate the Client Secret. Available algorithms are RS256, +ES256, EdDSA, and ES256K only when its build/key ring is present. EdDSA and ES256K accept only the +`openid` scope. + +#### 4. Integrate a relying party with standard OIDC + +Start by loading: + +```text +http://192.0.2.25:2401/.well-known/openid-configuration +``` + +The standard flow is Authorization Code with PKCE S256: + +1. Generate random `state`, `nonce`, and PKCE `code_verifier`; derive the S256 `code_challenge`. +2. Open the discovery `authorization_endpoint` with `client_id`, the exact `redirect_uri`, + `response_type=code`, a `scope` containing `openid`, `state`, `nonce`, `code_challenge`, and + `code_challenge_method=S256`. +3. After the user approves in TDesktop/Android, verify `state` at the relying-party callback and + read the one-time code. +4. Server-side, POST `grant_type=authorization_code`, the code, the same `redirect_uri`, and + `code_verifier` to the discovery `token_endpoint`. Confidential clients authenticate with HTTP + Basic or `client_secret_post`. +5. Verify the ID-token signature with the discovery `jwks_uri`, then strictly validate `iss`, + `aud`, `exp`, `nonce`, and a non-empty `sub`. Decoding without signature verification is not + sufficient. + +Supported scopes are `openid`, `profile`, `phone`, and `telegram:bot_access`. The provider does not +currently expose UserInfo, refresh tokens, or an introspection endpoint. Browser applications may +load `/js/telegram-login.js` for the local JS SDK. A Client Secret must remain server-side. + +#### 5. Verify the complete path with the Bedolaga demo + +Install the demo dependencies: + +```powershell +python -m venv "$env:TEMP\telesrv-bedolaga-demo-venv" +& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" -m pip install ` + -r .\cmd\bots\bedolagaformat\requirements.txt +``` + +Put the Client ID/Secret from step 3 and the same bot's Bot API token only in process environment: + +```powershell +$env:TELESRV_BOT_TOKEN = ":" +$env:TELESRV_BOT_API_SERVER = "http://192.0.2.25:8081" +$env:TELESRV_BOT_LOGIN_DEMO = "1" +$env:TELESRV_BOT_LOGIN_ISSUER = "http://192.0.2.25:2401" +$env:TELESRV_BOT_LOGIN_CLIENT_ID = "" +$env:TELESRV_BOT_LOGIN_CLIENT_SECRET = "" +$env:TELESRV_BOT_LOGIN_PUBLIC_URL = "http://192.0.2.30:3000" +$env:TELESRV_BOT_LOGIN_LISTEN = "0.0.0.0:3000" + +& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" ` + .\cmd\bots\bedolagaformat\demo.py --drop-pending --login-demo +``` + +The BotFather origin must equal `TELESRV_BOT_LOGIN_PUBLIC_URL`, and the redirect must equal +`/oauth/callback`. Send `/logindemo` to the bot. The first button +tests Bot API `login_url` plus the HMAC callback; the second page tests the local JS SDK popup and +Authorization Code + PKCE/JWKS. Omitting the Client Secret leaves JS popup verification available +but explicitly disables the server-side code flow. + +#### 6. Rotate keys + +When rotating a signing key, retain the old public key for at least the configured ID-token TTL +plus ten minutes. Restart all instances together after the operation: + +```powershell +go run ./cmd/telegramloginkeygen -mode rotate-signing -algorithm RS256 ` + -id-token-ttl 1h -publish-for 2h -dir data/telegram-login +go run ./cmd/telegramloginkeygen -mode rotate-code -dir data/telegram-login +``` + +Run `rotate-signing` separately for RS256, ES256, or EdDSA. `rotate-code` retains old code keys and +adds a new active key. Do not edit manifests or PEM files manually, and never generate divergent +key rings independently on different instances. ## 4. PostgreSQL, Redis, files, and seed data @@ -95,7 +277,7 @@ The language-pack file manifest is authoritative. To add a language, place `data | `TELESRV_AUTH_CODE_RATE_WINDOW` | duration / `10m` | Shared window for phone and auth-key issuance limits. | | `TELESRV_PHONE_CODE_DELIVERY_PROVIDER` | enum / `development` | `development` uses fixed codes; `webhook` generates random SMS codes for login, registration, and phone changes. Both modes first commit the same code to the durable 777000 dialog for existing accounts; Webhook is additive. | | `TELESRV_EMAIL_CODE_DELIVERY_PROVIDER` | enum / `smtp` | Delivery implementation for login-email and email setup/change codes: `smtp` or `webhook`. Existing-account login-email codes are first mirrored to 777000; setup/change remains provider-only. | -| `TELESRV_OTP_WEBHOOK_URL` | absolute URL / empty | Required when any provider selects `webhook`; see [otp-delivery.md](otp-delivery.md) for the fixed v1 contract. Must use `http`/`https` and contain no userinfo. | +| `TELESRV_OTP_WEBHOOK_URL` | absolute URL / empty | Required when any provider selects `webhook`; see [otp-delivery.md](otp-delivery.md) for the fixed v1 contract. Any valid `http://` or `https://` host/IP and port is accepted; userinfo is rejected. | | `TELESRV_OTP_WEBHOOK_SECRET` | secret string / empty | Optional HMAC-SHA256 signing secret; enables `X-Telesrv-Signature` when non-empty. | | `TELESRV_OTP_WEBHOOK_TIMEOUT` | duration / `5s` | Webhook HTTP timeout; must be positive when Webhook delivery is enabled. | | `TELESRV_LOGIN_EMAIL_ENABLE` | bool / `false` | Enables login-email verification. SMTP settings are required only when the email provider is `smtp`. | @@ -207,6 +389,19 @@ The following fallback keys are accepted from the **process environment only**. | `TELESRV_STARS_STARTING_GRANT` | int64 / `1000` | Idempotent lazy starting Stars balance for all accounts; `0` disables automatic grant. | | `TELESRV_PREMIUM_SWEEP_INTERVAL` | duration / `1m` | Expired-premium cleanup/push interval. Read paths derive expiry independently. | | `TELESRV_PREMIUM_SWEEP_BATCH` | int / `500` | Maximum expired premium rows processed per sweep. | +| `TELESRV_STARGIFT_SWEEP_INTERVAL` | duration / `15s` | Local Star Gift offer/auction lifecycle sweep interval; no blockchain connection is made. | +| `TELESRV_STARGIFT_SWEEP_BATCH` | int / `1000` | Maximum offer/auction/outbox work claimed per lifecycle sweep. | +| `TELESRV_STARGIFT_TON_STARTING_GRANT` | int64 / `10000000000` | Nanoton granted idempotently on a user's first access to the internal telesrv TON ledger; `0` disables it. This is not an on-chain asset. | +| `TELESRV_STARGIFT_TRANSFER_STARS` | int64 / `25` | Stars charged for a collectible transfer; `0` enables the free-transfer RPC. | +| `TELESRV_STARGIFT_DROP_DETAILS_STARS` | int64 / `25` | Stars charged to remove a collectible's original sender/message details. | +| `TELESRV_STARGIFT_OFFER_MIN_STARS` | int / `1` | Minimum Stars offer snapshotted for user-owned collectibles; `0` disables the offer entry point. | +| `TELESRV_STARGIFT_STARS_PROCEEDS_PERMILLE` | int / `1000` | Seller share in Stars sales, in permille; the remainder is recorded as platform commission. | +| `TELESRV_STARGIFT_TON_PROCEEDS_PERMILLE` | int / `1000` | Seller share in internal-TON sales, in permille; this affects only the local ledger. | +| `TELESRV_STARGIFT_EXPORT_DELAY` | duration / `0s` | Delay snapshotted into `can_export_at` when a collectible is issued. | +| `TELESRV_STARGIFT_TRANSFER_DELAY` | duration / `0s` | Delay snapshotted into `can_transfer_at`. | +| `TELESRV_STARGIFT_RESELL_DELAY` | duration / `0s` | Delay snapshotted into `can_resell_at`. | +| `TELESRV_STARGIFT_CRAFT_DELAY` | duration / `0s` | Delay snapshotted into `can_craft_at`. | +| `TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE` | int / `250` | Per-input local craft success contribution, capped at 1000 permille. | ## 11. Private calls, group calls, TURN, SFU, and livestream diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md index e08a2291..4c2e6af2 100644 --- a/docs/configuration.zh-CN.md +++ b/docs/configuration.zh-CN.md @@ -51,7 +51,7 @@ | 参数 | 类型 / 代码默认值 | 说明与约束 | |---|---|---| | `TELESRV_DEBUG_ADDR` | nullable address / `127.0.0.1:6060` | pprof/debug 监听;空值关闭。生产必须保持 loopback,通过 SSH 隧道抓取。 | -| `TELESRV_BOT_API_ADDR` | nullable address / 空 | 最小 HTTP Bot API 监听;空值关闭,与 MTProto 共用 app/store 事实。 | +| `TELESRV_BOT_API_ADDR` | nullable address / 空 | 最小 HTTP Bot API 监听;空值关闭,与 MTProto 共用 app/store 事实。`setWebhook` 接受任意合法 `http://` 或 `https://` 主机/IP 与 `1..65535` 端口。 | | `TELESRV_ADMIN_API_ADDR` | nullable address / 空 | 进程内 Admin 写 API;空值关闭,生产应只监听 loopback。 | | `TELESRV_ADMIN_API_TOKEN` | secret string / 空 | Admin API bearer token;启用 Admin API 时必须显式配置,并与 Admin UI 使用的 token 一致。 | | `TELESRV_ADMIN_UI_ADDR` | address / `127.0.0.1:2600` | 独立 `cmd/telesrv-admin` 监听地址。 | @@ -62,7 +62,181 @@ | `TELESRV_PUBLIC_APP_SCHEME` | URL scheme / `telesrv` | 落地页自动唤起客户端的 scheme,必须与 patched 客户端注册值一致;禁止 `tg`、`http`、`https`。 | | `TELESRV_PUBLIC_WEB_BASE_URL` | HTTP(S) URL / `https://web.telesrv.net` | username 页面 Web 客户端入口,校验规则同 `TELESRV_PUBLIC_BASE_URL`。 | | `TELESRV_PUBLIC_APP_NAME` | string / `telesrv` | 公开落地页产品名;trim 后非空、无控制字符、最多 64 个 Unicode 字符。 | -| `TELESRV_PUBLIC_LINK_WEB_ADDR` | nullable address / 空 | 只读 username/avatar/sticker/emoji/chatlist 落地页监听;空值关闭。生产应 loopback + nginx 精确反代;`.env.example` 为开发启用 `127.0.0.1:2401`。 | +| `TELESRV_PUBLIC_LINK_WEB_ADDR` | nullable address / 空 | 只读 username/avatar/sticker/emoji/chatlist/collectible gift 落地页监听;空值关闭。生产应 loopback + nginx 精确反代;`.env.example` 为开发启用 `127.0.0.1:2401`。 | +| `TELESRV_TELEGRAM_LOGIN_ENABLE` | bool / `false` | 在 `TELESRV_PUBLIC_LINK_WEB_ADDR` 上挂载自建 Telegram Login/OIDC Provider;启用时必须同时配置该 listener 与下列全部密钥文件。 | +| `TELESRV_TELEGRAM_LOGIN_ISSUER` | 绝对 origin URL / `TELESRV_PUBLIC_BASE_URL` | discovery 与 token 使用的精确公开 issuer;默认必须 HTTPS,禁止 path、credentials、query、fragment。开启下一项后可直接配置任意 HTTP 域名/IP。 | +| `TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP` | bool / `false` | 开启后允许任意合法 HTTP issuer、BotFather Web origin、redirect URI 和 native HTTP callback,不限制为 loopback,也不限制 IP 网段或端口。关闭时这些 Web URL 仍必须 HTTPS。 | +| `TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE` | path / `data/telegram-login/signing-keys.json` | 由 `cmd/telegramloginkeygen` 生成的 JOSE 私钥环;JWKS 会发布 active 和仍在退役窗口内的公钥。 | +| `TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE` | path / `data/telegram-login/code-keys.json` | 用于可恢复一次性 authorization code 的 AES-256-GCM envelope key ring。 | +| `TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE` | path / `data/telegram-login/client-secret-pepper` | HMAC-SHA-256 Client Secret 摘要的部署 pepper 文件,内容必须是恰好 32 个随机字节的 base64 编码。 | +| `TELESRV_TELEGRAM_LOGIN_REQUEST_TTL` | duration / `5m` | pending authorization 生命周期,限定 `1m..15m`。 | +| `TELESRV_TELEGRAM_LOGIN_CODE_TTL` | duration / `2m` | 一次性 code 生命周期,限定 `30s..10m`。 | +| `TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL` | duration / `1h` | ID token 生命周期,限定 `1m..24h`;退役签名公钥必须覆盖该窗口。 | +| `TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS` | 逗号分隔 CIDR / 空 | 只有直连 peer 落在该列表时才信任 `Forwarded`/`X-Forwarded-*` 客户端元数据;文档中的单机 nginx 部署使用 `127.0.0.1/32,::1/128`。 | +| `TELESRV_TELEGRAM_LOGIN_RETENTION` | duration / `168h` | terminal request/code/revocation 后的保留期,限定 `1h..90d`。 | +| `TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL` | duration / `5m` | retention worker 周期,限定 `10s..1h`。 | +| `TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH` | int / `500` | 每轮最大清理行数,限定 `1..1000`。 | + +### 3.1 Telegram Login / OIDC 完整启用流程 + +#### 1. 一次性生成 `data/telegram-login` + +在 `telesrv` 仓库根目录执行: + +```powershell +go run ./cmd/telegramloginkeygen -mode init -dir data/telegram-login +Get-ChildItem .\data\telegram-login +``` + +Linux 部署也可使用同一命令;生成后应限制目录权限: + +```bash +go run ./cmd/telegramloginkeygen -mode init -dir data/telegram-login +chmod 0700 data/telegram-login +chmod 0600 data/telegram-login/* +``` + +初始化会生成以下私密文件,命令不会把密钥内容输出到终端,并会拒绝覆盖已经存在的 +`signing-keys.json`、`code-keys.json` 或 `client-secret-pepper`: + +- `signing-keys.json` 和三个 `signing-*.pem`:RS256、ES256、EdDSA ID token 签名私钥及清单; +- `code-keys.json`:一次性 authorization code 使用的 AES-256-GCM envelope key ring; +- `client-secret-pepper`:保存和校验 OIDC Client Secret 摘要时使用的 32 字节部署 pepper。 + +`data/*` 默认已被仓库 `.gitignore` 排除。不要把该目录放入 Git、发布压缩包、日志或 +普通备份;多实例必须挂载同一份受保护的文件,并在轮换后一起重启。丢失 pepper 会让 +现有 Client Secret 无法验证,丢失仍在发布窗口内的签名私钥会让尚未过期的 ID token +无法继续通过 JWKS 验证。 + +#### 2. 配置并启动 Provider + +以下示例直接通过 `http://192.0.2.25:2401` 对外提供 OIDC;请替换成客户端实际可达的 +服务器 IP。直接监听局域网/公网网卡时使用 `0.0.0.0:2401`,仅由同机反向代理转发时 +应改回 `127.0.0.1:2401`: + +```env +TELESRV_PUBLIC_BASE_URL=http://192.0.2.25:2401 +TELESRV_PUBLIC_LINK_WEB_ADDR=0.0.0.0:2401 +TELESRV_PUBLIC_APP_SCHEME=telesrv + +TELESRV_TELEGRAM_LOGIN_ENABLE=true +TELESRV_TELEGRAM_LOGIN_ISSUER=http://192.0.2.25:2401 +TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=true +TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE=data/telegram-login/signing-keys.json +TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE=data/telegram-login/code-keys.json +TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE=data/telegram-login/client-secret-pepper +``` + +使用 HTTPS 时,把 `TELESRV_TELEGRAM_LOGIN_ISSUER` 和公开根地址改成精确 HTTPS +origin,并保持 `TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=false`。issuer 是 token 的 `iss` +以及 discovery 中所有端点的根地址,scheme、host 和 port 必须与依赖方访问的地址完全 +一致。启动或重启 `telesrv` 后,先验证公开端点: + +```powershell +curl.exe http://192.0.2.25:2401/.well-known/openid-configuration +curl.exe http://192.0.2.25:2401/.well-known/jwks.json +curl.exe -I http://192.0.2.25:2401/js/telegram-login.js +``` + +discovery 返回的 `issuer` 必须等于配置值,`authorization_endpoint`、`token_endpoint` +和 `jwks_uri` 必须可从依赖方访问。使用反向代理时需原样转发 +`/.well-known/openid-configuration`、`/.well-known/jwks.json`、`/auth`、`/auth/status`、 +`/token`、`/crossapp`、`/inapp`、`/telegram-login.js` 和 `/js/telegram-login.js`。 + +#### 3. 用本服 `@BotFather` 创建 OIDC Client + +先用 `/newbot` 创建或选择已有 bot,然后在本服 `@BotFather` 中执行 `/setlogin` 并选择 +该 bot。首次配置会返回: + +- `Client ID`:bot user ID 的十进制字符串; +- `Client Secret`:只显示一次,与 Bot API token 不同,必须立即保存到密钥管理系统。 + +接着逐条发送配置命令。下面假设依赖方页面运行在 `http://192.0.2.30:3000`: + +```text +add origin http://192.0.2.30:3000 +add redirect http://192.0.2.30:3000/oauth/callback +algorithm RS256 +enable +``` + +`origin` 只能是无 path/query/fragment 的精确 Web origin,用于 JS SDK、popup CORS 和 +legacy `login_url`;`redirect` 是 Authorization Code Flow 返回 code 的精确完整 URI。 +不支持 wildcard 或 prefix 匹配。用 `/logininfo` 检查状态和登记值;用 `/setlogin` +增删 URL、切换签名算法或 disable;用 `/resetloginsecret` 轮换 Client Secret。可用的 +签名算法为 RS256、ES256、EdDSA,以及仅在对应构建和 key ring 已提供时可选的 ES256K; +EdDSA/ES256K 只允许 `openid` scope。 + +#### 4. 依赖方接入标准 OIDC + +依赖方应首先读取: + +```text +http://192.0.2.25:2401/.well-known/openid-configuration +``` + +标准流程为 Authorization Code + PKCE S256: + +1. 生成随机 `state`、`nonce` 和 PKCE `code_verifier`,计算 S256 `code_challenge`; +2. 浏览器打开 discovery 中的 `authorization_endpoint`,携带 `client_id`、精确 + `redirect_uri`、`response_type=code`、包含 `openid` 的 `scope`、`state`、`nonce`、 + `code_challenge` 和 `code_challenge_method=S256`; +3. 用户在 TDesktop/Android 中确认后,依赖方 callback 校验 `state` 并取得一次性 code; +4. 服务端向 discovery 中的 `token_endpoint` POST `grant_type=authorization_code`、code、 + 同一 `redirect_uri` 和 `code_verifier`,机密 client 使用 HTTP Basic 或 + `client_secret_post` 提交 Client Secret; +5. 用 discovery 的 `jwks_uri` 验证 ID token 签名,并严格校验 `iss`、`aud`、`exp`、 + `nonce` 和非空 `sub`。不要只解码而不验签。 + +支持的 scope 为 `openid`、`profile`、`phone`、`telegram:bot_access`。当前不提供 +UserInfo、refresh token 或 introspection endpoint。浏览器前端可以加载 +`/js/telegram-login.js` 使用本地 JS SDK;Client Secret 只能留在服务端。 + +#### 5. 使用 Bedolaga demo 验证完整链路 + +安装 demo 依赖: + +```powershell +python -m venv "$env:TEMP\telesrv-bedolaga-demo-venv" +& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" -m pip install ` + -r .\cmd\bots\bedolagaformat\requirements.txt +``` + +将第 3 步得到的 Client ID/Secret 和同一个 Bot API token 仅放入进程环境: + +```powershell +$env:TELESRV_BOT_TOKEN = ":" +$env:TELESRV_BOT_API_SERVER = "http://192.0.2.25:8081" +$env:TELESRV_BOT_LOGIN_DEMO = "1" +$env:TELESRV_BOT_LOGIN_ISSUER = "http://192.0.2.25:2401" +$env:TELESRV_BOT_LOGIN_CLIENT_ID = "" +$env:TELESRV_BOT_LOGIN_CLIENT_SECRET = "<只显示一次的 OIDC Client Secret>" +$env:TELESRV_BOT_LOGIN_PUBLIC_URL = "http://192.0.2.30:3000" +$env:TELESRV_BOT_LOGIN_LISTEN = "0.0.0.0:3000" + +& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" ` + .\cmd\bots\bedolagaformat\demo.py --drop-pending --login-demo +``` + +确保 BotFather 登记的 origin 等于 `TELESRV_BOT_LOGIN_PUBLIC_URL`,redirect 等于 +`/oauth/callback`。在客户端向 bot 发送 `/logindemo`:第一颗 +按钮验证 Bot API `login_url` 和 HMAC 回调,第二颗按钮页面分别验证本地 JS SDK popup +以及 Authorization Code + PKCE/JWKS。省略 Client Secret 时只能验证 JS popup,服务端 +code flow 会明确禁用。 + +#### 6. 密钥轮换 + +签名 key 轮换时,旧公钥发布窗口必须至少覆盖配置的 ID token TTL 再加 10 分钟;操作 +完成后所有实例一起重启: + +```powershell +go run ./cmd/telegramloginkeygen -mode rotate-signing -algorithm RS256 ` + -id-token-ttl 1h -publish-for 2h -dir data/telegram-login +go run ./cmd/telegramloginkeygen -mode rotate-code -dir data/telegram-login +``` + +`rotate-signing` 可分别用于 RS256、ES256、EdDSA;`rotate-code` 保留旧 code key 并新增 +active key。不要手工编辑 manifest 或 PEM,不要在各实例上分别生成不一致的 key ring。 ## 4. PostgreSQL、Redis、文件与 seed @@ -95,7 +269,7 @@ | `TELESRV_AUTH_CODE_RATE_WINDOW` | duration / `10m` | 手机号与 auth-key 发码限流共用窗口。 | | `TELESRV_PHONE_CODE_DELIVERY_PROVIDER` | enum / `development` | `development` 使用固定码;`webhook` 为登录、注册、改号生成随机 SMS code 并调用 OTP Webhook。已有账号在两种模式下都先 durable 写入同码 777000 消息,Webhook 只是附加渠道。 | | `TELESRV_EMAIL_CODE_DELIVERY_PROVIDER` | enum / `smtp` | 登录邮箱、邮箱 setup/change 的投递实现:`smtp` 或 `webhook`。已有账号的登录邮箱码会先同码镜像到 777000;邮箱 setup/change 仍只走 provider。 | -| `TELESRV_OTP_WEBHOOK_URL` | absolute URL / 空 | 任一 provider 选择 `webhook` 时必填;固定 v1 协议见 [otp-delivery.md](otp-delivery.md)。只允许 `http`/`https` 且不得含 userinfo。 | +| `TELESRV_OTP_WEBHOOK_URL` | absolute URL / 空 | 任一 provider 选择 `webhook` 时必填;固定 v1 协议见 [otp-delivery.md](otp-delivery.md)。允许任意合法 `http://` 或 `https://` 主机/IP 与端口,不得含 userinfo。 | | `TELESRV_OTP_WEBHOOK_SECRET` | secret string / 空 | 可选 HMAC-SHA256 签名密钥;非空时发送 `X-Telesrv-Signature`。 | | `TELESRV_OTP_WEBHOOK_TIMEOUT` | duration / `5s` | Webhook HTTP 请求超时,启用 Webhook 时必须为正数。 | | `TELESRV_LOGIN_EMAIL_ENABLE` | bool / `false` | 启用登录邮箱验证码;email provider 为 `smtp` 时要求 SMTP 配置,`webhook` 时不依赖 SMTP。 | @@ -207,6 +381,19 @@ | `TELESRV_STARS_STARTING_GRANT` | int64 / `1000` | 对所有账号幂等惰性授予的 Stars 起始余额;`0` 关闭自动赠送。 | | `TELESRV_PREMIUM_SWEEP_INTERVAL` | duration / `1m` | 过期 Premium 清理/推送周期;读取路径独立即时派生到期状态。 | | `TELESRV_PREMIUM_SWEEP_BATCH` | int / `500` | 单次 sweep 最大处理行数。 | +| `TELESRV_STARGIFT_SWEEP_INTERVAL` | duration / `15s` | Star Gift 报价/竞拍本地生命周期清扫周期;不会连接区块链。 | +| `TELESRV_STARGIFT_SWEEP_BATCH` | int / `1000` | 单次礼物生命周期清扫最多处理的报价、竞拍与 outbox 工作量。 | +| `TELESRV_STARGIFT_TON_STARTING_GRANT` | int64 / `10000000000` | 每个用户首次访问 telesrv 内部 TON 账本时幂等授予的 nanoton;`0` 关闭赠送。它不是链上资产。 | +| `TELESRV_STARGIFT_TRANSFER_STARS` | int64 / `25` | collectible 转赠费用;设为 `0` 时使用免费转赠 RPC。 | +| `TELESRV_STARGIFT_DROP_DETAILS_STARS` | int64 / `25` | 移除 collectible 原始发送者/附言信息所需 Stars。 | +| `TELESRV_STARGIFT_OFFER_MIN_STARS` | int / `1` | collectible 签发时固化的用户持有礼物最低 Stars 报价;`0` 不开放报价入口。 | +| `TELESRV_STARGIFT_STARS_PROCEEDS_PERMILLE` | int / `1000` | Stars 成交时卖方实收比例(千分比);差额作为平台佣金写入成交记录。 | +| `TELESRV_STARGIFT_TON_PROCEEDS_PERMILLE` | int / `1000` | 内部 TON 成交时卖方实收比例(千分比);只影响本地账本。 | +| `TELESRV_STARGIFT_EXPORT_DELAY` | duration / `0s` | collectible 签发时固化到 `can_export_at` 的等待期。 | +| `TELESRV_STARGIFT_TRANSFER_DELAY` | duration / `0s` | 签发时固化到 `can_transfer_at` 的等待期。 | +| `TELESRV_STARGIFT_RESELL_DELAY` | duration / `0s` | 签发时固化到 `can_resell_at` 的等待期。 | +| `TELESRV_STARGIFT_CRAFT_DELAY` | duration / `0s` | 签发时固化到 `can_craft_at` 的等待期;可 Craft 礼物即使为 `0s` 也写升级时间这一正数能力边界,0 只表示不具备 Craft 能力或已终结。 | +| `TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE` | int / `250` | 每份输入礼物贡献的本地合成成功概率,累计上限 1000‰。 | ## 11. 私聊通话、群通话、TURN、SFU 与直播 diff --git a/internal/app/bots/botfather_login_test.go b/internal/app/bots/botfather_login_test.go index 8a77f63c..f48543fe 100644 --- a/internal/app/bots/botfather_login_test.go +++ b/internal/app/bots/botfather_login_test.go @@ -22,7 +22,7 @@ func newBotFatherTelegramLoginService(t *testing.T) *telegramloginapp.Service { pepper := make([]byte, 32) pepper[0] = 2 service, err := telegramloginapp.NewService(memory.NewTelegramLoginStore(nil), sealer, telegramloginapp.Config{ - Issuer: "http://localhost:2404", AppScheme: "telesrv", AllowLoopbackHTTP: true, + Issuer: "http://192.0.2.25:2404", AppScheme: "telesrv", AllowHTTP: true, ClientSecretPepper: pepper, Now: func() time.Time { return time.Unix(1_780_000_000, 0).UTC() }, }) if err != nil { @@ -52,13 +52,13 @@ func TestBotFatherTelegramLoginConfigurationFlow(t *testing.T) { if len(secret) < 32 { t.Fatalf("client secret is unexpectedly short: %q", secret) } - if reply := sendToBotFather(t, svc, messages, owner, "add origin http://localhost:3000"); !strings.Contains(reply, "Success!") { + if reply := sendToBotFather(t, svc, messages, owner, "add origin http://rp.example.test:3000"); !strings.Contains(reply, "Success!") { t.Fatalf("add origin reply = %q", reply) } sendToBotFather(t, svc, messages, owner, "/setlogin") sendToBotFather(t, svc, messages, owner, "login_demo_bot") - if reply := sendToBotFather(t, svc, messages, owner, "add redirect http://localhost:3000/auth/callback"); !strings.Contains(reply, "Success!") { + if reply := sendToBotFather(t, svc, messages, owner, "add redirect http://192.0.2.26:3000/auth/callback"); !strings.Contains(reply, "Success!") { t.Fatalf("add redirect reply = %q", reply) } @@ -83,7 +83,7 @@ func TestBotFatherTelegramLoginConfigurationFlow(t *testing.T) { sendToBotFather(t, svc, messages, owner, "/logininfo") info := sendToBotFather(t, svc, messages, owner, "login_demo_bot") - for _, want := range []string{"Signing algorithm: ES256", "web_origin http://localhost:3000", "redirect_uri http://localhost:3000/auth/callback", "dev.bedolaga.demo", "Bedolaga iOS Demo", "Bedolaga Android Demo"} { + for _, want := range []string{"Signing algorithm: ES256", "web_origin http://rp.example.test:3000", "redirect_uri http://192.0.2.26:3000/auth/callback", "dev.bedolaga.demo", "Bedolaga iOS Demo", "Bedolaga Android Demo"} { if !strings.Contains(info, want) { t.Fatalf("login info = %q, missing %q", info, want) } diff --git a/internal/app/telegramlogin/jose.go b/internal/app/telegramlogin/jose.go index 60986af6..a05424f2 100644 --- a/internal/app/telegramlogin/jose.go +++ b/internal/app/telegramlogin/jose.go @@ -293,9 +293,10 @@ func (r *SigningKeyRing) sign(algorithm domain.TelegramLoginSigningAlgorithm, to } type IDTokenIssuerConfig struct { - Issuer string - TTL time.Duration - Now func() time.Time + Issuer string + TTL time.Duration + Now func() time.Time + AllowHTTP bool } type IDTokenIssuer struct { @@ -337,7 +338,7 @@ func NewIDTokenIssuer(keys *SigningKeyRing, cfg IDTokenIssuerConfig) (*IDTokenIs if keys == nil { return nil, errors.New("telegram login signing key ring is required") } - issuer, err := NormalizeWebOrigin(cfg.Issuer, true) + issuer, err := NormalizeWebOrigin(cfg.Issuer, cfg.AllowHTTP) if err != nil { return nil, fmt.Errorf("telegram login ID token issuer: %w", err) } diff --git a/internal/app/telegramlogin/jose_test.go b/internal/app/telegramlogin/jose_test.go index 108cd20a..eaea7b1f 100644 --- a/internal/app/telegramlogin/jose_test.go +++ b/internal/app/telegramlogin/jose_test.go @@ -167,6 +167,21 @@ func TestIDTokenIssuerScopeProjectionAndVerification(t *testing.T) { } } +func TestIDTokenIssuerAcceptsHTTPIPOnlyWhenEnabled(t *testing.T) { + now := time.Date(2026, 7, 21, 10, 0, 0, 0, time.UTC) + ring := telegramLoginTestSigningKeys(t, &now) + if _, err := NewIDTokenIssuer(ring, IDTokenIssuerConfig{Issuer: "http://192.0.2.25:2401"}); err == nil { + t.Fatal("HTTP issuer was accepted while AllowHTTP was false") + } + issuer, err := NewIDTokenIssuer(ring, IDTokenIssuerConfig{Issuer: "http://192.0.2.25:2401", AllowHTTP: true}) + if err != nil { + t.Fatal(err) + } + if issuer.Issuer() != "http://192.0.2.25:2401" { + t.Fatalf("issuer=%q", issuer.Issuer()) + } +} + func TestSigningKeyRingRejectsWrongCurveAndDuplicateActiveKey(t *testing.T) { p384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) if err != nil { diff --git a/internal/app/telegramlogin/native.go b/internal/app/telegramlogin/native.go index 97d74282..565a4352 100644 --- a/internal/app/telegramlogin/native.go +++ b/internal/app/telegramlogin/native.go @@ -45,7 +45,7 @@ func normalizeNativeVerificationID(platform domain.TelegramLoginNativePlatform, // non-web custom scheme registered for a native application. Query and // fragment components are forbidden because OAuth response fields are // appended by the provider and must not collide with application input. -func NormalizeNativeCallbackURI(raw string, allowLoopbackHTTP bool) (string, error) { +func NormalizeNativeCallbackURI(raw string, allowHTTP bool) (string, error) { if raw == "" || len(raw) > maxTelegramLoginURLLength || raw != strings.TrimSpace(raw) || strings.IndexFunc(raw, unicode.IsControl) >= 0 { return "", domain.ErrTelegramLoginURLInvalid } @@ -54,7 +54,7 @@ func NormalizeNativeCallbackURI(raw string, allowLoopbackHTTP bool) (string, err return "", domain.ErrTelegramLoginURLInvalid } if strings.EqualFold(u.Scheme, "http") || strings.EqualFold(u.Scheme, "https") { - normalized, _, err := NormalizeRedirectURI(raw, allowLoopbackHTTP) + normalized, _, err := NormalizeRedirectURI(raw, allowHTTP) return normalized, err } scheme := strings.ToLower(u.Scheme) diff --git a/internal/app/telegramlogin/service.go b/internal/app/telegramlogin/service.go index 77c34f97..dd725a03 100644 --- a/internal/app/telegramlogin/service.go +++ b/internal/app/telegramlogin/service.go @@ -36,7 +36,7 @@ var telegramLoginMatchCodePool = []string{ type Config struct { Issuer string AppScheme string - AllowLoopbackHTTP bool + AllowHTTP bool ClientSecretPepper []byte SupportedSigningAlgorithms []domain.TelegramLoginSigningAlgorithm RequestTTL time.Duration @@ -49,7 +49,7 @@ type Service struct { sealer *CodeSealer issuer string appScheme string - allowLoopbackHTTP bool + allowHTTP bool clientSecretPepper []byte signingAlgorithms []domain.TelegramLoginSigningAlgorithm signingAlgorithmSet map[domain.TelegramLoginSigningAlgorithm]struct{} @@ -62,7 +62,7 @@ func NewService(loginStore store.TelegramLoginStore, sealer *CodeSealer, cfg Con if loginStore == nil || sealer == nil || len(cfg.ClientSecretPepper) < 32 { return nil, fmt.Errorf("telegram login dependencies are incomplete") } - issuer, err := NormalizeWebOrigin(cfg.Issuer, cfg.AllowLoopbackHTTP) + issuer, err := NormalizeWebOrigin(cfg.Issuer, cfg.AllowHTTP) if err != nil { return nil, fmt.Errorf("telegram login issuer: %w", err) } @@ -93,7 +93,7 @@ func NewService(loginStore store.TelegramLoginStore, sealer *CodeSealer, cfg Con } return &Service{ store: loginStore, sealer: sealer, issuer: issuer, appScheme: strings.ToLower(cfg.AppScheme), - allowLoopbackHTTP: cfg.AllowLoopbackHTTP, + allowHTTP: cfg.AllowHTTP, clientSecretPepper: append([]byte(nil), cfg.ClientSecretPepper...), signingAlgorithms: append([]domain.TelegramLoginSigningAlgorithm(nil), cfg.SupportedSigningAlgorithms...), signingAlgorithmSet: signingAlgorithmSet, @@ -248,9 +248,9 @@ func (s *Service) AddAllowedURL(ctx context.Context, botUserID int64, kind domai var err error switch kind { case domain.TelegramLoginAllowedWebOrigin: - normalized, err = NormalizeWebOrigin(raw, s.allowLoopbackHTTP) + normalized, err = NormalizeWebOrigin(raw, s.allowHTTP) case domain.TelegramLoginAllowedRedirectURI: - normalized, _, err = NormalizeRedirectURI(raw, s.allowLoopbackHTTP) + normalized, _, err = NormalizeRedirectURI(raw, s.allowHTTP) default: err = domain.ErrTelegramLoginURLInvalid } @@ -267,9 +267,9 @@ func (s *Service) DeleteAllowedURL(ctx context.Context, botUserID int64, kind do var err error switch kind { case domain.TelegramLoginAllowedWebOrigin: - normalized, err = NormalizeWebOrigin(raw, s.allowLoopbackHTTP) + normalized, err = NormalizeWebOrigin(raw, s.allowHTTP) case domain.TelegramLoginAllowedRedirectURI: - normalized, _, err = NormalizeRedirectURI(raw, s.allowLoopbackHTTP) + normalized, _, err = NormalizeRedirectURI(raw, s.allowHTTP) default: err = domain.ErrTelegramLoginURLInvalid } @@ -311,7 +311,7 @@ func (s *Service) AddNativeApp(ctx context.Context, botUserID int64, platform do if err != nil { return domain.TelegramLoginNativeApp{}, err } - callbackURI, err = NormalizeNativeCallbackURI(callbackURI, s.allowLoopbackHTTP) + callbackURI, err = NormalizeNativeCallbackURI(callbackURI, s.allowHTTP) if err != nil { return domain.TelegramLoginNativeApp{}, err } @@ -335,7 +335,7 @@ func (s *Service) DeleteNativeApp(ctx context.Context, botUserID, appID int64) ( } func (s *Service) matchNativeApp(ctx context.Context, botUserID int64, platform domain.TelegramLoginNativePlatform, rawCallbackURI string) (domain.TelegramLoginNativeApp, string, bool, error) { - callbackURI, err := NormalizeNativeCallbackURI(rawCallbackURI, s.allowLoopbackHTTP) + callbackURI, err := NormalizeNativeCallbackURI(rawCallbackURI, s.allowHTTP) if err != nil { return domain.TelegramLoginNativeApp{}, "", false, nil } @@ -362,7 +362,7 @@ func (s *Service) ValidateMessageButton(ctx context.Context, botUserID int64, ra if !found || !client.Enabled { return "", "", domain.ErrTelegramLoginClientDisabled } - normalizedURL, domainName, err = NormalizeRedirectURI(rawURL, s.allowLoopbackHTTP) + normalizedURL, domainName, err = NormalizeRedirectURI(rawURL, s.allowHTTP) if err != nil { return "", "", err } @@ -370,7 +370,7 @@ func (s *Service) ValidateMessageButton(ctx context.Context, botUserID int64, ra if err != nil { return "", "", domain.ErrTelegramLoginURLInvalid } - origin, err := NormalizeWebOrigin(u.Scheme+"://"+u.Host, s.allowLoopbackHTTP) + origin, err := NormalizeWebOrigin(u.Scheme+"://"+u.Host, s.allowHTTP) if err != nil { return "", "", err } @@ -415,7 +415,7 @@ func (s *Service) AuthorizeMessageButton(ctx context.Context, params domain.Tele if err != nil { return domain.TelegramLoginMessageButtonResult{}, domain.ErrTelegramLoginURLInvalid } - origin, err := NormalizeWebOrigin(u.Scheme+"://"+u.Host, s.allowLoopbackHTTP) + origin, err := NormalizeWebOrigin(u.Scheme+"://"+u.Host, s.allowHTTP) if err != nil { return domain.TelegramLoginMessageButtonResult{}, err } @@ -552,7 +552,7 @@ func (s *Service) CreateAuthorization(ctx context.Context, params CreateAuthoriz } var allowed, isApp bool var nativeApp domain.TelegramLoginNativeApp - redirectURI, domainName, redirectErr := NormalizeRedirectURI(params.RedirectURI, s.allowLoopbackHTTP) + redirectURI, domainName, redirectErr := NormalizeRedirectURI(params.RedirectURI, s.allowHTTP) if params.ResponseType == "code" && redirectErr == nil && !params.NativePlatform.Valid() { allowed, err = s.store.IsTelegramLoginURLAllowed(ctx, client.BotUserID, domain.TelegramLoginAllowedRedirectURI, redirectURI) if err != nil { @@ -603,14 +603,14 @@ func (s *Service) CreateAuthorization(ctx context.Context, params CreateAuthoriz u, _ := url.Parse(redirectURI) origin = u.Scheme + "://" + u.Host } - origin, err = NormalizeWebOrigin(origin, s.allowLoopbackHTTP) + origin, err = NormalizeWebOrigin(origin, s.allowHTTP) if err != nil { return CreatedAuthorization{}, err } } if params.ResponseType == "post_message" { redirectURL, _ := url.Parse(redirectURI) - redirectOrigin, redirectOriginErr := NormalizeWebOrigin(redirectURL.Scheme+"://"+redirectURL.Host, s.allowLoopbackHTTP) + redirectOrigin, redirectOriginErr := NormalizeWebOrigin(redirectURL.Scheme+"://"+redirectURL.Host, s.allowHTTP) if redirectOriginErr != nil || redirectOrigin != origin { return CreatedAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed } @@ -627,7 +627,7 @@ func (s *Service) CreateAuthorization(ctx context.Context, params CreateAuthoriz if isApp { return CreatedAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed } - inAppOrigin, err = NormalizeWebOrigin(params.InAppOrigin, s.allowLoopbackHTTP) + inAppOrigin, err = NormalizeWebOrigin(params.InAppOrigin, s.allowHTTP) if err != nil { return CreatedAuthorization{}, err } @@ -693,7 +693,7 @@ func (s *Service) ResolveAuthorizationErrorTarget(ctx context.Context, clientID, redirectURI, safe, err := s.safeCodeRedirect(ctx, client.BotUserID, rawRedirectURI) return AuthorizationErrorTarget{ResponseType: responseType, RedirectURI: redirectURI}, safe, err case "post_message": - redirectURI, _, err := NormalizeRedirectURI(rawRedirectURI, s.allowLoopbackHTTP) + redirectURI, _, err := NormalizeRedirectURI(rawRedirectURI, s.allowHTTP) if err != nil { return AuthorizationErrorTarget{}, false, nil } @@ -702,12 +702,12 @@ func (s *Service) ResolveAuthorizationErrorTarget(ctx context.Context, clientID, redirect, _ := url.Parse(redirectURI) origin = redirect.Scheme + "://" + redirect.Host } - origin, err = NormalizeWebOrigin(origin, s.allowLoopbackHTTP) + origin, err = NormalizeWebOrigin(origin, s.allowHTTP) if err != nil { return AuthorizationErrorTarget{}, false, nil } redirect, _ := url.Parse(redirectURI) - redirectOrigin, err := NormalizeWebOrigin(redirect.Scheme+"://"+redirect.Host, s.allowLoopbackHTTP) + redirectOrigin, err := NormalizeWebOrigin(redirect.Scheme+"://"+redirect.Host, s.allowHTTP) if err != nil || redirectOrigin != origin { return AuthorizationErrorTarget{}, false, nil } @@ -725,7 +725,7 @@ func (s *Service) ResolveAuthorizationErrorTarget(ctx context.Context, clientID, } func (s *Service) safeCodeRedirect(ctx context.Context, botUserID int64, raw string) (string, bool, error) { - if redirectURI, _, err := NormalizeRedirectURI(raw, s.allowLoopbackHTTP); err == nil { + if redirectURI, _, err := NormalizeRedirectURI(raw, s.allowHTTP); err == nil { allowed, err := s.store.IsTelegramLoginURLAllowed(ctx, botUserID, domain.TelegramLoginAllowedRedirectURI, redirectURI) if err != nil || allowed { return redirectURI, allowed, err @@ -856,7 +856,7 @@ func (s *Service) RequestByDeepLinkForOrigin(ctx context.Context, rawURL, rawOri if rawOrigin == "" || request.InAppOrigin == "" { return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginOriginNotAllowed } - origin, err := NormalizeWebOrigin(rawOrigin, s.allowLoopbackHTTP) + origin, err := NormalizeWebOrigin(rawOrigin, s.allowHTTP) if err != nil { return domain.TelegramLoginRequest{}, err } @@ -1067,7 +1067,7 @@ func (s *Service) ExchangeInAppTokenAndIssue(ctx context.Context, token, rawOrig if issuer == nil || len(token) < 16 || len(token) > 1024 || strings.IndexFunc(token, func(r rune) bool { return r <= 0x20 || r == 0x7f }) >= 0 { return IssuedAuthorization{}, domain.ErrTelegramLoginCodeInvalid } - origin, err := NormalizeWebOrigin(rawOrigin, s.allowLoopbackHTTP) + origin, err := NormalizeWebOrigin(rawOrigin, s.allowHTTP) if err != nil { return IssuedAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed } @@ -1294,7 +1294,7 @@ func (s *Service) exchangeAuthorizationCode(ctx context.Context, params Exchange return ExchangedAuthorization{}, "", domain.ErrTelegramLoginCodeInvalid } } else { - redirectURI, _, err = NormalizeRedirectURI(params.RedirectURI, s.allowLoopbackHTTP) + redirectURI, _, err = NormalizeRedirectURI(params.RedirectURI, s.allowHTTP) if err != nil { return ExchangedAuthorization{}, "", err } diff --git a/internal/app/telegramlogin/service_test.go b/internal/app/telegramlogin/service_test.go index d0d02216..dc98e407 100644 --- a/internal/app/telegramlogin/service_test.go +++ b/internal/app/telegramlogin/service_test.go @@ -94,7 +94,7 @@ func newTelegramLoginTestServiceWithAlgorithms(t *testing.T, now *time.Time, alg pepper[0] = 9 service, err := NewService(loginStore, sealer, Config{ Issuer: "https://oauth.telesrv.test", AppScheme: "telesrv", - AllowLoopbackHTTP: true, ClientSecretPepper: pepper, + AllowHTTP: true, ClientSecretPepper: pepper, SupportedSigningAlgorithms: algorithms, Now: func() time.Time { return *now }, }) diff --git a/internal/app/telegramlogin/url.go b/internal/app/telegramlogin/url.go index dfc9a21a..62ec11ec 100644 --- a/internal/app/telegramlogin/url.go +++ b/internal/app/telegramlogin/url.go @@ -14,8 +14,8 @@ import ( const maxTelegramLoginURLLength = 4096 -func NormalizeRedirectURI(raw string, allowLoopbackHTTP bool) (normalized, domainName string, err error) { - u, err := parseWebURL(raw, allowLoopbackHTTP) +func NormalizeRedirectURI(raw string, allowHTTP bool) (normalized, domainName string, err error) { + u, err := parseWebURL(raw, allowHTTP) if err != nil { return "", "", err } @@ -34,8 +34,8 @@ func NormalizeRedirectURI(raw string, allowLoopbackHTTP bool) (normalized, domai return u.String(), u.Hostname(), nil } -func NormalizeWebOrigin(raw string, allowLoopbackHTTP bool) (string, error) { - u, err := parseWebURL(raw, allowLoopbackHTTP) +func NormalizeWebOrigin(raw string, allowHTTP bool) (string, error) { + u, err := parseWebURL(raw, allowHTTP) if err != nil { return "", err } @@ -46,7 +46,7 @@ func NormalizeWebOrigin(raw string, allowLoopbackHTTP bool) (string, error) { return u.String(), nil } -func parseWebURL(raw string, allowLoopbackHTTP bool) (*url.URL, error) { +func parseWebURL(raw string, allowHTTP bool) (*url.URL, error) { if raw == "" || len(raw) > maxTelegramLoginURLLength || raw != strings.TrimSpace(raw) || strings.IndexFunc(raw, unicode.IsControl) >= 0 { return nil, domain.ErrTelegramLoginURLInvalid } @@ -78,7 +78,7 @@ func parseWebURL(raw string, allowLoopbackHTTP bool) (*url.URL, error) { port = "" } case "http": - if !allowLoopbackHTTP || !isLoopbackHost(host) { + if !allowHTTP { return nil, domain.ErrTelegramLoginURLInvalid } if port == "80" { @@ -99,14 +99,6 @@ func parseWebURL(raw string, allowLoopbackHTTP bool) (*url.URL, error) { return u, nil } -func isLoopbackHost(host string) bool { - if host == "localhost" { - return true - } - ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() -} - func AppendAuthorizationResult(redirectURI, code, state string) (string, error) { u, err := url.Parse(redirectURI) if err != nil || !u.IsAbs() || code == "" { diff --git a/internal/app/telegramlogin/url_test.go b/internal/app/telegramlogin/url_test.go index 17dc4549..49f35aa7 100644 --- a/internal/app/telegramlogin/url_test.go +++ b/internal/app/telegramlogin/url_test.go @@ -18,8 +18,9 @@ func TestNormalizeRedirectURIIsExactAndRejectsOpenRedirectShapes(t *testing.T) { }{ {name: "https canonical", raw: "https://EXAMPLE.com:443/callback?tenant=one", want: "https://example.com/callback?tenant=one", valid: true}, {name: "idna", raw: "https://例子.测试/callback", want: "https://xn--fsqu00a.xn--0zwm56d/callback", valid: true}, - {name: "loopback dev", raw: "http://127.0.0.1:8080/callback", allowHTTP: true, want: "http://127.0.0.1:8080/callback", valid: true}, - {name: "http production", raw: "http://example.com/callback"}, + {name: "http hostname enabled", raw: "http://example.com:8080/callback", allowHTTP: true, want: "http://example.com:8080/callback", valid: true}, + {name: "http ipv4 enabled", raw: "http://192.0.2.25:3000/callback", allowHTTP: true, want: "http://192.0.2.25:3000/callback", valid: true}, + {name: "http disabled", raw: "http://example.com/callback"}, {name: "userinfo", raw: "https://user@example.com/callback"}, {name: "fragment", raw: "https://example.com/callback#token"}, {name: "reserved code", raw: "https://example.com/callback?code=attacker"}, @@ -64,19 +65,19 @@ func TestNormalizeWebOriginRejectsPathAndQuery(t *testing.T) { } } -func TestNormalizeLoopbackIPv6PreservesURLBrackets(t *testing.T) { - origin, err := NormalizeWebOrigin("http://[0:0:0:0:0:0:0:1]:80/", true) +func TestNormalizeHTTPIPv6PreservesURLBrackets(t *testing.T) { + origin, err := NormalizeWebOrigin("http://[2001:db8::25]:80/", true) if err != nil { t.Fatal(err) } - if origin != "http://[0:0:0:0:0:0:0:1]" { + if origin != "http://[2001:db8::25]" { t.Fatalf("origin=%q", origin) } - redirect, domainName, err := NormalizeRedirectURI("http://[::1]/callback", true) + redirect, domainName, err := NormalizeRedirectURI("http://[2001:db8::26]:3000/callback", true) if err != nil { t.Fatal(err) } - if redirect != "http://[::1]/callback" || domainName != "::1" { + if redirect != "http://[2001:db8::26]:3000/callback" || domainName != "2001:db8::26" { t.Fatalf("redirect=%q domain=%q", redirect, domainName) } } diff --git a/internal/botapi/server.go b/internal/botapi/server.go index 333fe288..ff3b3df8 100644 --- a/internal/botapi/server.go +++ b/internal/botapi/server.go @@ -1077,11 +1077,18 @@ func validateWebhookURL(raw string) error { return errors.New("WEBHOOK_URL_INVALID") } u, err := neturl.ParseRequestURI(raw) - if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Fragment != "" { + if err != nil { return errors.New("WEBHOOK_URL_INVALID") } - if port := u.Port(); port != "" && port != "443" && port != "80" && port != "88" && port != "8443" { - return errors.New("WEBHOOK_PORT_NOT_ALLOWED") + scheme := strings.ToLower(u.Scheme) + if (scheme != "http" && scheme != "https") || u.Hostname() == "" || u.User != nil || u.Fragment != "" { + return errors.New("WEBHOOK_URL_INVALID") + } + if port := u.Port(); port != "" { + n, err := strconv.Atoi(port) + if err != nil || n < 1 || n > 65535 { + return errors.New("WEBHOOK_URL_INVALID") + } } return nil } diff --git a/internal/botapi/server_test.go b/internal/botapi/server_test.go index 1bbc014a..b4b36fdc 100644 --- a/internal/botapi/server_test.go +++ b/internal/botapi/server_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "mime/multipart" "net/http" "net/http/httptest" @@ -977,6 +978,23 @@ func TestSetWebhookPersistsConfigReportsInfoAndConflictsWithPolling(t *testing.T } } +func TestSetWebhookAcceptsHTTPHostIPAndArbitraryPort(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + for _, rawURL := range []string{ + "http://bot.example.test:3000/hook", + "http://192.0.2.25:18080/hook", + "http://[2001:db8::25]:28080/hook", + "HTTP://bot.example.test:3100/hook", + } { + gateway := &fakeBotAPIGateway{} + h := (&handler{bots: bots, gateway: gateway}).routes() + rec := performBotAPIRequest(t, h, bots.profile, "setWebhook", fmt.Sprintf(`{"url":%q}`, rawURL)) + if rec.Code != http.StatusOK || !gateway.webhookFound || gateway.webhook.URL != rawURL { + t.Fatalf("setWebhook url=%q status=%d body=%s config=%#v", rawURL, rec.Code, rec.Body.String(), gateway.webhook) + } + } +} + func TestSetWebhookRejectsUnsafeParameters(t *testing.T) { bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} h := (&handler{bots: bots, gateway: &fakeBotAPIGateway{}}).routes() @@ -984,8 +1002,9 @@ func TestSetWebhookRejectsUnsafeParameters(t *testing.T) { body string want string }{ - {`{"url":"http://example.test/hook"}`, "WEBHOOK_URL_INVALID"}, - {`{"url":"https://example.test:444/hook"}`, "WEBHOOK_PORT_NOT_ALLOWED"}, + {`{"url":"ftp://example.test/hook"}`, "WEBHOOK_URL_INVALID"}, + {`{"url":"http://user@example.test/hook"}`, "WEBHOOK_URL_INVALID"}, + {`{"url":"http://example.test:0/hook"}`, "WEBHOOK_URL_INVALID"}, {`{"url":"https://example.test/hook","secret_token":"bad secret"}`, "SECRET_TOKEN_INVALID"}, {`{"url":"https://example.test/hook","max_connections":101}`, "MAX_CONNECTIONS_INVALID"}, } diff --git a/internal/config/config.go b/internal/config/config.go index 3c08d7dd..72fcbd47 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,7 +4,6 @@ package config import ( "bufio" "fmt" - "net" "net/netip" "net/url" "os" @@ -93,9 +92,11 @@ type Config struct { // TelegramLoginEnabled mounts the self-hosted Telegram Login/OIDC provider // on PublicLinkWebAddr. Secrets are file-backed so they are not exposed in // process listings or accidentally copied into tracked .env templates. - TelegramLoginEnabled bool - TelegramLoginIssuer string - TelegramLoginAllowLoopbackHTTP bool + TelegramLoginEnabled bool + TelegramLoginIssuer string + // TelegramLoginAllowHTTP permits HTTP issuers and registered Login URLs on + // any valid host/IP and port. HTTPS remains mandatory when false. + TelegramLoginAllowHTTP bool TelegramLoginSigningKeysFile string TelegramLoginCodeKeysFile string TelegramLoginSecretPepperFile string @@ -491,7 +492,7 @@ func Load() (Config, error) { PublicLinkWebAddr: envAllowEmptyOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""), TelegramLoginEnabled: envBoolOr("TELESRV_TELEGRAM_LOGIN_ENABLE", false), TelegramLoginIssuer: strings.TrimSuffix(envOr("TELESRV_TELEGRAM_LOGIN_ISSUER", publicBaseURL), "/"), - TelegramLoginAllowLoopbackHTTP: envBoolOr("TELESRV_TELEGRAM_LOGIN_ALLOW_LOOPBACK_HTTP", false), + TelegramLoginAllowHTTP: envBoolOr("TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP", false), TelegramLoginSigningKeysFile: envOr("TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE", "data/telegram-login/signing-keys.json"), TelegramLoginCodeKeysFile: envOr("TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE", "data/telegram-login/code-keys.json"), TelegramLoginSecretPepperFile: envOr("TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE", "data/telegram-login/client-secret-pepper"), @@ -681,10 +682,8 @@ func validateTelegramLoginConfig(cfg Config) error { switch issuer.Scheme { case "https": case "http": - host := issuer.Hostname() - ip := net.ParseIP(host) - if !cfg.TelegramLoginAllowLoopbackHTTP || (host != "localhost" && (ip == nil || !ip.IsLoopback())) { - return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER http is allowed only for explicit loopback development") + if !cfg.TelegramLoginAllowHTTP { + return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER http requires TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=true") } default: return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER must use https") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7d9b35cf..0339fb40 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -435,8 +435,8 @@ func TestLoadTelegramLoginConfig(t *testing.T) { disableDefaultConfigFile(t) t.Setenv("TELESRV_PUBLIC_LINK_WEB_ADDR", "127.0.0.1:2401") t.Setenv("TELESRV_TELEGRAM_LOGIN_ENABLE", "true") - t.Setenv("TELESRV_TELEGRAM_LOGIN_ISSUER", "http://127.0.0.1:2401/") - t.Setenv("TELESRV_TELEGRAM_LOGIN_ALLOW_LOOPBACK_HTTP", "true") + t.Setenv("TELESRV_TELEGRAM_LOGIN_ISSUER", "http://192.0.2.25:2401/") + t.Setenv("TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP", "true") t.Setenv("TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE", "secrets/signing.json") t.Setenv("TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE", "secrets/codes.json") t.Setenv("TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE", "secrets/pepper") @@ -452,8 +452,8 @@ func TestLoadTelegramLoginConfig(t *testing.T) { if err != nil { t.Fatalf("Load: %v", err) } - if !cfg.TelegramLoginEnabled || cfg.TelegramLoginIssuer != "http://127.0.0.1:2401" || !cfg.TelegramLoginAllowLoopbackHTTP { - t.Fatalf("telegram login endpoint config = enabled:%v issuer:%q loopback:%v", cfg.TelegramLoginEnabled, cfg.TelegramLoginIssuer, cfg.TelegramLoginAllowLoopbackHTTP) + if !cfg.TelegramLoginEnabled || cfg.TelegramLoginIssuer != "http://192.0.2.25:2401" || !cfg.TelegramLoginAllowHTTP { + t.Fatalf("telegram login endpoint config = enabled:%v issuer:%q allow_http:%v", cfg.TelegramLoginEnabled, cfg.TelegramLoginIssuer, cfg.TelegramLoginAllowHTTP) } if cfg.TelegramLoginSigningKeysFile != "secrets/signing.json" || cfg.TelegramLoginCodeKeysFile != "secrets/codes.json" || cfg.TelegramLoginSecretPepperFile != "secrets/pepper" { t.Fatalf("telegram login secret files = %q / %q / %q", cfg.TelegramLoginSigningKeysFile, cfg.TelegramLoginCodeKeysFile, cfg.TelegramLoginSecretPepperFile) @@ -484,11 +484,7 @@ func TestValidateTelegramLoginConfigRejectsUnsafeOrUnboundedSettings(t *testing. }{ {name: "missing listener", mutate: func(c *Config) { c.PublicLinkWebAddr = "" }}, {name: "issuer path", mutate: func(c *Config) { c.TelegramLoginIssuer = "https://login.example.test/oauth" }}, - {name: "public http", mutate: func(c *Config) { - c.TelegramLoginIssuer = "http://login.example.test" - c.TelegramLoginAllowLoopbackHTTP = true - }}, - {name: "loopback http disabled", mutate: func(c *Config) { c.TelegramLoginIssuer = "http://127.0.0.1:2401" }}, + {name: "http disabled", mutate: func(c *Config) { c.TelegramLoginIssuer = "http://192.0.2.25:2401" }}, {name: "missing key file", mutate: func(c *Config) { c.TelegramLoginSigningKeysFile = "" }}, {name: "request ttl too long", mutate: func(c *Config) { c.TelegramLoginRequestTTL = 16 * time.Minute }}, {name: "code ttl too short", mutate: func(c *Config) { c.TelegramLoginCodeTTL = 29 * time.Second }}, @@ -508,6 +504,22 @@ func TestValidateTelegramLoginConfigRejectsUnsafeOrUnboundedSettings(t *testing. } } +func TestValidateTelegramLoginConfigAcceptsHTTPHostAndIPWhenEnabled(t *testing.T) { + valid := Config{ + TelegramLoginEnabled: true, TelegramLoginAllowHTTP: true, PublicLinkWebAddr: "127.0.0.1:2401", + TelegramLoginSigningKeysFile: "signing.json", TelegramLoginCodeKeysFile: "codes.json", TelegramLoginSecretPepperFile: "pepper", + TelegramLoginRequestTTL: 5 * time.Minute, TelegramLoginCodeTTL: 2 * time.Minute, TelegramLoginIDTokenTTL: time.Hour, + TelegramLoginRetention: 7 * 24 * time.Hour, TelegramLoginSweepInterval: 5 * time.Minute, TelegramLoginSweepBatch: 500, + } + for _, issuer := range []string{"http://login.example.test:3000", "http://192.0.2.25:2401", "http://[2001:db8::25]:2401"} { + cfg := valid + cfg.TelegramLoginIssuer = issuer + if err := validateTelegramLoginConfig(cfg); err != nil { + t.Fatalf("issuer %q was rejected: %v", issuer, err) + } + } +} + func TestLoadRejectsInvalidPublicBaseURL(t *testing.T) { disableDefaultConfigFile(t) t.Setenv("TELESRV_PUBLIC_BASE_URL", "https://links.example.test/root?tenant=one") diff --git a/internal/domain/message_markup.go b/internal/domain/message_markup.go index 9196d574..da638c88 100644 --- a/internal/domain/message_markup.go +++ b/internal/domain/message_markup.go @@ -2,7 +2,6 @@ package domain import ( "errors" - "net" "net/url" "strings" "unicode/utf8" @@ -397,8 +396,9 @@ func validateButtonURL(raw string) error { // validateLoginButtonURL performs only the protocol-shape validation shared by // Bot API and MTProto input buttons. The Telegram Login service remains the -// authority for the deployment policy: it rejects loopback HTTP unless the -// explicit development switch is enabled and the exact origin is registered. +// authority for the deployment policy: HTTP is accepted here as a protocol +// shape, then allowed only when the Login HTTP switch is enabled and the exact +// origin is registered. func validateLoginButtonURL(raw string) error { raw = strings.TrimSpace(raw) if raw == "" || len(raw) > MaxBotMenuButtonURLLen { @@ -408,15 +408,8 @@ func validateLoginButtonURL(raw string) error { if err != nil || u.Host == "" || u.User != nil { return ErrButtonURLInvalid } - if u.Scheme == "https" { - return nil - } - if u.Scheme != "http" { - return ErrButtonURLInvalid - } - host := strings.ToLower(u.Hostname()) - ip := net.ParseIP(host) - if host != "localhost" && (ip == nil || !ip.IsLoopback()) { + scheme := strings.ToLower(u.Scheme) + if scheme != "http" && scheme != "https" { return ErrButtonURLInvalid } return nil diff --git a/internal/domain/message_markup_test.go b/internal/domain/message_markup_test.go index 8ff348ed..76a27755 100644 --- a/internal/domain/message_markup_test.go +++ b/internal/domain/message_markup_test.go @@ -28,7 +28,8 @@ func TestValidateReplyMarkup(t *testing.T) { {"url empty bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: ""}}}}, ErrButtonURLInvalid}, {"login url loopback http ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://127.0.0.1:8080/login"}}}}, nil}, {"login url localhost http ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://localhost:8080/login"}}}}, nil}, - {"login url public http bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://example.com/login"}}}}, ErrButtonURLInvalid}, + {"login url public http host ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://example.com:3000/login"}}}}, nil}, + {"login url public http ip ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://192.0.2.25:18080/login"}}}}, nil}, {"login url credentials bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "https://user@example.com/login"}}}}, ErrButtonURLInvalid}, {"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "rainbow", Text: "x"}}}}, ErrButtonTypeInvalid}, {"reply keyboard ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}, Resize: true, Persistent: true, Placeholder: "Choose"}, nil}, diff --git a/internal/rpc/telegram_login_rpc_test.go b/internal/rpc/telegram_login_rpc_test.go index 6b22ce70..dc1c31f6 100644 --- a/internal/rpc/telegram_login_rpc_test.go +++ b/internal/rpc/telegram_login_rpc_test.go @@ -261,7 +261,7 @@ func TestTelegramLoginMessageButtonRereadSignsAndGrantsWriteAccess(t *testing.T) pepper[0] = 8 loginStore := memory.NewTelegramLoginStore(telegramLoginBotPermissionAdapter{bots: f.router.deps.Bots}) login, err := telegramloginapp.NewService(loginStore, sealer, telegramloginapp.Config{ - Issuer: "https://oauth.test", AppScheme: "telesrv", ClientSecretPepper: pepper, + Issuer: "http://192.0.2.25:2401", AppScheme: "telesrv", AllowHTTP: true, ClientSecretPepper: pepper, Now: func() time.Time { return time.Unix(1_780_000_000, 0).UTC() }, }) if err != nil { @@ -270,13 +270,13 @@ func TestTelegramLoginMessageButtonRereadSignsAndGrantsWriteAccess(t *testing.T) if _, err := login.CreateClient(f.ctx, f.bot.ID, domain.TelegramLoginSigningRS256); err != nil { t.Fatal(err) } - if _, err := login.AddAllowedURL(f.ctx, f.bot.ID, domain.TelegramLoginAllowedWebOrigin, "https://rp.test"); err != nil { + if _, err := login.AddAllowedURL(f.ctx, f.bot.ID, domain.TelegramLoginAllowedWebOrigin, "http://rp.test:3000"); err != nil { t.Fatal(err) } f.router.deps.TelegramLogin = login markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{ - Type: domain.MarkupButtonLoginURL, Text: "Log in", URL: "https://rp.test/login?next=%2Fhome", RequestWriteAccess: true, + Type: domain.MarkupButtonLoginURL, Text: "Log in", URL: "http://rp.test:3000/login?next=%2Fhome", RequestWriteAccess: true, }}}} if _, err := f.router.BotAPISendMessage(f.ctx, f.bot.ID, f.owner.ID, "Authorize", nil, markup, false, false, 0); err != nil { t.Fatalf("BotAPISendMessage: %v", err) diff --git a/internal/telegramloginhttp/handler.go b/internal/telegramloginhttp/handler.go index b977b76d..feda7894 100644 --- a/internal/telegramloginhttp/handler.go +++ b/internal/telegramloginhttp/handler.go @@ -40,18 +40,18 @@ type Config struct { AppName string Logger *zap.Logger TrustedProxyCIDRs []string - AllowLoopbackHTTP bool + AllowHTTP bool } type Handler struct { - service *loginapp.Service - tokens *loginapp.IDTokenIssuer - appName string - logger *zap.Logger - limiter RateLimiter - trustedProxies []netip.Prefix - allowLoopbackHTTP bool - mux *http.ServeMux + service *loginapp.Service + tokens *loginapp.IDTokenIssuer + appName string + logger *zap.Logger + limiter RateLimiter + trustedProxies []netip.Prefix + allowHTTP bool + mux *http.ServeMux } type RateLimiter interface { @@ -76,7 +76,7 @@ func NewHandler(cfg Config) (*Handler, error) { } trustedProxies = append(trustedProxies, prefix.Masked()) } - h := &Handler{service: cfg.Service, tokens: cfg.Tokens, appName: strings.TrimSpace(cfg.AppName), logger: cfg.Logger, limiter: cfg.Limiter, trustedProxies: trustedProxies, allowLoopbackHTTP: cfg.AllowLoopbackHTTP} + h := &Handler{service: cfg.Service, tokens: cfg.Tokens, appName: strings.TrimSpace(cfg.AppName), logger: cfg.Logger, limiter: cfg.Limiter, trustedProxies: trustedProxies, allowHTTP: cfg.AllowHTTP} mux := http.NewServeMux() mux.HandleFunc("GET /.well-known/openid-configuration", h.discovery) mux.HandleFunc("GET /.well-known/jwks.json", h.jwks) @@ -333,7 +333,7 @@ func (h *Handler) inApp(w http.ResponseWriter, r *http.Request) { writeOAuthError(w, http.StatusBadRequest, "unsupported_response_type", "only id_token is supported") return } - origin, err := loginapp.NormalizeWebOrigin(values["origin"], h.allowLoopbackHTTP) + origin, err := loginapp.NormalizeWebOrigin(values["origin"], h.allowHTTP) if err != nil || r.Header.Get("Origin") != origin { writeOAuthError(w, http.StatusBadRequest, "invalid_request", "in-app origin is invalid") return @@ -511,7 +511,7 @@ func (h *Handler) authorizeStatusOrigin(w http.ResponseWriter, r *http.Request, if origin == "" { return true } - issuerOrigin, err := loginapp.NormalizeWebOrigin(h.tokens.Issuer(), h.allowLoopbackHTTP) + issuerOrigin, err := loginapp.NormalizeWebOrigin(h.tokens.Issuer(), h.allowHTTP) if err == nil && origin == issuerOrigin { return true } diff --git a/internal/telegramloginhttp/handler_test.go b/internal/telegramloginhttp/handler_test.go index 2563de45..9b7abb16 100644 --- a/internal/telegramloginhttp/handler_test.go +++ b/internal/telegramloginhttp/handler_test.go @@ -109,7 +109,7 @@ func newTelegramLoginHTTPFixture(t *testing.T) telegramLoginHTTPFixture { if err != nil { t.Fatal(err) } - handler, err := NewHandler(Config{Service: service, Tokens: tokens, AppName: "Telesrv", AllowLoopbackHTTP: true}) + handler, err := NewHandler(Config{Service: service, Tokens: tokens, AppName: "Telesrv", AllowHTTP: true}) if err != nil { t.Fatal(err) }