Files
Aislo/A07_Register/A07_Register_Router.py
T
eomsangdonandClaude Opus 5.5 ff8a8bad08 feat(528): 신규 프로젝트 그룹 공통 백엔드 — 종류 칸 · 산림청 판정 · D01 · E01 생성 API · 공유 GET 만 · 가입 go.kr = 산림청
- 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>
2026-10-25 19:32:25 +09:00

106 lines
4.2 KiB
Python

"""회원가입, 이메일 인증 및 회사 검색 API."""
import logging
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from common_util.common_util_auth import (
generate_device_token,
generate_otp,
hash_device_token,
hash_password,
hash_user_agent,
is_forest_email,
set_device_token_cookie,
verify_password,
)
from common_util.common_util_auth_repository import (
complete_registration,
consume_otp,
create_registration,
get_active_otp,
get_user_by_email,
refresh_pending_registration,
replace_otp,
search_companies,
trust_device,
)
from common_util.common_util_email import send_email_background
from common_util.common_util_email_templates import otp_email
from config.config_system import AUTH_OTP_DISABLED
from .A07_Register_Schema import RegisterRequest, RegisterVerifyRequest
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
@router.get("/companies")
async def find_companies(q: str = Query(min_length=1, max_length=100)):
return {"status": "success", "companies": await search_companies(q.strip())}
@router.post("/register/request", status_code=202)
async def request_registration(payload: RegisterRequest):
email = str(payload.email).lower()
data = payload.model_dump(exclude={"password_confirm"})
data["email"] = email
password_hash = hash_password(payload.password)
existing = await get_user_by_email(email)
if existing:
# 이미 이메일 인증까지 마친 계정이면 재가입 불가
if existing["status"] != "PENDING_EMAIL":
raise HTTPException(status_code=409, detail="이미 등록된 이메일입니다.")
# 미인증 계정이면 최신 요청으로 갱신하고 새 인증 코드를 발급
user_id = existing["id"]
await refresh_pending_registration(user_id, data, password_hash)
else:
user_id = await create_registration(data, password_hash)
code = generate_otp()
await replace_otp(user_id, "REGISTER", hash_password(code))
subject, html = otp_email(code, "회원가입")
send_email_background(email, subject, html)
# 개발 단계에서는 메일을 못 받아도 가입을 이어갈 수 있게 코드를 로그에 찍는다
# (2026-09-13 사용자 지시). ENVIRONMENT=development + AUTH_OTP_DISABLED=true 에서만.
if AUTH_OTP_DISABLED:
logger.warning("[auth] 개발용 가입 인증 코드 — %s : %s", email, code)
return {"status": "success", "message": "인증 코드를 발송했습니다."}
@router.post("/register/verify")
async def verify_registration(payload: RegisterVerifyRequest, request: Request):
user = await get_user_by_email(str(payload.email).lower())
if not user or user["status"] != "PENDING_EMAIL":
raise HTTPException(status_code=400, detail="인증 대기 중인 가입 요청이 없습니다.")
otp = await get_active_otp(user["id"], "REGISTER")
if (
not otp
or otp["expires_at"] < datetime.utcnow()
or not verify_password(payload.otp_code, otp["otp_hash"])
):
raise HTTPException(status_code=400, detail="인증 코드가 유효하지 않습니다.")
await consume_otp(otp["id"])
# 메일 *.go.kr = 회사 「산림청」 고정 — 화면이 보낸 회사 값은 받지 않음(요구 347-8).
account_status = await complete_registration(
user["id"], bool(user["is_master"]), forest=is_forest_email(user["email"])
)
# 가입 인증한 브라우저를 신뢰 등록하여 첫 로그인 시 OTP를 생략한다.
agent = request.headers.get("user-agent", "unknown")[:1000]
device_token = generate_device_token()
await trust_device(user["id"], hash_device_token(device_token), hash_user_agent(agent))
# 회원가입 시점에는 회사가 없으므로(NO_COMPANY) 마스터 알림은 발송하지 않는다.
# 회사 생성/연결은 로그인 후 B01_Dashboard에서 진행한다(산림청 메일은 위에서 붙음).
response = JSONResponse(
{
"status": "success",
"account_status": account_status,
}
)
set_device_token_cookie(response, device_token)
return response