diff --git a/.gitignore b/.gitignore index 6e9e5d44..447c8808 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,9 @@ src/main/generated/ /.cursor/ ### AntiGravity ### -/.agent/ \ No newline at end of file +/.agent/ + +### claude ### +/.claude/ +CLAUDE.md +/.ai/ \ No newline at end of file diff --git a/build.gradle b/build.gradle index efc1b752..c3751e39 100644 --- a/build.gradle +++ b/build.gradle @@ -46,6 +46,9 @@ dependencies { exclude group: 'io.swagger.core.v3', module: 'swagger-annotations' } + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'software.amazon.awssdk:ses:2.29.46' + compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' @@ -63,7 +66,7 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } dependencyManagement { diff --git a/src/main/java/com/dreamteam/alter/AlterApplication.java b/src/main/java/com/dreamteam/alter/AlterApplication.java index 419357bd..15292296 100644 --- a/src/main/java/com/dreamteam/alter/AlterApplication.java +++ b/src/main/java/com/dreamteam/alter/AlterApplication.java @@ -4,10 +4,12 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.retry.annotation.EnableRetry; +import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication @EnableJpaAuditing @EnableRetry +@EnableAsync public class AlterApplication { public static void main(String[] args) { diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/SendEmailVerificationCodeRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/SendEmailVerificationCodeRequestDto.java new file mode 100644 index 00000000..b86b064a --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/SendEmailVerificationCodeRequestDto.java @@ -0,0 +1,20 @@ +package com.dreamteam.alter.adapter.inbound.general.email.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "이메일 인증 코드 발송 요청") +public class SendEmailVerificationCodeRequestDto { + + @NotBlank + @Email + @Schema(description = "인증할 이메일 주소", example = "user@example.com") + private String email; +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/VerifyEmailVerificationCodeRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/VerifyEmailVerificationCodeRequestDto.java new file mode 100644 index 00000000..864f6d30 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/VerifyEmailVerificationCodeRequestDto.java @@ -0,0 +1,26 @@ +package com.dreamteam.alter.adapter.inbound.general.email.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "이메일 인증 코드 검증 요청") +public class VerifyEmailVerificationCodeRequestDto { + + @NotBlank + @Email + @Schema(description = "인증할 이메일 주소", example = "user@example.com") + private String email; + + @NotBlank + @Pattern(regexp = "^[0-9]{6}$", message = "인증 코드는 6자리 숫자여야 합니다.") + @Schema(description = "수신한 인증 코드 6자리", example = "123456") + private String code; +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/VerifyEmailVerificationCodeResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/VerifyEmailVerificationCodeResponseDto.java new file mode 100644 index 00000000..060cba43 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/VerifyEmailVerificationCodeResponseDto.java @@ -0,0 +1,16 @@ +package com.dreamteam.alter.adapter.inbound.general.email.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "이메일 인증 성공 응답") +public class VerifyEmailVerificationCodeResponseDto { + + @Schema(description = "이메일 인증 세션 토큰", example = "a1b2c3d4-e5f6-4a09-8c13-1b2a3d4e5f6a") + private String verificationToken; +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicController.java index ebdba828..f40fe471 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicController.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicController.java @@ -55,6 +55,7 @@ public class UserPublicController implements UserPublicControllerSpec { @Resource(name = "resetPassword") private final ResetPasswordUseCase resetPassword; + @Override @PostMapping("/signup-session") public ResponseEntity> createSignupSession( @@ -135,4 +136,5 @@ public ResponseEntity> resetPassword( resetPassword.execute(request); return ResponseEntity.ok(CommonApiResponse.empty()); } + } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java index 702cc984..998b18da 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java @@ -62,10 +62,6 @@ public interface UserPublicControllerSpec { mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = { - @ExampleObject( - name = "이메일 중복", - value = "{\"code\" : \"A004\"}" - ), @ExampleObject( name = "소셜 플랫폼 ID 중복", value = "{\"code\" : \"A005\"}" @@ -139,6 +135,10 @@ public interface UserPublicControllerSpec { @ExampleObject( name = "존재하지 않는 사용자", value = "{\"success\": false, \"code\" : \"B011\", \"message\" : \"존재하지 않는 사용자입니다.\"}" + ), + @ExampleObject( + name = "이메일 미등록 사용자", + value = "{\"success\": false, \"code\" : \"A015\", \"message\" : \"이메일이 등록되지 않은 사용자입니다.\"}" ) })) }) @@ -171,6 +171,10 @@ public interface UserPublicControllerSpec { @ExampleObject( name = "존재하지 않는 사용자", value = "{\"success\": false, \"code\" : \"B011\", \"message\" : \"존재하지 않는 사용자입니다.\"}" + ), + @ExampleObject( + name = "이메일 미등록 사용자", + value = "{\"success\": false, \"code\" : \"A015\", \"message\" : \"이메일이 등록되지 않은 사용자입니다.\"}" ) })) }) diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfController.java index c8fe33fd..7bf0e407 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfController.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfController.java @@ -1,11 +1,17 @@ package com.dreamteam.alter.adapter.inbound.general.user.controller; import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; +import com.dreamteam.alter.adapter.inbound.general.email.dto.SendEmailVerificationCodeRequestDto; +import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeRequestDto; +import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeResponseDto; import com.dreamteam.alter.adapter.inbound.general.user.dto.*; import com.dreamteam.alter.application.aop.AppActionContext; +import com.dreamteam.alter.domain.email.port.inbound.SendEmailVerificationCodeUseCase; +import com.dreamteam.alter.domain.email.port.inbound.VerifyEmailVerificationCodeUseCase; import com.dreamteam.alter.domain.user.context.AppActor; import com.dreamteam.alter.domain.user.port.inbound.*; import jakarta.annotation.Resource; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -39,6 +45,21 @@ public class UserSelfController implements UserSelfControllerSpec { @Resource(name = "deleteUserSelfCertificate") private final DeleteUserSelfCertificateUseCase deleteUserSelfCertificate; + @Resource(name = "registerEmail") + private final RegisterEmailUseCase registerEmail; + + @Resource(name = "updateEmail") + private final UpdateEmailUseCase updateEmail; + + @Resource(name = "removeEmail") + private final RemoveEmailUseCase removeEmail; + + @Resource(name = "sendEmailVerificationCode") + private final SendEmailVerificationCodeUseCase sendEmailVerificationCode; + + @Resource(name = "verifyEmailVerificationCode") + private final VerifyEmailVerificationCodeUseCase verifyEmailVerificationCode; + @Override @GetMapping public ResponseEntity> getUserSelfInfo() { @@ -99,4 +120,52 @@ public ResponseEntity> deleteUserSelfCertificate( return ResponseEntity.ok(CommonApiResponse.empty()); } + @Override + @PostMapping("/email") + public ResponseEntity> registerEmail( + @Valid @RequestBody RegisterEmailRequestDto request + ) { + AppActor actor = AppActionContext.getInstance().getActor(); + + registerEmail.execute(actor, request.getEmailVerificationSessionId()); + return ResponseEntity.ok(CommonApiResponse.empty()); + } + + @Override + @PutMapping("/email") + public ResponseEntity> updateEmail( + @Valid @RequestBody RegisterEmailRequestDto request + ) { + AppActor actor = AppActionContext.getInstance().getActor(); + + updateEmail.execute(actor, request.getEmailVerificationSessionId()); + return ResponseEntity.ok(CommonApiResponse.empty()); + } + + @Override + @DeleteMapping("/email") + public ResponseEntity> removeEmail() { + AppActor actor = AppActionContext.getInstance().getActor(); + + removeEmail.execute(actor); + return ResponseEntity.ok(CommonApiResponse.empty()); + } + + @Override + @PostMapping("/email/send") + public ResponseEntity> sendVerificationCode( + @Valid @RequestBody SendEmailVerificationCodeRequestDto request + ) { + sendEmailVerificationCode.execute(request); + return ResponseEntity.ok(CommonApiResponse.empty()); + } + + @Override + @PostMapping("/email/verify") + public ResponseEntity> verifyVerificationCode( + @Valid @RequestBody VerifyEmailVerificationCodeRequestDto request + ) { + return ResponseEntity.ok(CommonApiResponse.of(verifyEmailVerificationCode.execute(request))); + } + } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfControllerSpec.java index bc30b06e..71718c8d 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfControllerSpec.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfControllerSpec.java @@ -2,6 +2,9 @@ import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; import com.dreamteam.alter.adapter.inbound.common.dto.ErrorResponse; +import com.dreamteam.alter.adapter.inbound.general.email.dto.SendEmailVerificationCodeRequestDto; +import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeRequestDto; +import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeResponseDto; import com.dreamteam.alter.adapter.inbound.general.user.dto.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -96,4 +99,151 @@ ResponseEntity> updateUserSelfCertificate( }) ResponseEntity> deleteUserSelfCertificate(Long certificateId); + @Operation( + summary = "이메일 등록", + description = "이메일 인증 완료 후 발급된 세션 ID로 본인 계정에 이메일을 등록합니다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "이메일 등록 성공"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "이메일 인증 세션 유효하지 않음", + value = "{\"code\" : \"B001\", \"message\" : \"이메일 인증 세션이 유효하지 않거나 만료되었습니다.\"}" + ), + @ExampleObject( + name = "이메일 중복", + value = "{\"code\" : \"A004\"}" + ), + @ExampleObject( + name = "사용자 조회 실패", + value = "{\"code\" : \"B011\"}" + ) + })) + }) + ResponseEntity> registerEmail(@RequestBody @Valid RegisterEmailRequestDto request); + + @Operation( + summary = "이메일 갱신", + description = "이메일 인증 완료 후 발급된 세션 ID로 본인 계정의 이메일을 변경합니다. 기존 이메일이 등록된 경우에만 사용 가능합니다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "이메일 갱신 성공"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "이메일 인증 세션 유효하지 않음", + value = "{\"code\" : \"B001\", \"message\" : \"이메일 인증 세션이 유효하지 않거나 만료되었습니다.\"}" + ), + @ExampleObject( + name = "이메일 미등록 사용자 (등록 API 사용 필요)", + value = "{\"code\" : \"A015\"}" + ), + @ExampleObject( + name = "현재 이메일과 동일", + value = "{\"code\" : \"B001\", \"message\" : \"현재 등록된 이메일과 동일합니다.\"}" + ), + @ExampleObject( + name = "이메일 중복", + value = "{\"code\" : \"A004\"}" + ), + @ExampleObject( + name = "사용자 조회 실패", + value = "{\"code\" : \"B011\"}" + ) + })) + }) + ResponseEntity> updateEmail(@RequestBody @Valid RegisterEmailRequestDto request); + + @Operation( + summary = "이메일 삭제", + description = "본인 계정에 등록된 이메일을 삭제합니다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "이메일 삭제 성공"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "이메일 미등록 사용자", + value = "{\"code\" : \"A015\"}" + ), + @ExampleObject( + name = "사용자 조회 실패", + value = "{\"code\" : \"B011\"}" + ) + })) + }) + ResponseEntity> removeEmail(); + + @Operation( + summary = "이메일 인증 코드 발송", + description = "이메일로 6자리 인증 코드 발송 (로그인 필요)" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "인증 코드 발송 성공" + ), + @ApiResponse(responseCode = "429", description = "요청이 너무 많음 (쿨다운)", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "쿨다운 위반", + value = "{\"success\": false, \"code\" : \"E004\", \"message\" : \"요청이 너무 많습니다. 잠시 후 다시 시도해주세요.\"}" + ) + } + ) + ), + @ApiResponse(responseCode = "500", description = "서버 에러", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "이메일 전송 실패", + value = "{\"success\": false, \"code\" : \"E003\", \"message\" : \"이메일 전송에 실패했습니다.\"}" + ) + } + ) + ) + }) + ResponseEntity> sendVerificationCode(@RequestBody @Valid SendEmailVerificationCodeRequestDto request); + + @Operation(summary = "이메일 인증 코드 검증", description = "발송된 인증 코드를 검증합니다. (로그인 필요)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "인증 코드 검증 성공"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "인증 코드 만료/없음", + value = "{\"success\": false, \"code\" : \"E001\", \"message\" : \"인증 코드가 없거나 만료되었습니다.\"}" + ), + @ExampleObject( + name = "인증 코드 불일치", + value = "{\"success\": false, \"code\" : \"E002\", \"message\" : \"인증 코드가 일치하지 않습니다.\"}" + ), + @ExampleObject( + name = "인증 시도 횟수 초과", + value = "{\"success\": false, \"code\" : \"E005\", \"message\" : \"인증 시도 횟수를 초과했습니다. 코드를 다시 발송해주세요.\"}" + ) + } + ) + ) + }) + ResponseEntity> verifyVerificationCode(@RequestBody @Valid VerifyEmailVerificationCodeRequestDto request); + } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserRequestDto.java index fc003a60..c5eea392 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserRequestDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserRequestDto.java @@ -2,7 +2,6 @@ import com.dreamteam.alter.domain.user.type.UserGender; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -21,12 +20,6 @@ public class CreateUserRequestDto { @Schema(description = "회원가입 세션 ID", example = "UUID") private String signupSessionId; - @NotBlank - @Email - @Size(max = 255) - @Schema(description = "이메일", example = "user@example.com") - private String email; - @NotBlank @Size(min = 8, max = 16) @Schema(description = "비밀번호", example = "password123") diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/RegisterEmailRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/RegisterEmailRequestDto.java new file mode 100644 index 00000000..6f5aba04 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/RegisterEmailRequestDto.java @@ -0,0 +1,19 @@ +package com.dreamteam.alter.adapter.inbound.general.user.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "이메일 등록 요청 DTO") +public class RegisterEmailRequestDto { + + @NotBlank + @Schema(description = "이메일 인증 성공 후 받은 세션 ID") + private String emailVerificationSessionId; + +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/email/persistence/EmailSendLogJpaRepository.java b/src/main/java/com/dreamteam/alter/adapter/outbound/email/persistence/EmailSendLogJpaRepository.java new file mode 100644 index 00000000..08d171e4 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/email/persistence/EmailSendLogJpaRepository.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.adapter.outbound.email.persistence; + +import com.dreamteam.alter.domain.email.entity.EmailSendLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EmailSendLogJpaRepository extends JpaRepository { +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/email/persistence/EmailSendLogRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/email/persistence/EmailSendLogRepositoryImpl.java new file mode 100644 index 00000000..c066b81b --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/email/persistence/EmailSendLogRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.dreamteam.alter.adapter.outbound.email.persistence; + +import com.dreamteam.alter.domain.email.entity.EmailSendLog; +import com.dreamteam.alter.domain.email.port.outbound.EmailSendLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class EmailSendLogRepositoryImpl implements EmailSendLogRepository { + + private final EmailSendLogJpaRepository jpaRepository; + + @Override + public EmailSendLog save(EmailSendLog log) { + return jpaRepository.save(log); + } + + @Override + public Optional findById(Long id) { + return jpaRepository.findById(id); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/email/redis/RedisEmailVerificationSessionStoreAdapter.java b/src/main/java/com/dreamteam/alter/adapter/outbound/email/redis/RedisEmailVerificationSessionStoreAdapter.java new file mode 100644 index 00000000..101562df --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/email/redis/RedisEmailVerificationSessionStoreAdapter.java @@ -0,0 +1,103 @@ +package com.dreamteam.alter.adapter.outbound.email.redis; + +import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationSessionStoreRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class RedisEmailVerificationSessionStoreAdapter implements EmailVerificationSessionStoreRepository { + + private final StringRedisTemplate redisTemplate; + + private static final String KEY_PREFIX_CODE = "auth:email:code:"; + private static final String KEY_PREFIX_COOLDOWN = "auth:email:cooldown:"; + private static final String KEY_PREFIX_ATTEMPTS = "auth:email:attempts:"; + private static final String KEY_PREFIX_SESSION = "auth:email:session:"; + private static final String KEY_PREFIX_SEND_FAILED = "auth:email:send-failed:"; + + + // --- 인증 코드 관련 --- + @Override + public void saveCode(String email, String code, Duration ttl) { + redisTemplate.delete(KEY_PREFIX_ATTEMPTS + email); + redisTemplate.opsForValue().set(KEY_PREFIX_CODE + email, code, ttl); + } + + @Override + public Optional findCode(String email) { + String code = redisTemplate.opsForValue().get(KEY_PREFIX_CODE + email); + return Optional.ofNullable(code); + } + + @Override + public void deleteCode(String email) { + redisTemplate.delete(KEY_PREFIX_CODE + email); + redisTemplate.delete(KEY_PREFIX_ATTEMPTS + email); + } + + @Override + public long incrementAttempt(String email, Duration ttl) { + String key = KEY_PREFIX_ATTEMPTS + email; + Long attempts = redisTemplate.opsForValue().increment(key); + if (attempts != null && attempts == 1) { + // 처음 생성된 키라면 TTL 설정 (코드 TTL과 맞추거나 별도 설정) + redisTemplate.expire(key, ttl); + } + return attempts != null ? attempts : 1L; + } + + // --- 쿨다운 관련 --- + + @Override + public boolean isCooldown(String email) { + return redisTemplate.hasKey(KEY_PREFIX_COOLDOWN + email); + } + + @Override + public void markCooldown(String email, Duration ttl) { + redisTemplate.opsForValue().set(KEY_PREFIX_COOLDOWN + email, "true", ttl); + } + + // --- 인증 세션 토큰 관련 (신규) --- + + @Override + public String createVerificationSession(String email, Duration ttl) { + String token = UUID.randomUUID().toString(); + // Key: 토큰, Value: 이메일 + redisTemplate.opsForValue().set(KEY_PREFIX_SESSION + token, email, ttl); + return token; + } + + @Override + public Optional getEmailBySession(String token) { + return Optional.ofNullable(redisTemplate.opsForValue().get(KEY_PREFIX_SESSION + token)); + } + + @Override + public void deleteSession(String token) { + redisTemplate.delete(KEY_PREFIX_SESSION + token); + } + + // --- 발송 실패 관련 --- + + @Override + public void markSendFailed(String email) { + redisTemplate.opsForValue().set(KEY_PREFIX_SEND_FAILED + email, "true", Duration.ofMinutes(10)); + } + + @Override + public boolean isSendFailed(String email) { + return redisTemplate.hasKey(KEY_PREFIX_SEND_FAILED + email); + } + + @Override + public void clearSendFailed(String email) { + redisTemplate.delete(KEY_PREFIX_SEND_FAILED + email); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/email/SesEmailSender.java b/src/main/java/com/dreamteam/alter/application/email/SesEmailSender.java new file mode 100644 index 00000000..585e05d0 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/email/SesEmailSender.java @@ -0,0 +1,50 @@ +package com.dreamteam.alter.application.email; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.email.port.outbound.EmailClient; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.ses.SesClient; +import software.amazon.awssdk.services.ses.model.*; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SesEmailSender implements EmailClient { + + private final SesClient sesClient; + + @Value("${alter.email.from}") + private String from; + + @Override + public void sendVerificationCode(String toEmail, String code) { + try { + String subject = "[ALTER] 이메일 인증 코드"; + String bodyText = "인증 코드: " + code + "\n\n이 코드는 5분간 유효합니다."; + + SendEmailRequest request = SendEmailRequest.builder() + .source(from) + .destination(Destination.builder().toAddresses(toEmail).build()) + .message(Message.builder() + .subject(Content.builder().data(subject).build()) + .body(Body.builder() + .text(Content.builder().data(bodyText).build()) + .build()) + .build()) + .build(); + + sesClient.sendEmail(request); + + } catch (SesException e) { + log.error("Failed to send SES email to {}: {}", toEmail, e.awsErrorDetails().errorMessage()); + throw new CustomException(ErrorCode.EXTERNAL_API_ERROR, "이메일 전송에 실패했습니다."); + } catch (Exception e) { + log.error("Unexpected error sending email to {}: {}", toEmail, e.getMessage()); + throw new CustomException(ErrorCode.EXTERNAL_API_ERROR, "이메일 전송에 실패했습니다."); + } + } +} diff --git a/src/main/java/com/dreamteam/alter/application/email/event/EmailSendEvent.java b/src/main/java/com/dreamteam/alter/application/email/event/EmailSendEvent.java new file mode 100644 index 00000000..99dec59a --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/email/event/EmailSendEvent.java @@ -0,0 +1,10 @@ +package com.dreamteam.alter.application.email.event; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class EmailSendEvent { + private Long logId; +} diff --git a/src/main/java/com/dreamteam/alter/application/email/event/EmailSendEventListener.java b/src/main/java/com/dreamteam/alter/application/email/event/EmailSendEventListener.java new file mode 100644 index 00000000..c5b3164c --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/email/event/EmailSendEventListener.java @@ -0,0 +1,44 @@ +package com.dreamteam.alter.application.email.event; + +import com.dreamteam.alter.domain.email.port.outbound.EmailSendLogRepository; +import com.dreamteam.alter.domain.email.port.outbound.EmailClient; +import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationSessionStoreRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class EmailSendEventListener { + + private final EmailSendLogRepository emailSendLogRepository; + private final EmailClient emailClient; + private final EmailVerificationSessionStoreRepository sessionStoreRepository; + + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleEmailSendEvent(EmailSendEvent event) { + Long logId = event.getLogId(); + + emailSendLogRepository.findById(logId).ifPresent(logItem -> { + try { + emailClient.sendVerificationCode(logItem.getEmail(), logItem.getCode()); + logItem.markSent(); + } catch (Exception e) { + log.error("Async failed to send email to: {}", logItem.getEmail(), e); + logItem.markFailed(); + // 발송 실패 시 인증 코드 삭제 및 실패 상태 기록 + sessionStoreRepository.deleteCode(logItem.getEmail()); + sessionStoreRepository.markSendFailed(logItem.getEmail()); + } + emailSendLogRepository.save(logItem); + }); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/email/service/VerificationCodeGenerator.java b/src/main/java/com/dreamteam/alter/application/email/service/VerificationCodeGenerator.java new file mode 100644 index 00000000..eb8471db --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/email/service/VerificationCodeGenerator.java @@ -0,0 +1,16 @@ +package com.dreamteam.alter.application.email.service; + +import org.springframework.stereotype.Component; + +import java.security.SecureRandom; + +@Component +public class VerificationCodeGenerator { + + private static final SecureRandom RANDOM = new SecureRandom(); + + public String generate() { + int code = 100000 + RANDOM.nextInt(900000); + return String.valueOf(code); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/email/usecase/SendEmailVerificationCode.java b/src/main/java/com/dreamteam/alter/application/email/usecase/SendEmailVerificationCode.java new file mode 100644 index 00000000..04b3aa52 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/email/usecase/SendEmailVerificationCode.java @@ -0,0 +1,66 @@ +package com.dreamteam.alter.application.email.usecase; + +import com.dreamteam.alter.adapter.inbound.general.email.dto.SendEmailVerificationCodeRequestDto; +import com.dreamteam.alter.application.email.event.EmailSendEvent; +import com.dreamteam.alter.application.email.service.VerificationCodeGenerator; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.email.port.inbound.SendEmailVerificationCodeUseCase; +import com.dreamteam.alter.domain.email.entity.EmailSendLog; +import com.dreamteam.alter.domain.email.port.outbound.EmailSendLogRepository; +import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationSessionStoreRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; + +@Service("sendEmailVerificationCode") +@RequiredArgsConstructor +@Transactional +public class SendEmailVerificationCode implements SendEmailVerificationCodeUseCase { + + private final EmailVerificationSessionStoreRepository sessionStorePort; + private final EmailSendLogRepository emailSendLogRepository; + private final VerificationCodeGenerator codeGenerator; + private final ApplicationEventPublisher eventPublisher; + + @Value("${alter.email.code-ttl-seconds:300}") + private long codeTtlSeconds; + + @Value("${alter.email.cooldown-seconds:30}") + private long cooldownSeconds; + + + @Override + public void execute(SendEmailVerificationCodeRequestDto request) { + String email = request.getEmail(); + + // Check Cooldown + if (sessionStorePort.isCooldown(email)) { + throw new CustomException(ErrorCode.TOO_MANY_REQUESTS); + } + + // 이전 발송 실패 플래그 초기화 + sessionStorePort.clearSendFailed(email); + + // Generate Code + String code = codeGenerator.generate(); + + // Save Code (TTL) + sessionStorePort.saveCode(email, code, Duration.ofSeconds(codeTtlSeconds)); + + // Mark Cooldown + sessionStorePort.markCooldown(email, Duration.ofSeconds(cooldownSeconds)); + + // Save to DB for batch Sending (Not Sending immediately) + EmailSendLog log = EmailSendLog.create(email, code); + + // Send Email + EmailSendLog saved = emailSendLogRepository.save(log); + + eventPublisher.publishEvent(new EmailSendEvent(saved.getId())); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java b/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java new file mode 100644 index 00000000..f91bca5a --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java @@ -0,0 +1,67 @@ +package com.dreamteam.alter.application.email.usecase; + +import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeRequestDto; +import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeResponseDto; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.email.port.inbound.VerifyEmailVerificationCodeUseCase; +import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationSessionStoreRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; + +@Service("verifyEmailVerificationCode") +@RequiredArgsConstructor +@Transactional +public class VerifyEmailVerificationCode implements VerifyEmailVerificationCodeUseCase { + + private final EmailVerificationSessionStoreRepository sessionStoreRepository; + + @Value("${alter.email.code-ttl-seconds:300}") + private long codeTtlSeconds; + + @Value("${alter.email.verified-ttl-seconds:900}") + private long verifiedTtlSeconds; + + @Value("${alter.email.max-attempts:5}") + private int maxAttempts; + + @Override + public VerifyEmailVerificationCodeResponseDto execute(VerifyEmailVerificationCodeRequestDto request) { + String email = request.getEmail(); + String inputCode = request.getCode(); + + // 발송 실패 여부 확인 + if (sessionStoreRepository.isSendFailed(email)) { + sessionStoreRepository.clearSendFailed(email); + throw new CustomException(ErrorCode.EMAIL_SEND_FAILED); + } + + // Find Code + String storedCode = sessionStoreRepository.findCode(email) + .orElseThrow(() -> new CustomException(ErrorCode.ILLEGAL_ARGUMENT, "인증 코드가 없거나 만료되었습니다.")); + + // Compare + if (!storedCode.equals(inputCode)) { + // 시도 횟수 증가 + long attempts = sessionStoreRepository.incrementAttempt(email, Duration.ofSeconds(codeTtlSeconds)); + + if (attempts >= maxAttempts) { + sessionStoreRepository.deleteCode(email); + throw new CustomException(ErrorCode.ILLEGAL_ARGUMENT, "인증 시도 횟수를 초과했습니다."); + } + throw new CustomException(ErrorCode.ILLEGAL_ARGUMENT, "인증 코드가 일치하지 않습니다."); + } + + // Success -> Delete Code & Mark Verified + sessionStoreRepository.deleteCode(email); + String emailVerificationSessionId = sessionStoreRepository.createVerificationSession( + email, Duration.ofSeconds(verifiedTtlSeconds) + ); + + return new VerifyEmailVerificationCodeResponseDto(emailVerificationSessionId); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/CreatePasswordResetSession.java b/src/main/java/com/dreamteam/alter/application/user/usecase/CreatePasswordResetSession.java index b18963ef..e6d5ce90 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/CreatePasswordResetSession.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/CreatePasswordResetSession.java @@ -30,10 +30,20 @@ public class CreatePasswordResetSession implements CreatePasswordResetSessionUse @Override public CreatePasswordResetSessionResponseDto execute(CreatePasswordResetSessionRequestDto request) { - // 이메일과 전화번호로 사용자 확인 - User user = userQueryRepository.findByEmailAndContact(request.getEmail(), request.getContact()) + // 전화번호로 사용자 확인 + User user = userQueryRepository.findByContact(request.getContact()) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + // 이메일 등록 여부 확인 + if (user.getEmail() == null) { + throw new CustomException(ErrorCode.EMAIL_NOT_REGISTERED); + } + + // 이메일 일치 여부 확인 + if (!user.getEmail().equals(request.getEmail())) { + throw new CustomException(ErrorCode.USER_NOT_FOUND); + } + // 기존 세션 확인 및 삭제 String userIndexKey = USER_INDEX_KEY_PREFIX + user.getId(); String existingSessionId = redisTemplate.opsForValue().get(userIndexKey); diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUser.java b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUser.java index da95165e..0bbaeded 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUser.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUser.java @@ -37,6 +37,7 @@ public class CreateUser implements CreateUserUseCase { @Override public GenerateTokenResponseDto execute(CreateUserRequestDto request) { + // Redis 세션에서 휴대폰 인증 정보 확인 String sessionIdKey = KEY_PREFIX + request.getSignupSessionId(); String userInfoJson = redisTemplate.opsForValue().get(sessionIdKey); @@ -48,11 +49,11 @@ public GenerateTokenResponseDto execute(CreateUserRequestDto request) { try { // Redis에서 사용자 정보 복원 CreateSignupSessionRequestDto sessionUserInfo = objectMapper.readValue( - userInfoJson, + userInfoJson, CreateSignupSessionRequestDto.class ); - // 중복 확인 (요청의 email과 세션의 contact 사용) + // 중복 확인 validateDuplication(request, sessionUserInfo, sessionIdKey); // 비밀번호 형식 검증 @@ -60,9 +61,8 @@ public GenerateTokenResponseDto execute(CreateUserRequestDto request) { throw new CustomException(ErrorCode.INVALID_PASSWORD_FORMAT); } - // 사용자 생성 (요청의 email과 세션의 contact 사용) + // 사용자 생성 User user = userRepository.save(User.create( - request.getEmail(), sessionUserInfo.getContact(), passwordEncoder.encode(request.getPassword()), request.getName(), @@ -70,8 +70,8 @@ public GenerateTokenResponseDto execute(CreateUserRequestDto request) { request.getGender(), request.getBirthday() )); - - // 세션 삭제 + + // 회원가입 세션 삭제 redisTemplate.delete(sessionIdKey); return GenerateTokenResponseDto.of(authService.generateAuthorization(user, TokenScope.APP)); @@ -80,16 +80,7 @@ public GenerateTokenResponseDto execute(CreateUserRequestDto request) { } } - /** - * 사용자 정보의 중복 여부를 확인합니다. - */ private void validateDuplication(CreateUserRequestDto request, CreateSignupSessionRequestDto sessionUserInfo, String sessionIdKey) { - // 이메일 중복 확인 - if (userQueryRepository.findByEmail(request.getEmail()).isPresent()) { - redisTemplate.delete(sessionIdKey); - throw new CustomException(ErrorCode.EMAIL_DUPLICATED); - } - // 닉네임 중복 확인 if (userQueryRepository.findByNickname(request.getNickname()).isPresent()) { redisTemplate.delete(sessionIdKey); diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/FindEmailByContact.java b/src/main/java/com/dreamteam/alter/application/user/usecase/FindEmailByContact.java index 9c4151b0..5413fa89 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/FindEmailByContact.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/FindEmailByContact.java @@ -24,6 +24,10 @@ public FindEmailResponseDto execute(FindEmailRequestDto request) { User user = userQueryRepository.findByContact(request.getContact()) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + if (user.getEmail() == null) { + throw new CustomException(ErrorCode.EMAIL_NOT_REGISTERED); + } + String maskedEmail = MaskUtil.maskEmail(user.getEmail()); return FindEmailResponseDto.of(maskedEmail); diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/RegisterEmail.java b/src/main/java/com/dreamteam/alter/application/user/usecase/RegisterEmail.java new file mode 100644 index 00000000..8f302567 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/RegisterEmail.java @@ -0,0 +1,44 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationSessionStoreRepository; +import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.port.inbound.RegisterEmailUseCase; +import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("registerEmail") +@RequiredArgsConstructor +@Transactional +public class RegisterEmail implements RegisterEmailUseCase { + + private final UserQueryRepository userQueryRepository; + private final EmailVerificationSessionStoreRepository emailVerificationSessionStoreRepository; + + @Override + public void execute(AppActor actor, String emailVerificationSessionId) { + + // 이메일 인증 세션 검증 + String verifiedEmail = emailVerificationSessionStoreRepository + .getEmailBySession(emailVerificationSessionId) + .orElseThrow(() -> new CustomException(ErrorCode.ILLEGAL_ARGUMENT, "이메일 인증 세션이 유효하지 않거나 만료되었습니다.")); + + // 이메일 중복 확인 + if (userQueryRepository.findByEmail(verifiedEmail).isPresent()) { + throw new CustomException(ErrorCode.EMAIL_DUPLICATED); + } + + // 사용자 조회 및 이메일 등록 + User user = userQueryRepository.findById(actor.getUserId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + user.registerEmail(verifiedEmail); + + // 세션 삭제 + emailVerificationSessionStoreRepository.deleteSession(emailVerificationSessionId); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/RemoveEmail.java b/src/main/java/com/dreamteam/alter/application/user/usecase/RemoveEmail.java new file mode 100644 index 00000000..a53230c0 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/RemoveEmail.java @@ -0,0 +1,32 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.port.inbound.RemoveEmailUseCase; +import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("removeEmail") +@RequiredArgsConstructor +@Transactional +public class RemoveEmail implements RemoveEmailUseCase { + + private final UserQueryRepository userQueryRepository; + + @Override + public void execute(AppActor actor) { + + User user = userQueryRepository.findById(actor.getUserId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + if (user.getEmail() == null) { + throw new CustomException(ErrorCode.EMAIL_NOT_REGISTERED); + } + + user.removeEmail(); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/UpdateEmail.java b/src/main/java/com/dreamteam/alter/application/user/usecase/UpdateEmail.java new file mode 100644 index 00000000..501f2570 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/UpdateEmail.java @@ -0,0 +1,55 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationSessionStoreRepository; +import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.port.inbound.UpdateEmailUseCase; +import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("updateEmail") +@RequiredArgsConstructor +@Transactional +public class UpdateEmail implements UpdateEmailUseCase { + + private final UserQueryRepository userQueryRepository; + private final EmailVerificationSessionStoreRepository emailVerificationSessionStoreRepository; + + @Override + public void execute(AppActor actor, String emailVerificationSessionId) { + + // 이메일 인증 세션 검증 + String verifiedEmail = emailVerificationSessionStoreRepository + .getEmailBySession(emailVerificationSessionId) + .orElseThrow(() -> new CustomException(ErrorCode.ILLEGAL_ARGUMENT, "이메일 인증 세션이 유효하지 않거나 만료되었습니다.")); + + // 사용자 조회 + User user = userQueryRepository.findById(actor.getUserId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // 이미 등록된 이메일이 없으면 등록 API 사용 유도 + if (user.getEmail() == null) { + throw new CustomException(ErrorCode.EMAIL_NOT_REGISTERED); + } + + // 현재 이메일과 동일하면 변경 불필요 + if (verifiedEmail.equals(user.getEmail())) { + throw new CustomException(ErrorCode.ILLEGAL_ARGUMENT, "현재 등록된 이메일과 동일합니다."); + } + + // 다른 사용자가 이미 사용 중인 이메일인지 확인 + userQueryRepository.findByEmail(verifiedEmail) + .ifPresent(existing -> { + throw new CustomException(ErrorCode.EMAIL_DUPLICATED); + }); + + user.updateEmail(verifiedEmail); + + // 세션 삭제 + emailVerificationSessionStoreRepository.deleteSession(emailVerificationSessionId); + } +} diff --git a/src/main/java/com/dreamteam/alter/common/config/AwsSesConfig.java b/src/main/java/com/dreamteam/alter/common/config/AwsSesConfig.java new file mode 100644 index 00000000..a46a5202 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/common/config/AwsSesConfig.java @@ -0,0 +1,39 @@ +package com.dreamteam.alter.common.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.ses.SesClient; +import software.amazon.awssdk.services.ses.SesClientBuilder; + +@Configuration +@RequiredArgsConstructor +public class AwsSesConfig { + + @Value("${aws.region}") + private String region; + + @Value("${aws.access-key:}") + private String accessKey; + + @Value("${aws.secret-key:}") + private String secretKey; + + @Bean + public SesClient sesClient() { + SesClientBuilder builder = SesClient.builder() + .region(Region.of(region)); + + if (StringUtils.hasText(accessKey) && StringUtils.hasText(secretKey)) { + AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey); + builder.credentialsProvider(StaticCredentialsProvider.create(credentials)); + } + + return builder.build(); + } +} diff --git a/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java index 4d37eccc..6fddd5c0 100644 --- a/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java +++ b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java @@ -21,6 +21,7 @@ public enum ErrorCode { SOCIAL_PROVIDER_ALREADY_LINKED(400, "A012", "이미 연동되어 있는 소셜 플랫폼입니다."), PASSWORD_RESET_SESSION_NOT_EXIST(400, "A013", "비밀번호 재설정 세션이 존재하지 않거나 만료되었습니다."), INVALID_PASSWORD_FORMAT(400, "A014", "비밀번호는 8~16자 이내 영문, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다."), + EMAIL_NOT_REGISTERED(400, "A015", "이메일이 등록되지 않은 사용자입니다."), ILLEGAL_ARGUMENT(400, "B001", "잘못된 요청입니다."), REFRESH_TOKEN_REQUIRED(400, "B002", "RefreshToken을 통해 요청해야 합니다."), @@ -42,6 +43,10 @@ public enum ErrorCode { WORKSPACE_WORKER_ALREADY_EXISTS(400, "B018", "이미 근무중인 사용자입니다."), NOT_FOUND(404, "B019", "요청한 리소스를 찾을 수 없습니다."), CONFLICT(409, "B020", "변경할 수 없는 상태입니다."), + + TOO_MANY_REQUESTS(429, "E001", "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), + EMAIL_SEND_FAILED(500, "E002", "이메일 발송에 실패했습니다. 인증 코드를 다시 발송해주세요."), + INTERNAL_SERVER_ERROR(400, "C001", "서버 내부 오류입니다."), EXTERNAL_API_ERROR(502, "C002", "외부 API 연동에 실패했습니다."), ; diff --git a/src/main/java/com/dreamteam/alter/domain/email/entity/EmailSendLog.java b/src/main/java/com/dreamteam/alter/domain/email/entity/EmailSendLog.java new file mode 100644 index 00000000..176ecc46 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/email/entity/EmailSendLog.java @@ -0,0 +1,62 @@ +package com.dreamteam.alter.domain.email.entity; + +import com.dreamteam.alter.domain.email.type.EmailSendStatus; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "email_send_log") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class EmailSendLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "email", nullable = false, length = 100) + private String email; + + @Column(name = "code", nullable = false, length = 6) + private String code; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private EmailSendStatus status; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public static EmailSendLog create(String email, String code) { + return EmailSendLog.builder() + .email(email) + .code(code) + .status(EmailSendStatus.PENDING) + .build(); + } + + public void markSent() { + this.status = EmailSendStatus.SENT; + } + + public void markFailed() { + this.status = EmailSendStatus.FAILED; + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/email/port/inbound/SendEmailVerificationCodeUseCase.java b/src/main/java/com/dreamteam/alter/domain/email/port/inbound/SendEmailVerificationCodeUseCase.java new file mode 100644 index 00000000..d6eb848a --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/email/port/inbound/SendEmailVerificationCodeUseCase.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.email.port.inbound; + +import com.dreamteam.alter.adapter.inbound.general.email.dto.SendEmailVerificationCodeRequestDto; + +public interface SendEmailVerificationCodeUseCase { + void execute(SendEmailVerificationCodeRequestDto request); +} diff --git a/src/main/java/com/dreamteam/alter/domain/email/port/inbound/VerifyEmailVerificationCodeUseCase.java b/src/main/java/com/dreamteam/alter/domain/email/port/inbound/VerifyEmailVerificationCodeUseCase.java new file mode 100644 index 00000000..428877c1 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/email/port/inbound/VerifyEmailVerificationCodeUseCase.java @@ -0,0 +1,8 @@ +package com.dreamteam.alter.domain.email.port.inbound; + +import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeRequestDto; +import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeResponseDto; + +public interface VerifyEmailVerificationCodeUseCase { + VerifyEmailVerificationCodeResponseDto execute(VerifyEmailVerificationCodeRequestDto request); +} diff --git a/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailClient.java b/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailClient.java new file mode 100644 index 00000000..4f2831f0 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailClient.java @@ -0,0 +1,5 @@ +package com.dreamteam.alter.domain.email.port.outbound; + +public interface EmailClient { + void sendVerificationCode(String toEmail, String code); +} diff --git a/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailSendLogRepository.java b/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailSendLogRepository.java new file mode 100644 index 00000000..0414eb9d --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailSendLogRepository.java @@ -0,0 +1,10 @@ +package com.dreamteam.alter.domain.email.port.outbound; + +import com.dreamteam.alter.domain.email.entity.EmailSendLog; + +import java.util.Optional; + +public interface EmailSendLogRepository { + EmailSendLog save(EmailSendLog log); + Optional findById(Long id); +} diff --git a/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailVerificationSessionStoreRepository.java b/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailVerificationSessionStoreRepository.java new file mode 100644 index 00000000..d114d760 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailVerificationSessionStoreRepository.java @@ -0,0 +1,26 @@ +package com.dreamteam.alter.domain.email.port.outbound; + +import java.time.Duration; +import java.util.Optional; + +public interface EmailVerificationSessionStoreRepository { + // --- 인증 코드 관련 --- + void saveCode(String email, String code, Duration ttl); + Optional findCode(String email); + void deleteCode(String email); + long incrementAttempt(String email, Duration ttl); + + // --- 쿨다운 관련 --- + boolean isCooldown(String email); + void markCooldown(String email, Duration ttl); + + // -- 인증 세션 관련 --- + String createVerificationSession(String email, Duration ttl); + Optional getEmailBySession(String token); + void deleteSession(String token); + + // --- 발송 실패 관련 --- + void markSendFailed(String email); + boolean isSendFailed(String email); + void clearSendFailed(String email); +} diff --git a/src/main/java/com/dreamteam/alter/domain/email/type/EmailSendStatus.java b/src/main/java/com/dreamteam/alter/domain/email/type/EmailSendStatus.java new file mode 100644 index 00000000..e42b8a9b --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/email/type/EmailSendStatus.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.email.type; + +public enum EmailSendStatus { + PENDING, + SENT, + FAILED +} diff --git a/src/main/java/com/dreamteam/alter/domain/user/entity/User.java b/src/main/java/com/dreamteam/alter/domain/user/entity/User.java index 435470f3..6c69d18a 100644 --- a/src/main/java/com/dreamteam/alter/domain/user/entity/User.java +++ b/src/main/java/com/dreamteam/alter/domain/user/entity/User.java @@ -28,9 +28,13 @@ public class User { @Column(name = "id") private Long id; - @Column(name = "email", length = 255, nullable = false, unique = true) + @Column(name = "email", length = 255, nullable = true, unique = true) private String email; + @Builder.Default + @Column(name = "email_verified", nullable = false) + private boolean emailVerified = false; + @Column(name = "password", length = 255, nullable = false) private String password; @@ -78,7 +82,6 @@ public class User { private List userSocials; public static User create( - String email, String contact, String encodedPassword, String name, @@ -87,7 +90,8 @@ public static User create( String birthday ) { return User.builder() - .email(email) + .email(null) + .emailVerified(false) .password(encodedPassword) .name(name) .nickname(nickname) @@ -99,6 +103,21 @@ public static User create( .build(); } + public void registerEmail(String email) { + this.email = email; + this.emailVerified = true; + } + + public void updateEmail(String email) { + this.email = email; + this.emailVerified = true; + } + + public void removeEmail() { + this.email = null; + this.emailVerified = false; + } + public void addCertificate(UserCertificate userCertificate) { certificates.add(userCertificate); } diff --git a/src/main/java/com/dreamteam/alter/domain/user/port/inbound/RegisterEmailUseCase.java b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/RegisterEmailUseCase.java new file mode 100644 index 00000000..00d922c9 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/RegisterEmailUseCase.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.user.port.inbound; + +import com.dreamteam.alter.domain.user.context.AppActor; + +public interface RegisterEmailUseCase { + void execute(AppActor actor, String emailVerificationSessionId); +} diff --git a/src/main/java/com/dreamteam/alter/domain/user/port/inbound/RemoveEmailUseCase.java b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/RemoveEmailUseCase.java new file mode 100644 index 00000000..c3991b83 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/RemoveEmailUseCase.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.user.port.inbound; + +import com.dreamteam.alter.domain.user.context.AppActor; + +public interface RemoveEmailUseCase { + void execute(AppActor actor); +} diff --git a/src/main/java/com/dreamteam/alter/domain/user/port/inbound/UpdateEmailUseCase.java b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/UpdateEmailUseCase.java new file mode 100644 index 00000000..6428cdc8 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/UpdateEmailUseCase.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.user.port.inbound; + +import com.dreamteam.alter.domain.user.context.AppActor; + +public interface UpdateEmailUseCase { + void execute(AppActor actor, String emailVerificationSessionId); +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 54b48e47..a370c2c3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -55,10 +55,20 @@ alter: exposed-headers: ${EXPOSED_HEADERS} allowed-pattern: ${ALLOWED_PATTERN} permit-all-urls: ${PERMIT_ALL_URLS} + email: + from: ${SES_FROM_EMAIL} + code-ttl-seconds: ${EMAIL_CODE_TTL_SECONDS:300} + verified-ttl-seconds: ${EMAIL_VERIFIED_TTL_SECONDS:900} + cooldown-seconds: ${EMAIL_COOLDOWN_SECONDS:30} + max-attempts: ${EMAIL_MAX_ATTEMPTS:5} firebase: fcm: project-id: ${FIREBASE_PROJECT_ID} service-account-key: ${FIREBASE_SERVICE_ACCOUNT_KEY} sgis: service-id: ${SGIS_SERVICE_ID} - service-secret: ${SGIS_SERVICE_SECRET} \ No newline at end of file + service-secret: ${SGIS_SERVICE_SECRET} +aws: + region: ${AWS_REGION} + access-key: ${AWS_ACCESS_KEY_ID:} + secret-key: ${AWS_SECRET_ACCESS_KEY:} \ No newline at end of file diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/ResetPasswordTest.java b/src/test/java/com/dreamteam/alter/application/user/usecase/ResetPasswordTest.java index 6d936224..985eaef8 100644 --- a/src/test/java/com/dreamteam/alter/application/user/usecase/ResetPasswordTest.java +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/ResetPasswordTest.java @@ -104,7 +104,6 @@ class ResetPasswordTest { ResetPasswordRequestDto request = new ResetPasswordRequestDto(sessionId, newPassword); User user = User.create( - "test@example.com", "01012345678", "oldPassword", "테스트", @@ -147,7 +146,6 @@ class ResetPasswordTest { ResetPasswordRequestDto request = new ResetPasswordRequestDto(sessionId, invalidPassword); User user = User.create( - "test@example.com", "01012345678", "oldPassword", "테스트", diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 3910362a..8809f35b 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -49,6 +49,12 @@ alter: exposed-headers: "*" allowed-pattern: "*" permit-all-urls: "/**" + email: + from: mock@mock.com + code-ttl-seconds: 300 + verified-ttl-seconds: 900 + cooldown-seconds: 30 + max-attempts: 5 firebase: fcm: service-account-key: 'ewogICJ0eXBlIjogInNlcnZpY2VfYWNjb3VudCIsCiAgInByb2plY3RfaWQiOiAiZHVtbXktZHVtbXktZHVtbXkiLAogICJwcml2YXRlX2tleV9pZCI6ICJzYWZoYXNlaWZoZXNpZm5lc2FuaWZ1Z2h3aXJ1Zmhpd2FuZmp3a2RzIiwKICAicHJpdmF0ZV9rZXkiOiAiLS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2QUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktZd2dnU2lBZ0VBQW9JQkFRRENJblhCQ2RjdnJrMHQKMEx6N0orOU9MZ25CdWJJUEdGVXNocjFWd29hdFZZZHgwMWduUjJQVllHVVowMHREOW5lRzl6d1VWbEh6U3ZzeQpzd09wZHBCaFV3MEtRaUo4TGJvMFYyMGtjbGNaZk1ENFZMUERoWHE2L1VuMkZ1T1dTK1lwb1hidHJqRzlSTmtkCjA1cS9MYUdoK1FoRHVpS3h1KzdNYjcyTGdoUU51K3pHRFlFSE16NTZBSC9qZ211VUd3aVM5bURKV1J4NDRiSXUKNDlXcDROK2NKb1NNYU1qYXEzUE9rREVyMGJpei9tc09wK1FUTmtIeG5aWXRHbVQ2anpBRTBIMkZrQVY1U0sveQpFdlFrWElENUN0NUUzaHR4NGFSWDIyY2RXcU9IazJtU2VxaGdZRUswc3VwbW5FOWZ1U3lxMHVKNXJTQTgrc1JvClBKS0xiY2ZUQWdNQkFBRUNnZ0VBQUp6VEEwemgvMk9vRVRTbjI0WGhncEo2bHBXdU9rdlFualBwZmRzRHVnMjQKa2JXcmRYZUxWd2NqQ2d6TzFkdGlnS2cxTjNaTm1rclVRNlBqakRTcXZXUWZwMnp6ZkFjcDBoN3FvQ1V5R2E0NwpQQ090QXkyV0IxVFFWNTJmRmpMYlU1ckNIa3pEdE5jazZjTlJYNDNTb0ZiSG8yMUJTVmo5QjVuSkpwZENhZkM4CmF0eTdLd240eHpqVUFIWE9HK3crb1V3WmVET3Y1UGd6dVlld09FTDhrZ2NnWWxSRzZSVnJIME14RWZuRkltdmEKS2FzemVrNHhUTWFnL1hDSDZ0bFNvU3ZTcnRJS1BzL3VUaDBRaW5uKzJQTHRWYWR0djFIaTltalk4VDJoV0l5Two2N3M2MUJzTzdQbU1oU3FyUXZuaSsrK21JcTl0TVVBZ2czc092RVFuUFFLQmdRRGhqUVQxYy9TdGlmSHBnd0ZLCmFIMytOWG9wbGdPMW12SnZyQjREM0U2dENFR1c0M1haQlpobmlncU1xN3F2OUlGa1I3RDd5NmV6ekFic2trYUkKUWxORTlsVCtuOW1ZaFdGK0xiRU5tSXJ1WlVhR1hwLzdBeWhXZDlBZmZUcmFrOWh2RVJscmdNeG1uSW9SckhyNApjZU8yYk9scEtmclVyc1BpMVRBazhaZDRWUUtCZ1FEY1Y3UDVmUU9pMzFPSjRvMnFwcElyN0lpMjVLQ1dkcFVhCmNUR0duYkc2Vll5dGxwTTF1SDdyMXZsVWdXQWp6UkRCdjRTaUxqRzdvYXdiNmFJSk1FLytzNWZ4Q1lxM3Z5L2cKNUlxVUhudU04YmFadzMvbEJsbVpic1N0bXVCYmpUdzA4bDNtYktzc0RDc2ZUU3IwMHhlRFp4OTFhTEpISGNWYwpyN3RxampjSGh3S0JnRzlqQWhqcGUrTWI1YkVKTm1EMXU0c0lBOTEzclR0Sld3TFZRRGx0MmhqUG8veU5Oa3pICjI1eithZmxRY2JDbGtpVGcxc0Z5c000MUt4STNwc2R4NGNlRDB6T3Y0M2pVSGZKL1JCblB4SVM0MVJ4VXJMTDgKdXpZQWszS0ptTUFMRlc2OFJnNTJHL3Rzd1M2N1BEdG5teW9qSFI0SFVrMG9SYXJHMTdEVzhwUEZBb0dBREM1UgpCYjdTZjZPRzg3MXhoWGlWNWhXNmJSbndnc0RsZDBQQXNDZHhsdEo1NTNMR2lwYTdkWUE1NG1FUWxvb1VuaEZmClhMUGZEZmRmRTEvMEZEdjJnQ2NmaERTNTFYU2RTZnA0YXIzUXFMY0lHRElGbFB5bjRXS05QdWVyOVlPMlMxc0cKcytGWUNTUlhFZkRyS2dPdGJoYzZWdnhGdHNhL2pXTXRvak5nZVdzQ2dZQnlLUCtuUlNwS1psYVNXVmF0SkV1ZAp5eHF1M3VMQUFBRWNPYUJUV05nVGliTnlyR1ByMmVVelRPd3ZtVkpjdlA5NlZlTEk5QlFjVlJwYmkwY1Vxb0x1ClhSZjZZbC81WEhVb1RvNHVqRUUyeTdwVjFLWGFZdVlURXkrZ3hVQmQyVzVNYTVyR3p5MFFnYzEvd0VLcEtweDQKclZmVWg5TVpycTdjRjl2VkI3a1BTZz09Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0iLAogICJjbGllbnRfZW1haWwiOiAiZmFzZW9pZmpuZXdub2lmZXNmamFzaGZqaWRzamZlc2FqaGZ1YW5zZGtmZWZpZmRrLmlhbS5nc2VydmljZWFjY291bnQuY29tIiwKICAiY2xpZW50X2lkIjogIjEyMzQ1Njc4OTEyMzQ1Njc4OSIsCiAgImF1dGhfdXJpIjogImh0dHBzOi8vYWNjb3VudHMuZ29vZ2xlLmNvbS9vL29hdXRoMi9hdXRoIiwKICAidG9rZW5fdXJpIjogImh0dHBzOi8vb2F1dGgyLmdvb2dsZWFwaXMuY29tL3Rva2VuIiwKICAiYXV0aF9wcm92aWRlcl94NTA5X2NlcnRfdXJsIjogImh0dHBzOi8vd3d3Lmdvb2dsZWFwaXMuY29tL29hdXRoMi92MS9jZXJ0cyIsCiAgImNsaWVudF94NTA5X2NlcnRfdXJsIjogImh0dHBzOi8vd3d3Lmdvb2dsZWFwaXMuY29tL3JvYm90L3YxL21ldGFkYXRhL3g1MDkvYXNvZWlmam93YWVqZmxrYWpzZm9pamV3YW9pZmpldy5pYW0uZ3NlcnZpY2VhY2NvdW50LmNvbSIsCiAgInVuaXZlcnNlX2RvbWFpbiI6ICJnb29nbGVhcGlzLmNvbSIKfQ==' @@ -57,3 +63,7 @@ firebase: sgis: service-id: mock-sgis-service-id service-secret: mock-sgis-service-secret +aws: + region: mockocko + access-key: mockmockmockmock + secret-key: mockmockmockmock