팀플링에 오신 것을 환영합니다!
+팀 프로젝트를 더 쉽고 효율적으로 관리하세요.
+diff --git a/alembic/versions/19316f07bc9b_add_role_to_memeber.py b/alembic/versions/19316f07bc9b_add_role_to_memeber.py new file mode 100644 index 0000000..bde1387 --- /dev/null +++ b/alembic/versions/19316f07bc9b_add_role_to_memeber.py @@ -0,0 +1,32 @@ +"""Add role to memeber + +Revision ID: 19316f07bc9b +Revises: a2d5453e6a58 +Create Date: 2026-04-13 02:13:51.281738 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '19316f07bc9b' +down_revision: Union[str, Sequence[str], None] = 'a2d5453e6a58' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('members', sa.Column('role', sa.Enum('user', 'admin', name='memberrole'), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('members', 'role') + # ### end Alembic commands ### diff --git a/app/core/config.py b/app/core/config.py index 4b4d587..8865dd9 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -51,6 +51,19 @@ def LOCAL_DATABASE_URL(self) -> str: # LOGGING LOG_LEVEL: str = "INFO" + # SMTP Settings + SMTP_HOST: str = "smtp.gmail.com" + SMTP_PORT: int = 587 + SMTP_USER: str = "" + SMTP_PASSWORD: str = "" + EMAIL_FROM: str = "" + + # REDIS Settings + REDIS_HOST: str = "localhost" + REDIS_PORT: int = 6379 + REDIS_PASSWORD: str | None = None + REDIS_DB: int = 0 + # Oracle Cloud Storage OCI_USER_OCID: str = "" OCI_API_KEY_PATH: str = "" diff --git a/app/core/exceptions.py b/app/core/exceptions.py index 9370c49..aa96d7c 100644 --- a/app/core/exceptions.py +++ b/app/core/exceptions.py @@ -21,4 +21,8 @@ def forbidden(message="Forbidden"): @staticmethod def not_found(entity="resourse"): - return AppError(status.HTTP_404_NOT_FOUND, "NOT_FOUND", f"{entity}을(를) 찾을 수 없습니다.") \ No newline at end of file + return AppError(status.HTTP_404_NOT_FOUND, "NOT_FOUND", f"{entity}을(를) 찾을 수 없습니다.") + + @staticmethod + def internal_server_error(message="Internal Server Error"): + return AppError(status.HTTP_500_INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", message) \ No newline at end of file diff --git a/app/core/redis.py b/app/core/redis.py new file mode 100644 index 0000000..70cd1c2 --- /dev/null +++ b/app/core/redis.py @@ -0,0 +1,17 @@ +import redis.asyncio as redis +from app.core.config import settings + +# Redis 클라이언트 설정 +redis_client = redis.Redis( + host=settings.REDIS_HOST, + port=settings.REDIS_PORT, + password=settings.REDIS_PASSWORD, + db=settings.REDIS_DB, + decode_responses=True # 결과를 문자열로 받기 위해 +) + +async def get_redis(): + """ + FastAPI 의존성 주입을 위한 redis 클라이언트 반환 함수 + """ + return redis_client diff --git a/app/main.py b/app/main.py index b962bdf..e581bcb 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,8 @@ from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field, ConfigDict from starlette.middleware.cors import CORSMiddleware -from starlette.responses import JSONResponse +from starlette.responses import JSONResponse, FileResponse from app.core.config import settings from app.core.exception_handler import register_exception_handlers @@ -43,6 +44,17 @@ def create_app() -> FastAPI: app.include_router(skill_router) app.include_router(member_router) + # Static Files + app.mount("/static", StaticFiles(directory="app/static"), name="static") + + @app.get("/") + async def read_index(): + return FileResponse("app/static/index.html") + + @app.get("/{page}.html") + async def read_html(page: str): + return FileResponse(f"app/static/{page}.html") + # Middleware app.add_middleware(RequestIdMiddleware) diff --git a/app/modules/member/dependencies.py b/app/modules/member/dependencies.py index 8910657..fc82349 100644 --- a/app/modules/member/dependencies.py +++ b/app/modules/member/dependencies.py @@ -10,6 +10,7 @@ from app.modules.member.models import Member from app.modules.member.repository import MemberRepository from app.modules.member.service import MemberService +from app.shared.enums import MemberRole #전체 흐름 @@ -53,4 +54,16 @@ async def get_current_member( except Exception as e: raise AppError.unauthorized(f"인증 과정에서 오류가 발생했습니다.: {str(e)}") -CurrentMemberDep = Annotated[Member, Depends(get_current_member)] \ No newline at end of file +CurrentMemberDep = Annotated[Member, Depends(get_current_member)] + +async def get_current_admin( + current_member: CurrentMemberDep, +) -> Member: + """ + 현재 로그인한 사용자가 관리자인지 확인하는 의존성 주입 함수 + """ + if current_member.role != MemberRole.ADMIN: + raise AppError.forbidden("관리자 권한이 필요합니다.") + return current_member + +AdminMemberDep = Annotated[Member, Depends(get_current_admin)] \ No newline at end of file diff --git a/app/modules/member/models.py b/app/modules/member/models.py index 874f08d..ff6a3cd 100644 --- a/app/modules/member/models.py +++ b/app/modules/member/models.py @@ -2,9 +2,11 @@ from typing import TYPE_CHECKING from uuid import UUID, uuid4 +from sqlalchemy import Enum, Column from sqlmodel import Field, Relationship from app.shared.models.base import BaseModel +from app.shared.enums import MemberRole if TYPE_CHECKING: from app.modules.resource.models import Resource @@ -24,6 +26,18 @@ class Member(BaseModel, table=True): description="회원 고유키" ) + role: MemberRole = Field( + default=MemberRole.USER, + sa_column=Column( + Enum( + MemberRole, + name="memberrole", + values_callable=lambda x: [e.value for e in x], + ), + nullable=False, + ) + ) + provider: str = Field( nullable=False, description="OAuth2 제공자" diff --git a/app/modules/member/repository.py b/app/modules/member/repository.py index 6da504a..1e3f962 100644 --- a/app/modules/member/repository.py +++ b/app/modules/member/repository.py @@ -142,6 +142,3 @@ async def hard_delete(self, member: Member) -> None: #지금까지 변경사항 DB에 반영해줘->진짜 삭제 await self.session.flush() #refresh 대상 없음, None이기에 return도 X - - - diff --git a/app/modules/member/router.py b/app/modules/member/router.py index 7243397..e10512b 100644 --- a/app/modules/member/router.py +++ b/app/modules/member/router.py @@ -5,8 +5,12 @@ from fastapi import APIRouter, Path, Query, Depends, status from fastapi.security import OAuth2PasswordRequestForm -from app.modules.member.dependencies import CurrentMemberDep, MemberServiceDep -from app.modules.member.schemas import MemberCreateIn, MemberOut, MemberUpdateIn, TokenOut, RefreshTokenIn +from app.modules.member.dependencies import CurrentMemberDep, MemberServiceDep, AdminMemberDep +from app.modules.member.schemas import ( + MemberCreateIn, MemberOut, MemberUpdateIn, TokenOut, RefreshTokenIn, + PasswordResetRequestIn, PasswordResetConfirmIn, + SignupVerifyRequestIn, SignupVerifyConfirmIn, MemberRoleUpdateIn +) from app.shared.schemas import ApiResponse, PageOut @@ -24,6 +28,7 @@ ) async def list_members( service: MemberServiceDep, + admin: AdminMemberDep, keyword: Annotated[str | None, Query(description="검색 키워드", example="수진")] = None, #ge: 이상, le: 이하 page: Annotated[int,Query(ge=1, description="페이지 번호")] = 1, @@ -53,6 +58,21 @@ async def list_members( ), ) +@router.get( + path="/me", + response_model=ApiResponse[MemberOut], + summary="내 정보 조회", + description="현재 로그인한 회원의 정보를 조회합니다." +) +async def get_my_info( + current_member: CurrentMemberDep, +): + return ApiResponse.success( + code="MEMBER_FETCHED", + message="내 정보 조회 성공", + data=MemberOut.model_validate(current_member) + ) + #path: 경로, response_model: Swagger에서 보여줄 응답 예시 형태 #summary: Swagger에서 보여줄 간단한 API 설명 #description: Swagger에서 보여줄 상세한 API 설명 @@ -80,7 +100,7 @@ async def get_member( ) @router.post( - path="", + path="/signup", response_model=ApiResponse[MemberOut], status_code=status.HTTP_201_CREATED, summary="회원 생성", @@ -118,7 +138,7 @@ async def update_member( #DB 수정 → 수정된 객체 받음 updated = await service.update( target_member_id=member_id, - actor_member_id=current_member.id, + actor=current_member, data=data ) return ApiResponse.success( @@ -130,6 +150,26 @@ async def update_member( data=MemberOut.model_validate(updated), #응답용 데이터로 변환 (필터링 + 검증) ) + +@router.patch( + path="/{member_id}/role", + response_model=ApiResponse[MemberOut], + summary="회원 권한 수정", + description="관리자가 특정 회원의 권한을 수정합니다." +) +async def update_member_role( + service: MemberServiceDep, + admin: AdminMemberDep, + member_id: Annotated[UUID, Path(description="권한을 수정할 member ID")], + data: MemberRoleUpdateIn, +): + updated = await service.update_role(member_id=member_id, role=data.role) + return ApiResponse.success( + code="MEMBER_ROLE_UPDATED", + message="회원 권한 수정 성공", + data=MemberOut.model_validate(updated) + ) + #model_dump()와 model_validated() 차이점 #model_dump(): Pydantic → dict(Json 형태) #언제 할까?: DB에 넣을 때, PATCH 할 때 (exclude_unset=True), JSON 응답 만들 때 @@ -152,7 +192,7 @@ async def delete_member( #router는 요청 받기만 하고, Service가 진짜 일함 await service.delete( target_member_id=member_id, - actor_member_id=current_member.id, + actor=current_member, hard=hard ) return ApiResponse.success( @@ -210,4 +250,83 @@ async def reissue_refresh_token( data: RefreshTokenIn, ): tokens = await service.reissue(data.refresh_token) - return TokenOut(**tokens) \ No newline at end of file + return TokenOut(**tokens) + +# 회원가입 이메일 인증 요청 (인증 코드 발송) +@router.post( + path="/signup/verify/request", + response_model=ApiResponse[None], + summary="회원가입 이메일 인증 요청", + description="회원가입 전 이메일 중복 확인 및 인증 코드를 발송합니다." +) +async def request_signup_verification( + service: MemberServiceDep, + data: SignupVerifyRequestIn, +): + await service.request_signup_verification(data.email) + return ApiResponse.success( + code="SIGNUP_VERIFY_CODE_SENT", + message="인증 코드가 이메일로 발송되었습니다.", + data=None + ) + +# 회원가입 이메일 인증 확인 (코드 검증) +@router.post( + path="/signup/verify/confirm", + response_model=ApiResponse[None], + summary="회원가입 이메일 인증 확인", + description="인증 코드를 검증하고 회원가입 가능한 상태로 표시합니다." +) +async def confirm_signup_verification( + service: MemberServiceDep, + data: SignupVerifyConfirmIn, +): + await service.confirm_signup_verification( + email=data.email, + code=data.code + ) + return ApiResponse.success( + code="SIGNUP_VERIFY_SUCCESS", + message="이메일 인증이 완료되었습니다.", + data=None + ) + +# 비밀번호 재설정 요청 (인증 코드 이메일 발송) +@router.post( + path="/password/reset/request", + response_model=ApiResponse[None], + summary="비밀번호 재설정 요청", + description="이메일로 6자리 인증 코드를 발송합니다." +) +async def request_password_reset( + service: MemberServiceDep, + data: PasswordResetRequestIn, +): + await service.request_password_reset(data.email) + return ApiResponse.success( + code="PASSWORD_RESET_CODE_SENT", + message="인증 코드가 이메일로 발송되었습니다.", + data=None + ) + +# 비밀번호 재설정 확정 (코드 검증 + 새 비밀번호 설정) +@router.post( + path="/password/reset/confirm", + response_model=ApiResponse[None], + summary="비밀번호 재설정 확정", + description="이메일과 인증 코드, 새 비밀번호를 받아 비밀번호를 변경합니다." +) +async def confirm_password_reset( + service: MemberServiceDep, + data: PasswordResetConfirmIn, +): + await service.confirm_password_reset( + email=data.email, + code=data.code, + new_password=data.new_password + ) + return ApiResponse.success( + code="PASSWORD_RESET_SUCCESS", + message="비밀번호가 성공적으로 변경되었습니다.", + data=None + ) \ No newline at end of file diff --git a/app/modules/member/schemas.py b/app/modules/member/schemas.py index 9d20d2d..48cb94c 100644 --- a/app/modules/member/schemas.py +++ b/app/modules/member/schemas.py @@ -5,6 +5,8 @@ from pydantic import HttpUrl, ConfigDict, EmailStr from sqlmodel import SQLModel, Field +from app.shared.enums import MemberRole + #In: 서버 API로 들어오는 데이터(요청) #Out: 서버 API에서 나가는 데이터(응답) @@ -84,6 +86,7 @@ class MemberUpdateIn(SQLModel): class MemberOut(SQLModel): id: UUID = Field(description="회원 ID") email: EmailStr = Field(description="회원 이메일") + role: MemberRole = Field(description="회원 권한") name: str = Field(description="이름") birth: date = Field(description="생년월일") gender: bool | None = Field(default=None, description="성별") @@ -121,7 +124,7 @@ class TokenOut(SQLModel): token_type: str = Field(default="Bearer", description="토큰 타입") model_config = ConfigDict( - json_schema_extra={ + json_schema_extra= { "example": { "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", @@ -140,4 +143,69 @@ class RefreshTokenIn(SQLModel): "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } } + } + +#회원가입 이메일 인증 요청 (이메일 입력) +class SignupVerifyRequestIn(SQLModel): + email: EmailStr = Field(description="인증할 이메일") + + model_config = { + "json_schema_extra": { + "example": { + "email": "newuser@example.com" + } + } + } + +#회원가입 이메일 인증 확인 (이메일 + 인증코드) +class SignupVerifyConfirmIn(SQLModel): + email: EmailStr = Field(description="이메일") + code: str = Field(..., min_length=6, max_length=6, description="6자리 인증 코드") + + model_config = { + "json_schema_extra": { + "example": { + "email": "newuser@example.com", + "code": "123456" + } + } + } + +#비밀번호 재설정 요청 (이메일 입력) +class PasswordResetRequestIn(SQLModel): + email: EmailStr = Field(description="비밀번호 재설정 요청 이메일") + + model_config = { + "json_schema_extra": { + "example": { + "email": "test@example.com" + } + } + } + +#비밀번호 재설정 확인 (이메일 + 인증코드 + 새 비밀번호) +class PasswordResetConfirmIn(SQLModel): + email: EmailStr = Field(description="이메일") + code: str = Field(..., min_length=6, max_length=6, description="6자리 인증 코드") + new_password: str = Field(..., min_length=8, description="새 비밀번호") + + model_config = { + "json_schema_extra": { + "example": { + "email": "test@example.com", + "code": "123456", + "new_password": "newpassword123!" + } + } + } + +class MemberRoleUpdateIn(SQLModel): + role: MemberRole = Field(description="변경할 회원 권한") + + model_config = { + "json_schema_extra": { + "example": { + "role": "admin" + } + } } \ No newline at end of file diff --git a/app/modules/member/service.py b/app/modules/member/service.py index 27a5e01..80864e0 100644 --- a/app/modules/member/service.py +++ b/app/modules/member/service.py @@ -7,10 +7,12 @@ from app.core.exceptions import AppError from app.core.security import password_hash, verify_password, create_access_token, create_refresh_token, decode_token +from app.shared.utils.email import generate_verification_code, send_verification_email, verify_code +from app.core.redis import redis_client from app.modules.member.models import Member from app.modules.member.repository import MemberRepository from app.modules.member.schemas import MemberCreateIn, MemberUpdateIn -from app.shared.enums import ProviderType +from app.shared.enums import ProviderType, MemberRole #pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") #비밀번호 해쉬화 @@ -83,8 +85,37 @@ async def list( "total": total } + # [1단계] 회원가입용 인증 코드 발송 + async def request_signup_verification(self, email: str) -> None: + # 이미 가입된 이메일인지 먼저 체크 + existing = await self.repository.get_by_email(email) + if existing: + raise AppError.bad_request(f"[{email}]은(는) 이미 존재하는 회원 이메일입니다.") + + code = generate_verification_code() + await send_verification_email( + email=email, + code=code, + purpose="signup", + custom_message="팀플링 회원가입을 위한 인증 코드입니다." + ) + + # [2단계] 인증 코드 검증 및 '인증 완료' 증표 남기기 + async def confirm_signup_verification(self, email: str, code: str) -> None: + is_valid = await verify_code(email, code, purpose="signup") + if not is_valid: + raise AppError.bad_request("인증 코드가 틀렸거나 만료되었습니다.") + + # 인증 완료 증표를 Redis에 10분(600초)간 저장 (key: verify:signup_passed:{email}) + await redis_client.setex(f"verify:signup_passed:{email}", 600, "true") + #멤버 생성 서비스(save) async def create(self, data: MemberCreateIn) -> Member: + # [3단계] 최종 회원가입 시 증표 확인 + passed = await redis_client.get(f"verify:signup_passed:{data.email}") + if not passed: + raise AppError.bad_request("이메일 인증이 완료되지 않았거나 인증 시간이 만료되었습니다.") + existing = await self.repository.get_by_email(data.email) #회원이 이미 있는 경우 가입 불가능하게 하기 위한 작업 if existing: raise AppError.bad_request(f"[{data.email}]은(는) 이미 존재하는 회원 이메일입니다.") @@ -114,6 +145,9 @@ async def create(self, data: MemberCreateIn) -> Member: await self.session.commit() #서비스 단계에서 DB 데이터 변화가 있을 수 있기 때문에 또 refresh함. await self.session.refresh(saved) + + # 가입 성공 후 Redis 증표 삭제 + await redis_client.delete(f"verify:signup_passed:{data.email}") return saved #무결성 제약 조건 에러 #위의 AppError.bad_request 에러 부분과 다른 점은 @@ -124,9 +158,9 @@ async def create(self, data: MemberCreateIn) -> Member: #멤버 수정 서비스(save) #여기서의 member_id는 수정을 원하는 회원 id인데 수정할 회원 id가 없으면 안되므로 이렇게 구현. - async def update(self, target_member_id: UUID, actor_member_id: UUID, data: MemberUpdateIn) -> Member: - if actor_member_id != target_member_id: - raise AppError.forbidden("본인 정보만 수정할 수 있습니다.") + async def update(self, target_member_id: UUID, actor: Member, data: MemberUpdateIn) -> Member: + if actor.role != MemberRole.ADMIN and actor.id != target_member_id: + raise AppError.forbidden("본인 정보만 수정할 수 있거나 관리자 권한이 필요합니다.") member = await self.repository.get_by_id(target_member_id, include_deleted=False) if not member: @@ -181,11 +215,31 @@ async def update(self, target_member_id: UUID, actor_member_id: UUID, data: Memb await self.session.rollback() #아까 했던 DB 작업 전부 취소 raise AppError.bad_request(f"[{data.email}]은(는) 이미 존재하는 회원 이메일입니다.") + async def update_role(self, member_id: UUID, role: MemberRole) -> Member: + """ + 관리자가 특정 회원의 권한을 수정합니다. + """ + member = await self.repository.get_by_id(member_id, include_deleted=False) + if not member: + raise AppError.not_found(f"Member[{member_id}]") + + member.role = role + + try: + updated = await self.repository.save(member) + await self.session.commit() + await self.session.refresh(updated) + return updated + except Exception: + await self.session.rollback() + raise + # 멤버 삭제 서비스(soft_delete, hard_delete) #member를 삭제하는데 hard=True면 진짜 삭제, 아니면 soft delete - async def delete(self, target_member_id: UUID, actor_member_id: UUID, *, hard: bool = False) -> None: - if actor_member_id != target_member_id: - raise AppError.forbidden("본인 정보만 삭제할 수 있습니다.") + async def delete(self, target_member_id: UUID, actor: Member, *, hard: bool = False) -> None: + # 관리자가 아니면서 본인이 아닌 경우에만 차단 + if actor.role != MemberRole.ADMIN and actor.id != target_member_id: + raise AppError.forbidden("본인 정보만 삭제할 수 있거나 관리자 권한이 필요합니다.") #삭제된 것이든 아니든 다 가져옴 #왜냐하면, hard delete 하려면 이미 삭제된 것도 찾아야 하기 때문에 @@ -285,3 +339,39 @@ async def reissue(self, refresh_token: str) -> dict[str, str]: except Exception as e: raise AppError.unauthorized(f"토큰 재발급 과정에서 오류가 발생했습니다.: {str(e)}") + # [1단계] 비밀번호 재설정 요청: 인증 코드 발송 + async def request_password_reset(self, email: str) -> None: + member = await self.repository.get_by_email(email) + # 보안상 이메일이 없어도 오류를 내지 않고 발송 완료 메시지만 띄우는 것이 일반적입니다. + if not member: + return + + code = generate_verification_code() + await send_verification_email( + email=email, + code=code, + purpose="reset_password", + custom_message="비밀번호 재설정 인증 코드입니다." + ) + + # [2단계] 코드 검증 및 실제 비밀번호 변경 + async def confirm_password_reset(self, email: str, code: str, new_password: str) -> None: + # 1. 코드 검증 (Redis 확인 및 성공 시 자동 삭제) + is_valid = await verify_code(email, code, purpose="reset_password") + if not is_valid: + raise AppError.bad_request("인증 코드가 틀렸거나 만료되었습니다.") + + # 2. 사용자 확인 + member = await self.repository.get_by_email(email) + if not member: + raise AppError.not_found("사용자를 찾을 수 없습니다.") + + # 3. 비밀번호 업데이트 (해싱 후 저장) + member.hashed_password = password_hash(new_password) + + try: + await self.repository.save(member) + await self.session.commit() + except Exception: + await self.session.rollback() + raise AppError.internal_server_error("비밀번호 변경 중 오류가 발생했습니다.") \ No newline at end of file diff --git a/app/shared/enums.py b/app/shared/enums.py index fc154ca..d8880f9 100644 --- a/app/shared/enums.py +++ b/app/shared/enums.py @@ -10,4 +10,8 @@ class ProviderType(str, Enum): LOCAL = "local" GOOGLE = "google" KAKAO = "kakao" - NAVER = "naver" \ No newline at end of file + NAVER = "naver" + +class MemberRole(str, Enum): + USER = "user" + ADMIN = "admin" \ No newline at end of file diff --git a/app/shared/utils/email.py b/app/shared/utils/email.py new file mode 100644 index 0000000..8c9ae42 --- /dev/null +++ b/app/shared/utils/email.py @@ -0,0 +1,88 @@ +import random +import string +import aiosmtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from app.core.config import settings +from app.core.redis import redis_client + +async def send_email(subject: str, recipient: str, body: str): + """ + aiosmtplib를 사용하여 이메일을 비동기로 발송합니다. + """ + if not settings.SMTP_USER or not settings.SMTP_PASSWORD: + print(f"SMTP 설정이 없어 이메일을 발송하지 않았습니다. [To: {recipient}, Code/Body: {body}]") + return + + message = MIMEMultipart() + message["From"] = settings.EMAIL_FROM + message["To"] = recipient + message["Subject"] = subject + + # HTML 형식으로 보낼 수도 있으므로 plain 대신 html을 선택적으로 사용할 수 있게 확장 가능 + message.attach(MIMEText(body, "plain")) + + try: + await aiosmtplib.send( + message, + hostname=settings.SMTP_HOST, + port=settings.SMTP_PORT, + username=settings.SMTP_USER, + password=settings.SMTP_PASSWORD, + start_tls=True, + ) + except Exception as e: + print(f"이메일 발송 중 오류 발생: {str(e)}") + # 실제 운영 환경에서는 로거를 사용하세요. + +def generate_verification_code(length: int = 6) -> str: + """ + 지정된 길이의 랜덤 숫자 코드를 생성합니다. + """ + return "".join(random.choices(string.digits, k=length)) + +async def send_verification_email( + email: str, + code: str, + purpose: str = "signup", + custom_message: str | None = None +): + """ + 인증 코드를 포함한 이메일을 발송하고 Redis에 저장합니다. + + :param email: 수신자 이메일 + :param code: 6자리 인증 코드 + :param purpose: 인증 목적 (예: signup, reset_password) + :param custom_message: 이메일 본문에 포함할 커스텀 메시지 + """ + # Redis에 저장 (key format: "verify:{purpose}:{email}", TTL: 5분(300초)) + redis_key = f"verify:{purpose}:{email}" + await redis_client.setex(redis_key, 300, code) + + # 이메일 제목 및 본문 설정 + subjects = { + "signup": "[Teampling] 회원가입 인증 코드입니다.", + "reset_password": "[Teampling] 비밀번호 재설정 인증 코드입니다.", + "common": "[Teampling] 인증 코드입니다." + } + subject = subjects.get(purpose, subjects["common"]) + + if custom_message: + body = f"{custom_message}\n\n인증 코드: {code}\n\n이 코드는 5분 동안 유효합니다." + else: + body = f"요청하신 인증 코드는 다음과 같습니다.\n\n인증 코드: {code}\n\n이 코드는 5분 동안 유효합니다." + + await send_email(subject, email, body) + +async def verify_code(email: str, code: str, purpose: str = "signup") -> bool: + """ + Redis에 저장된 코드와 입력된 코드가 일치하는지 확인합니다. + """ + redis_key = f"verify:{purpose}:{email}" + stored_code = await redis_client.get(redis_key) + + if stored_code and stored_code == code: + # 인증 성공 시 코드 삭제 (1회용 인증) + await redis_client.delete(redis_key) + return True + return False diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..ea09a3f --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,57 @@ + + +
+ + +팀 프로젝트를 더 쉽고 효율적으로 관리하세요.
+