From 40bde12c6d70f1c28ec887f3e40e497fba5e5b01 Mon Sep 17 00:00:00 2001 From: pandoli365 Date: Sat, 13 Jun 2026 15:05:18 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A02?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/security-hardening-ddl.sql | 54 ++++++++++++++++++ .../bibimbap/security/CsrfTokens.java | 57 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 docs/security-hardening-ddl.sql create mode 100644 src/main/java/com/pandoli365/bibimbap/security/CsrfTokens.java diff --git a/docs/security-hardening-ddl.sql b/docs/security-hardening-ddl.sql new file mode 100644 index 0000000..daa4505 --- /dev/null +++ b/docs/security-hardening-ddl.sql @@ -0,0 +1,54 @@ +-- Security hardening DDL for environments that already have the base tables. +-- Review duplicate rows before adding the unique constraint. + +-- Find duplicate login identities before enforcing uniqueness. +SELECT + "provider", + "provider_user_id", + COUNT(*) AS duplicate_count +FROM "user_auth_identities" +WHERE "is_delete" IS NOT TRUE +GROUP BY "provider", "provider_user_id" +HAVING COUNT(*) > 1; + +-- Enforce one active identity per provider account. +CREATE UNIQUE INDEX IF NOT EXISTS "ux_user_auth_identities_provider_user_id_active" + ON "user_auth_identities" ("provider", "provider_user_id") + WHERE "is_delete" IS NOT TRUE; + +-- Add missing recruit_posts constraints safely if the base DDL was applied before it became idempotent. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'recruit_posts_user_id_fkey' + ) THEN + ALTER TABLE "recruit_posts" + ADD CONSTRAINT "recruit_posts_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id"); + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'recruit_posts_role_check' + ) THEN + ALTER TABLE "recruit_posts" + ADD CONSTRAINT "recruit_posts_role_check" + CHECK ("role" IN ('기획', '아트', '프로그래머')); + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'recruit_posts_participation_type_check' + ) THEN + ALTER TABLE "recruit_posts" + ADD CONSTRAINT "recruit_posts_participation_type_check" + CHECK ("participation_type" IN ('취미', '수익쉐어', '유급', '게임잼')); + END IF; +END +$$; diff --git a/src/main/java/com/pandoli365/bibimbap/security/CsrfTokens.java b/src/main/java/com/pandoli365/bibimbap/security/CsrfTokens.java new file mode 100644 index 0000000..664a8c4 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/security/CsrfTokens.java @@ -0,0 +1,57 @@ +package com.pandoli365.bibimbap.security; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Map; + +public final class CsrfTokens { + + public static final String SESSION_ATTRIBUTE = "csrfToken"; + public static final String HEADER_NAME = "X-CSRF-Token"; + private static final int TOKEN_BYTES = 32; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private CsrfTokens() { + } + + public static String getOrCreate(HttpSession session) { + if (session == null) { + return ""; + } + Object existing = session.getAttribute(SESSION_ATTRIBUTE); + if (existing instanceof String token && !token.isBlank()) { + return token; + } + byte[] bytes = new byte[TOKEN_BYTES]; + SECURE_RANDOM.nextBytes(bytes); + String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + session.setAttribute(SESSION_ATTRIBUTE, token); + return token; + } + + public static boolean isValid(HttpServletRequest request) { + HttpSession session = request == null ? null : request.getSession(false); + if (session == null) { + return false; + } + Object expected = session.getAttribute(SESSION_ATTRIBUTE); + if (!(expected instanceof String expectedToken) || expectedToken.isBlank()) { + return false; + } + String provided = request.getHeader(HEADER_NAME); + if (provided == null || provided.isBlank()) { + provided = request.getParameter("_csrf"); + } + return expectedToken.equals(provided); + } + + public static Map errorBody() { + return Map.of( + "status", 403, + "message", "요청 보안 토큰이 유효하지 않습니다." + ); + } +}