diff --git a/.gitignore b/.gitignore index 8eb9e91..56cd8f8 100644 --- a/.gitignore +++ b/.gitignore @@ -167,3 +167,24 @@ calm-scarab-478705-c7-b25fdd5670c3.json **/values.yaml AGENTS.md + +# --- 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 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() + + 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() diff --git a/main_server/app/routers/auth_router.py b/main_server/app/routers/auth_router.py index 298660c..fe425ea 100644 --- a/main_server/app/routers/auth_router.py +++ b/main_server/app/routers/auth_router.py @@ -1,159 +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 - -load_dotenv() +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"]) -# 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 +@router.get("/check-email", response_model=EmailCheckResponse) +async def check_email(email: EmailStr = Query(...), db: Session = Depends(get_db)): + """ + 이메일 중복 검사를 수행합니다. -# JWT 생성 + :param email: 사용자가 입력한 이메일 주소 + :type email: EmailStr + :param db: 데이터베이스 세션 (Depends 주입) + :type db: Session + :return: 사용 가능 여부 결과 + """ + return await AuthService(AuthRepository(db)).check_email_availability(email) -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") +@router.post("/signup", status_code=201, response_model=SignUpResponse) 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 - -@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() - - if not shadow: - raise HTTPException(status_code=400, detail="인증 정보가 없습니다") - - 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": "이메일 인증 완료!"} - - -# 로그인 - -@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} - + """ + 회원가입을 진행하고 인증 메일을 발송합니다. + + :param data: 이메일, 비밀번호, 이름이 포함된 요청 바디 + :type data: SignUpRequest + :param db: 데이터베이스 세션 + :type db: Session + :return: 가입 처리 상세 정보 + """ + return await AuthService(AuthRepository(db)).register_user(data) + +@router.get("/is-verified", response_model=VerificationStatusResponse) +async def is_verified(email: EmailStr = Query(...), 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)): + """ + 미인증 사용자의 이메일로 인증 메일을 재전송합니다. + + :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: 데이터베이스 세션 + :type db: Session + :return: 인증 성공 시 html 페이지 반환 + :rtype: HTMLResponse + """ + service = AuthService(AuthRepository(db)) + return await service.verify_email_token(token) + +@router.post("/signin", response_model=SignInResponse) +async 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 토큰을 갱신합니다. + + :param data: Refresh 토큰이 담긴 요청 바디 + :type data: TokenRefreshRequest + :param db: 데이터베이스 세션 + :type db: Session + :return: 새로운 토큰 정보 + """ + return await AuthService(AuthRepository(db)).refresh_access_token(data.refresh_token) + +@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: 발송 결과 메시지 + """ + 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)): + """ + 임시 토큰을 사용하여 새로운 비밀번호를 등록합니다. + + :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 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 diff --git a/main_server/app/schemas/auth_schemas.py b/main_server/app/schemas/auth_schemas.py index 4188c61..8ac7ced 100644 --- a/main_server/app/schemas/auth_schemas.py +++ b/main_server/app/schemas/auth_schemas.py @@ -1,35 +1,101 @@ +# Pydantic Models : 값을 쿼리가 아닌 json으로 넘겨주기 위해 + import re -from pydantic import BaseModel, EmailStr, validator +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 -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: - 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): + """회원가입 응답 모델""" + message: str + email: str + verification_status: str = "pending" + expires_at: datetime class SignInResponse(BaseModel): + """ + 로그인 응답 모델 + """ access_token: str + refresh_token: str + + + + +class SignInRequest(BaseModel): + """ + 로그인 요청 모델 + """ + email: EmailStr + password: str + +class TokenRefreshRequest(BaseModel): + """토큰 재발급 요청 모델""" + refresh_token: str + +class TokenRefreshResponse(BaseModel): + """토큰 재발급 응답 모델""" + '#TODO : 액세스 토큰까지 줘야 되는데 api명세서에 리프레시 토큰만 명시했습니다. 수정하겠습니다.' + refresh_token: str + access_token : str + + +class PasswordResetRequest(BaseModel): + """비밀번호 재설정 요청 모델""" + email: EmailStr + +class PasswordResetConfirm(BaseModel): + """새 비밀번호 등록 모델""" + newPassword: str + + @field_validator("newPassword") + @classmethod + def password_strength(cls, value): + # 공통 함수 호출 + return validate_password(value) + +class EmailCheckResponse(BaseModel): + """이메일 중복 확인 응답 모델""" + available: bool + message: str + +class VerificationStatusResponse(BaseModel): + """이메일 인증 여부 조회 응답 모델""" + is_verified: bool \ No newline at end of file diff --git a/main_server/app/schemas/jandi_schemas.py b/main_server/app/schemas/jandi_schemas.py index ce50a77..d6e1df5 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 GetSignedUrlResponse(BaseModel): + """서명된 URL 응답 모델""" + url: str \ No newline at end of file diff --git a/main_server/app/services/auth_service.py b/main_server/app/services/auth_service.py index f118674..1883c92 100644 --- a/main_server/app/services/auth_service.py +++ b/main_server/app/services/auth_service.py @@ -1,3 +1,275 @@ +import os +import jwt +from datetime import datetime, timedelta, timezone +from fastapi import HTTPException +from passlib.context import CryptContext +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", "jandi_secret_key") +ALGORITHM = "HS256" + class AuthService: def __init__(self, repository): + """ + 인증 관련 비즈니스 로직 서비스를 초기화합니다. + + :param repository: 데이터베이스 접근을 담당하는 저장소 객체 + :type repository: AuthRepository + """ self.repository = repository + + 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) + + async def check_email_availability(self, email: str) -> dict: + """ + 회원가입 전 이메일의 중복 여부를 확인합니다. + + :param email: 중복 확인을 진행할 사용자의 이메일 + :type email: str + :return: 사용 가능 여부와 안내 메시지 + :rtype: dict + """ + 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=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)) + + shadow = AuthUser( + 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) + await send_verification_email(data.email, verify_token) + return SignUpResponse(message="회원가입 성공", email=data.email, verification_status= "pending",expires_at=expires_at) + + async def get_verification_status(self, email: str) -> dict: + """ + 특정 이메일의 인증 완료 여부를 조회합니다. + + :param email: 조회를 원하는 사용자의 이메일 + :type email: str + :return: 인증 여부 (True/False) + :rtype: dict + :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.PyJWTError: + raise HTTPException(status_code=401, detail="유효하지 않거나 만료된 토큰입니다.") + + shadow = self.repository.get_auth_user_by_id(user_id) + if not shadow: raise HTTPException(status_code=404, detail="정보를 찾을 수 없습니다.") + + if shadow.is_verified: + raise HTTPException(status_code=409, detail="이미 인증이 완료된 계정입니다.") + + shadow.is_verified = True + shadow.verification_token = None + self.repository.commit() + + html_content = get_verify_success_page_html() + return HTMLResponse(content=html_content, status_code=200) + + async def login(self, data: SignInRequest) -> SignInResponse: + """ + 로그인을 처리하고 Access 및 Refresh 토큰을 발급합니다. 사유별 명확한 에러를 반환합니다. + + :param data: 로그인 요청 정보 (email, password) + :type data: SignInRequest + :return: Access 및 Refresh 토큰 객체 + :rtype: SignInResponse + :raises HTTPException: 이메일 미존재(400), 비밀번호 불일치(400), 미인증 계정(403) 발생 + """ + user = self.repository.get_user_by_email(data.email) + if not user: + 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=401, detail="비밀번호가 일치하지 않습니다.") + + if not shadow.is_verified: + raise HTTPException(status_code=403, detail="이메일 인증이 필요합니다.") + + 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) -> TokenRefreshResponse: + """ + 유효한 Refresh 토큰을 사용하여 새로운 Access 토큰을 발급합니다. + + :param refresh_token: 사용자가 보유한 Refresh 토큰 + :type refresh_token: str + :return: 새로 발급된 Access 토큰 정보 + :rtype: TokenRefreshResponse응답모델 + :raises HTTPException: 토큰이 유효하지 않거나 만료된 경우 401 Unauthorized 발생 + :raises HTTPException: 인증되지 않았거나 차단된 유저일 경우 403 Forbidden 발생 + """ + try: + # 1. 토큰 디코딩 + payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM]) + + # 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="인증되지 않은 유저이거나 존재하지 않는 유저입니다.") + + #4. 새로운 Access 토큰만 생성해서 반환 + new_access = self._generate_token( + {"sub": user_id, "scope": "access"}, + timedelta(hours=2) + ) + + return TokenRefreshResponse(refresh_token=refresh_token, access_token=new_access) + + 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 diff --git a/main_server/app/services/email_service.py b/main_server/app/services/email_service.py index 8081f33..684fac8 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() @@ -13,44 +14,30 @@ 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:3000" 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 +50,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('/')}/api/auth/password/reset/{token}" + + subject = "[잔디] 비밀번호 재설정 안내" + html_body = get_password_reset_html(reset_url) # 템플릿 호출 + plain_body = f"비밀번호 재설정 링크: {reset_url}" + + await _send_email(email, subject, html_body, plain_body) 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 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