Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
# [email protected]
# 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
Expand Down
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
13 changes: 13 additions & 0 deletions compose.demo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}
Expand Down
17 changes: 17 additions & 0 deletions docs/deployment-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`로 제한합니다.

Expand Down Expand Up @@ -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 결과를 만들지 않고 안전한 오류 또는 수동 처리 상태로
남아야 합니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
5 changes: 5 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
"[email protected]",
"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);
}
}
Original file line number Diff line number Diff line change
@@ -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(
"[email protected]",
URI.create("https://demo.fowoco.test/reset-password"),
"FOWOCO 비밀번호 재설정"
)
);
Instant expiresAt = Instant.parse("2026-08-07T06:30:00Z");

adapter.sendResetLink("[email protected]", "safe_token-123", expiresAt);

ArgumentCaptor<SimpleMailMessage> captor = ArgumentCaptor.forClass(SimpleMailMessage.class);
verify(mailSender).send(captor.capture());
SimpleMailMessage message = captor.getValue();
assertThat(message.getFrom()).isEqualTo("[email protected]");
assertThat(message.getTo()).containsExactly("[email protected]");
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(
"[email protected]",
URI.create("file:///tmp/reset-password"),
"FOWOCO 비밀번호 재설정"
)
)).isInstanceOf(IllegalStateException.class)
.hasMessageContaining("PASSWORD_RESET_CLIENT_URL");
}
}
Loading