diff --git a/.env.example b/.env.example index a13829df..d464b1c4 100644 --- a/.env.example +++ b/.env.example @@ -77,13 +77,27 @@ REFRESH_TOKEN_COOKIE_SAME_SITE=Strict # REFRESH_TOKEN_COOKIE_SECURE=true # 비밀번호 재설정 token은 알림 Provider에만 원문을 전달하고 DB에는 SHA-256 hash만 저장합니다. -# 현재 기본 Adapter는 발송하지 않으므로 실제 데모 메일 연동 전 Provider Adapter를 구현해야 합니다. PASSWORD_RESET_TTL=30m PASSWORD_RESET_COOLDOWN=1m PASSWORD_RESET_MINIMUM_RESPONSE_TIME=250ms PASSWORD_RESET_RATE_LIMIT_MAX_REQUESTS=10 PASSWORD_RESET_RATE_LIMIT_WINDOW=10m +# 기본값 none은 메일을 발송하지 않습니다. 실제 SMTP 발송 시에만 smtp로 바꿉니다. +PASSWORD_RESET_NOTIFICATION_PROVIDER=none +PASSWORD_RESET_CLIENT_URL=http://localhost:5173/reset-password +# PASSWORD_RESET_MAIL_FROM=no-reply@example.com +# PASSWORD_RESET_MAIL_SUBJECT=FOWOCO 비밀번호 재설정 +# SPRING_MAIL_HOST=smtp.example.com +# SPRING_MAIL_PORT=587 +# SPRING_MAIL_USERNAME= +# SPRING_MAIL_PASSWORD= +# SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=true +# SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=true +# SPRING_MAIL_PROPERTIES_MAIL_SMTP_CONNECTIONTIMEOUT=5000 +# SPRING_MAIL_PROPERTIES_MAIL_SMTP_TIMEOUT=5000 +# SPRING_MAIL_PROPERTIES_MAIL_SMTP_WRITETIMEOUT=5000 + # 회원가입 화면과 Server가 같은 약관 version을 사용해야 합니다. SERVICE_TERMS_VERSION=1.0 PRIVACY_POLICY_VERSION=1.0 diff --git a/build.gradle b/build.gradle index 465b2f09..a6beb7d6 100644 --- a/build.gradle +++ b/build.gradle @@ -22,6 +22,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-flyway' + implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-webmvc' diff --git a/compose.demo.yml b/compose.demo.yml index c21ecad2..2893a9de 100644 --- a/compose.demo.yml +++ b/compose.demo.yml @@ -35,6 +35,19 @@ services: JWT_SECRET_BASE64: ${JWT_SECRET_BASE64:?JWT_SECRET_BASE64 must be set} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:5173} REFRESH_TOKEN_COOKIE_SECURE: "false" + PASSWORD_RESET_NOTIFICATION_PROVIDER: ${PASSWORD_RESET_NOTIFICATION_PROVIDER:-none} + PASSWORD_RESET_CLIENT_URL: ${PASSWORD_RESET_CLIENT_URL:-http://localhost:5173/reset-password} + PASSWORD_RESET_MAIL_FROM: ${PASSWORD_RESET_MAIL_FROM:-} + PASSWORD_RESET_MAIL_SUBJECT: ${PASSWORD_RESET_MAIL_SUBJECT:-FOWOCO 비밀번호 재설정} + SPRING_MAIL_HOST: ${SPRING_MAIL_HOST:-} + SPRING_MAIL_PORT: ${SPRING_MAIL_PORT:-587} + SPRING_MAIL_USERNAME: ${SPRING_MAIL_USERNAME:-} + SPRING_MAIL_PASSWORD: ${SPRING_MAIL_PASSWORD:-} + SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH:-true} + SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE:-true} + SPRING_MAIL_PROPERTIES_MAIL_SMTP_CONNECTIONTIMEOUT: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_CONNECTIONTIMEOUT:-5000} + SPRING_MAIL_PROPERTIES_MAIL_SMTP_TIMEOUT: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_TIMEOUT:-5000} + SPRING_MAIL_PROPERTIES_MAIL_SMTP_WRITETIMEOUT: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_WRITETIMEOUT:-5000} AI_RUNTIME_ENABLED: ${AI_RUNTIME_ENABLED:-false} AI_RUNTIME_ENDPOINT: ${AI_RUNTIME_ENDPOINT:-http://host.docker.internal:8000/internal/v1/analyses} AI_RUNTIME_SERVICE_CREDENTIAL: ${AI_RUNTIME_SERVICE_CREDENTIAL:-} diff --git a/docs/deployment-runbook.md b/docs/deployment-runbook.md index 3085654f..bde7745c 100644 --- a/docs/deployment-runbook.md +++ b/docs/deployment-runbook.md @@ -45,6 +45,22 @@ Secret은 Git과 Actions 로그에 값을 남기지 않고 `kubectl create secre | AI | `AI_RUNTIME_ENDPOINT` | 예: `http://ai:8000/internal/v1/analyses` | | AI | `AI_RUNTIME_SERVICE_CREDENTIAL` | Server↔AI 내부 Bearer credential | +비밀번호 재설정 메일을 실제로 발송할 때만 다음 값을 `server-env`에 추가합니다. 기본 +`PASSWORD_RESET_NOTIFICATION_PROVIDER=none`에서는 메일을 발송하지 않습니다. + +| 분류 | 환경변수 | 설명 | +| --- | --- | --- | +| Mail | `PASSWORD_RESET_NOTIFICATION_PROVIDER=smtp` | SMTP Adapter 활성화 | +| Mail | `PASSWORD_RESET_CLIENT_URL` | 예: `https://demo.example.com/reset-password` | +| Mail | `PASSWORD_RESET_MAIL_FROM` | 검증된 발신자 주소 | +| Mail | `SPRING_MAIL_HOST`, `SPRING_MAIL_PORT` | SMTP Endpoint | +| Mail | `SPRING_MAIL_USERNAME`, `SPRING_MAIL_PASSWORD` | SMTP credential | +| Mail | `SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=true` | SMTP 인증 사용 | +| Mail | `SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=true` | STARTTLS 사용 | + +SMTP 비밀번호와 재설정 원본 token은 Git, Issue, 일반 로그에 기록하지 않습니다. Provider 장애가 +비밀번호 재설정 요청의 외부 응답을 바꾸지 않도록 발송은 비동기로 격리되어 있습니다. + DB pool은 기본 최대 10개입니다. 클러스터 규모에 따라 `DB_MAX_POOL_SIZE`, `DB_MIN_IDLE`, `DB_CONNECTION_TIMEOUT_MS`, `DB_VALIDATION_TIMEOUT_MS`로 제한합니다. @@ -84,6 +100,7 @@ curl --fail http://127.0.0.1:8080/health 5. `POST /api/v1/ai-runs`의 실제 Server→AI 왕복 확인 6. 후보 채택 후 Case·Task 조회 확인 7. Worker Link 대표 흐름 확인 +8. SMTP가 활성화된 환경에서는 재설정 메일 수신·링크 token·새 비밀번호 로그인 확인 Runtime 장애 테스트에서는 가짜 AI 결과를 만들지 않고 안전한 오류 또는 수동 처리 상태로 남아야 합니다. diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/notification/NoOpPasswordResetNotificationAdapter.java b/src/main/java/com/fowoco/server/auth/infrastructure/notification/NoOpPasswordResetNotificationAdapter.java index 2de5c49b..d51be413 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/notification/NoOpPasswordResetNotificationAdapter.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/notification/NoOpPasswordResetNotificationAdapter.java @@ -2,11 +2,18 @@ import com.fowoco.server.auth.application.port.PasswordResetNotificationPort; import java.time.Instant; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; @Component +@ConditionalOnProperty( + prefix = "app.auth.password-reset.notification", + name = "provider", + havingValue = "none", + matchIfMissing = true +) public final class NoOpPasswordResetNotificationAdapter implements PasswordResetNotificationPort { private static final Logger LOGGER = LoggerFactory.getLogger(NoOpPasswordResetNotificationAdapter.class); diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/notification/PasswordResetNotificationConfig.java b/src/main/java/com/fowoco/server/auth/infrastructure/notification/PasswordResetNotificationConfig.java index f323e6c1..53305405 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/notification/PasswordResetNotificationConfig.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/notification/PasswordResetNotificationConfig.java @@ -1,11 +1,13 @@ package com.fowoco.server.auth.infrastructure.notification; import java.util.concurrent.Executor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; @Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(PasswordResetNotificationProperties.class) public class PasswordResetNotificationConfig { @Bean(name = "passwordResetNotificationExecutor") diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/notification/PasswordResetNotificationProperties.java b/src/main/java/com/fowoco/server/auth/infrastructure/notification/PasswordResetNotificationProperties.java new file mode 100644 index 00000000..1a50e0c9 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/notification/PasswordResetNotificationProperties.java @@ -0,0 +1,38 @@ +package com.fowoco.server.auth.infrastructure.notification; + +import java.net.URI; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.StringUtils; + +@ConfigurationProperties(prefix = "app.auth.password-reset.notification") +public record PasswordResetNotificationProperties( + String from, + URI resetUrl, + String subject +) { + + public PasswordResetNotificationProperties { + from = from == null ? "" : from.trim(); + subject = subject == null ? "" : subject.trim(); + } + + void validateForSmtp() { + if (!StringUtils.hasText(from) || containsLineBreak(from)) { + throw new IllegalStateException("PASSWORD_RESET_MAIL_FROM must contain a safe sender address"); + } + if (resetUrl == null || !isHttp(resetUrl)) { + throw new IllegalStateException("PASSWORD_RESET_CLIENT_URL must be an HTTP(S) URL"); + } + if (!StringUtils.hasText(subject) || containsLineBreak(subject)) { + throw new IllegalStateException("PASSWORD_RESET_MAIL_SUBJECT must contain safe text"); + } + } + + private boolean isHttp(URI uri) { + return "http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()); + } + + private boolean containsLineBreak(String value) { + return value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0; + } +} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/notification/SmtpPasswordResetNotificationAdapter.java b/src/main/java/com/fowoco/server/auth/infrastructure/notification/SmtpPasswordResetNotificationAdapter.java new file mode 100644 index 00000000..4b8ac4d4 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/notification/SmtpPasswordResetNotificationAdapter.java @@ -0,0 +1,62 @@ +package com.fowoco.server.auth.infrastructure.notification; + +import com.fowoco.server.auth.application.port.PasswordResetNotificationPort; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +@Component +@ConditionalOnProperty( + prefix = "app.auth.password-reset.notification", + name = "provider", + havingValue = "smtp" +) +public final class SmtpPasswordResetNotificationAdapter implements PasswordResetNotificationPort { + + private final JavaMailSender mailSender; + private final PasswordResetNotificationProperties properties; + + public SmtpPasswordResetNotificationAdapter( + JavaMailSender mailSender, + PasswordResetNotificationProperties properties + ) { + properties.validateForSmtp(); + this.mailSender = mailSender; + this.properties = properties; + } + + @Override + public void sendResetLink(String email, String rawToken, Instant expiresAt) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(properties.from()); + message.setTo(email); + message.setSubject(properties.subject()); + message.setText(body(resetLink(rawToken), expiresAt)); + mailSender.send(message); + } + + private String resetLink(String rawToken) { + return UriComponentsBuilder.fromUri(properties.resetUrl()) + .queryParam("token", rawToken) + .build() + .encode() + .toUriString(); + } + + private String body(String resetLink, Instant expiresAt) { + return """ + FOWOCO 비밀번호 재설정 요청이 접수되었습니다. + + 아래 링크에서 새 비밀번호를 설정해 주세요. + %s + + 링크 만료 시각(UTC): %s + + 본인이 요청하지 않았다면 이 메일을 무시해 주세요. + """.formatted(resetLink, DateTimeFormatter.ISO_INSTANT.format(expiresAt)); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 189571f3..9ee4f572 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -123,6 +123,11 @@ app: ttl: ${PASSWORD_RESET_TTL:30m} cooldown: ${PASSWORD_RESET_COOLDOWN:1m} minimum-response-time: ${PASSWORD_RESET_MINIMUM_RESPONSE_TIME:250ms} + notification: + provider: ${PASSWORD_RESET_NOTIFICATION_PROVIDER:none} + from: ${PASSWORD_RESET_MAIL_FROM:} + reset-url: ${PASSWORD_RESET_CLIENT_URL:http://localhost:5173/reset-password} + subject: ${PASSWORD_RESET_MAIL_SUBJECT:FOWOCO 비밀번호 재설정} rate-limit: max-requests: ${PASSWORD_RESET_RATE_LIMIT_MAX_REQUESTS:10} window: ${PASSWORD_RESET_RATE_LIMIT_WINDOW:10m} diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/notification/PasswordResetSmtpConfigurationTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/notification/PasswordResetSmtpConfigurationTest.java new file mode 100644 index 00000000..5a247ac8 --- /dev/null +++ b/src/test/java/com/fowoco/server/auth/infrastructure/notification/PasswordResetSmtpConfigurationTest.java @@ -0,0 +1,32 @@ +package com.fowoco.server.auth.infrastructure.notification; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fowoco.server.auth.application.port.PasswordResetNotificationPort; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@ActiveProfiles("test") +@SpringBootTest(properties = { + "app.auth.password-reset.notification.provider=smtp", + "app.auth.password-reset.notification.from=no-reply@fowoco.test", + "app.auth.password-reset.notification.reset-url=https://demo.fowoco.test/reset-password", + "app.auth.password-reset.notification.subject=FOWOCO 비밀번호 재설정" +}) +class PasswordResetSmtpConfigurationTest { + + @MockitoBean + private JavaMailSender mailSender; + + @Autowired + private PasswordResetNotificationPort notificationPort; + + @Test + void smtpProviderSelectsSmtpAdapterInsteadOfNoOp() { + assertThat(notificationPort).isInstanceOf(SmtpPasswordResetNotificationAdapter.class); + } +} diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/notification/SmtpPasswordResetNotificationAdapterTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/notification/SmtpPasswordResetNotificationAdapterTest.java new file mode 100644 index 00000000..f5c4cffd --- /dev/null +++ b/src/test/java/com/fowoco/server/auth/infrastructure/notification/SmtpPasswordResetNotificationAdapterTest.java @@ -0,0 +1,68 @@ +package com.fowoco.server.auth.infrastructure.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.net.URI; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; + +class SmtpPasswordResetNotificationAdapterTest { + + @Test + void sendsResetLinkWithTokenAndExpiry() { + JavaMailSender mailSender = mock(JavaMailSender.class); + SmtpPasswordResetNotificationAdapter adapter = new SmtpPasswordResetNotificationAdapter( + mailSender, + new PasswordResetNotificationProperties( + "no-reply@fowoco.test", + URI.create("https://demo.fowoco.test/reset-password"), + "FOWOCO 비밀번호 재설정" + ) + ); + Instant expiresAt = Instant.parse("2026-08-07T06:30:00Z"); + + adapter.sendResetLink("worker@example.com", "safe_token-123", expiresAt); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SimpleMailMessage.class); + verify(mailSender).send(captor.capture()); + SimpleMailMessage message = captor.getValue(); + assertThat(message.getFrom()).isEqualTo("no-reply@fowoco.test"); + assertThat(message.getTo()).containsExactly("worker@example.com"); + assertThat(message.getSubject()).isEqualTo("FOWOCO 비밀번호 재설정"); + assertThat(message.getText()) + .contains("https://demo.fowoco.test/reset-password?token=safe_token-123") + .contains("2026-08-07T06:30:00Z") + .contains("본인이 요청하지 않았다면"); + } + + @Test + void rejectsUnsafeOrIncompleteSmtpConfiguration() { + JavaMailSender mailSender = mock(JavaMailSender.class); + + assertThatThrownBy(() -> new SmtpPasswordResetNotificationAdapter( + mailSender, + new PasswordResetNotificationProperties( + "", + URI.create("https://demo.fowoco.test/reset-password"), + "FOWOCO 비밀번호 재설정" + ) + )).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("PASSWORD_RESET_MAIL_FROM"); + + assertThatThrownBy(() -> new SmtpPasswordResetNotificationAdapter( + mailSender, + new PasswordResetNotificationProperties( + "no-reply@fowoco.test", + URI.create("file:///tmp/reset-password"), + "FOWOCO 비밀번호 재설정" + ) + )).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("PASSWORD_RESET_CLIENT_URL"); + } +}