From 8acaa633d19a0387fd3affe4f423ef310611aee3 Mon Sep 17 00:00:00 2001 From: myoungjugo Date: Thu, 26 Mar 2026 21:06:40 +0900 Subject: [PATCH 01/19] =?UTF-8?q?refactor=20:=20gitignore=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - venv를 써서 작업했기에 에디터 및 OS 설정과 파이썬 가상환경 관련한 내용을 추가 --- .gitignore | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.gitignore b/.gitignore index a42a290..2521bcf 100644 --- a/.gitignore +++ b/.gitignore @@ -165,3 +165,24 @@ vite.config.ts.timestamp-* calm-scarab-478705-c7-b25fdd5670c3.json **/values.yaml + +# --- JetBrains (PyCharm, IntelliJ) 관련 설정 --- +.idea/ +*.iml +*.iws +*.ipr + +# --- Python 빌드 및 배포 관련 (추가 권장) --- +build/ +dist/ +*.egg-info/ + +# --- Windows 시스템 파일 --- +Thumbs.db +desktop.ini + +# --- 개인 설정 및 임시 파일 --- +.env.local +.env.development.local +.env.test.local +.env.production.local From 7bd7be58a5cdb1f2e20e8933bc2972196d59b710 Mon Sep 17 00:00:00 2001 From: myoungjugo Date: Thu, 26 Mar 2026 21:08:24 +0900 Subject: [PATCH 02/19] =?UTF-8?q?refactor=20:=20auth=5Fschemas=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - password 설정 모델을 signup안으로 넣음 - pydantic 모델을 모음 - docstring 추가 --- main_server/app/schemas/auth_schemas.py | 28 ++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/main_server/app/schemas/auth_schemas.py b/main_server/app/schemas/auth_schemas.py index 4188c61..3930488 100644 --- a/main_server/app/schemas/auth_schemas.py +++ b/main_server/app/schemas/auth_schemas.py @@ -1,21 +1,26 @@ +# Pydantic Models : 값을 쿼리가 아닌 json으로 넘겨주기 위해 + import re -from pydantic import BaseModel, EmailStr, validator +from pydantic import BaseModel, EmailStr, field_validator class AuthBaseSchema(BaseModel): pass -class SignInRequest(BaseModel): +class SignUpRequest(BaseModel): """ - Request model for signing in. - Note: Passwords should be transmitted over HTTPS only. + 회원가입 요청 모델 + (Note: Passwords should be transmitted over HTTPS only.) """ email: EmailStr password: str + name : str - @validator("password") + + @field_validator("password") + @classmethod def password_strength(cls, value): # Minimum 8 characters, at least one uppercase, one lowercase, one digit, one special character if len(value) < 8: @@ -32,4 +37,17 @@ def password_strength(cls, value): class SignInResponse(BaseModel): + """ + 로그인 응답 모델 + """ access_token: str + + + + +class SignInRequest(BaseModel): + """ + 로그인 요청 모델 + """ + email: EmailStr + password: str From 5a50ee90b0a1fe1d9379cf65089ffb78ea5d9895 Mon Sep 17 00:00:00 2001 From: myoungjugo Date: Thu, 26 Mar 2026 21:10:59 +0900 Subject: [PATCH 03/19] =?UTF-8?q?refactor=20:=20auth=5Frepository=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 데이터베이스 접근 위한 함수 추가 - 하나의 코드 파일에서 db접근까지 하는 것을 방지하기 위함 - SQLAlchemy 메서드를 주로 사용함 - docstring로 타입명시 [추가한 함수들] - get_db - get_user_by_email - get_auth_user_by_id - create_user - create_auth_user - commit - refresh_user_stat --- .../app/repositories/auth_repository.py | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/main_server/app/repositories/auth_repository.py b/main_server/app/repositories/auth_repository.py index e78568e..2d360c2 100644 --- a/main_server/app/repositories/auth_repository.py +++ b/main_server/app/repositories/auth_repository.py @@ -1,3 +1,77 @@ +from sqlalchemy.orm import Session +from sqlalchemy import text +from app.models.user_models import User, AuthUser + class AuthRepository: def __init__(self, db): + """ + 인증 관련 데이터베이스 접근을 위한 저장소 초기화 + + :param db: SQLAlchemy 데이터베이스 세션 + :type db: Session + """ self.db = db + + def get_user_by_email(self, email: str): + """ + 이메일을 통해 사용자 정보를 조회. + + :param email: 조회할 사용자의 이메일 + :type email: str + :return: 조회된 유저 객체 또는 None + :rtype: User | None + """ + + return self.db.query(User).filter(User.email == email).first() + + def get_auth_user_by_id(self, user_id:str): + """ + 사용자 ID를 통해 인증 정보를 조회. + + :param user_id: 유저의 고유 식별자 + :type user_id: str + :return: 조회된 인증 정보 객체 또는 None + :rtype: AuthUser | None + """ + return self.db.query(AuthUser).filter(AuthUser.user_id == user_id).first() + + def create_user(self, email:str, name:str): + """ + 새로운 유저 기본 정보를 생성. (flush를 통해 ID를 생성함) + + :param email: 유저 이메일 + :type email: str + :param name: 유저 이름 + :type name: str + :return: 생성된 유저 객체 + :rtype: User + """ + new_user = User(email=email, name=name) + self.db.add(new_user) + self.db.flush() # user_id생성 위해 flush + return new_user + + def create_auth_user(self, shadow:AuthUser): + """ + 유저의 인증 정보(비밀번호, 토큰 등)를 저장. + + :param shadow: 저장할 인증 정보 객체 + :type shadow: AuthUser + """ + self.db.add(shadow) + self.db.commit() + + def commit(self): + """ + 현재 세션의 변경사항을 확정(Commit). + """ + self.db.commit() + + def refresh_user_stat(self): + """ + 통계용 Materialized View를 갱신. + """ + self.db.execute(text('REFRESH MATERIALIZED VIEW "USER_STAT"')) + self.db.commit() + + From aa61d3f5a130c1409181d65dc094f1f9ad6e5f6b Mon Sep 17 00:00:00 2001 From: myoungjugo Date: Thu, 26 Mar 2026 21:22:55 +0900 Subject: [PATCH 04/19] =?UTF-8?q?refactor=20:=20auth=5Fservice=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사용자 등록, 이메일 인증, 로그인 흐름 구현 - 비밀번호 해싱은 passlib(bcrypt)사용 - 토큰 생성은 JWT(HS256)사용 - SECRET_KEY는 환경 변수에서 읽어옴 [함수] - register_new_user - 사용자/인증 사용자 생성 - 비밀번호 해싱 - 인증 토큰 생성 - 인증 이메일 전송 - verify_email - 토큰 디코딩 - 계정 인증 완료 표시 - sign_in - 자격 증명 유효성 검사 - 액세스 토큰 반환 --- main_server/app/services/auth_service.py | 128 +++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/main_server/app/services/auth_service.py b/main_server/app/services/auth_service.py index f118674..c52da45 100644 --- a/main_server/app/services/auth_service.py +++ b/main_server/app/services/auth_service.py @@ -1,3 +1,131 @@ +from datetime import datetime, timedelta +import jwt +import os +from fastapi import HTTPException +from app.dependencies.database import get_db +from app.models.user_models import User, AuthUser +from passlib.context import CryptContext +from pydantic import BaseModel, EmailStr +from dotenv import load_dotenv +from app.schemas.auth_schemas import SignUpRequest, SignInRequest, SignInResponse + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +SECRET_KEY = os.getenv("JWT_SECRET") +ALGORITHM = "HS256" + class AuthService: def __init__(self, repository): + """ + 인증 관련 비즈니스 로직 서비스를 초기화. + + :param repository: DB 접근을 담당하는 저장소 객체 + :type repository: AuthRepository + """ self.repository = repository + + async def register_new_user(self, data:SignUpRequest): + + """ + 신규 유저 등록 및 이메일 인증 발송 프로세스를 처리. + + :param data: 검증된 회원가입 요청 데이터 + :type data: SignUpRequest + :return: 성공 메시지 딕셔너리 + :rtype: dict + :raises HTTPException: 이미 이메일이 존재할 경우 발생 + """ + + #1. 중복검사 + '#TODO: /api/auth/check-email로 중복 검사 api 제작' + + if self.repository.get_user_by_email(data.email): + raise HTTPException(status_code=400, detail="이미 존재하는 이메일입니다") + #2. User 생성 + new_user = self.repository.create_user(data.email, data.name) + + #3. 비밀번호 해시 및 토큰 생성 + hashed_pw = pwd_context.hash(data.password) + verify_token = jwt.encode( + {"sub" : str(new_user.user_id), "exp":datetime.now(datetime.timezone.utc) + timedelta(hours=1)}, + SECRET_KEY, algorithm = ALGORITHM + ) + + #AuthUser 생성 및 저장 + shadow = AuthUser( + user_id = new_user.user_id, + emial = data.email, + hashed_password = hashed_pw, + is_verified=False, + verification_token=verify_token + ) + self.repository.create_auth_user(shadow) + + #5. 이메일 발송 + await send_verification_email(data.email, verify_token) + return {"message" : "회원가입 완료! 이메일을 확인해주세요."} + + def verify_email(self, token:str): + + """ + 메일 인증 토큰의 유효성을 검증하고 계정을 활성화. + + :param token: 이메일로 발송된 JWT 인증 토큰 + :type token: str + :return: 인증 완료 메시지 + :rtype: dict + :raises HTTPException: 토큰 만료 또는 유효하지 않을 때 발생 + """ + + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + user_id = payload["sub"] + except jwt.ExpiredSignatureError: + raise HTTPException(status_code = 400, detail="토큰이 만료되었습니다") + except jwt.InvalidTokenError: + raise HTTPException(status_code=40, detail="유효하지 않은 토큰입니다") + + shadow = self.repository.get_auth_user_by_id(user_id) + if not shadow or shadow.verification_token != token: + raise HTTPException(status_code=400, detail="인증 정보가 올바르지 않습니다") + + shadow.is_verified=True + shadow.verification_token = None + self.repository.commit() + self.repository.refresh_user_stat() + return{"message" : "이메일 인증 완료!"} + + def sign_in(self, data: SignInRequest): + + """ + 사용자 자격 증명을 확인하고 서비스 이용 토큰을 발급. + + :param data: 로그인 요청 데이터 (이메일, 비번) + :type data: SignInRequest + :return: 엑세스 토큰 정보 + :rtype: dict + :raises HTTPException: 인증 실패 또는 미인증 계정일 때 발생 + """ + + user = self.repository.get_user_by_email(data.email) + '#TODO: CIA triad에서 사용자 존재 여부를 드러내는 것은 기밀성을 떨어트릴 수 있습니다. ' + '#TODO: 이메일 존재 여부와 비밀번호 일치 여부를 나타내는 대신 이메일과 비밀번호가 일치하지 않는다 정도로 보여주는 건 어떨까요? ' + if not user: + raise HTTPException(status_code=400, detail="존재하지 않는 이메일입니다") + shadow = self.repository.get_auth_user_by_id(user, user_id) + if not shadow or not pwd_context.verify(data.password, shadow.hashed_password): + raise HTTPException(status_code=400, detail="비밀번호가 일치") + + if user.email != shadow.email: + user.email = shadow.email + self.repository.commit() + + token = jwt.encode( + {"sub": str(user.user_id), "exp": datetime.utcnow() + timedelta(days=7)}, + SECRET_KEY, algorithm=ALGORITHM + ) + return {"access_token": token} + + + + From d06b1230ee6fb0bc7f11d9eddddb8ab9cb867673 Mon Sep 17 00:00:00 2001 From: myoungjugo Date: Thu, 26 Mar 2026 21:29:23 +0900 Subject: [PATCH 05/19] =?UTF-8?q?refactor=20:=20auth=5Frouter=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 예전에 수많은 함수 포함 -> 통신 및 응답 기능만 - docstring으로 타입 명시 [엔드포인트 안 함수] - signup 회원가입 api엔드포인트 json 요청 바디 데이터를 받고 db 세션 주입 pydantic 사용해 해당 타입으로 받고 register_new_user를 수행하고 기다림 - verify_email 이메일 인증 토큰을 검증해 계정을 활성화 사용자가 메일로 받은 링크 클릭 시 호출되며, 토큰이 유효하면 AuthUser의 인증 상태를 True로 변경하고 통계 뷰 갱신 - signin 사용자 로그인을 처리하고 액세스 토큰 발급 이메일과 비밀번호 검증, 모든 조건(계정 존재, 비밀번호 일치, 이메일 인증 완료)이 충족되면 향후 API 요청에 사용할 JWT 액세스 토큰 반환 --- main_server/app/routers/auth_router.py | 170 +++++++------------------ 1 file changed, 45 insertions(+), 125 deletions(-) diff --git a/main_server/app/routers/auth_router.py b/main_server/app/routers/auth_router.py index 298660c..88ce544 100644 --- a/main_server/app/routers/auth_router.py +++ b/main_server/app/routers/auth_router.py @@ -12,148 +12,68 @@ from passlib.context import CryptContext from pydantic import BaseModel, EmailStr from dotenv import load_dotenv - -load_dotenv() +from app.schemas.auth_schemas import SignUpRequest, SignInRequest, SignInResponse +from app.repositories.auth_repository import AuthRepository +from app.services.auth_service import AuthService router = APIRouter(prefix="/api/auth", tags=["Auth"]) -# TODO: 인증 도메인 비즈니스 로직은 app/services/auth_service.py로 점진 이관 - -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - -SECRET_KEY = os.getenv("JWT_SECRET") -ALGORITHM = "HS256" - - - -# Pydantic Models : 값을 쿼리가 아닌 json으로 넘겨주기 위해 - -class SignUpRequest(BaseModel): - email: EmailStr - password: str - name: str - - -class SignInRequest(BaseModel): - email: EmailStr - password: str - - -# JWT 생성 - -def create_access_token(user_id: str): - payload = { - "sub": user_id, - "exp": datetime.utcnow() + timedelta(hours=24), - } - return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) - - -# 회원가입 @router.post("/signup") async def signup(data: SignUpRequest, db: Session = Depends(get_db)): - # 이메일 중복 검사 - user = db.query(User).filter(User.email == data.email).first() - if user: - raise HTTPException(status_code=400, detail="이미 존재하는 이메일입니다") - - # User 생성 - new_user = User(email=data.email, name=data.name) - db.add(new_user) - db.flush() # user_id 생성됨 - - # 비밀번호 해시 - hashed_pw = pwd_context.hash(data.password) - - # 인증용 토큰 - verify_token = jwt.encode( - {"sub": str(new_user.user_id), "exp": datetime.utcnow() + timedelta(hours=1)}, - SECRET_KEY, - algorithm=ALGORITHM - ) - - # AuthUser 생성 - shadow = AuthUser( - user_id=new_user.user_id, - email=data.email, - hashed_password=hashed_pw, - is_verified=False, - verification_token=verify_token - ) - - db.add(shadow) - db.commit() - - # 이메일 인증 발송 - await send_verification_email(data.email, verify_token) - return {"message": "회원가입 완료! 이메일을 확인해주세요."} + """ + 회원가입 API 엔드포인트 + :param data: JSON 요청 바디 데이터 + :type data: SignUpRequest + :param db: DB 세션 주입 + :type db: Session + :return: 회원가입 처리 결과 + :rtype: dict + """ -# 이메일 인증 API + repo = AuthRepository(db) + service = AuthService(repo) + return await service.register_new_user(data) @router.get("/verify-email") def verify_email(token: str, db: Session = Depends(get_db)): - try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - user_id = payload["sub"] - except(jwt.exceptions.ExpiredSignatureError): - raise HTTPException(status_code=400, detail="토큰이 만료되었습니다") - except(jwt.exceptions.InvalidTokenError): - raise HTTPException(status_code=400, detail="유효하지 않은 토큰입니다") + """ + 이메일 인증 토큰을 검증하여 계정을 활성화. - shadow = db.query(AuthUser).filter(AuthUser.user_id == user_id).first() + 사용자가 메일로 받은 링크를 클릭했을 때 호출되며, 토큰이 유효하면 + AuthUser의 인증 상태를 완료로 변경하고 통계 뷰를 갱신. - if not shadow: - raise HTTPException(status_code=400, detail="인증 정보가 없습니다") + :param token: 이메일에 포함된 JWT 인증 토큰 + :type token: str + :param db: 데이터베이스 세션 (Depends를 통해 주입) + :type db: Session + :return: 인증 완료 메시지 + :rtype: dict + """ - if shadow.verification_token != token: - raise HTTPException(status_code=400, detail="토큰이 일치하지 않습니다") - - shadow.is_verified = True - shadow.verification_token = None - db.commit() - db.execute(text('REFRESH MATERIALIZED VIEW "USER_STAT"')) - db.commit() - - return {"message": "이메일 인증 완료!"} - - -# 로그인 + repo = AuthRepository(db) + service = AuthService(repo) + return service.verify_email(token) @router.post("/signin") def signin(data: SignInRequest, db: Session = Depends(get_db)): - # 이메일 일치 유저 찾기 - user = db.query(User).filter(User.email == data.email).first() - if not user: - raise HTTPException(status_code=400, detail="존재하지 않는 이메일입니다") - - # AuthUser 찾기 - shadow = db.query(AuthUser).filter(AuthUser.user_id == user.user_id).first() - if not shadow: - raise HTTPException(status_code=400, detail="인증 정보 없음") - - # 비밀번호 검증 - if not pwd_context.verify(data.password, shadow.hashed_password): - raise HTTPException(status_code=400, detail="비밀번호가 일치하지 않습니다") - - # 이메일 인증 체크 - if not shadow.is_verified: - raise HTTPException(status_code=400, detail="이메일 인증이 필요합니다") - - #로그인 성공 시 user테이블의 email에 값 추가 - if user.email != shadow.email: - user.email = shadow.email - db.commit() - - # JWT 토큰 발급 - token = jwt.encode( - {"sub": str(user.user_id), "exp": datetime.utcnow() + timedelta(days=7)}, - SECRET_KEY, - algorithm=ALGORITHM - ) - - return {"access_token": token} + """ + 사용자 로그인을 처리하고 액세스 토큰을 발급. + + 이메일과 비밀번호를 검증하고, 모든 조건(계정 존재, 비밀번호 일치, 이메일 인증 완료)이 + 충족되면 향후 API 요청에 사용할 JWT 액세스 토큰을 반환. + + :param data: 로그인 요청 정보 (이메일, 비밀번호) + :type data: SignInRequest + :param db: 데이터베이스 세션 (Depends를 통해 주입) + :type db: Session + :return: 발급된 액세스 토큰 정보 + :rtype: dict + """ + repo = AuthRepository(db) + service = AuthService(repo) + return service.sign_in(data) \ No newline at end of file From 67c0abfd24db7c37accf1dc48859a4f03bb26a2c Mon Sep 17 00:00:00 2001 From: myoungjugo Date: Thu, 26 Mar 2026 21:36:49 +0900 Subject: [PATCH 06/19] =?UTF-8?q?fix=20:=20auth=5Fservice=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상단 이메일 발송 메소드 import(app.services.email_service - utcnow가 더 이상 쓰이지 않기에 datetime.now(datetime.timezone.utc)로 수정 --- main_server/app/services/auth_service.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/main_server/app/services/auth_service.py b/main_server/app/services/auth_service.py index c52da45..d48646c 100644 --- a/main_server/app/services/auth_service.py +++ b/main_server/app/services/auth_service.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, EmailStr from dotenv import load_dotenv from app.schemas.auth_schemas import SignUpRequest, SignInRequest, SignInResponse +from app.services.email_service import send_verification_email pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -112,7 +113,7 @@ def sign_in(self, data: SignInRequest): '#TODO: 이메일 존재 여부와 비밀번호 일치 여부를 나타내는 대신 이메일과 비밀번호가 일치하지 않는다 정도로 보여주는 건 어떨까요? ' if not user: raise HTTPException(status_code=400, detail="존재하지 않는 이메일입니다") - shadow = self.repository.get_auth_user_by_id(user, user_id) + shadow = self.repository.get_auth_user_by_id(user.user_id) if not shadow or not pwd_context.verify(data.password, shadow.hashed_password): raise HTTPException(status_code=400, detail="비밀번호가 일치") @@ -121,7 +122,7 @@ def sign_in(self, data: SignInRequest): self.repository.commit() token = jwt.encode( - {"sub": str(user.user_id), "exp": datetime.utcnow() + timedelta(days=7)}, + {"sub": str(user.user_id), "exp":datetime.now(datetime.timezone.utc) + timedelta(days=7)}, SECRET_KEY, algorithm=ALGORITHM ) return {"access_token": token} From d091f8da2e8986c1c3444b0ed110d53ddd36d71a Mon Sep 17 00:00:00 2001 From: myoungjugo Date: Thu, 26 Mar 2026 21:39:18 +0900 Subject: [PATCH 07/19] =?UTF-8?q?fix=20:=20=EC=98=A4=ED=83=80=20=EB=B0=8F?= =?UTF-8?q?=20impory=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - timezone을 쓰기 위해 임포트 - 그 외 오타 수정 --- main_server/app/services/auth_service.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/main_server/app/services/auth_service.py b/main_server/app/services/auth_service.py index d48646c..ee00adc 100644 --- a/main_server/app/services/auth_service.py +++ b/main_server/app/services/auth_service.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import jwt import os from fastapi import HTTPException @@ -55,7 +55,7 @@ async def register_new_user(self, data:SignUpRequest): #AuthUser 생성 및 저장 shadow = AuthUser( user_id = new_user.user_id, - emial = data.email, + email = data.email, hashed_password = hashed_pw, is_verified=False, verification_token=verify_token @@ -84,7 +84,7 @@ def verify_email(self, token:str): except jwt.ExpiredSignatureError: raise HTTPException(status_code = 400, detail="토큰이 만료되었습니다") except jwt.InvalidTokenError: - raise HTTPException(status_code=40, detail="유효하지 않은 토큰입니다") + raise HTTPException(status_code=400, detail="유효하지 않은 토큰입니다") shadow = self.repository.get_auth_user_by_id(user_id) if not shadow or shadow.verification_token != token: From f081889aea549a30bc9ea49306e15c9d7704ca1f Mon Sep 17 00:00:00 2001 From: myoungjugo Date: Mon, 30 Mar 2026 08:22:54 +0900 Subject: [PATCH 08/19] Refactor:jandi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pydantic 스키마 정의 --- main_server/app/schemas/jandi_schemas.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/main_server/app/schemas/jandi_schemas.py b/main_server/app/schemas/jandi_schemas.py index ce50a77..1ae0dfd 100644 --- a/main_server/app/schemas/jandi_schemas.py +++ b/main_server/app/schemas/jandi_schemas.py @@ -6,6 +6,11 @@ class JandiBaseSchema(BaseModel): class GetJandiResponse(BaseModel): + """잔디 데이터 응답 모델""" date: str category: str count: int + +class GetSignedUrlRequest(BaseModel): + """서명된 URL 응답 모델""" + url: str \ No newline at end of file From 446ac0387b43af7746ac23d4d3bedaf328377d5a Mon Sep 17 00:00:00 2001 From: myoungjugo Date: Mon, 30 Mar 2026 08:23:33 +0900 Subject: [PATCH 09/19] Refactor: jandi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit db조회 함수들(반복적으로 쓰이던 db함수를 여기서 정의) --- .../app/repositories/jandi_repository.py | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/main_server/app/repositories/jandi_repository.py b/main_server/app/repositories/jandi_repository.py index 1b1f748..8dad3d6 100644 --- a/main_server/app/repositories/jandi_repository.py +++ b/main_server/app/repositories/jandi_repository.py @@ -1,3 +1,31 @@ +from sqlalchemy.orm import Session +from datetime import datetime, timedelta +from app.models.post_models import POST_AGG + class JandiRepository: - def __init__(self, db): + def __init__(self, db: Session): + """ + 잔디 데이터 접근을 위한 저장소 초기화 + + :param db: SQLAlchemy 데이터베이스 세션 + :type db: Session + """ self.db = db + + def get_posts_by_date_range(self, user_id: str, start_date: datetime, end_date: datetime) -> list[POST_AGG]: + """ + 특정 기간 동안의 유저 포스트 통계 데이터를 조회합니다. + + :param user_id: 조회할 유저의 ID + :type user_id: str + :param start_date: 조회 시작 날짜 + :type start_date: datetime + :param end_date: 조회 종료 날짜 + :type end_date: datetime + :return: POST_AGG 모델 객체 리스트 + :rtype: list[POST_AGG] + """ + return self.db.query(POST_AGG).filter( + POST_AGG.user_id == user_id, + POST_AGG.date.between(start_date, end_date) + ).all() From 279add12b1df16d17fac254d43ed2d368ef5dcce Mon Sep 17 00:00:00 2001 From: myoungjugo Date: Mon, 30 Mar 2026 08:23:55 +0900 Subject: [PATCH 10/19] Refactor:jandi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 라우터 함수만 남김 --- main_server/app/routers/jandi_router.py | 112 +++++++++++------------- 1 file changed, 49 insertions(+), 63 deletions(-) diff --git a/main_server/app/routers/jandi_router.py b/main_server/app/routers/jandi_router.py index d5d3201..da6b36b 100644 --- a/main_server/app/routers/jandi_router.py +++ b/main_server/app/routers/jandi_router.py @@ -1,70 +1,56 @@ -from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import HTMLResponse +from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from app.dependencies.database import get_db -from app.core.verify_jwt import get_current_user_id, get_jandi_user_id -from app.schemas.jandi_schemas import GetJandiResponse -from app.models.post_models import POST_AGG -from app.templates.html_template import get_html_template -from datetime import datetime, timedelta -from pydantic import BaseModel +from app.core.verify_jwt import get_current_user_id +from app.schemas.jandi_schemas import GetJandiResponse, GetSignedUrlResponse +from app.repositories.jandi_repository import JandiRepository +from app.services.jandi_service import JandiService -import os -import jwt -import dotenv -router = APIRouter(prefix='/api/jandi') - -dotenv.load_dotenv() - -UI_SECRET_KEY = os.getenv("UI_SECRET_KEY", "my_super_secret_key") -ALGORITHM = "HS256" +router = APIRouter(prefix='/api/jandi', tags=['Jandi']) @router.get("/", response_model=list[GetJandiResponse]) -async def get_jandi_data(date: str | None = None,db: Session = Depends(get_db), user_id: str = Depends(get_current_user_id)): - # date가 없으면 오늘 날짜로 설정 - if date is None: - date = datetime.now().strftime("%Y-%m-%d") - - dt = datetime.strptime(date, "%Y-%m-%d") - - # user_id로 POST_AGG 테이블에서 데이터를 가져옴 - posts: list[POST_AGG] = db.query(POST_AGG).filter(POST_AGG.user_id == user_id, POST_AGG.date.between(dt-timedelta(days=30), dt)).all() - if len(posts) == 0: - return [] - # 그걸 [GetJandiResponse]로 변환 - response = [GetJandiResponse(date=post.date.strftime("%Y-%m-%d"), category=post.category, count=post.count) for post in posts] # type: ignore - return response - -class GetSignedUrlRequest(BaseModel): - url: str - -@router.get("/signedUrl", response_model=GetSignedUrlRequest) -async def get_signed_url(db: Session = Depends(get_db), user_id: str = Depends(get_current_user_id)): - if user_id is None: - raise HTTPException(status_code=401, detail="인증 헤더가 필요합니다.") - verify_token = jwt.encode( - {"sub": str(user_id)}, - UI_SECRET_KEY, - algorithm=ALGORITHM - ) - return {"url": f"http://136.110.239.66/api/jandi/widget?token={verify_token}"} - +def read_jandi_data(date: str | None = None, db: Session = Depends(get_db), user_id: str = Depends(get_current_user_id)): + """ + 유저의 잔디 데이터를 조회합니다. + + :param date: 기준 날짜 (YYYY-MM-DD) + :type date: str | None + :param db: 데이터베이스 세션 + :type db: Session + :param user_id: 현재 인증된 유저의 ID + :type user_id: str + :return: 잔디 통계 데이터 리스트 + """ + repo = JandiRepository(db) + service = JandiService(repo) + return service.get_user_jandi_data(user_id, end_date=date) + +@router.get("/signedUrl", response_model=GetSignedUrlResponse) +def read_signed_url(db: Session = Depends(get_db), user_id: str = Depends(get_current_user_id)): + """ + 위젯용 서명된 URL을 생성하여 반환합니다. + + :param db: 데이터베이스 세션 + :type db: Session + :param user_id: 현재 인증된 유저의 ID + :type user_id: str + :return: 위젯 URL 정보 + """ + repo = JandiRepository(db) + service = JandiService(repo) + return service.generate_signed_url(user_id) @router.get("/widget") -def get_jandi(db: Session = Depends(get_db), token: str|None = None): - if token is None: - raise HTTPException(status_code=401, detail="인증 헤더가 필요합니다.") - user_id = get_jandi_user_id(token) - if user_id is None: - raise HTTPException(status_code=401, detail="인증 헤더가 필요합니다.") - - date = datetime.now() - # user_id로 POST_AGG 테이블에서 데이터를 가져옴 - posts: list[POST_AGG] = db.query(POST_AGG).filter(POST_AGG.user_id == user_id, POST_AGG.date.between(date-timedelta(days=30), date)).all() - if len(posts) == 0: - return [] - # 그걸 [GetJandiResponse]로 변환 - response = [{"date": post.date.strftime("%Y-%m-%d"), "category": post.category, "count": post.count} for post in posts] - - - return HTMLResponse(content=get_html_template(response), status_code=200) \ No newline at end of file +def read_jandi_widget(token: str | None = None, db: Session = Depends(get_db)): + """ + 외부 위젯용 HTML 페이지를 반환합니다. + + :param token: 위젯 인증 토큰 (Query parameter) + :type token: str | None + :param db: 데이터베이스 세션 + :type db: Session + :return: HTMLResponse 위젯 페이지 + """ + repo = JandiRepository(db) + service = JandiService(repo) + return service.get_jandi_widget_html(token) \ No newline at end of file From 5d3c9cb08d592c19fe2426ed9760fcaef78e70cd Mon Sep 17 00:00:00 2001 From: myoungjugo Date: Mon, 30 Mar 2026 08:25:33 +0900 Subject: [PATCH 11/19] Refactor:jandi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서비스 함수 정리 - get_user_jandi_data - generate_signed_url - get_jandi_widget_html - 기존에 있던 get_jandi_data를 db함수로 뺌 --- main_server/app/services/jandi_service.py | 89 ++++++++++++++++++++--- 1 file changed, 78 insertions(+), 11 deletions(-) diff --git a/main_server/app/services/jandi_service.py b/main_server/app/services/jandi_service.py index 87fc30d..6c4c7b4 100644 --- a/main_server/app/services/jandi_service.py +++ b/main_server/app/services/jandi_service.py @@ -1,16 +1,83 @@ +import os +import jwt from datetime import datetime, timedelta +from fastapi import HTTPException +from fastapi.responses import HTMLResponse +from app.repositories.jandi_repository import JandiRepository +from app.schemas.jandi_schemas import GetJandiResponse +from app.templates.html_template import get_html_template +from app.core.verify_jwt import get_jandi_user_id -from sqlalchemy.orm import Session +UI_SECRET_KEY = os.getenv("UI_SECRET_KEY", "my_super_secret_key") +ALGORITHM = "HS256" -from app.schemas.jandi_schemas import GetJandiResponse -from app.models.post_models import POST_AGG +class JandiService: + def __init__(self, repository: JandiRepository): + """ + 잔디 비즈니스 로직 서비스를 초기화합니다. + + :param repository: 잔디 데이터 저장소 객체 + :type repository: JandiRepository + """ + self.repository = repository + + def get_user_jandi_data(self, user_id: str, days: int = 30, end_date: str | None = None) -> list[GetJandiResponse]: + """ + 유저의 최근 잔디 데이터를 조회하여 스키마 형식으로 반환합니다. + + :param user_id: 유저의 고유 식별자 + :type user_id: str + :param days: 조회할 기간 (일 단위) + :type days: int + :param end_date: 조회 종료 기준 날짜 (None이면 오늘) + :type end_date: str | None + :return: 검증된 잔디 데이터 리스트 + :rtype: list[GetJandiResponse] + """ + dt_end = datetime.strptime(end_date, "%Y-%m-%d") if end_date else datetime.now() + dt_start = dt_end - timedelta(days=days) + + posts = self.repository.get_posts_by_date_range(user_id, dt_start, dt_end) + + return [ + GetJandiResponse( + date=post.date.strftime("%Y-%m-%d"), + category=post.category, + count=post.count + ) for post in posts + ] + + def generate_signed_url(self, user_id: str) -> dict: + """ + 위젯 접근을 위한 서명된 토큰이 포함된 URL을 생성합니다. + + :param user_id: URL을 생성할 유저의 ID + :type user_id: str + :return: 생성된 URL 정보 딕셔너리 + :rtype: dict + """ + verify_token = jwt.encode({"sub": str(user_id)}, UI_SECRET_KEY, algorithm=ALGORITHM) + return {"url": f"http://136.110.239.66/api/jandi/widget?token={verify_token}"} + + def get_jandi_widget_html(self, token: str | None) -> HTMLResponse: + """ + 토큰을 검증하고 잔디 위젯용 HTML 응답을 생성합니다. + :param token: 위젯 인증용 JWT 토큰 + :type token: str | None + :return: HTML 템플릿 응답 + :rtype: HTMLResponse + :raises HTTPException: 토큰이 없거나 유효하지 않을 때 발생 + """ + if not token: + raise HTTPException(status_code=401, detail="인증 토큰이 필요합니다.") + + user_id = get_jandi_user_id(token) + if not user_id: + raise HTTPException(status_code=401, detail="유효하지 않은 토큰입니다.") -def get_jandi_data(db: Session, user_id: str) -> list[GetJandiResponse]: - posts: list[POST_AGG] = db.query(POST_AGG).filter(POST_AGG.user_id == user_id, POST_AGG.date.between(datetime.now() - timedelta(days=365), datetime.now())).all() - if len(posts) == 0: - return [] - # 그걸 [GetJandiResponse]로 변환 - response = [GetJandiResponse(date=post.date.strftime("%Y-%m-%d"), category=post.category, count=post.count) for post in posts] # pyright: ignore[reportArgumentType] - return response - \ No newline at end of file + # 위젯용 최근 30일 데이터 조회 + data = self.get_user_jandi_data(user_id, days=30) + formatted_data = [{"date": d.date, "category": d.category, "count": d.count} for d in data] + + return HTMLResponse(content=get_html_template(formatted_data), status_code=200) \ No newline at end of file From f2be2cb8e1ed98c29426c25aaa3a7079f53c446a Mon Sep 17 00:00:00 2001 From: myoungjugo Date: Mon, 6 Apr 2026 02:23:32 +0900 Subject: [PATCH 12/19] feat/auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 라우터 함수 9개 엔드포인트 구현 --- main_server/app/routers/auth_router.py | 145 +++++++++++++++++-------- 1 file changed, 98 insertions(+), 47 deletions(-) diff --git a/main_server/app/routers/auth_router.py b/main_server/app/routers/auth_router.py index 88ce544..fe425ea 100644 --- a/main_server/app/routers/auth_router.py +++ b/main_server/app/routers/auth_router.py @@ -1,79 +1,130 @@ -# app/routers/auth_router.py - -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, Query, Path from sqlalchemy.orm import Session -from sqlalchemy import text -from datetime import datetime, timedelta -import jwt -import os from app.dependencies.database import get_db -from app.services.email_service import send_verification_email -from app.models.user_models import User, AuthUser -from passlib.context import CryptContext -from pydantic import BaseModel, EmailStr -from dotenv import load_dotenv -from app.schemas.auth_schemas import SignUpRequest, SignInRequest, SignInResponse +from app.schemas.auth_schemas import * from app.repositories.auth_repository import AuthRepository from app.services.auth_service import AuthService +from fastapi.responses import HTMLResponse router = APIRouter(prefix="/api/auth", tags=["Auth"]) -@router.post("/signup") -async def signup(data: SignUpRequest, db: Session = Depends(get_db)): +@router.get("/check-email", response_model=EmailCheckResponse) +async def check_email(email: EmailStr = Query(...), db: Session = Depends(get_db)): + """ + 이메일 중복 검사를 수행합니다. + :param email: 사용자가 입력한 이메일 주소 + :type email: EmailStr + :param db: 데이터베이스 세션 (Depends 주입) + :type db: Session + :return: 사용 가능 여부 결과 + """ + return await AuthService(AuthRepository(db)).check_email_availability(email) + +@router.post("/signup", status_code=201, response_model=SignUpResponse) +async def signup(data: SignUpRequest, db: Session = Depends(get_db)): """ - 회원가입 API 엔드포인트 + 회원가입을 진행하고 인증 메일을 발송합니다. - :param data: JSON 요청 바디 데이터 + :param data: 이메일, 비밀번호, 이름이 포함된 요청 바디 :type data: SignUpRequest - :param db: DB 세션 주입 + :param db: 데이터베이스 세션 :type db: Session - :return: 회원가입 처리 결과 - :rtype: dict + :return: 가입 처리 상세 정보 """ + return await AuthService(AuthRepository(db)).register_user(data) - repo = AuthRepository(db) - service = AuthService(repo) - return await service.register_new_user(data) +@router.get("/is-verified", response_model=VerificationStatusResponse) +async def is_verified(email: EmailStr = Query(...), db: Session = Depends(get_db)): + """ + 사용자의 이메일 인증 여부를 확인합니다. -@router.get("/verify-email") -def verify_email(token: str, db: Session = Depends(get_db)): + :param email: 조회할 사용자의 이메일 + :type email: EmailStr + :param db: 데이터베이스 세션 + :type db: Session + :return: 인증 완료 여부 (bool) + """ + return await AuthService(AuthRepository(db)).get_verification_status(email) +@router.post("/resend-email") +async def resend_email(email: EmailStr = Query(...), db: Session = Depends(get_db)): """ - 이메일 인증 토큰을 검증하여 계정을 활성화. + 미인증 사용자의 이메일로 인증 메일을 재전송합니다. - 사용자가 메일로 받은 링크를 클릭했을 때 호출되며, 토큰이 유효하면 - AuthUser의 인증 상태를 완료로 변경하고 통계 뷰를 갱신. + :param email: 인증 메일을 다시 보낼 이메일 주소 + :type email: EmailStr + :param db: 데이터베이스 세션 + :type db: Session + :return: 재전송 결과 메시지 + """ + return await AuthService(AuthRepository(db)).resend_verification(email) + +@router.get("/verify-email", response_class=HTMLResponse) +async def verify_email(token: str = Query(...), db: Session = Depends(get_db)): + """ + 이메일 인증 링크 클릭 시 토큰을 검증하고 결과 페이지 보여줌. :param token: 이메일에 포함된 JWT 인증 토큰 :type token: str - :param db: 데이터베이스 세션 (Depends를 통해 주입) + :param db: 데이터베이스 세션 :type db: Session - :return: 인증 완료 메시지 - :rtype: dict + :return: 인증 성공 시 html 페이지 반환 + :rtype: HTMLResponse """ + service = AuthService(AuthRepository(db)) + return await service.verify_email_token(token) - repo = AuthRepository(db) - service = AuthService(repo) - return service.verify_email(token) +@router.post("/signin", response_model=SignInResponse) +async def signin(data: SignInRequest, db: Session = Depends(get_db)): + """ + 로그인을 시도하고 토큰을 발급받습니다. -@router.post("/signin") -def signin(data: SignInRequest, db: Session = Depends(get_db)): + :param data: 이메일과 비밀번호 정보 + :type data: SignInRequest + :param db: 데이터베이스 세션 + :type db: Session + :return: Access 및 Refresh 토큰 + """ + return await AuthService(AuthRepository(db)).login(data) +@router.post("/refresh") +async def refresh_token(data: TokenRefreshRequest, db: Session = Depends(get_db)): """ - 사용자 로그인을 처리하고 액세스 토큰을 발급. + 유효한 Refresh 토큰으로 Access 토큰을 갱신합니다. - 이메일과 비밀번호를 검증하고, 모든 조건(계정 존재, 비밀번호 일치, 이메일 인증 완료)이 - 충족되면 향후 API 요청에 사용할 JWT 액세스 토큰을 반환. + :param data: Refresh 토큰이 담긴 요청 바디 + :type data: TokenRefreshRequest + :param db: 데이터베이스 세션 + :type db: Session + :return: 새로운 토큰 정보 + """ + return await AuthService(AuthRepository(db)).refresh_access_token(data.refresh_token) - :param data: 로그인 요청 정보 (이메일, 비밀번호) - :type data: SignInRequest - :param db: 데이터베이스 세션 (Depends를 통해 주입) +@router.post("/password/reset/request") +async def pw_reset_request(data: PasswordResetRequest, db: Session = Depends(get_db)): + """ + 비밀번호 재설정을 위한 이메일 발송을 요청합니다. + + :param data: 비밀번호를 잊은 사용자의 이메일 + :type data: PasswordResetRequest + :param db: 데이터베이스 세션 :type db: Session - :return: 발급된 액세스 토큰 정보 - :rtype: dict + :return: 발송 결과 메시지 + """ + return await AuthService(AuthRepository(db)).request_pw_reset(data.email) + +@router.post("/password/reset/{user_token}", status_code=201) +async def pw_reset_confirm(user_token: str = Path(...), data: PasswordResetConfirm = None, db: Session = Depends(get_db)): """ + 임시 토큰을 사용하여 새로운 비밀번호를 등록합니다. - repo = AuthRepository(db) - service = AuthService(repo) - return service.sign_in(data) \ No newline at end of file + :param user_token: URL 경로에 포함된 30분 유효 임시 토큰 + :type user_token: str + :param data: 새 비밀번호 데이터 + :type data: PasswordResetConfirm + :param db: 데이터베이스 세션 + :type db: Session + :return: 변경 성공 결과 + """ + return await AuthService(AuthRepository(db)).confirm_pw_reset(user_token, data.newPassword) \ No newline at end of file From d8a2967c9c457c2d8f5f9de060d2d1276c3f2e74 Mon Sep 17 00:00:00 2001 From: myoungjugo Date: Mon, 6 Apr 2026 02:24:00 +0900 Subject: [PATCH 13/19] =?UTF-8?q?docs/auth=20=EC=8A=A4=ED=82=A4=EB=A7=88?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 추가된 엔드포인트에 맞는 응답/요청 모델 작성 --- main_server/app/schemas/auth_schemas.py | 29 ++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/main_server/app/schemas/auth_schemas.py b/main_server/app/schemas/auth_schemas.py index 3930488..5eb64ae 100644 --- a/main_server/app/schemas/auth_schemas.py +++ b/main_server/app/schemas/auth_schemas.py @@ -2,7 +2,7 @@ import re from pydantic import BaseModel, EmailStr, field_validator - +from datetime import datetime class AuthBaseSchema(BaseModel): pass @@ -35,6 +35,12 @@ def password_strength(cls, value): raise ValueError("Password must contain at least one special character") return value +class SignUpResponse(BaseModel): + """회원가입 응답 모델""" + message: str + email: str + verification_status: str = "pending" + expires_at: datetime class SignInResponse(BaseModel): """ @@ -51,3 +57,24 @@ class SignInRequest(BaseModel): """ email: EmailStr password: str + +class TokenRefreshRequest(BaseModel): + """토큰 재발급 요청 모델""" + refresh_token: str + +class PasswordResetRequest(BaseModel): + """비밀번호 재설정 요청 모델""" + email: EmailStr + +class PasswordResetConfirm(BaseModel): + """새 비밀번호 등록 모델""" + newPassword: str + +class EmailCheckResponse(BaseModel): + """이메일 중복 확인 응답 모델""" + available: bool + message: str + +class VerificationStatusResponse(BaseModel): + """이메일 인증 여부 조회 응답 모델""" + is_verified: bool \ No newline at end of file From 9eb75a24d64e1a95a014a7d54096c96b2235b597 Mon Sep 17 00:00:00 2001 From: myoungjugo Date: Mon, 6 Apr 2026 02:24:53 +0900 Subject: [PATCH 14/19] =?UTF-8?q?fix/jandi=20=EC=8A=A4=ED=82=A4=EB=A7=88?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trend에서 응답 모델을 원하는데 request라고 써둔 것을 응답으로 알맞게 고침 --- main_server/app/schemas/jandi_schemas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main_server/app/schemas/jandi_schemas.py b/main_server/app/schemas/jandi_schemas.py index 1ae0dfd..d6e1df5 100644 --- a/main_server/app/schemas/jandi_schemas.py +++ b/main_server/app/schemas/jandi_schemas.py @@ -11,6 +11,6 @@ class GetJandiResponse(BaseModel): category: str count: int -class GetSignedUrlRequest(BaseModel): +class GetSignedUrlResponse(BaseModel): """서명된 URL 응답 모델""" url: str \ No newline at end of file From 3be13041552d3f1a89c138c49c79f7da714290f2 Mon Sep 17 00:00:00 2001 From: myoungjugo Date: Mon, 6 Apr 2026 02:26:46 +0900 Subject: [PATCH 15/19] =?UTF-8?q?feat/auth=20=EC=84=9C=EB=B9=84=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새로 추가된 엔드포인트에 맞는 서비스 함수들 작성 - password/reset/{user} api 명세서에서 응답으로 바뀐 새 비밀번호를 리턴하는 게 있었음 -> 바뀐 새 비밀번호를 평문으로 제시할 경우 패킷 공격에 취약하므로 내부적으로만 업데이트, 응답으로는 성공적으로 변경되었음만 알림 --- main_server/app/services/auth_service.py | 293 +++++++++++++++++------ 1 file changed, 214 insertions(+), 79 deletions(-) diff --git a/main_server/app/services/auth_service.py b/main_server/app/services/auth_service.py index ee00adc..beb3335 100644 --- a/main_server/app/services/auth_service.py +++ b/main_server/app/services/auth_service.py @@ -1,132 +1,267 @@ -from datetime import datetime, timedelta, timezone -import jwt import os +import jwt +from datetime import datetime, timedelta, timezone from fastapi import HTTPException -from app.dependencies.database import get_db -from app.models.user_models import User, AuthUser from passlib.context import CryptContext -from pydantic import BaseModel, EmailStr -from dotenv import load_dotenv -from app.schemas.auth_schemas import SignUpRequest, SignInRequest, SignInResponse -from app.services.email_service import send_verification_email +from app.schemas.auth_schemas import * +from app.services.email_service import send_verification_email, send_password_reset_email +from app.models.user_models import User, AuthUser +from fastapi.responses import HTMLResponse +from app.templates.email_templates import get_verification_html, get_verify_success_page_html, get_password_reset_html pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - -SECRET_KEY = os.getenv("JWT_SECRET") +SECRET_KEY = os.getenv("JWT_SECRET", "jandi_secret_key") ALGORITHM = "HS256" class AuthService: def __init__(self, repository): """ - 인증 관련 비즈니스 로직 서비스를 초기화. + 인증 관련 비즈니스 로직 서비스를 초기화합니다. - :param repository: DB 접근을 담당하는 저장소 객체 + :param repository: 데이터베이스 접근을 담당하는 저장소 객체 :type repository: AuthRepository """ self.repository = repository - async def register_new_user(self, data:SignUpRequest): + def _generate_token(self, payload: dict, expires_delta: timedelta) -> str: + """ + 내부적으로 사용하는 JWT 토큰 생성 유틸리티입니다. + :param payload: 토큰에 담을 데이터 내용 + :type payload: dict + :param expires_delta: 토큰의 유효 기간 + :type expires_delta: timedelta + :return: 인코딩된 JWT 토큰 문자열 + :rtype: str """ - 신규 유저 등록 및 이메일 인증 발송 프로세스를 처리. + to_encode = payload.copy() + expire = datetime.now(timezone.utc) + expires_delta + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) - :param data: 검증된 회원가입 요청 데이터 - :type data: SignUpRequest - :return: 성공 메시지 딕셔너리 + async def check_email_availability(self, email: str) -> dict: + """ + 회원가입 전 이메일의 중복 여부를 확인합니다. + + :param email: 중복 확인을 진행할 사용자의 이메일 + :type email: str + :return: 사용 가능 여부와 안내 메시지 :rtype: dict - :raises HTTPException: 이미 이메일이 존재할 경우 발생 """ - - #1. 중복검사 - '#TODO: /api/auth/check-email로 중복 검사 api 제작' - + user = self.repository.get_user_by_email(email) + if user: + return {"available": False, "message": "이미 존재하는 이메일입니다."} + return {"available": True, "message": "사용 가능한 이메일입니다."} + + async def register_user(self, data: SignUpRequest) -> SignUpResponse: + """ + 신규 유저를 등록하고 1시간 유효한 인증 메일을 발송합니다. + + :param data: 회원가입에 필요한 유저 정보 (email, password, name) + :type data: SignUpRequest + :return: 가입 정보 및 인증 만료 시간을 포함한 응답 객체 + :rtype: SignUpResponse + :raises HTTPException: 이미 가입된 이메일일 경우 409 Conflict 발생 + """ if self.repository.get_user_by_email(data.email): - raise HTTPException(status_code=400, detail="이미 존재하는 이메일입니다") - #2. User 생성 - new_user = self.repository.create_user(data.email, data.name) - - #3. 비밀번호 해시 및 토큰 생성 - hashed_pw = pwd_context.hash(data.password) - verify_token = jwt.encode( - {"sub" : str(new_user.user_id), "exp":datetime.now(datetime.timezone.utc) + timedelta(hours=1)}, - SECRET_KEY, algorithm = ALGORITHM - ) + raise HTTPException(status_code=409, detail="이미 가입된 이메일입니다.") + + user = self.repository.create_user(data.email, data.name) + expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + verify_token = self._generate_token({"sub": str(user.user_id), "type": "verify"}, timedelta(hours=1)) - #AuthUser 생성 및 저장 shadow = AuthUser( - user_id = new_user.user_id, - email = data.email, - hashed_password = hashed_pw, + user_id=user.user_id, + email=data.email, + hashed_password=pwd_context.hash(data.password), is_verified=False, verification_token=verify_token ) - self.repository.create_auth_user(shadow) - - #5. 이메일 발송 + self.repository.save_auth_user(shadow) await send_verification_email(data.email, verify_token) - return {"message" : "회원가입 완료! 이메일을 확인해주세요."} - - def verify_email(self, token:str): + return SignUpResponse(message="회원가입 성공", email=data.email, verification_status= "pending",expires_at=expires_at) + async def get_verification_status(self, email: str) -> dict: """ - 메일 인증 토큰의 유효성을 검증하고 계정을 활성화. + 특정 이메일의 인증 완료 여부를 조회합니다. - :param token: 이메일로 발송된 JWT 인증 토큰 - :type token: str - :return: 인증 완료 메시지 + :param email: 조회를 원하는 사용자의 이메일 + :type email: str + :return: 인증 여부 (True/False) :rtype: dict - :raises HTTPException: 토큰 만료 또는 유효하지 않을 때 발생 + :raises HTTPException: 존재하지 않는 유저일 경우 404 Not Found 발생 + """ + user = self.repository.get_user_by_email(email) + if not user: + raise HTTPException(status_code=404, detail="유저를 찾을 수 없습니다.") + shadow = self.repository.get_auth_user_by_id(user.user_id) + return {"is_verified": shadow.is_verified if shadow else False} + + async def resend_verification(self, email: str) -> dict: + """ + 인증 메일을 재전송합니다. 보안을 위해 유저 존재 여부와 관계없이 성공 응답을 반환합니다. + + :param email: 인증 메일을 다시 받을 사용자의 이메일 + :type email: str + :return: 재전송 성공 메시지 + :rtype: dict + """ + user = self.repository.get_user_by_email(email) + if user: + shadow = self.repository.get_auth_user_by_id(user.user_id) + if shadow and not shadow.is_verified: + token = self._generate_token({"sub": str(user.user_id), "type": "verify"}, timedelta(hours=1)) + shadow.verification_token = token + self.repository.commit() + await send_verification_email(email, token) + return {"message": "인증 메일이 재전송되었습니다."} + + async def verify_email_token(self, token: str)-> HTMLResponse: + """ + 이메일 인증 토큰을 검증하고 사용자의 인증 상태를 활성화한 뒤 성공 HTML 페이지를 반환합니다. + + :param token: 이메일 링크에 포함된 JWT 인증 토큰 + :type token: str + :return: 브라우저에서 렌더링될 HTML 페이지 + :rtype: HTMLResponse + :raises HTTPException: 잘못된 형식의 토큰일 경우 400 Bad Request 발생 + :raises HTTPException: 토큰 만료 또는 변조된 경우 401 Unauthorized 발생 + :raises HTTPException: 해당 유저 정보를 찾을 수 없을 경우 404 Not Found 발생 + :raises HTTPException: 이미 인증이 완료된 유저일 경우 409 Conflict 발생 """ - try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + if payload.get("type") != "verify": + raise HTTPException(status_code=400, detail="잘못된 용도의 토큰입니다.") user_id = payload["sub"] - except jwt.ExpiredSignatureError: - raise HTTPException(status_code = 400, detail="토큰이 만료되었습니다") - except jwt.InvalidTokenError: - raise HTTPException(status_code=400, detail="유효하지 않은 토큰입니다") + except jwt.PyJWTError: + raise HTTPException(status_code=401, detail="유효하지 않거나 만료된 토큰입니다.") shadow = self.repository.get_auth_user_by_id(user_id) - if not shadow or shadow.verification_token != token: - raise HTTPException(status_code=400, detail="인증 정보가 올바르지 않습니다") + if not shadow: raise HTTPException(status_code=404, detail="정보를 찾을 수 없습니다.") + + if shadow.is_verified: + raise HTTPException(status_code=409, detail="이미 인증이 완료된 계정입니다.") - shadow.is_verified=True + shadow.is_verified = True shadow.verification_token = None self.repository.commit() - self.repository.refresh_user_stat() - return{"message" : "이메일 인증 완료!"} - - def sign_in(self, data: SignInRequest): + html_content = get_verification_html() + return "이메일 인증이 완료되었습니다." + + async def login(self, data: SignInRequest) -> SignInResponse: """ - 사용자 자격 증명을 확인하고 서비스 이용 토큰을 발급. + 로그인을 처리하고 Access 및 Refresh 토큰을 발급합니다. 사유별 명확한 에러를 반환합니다. - :param data: 로그인 요청 데이터 (이메일, 비번) + :param data: 로그인 요청 정보 (email, password) :type data: SignInRequest - :return: 엑세스 토큰 정보 - :rtype: dict - :raises HTTPException: 인증 실패 또는 미인증 계정일 때 발생 + :return: Access 및 Refresh 토큰 객체 + :rtype: SignInResponse + :raises HTTPException: 이메일 미존재(400), 비밀번호 불일치(400), 미인증 계정(403) 발생 """ - user = self.repository.get_user_by_email(data.email) - '#TODO: CIA triad에서 사용자 존재 여부를 드러내는 것은 기밀성을 떨어트릴 수 있습니다. ' - '#TODO: 이메일 존재 여부와 비밀번호 일치 여부를 나타내는 대신 이메일과 비밀번호가 일치하지 않는다 정도로 보여주는 건 어떨까요? ' if not user: - raise HTTPException(status_code=400, detail="존재하지 않는 이메일입니다") + raise HTTPException(status_code=401, detail="존재하지 않는 이메일입니다.") + shadow = self.repository.get_auth_user_by_id(user.user_id) if not shadow or not pwd_context.verify(data.password, shadow.hashed_password): - raise HTTPException(status_code=400, detail="비밀번호가 일치") - - if user.email != shadow.email: - user.email = shadow.email - self.repository.commit() + raise HTTPException(status_code=401, detail="비밀번호가 일치하지 않습니다.") + + if not shadow.is_verified: + raise HTTPException(status_code=403, detail="이메일 인증이 필요합니다.") - token = jwt.encode( - {"sub": str(user.user_id), "exp":datetime.now(datetime.timezone.utc) + timedelta(days=7)}, - SECRET_KEY, algorithm=ALGORITHM - ) - return {"access_token": token} - + access = self._generate_token({"sub": str(user.user_id), "scope": "access"}, timedelta(hours=2)) + refresh = self._generate_token({"sub": str(user.user_id), "scope": "refresh"}, timedelta(days=7)) + return SignInResponse(access_token=access, refresh_token=refresh) + + async def refresh_access_token(self, refresh_token: str) -> dict: + """ + 유효한 Refresh 토큰을 사용하여 새로운 Access 토큰을 발급합니다. + + :param refresh_token: 사용자가 보유한 Refresh 토큰 + :type refresh_token: str + :return: 새로 발급된 Access 토큰 정보 + :rtype: dict + :raises HTTPException: 토큰이 유효하지 않거나 만료된 경우 401 Unauthorized 발생 + :raises HTTPException: 인증되지 않았거나 차단된 유저일 경우 403 Forbidden 발생 + """ + try: + payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM]) + if payload.get("scope") != "refresh": + raise ValueError("Invalid scope") + new_access = self._generate_token({"sub": payload["sub"], "scope": "access"}, timedelta(hours=2)) + return {"access_token": new_access, "refresh_token": refresh_token} + except: + raise HTTPException(status_code=401, detail="유효하지 않은 리프레시 토큰입니다.") + shadow = self.repository.get_auth_user_by_id(user_id) + if not shadow or not shadow.is_verified: + raise HTTPException( + status_code=403, + detail="권한이 없습니다. 다시 로그인하거나 이메일 인증을 완료해주세요." + ) + # 새로운 Access 토큰 생성 + new_access = self._generate_token( + {"sub": user_id, "scope": "access"}, + timedelta(hours=2) + ) + return {"access_token": new_access, "refresh_token": refresh_token} + + async def request_pw_reset(self, email: str) -> dict: + """ + 비밀번호 재설정을 위한 30분 유효 임시 토큰 링크를 메일로 발송합니다. + + :param email: 비밀번호를 재설정할 사용자의 이메일 + :type email: str + :return: 안내 메시지 + :rtype: dict + """ + user = self.repository.get_user_by_email(email) + if user: + token = self._generate_token({"sub": str(user.user_id), "type": "pw_reset"}, timedelta(minutes=30)) + await send_password_reset_email(email, token) + return {"message": "인증 메일이 전송되었습니다."} + + async def confirm_pw_reset(self, token: str, new_pw: str) -> dict: + """ + 임시 토큰을 확인하고 사용자의 비밀번호를 새로운 해시값으로 업데이트합니다. + + :param token: URL 경로에 포함된 30분 유효 임시 토큰 + :type token: str + :param new_pw: 새로 등록할 비밀번호 원문 + :type new_pw: str + :return: 성공 확인 메시지 + :rtype: dict + :raises HTTPException: 잘못된 토큰 형식일 경우 400 Bad Request 발생 + :raises HTTPException: 토큰 만료 또는 변조 시 401 Unauthorized 발생 + :raises HTTPException: 유저 정보를 찾을 수 없을 경우 404 Not Found 발생 + :raises HTTPException: 기존 비밀번호와 동일할 경우 409 Conflict 발생 + """ + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + if payload.get("type") != "pw_reset": + raise ValueError() + user_id = payload["sub"] + except jwt.PyJWTError: + raise HTTPException(status_code=401, detail="유효기간 만료 혹은 잘못된 토큰입니다.") + + shadow = self.repository.get_auth_user_by_id(user_id) + # [404 로직] 토큰의 유저는 존재하나 DB에 인증 정보(Shadow)가 없는 경우 + if not shadow: + raise HTTPException(status_code=404, detail="해당 유저의 인증 정보를 찾을 수 없습니다.") + + # [409 로직] 보안 정책: 기존 비밀번호와 새 비밀번호가 동일한지 체크 + if pwd_context.verify(new_pw, shadow.hashed_password): + raise HTTPException(status_code=409, detail="기존 비밀번호와 다른 비밀번호를 입력해주세요.") + + # 비밀번호 업데이트 + shadow.hashed_password = pwd_context.hash(new_pw) + + + self.repository.commit() + '#TODO: 바뀐 새 비밀번호를 평문으로 제시할 경우 패킷 공격에 취약하므로' + '#TODO: 내부적으로 업데이트, 응답으로는 성공적으로 변경되었음만 알림' + return {"newPassword": "비밀번호가 성공적으로 변경되었습니다."} \ No newline at end of file From b25f517735adbd75f1c25da54a8d99d00faa159f Mon Sep 17 00:00:00 2001 From: myoungjugo Date: Mon, 6 Apr 2026 02:28:03 +0900 Subject: [PATCH 16/19] =?UTF-8?q?feat/email=20=EC=84=9C=EB=B9=84=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth에서 인증 메일 보내는 함수 구현 - 기존 html부분을 템플릿으로 넘기고 이메일 인증/비밀번호 재설정 이렇게 기능별로 나눔 --- main_server/app/services/email_service.py | 61 ++++++++++++----------- 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/main_server/app/services/email_service.py b/main_server/app/services/email_service.py index 8081f33..c1ccc0d 100644 --- a/main_server/app/services/email_service.py +++ b/main_server/app/services/email_service.py @@ -6,6 +6,7 @@ from dotenv import load_dotenv from email.message import EmailMessage import aiosmtplib +from app.templates.email_templates import get_verification_html, get_password_reset_html, get_verify_success_page_html load_dotenv() @@ -17,40 +18,24 @@ FRONTEND_URL = "http://136.110.239.66" MAIL_FROM = os.getenv("MAIL_FROM", SMTP_USER) -async def send_verification_email(email: str, token: str) -> None: +async def _send_email(to_email: str, subject: str, html_body: str, plain_body: str) -> None: """ - 이메일 인증 메일 전송 (비동기) - - email: 수신자 이메일 - - token: 인증 토큰 (URL에 포함) + 공통 메일 발송 처리 함수 (내부 전용). + + :param to_email: 수신자 이메일 주소 + :param subject: 메일 제목 + :param html_body: HTML 형식의 메일 본문 + :param plain_body: 텍스트 형식의 메일 본문 + :raises RuntimeError: SMTP 설정이 누락되었을 경우 발생 + :raises Exception: 메일 발송 중 발생하는 네트워크 및 SMTP 오류 """ if not SMTP_USER or not SMTP_PASSWORD: raise RuntimeError("SMTP_USER 또는 SMTP_PASSWORD가 설정되지 않았습니다. .env를 확인하세요.") - verify_url = f"{FRONTEND_URL.rstrip('/')}/api/auth/verify-email?token={token}" - - html_body = f""" - - -

이메일 인증을 완료해주세요

-

아래 버튼을 클릭하면 인증이 완료됩니다:

-

- - 이메일 인증하기 - -

-
-

감사합니다.

- - - """ - - plain_body = f"이메일 인증을 완료하려면 다음 링크로 방문하세요: {verify_url}" - msg = EmailMessage() msg["From"] = MAIL_FROM - msg["To"] = email - msg["Subject"] = "이메일 인증 요청" + msg["To"] = to_email + msg["Subject"] = subject msg.set_content(plain_body) msg.add_alternative(html_body, subtype="html") @@ -63,5 +48,25 @@ async def send_verification_email(email: str, token: str) -> None: username=SMTP_USER, password=SMTP_PASSWORD, ) - except Exception as e: + except Exception: raise + +async def send_verification_email(email: str, token: str) -> None: + """이메일 인증 메일 발송 로직""" + verify_url = f"{FRONTEND_URL.rstrip('/')}/api/auth/verify-email?token={token}" + + subject = "[잔디] 이메일 인증 요청" + html_body = get_verification_html(verify_url) # 템플릿 호출 + plain_body = f"이메일 인증 링크: {verify_url}" + + await _send_email(email, subject, html_body, plain_body) + +async def send_password_reset_email(email: str, token: str) -> None: + """비밀번호 재설정 메일 발송 로직""" + reset_url = f"{FRONTEND_URL.rstrip('/')}/password-reset?token={token}" + + subject = "[잔디] 비밀번호 재설정 안내" + html_body = get_password_reset_html(reset_url) # 템플릿 호출 + plain_body = f"비밀번호 재설정 링크: {reset_url}" + + await _send_email(email, subject, html_body, plain_body) From eda1b8630fd45c931681f3d0554d2c8b123321e7 Mon Sep 17 00:00:00 2001 From: myoungjugo Date: Mon, 6 Apr 2026 02:28:45 +0900 Subject: [PATCH 17/19] =?UTF-8?q?feat/email=20html=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이메일로 유저에게 보여지는 임의 html 페이지를 email_templates에 모아둠 --- main_server/app/templates/email_templates.py | 54 ++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 main_server/app/templates/email_templates.py diff --git a/main_server/app/templates/email_templates.py b/main_server/app/templates/email_templates.py new file mode 100644 index 0000000..a300605 --- /dev/null +++ b/main_server/app/templates/email_templates.py @@ -0,0 +1,54 @@ +# app/templates/email_templates.py +# 사용자에게 보여줄 임시 html 템플릿 + +def get_verification_html(verify_url: str = "") -> str: + """회원가입 인증 메일 HTML 템플릿""" + return f""" + + +
+

이메일 인증을 완료해주세요

+

잔디 서비스 가입을 환영합니다! 아래 버튼을 클릭하여 인증을 완료해 주세요.

+ +

본 링크는 1시간 동안만 유효합니다.

+
+ + + """ + +def get_verify_success_page_html() -> str: + """인증 완료 후 브라우저에 보여줄 성공 페이지""" + return """ + + +

✅ 인증이 완료되었습니다!

+

이제 잔디 서비스를 정상적으로 이용하실 수 있습니다.

+

이 창을 닫고 로그인을 진행해주세요.

+ + + """ + +def get_password_reset_html(reset_url: str) -> str: + """비밀번호 재설정 메일 HTML 템플릿""" + return f""" + + +
+

비밀번호 재설정 요청

+

계정의 비밀번호를 재설정하려면 아래 버튼을 클릭하세요.

+ +

본 링크는 30분 동안만 유효합니다.

+
+ + + """ \ No newline at end of file From 3b3a76ac0bfa7335e933fab45174963f3397a33c Mon Sep 17 00:00:00 2001 From: myoungjugo Date: Thu, 9 Apr 2026 13:52:26 +0900 Subject: [PATCH 18/19] =?UTF-8?q?fix=20:=20=EC=BD=94=EB=93=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=9B=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TokenRefreshResponse 응답모델 추가, refresh_token과 새 access_token까지 리턴하도록 수정 - 인증메일 클릭 시 html 페이지 안보이던것 해결, 인증 완료 시 html 페이지도 임의로 email_template에 만듦 - 이메일 보내는 함수에 프론트엔드 주소가 정해지지 않아 404가 뜨던 문제를, 주소가 정해질 때까지 임시로 localhost:8000으로 설정 - 현재 회원가입, 로그인, 토큰 관리 로직은 테스트 완료했고 비밀번호 재설정 로직 테스트 진행중 --- main_server/app/schemas/auth_schemas.py | 8 +++++ main_server/app/services/auth_service.py | 42 ++++++++++++++--------- main_server/app/services/email_service.py | 8 +++-- 3 files changed, 38 insertions(+), 20 deletions(-) diff --git a/main_server/app/schemas/auth_schemas.py b/main_server/app/schemas/auth_schemas.py index 5eb64ae..55c00a8 100644 --- a/main_server/app/schemas/auth_schemas.py +++ b/main_server/app/schemas/auth_schemas.py @@ -47,6 +47,7 @@ class SignInResponse(BaseModel): 로그인 응답 모델 """ access_token: str + refresh_token: str @@ -62,6 +63,13 @@ class TokenRefreshRequest(BaseModel): """토큰 재발급 요청 모델""" refresh_token: str +class TokenRefreshResponse(BaseModel): + """토큰 재발급 응답 모델""" + '#TODO : 액세스 토큰까지 줘야 되는데 api명세서에 리프레시 토큰만 명시했습니다. 수정하겠습니다.' + refresh_token: str + access_token : str + + class PasswordResetRequest(BaseModel): """비밀번호 재설정 요청 모델""" email: EmailStr diff --git a/main_server/app/services/auth_service.py b/main_server/app/services/auth_service.py index beb3335..1883c92 100644 --- a/main_server/app/services/auth_service.py +++ b/main_server/app/services/auth_service.py @@ -77,7 +77,7 @@ async def register_user(self, data: SignUpRequest) -> SignUpResponse: is_verified=False, verification_token=verify_token ) - self.repository.save_auth_user(shadow) + self.repository.create_auth_user(shadow) await send_verification_email(data.email, verify_token) return SignUpResponse(message="회원가입 성공", email=data.email, verification_status= "pending",expires_at=expires_at) @@ -147,8 +147,8 @@ async def verify_email_token(self, token: str)-> HTMLResponse: shadow.verification_token = None self.repository.commit() - html_content = get_verification_html() - return "이메일 인증이 완료되었습니다." + html_content = get_verify_success_page_html() + return HTMLResponse(content=html_content, status_code=200) async def login(self, data: SignInRequest) -> SignInResponse: """ @@ -175,38 +175,46 @@ async def login(self, data: SignInRequest) -> SignInResponse: refresh = self._generate_token({"sub": str(user.user_id), "scope": "refresh"}, timedelta(days=7)) return SignInResponse(access_token=access, refresh_token=refresh) - async def refresh_access_token(self, refresh_token: str) -> dict: + async def refresh_access_token(self, refresh_token: str) -> TokenRefreshResponse: """ 유효한 Refresh 토큰을 사용하여 새로운 Access 토큰을 발급합니다. :param refresh_token: 사용자가 보유한 Refresh 토큰 :type refresh_token: str :return: 새로 발급된 Access 토큰 정보 - :rtype: dict + :rtype: TokenRefreshResponse응답모델 :raises HTTPException: 토큰이 유효하지 않거나 만료된 경우 401 Unauthorized 발생 :raises HTTPException: 인증되지 않았거나 차단된 유저일 경우 403 Forbidden 발생 """ try: + # 1. 토큰 디코딩 payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM]) - if payload.get("scope") != "refresh": - raise ValueError("Invalid scope") - new_access = self._generate_token({"sub": payload["sub"], "scope": "access"}, timedelta(hours=2)) - return {"access_token": new_access, "refresh_token": refresh_token} - except: - raise HTTPException(status_code=401, detail="유효하지 않은 리프레시 토큰입니다.") + # 2. Signin에서 "scope": "refresh"로 넣었는지 확인하세요! + if payload.get("scope") != "refresh": + raise HTTPException(status_code=401, detail="리프레시 토큰이 아닙니다.") + + user_id = payload.get("sub") + if not user_id: + raise HTTPException(status_code=401, detail="토큰 페이로드가 유효하지 않습니다.") + + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="토큰이 만료되었습니다. 다시 로그인하세요.") + except jwt.PyJWTError: + raise HTTPException(status_code=401, detail="유효하지 않은 토큰입니다.") + + # 3. 유저 상태 확인 (DB 조회) shadow = self.repository.get_auth_user_by_id(user_id) if not shadow or not shadow.is_verified: - raise HTTPException( - status_code=403, - detail="권한이 없습니다. 다시 로그인하거나 이메일 인증을 완료해주세요." - ) - # 새로운 Access 토큰 생성 + raise HTTPException(status_code=403, detail="인증되지 않은 유저이거나 존재하지 않는 유저입니다.") + + #4. 새로운 Access 토큰만 생성해서 반환 new_access = self._generate_token( {"sub": user_id, "scope": "access"}, timedelta(hours=2) ) - return {"access_token": new_access, "refresh_token": refresh_token} + + return TokenRefreshResponse(refresh_token=refresh_token, access_token=new_access) async def request_pw_reset(self, email: str) -> dict: """ diff --git a/main_server/app/services/email_service.py b/main_server/app/services/email_service.py index c1ccc0d..557be51 100644 --- a/main_server/app/services/email_service.py +++ b/main_server/app/services/email_service.py @@ -14,8 +14,10 @@ SMTP_SERVER = os.getenv("SMTP_SERVER", "smtp.gmail.com") SMTP_PORT = int(os.getenv("SMTP_PORT", 587)) SMTP_USER = os.getenv("SMTP_USER") -SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") -FRONTEND_URL = "http://136.110.239.66" +SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") + +"#TODO: 프론트엔드 주소 정해질때까지 로컬테스트용" +FRONTEND_URL = "http://localhost:8000" MAIL_FROM = os.getenv("MAIL_FROM", SMTP_USER) async def _send_email(to_email: str, subject: str, html_body: str, plain_body: str) -> None: @@ -63,7 +65,7 @@ async def send_verification_email(email: str, token: str) -> None: async def send_password_reset_email(email: str, token: str) -> None: """비밀번호 재설정 메일 발송 로직""" - reset_url = f"{FRONTEND_URL.rstrip('/')}/password-reset?token={token}" + reset_url = f"{FRONTEND_URL.rstrip('/')}/api/auth/password/reset/{token}" subject = "[잔디] 비밀번호 재설정 안내" html_body = get_password_reset_html(reset_url) # 템플릿 호출 From 790a5f625073214e3eeed54d558cb64389a4646c Mon Sep 17 00:00:00 2001 From: myoungjugo Date: Thu, 9 Apr 2026 18:01:21 +0900 Subject: [PATCH 19/19] =?UTF-8?q?fix=20:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C,=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *테스트 중 명세서에 잘못 기입한 부분이 있어 수정하였습니다!* *비밀번호 재설정 로직 테스트까지 완료하였습니다. 테스트 요청은 hoppscotch에 올려뒀습니다!* - GET password/reset/{user}는 프론트에서 개발, 즉 POST password/reset/request 시 이메일로 간 링크를 프론트로 보내줘야 하기 때문에 localhost:8000 -> localhost:3000(임시)로 바꿈 - 비밀번호 검증(8자이상, 대소문자, 숫자, 특수문자 포함) 로직이 회원가입, 비밀번호 재설정 두 가지 경우 모두 쓰이기 때문에 따로 validate_password 함수로 빼서 auth_schemas 상단에 정의함. --- main_server/app/schemas/auth_schemas.py | 37 +++++++++++++++-------- main_server/app/services/email_service.py | 4 +-- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/main_server/app/schemas/auth_schemas.py b/main_server/app/schemas/auth_schemas.py index 55c00a8..8ac7ced 100644 --- a/main_server/app/schemas/auth_schemas.py +++ b/main_server/app/schemas/auth_schemas.py @@ -4,6 +4,24 @@ from pydantic import BaseModel, EmailStr, field_validator from datetime import datetime +# 비밀번호 공통 검증 함수 +def validate_password(value: str) -> str: + """ + 비밀번호 유효성 검사 공통 함수: + 8자 이상, 대문자, 소문자, 숫자, 특수문자 포함 여부 확인 + """ + if len(value) < 8: + raise ValueError("Password must be at least 8 characters long") + if not re.search(r"[A-Z]", value): + raise ValueError("Password must contain at least one uppercase letter") + if not re.search(r"[a-z]", value): + raise ValueError("Password must contain at least one lowercase letter") + if not re.search(r"\d", value): + raise ValueError("Password must contain at least one digit") + if not re.search(r"[!@#$%^&*(),.?\":{}|<>]", value): + raise ValueError("Password must contain at least one special character") + return value + class AuthBaseSchema(BaseModel): pass @@ -22,18 +40,7 @@ class SignUpRequest(BaseModel): @field_validator("password") @classmethod def password_strength(cls, value): - # Minimum 8 characters, at least one uppercase, one lowercase, one digit, one special character - if len(value) < 8: - raise ValueError("Password must be at least 8 characters long") - if not re.search(r"[A-Z]", value): - raise ValueError("Password must contain at least one uppercase letter") - if not re.search(r"[a-z]", value): - raise ValueError("Password must contain at least one lowercase letter") - if not re.search(r"\d", value): - raise ValueError("Password must contain at least one digit") - if not re.search(r"[!@#$%^&*(),.?\":{}|<>]", value): - raise ValueError("Password must contain at least one special character") - return value + return validate_password(value) #공통 함수 호출 class SignUpResponse(BaseModel): """회원가입 응답 모델""" @@ -78,6 +85,12 @@ class PasswordResetConfirm(BaseModel): """새 비밀번호 등록 모델""" newPassword: str + @field_validator("newPassword") + @classmethod + def password_strength(cls, value): + # 공통 함수 호출 + return validate_password(value) + class EmailCheckResponse(BaseModel): """이메일 중복 확인 응답 모델""" available: bool diff --git a/main_server/app/services/email_service.py b/main_server/app/services/email_service.py index 557be51..684fac8 100644 --- a/main_server/app/services/email_service.py +++ b/main_server/app/services/email_service.py @@ -16,8 +16,8 @@ SMTP_USER = os.getenv("SMTP_USER") SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") -"#TODO: 프론트엔드 주소 정해질때까지 로컬테스트용" -FRONTEND_URL = "http://localhost:8000" +"#TODO: 임시 프론트엔드 주소" +FRONTEND_URL = "http://localhost:3000" MAIL_FROM = os.getenv("MAIL_FROM", SMTP_USER) async def _send_email(to_email: str, subject: str, html_body: str, plain_body: str) -> None: