Skip to content

Commit 67a70ae

Browse files
authored
[CHORE] 관리자 로그인 인증 Slack -> Discord 변경 (#331)
* chore: 슬랙 인증 코드 전송 및 검증 기능을 디스코드로 변경 * test: 인증 코드 검증 관련 테스트 메서드 이름 변경 * feat: Discord 메시지 전송 기능을 WebClient로 변경 및 개선 * feat: Discord 메시지 전송 기능을 논블로킹 방식으로 개선 * feat: WebClient 설정에 연결 및 응답 타임아웃 추가 * feat: Discord webhook URL 유효성 검사 개선 및 불필요한 메서드 제거 * feat: Discord 메시지 전송 성공 로그 개선 및 오류 로그 메시지 수정 * feat: DiscordComponent에서 prefix 초기화 방식 개선 * feat: Discord 메시지 전송 실패 로그에 오류 메시지 추가
1 parent 873568c commit 67a70ae

16 files changed

Lines changed: 207 additions & 137 deletions

File tree

build.gradle

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,12 +124,12 @@ dependencies {
124124
// Swagger
125125
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'
126126

127-
// Slack
128-
implementation 'com.slack.api:slack-api-client:1.45.3'
129-
130127
// Flyway
131128
implementation 'org.flywaydb:flyway-core'
132129
implementation 'org.flywaydb:flyway-mysql'
130+
131+
// WebFlux
132+
implementation 'org.springframework.boot:spring-boot-starter-webflux'
133133
}
134134

135135
configurations {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package org.ject.support.common.webclient;
2+
3+
import io.netty.channel.ChannelOption;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
7+
import org.springframework.web.reactive.function.client.WebClient;
8+
import reactor.netty.http.client.HttpClient;
9+
10+
import java.time.Duration;
11+
12+
@Configuration
13+
public class WebClientConfig {
14+
15+
private static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 3_000; // 연결 최대 시간
16+
private static final Duration DEFAULT_RESPONSE_TIMEOUT = Duration.ofSeconds(5); // 응답 대기 최대 시간
17+
18+
@Bean
19+
public WebClient webClient(WebClient.Builder builder) {
20+
HttpClient httpClient = HttpClient.create()
21+
.responseTimeout(DEFAULT_RESPONSE_TIMEOUT)
22+
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, DEFAULT_CONNECT_TIMEOUT_MILLIS);
23+
24+
return builder
25+
.clientConnector(new ReactorClientHttpConnector(httpClient))
26+
.build();
27+
}
28+
}

src/main/java/org/ject/support/domain/admin/controller/AdminAuthApiSpec.java

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,26 @@
55
import jakarta.servlet.http.HttpServletRequest;
66
import jakarta.servlet.http.HttpServletResponse;
77
import jakarta.validation.Valid;
8-
import org.ject.support.domain.admin.dto.AdminAuthSendSlackRequest;
9-
import org.ject.support.domain.admin.dto.AdminAuthSendSlackResponse;
10-
import org.ject.support.domain.admin.dto.AdminVerifySlackRequest;
8+
import org.ject.support.domain.admin.dto.AdminAuthSendRequest;
9+
import org.ject.support.domain.admin.dto.AdminAuthSendResponse;
10+
import org.ject.support.domain.admin.dto.AdminVerifyRequest;
1111
import org.springframework.web.bind.annotation.RequestBody;
1212

1313
@Tag(name = "Admin", description = "관리자 API")
1414
public interface AdminAuthApiSpec {
1515

1616
@Operation(
17-
summary = "관리자 인증 Slack 코드 전송",
18-
description = "관리자 인증 코드를 Slack으로 전송합니다."
17+
summary = "관리자 로그인 인증 코드 전송",
18+
description = "관리자 로그인 인증 코드를 전송합니다."
1919
)
20-
AdminAuthSendSlackResponse sendAdminAuthSlackCode(@RequestBody @Valid AdminAuthSendSlackRequest request);
20+
AdminAuthSendResponse sendAdminAuthCode(@RequestBody @Valid AdminAuthSendRequest request);
2121

2222
@Operation(
23-
summary = "관리자 인증 Slack 코드 검증",
24-
description = "관리자 인증 Slack 코드를 검증합니다."
23+
summary = "관리자 로그인 인증 코드 검증",
24+
description = "관리자 로그인 인증 코드를 검증합니다."
2525
)
26-
boolean verifyAdminAuthSlackCode(
27-
@RequestBody @Valid AdminVerifySlackRequest request,
26+
boolean verifyAdminAuthCode(
27+
@RequestBody @Valid AdminVerifyRequest request,
2828
HttpServletRequest httpRequest,
2929
HttpServletResponse httpResponse
3030
);

src/main/java/org/ject/support/domain/admin/controller/AdminAuthController.java

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
import jakarta.validation.Valid;
66
import lombok.RequiredArgsConstructor;
77
import org.ject.support.common.security.CustomSuccessHandler;
8-
import org.ject.support.domain.admin.dto.AdminAuthSendSlackRequest;
9-
import org.ject.support.domain.admin.dto.AdminAuthSendSlackResponse;
10-
import org.ject.support.domain.admin.dto.AdminVerifySlackRequest;
8+
import org.ject.support.domain.admin.dto.AdminAuthSendRequest;
9+
import org.ject.support.domain.admin.dto.AdminAuthSendResponse;
10+
import org.ject.support.domain.admin.dto.AdminVerifyRequest;
1111
import org.ject.support.domain.admin.service.AdminAuthService;
1212
import org.springframework.security.core.Authentication;
1313
import org.springframework.web.bind.annotation.PostMapping;
@@ -23,21 +23,21 @@ public class AdminAuthController implements AdminAuthApiSpec {
2323
private final AdminAuthService adminAuthService;
2424
private final CustomSuccessHandler customSuccessHandler;
2525

26-
@PostMapping("/auth/slack-codes")
27-
public AdminAuthSendSlackResponse sendAdminAuthSlackCode(@RequestBody @Valid AdminAuthSendSlackRequest request) {
28-
String email = adminAuthService.sendSlackAdminAuthCode(request.email());
29-
return AdminAuthSendSlackResponse.builder()
26+
@PostMapping("/auth/codes")
27+
public AdminAuthSendResponse sendAdminAuthCode(@RequestBody @Valid AdminAuthSendRequest request) {
28+
String email = adminAuthService.sendAdminAuthCode(request.email());
29+
return AdminAuthSendResponse.builder()
3030
.email(email)
3131
.build();
3232
}
3333

34-
@PostMapping("/auth/slack-codes/verify")
35-
public boolean verifyAdminAuthSlackCode(
36-
@RequestBody @Valid AdminVerifySlackRequest request,
34+
@PostMapping("/auth/codes/verify")
35+
public boolean verifyAdminAuthCode(
36+
@RequestBody @Valid AdminVerifyRequest request,
3737
HttpServletRequest httpRequest,
3838
HttpServletResponse httpResponse
3939
) {
40-
Authentication authentication = adminAuthService.verifySlackAdminAuthCode(request.email(), request.code());
40+
Authentication authentication = adminAuthService.verifyAdminAuthCode(request.email(), request.code());
4141
customSuccessHandler.onAuthenticationSuccess(httpRequest, httpResponse, authentication);
4242
return true;
4343
}

src/main/java/org/ject/support/domain/admin/dto/AdminAuthSendSlackRequest.java renamed to src/main/java/org/ject/support/domain/admin/dto/AdminAuthSendRequest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import jakarta.validation.constraints.Email;
44
import jakarta.validation.constraints.NotBlank;
55

6-
public record AdminAuthSendSlackRequest(
6+
public record AdminAuthSendRequest(
77
@NotBlank(message = "Email은 필수 입력 값입니다.")
88
@Email(message = "유효하지 않은 이메일 형식입니다.")
99
String email

src/main/java/org/ject/support/domain/admin/dto/AdminAuthSendSlackResponse.java renamed to src/main/java/org/ject/support/domain/admin/dto/AdminAuthSendResponse.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
import lombok.Builder;
44

55
@Builder
6-
public record AdminAuthSendSlackResponse(String email) {
6+
public record AdminAuthSendResponse(String email) {
77
}

src/main/java/org/ject/support/domain/admin/dto/AdminVerifySlackRequest.java renamed to src/main/java/org/ject/support/domain/admin/dto/AdminVerifyRequest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import jakarta.validation.constraints.NotBlank;
55
import jakarta.validation.constraints.Size;
66

7-
public record AdminVerifySlackRequest(
7+
public record AdminVerifyRequest(
88
@NotBlank(message = "Email은 필수 입력 값입니다.")
99
@Email(message = "유효하지 않은 이메일 형식입니다.")
1010
String email,

src/main/java/org/ject/support/domain/admin/service/AdminAuthService.java

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,33 @@
11
package org.ject.support.domain.admin.service;
22

33
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
45
import org.ject.support.common.security.jwt.JwtTokenProvider;
56
import org.ject.support.common.util.CodeGeneratorUtil;
67
import org.ject.support.domain.admin.component.AdminMemberComponent;
78
import org.ject.support.domain.admin.exception.AdminErrorCode;
89
import org.ject.support.domain.admin.exception.AdminException;
910
import org.ject.support.domain.member.MemberStatus;
1011
import org.ject.support.domain.member.entity.Member;
11-
import org.ject.support.external.infrastructure.SlackRateLimiter;
12-
import org.ject.support.external.slack.SlackComponent;
12+
import org.ject.support.external.discord.DiscordComponent;
13+
import org.ject.support.external.infrastructure.DiscordRateLimiter;
1314
import org.springframework.data.redis.core.RedisTemplate;
1415
import org.springframework.security.core.Authentication;
1516
import org.springframework.stereotype.Service;
1617
import org.springframework.transaction.annotation.Transactional;
1718

1819
import java.time.Duration;
1920

21+
@Slf4j
2022
@Service
2123
@RequiredArgsConstructor
2224
@Transactional(readOnly = true)
2325
public class AdminAuthService {
2426

2527
private final RedisTemplate<String, String> redisTemplate;
2628
private final AdminMemberComponent adminMemberComponent;
27-
private final SlackRateLimiter slackRateLimiter;
28-
private final SlackComponent slackComponent;
29+
private final DiscordRateLimiter discordRateLimiter;
30+
private final DiscordComponent discordComponent;
2931
private final JwtTokenProvider jwtTokenProvider;
3032

3133
private static final String ADMIN_LOGIN_AUTH_CODE_KEY_PREFIX = "admin-login:";
@@ -35,16 +37,18 @@ public class AdminAuthService {
3537
private static final long ADMIN_LOGIN_AUTH_CODE_FAIL_LOCK_TIME = 10 * 60;
3638
private static final int ADMIN_LOGIN_MAX_FAILURE_COUNT = 3;
3739

38-
public String sendSlackAdminAuthCode(String email) {
40+
public String sendAdminAuthCode(String email) {
3941
Member member = adminMemberComponent.getMemberAdminByEmail(email);
4042
checkMemberStatus(member);
4143

4244
String authCode = CodeGeneratorUtil.generateUpperAlphaNumCode(ADMIN_LOGIN_AUTH_CODE_LENGTH);
4345
String key = ADMIN_LOGIN_AUTH_CODE_KEY_PREFIX + member.getId();
4446

45-
if (slackRateLimiter.tryConsume(1)) {
47+
if (discordRateLimiter.tryConsume(1)) {
4648
redisTemplate.opsForValue().set(key, authCode, Duration.ofSeconds(ADMIN_LOGIN_AUTH_CODE_EXPIRATION));
47-
slackComponent.sendAdminLoginMessage(makeAdminLoginMessage(member.getEmail(), authCode));
49+
discordComponent.sendAdminLoginMessage(makeAdminLoginMessage(member.getEmail(), authCode))
50+
.doOnError(e -> log.error("Discord 전송 실패: {}", member.getEmail(), e))
51+
.subscribe();
4852
} else {
4953
throw new AdminException(AdminErrorCode.TOO_MANY_REQUESTS);
5054
}
@@ -53,7 +57,7 @@ public String sendSlackAdminAuthCode(String email) {
5357
}
5458

5559
@Transactional
56-
public Authentication verifySlackAdminAuthCode(String email, String authCode) {
60+
public Authentication verifyAdminAuthCode(String email, String authCode) {
5761
Member member = adminMemberComponent.getMemberAdminByEmail(email);
5862

5963
checkMemberStatus(member);
@@ -107,6 +111,10 @@ private int getFailCount(String failCountKey) {
107111
}
108112

109113
private String makeAdminLoginMessage(String email, String code) {
110-
return "관리자 로그인 : { " + email + " } 인증 코드를 입력해 주세요 [" + code + "]";
114+
return """
115+
관리자 로그인 인증 코드 요청
116+
관리자 이메일: %s
117+
인증 코드 : %s
118+
""".formatted(email, code);
111119
}
112120
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package org.ject.support.external.discord;
2+
3+
import lombok.extern.slf4j.Slf4j;
4+
import org.springframework.beans.factory.annotation.Value;
5+
import org.springframework.core.env.Environment;
6+
import org.springframework.core.env.Profiles;
7+
import org.springframework.http.ResponseEntity;
8+
import org.springframework.stereotype.Component;
9+
import org.springframework.util.StringUtils;
10+
import org.springframework.web.reactive.function.client.WebClient;
11+
import reactor.core.publisher.Mono;
12+
13+
@Slf4j
14+
@Component
15+
public class DiscordComponent {
16+
17+
private final WebClient webClient;
18+
private final Environment environment;
19+
private final String prefix;
20+
21+
@Value("${notification.discord.webhook.admin-login}")
22+
private String adminLoginWebhook;
23+
24+
public DiscordComponent(WebClient webClient, Environment environment) {
25+
this.webClient = webClient;
26+
this.environment = environment;
27+
this.prefix = resolvePrefix(environment);
28+
}
29+
30+
public Mono<Void> sendAdminLoginMessage(String description) {
31+
DiscordWebhookPayload.Embed content = new DiscordWebhookPayload.Embed("로그인 인증 요청", description);
32+
DiscordWebhookPayload payload = new DiscordWebhookPayload();
33+
payload.setContent("[관리자 로그인]");
34+
payload.getEmbeds().add(content);
35+
return sendMessage(adminLoginWebhook, payload);
36+
}
37+
38+
/**
39+
* 논블로킹 방식으로 메시지 전송. 호출자가 구독/에러처리를 담당해야 함.
40+
* 기존 payload는 변경하지 않고 사본을 만들어 전송.
41+
*/
42+
public Mono<Void> sendMessage(String webhookUrl, DiscordWebhookPayload payload) {
43+
if (!StringUtils.hasText(webhookUrl)) {
44+
log.warn("Discord webhook URL이 설정되지 않았습니다, 메시지 전송을 건너뜁니다.");
45+
return Mono.empty();
46+
}
47+
48+
DiscordWebhookPayload toSend = createPayloadCopyWithPrefix(payload, prefix);
49+
50+
return webClient.post()
51+
.uri(webhookUrl)
52+
.bodyValue(toSend)
53+
.retrieve()
54+
.toBodilessEntity()
55+
.doOnSuccess((ResponseEntity<Void> resp) ->
56+
log.info("Discord message sent successfully"))
57+
.doOnError(e ->
58+
log.error("Discord message send failed: {} - {}", e.getClass().getSimpleName(), e.getMessage()))
59+
.then();
60+
}
61+
62+
private DiscordWebhookPayload createPayloadCopyWithPrefix(DiscordWebhookPayload original, String prefix) {
63+
DiscordWebhookPayload copy = new DiscordWebhookPayload();
64+
String originalContent = original == null || original.getContent() == null ? "" : original.getContent();
65+
copy.setContent(prefix + originalContent);
66+
67+
if (original != null && original.getEmbeds() != null) {
68+
copy.getEmbeds().addAll(original.getEmbeds());
69+
}
70+
return copy;
71+
}
72+
73+
private String resolvePrefix(Environment environment) {
74+
if (environment != null && environment.acceptsProfiles(Profiles.of("prod", "production"))) {
75+
return "";
76+
} else {
77+
return "[개발] ";
78+
}
79+
}
80+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package org.ject.support.external.discord;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Data;
5+
6+
import java.util.ArrayList;
7+
import java.util.List;
8+
9+
@Data
10+
public class DiscordWebhookPayload {
11+
private String content;
12+
private boolean tts = false;
13+
private List<Embed> embeds = new ArrayList<>();
14+
15+
@Data
16+
@AllArgsConstructor
17+
public static class Embed {
18+
private String title;
19+
private String description;
20+
}
21+
}

0 commit comments

Comments
 (0)