146 lines
4.7 KiB
Python
146 lines
4.7 KiB
Python
import hashlib
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from sqlalchemy import text
|
|
from sqlalchemy.orm import Session
|
|
|
|
try:
|
|
# For module mode: `uvicorn app.main:app`
|
|
from app.db import get_db
|
|
from app.schemas.auth import LoginRequest, LoginResponse
|
|
from app.security import create_access_token
|
|
except ModuleNotFoundError:
|
|
# For script mode: `python app/main.py` or VS Code "Run Python File"
|
|
from db import get_db
|
|
from schemas.auth import LoginRequest, LoginResponse
|
|
from security import create_access_token
|
|
|
|
|
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
logger = logging.getLogger("app.auth")
|
|
|
|
|
|
def _mask_username(username: str) -> str:
|
|
if len(username) <= 2:
|
|
return "*" * len(username)
|
|
return f"{username[:2]}***"
|
|
|
|
|
|
@router.post("/login", response_model=LoginResponse)
|
|
def login(
|
|
payload: LoginRequest,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
) -> LoginResponse:
|
|
username_masked = _mask_username(payload.username)
|
|
with db.begin():
|
|
row = (
|
|
db.execute(
|
|
text(
|
|
"""
|
|
SELECT
|
|
ua.id AS user_auth_id,
|
|
ua.user_id,
|
|
ua.password_hash,
|
|
ua.status AS auth_status,
|
|
u.status AS user_status
|
|
FROM chat_user_auth ua
|
|
JOIN chat_user u ON u.id = ua.user_id
|
|
WHERE ua.auth_type = 6
|
|
AND ua.auth_identifier = :username
|
|
LIMIT 1
|
|
"""
|
|
),
|
|
{"username": payload.username},
|
|
)
|
|
.mappings()
|
|
.first()
|
|
)
|
|
if not row:
|
|
logger.warning(
|
|
"login failed",
|
|
extra={
|
|
"event": "login",
|
|
"request_id": getattr(request.state, "request_id", None),
|
|
"username": username_masked,
|
|
"reason": "invalid_username_or_password",
|
|
},
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="invalid username or password",
|
|
)
|
|
|
|
if int(row["auth_status"]) != 1 or int(row["user_status"]) != 1:
|
|
logger.warning(
|
|
"login failed",
|
|
extra={
|
|
"event": "login",
|
|
"request_id": getattr(request.state, "request_id", None),
|
|
"username": username_masked,
|
|
"user_id": int(row["user_id"]),
|
|
"reason": "account_disabled",
|
|
},
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="account is disabled",
|
|
)
|
|
|
|
# Seed data currently stores sha256; migrate to bcrypt/argon2 in production.
|
|
password_hash = hashlib.sha256(payload.password.encode("utf-8")).hexdigest()
|
|
if not row["password_hash"] or password_hash != row["password_hash"]:
|
|
logger.warning(
|
|
"login failed",
|
|
extra={
|
|
"event": "login",
|
|
"request_id": getattr(request.state, "request_id", None),
|
|
"username": username_masked,
|
|
"reason": "invalid_username_or_password",
|
|
},
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="invalid username or password",
|
|
)
|
|
|
|
db.execute(
|
|
text(
|
|
"""
|
|
UPDATE chat_user_auth
|
|
SET last_login_at = CURRENT_TIMESTAMP(3),
|
|
updated_at = CURRENT_TIMESTAMP(3)
|
|
WHERE id = :user_auth_id
|
|
"""
|
|
),
|
|
{"user_auth_id": int(row["user_auth_id"])},
|
|
)
|
|
db.execute(
|
|
text(
|
|
"""
|
|
UPDATE chat_user
|
|
SET last_login_at = CURRENT_TIMESTAMP(3),
|
|
updated_at = CURRENT_TIMESTAMP(3)
|
|
WHERE id = :user_id
|
|
"""
|
|
),
|
|
{"user_id": int(row["user_id"])},
|
|
)
|
|
|
|
access_token, expires_in = create_access_token(user_id=int(row["user_id"]))
|
|
logger.info(
|
|
"login succeeded",
|
|
extra={
|
|
"event": "login",
|
|
"request_id": getattr(request.state, "request_id", None),
|
|
"user_id": int(row["user_id"]),
|
|
"username": username_masked,
|
|
},
|
|
)
|
|
return LoginResponse(
|
|
access_token=access_token,
|
|
expires_in=expires_in,
|
|
user_id=int(row["user_id"]),
|
|
)
|