diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8f017c8 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Database +DB_URL=jdbc:mysql://localhost:3306/whereyouad?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 +DB_USERNAME=root +DB_PASSWORD= + +# Redis +REDIS_HOST=localhost +REDIS_PORT=6379 + +# Mail +MAIL_USERNAME=your-email@gmail.com +MAIL_PASSWORD=email-app-password + +# JWT +JWT_SECRET=your_very_long_and_secret_random_string_for_signing_tokens_to_match_HS512 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 284990a..ad380ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,8 +44,11 @@ jobs: run: ./gradlew build # CI용, 환경변수로 DB 설정 env: - SPRING_DATASOURCE_URL: jdbc:mysql://localhost:3306/where_you_ad?serverTimezone=Asia/Seoul - SPRING_DATASOURCE_USERNAME: root - SPRING_DATASOURCE_PASSWORD: root - SPRING_DATA_REDIS_HOST: localhost - SPRING_DATA_REDIS_PORT: 6379 \ No newline at end of file + DB_URL: jdbc:mysql://localhost:3306/where_you_ad?serverTimezone=Asia/Seoul + DB_USERNAME: root + DB_PASSWORD: root + REDIS_HOST: localhost + REDIS_PORT: 6379 + MAIL_USERNAME: test@example.com + MAIL_PASSWORD: test_password + JWT_SECRET: dGhpcy1pcy1hLXRlc3Qtc2VjcmV0LWtleS1mb3ItY2ktdGVzdC1wdXJwb3Nlcw== \ No newline at end of file diff --git a/.gitignore b/.gitignore index a50e99a..d0dc5cd 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,5 @@ out/ ### VS Code ### .vscode/ -src/main/resources/application.yml +#src/main/resources/application.yml +.env diff --git a/build.gradle b/build.gradle index f352d78..6f7a92b 100644 --- a/build.gradle +++ b/build.gradle @@ -53,6 +53,9 @@ dependencies { //swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.0' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.0' + + //Email + implementation 'org.springframework.boot:spring-boot-starter-mail' } tasks.named('test') { diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/application/dto/request/EmailRequest.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/application/dto/request/EmailRequest.java new file mode 100644 index 0000000..dd78fee --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/application/dto/request/EmailRequest.java @@ -0,0 +1,21 @@ +package com.whereyouad.WhereYouAd.domains.user.application.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public class EmailRequest { + + public record Send ( + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email + ) {} + + public record Verify( + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email, + @NotBlank + String authCode + ) {} +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/application/dto/response/EmailSentResponse.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/application/dto/response/EmailSentResponse.java new file mode 100644 index 0000000..43e50e5 --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/application/dto/response/EmailSentResponse.java @@ -0,0 +1,8 @@ +package com.whereyouad.WhereYouAd.domains.user.application.dto.response; + +public record EmailSentResponse( + String message, //인증코드를 이메일로 전송했습니다. + String email, //전송한 이메일 + long expireIn //만료시간 (500L -> 500초) +) { +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/EmailService.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/EmailService.java new file mode 100644 index 0000000..abb31ce --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/EmailService.java @@ -0,0 +1,107 @@ +package com.whereyouad.WhereYouAd.domains.user.domain.service; + +import com.whereyouad.WhereYouAd.domains.user.application.dto.response.EmailSentResponse; +import com.whereyouad.WhereYouAd.domains.user.exception.UserSignUpException; +import com.whereyouad.WhereYouAd.domains.user.exception.code.UserErrorCode; +import com.whereyouad.WhereYouAd.domains.user.persistence.repository.UserRepository; +import com.whereyouad.WhereYouAd.global.utils.RedisUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.MailException; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@Service +@RequiredArgsConstructor +@Slf4j +public class EmailService { + + private final JavaMailSender emailSender; + private final RedisUtil redisUtil; + private final UserRepository userRepository; + + //application.yml 적용 필요 + @Value("${spring.mail.username}") + private String senderEmail; + + //인증코드 이메일 발송 로직 + public EmailSentResponse sendEmail(String toEmail) { + + if (userRepository.existsByEmail(toEmail)) { //이미 해당 이메일로 생성한 계정이 있으면 + throw new UserSignUpException(UserErrorCode.USER_EMAIL_DUPLICATE); //이메일 중복 예외(회원가입 시 사용했던 예외) + } + + //인증코드 재전송 로직 -> 이미 Redis 에 해당 이메일 인증코드가 있을시 삭제 + String redisKey = "CODE:" + toEmail; + if (redisUtil.getData(redisKey) != null) { + redisUtil.deleteData(redisKey); + } + + String authCode = createCode(); //인증코드 생성 + + if (isTestEmail(toEmail)) { + //테스트용 가짜 이메일은 서버 로그로만 인증코드를 보여주기 + //실제 이메일 발송 X + //존재하지 않는 이메일 주소로 계속 이메일을 보내면 인증코드 전송용 이메일 계정이 스팸처리 될 가능성 존재하여 로그로 남기기 + log.warn("{} 이메일 -> 테스트 or 개발용 가짜 이메일 입니다.", toEmail); + log.warn("[TEST 모드] 실제 발송을 건너뜁니다."); + log.warn("[TEST 모드] 수신자: {}", toEmail); + log.warn("[TEST 모드] 인증코드: {}", authCode); + + } else { //실제 존재하는 이메일이라면 + + try { + //실제 인증 코드가 담긴 이메일 전송 + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(toEmail); + message.setSubject("whereyouad 회원가입 인증번호"); + message.setText("인증번호: " + authCode); + message.setFrom(senderEmail); + + emailSender.send(message); //만약 실제 존재하는 이메일인데 사용자가 오타를 냈다면 + } catch (MailException e) { //예외 발생 + throw new UserSignUpException(UserErrorCode.USER_EMAIL_NOT_VALID); //통합 응답 처리 예외로 반환 + } + + } + + //Redis에 저장 (Key: "CODE:이메일", Value: "123456", 유효시간: 300초(5분)) + //테스트 계정도 인증은 해야하니 Redis 에 코드가 저장 되어야 함. + //테스트 계정의 인증은 서버 로그를 통해 인증코드를 얻어 입력. + redisUtil.setDataExpire("CODE:" + toEmail, authCode, 60 * 5L); + + return new EmailSentResponse("인증코드를 이메일로 전송했습니다.", toEmail, 300L); + } + + //인증코드 검증 메서드 + public void verifyEmailCode(String email, String inputCode) { + //해당 이메일 값으로 Redis 에서 조회 + String key = "CODE:" + email; + String savedCode = redisUtil.getData(key); + + //만약 인증코드가 없거나 잘못 입력했다면, + if (savedCode == null || !savedCode.equals(inputCode)) { + throw new UserSignUpException(UserErrorCode.USER_EMAIL_AUTH_INVALID); //예외 발생(BAD_REQUEST) + } + + //정상적으로 인증코드를 입력했다면, + //기존 Redis 에 저장된 데이터를 지우고, + redisUtil.deleteData(key); + //"해당 이메일이 정상적으로 인증되었다" 는 값을 다시 Redis 에 저장 -> 이후 회원가입(signup) 내부 로직에서 활용 + redisUtil.setDataExpire("VERIFIED:" + email, "TRUE", 60 * 60L); //1시간 뒤 Expire + } + + //테스트용 이메일은, "test" 로 시작하거나, "example.com" 으로 끝나야 한다. + private boolean isTestEmail(String email) { + return email.startsWith("test") || email.endsWith("example.com"); + } + + //무작위 인증코드 값 생성 + private String createCode() { + return String.valueOf((int)(Math.random() * (900000)) + 100000); + } +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/UserService.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/UserService.java index 6c3c8d6..c347422 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/UserService.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/UserService.java @@ -8,6 +8,7 @@ import com.whereyouad.WhereYouAd.domains.user.application.dto.response.SignUpResponse; import com.whereyouad.WhereYouAd.domains.user.persistence.entity.User; import com.whereyouad.WhereYouAd.domains.user.persistence.repository.UserRepository; +import com.whereyouad.WhereYouAd.global.utils.RedisUtil; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -20,6 +21,7 @@ public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; + private final RedisUtil redisUtil; //회원가입 메서드 public SignUpResponse signUpUser(SignUpRequest request) { @@ -27,6 +29,15 @@ public SignUpResponse signUpUser(SignUpRequest request) { throw new UserSignUpException(UserErrorCode.USER_EMAIL_DUPLICATE); //이메일 중복 예외 } + //추가 : 이메일 인증되었는지 확인 -> 악의적 공격자가 이메일 인증을 건너뛰고 회원가입 URL 등으로 바로 들어왔을 경우 + //Redis 에 해당 이메일 인증이 완료되었는지 값을 꺼내보기 + String isEmailVerified = redisUtil.getData("VERIFIED:" + request.email()); + + //인증이 안되었다면, + if (isEmailVerified == null || !isEmailVerified.equals("TRUE")) { + throw new UserSignUpException(UserErrorCode.USER_EMAIL_NOT_VERIFIED); //예외 발생(UNAUTHORIZED) + } + //비밀번호 암호화 -> SecurityConfig 클래스 내 에서 BCryptPasswordEncoder 를 Bean 등록한거로 사용 String encodedPwd = passwordEncoder.encode(request.password()); @@ -38,7 +49,7 @@ public SignUpResponse signUpUser(SignUpRequest request) { .phoneNumber(request.phoneNumber()) .profileImageUrl(null) .status(UserStatus.ACTIVE) - .isEmailVerified(false) + .isEmailVerified(true) //회원가입 하는 사용자는 모두 이메일 인증 완료된 것 .build(); User savedUser = userRepository.save(user); diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/code/UserErrorCode.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/code/UserErrorCode.java index 9e591f7..50a7027 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/code/UserErrorCode.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/code/UserErrorCode.java @@ -8,7 +8,11 @@ @Getter @AllArgsConstructor public enum UserErrorCode implements BaseErrorCode { - USER_EMAIL_DUPLICATE(HttpStatus.BAD_REQUEST, "USER_400_2", "이미 사용중인 이메일 입니다."), + USER_EMAIL_DUPLICATE(HttpStatus.BAD_REQUEST, "USER_400_1", "이미 사용중인 이메일 입니다."), + USER_EMAIL_NOT_VALID(HttpStatus.BAD_REQUEST, "USER_400_2", "해당 이메일로 메일 전송에 실패했습니다."), + USER_EMAIL_AUTH_INVALID(HttpStatus.BAD_REQUEST, "USER_400_3", "인증 코드가 올바르지 않습니다."), + + USER_EMAIL_NOT_VERIFIED(HttpStatus.UNAUTHORIZED, "USER_401_1", "이메일 인증이 진행되지 않았습니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/UserController.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/UserController.java index fe397b3..b0c6d39 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/UserController.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/UserController.java @@ -1,5 +1,8 @@ package com.whereyouad.WhereYouAd.domains.user.presentation; +import com.whereyouad.WhereYouAd.domains.user.application.dto.request.EmailRequest; +import com.whereyouad.WhereYouAd.domains.user.application.dto.response.EmailSentResponse; +import com.whereyouad.WhereYouAd.domains.user.domain.service.EmailService; import com.whereyouad.WhereYouAd.domains.user.domain.service.UserService; import com.whereyouad.WhereYouAd.domains.user.application.dto.request.SignUpRequest; import com.whereyouad.WhereYouAd.domains.user.application.dto.response.SignUpResponse; @@ -19,14 +22,30 @@ public class UserController implements UserControllerDocs { private final UserService userService; + private final EmailService emailService; @PostMapping("/signup") - public ResponseEntity> signUp(@RequestBody @Valid SignUpRequest request) - { + public ResponseEntity> signUp(@RequestBody @Valid SignUpRequest request) { SignUpResponse signUpResponse = userService.signUpUser(request); return ResponseEntity.ok( DataResponse.created(signUpResponse) ); } + @PostMapping("/email-send") + public ResponseEntity> sendEmail(@RequestBody @Valid EmailRequest.Send request) { + EmailSentResponse emailSentResponse = emailService.sendEmail(request.email()); + return ResponseEntity.ok( + DataResponse.from(emailSentResponse) + ); + } + + @PostMapping("/email-verify") + public ResponseEntity> verifyEmail(@RequestBody @Valid EmailRequest.Verify request) { + emailService.verifyEmailCode(request.email(), request.authCode()); + + return ResponseEntity.ok( + DataResponse.from("이메일 인증이 성공적으로 완료되었습니다.") + ); + } } diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/docs/UserControllerDocs.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/docs/UserControllerDocs.java index 531cd06..9048747 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/docs/UserControllerDocs.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/docs/UserControllerDocs.java @@ -1,6 +1,8 @@ package com.whereyouad.WhereYouAd.domains.user.presentation.docs; +import com.whereyouad.WhereYouAd.domains.user.application.dto.request.EmailRequest; import com.whereyouad.WhereYouAd.domains.user.application.dto.request.SignUpRequest; +import com.whereyouad.WhereYouAd.domains.user.application.dto.response.EmailSentResponse; import com.whereyouad.WhereYouAd.domains.user.application.dto.response.SignUpResponse; import com.whereyouad.WhereYouAd.global.response.DataResponse; import io.swagger.v3.oas.annotations.Operation; @@ -13,11 +15,32 @@ public interface UserControllerDocs { @Operation( summary = "단순 회원가입 API", - description = "이메일, 비밀번호, 이름, 전화번호를 받아 회원가입을 진행합니다(이메일 인증 미진행상태)" + description = "이메일, 비밀번호, 이름, 전화번호를 받아 회원가입을 진행합니다(먼저 이메일 인증이 진행되어야 회원가입 가능)" ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "성공"), @ApiResponse(responseCode = "400_2", description = "이메일 중복 회원 존재") }) public ResponseEntity> signUp(@RequestBody @Valid SignUpRequest request); + + @Operation( + summary = "이메일 인증코드 전송 API", + description = "입력받은 이메일로 인증코드를 전송합니다. 인증코드 재전송도 해당 API 를 호출합니다.\n\n" + + "테스트용 이메일은 'test' 로 시작하거나 'example.com' 으로 끝나야합니다. 테스트용 이메일의 인증코드는 서버 로그로 확인 가능합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "400_3", description = "이메일 전송실패(이메일 오타 등)") + }) + public ResponseEntity> sendEmail(@RequestBody @Valid EmailRequest.Send request); + + @Operation( + summary = "이메일 인증코드 인증 API", + description = "이메일과 인증코드를 받아 인증코드가 맞는지 검증합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "400_4", description = "실패(인증코드 불일치)") + }) + public ResponseEntity> verifyEmail(@RequestBody @Valid EmailRequest.Verify request); } diff --git a/src/main/java/com/whereyouad/WhereYouAd/global/security/SecurityConfig.java b/src/main/java/com/whereyouad/WhereYouAd/global/security/SecurityConfig.java index 6f0ab6d..213c732 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/global/security/SecurityConfig.java +++ b/src/main/java/com/whereyouad/WhereYouAd/global/security/SecurityConfig.java @@ -34,7 +34,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { ) .authorizeHttpRequests(auth -> auth .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() //swagger 접근 허용 - .requestMatchers("/api/users/signup", "/api/auth/**").permitAll() //로그인, 회원가입 접근 허용 + .requestMatchers("/api/users/**", "/api/auth/**").permitAll() //로그인, 회원가입, 이메일 인증 접근 허용 .anyRequest().authenticated() //이외 접근은 인증 필요 ) //Spring Security 의 기본 UsernamePasswordAuthenticationFilter 앞에 JwtAuthenticationFilter 등록 diff --git a/src/main/java/com/whereyouad/WhereYouAd/global/utils/RedisUtil.java b/src/main/java/com/whereyouad/WhereYouAd/global/utils/RedisUtil.java new file mode 100644 index 0000000..58234e6 --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/global/utils/RedisUtil.java @@ -0,0 +1,33 @@ +package com.whereyouad.WhereYouAd.global.utils; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +@Service +@RequiredArgsConstructor +public class RedisUtil { + + private final StringRedisTemplate template; + + //Redis 에 데이터 저장(유효시간 설정) + public void setDataExpire(String key, String value, long duration) { + ValueOperations valueOperations = template.opsForValue(); + Duration expireDuration = Duration.ofSeconds(duration); + valueOperations.set(key, value, expireDuration); + } + + //Redis 에서 데이터 꺼내기(Value 꺼내기) + public String getData(String key) { + ValueOperations valueOperations = template.opsForValue(); + return valueOperations.get(key); + } + + //Redis 에서 데이터 지우기(key 값 기반) + public void deleteData(String key) { + template.delete(key); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..e81a15e --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,46 @@ +server: + port: 8080 + +spring: + application: + name: WhereYouAd + + # 데이터베이스 설정 + datasource: + url: ${DB_URL} # 예: jdbc:mysql://localhost:3306/whereyouad + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + + # JPA 설정 + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + show_sql: true + + # Redis 설정 + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + + # 이메일 설정 + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} #이메일 앱 비밀번호 입력 + properties: + mail: + smtp: + auth: true + starttls: + enable: true + +# JWT 또는 기타 커스텀 설정 (필요시) +jwt: + secret: ${JWT_SECRET} +# expiration: 86400000 \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml deleted file mode 100644 index 8ff5a60..0000000 --- a/src/test/resources/application.yml +++ /dev/null @@ -1,3 +0,0 @@ -jwt: - secret: "rEYzQhGBcEalhSkn2Fh4KaH+7qgWC/jsHGtQkr9/6IZT5irDbSJeyIW+Iq0UV2k1vX5Z72lEL28LfcDA+szesA==" - #PR 빌드 통과를 위한 임의의 jwt.secret 값 -> 이 값이 아닌 추후에 협의한 랜덤값 사용 \ No newline at end of file