"""회원가입, 이메일 인증 및 회사 검색 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, 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"]) await complete_registration(user["id"], bool(user["is_master"])) # 가입 인증한 브라우저를 신뢰 등록하여 첫 로그인 시 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": "NO_COMPANY", } ) set_device_token_cookie(response, device_token) return response