Add auth foundation and app DB management
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Request, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.dependencies import get_app_settings, get_auth_db, get_current_auth_session
|
||||
from app.services.auth import (
|
||||
AuthenticatedSession,
|
||||
authenticate_user,
|
||||
change_password,
|
||||
create_session,
|
||||
AuthPasswordChangeError,
|
||||
issue_login_csrf_token,
|
||||
revoke_session,
|
||||
validate_csrf_token,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
||||
LOGIN_CSRF_COOKIE_NAME = "login_csrf"
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
def login_page(
|
||||
request: Request,
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> Response:
|
||||
if current_auth is not None:
|
||||
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
csrf_token = issue_login_csrf_token()
|
||||
response = templates.TemplateResponse(
|
||||
request,
|
||||
"login.html",
|
||||
{
|
||||
"app_name": settings.app_name,
|
||||
"app_env": settings.app_env,
|
||||
"csrf_token": csrf_token,
|
||||
"error_message": None,
|
||||
},
|
||||
)
|
||||
_set_login_csrf_cookie(response, settings=settings, token=csrf_token)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/login", response_class=HTMLResponse)
|
||||
def login_submit(
|
||||
request: Request,
|
||||
username: str = Form(),
|
||||
password: str = Form(),
|
||||
csrf_token: str = Form(),
|
||||
session: Session = Depends(get_auth_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
) -> Response:
|
||||
cookie_csrf_token = request.cookies.get(LOGIN_CSRF_COOKIE_NAME)
|
||||
if not validate_csrf_token(expected=cookie_csrf_token, actual=csrf_token):
|
||||
logger.warning("Rejected login attempt due to CSRF validation failure")
|
||||
return _render_login_error(
|
||||
request,
|
||||
settings=settings,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
error_message="invalid login request",
|
||||
)
|
||||
|
||||
user = authenticate_user(session, username=username, password=password)
|
||||
if user is None:
|
||||
return _render_login_error(
|
||||
request,
|
||||
settings=settings,
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
error_message="invalid username or password",
|
||||
)
|
||||
|
||||
auth_session, raw_token = create_session(session, user=user, settings=settings)
|
||||
response = RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
|
||||
response.delete_cookie(LOGIN_CSRF_COOKIE_NAME, path="/login")
|
||||
response.set_cookie(
|
||||
key=settings.auth_session_cookie_name,
|
||||
value=raw_token,
|
||||
max_age=settings.auth_session_ttl_hours * 3600,
|
||||
httponly=True,
|
||||
secure=settings.auth_cookie_secure,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
logger.info("Created authenticated session for user '%s'", user.username)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/admin/change-password", response_class=HTMLResponse)
|
||||
def change_password_submit(
|
||||
request: Request,
|
||||
current_password: str = Form(),
|
||||
new_password: str = Form(),
|
||||
confirm_password: str = Form(),
|
||||
csrf_token: str = Form(),
|
||||
session: Session = Depends(get_auth_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> Response:
|
||||
if current_auth is None:
|
||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
if not validate_csrf_token(expected=current_auth.session.csrf_token, actual=csrf_token):
|
||||
logger.warning("Rejected password change attempt due to CSRF validation failure")
|
||||
return _render_admin_page(
|
||||
request,
|
||||
settings=settings,
|
||||
current_auth=current_auth,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
password_change_error="invalid password change request",
|
||||
)
|
||||
|
||||
try:
|
||||
change_password(
|
||||
session,
|
||||
user=current_auth.user,
|
||||
current_password=current_password,
|
||||
new_password=new_password,
|
||||
confirm_password=confirm_password,
|
||||
)
|
||||
except AuthPasswordChangeError as exc:
|
||||
logger.info(
|
||||
"Rejected password change for user '%s': %s",
|
||||
current_auth.user.username,
|
||||
exc,
|
||||
)
|
||||
return _render_admin_page(
|
||||
request,
|
||||
settings=settings,
|
||||
current_auth=current_auth,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
password_change_error="password change failed",
|
||||
)
|
||||
|
||||
logger.info("Password updated for user '%s'", current_auth.user.username)
|
||||
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(
|
||||
request: Request,
|
||||
csrf_token: str = Form(),
|
||||
session: Session = Depends(get_auth_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> RedirectResponse:
|
||||
if current_auth is not None and validate_csrf_token(
|
||||
expected=current_auth.session.csrf_token, actual=csrf_token
|
||||
):
|
||||
revoke_session(session, auth_session=current_auth.session)
|
||||
logger.info("Revoked authenticated session for user '%s'", current_auth.user.username)
|
||||
else:
|
||||
logger.warning("Rejected logout request due to missing session or invalid CSRF token")
|
||||
|
||||
response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
response.delete_cookie(settings.auth_session_cookie_name, path="/")
|
||||
return response
|
||||
|
||||
|
||||
def _render_login_error(
|
||||
request: Request,
|
||||
*,
|
||||
settings: Settings,
|
||||
status_code: int,
|
||||
error_message: str,
|
||||
) -> HTMLResponse:
|
||||
csrf_token = issue_login_csrf_token()
|
||||
response = templates.TemplateResponse(
|
||||
request,
|
||||
"login.html",
|
||||
{
|
||||
"app_name": settings.app_name,
|
||||
"app_env": settings.app_env,
|
||||
"csrf_token": csrf_token,
|
||||
"error_message": error_message,
|
||||
},
|
||||
status_code=status_code,
|
||||
)
|
||||
_set_login_csrf_cookie(response, settings=settings, token=csrf_token)
|
||||
return response
|
||||
|
||||
|
||||
def _set_login_csrf_cookie(response: HTMLResponse, *, settings: Settings, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=LOGIN_CSRF_COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=1800,
|
||||
httponly=True,
|
||||
secure=settings.auth_cookie_secure,
|
||||
samesite="lax",
|
||||
path="/login",
|
||||
)
|
||||
|
||||
|
||||
def _render_admin_page(
|
||||
request: Request,
|
||||
*,
|
||||
settings: Settings,
|
||||
current_auth: AuthenticatedSession,
|
||||
status_code: int,
|
||||
password_change_error: str | None,
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin.html",
|
||||
{
|
||||
"app_name": settings.app_name,
|
||||
"app_env": settings.app_env,
|
||||
"current_username": current_auth.user.username,
|
||||
"csrf_token": current_auth.session.csrf_token,
|
||||
"force_password_change": current_auth.user.force_password_change,
|
||||
"password_change_error": password_change_error,
|
||||
},
|
||||
status_code=status_code,
|
||||
)
|
||||
+24
-3
@@ -1,11 +1,12 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.config import Settings
|
||||
from app.dependencies import get_app_settings
|
||||
from app.dependencies import get_app_settings, get_current_auth_session
|
||||
from app.services.auth import AuthenticatedSession
|
||||
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
|
||||
router = APIRouter(tags=["pages"])
|
||||
@@ -19,3 +20,23 @@ def home(request: Request, settings: Settings = Depends(get_app_settings)) -> HT
|
||||
"notion_status": "Legacy scope, removed from the Python rewrite target.",
|
||||
}
|
||||
return templates.TemplateResponse(request, "home.html", context)
|
||||
|
||||
|
||||
@router.get("/admin", response_class=HTMLResponse)
|
||||
def admin_page(
|
||||
request: Request,
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> Response:
|
||||
if current_auth is None:
|
||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
context = {
|
||||
"app_name": settings.app_name,
|
||||
"app_env": settings.app_env,
|
||||
"current_username": current_auth.user.username,
|
||||
"csrf_token": current_auth.session.csrf_token,
|
||||
"force_password_change": current_auth.user.force_password_change,
|
||||
"password_change_error": None,
|
||||
}
|
||||
return templates.TemplateResponse(request, "admin.html", context)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from collections.abc import Generator
|
||||
from functools import lru_cache
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
class AuthBase(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def _build_connect_args(database_url: str) -> dict[str, object]:
|
||||
connect_args: dict[str, object] = {}
|
||||
if database_url.startswith("sqlite"):
|
||||
connect_args["check_same_thread"] = False
|
||||
return connect_args
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _get_auth_engine(database_url: str):
|
||||
return create_engine(database_url, connect_args=_build_connect_args(database_url))
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _get_auth_session_local(database_url: str):
|
||||
engine = _get_auth_engine(database_url)
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
|
||||
|
||||
|
||||
def get_auth_engine():
|
||||
settings = get_settings()
|
||||
return _get_auth_engine(settings.app_database_url)
|
||||
|
||||
|
||||
def get_auth_session_local():
|
||||
settings = get_settings()
|
||||
return _get_auth_session_local(settings.app_database_url)
|
||||
|
||||
|
||||
def reset_auth_db_caches() -> None:
|
||||
_get_auth_session_local.cache_clear()
|
||||
_get_auth_engine.cache_clear()
|
||||
|
||||
|
||||
def get_auth_db_session() -> Generator[Session, None, None]:
|
||||
session_local = get_auth_session_local()
|
||||
session = session_local()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
@@ -11,6 +11,7 @@ class Settings(BaseSettings):
|
||||
app_debug: bool = False
|
||||
app_host: str = "0.0.0.0"
|
||||
app_port: int = 8000
|
||||
app_database_url: str = "sqlite:///./data/app.db"
|
||||
|
||||
location_database_url: str = "sqlite:///./data/locationRecorder.db"
|
||||
poo_database_url: str = "sqlite:///./data/pooRecorder.db"
|
||||
@@ -27,6 +28,11 @@ class Settings(BaseSettings):
|
||||
poo_webhook_id: str = ""
|
||||
poo_sensor_entity_name: str = "sensor.test_poo_status"
|
||||
poo_sensor_friendly_name: str = "Poo Status"
|
||||
auth_bootstrap_username: str = "admin"
|
||||
auth_bootstrap_password: str = "admin"
|
||||
auth_session_cookie_name: str = "home_automation_session"
|
||||
auth_session_ttl_hours: int = 12
|
||||
auth_cookie_secure_override: bool | None = None
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
@@ -52,11 +58,23 @@ class Settings(BaseSettings):
|
||||
def location_sqlite_path(self) -> Path | None:
|
||||
return self._sqlite_path_from_url(self.location_database_url)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def app_sqlite_path(self) -> Path | None:
|
||||
return self._sqlite_path_from_url(self.app_database_url)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def poo_sqlite_path(self) -> Path | None:
|
||||
return self._sqlite_path_from_url(self.poo_database_url)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def auth_cookie_secure(self) -> bool:
|
||||
if self.auth_cookie_secure_override is not None:
|
||||
return self.auth_cookie_secure_override
|
||||
return not self.is_development
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth_db import get_auth_db_session
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import get_db_session
|
||||
from app.integrations.homeassistant import HomeAssistantClient
|
||||
from app.poo_db import get_poo_db_session
|
||||
from app.services.auth import AuthenticatedSession, get_authenticated_session
|
||||
|
||||
|
||||
def get_app_settings() -> Settings:
|
||||
return get_settings()
|
||||
|
||||
|
||||
def get_auth_db() -> Generator[Session, None, None]:
|
||||
yield from get_auth_db_session()
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
yield from get_db_session()
|
||||
|
||||
@@ -22,3 +29,12 @@ def get_poo_db() -> Generator[Session, None, None]:
|
||||
|
||||
def get_homeassistant_client() -> HomeAssistantClient:
|
||||
return HomeAssistantClient(get_settings())
|
||||
|
||||
|
||||
def get_current_auth_session(
|
||||
request: Request,
|
||||
session: Session = Depends(get_auth_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
) -> AuthenticatedSession | None:
|
||||
raw_token = request.cookies.get(settings.auth_session_cookie_name)
|
||||
return get_authenticated_session(session, raw_token=raw_token)
|
||||
|
||||
+22
-1
@@ -3,17 +3,36 @@ from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import models # noqa: F401
|
||||
from app.api.routes.auth import router as auth_router
|
||||
from app.api.routes import pages, status
|
||||
import app.auth_db as auth_db
|
||||
from app.api.routes.homeassistant import router as homeassistant_router
|
||||
from app.api.routes.location import router as location_router
|
||||
from app.api.routes.poo import router as poo_router
|
||||
from app.config import get_settings
|
||||
from app.services.auth import AuthBootstrapError, initialize_auth_schema
|
||||
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
||||
from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db
|
||||
from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_runtime_db
|
||||
|
||||
|
||||
def ensure_auth_db_ready() -> None:
|
||||
session_local = auth_db.get_auth_session_local()
|
||||
session: Session = session_local()
|
||||
try:
|
||||
validate_app_runtime_db(get_settings().app_database_url)
|
||||
initialize_auth_schema(session, get_settings())
|
||||
except AppDatabaseAdoptionError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
except AuthBootstrapError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def ensure_location_db_ready() -> None:
|
||||
settings = get_settings()
|
||||
if settings.location_sqlite_path is None:
|
||||
@@ -38,7 +57,7 @@ def ensure_poo_db_ready() -> None:
|
||||
|
||||
def ensure_runtime_dirs() -> None:
|
||||
settings = get_settings()
|
||||
for path in (settings.location_sqlite_path, settings.poo_sqlite_path):
|
||||
for path in (settings.app_sqlite_path, settings.location_sqlite_path, settings.poo_sqlite_path):
|
||||
if path is not None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -46,6 +65,7 @@ def ensure_runtime_dirs() -> None:
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
ensure_runtime_dirs()
|
||||
ensure_auth_db_ready()
|
||||
ensure_location_db_ready()
|
||||
ensure_poo_db_ready()
|
||||
yield
|
||||
@@ -68,6 +88,7 @@ def create_app() -> FastAPI:
|
||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
|
||||
app.include_router(status.router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(pages.router)
|
||||
app.include_router(homeassistant_router)
|
||||
app.include_router(location_router)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""SQLAlchemy models package."""
|
||||
|
||||
from app.models.auth import AuthSession, AuthUser
|
||||
from app.models.location import Location
|
||||
|
||||
__all__ = ["Location"]
|
||||
__all__ = ["AuthSession", "AuthUser", "Location"]
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.auth_db import AuthBase
|
||||
|
||||
|
||||
class AuthUser(AuthBase):
|
||||
__tablename__ = "auth_users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
username: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
force_password_change: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
sessions: Mapped[list["AuthSession"]] = relationship(back_populates="user")
|
||||
|
||||
|
||||
class AuthSession(AuthBase):
|
||||
__tablename__ = "auth_sessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("auth_users.id"), nullable=False, index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||
csrf_token: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
user: Mapped[AuthUser] = relationship(back_populates="sessions")
|
||||
@@ -0,0 +1,226 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import Select, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.models.auth import AuthSession, AuthUser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCRYPT_N = 2**14
|
||||
SCRYPT_R = 8
|
||||
SCRYPT_P = 1
|
||||
SCRYPT_DKLEN = 64
|
||||
|
||||
|
||||
class AuthBootstrapError(RuntimeError):
|
||||
"""Raised when the auth system cannot be safely initialized."""
|
||||
|
||||
|
||||
class AuthPasswordChangeError(ValueError):
|
||||
"""Raised when a password change request is invalid."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AuthenticatedSession:
|
||||
user: AuthUser
|
||||
session: AuthSession
|
||||
|
||||
|
||||
def initialize_auth_schema(session: Session, settings: Settings) -> None:
|
||||
has_any_user = session.scalar(select(AuthUser.id).limit(1)) is not None
|
||||
if has_any_user:
|
||||
return
|
||||
|
||||
if not settings.auth_bootstrap_username or not settings.auth_bootstrap_password:
|
||||
raise AuthBootstrapError(
|
||||
"Auth DB has no users. Set AUTH_BOOTSTRAP_USERNAME and "
|
||||
"AUTH_BOOTSTRAP_PASSWORD before starting the app."
|
||||
)
|
||||
|
||||
bootstrap_user = AuthUser(
|
||||
username=settings.auth_bootstrap_username,
|
||||
password_hash=hash_password(settings.auth_bootstrap_password),
|
||||
is_active=True,
|
||||
force_password_change=True,
|
||||
created_at=_utc_now(),
|
||||
)
|
||||
session.add(bootstrap_user)
|
||||
session.commit()
|
||||
logger.warning(
|
||||
"Bootstrapped initial auth user '%s'. Rotate AUTH_BOOTSTRAP_PASSWORD after first setup.",
|
||||
bootstrap_user.username,
|
||||
)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = secrets.token_bytes(16)
|
||||
derived_key = hashlib.scrypt(
|
||||
password.encode("utf-8"),
|
||||
salt=salt,
|
||||
n=SCRYPT_N,
|
||||
r=SCRYPT_R,
|
||||
p=SCRYPT_P,
|
||||
dklen=SCRYPT_DKLEN,
|
||||
)
|
||||
return "$".join(
|
||||
[
|
||||
"scrypt",
|
||||
str(SCRYPT_N),
|
||||
str(SCRYPT_R),
|
||||
str(SCRYPT_P),
|
||||
base64.b64encode(salt).decode("ascii"),
|
||||
base64.b64encode(derived_key).decode("ascii"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def verify_password(password: str, stored_hash: str) -> bool:
|
||||
try:
|
||||
algorithm, n, r, p, encoded_salt, encoded_key = stored_hash.split("$")
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
if algorithm != "scrypt":
|
||||
return False
|
||||
|
||||
try:
|
||||
salt = base64.b64decode(encoded_salt.encode("ascii"))
|
||||
expected_key = base64.b64decode(encoded_key.encode("ascii"))
|
||||
derived_key = hashlib.scrypt(
|
||||
password.encode("utf-8"),
|
||||
salt=salt,
|
||||
n=int(n),
|
||||
r=int(r),
|
||||
p=int(p),
|
||||
dklen=len(expected_key),
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
return secrets.compare_digest(derived_key, expected_key)
|
||||
|
||||
|
||||
def authenticate_user(session: Session, *, username: str, password: str) -> AuthUser | None:
|
||||
user = session.scalar(select(AuthUser).where(AuthUser.username == username).limit(1))
|
||||
if user is None or not user.is_active:
|
||||
logger.info("Failed login for unknown or inactive user '%s'", username)
|
||||
return None
|
||||
|
||||
if not verify_password(password, user.password_hash):
|
||||
logger.info("Failed login due to invalid password for user '%s'", username)
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def create_session(session: Session, *, user: AuthUser, settings: Settings) -> tuple[AuthSession, str]:
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
auth_session = AuthSession(
|
||||
user_id=user.id,
|
||||
token_hash=_hash_token(raw_token),
|
||||
csrf_token=secrets.token_urlsafe(24),
|
||||
created_at=_utc_now(),
|
||||
expires_at=_utc_now() + timedelta(hours=settings.auth_session_ttl_hours),
|
||||
revoked_at=None,
|
||||
)
|
||||
session.add(auth_session)
|
||||
session.commit()
|
||||
session.refresh(auth_session)
|
||||
return auth_session, raw_token
|
||||
|
||||
|
||||
def get_authenticated_session(session: Session, *, raw_token: str | None) -> AuthenticatedSession | None:
|
||||
if not raw_token:
|
||||
return None
|
||||
|
||||
stmt: Select[tuple[AuthSession, AuthUser]] = (
|
||||
select(AuthSession, AuthUser)
|
||||
.join(AuthUser, AuthSession.user_id == AuthUser.id)
|
||||
.where(AuthSession.token_hash == _hash_token(raw_token))
|
||||
.limit(1)
|
||||
)
|
||||
result = session.execute(stmt).first()
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
auth_session, user = result
|
||||
now = _utc_now()
|
||||
expires_at = _as_utc(auth_session.expires_at)
|
||||
revoked_at = _as_utc(auth_session.revoked_at)
|
||||
if revoked_at is not None or expires_at <= now or not user.is_active:
|
||||
if revoked_at is None and expires_at <= now:
|
||||
auth_session.revoked_at = now
|
||||
session.commit()
|
||||
return None
|
||||
|
||||
return AuthenticatedSession(user=user, session=auth_session)
|
||||
|
||||
|
||||
def revoke_session(session: Session, *, auth_session: AuthSession) -> None:
|
||||
if auth_session.revoked_at is not None:
|
||||
return
|
||||
auth_session.revoked_at = _utc_now()
|
||||
session.commit()
|
||||
|
||||
|
||||
def change_password(
|
||||
session: Session,
|
||||
*,
|
||||
user: AuthUser,
|
||||
current_password: str,
|
||||
new_password: str,
|
||||
confirm_password: str,
|
||||
) -> None:
|
||||
if not verify_password(current_password, user.password_hash):
|
||||
raise AuthPasswordChangeError("current password is invalid")
|
||||
|
||||
if not new_password:
|
||||
raise AuthPasswordChangeError("new password must not be empty")
|
||||
|
||||
if new_password != confirm_password:
|
||||
raise AuthPasswordChangeError("new password confirmation does not match")
|
||||
|
||||
if len(new_password) < 8:
|
||||
raise AuthPasswordChangeError("new password must be at least 8 characters long")
|
||||
|
||||
if verify_password(new_password, user.password_hash):
|
||||
raise AuthPasswordChangeError("new password must be different from the current password")
|
||||
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.force_password_change = False
|
||||
session.commit()
|
||||
|
||||
|
||||
def issue_login_csrf_token() -> str:
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
|
||||
def validate_csrf_token(*, expected: str | None, actual: str | None) -> bool:
|
||||
if not expected or not actual:
|
||||
return False
|
||||
return secrets.compare_digest(expected, actual)
|
||||
|
||||
|
||||
def _hash_token(raw_token: str) -> str:
|
||||
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _as_utc(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
+53
-1
@@ -83,6 +83,59 @@ a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
max-width: 520px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.auth-form,
|
||||
.logout-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.auth-form label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.auth-form input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(31, 41, 51, 0.14);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
width: fit-content;
|
||||
min-width: 120px;
|
||||
padding: 12px 18px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
filter: brightness(1.04);
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: rgba(157, 37, 37, 0.08);
|
||||
border: 1px solid rgba(157, 37, 37, 0.14);
|
||||
color: #8b2a2a;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.shell {
|
||||
margin: 24px auto;
|
||||
@@ -92,4 +145,3 @@ a {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin · {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<p class="eyebrow">Protected Area</p>
|
||||
<h1>Admin</h1>
|
||||
{% if force_password_change %}
|
||||
<p class="lead">
|
||||
首次登录后需要先修改密码。完成后,这里会继续作为未来配置页面的入口。
|
||||
</p>
|
||||
|
||||
{% if password_change_error %}
|
||||
<div class="alert">{{ password_change_error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="auth-form" method="post" action="/admin/change-password">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<label>
|
||||
<span>Current Password</span>
|
||||
<input type="password" name="current_password" autocomplete="current-password" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>New Password</span>
|
||||
<input type="password" name="new_password" autocomplete="new-password" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Confirm New Password</span>
|
||||
<input type="password" name="confirm_password" autocomplete="new-password" required>
|
||||
</label>
|
||||
|
||||
<button type="submit">修改密码</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="lead">
|
||||
你已经登录。这个页面目前是一个受保护的空白配置占位页,后续会在这里接入配置的增删查改。
|
||||
</p>
|
||||
|
||||
<dl class="meta">
|
||||
<div>
|
||||
<dt>当前用户</dt>
|
||||
<dd>{{ current_username }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>运行环境</dt>
|
||||
<dd>{{ app_env }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>下一步</dt>
|
||||
<dd>在这里接入配置页面与更细的受保护操作。</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{% endif %}
|
||||
|
||||
<form class="logout-form" method="post" action="/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">登出</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -23,6 +23,10 @@
|
||||
<dt>OpenAPI</dt>
|
||||
<dd><a href="/docs">/docs</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>登录</dt>
|
||||
<dd><a href="/login">/login</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Notion</dt>
|
||||
<dd>{{ notion_status }}</dd>
|
||||
@@ -30,4 +34,3 @@
|
||||
</dl>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}登录 · {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel auth-panel">
|
||||
<p class="eyebrow">Authentication</p>
|
||||
<h1>登录</h1>
|
||||
<p class="lead">
|
||||
这个页面只负责当前 Python 重构项目的基础登录能力。配置管理等页面会在后续迭代中接入。
|
||||
</p>
|
||||
|
||||
{% if error_message %}
|
||||
<div class="alert">{{ error_message }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="auth-form" method="post" action="/login">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<label>
|
||||
<span>Username</span>
|
||||
<input type="text" name="username" autocomplete="username" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input type="password" name="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
|
||||
<button type="submit">登录</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user