Skip to content

Commit 32748dd

Browse files
authored
Merge pull request #175 from prgrms-web-devcourse-final-project/feature/EA3-173-user-mail
[EA3-173] 회원가입 시 메일을 통한 인증 기능 추가
2 parents 285b198 + fe48dd5 commit 32748dd

File tree

15 files changed

+314
-59
lines changed

15 files changed

+314
-59
lines changed

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ dependencies {
9191

9292
// WebSocket + STOMP 통신용
9393
implementation 'org.springframework.boot:spring-boot-starter-websocket'
94+
95+
// 이메일 전송 의존성
96+
implementation 'org.springframework.boot:spring-boot-starter-mail'
9497
}
9598

9699
tasks.named('test') {

src/main/java/grep/neogul_coder/domain/users/controller/UserController.java

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,36 @@
44
import grep.neogul_coder.domain.users.controller.dto.request.SignUpRequest;
55
import grep.neogul_coder.domain.users.controller.dto.request.UpdatePasswordRequest;
66
import grep.neogul_coder.domain.users.controller.dto.response.UserResponse;
7+
import grep.neogul_coder.domain.users.service.EmailVerificationService;
78
import grep.neogul_coder.domain.users.service.UserService;
89
import grep.neogul_coder.global.auth.Principal;
910
import grep.neogul_coder.global.response.ApiResponse;
1011
import jakarta.validation.Valid;
12+
import java.io.IOException;
1113
import lombok.RequiredArgsConstructor;
1214
import org.springframework.http.HttpStatus;
1315
import org.springframework.http.MediaType;
1416
import org.springframework.security.core.annotation.AuthenticationPrincipal;
15-
import org.springframework.web.bind.annotation.*;
17+
import org.springframework.web.bind.annotation.DeleteMapping;
18+
import org.springframework.web.bind.annotation.GetMapping;
19+
import org.springframework.web.bind.annotation.PathVariable;
20+
import org.springframework.web.bind.annotation.PostMapping;
21+
import org.springframework.web.bind.annotation.PutMapping;
22+
import org.springframework.web.bind.annotation.RequestBody;
23+
import org.springframework.web.bind.annotation.RequestMapping;
24+
import org.springframework.web.bind.annotation.RequestParam;
25+
import org.springframework.web.bind.annotation.RequestPart;
26+
import org.springframework.web.bind.annotation.ResponseStatus;
27+
import org.springframework.web.bind.annotation.RestController;
1628
import org.springframework.web.multipart.MultipartFile;
1729

18-
import java.io.IOException;
19-
2030
@RestController
2131
@RequiredArgsConstructor
2232
@RequestMapping("/api/users")
2333
public class UserController implements UserSpecification {
2434

2535
private final UserService usersService;
36+
private final EmailVerificationService verificationService;
2637

2738
@GetMapping("/me")
2839
public ApiResponse<UserResponse> get(@AuthenticationPrincipal Principal principal) {
@@ -38,24 +49,25 @@ public ApiResponse<UserResponse> get(@PathVariable("userid") Long userId) {
3849

3950
@PutMapping(value = "/update/profile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
4051
public ApiResponse<Void> updateProfile(
41-
@AuthenticationPrincipal Principal principal,
42-
@RequestPart("nickname") String nickname,
43-
@RequestPart(value = "profileImage", required = false) MultipartFile profileImage
52+
@AuthenticationPrincipal Principal principal,
53+
@RequestPart("nickname") String nickname,
54+
@RequestPart(value = "profileImage", required = false) MultipartFile profileImage
4455
) throws IOException {
4556
usersService.updateProfile(principal.getUserId(), nickname, profileImage);
4657
return ApiResponse.noContent();
4758
}
4859

4960
@PutMapping("/update/password")
5061
public ApiResponse<Void> updatePassword(@AuthenticationPrincipal Principal principal,
51-
@Valid @RequestBody UpdatePasswordRequest request) {
52-
usersService.updatePassword(principal.getUserId(), request.getPassword(), request.getNewPassword(), request.getNewPasswordCheck());
62+
@Valid @RequestBody UpdatePasswordRequest request) {
63+
usersService.updatePassword(principal.getUserId(), request.getPassword(),
64+
request.getNewPassword(), request.getNewPasswordCheck());
5365
return ApiResponse.noContent();
5466
}
5567

5668
@DeleteMapping("/delete/me")
5769
public ApiResponse<Void> delete(@AuthenticationPrincipal Principal principal,
58-
@RequestBody @Valid PasswordRequest request) {
70+
@RequestBody @Valid PasswordRequest request) {
5971
usersService.deleteUser(principal.getUserId(), request.getPassword());
6072
return ApiResponse.noContent();
6173
}
@@ -67,4 +79,21 @@ public ApiResponse<Void> signUp(@Valid @RequestBody SignUpRequest request) {
6779
return ApiResponse.noContent();
6880
}
6981

82+
@PostMapping("/mail/send")
83+
public ApiResponse<Void> sendCode(@RequestParam String email) {
84+
verificationService.sendVerificationEmail(email);
85+
return ApiResponse.noContent();
86+
}
87+
88+
@PostMapping("/mail/verify")
89+
public ApiResponse<Void> verifyCode(
90+
@RequestParam String email,
91+
@RequestParam String code
92+
) {
93+
boolean result = verificationService.verifyCode(email, code);
94+
return result ?
95+
ApiResponse.noContent() :
96+
ApiResponse.badRequest();
97+
}
98+
7099
}

src/main/java/grep/neogul_coder/domain/users/controller/UserSpecification.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
import grep.neogul_coder.global.auth.Principal;
88
import grep.neogul_coder.global.response.ApiResponse;
99
import io.swagger.v3.oas.annotations.Operation;
10+
import io.swagger.v3.oas.annotations.Parameter;
1011
import io.swagger.v3.oas.annotations.parameters.RequestBody;
1112
import io.swagger.v3.oas.annotations.tags.Tag;
1213
import java.io.IOException;
1314
import org.springframework.security.core.annotation.AuthenticationPrincipal;
15+
import org.springframework.web.bind.annotation.RequestParam;
1416
import org.springframework.web.bind.annotation.RequestPart;
1517
import org.springframework.web.multipart.MultipartFile;
1618

@@ -36,4 +38,19 @@ ApiResponse<Void> updatePassword(@AuthenticationPrincipal Principal principal,
3638
@Operation(summary = "회원 상태 삭제로 변경", description = "회원 상태를 삭제로 변경합니다.")
3739
ApiResponse<Void> delete(@AuthenticationPrincipal Principal principal,
3840
@RequestBody PasswordRequest request);
41+
42+
@Operation(summary = "이메일 인증 코드 발송", description = "입력한 이메일 주소로 인증 코드를 발송합니다.")
43+
ApiResponse<Void> sendCode(
44+
@Parameter(description = "인증 코드를 보낼 이메일 주소", required = true, example = "[email protected]")
45+
@RequestParam String email
46+
);
47+
48+
@Operation(summary = "이메일 인증 코드 검증", description = "사용자가 입력한 인증 코드가 올바른지 검증합니다.")
49+
ApiResponse<Void> verifyCode(
50+
@Parameter(description = "인증 요청한 이메일 주소", required = true, example = "[email protected]")
51+
@RequestParam String email,
52+
53+
@Parameter(description = "사용자가 입력한 인증 코드", required = true, example = "123456")
54+
@RequestParam String code
55+
);
3956
}

src/main/java/grep/neogul_coder/domain/users/controller/dto/response/UserResponse.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,24 +20,29 @@ public class UserResponse {
2020
@Schema(description = "회원 프로필 이미지", example = "profileImageUrl")
2121
private String profileImageUrl;
2222

23+
@Schema(description = "회원 OAuth 정보", example = "Google")
24+
private String oauth;
25+
2326
@Schema(description = "Role")
2427
private Role role;
2528

2629
@Builder
27-
private UserResponse(Long id, String email, String nickname,String profileImageUrl, Role role) {
30+
private UserResponse(Long id, String email, String nickname,String profileImageUrl, String oauth ,Role role) {
2831
this.id = id;
2932
this.email = email;
3033
this.nickname = nickname;
3134
this.profileImageUrl = profileImageUrl;
35+
this.oauth = oauth;
3236
this.role = role;
3337
}
3438

35-
public static UserResponse toUserResponse(Long id, String email, String nickname,String profileImageUrl, Role role){
39+
public static UserResponse toUserResponse(Long id, String email, String nickname,String profileImageUrl, String oauth, Role role){
3640
return UserResponse.builder()
3741
.id(id)
3842
.email(email)
3943
.nickname(nickname)
4044
.profileImageUrl(profileImageUrl)
45+
.oauth(oauth)
4146
.role(role)
4247
.build();
4348
}

src/main/java/grep/neogul_coder/domain/users/entity/User.java

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22

33
import grep.neogul_coder.global.auth.code.Role;
44
import grep.neogul_coder.global.entity.BaseEntity;
5-
import jakarta.persistence.*;
5+
import jakarta.persistence.Entity;
6+
import jakarta.persistence.EnumType;
7+
import jakarta.persistence.Enumerated;
8+
import jakarta.persistence.GeneratedValue;
9+
import jakarta.persistence.GenerationType;
10+
import jakarta.persistence.Id;
11+
import jakarta.persistence.Table;
612
import jakarta.validation.constraints.Email;
713
import lombok.Builder;
814
import lombok.Getter;
@@ -34,11 +40,12 @@ public class User extends BaseEntity {
3440

3541
public static User UserInit(String email, String password, String nickname) {
3642
return User.builder()
37-
.email(email)
38-
.password(password)
39-
.nickname(nickname)
40-
.role(Role.ROLE_USER)
41-
.build();
43+
.email(email)
44+
.password(password)
45+
.nickname(nickname)
46+
.role(Role.ROLE_USER)
47+
.activated(true)
48+
.build();
4249
}
4350

4451
public void updateProfile(String nickname, String profileImageUrl) {
@@ -58,14 +65,15 @@ public void delete() {
5865

5966
@Builder
6067
private User(Long id, String oauthId, String oauthProvider, String email, String password,
61-
String nickname, String profileImageUrl, Role role) {
68+
String nickname, String profileImageUrl, Boolean activated, Role role) {
6269
this.id = id;
6370
this.oauthId = oauthId;
6471
this.oauthProvider = oauthProvider;
6572
this.email = email;
6673
this.password = password;
6774
this.nickname = nickname;
6875
this.profileImageUrl = profileImageUrl;
76+
this.activated = activated;
6977
this.role = role;
7078
}
7179

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package grep.neogul_coder.domain.users.exception;
2+
3+
import grep.neogul_coder.global.exception.business.BusinessException;
4+
import grep.neogul_coder.global.response.code.ErrorCode;
5+
6+
public class NotVerifiedEmailException extends BusinessException {
7+
8+
public NotVerifiedEmailException(ErrorCode errorCode) {
9+
super(errorCode);
10+
}
11+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package grep.neogul_coder.domain.users.exception;
2+
3+
import grep.neogul_coder.global.exception.business.BusinessException;
4+
import grep.neogul_coder.global.response.code.ErrorCode;
5+
6+
public class UnActivatedUserException extends BusinessException {
7+
8+
public UnActivatedUserException(ErrorCode errorCode) {
9+
super(errorCode);
10+
}
11+
}

src/main/java/grep/neogul_coder/domain/users/exception/advice/UserAdvice.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import grep.neogul_coder.domain.users.exception.EmailDuplicationException;
44
import grep.neogul_coder.domain.users.exception.NicknameDuplicatedException;
5+
import grep.neogul_coder.domain.users.exception.NotVerifiedEmailException;
56
import grep.neogul_coder.domain.users.exception.PasswordNotMatchException;
67
import grep.neogul_coder.domain.users.exception.PasswordUncheckException;
78
import grep.neogul_coder.domain.users.exception.UserNotFoundException;
@@ -50,4 +51,11 @@ public ResponseEntity<ApiResponse<Void>> nicknameDuplicationException(NicknameDu
5051
.body(ApiResponse.errorWithoutData(UserErrorCode.IS_DUPLICATED_NICKNAME));
5152
}
5253

54+
@ ExceptionHandler(NotVerifiedEmailException.class)
55+
public ResponseEntity<ApiResponse<Void>> notVerifiedEmailException(NotVerifiedEmailException ex) {
56+
return ResponseEntity
57+
.status(HttpStatus.BAD_REQUEST)
58+
.body(ApiResponse.errorWithoutData(UserErrorCode.NOT_VERIFIED_EMAIL));
59+
}
60+
5361
}

src/main/java/grep/neogul_coder/domain/users/exception/code/UserErrorCode.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ public enum UserErrorCode implements ErrorCode {
1111
PASSWORD_MISMATCH("U002", HttpStatus.BAD_REQUEST, "비밀번호를 다시 확인해주세요."),
1212
PASSWORD_UNCHECKED("U003", HttpStatus.BAD_REQUEST, "비밀번호와 비밀번호 확인이 다릅니다"),
1313
IS_DUPLICATED_MALI("U004", HttpStatus.BAD_REQUEST,"이미 존재하는 이메일입니다."),
14-
IS_DUPLICATED_NICKNAME("U005", HttpStatus.BAD_REQUEST,"이미 존재하는 닉네임입니다.");
14+
IS_DUPLICATED_NICKNAME("U005", HttpStatus.BAD_REQUEST,"이미 존재하는 닉네임입니다."),
15+
UNACTIVATED_USER("U006", HttpStatus.BAD_REQUEST,"탈퇴된 회원입니다."),
16+
NOT_VERIFIED_EMAIL("U007", HttpStatus.BAD_REQUEST, "메일 인증이 되지 않은 이메일입니다.");
1517

1618

1719
private final String code;
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package grep.neogul_coder.domain.users.service;
2+
3+
import java.time.Duration;
4+
import java.util.Random;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.data.redis.core.RedisTemplate;
7+
import org.springframework.mail.SimpleMailMessage;
8+
import org.springframework.mail.javamail.JavaMailSender;
9+
import org.springframework.stereotype.Service;
10+
11+
@Service
12+
@RequiredArgsConstructor
13+
public class EmailVerificationService {
14+
15+
private final JavaMailSender mailSender;
16+
private final RedisTemplate<String, Object> redisTemplate;
17+
18+
private static final long CODE_TTL_SECONDS = 300;
19+
20+
public void sendVerificationEmail(String email) {
21+
String code = generateRandomCode();
22+
23+
sendEmail(email,code);
24+
25+
redisTemplate.opsForValue().set(getRedisKey(email), code, Duration.ofSeconds(CODE_TTL_SECONDS));
26+
}
27+
28+
public boolean verifyCode(String email, String inputCode) {
29+
String redisKey = getRedisKey(email);
30+
Object storedCode = redisTemplate.opsForValue().get(getRedisKey(email));
31+
32+
33+
if (isValidCode(storedCode,inputCode)) {
34+
redisTemplate.delete(redisKey);
35+
36+
redisTemplate.opsForValue().set(getVerifiedKey(email), "true", Duration.ofMinutes(10));
37+
return true;
38+
}
39+
return false;
40+
}
41+
42+
public boolean isNotEmailVerified(String email) {
43+
Object value = redisTemplate.opsForValue().get(getVerifiedKey(email));
44+
return !"true".equals(value);
45+
}
46+
47+
public void clearVerifiedStatus(String email) {
48+
redisTemplate.delete(getVerifiedKey(email));
49+
}
50+
51+
private void sendEmail(String to, String code) {
52+
SimpleMailMessage message = new SimpleMailMessage();
53+
message.setTo(to);
54+
message.setSubject("[wibby] 이메일 인증 코드");
55+
message.setText("인증 코드: " + code + "\n5분 안에 입력해주세요.");
56+
mailSender.send(message);
57+
}
58+
59+
private String generateRandomCode() {
60+
return String.format("%06d", new Random().nextInt(1000000));
61+
}
62+
63+
private String getRedisKey(String email) {
64+
return "email_verification:" + email;
65+
}
66+
67+
private String getVerifiedKey(String email) {
68+
return "email_verified:" + email;
69+
}
70+
71+
private boolean isValidCode(Object storedCode, String inputCode) {
72+
return storedCode != null && storedCode.equals(inputCode);
73+
}
74+
}

0 commit comments

Comments
 (0)