feat: sync Telegram Login OIDC provider
This commit is contained in:
parent
30774f8c39
commit
ebead9e98c
63 changed files with 11374 additions and 37 deletions
|
|
@ -1,4 +1,4 @@
|
|||
# Bedolaga formatted-text demo
|
||||
# Bedolaga formatted-text + Telegram Login demo
|
||||
|
||||
这个 demo 复刻 Bedolaga 的 Bot 工厂关键配置:
|
||||
|
||||
|
|
@ -67,3 +67,61 @@ $env:TELESRV_BOT_API_SERVER = "http://127.0.0.1:8081"
|
|||
|
||||
`--base-url` 只接受 API server 根地址,不要追加 `/bot`。脚本不会打印 token,也不会
|
||||
把 token 写入文件。
|
||||
|
||||
## Telegram Login 全链路
|
||||
|
||||
同一个 demo 还提供 `/logindemo`,覆盖三个互相独立的公开契约:
|
||||
|
||||
1. Bot API `login_url` 按钮 → TDesktop/Android 的
|
||||
`messages.requestUrlAuth/acceptUrlAuth` → legacy HMAC 回调;
|
||||
2. telesrv 本地 `/telegram-login.js` → popup `postMessage` → JWKS 验签;
|
||||
3. 服务端 Authorization Code + PKCE S256 → `/token` Basic Client Secret →
|
||||
JWKS 验签和 `issuer/audience/nonce/subject` 复核。
|
||||
|
||||
先在 telesrv 的 @BotFather 中对目标 bot 运行 `/setlogin`。选择 bot 后逐条登记 demo
|
||||
的精确 origin 和 callback(本机示例):
|
||||
|
||||
```text
|
||||
add origin http://127.0.0.1:3000
|
||||
add redirect http://127.0.0.1:3000/oauth/callback
|
||||
enable
|
||||
```
|
||||
|
||||
`/setlogin` 首次创建 client 时只展示一次 OIDC Client Secret;不要写进仓库。可用
|
||||
`/logininfo` 查看 Client ID 和登记结果,或用 `/resetloginsecret` 轮换 secret。
|
||||
loopback HTTP 仅应配合 telesrv 的显式开发开关使用;testserver/生产必须换成精确
|
||||
HTTPS origin。
|
||||
|
||||
把一次性 secret 和 Client ID 放入进程环境,再启动:
|
||||
|
||||
```powershell
|
||||
$env:TELESRV_BOT_LOGIN_DEMO = "1"
|
||||
$env:TELESRV_BOT_LOGIN_ISSUER = "http://127.0.0.1:2401"
|
||||
$env:TELESRV_BOT_LOGIN_CLIENT_ID = "<numeric bot user id>"
|
||||
$env:TELESRV_BOT_LOGIN_CLIENT_SECRET = "<one-time OIDC client secret>"
|
||||
$env:TELESRV_BOT_LOGIN_PUBLIC_URL = "http://127.0.0.1:3000"
|
||||
$env:TELESRV_BOT_LOGIN_LISTEN = "127.0.0.1:3000"
|
||||
|
||||
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" `
|
||||
.\cmd\bots\bedolagaformat\demo.py --drop-pending --login-demo
|
||||
```
|
||||
|
||||
向 bot 发送 `/logindemo`。第一颗按钮必须出现 Telegram 客户端原生授权确认框,批准
|
||||
后网页显示 `login_url HMAC verified`;第二颗按钮打开测试页,可分别运行 JS SDK popup
|
||||
和 Authorization Code + PKCE。页面只展示验签后的 claims,不展示 access token 或
|
||||
Client Secret。省略 `TELESRV_BOT_LOGIN_CLIENT_SECRET` 时仍可验证 JS popup,但服务端
|
||||
code flow 会明确禁用。
|
||||
|
||||
demo 的 flow/state/nonce 只保存在单进程内存中,带 10 分钟过期和 256 条上限,专用于
|
||||
本地与 testserver 端到端验证,不是生产 relying-party 实现。官方 iOS/Android SDK
|
||||
目前把 `https://oauth.telegram.org` 写死;验证自建 issuer 时需使用项目记录的最小
|
||||
base-URL patch 或等价测试构建,不能把官方生产 SDK 未修改的结果误判为自建服务结果。
|
||||
|
||||
测试命令:
|
||||
|
||||
```powershell
|
||||
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" `
|
||||
.\cmd\bots\bedolagaformat\test_demo.py -v
|
||||
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" `
|
||||
.\cmd\bots\bedolagaformat\test_login_demo.py -v
|
||||
```
|
||||
|
|
|
|||
|
|
@ -27,15 +27,27 @@ from aiogram.types import (
|
|||
InlineKeyboardButton,
|
||||
InlineKeyboardMarkup,
|
||||
InputRichMessage,
|
||||
LoginUrl,
|
||||
Message,
|
||||
)
|
||||
|
||||
from login_demo import (
|
||||
LoginDemoConfig,
|
||||
LoginDemoServer,
|
||||
normalize_web_base,
|
||||
parse_listen,
|
||||
)
|
||||
|
||||
|
||||
LOG = logging.getLogger("bedolagaformat")
|
||||
MARKER_RE = re.compile(r"^[A-Za-z0-9-]{1,64}$")
|
||||
MARKDOWN_V2_RESERVED_RE = re.compile(r"([_\*\[\]\(\)~`>#+\-=|{}\.!\\])")
|
||||
|
||||
|
||||
def env_flag(name: str) -> bool:
|
||||
return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FormatSample:
|
||||
name: str
|
||||
|
|
@ -121,6 +133,34 @@ def parse_args() -> argparse.Namespace:
|
|||
parser.add_argument("--polling-timeout", type=int, default=10)
|
||||
parser.add_argument("--marker", default=default_marker())
|
||||
parser.add_argument("--log-level", default="INFO")
|
||||
parser.add_argument(
|
||||
"--login-demo",
|
||||
action="store_true",
|
||||
default=env_flag("TELESRV_BOT_LOGIN_DEMO"),
|
||||
help="serve and send the Bedolaga Telegram Login/OIDC demo",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--login-issuer",
|
||||
default=os.getenv("TELESRV_BOT_LOGIN_ISSUER", "http://127.0.0.1:2401"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--login-client-id",
|
||||
default=os.getenv("TELESRV_BOT_LOGIN_CLIENT_ID", ""),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--login-client-secret",
|
||||
default=os.getenv("TELESRV_BOT_LOGIN_CLIENT_SECRET", ""),
|
||||
help="confidential OIDC secret; never printed (optional for JS-only demo)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--login-public-url",
|
||||
default=os.getenv("TELESRV_BOT_LOGIN_PUBLIC_URL", "http://127.0.0.1:3000"),
|
||||
help="registered origin where this demo is reachable",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--login-listen",
|
||||
default=os.getenv("TELESRV_BOT_LOGIN_LISTEN", "127.0.0.1:3000"),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if not args.token:
|
||||
parser.error("missing --token or TELESRV_BOT_TOKEN")
|
||||
|
|
@ -132,6 +172,24 @@ def parse_args() -> argparse.Namespace:
|
|||
parser.error("--marker must contain 1-64 ASCII letters, digits, or hyphens")
|
||||
if not 0 <= args.polling_timeout <= 50:
|
||||
parser.error("--polling-timeout must be between 0 and 50")
|
||||
args.login_config = None
|
||||
if args.login_demo:
|
||||
if not re.fullmatch(r"[0-9]{1,64}", args.login_client_id):
|
||||
parser.error("--login-demo requires a numeric --login-client-id")
|
||||
try:
|
||||
issuer = normalize_web_base(args.login_issuer, name="login issuer")
|
||||
public_url = normalize_web_base(args.login_public_url, name="login public URL")
|
||||
listen_host, listen_port = parse_listen(args.login_listen)
|
||||
except ValueError as exc:
|
||||
parser.error(str(exc))
|
||||
args.login_config = LoginDemoConfig(
|
||||
issuer=issuer,
|
||||
client_id=args.login_client_id,
|
||||
client_secret=args.login_client_secret,
|
||||
public_url=public_url,
|
||||
listen_host=listen_host,
|
||||
listen_port=listen_port,
|
||||
)
|
||||
return args
|
||||
|
||||
|
||||
|
|
@ -207,6 +265,39 @@ def rich_menu_keyboard() -> InlineKeyboardMarkup:
|
|||
)
|
||||
|
||||
|
||||
def login_demo_keyboard(config: LoginDemoConfig) -> InlineKeyboardMarkup:
|
||||
"""Exercise both Telegram's login_url button and a plain OIDC page URL."""
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="Log in with Telegram",
|
||||
login_url=LoginUrl(
|
||||
url=config.public_url + "/",
|
||||
forward_text="Bedolaga Login",
|
||||
request_write_access=True,
|
||||
),
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text="Open OIDC test page", url=config.public_url + "/")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def send_login_demo(bot: Bot, chat_id: int, marker: str, config: LoginDemoConfig) -> int:
|
||||
message = await bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=(
|
||||
f"<b>{marker} Telegram Login</b>\n"
|
||||
"The first button validates Bot API <code>login_url</code>; "
|
||||
"the second page validates the local JS SDK and OIDC + PKCE."
|
||||
),
|
||||
reply_markup=login_demo_keyboard(config),
|
||||
)
|
||||
LOG.info("sent Telegram Login demo chat_id=%s message_id=%s", chat_id, message.message_id)
|
||||
return message.message_id
|
||||
|
||||
|
||||
def is_rich_media_retry_error(exc: TelegramBadRequest) -> bool:
|
||||
message = str(exc).lower()
|
||||
return "webpage_" in message or "media_empty" in message or "media_invalid" in message
|
||||
|
|
@ -259,7 +350,7 @@ async def send_rich_suite(bot: Bot, chat_id: int, marker: str) -> list[int]:
|
|||
return ids
|
||||
|
||||
|
||||
def build_dispatcher(marker: str) -> Dispatcher:
|
||||
def build_dispatcher(marker: str, login_config: LoginDemoConfig | None = None) -> Dispatcher:
|
||||
router = Router(name="telesrv-bedolaga-format")
|
||||
|
||||
@router.message(CommandStart())
|
||||
|
|
@ -291,6 +382,22 @@ def build_dispatcher(marker: str) -> Dispatcher:
|
|||
ids,
|
||||
)
|
||||
|
||||
@router.message(Command("logindemo"))
|
||||
async def login_demo(message: Message) -> None:
|
||||
if login_config is None:
|
||||
await message.answer(
|
||||
"<b>Telegram Login demo is disabled.</b> Start this program with "
|
||||
"<code>--login-demo</code>."
|
||||
)
|
||||
return
|
||||
message_id = await send_login_demo(message.bot, message.chat.id, marker, login_config)
|
||||
LOG.info(
|
||||
"handled /logindemo chat_id=%s incoming_message_id=%s sent_message_id=%s",
|
||||
message.chat.id,
|
||||
message.message_id,
|
||||
message_id,
|
||||
)
|
||||
|
||||
dispatcher = Dispatcher()
|
||||
dispatcher.include_router(router)
|
||||
return dispatcher
|
||||
|
|
@ -298,7 +405,10 @@ def build_dispatcher(marker: str) -> Dispatcher:
|
|||
|
||||
async def run(args: argparse.Namespace) -> None:
|
||||
bot = create_bot(args.token, args.base_url)
|
||||
login_server = LoginDemoServer(args.login_config, args.token) if args.login_config else None
|
||||
try:
|
||||
if login_server is not None:
|
||||
await login_server.start()
|
||||
me = await bot.get_me()
|
||||
LOG.info(
|
||||
"authenticated bot_id=%s username=@%s bot_api=%s marker=%s",
|
||||
|
|
@ -312,12 +422,17 @@ async def run(args: argparse.Namespace) -> None:
|
|||
await send_format_suite(bot, args.send_chat_id, args.marker)
|
||||
if args.rich_menu or args.rich_only:
|
||||
await send_rich_suite(bot, args.send_chat_id, args.marker)
|
||||
if args.login_config is not None:
|
||||
await send_login_demo(bot, args.send_chat_id, args.marker, args.login_config)
|
||||
if args.send_only:
|
||||
return
|
||||
|
||||
await bot.delete_webhook(drop_pending_updates=args.drop_pending)
|
||||
dispatcher = build_dispatcher(args.marker)
|
||||
LOG.info("polling started; send /start, /formatdemo or /richdemo to @%s", me.username or me.id)
|
||||
dispatcher = build_dispatcher(args.marker, args.login_config)
|
||||
LOG.info(
|
||||
"polling started; send /start, /formatdemo, /richdemo or /logindemo to @%s",
|
||||
me.username or me.id,
|
||||
)
|
||||
await dispatcher.start_polling(
|
||||
bot,
|
||||
allowed_updates=["message"],
|
||||
|
|
@ -325,6 +440,8 @@ async def run(args: argparse.Namespace) -> None:
|
|||
close_bot_session=False,
|
||||
)
|
||||
finally:
|
||||
if login_server is not None:
|
||||
await login_server.close()
|
||||
await bot.session.close()
|
||||
|
||||
|
||||
|
|
|
|||
462
cmd/bots/bedolagaformat/login_demo.py
Normal file
462
cmd/bots/bedolagaformat/login_demo.py
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
"""Local Bedolaga Telegram Login/OIDC relying-party demo.
|
||||
|
||||
This is deliberately a relying party, not a shortcut into telesrv internals. It
|
||||
validates the three public contracts used by a Bedolaga-style bot:
|
||||
|
||||
* Bot API ``login_url`` legacy HMAC callbacks;
|
||||
* the self-hosted Telegram Login JavaScript SDK ``post_message`` response; and
|
||||
* confidential authorization-code + PKCE followed by JWKS ID-token validation.
|
||||
|
||||
The demo keeps its short-lived browser flows in memory. It is intended for
|
||||
local/end-to-end verification only and must not be used as a production login
|
||||
backend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import hmac
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
from aiohttp import BasicAuth, ClientSession, ClientTimeout, web
|
||||
import jwt
|
||||
|
||||
|
||||
LOG = logging.getLogger("bedolagaformat.login")
|
||||
FLOW_TTL_SECONDS = 10 * 60
|
||||
MAX_PENDING_FLOWS = 256
|
||||
LEGACY_AUTH_MAX_AGE_SECONDS = 15 * 60
|
||||
OIDC_ALGORITHMS = ("RS256", "ES256", "EdDSA", "ES256K")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoginDemoConfig:
|
||||
issuer: str
|
||||
client_id: str
|
||||
client_secret: str
|
||||
public_url: str
|
||||
listen_host: str
|
||||
listen_port: int
|
||||
|
||||
@property
|
||||
def redirect_uri(self) -> str:
|
||||
return self.public_url + "/oauth/callback"
|
||||
|
||||
@property
|
||||
def origin(self) -> str:
|
||||
parsed = urlsplit(self.public_url)
|
||||
return f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
@property
|
||||
def code_flow_enabled(self) -> bool:
|
||||
return bool(self.client_secret)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PendingFlow:
|
||||
nonce: str
|
||||
expires_at: float
|
||||
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)
|
||||
if (
|
||||
parsed.scheme not in {"http", "https"}
|
||||
or not parsed.netloc
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
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}"
|
||||
|
||||
|
||||
def parse_listen(value: str) -> tuple[str, int]:
|
||||
parsed = urlsplit("//" + value.strip())
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise ValueError("login demo listen port is invalid") from exc
|
||||
if not parsed.hostname or port is None or not 1 <= port <= 65535:
|
||||
raise ValueError("login demo listen address must be host:port")
|
||||
return parsed.hostname, port
|
||||
|
||||
|
||||
def base64url(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def generate_pkce() -> tuple[str, str]:
|
||||
verifier = base64url(secrets.token_bytes(32))
|
||||
challenge = base64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
def verify_legacy_login_query(
|
||||
query: dict[str, str], bot_token: str, *, now: int | None = None
|
||||
) -> dict[str, str]:
|
||||
"""Verify Telegram's legacy login_url data-check string.
|
||||
|
||||
Only the documented signed identity fields participate. Query parameters
|
||||
already present on the relying-party URL are intentionally excluded.
|
||||
"""
|
||||
|
||||
signed_names = (
|
||||
"auth_date",
|
||||
"first_name",
|
||||
"id",
|
||||
"last_name",
|
||||
"photo_url",
|
||||
"username",
|
||||
)
|
||||
supplied_hash = query.get("hash", "")
|
||||
if len(supplied_hash) != 64:
|
||||
raise ValueError("missing legacy login signature")
|
||||
values = {name: query[name] for name in signed_names if name in query}
|
||||
if not all(values.get(name) for name in ("auth_date", "first_name", "id")):
|
||||
raise ValueError("incomplete legacy login payload")
|
||||
try:
|
||||
auth_date = int(values["auth_date"])
|
||||
user_id = int(values["id"])
|
||||
except ValueError as exc:
|
||||
raise ValueError("invalid legacy login payload") from exc
|
||||
current = int(time.time()) if now is None else now
|
||||
if user_id <= 0 or auth_date > current + 30 or current - auth_date > LEGACY_AUTH_MAX_AGE_SECONDS:
|
||||
raise ValueError("expired legacy login payload")
|
||||
data_check = "\n".join(f"{name}={values[name]}" for name in sorted(values))
|
||||
secret_key = hashlib.sha256(bot_token.encode("utf-8")).digest()
|
||||
actual = hmac.new(secret_key, data_check.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(actual, supplied_hash.lower()):
|
||||
raise ValueError("invalid legacy login signature")
|
||||
return values
|
||||
|
||||
|
||||
def _safe_claims(claims: dict[str, Any]) -> dict[str, Any]:
|
||||
allowed = (
|
||||
"iss",
|
||||
"aud",
|
||||
"sub",
|
||||
"iat",
|
||||
"exp",
|
||||
"nonce",
|
||||
"id",
|
||||
"name",
|
||||
"given_name",
|
||||
"family_name",
|
||||
"preferred_username",
|
||||
"picture",
|
||||
"phone_number",
|
||||
"phone_number_verified",
|
||||
)
|
||||
return {key: claims[key] for key in allowed if key in claims}
|
||||
|
||||
|
||||
class LoginDemoServer:
|
||||
def __init__(self, config: LoginDemoConfig, bot_token: str) -> None:
|
||||
self.config = config
|
||||
self.bot_token = bot_token
|
||||
self._flows: dict[str, PendingFlow] = {}
|
||||
self._flow_lock = asyncio.Lock()
|
||||
self._http: ClientSession | None = None
|
||||
self._runner: web.AppRunner | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
timeout = ClientTimeout(total=10)
|
||||
self._http = ClientSession(timeout=timeout)
|
||||
app = web.Application(client_max_size=64 * 1024)
|
||||
app.add_routes(
|
||||
[
|
||||
web.get("/", self.root),
|
||||
web.get("/login/code", self.start_code_flow),
|
||||
web.get("/oauth/callback", self.code_callback),
|
||||
web.post("/verify-popup", self.verify_popup),
|
||||
web.get("/healthz", self.health),
|
||||
]
|
||||
)
|
||||
self._runner = web.AppRunner(app, access_log=None)
|
||||
await self._runner.setup()
|
||||
site = web.TCPSite(self._runner, self.config.listen_host, self.config.listen_port)
|
||||
await site.start()
|
||||
LOG.info(
|
||||
"Telegram Login demo listening at %s (issuer=%s client_id=%s)",
|
||||
self.config.public_url,
|
||||
self.config.issuer,
|
||||
self.config.client_id,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._runner is not None:
|
||||
await self._runner.cleanup()
|
||||
self._runner = None
|
||||
if self._http is not None:
|
||||
await self._http.close()
|
||||
self._http = None
|
||||
|
||||
async def _put_flow(self, flow: PendingFlow) -> str:
|
||||
flow_id = secrets.token_urlsafe(24)
|
||||
now = time.time()
|
||||
async with self._flow_lock:
|
||||
self._flows = {
|
||||
key: value for key, value in self._flows.items() if value.expires_at > now
|
||||
}
|
||||
if len(self._flows) >= MAX_PENDING_FLOWS:
|
||||
oldest = min(self._flows, key=lambda key: self._flows[key].expires_at)
|
||||
del self._flows[oldest]
|
||||
self._flows[flow_id] = flow
|
||||
return flow_id
|
||||
|
||||
async def _take_flow(self, flow_id: str, *, consume: bool) -> PendingFlow:
|
||||
async with self._flow_lock:
|
||||
flow = self._flows.get(flow_id)
|
||||
if flow is None or flow.expires_at <= time.time():
|
||||
self._flows.pop(flow_id, None)
|
||||
raise ValueError("login flow is invalid or expired")
|
||||
if consume:
|
||||
del self._flows[flow_id]
|
||||
return flow
|
||||
|
||||
@staticmethod
|
||||
def _headers(response: web.StreamResponse) -> None:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
|
||||
async def health(self, _: web.Request) -> web.Response:
|
||||
response = web.json_response({"status": "ok"})
|
||||
self._headers(response)
|
||||
return response
|
||||
|
||||
async def root(self, request: web.Request) -> web.Response:
|
||||
legacy_result = ""
|
||||
if "hash" in request.query:
|
||||
try:
|
||||
signed_names = {
|
||||
"auth_date", "first_name", "id", "last_name", "photo_url", "username", "hash"
|
||||
}
|
||||
if any(len(request.query.getall(key, [])) != 1 for key in signed_names if key in request.query):
|
||||
raise ValueError("duplicate legacy login field")
|
||||
query = {key: request.query[key] for key in request.query}
|
||||
identity = verify_legacy_login_query(query, self.bot_token)
|
||||
legacy_result = (
|
||||
"<p class=\"ok\">login_url HMAC verified for user "
|
||||
+ html.escape(identity["id"])
|
||||
+ ".</p>"
|
||||
)
|
||||
except ValueError:
|
||||
legacy_result = '<p class="error">login_url HMAC verification failed.</p>'
|
||||
|
||||
nonce = secrets.token_urlsafe(24)
|
||||
flow_id = await self._put_flow(PendingFlow(nonce=nonce, expires_at=time.time() + FLOW_TTL_SECONDS))
|
||||
sdk_url = self.config.issuer + "/js/telegram-login.js"
|
||||
csp_nonce = secrets.token_urlsafe(18)
|
||||
code_link = '<a class="button" href="/login/code">Authorization Code + PKCE</a>'
|
||||
if not self.config.code_flow_enabled:
|
||||
code_link = '<span class="disabled">Code flow disabled: configure client secret.</span>'
|
||||
script_config = json.dumps(
|
||||
{"clientID": self.config.client_id, "flowID": flow_id, "nonce": nonce},
|
||||
separators=(",", ":"),
|
||||
).replace("<", "\\u003c")
|
||||
body = f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Bedolaga Telegram Login Demo</title>
|
||||
<style>body{{font:16px/1.5 system-ui,sans-serif;max-width:720px;margin:8vh auto;padding:24px;background:#17212b;color:#fff}}.card{{background:#202b36;padding:28px;border-radius:18px}}button,.button{{display:inline-block;border:0;border-radius:10px;padding:12px 18px;margin:8px 8px 8px 0;background:#2aabee;color:#fff;text-decoration:none;font-weight:700;cursor:pointer}}.disabled{{display:block;color:#a9b5c1;margin:12px 0}}pre{{white-space:pre-wrap;overflow-wrap:anywhere;background:#111b24;padding:14px;border-radius:8px}}.ok{{color:#73d18a}}.error{{color:#ff8d8d}}</style></head>
|
||||
<body><main class="card"><h1>Bedolaga Telegram Login Demo</h1>{legacy_result}
|
||||
<p>This page verifies the self-hosted JavaScript SDK and the standard server-side OIDC flow.</p>
|
||||
<button id="popup">JavaScript SDK popup</button>{code_link}<pre id="result">Ready.</pre></main>
|
||||
<script src="{html.escape(sdk_url, quote=True)}"></script>
|
||||
<script nonce="{csp_nonce}">const cfg={script_config},out=document.getElementById('result');document.getElementById('popup').addEventListener('click',()=>{{out.textContent='Waiting for Telegram approval…';Telegram.Login.auth({{client_id:cfg.clientID,request_access:['phone','write'],nonce:cfg.nonce}},async result=>{{if(result.error){{out.textContent='Login failed: '+result.error;return}}try{{const response=await fetch('/verify-popup',{{method:'POST',headers:{{'content-type':'application/json'}},body:JSON.stringify({{flow_id:cfg.flowID,id_token:result.id_token,in_app:Boolean(window.TelegramWebviewProxy)}})}}),verified=await response.json();if(!response.ok)throw new Error(verified.error||'verification_failed');out.textContent=JSON.stringify(verified.claims,null,2)}}catch(error){{out.textContent='Verification failed: '+error.message}}}})}});</script></body></html>"""
|
||||
response = web.Response(text=body, content_type="text/html")
|
||||
self._headers(response)
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'none'; style-src 'unsafe-inline'; "
|
||||
f"script-src '{csp_nonce}' {self.config.issuer}; connect-src 'self' {self.config.issuer}; "
|
||||
"frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
||||
)
|
||||
# CSP nonce source expressions include the nonce- prefix.
|
||||
response.headers["Content-Security-Policy"] = response.headers["Content-Security-Policy"].replace(
|
||||
f"'{csp_nonce}'", f"'nonce-{csp_nonce}'"
|
||||
)
|
||||
return response
|
||||
|
||||
async def start_code_flow(self, _: web.Request) -> web.Response:
|
||||
if not self.config.code_flow_enabled:
|
||||
raise web.HTTPNotFound()
|
||||
verifier, challenge = generate_pkce()
|
||||
nonce = secrets.token_urlsafe(24)
|
||||
state = await self._put_flow(
|
||||
PendingFlow(nonce=nonce, code_verifier=verifier, expires_at=time.time() + FLOW_TTL_SECONDS)
|
||||
)
|
||||
query = urlencode(
|
||||
{
|
||||
"client_id": self.config.client_id,
|
||||
"redirect_uri": self.config.redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": "openid profile phone telegram:bot_access",
|
||||
"state": state,
|
||||
"nonce": nonce,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
)
|
||||
response = web.HTTPFound(self.config.issuer + "/auth?" + query)
|
||||
self._headers(response)
|
||||
raise response
|
||||
|
||||
async def code_callback(self, request: web.Request) -> web.Response:
|
||||
state = request.query.get("state", "")
|
||||
try:
|
||||
flow = await self._take_flow(state, consume=True)
|
||||
except ValueError:
|
||||
return self._result_page("Authorization failed", {"error": "invalid_or_expired_state"}, ok=False)
|
||||
if request.query.get("error"):
|
||||
return self._result_page("Authorization declined", {"error": request.query["error"]}, ok=False)
|
||||
code = request.query.get("code", "")
|
||||
if not code or len(code) > 2048:
|
||||
return self._result_page("Authorization failed", {"error": "missing_code"}, ok=False)
|
||||
try:
|
||||
token = await self._exchange_code(code, flow.code_verifier)
|
||||
claims = await self.verify_id_token(token, flow.nonce)
|
||||
except Exception as exc: # noqa: BLE001 - convert all protocol failures to a safe demo page
|
||||
LOG.warning("OIDC code flow verification failed: %s", type(exc).__name__)
|
||||
return self._result_page("Authorization failed", {"error": "token_verification_failed"}, ok=False)
|
||||
return self._result_page("Authorization complete", _safe_claims(claims), ok=True)
|
||||
|
||||
async def verify_popup(self, request: web.Request) -> web.Response:
|
||||
origin = request.headers.get("Origin")
|
||||
if origin and origin != self.config.origin:
|
||||
return web.json_response({"error": "invalid_origin"}, status=403)
|
||||
try:
|
||||
payload = await request.json()
|
||||
flow_id = str(payload.get("flow_id", ""))
|
||||
id_token = str(payload.get("id_token", ""))
|
||||
in_app = payload.get("in_app", False)
|
||||
if (
|
||||
not flow_id
|
||||
or len(flow_id) > 128
|
||||
or not id_token
|
||||
or len(id_token) > 16384
|
||||
or not isinstance(in_app, bool)
|
||||
):
|
||||
raise ValueError("invalid popup response")
|
||||
flow = await self._take_flow(flow_id, consume=True)
|
||||
# Telegram's official Mini App /inapp contract has exactly four
|
||||
# request parameters and does not carry the JS API nonce. Popup
|
||||
# flows still require it; Mini App tokens must omit it.
|
||||
claims = await self.verify_id_token(id_token, "" if in_app else flow.nonce)
|
||||
except Exception as exc: # noqa: BLE001 - safe public validation failure
|
||||
LOG.info("OIDC popup verification rejected: %s", type(exc).__name__)
|
||||
response = web.json_response({"error": "token_verification_failed"}, status=400)
|
||||
self._headers(response)
|
||||
return response
|
||||
response = web.json_response({"claims": _safe_claims(claims)})
|
||||
self._headers(response)
|
||||
return response
|
||||
|
||||
async def _discovery(self) -> dict[str, Any]:
|
||||
if self._http is None:
|
||||
raise RuntimeError("login demo server is not started")
|
||||
async with self._http.get(self.config.issuer + "/.well-known/openid-configuration") as response:
|
||||
response.raise_for_status()
|
||||
document = await response.json()
|
||||
if document.get("issuer") != self.config.issuer:
|
||||
raise ValueError("OIDC issuer mismatch")
|
||||
issuer_origin = urlsplit(self.config.issuer).netloc
|
||||
for name in ("token_endpoint", "jwks_uri"):
|
||||
endpoint = urlsplit(str(document.get(name, "")))
|
||||
if endpoint.scheme != urlsplit(self.config.issuer).scheme or endpoint.netloc != issuer_origin:
|
||||
raise ValueError(f"OIDC {name} must share the configured issuer origin")
|
||||
return document
|
||||
|
||||
async def _exchange_code(self, code: str, verifier: str) -> str:
|
||||
if self._http is None:
|
||||
raise RuntimeError("login demo server is not started")
|
||||
discovery = await self._discovery()
|
||||
form = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": self.config.redirect_uri,
|
||||
"code_verifier": verifier,
|
||||
}
|
||||
async with self._http.post(
|
||||
discovery["token_endpoint"],
|
||||
data=form,
|
||||
auth=BasicAuth(self.config.client_id, self.config.client_secret),
|
||||
) as response:
|
||||
document = await response.json()
|
||||
if response.status != 200:
|
||||
raise ValueError("OIDC token endpoint rejected the grant")
|
||||
token = document.get("id_token")
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("OIDC token response omitted id_token")
|
||||
return token
|
||||
|
||||
async def verify_id_token(self, token: str, nonce: str) -> dict[str, Any]:
|
||||
if self._http is None:
|
||||
raise RuntimeError("login demo server is not started")
|
||||
discovery = await self._discovery()
|
||||
header = jwt.get_unverified_header(token)
|
||||
kid, algorithm = header.get("kid"), header.get("alg")
|
||||
if not isinstance(kid, str) or algorithm not in OIDC_ALGORITHMS:
|
||||
raise ValueError("unsupported ID token header")
|
||||
async with self._http.get(discovery["jwks_uri"]) as response:
|
||||
response.raise_for_status()
|
||||
document = await response.json()
|
||||
raw_key = next((key for key in document.get("keys", []) if key.get("kid") == kid), None)
|
||||
if raw_key is None:
|
||||
raise ValueError("ID token signing key not found")
|
||||
public_key = jwt.PyJWK.from_dict(raw_key, algorithm=algorithm).key
|
||||
required_claims = ["iss", "aud", "sub", "iat", "exp"]
|
||||
if nonce:
|
||||
required_claims.append("nonce")
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
public_key,
|
||||
algorithms=[algorithm],
|
||||
audience=self.config.client_id,
|
||||
issuer=self.config.issuer,
|
||||
options={"require": required_claims},
|
||||
)
|
||||
if nonce and not hmac.compare_digest(str(claims.get("nonce", "")), nonce):
|
||||
raise ValueError("ID token nonce mismatch")
|
||||
if not nonce and claims.get("nonce") not in (None, ""):
|
||||
raise ValueError("unexpected ID token nonce")
|
||||
if str(claims.get("sub", "")) != str(claims.get("id", "")):
|
||||
raise ValueError("ID token subject mismatch")
|
||||
return claims
|
||||
|
||||
def _result_page(self, title: str, payload: dict[str, Any], *, ok: bool) -> web.Response:
|
||||
css_class = "ok" if ok else "error"
|
||||
body = (
|
||||
'<!doctype html><html lang="en"><head><meta charset="utf-8"><title>'
|
||||
+ html.escape(title)
|
||||
+ "</title></head><body><h1 class=\""
|
||||
+ css_class
|
||||
+ "\">"
|
||||
+ html.escape(title)
|
||||
+ "</h1><pre>"
|
||||
+ html.escape(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
+ '</pre><p><a href="/">Run another flow</a></p></body></html>'
|
||||
)
|
||||
response = web.Response(text=body, content_type="text/html")
|
||||
self._headers(response)
|
||||
response.headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'"
|
||||
return response
|
||||
|
|
@ -1 +1,2 @@
|
|||
aiogram==3.30.0
|
||||
PyJWT[crypto]==2.10.1
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from aiogram.types import InputRichMessage
|
|||
|
||||
|
||||
MODULE_PATH = Path(__file__).with_name("demo.py")
|
||||
sys.path.insert(0, str(MODULE_PATH.parent))
|
||||
SPEC = importlib.util.spec_from_file_location("bedolagaformat_demo", MODULE_PATH)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
demo = importlib.util.module_from_spec(SPEC)
|
||||
|
|
@ -89,6 +90,44 @@ class BedolagaFormatDemoTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(edit.kwargs["message_id"], 21)
|
||||
self.assertIn("EDITED", edit.kwargs["rich_message"].html)
|
||||
|
||||
def test_login_demo_keyboard_has_login_url_and_plain_oidc_link(self) -> None:
|
||||
config = demo.LoginDemoConfig(
|
||||
issuer="https://oauth.example",
|
||||
client_id="9001",
|
||||
client_secret="secret",
|
||||
public_url="https://rp.example",
|
||||
listen_host="127.0.0.1",
|
||||
listen_port=3000,
|
||||
)
|
||||
markup = demo.login_demo_keyboard(config)
|
||||
login = markup.inline_keyboard[0][0].login_url
|
||||
self.assertIsNotNone(login)
|
||||
self.assertEqual(login.url, "https://rp.example/")
|
||||
self.assertTrue(login.request_write_access)
|
||||
self.assertEqual(markup.inline_keyboard[1][0].url, "https://rp.example/")
|
||||
|
||||
async def test_send_login_demo_preserves_default_html_and_keyboard(self) -> None:
|
||||
config = demo.LoginDemoConfig(
|
||||
issuer="https://oauth.example",
|
||||
client_id="9001",
|
||||
client_secret="secret",
|
||||
public_url="https://rp.example",
|
||||
listen_host="127.0.0.1",
|
||||
listen_port=3000,
|
||||
)
|
||||
bot = AsyncMock()
|
||||
bot.send_message.return_value = SentMessage(31)
|
||||
|
||||
message_id = await demo.send_login_demo(bot, 1780243200, "BEDOLAGA123", config)
|
||||
|
||||
self.assertEqual(message_id, 31)
|
||||
call = bot.send_message.await_args
|
||||
self.assertNotIn("parse_mode", call.kwargs)
|
||||
self.assertEqual(
|
||||
call.kwargs["reply_markup"].inline_keyboard[0][0].login_url.url,
|
||||
"https://rp.example/",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
154
cmd/bots/bedolagaformat/test_login_demo.py
Normal file
154
cmd/bots/bedolagaformat/test_login_demo.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from aiohttp import ClientSession, web
|
||||
from aiohttp.test_utils import TestServer
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
import jwt
|
||||
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
import login_demo as demo # noqa: E402
|
||||
|
||||
|
||||
class LoginDemoHelpersTest(unittest.TestCase):
|
||||
def test_legacy_login_hmac_and_freshness(self) -> None:
|
||||
now = 1_800_000_000
|
||||
token = "9001:bot-secret"
|
||||
values = {
|
||||
"auth_date": str(now - 10),
|
||||
"first_name": "Alice",
|
||||
"id": "42",
|
||||
"username": "alice",
|
||||
}
|
||||
data_check = "\n".join(f"{key}={values[key]}" for key in sorted(values))
|
||||
key = hashlib.sha256(token.encode()).digest()
|
||||
values["hash"] = hmac.new(key, data_check.encode(), hashlib.sha256).hexdigest()
|
||||
values["untrusted_existing_query"] = "not-signed"
|
||||
|
||||
verified = demo.verify_legacy_login_query(values, token, now=now)
|
||||
|
||||
self.assertEqual(verified["id"], "42")
|
||||
self.assertNotIn("untrusted_existing_query", verified)
|
||||
with self.assertRaisesRegex(ValueError, "signature"):
|
||||
demo.verify_legacy_login_query({**values, "id": "43"}, token, now=now)
|
||||
with self.assertRaisesRegex(ValueError, "expired"):
|
||||
demo.verify_legacy_login_query(values, token, now=now + 3600)
|
||||
|
||||
def test_web_origins_and_listen_are_strict(self) -> None:
|
||||
self.assertEqual(
|
||||
demo.normalize_web_base("https://rp.example/", name="RP"),
|
||||
"https://rp.example",
|
||||
)
|
||||
self.assertEqual(
|
||||
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")
|
||||
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))
|
||||
|
||||
|
||||
class LoginDemoTokenTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
self.private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
raw_jwk = jwt.algorithms.RSAAlgorithm.to_jwk(self.private_key.public_key(), as_dict=True)
|
||||
raw_jwk.update({"kid": "demo-rs256", "alg": "RS256", "use": "sig"})
|
||||
self.jwk = raw_jwk
|
||||
self.issuer = ""
|
||||
|
||||
async def discovery(_: web.Request) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"issuer": self.issuer,
|
||||
"token_endpoint": self.issuer + "/token",
|
||||
"jwks_uri": self.issuer + "/jwks",
|
||||
}
|
||||
)
|
||||
|
||||
async def jwks(_: web.Request) -> web.Response:
|
||||
return web.json_response({"keys": [self.jwk]})
|
||||
|
||||
app = web.Application()
|
||||
app.add_routes([web.get("/.well-known/openid-configuration", discovery), web.get("/jwks", jwks)])
|
||||
self.http_server = TestServer(app)
|
||||
await self.http_server.start_server()
|
||||
self.issuer = str(self.http_server.make_url("")).rstrip("/")
|
||||
config = demo.LoginDemoConfig(
|
||||
issuer=self.issuer,
|
||||
client_id="9001",
|
||||
client_secret="secret",
|
||||
public_url="http://127.0.0.1:3000",
|
||||
listen_host="127.0.0.1",
|
||||
listen_port=3000,
|
||||
)
|
||||
self.demo = demo.LoginDemoServer(config, "9001:bot-secret")
|
||||
self.demo._http = ClientSession()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
await self.demo._http.close()
|
||||
await self.http_server.close()
|
||||
|
||||
async def test_id_token_requires_signature_issuer_audience_nonce_and_subject(self) -> None:
|
||||
now = int(time.time())
|
||||
claims = {
|
||||
"iss": self.issuer,
|
||||
"aud": "9001",
|
||||
"sub": "42",
|
||||
"id": 42,
|
||||
"iat": now,
|
||||
"exp": now + 300,
|
||||
"nonce": "expected-nonce",
|
||||
"name": "Alice",
|
||||
}
|
||||
token = jwt.encode(
|
||||
claims,
|
||||
self.private_key,
|
||||
algorithm="RS256",
|
||||
headers={"kid": "demo-rs256"},
|
||||
)
|
||||
|
||||
verified = await self.demo.verify_id_token(token, "expected-nonce")
|
||||
|
||||
self.assertEqual(verified["sub"], "42")
|
||||
with self.assertRaisesRegex(ValueError, "nonce"):
|
||||
await self.demo.verify_id_token(token, "wrong-nonce")
|
||||
with self.assertRaisesRegex(ValueError, "nonce"):
|
||||
await self.demo.verify_id_token(token, "")
|
||||
|
||||
in_app_claims = dict(claims)
|
||||
in_app_claims.pop("nonce")
|
||||
in_app_token = jwt.encode(
|
||||
in_app_claims,
|
||||
self.private_key,
|
||||
algorithm="RS256",
|
||||
headers={"kid": "demo-rs256"},
|
||||
)
|
||||
verified_in_app = await self.demo.verify_id_token(in_app_token, "")
|
||||
self.assertEqual(verified_in_app["sub"], "42")
|
||||
|
||||
async def test_pending_flow_is_one_time_and_expiring(self) -> None:
|
||||
flow_id = await self.demo._put_flow(
|
||||
demo.PendingFlow(nonce="n", expires_at=time.time() + 10)
|
||||
)
|
||||
flow = await self.demo._take_flow(flow_id, consume=True)
|
||||
self.assertEqual(flow.nonce, "n")
|
||||
with self.assertRaises(ValueError):
|
||||
await self.demo._take_flow(flow_id, consume=True)
|
||||
|
||||
expired = await self.demo._put_flow(
|
||||
demo.PendingFlow(nonce="old", expires_at=time.time() - 1)
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
await self.demo._take_flow(expired, consume=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
391
cmd/telegramloginkeygen/main.go
Normal file
391
cmd/telegramloginkeygen/main.go
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
// Command telegramloginkeygen initializes and rotates telesrv Telegram Login
|
||||
// key files without ever writing secret material to stdout.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
telegramlogin "telesrv/internal/app/telegramlogin"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const maxManifestBytes = 1 << 20
|
||||
const signingRetirementMargin = 10 * time.Minute
|
||||
|
||||
type signingManifest struct {
|
||||
Version int `json:"version"`
|
||||
Keys []signingManifestKey `json:"keys"`
|
||||
}
|
||||
|
||||
type signingManifestKey struct {
|
||||
Algorithm domain.TelegramLoginSigningAlgorithm `json:"algorithm"`
|
||||
KeyID string `json:"kid"`
|
||||
PrivateKeyFile string `json:"private_key_file"`
|
||||
Active bool `json:"active"`
|
||||
PublishUntil string `json:"publish_until,omitempty"`
|
||||
}
|
||||
|
||||
type codeManifest struct {
|
||||
Version int `json:"version"`
|
||||
Active string `json:"active"`
|
||||
Keys map[string]string `json:"keys"`
|
||||
}
|
||||
|
||||
type options struct {
|
||||
mode string
|
||||
dir string
|
||||
algorithm domain.TelegramLoginSigningAlgorithm
|
||||
publishFor time.Duration
|
||||
idTokenTTL time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func main() {
|
||||
mode := flag.String("mode", "init", "init, rotate-signing, or rotate-code")
|
||||
dir := flag.String("dir", "data/telegram-login", "key directory")
|
||||
algorithm := flag.String("algorithm", "RS256", "signing algorithm to rotate: RS256, ES256, or EdDSA")
|
||||
publishFor := flag.Duration("publish-for", 2*time.Hour, "how long the retiring public key remains in JWKS")
|
||||
idTokenTTL := flag.Duration("id-token-ttl", time.Hour, "configured TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL")
|
||||
flag.Parse()
|
||||
if flag.NArg() != 0 {
|
||||
fatal(errors.New("positional arguments are not accepted"))
|
||||
}
|
||||
opts := options{
|
||||
mode: strings.ToLower(strings.TrimSpace(*mode)), dir: strings.TrimSpace(*dir),
|
||||
algorithm: domain.TelegramLoginSigningAlgorithm(strings.ToUpper(strings.TrimSpace(*algorithm))),
|
||||
publishFor: *publishFor, idTokenTTL: *idTokenTTL, now: time.Now,
|
||||
}
|
||||
if err := run(opts); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
fmt.Printf("Telegram Login key operation %s completed in %s; restart all instances to load one consistent key ring.\n", opts.mode, opts.dir)
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, "telegramloginkeygen:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func run(opts options) error {
|
||||
if opts.now == nil {
|
||||
opts.now = time.Now
|
||||
}
|
||||
if opts.dir == "" {
|
||||
return errors.New("key directory is required")
|
||||
}
|
||||
absDir, err := filepath.Abs(opts.dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve key directory: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(absDir, 0o700); err != nil {
|
||||
return fmt.Errorf("create key directory: %w", err)
|
||||
}
|
||||
if err := os.Chmod(absDir, 0o700); err != nil {
|
||||
return fmt.Errorf("restrict key directory: %w", err)
|
||||
}
|
||||
lockPath := filepath.Join(absDir, ".keygen.lock")
|
||||
lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("acquire key operation lock: %w", err)
|
||||
}
|
||||
_ = lock.Close()
|
||||
defer func() { _ = os.Remove(lockPath) }()
|
||||
|
||||
switch opts.mode {
|
||||
case "init":
|
||||
return initialize(absDir, opts.now().UTC())
|
||||
case "rotate-signing":
|
||||
if opts.idTokenTTL < time.Minute || opts.idTokenTTL > 24*time.Hour {
|
||||
return errors.New("id-token-ttl must match the configured 1m..24h ID-token TTL")
|
||||
}
|
||||
if opts.publishFor < opts.idTokenTTL+signingRetirementMargin || opts.publishFor > 90*24*time.Hour {
|
||||
return fmt.Errorf("publish-for must be at least id-token-ttl plus %s and at most 2160h", signingRetirementMargin)
|
||||
}
|
||||
return rotateSigning(absDir, opts.algorithm, opts.publishFor, opts.now().UTC())
|
||||
case "rotate-code":
|
||||
return rotateCode(absDir, opts.now().UTC())
|
||||
default:
|
||||
return errors.New("mode must be init, rotate-signing, or rotate-code")
|
||||
}
|
||||
}
|
||||
|
||||
func initialize(dir string, now time.Time) error {
|
||||
for _, name := range []string{"signing-keys.json", "code-keys.json", "client-secret-pepper"} {
|
||||
if _, err := os.Lstat(filepath.Join(dir, name)); err == nil {
|
||||
return fmt.Errorf("refusing to overwrite existing %s", name)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("inspect %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
manifest := signingManifest{Version: 1}
|
||||
for _, algorithm := range []domain.TelegramLoginSigningAlgorithm{
|
||||
domain.TelegramLoginSigningRS256,
|
||||
domain.TelegramLoginSigningES256,
|
||||
domain.TelegramLoginSigningEdDSA,
|
||||
} {
|
||||
entry, err := generateSigningKey(dir, algorithm, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manifest.Keys = append(manifest.Keys, entry)
|
||||
}
|
||||
if err := writeSigningManifest(dir, manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
codeID, err := newKeyID("code", now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
codeKey, err := randomBytes(32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeCodeManifest(dir, codeManifest{
|
||||
Version: 1, Active: codeID,
|
||||
Keys: map[string]string{codeID: base64.RawURLEncoding.EncodeToString(codeKey)},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
pepper, err := randomBytes(32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeExclusive(filepath.Join(dir, "client-secret-pepper"), []byte(base64.RawURLEncoding.EncodeToString(pepper)+"\n")); err != nil {
|
||||
return fmt.Errorf("write client-secret pepper: %w", err)
|
||||
}
|
||||
_, err = telegramlogin.LoadClientSecretPepper(filepath.Join(dir, "client-secret-pepper"))
|
||||
return err
|
||||
}
|
||||
|
||||
func rotateSigning(dir string, algorithm domain.TelegramLoginSigningAlgorithm, publishFor time.Duration, now time.Time) error {
|
||||
if algorithm != domain.TelegramLoginSigningRS256 && algorithm != domain.TelegramLoginSigningES256 && algorithm != domain.TelegramLoginSigningEdDSA {
|
||||
return errors.New("default keygen supports RS256, ES256, and EdDSA; ES256K requires an explicit jwx_es256k build and external JWK lifecycle")
|
||||
}
|
||||
path := filepath.Join(dir, "signing-keys.json")
|
||||
var manifest signingManifest
|
||||
if err := readStrictJSON(path, &manifest); err != nil {
|
||||
return fmt.Errorf("read signing manifest: %w", err)
|
||||
}
|
||||
if manifest.Version != 1 || len(manifest.Keys) == 0 || len(manifest.Keys) >= 32 {
|
||||
return errors.New("signing manifest version or key count is invalid")
|
||||
}
|
||||
foundActive := false
|
||||
kept := make([]signingManifestKey, 0, len(manifest.Keys)+1)
|
||||
for _, key := range manifest.Keys {
|
||||
if !key.Active && key.PublishUntil != "" {
|
||||
until, err := time.Parse(time.RFC3339, key.PublishUntil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse retiring key %s: %w", key.KeyID, err)
|
||||
}
|
||||
if !now.Before(until) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if key.Algorithm == algorithm && key.Active {
|
||||
if foundActive {
|
||||
return fmt.Errorf("multiple active %s keys", algorithm)
|
||||
}
|
||||
foundActive = true
|
||||
key.Active = false
|
||||
key.PublishUntil = now.Add(publishFor).UTC().Format(time.RFC3339)
|
||||
}
|
||||
kept = append(kept, key)
|
||||
}
|
||||
if !foundActive {
|
||||
return fmt.Errorf("no active %s key to rotate", algorithm)
|
||||
}
|
||||
entry, err := generateSigningKey(dir, algorithm, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manifest.Keys = append(kept, entry)
|
||||
return writeSigningManifest(dir, manifest)
|
||||
}
|
||||
|
||||
func rotateCode(dir string, now time.Time) error {
|
||||
path := filepath.Join(dir, "code-keys.json")
|
||||
var manifest codeManifest
|
||||
if err := readStrictJSON(path, &manifest); err != nil {
|
||||
return fmt.Errorf("read code-key manifest: %w", err)
|
||||
}
|
||||
if manifest.Version != 1 || manifest.Active == "" || len(manifest.Keys) == 0 || len(manifest.Keys) >= 16 {
|
||||
return errors.New("code-key manifest is invalid or at its 16-key safety limit")
|
||||
}
|
||||
keyID, err := newKeyID("code", now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, err := randomBytes(32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manifest.Active = keyID
|
||||
manifest.Keys[keyID] = base64.RawURLEncoding.EncodeToString(key)
|
||||
return writeCodeManifest(dir, manifest)
|
||||
}
|
||||
|
||||
func generateSigningKey(dir string, algorithm domain.TelegramLoginSigningAlgorithm, now time.Time) (signingManifestKey, error) {
|
||||
keyID, err := newKeyID(strings.ToLower(string(algorithm)), now)
|
||||
if err != nil {
|
||||
return signingManifestKey{}, err
|
||||
}
|
||||
var privateKey any
|
||||
switch algorithm {
|
||||
case domain.TelegramLoginSigningRS256:
|
||||
privateKey, err = rsa.GenerateKey(rand.Reader, 3072)
|
||||
case domain.TelegramLoginSigningES256:
|
||||
privateKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
case domain.TelegramLoginSigningEdDSA:
|
||||
_, privateKey, err = ed25519.GenerateKey(rand.Reader)
|
||||
default:
|
||||
return signingManifestKey{}, fmt.Errorf("unsupported keygen algorithm %s", algorithm)
|
||||
}
|
||||
if err != nil {
|
||||
return signingManifestKey{}, fmt.Errorf("generate %s key: %w", algorithm, err)
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
if err != nil {
|
||||
return signingManifestKey{}, fmt.Errorf("marshal %s key: %w", algorithm, err)
|
||||
}
|
||||
filename := "signing-" + keyID + ".pem"
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
|
||||
if err := writeExclusive(filepath.Join(dir, filename), pemBytes); err != nil {
|
||||
return signingManifestKey{}, fmt.Errorf("write %s key: %w", algorithm, err)
|
||||
}
|
||||
return signingManifestKey{
|
||||
Algorithm: algorithm, KeyID: keyID, PrivateKeyFile: filename, Active: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newKeyID(prefix string, now time.Time) (string, error) {
|
||||
raw, err := randomBytes(8)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s-%s-%s", prefix, now.UTC().Format("20060102T150405Z"), base64.RawURLEncoding.EncodeToString(raw)), nil
|
||||
}
|
||||
|
||||
func randomBytes(size int) ([]byte, error) {
|
||||
raw := make([]byte, size)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return nil, fmt.Errorf("read cryptographic randomness: %w", err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func writeSigningManifest(dir string, manifest signingManifest) error {
|
||||
return writeValidatedManifest(filepath.Join(dir, "signing-keys.json"), manifest, func(path string) error {
|
||||
_, err := telegramlogin.LoadSigningKeyRing(path, time.Now)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func writeCodeManifest(dir string, manifest codeManifest) error {
|
||||
return writeValidatedManifest(filepath.Join(dir, "code-keys.json"), manifest, func(path string) error {
|
||||
_, err := telegramlogin.LoadCodeSealer(path)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func writeValidatedManifest(path string, value any, validate func(string) error) error {
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal manifest: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
temp, err := os.CreateTemp(filepath.Dir(path), ".telegram-login-manifest-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary manifest: %w", err)
|
||||
}
|
||||
tempPath := temp.Name()
|
||||
defer func() { _ = os.Remove(tempPath) }()
|
||||
if err := temp.Chmod(0o600); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Sync(); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate(tempPath); err != nil {
|
||||
return fmt.Errorf("validate generated manifest: %w", err)
|
||||
}
|
||||
if err := os.Rename(tempPath, path); err != nil {
|
||||
return fmt.Errorf("atomically replace manifest: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeExclusive(path string, data []byte) error {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ok := false
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
if !ok {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}()
|
||||
if _, err := file.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
ok = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func readStrictJSON(path string, target any) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() > maxManifestBytes {
|
||||
return errors.New("manifest must be a bounded regular file")
|
||||
}
|
||||
decoder := json.NewDecoder(io.LimitReader(file, maxManifestBytes+1))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return errors.New("manifest contains multiple JSON values")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
109
cmd/telegramloginkeygen/main_test.go
Normal file
109
cmd/telegramloginkeygen/main_test.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
telegramlogin "telesrv/internal/app/telegramlogin"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestInitializeAndRotateKeyFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
now := time.Date(2026, 7, 21, 1, 2, 3, 0, time.UTC)
|
||||
if err := run(options{mode: "init", dir: dir, now: func() time.Time { return now }}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ring, err := telegramlogin.LoadSigningKeyRing(filepath.Join(dir, "signing-keys.json"), func() time.Time { return now })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ring.SupportedAlgorithms(); len(got) != 3 || got[0] != "RS256" || got[1] != "ES256" || got[2] != "EdDSA" {
|
||||
t.Fatalf("supported algorithms = %#v", got)
|
||||
}
|
||||
if _, err := telegramlogin.LoadCodeSealer(filepath.Join(dir, "code-keys.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := telegramlogin.LoadClientSecretPepper(filepath.Join(dir, "client-secret-pepper")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := run(options{mode: "init", dir: dir, now: func() time.Time { return now }}); err == nil {
|
||||
t.Fatal("second initialization unexpectedly overwrote keys")
|
||||
}
|
||||
|
||||
later := now.Add(time.Minute)
|
||||
if err := run(options{
|
||||
mode: "rotate-signing", dir: dir, algorithm: domain.TelegramLoginSigningRS256,
|
||||
publishFor: 2 * time.Hour, idTokenTTL: time.Hour, now: func() time.Time { return later },
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var manifest signingManifest
|
||||
readJSONForTest(t, filepath.Join(dir, "signing-keys.json"), &manifest)
|
||||
active, retiring := 0, 0
|
||||
for _, key := range manifest.Keys {
|
||||
if key.Algorithm != domain.TelegramLoginSigningRS256 {
|
||||
continue
|
||||
}
|
||||
if key.Active {
|
||||
active++
|
||||
} else if key.PublishUntil == later.Add(2*time.Hour).Format(time.RFC3339) {
|
||||
retiring++
|
||||
}
|
||||
}
|
||||
if active != 1 || retiring != 1 {
|
||||
t.Fatalf("RS256 active=%d retiring=%d manifest=%#v", active, retiring, manifest)
|
||||
}
|
||||
ring, err = telegramlogin.LoadSigningKeyRing(filepath.Join(dir, "signing-keys.json"), func() time.Time { return later })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jwks, _, err := ring.JWKS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var set struct {
|
||||
Keys []json.RawMessage `json:"keys"`
|
||||
}
|
||||
if err := json.Unmarshal(jwks, &set); err != nil || len(set.Keys) != 4 {
|
||||
t.Fatalf("JWKS key count=%d err=%v body=%s", len(set.Keys), err, jwks)
|
||||
}
|
||||
|
||||
var before codeManifest
|
||||
readJSONForTest(t, filepath.Join(dir, "code-keys.json"), &before)
|
||||
if err := run(options{mode: "rotate-code", dir: dir, now: func() time.Time { return later }}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var after codeManifest
|
||||
readJSONForTest(t, filepath.Join(dir, "code-keys.json"), &after)
|
||||
if after.Active == before.Active || len(after.Keys) != 2 {
|
||||
t.Fatalf("code ring before=%#v after=%#v", before, after)
|
||||
}
|
||||
if _, err := telegramlogin.LoadCodeSealer(filepath.Join(dir, "code-keys.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateSigningRejectsTooShortRetirementWindow(t *testing.T) {
|
||||
err := run(options{
|
||||
mode: "rotate-signing", dir: t.TempDir(), algorithm: domain.TelegramLoginSigningRS256,
|
||||
publishFor: 69 * time.Minute, idTokenTTL: time.Hour, now: time.Now,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("short publish-for unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func readJSONForTest(t *testing.T, path string, target any) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(data, target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,7 @@ import (
|
|||
"telesrv/internal/app/stargifts"
|
||||
"telesrv/internal/app/stars"
|
||||
storiesapp "telesrv/internal/app/stories"
|
||||
telegramloginapp "telesrv/internal/app/telegramlogin"
|
||||
themesapp "telesrv/internal/app/themes"
|
||||
translationapp "telesrv/internal/app/translation"
|
||||
"telesrv/internal/app/updates"
|
||||
|
|
@ -69,6 +70,7 @@ import (
|
|||
"telesrv/internal/store/memory"
|
||||
"telesrv/internal/store/postgres"
|
||||
"telesrv/internal/store/redisstore"
|
||||
"telesrv/internal/telegramloginhttp"
|
||||
"telesrv/internal/turnsrv"
|
||||
"telesrv/internal/web"
|
||||
)
|
||||
|
|
@ -346,12 +348,60 @@ func run(logger *zap.Logger) error {
|
|||
}
|
||||
defer pool.Close()
|
||||
|
||||
var telegramLoginService *telegramloginapp.Service
|
||||
var telegramLoginIDTokens *telegramloginapp.IDTokenIssuer
|
||||
var telegramLoginHTTPHandler http.Handler
|
||||
if cfg.TelegramLoginEnabled {
|
||||
codeSealer, err := telegramloginapp.LoadCodeSealer(cfg.TelegramLoginCodeKeysFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load telegram login code keys: %w", err)
|
||||
}
|
||||
clientSecretPepper, err := telegramloginapp.LoadClientSecretPepper(cfg.TelegramLoginSecretPepperFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load telegram login client-secret pepper: %w", err)
|
||||
}
|
||||
signingKeys, err := telegramloginapp.LoadSigningKeyRing(cfg.TelegramLoginSigningKeysFile, time.Now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load telegram login signing keys: %w", err)
|
||||
}
|
||||
telegramLoginService, err = telegramloginapp.NewService(postgres.NewTelegramLoginStore(pool), codeSealer, telegramloginapp.Config{
|
||||
Issuer: cfg.TelegramLoginIssuer, AppScheme: cfg.PublicAppScheme,
|
||||
AllowLoopbackHTTP: cfg.TelegramLoginAllowLoopbackHTTP,
|
||||
ClientSecretPepper: clientSecretPepper,
|
||||
SupportedSigningAlgorithms: signingKeys.ActiveAlgorithms(),
|
||||
RequestTTL: cfg.TelegramLoginRequestTTL, CodeTTL: cfg.TelegramLoginCodeTTL,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize telegram login service: %w", err)
|
||||
}
|
||||
telegramLoginIDTokens, err = telegramloginapp.NewIDTokenIssuer(signingKeys, telegramloginapp.IDTokenIssuerConfig{
|
||||
Issuer: cfg.TelegramLoginIssuer, TTL: cfg.TelegramLoginIDTokenTTL,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize telegram login ID-token issuer: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
rdb, err := redisstore.Open(ctx, cfg.RedisAddr, cfg.RedisPassword, cfg.RedisDB)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect redis: %w", err)
|
||||
}
|
||||
defer func() { _ = rdb.Close() }()
|
||||
logger.Info("持久化依赖就绪", zap.String("redis", cfg.RedisAddr))
|
||||
if cfg.TelegramLoginEnabled {
|
||||
telegramLoginHTTPHandler, err = telegramloginhttp.NewHandler(telegramloginhttp.Config{
|
||||
Service: telegramLoginService, Tokens: telegramLoginIDTokens,
|
||||
Limiter: redisstore.NewRateLimiter(rdb), AppName: cfg.PublicAppName,
|
||||
Logger: logger.Named("telegram-login-http"), TrustedProxyCIDRs: cfg.TelegramLoginTrustedProxyCIDRs,
|
||||
AllowLoopbackHTTP: cfg.TelegramLoginAllowLoopbackHTTP,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize telegram login HTTP provider: %w", err)
|
||||
}
|
||||
logger.Info("Telegram Login/OIDC provider enabled",
|
||||
zap.String("issuer", telegramLoginIDTokens.Issuer()),
|
||||
zap.Strings("signing_algorithms", telegramLoginIDTokens.SupportedAlgorithms()))
|
||||
}
|
||||
|
||||
authKeyStore := postgres.NewAuthKeyStore(pool)
|
||||
userStore := postgres.NewUserStore(pool)
|
||||
|
|
@ -585,6 +635,7 @@ func run(logger *zap.Logger) error {
|
|||
botsapp.WithUserCache(userCache),
|
||||
botsapp.WithStickerSetCreator(filesService),
|
||||
botsapp.WithUserStickerSets(accountService),
|
||||
botsapp.WithTelegramLogin(telegramLoginService),
|
||||
botsapp.WithPublicBaseURL(cfg.PublicBaseURL))
|
||||
groupCallStore := postgres.NewGroupCallStore(pool)
|
||||
groupCallsService := groupcallsapp.NewService(groupCallStore, groupcallsapp.WithPublicBaseURL(cfg.PublicBaseURL))
|
||||
|
|
@ -800,6 +851,7 @@ func run(logger *zap.Logger) error {
|
|||
EphemeralPush: ephemeralStore,
|
||||
EphemeralReports: ephemeralReportStore,
|
||||
Users: usersService,
|
||||
TelegramLogin: telegramLoginService,
|
||||
Updates: updatesService,
|
||||
BootstrapUpdates: bootstrapUpdateStore,
|
||||
BotAPIUpdates: botAPIUpdateStore,
|
||||
|
|
@ -886,6 +938,9 @@ func run(logger *zap.Logger) error {
|
|||
go activeSessions.RunPendingSweeper(ctx, time.Minute)
|
||||
go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch)
|
||||
go router.RunAccountLifecycle(ctx, time.Minute, 500)
|
||||
if telegramLoginService != nil {
|
||||
go runTelegramLoginRetention(ctx, telegramLoginService, cfg.TelegramLoginRetention, cfg.TelegramLoginSweepInterval, cfg.TelegramLoginSweepBatch, logger.Named("telegram-login-retention"))
|
||||
}
|
||||
go func() {
|
||||
interval := cfg.StarGiftSweepInterval
|
||||
if interval <= 0 {
|
||||
|
|
@ -934,6 +989,7 @@ func run(logger *zap.Logger) error {
|
|||
Photos: filesService,
|
||||
UniqueGifts: giftsService,
|
||||
GiftWithdrawals: giftsService,
|
||||
TelegramLogin: telegramLoginHTTPHandler,
|
||||
}, logger.Named("public-web")); err != nil {
|
||||
return fmt.Errorf("start public Web: %w", err)
|
||||
}
|
||||
|
|
@ -984,3 +1040,38 @@ func run(logger *zap.Logger) error {
|
|||
// public listener so no seed/prewarm work can run after port 2398 is exposed.
|
||||
return srv.ListenAndServe(ctx, cfg.ListenAddr)
|
||||
}
|
||||
|
||||
func runTelegramLoginRetention(ctx context.Context, service *telegramloginapp.Service, retention, interval time.Duration, batch int, logger *zap.Logger) {
|
||||
run := func() {
|
||||
var total int64
|
||||
// Bound one tick even when a deployment accumulated years of stale data;
|
||||
// subsequent ticks continue without monopolizing the database pool.
|
||||
for range 10 {
|
||||
deleted, err := service.DeleteExpiredArtifacts(ctx, time.Now().UTC().Add(-retention), batch)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
logger.Warn("telegram_login_retention_failed", zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
total += deleted
|
||||
if deleted < int64(batch) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if total > 0 {
|
||||
logger.Info("telegram_login_retention_completed", zap.Int64("deleted", total))
|
||||
}
|
||||
}
|
||||
run()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue