Update configuration and access control for Telegram miniapp
- Added ALLOWED_USERNAMES and ADMIN_USERNAMES to .env.example for user access control. - Implemented validation of Telegram Web App initData in a new telegram_auth.py module. - Enhanced API to check user access before fetching duties. - Updated README with instructions for configuring miniapp access. - Modified .dockerignore and .gitignore to include data directory and database files.
This commit is contained in:
@@ -7,3 +7,4 @@ __pycache__/
|
|||||||
*.md
|
*.md
|
||||||
.cursor/
|
.cursor/
|
||||||
*.plan.md
|
*.plan.md
|
||||||
|
data/
|
||||||
|
|||||||
@@ -2,3 +2,7 @@ BOT_TOKEN=your_bot_token_here
|
|||||||
DATABASE_URL=sqlite:///data/duty_teller.db
|
DATABASE_URL=sqlite:///data/duty_teller.db
|
||||||
MINI_APP_BASE_URL=
|
MINI_APP_BASE_URL=
|
||||||
HTTP_PORT=8080
|
HTTP_PORT=8080
|
||||||
|
|
||||||
|
# Miniapp access: comma-separated Telegram usernames (no @). Empty = no one allowed.
|
||||||
|
ALLOWED_USERNAMES=username1,username2
|
||||||
|
ADMIN_USERNAMES=admin1,admin2
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,3 +3,5 @@ __pycache__/
|
|||||||
venv/
|
venv/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ A minimal Telegram bot boilerplate using [python-telegram-bot](https://github.co
|
|||||||
```
|
```
|
||||||
Edit `.env` and set `BOT_TOKEN` to the token from BotFather.
|
Edit `.env` and set `BOT_TOKEN` to the token from BotFather.
|
||||||
|
|
||||||
|
5. **Miniapp access (calendar)**
|
||||||
|
To allow access to the calendar miniapp, set `ALLOWED_USERNAMES` to a comma-separated list of Telegram usernames (without `@`). Users in `ADMIN_USERNAMES` also have access; the admin role is reserved for future bot commands and API features. If both are empty, no one can open the calendar.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -60,7 +63,7 @@ Ensure `.env` exists (e.g. `cp .env.example .env`) and contains `BOT_TOKEN`.
|
|||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
- `main.py` – Builds the `Application`, registers handlers, runs polling.
|
- `main.py` – Builds the `Application`, registers handlers, runs polling.
|
||||||
- `config.py` – Loads `BOT_TOKEN` from env; exits if missing.
|
- `config.py` – Loads `BOT_TOKEN`, `ALLOWED_USERNAMES`, `ADMIN_USERNAMES` from env; exits if `BOT_TOKEN` is missing.
|
||||||
- `handlers/` – Command and error handlers; add new handlers here.
|
- `handlers/` – Command and error handlers; add new handlers here.
|
||||||
- `requirements.txt` – Pinned dependencies (PTB with job-queue, python-dotenv).
|
- `requirements.txt` – Pinned dependencies (PTB with job-queue, python-dotenv).
|
||||||
|
|
||||||
|
|||||||
12
api/app.py
12
api/app.py
@@ -2,13 +2,14 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import config
|
import config
|
||||||
from fastapi import FastAPI, Query
|
from fastapi import FastAPI, Header, HTTPException, Query
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from db.session import get_session
|
from db.session import get_session
|
||||||
from db.repository import get_duties
|
from db.repository import get_duties
|
||||||
from db.schemas import DutyWithUser
|
from db.schemas import DutyWithUser
|
||||||
|
from api.telegram_auth import validate_init_data
|
||||||
|
|
||||||
app = FastAPI(title="Duty Teller API")
|
app = FastAPI(title="Duty Teller API")
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -24,7 +25,16 @@ app.add_middleware(
|
|||||||
def list_duties(
|
def list_duties(
|
||||||
from_date: str = Query(..., description="ISO date YYYY-MM-DD"),
|
from_date: str = Query(..., description="ISO date YYYY-MM-DD"),
|
||||||
to_date: str = Query(..., description="ISO date YYYY-MM-DD"),
|
to_date: str = Query(..., description="ISO date YYYY-MM-DD"),
|
||||||
|
x_telegram_init_data: str | None = Header(None, alias="X-Telegram-Init-Data"),
|
||||||
) -> list[DutyWithUser]:
|
) -> list[DutyWithUser]:
|
||||||
|
init_data = (x_telegram_init_data or "").strip()
|
||||||
|
if not init_data:
|
||||||
|
raise HTTPException(status_code=403, detail="Откройте календарь из Telegram")
|
||||||
|
username = validate_init_data(init_data, config.BOT_TOKEN)
|
||||||
|
if username is None:
|
||||||
|
raise HTTPException(status_code=403, detail="Неверные данные авторизации")
|
||||||
|
if not config.can_access_miniapp(username):
|
||||||
|
raise HTTPException(status_code=403, detail="Доступ запрещён")
|
||||||
session = get_session(config.DATABASE_URL)
|
session = get_session(config.DATABASE_URL)
|
||||||
try:
|
try:
|
||||||
rows = get_duties(session, from_date=from_date, to_date=to_date)
|
rows = get_duties(session, from_date=from_date, to_date=to_date)
|
||||||
|
|||||||
61
api/telegram_auth.py
Normal file
61
api/telegram_auth.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Validate Telegram Web App initData and extract user username."""
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
# Telegram algorithm: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
||||||
|
# Data-check string must use the same key=value pairs as received (sorted by key); we preserve raw values.
|
||||||
|
|
||||||
|
|
||||||
|
def validate_init_data(init_data: str, bot_token: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Validate initData signature and return the Telegram username (lowercase, no @).
|
||||||
|
Returns None if data is invalid, forged, or user has no username.
|
||||||
|
"""
|
||||||
|
if not init_data or not bot_token:
|
||||||
|
return None
|
||||||
|
init_data = init_data.strip()
|
||||||
|
# Parse preserving raw values for HMAC (key -> raw value)
|
||||||
|
params = {}
|
||||||
|
for part in init_data.split("&"):
|
||||||
|
if "=" not in part:
|
||||||
|
continue
|
||||||
|
key, _, value = part.partition("=")
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
params[key] = value
|
||||||
|
hash_val = params.pop("hash", None)
|
||||||
|
if not hash_val:
|
||||||
|
return None
|
||||||
|
# Build data-check string: keys sorted, format key=value per line (raw values)
|
||||||
|
data_pairs = sorted(params.items())
|
||||||
|
data_string = "\n".join(f"{k}={v}" for k, v in data_pairs)
|
||||||
|
# secret_key = HMAC-SHA256(key=b"WebAppData", msg=bot_token.encode()).digest()
|
||||||
|
secret_key = hmac.new(
|
||||||
|
b"WebAppData",
|
||||||
|
msg=bot_token.encode(),
|
||||||
|
digestmod=hashlib.sha256,
|
||||||
|
).digest()
|
||||||
|
# computed = HMAC-SHA256(key=secret_key, msg=data_string.encode()).hexdigest()
|
||||||
|
computed = hmac.new(
|
||||||
|
secret_key,
|
||||||
|
msg=data_string.encode(),
|
||||||
|
digestmod=hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
if not hmac.compare_digest(computed.lower(), hash_val.lower()):
|
||||||
|
return None
|
||||||
|
# Parse user JSON (value may be URL-encoded in the raw string)
|
||||||
|
user_raw = params.get("user")
|
||||||
|
if not user_raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
user = json.loads(unquote(user_raw))
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return None
|
||||||
|
if not isinstance(user, dict):
|
||||||
|
return None
|
||||||
|
username = user.get("username")
|
||||||
|
if not username or not isinstance(username, str):
|
||||||
|
return None
|
||||||
|
return username.strip().lstrip("@").lower()
|
||||||
18
config.py
18
config.py
@@ -14,3 +14,21 @@ DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db")
|
|||||||
MINI_APP_BASE_URL = os.getenv("MINI_APP_BASE_URL", "").rstrip("/")
|
MINI_APP_BASE_URL = os.getenv("MINI_APP_BASE_URL", "").rstrip("/")
|
||||||
HTTP_PORT = int(os.getenv("HTTP_PORT", "8080"))
|
HTTP_PORT = int(os.getenv("HTTP_PORT", "8080"))
|
||||||
DATA_DIR = Path(__file__).resolve().parent / "data"
|
DATA_DIR = Path(__file__).resolve().parent / "data"
|
||||||
|
|
||||||
|
# Miniapp access: comma-separated Telegram usernames (no @). Empty = no one allowed.
|
||||||
|
_raw_allowed = os.getenv("ALLOWED_USERNAMES", "").strip()
|
||||||
|
ALLOWED_USERNAMES = {s.strip().lstrip("@").lower() for s in _raw_allowed.split(",") if s.strip()}
|
||||||
|
|
||||||
|
_raw_admin = os.getenv("ADMIN_USERNAMES", "").strip()
|
||||||
|
ADMIN_USERNAMES = {s.strip().lstrip("@").lower() for s in _raw_admin.split(",") if s.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin(username: str) -> bool:
|
||||||
|
"""True if the given Telegram username (no @, any case) is in ADMIN_USERNAMES."""
|
||||||
|
return (username or "").strip().lower() in ADMIN_USERNAMES
|
||||||
|
|
||||||
|
|
||||||
|
def can_access_miniapp(username: str) -> bool:
|
||||||
|
"""True if username is in ALLOWED_USERNAMES or ADMIN_USERNAMES."""
|
||||||
|
u = (username or "").strip().lower()
|
||||||
|
return u in ALLOWED_USERNAMES or u in ADMIN_USERNAMES
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
const dutyListEl = document.getElementById("dutyList");
|
const dutyListEl = document.getElementById("dutyList");
|
||||||
const loadingEl = document.getElementById("loading");
|
const loadingEl = document.getElementById("loading");
|
||||||
const errorEl = document.getElementById("error");
|
const errorEl = document.getElementById("error");
|
||||||
|
const accessDeniedEl = document.getElementById("accessDenied");
|
||||||
|
const headerEl = document.querySelector(".header");
|
||||||
|
const weekdaysEl = document.querySelector(".weekdays");
|
||||||
|
|
||||||
function isoDate(d) {
|
function isoDate(d) {
|
||||||
return d.toISOString().slice(0, 10);
|
return d.toISOString().slice(0, 10);
|
||||||
@@ -29,10 +32,38 @@
|
|||||||
return new Date(d.getFullYear(), d.getMonth(), diff);
|
return new Date(d.getFullYear(), d.getMonth(), diff);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getInitData() {
|
||||||
|
return (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAccessDenied() {
|
||||||
|
if (headerEl) headerEl.hidden = true;
|
||||||
|
if (weekdaysEl) weekdaysEl.hidden = true;
|
||||||
|
calendarEl.hidden = true;
|
||||||
|
dutyListEl.hidden = true;
|
||||||
|
loadingEl.classList.add("hidden");
|
||||||
|
errorEl.hidden = true;
|
||||||
|
accessDeniedEl.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAccessDenied() {
|
||||||
|
accessDeniedEl.hidden = true;
|
||||||
|
if (headerEl) headerEl.hidden = false;
|
||||||
|
if (weekdaysEl) weekdaysEl.hidden = false;
|
||||||
|
calendarEl.hidden = false;
|
||||||
|
dutyListEl.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchDuties(from, to) {
|
async function fetchDuties(from, to) {
|
||||||
const base = window.location.origin;
|
const base = window.location.origin;
|
||||||
const url = base + "/api/duties?from=" + encodeURIComponent(from) + "&to=" + encodeURIComponent(to);
|
const url = base + "/api/duties?from=" + encodeURIComponent(from) + "&to=" + encodeURIComponent(to);
|
||||||
const res = await fetch(url);
|
const initData = getInitData();
|
||||||
|
const headers = {};
|
||||||
|
if (initData) headers["X-Telegram-Init-Data"] = initData;
|
||||||
|
const res = await fetch(url, { headers: headers });
|
||||||
|
if (res.status === 403) {
|
||||||
|
throw new Error("ACCESS_DENIED");
|
||||||
|
}
|
||||||
if (!res.ok) throw new Error("Ошибка загрузки");
|
if (!res.ok) throw new Error("Ошибка загрузки");
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@@ -116,6 +147,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadMonth() {
|
async function loadMonth() {
|
||||||
|
if (!getInitData()) {
|
||||||
|
showAccessDenied();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hideAccessDenied();
|
||||||
loadingEl.classList.remove("hidden");
|
loadingEl.classList.remove("hidden");
|
||||||
errorEl.hidden = true;
|
errorEl.hidden = true;
|
||||||
const from = isoDate(firstDayOfMonth(current));
|
const from = isoDate(firstDayOfMonth(current));
|
||||||
@@ -126,6 +162,10 @@
|
|||||||
renderCalendar(current.getFullYear(), current.getMonth(), byDate);
|
renderCalendar(current.getFullYear(), current.getMonth(), byDate);
|
||||||
renderDutyList(duties);
|
renderDutyList(duties);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e.message === "ACCESS_DENIED") {
|
||||||
|
showAccessDenied();
|
||||||
|
return;
|
||||||
|
}
|
||||||
showError(e.message || "Не удалось загрузить данные.");
|
showError(e.message || "Не удалось загрузить данные.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,12 @@
|
|||||||
<div class="duty-list" id="dutyList"></div>
|
<div class="duty-list" id="dutyList"></div>
|
||||||
<div class="loading" id="loading">Загрузка…</div>
|
<div class="loading" id="loading">Загрузка…</div>
|
||||||
<div class="error" id="error" hidden></div>
|
<div class="error" id="error" hidden></div>
|
||||||
|
<div class="access-denied" id="accessDenied" hidden>
|
||||||
|
<p>Доступ запрещён.</p>
|
||||||
|
<p class="muted">Откройте календарь из Telegram.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -150,3 +150,22 @@ body {
|
|||||||
.error[hidden], .loading.hidden {
|
.error[hidden], .loading.hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.access-denied {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-denied p {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-denied p:first-child {
|
||||||
|
color: #f7768e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-denied[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user