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
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ dependencies {

implementation 'com.amazonaws:aws-java-sdk-s3:1.12.529'

implementation 'com.google.firebase:firebase-admin:9.4.3'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Firebase Admin SDK 최신 버전 확인
curl -s https://repo1.maven.org/maven2/com/google/firebase/firebase-admin/maven-metadata.xml | grep -o '<latest>[^<]*</latest>' | sed 's/<[^>]*>//g'

# GitHub Security Advisories 확인
gh api graphql -f query='
{
  securityVulnerabilities(first: 5, ecosystem: MAVEN, package: "com.google.firebase:firebase-admin") {
    nodes {
      advisory {
        summary
        severity
        publishedAt
      }
      vulnerableVersionRange
      firstPatchedVersion {
        identifier
      }
    }
  }
}'

Repository: souzip/souzip-api

Length of output: 244


firebase-admin 9.4.3 버전(보안 권고/취약점) 확인 및 필요 시 업그레이드 권장 (build.gradle 63행)

  • 현재 Gradle에 com.google.firebase:firebase-admin:9.4.3을 사용 중이며, Maven 메타데이터 기준 최신은 9.9.0입니다. 취약점 스캔/호환성 검토 후 9.9.0(또는 그 이후)로 업그레이드를 검토하세요.
  • GitHub Security Advisories는 API 호출이 인증 필요(HTTP 401)로 현재 상태에서 직접 확인이 어렵습니다. 대신 Gradle 의존성 트리(compileClasspath/runtimeClasspath) 기반 SCA로 실제 취약 컴포넌트(트랜지티브 포함) 유무를 확인하고, 필요 시 dependency constraints/override로 패치 버전을 적용하세요.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@build.gradle` at line 63, The build declares an outdated Firebase Admin
dependency ("com.google.firebase:firebase-admin:9.4.3") which should be reviewed
and updated; change the version to 9.9.0 (or later) in the implementation
coordinate, then run a Gradle dependency report (e.g., ./gradlew dependencies
--configuration compileClasspath and runtimeClasspath or dependencyInsight) to
verify transitive vulnerabilities and compatibility, and if vulnerable
transitive artifacts are found add dependency constraints or
force/implementation overrides in build.gradle to pin safe patch versions and
run your test suite to confirm no regressions.


compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

Expand Down
1 change: 1 addition & 0 deletions src/docs/asciidoc/admin/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ include::auth.adoc[]
include::management.adoc[]
include::location.adoc[]
include::notice.adoc[]
include::push.adoc[]
22 changes: 22 additions & 0 deletions src/docs/asciidoc/admin/push.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[[admin-push-api]]
== 푸시 브로드캐스트 관리

[[admin-push-broadcast]]
=== 전체 기기 푸시 발송

관리자가 입력한 제목·본문으로 활성 FCM 토큰 전체에 푸시를 발송합니다.

* ADMIN 권한이 필요합니다.
* 발송 결과(대상/성공/실패 수)는 이력으로 저장됩니다.
* Firebase가 설정되지 않은 경우 전송하지 않고 대상 기기 수만 반환합니다.

operation::admin/push/broadcast[snippets='http-request,request-fields,http-response,response-fields']

[[admin-push-broadcast-history]]
=== 푸시 발송 이력 조회

푸시 브로드캐스트 발송 이력을 최신순으로 페이지 조회합니다.

* VIEWER 이상 권한이 필요합니다.

operation::admin/push/broadcast-history[snippets='http-request,query-parameters,http-response,response-fields']
1 change: 1 addition & 0 deletions src/docs/asciidoc/app/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ include::location.adoc[]
include::geocoding.adoc[]
include::search.adoc[]
include::notice.adoc[]
include::notification.adoc[]
21 changes: 21 additions & 0 deletions src/docs/asciidoc/app/notification.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[[notification-api]]
== FCM 토큰 API

[[fcm-token-register]]
=== FCM 토큰 등록

로그인 사용자의 FCM 디바이스 토큰을 등록하거나 갱신합니다.

* 인증이 필요합니다.
* 동일 토큰 또는 동일 디바이스가 있으면 갱신하고, 없으면 신규 등록합니다.

operation::fcm-token/register[snippets='http-request,request-fields,http-response,response-fields']

[[fcm-token-deactivate]]
=== FCM 토큰 비활성화

로그아웃 등으로 해당 디바이스의 FCM 토큰을 비활성화합니다.

* 인증이 필요합니다.

operation::fcm-token/deactivate[snippets='http-request,query-parameters,http-response,response-fields']
75 changes: 75 additions & 0 deletions src/main/java/com/souzip/adapter/webapi/admin/AdminPushApi.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.souzip.adapter.webapi.admin;

import com.souzip.adapter.webapi.admin.dto.PushBroadcastRequest;
import com.souzip.application.notification.FcmNotificationService;
import com.souzip.application.notification.PushBroadcastHistoryCommandService;
import com.souzip.application.notification.PushBroadcastHistoryQueryService;
import com.souzip.application.notification.dto.PushBroadcastHistoryResponse;
import com.souzip.application.notification.dto.PushBroadcastResult;
import com.souzip.domain.admin.infrastructure.security.annotation.AdminAccess;
import com.souzip.domain.admin.infrastructure.security.annotation.CurrentAdminId;
import com.souzip.domain.admin.infrastructure.security.annotation.ViewerAccess;
import com.souzip.shared.common.dto.SuccessResponse;
import com.souzip.shared.common.dto.pagination.PaginationRequest;
import com.souzip.shared.common.dto.pagination.PaginationResponse;
import jakarta.validation.Valid;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RequestMapping("/api/admin/push")
@RequiredArgsConstructor
@RestController
public class AdminPushApi {

private final FcmNotificationService fcmNotificationService;
private final PushBroadcastHistoryCommandService pushBroadcastHistoryCommandService;
private final PushBroadcastHistoryQueryService pushBroadcastHistoryQueryService;

// 관리자가 입력한 제목·본문으로 활성 FCM 토큰 전체에 푸시를 발송합니다.
@AdminAccess
@PostMapping("/broadcast")
public SuccessResponse<PushBroadcastResult> broadcast(
@CurrentAdminId UUID adminId,
@Valid @RequestBody PushBroadcastRequest request
) {
PushBroadcastResult result = fcmNotificationService.broadcastToAllActiveTokens(
request.title(),
request.body()
);
pushBroadcastHistoryCommandService.record(adminId, request.title(), request.body(), result);
String message = buildMessage(result);
return SuccessResponse.of(result, message);
}

// 푸시 브로드캐스트 발송 이력을 최신순으로 페이지 조회합니다.
@ViewerAccess
@GetMapping("/broadcast/history")
public SuccessResponse<PaginationResponse<PushBroadcastHistoryResponse>> broadcastHistory(
@ModelAttribute PaginationRequest paginationRequest

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 문서화한 페이지 파라미터를 실제로 바인딩하세요

medium: 신규 history API의 RestDocs는 page/size를 노출하지만, 여기서 받는 PaginationRequest는 기존 컨벤션처럼 pageNo/pageSize 생성자 파라미터만 바인딩합니다. 문서대로 ?page=2&size=20을 보내는 클라이언트는 값이 무시되어 기본값(1, 10)으로만 조회하게 되므로, 문서/테스트를 pageNo/pageSize로 맞추거나 이 endpoint에서 @RequestParam page/size를 명시적으로 받아 변환해 주세요.

Useful? React with 👍 / 👎.

) {
PaginationResponse<PushBroadcastHistoryResponse> page =
pushBroadcastHistoryQueryService.findPage(paginationRequest);
return SuccessResponse.of(page);
}

private static String buildMessage(PushBroadcastResult result) {
if (result.totalTargets() == 0) {
return "등록된 활성 기기가 없어 전송하지 않았습니다.";
}
if (!result.firebaseConfigured()) {
return "Firebase가 설정되지 않아 전송하지 않았습니다. (대상 기기 " + result.totalTargets() + "건)";
}
return String.format(
"푸시 발송 완료. 대상 %d건, 성공 %d건, 실패 %d건",
result.totalTargets(),
result.successCount(),
result.failCount()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.souzip.adapter.webapi.admin.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record PushBroadcastRequest(
@NotBlank(message = "제목은 필수입니다.")
@Size(max = 200, message = "제목은 200자를 초과할 수 없습니다.")
String title,

@NotBlank(message = "내용은 필수입니다.")
@Size(max = 1000, message = "내용은 1000자를 초과할 수 없습니다.")
String body
) {
public PushBroadcastRequest {
title = title != null ? title.trim() : null;
body = body != null ? body.trim() : null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package com.souzip.application.notification;

import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.FirebaseMessagingException;
import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.Notification;
import com.souzip.application.notification.dto.PushBroadcastResult;
import com.souzip.application.notification.provided.FcmTokenFinder;
import com.souzip.domain.notification.FcmToken;
import com.souzip.shared.exception.BusinessException;
import com.souzip.shared.exception.ErrorCode;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class FcmNotificationService {

private final FcmTokenFinder fcmTokenFinder;
private final FcmTokenCommandService fcmTokenCommandService;
private final ObjectProvider<FirebaseMessaging> firebaseMessaging;

// 단일 기기 토큰으로 알림(제목·본문)을 전송합니다.
public void sendToToken(String registrationToken, String title, String body) {
sendToToken(registrationToken, title, body, Map.of());
}

// 데이터 페이로드를 포함해 전송합니다.
public void sendToToken(String registrationToken, String title, String body, Map<String, String> data) {
FirebaseMessaging messaging = firebaseMessaging.getIfAvailable();
if (messaging == null) {
log.warn("FirebaseMessaging 빈이 없습니다. firebase.enabled 와 credentials 를 확인하세요. 전송을 건너뜁니다.");
return;
}
try {
Notification notification = Notification.builder()
.setTitle(title)
.setBody(body)
.build();
Message.Builder messageBuilder = Message.builder()
.setToken(registrationToken)
.setNotification(notification);
if (data != null && !data.isEmpty()) {
messageBuilder.putAllData(new HashMap<>(data));
}
messaging.send(messageBuilder.build());
} catch (FirebaseMessagingException e) {
log.error("FCM 전송 실패 tokenPrefix={} error={}", maskToken(registrationToken), e.getMessagingErrorCode(), e);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Firebase 예외 원문을 로그에 남기지 마세요

medium: 아래 주석처럼 FirebaseMessagingException 메시지에는 원본 registration token 같은 민감 정보가 섞일 수 있는데, log.error(..., e)로 throwable을 그대로 넘기면 stack trace의 Exception.toString()에 그 메시지가 다시 출력됩니다. FCM이 잘못된 토큰 값을 메시지에 포함하는 실패 케이스에서는 maskToken()과 무관하게 운영 로그에 토큰이 남을 수 있으니, error code/마스킹 값만 로깅하거나 throwable 메시지를 sanitize해 주세요.

Useful? React with 👍 / 👎.

// 예외 메시지에 원본 토큰 등 민감 정보가 섞일 수 있어 그대로 전파하지 않고, 오류 코드만 보존합니다.
throw new FcmSendException(e.getMessagingErrorCode());
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// DB 조회는 FcmTokenFinder(짧은 readOnly 트랜잭션)에서만 하고, FCM 전송은 트랜잭션 밖에서 수행합니다.
public void sendToUser(Long userId, String title, String body) {
List<FcmToken> tokens = fcmTokenFinder.getActiveTokensByUserId(userId);
if (tokens.isEmpty()) {
return;
}
int successCount = 0;
int failCount = 0;
List<Long> staleTokenIds = new ArrayList<>();
for (FcmToken token : tokens) {
try {
sendToToken(token.getToken(), title, body);
successCount++;
} catch (FcmSendException e) {
failCount++;
if (e.isPermanentFailure()) {
staleTokenIds.add(token.getId());
}
log.warn(
"FCM 전송 실패(다음 토큰으로 계속) userId={}, fcmTokenId={}, errorCode={}",
userId,
token.getId(),
e.getMessagingErrorCode()
);
}
}
deactivateStaleTokens(staleTokenIds);
if (failCount > 0 && successCount == 0) {
throw new BusinessException(ErrorCode.FCM_SEND_FAILED, "활성 토큰 전송이 모두 실패했습니다.");
}
if (failCount > 0) {
log.warn("FCM 일부 토큰 실패 userId={}, 성공={}, 실패={}", userId, successCount, failCount);
}
}
Comment on lines +62 to +94

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

userId 로깅은 PII 유출에 해당할 수 있음.

Lines 72-77, 84에서 userId를 로그에 기록하고 있습니다. 코딩 가이드라인에 따르면 "Never log PII (email, phone, tokens, access keys)"이며, 사용자 식별자(userId)는 PII로 간주될 수 있습니다. 운영 환경에서 민감 정보 유출을 방지하기 위해 userId 로깅을 제거하거나 마스킹하는 것을 권장합니다.

디버깅 목적이라면 fcmTokenId만 로깅하거나, 필요시 별도의 디버그 레벨 로그로 분리하는 방안을 고려하세요.

🔒 PII 로깅 제거 제안
                 failCount++;
                 log.warn(
-                        "FCM 전송 실패(다음 토큰으로 계속) userId={}, fcmTokenId={}, errorCode={}",
-                        userId,
+                        "FCM 전송 실패(다음 토큰으로 계속) fcmTokenId={}, errorCode={}",
                         token.getId(),
                         e.getErrorCode()
                 );
         if (failCount > 0) {
-            log.warn("FCM 일부 토큰 실패 userId={}, 성공={}, 실패={}", userId, successCount, failCount);
+            log.warn("FCM 일부 토큰 실패 성공={}, 실패={}", successCount, failCount);
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public void sendToUser(Long userId, String title, String body) {
List<FcmToken> tokens = fcmTokenFinder.getActiveTokensByUserId(userId);
if (tokens.isEmpty()) {
return;
}
int successCount = 0;
int failCount = 0;
for (FcmToken token : tokens) {
try {
sendToToken(token.getToken(), title, body);
successCount++;
} catch (BusinessException e) {
failCount++;
log.warn(
"FCM 전송 실패(다음 토큰으로 계속) userId={}, fcmTokenId={}, errorCode={}",
userId,
token.getId(),
e.getErrorCode()
);
}
}
if (failCount > 0 && successCount == 0) {
throw new BusinessException(ErrorCode.FCM_SEND_FAILED, "활성 토큰 전송이 모두 실패했습니다.");
}
if (failCount > 0) {
log.warn("FCM 일부 토큰 실패 userId={}, 성공={}, 실패={}", userId, successCount, failCount);
}
}
public void sendToUser(Long userId, String title, String body) {
List<FcmToken> tokens = fcmTokenFinder.getActiveTokensByUserId(userId);
if (tokens.isEmpty()) {
return;
}
int successCount = 0;
int failCount = 0;
for (FcmToken token : tokens) {
try {
sendToToken(token.getToken(), title, body);
successCount++;
} catch (BusinessException e) {
failCount++;
log.warn(
"FCM 전송 실패(다음 토큰으로 계속) fcmTokenId={}, errorCode={}",
token.getId(),
e.getErrorCode()
);
}
}
if (failCount > 0 && successCount == 0) {
throw new BusinessException(ErrorCode.FCM_SEND_FAILED, "활성 토큰 전송이 모두 실패했습니다.");
}
if (failCount > 0) {
log.warn("FCM 일부 토큰 실패 성공={}, 실패={}", successCount, failCount);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/souzip/application/notification/FcmNotificationService.java`
around lines 59 - 86, The logs in sendToUser leak a user identifier (userId)
which is PII; remove or mask userId in all log.warn calls inside sendToUser and
instead log only the non-PII token id (token.getId()) and error info
(e.getErrorCode()), or move detailed user-linked logs to a debug-only message.
Concretely, update the catch block that calls log.warn("FCM 전송 실패... userId={},
fcmTokenId={}, errorCode={}") to omit or mask userId and keep fcmTokenId and
errorCode, and update the later log.warn("FCM 일부 토큰 실패 userId={}, 성공={}, 실패={}")
to remove or mask userId (e.g., log only success/fail counts or use a
debug-level message that includes a masked userId). Ensure these changes are
applied in the sendToUser method and that references to FcmToken.getId(),
sendToToken, and BusinessException handling remain intact.

Source: Coding guidelines


// DB 조회는 FcmTokenFinder(짧은 readOnly 트랜잭션)에서만 하고, FCM 전송 루프는 트랜잭션 밖에서 수행합니다.
public PushBroadcastResult broadcastToAllActiveTokens(String title, String body) {
List<FcmToken> tokens = fcmTokenFinder.getAllActiveTokens();
if (tokens.isEmpty()) {
return new PushBroadcastResult(0, 0, 0, true);
}
FirebaseMessaging messaging = firebaseMessaging.getIfAvailable();
if (messaging == null) {
log.warn("FirebaseMessaging 빈이 없습니다. 브로드캐스트를 건너뜁니다. 대상 기기 수={}", tokens.size());
return new PushBroadcastResult(tokens.size(), 0, 0, false);
}
int successCount = 0;
int failCount = 0;
List<Long> staleTokenIds = new ArrayList<>();
for (FcmToken token : tokens) {
try {
sendToToken(token.getToken(), title, body);
successCount++;
} catch (FcmSendException e) {
failCount++;
if (e.isPermanentFailure()) {
staleTokenIds.add(token.getId());
}
log.warn(
"FCM 브로드캐스트 실패(다음 토큰으로 계속) fcmTokenId={}, errorCode={}",
token.getId(),
e.getMessagingErrorCode()
);
}
}
deactivateStaleTokens(staleTokenIds);
return new PushBroadcastResult(tokens.size(), successCount, failCount, true);
}

// 영구 실패(UNREGISTERED 등) 토큰은 이후 전송 대상에서 제외되도록 비활성화합니다.
private void deactivateStaleTokens(List<Long> staleTokenIds) {
if (staleTokenIds.isEmpty()) {
return;
}
fcmTokenCommandService.deactivateByIds(staleTokenIds);
log.info("영구 실패로 비활성화한 FCM 토큰 수={}", staleTokenIds.size());
}

private static String maskToken(String token) {
if (token == null || token.length() < 12) {
return "***";
}
return token.substring(0, 6) + "..." + token.substring(token.length() - 4);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.souzip.application.notification;

import com.google.firebase.messaging.MessagingErrorCode;
import com.souzip.shared.exception.BusinessException;
import com.souzip.shared.exception.ErrorCode;

// FCM 전송 실패를 나타내며, 영구 실패 여부 판별을 위해 MessagingErrorCode 를 보존합니다.
// (예외 메시지에는 토큰 등 민감 정보를 담지 않습니다.)
public class FcmSendException extends BusinessException {

private final transient MessagingErrorCode messagingErrorCode;

public FcmSendException(MessagingErrorCode messagingErrorCode) {
super(ErrorCode.FCM_SEND_FAILED);
this.messagingErrorCode = messagingErrorCode;
}

public MessagingErrorCode getMessagingErrorCode() {
return messagingErrorCode;
}

// 토큰이 더 이상 유효하지 않아 재전송해도 계속 실패하는 영구 오류인지 여부.
public boolean isPermanentFailure() {
return messagingErrorCode == MessagingErrorCode.UNREGISTERED
|| messagingErrorCode == MessagingErrorCode.INVALID_ARGUMENT
|| messagingErrorCode == MessagingErrorCode.SENDER_ID_MISMATCH;
Comment on lines +23 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge INVALID_ARGUMENT만으로 토큰을 비활성화하지 마세요

medium: INVALID_ARGUMENT는 stale token뿐 아니라 잘못된 message/data payload에도 반환될 수 있습니다. 예를 들어 sendToToken(..., data) 호출에서 지원하지 않는 data key/값 때문에 FCM이 INVALID_ARGUMENT를 반환하면, 사용자 전송이나 broadcast 루프가 유효한 토큰들을 모두 영구 실패로 분류해 active=false로 바꿀 수 있습니다. 토큰 자체가 무효인 경우만 비활성화하도록 오류 세부 원인이나 토큰 검증 경로를 분리해 주세요.

Useful? React with 👍 / 👎.

}
}
Loading
Loading