Skip to content

Commit 34aac99

Browse files
authored
Feat: 이메일 인증 및 인증 메일 재발송 API 구현 (#55) (#121)
* Feat: 이메일 인증 API 구현 * Feat: 인증 이메일 발송 추가 * Test: 토큰 및 이메일 서비스 테스트 작성 * Test: Auth 테스트 작성 * Docs: Swagger 문서 작성 * Ref: 이메일 인증 API 경로 수정 * Feat: 인증 메일 재발송 API 구현 * Test: 인증 메일 재발송 테스트 작성 * Docs: Swagger 문서 작성 * Ref: 이메일 서비스 개선 * Chore: CI 환경에서 Redis 추가 # Conflicts: # .github/workflows/backend-ci.yml * Ref: API 경로 개선
1 parent 2e01c0b commit 34aac99

File tree

16 files changed

+933
-74
lines changed

16 files changed

+933
-74
lines changed

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dependencies {
2929
implementation("org.springframework.boot:spring-boot-starter-web")
3030
implementation("org.springframework.boot:spring-boot-starter-websocket")
3131
implementation("org.springframework.boot:spring-boot-starter-validation")
32+
implementation("org.springframework.boot:spring-boot-starter-mail")
3233

3334
// Database & JPA
3435
implementation("org.springframework.boot:spring-boot-starter-data-jpa")

src/main/java/com/back/domain/user/controller/AuthController.java

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
package com.back.domain.user.controller;
22

3-
import com.back.domain.user.dto.LoginRequest;
4-
import com.back.domain.user.dto.LoginResponse;
5-
import com.back.domain.user.dto.UserRegisterRequest;
6-
import com.back.domain.user.dto.UserResponse;
3+
import com.back.domain.user.dto.*;
74
import com.back.domain.user.service.AuthService;
85
import com.back.global.common.dto.RsData;
96
import jakarta.servlet.http.HttpServletRequest;
@@ -37,6 +34,32 @@ public ResponseEntity<RsData<UserResponse>> register(
3734
));
3835
}
3936

37+
// 이메일 인증
38+
@GetMapping("/email/verify")
39+
public ResponseEntity<RsData<UserResponse>> verifyEmail(
40+
@RequestParam("token") String token
41+
) {
42+
UserResponse userResponse = authService.verifyEmail(token);
43+
return ResponseEntity
44+
.ok(RsData.success(
45+
"이메일 인증이 완료되었습니다.",
46+
userResponse
47+
));
48+
}
49+
50+
// 인증 메일 재발송
51+
@PostMapping("/email/verify")
52+
public ResponseEntity<RsData<Void>> resendVerificationEmail(
53+
@Valid @RequestBody ResendVerificationRequest request
54+
) {
55+
authService.resendVerificationEmail(request.email());
56+
return ResponseEntity
57+
.ok(RsData.success(
58+
"인증 메일이 재발송되었습니다.",
59+
null
60+
));
61+
}
62+
4063
// 로그인
4164
@PostMapping("/login")
4265
public ResponseEntity<RsData<LoginResponse>> login(

src/main/java/com/back/domain/user/controller/AuthControllerDocs.java

Lines changed: 182 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
package com.back.domain.user.controller;
22

3-
import com.back.domain.user.dto.LoginRequest;
4-
import com.back.domain.user.dto.LoginResponse;
5-
import com.back.domain.user.dto.UserRegisterRequest;
6-
import com.back.domain.user.dto.UserResponse;
3+
import com.back.domain.user.dto.*;
74
import com.back.global.common.dto.RsData;
85
import io.swagger.v3.oas.annotations.Operation;
96
import io.swagger.v3.oas.annotations.media.Content;
@@ -17,6 +14,7 @@
1714
import org.springframework.http.ResponseEntity;
1815
import org.springframework.web.bind.annotation.GetMapping;
1916
import org.springframework.web.bind.annotation.RequestBody;
17+
import org.springframework.web.bind.annotation.RequestParam;
2018

2119
import java.util.Map;
2220

@@ -130,6 +128,186 @@ ResponseEntity<RsData<UserResponse>> register(
130128
@Valid @RequestBody UserRegisterRequest request
131129
);
132130

131+
@Operation(
132+
summary = "이메일 인증",
133+
description = "회원가입 시 발송된 이메일의 인증 링크를 검증합니다. " +
134+
"유효한 토큰이면 해당 사용자의 상태를 `ACTIVE`로 변경합니다."
135+
)
136+
@ApiResponses({
137+
@ApiResponse(
138+
responseCode = "200",
139+
description = "이메일 인증 성공",
140+
content = @Content(
141+
mediaType = "application/json",
142+
examples = @ExampleObject(value = """
143+
{
144+
"success": true,
145+
"code": "SUCCESS_200",
146+
"message": "이메일 인증이 완료되었습니다.",
147+
"data": {
148+
"userId": 1,
149+
"username": "testuser",
150+
"email": "[email protected]",
151+
"nickname": "홍길동",
152+
"role": "USER",
153+
"status": "ACTIVE",
154+
"createdAt": "2025-09-19T15:00:00"
155+
}
156+
}
157+
""")
158+
)
159+
),
160+
@ApiResponse(
161+
responseCode = "401",
162+
description = "유효하지 않거나 만료된 토큰",
163+
content = @Content(
164+
mediaType = "application/json",
165+
examples = @ExampleObject(value = """
166+
{
167+
"success": false,
168+
"code": "TOKEN_001",
169+
"message": "유효하지 않은 이메일 인증 토큰입니다.",
170+
"data": null
171+
}
172+
""")
173+
)
174+
),
175+
@ApiResponse(
176+
responseCode = "409",
177+
description = "이미 인증된 계정",
178+
content = @Content(
179+
mediaType = "application/json",
180+
examples = @ExampleObject(value = """
181+
{
182+
"success": false,
183+
"code": "TOKEN_002",
184+
"message": "이미 인증된 계정입니다.",
185+
"data": null
186+
}
187+
""")
188+
)
189+
),
190+
@ApiResponse(
191+
responseCode = "400",
192+
description = "토큰 파라미터 누락",
193+
content = @Content(
194+
mediaType = "application/json",
195+
examples = @ExampleObject(value = """
196+
{
197+
"success": false,
198+
"code": "COMMON_400",
199+
"message": "잘못된 요청입니다.",
200+
"data": null
201+
}
202+
""")
203+
)
204+
),
205+
@ApiResponse(
206+
responseCode = "500",
207+
description = "서버 내부 오류",
208+
content = @Content(
209+
mediaType = "application/json",
210+
examples = @ExampleObject(value = """
211+
{
212+
"success": false,
213+
"code": "COMMON_500",
214+
"message": "서버 오류가 발생했습니다.",
215+
"data": null
216+
}
217+
""")
218+
)
219+
)
220+
})
221+
ResponseEntity<RsData<UserResponse>> verifyEmail(
222+
@RequestParam("token") String token
223+
);
224+
225+
@Operation(
226+
summary = "인증 메일 재발송",
227+
description = "회원가입 후 아직 활성화되지 않은 계정에 대해 인증 메일을 재발송합니다. " +
228+
"새 토큰이 생성되어 메일이 발송됩니다."
229+
)
230+
@ApiResponses({
231+
@ApiResponse(
232+
responseCode = "200",
233+
description = "인증 메일 재발송 성공",
234+
content = @Content(
235+
mediaType = "application/json",
236+
examples = @ExampleObject(value = """
237+
{
238+
"success": true,
239+
"code": "SUCCESS_200",
240+
"message": "인증 메일이 재발송되었습니다.",
241+
"data": null
242+
}
243+
""")
244+
)
245+
),
246+
@ApiResponse(
247+
responseCode = "404",
248+
description = "존재하지 않는 사용자",
249+
content = @Content(
250+
mediaType = "application/json",
251+
examples = @ExampleObject(value = """
252+
{
253+
"success": false,
254+
"code": "USER_001",
255+
"message": "존재하지 않는 사용자입니다.",
256+
"data": null
257+
}
258+
""")
259+
)
260+
),
261+
@ApiResponse(
262+
responseCode = "409",
263+
description = "이미 인증된 계정",
264+
content = @Content(
265+
mediaType = "application/json",
266+
examples = @ExampleObject(value = """
267+
{
268+
"success": false,
269+
"code": "TOKEN_002",
270+
"message": "이미 인증된 계정입니다.",
271+
"data": null
272+
}
273+
""")
274+
)
275+
),
276+
@ApiResponse(
277+
responseCode = "400",
278+
description = "잘못된 요청 (이메일 필드 누락 등)",
279+
content = @Content(
280+
mediaType = "application/json",
281+
examples = @ExampleObject(value = """
282+
{
283+
"success": false,
284+
"code": "COMMON_400",
285+
"message": "잘못된 요청입니다.",
286+
"data": null
287+
}
288+
""")
289+
)
290+
),
291+
@ApiResponse(
292+
responseCode = "500",
293+
description = "서버 내부 오류",
294+
content = @Content(
295+
mediaType = "application/json",
296+
examples = @ExampleObject(value = """
297+
{
298+
"success": false,
299+
"code": "COMMON_500",
300+
"message": "서버 오류가 발생했습니다.",
301+
"data": null
302+
}
303+
""")
304+
)
305+
)
306+
})
307+
ResponseEntity<RsData<Void>> resendVerificationEmail(
308+
@Valid @RequestBody ResendVerificationRequest request
309+
);
310+
133311
@Operation(
134312
summary = "로그인",
135313
description = "username + password로 로그인합니다. " +
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.back.domain.user.dto;
2+
3+
import jakarta.validation.constraints.Email;
4+
import jakarta.validation.constraints.NotBlank;
5+
6+
public record ResendVerificationRequest(
7+
@NotBlank @Email String email
8+
) {
9+
}

src/main/java/com/back/domain/user/repository/UserRepository.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
public interface UserRepository extends JpaRepository<User, Long> {
1111
boolean existsByUsername(String username);
1212
boolean existsByEmail(String email);
13+
Optional<User> findByEmail(String email);
1314
Optional<User> findByUsername(String username);
1415
Optional<User> findByProviderAndProviderId(String provider, String providerId);
1516
}

src/main/java/com/back/domain/user/service/AuthService.java

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ public class AuthService {
3333
private final UserRepository userRepository;
3434
private final UserProfileRepository userProfileRepository;
3535
private final UserTokenRepository userTokenRepository;
36+
private final EmailService emailService;
37+
private final TokenService tokenService;
3638
private final PasswordEncoder passwordEncoder;
3739
private final JwtTokenProvider jwtTokenProvider;
3840

@@ -71,18 +73,82 @@ public UserResponse register(UserRegisterRequest request) {
7173
// 연관관계 설정
7274
user.setUserProfile(profile);
7375

74-
// TODO: 임시 로직 - 이메일 인증 기능 개발 전까지는 바로 ACTIVE 처리
75-
user.setUserStatus(UserStatus.ACTIVE);
76-
7776
// 저장 (cascade로 Profile도 함께 저장됨)
7877
User saved = userRepository.save(user);
7978

80-
// TODO: 이메일 인증 로직 추가 예정
79+
// 이메일 인증 토큰 생성 및 이메일 발송
80+
String emailToken = tokenService.createEmailVerificationToken(saved.getId());
81+
emailService.sendVerificationEmail(saved.getEmail(), emailToken);
8182

8283
// UserResponse 변환 및 반환
8384
return UserResponse.from(saved);
8485
}
8586

87+
/**
88+
* 이메일 인증 서비스
89+
* 1. 토큰 존재 여부 확인
90+
* 2. 사용자 조회 및 활성화 (PENDING -> ACTIVE)
91+
* 5. 토큰 삭제
92+
* 6. UserResponse 반환
93+
*/
94+
public UserResponse verifyEmail(String token) {
95+
96+
// 토큰 존재 여부 확인
97+
if (token == null || token.isEmpty()) {
98+
throw new CustomException(ErrorCode.BAD_REQUEST);
99+
}
100+
101+
// 토큰으로 사용자 ID 조회
102+
Long userId = tokenService.getUserIdByEmailVerificationToken(token);
103+
if (userId == null) {
104+
throw new CustomException(ErrorCode.INVALID_EMAIL_TOKEN);
105+
}
106+
107+
// 사용자 조회
108+
User user = userRepository.findById(userId)
109+
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
110+
111+
// 사용자 상태 검증
112+
if (user.getUserStatus() == UserStatus.ACTIVE) {
113+
throw new CustomException(ErrorCode.ALREADY_VERIFIED);
114+
}
115+
116+
// 사용자 상태를 ACTIVE로 변경
117+
user.setUserStatus(UserStatus.ACTIVE);
118+
119+
// 토큰 삭제 (재사용 방지)
120+
tokenService.deleteEmailVerificationToken(token);
121+
122+
// UserResponse 반환
123+
return UserResponse.from(user);
124+
}
125+
126+
/**
127+
* 인증 메일 재발송 서비스
128+
* 1. 사용자 조회
129+
* 2. 이미 활성화된 사용자면 예외 처리
130+
* 3. 새로운 이메일 인증 토큰 생성
131+
* 4. 이메일 발송
132+
*/
133+
public void resendVerificationEmail(String email) {
134+
// 사용자 조회
135+
User user = userRepository.findByEmail(email)
136+
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
137+
138+
// 이미 활성화된 사용자면 예외 처리
139+
if (user.getUserStatus() == UserStatus.ACTIVE) {
140+
throw new CustomException(ErrorCode.ALREADY_VERIFIED);
141+
}
142+
143+
// TODO: 기존 토큰이 남아있는 경우 삭제하는 로직 추가 고려
144+
145+
// 새로운 이메일 인증 토큰 생성
146+
String emailToken = tokenService.createEmailVerificationToken(user.getId());
147+
148+
// 이메일 발송
149+
emailService.sendVerificationEmail(user.getEmail(), emailToken);
150+
}
151+
86152
/**
87153
* 로그인 서비스
88154
* 1. 사용자 조회 및 비밀번호 검증

0 commit comments

Comments
 (0)