Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c5b7666
:sparkles: ์ด๋ฉ”์ผ ์ธ์ฆ ๊ธฐ๋Šฅ ์˜์กด์„ฑ ์ถ”๊ฐ€
ohyuchan123 Jan 7, 2025
8a35e5b
:sparkles: Email ์ธ์ฆ ๋ฒˆํ˜ธ ์ „์†ก ๋ฐ ์ฒดํฌ ๊ธฐ๋Šฅ ๊ตฌํ˜„
ohyuchan123 Jan 7, 2025
b4a5657
:recycle: ์ด๋ฉ”์ผ ์ธ์ฆ ๋ฒˆํ˜ธ ์ „๋‹ฌ ๋ฐฉ์‹ ๋ฆฌํŒฉํ† ๋ง(์กฐ๊ธˆ๋” ๋ณด์•ˆ์ ์œผ๋กœ)
ohyuchan123 Jan 8, 2025
92944df
:sparkles: ์ด๋ฉ”์ผ ์ธ์ฆ ๋ฒˆํ˜ธ ์—ฌ๋Ÿฌ๋ฒˆ ์š”์ฒญ ์ œํ•œ (1๋ถ„๋‹น ์ตœ๋Œ€ 3๋ฒˆ)
ohyuchan123 Jan 8, 2025
3915305
:recycle: ์ง€๊ธˆ์€ ์Šค์ผ€์ค„๋Ÿฌ ๋ฐฉ์‹์„ ํ†ตํ•ด์„œ ์ธ๋ฉ”๋ชจ๋ฆฌ๋ฅผ ์ œ๊ฑฐ ์ถ”ํ›„ Redis๋ฅผ ํ†ตํ•ด์„œ ๊ฐœ์„  ํ•„์š”
ohyuchan123 Jan 8, 2025
d6f20ad
:recycle: ์ธ์ฆ๋ฒˆํ˜ธ ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ์ง€์ •
ohyuchan123 Jan 8, 2025
5fca75a
:recycle: ์ธ์ฆ๋ฒˆํ˜ธ ์š”์ฒญ ๋งŽ์€ ์‹œ ๋ฐœ์ƒํ•˜๋Š” ์˜ˆ์™ธ์ฒ˜๋ฆฌ ์ƒ์„ธํ™”
ohyuchan123 Jan 8, 2025
b40a9e5
:recycle: VerificationData Record๋ฅผ ํ†ตํ•ด์„œ ๋” ํšจ์œจ์ ์œผ๋กœ ์ฝ”๋“œ ๊ด€๋ฆฌ
ohyuchan123 Jan 8, 2025
0bae0de
:recycle: ์ฝ”๋“œ ๊ตฌ์กฐ ๊ฐœ์„ 
ohyuchan123 Jan 8, 2025
0bb17dc
:fire: ๋ถˆํ•„์š”ํ•œ import ๋ฌธ ์ œ๊ฑฐ
ohyuchan123 Jan 8, 2025
13cd90f
:fire: ๋ถˆํ•„์š”ํ•œ gitkeep ์ œ๊ฑฐ
ohyuchan123 Jan 8, 2025
1da4aef
:recycle: ApiResponse ์ „์†ก ๋ฐฉ์‹ ์–‘์‹ ๋ณ€๊ฒฝ
ohyuchan123 Jan 8, 2025
579d964
:recycle: ์˜ˆ์™ธ ์ฒ˜๋ฆฌ ์–‘์‹ ๋ณ€๊ฒฝ
ohyuchan123 Jan 8, 2025
ed0d689
:recycle: ์ด๋ฉ”์ผ ์ธ์ฆ ๋ฐฉ์‹ Request ์™€ Response Dto ๋ฐฉ์‹์œผ๋กœ ์ฝ”๋“œ ๊ฐœ์„ 
ohyuchan123 Jan 8, 2025
8aa701d
:fire: ๋ถˆํ•„์š”ํ•œ import ์ œ๊ฑฐ
ohyuchan123 Jan 8, 2025
9991a3d
:recycle: ์˜ˆ์™ธ์ฒ˜๋ฆฌ ๋กœ์ง ๊ฐœ์„ 
ohyuchan123 Jan 8, 2025
89a7f63
:recycle: ์ด๋ฉ”์ผ ์ธ์ฆ ๋กœ์ง ๊ฐœ์„  Get -> Post
ohyuchan123 Jan 8, 2025
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
Empty file removed .gitkeep
Empty file.
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,11 @@ dependencies {
// Security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'

}

test {
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/mycom/socket/GoSocketBeApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@EnableScheduling
@SpringBootApplication
public class GoSocketBeApplication {

Expand Down
37 changes: 33 additions & 4 deletions src/main/java/com/mycom/socket/auth/controller/AuthController.java
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
package com.mycom.socket.auth.controller;

import com.mycom.socket.auth.dto.request.EmailRequestDto;
import com.mycom.socket.auth.dto.request.EmailVerificationRequestDto;
import com.mycom.socket.auth.dto.request.LoginRequestDto;
import com.mycom.socket.auth.dto.request.RegisterRequestDto;
import com.mycom.socket.auth.dto.response.EmailVerificationCheckResponseDto;
import com.mycom.socket.auth.dto.response.EmailVerificationResponseDto;
import com.mycom.socket.auth.dto.response.LoginResponseDto;
import com.mycom.socket.auth.service.AuthService;
import com.mycom.socket.auth.service.MailService;
import com.mycom.socket.auth.service.RateLimiter;
import com.mycom.socket.global.exception.BaseException;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
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;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

private final AuthService authService;
private final MailService mailService;
private final RateLimiter rateLimiter;

@PostMapping("/login")
public LoginResponseDto login(@Valid @RequestBody LoginRequestDto request,
Expand All @@ -34,4 +40,27 @@ public void logout(HttpServletResponse response) {
public Long register(@Valid @RequestBody RegisterRequestDto request) {
return authService.register(request);
}

@PostMapping("/verification")
public EmailVerificationResponseDto mailSend(@Valid @RequestBody EmailRequestDto emailRequestDto) {
try {
boolean isSuccess = mailService.sendMail(emailRequestDto.email());
return isSuccess ? EmailVerificationResponseDto.createSuccessResponse() : EmailVerificationResponseDto.createFailureResponse("์ด๋ฉ”์ผ ์ „์†ก์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.");
} catch (BaseException e) {
return EmailVerificationResponseDto.createFailureResponse(e.getMessage());
}
}

@PostMapping("/email/verify")
public EmailVerificationCheckResponseDto mailCheck(@Valid @RequestBody EmailVerificationRequestDto emailRequestDto) {
try{
rateLimiter.checkRateLimit(emailRequestDto.email());// ์‹œ๋„ ํšŸ์ˆ˜ ์ œํ•œ
boolean isVerified = mailService.verifyCode(emailRequestDto.email(), emailRequestDto.code());
return isVerified ? EmailVerificationCheckResponseDto.createSuccessResponse() :
EmailVerificationCheckResponseDto.createFailureResponse("์ด๋ฉ”์ผ ์ธ์ฆ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.");
}catch (BaseException e){
return EmailVerificationCheckResponseDto.createFailureResponse(e.getMessage());
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.mycom.socket.auth.dto.request;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;

public record EmailRequestDto(
@NotEmpty(message = "์ด๋ฉ”์ผ ์ฃผ์†Œ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.")
@Email(message = "์œ ํšจํ•˜์ง€ ์•Š์€ ์ด๋ฉ”์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค.")
String email
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.mycom.socket.auth.dto.request;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Pattern;

public record EmailVerificationRequestDto(
@NotEmpty(message = "์ด๋ฉ”์ผ ์ฃผ์†Œ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.")
@Email(message = "์œ ํšจํ•˜์ง€ ์•Š์€ ์ด๋ฉ”์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค.")
String email,
@NotEmpty(message = "์ธ์ฆ ์ฝ”๋“œ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.")
@Pattern(regexp = "^[0-9]{6}$", message = "์ธ์ฆ ์ฝ”๋“œ๋Š” 6์ž๋ฆฌ ์ˆซ์ž์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.")
String code
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.mycom.socket.auth.dto.response;

import com.mycom.socket.global.dto.ApiResponse;

public record EmailVerificationCheckResponseDto(ApiResponse<Boolean> apiResponse) {

public static EmailVerificationCheckResponseDto createSuccessResponse() {
return new EmailVerificationCheckResponseDto(ApiResponse.success("์ด๋ฉ”์ผ ์ธ์ฆ ์„ฑ๊ณต", true));
}

public static EmailVerificationCheckResponseDto createFailureResponse(String errorMessage) {
return new EmailVerificationCheckResponseDto(ApiResponse.error(errorMessage));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.mycom.socket.auth.dto.response;

import com.mycom.socket.global.dto.ApiResponse;

public record EmailVerificationResponseDto(ApiResponse<String> apiResponse) {

public static EmailVerificationResponseDto createSuccessResponse() {
return new EmailVerificationResponseDto(ApiResponse.success("์ด๋ฉ”์ผ ์ „์†ก ์„ฑ๊ณต"));
}

public static EmailVerificationResponseDto createFailureResponse(String errorMessage) {
return new EmailVerificationResponseDto(ApiResponse.error(errorMessage));
}
}
4 changes: 0 additions & 4 deletions src/main/java/com/mycom/socket/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,6 @@ public LoginResponseDto login(LoginRequestDto request, HttpServletResponse respo
);
}

// ์ด๋ฉ”์ผ ์ธ์ฆ ์ฝ”๋“œ ์ „์†ก

// ์ด๋ฉ”์ผ ์ธ์ฆ ์ฝ”๋“œ ๋งŒ๋ฃŒ

@Transactional
public Long register(RegisterRequestDto request) {
// ์ด๋ฉ”์ผ ์ค‘๋ณต ๊ฒ€์‚ฌ
Expand Down
111 changes: 111 additions & 0 deletions src/main/java/com/mycom/socket/auth/service/MailService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package com.mycom.socket.auth.service;

import com.mycom.socket.auth.service.data.VerificationData;
import com.mycom.socket.global.exception.BaseException;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.security.SecureRandom;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Service
@RequiredArgsConstructor
public class MailService {

private final JavaMailSender javaMailSender;
private final RateLimiter rateLimiter; // ์ธ์ฆ ๋ฒˆํ˜ธ ์š”์ฒญ ์ œํ•œ

private final Map<String, VerificationData> verificationDataMap = new ConcurrentHashMap<>();

@Value("${spring.mail.username}")
private String senderEmail;

/**
* 6์ž๋ฆฌ ๋‚œ์ˆ˜ ์ธ์ฆ๋ฒˆํ˜ธ ์ƒ์„ฑ
* SecureRandom ์‚ฌ์šฉํ•˜์—ฌ ๋ณด์•ˆ์„ฑ ํ–ฅ์ƒ
* @return 100000~999999 ๋ฒ”์œ„์˜ ์ธ์ฆ๋ฒˆํ˜ธ
*/
private String createVerificationCode() {
// Math.random()์€ ์˜ˆ์ธก ๊ฐ€๋Šฅํ•œ ๋‚œ์ˆ˜๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์–ด ๋ณด์•ˆ์— ์ทจ์•ฝ
// SecureRandom์€ ์•”ํ˜ธํ•™์ ์œผ๋กœ ์•ˆ์ „ํ•œ ๋‚œ์ˆ˜๋ฅผ ์ƒ์„ฑํ•˜๋ฏ€๋กœ ์ธ์ฆ๋ฒˆํ˜ธ ์ƒ์„ฑ์— ๋” ์ ํ•ฉ
SecureRandom secureRandom = new SecureRandom();
return String.format("%06d", secureRandom.nextInt(1000000));
}

/**
* ์ธ์ฆ๋ฉ”์ผ ์ƒ์„ฑ
* @param mail ์ˆ˜์‹ ์ž ์ด๋ฉ”์ผ ์ฃผ์†Œ
* @return ์ƒ์„ฑ๋œ ์ธ์ฆ๋ฉ”์ผ
*/
public MimeMessage createMail(String mail, String verificationCode) {
MimeMessage message = javaMailSender.createMimeMessage();
try {
message.setFrom(senderEmail);
message.setRecipients(MimeMessage.RecipientType.TO, mail);
message.setSubject("์ด๋ฉ”์ผ ์ธ์ฆ");
String body = String.format("""
<h3>์š”์ฒญํ•˜์‹  ์ธ์ฆ ๋ฒˆํ˜ธ์ž…๋‹ˆ๋‹ค.</h3>
<h1>%s</h1>
<h3>๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค.</h3>
""", verificationCode);
message.setText(body, "UTF-8", "html");
} catch (MessagingException e) {
throw new BaseException("์ด๋ฉ”์ผ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: " + e.getMessage(),
HttpStatus.BAD_REQUEST);
}
return message;
}

/**
* ์ธ์ฆ๋ฉ”์ผ ๋ฐœ์†ก ๋ฐ ์ธ์ฆ๋ฒˆํ˜ธ ๋ฐ˜ํ™˜
* @param mail ์ˆ˜์‹ ์ž ์ด๋ฉ”์ผ ์ฃผ์†Œ
* @return ์ƒ์„ฑ๋œ ์ธ์ฆ๋ฒˆํ˜ธ
*/
public boolean sendMail(String mail) {
rateLimiter.checkRateLimit(mail);
String verificationCode = createVerificationCode();
verificationDataMap.put(mail, new VerificationData(verificationCode));

MimeMessage message = createMail(mail, verificationCode);
try{
javaMailSender.send(message);
return true;
}catch (Exception e) {
throw new BaseException("์ด๋ฉ”์ผ ๋ฐœ์†ก ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: " + e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
}
}

/**
* ์ธ์ฆ๋ฒˆํ˜ธ ๊ฒ€์ฆ
* @param email ์ˆ˜์‹ ์ž ์ด๋ฉ”์ผ ์ฃผ์†Œ
* @param code ์‚ฌ์šฉ์ž๊ฐ€ ์ž…๋ ฅํ•œ ์ธ์ฆ๋ฒˆํ˜ธ
* @return ์ธ์ฆ๋ฒˆํ˜ธ ์ผ์น˜ ์—ฌ๋ถ€
*/
public boolean verifyCode(String email, String code) {
if (!StringUtils.hasText(code) || !code.matches("\\d{6}")) {
return false;
}

VerificationData data = verificationDataMap.get(email);

if (data == null || data.isExpired()) {
return false;
}

boolean isVerified = data.code().equals(code);

if (isVerified){
verificationDataMap.remove(email);
}

return isVerified;
}
}

47 changes: 47 additions & 0 deletions src/main/java/com/mycom/socket/auth/service/RateLimiter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.mycom.socket.auth.service;

import com.mycom.socket.global.exception.BaseException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Component
@RequiredArgsConstructor
public class RateLimiter {
private final Map<String, List<LocalDateTime>> requestMap = new ConcurrentHashMap<>();
private static final int MAX_REQUESTS = 3; // 1๋ถ„๋‹น ์ตœ๋Œ€ 3๋ฒˆ
private static final Duration WINDOW_SIZE = Duration.ofMinutes(1); // 1๋ถ„์˜ ์‹œ๊ฐ„ ๊ฐ„๊ฒฉ
Comment on lines +19 to +21
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ› ๏ธ Refactor suggestion

๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜ ๊ฐ€๋Šฅ์„ฑ ๊ฐœ์„  ํ•„์š”

requestMap์— ์ €์žฅ๋œ ์ด๋ฉ”์ผ ์ฃผ์†Œ์™€ ์š”์ฒญ ์‹œ๊ฐ„์ด ์ง€์†์ ์œผ๋กœ ๋ˆ„์ ๋˜์–ด ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ฃผ๊ธฐ์ ์œผ๋กœ ์˜ค๋ž˜๋œ ๋ฐ์ดํ„ฐ๋ฅผ ์ •๋ฆฌํ•˜๋Š” ๋ฉ”์ปค๋‹ˆ์ฆ˜์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฐœ์„ ์„ ์ œ์•ˆํ•ฉ๋‹ˆ๋‹ค:

 private final Map<String, List<LocalDateTime>> requestMap = new ConcurrentHashMap<>();
 private static final int MAX_REQUESTS = 3;  // 1๋ถ„๋‹น ์ตœ๋Œ€ 3๋ฒˆ
 private static final Duration WINDOW_SIZE = Duration.ofMinutes(1);  // 1๋ถ„์˜ ์‹œ๊ฐ„ ๊ฐ„๊ฒฉ
+private static final Duration CLEANUP_INTERVAL = Duration.ofHours(1);
+
+@Scheduled(fixedRate = 3600000) // 1์‹œ๊ฐ„๋งˆ๋‹ค ์‹คํ–‰
+public void cleanup() {
+    LocalDateTime threshold = LocalDateTime.now().minus(WINDOW_SIZE);
+    requestMap.entrySet().removeIf(entry ->
+        entry.getValue().stream().allMatch(time -> time.isBefore(threshold)));
+}

Committable suggestion skipped: line range outside the PR's diff.


@Scheduled(fixedRate = 3600000) // 1์‹œ๊ฐ„๋งˆ๋‹ค ์‹คํ–‰
public void cleanup() {
LocalDateTime threshold = LocalDateTime.now().minus(WINDOW_SIZE);
requestMap.entrySet().removeIf(entry ->
entry.getValue().stream().allMatch(time -> time.isBefore(threshold)));
}

public void checkRateLimit(String email) {
List<LocalDateTime> requests = requestMap.computeIfAbsent(email, k -> new ArrayList<>());
LocalDateTime now = LocalDateTime.now();

requests.removeIf(requestTime ->
requestTime.plus(WINDOW_SIZE).isBefore(now));

if (requests.size() >= MAX_REQUESTS) {
LocalDateTime oldestRequest = requests.get(0);
Duration waitTime = WINDOW_SIZE.minus(Duration.between(oldestRequest, now));
throw new BaseException(
String.format("๋„ˆ๋ฌด ๋งŽ์€ ์š”์ฒญ์ž…๋‹ˆ๋‹ค. %d์ดˆ ํ›„์— ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.",waitTime.getSeconds()),
HttpStatus.TOO_MANY_REQUESTS);
}

requests.add(now);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.mycom.socket.auth.service.data;

import java.time.LocalDateTime;

import java.time.Duration;

public record VerificationData(String code, LocalDateTime expiryTime) {

private static final Duration CODE_VALID_DURATION = Duration.ofMinutes(5);

public VerificationData(String code) {
this(code, LocalDateTime.now().plus(CODE_VALID_DURATION));
}

public boolean isExpired() {
return LocalDateTime.now().isAfter(expiryTime);
}
}
Loading
Loading