auth: add /api/auth router (login, logout, me, setup)
This commit is contained in:
parent
7c9431c615
commit
d1ed9cb4b5
139
containers/clearview/src/clearview_app/auth/router.py
Normal file
139
containers/clearview/src/clearview_app/auth/router.py
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
"""Routes for login, logout, identity, and initial setup."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..config import COOKIE_NAME, COOKIE_SAMESITE, COOKIE_SECURE
|
||||||
|
from . import sessions as S
|
||||||
|
from .audit import record_event
|
||||||
|
from .dependencies import get_db, require_user
|
||||||
|
from .models import User, UserSession
|
||||||
|
from .schemas import LoginRequest, MeResponse, SetupRequest, SetupRequiredResponse
|
||||||
|
from .security import (
|
||||||
|
PasswordPolicyError,
|
||||||
|
hash_password,
|
||||||
|
validate_password,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _ip(request: Request) -> str | None:
|
||||||
|
return request.client.host if request.client else None
|
||||||
|
|
||||||
|
|
||||||
|
def _set_cookie(response: Response, sid: str, *, remember: bool) -> None:
|
||||||
|
max_age = 30 * 24 * 3600 if remember else 8 * 3600
|
||||||
|
response.set_cookie(
|
||||||
|
key=COOKIE_NAME,
|
||||||
|
value=sid,
|
||||||
|
max_age=max_age,
|
||||||
|
httponly=True,
|
||||||
|
secure=COOKIE_SECURE,
|
||||||
|
samesite=COOKIE_SAMESITE,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_cookie(response: Response) -> None:
|
||||||
|
response.delete_cookie(key=COOKIE_NAME, path="/")
|
||||||
|
|
||||||
|
|
||||||
|
def _users_count(db: Session) -> int:
|
||||||
|
return db.execute(select(func.count(User.id))).scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/auth/setup-required", response_model=SetupRequiredResponse)
|
||||||
|
def setup_required(db: Annotated[Session, Depends(get_db)]) -> SetupRequiredResponse:
|
||||||
|
return SetupRequiredResponse(setup_required=_users_count(db) == 0)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/auth/setup", response_model=MeResponse)
|
||||||
|
def setup(
|
||||||
|
payload: SetupRequest,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> MeResponse:
|
||||||
|
if _users_count(db) > 0:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Setup already completed")
|
||||||
|
try:
|
||||||
|
validate_password(payload.password)
|
||||||
|
except PasswordPolicyError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
username=payload.username,
|
||||||
|
password_hash=hash_password(payload.password),
|
||||||
|
role="admin",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(user); db.flush()
|
||||||
|
|
||||||
|
sid, _ = S.create_session(
|
||||||
|
db, user_id=user.id, remember=False, ip=_ip(request), user_agent=request.headers.get("user-agent")
|
||||||
|
)
|
||||||
|
record_event(db, event="setup", user_id=user.id, ip=_ip(request), detail={"username": user.username})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
_set_cookie(response, sid, remember=False)
|
||||||
|
return MeResponse(username=user.username, role=user.role) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/auth/login", response_model=MeResponse)
|
||||||
|
def login(
|
||||||
|
payload: LoginRequest,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> MeResponse:
|
||||||
|
user = db.execute(select(User).where(User.username == payload.username)).scalar_one_or_none()
|
||||||
|
if user is None or not user.is_active or not verify_password(payload.password, user.password_hash):
|
||||||
|
record_event(
|
||||||
|
db,
|
||||||
|
event="login_fail",
|
||||||
|
user_id=user.id if user else None,
|
||||||
|
ip=_ip(request),
|
||||||
|
detail={"username": payload.username},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||||
|
|
||||||
|
sid, _ = S.create_session(
|
||||||
|
db, user_id=user.id, remember=payload.remember, ip=_ip(request), user_agent=request.headers.get("user-agent")
|
||||||
|
)
|
||||||
|
record_event(db, event="login_ok", user_id=user.id, ip=_ip(request), detail=None)
|
||||||
|
S.purge_expired(db)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
_set_cookie(response, sid, remember=payload.remember)
|
||||||
|
return MeResponse(username=user.username, role=user.role) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/auth/logout")
|
||||||
|
def logout(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> dict[str, bool]:
|
||||||
|
sid = request.cookies.get(COOKIE_NAME)
|
||||||
|
user_id: int | None = None
|
||||||
|
if sid:
|
||||||
|
existing = db.get(UserSession, sid)
|
||||||
|
if existing is not None:
|
||||||
|
user_id = existing.user_id
|
||||||
|
S.revoke(db, sid)
|
||||||
|
record_event(db, event="logout", user_id=user_id, ip=_ip(request), detail=None)
|
||||||
|
db.commit()
|
||||||
|
_clear_cookie(response)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/auth/me", response_model=MeResponse)
|
||||||
|
def me(user: Annotated[User, Depends(require_user)]) -> MeResponse:
|
||||||
|
return MeResponse(username=user.username, role=user.role) # type: ignore[arg-type]
|
||||||
59
containers/clearview/src/clearview_app/auth/schemas.py
Normal file
59
containers/clearview/src/clearview_app/auth/schemas.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"""Pydantic schemas for the auth and users routers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=1, max_length=128)
|
||||||
|
password: str = Field(min_length=1, max_length=1024)
|
||||||
|
remember: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SetupRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=1, max_length=128)
|
||||||
|
password: str = Field(min_length=1, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class MeResponse(BaseModel):
|
||||||
|
username: str
|
||||||
|
role: Literal["admin", "user"]
|
||||||
|
|
||||||
|
|
||||||
|
class SetupRequiredResponse(BaseModel):
|
||||||
|
setup_required: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UserItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
role: Literal["admin", "user"]
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUserRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=1, max_length=128)
|
||||||
|
password: str = Field(min_length=1, max_length=1024)
|
||||||
|
role: Literal["admin", "user"] = "user"
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateUserRequest(BaseModel):
|
||||||
|
role: Literal["admin", "user"] | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPasswordRequest(BaseModel):
|
||||||
|
password: str = Field(min_length=1, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
ts: datetime
|
||||||
|
user_id: int | None
|
||||||
|
event: str
|
||||||
|
ip: str | None
|
||||||
|
detail: dict | None
|
||||||
76
containers/clearview/tests/test_auth_router.py
Normal file
76
containers/clearview/tests/test_auth_router.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from clearview_app.auth.dependencies import get_db
|
||||||
|
from clearview_app.auth.router import router as auth_router
|
||||||
|
|
||||||
|
|
||||||
|
def make_app(db_engine):
|
||||||
|
Session = sessionmaker(bind=db_engine, autoflush=False, autocommit=False, future=True)
|
||||||
|
|
||||||
|
def override_get_db():
|
||||||
|
s = Session()
|
||||||
|
try:
|
||||||
|
yield s
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_required_when_empty(db_engine):
|
||||||
|
c = TestClient(make_app(db_engine))
|
||||||
|
assert c.get("/api/auth/setup-required").json() == {"setup_required": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_creates_first_admin_and_logs_in(db_engine):
|
||||||
|
c = TestClient(make_app(db_engine))
|
||||||
|
r = c.post("/api/auth/setup", json={"username": "root", "password": "CorrectHorse42"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.cookies.get("clearview_session")
|
||||||
|
me = c.get("/api/auth/me")
|
||||||
|
assert me.status_code == 200
|
||||||
|
assert me.json() == {"username": "root", "role": "admin"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_rejects_when_users_exist(db_engine):
|
||||||
|
app = make_app(db_engine)
|
||||||
|
TestClient(app).post("/api/auth/setup", json={"username": "root", "password": "CorrectHorse42"})
|
||||||
|
r = TestClient(app).post("/api/auth/setup", json={"username": "x", "password": "CorrectHorse42"})
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_wrong_password_returns_401(db_engine):
|
||||||
|
app = make_app(db_engine)
|
||||||
|
TestClient(app).post("/api/auth/setup", json={"username": "root", "password": "CorrectHorse42"})
|
||||||
|
r = TestClient(app).post("/api/auth/login", json={"username": "root", "password": "WrongPass00X", "remember": False})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_success_sets_cookie(db_engine):
|
||||||
|
app = make_app(db_engine)
|
||||||
|
TestClient(app).post("/api/auth/setup", json={"username": "root", "password": "CorrectHorse42"})
|
||||||
|
c2 = TestClient(app)
|
||||||
|
r = c2.post("/api/auth/login", json={"username": "root", "password": "CorrectHorse42", "remember": True})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert c2.cookies.get("clearview_session")
|
||||||
|
assert c2.get("/api/auth/me").json()["username"] == "root"
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_invalidates_session(db_engine):
|
||||||
|
app = make_app(db_engine)
|
||||||
|
c = TestClient(app)
|
||||||
|
c.post("/api/auth/setup", json={"username": "root", "password": "CorrectHorse42"})
|
||||||
|
assert c.get("/api/auth/me").status_code == 200
|
||||||
|
c.post("/api/auth/logout")
|
||||||
|
assert c.get("/api/auth/me").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_policy_enforced_on_setup(db_engine):
|
||||||
|
c = TestClient(make_app(db_engine))
|
||||||
|
r = c.post("/api/auth/setup", json={"username": "root", "password": "short"})
|
||||||
|
assert r.status_code == 400
|
||||||
Loading…
Reference in New Issue
Block a user