Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
136 changes: 136 additions & 0 deletions docs/LOCAL_TEST_GUIDE.md
Original file line number Diff line number Diff line change
@@ -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로 바로 조회됨
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,9 @@ public interface MemberRepository extends JpaRepository<Member, UUID> {
List<Member> searchByNameContainingIgnoreCase(@Param("name") String name);

List<Member> findAllByIdInAndIsDeletedFalse(List<UUID> ids);

Optional<Member> findByCiAndIsDeletedFalse(String ci);

@Query("SELECT m FROM Member m WHERE m.name.value = :name AND m.birthDate = :birthDate AND m.isDeleted = false")
List<Member> findByNameAndBirthDateAndIsDeletedFalse(@Param("name") String name, @Param("birthDate") String birthDate);
}
Original file line number Diff line number Diff line change
@@ -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<PasswordResetToken, String> {
Optional<PasswordResetToken> findByToken(String token);
void deleteByMemberId(String memberId);
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading