o2o-site-AEO/solution/backend/services/auth_service.py
Mina Choi 48109fdc99 [feat] solution/backend,frontend: id/pw 가입 · 구글 로그인 — 계정을 만들 길이 없던 걸 연다
계정 생성 API 가 아예 없었다(그동안 users 를 손으로 INSERT 했다). 로그인 화면은 있는데
그 뒤에 설 계정을 만들 방법이 제품에 없는 상태였다.

- auth_service.signup: 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개**. users.company_id 가
  NOT NULL 이고 모든 도메인이 company 로 스코프돼서, 회사 없는 계정은 아무것도 못 만든다
- services/external/google_identity: 구글 ID 토큰의 서명·iss·만료에 더해 **aud(우리 client_id)와
  email_verified 를 본다.** aud 검사가 빠지면 남의 앱에 발급된 '진짜' 구글 토큰으로 우리 계정에
  들어온다 — 서명도 발급자도 전부 맞으므로 다른 검사로는 안 걸린다
- users.provider/provider_uid 추가, password NULL 허용, id 20→64자(google_<sub> 가 20자를 넘는다).
  provider 에 server_default 를 같이 준 이유: ORM default 는 raw INSERT(테스트 시드)에 안 먹어서
  NOT NULL 컬럼이면 그 경로가 통째로 깨진다
- attempt_login: 소셜 계정을 먼저 끊는다. 안 끊으면 bcrypt 가 None 해시를 만나 500 이다
- 같은 이메일이라도 id/pw 계정과 구글 계정을 **잇지 않는다.** 이으면 계정 선점이다 —
  남의 이메일로 먼저 만들어 둔 계정에 그 사람의 구글 로그인이 들어간다 → DECISIONS 1-5
- LoginPage 는 admin 과 공유라 selfServe 로 갈랐다. admin 은 가입 링크도 구글 버튼도 안 뜬다
  (admin 라우터에 /signup 이 없어 404 가 난다)
- GOOGLE_CLIENT_ID 는 루트 .env 한 곳. compose 가 VITE_GOOGLE_CLIENT_ID 로 흘려보낸다 —
  두 곳에 적으면 백엔드 aud 대조와 화면 버튼이 조용히 갈라진다

★ 이미 도는 DB 는 init.sql 을 다시 적용해야 한다(말미 ALTER 섹션).

pytest: auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료·email_verified·변조
거절 확인) 통과. 전체 527 passed / 8 failed(전부 기존 실패, 인증과 무관).
tsc·eslint·vite build 통과.
2026-09-02 09:33:59 +09:00

370 lines
15 KiB
Python

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