add auth
This commit is contained in:
22 files changed
+1034
-25
No files matched your search
+32
-11
@@ -1,33 +1,54 @@
|
||||
"""Shared dependencies: the database session and the optional shared secret."""
|
||||
"""Shared dependencies: the database session and the two ways into the API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
from fastapi import Cookie, Depends, Header, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import env_settings
|
||||
from app.db import get_session
|
||||
from app.services import auth_service
|
||||
from app.services.settings_service import load_settings
|
||||
from app.settings_schema import AppSettings
|
||||
|
||||
DbSession = Annotated[Session, Depends(get_session)]
|
||||
|
||||
|
||||
def require_token(x_api_token: Annotated[str | None, Header()] = None) -> None:
|
||||
"""Guard every API call when `API_TOKEN` is set.
|
||||
def require_auth(
|
||||
session: DbSession,
|
||||
x_api_token: Annotated[str | None, Header()] = None,
|
||||
kadence_session: Annotated[str | None, Cookie()] = None,
|
||||
) -> None:
|
||||
"""Guard every API call.
|
||||
|
||||
Off by default: on a laptop the backend is bound to localhost and a token is
|
||||
friction. Set it the moment the container is reachable from anywhere else.
|
||||
Two independent gates, either of which lets a request through:
|
||||
|
||||
* `X-API-Token` against `API_TOKEN` — the desktop app and scripts, off by
|
||||
default (friction-free on a laptop; set the moment the backend leaves it).
|
||||
* a `kadence_session` cookie from `/api/auth/login*` — the browser, closed
|
||||
the moment a password or Telegram login is configured in Settings → Access.
|
||||
|
||||
With neither `API_TOKEN` nor a login configured, the API stays open, exactly
|
||||
as before this existed.
|
||||
"""
|
||||
expected = env_settings().api_token
|
||||
if not expected:
|
||||
if expected and x_api_token == expected:
|
||||
return
|
||||
if x_api_token != expected:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Bad or missing X-API-Token."
|
||||
)
|
||||
if auth_service.valid_session(session, kadence_session):
|
||||
return
|
||||
|
||||
settings = load_settings(session)
|
||||
login_required = bool(settings.auth.password_hash) or (
|
||||
settings.auth.telegram_login_enabled and bool(settings.auth.telegram_owner_id)
|
||||
)
|
||||
if not expected and not login_required:
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Sign in, or send a valid X-API-Token.",
|
||||
)
|
||||
|
||||
|
||||
def get_settings(session: DbSession) -> AppSettings:
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Signing in and out of the browser UI.
|
||||
|
||||
Mounted directly on the app, not through `api_router` — these endpoints have
|
||||
to be reachable *before* a session exists, so they never carry the
|
||||
`require_auth` dependency. `change_password` checks for a live session itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Body, Cookie, HTTPException, Request, Response, status
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.services import auth_service, settings_service
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
_COOKIE = auth_service.SESSION_COOKIE
|
||||
|
||||
|
||||
def _client_key(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _is_https(request: Request) -> bool:
|
||||
# TLS usually terminates at a reverse proxy in front of this container, so
|
||||
# `request.url.scheme` alone would read "http" even on an HTTPS deployment.
|
||||
return request.url.scheme == "https" or request.headers.get("x-forwarded-proto") == "https"
|
||||
|
||||
|
||||
def _set_cookie(response: Response, request: Request, token: str, max_age: int) -> None:
|
||||
response.set_cookie(
|
||||
_COOKIE,
|
||||
token,
|
||||
max_age=max_age,
|
||||
httponly=True,
|
||||
secure=_is_https(request),
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def me(
|
||||
session: DbSession,
|
||||
kadence_session: Annotated[str | None, Cookie()] = None,
|
||||
) -> dict:
|
||||
settings = settings_service.load_settings(session)
|
||||
row = auth_service.valid_session(session, kadence_session)
|
||||
login_required = bool(settings.auth.password_hash) or (
|
||||
settings.auth.telegram_login_enabled and bool(settings.auth.telegram_owner_id)
|
||||
)
|
||||
return {
|
||||
"authenticated": row is not None or not login_required,
|
||||
"login_required": login_required,
|
||||
"username": settings.auth.username,
|
||||
"password_login_enabled": bool(settings.auth.password_hash),
|
||||
"telegram_login_enabled": settings.auth.telegram_login_enabled
|
||||
and bool(settings.auth.telegram_owner_id),
|
||||
"telegram_bot_username": settings.auth.telegram_bot_username,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(
|
||||
session: DbSession,
|
||||
request: Request,
|
||||
response: Response,
|
||||
body: dict[str, Any] = Body(...),
|
||||
) -> dict:
|
||||
key = f"password:{_client_key(request)}"
|
||||
if auth_service.rate_limited(key):
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Too many attempts. Wait a few minutes.")
|
||||
|
||||
settings = settings_service.load_settings(session)
|
||||
username = str(body.get("username", ""))
|
||||
password = str(body.get("password", ""))
|
||||
ok = (
|
||||
bool(settings.auth.password_hash)
|
||||
and username == settings.auth.username
|
||||
and auth_service.verify_password(password, settings.auth.password_hash)
|
||||
)
|
||||
if not ok:
|
||||
auth_service.record_failure(key)
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wrong username or password.")
|
||||
|
||||
auth_service.record_success(key)
|
||||
row = auth_service.create_session(session, "password", settings.auth.session_days)
|
||||
_set_cookie(response, request, row.token, settings.auth.session_days * 86400)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/login/telegram")
|
||||
def login_telegram(
|
||||
session: DbSession,
|
||||
request: Request,
|
||||
response: Response,
|
||||
body: dict[str, Any] = Body(...),
|
||||
) -> dict:
|
||||
key = f"telegram:{_client_key(request)}"
|
||||
if auth_service.rate_limited(key):
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Too many attempts. Wait a few minutes.")
|
||||
|
||||
settings = settings_service.load_settings(session)
|
||||
telegram = settings.notifications.telegram
|
||||
auth = settings.auth
|
||||
ok = (
|
||||
auth.telegram_login_enabled
|
||||
and bool(auth.telegram_owner_id)
|
||||
and bool(telegram.bot_token)
|
||||
and auth_service.verify_telegram_payload(body, telegram.bot_token)
|
||||
and str(body.get("id")) == auth.telegram_owner_id
|
||||
)
|
||||
if not ok:
|
||||
auth_service.record_failure(key)
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Telegram sign-in did not check out.")
|
||||
|
||||
auth_service.record_success(key)
|
||||
row = auth_service.create_session(session, "telegram", auth.session_days)
|
||||
_set_cookie(response, request, row.token, auth.session_days * 86400)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(
|
||||
session: DbSession,
|
||||
response: Response,
|
||||
kadence_session: Annotated[str | None, Cookie()] = None,
|
||||
) -> dict:
|
||||
auth_service.delete_session(session, kadence_session)
|
||||
response.delete_cookie(_COOKIE, path="/")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/password")
|
||||
def change_password(
|
||||
session: DbSession,
|
||||
body: dict[str, Any] = Body(...),
|
||||
kadence_session: Annotated[str | None, Cookie()] = None,
|
||||
) -> dict:
|
||||
"""Requires a live session — set the first password via `AUTH_PASSWORD` or
|
||||
`make set-password` instead, since there is nothing to authenticate with
|
||||
yet on a fresh install."""
|
||||
if auth_service.valid_session(session, kadence_session) is None:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Sign in first.")
|
||||
|
||||
settings = settings_service.load_settings(session)
|
||||
current = str(body.get("current_password", ""))
|
||||
new = str(body.get("new_password", ""))
|
||||
if settings.auth.password_hash and not auth_service.verify_password(
|
||||
current, settings.auth.password_hash
|
||||
):
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Current password is wrong.")
|
||||
if len(new) < 8:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Use at least 8 characters.")
|
||||
|
||||
settings.auth.password_hash = auth_service.hash_password(new)
|
||||
settings_service.save_settings(session, settings)
|
||||
return {"ok": True}
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
@@ -27,6 +28,9 @@ def main() -> int:
|
||||
report_cmd.add_argument("--print", action="store_true", help="print, do not send")
|
||||
sub.add_parser("stats", help="print a summary as JSON")
|
||||
|
||||
setpw = sub.add_parser("set-password", help="set or replace the web login password")
|
||||
setpw.add_argument("--username", default=None, help="defaults to the current one")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "serve":
|
||||
@@ -69,6 +73,21 @@ def main() -> int:
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
elif args.command == "set-password":
|
||||
from app.services import auth_service
|
||||
|
||||
password = getpass.getpass("New web login password: ")
|
||||
if password != getpass.getpass("Confirm: "):
|
||||
print("Passwords did not match.")
|
||||
return 1
|
||||
if len(password) < 8:
|
||||
print("Use at least 8 characters.")
|
||||
return 1
|
||||
if args.username:
|
||||
settings.auth.username = args.username
|
||||
settings.auth.password_hash = auth_service.hash_password(password)
|
||||
settings_service.save_settings(session, settings)
|
||||
print(f"Password set for {settings.auth.username!r}.")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -85,6 +85,17 @@ class EnvSettings(BaseSettings):
|
||||
telegram_chat_id: str = ""
|
||||
slack_webhook_url: str = ""
|
||||
|
||||
# ---- web login (closes the browser UI; the desktop app keeps API_TOKEN) ---
|
||||
auth_username: str = "timka"
|
||||
auth_password: str = ""
|
||||
"""Set once; the hash is seeded into settings on first boot, never stored here."""
|
||||
|
||||
telegram_owner_id: str = ""
|
||||
"""Your numeric Telegram user id — https://t.me/userinfobot says it in a DM."""
|
||||
|
||||
telegram_bot_username: str = ""
|
||||
"""Without the `@`. Needed to render the Telegram Login Widget."""
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
if self.cors_origins.strip() == "*":
|
||||
|
||||
+10
-3
@@ -20,7 +20,8 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app import __version__
|
||||
from app.api import api_router
|
||||
from app.api.deps import require_token
|
||||
from app.api.deps import require_auth
|
||||
from app.api.routes_auth import router as auth_router
|
||||
from app.config import env_settings
|
||||
from app.db import init_db, session_scope
|
||||
from app.services import scheduler, settings_service, telegram_bot
|
||||
@@ -70,7 +71,8 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router, dependencies=[Depends(require_token)])
|
||||
app.include_router(auth_router)
|
||||
app.include_router(api_router, dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
@app.get("/api/health", tags=["meta"])
|
||||
@@ -82,9 +84,14 @@ def health() -> dict:
|
||||
def info() -> dict:
|
||||
"""What a client needs to know before it has a token: who we are, what is on."""
|
||||
env = env_settings()
|
||||
with session_scope() as session:
|
||||
settings = settings_service.load_settings(session)
|
||||
login_required = bool(settings.auth.password_hash) or (
|
||||
settings.auth.telegram_login_enabled and bool(settings.auth.telegram_owner_id)
|
||||
)
|
||||
return {
|
||||
"version": __version__,
|
||||
"auth_required": bool(env.api_token),
|
||||
"auth_required": bool(env.api_token) or login_required,
|
||||
"scheduler": env.scheduler_enabled,
|
||||
"jobs": scheduler.describe(),
|
||||
}
|
||||
|
||||
@@ -173,6 +173,22 @@ class DailyReport(Base):
|
||||
score: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
|
||||
|
||||
class AuthSession(Base):
|
||||
"""A browser login, created by a password or Telegram sign-in.
|
||||
|
||||
The token is the cookie value itself (a random opaque string) — no server
|
||||
side id/secret split needed for a single-user app. Expired rows are pruned
|
||||
lazily whenever a session is looked up.
|
||||
"""
|
||||
|
||||
__tablename__ = "auth_sessions"
|
||||
|
||||
token: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
method: Mapped[str] = mapped_column(String(16)) # password | telegram
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
|
||||
|
||||
class EventLog(Base):
|
||||
"""Small append-only log used for "did we already send this?" questions.
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Who is allowed into the browser UI.
|
||||
|
||||
Two ways in, both ending in the same thing — a row in `auth_sessions` and a
|
||||
cookie that names it:
|
||||
|
||||
* a password, hashed with stdlib PBKDF2 (no new dependency for one check);
|
||||
* the Telegram Login Widget, verified with the documented HMAC scheme against
|
||||
the bot token already configured for notifications.
|
||||
|
||||
The desktop app and scripts are untouched — they keep using `X-API-Token`
|
||||
(see `app.api.deps`). This module only ever runs for the browser.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import AuthSession
|
||||
|
||||
PBKDF2_ALGO = "pbkdf2_sha256"
|
||||
PBKDF2_ITERATIONS = 260_000
|
||||
SESSION_COOKIE = "kadence_session"
|
||||
TELEGRAM_AUTH_MAX_AGE = 86400 # a login link older than a day is refused
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Passwords
|
||||
# --------------------------------------------------------------------------
|
||||
def hash_password(password: str) -> str:
|
||||
salt = secrets.token_hex(16)
|
||||
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), PBKDF2_ITERATIONS)
|
||||
return f"{PBKDF2_ALGO}${PBKDF2_ITERATIONS}${salt}${digest.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored_hash: str) -> bool:
|
||||
try:
|
||||
algo, iterations, salt, expected = stored_hash.split("$")
|
||||
if algo != PBKDF2_ALGO:
|
||||
return False
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256", password.encode(), bytes.fromhex(salt), int(iterations)
|
||||
)
|
||||
return hmac.compare_digest(digest.hex(), expected)
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Telegram Login Widget
|
||||
# --------------------------------------------------------------------------
|
||||
def verify_telegram_payload(data: dict[str, Any], bot_token: str) -> bool:
|
||||
"""https://core.telegram.org/widgets/login#checking-authorization"""
|
||||
received_hash = data.get("hash")
|
||||
if not received_hash or not bot_token:
|
||||
return False
|
||||
auth_date = data.get("auth_date")
|
||||
try:
|
||||
if time.time() - int(auth_date) > TELEGRAM_AUTH_MAX_AGE:
|
||||
return False
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
check_string = "\n".join(
|
||||
f"{key}={data[key]}" for key in sorted(data) if key not in ("hash",)
|
||||
)
|
||||
secret_key = hashlib.sha256(bot_token.encode()).digest()
|
||||
computed = hmac.new(secret_key, check_string.encode(), hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(computed, received_hash)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Sessions
|
||||
# --------------------------------------------------------------------------
|
||||
def create_session(session: Session, method: str, session_days: int) -> AuthSession:
|
||||
_prune_expired(session)
|
||||
row = AuthSession(
|
||||
token=secrets.token_urlsafe(32),
|
||||
method=method,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(days=session_days),
|
||||
)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def _aware(dt: datetime) -> datetime:
|
||||
"""SQLite drops the tzinfo it was stored with; Postgres does not."""
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def valid_session(session: Session, token: str | None) -> AuthSession | None:
|
||||
if not token:
|
||||
return None
|
||||
row = session.get(AuthSession, token)
|
||||
if row is None:
|
||||
return None
|
||||
if _aware(row.expires_at) < datetime.now(timezone.utc):
|
||||
session.delete(row)
|
||||
session.commit()
|
||||
return None
|
||||
return row
|
||||
|
||||
|
||||
def delete_session(session: Session, token: str | None) -> None:
|
||||
if not token:
|
||||
return
|
||||
session.execute(delete(AuthSession).where(AuthSession.token == token))
|
||||
session.commit()
|
||||
|
||||
|
||||
def _prune_expired(session: Session) -> None:
|
||||
session.execute(delete(AuthSession).where(AuthSession.expires_at < datetime.now(timezone.utc)))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Login rate limiting (in-process; one instance, one user)
|
||||
# --------------------------------------------------------------------------
|
||||
_MAX_ATTEMPTS = 8
|
||||
_WINDOW_SECONDS = 300
|
||||
_failures: dict[str, list[float]] = {}
|
||||
|
||||
|
||||
def rate_limited(key: str) -> bool:
|
||||
now = time.monotonic()
|
||||
recent = [t for t in _failures.get(key, []) if now - t < _WINDOW_SECONDS]
|
||||
_failures[key] = recent
|
||||
return len(recent) >= _MAX_ATTEMPTS
|
||||
|
||||
|
||||
def record_failure(key: str) -> None:
|
||||
_failures.setdefault(key, []).append(time.monotonic())
|
||||
|
||||
|
||||
def record_success(key: str) -> None:
|
||||
_failures.pop(key, None)
|
||||
@@ -83,6 +83,18 @@ def settings_from_env() -> AppSettings:
|
||||
settings.notifications.slack.enabled = True
|
||||
settings.notifications.slack.webhook_url = env.slack_webhook_url
|
||||
|
||||
auth = settings.auth
|
||||
if env.auth_password:
|
||||
from app.services import auth_service
|
||||
|
||||
auth.username = env.auth_username or auth.username
|
||||
auth.password_hash = auth_service.hash_password(env.auth_password)
|
||||
if env.telegram_owner_id:
|
||||
auth.telegram_owner_id = env.telegram_owner_id
|
||||
auth.telegram_login_enabled = True
|
||||
if env.telegram_bot_username:
|
||||
auth.telegram_bot_username = env.telegram_bot_username
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
|
||||
@@ -276,6 +276,32 @@ class NotificationsSettings(BaseModel):
|
||||
report: ReportSettings = Field(default_factory=ReportSettings)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Web login
|
||||
# --------------------------------------------------------------------------
|
||||
class AuthSettings(BaseModel):
|
||||
"""Gates the browser UI. The desktop app and scripts keep using `API_TOKEN`.
|
||||
|
||||
The web UI is closed the moment either credential is configured: a password
|
||||
(`password_hash` set) or Telegram sign-in (`telegram_login_enabled` with an
|
||||
`telegram_owner_id`). With neither set, the browser is open — the same
|
||||
"friction-free on a laptop" default `API_TOKEN` already has.
|
||||
"""
|
||||
|
||||
username: str = "timka"
|
||||
password_hash: str = ""
|
||||
"""`pbkdf2_sha256$<iterations>$<salt>$<hash>`, never the plaintext password."""
|
||||
|
||||
telegram_login_enabled: bool = False
|
||||
telegram_owner_id: str = ""
|
||||
"""The numeric Telegram user id allowed to sign in with the Login Widget."""
|
||||
|
||||
telegram_bot_username: str = ""
|
||||
"""Without the `@`, e.g. `kadence_bot` — the widget needs it to render."""
|
||||
|
||||
session_days: int = Field(default=30, ge=1, le=365)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Root
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -298,6 +324,7 @@ class AppSettings(BaseModel):
|
||||
blocker: BlockerSettings = Field(default_factory=BlockerSettings)
|
||||
notifications: NotificationsSettings = Field(default_factory=NotificationsSettings)
|
||||
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
|
||||
auth: AuthSettings = Field(default_factory=AuthSettings)
|
||||
|
||||
@field_validator("timezone")
|
||||
@classmethod
|
||||
@@ -321,6 +348,7 @@ SECRET_FIELDS = frozenset(
|
||||
"session_cookie",
|
||||
"bot_token",
|
||||
"webhook_url",
|
||||
"password_hash",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ for name in (
|
||||
"TELEGRAM_BOT_TOKEN",
|
||||
"TELEGRAM_CHAT_ID",
|
||||
"SLACK_WEBHOOK_URL",
|
||||
"AUTH_PASSWORD",
|
||||
"TELEGRAM_OWNER_ID",
|
||||
"TELEGRAM_BOT_USERNAME",
|
||||
"API_TOKEN",
|
||||
):
|
||||
os.environ.pop(name, None)
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Login gate: closed only once a password or Telegram login is configured."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services import auth_service
|
||||
|
||||
|
||||
def test_open_by_default(client):
|
||||
assert client.get("/api/settings").status_code == 200
|
||||
|
||||
|
||||
def test_password_hash_round_trips():
|
||||
hashed = auth_service.hash_password("correct horse battery staple")
|
||||
assert auth_service.verify_password("correct horse battery staple", hashed)
|
||||
assert not auth_service.verify_password("wrong", hashed)
|
||||
|
||||
|
||||
def test_login_flow(client):
|
||||
from app.db import SessionLocal
|
||||
from app.services import settings_service
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
settings = settings_service.load_settings(db)
|
||||
settings.auth.password_hash = auth_service.hash_password("s3cret!!")
|
||||
settings_service.save_settings(db, settings)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Locked out without a session.
|
||||
assert client.get("/api/settings").status_code == 401
|
||||
assert client.get("/api/auth/me").json()["login_required"] is True
|
||||
|
||||
bad = client.post("/api/auth/login", json={"username": "timka", "password": "nope"})
|
||||
assert bad.status_code == 401
|
||||
|
||||
ok = client.post("/api/auth/login", json={"username": "timka", "password": "s3cret!!"})
|
||||
assert ok.status_code == 200
|
||||
assert "kadence_session" in client.cookies
|
||||
|
||||
assert client.get("/api/settings").status_code == 200
|
||||
assert client.get("/api/auth/me").json()["authenticated"] is True
|
||||
|
||||
client.post("/api/auth/logout")
|
||||
assert client.get("/api/settings").status_code == 401
|
||||
|
||||
|
||||
def test_api_token_still_works_once_login_is_configured(client, monkeypatch):
|
||||
from app.config import env_settings
|
||||
from app.db import SessionLocal
|
||||
from app.services import settings_service
|
||||
|
||||
env_settings.cache_clear()
|
||||
monkeypatch.setenv("API_TOKEN", "desktop-secret")
|
||||
env_settings.cache_clear()
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
settings = settings_service.load_settings(db)
|
||||
settings.auth.password_hash = auth_service.hash_password("s3cret!!")
|
||||
settings_service.save_settings(db, settings)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
assert client.get("/api/settings").status_code == 401
|
||||
assert client.get("/api/settings", headers={"X-API-Token": "desktop-secret"}).status_code == 200
|
||||
env_settings.cache_clear()
|
||||
Reference in new issue
Block a user