import re import uuid from fastapi import Depends from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import companies, users from common.enums import AuthProvider, CompanyStatus, DBWRType, ErrorType, UserRole, UserStatus from common.logger import LOG from common.models.gmodel import UserInfo from crud.user_crud import IUserCRUD, UserCRUD from router.v1.auth.protocol import ( CompanyData, Req_GoogleLogin, Req_Signup, Req_UpdateMe, Res_Login, Res_Me, Res_RefreshToken, ) from router.v1.validator.dependencies import ( CreateAccessToken, CreateRefreshToken, DecodeRefreshToken, GetHashedPW, VerifyPW, ) from services.external.google_identity import ( GoogleAccount, GoogleNotConfigured, GoogleTokenInvalid, verify_id_token, ) # 로그인 아이디: 영문으로 시작하는 4~20자. 대소문자를 구분하지 않고 저장은 입력 그대로 한다. _LOGIN_ID_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9._-]{3,19}$") # 형식만 본다 — 이메일 소유 증명은 여기 없다(Req_Signup 주석). _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") _MIN_PASSWORD_LEN = 8 # 구글 계정의 로그인 아이디 접두어. 사람이 이 접두어로 가입해 두면 같은 이름의 구글 계정이 # 영영 못 만들어진다(유니크 충돌) — 가입 단계에서 막는다. _SOCIAL_ID_PREFIX = "google_" def _google_login_id(sub: str) -> str: """구글 계정의 로그인 아이디. sub 를 그대로 붙인다 — 자르면 앞자리가 같은 두 계정이 겹친다.""" return f"{_SOCIAL_ID_PREFIX}{sub}" def _fit(value: str | None, limit: int) -> str | None: """컬럼 길이에 맞춰 자른다. 구글 표시 이름이나 사장님 입력이 길면 INSERT 가 통째로 터지는데, 그건 "이름이 길다" 가 아니라 "가입이 안 된다" 로 보인다. 표시용 값이라 자르는 편이 낫다.""" if value is None: return None return value[:limit] class AuthService: """비즈니스 로직 계층 (MVC 의 컨트롤러-서비스 분리에서 서비스). - CRUD 는 Depends 로 인터페이스 타입으로 주입받는다. - DB 접근은 DB_SESSION_MNG 의 람다 실행으로만 한다. 조회 = execute_lambda(..., DB_READ, lambda s: crud.xxx(s, ...)) 변경 = execute_lambda_run([DBType], [lambda s: crud.xxx(s, ...)]) - 모든 메서드는 Res_* 를 만들어 result 에 ErrorType 을 세팅해 반환한다. """ def __init__(self, user_crud: IUserCRUD = Depends(UserCRUD)): self.user_crud = user_crud @staticmethod def _user_info(user: users) -> UserInfo: # uuid → str (JWT json 직렬화 위해). 기능 라우터는 company_id 로 스코프한다. return UserInfo( user_id=str(user.user_id), id=user.id, company_id=str(user.company_id), role=user.role, ) async def _finish_login(self, user: users) -> Res_Login: """신원이 확인된 뒤의 마지막 단계 — id/pw 와 구글이 공유한다. 상태 확인 → 우리 토큰 발급 → 마지막 접속 갱신. 어느 수단으로 들어왔든 여기서부터는 같은 세션이다(구글 토큰을 세션으로 들고 다니지 않는다).""" res = Res_Login() if user.status != UserStatus.ACTIVE.value: res.result.SetResult(ErrorType.ACCOUNT_BLOCKED_USER) return res user_info = self._user_info(user) res.access_token = CreateAccessToken(user_info) res.refresh_token = CreateRefreshToken(user_info) err_type = await DB_SESSION_MNG.execute_lambda_run( [users.DBType()], [lambda s: self.user_crud.update_last_accessed(s, user.user_id)], ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res async def attempt_login(self, login_id: str, password: str, connect_ip: str) -> Res_Login: LOG.i(f"LOGIN : id={login_id}") res = Res_Login() # 1) 계정 조회 (Read DB) err_type, user = await DB_SESSION_MNG.execute_lambda( users.DBType(), DBWRType.DB_READ.value, lambda s: self.user_crud.get_user_by_login_id(s, login_id), ) if err_type != ErrorType.SUCCESS: # 계정 없음/조회 실패 모두 로그인 실패로 일반화 res.result.SetResult(ErrorType.ACCOUNT_INVALID_INFO) return res user: users # 2) 소셜 계정에는 대조할 비밀번호가 없다. # 여기서 끊지 않으면 VerifyPW 가 None 해시를 만나 500 이 난다. # '아이디/비번 오류' 로 뭉개지 않는 이유: 화면이 "구글로 로그인하세요" 를 안내해야 한다. if user.provider != AuthProvider.LOCAL.value or not user.password: res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT) return res # 3) 비밀번호 검증 if not await VerifyPW(password, user.password): res.result.SetResult(ErrorType.ACCOUNT_INVALID_INFO) return res return await self._finish_login(user) # ---- 가입 -------------------------------------------------------------- async def _create_account( self, *, login_id: str, password_hash: str | None, name: str | None, email: str | None, company_name: str, provider: AuthProvider, provider_uid: str | None, ) -> tuple[ErrorType, users]: """회사 1개 + 그 회사의 첫 계정 1개를 한 트랜잭션으로 만든다. ★ 가입은 곧 새 테넌트다. users.company_id 가 NOT NULL 이고 모든 도메인(사업장·사이트)이 company_id 로 스코프되므로, 회사 없는 계정은 아무것도 만들지 못한다. ★ uuid 를 여기서 미리 만든다. 모델 default 는 flush 시점에 적용돼서, 그 전에 company.company_id 를 읽으면 None 이다 — 그대로 넣으면 NOT NULL 위반이다.""" company_uuid = uuid.uuid4() company = companies( company_id=company_uuid, name=_fit(company_name, 100), email=_fit(email, 255), status=CompanyStatus.ACTIVE.value, ) user = users( user_id=uuid.uuid4(), company_id=company_uuid, id=login_id, password=password_hash, name=_fit(name, 50), email=_fit(email, 255), status=UserStatus.ACTIVE.value, role=UserRole.USER.value, provider=provider.value, provider_uid=provider_uid, ) err_type = await DB_SESSION_MNG.execute_lambda_run( [users.DBType()], [ lambda s: self.user_crud.add_company(s, company), lambda s: self.user_crud.add_user(s, user), ], ) if err_type != ErrorType.SUCCESS: return err_type, None return ErrorType.SUCCESS, user async def _email_taken(self, email: str) -> bool: err_type, existing = await DB_SESSION_MNG.execute_lambda( users.DBType(), DBWRType.DB_READ.value, lambda s: self.user_crud.get_user_by_email(s, email), ) return err_type == ErrorType.SUCCESS and existing is not None async def signup(self, req: Req_Signup) -> Res_Login: """id/pw 가입. 성공하면 곧바로 로그인 상태로 만든다(토큰을 실어 보낸다) — 가입 직후 로그인 화면으로 되돌리면 방금 정한 비밀번호를 또 치게 된다.""" res = Res_Login() login_id = req.id.strip() email = (req.email or "").strip().lower() name = (req.name or "").strip() or None if not _LOGIN_ID_RE.match(login_id) or login_id.lower().startswith(_SOCIAL_ID_PREFIX): res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) return res if len(req.password) < _MIN_PASSWORD_LEN: res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) return res if not _EMAIL_RE.match(email): res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) return res # 아이디 중복. is_user 는 "없으면 SUCCESS" 다 — 있으면 DB_ALREADY_SAME_KEY 를 준다. dup = await DB_SESSION_MNG.execute_lambda( users.DBType(), DBWRType.DB_READ.value, lambda s: self.user_crud.is_user(s, login_id), ) if dup != ErrorType.SUCCESS: res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST) return res # 이메일 중복 — 구글로 이미 가입한 사람이 같은 이메일로 계정을 하나 더 만드는 걸 막는다. if await self._email_taken(email): res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST) return res err_type, user = await self._create_account( login_id=login_id, password_hash=await GetHashedPW(req.password), name=name, email=email, company_name=(req.company_name or "").strip() or name or login_id, provider=AuthProvider.LOCAL, provider_uid=None, ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res LOG.i(f"SIGNUP : id={login_id}") return await self._finish_login(user) # ---- 구글 로그인 -------------------------------------------------------- async def google_login(self, req: Req_GoogleLogin) -> Res_Login: """구글 ID 토큰 → 우리 세션. 처음 온 계정이면 그 자리에서 만든다(별도 가입 절차 없음).""" res = Res_Login() try: account: GoogleAccount = await verify_id_token(req.credential) except GoogleNotConfigured: res.result.SetResult(ErrorType.OAUTH_NOT_CONFIGURED) return res except GoogleTokenInvalid: res.result.SetResult(ErrorType.OAUTH_INVALID_TOKEN) return res # 1) 이미 있는 구글 계정인가 — 판정 키는 sub 다(이메일이 바뀌어도 같은 사람). err_type, user = await DB_SESSION_MNG.execute_lambda( users.DBType(), DBWRType.DB_READ.value, lambda s: self.user_crud.get_user_by_provider_uid(s, AuthProvider.GOOGLE.value, account.sub), ) if err_type == ErrorType.SUCCESS and user is not None: LOG.i(f"LOGIN(google) : sub={account.sub}") return await self._finish_login(user) # 2) 같은 이메일이 다른 수단으로 이미 가입돼 있으면 **잇지 않는다.** # 자동으로 이으면, 남의 이메일로 먼저 만들어 둔 id/pw 계정에 그 사람의 구글 로그인이 # 그대로 들어간다(계정 선점). 소유 증명 없이 잇는 건 로그인 하나를 통째로 넘기는 것이다. if account.email and await self._email_taken(account.email): res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT) return res # 3) 첫 방문 — 계정과 회사를 만든다. err_type, user = await self._create_account( login_id=_google_login_id(account.sub), password_hash=None, name=account.name or None, email=account.email or None, company_name=account.name or account.email or _google_login_id(account.sub), provider=AuthProvider.GOOGLE, provider_uid=account.sub, ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res LOG.i(f"SIGNUP(google) : sub={account.sub}") return await self._finish_login(user) async def get_me(self, user_info: UserInfo) -> Res_Me: res = Res_Me() # 1) 유저 조회 err_type, user = await DB_SESSION_MNG.execute_lambda( users.DBType(), DBWRType.DB_READ.value, lambda s: self.user_crud.get_user_by_login_id(s, user_info.id), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(ErrorType.ACCOUNT_INVALID_INFO) return res user: users # 2) 소속사 조회 (없어도 치명적 아님) company = None c_err, company_row = await DB_SESSION_MNG.execute_lambda( users.DBType(), DBWRType.DB_READ.value, lambda s: self.user_crud.get_company(s, user.company_id), ) if c_err == ErrorType.SUCCESS and company_row is not None: company = CompanyData(company_id=str(company_row.company_id), name=company_row.name) res.user_id = str(user.user_id) res.id = user.id res.name = user.name res.email = user.email res.contact_number = user.contact_number res.role = UserRole(user.role) res.provider = AuthProvider(user.provider) res.company = company return res async def update_me(self, user_info: UserInfo, req: Req_UpdateMe) -> Res_Me: res = Res_Me() data = req.model_dump(exclude_unset=True) # 비밀번호: 값 있으면 해시 교체, 비었으면 변경 안 함. if data.get("password"): # 구글 계정에 비밀번호를 심으면 id/pw 로도 열리는 반쪽 계정이 된다 — # 로그인 수단이 둘인데 어느 쪽도 상대를 모르는 상태다. 받지 않는다. if len(data["password"]) < _MIN_PASSWORD_LEN: res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) return res err_type, me = await DB_SESSION_MNG.execute_lambda( users.DBType(), DBWRType.DB_READ.value, lambda s: self.user_crud.get_user_by_login_id(s, user_info.id), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(ErrorType.ACCOUNT_NOT_FOUND) return res if me.provider != AuthProvider.LOCAL.value: res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT) return res data["password"] = await GetHashedPW(data["password"]) else: data.pop("password", None) # 빈 문자열은 NULL 로 저장(미입력 = 값 비움). for k in ("name", "email", "contact_number"): if k in data and data[k] == "": data[k] = None if data: err_type = await DB_SESSION_MNG.execute_lambda_run( [users.DBType()], [lambda s: self.user_crud.update_user(s, uuid.UUID(user_info.user_id), data)], ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res # 갱신 후 최신 정보로 응답(프론트가 스토어 갱신에 사용). return await self.get_me(user_info) async def refresh_token(self, refresh_token: str) -> Res_RefreshToken: res = Res_RefreshToken() # refresh 토큰 검증은 라우터 Depends(IsValidRefreshToken) 에서 1차 수행됨. user_info = DecodeRefreshToken(refresh_token) res.access_token = CreateAccessToken(user_info) return res