feat: auth

This commit is contained in:
tao.chen
2026-08-03 10:51:55 +08:00
parent bededeb101
commit 7a3c2452e1
6 changed files with 905 additions and 0 deletions
+256
View File
@@ -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",
]