diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index d301ed1..153a1ac 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -1,4 +1,5 @@ import json +import secrets from fastapi import APIRouter, Depends, Header, HTTPException, status from pydantic import BaseModel @@ -6,6 +7,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.audit.log import write_audit_log +from app.auth.security import hash_password from app.config import settings from app.db.models import AuditLog, PendingTransaction, Round, User from app.db.session import get_session @@ -147,6 +149,35 @@ async def user_privkey(user_id: int, session: AsyncSession = Depends(get_session return AdminPrivkeyResponse(address=user.address, wif=wif) +class AdminPasswordResetResponse(BaseModel): + username: str + new_password: str + + +@router.post( + "/users/{user_id}/reset-password", + response_model=AdminPasswordResetResponse, + dependencies=[Depends(require_admin)], +) +async def reset_user_password( + user_id: int, session: AsyncSession = Depends(get_session) +) -> AdminPasswordResetResponse: + """Admin-only password reset for a user who's locked out: passwords are + Argon2-hashed (one-way), so an existing password can never be recovered or + displayed — this generates and sets a brand new one instead, shown once so + the admin can relay it to the user. There is no user-facing self-service + reset; only an admin (via /admin, token-gated) can trigger this.""" + user = await session.get(User, user_id) + if user is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found") + + new_password = secrets.token_urlsafe(12) + user.password_hash = hash_password(new_password) + await write_audit_log(session, "admin_password_reset", {"user_id": user_id}, user_id=user_id) + await session.commit() + return AdminPasswordResetResponse(username=user.username, new_password=new_password) + + class AdminRoundResponse(BaseModel): id: int status: str diff --git a/app/api/routes/users.py b/app/api/routes/users.py index 745b843..173025c 100644 --- a/app/api/routes/users.py +++ b/app/api/routes/users.py @@ -1,21 +1,54 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession from app.auth.dependencies import get_current_user +from app.auth.security import hash_password, verify_password from app.db.models import User +from app.db.session import get_session router = APIRouter(prefix="/users", tags=["users"]) +_MIN_PASSWORD_LENGTH = 8 + class MeResponse(BaseModel): id: int username: str address: str balance_sats: int + created_at: str @router.get("/me", response_model=MeResponse) async def me(user: User = Depends(get_current_user)) -> MeResponse: return MeResponse( - id=user.id, username=user.username, address=user.address, balance_sats=user.cached_balance_sats + id=user.id, + username=user.username, + address=user.address, + balance_sats=user.cached_balance_sats, + created_at=user.created_at.isoformat(), ) + + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str + + +@router.post("/me/change-password", status_code=status.HTTP_204_NO_CONTENT) +async def change_password( + body: ChangePasswordRequest, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> None: + """Self-service password change — requires the current password, unlike the + admin-only /admin/users/{id}/reset-password (which is for a user who's + actually locked out and can't provide it).""" + if not verify_password(body.current_password, user.password_hash): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "current password is incorrect") + if len(body.new_password) < _MIN_PASSWORD_LENGTH: + raise HTTPException(status.HTTP_400_BAD_REQUEST, f"new password must be at least {_MIN_PASSWORD_LENGTH} characters") + + user.password_hash = hash_password(body.new_password) + await session.commit() diff --git a/app/main.py b/app/main.py index e49a3f1..c6e8bf6 100644 --- a/app/main.py +++ b/app/main.py @@ -82,9 +82,17 @@ async def health() -> dict[str, str]: return {"status": "ok"} +_NO_STORE_HEADERS = {"Cache-Control": "no-store"} + + +@app.get("/", include_in_schema=False) +async def index_page() -> FileResponse: + return FileResponse("app/static/index.html", headers=_NO_STORE_HEADERS) + + @app.get("/admin", include_in_schema=False) async def admin_panel() -> FileResponse: - return FileResponse("app/static/admin.html") + return FileResponse("app/static/admin.html", headers=_NO_STORE_HEADERS) app.mount("/", StaticFiles(directory="app/static", html=True), name="static") diff --git a/app/static/admin.html b/app/static/admin.html index 458032c..e462973 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -271,17 +271,17 @@
Elenco utenti registrati, con saldo interno e accesso alla chiave privata per interventi manuali (es. restituire fondi bloccati).
+Elenco utenti registrati, con saldo interno, accesso alla chiave privata per interventi manuali (es. restituire fondi bloccati) e reset password per chi resta bloccato fuori dall'account.
| ID | Username | Indirizzo | Saldo (PLM) | Registrato | Chiave | |
|---|---|---|---|---|---|---|
| ID | Username | Indirizzo | Saldo (PLM) | Registrato | Chiave | Password |