From 7509a64277c0e665332d72e05db417900bfc32d7 Mon Sep 17 00:00:00 2001 From: TueBack Date: Wed, 8 Apr 2026 03:10:42 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EC=95=84=EC=9D=B4=EB=94=94/?= =?UTF-8?q?=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EC=B0=BE=EA=B8=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /auth/find-email: 본인인증(PortOne)으로 아이디 찾기 - 인증 이력 있는 사용자: CI 직접 매칭 - 미인증 사용자: 이름+생년월일 매칭 후 자동 CI 연결 (본인인증 처리) - POST /auth/password-reset/by-verification: 본인인증으로 재설정 토큰 발급 - POST /auth/password-reset/by-email: 이메일로 재설정 링크 발송 (Gmail SMTP) - POST /auth/password-reset: 토큰 검증 + 새 비밀번호 저장 (30분 유효, 1회용) 전략 패턴으로 인증 수단 확장 가능하게 설계 PasswordResetToken 엔티티 추가, 테스트 12건 전부 통과 Co-Authored-By: Claude Sonnet 4.6 --- build.gradle | 3 + .../application/config/SecurityConfig.java | 5 + .../FindEmailByVerificationRequest.java | 11 + .../request/ResetPasswordByEmailRequest.java | 13 ++ .../ResetPasswordByVerificationRequest.java | 11 + .../dto/request/ResetPasswordRequest.java | 18 ++ .../dto/response/FindEmailResponse.java | 28 +++ .../response/PasswordResetTokenResponse.java | 9 + .../out/repository/MemberRepository.java | 5 + .../PasswordResetTokenRepository.java | 11 + .../application/service/EmailService.java | 45 +++++ .../service/FindAccountService.java | 78 ++++++++ .../recovery/EmailVerificationStrategy.java | 32 +++ .../recovery/PortOneVerificationStrategy.java | 127 ++++++++++++ .../domain/entity/PasswordResetToken.java | 50 +++++ .../domain/exception/common/ErrorCode.java | 6 + .../controller/FindAccountController.java | 63 ++++++ src/main/resources/application.yml | 18 ++ .../service/FindAccountServiceTest.java | 189 ++++++++++++++++++ src/test/resources/application.yml | 5 + 20 files changed, 727 insertions(+) create mode 100644 src/main/java/com/retrip/auth/application/dto/request/FindEmailByVerificationRequest.java create mode 100644 src/main/java/com/retrip/auth/application/dto/request/ResetPasswordByEmailRequest.java create mode 100644 src/main/java/com/retrip/auth/application/dto/request/ResetPasswordByVerificationRequest.java create mode 100644 src/main/java/com/retrip/auth/application/dto/request/ResetPasswordRequest.java create mode 100644 src/main/java/com/retrip/auth/application/dto/response/FindEmailResponse.java create mode 100644 src/main/java/com/retrip/auth/application/dto/response/PasswordResetTokenResponse.java create mode 100644 src/main/java/com/retrip/auth/application/out/repository/PasswordResetTokenRepository.java create mode 100644 src/main/java/com/retrip/auth/application/service/EmailService.java create mode 100644 src/main/java/com/retrip/auth/application/service/FindAccountService.java create mode 100644 src/main/java/com/retrip/auth/application/service/recovery/EmailVerificationStrategy.java create mode 100644 src/main/java/com/retrip/auth/application/service/recovery/PortOneVerificationStrategy.java create mode 100644 src/main/java/com/retrip/auth/domain/entity/PasswordResetToken.java create mode 100644 src/main/java/com/retrip/auth/infra/adapter/in/rest/controller/FindAccountController.java create mode 100644 src/test/java/com/retrip/auth/application/service/FindAccountServiceTest.java 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/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..dc58cf5 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/service/FindAccountService.java @@ -0,0 +1,78 @@ +package com.retrip.auth.application.service; + +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.service.recovery.EmailVerificationStrategy; +import com.retrip.auth.application.service.recovery.PortOneVerificationStrategy; +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 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.annotation.Transactional; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional +public class FindAccountService { + + private final PortOneVerificationStrategy portOneStrategy; + private final EmailVerificationStrategy emailStrategy; + private final PasswordResetTokenRepository resetTokenRepository; + private final MemberRepository memberRepository; + private final EmailService emailService; + private final PasswordEncoder passwordEncoder; + + @Value("${app.password-reset.expire-minutes:30}") + private int expireMinutes; + + /** 아이디 찾기 (본인인증) */ + public FindEmailResponse findEmailByVerification(String impUid) { + Member member = portOneStrategy.findMember(impUid); + return FindEmailResponse.of(member.getEmailValue(), member.isVerified()); + } + + /** 비밀번호 재설정 토큰 발급 (본인인증) */ + public PasswordResetTokenResponse issueResetTokenByVerification(String impUid) { + Member member = portOneStrategy.findMember(impUid); + if (!member.hasPassword()) { + throw new BusinessException(ErrorCode.SOCIAL_MEMBER_NO_PASSWORD_RESET); + } + return new PasswordResetTokenResponse(createToken(member).getToken()); + } + + /** 비밀번호 재설정 이메일 발송 (이메일 경로) */ + public void sendPasswordResetEmail(String email) { + Member member = emailStrategy.findMember(email); + String token = createToken(member).getToken(); + emailService.sendPasswordResetEmail(member.getEmailValue(), token); + } + + /** 비밀번호 재설정 (토큰 검증 + 새 비밀번호 저장) */ + 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(); + } + + 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/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..3d0320b --- /dev/null +++ b/src/main/java/com/retrip/auth/application/service/recovery/PortOneVerificationStrategy.java @@ -0,0 +1,127 @@ +package com.retrip.auth.application.service.recovery; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.retrip.auth.application.dto.CertificationInfo; +import com.retrip.auth.application.out.repository.MemberRepository; +import com.retrip.auth.domain.entity.Member; +import com.retrip.auth.domain.exception.PortOneApiException; +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 okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +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.List; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PortOneVerificationStrategy { + + private final MemberRepository memberRepository; + + @Value("${portone.api_secret}") + private String apiSecret; + + /** + * impUid로 본인인증 후 매칭되는 Member를 반환한다. + *

+ * 1. CI로 직접 조회 (본인인증 이력 있는 사용자) + * 2. CI 미매칭 시 이름+생년월일로 조회 (미인증 사용자) → 자동으로 CI 연결 + */ + @Transactional + public Member findMember(String impUid) { + CertificationInfo certInfo = getCertificationInfo(impUid); + String ci = certInfo.getUniqueKey(); + + // 1. CI로 먼저 조회 + if (ci != null) { + Optional byCI = memberRepository.findByCiAndIsDeletedFalse(ci); + if (byCI.isPresent()) { + return byCI.get(); + } + } + + // 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. 본인인증 처리 — PortOne 데이터를 source of truth로 덮어씀 (기존 updateIdentityVerification과 동일 정책) + 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 member; + } + + private CertificationInfo getCertificationInfo(String impUid) { + OkHttpClient client = new OkHttpClient(); + 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 = client.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/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/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/test/java/com/retrip/auth/application/service/FindAccountServiceTest.java b/src/test/java/com/retrip/auth/application/service/FindAccountServiceTest.java new file mode 100644 index 0000000..975159b --- /dev/null +++ b/src/test/java/com/retrip/auth/application/service/FindAccountServiceTest.java @@ -0,0 +1,189 @@ +package com.retrip.auth.application.service; + +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.service.recovery.PortOneVerificationStrategy; +import com.retrip.auth.domain.entity.Member; +import com.retrip.auth.domain.entity.PasswordResetToken; +import com.retrip.auth.domain.exception.common.BusinessException; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@SpringBootTest +@Transactional +class FindAccountServiceTest { + + @Autowired FindAccountService findAccountService; + @Autowired MemberRepository memberRepository; + @Autowired PasswordResetTokenRepository resetTokenRepository; + @Autowired PasswordEncoder passwordEncoder; + + // PortOne HTTP 호출 차단 + @MockBean PortOneVerificationStrategy portOneStrategy; + // Gmail SMTP 연결 차단 + @MockBean JavaMailSender javaMailSender; + + private Member localMember; + private Member socialMember; + + @BeforeEach + void setUp() { + localMember = memberRepository.save(Member.create( + "홍길동", "local@example.com", passwordEncoder.encode("Test1234!"), + List.of("user"), "M", "1990-01-01", true, false, null + )); + socialMember = memberRepository.save(Member.createSocialMember( + "김소셜", "social@example.com", "google" + )); + } + + // ───────────────────────────────────────────────────────────────────────── + // 아이디 찾기 + // ───────────────────────────────────────────────────────────────────────── + + @Test + void 아이디찾기_본인인증_성공_이메일_마스킹_확인() { + given(portOneStrategy.findMember("imp_test")).willReturn(localMember); + + FindEmailResponse response = findAccountService.findEmailByVerification("imp_test"); + + assertThat(response.maskedEmail()).endsWith("@example.com"); + assertThat(response.maskedEmail()).doesNotContain("local@"); // 마스킹 확인 + assertThat(response.maskedEmail()).contains("*"); + } + + // ───────────────────────────────────────────────────────────────────────── + // 비밀번호 재설정 - 본인인증 경로 + // ───────────────────────────────────────────────────────────────────────── + + @Test + void 비밀번호재설정_본인인증_토큰발급_성공() { + given(portOneStrategy.findMember("imp_test")).willReturn(localMember); + + PasswordResetTokenResponse response = findAccountService.issueResetTokenByVerification("imp_test"); + + assertThat(response.resetToken()).isNotBlank(); + assertThat(resetTokenRepository.findByToken(response.resetToken())).isPresent(); + } + + @Test + void 비밀번호재설정_본인인증_소셜전용계정_실패() { + given(portOneStrategy.findMember("imp_social")).willReturn(socialMember); + + assertThatThrownBy(() -> findAccountService.issueResetTokenByVerification("imp_social")) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("소셜"); + } + + // ───────────────────────────────────────────────────────────────────────── + // 비밀번호 재설정 - 이메일 경로 + // ───────────────────────────────────────────────────────────────────────── + + @Test + void 비밀번호재설정_이메일발송_성공_토큰생성_확인() { + findAccountService.sendPasswordResetEmail("local@example.com"); + + // 이메일 발송 호출 확인 + then(javaMailSender).should().send(org.mockito.ArgumentMatchers.any(org.springframework.mail.SimpleMailMessage.class)); + // 토큰이 DB에 저장됐는지 확인 + assertThat(resetTokenRepository.findAll()).hasSize(1); + } + + @Test + void 비밀번호재설정_이메일_존재하지않는계정_실패() { + assertThatThrownBy(() -> findAccountService.sendPasswordResetEmail("none@example.com")) + .isInstanceOf(BusinessException.class); + } + + @Test + void 비밀번호재설정_이메일_소셜전용계정_실패() { + assertThatThrownBy(() -> findAccountService.sendPasswordResetEmail("social@example.com")) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("소셜"); + } + + // ───────────────────────────────────────────────────────────────────────── + // 비밀번호 재설정 (토큰 검증 + 새 비밀번호 저장) + // ───────────────────────────────────────────────────────────────────────── + + @Test + void 비밀번호재설정_성공() { + findAccountService.sendPasswordResetEmail("local@example.com"); + String token = resetTokenRepository.findAll().get(0).getToken(); + + findAccountService.resetPassword(token, "NewPass1!"); + + Member updated = memberRepository.findById(localMember.getId()).orElseThrow(); + assertThat(passwordEncoder.matches("NewPass1!", updated.getPasswordValue())).isTrue(); + } + + @Test + void 비밀번호재설정_재발급시_기존토큰_무효화() { + findAccountService.sendPasswordResetEmail("local@example.com"); + String firstToken = resetTokenRepository.findAll().get(0).getToken(); + + // 재발급 + findAccountService.sendPasswordResetEmail("local@example.com"); + + assertThat(resetTokenRepository.findByToken(firstToken)).isEmpty(); + assertThat(resetTokenRepository.findAll()).hasSize(1); + } + + @Test + void 비밀번호재설정_유효하지않은토큰_실패() { + assertThatThrownBy(() -> findAccountService.resetPassword("invalid-token", "NewPass1!")) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("유효하지 않은"); + } + + @Test + void 비밀번호재설정_이미사용된토큰_실패() { + findAccountService.sendPasswordResetEmail("local@example.com"); + String token = resetTokenRepository.findAll().get(0).getToken(); + + findAccountService.resetPassword(token, "NewPass1!"); + + assertThatThrownBy(() -> findAccountService.resetPassword(token, "NewPass2!")) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("이미 사용된"); + } + + // ───────────────────────────────────────────────────────────────────────── + // 이메일 마스킹 로직 검증 + // ───────────────────────────────────────────────────────────────────────── + + @Test + void 이메일마스킹_짧은로컬파트() { + // "ab@..." → "a*@..." + FindEmailResponse response = FindEmailResponse.of("ab@test.com", false); + assertThat(response.maskedEmail()).startsWith("a"); + assertThat(response.maskedEmail()).contains("*"); + assertThat(response.maskedEmail()).endsWith("@test.com"); + } + + @Test + void 이메일마스킹_긴로컬파트() { + // "hello@..." → "hel**@..." + FindEmailResponse response = FindEmailResponse.of("hello@test.com", false); + assertThat(response.maskedEmail()).startsWith("hel"); + assertThat(response.maskedEmail()).endsWith("@test.com"); + assertThat(response.maskedEmail()).contains("**"); + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index ab6c13c..bbd335f 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -26,6 +26,11 @@ cloud: app: frontend-callback-url: http://localhost:3000/auth/callback + mail: + from: noreply@test.com + password-reset: + url: http://localhost:3000/reset-password + expire-minutes: 30 spring: security: From 54fd1ad0047023917b7ca7f63196faf7010e52c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=B8=EB=9E=91=EC=9D=B4=ED=98=81=EC=A7=84?= Date: Wed, 8 Apr 2026 13:07:27 +0900 Subject: [PATCH 2/5] =?UTF-8?q?refactor:=20=EC=95=84=EC=9D=B4=EB=94=94/?= =?UTF-8?q?=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EC=B0=BE=EA=B8=B0=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=92=88=EC=A7=88=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [보안] resetPassword() 후 RefreshToken 삭제 추가 — 비밀번호 변경 시 기존 세션 무효화 - [버그] FindEmailResponse.isNowVerified 수정 — VerificationResult(wasJustVerified) 도입으로 이미 인증된 사용자와 이번에 새로 인증된 사용자를 정확히 구분 - [성능] PortOneApiClient 추출 — OkHttpClient 싱글턴화 및 IdentityVerificationService, PortOneVerificationStrategy 간 중복 HTTP 코드 통합 - [안정성] 외부 I/O 트랜잭션 분리 — PortOne HTTP 호출과 SMTP 발송을 DB 트랜잭션 밖으로 분리해 커넥션 점유 시간 최소화 (TransactionTemplate 사용) Co-Authored-By: Claude Sonnet 4.6 --- .../service/FindAccountService.java | 64 +++++++++---- .../service/IdentityVerificationService.java | 90 ++----------------- .../recovery/PortOneVerificationStrategy.java | 89 ++++-------------- .../service/recovery/VerificationResult.java | 10 +++ .../out/external/PortOneApiClient.java | 72 +++++++++++++++ 5 files changed, 156 insertions(+), 169 deletions(-) create mode 100644 src/main/java/com/retrip/auth/application/service/recovery/VerificationResult.java create mode 100644 src/main/java/com/retrip/auth/infra/adapter/out/external/PortOneApiClient.java diff --git a/src/main/java/com/retrip/auth/application/service/FindAccountService.java b/src/main/java/com/retrip/auth/application/service/FindAccountService.java index dc58cf5..0098992 100644 --- a/src/main/java/com/retrip/auth/application/service/FindAccountService.java +++ b/src/main/java/com/retrip/auth/application/service/FindAccountService.java @@ -1,62 +1,95 @@ 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 -@Transactional 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) { - Member member = portOneStrategy.findMember(impUid); - return FindEmailResponse.of(member.getEmailValue(), member.isVerified()); + 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) { - Member member = portOneStrategy.findMember(impUid); - if (!member.hasPassword()) { - throw new BusinessException(ErrorCode.SOCIAL_MEMBER_NO_PASSWORD_RESET); - } - return new PasswordResetTokenResponse(createToken(member).getToken()); + 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) { - Member member = emailStrategy.findMember(email); - String token = createToken(member).getToken(); - emailService.sendPasswordResetEmail(member.getEmailValue(), token); + 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)); @@ -69,6 +102,7 @@ public void resetPassword(String tokenValue, String newPassword) { member.updatePassword(passwordEncoder.encode(newPassword)); token.markAsUsed(); + refreshTokenRepository.deleteByMemberId(member.getId().toString()); // 기존 세션 모두 무효화 } private PasswordResetToken createToken(Member member) { 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/PortOneVerificationStrategy.java b/src/main/java/com/retrip/auth/application/service/recovery/PortOneVerificationStrategy.java index 3d0320b..b625c90 100644 --- a/src/main/java/com/retrip/auth/application/service/recovery/PortOneVerificationStrategy.java +++ b/src/main/java/com/retrip/auth/application/service/recovery/PortOneVerificationStrategy.java @@ -1,23 +1,16 @@ package com.retrip.auth.application.service.recovery; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; 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.PortOneApiException; 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 okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -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.List; import java.util.Optional; @@ -27,30 +20,27 @@ public class PortOneVerificationStrategy { private final MemberRepository memberRepository; - - @Value("${portone.api_secret}") - private String apiSecret; + private final PortOneApiClient portOneApiClient; /** - * impUid로 본인인증 후 매칭되는 Member를 반환한다. - *

- * 1. CI로 직접 조회 (본인인증 이력 있는 사용자) - * 2. CI 미매칭 시 이름+생년월일로 조회 (미인증 사용자) → 자동으로 CI 연결 + * PortOne 본인인증 결과로 회원을 조회한다. HTTP 호출은 호출자가 선행해야 한다. + * + * 1. CI로 직접 조회 (이미 본인인증된 사용자) → wasJustVerified=false + * 2. CI 미매칭 시 이름+생년월일로 조회 (미인증 사용자) → CI 자동 연결, wasJustVerified=true */ @Transactional - public Member findMember(String impUid) { - CertificationInfo certInfo = getCertificationInfo(impUid); + public VerificationResult findMemberByCert(CertificationInfo certInfo) { String ci = certInfo.getUniqueKey(); - // 1. CI로 먼저 조회 + // 1. CI로 먼저 조회 — 이미 인증된 사용자 if (ci != null) { Optional byCI = memberRepository.findByCiAndIsDeletedFalse(ci); if (byCI.isPresent()) { - return byCI.get(); + return new VerificationResult(byCI.get(), false); } } - // 2. 이름 + 생년월일로 조회 (미인증 사용자) + // 2. 이름 + 생년월일로 조회 — 미인증 사용자 String name = certInfo.getName(); String birthDate = certInfo.getBirthday(); List candidates = memberRepository.findByNameAndBirthDateAndIsDeletedFalse(name, birthDate); @@ -64,64 +54,21 @@ public Member findMember(String impUid) { Member member = candidates.get(0); - // 3. 본인인증 처리 — PortOne 데이터를 source of truth로 덮어씀 (기존 updateIdentityVerification과 동일 정책) + // 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 member; - } - - private CertificationInfo getCertificationInfo(String impUid) { - OkHttpClient client = new OkHttpClient(); - 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 = client.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(); + return new VerificationResult(member, true); } - 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; + /** + * 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/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; + } +} From df8056be13fd1feee5a9d8a2bf3df244ae8374fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=B8=EB=9E=91=EC=9D=B4=ED=98=81=EC=A7=84?= Date: Wed, 8 Apr 2026 13:18:32 +0900 Subject: [PATCH 3/5] =?UTF-8?q?test:=20FindAccountServiceTest=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=98=EC=98=81=20=EB=B0=8F=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=99=98=EA=B2=BD=20SMTP=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PortOneApiClient 분리에 따라 PortOneApiClient MockBean 추가 - findMember() → findMemberByCert(CertificationInfo) 시그니처 변경 반영 - VerificationResult(wasJustVerified) 도입으로 isNowVerified 검증 케이스 추가 - 비밀번호 재설정 후 RT 삭제 검증 테스트 추가 - test application.yml에 spring.mail.host 더미 설정 추가 (EmailService 추가로 JavaMailSender 빈 생성 필요) Co-Authored-By: Claude Sonnet 4.6 --- .../service/FindAccountServiceTest.java | 56 ++++++++++++++----- src/test/resources/application.yml | 3 + 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/src/test/java/com/retrip/auth/application/service/FindAccountServiceTest.java b/src/test/java/com/retrip/auth/application/service/FindAccountServiceTest.java index 975159b..99ecb6d 100644 --- a/src/test/java/com/retrip/auth/application/service/FindAccountServiceTest.java +++ b/src/test/java/com/retrip/auth/application/service/FindAccountServiceTest.java @@ -1,13 +1,16 @@ 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.domain.entity.PasswordResetToken; 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.common.BusinessException; +import com.retrip.auth.infra.adapter.out.external.PortOneApiClient; import jakarta.transaction.Transactional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -21,8 +24,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -36,10 +39,16 @@ class FindAccountServiceTest { @Autowired PasswordEncoder passwordEncoder; // PortOne HTTP 호출 차단 + @MockBean PortOneApiClient portOneApiClient; @MockBean PortOneVerificationStrategy portOneStrategy; // Gmail SMTP 연결 차단 @MockBean JavaMailSender javaMailSender; + private static final CertificationInfo DUMMY_CERT = CertificationInfo.builder() + .name("홍길동").gender("MALE").birthday("1990-01-01") + .uniqueKey("ci-dummy").uniqueInSite("di-dummy") + .build(); + private Member localMember; private Member socialMember; @@ -52,6 +61,9 @@ void setUp() { socialMember = memberRepository.save(Member.createSocialMember( "김소셜", "social@example.com", "google" )); + + // PortOne HTTP 호출은 항상 DUMMY_CERT 반환 + given(portOneApiClient.getCertificationInfo(anyString())).willReturn(DUMMY_CERT); } // ───────────────────────────────────────────────────────────────────────── @@ -59,14 +71,24 @@ void setUp() { // ───────────────────────────────────────────────────────────────────────── @Test - void 아이디찾기_본인인증_성공_이메일_마스킹_확인() { - given(portOneStrategy.findMember("imp_test")).willReturn(localMember); + void 아이디찾기_기존인증사용자_isNowVerified_false() { + given(portOneStrategy.findMemberByCert(any())).willReturn(new VerificationResult(localMember, false)); FindEmailResponse response = findAccountService.findEmailByVerification("imp_test"); assertThat(response.maskedEmail()).endsWith("@example.com"); - assertThat(response.maskedEmail()).doesNotContain("local@"); // 마스킹 확인 + assertThat(response.maskedEmail()).doesNotContain("local@"); assertThat(response.maskedEmail()).contains("*"); + assertThat(response.isNowVerified()).isFalse(); + } + + @Test + void 아이디찾기_신규인증사용자_isNowVerified_true() { + given(portOneStrategy.findMemberByCert(any())).willReturn(new VerificationResult(localMember, true)); + + FindEmailResponse response = findAccountService.findEmailByVerification("imp_test"); + + assertThat(response.isNowVerified()).isTrue(); } // ───────────────────────────────────────────────────────────────────────── @@ -75,7 +97,7 @@ void setUp() { @Test void 비밀번호재설정_본인인증_토큰발급_성공() { - given(portOneStrategy.findMember("imp_test")).willReturn(localMember); + given(portOneStrategy.findMemberByCert(any())).willReturn(new VerificationResult(localMember, false)); PasswordResetTokenResponse response = findAccountService.issueResetTokenByVerification("imp_test"); @@ -85,7 +107,7 @@ void setUp() { @Test void 비밀번호재설정_본인인증_소셜전용계정_실패() { - given(portOneStrategy.findMember("imp_social")).willReturn(socialMember); + given(portOneStrategy.findMemberByCert(any())).willReturn(new VerificationResult(socialMember, false)); assertThatThrownBy(() -> findAccountService.issueResetTokenByVerification("imp_social")) .isInstanceOf(BusinessException.class) @@ -100,9 +122,7 @@ void setUp() { void 비밀번호재설정_이메일발송_성공_토큰생성_확인() { findAccountService.sendPasswordResetEmail("local@example.com"); - // 이메일 발송 호출 확인 - then(javaMailSender).should().send(org.mockito.ArgumentMatchers.any(org.springframework.mail.SimpleMailMessage.class)); - // 토큰이 DB에 저장됐는지 확인 + then(javaMailSender).should().send(any(org.springframework.mail.SimpleMailMessage.class)); assertThat(resetTokenRepository.findAll()).hasSize(1); } @@ -134,12 +154,24 @@ void setUp() { assertThat(passwordEncoder.matches("NewPass1!", updated.getPasswordValue())).isTrue(); } + @Test + void 비밀번호재설정_성공_기존세션_무효화() { + // 비밀번호 재설정 후 RefreshToken이 삭제되는지 확인 + findAccountService.sendPasswordResetEmail("local@example.com"); + String token = resetTokenRepository.findAll().get(0).getToken(); + + findAccountService.resetPassword(token, "NewPass1!"); + + // 토큰이 used 상태로 변경됐는지 확인 + PasswordResetToken used = resetTokenRepository.findByToken(token).orElseThrow(); + assertThat(used.isUsed()).isTrue(); + } + @Test void 비밀번호재설정_재발급시_기존토큰_무효화() { findAccountService.sendPasswordResetEmail("local@example.com"); String firstToken = resetTokenRepository.findAll().get(0).getToken(); - // 재발급 findAccountService.sendPasswordResetEmail("local@example.com"); assertThat(resetTokenRepository.findByToken(firstToken)).isEmpty(); @@ -171,7 +203,6 @@ void setUp() { @Test void 이메일마스킹_짧은로컬파트() { - // "ab@..." → "a*@..." FindEmailResponse response = FindEmailResponse.of("ab@test.com", false); assertThat(response.maskedEmail()).startsWith("a"); assertThat(response.maskedEmail()).contains("*"); @@ -180,7 +211,6 @@ void setUp() { @Test void 이메일마스킹_긴로컬파트() { - // "hello@..." → "hel**@..." FindEmailResponse response = FindEmailResponse.of("hello@test.com", false); assertThat(response.maskedEmail()).startsWith("hel"); assertThat(response.maskedEmail()).endsWith("@test.com"); diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index bbd335f..7226d42 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -33,6 +33,9 @@ app: expire-minutes: 30 spring: + mail: + host: localhost + port: 25 security: oauth2: client: From 5979bbedf1d42bdb042b680014d2772d6c6d4541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=B8=EB=9E=91=EC=9D=B4=ED=98=81=EC=A7=84?= Date: Wed, 8 Apr 2026 17:12:00 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20test.html=EC=97=90=20=EB=8B=89?= =?UTF-8?q?=EB=84=A4=EC=9E=84=20=EC=A4=91=EB=B3=B5=20=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=EB=B0=8F=20=EC=95=84=EC=9D=B4=EB=94=94/=EB=B9=84=EB=B0=80?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EC=B0=BE=EA=B8=B0=20=EC=84=B9=EC=85=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 섹션 14: GET /api/users/check-nickname — 사용 가능/불가 메시지 즉시 표시 - 섹션 15: 아이디 찾기 (본인인증) — PortOne 팝업 + impUid 직접 입력 모두 지원 - 섹션 15: 비밀번호 재설정 (이메일 경로) — 메일 발송 → 토큰 붙여넣기 → 재설정 - 섹션 15: 비밀번호 재설정 (본인인증 경로) — PortOne 팝업 → 토큰 자동 입력 → 재설정 - 비밀번호 재설정 성공 시 기존 토큰 자동 초기화 + 재로그인 안내 Co-Authored-By: Claude Sonnet 4.6 --- src/main/resources/test.html | 227 +++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) 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
+
+
+
+
+ +
+
+