diff --git a/build.gradle b/build.gradle index 83164a3..5639b98 100644 --- a/build.gradle +++ b/build.gradle @@ -61,6 +61,9 @@ dependencies { // AWS S3 implementation 'software.amazon.awssdk:s3:2.17.41' + + // Email (Gmail SMTP) + implementation 'org.springframework.boot:spring-boot-starter-mail' } tasks.named('test') { diff --git a/docs/LOCAL_TEST_GUIDE.md b/docs/LOCAL_TEST_GUIDE.md new file mode 100644 index 0000000..e46d368 --- /dev/null +++ b/docs/LOCAL_TEST_GUIDE.md @@ -0,0 +1,136 @@ +# 로컬 테스트 가이드 — 아이디/비밀번호 찾기 & 닉네임 중복 확인 + +`feat/find-account` 브랜치 기준. 서버를 로컬에서 실행하고 `test.html`로 테스트하는 전체 절차. + +--- + +## 1. 사전 준비 — Gmail 앱 비밀번호 발급 + +이메일 경로 비밀번호 찾기 기능은 Gmail SMTP로 메일을 발송합니다. +**앱 비밀번호**는 2단계 인증이 활성화된 Google 계정에서만 발급됩니다. + +1. [myaccount.google.com](https://myaccount.google.com) → **보안** 탭 +2. **2단계 인증** 활성화 (이미 돼 있으면 패스) +3. 보안 탭 검색창에 **앱 비밀번호** 검색 → 진입 +4. 앱: `메일` / 기기: `기타(직접 입력)` → 이름 아무거나 입력 → **생성** +5. 표시된 **16자리 비밀번호 복사** (공백 포함 표시되지만 입력 시 공백 제거) + +--- + +## 2. 환경변수 설정 + +### IntelliJ Run/Debug Configurations +`Run` → `Edit Configurations` → `Environment variables`에 아래 추가: + +``` +MAIL_USERNAME=발신용Gmail주소@gmail.com +MAIL_PASSWORD=앱비밀번호16자리(공백없이) +FRONTEND_PASSWORD_RESET_URL=http://localhost:3000/reset-password +``` + +> `FRONTEND_PASSWORD_RESET_URL`은 재설정 메일에 포함되는 링크 앞부분입니다. +> 프론트 로컬이 없으면 `http://localhost:8080/test.html`로 설정하고 URL에서 token만 복사해서 사용해도 됩니다. + +### 나머지 필수 환경변수 (기존과 동일) +``` +JWT_PRIVATE_KEY=... +JWT_PUBLIC_KEY=... +PORTONE_STORE_ID=... +PORTONE_CHANNEL_KEY=... +PORTONE_API_SECRET=... +``` + +--- + +## 3. 서버 실행 후 test.html 접속 + +서버 실행 후 브라우저에서: + +``` +http://localhost:8080/test.html +``` + +상단 Config 바에 **PortOne Store ID**, **Channel Key** 입력 후 **저장** 클릭. + +--- + +## 4. 테스트 항목별 절차 + +### 4-1. 닉네임 중복 확인 (섹션 14) + +| 순서 | 행동 | 기대 결과 | +|------|------|----------| +| 1 | 닉네임 입력 | - | +| 2 | **중복 확인** 클릭 | 사용 가능: 초록 메시지 / 사용 중: 빨간 메시지 | + +- 인증 토큰 불필요 (비로그인 가능) +- `true` = 사용 가능, `false` = 이미 사용 중 + +--- + +### 4-2. 비밀번호 찾기 — 이메일 경로 (섹션 15) + +**전제**: 이메일+비밀번호로 가입된 계정 필요. 소셜 전용 계정은 `Auth-005` 에러. + +| 순서 | 행동 | 기대 결과 | +|------|------|----------| +| 1 | 이메일 입력 후 **① 재설정 메일 발송** | `204 No Content` | +| 2 | Gmail 받은편지함 확인 | `[Retrip] 비밀번호 재설정 안내` 메일 수신 | +| 3 | 메일 링크에서 `?token=` 이후 uuid 복사 | - | +| 4 | 토큰 붙여넣기 + 새 비밀번호 입력 후 **② 비밀번호 재설정** | `204 No Content` | +| 5 | 섹션 1에서 새 비밀번호로 로그인 | 로그인 성공 | + +> 재설정 완료 시 기존 로그인 세션(RefreshToken)이 모두 만료됩니다. + +--- + +### 4-3. 아이디 찾기 — 본인인증 경로 (섹션 15) + +**전제**: DB에 같은 이름+생년월일로 가입된 계정 필요. 실제 본인 명의 폰 필요. + +| 순서 | 행동 | 기대 결과 | +|------|------|----------| +| 1 | **① 본인인증 팝업 열기** | PortOne 팝업 | +| 2 | 통신사 본인인증 진행 | 팝업 자동 완료 | +| 3 | 자동으로 `/auth/find-email` 호출 | 마스킹 이메일 표시 (예: `te**@example.com`) | + +- `isNowVerified: true` — 이번 요청에서 처음 본인인증이 연결된 계정 +- `isNowVerified: false` — 이미 본인인증이 완료된 계정 + +PortOne SDK 팝업이 뜨지 않으면 impUid 직접 입력란을 사용하세요. + +--- + +### 4-4. 비밀번호 찾기 — 본인인증 경로 (섹션 15) + +**전제**: 이메일+비밀번호 계정 + 실제 본인 명의 폰 필요. + +| 순서 | 행동 | 기대 결과 | +|------|------|----------| +| 1 | **① 본인인증 팝업 열기** | PortOne 팝업 | +| 2 | 통신사 본인인증 진행 | 팝업 완료 | +| 3 | 자동으로 `/auth/password-reset/by-verification` 호출 | 재설정 토큰 자동 입력 | +| 4 | 새 비밀번호 입력 후 **③ 비밀번호 재설정** | `204 No Content` | +| 5 | 섹션 1에서 새 비밀번호로 로그인 | 로그인 성공 | + +--- + +## 5. 주요 에러 코드 + +| 코드 | 의미 | 해결 | +|------|------|------| +| `Auth-001` | 재설정 토큰 없음/잘못됨 | 토큰 재확인 | +| `Auth-002` | 토큰 만료 (30분) | 메일 재발송 후 재시도 | +| `Auth-003` | 이미 사용된 토큰 | 메일 재발송 후 재시도 | +| `Auth-004` | 본인인증 정보로 계정 못 찾음 | DB에 같은 이름+생년월일 계정 확인 | +| `Auth-005` | 소셜 전용 계정 (비밀번호 없음) | 비밀번호 찾기 불가. `/users/password`로 최초 설정 | +| `Member-010` | 닉네임 중복 | 다른 닉네임 사용 | + +--- + +## 6. PortOne 테스트 환경 주의사항 + +- PortOne **테스트 모드**에서도 본인인증은 **실제 명의 폰**이 필요합니다 (가상 번호 불가) +- 인증 후 DB에 같은 `이름 + 생년월일` 계정이 없으면 `Auth-004` 에러 +- 회원가입 시 이름/생년월일을 실제 정보와 동일하게 입력해야 매칭됨 +- 이미 CI(본인인증 고유키)가 저장된 계정은 이름+생년월일 없이 CI로 바로 조회됨 diff --git a/src/main/java/com/retrip/auth/application/config/SecurityConfig.java b/src/main/java/com/retrip/auth/application/config/SecurityConfig.java index 5217a0b..2956dac 100644 --- a/src/main/java/com/retrip/auth/application/config/SecurityConfig.java +++ b/src/main/java/com/retrip/auth/application/config/SecurityConfig.java @@ -117,6 +117,11 @@ public SecurityFilterChain securityFilterChain( // ✅ 수정: /api/users 경로 추가 .requestMatchers(HttpMethod.POST, "/users", "/api/users").permitAll() .requestMatchers("/login/**", "/oauth2/**", "/auth/reissue", "/auth/logout", "/").permitAll() + .requestMatchers(HttpMethod.POST, + "/auth/find-email", + "/auth/password-reset/by-verification", + "/auth/password-reset/by-email", + "/auth/password-reset").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**").permitAll() // ✅ 추가: 본인인증 및 여행 스타일 조회 API 허용 .requestMatchers(HttpMethod.GET, "/api/travel-styles", "/api/users/check-nickname").permitAll() diff --git a/src/main/java/com/retrip/auth/application/dto/request/FindEmailByVerificationRequest.java b/src/main/java/com/retrip/auth/application/dto/request/FindEmailByVerificationRequest.java new file mode 100644 index 0000000..096117b --- /dev/null +++ b/src/main/java/com/retrip/auth/application/dto/request/FindEmailByVerificationRequest.java @@ -0,0 +1,11 @@ +package com.retrip.auth.application.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "아이디 찾기 요청 (본인인증)") +public record FindEmailByVerificationRequest( + @NotBlank + @Schema(description = "PortOne 본인인증 impUid", example = "imp_1234567890") + String impUid +) {} diff --git a/src/main/java/com/retrip/auth/application/dto/request/ResetPasswordByEmailRequest.java b/src/main/java/com/retrip/auth/application/dto/request/ResetPasswordByEmailRequest.java new file mode 100644 index 0000000..99017d4 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/dto/request/ResetPasswordByEmailRequest.java @@ -0,0 +1,13 @@ +package com.retrip.auth.application.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "비밀번호 재설정 이메일 발송 요청") +public record ResetPasswordByEmailRequest( + @NotBlank + @Email + @Schema(description = "가입 시 사용한 이메일", example = "user@example.com") + String email +) {} diff --git a/src/main/java/com/retrip/auth/application/dto/request/ResetPasswordByVerificationRequest.java b/src/main/java/com/retrip/auth/application/dto/request/ResetPasswordByVerificationRequest.java new file mode 100644 index 0000000..6c538c1 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/dto/request/ResetPasswordByVerificationRequest.java @@ -0,0 +1,11 @@ +package com.retrip.auth.application.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "비밀번호 재설정 토큰 요청 (본인인증)") +public record ResetPasswordByVerificationRequest( + @NotBlank + @Schema(description = "PortOne 본인인증 impUid", example = "imp_1234567890") + String impUid +) {} diff --git a/src/main/java/com/retrip/auth/application/dto/request/ResetPasswordRequest.java b/src/main/java/com/retrip/auth/application/dto/request/ResetPasswordRequest.java new file mode 100644 index 0000000..d9320f1 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/dto/request/ResetPasswordRequest.java @@ -0,0 +1,18 @@ +package com.retrip.auth.application.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +@Schema(description = "비밀번호 재설정 요청") +public record ResetPasswordRequest( + @NotBlank + @Schema(description = "재설정 토큰") + String token, + + @NotBlank + @Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[!@#$%^&*()\\-_=+\\[\\]|;:,./]).{8,20}$", + message = "비밀번호는 영문, 숫자, 특수문자를 포함한 8~20자여야 합니다.") + @Schema(description = "새 비밀번호 (영문+숫자+특수문자 8~20자)", example = "NewPass1!") + String newPassword +) {} diff --git a/src/main/java/com/retrip/auth/application/dto/response/FindEmailResponse.java b/src/main/java/com/retrip/auth/application/dto/response/FindEmailResponse.java new file mode 100644 index 0000000..31fc79e --- /dev/null +++ b/src/main/java/com/retrip/auth/application/dto/response/FindEmailResponse.java @@ -0,0 +1,28 @@ +package com.retrip.auth.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "아이디 찾기 응답") +public record FindEmailResponse( + @Schema(description = "마스킹된 이메일", example = "us**@example.com") + String maskedEmail, + + @Schema(description = "이번 찾기 과정에서 본인인증이 새로 연결된 경우 true") + boolean isNowVerified +) { + public static FindEmailResponse of(String email, boolean isNowVerified) { + return new FindEmailResponse(maskEmail(email), isNowVerified); + } + + private static String maskEmail(String email) { + int atIndex = email.indexOf('@'); + if (atIndex <= 0) return email; + + String local = email.substring(0, atIndex); + String domain = email.substring(atIndex); + + int showLength = Math.min(3, Math.max(1, (local.length() + 1) / 2)); + String masked = local.substring(0, showLength) + "*".repeat(local.length() - showLength); + return masked + domain; + } +} diff --git a/src/main/java/com/retrip/auth/application/dto/response/PasswordResetTokenResponse.java b/src/main/java/com/retrip/auth/application/dto/response/PasswordResetTokenResponse.java new file mode 100644 index 0000000..f4db621 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/dto/response/PasswordResetTokenResponse.java @@ -0,0 +1,9 @@ +package com.retrip.auth.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "비밀번호 재설정 토큰 응답 (본인인증 경로)") +public record PasswordResetTokenResponse( + @Schema(description = "비밀번호 재설정 토큰 (30분 유효, 1회용)") + String resetToken +) {} diff --git a/src/main/java/com/retrip/auth/application/out/repository/MemberRepository.java b/src/main/java/com/retrip/auth/application/out/repository/MemberRepository.java index 3645de5..e071af2 100644 --- a/src/main/java/com/retrip/auth/application/out/repository/MemberRepository.java +++ b/src/main/java/com/retrip/auth/application/out/repository/MemberRepository.java @@ -28,4 +28,9 @@ public interface MemberRepository extends JpaRepository { List searchByNameContainingIgnoreCase(@Param("name") String name); List findAllByIdInAndIsDeletedFalse(List ids); + + Optional findByCiAndIsDeletedFalse(String ci); + + @Query("SELECT m FROM Member m WHERE m.name.value = :name AND m.birthDate = :birthDate AND m.isDeleted = false") + List findByNameAndBirthDateAndIsDeletedFalse(@Param("name") String name, @Param("birthDate") String birthDate); } diff --git a/src/main/java/com/retrip/auth/application/out/repository/PasswordResetTokenRepository.java b/src/main/java/com/retrip/auth/application/out/repository/PasswordResetTokenRepository.java new file mode 100644 index 0000000..85c18d6 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/out/repository/PasswordResetTokenRepository.java @@ -0,0 +1,11 @@ +package com.retrip.auth.application.out.repository; + +import com.retrip.auth.domain.entity.PasswordResetToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PasswordResetTokenRepository extends JpaRepository { + Optional findByToken(String token); + void deleteByMemberId(String memberId); +} diff --git a/src/main/java/com/retrip/auth/application/service/EmailService.java b/src/main/java/com/retrip/auth/application/service/EmailService.java new file mode 100644 index 0000000..ba5571c --- /dev/null +++ b/src/main/java/com/retrip/auth/application/service/EmailService.java @@ -0,0 +1,45 @@ +package com.retrip.auth.application.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class EmailService { + + private final JavaMailSender mailSender; + + @Value("${app.mail.from}") + private String fromAddress; + + @Value("${app.password-reset.url:http://localhost:3000/reset-password}") + private String resetPasswordUrl; + + public void sendPasswordResetEmail(String toEmail, String token) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(fromAddress); + message.setTo(toEmail); + message.setSubject("[Retrip] 비밀번호 재설정 안내"); + message.setText(buildEmailBody(token)); + mailSender.send(message); + } + + private String buildEmailBody(String token) { + return String.format(""" + 안녕하세요, Retrip입니다. + + 아래 링크를 클릭하여 비밀번호를 재설정해 주세요. + 링크는 30분 동안 유효하며, 1회만 사용할 수 있습니다. + + %s?token=%s + + 본인이 요청하지 않은 경우 이 메일을 무시하셔도 됩니다. + + 감사합니다. + Retrip 팀 + """, resetPasswordUrl, token); + } +} diff --git a/src/main/java/com/retrip/auth/application/service/FindAccountService.java b/src/main/java/com/retrip/auth/application/service/FindAccountService.java new file mode 100644 index 0000000..0098992 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/service/FindAccountService.java @@ -0,0 +1,112 @@ +package com.retrip.auth.application.service; + +import com.retrip.auth.application.dto.CertificationInfo; +import com.retrip.auth.application.dto.response.FindEmailResponse; +import com.retrip.auth.application.dto.response.PasswordResetTokenResponse; +import com.retrip.auth.application.out.repository.MemberRepository; +import com.retrip.auth.application.out.repository.PasswordResetTokenRepository; +import com.retrip.auth.application.out.repository.RefreshTokenRepository; +import com.retrip.auth.application.service.recovery.EmailVerificationStrategy; +import com.retrip.auth.application.service.recovery.PortOneVerificationStrategy; +import com.retrip.auth.application.service.recovery.VerificationResult; +import com.retrip.auth.domain.entity.Member; +import com.retrip.auth.domain.entity.PasswordResetToken; +import com.retrip.auth.domain.exception.MemberNotFoundException; +import com.retrip.auth.domain.exception.common.BusinessException; +import com.retrip.auth.domain.exception.common.ErrorCode; +import com.retrip.auth.infra.adapter.out.external.PortOneApiClient; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class FindAccountService { + + private final PortOneApiClient portOneApiClient; + private final PortOneVerificationStrategy portOneStrategy; + private final EmailVerificationStrategy emailStrategy; + private final PasswordResetTokenRepository resetTokenRepository; + private final MemberRepository memberRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final EmailService emailService; + private final PasswordEncoder passwordEncoder; + private final PlatformTransactionManager transactionManager; + + @Value("${app.password-reset.expire-minutes:30}") + private int expireMinutes; + + /** + * 아이디 찾기 (본인인증) + * HTTP 호출 후 트랜잭션 시작 — DB 커넥션 점유 최소화 + */ + public FindEmailResponse findEmailByVerification(String impUid) { + CertificationInfo certInfo = portOneApiClient.getCertificationInfo(impUid); // HTTP, 트랜잭션 밖 + VerificationResult result = portOneStrategy.findMemberByCert(certInfo); // @Transactional + return FindEmailResponse.of(result.member().getEmailValue(), result.wasJustVerified()); + } + + /** + * 비밀번호 재설정 토큰 발급 (본인인증) + * HTTP 호출 후 트랜잭션 시작 — DB 커넥션 점유 최소화. + * TransactionTemplate으로 DB 작업만 트랜잭션에 포함시킨다. + */ + public PasswordResetTokenResponse issueResetTokenByVerification(String impUid) { + CertificationInfo certInfo = portOneApiClient.getCertificationInfo(impUid); // HTTP, 트랜잭션 밖 + TransactionTemplate tx = new TransactionTemplate(transactionManager); + return tx.execute(status -> { + VerificationResult result = portOneStrategy.findMemberByCert(certInfo); + Member member = result.member(); + if (!member.hasPassword()) { + throw new BusinessException(ErrorCode.SOCIAL_MEMBER_NO_PASSWORD_RESET); + } + return new PasswordResetTokenResponse(createToken(member).getToken()); + }); + } + + /** + * 비밀번호 재설정 이메일 발송 (이메일 경로) + * TransactionTemplate으로 토큰 저장 트랜잭션을 먼저 커밋한 뒤 이메일 발송. + * SMTP 호출이 DB 트랜잭션 안에 포함되지 않도록 경계를 분리한다. + */ + public void sendPasswordResetEmail(String email) { + TransactionTemplate tx = new TransactionTemplate(transactionManager); + String[] tokenData = tx.execute(status -> { + Member member = emailStrategy.findMember(email); + String token = createToken(member).getToken(); + return new String[]{member.getEmailValue(), token}; + }); + emailService.sendPasswordResetEmail(tokenData[0], tokenData[1]); // 트랜잭션 커밋 후 발송 + } + + /** + * 비밀번호 재설정 (토큰 검증 + 새 비밀번호 저장) + * 비밀번호 변경 후 기존 RefreshToken을 모두 삭제해 기존 세션을 무효화한다. + */ + @Transactional + public void resetPassword(String tokenValue, String newPassword) { + PasswordResetToken token = resetTokenRepository.findByToken(tokenValue) + .orElseThrow(() -> new BusinessException(ErrorCode.RESET_TOKEN_NOT_FOUND)); + + if (token.isUsed()) throw new BusinessException(ErrorCode.RESET_TOKEN_ALREADY_USED); + if (token.isExpired()) throw new BusinessException(ErrorCode.RESET_TOKEN_EXPIRED); + + Member member = memberRepository.findById(UUID.fromString(token.getMemberId())) + .orElseThrow(MemberNotFoundException::new); + + member.updatePassword(passwordEncoder.encode(newPassword)); + token.markAsUsed(); + refreshTokenRepository.deleteByMemberId(member.getId().toString()); // 기존 세션 모두 무효화 + } + + private PasswordResetToken createToken(Member member) { + resetTokenRepository.deleteByMemberId(member.getId().toString()); + return resetTokenRepository.save(PasswordResetToken.create(member.getId().toString(), expireMinutes)); + } +} diff --git a/src/main/java/com/retrip/auth/application/service/IdentityVerificationService.java b/src/main/java/com/retrip/auth/application/service/IdentityVerificationService.java index b6eaea3..091ffa9 100644 --- a/src/main/java/com/retrip/auth/application/service/IdentityVerificationService.java +++ b/src/main/java/com/retrip/auth/application/service/IdentityVerificationService.java @@ -1,23 +1,17 @@ package com.retrip.auth.application.service; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; import com.retrip.auth.application.dto.CertificationInfo; import com.retrip.auth.application.dto.response.VerifyIdentityResponse; import com.retrip.auth.application.out.repository.MemberRepository; import com.retrip.auth.domain.entity.Member; import com.retrip.auth.domain.exception.DuplicateUserException; import com.retrip.auth.domain.exception.MemberNotFoundException; -import com.retrip.auth.domain.exception.PortOneApiException; +import com.retrip.auth.infra.adapter.out.external.PortOneApiClient; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import okhttp3.*; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.io.IOException; import java.util.UUID; @Slf4j @@ -26,32 +20,25 @@ public class IdentityVerificationService { private final MemberRepository memberRepository; + private final PortOneApiClient portOneApiClient; - @Value("${portone.api_secret}") - private String apiSecret; - - // [수정] 매개변수 이름을 email -> memberId로 변경하고 UUID로 조회 @Transactional public VerifyIdentityResponse verifyAndSave(String identityVerificationId, String memberId) { - log.info("🔍 본인인증 검증 시작 - ID: {}, MemberId: {}", identityVerificationId, memberId); + log.info("본인인증 검증 시작 - ID: {}, MemberId: {}", identityVerificationId, memberId); - // 1. 포트원 V2 API로 본인인증 정보 조회 - CertificationInfo certInfo = getCertificationInfo(identityVerificationId); + CertificationInfo certInfo = portOneApiClient.getCertificationInfo(identityVerificationId); - // 2. 중복 가입 체크 (CI가 존재할 경우에만) if (certInfo.getUniqueKey() != null && memberRepository.existsByCi(certInfo.getUniqueKey())) { - log.warn("⚠️ 중복 가입 시도 - CI: {}", certInfo.getUniqueKey()); + log.warn("중복 가입 시도 - CI: {}", certInfo.getUniqueKey()); throw new DuplicateUserException(); } - // 3. 사용자 정보 업데이트 (UUID로 조회) Member member = memberRepository.findById(UUID.fromString(memberId)) .orElseThrow(MemberNotFoundException::new); - // 성별 변환 (MALE -> M, FEMALE -> F) String gender = "MALE".equals(certInfo.getGender()) ? "M" : "F"; - log.info("✅ 회원 본인인증 정보 업데이트 - Name: {}, Gender: {}, BirthDate: {}", + log.info("회원 본인인증 정보 업데이트 - Name: {}, Gender: {}, BirthDate: {}", certInfo.getName(), gender, certInfo.getBirthday()); member.updateIdentityVerification( @@ -64,67 +51,4 @@ public VerifyIdentityResponse verifyAndSave(String identityVerificationId, Strin return VerifyIdentityResponse.from(member); } - - private CertificationInfo getCertificationInfo(String identityVerificationId) { - OkHttpClient client = new OkHttpClient(); - String url = "https://api.portone.io/identity-verifications/" + identityVerificationId; - - Request request = new Request.Builder() - .url(url) - .addHeader("Authorization", "PortOne " + apiSecret) - .get() - .build(); - - try (Response response = client.newCall(request).execute()) { - String responseBody = response.body().string(); - - if (!response.isSuccessful()) { - throw new PortOneApiException("본인인증 정보 조회 실패: " + response.code()); - } - - JsonObject json = JsonParser.parseString(responseBody).getAsJsonObject(); - if (!json.has("verifiedCustomer")) { - throw new PortOneApiException("Missing verifiedCustomer in response"); - } - - JsonObject verifiedCustomer = json.getAsJsonObject("verifiedCustomer"); - - // 필수 필드 - String name = getStringField(verifiedCustomer, "name"); - String gender = getStringField(verifiedCustomer, "gender"); - String birthday = verifiedCustomer.has("birthDate") - ? getStringField(verifiedCustomer, "birthDate") - : getStringField(verifiedCustomer, "birthday"); - - // [수정] CI, DI는 선택적 필드로 처리 (테스트 환경 대응) - String ci = getOptionalStringField(verifiedCustomer, "ci"); - String di = getOptionalStringField(verifiedCustomer, "di"); - - return CertificationInfo.builder() - .name(name) - .gender(gender) - .birthday(birthday) - .uniqueKey(ci) - .uniqueInSite(di) - .build(); - } catch (IOException e) { - throw new PortOneApiException("IO Error: " + e.getMessage()); - } - } - - private String getStringField(JsonObject json, String fieldName) { - if (!json.has(fieldName) || json.get(fieldName).isJsonNull()) { - throw new PortOneApiException("Missing required field: " + fieldName); - } - return json.get(fieldName).getAsString(); - } - - // [추가] 선택적 필드 추출 메서드 - private String getOptionalStringField(JsonObject json, String fieldName) { - if (!json.has(fieldName) || json.get(fieldName).isJsonNull()) { - return null; - } - String value = json.get(fieldName).getAsString(); - return value.isEmpty() ? null : value; - } -} \ No newline at end of file +} diff --git a/src/main/java/com/retrip/auth/application/service/recovery/EmailVerificationStrategy.java b/src/main/java/com/retrip/auth/application/service/recovery/EmailVerificationStrategy.java new file mode 100644 index 0000000..65c07ce --- /dev/null +++ b/src/main/java/com/retrip/auth/application/service/recovery/EmailVerificationStrategy.java @@ -0,0 +1,32 @@ +package com.retrip.auth.application.service.recovery; + +import com.retrip.auth.application.out.repository.MemberRepository; +import com.retrip.auth.domain.entity.Member; +import com.retrip.auth.domain.exception.common.BusinessException; +import com.retrip.auth.domain.exception.common.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class EmailVerificationStrategy { + + private final MemberRepository memberRepository; + + /** + * 이메일로 회원을 조회한다. + * 소셜 전용 계정(비밀번호 없음)은 이메일 기반 재설정 불가. + */ + public Member findMember(String email) { + Member member = memberRepository.findByEmail_Value(email) + .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + + if (Boolean.TRUE.equals(member.getIsDeleted())) { + throw new BusinessException(ErrorCode.MEMBER_NOT_FOUND); + } + if (!member.hasPassword()) { + throw new BusinessException(ErrorCode.SOCIAL_MEMBER_NO_PASSWORD_RESET); + } + return member; + } +} diff --git a/src/main/java/com/retrip/auth/application/service/recovery/PortOneVerificationStrategy.java b/src/main/java/com/retrip/auth/application/service/recovery/PortOneVerificationStrategy.java new file mode 100644 index 0000000..b625c90 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/service/recovery/PortOneVerificationStrategy.java @@ -0,0 +1,74 @@ +package com.retrip.auth.application.service.recovery; + +import com.retrip.auth.application.dto.CertificationInfo; +import com.retrip.auth.application.out.repository.MemberRepository; +import com.retrip.auth.infra.adapter.out.external.PortOneApiClient; +import com.retrip.auth.domain.entity.Member; +import com.retrip.auth.domain.exception.common.BusinessException; +import com.retrip.auth.domain.exception.common.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PortOneVerificationStrategy { + + private final MemberRepository memberRepository; + private final PortOneApiClient portOneApiClient; + + /** + * PortOne 본인인증 결과로 회원을 조회한다. HTTP 호출은 호출자가 선행해야 한다. + * + * 1. CI로 직접 조회 (이미 본인인증된 사용자) → wasJustVerified=false + * 2. CI 미매칭 시 이름+생년월일로 조회 (미인증 사용자) → CI 자동 연결, wasJustVerified=true + */ + @Transactional + public VerificationResult findMemberByCert(CertificationInfo certInfo) { + String ci = certInfo.getUniqueKey(); + + // 1. CI로 먼저 조회 — 이미 인증된 사용자 + if (ci != null) { + Optional byCI = memberRepository.findByCiAndIsDeletedFalse(ci); + if (byCI.isPresent()) { + return new VerificationResult(byCI.get(), false); + } + } + + // 2. 이름 + 생년월일로 조회 — 미인증 사용자 + String name = certInfo.getName(); + String birthDate = certInfo.getBirthday(); + List candidates = memberRepository.findByNameAndBirthDateAndIsDeletedFalse(name, birthDate); + + if (candidates.isEmpty()) { + throw new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND_BY_VERIFICATION); + } + if (candidates.size() > 1) { + log.warn("이름+생년월일 중복 계정 존재 - name: {}, birthDate: {}", name, birthDate); + } + + Member member = candidates.get(0); + + // 3. 본인인증 처리 — 이번 요청에서 최초 연결 + if (ci != null) { + String gender = "MALE".equals(certInfo.getGender()) ? "M" : "F"; + member.updateIdentityVerification(certInfo.getName(), gender, birthDate, ci, certInfo.getUniqueInSite()); + log.info("미인증 사용자 본인인증 처리 완료 - memberId: {}", member.getId()); + } + + return new VerificationResult(member, true); + } + + /** + * impUid로 PortOne API를 호출해 본인인증 정보를 조회한다. + * DB 트랜잭션 밖에서 호출해야 커넥션 점유 시간을 줄일 수 있다. + */ + public CertificationInfo getCertificationInfo(String impUid) { + return portOneApiClient.getCertificationInfo(impUid); + } +} diff --git a/src/main/java/com/retrip/auth/application/service/recovery/VerificationResult.java b/src/main/java/com/retrip/auth/application/service/recovery/VerificationResult.java new file mode 100644 index 0000000..71ce34c --- /dev/null +++ b/src/main/java/com/retrip/auth/application/service/recovery/VerificationResult.java @@ -0,0 +1,10 @@ +package com.retrip.auth.application.service.recovery; + +import com.retrip.auth.domain.entity.Member; + +/** + * 본인인증 기반 회원 조회 결과. + * wasJustVerified: 이번 요청에서 처음으로 본인인증이 연결된 경우 true (이미 인증된 사용자면 false) + */ +public record VerificationResult(Member member, boolean wasJustVerified) { +} diff --git a/src/main/java/com/retrip/auth/domain/entity/PasswordResetToken.java b/src/main/java/com/retrip/auth/domain/entity/PasswordResetToken.java new file mode 100644 index 0000000..297c507 --- /dev/null +++ b/src/main/java/com/retrip/auth/domain/entity/PasswordResetToken.java @@ -0,0 +1,50 @@ +package com.retrip.auth.domain.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "password_reset_token") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PasswordResetToken { + + @Id + @Column(length = 36) + private String token; + + @Column(nullable = false, length = 36) + private String memberId; + + @Column(nullable = false) + private LocalDateTime expiresAt; + + @Column(nullable = false) + private boolean used; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public static PasswordResetToken create(String memberId, int expireMinutes) { + PasswordResetToken t = new PasswordResetToken(); + t.token = UUID.randomUUID().toString(); + t.memberId = memberId; + t.expiresAt = LocalDateTime.now().plusMinutes(expireMinutes); + t.used = false; + t.createdAt = LocalDateTime.now(); + return t; + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiresAt); + } + + public void markAsUsed() { + this.used = true; + } +} diff --git a/src/main/java/com/retrip/auth/domain/exception/common/ErrorCode.java b/src/main/java/com/retrip/auth/domain/exception/common/ErrorCode.java index 669c973..f35d4f1 100644 --- a/src/main/java/com/retrip/auth/domain/exception/common/ErrorCode.java +++ b/src/main/java/com/retrip/auth/domain/exception/common/ErrorCode.java @@ -27,6 +27,12 @@ public enum ErrorCode { NICKNAME_ALREADY_EXISTS(HttpStatus.CONFLICT, "Member-010", "이미 사용 중인 닉네임입니다."), EXTENSION_NOT_FOUND(BAD_REQUEST, "Image-001", "지원하지 않는 이미지 확장자입니다."), + + RESET_TOKEN_NOT_FOUND(BAD_REQUEST, "Auth-001", "유효하지 않은 재설정 토큰입니다."), + RESET_TOKEN_EXPIRED(BAD_REQUEST, "Auth-002", "만료된 재설정 토큰입니다."), + RESET_TOKEN_ALREADY_USED(BAD_REQUEST, "Auth-003", "이미 사용된 재설정 토큰입니다."), + ACCOUNT_NOT_FOUND_BY_VERIFICATION(NOT_FOUND, "Auth-004", "본인인증 정보와 일치하는 계정을 찾을 수 없습니다."), + SOCIAL_MEMBER_NO_PASSWORD_RESET(BAD_REQUEST, "Auth-005", "소셜 로그인 전용 계정은 비밀번호 재설정이 불가합니다."), ; private final HttpStatus status; diff --git a/src/main/java/com/retrip/auth/infra/adapter/in/rest/controller/FindAccountController.java b/src/main/java/com/retrip/auth/infra/adapter/in/rest/controller/FindAccountController.java new file mode 100644 index 0000000..7f2f5d6 --- /dev/null +++ b/src/main/java/com/retrip/auth/infra/adapter/in/rest/controller/FindAccountController.java @@ -0,0 +1,63 @@ +package com.retrip.auth.infra.adapter.in.rest.controller; + +import com.retrip.auth.application.dto.request.FindEmailByVerificationRequest; +import com.retrip.auth.application.dto.request.ResetPasswordByEmailRequest; +import com.retrip.auth.application.dto.request.ResetPasswordByVerificationRequest; +import com.retrip.auth.application.dto.request.ResetPasswordRequest; +import com.retrip.auth.application.dto.response.FindEmailResponse; +import com.retrip.auth.application.dto.response.PasswordResetTokenResponse; +import com.retrip.auth.application.service.FindAccountService; +import com.retrip.auth.infra.adapter.in.rest.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +@Tag(name = "계정 찾기", description = "아이디/비밀번호 찾기 API") +public class FindAccountController { + + private final FindAccountService findAccountService; + + @Operation(summary = "아이디 찾기 (본인인증)", + description = "PortOne 본인인증으로 가입 이메일을 조회합니다. 미인증 사용자는 이번 인증으로 자동 연결됩니다.") + @PostMapping("/find-email") + public ApiResponse findEmail( + @Valid @RequestBody FindEmailByVerificationRequest request) { + return ApiResponse.ok(findAccountService.findEmailByVerification(request.impUid())); + } + + @Operation(summary = "비밀번호 재설정 토큰 발급 (본인인증)", + description = "PortOne 본인인증으로 신원 확인 후 비밀번호 재설정 토큰을 발급합니다. (30분 유효, 1회용)") + @PostMapping("/password-reset/by-verification") + public ApiResponse requestResetByVerification( + @Valid @RequestBody ResetPasswordByVerificationRequest request) { + return ApiResponse.ok(findAccountService.issueResetTokenByVerification(request.impUid())); + } + + @Operation(summary = "비밀번호 재설정 이메일 발송", + description = "가입 이메일로 비밀번호 재설정 링크를 전송합니다. 소셜 전용 계정은 불가합니다.") + @PostMapping("/password-reset/by-email") + public ResponseEntity> requestResetByEmail( + @Valid @RequestBody ResetPasswordByEmailRequest request) { + findAccountService.sendPasswordResetEmail(request.email()); + return ResponseEntity.status(HttpStatus.NO_CONTENT).body(ApiResponse.noContent()); + } + + @Operation(summary = "비밀번호 재설정", + description = "재설정 토큰과 새 비밀번호로 비밀번호를 변경합니다.") + @PostMapping("/password-reset") + public ResponseEntity> resetPassword( + @Valid @RequestBody ResetPasswordRequest request) { + findAccountService.resetPassword(request.token(), request.newPassword()); + return ResponseEntity.status(HttpStatus.NO_CONTENT).body(ApiResponse.noContent()); + } +} diff --git a/src/main/java/com/retrip/auth/infra/adapter/out/external/PortOneApiClient.java b/src/main/java/com/retrip/auth/infra/adapter/out/external/PortOneApiClient.java new file mode 100644 index 0000000..a41ffec --- /dev/null +++ b/src/main/java/com/retrip/auth/infra/adapter/out/external/PortOneApiClient.java @@ -0,0 +1,72 @@ +package com.retrip.auth.infra.adapter.out.external; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.retrip.auth.application.dto.CertificationInfo; +import com.retrip.auth.domain.exception.PortOneApiException; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class PortOneApiClient { + + private final OkHttpClient httpClient = new OkHttpClient(); + + @Value("${portone.api_secret}") + private String apiSecret; + + public CertificationInfo getCertificationInfo(String impUid) { + String url = "https://api.portone.io/identity-verifications/" + impUid; + + Request request = new Request.Builder() + .url(url) + .addHeader("Authorization", "PortOne " + apiSecret) + .get() + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + String body = response.body().string(); + if (!response.isSuccessful()) { + throw new PortOneApiException("본인인증 조회 실패: " + response.code()); + } + + JsonObject json = JsonParser.parseString(body).getAsJsonObject(); + if (!json.has("verifiedCustomer")) { + throw new PortOneApiException("Missing verifiedCustomer in response"); + } + + JsonObject vc = json.getAsJsonObject("verifiedCustomer"); + String birthday = vc.has("birthDate") + ? getStringField(vc, "birthDate") + : getStringField(vc, "birthday"); + + return CertificationInfo.builder() + .name(getStringField(vc, "name")) + .gender(getStringField(vc, "gender")) + .birthday(birthday) + .uniqueKey(getOptionalStringField(vc, "ci")) + .uniqueInSite(getOptionalStringField(vc, "di")) + .build(); + } catch (IOException e) { + throw new PortOneApiException("IO Error: " + e.getMessage()); + } + } + + private String getStringField(JsonObject json, String field) { + if (!json.has(field) || json.get(field).isJsonNull()) { + throw new PortOneApiException("Missing required field: " + field); + } + return json.get(field).getAsString(); + } + + private String getOptionalStringField(JsonObject json, String field) { + if (!json.has(field) || json.get(field).isJsonNull()) return null; + String v = json.get(field).getAsString(); + return v.isEmpty() ? null : v; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a051d48..a86d86d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -63,6 +63,19 @@ spring: user-info-uri: https://openapi.naver.com/v1/nid/me user-name-attribute: response + # Gmail SMTP + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_USERNAME:noreply@example.com} + password: ${MAIL_PASSWORD:} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + #logging logging: level: @@ -94,6 +107,11 @@ springdoc: app: frontend-callback-url: ${FRONTEND_CALLBACK_URL:http://localhost:3000/auth/callback} + mail: + from: ${MAIL_USERNAME:noreply@example.com} + password-reset: + url: ${FRONTEND_PASSWORD_RESET_URL:http://localhost:3000/reset-password} + expire-minutes: 30 cloud: aws: diff --git a/src/main/resources/test.html b/src/main/resources/test.html index bc3b24a..1e1cb5d 100644 --- a/src/main/resources/test.html +++ b/src/main/resources/test.html @@ -134,6 +134,8 @@

ReTrip Auth API 통합 테스트

11.토큰/로그아웃 12.회원탈퇴 13.시나리오검증 + 14.닉네임중복확인 + 15.아이디/비번찾기 @@ -652,6 +654,110 @@

DELETE /users 회원 탈퇴

+ +
+
14. 닉네임 중복 확인
+
+ +
+

GET /api/users/check-nickname

+
인증 불필요. true = 사용 가능, false = 이미 사용 중
+ + + + +
+
GET /api/users/check-nickname
+
+
+
+ +
+
+ + +
+
15. 아이디 / 비밀번호 찾기
+ +
+ 본인인증 경로: 상단 Config 바에 PortOne Store ID와 Channel Key가 입력돼 있어야 합니다.
+ 이메일 경로: 서버에 MAIL_USERNAME / MAIL_PASSWORD 환경변수가 설정돼 있어야 합니다. +
+ +
+ + +
+

POST /auth/find-email  아이디 찾기 (본인인증)

+
인증 불필요. PortOne 본인인증 후 maskedEmail 반환. isNowVerified=true면 이번 요청에서 본인인증이 처음 연결된 것.
+
+ +
또는 impUid 직접 입력 (팝업 불가 시)
+ + + +
+
POST /auth/find-email
+
+
+
+ + +
+

POST /auth/password-reset/by-email  비밀번호 찾기 (이메일)

+
인증 불필요. 입력한 이메일로 재설정 링크 발송. 소셜 전용 계정은 Auth-005 오류.
+ + + +
+
POST /auth/password-reset/by-email
+
+
+
+
메일함에서 링크의 ?token= 값 복사 후 아래에 붙여넣기
+ + + + + +
+
POST /auth/password-reset
+
+
+
+
+ + +
+

POST /auth/password-reset/by-verification  비밀번호 찾기 (본인인증)

+
인증 불필요. PortOne 본인인증 후 재설정 토큰 발급. 소셜 전용 계정은 Auth-005 오류.
+
+ +
또는 impUid 직접 입력
+ + +
+
+
POST /auth/password-reset/by-verification
+
+
+
+
위에서 발급된 토큰이 자동 입력됩니다.
+ + + + + +
+
POST /auth/password-reset
+
+
+
+
+ +
+
+