Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import org.springframework.web.util.UrlPathHelper

// prod·전 환경의 /admin 게이트 — 슬랙으로 검증된 세션 + allowlist IP + 세션-IP 바인딩 셋 다 맞아야 통과, 아니면 404.
// password 가 아니라 "슬랙 링크 클릭으로 발급된 세션"이 신원이다. 미허용은 401/302 가 아니라 404(존재 숨김).
// 공개 진입(/admin-access/**)·정적(/admin-assets/**)은 경로가 달라 이 필터 대상이 아니다(shouldNotFilter).
// 공개 진입(/admin-access/**)은 경로가 달라 이 필터 대상이 아니다(shouldNotFilter).
//
// order: SessionRepositoryFilter 바로 안쪽이어야 한다(#891). 세션이 Redis 로 옮겨간 뒤(#885/#888)
// getSession 은 그 필터가 씌우는 요청 래퍼를 통해서만 저장소에 닿는다 — 바깥에서 부르면 래퍼가 없는 원본 요청이라
Expand Down Expand Up @@ -81,7 +81,7 @@ class AdminAccessFilter(

fun isGatedRequest(request: HttpServletRequest): Boolean = isGatedPath(PATH_HELPER.getPathWithinApplication(request))

// /admin 과 /admin/** 만 게이트. /admin-assets·/admin-access 는 다른 prefix 라 제외(공개 진입·정적).
// /admin 과 /admin/** 만 게이트. /admin-access 는 다른 prefix 라 제외(공개 진입).
// 세그먼트 경계로 매칭해 /admin-access 류 과매칭을 막는다.
fun isGatedPath(uri: String): Boolean = uri == "/admin" || uri.startsWith("/admin/")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.depromeet.piki.admin.access

import com.depromeet.piki.admin.config.ClientIp
import com.depromeet.piki.admin.config.ConditionalOnAdminEnabled
import com.depromeet.piki.common.response.ApiResponseBody
import io.swagger.v3.oas.annotations.Hidden
import jakarta.servlet.http.HttpServletRequest
import org.springframework.web.bind.annotation.GetMapping
Expand All @@ -19,16 +20,23 @@ class AdminSessionController(
private val allowlistService: AdminAllowlistService,
) {
// 남은 세션 시간(초). 카운트다운 UI 가 폴링한다. 만료·미등록이면 0.
// 내부 SSR 엔드포인트도 공통 응답 래퍼로 통일한다(AdminTemplateController.preview·resultJson 과 같은 결).
@GetMapping("/ttl")
fun ttl(request: HttpServletRequest): Map<String, Long> = mapOf("remainingSeconds" to remainingSeconds(ClientIp.of(request)))
fun ttl(request: HttpServletRequest): ApiResponseBody<AdminSessionTtl> =
ApiResponseBody.ok(AdminSessionTtl(remainingSeconds(ClientIp.of(request))))

// 명시적 연장 — allowlist TTL 을 다시 채운다. 연장 버튼 클릭으로만 호출된다(배경 요청으로는 갱신되지 않음).
@PostMapping("/extend")
fun extend(request: HttpServletRequest): Map<String, Long> {
fun extend(request: HttpServletRequest): ApiResponseBody<AdminSessionTtl> {
val ip = ClientIp.of(request)
allowlistService.refresh(ip)
return mapOf("remainingSeconds" to remainingSeconds(ip))
return ApiResponseBody.ok(AdminSessionTtl(remainingSeconds(ip)))
}

private fun remainingSeconds(ip: String): Long = allowlistService.ttl(ip)?.seconds ?: 0L
}

// 상단바 카운트다운이 읽는 유일한 값. Map 대신 타입을 두어 응답 모양이 컴파일로 추적된다.
data class AdminSessionTtl(
val remainingSeconds: Long,
)
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,13 @@ class DiscordAccessController(
val ip = ClientIp.of(request)
allowlistService.grant(ip, identity.name)
// ADMIN 은 백오피스라 세션(신원)을 발급한다. DOCS/SPEC(#733)는 문서 노출용이라 IP 등록만 하고 세션은 안 준다.
//
// 신원을 심기 전에 기존 세션을 버리고 새 id 를 발급한다(#988) — 세션 픽세이션 방어. 그대로 getSession(true)
// 를 부르면 공격자가 미리 심어둔 JSESSIONID 에 admin 신원이 얹혀, 공격자가 이미 아는 id 가 admin 세션이 된다.
// Spring Security 의 세션 픽세이션 보호는 인증 이벤트에서 도는데 이 체인은 permitAll(인증 없음)이라 안 돈다.
// 지금은 아래 IP 바인딩이 피해를 막고 있으나, 같은 egress NAT 를 쓰는 공격자에겐 그 한 겹이 통하지 않는다.
if (identity.dest.issueSession) {
request.getSession(false)?.invalidate()
AdminSession.establish(request.getSession(true), identity.name, ip)
}
auditService.record(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@ import org.springframework.stereotype.Component

// `/docs` — dev API 레퍼런스 문서 접근 grant(#733). Link 버튼 2개([API 문서]·[api.json])를 발급한다.
// 문서는 dev 만 노출하므로 env 는 dev 고정(prod 는 springdoc off 로 문서 자체가 404). 버튼을 사용자 브라우저에서
// 열면 그 기기 IP 가 allowlist 에 등록되고(1h sliding), 이후 그 IP 에서 /docs·/v3/api-docs 둘 다 열린다(세션 없이 IP 만).
// 열면 그 기기 IP 가 allowlist 에 등록되고, 이후 그 IP 에서 /docs·/v3/api-docs 둘 다 열린다(세션 없이 IP 만).
//
// 등록 수명은 admin.allowlist-ttl(기본 24h) 이고 EnvironmentAccessFilter 가 접근마다 sliding 으로 밀어준다 —
// 주석·안내 문구가 오래 1시간이라 적어 실제보다 짧은 창을 알렸다(#988). 짧은 창을 원하면 문구가 아니라
// DOCS/SPEC 전용 TTL 을 새로 두어야 한다(지금은 admin 세션과 같은 값을 공유한다).
//
// 두 버튼은 각각 grant 토큰(DOCS·SPEC)이라 목적지 페이지로 바로 리다이렉트한다. 하나만 눌러 IP 가 등록되면 나머지
// 페이지는 3분(토큰 수명)과 무관하게 1시간 동안 주소창으로 접근 가능하다 — 버튼은 목적지 편의일 뿐이다.
// 페이지는 3분(토큰 수명)과 무관하게 그 TTL 동안 주소창으로 접근 가능하다 — 버튼은 목적지 편의일 뿐이다.
@Component
@ConditionalOnAdminEnabled
class DocsCommandHandler(
Expand All @@ -31,7 +35,7 @@ class DocsCommandHandler(
return DiscordInteractions.embedWithLinkButtons(
DiscordInteractions.COLOR_GREEN,
"📄 dev API 문서 접근 — ${interaction.userName}",
"아래 버튼을 이 기기 브라우저에서 여세요 (3분 내). 한 번 열면 그 IP 가 1시간 등록돼 두 페이지 다 접근됩니다.",
"아래 버튼을 이 기기 브라우저에서 여세요 (3분 내). 한 번 열면 그 IP 가 ${adminProperties.allowlistTtl.toHours()}시간 등록돼 두 페이지 다 접근됩니다.",
listOf(
"API 문서" to "$host/admin-access/grant?token=$docsToken",
"api.json" to "$host/admin-access/grant?token=$specToken",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ import org.springframework.web.util.UrlPathHelper
// 게이트를 통과한다 — "내부 직결" 판정은 ClientIp.isInBoxDirect 가 소유한다(#872).
@Component
@ConditionalOnAdminEnabled
@Order(Ordered.HIGHEST_PRECEDENCE + 3)
// AccessLogFilter(HIGHEST+3) 바로 안쪽에 둔다(#988) — 같은 값이면 두 필터의 상대 순서가 빈 이름·등록 순서 같은
// 비명세 규칙으로 갈려, 이 게이트가 바깥으로 정렬되는 순간 게이트가 낸 404 가 access log 도 traceId 도 없이 사라진다.
// dev 문서 표면을 훑는 시도가 관측에서 통째로 실명하므로 값을 한 칸 안으로 내려 순서를 확정한다.
@Order(Ordered.HIGHEST_PRECEDENCE + 4)
class EnvironmentAccessFilter(
private val allowlistService: AdminAllowlistService,
private val adminProperties: AdminProperties,
Expand Down
6 changes: 4 additions & 2 deletions src/main/kotlin/com/depromeet/piki/admin/access/StatsEmbed.kt
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ object StatsEmbed {
),
)

private const val TYPE_CHANNEL_MESSAGE = 4
private const val FLAG_EPHEMERAL = 64
// 인터랙션 응답 타입·ephemeral 플래그는 같은 패키지 DiscordInteractions 가 소유한다(#988) — 여기서 다시
// 선언하면 Discord 가 값을 바꿀 때 한쪽만 고쳐져, /stats 만 admin 채널에 공개로 올라가는 식으로 조용히 갈린다.
private const val TYPE_CHANNEL_MESSAGE = DiscordInteractions.TYPE_CHANNEL_MESSAGE
private const val FLAG_EPHEMERAL = DiscordInteractions.FLAG_EPHEMERAL
private const val COLOR_BLURPLE = 0x5865F2
}
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,15 @@ class AdminAnnouncementController(
scheduledAt?.trim()?.ifBlank { null }?.let {
runCatching { LocalDateTime.parse(it) }.getOrElse { return "redirect:/admin/announcements/$id/send?error=time" }
}
adminAnnouncementService.schedule(id, at, actor = actor(request), clientIp = clientIp(request))
// 예약이면 목록에서 예약 상태를 보고, 즉시면 바로 결과(진행률) 화면으로 보낸다.
return at?.let { "redirect:/admin/announcements?scheduled" } ?: "redirect:/admin/announcements/$id/result"
return try {
adminAnnouncementService.schedule(id, at, actor = actor(request), clientIp = clientIp(request))
// 예약이면 목록에서 예약 상태를 보고, 즉시면 바로 결과(진행률) 화면으로 보낸다.
at?.let { "redirect:/admin/announcements?scheduled" } ?: "redirect:/admin/announcements/$id/result"
} catch (e: IllegalArgumentException) {
// 이미 발송·예약된 공지(발송 버튼 더블클릭 등)와 과거 시각 예약 — 흔한 운영자 레이스라 계약 실패다.
// 안 잡으면 @RestControllerAdvice 가 SSR 페이지를 raw JSON 400 으로 갈아치워 운영자가 화면을 잃는다(#988).
"redirect:/admin/announcements?error=send"
}
}

// 예약 취소 — SCHEDULED → DRAFT.
Expand All @@ -180,8 +186,13 @@ class AdminAnnouncementController(
@PathVariable id: Long,
request: HttpServletRequest,
): String {
adminAnnouncementService.cancelSchedule(id, actor = actor(request), clientIp = clientIp(request))
return "redirect:/admin/announcements?canceled"
return try {
adminAnnouncementService.cancelSchedule(id, actor = actor(request), clientIp = clientIp(request))
"redirect:/admin/announcements?canceled"
} catch (e: IllegalArgumentException) {
// 스케줄러가 방금 집어간 공지 — 취소할 대상이 이미 없다. 목록으로 돌려 현재 상태를 보게 한다.
"redirect:/admin/announcements?error=cancel"
}
}

// 발송 결과 화면 — 집계(성공·실패·코드별)와 진행률 %. SENDING 동안 result.json 을 폴링해 갱신한다.
Expand All @@ -206,8 +217,13 @@ class AdminAnnouncementController(
fun delete(
@PathVariable id: Long,
): String {
adminAnnouncementService.delete(id)
return "redirect:/admin/announcements?deleted"
return try {
adminAnnouncementService.delete(id)
"redirect:/admin/announcements?deleted"
} catch (e: IllegalArgumentException) {
// 그 사이 발송·예약으로 넘어갔거나 다른 운영자가 이미 지운 공지 — 삭제 대상이 아니다.
"redirect:/admin/announcements?error=delete"
}
}

private fun actor(request: HttpServletRequest): String = AdminSession.actorName(request)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.depromeet.piki.admin.announcement

import com.depromeet.piki.admin.config.ConditionalOnAdminEnabled
import com.depromeet.piki.announcement.domain.AnnouncementImageException
import org.slf4j.LoggerFactory
import org.springframework.http.client.SimpleClientHttpRequestFactory
Expand All @@ -26,6 +27,7 @@ interface AnnouncementImageFetcher {
}

@Component
@ConditionalOnAdminEnabled
class DefaultAnnouncementImageFetcher : AnnouncementImageFetcher {
private val log = LoggerFactory.getLogger(javaClass)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.depromeet.piki.admin.announcement

import com.depromeet.piki.admin.config.ConditionalOnAdminEnabled
import com.depromeet.piki.announcement.domain.AnnouncementBodyImages
import com.depromeet.piki.announcement.domain.AnnouncementImageFile
import com.depromeet.piki.common.storage.ImageStorage
Expand All @@ -12,6 +13,7 @@ import java.util.UUID
// fetch·업로드는 외부 호출이라 @Transactional 을 두지 않는다 — 호출부(AdminAnnouncementService)가
// 트랜잭션 밖에서 이 빈을 부르고, 영속화만 짧은 트랜잭션(AnnouncementWriter)에 위임한다(## 트랜잭션 경계).
@Component
@ConditionalOnAdminEnabled
class AnnouncementImageRehoster(
private val fetcher: AnnouncementImageFetcher,
private val imageStorage: ImageStorage,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.depromeet.piki.admin.announcement
import com.depromeet.piki.announcement.repository.AnnouncementRepository

import com.depromeet.piki.admin.config.ConditionalOnAdminEnabled
import com.depromeet.piki.notification.fcm.service.UserDeviceService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
Expand All @@ -10,6 +11,7 @@ import java.util.UUID
// 트랜잭션 밖에서 돌므로, 상태 전환·진행률 갱신·delivery 행 저장만 이 별도 빈의 짧은 트랜잭션에 위임한다
// (CLAUDE.md "외부 호출은 트랜잭션 밖" + self-invocation 회피 — async 빈에서 직접 @Transactional 호출은 proxy 우회).
@Service
@ConditionalOnAdminEnabled
class AnnouncementProgressWriter(
private val announcementRepository: AnnouncementRepository,
private val deliveryRepository: AnnouncementDeliveryRepository,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.depromeet.piki.admin.announcement

import com.depromeet.piki.admin.config.ConditionalOnAdminEnabled
import com.depromeet.piki.common.storage.S3Properties
import org.springframework.stereotype.Component

Expand All @@ -10,6 +11,7 @@ import org.springframework.stereotype.Component
// (#473 고도화 — kind 로 라벨·아이콘을 가른다) 알림 발송 경로의 소비자가 사라졌다. 지금 유일한 소비자가
// AdminAnnouncementController 뿐이라 admin 패키지에 둔다 — 알림 발송이 이 이미지를 쓴다는 오독을 막는다.
@Component
@ConditionalOnAdminEnabled
class DefaultPushImage(
s3Properties: S3Properties,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import org.springframework.security.web.servlet.util.matcher.PathPatternRequestM
import org.springframework.security.web.util.matcher.OrRequestMatcher

/**
* 백오피스 경로(/admin·/admin-access·/admin-assets)를 메인 JWT(stateless) 체인(@Order(2))에서 떼어내는 체인.
* 백오피스 경로(/admin·/admin-access)를 메인 JWT(stateless) 체인(@Order(2))에서 떼어내는 체인.
*
* 접근 제어는 이 체인이 아니라 그 앞단 서블릿 필터(EnvironmentAccessFilter·AdminAccessFilter, #526)가 한다 —
* 슬랙으로 검증된 세션 + IP allowlist. 따라서 이 체인은 admin 경로를 메인 체인에서 떼어 permitAll 로 통과시키고
Expand All @@ -31,13 +31,14 @@ class AdminSecurityConfig {
.securityMatcher(adminPathMatcher())
.authorizeHttpRequests { it.anyRequest().permitAll() }
.csrf {
// /admin-access/** 는 슬랙·디스코드 진입(서명검증으로 보호), /admin/session/** 는 폼이 아니라
// fetch(JSON) API(세션 남은시간·연장)라 CSRF 토큰을 실을 수 없다 — 둘 다 CSRF 제외한다.
// 나머지 /admin/** 폼(Thymeleaf)은 CSRF 를 유지한다(_csrf 히든).
it.ignoringRequestMatchers(
ADMIN_PATHS.matcher("/admin-access/**"),
ADMIN_PATHS.matcher("/admin/session/**"),
)
// /admin-access/** 만 CSRF 제외한다 — 슬랙·디스코드 진입이라 우리 페이지에서 토큰을 실을 수 없고,
// 대신 서명검증으로 보호된다. 나머지 /admin/** 은 폼(Thymeleaf _csrf 히든)이든 fetch 든 CSRF 를 유지한다.
//
// /admin/session/** 를 제외 목록에서 뺐다(#988) — "fetch 라 토큰을 실을 수 없다"는 전제가 틀렸다.
// 같은 admin 화면의 다른 fetch(템플릿 미리보기·공지 이미지 업로드)는 이미 헤더로 토큰을 싣고 있다.
// 제외해 두면 제3자 페이지가 cross-origin 폼 자동 제출로 extend 를 호출해 allowlist TTL 을 무기한
// 늘릴 수 있어, 연장은 명시적 버튼으로만 한다는 #669 의 보장이 무력화된다(GET /ttl 은 안전 메서드라 무관).
it.ignoringRequestMatchers(ADMIN_PATHS.matcher("/admin-access/**"))
}
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) }
.build()
Expand All @@ -48,7 +49,6 @@ class AdminSecurityConfig {
OrRequestMatcher(
ADMIN_PATHS.matcher("/admin"),
ADMIN_PATHS.matcher("/admin/**"),
ADMIN_PATHS.matcher("/admin-assets/**"),
ADMIN_PATHS.matcher("/admin-access/**"),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,15 @@ class AdminExtractionPolicyController(
)
"redirect:/admin/extraction-policies?updated"
} catch (e: IllegalArgumentException) {
model.addAttribute("error", e.message)
model.addAttribute("draftReason", reason)
detailView(
adminExtractionPolicyService.find(domain),
model,
selectedAccess = access.name,
draftPermissionRef = permissionRef,
)
// find 가 다시 던질 수 있다 — 그 사이 다른 운영자가 행을 지웠거나, 애초에 domain 이 정규화를 통과 못 하는
// 값(경로에 공백 등)이라 save 가 막힌 경우다. 복구 경로에서 새 예외가 새면 GlobalExceptionHandler 가
// 화면을 raw JSON 400 으로 갈아치워, 에러 메시지도 제출값도 함께 잃는다(#988). 그 땐 목록으로 돌린다.
runCatching { adminExtractionPolicyService.find(domain) }
.map { policy ->
model.addAttribute("error", e.message)
model.addAttribute("draftReason", reason)
detailView(policy, model, selectedAccess = access.name, draftPermissionRef = permissionRef)
}.getOrElse { "redirect:/admin/extraction-policies?missing" }
}

@PostMapping("/{domain}/delete")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,14 @@ class AdminSourcePlatformController(
adminSourcePlatformService.save(domain, displayName, actor = AdminSession.actorName(request), clientIp = ClientIp.of(request))
"redirect:/admin/source-platforms?updated"
} catch (e: IllegalArgumentException) {
model.addAttribute("error", e.message)
model.addAttribute("draftDisplayName", displayName)
detailView(adminSourcePlatformService.find(domain), model)
// find 가 다시 던질 수 있다 — 정규화를 통과 못 하는 domain(경로에 공백 등)이면 save 와 같은 이유로 막힌다.
// 복구 경로에서 새 예외가 새면 화면이 raw JSON 400 으로 갈린다(#988). 그 땐 목록으로 돌린다.
runCatching { adminSourcePlatformService.find(domain) }
.map { platform ->
model.addAttribute("error", e.message)
model.addAttribute("draftDisplayName", displayName)
detailView(platform, model)
}.getOrElse { "redirect:/admin/source-platforms?missing" }
}

@PostMapping("/{domain}/delete")
Expand Down
Loading
Loading