Files
ctms/backend/app/api/v1/auth.py
T
2026-06-10 15:20:06 +08:00

248 lines
8.9 KiB
Python

from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import File, UploadFile
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from pathlib import Path
import uuid
from app.core.config import settings
from app.core.login_crypto import create_login_challenge, decrypt_login_payload, get_public_key_pem
from app.core.security import create_access_token, decode_token_allow_expired, oauth2_scheme, verify_password
from app.core.deps import get_current_user, get_db_session
from app.crud import user as user_crud
from app.models.user import UserStatus
from app.schemas.user import Token, UserRead, UserRegisterRequest, UserSelfUpdate, UserUpdate
from fastapi.responses import FileResponse
class LoginRequest(BaseModel):
key_id: str = Field(min_length=1)
challenge: str = Field(min_length=16)
ciphertext: str = Field(min_length=1)
class DevLoginRequest(BaseModel):
email: str = Field(min_length=1)
password: str = Field(min_length=1)
class LoginKeyResponse(BaseModel):
key_id: str
public_key: str
challenge: str
expires_at: datetime
class ExtendResponse(BaseModel):
accessToken: str
expiresAt: datetime
router = APIRouter()
AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars"
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
def issue_user_token(db_user) -> Token:
session_start = datetime.now(timezone.utc)
access_token = create_access_token(
user_id=str(db_user.id),
expires_minutes=None,
session_start=session_start,
)
return Token(access_token=access_token, token_type="bearer")
async def authenticate_encrypted_password(payload: LoginRequest, db: AsyncSession):
decrypted = decrypt_login_payload(
key_id=payload.key_id,
challenge=payload.challenge,
ciphertext=payload.ciphertext,
)
if not decrypted:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无法验证登录凭据",
)
db_user = await user_crud.get_by_email(db, decrypted.email)
if not db_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="账号不存在",
)
if not verify_password(decrypted.password, db_user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="密码错误",
)
return db_user
async def authenticate_plain_password(payload: DevLoginRequest, db: AsyncSession):
db_user = await user_crud.get_by_email(db, payload.email)
if not db_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="账号不存在",
)
if not verify_password(payload.password, db_user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="密码错误",
)
return db_user
def ensure_user_active(db_user) -> None:
if db_user.status != UserStatus.ACTIVE:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="账号未审核或不可用",
)
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(
payload: UserRegisterRequest,
db: AsyncSession = Depends(get_db_session),
):
existing = await user_crud.get_by_email(db, payload.email)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册")
await user_crud.create_pending_user(db, payload)
return {"message": "注册成功,等待管理员审核"}
@router.get("/login-key", response_model=LoginKeyResponse)
async def get_login_key() -> LoginKeyResponse:
challenge = create_login_challenge()
return LoginKeyResponse(
key_id=settings.LOGIN_RSA_KEY_ID,
public_key=get_public_key_pem(),
challenge=challenge.value,
expires_at=challenge.expires_at,
)
@router.post("/login", response_model=Token)
async def login_for_access_token(
payload: LoginRequest, db: AsyncSession = Depends(get_db_session)
) -> Token:
db_user = await authenticate_encrypted_password(payload, db)
ensure_user_active(db_user)
return issue_user_token(db_user)
@router.post("/dev-login", response_model=Token)
async def dev_login_for_access_token(
payload: DevLoginRequest, db: AsyncSession = Depends(get_db_session)
) -> Token:
if settings.ENV != "development":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
db_user = await authenticate_plain_password(payload, db)
ensure_user_active(db_user)
return issue_user_token(db_user)
@router.get("/me", response_model=UserRead)
async def read_me(current_user=Depends(get_current_user)) -> UserRead:
return current_user
@router.post("/extend", response_model=ExtendResponse)
async def extend_access_token(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db_session),
) -> ExtendResponse:
payload = decode_token_allow_expired(token)
exp = payload.get("exp")
if not exp:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期")
now_ts = int(datetime.now(timezone.utc).timestamp())
if now_ts > int(exp) + settings.JWT_EXTEND_GRACE_SECONDS:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期")
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无法验证登录凭据")
db_user = await user_crud.get_by_id(db, uuid.UUID(str(user_id)))
if not db_user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在或已停用")
if db_user.status != UserStatus.ACTIVE:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用")
session_start_ts = payload.get("orig_iat") or payload.get("iat")
if session_start_ts:
max_seconds = settings.ABSOLUTE_SESSION_MAX_HOURS * 3600
if now_ts - int(session_start_ts) > max_seconds:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已到期,请重新登录")
session_start = datetime.fromtimestamp(int(session_start_ts), tz=timezone.utc)
else:
session_start = datetime.now(timezone.utc)
new_token = create_access_token(
user_id=str(db_user.id),
expires_minutes=None,
session_start=session_start,
)
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
return ExtendResponse(accessToken=new_token, expiresAt=expires_at)
@router.patch("/me", response_model=UserRead)
async def update_me(
payload: UserSelfUpdate,
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
) -> UserRead:
if payload.password:
if not payload.current_password or not verify_password(payload.current_password, current_user.password_hash):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确")
update_data = {
"full_name": payload.full_name if payload.full_name is not None else current_user.full_name,
"clinical_department": (
payload.clinical_department if payload.clinical_department is not None else current_user.clinical_department
),
"password": payload.password if payload.password else None,
"avatar_url": payload.avatar_url if payload.avatar_url is not None else current_user.avatar_url,
}
updated = await user_crud.update_user(db, current_user, UserUpdate(**update_data))
return updated
@router.post("/me/avatar", response_model=UserRead)
async def upload_avatar(
file: UploadFile = File(...),
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
) -> UserRead:
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
user_dir = AVATAR_ROOT / str(current_user.id)
user_dir.mkdir(parents=True, exist_ok=True)
ext = Path(file.filename).suffix or ".png"
filename = f"{uuid.uuid4()}{ext}"
dest = user_dir / filename
content = await file.read()
dest.write_bytes(content)
# Optionally clean old files
for old in user_dir.iterdir():
if old.name != filename:
try:
old.unlink()
except OSError:
pass
rel_url = f"/api/v1/auth/avatar/{current_user.id}/{filename}"
updated = await user_crud.update_user(
db,
current_user,
UserUpdate(avatar_url=rel_url),
)
return updated
@router.get("/avatar/{user_id}/{filename}")
async def get_avatar(user_id: str, filename: str):
file_path = AVATAR_ROOT / user_id / filename
if not file_path.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="头像不存在")
return FileResponse(file_path)