feat: auth
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
"""Cookie+JWT authentication endpoints.
|
||||
|
||||
The user-facing flow is:
|
||||
1. POST /api/v1/auth/login — verify password, set HttpOnly cookie
|
||||
2. every other /api/ request reads the cookie via
|
||||
``backend.dependencies.request_context``
|
||||
3. POST /api/v1/auth/logout — clear the cookie
|
||||
4. GET /api/v1/auth/me — return the current user
|
||||
|
||||
Service-to-service calls do not use these endpoints — they live on the
|
||||
shared Docker network and have no application-layer auth. See
|
||||
``docker-compose.yml`` and ``schedule/service.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.dependencies import database_session
|
||||
from common.auth.jwt import JwtError, issue_jwt, verify_jwt_token
|
||||
from common.auth.passwords import verify_password
|
||||
from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
|
||||
from common.ids import new_ulid
|
||||
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
||||
# Cookie config. ``secure=True`` requires HTTPS — the only safe
|
||||
# assumption in production. Dev environments running on plain HTTP
|
||||
# should reverse-proxy with TLS termination or set the env knob
|
||||
# (future extension).
|
||||
COOKIE_NAME = "access_token"
|
||||
COOKIE_TTL_SECONDS = 24 * 60 * 60
|
||||
COOKIE_SECURE = True
|
||||
COOKIE_SAMESITE = "lax"
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=COOKIE_TTL_SECONDS,
|
||||
path="/",
|
||||
httponly=True,
|
||||
secure=COOKIE_SECURE,
|
||||
samesite=COOKIE_SAMESITE,
|
||||
)
|
||||
|
||||
|
||||
def _clear_session_cookie(response: Response) -> None:
|
||||
response.delete_cookie(key=COOKIE_NAME, path="/")
|
||||
|
||||
|
||||
def _user_payload(user: Users, role_code: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"user_id": user.user_id,
|
||||
"username": user.username,
|
||||
"display_name": user.display_name,
|
||||
"email": user.email,
|
||||
"status": user.status,
|
||||
"role_code": role_code,
|
||||
}
|
||||
|
||||
|
||||
def _workspace_payload(
|
||||
workspace: Workspaces,
|
||||
role: Roles,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"workspace_id": workspace.workspace_id,
|
||||
"workspace_code": workspace.workspace_code,
|
||||
"workspace_name": workspace.workspace_name,
|
||||
"role_code": role.role_code,
|
||||
"role_name": role.role_name,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/v1/auth/login")
|
||||
async def login(
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Verify username/password and issue a session cookie.
|
||||
|
||||
Returns the user record and the workspaces they are an active
|
||||
member of (joined earliest first, which doubles as the default
|
||||
selection until the user picks a different one in the UI). The
|
||||
workspace list is informational — the JWT itself does not bind a
|
||||
workspace; each request specifies its own ``?workspace_id=``.
|
||||
"""
|
||||
body = await request.json()
|
||||
username = (body or {}).get("username", "").strip()
|
||||
password = (body or {}).get("password", "")
|
||||
if not username or not password:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"username and password are required",
|
||||
)
|
||||
|
||||
user = await session.scalar(
|
||||
select(Users).where(
|
||||
Users.username == username,
|
||||
Users.status == "active",
|
||||
)
|
||||
)
|
||||
if user is None or not verify_password(password, user.password_hash):
|
||||
# Unified 401 to prevent username enumeration.
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
"invalid username or password",
|
||||
)
|
||||
|
||||
# Pull all active memberships. The earliest join wins as default
|
||||
# because there is no `is_default` column on `workspace_members`.
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Workspaces, Roles, WorkspaceMembers.joined_at)
|
||||
.join(
|
||||
WorkspaceMembers,
|
||||
WorkspaceMembers.workspace_id == Workspaces.workspace_id,
|
||||
)
|
||||
.join(Roles, Roles.role_id == WorkspaceMembers.role_id)
|
||||
.where(
|
||||
WorkspaceMembers.user_id == user.user_id,
|
||||
WorkspaceMembers.member_status == "active",
|
||||
Workspaces.status == "active",
|
||||
)
|
||||
.order_by(WorkspaceMembers.joined_at.asc())
|
||||
)
|
||||
).all()
|
||||
if not rows:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
"user is not a member of any active workspace",
|
||||
)
|
||||
|
||||
workspaces = []
|
||||
default_workspace_id: str | None = None
|
||||
for workspace, role, joined_at in rows:
|
||||
workspaces.append(_workspace_payload(workspace, role))
|
||||
if default_workspace_id is None:
|
||||
default_workspace_id = workspace.workspace_id
|
||||
|
||||
# Pick a default role_code for the user payload: prefer admin if
|
||||
# the user has it in any workspace, otherwise use the first one
|
||||
# returned. This is only for UI greeting; access control checks
|
||||
# run on a per-request basis via the chosen workspace_id.
|
||||
user_role_code: str | None = None
|
||||
for workspace, role, _ in rows:
|
||||
if role.role_code == "admin":
|
||||
user_role_code = "admin"
|
||||
break
|
||||
if user_role_code is None:
|
||||
user_role_code = rows[0][1].role_code
|
||||
|
||||
token = issue_jwt(user.user_id, ttl_seconds=COOKIE_TTL_SECONDS)
|
||||
_set_session_cookie(response, token)
|
||||
|
||||
return {
|
||||
"request_id": new_ulid(),
|
||||
"data": {
|
||||
"user": _user_payload(user, user_role_code),
|
||||
"workspaces": workspaces,
|
||||
"default_workspace_id": default_workspace_id,
|
||||
},
|
||||
"meta": {},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/v1/auth/logout")
|
||||
async def logout(response: Response) -> dict[str, Any]:
|
||||
"""Clear the session cookie. Idempotent."""
|
||||
_clear_session_cookie(response)
|
||||
return {
|
||||
"request_id": new_ulid(),
|
||||
"data": {"logged_out": True},
|
||||
"meta": {},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/v1/auth/me")
|
||||
async def me(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Return the current user record based on the session cookie.
|
||||
|
||||
Does not require a workspace_id — useful for the frontend to
|
||||
bootstrap identity on app load before any workspace has been
|
||||
selected. Workspace list is included so the login screen can be
|
||||
skipped on subsequent visits.
|
||||
"""
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not token:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not authenticated")
|
||||
try:
|
||||
payload = verify_jwt_token(token)
|
||||
except JwtError as exc:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(exc)) from exc
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid token")
|
||||
|
||||
user = await session.get(Users, user_id)
|
||||
if user is None or user.status != "active":
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found")
|
||||
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Workspaces, Roles, WorkspaceMembers.joined_at)
|
||||
.join(
|
||||
WorkspaceMembers,
|
||||
WorkspaceMembers.workspace_id == Workspaces.workspace_id,
|
||||
)
|
||||
.join(Roles, Roles.role_id == WorkspaceMembers.role_id)
|
||||
.where(
|
||||
WorkspaceMembers.user_id == user.user_id,
|
||||
WorkspaceMembers.member_status == "active",
|
||||
Workspaces.status == "active",
|
||||
)
|
||||
.order_by(WorkspaceMembers.joined_at.asc())
|
||||
)
|
||||
).all()
|
||||
|
||||
workspaces = [_workspace_payload(ws, role) for ws, role, _ in rows]
|
||||
default_workspace_id = workspaces[0]["workspace_id"] if workspaces else None
|
||||
user_role_code: str | None = None
|
||||
for _ws, role, _ in rows:
|
||||
if role.role_code == "admin":
|
||||
user_role_code = "admin"
|
||||
break
|
||||
if user_role_code is None and rows:
|
||||
user_role_code = rows[0][1].role_code
|
||||
|
||||
return {
|
||||
"request_id": new_ulid(),
|
||||
"data": {
|
||||
"user": _user_payload(user, user_role_code),
|
||||
"workspaces": workspaces,
|
||||
"default_workspace_id": default_workspace_id,
|
||||
},
|
||||
"meta": {},
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"COOKIE_NAME",
|
||||
"COOKIE_TTL_SECONDS",
|
||||
"router",
|
||||
]
|
||||
Reference in New Issue
Block a user