- DB 020(추가만): projects 종류 · 사업연도 끝 · 설계속도 · 원본 칸 · plan_route_years · plan_route_assignees · 회사 「산림청」 행 - is_forest_email · is_forest_service · /api/auth/session 에 is_forest_service - require_project_access 한 곳 — 지정 회사 = GET · HEAD 만 · 그 밖 403 - POST /api/d01/project(산림청만 · PLAN · 설계속도 기본 = resolve_design_speed) · GET /api/dashboard/plan-routes/assigned - GET /api/e01/source-candidates · POST /api/e01/project(B06 완료만 · STABILITY · 원본 칸 · 자료 복사는 528-4) - 종류별 단계 시드(PLAN 3 · STABILITY 4) · storage 허용 D0x · E0x · B01 목록에 project_kind - 가입 인증 — 메일 *.go.kr = 회사 산림청 · ACTIVE · 화면 회사 칸 「산림청」 잠금 표시 - 지킴 시험 test_528_project_groups.py Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
201 lines
7.9 KiB
Python
201 lines
7.9 KiB
Python
"""로그인, 재인증, 세션 및 비밀번호 API."""
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from common_util.common_util_auth import (
|
|
delete_session_cookie,
|
|
generate_device_token,
|
|
generate_otp,
|
|
get_device_token_cookie,
|
|
hash_device_token,
|
|
hash_password,
|
|
hash_user_agent,
|
|
is_forest_service,
|
|
set_device_token_cookie,
|
|
set_session_cookie,
|
|
verify_password,
|
|
verify_session,
|
|
)
|
|
from common_util.common_util_auth_repository import (
|
|
change_password,
|
|
consume_otp,
|
|
delete_session,
|
|
finish_login,
|
|
get_active_otp,
|
|
get_user_by_email,
|
|
has_trusted_device,
|
|
record_failed_login,
|
|
record_login,
|
|
replace_otp,
|
|
)
|
|
from common_util.common_util_email import send_email_background
|
|
from common_util.common_util_email_templates import otp_email, security_alert_email
|
|
from config.config_system import ADMIN_EMAIL, AUTH_OTP_DISABLED, EMAIL_REVERIFY_DAYS
|
|
|
|
from .A06_Login_Schema import LoginRequest, OtpVerifyRequest, PasswordChangeRequest
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
|
|
|
|
|
def _user_agent(request: Request) -> str:
|
|
return request.headers.get("user-agent", "unknown")[:1000]
|
|
|
|
|
|
async def _send_otp(user: dict, purpose: str, label: str) -> None:
|
|
code = generate_otp()
|
|
await replace_otp(user["id"], purpose, hash_password(code))
|
|
subject, html = otp_email(code, label)
|
|
send_email_background(user["email"], subject, html)
|
|
|
|
|
|
@router.post("/login/request")
|
|
async def request_login(payload: LoginRequest, request: Request):
|
|
email = str(payload.email).lower()
|
|
agent = _user_agent(request)
|
|
user = await get_user_by_email(email)
|
|
if not user:
|
|
await record_login(None, email, "FAILURE", "ACCOUNT_NOT_FOUND", agent)
|
|
raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 올바르지 않습니다.")
|
|
if user["account_locked_until"] and user["account_locked_until"] > datetime.utcnow():
|
|
await record_login(user["id"], email, "FAILURE", "ACCOUNT_LOCKED", agent)
|
|
raise HTTPException(status_code=423, detail="로그인 실패 누적으로 계정이 잠겼습니다.")
|
|
# bcrypt(cost 12 · 약 0.2 s)는 스레드에서 — 이벤트 루프(다른 요청)를 막지 않게
|
|
if not await asyncio.to_thread(verify_password, payload.password, user["password_hash"]):
|
|
failures = await record_failed_login(user["id"])
|
|
await record_login(user["id"], email, "FAILURE", "INVALID_PASSWORD", agent)
|
|
if failures >= 5 and ADMIN_EMAIL:
|
|
subject, html = security_alert_email(email, failures)
|
|
send_email_background(ADMIN_EMAIL, subject, html)
|
|
raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 올바르지 않습니다.")
|
|
if user["status"] not in ("ACTIVE", "NO_COMPANY", "PENDING"):
|
|
await record_login(user["id"], email, "FAILURE", "ACCOUNT_INACTIVE", agent)
|
|
raise HTTPException(status_code=403, detail="활성화되지 않은 계정입니다.")
|
|
|
|
reverify_before = datetime.utcnow() - timedelta(days=EMAIL_REVERIFY_DAYS)
|
|
periodic_reverify = (
|
|
user["last_email_verified_at"] is None or user["last_email_verified_at"] < reverify_before
|
|
)
|
|
device_token = get_device_token_cookie(request)
|
|
device_token_hash = hash_device_token(device_token) if device_token else None
|
|
trusted_device = bool(
|
|
device_token_hash and await has_trusted_device(user["id"], device_token_hash)
|
|
)
|
|
new_browser = not trusted_device
|
|
if periodic_reverify or new_browser:
|
|
# 개발 단계에서는 메일 인증을 건너뛴다 (2026-09-13 사용자 지시).
|
|
# 이 갈래는 ENVIRONMENT=development + AUTH_OTP_DISABLED=true 에서만 열린다.
|
|
if AUTH_OTP_DISABLED:
|
|
logger.warning(
|
|
"[auth] 개발용 OTP 생략 — %s (%s)",
|
|
email,
|
|
"PERIODIC" if periodic_reverify else "NEW_BROWSER",
|
|
)
|
|
else:
|
|
await _send_otp(user, "LOGIN", "로그인")
|
|
return {
|
|
"status": "otp_required",
|
|
"reason": "PERIODIC" if periodic_reverify else "NEW_BROWSER",
|
|
}
|
|
return await _finish_login(user, agent, device_token_hash)
|
|
|
|
|
|
async def _finish_login(
|
|
user: dict, agent: str, previous_device_token_hash: str | None = None
|
|
) -> JSONResponse:
|
|
device_token = generate_device_token()
|
|
session_id = await finish_login(
|
|
user["id"],
|
|
user["email"],
|
|
agent,
|
|
hash_user_agent(agent),
|
|
hash_device_token(device_token),
|
|
previous_device_token_hash,
|
|
)
|
|
response = JSONResponse(
|
|
{
|
|
"status": "success",
|
|
"user": {"id": user["id"], "email": user["email"], "role": user["role"]},
|
|
}
|
|
)
|
|
set_device_token_cookie(response, device_token)
|
|
set_session_cookie(response, session_id)
|
|
return response
|
|
|
|
|
|
@router.post("/login/verify")
|
|
async def verify_login(payload: OtpVerifyRequest, request: Request):
|
|
user = await get_user_by_email(str(payload.email).lower())
|
|
otp = await get_active_otp(user["id"], "LOGIN") if user else None
|
|
if (
|
|
not user
|
|
or not otp
|
|
or otp["expires_at"] < datetime.utcnow()
|
|
or not await asyncio.to_thread(verify_password, payload.otp_code, otp["otp_hash"])
|
|
):
|
|
if user:
|
|
failures = await record_failed_login(user["id"])
|
|
await record_login(
|
|
user["id"], user["email"], "FAILURE", "INVALID_OTP", _user_agent(request)
|
|
)
|
|
if failures >= 5 and ADMIN_EMAIL:
|
|
subject, html = security_alert_email(user["email"], failures)
|
|
send_email_background(ADMIN_EMAIL, subject, html)
|
|
raise HTTPException(status_code=400, detail="인증 코드가 유효하지 않습니다.")
|
|
await consume_otp(otp["id"])
|
|
from config.config_db import get_db_pool
|
|
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection, connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"UPDATE users SET last_email_verified_at = CURRENT_TIMESTAMP WHERE id = %s",
|
|
(user["id"],),
|
|
)
|
|
await connection.commit()
|
|
device_token = get_device_token_cookie(request)
|
|
previous_device_token_hash = hash_device_token(device_token) if device_token else None
|
|
return await _finish_login(user, _user_agent(request), previous_device_token_hash)
|
|
|
|
|
|
@router.get("/session")
|
|
async def current_session(session: dict = Depends(verify_session)):
|
|
return {
|
|
"status": "success",
|
|
"user": {
|
|
"id": session["user_id"],
|
|
"email": session["email"],
|
|
"name": session["name"],
|
|
"role": session["role"],
|
|
"company_id": session["company_id"],
|
|
"is_master": session["is_master"],
|
|
"is_forest_service": is_forest_service(session),
|
|
},
|
|
}
|
|
|
|
|
|
@router.post("/logout")
|
|
async def logout(request: Request):
|
|
session_id = request.cookies.get("session_id")
|
|
if session_id:
|
|
await delete_session(session_id)
|
|
response = JSONResponse({"status": "success"})
|
|
delete_session_cookie(response)
|
|
return response
|
|
|
|
|
|
@router.post("/password")
|
|
async def update_password(payload: PasswordChangeRequest, session: dict = Depends(verify_session)):
|
|
user = await get_user_by_email(session["email"])
|
|
if not user or not verify_password(payload.current_password, user["password_hash"]):
|
|
raise HTTPException(status_code=400, detail="현재 비밀번호가 올바르지 않습니다.")
|
|
if verify_password(payload.new_password, user["password_hash"]):
|
|
raise HTTPException(status_code=400, detail="기존 비밀번호와 다른 값을 사용하세요.")
|
|
await change_password(user["id"], hash_password(payload.new_password), payload.logout_all)
|
|
return {"status": "success", "reauthentication_required": True}
|