From efefafed58f98a1dbf33f6f17d94701c7a7e5ae6 Mon Sep 17 00:00:00 2001 From: kusuri12 Date: Mon, 19 Oct 2026 10:19:47 +0900 Subject: [PATCH 1/3] =?UTF-8?q?chore(ci):=20=EB=B3=80=EA=B2=BD=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=EB=A7=8C=20=EB=B9=8C=EB=93=9C=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EB=B0=B0=ED=8F=AC=ED=95=98=EB=8F=84=EB=A1=9D=20CD?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0=20#452?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/deploy_services.py | 76 ++++++++++++++++++++ .github/scripts/test_deploy_services.py | 92 +++++++++++++++++++++++++ .github/workflows/deploy.yml | 52 ++++++++++---- 3 files changed, 208 insertions(+), 12 deletions(-) create mode 100644 .github/scripts/deploy_services.py create mode 100644 .github/scripts/test_deploy_services.py diff --git a/.github/scripts/deploy_services.py b/.github/scripts/deploy_services.py new file mode 100644 index 000000000..ceb4cfb4e --- /dev/null +++ b/.github/scripts/deploy_services.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""변경된 빌드 입력으로 배포할 서비스 목록을 계산한다.""" + +import argparse +import json +from pathlib import Path +import re +import subprocess + +SERVICES = ("gateway", "identity", "application", "notification", "configuration", "admin", "observability") +ROOT = Path(__file__).resolve().parents[2] + + +def select_services(paths, root=ROOT): + # 문서와 CI 설정을 제외하고 실제 빌드 입력의 영향 범위를 모은다. + selected = set() + for name in paths: + path = Path(name) + parts = path.parts + if not parts or path.suffix.lower() in {".md", ".rst"}: + continue + if parts[0] in {".github", ".codex", ".agents", "documents", "docs"} or "src/test" in path.as_posix(): + continue + if len(parts) > 1 and parts[0] == "systems" and parts[1] in SERVICES: + selected.add(parts[1]) + elif parts[0] in {"systems", "contracts", "packages", "tools", "scripts"} or name in { + "Dockerfile", ".dockerignore", "prepare-images.sh", "BUILD.bazel", "MODULE.bazel", + "MODULE.bazel.lock", ".bazelrc", ".bazelversion", + } or path.suffix in {".bzl", ".bazel"} or path.name.startswith(".bazel"): + return list(SERVICES) + + # 다른 서비스 모듈을 참조하는 빌드 대상도 함께 갱신한다. + dependents = {service: set() for service in SERVICES} + for consumer in SERVICES: + directory = root / "systems" / consumer + for build_file in directory.rglob("*"): + if build_file.name != "BUILD.bazel" and build_file.suffix != ".bzl": + continue + for dependency in re.findall(r"//systems/([^/\s:\"]+)", build_file.read_text(encoding="utf-8")): + if dependency in dependents: + dependents[dependency].add(consumer) + pending = list(selected) + while pending: + for consumer in dependents[pending.pop()] - selected: + selected.add(consumer) + pending.append(consumer) + return [service for service in SERVICES if service in selected] + + +def main(): + # push 전체 범위의 삭제·이동 전후 경로를 포함하고 수동 실행은 전체 배포한다. + parser = argparse.ArgumentParser() + parser.add_argument("--before", default="") + parser.add_argument("--after", default="HEAD") + parser.add_argument("--all", action="store_true") + args = parser.parse_args() + if args.all or not args.before or set(args.before) == {"0"}: + services = list(SERVICES) + elif subprocess.run( + ["git", "cat-file", "-e", args.before + "^{commit}"], cwd=ROOT, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ).returncode: + # 강제 push 등으로 이전 커밋이 없으면 누락 방지를 위해 전체 배포한다. + services = list(SERVICES) + else: + paths = subprocess.check_output( + ["git", "diff", "--name-only", "--no-renames", "-z", args.before, args.after], cwd=ROOT, + ).decode("utf-8").split("\0") + services = select_services(filter(None, paths)) + print("services=" + json.dumps(services, separators=(",", ":"))) + print("service_args=" + " ".join(services)) + print("has_services=" + str(bool(services)).lower()) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/test_deploy_services.py b/.github/scripts/test_deploy_services.py new file mode 100644 index 000000000..7323c914a --- /dev/null +++ b/.github/scripts/test_deploy_services.py @@ -0,0 +1,92 @@ +"""배포 대상 선정의 요구사항 기반 회귀 테스트.""" + +import importlib.util +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +SCRIPT = Path(__file__).with_name("deploy_services.py") +SPEC = importlib.util.spec_from_file_location("deploy_services", SCRIPT) +deploy_services = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(deploy_services) + +ALL = ["gateway", "identity", "application", "notification", "configuration", "admin", "observability"] + + +class DeployServicesTest(unittest.TestCase): + def select(self, *paths): + return deploy_services.select_services(paths) + + def test_documentation_only_skips_deployment(self): + self.assertEqual([], self.select("README.md", "docs/auth.rst", "systems/identity/README.md")) + + def test_unrelated_workflow_only_skips_deployment(self): + self.assertEqual([], self.select(".github/workflows/lint.yml", ".github/ISSUE_TEMPLATE/bug.yml")) + + def test_no_changes_skips_deployment(self): + self.assertEqual([], self.select()) + + def test_identity_change_builds_only_identity(self): + self.assertEqual(["identity"], self.select("systems/identity/identity-application/src/main/kotlin/AuthService.kt")) + + def test_multiple_services_are_selected_once(self): + self.assertEqual(["identity", "notification"], self.select( + "systems/notification/notification-domain/src/main/kotlin/Notification.kt", + "systems/identity/identity-domain/src/main/kotlin/User.kt", + "systems/identity/identity-domain/src/main/kotlin/Token.kt")) + + def test_documentation_does_not_hide_code_change(self): + self.assertEqual(["identity"], self.select("README.md", "systems/identity/BUILD.bazel")) + + def test_common_code_affects_all_services(self): + self.assertEqual(ALL, self.select("systems/common/src/main/kotlin/Shared.kt")) + + def test_root_build_inputs_affect_all_services(self): + for path in ["Dockerfile", ".dockerignore", "MODULE.bazel", "MODULE.bazel.lock", ".bazelrc", ".bazelversion", "kotlin.bzl", "BUILD.bazel", "prepare-images.sh"]: + with self.subTest(path=path): + self.assertEqual(ALL, self.select(path)) + + def test_shared_input_directories_affect_all_services(self): + for path in ["contracts/example.proto", "packages/shared/BUILD.bazel", "tools/build.sh"]: + with self.subTest(path=path): + self.assertEqual(ALL, self.select(path)) + + def test_deleted_service_file_still_selects_service(self): + self.assertEqual(["identity"], self.select("systems/identity/deleted/Old.kt")) + + def test_unrelated_directory_does_not_match_service_prefix(self): + self.assertNotEqual(["identity"], self.select("systems/identity-other/src/Main.kt")) + + def test_transitive_consumers_are_included(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for service, dependency in [("application", "configuration"), ("admin", "application")]: + target = root / "systems" / service / "BUILD.bazel" + target.parent.mkdir(parents=True) + target.write_text(f'alias(name="main", actual="//systems/{dependency}/domain:main")', encoding="utf-8") + self.assertEqual(["application", "configuration", "admin"], deploy_services.select_services( + ["systems/configuration/domain/src/main/kotlin/Config.kt"], root=root)) + + def test_initial_push_and_manual_deployment_select_all(self): + for arguments in [[], ["--all"], ["--before", "0" * 40]]: + with self.subTest(arguments=arguments): + result = subprocess.run([sys.executable, str(SCRIPT), *arguments], capture_output=True, text=True, check=True) + outputs = dict(line.split("=", 1) for line in result.stdout.splitlines()) + self.assertEqual(ALL, json.loads(outputs["services"])) + self.assertEqual(" ".join(ALL), outputs["service_args"]) + self.assertEqual("true", outputs["has_services"]) + + def test_unavailable_revision_deploys_all_instead_of_silently_skipping(self): + result = subprocess.run([sys.executable, str(SCRIPT), "--before", "not-a-valid-deploy-revision"], capture_output=True, text=True) + self.assertEqual(0, result.returncode) + outputs = dict(line.split("=", 1) for line in result.stdout.splitlines()) + self.assertEqual(ALL, json.loads(outputs["services"])) + self.assertEqual("true", outputs["has_services"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 76163a734..fdbe2ca7f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -49,21 +49,43 @@ permissions: packages: write jobs: + changes: + name: 배포 대상 확인 + runs-on: ubuntu-latest + outputs: + services: ${{ steps.services.outputs.services }} + service_args: ${{ steps.services.outputs.service_args }} + has_services: ${{ steps.services.outputs.has_services }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 변경된 서비스 확인 + id: services + env: + BEFORE: ${{ github.event.before }} + AFTER: ${{ github.sha }} + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + args=(--before "$BEFORE" --after "$AFTER") + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + args+=(--all) + fi + python3 .github/scripts/deploy_services.py "${args[@]}" >> "$GITHUB_OUTPUT" + build: name: Build ${{ matrix.service }} + needs: changes + if: needs.changes.outputs.has_services == 'true' runs-on: ubuntu-latest strategy: fail-fast: false matrix: - service: - - gateway - - identity - - application - - notification - - configuration - - admin - - observability + service: ${{ fromJSON(needs.changes.outputs.services) }} steps: - name: Checkout @@ -127,9 +149,11 @@ jobs: name: Deploy STAG needs: + - changes - build if: | + needs.changes.outputs.has_services == 'true' && github.ref_name == 'develop' && vars.STAG_DEPLOY_HOSTS != '' @@ -174,6 +198,7 @@ jobs: GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }} IMAGE_TAG: ${{ github.sha }} + SERVICES: ${{ needs.changes.outputs.service_args }} run: | if [ -z "$GHCR_USERNAME" ] || [ -z "$GHCR_TOKEN" ]; then echo "GHCR_USERNAME 또는 GHCR_TOKEN이 없습니다." @@ -195,9 +220,9 @@ jobs: export IMAGE_TAG='$IMAGE_TAG' - docker compose pull + docker compose pull $SERVICES - docker compose up -d + docker compose up -d --no-deps $SERVICES docker compose restart nginx @@ -237,9 +262,11 @@ jobs: name: Deploy PROD needs: + - changes - build if: | + needs.changes.outputs.has_services == 'true' && github.ref_name == 'main' && vars.PROD_DEPLOY_HOSTS != '' @@ -284,6 +311,7 @@ jobs: GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }} IMAGE_TAG: ${{ github.sha }} + SERVICES: ${{ needs.changes.outputs.service_args }} run: | if [ -z "$GHCR_USERNAME" ] || [ -z "$GHCR_TOKEN" ]; then echo "GHCR_USERNAME 또는 GHCR_TOKEN이 없습니다." @@ -305,9 +333,9 @@ jobs: export IMAGE_TAG='$IMAGE_TAG' - docker compose pull + docker compose pull $SERVICES - docker compose up -d + docker compose up -d --no-deps $SERVICES docker image prune -f " From df8146a5e1ed9b24f49d106a11f2a6920457701a Mon Sep 17 00:00:00 2001 From: kusuri12 Date: Mon, 19 Oct 2026 10:19:47 +0900 Subject: [PATCH 2/3] =?UTF-8?q?chore(ci):=20=EA=B8=B0=EC=A1=B4=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EB=B0=B0=ED=8F=AC=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=20=EC=B6=94=EA=B0=80=20#45?= =?UTF-8?q?2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/test_deploy_only.py | 131 +++++++++++++++++++++++ .github/workflows/deploy-only.yml | 155 ++++++++++++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 .github/scripts/test_deploy_only.py create mode 100644 .github/workflows/deploy-only.yml diff --git a/.github/scripts/test_deploy_only.py b/.github/scripts/test_deploy_only.py new file mode 100644 index 000000000..2e740c862 --- /dev/null +++ b/.github/scripts/test_deploy_only.py @@ -0,0 +1,131 @@ +"""수동 배포 워크플로의 실제 입력 처리 및 원격 명령 검증.""" + +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import textwrap +import unittest + + +WORKFLOW = Path(__file__).parents[1] / "workflows" / "deploy-only.yml" +ALL = "gateway identity application notification configuration admin observability" + + +def step_script(name): + section = WORKFLOW.read_text(encoding="utf-8").split(f"- name: {name}\n", 1)[1] + lines = section.split("run: |\n", 1)[1].splitlines() + body = [] + for line in lines: + if line.strip() and not line.startswith(" "): + break + body.append(line) + return textwrap.dedent("\n".join(body)) + + +class DeployOnlyTest(unittest.TestCase): + def prepare(self, **changes): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "output" + env = dict(os.environ, BRANCH="develop", SERVICE="identity", INPUT_IMAGE_TAG="", STAG_HOSTS='["stag.example.com"]', PROD_HOSTS='["prod.example.com"]', GITHUB_OUTPUT=str(output)) + env.update(changes) + result = subprocess.run([sys.executable, "-c", step_script("서비스와 이미지 태그 검증")], env=env, capture_output=True, text=True) + values = dict(line.split("=", 1) for line in output.read_text(encoding="utf-8").splitlines()) if output.exists() else {} + return result, values + + def test_branch_defaults_and_environment_hosts(self): + for branch, stage in [("develop", "stag"), ("main", "prod")]: + with self.subTest(branch=branch): + result, output = self.prepare(BRANCH=branch) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(stage, output["stage"]) + self.assertEqual(stage, output["image_tag"]) + self.assertEqual([f"{stage}.example.com"], json.loads(output["hosts"])) + self.assertEqual("identity", output["services"]) + + def test_all_and_explicit_tag(self): + result, output = self.prepare(SERVICE="all", INPUT_IMAGE_TAG="release-2026.10_19") + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(ALL, output["services"]) + self.assertEqual("release-2026.10_19", output["image_tag"]) + + def test_each_supported_service(self): + for service in ALL.split(): + with self.subTest(service=service): + result, output = self.prepare(SERVICE=service) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(service, output["services"]) + + def test_invalid_inputs_rejected_before_outputs(self): + cases = [{"BRANCH": "feature/test"}, {"SERVICE": "identity notification"}, {"SERVICE": "identity;id"}] + cases += [{"INPUT_IMAGE_TAG": tag} for tag in ["bad tag", "x;id", "$(id)", "-bad", "a" * 129, "tag\nother"]] + cases += [{"STAG_HOSTS": hosts} for hosts in ['[]', '{}', '["host;id"]', '[42]']] + for changes in cases: + with self.subTest(changes=changes): + result, output = self.prepare(**changes) + self.assertNotEqual(0, result.returncode) + self.assertEqual({}, output) + + def test_tag_length_boundary(self): + result, output = self.prepare(INPUT_IMAGE_TAG="a" * 128) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("a" * 128, output["image_tag"]) + + @unittest.skipUnless(shutil.which("bash"), "Bash 실행 환경 필요") + def test_remote_commands_only_deploy_selected_service(self): + script = step_script("기존 이미지 배포") + mocks = ''' +ssh() { bash -c "${@: -1}"; } +docker() { printf 'DOCKER'; printf ' <%s>' "$@"; printf '\\n'; } +export -f docker +''' + for stage in ["stag", "prod"]: + with self.subTest(stage=stage): + env = dict(os.environ, GHCR_USERNAME="test", GHCR_TOKEN="fake", DEPLOY_USER="tester", HOST="mock", DEPLOY_PATH=".", IMAGE_TAG="existing-sha", SERVICES="identity", STAGE=stage) + result = subprocess.run([shutil.which("bash"), "-s"], input=mocks + script, env=env, capture_output=True, text=True) + self.assertEqual(0, result.returncode, result.stderr) + self.assertRegex(result.stdout, r"DOCKER [^\n]* ") + self.assertRegex(result.stdout, r"DOCKER [^\n]* <-d> <--no-deps> ") + self.assertNotIn("", result.stdout) + self.assertNotIn("", result.stdout) + self.assertNotIn("", result.stdout) + self.assertEqual(stage == "stag", "DOCKER " in result.stdout) + + @unittest.skipUnless(shutil.which("bash") and shutil.which("docker"), "Bash와 Docker Compose 필요") + def test_real_compose_preserves_dollar_environment_and_relative_mount(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "compose.yaml").write_text('name: deploy-fixture\nservices:\n identity:\n image: original:latest\n environment:\n SECRET: "literal$$TOKEN"\n volumes:\n - ./data:/data\n', encoding="utf-8") + baseline = subprocess.run([shutil.which("docker"), "compose", "-f", str(root / "compose.yaml"), "config", "--format", "json"], capture_output=True, text=True, check=True) + original_environment = json.loads(baseline.stdout)["services"]["identity"]["environment"] + mocks = ''' +ssh() { bash -c "${@: -1}"; } +docker() { + if [ "$1" = login ]; then return 0; fi + local args=("$@") + if [[ " ${args[*]} " == *" pull "* ]]; then + "$DOCKER_EXE" "${args[@]:0:${#args[@]}-2}" config --format json + elif [[ " ${args[*]} " == *" up "* ]]; then + return 0 + else + "$DOCKER_EXE" "$@" + fi +} +export -f docker +''' + env = dict(os.environ, GHCR_USERNAME="test", GHCR_TOKEN="fake", DEPLOY_USER="tester", HOST="mock", DEPLOY_PATH=root.as_posix(), IMAGE_TAG="existing-sha", SERVICES="identity", STAGE="prod", DOCKER_EXE=Path(shutil.which("docker")).as_posix()) + result = subprocess.run([shutil.which("bash"), "-s"], input=mocks + step_script("기존 이미지 배포"), env=env, capture_output=True, text=True) + self.assertEqual(0, result.returncode, result.stderr) + config = json.loads(result.stdout) + self.assertEqual("deploy-fixture", config["name"]) + identity = config["services"]["identity"] + self.assertEqual("ghcr.io/entrydsm/entrydsm-identity:existing-sha", identity["image"]) + self.assertEqual(original_environment, identity["environment"]) + self.assertEqual((root / "data").resolve(), Path(identity["volumes"][0]["source"]).resolve()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/deploy-only.yml b/.github/workflows/deploy-only.yml new file mode 100644 index 000000000..8f8cfa66c --- /dev/null +++ b/.github/workflows/deploy-only.yml @@ -0,0 +1,155 @@ +name: Deploy Only + +on: + workflow_dispatch: + inputs: + service: + description: 배포할 서비스 + required: true + type: choice + default: all + options: + - all + - gateway + - identity + - application + - notification + - configuration + - admin + - observability + image_tag: + description: 기존 이미지 태그 (비워두면 develop은 stag, main은 prod) + required: false + type: string + default: '' + +concurrency: + group: deploy-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + prepare: + name: 배포 입력 확인 + runs-on: ubuntu-latest + outputs: + hosts: ${{ steps.inputs.outputs.hosts }} + services: ${{ steps.inputs.outputs.services }} + image_tag: ${{ steps.inputs.outputs.image_tag }} + stage: ${{ steps.inputs.outputs.stage }} + steps: + - name: 서비스와 이미지 태그 검증 + id: inputs + env: + BRANCH: ${{ github.ref_name }} + SERVICE: ${{ inputs.service }} + INPUT_IMAGE_TAG: ${{ inputs.image_tag }} + STAG_HOSTS: ${{ vars.STAG_DEPLOY_HOSTS }} + PROD_HOSTS: ${{ vars.PROD_DEPLOY_HOSTS }} + shell: python + run: | + import json + import os + import re + + stages = {"develop": "stag", "main": "prod"} + branch = os.environ["BRANCH"] + if branch not in stages: + raise SystemExit("main 또는 develop 브랜치에서 실행하세요.") + stage = stages[branch] + services = ["gateway", "identity", "application", "notification", "configuration", "admin", "observability"] + service = os.environ["SERVICE"] + if service != "all" and service not in services: + raise SystemExit("지원하지 않는 서비스입니다.") + tag = os.environ["INPUT_IMAGE_TAG"] or stage + if not re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}", tag): + raise SystemExit("올바른 Docker 이미지 태그를 입력하세요.") + hosts = json.loads(os.environ[stage.upper() + "_HOSTS"] or "[]") + if not isinstance(hosts, list) or not hosts or any(not isinstance(host, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9.:-]*", host) for host in hosts): + raise SystemExit("해당 환경의 DEPLOY_HOSTS를 올바른 호스트 JSON 배열로 설정하세요.") + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write("hosts=" + json.dumps(hosts, separators=(",", ":")) + "\n") + output.write("services=" + " ".join(services if service == "all" else [service]) + "\n") + output.write("image_tag=" + tag + "\n") + output.write("stage=" + stage + "\n") + + deploy: + name: Deploy ${{ matrix.host }} + needs: prepare + runs-on: ubuntu-latest + strategy: + fail-fast: true + max-parallel: 1 + matrix: + host: ${{ fromJSON(needs.prepare.outputs.hosts) }} + env: + HOST: ${{ matrix.host }} + STAGE: ${{ needs.prepare.outputs.stage }} + SERVICES: ${{ needs.prepare.outputs.services }} + IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }} + DEPLOY_USER: ${{ vars[format('{0}_DEPLOY_USER', needs.prepare.outputs.stage == 'stag' && 'STAG' || 'PROD')] }} + DEPLOY_PATH: ${{ vars[format('{0}_DEPLOY_PATH', needs.prepare.outputs.stage == 'stag' && 'STAG' || 'PROD')] }} + HEALTHCHECK_URL: ${{ vars[format('{0}_HEALTHCHECK_URL', needs.prepare.outputs.stage == 'stag' && 'STAG' || 'PROD')] }} + steps: + - name: SSH 키 준비 + env: + SSH_KEY: ${{ secrets[format('{0}_DEPLOY_SSH_KEY', needs.prepare.outputs.stage == 'stag' && 'STAG' || 'PROD')] }} + KNOWN_HOSTS: ${{ secrets[format('{0}_DEPLOY_KNOWN_HOSTS', needs.prepare.outputs.stage == 'stag' && 'STAG' || 'PROD')] }} + run: | + set -euo pipefail + if [ -z "$SSH_KEY" ] || [ -z "$KNOWN_HOSTS" ] || [ -z "$DEPLOY_USER" ] || [ -z "$DEPLOY_PATH" ] || [ -z "$HEALTHCHECK_URL" ]; then + echo "배포 환경의 SSH 및 헬스체크 설정이 없습니다." + exit 1 + fi + mkdir -p ~/.ssh + chmod 700 ~/.ssh + printf '%s\n' "$SSH_KEY" > ~/.ssh/id_deploy + chmod 600 ~/.ssh/id_deploy + printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + + - name: 기존 이미지 배포 + env: + GHCR_USERNAME: ${{ vars.GHCR_USERNAME }} + GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }} + run: | + set -euo pipefail + if [ -z "$GHCR_USERNAME" ] || [ -z "$GHCR_TOKEN" ]; then + echo "GHCR_USERNAME 또는 GHCR_TOKEN이 없습니다." + exit 1 + fi + printf '%s' "$GHCR_TOKEN" | ssh -i ~/.ssh/id_deploy "$DEPLOY_USER@$HOST" " + set -e + docker login ghcr.io -u '$GHCR_USERNAME' --password-stdin + cd '$DEPLOY_PATH' + export IMAGE_TAG='$IMAGE_TAG' + base=\$(mktemp) + override=\$(mktemp) + trap 'rm -f \"\$base\" \"\$override\"' EXIT + docker compose config > \"\$base\" + printf 'services:\n' > \"\$override\" + for service in $SERVICES; do + printf ' %s:\n image: ghcr.io/entrydsm/entrydsm-%s:%s\n' \"\$service\" \"\$service\" '$IMAGE_TAG' >> \"\$override\" + done + docker compose --project-directory '$DEPLOY_PATH' -f \"\$base\" -f \"\$override\" pull $SERVICES + docker compose --project-directory '$DEPLOY_PATH' -f \"\$base\" -f \"\$override\" up -d --no-deps $SERVICES + if [ '$STAGE' = 'stag' ]; then + docker compose restart nginx + fi + " + + - name: 배포 헬스체크 + run: | + set -euo pipefail + for i in $(seq 1 30); do + if ssh -i ~/.ssh/id_deploy "$DEPLOY_USER@$HOST" "curl -fsS '$HEALTHCHECK_URL'" | grep -q '"status":"UP"'; then + echo "$HOST 정상" + exit 0 + fi + echo "$HOST 기동 대기 중... ($i/30)" + sleep 5 + done + echo "$HOST 헬스체크 실패" + exit 1 From 124c743577cb83bfa0c371b475c8ca08c20015d5 Mon Sep 17 00:00:00 2001 From: kusuri12 Date: Mon, 19 Oct 2026 10:41:31 +0900 Subject: [PATCH 3/3] =?UTF-8?q?enhancement(identity):=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=8B=A4=ED=8C=A8=20=EC=A0=9C=ED=95=9C=EC=9D=84=20?= =?UTF-8?q?30=ED=9A=8C=C2=B7300=ED=9A=8C=20=EB=B0=8F=205=EB=B6=84=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=99=84=ED=99=94=20#454?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/RedisAuthAttemptLimiter.kt | 88 ++++++++++++++++--- .../security/RedisAuthAttemptLimiterTest.kt | 52 +++++------ ...reshTokenRotationAdapterIntegrationTest.kt | 87 +++++++++++++++--- .../port/out/AuthAttemptLimiter.kt | 7 +- .../application/service/AuthService.kt | 6 +- .../application/service/AuthServiceTest.kt | 54 ++++++++++++ .../src/main/resources/application-dev.yaml | 6 +- .../src/main/resources/application.yaml | 6 +- .../src/test/resources/application-test.yml | 6 +- 9 files changed, 247 insertions(+), 65 deletions(-) diff --git a/systems/identity/identity-adapter-out/src/main/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisAuthAttemptLimiter.kt b/systems/identity/identity-adapter-out/src/main/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisAuthAttemptLimiter.kt index d4166d2b6..0c6227256 100644 --- a/systems/identity/identity-adapter-out/src/main/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisAuthAttemptLimiter.kt +++ b/systems/identity/identity-adapter-out/src/main/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisAuthAttemptLimiter.kt @@ -16,11 +16,11 @@ class RedisAuthAttemptLimiter( private val loginIdHasher: LoginIdHasher, @Value("\${identity.redis.namespace}") private val namespace: String, @Value("\${auth.jwt.issuer}") private val issuer: String, - @Value("\${auth.login.max-attempts:5}") private val loginMaxAttempts: Int, - @Value("\${auth.login.window-seconds:900}") private val loginWindowSeconds: Long, + @Value("\${auth.login.max-attempts:30}") private val loginMaxAttempts: Int, + @Value("\${auth.login.window-seconds:300}") private val loginWindowSeconds: Long, @Value("\${auth.password-reset.max-attempts:5}") private val resetMaxAttempts: Int, @Value("\${auth.password-reset.window-seconds:900}") private val resetWindowSeconds: Long, - @Value("\${auth.login.ip-max-attempts:50}") private val loginIpMaxAttempts: Int = 50, + @Value("\${auth.login.ip-max-attempts:300}") private val loginIpMaxAttempts: Int = 300, ) : AuthAttemptLimiter { init { require(namespace.isNotBlank() && issuer.isNotBlank()) @@ -28,22 +28,46 @@ class RedisAuthAttemptLimiter( require(loginWindowSeconds > 0 && resetWindowSeconds > 0) } + // 실패 횟수를 늘리지 않고 이미 생성된 차단만 검사한다. override fun checkLogin(loginId: String, clientIp: String) { - require(clientIp.isNotBlank()) - check("login-ip", clientIp, loginIpMaxAttempts, loginWindowSeconds) - check("login", "$clientIp\u0000$loginId", loginMaxAttempts, loginWindowSeconds) + execute(LOGIN_CHECK_SCRIPT, loginKeys(loginId, clientIp)) + } + + // 실패 누적과 임계 도달 시점의 차단을 Redis에서 원자적으로 기록한다. + override fun recordLoginFailure(loginId: String, clientIp: String) { + execute( + LOGIN_FAILURE_SCRIPT, + loginKeys(loginId, clientIp), + loginMaxAttempts.toString(), loginIpMaxAttempts.toString(), loginWindowSeconds.toString(), + ) + } + + // 동시에 생성된 차단을 재확인하고 계정별 연속 실패만 초기화한다. + override fun recordLoginSuccess(loginId: String, clientIp: String) { + execute(LOGIN_SUCCESS_SCRIPT, loginKeys(loginId, clientIp)) + } + + // 비밀번호 재설정은 기존 시도 횟수 제한을 유지한다. + override fun checkPasswordReset(loginId: String) { + execute( + ATTEMPT_SCRIPT, + listOf("$namespace:$issuer:identity:auth-attempt:password-reset:${loginIdHasher.hash(loginId)}"), + resetMaxAttempts.toString(), resetWindowSeconds.toString(), + ) } - override fun checkPasswordReset(loginId: String) = check("password-reset", loginId, resetMaxAttempts, resetWindowSeconds) + // 기존 전체 시도 카운터와 실패 카운터가 섞이지 않도록 키 버전을 분리한다. + private fun loginKeys(loginId: String, clientIp: String): List { + require(clientIp.isNotBlank()) + val pair = "$namespace:$issuer:identity:auth-attempt:login-failure-v2:${loginIdHasher.hash("$clientIp\u0000$loginId")}" + val ip = "$namespace:$issuer:identity:auth-attempt:login-ip-failure-v2:${loginIdHasher.hash(clientIp)}" + return listOf("$pair:count", "$ip:count", "$pair:lock", "$ip:lock") + } - private fun check(operation: String, loginId: String, maxAttempts: Int, windowSeconds: Long) { + // 저장소 오류와 차단 응답을 일관된 도메인 오류로 변환한다. + private fun execute(script: DefaultRedisScript, keys: List, vararg args: String) { val result = try { - redisTemplate.execute( - ATTEMPT_SCRIPT, - listOf("$namespace:$issuer:identity:auth-attempt:$operation:${loginIdHasher.hash(loginId)}"), - maxAttempts.toString(), - windowSeconds.toString(), - ) + redisTemplate.execute(script, keys, *args) } catch (exception: DataAccessException) { throw IdentityDomainException(ErrorCode.AUTH_ATTEMPT_STORE_UNAVAILABLE, exception) } @@ -55,6 +79,42 @@ class RedisAuthAttemptLimiter( } private companion object { + val LOGIN_CHECK_SCRIPT = DefaultRedisScript( + """ + if redis.call('EXISTS', KEYS[3], KEYS[4]) > 0 then + return 0 + end + return 1 + """.trimIndent(), + Long::class.java, + ) + val LOGIN_FAILURE_SCRIPT = DefaultRedisScript( + """ + for i = 1, 2 do + if redis.call('EXISTS', KEYS[i + 2]) == 0 then + local count = redis.call('INCR', KEYS[i]) + if count >= tonumber(ARGV[i]) then + redis.call('SET', KEYS[i + 2], '1', 'EX', ARGV[3]) + redis.call('DEL', KEYS[i]) + else + redis.call('EXPIRE', KEYS[i], ARGV[3]) + end + end + end + return 1 + """.trimIndent(), + Long::class.java, + ) + val LOGIN_SUCCESS_SCRIPT = DefaultRedisScript( + """ + if redis.call('EXISTS', KEYS[3], KEYS[4]) > 0 then + return 0 + end + redis.call('DEL', KEYS[1]) + return 1 + """.trimIndent(), + Long::class.java, + ) val ATTEMPT_SCRIPT = DefaultRedisScript( """ local count = tonumber(redis.call('GET', KEYS[1]) or '0') diff --git a/systems/identity/identity-adapter-out/src/test/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisAuthAttemptLimiterTest.kt b/systems/identity/identity-adapter-out/src/test/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisAuthAttemptLimiterTest.kt index 50da03958..46df1937f 100644 --- a/systems/identity/identity-adapter-out/src/test/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisAuthAttemptLimiterTest.kt +++ b/systems/identity/identity-adapter-out/src/test/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisAuthAttemptLimiterTest.kt @@ -9,7 +9,6 @@ import org.mockito.Mockito.any import org.mockito.Mockito.eq import org.mockito.Mockito.mock import org.mockito.Mockito.verify -import org.mockito.Mockito.verifyNoMoreInteractions import org.mockito.Mockito.`when` import org.springframework.dao.DataAccessResourceFailureException import org.springframework.data.redis.core.StringRedisTemplate @@ -18,59 +17,54 @@ import org.springframework.data.redis.core.script.RedisScript class RedisAuthAttemptLimiterTest { private val template = mock(StringRedisTemplate::class.java) private val hasher = HmacLoginIdHasher("attempt-test-key") - private val limiter = RedisAuthAttemptLimiter(template, hasher, "test", "issuer", 5, 900, 3, 60) + private val limiter = RedisAuthAttemptLimiter(template, hasher, "test", "issuer", 30, 300, 3, 60) @Test - fun usesSeparateHashedKeysAndConfiguredLimits() { - `when`(template.execute(any>(), eq(listOf(key("login-ip"))), eq("50"), eq("900"))) - .thenReturn(1L) - `when`(template.execute(any>(), eq(listOf(key("login"))), eq("5"), eq("900"))) - .thenReturn(1L) - `when`(template.execute(any>(), eq(listOf(key("password-reset"))), eq("3"), eq("60"))) - .thenReturn(1L) - limiter.checkLogin("01012345678", "192.0.2.1") - limiter.checkPasswordReset("01012345678") - verify(template).execute(any>(), eq(listOf(key("login"))), eq("5"), eq("900")) - verify(template).execute(any>(), eq(listOf(key("password-reset"))), eq("3"), eq("60")) + fun failureUsesHashedScopeKeysAndConfiguredFailureLimits() { + `when`(template.execute(any>(), eq(loginKeys()), eq("30"), eq("300"), eq("300"))).thenReturn(1L) + limiter.recordLoginFailure("01012345678", "192.0.2.1") + verify(template).execute(any>(), eq(loginKeys()), eq("30"), eq("300"), eq("300")) } @Test - fun exhaustedLimitReturns429AndRedisFailureReturns503() { - `when`(template.execute(any>(), eq(listOf(key("login-ip"))), eq("50"), eq("900"))) - .thenReturn(1L) - `when`(template.execute(any>(), eq(listOf(key("login"))), eq("5"), eq("900"))) + fun loginCheckAndSuccessMapLockAndStoreFailure() { + `when`(template.execute(any>(), eq(loginKeys()))) .thenReturn(0L) .thenThrow(DataAccessResourceFailureException("redis unavailable")) assertEquals(ErrorCode.AUTH_ATTEMPTS_EXCEEDED, assertThrows(IdentityDomainException::class.java) { limiter.checkLogin("01012345678", "192.0.2.1") }.errorCode) assertEquals(ErrorCode.AUTH_ATTEMPT_STORE_UNAVAILABLE, assertThrows(IdentityDomainException::class.java) { - limiter.checkLogin("01012345678", "192.0.2.1") + limiter.recordLoginSuccess("01012345678", "192.0.2.1") }.errorCode) } + @Test + fun passwordResetRetainsItsOwnKeysAndLimits() { + val key = "test:issuer:identity:auth-attempt:password-reset:${hasher.hash("01012345678")}" + `when`(template.execute(any>(), eq(listOf(key)), eq("3"), eq("60"))).thenReturn(1L) + limiter.checkPasswordReset("01012345678") + verify(template).execute(any>(), eq(listOf(key)), eq("3"), eq("60")) + } + @Test fun missingScriptResultFailsClosed() { assertEquals(ErrorCode.AUTH_ATTEMPT_STORE_UNAVAILABLE, assertThrows(IdentityDomainException::class.java) { limiter.checkPasswordReset("01012345678") }.errorCode) - } - - @Test - fun sourceLimitBlocksBeforeAccountLimit() { - `when`(template.execute(any>(), eq(listOf(key("login-ip"))), eq("50"), eq("900"))) - .thenReturn(0L) - assertEquals(ErrorCode.AUTH_ATTEMPTS_EXCEEDED, assertThrows(IdentityDomainException::class.java) { + assertEquals(ErrorCode.AUTH_ATTEMPT_STORE_UNAVAILABLE, assertThrows(IdentityDomainException::class.java) { limiter.checkLogin("01012345678", "192.0.2.1") }.errorCode) - verify(template).execute(any>(), eq(listOf(key("login-ip"))), eq("50"), eq("900")) - verifyNoMoreInteractions(template) } @Test(expected = IllegalArgumentException::class) fun rejectsZeroAttemptLimit() { - RedisAuthAttemptLimiter(template, hasher, "test", "issuer", 0, 900, 3, 60) + RedisAuthAttemptLimiter(template, hasher, "test", "issuer", 0, 300, 3, 60) } - private fun key(operation: String) = "test:issuer:identity:auth-attempt:$operation:${hasher.hash(when (operation) { "login-ip" -> "192.0.2.1"; "login" -> "192.0.2.1\u000001012345678"; else -> "01012345678" })}" + private fun loginKeys(): List { + val pair = "test:issuer:identity:auth-attempt:login-failure-v2:${hasher.hash("192.0.2.1\u000001012345678")}" + val ip = "test:issuer:identity:auth-attempt:login-ip-failure-v2:${hasher.hash("192.0.2.1")}" + return listOf("$pair:count", "$ip:count", "$pair:lock", "$ip:lock") + } } diff --git a/systems/identity/identity-adapter-out/src/test/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisRefreshTokenRotationAdapterIntegrationTest.kt b/systems/identity/identity-adapter-out/src/test/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisRefreshTokenRotationAdapterIntegrationTest.kt index d05de4ecc..7a2c8e6d9 100644 --- a/systems/identity/identity-adapter-out/src/test/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisRefreshTokenRotationAdapterIntegrationTest.kt +++ b/systems/identity/identity-adapter-out/src/test/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisRefreshTokenRotationAdapterIntegrationTest.kt @@ -38,7 +38,9 @@ class RedisRefreshTokenRotationAdapterIntegrationTest { val first = RedisAuthAttemptLimiter(template, hasher, namespace, ISSUER, 2, 1, 1, 30) val second = RedisAuthAttemptLimiter(template, hasher, namespace, ISSUER, 2, 1, 1, 30) first.checkLogin("known", "192.0.2.1") + first.recordLoginFailure("known", "192.0.2.1") second.checkLogin("known", "192.0.2.1") + second.recordLoginFailure("known", "192.0.2.1") assertEquals(ErrorCode.AUTH_ATTEMPTS_EXCEEDED, assertThrows(IdentityDomainException::class.java) { first.checkLogin("known", "192.0.2.1") }.errorCode) @@ -53,31 +55,96 @@ class RedisRefreshTokenRotationAdapterIntegrationTest { } @Test - fun concurrentLoginAttemptsDoNotExceedTheLimit() { + fun concurrentLoginFailuresSetOneLockWithoutContinuingToIncrement() { + val namespace = "attempt-${UUID.randomUUID()}" val limiter = RedisAuthAttemptLimiter(template, HmacLoginIdHasher("attempt-test-key"), - "attempt-${UUID.randomUUID()}", ISSUER, 5, 30, 5, 30) + namespace, ISSUER, 5, 30, 5, 30) val executor = Executors.newFixedThreadPool(16) val start = CountDownLatch(1) try { val results = (1..16).map { executor.submit { assertTrue(start.await(5, TimeUnit.SECONDS)) - try { - limiter.checkLogin("known", "192.0.2.1") - true - } catch (exception: IdentityDomainException) { - assertEquals(ErrorCode.AUTH_ATTEMPTS_EXCEEDED, exception.errorCode) - false - } + limiter.recordLoginFailure("known", "192.0.2.1") + true } } start.countDown() - assertEquals(5, results.count { it.get(10, TimeUnit.SECONDS) }) + assertEquals(16, results.count { it.get(10, TimeUnit.SECONDS) }) + assertEquals(ErrorCode.AUTH_ATTEMPTS_EXCEEDED, assertThrows(IdentityDomainException::class.java) { + limiter.checkLogin("known", "192.0.2.1") + }.errorCode) + val keys = template.keys("$namespace:*") + assertEquals(1, keys.count { it.endsWith(":lock") }) + assertFalse(keys.any { it.contains(":login-failure-v2:") && it.endsWith(":count") }) } finally { executor.shutdownNow() } } + @Test + fun successesDoNotCountAndClearOnlyPairFailures() { + val limiter = RedisAuthAttemptLimiter(template, HmacLoginIdHasher("attempt-test-key"), + "attempt-${UUID.randomUUID()}", ISSUER, 2, 300, 5, 30, 3) + repeat(50) { + limiter.checkLogin("known", "192.0.2.1") + limiter.recordLoginSuccess("known", "192.0.2.1") + } + limiter.recordLoginFailure("known", "192.0.2.1") + limiter.recordLoginSuccess("known", "192.0.2.1") + limiter.recordLoginFailure("known", "192.0.2.1") + limiter.checkLogin("known", "192.0.2.1") + limiter.recordLoginFailure("other", "192.0.2.1") + assertEquals(ErrorCode.AUTH_ATTEMPTS_EXCEEDED, assertThrows(IdentityDomainException::class.java) { + limiter.checkLogin("unrelated", "192.0.2.1") + }.errorCode) + limiter.checkLogin("known", "192.0.2.2") + } + + @Test + fun productionThresholdsAllowThirtyPairAndThreeHundredSourceFailures() { + val limiter = RedisAuthAttemptLimiter(template, HmacLoginIdHasher("attempt-test-key"), + "attempt-${UUID.randomUUID()}", ISSUER, 30, 300, 5, 30, 300) + repeat(30) { + limiter.checkLogin("known", "192.0.2.1") + limiter.recordLoginFailure("known", "192.0.2.1") + } + assertEquals(ErrorCode.AUTH_ATTEMPTS_EXCEEDED, assertThrows(IdentityDomainException::class.java) { + limiter.checkLogin("known", "192.0.2.1") + }.errorCode) + repeat(270) { index -> + limiter.checkLogin("other-$index", "192.0.2.1") + limiter.recordLoginFailure("other-$index", "192.0.2.1") + } + assertEquals(ErrorCode.AUTH_ATTEMPTS_EXCEEDED, assertThrows(IdentityDomainException::class.java) { + limiter.checkLogin("another", "192.0.2.1") + }.errorCode) + } + + @Test + fun failureCounterRefreshesButLockTtlStartsAtThresholdAndNeverRefreshes() { + val namespace = "attempt-${UUID.randomUUID()}" + val hasher = HmacLoginIdHasher("attempt-test-key") + val limiter = RedisAuthAttemptLimiter(template, hasher, namespace, ISSUER, 3, 300, 5, 30) + val pairPrefix = "$namespace:$ISSUER:identity:auth-attempt:login-failure-v2:${hasher.hash("192.0.2.1\u0000known")}:" + limiter.recordLoginFailure("known", "192.0.2.1") + template.expire(pairPrefix + "count", 30, TimeUnit.SECONDS) + limiter.recordLoginFailure("other", "192.0.2.1") + // 다른 계정 실패는 pair의 유효기간을 갱신하지 않는다. + assertTrue(template.getExpire(pairPrefix + "count") <= 30) + limiter.recordLoginFailure("known", "192.0.2.1") + assertTrue(template.getExpire(pairPrefix + "count") in 298L..300L) + limiter.recordLoginFailure("known", "192.0.2.1") + assertTrue(template.getExpire(pairPrefix + "lock") in 298L..300L) + template.expire(pairPrefix + "lock", 2, TimeUnit.SECONDS) + limiter.recordLoginFailure("known", "192.0.2.1") + assertThrows(IdentityDomainException::class.java) { limiter.recordLoginSuccess("known", "192.0.2.1") } + assertTrue(template.getExpire(pairPrefix + "lock") <= 2) + Thread.sleep(2100) + limiter.checkLogin("known", "192.0.2.1") + limiter.recordLoginSuccess("known", "192.0.2.1") + } + @Before fun clearState() { template.keys("$KEY_PREFIX*").forEach(template::delete) diff --git a/systems/identity/identity-application/src/main/kotlin/hs/kr/entrydsm/identity/application/port/out/AuthAttemptLimiter.kt b/systems/identity/identity-application/src/main/kotlin/hs/kr/entrydsm/identity/application/port/out/AuthAttemptLimiter.kt index 323dea97a..4330c2f89 100644 --- a/systems/identity/identity-application/src/main/kotlin/hs/kr/entrydsm/identity/application/port/out/AuthAttemptLimiter.kt +++ b/systems/identity/identity-application/src/main/kotlin/hs/kr/entrydsm/identity/application/port/out/AuthAttemptLimiter.kt @@ -1,7 +1,12 @@ package hs.kr.entrydsm.identity.application.port.out -/** 로그인과 비밀번호 재설정의 시도 횟수를 각각 제한한다. */ +/** 로그인 실패와 비밀번호 재설정 시도를 각각 제한한다. */ interface AuthAttemptLimiter { + /** 로그인 전에 기존 차단 여부만 확인한다. */ fun checkLogin(loginId: String, clientIp: String) + /** 자격 증명 불일치만 실패 횟수에 반영한다. */ + fun recordLoginFailure(loginId: String, clientIp: String) + /** 차단 여부를 재확인하고 해당 IP와 계정의 연속 실패를 초기화한다. */ + fun recordLoginSuccess(loginId: String, clientIp: String) fun checkPasswordReset(loginId: String) } diff --git a/systems/identity/identity-application/src/main/kotlin/hs/kr/entrydsm/identity/application/service/AuthService.kt b/systems/identity/identity-application/src/main/kotlin/hs/kr/entrydsm/identity/application/service/AuthService.kt index 4cd0a9f31..d310c626d 100644 --- a/systems/identity/identity-application/src/main/kotlin/hs/kr/entrydsm/identity/application/service/AuthService.kt +++ b/systems/identity/identity-application/src/main/kotlin/hs/kr/entrydsm/identity/application/service/AuthService.kt @@ -89,18 +89,20 @@ class AuthService( return savedAccount.toAccountResult() } + // 자격 증명 실패만 누적하고 차단 확인 후 성공 상태를 반영한다. override fun login(command: LoginCommand): AuthTokenResult { requireValidPassword(command.password) if (command.loginId.isBlank()) throw IdentityDomainException(ErrorCode.INVALID_REQUEST_BODY) authAttemptLimiter.checkLogin(command.loginId, command.clientIp) val account = accountQueryPort.findByLoginId(command.loginId) - ?: throw IdentityDomainException(ErrorCode.INVALID_CREDENTIALS) - if (!passwordHasher.matches(command.password, account.passwordHash)) { + if (account == null || !passwordHasher.matches(command.password, account.passwordHash)) { + authAttemptLimiter.recordLoginFailure(command.loginId, command.clientIp) throw IdentityDomainException(ErrorCode.INVALID_CREDENTIALS) } if (account.status != AccountStatus.ACTIVE) { throw IdentityDomainException(ErrorCode.ACCOUNT_INACTIVE) } + authAttemptLimiter.recordLoginSuccess(command.loginId, command.clientIp) return issueTokens(account.userId, account.role, account.status, account.tokenVersion) } diff --git a/systems/identity/identity-application/src/test/kotlin/hs/kr/entrydsm/identity/application/service/AuthServiceTest.kt b/systems/identity/identity-application/src/test/kotlin/hs/kr/entrydsm/identity/application/service/AuthServiceTest.kt index c5529ae9f..c395b9601 100644 --- a/systems/identity/identity-application/src/test/kotlin/hs/kr/entrydsm/identity/application/service/AuthServiceTest.kt +++ b/systems/identity/identity-application/src/test/kotlin/hs/kr/entrydsm/identity/application/service/AuthServiceTest.kt @@ -448,6 +448,60 @@ class AuthServiceTest { assertEquals(emptyMap(), refreshTokenVersions) } + @Test + fun successfulLoginClearsPairFailuresWithoutRecordingFailure() { + val limiter = mock(AuthAttemptLimiter::class.java) + `when`(queryPort.findByLoginId("entry")).thenReturn(account()) + `when`(passwordHasher.matches("password123!", PASSWORD_HASH)).thenReturn(true) + service(limiter = limiter).login(LoginCommand("entry", "password123!")) + val order = org.mockito.Mockito.inOrder(limiter, queryPort, passwordHasher) + order.verify(limiter).checkLogin("entry", "unknown") + order.verify(queryPort).findByLoginId("entry") + order.verify(passwordHasher).matches("password123!", PASSWORD_HASH) + order.verify(limiter).recordLoginSuccess("entry", "unknown") + org.mockito.Mockito.verify(limiter, org.mockito.Mockito.never()).recordLoginFailure("entry", "unknown") + } + + @Test + fun unknownAccountAndWrongPasswordBothRecordInvalidCredentialFailures() { + val limiter = mock(AuthAttemptLimiter::class.java) + val auth = service(limiter = limiter) + `when`(queryPort.findByLoginId("entry")).thenReturn(null).thenReturn(account()) + repeat(2) { + val exception = assertThrows(IdentityDomainException::class.java) { + auth.login(LoginCommand("entry", "wrong-password")) + } + assertEquals(ErrorCode.INVALID_CREDENTIALS, exception.errorCode) + } + org.mockito.Mockito.verify(limiter, org.mockito.Mockito.times(2)).recordLoginFailure("entry", "unknown") + org.mockito.Mockito.verify(limiter, org.mockito.Mockito.never()).recordLoginSuccess("entry", "unknown") + } + + @Test + fun inactiveAndMalformedLoginDoNotRecordCredentialFailure() { + val limiter = mock(AuthAttemptLimiter::class.java) + val auth = service(limiter = limiter) + assertThrows(IdentityDomainException::class.java) { auth.login(LoginCommand("", "password123!")) } + `when`(queryPort.findByLoginId("entry")).thenReturn(account(AccountStatus.INACTIVE)) + `when`(passwordHasher.matches("password123!", PASSWORD_HASH)).thenReturn(true) + assertEquals(ErrorCode.ACCOUNT_INACTIVE, assertThrows(IdentityDomainException::class.java) { + auth.login(LoginCommand("entry", "password123!")) + }.errorCode) + org.mockito.Mockito.verify(limiter, org.mockito.Mockito.never()).recordLoginFailure("entry", "unknown") + org.mockito.Mockito.verify(limiter, org.mockito.Mockito.never()).recordLoginSuccess("entry", "unknown") + } + + @Test + fun concurrentLockDetectedAtSuccessfulPasswordCheckStillRejectsLogin() { + val limiter = mock(AuthAttemptLimiter::class.java) + `when`(queryPort.findByLoginId("entry")).thenReturn(account()) + `when`(passwordHasher.matches("password123!", PASSWORD_HASH)).thenReturn(true) + doThrow(IdentityDomainException(ErrorCode.AUTH_ATTEMPTS_EXCEEDED)).`when`(limiter).recordLoginSuccess("entry", "unknown") + assertEquals(ErrorCode.AUTH_ATTEMPTS_EXCEEDED, assertThrows(IdentityDomainException::class.java) { + service(limiter = limiter).login(LoginCommand("entry", "password123!")) + }.errorCode) + } + private fun service( registration: AccountRegistrationPort = AccountRegistrationPort { _, _ -> account() }, limiter: AuthAttemptLimiter = mock(AuthAttemptLimiter::class.java), diff --git a/systems/identity/identity-bootstrap/src/main/resources/application-dev.yaml b/systems/identity/identity-bootstrap/src/main/resources/application-dev.yaml index e8f6163bd..ac4d8d2ac 100644 --- a/systems/identity/identity-bootstrap/src/main/resources/application-dev.yaml +++ b/systems/identity/identity-bootstrap/src/main/resources/application-dev.yaml @@ -35,9 +35,9 @@ auth: password: bcrypt-strength: ${BCRYPT_STRENGTH:12} login: - ip-max-attempts: ${AUTH_LOGIN_IP_MAX_ATTEMPTS:50} - max-attempts: ${AUTH_LOGIN_MAX_ATTEMPTS:5} - window-seconds: ${AUTH_LOGIN_WINDOW_SECONDS:900} + ip-max-attempts: ${AUTH_LOGIN_IP_MAX_ATTEMPTS:300} + max-attempts: ${AUTH_LOGIN_MAX_ATTEMPTS:30} + window-seconds: ${AUTH_LOGIN_WINDOW_SECONDS:300} password-reset: enabled: ${AUTH_PASSWORD_RESET_ENABLED:false} max-attempts: ${AUTH_PASSWORD_RESET_MAX_ATTEMPTS:5} diff --git a/systems/identity/identity-bootstrap/src/main/resources/application.yaml b/systems/identity/identity-bootstrap/src/main/resources/application.yaml index 82e2114bb..2ae2cdee2 100644 --- a/systems/identity/identity-bootstrap/src/main/resources/application.yaml +++ b/systems/identity/identity-bootstrap/src/main/resources/application.yaml @@ -42,9 +42,9 @@ auth: password: bcrypt-strength: ${BCRYPT_STRENGTH} login: - ip-max-attempts: ${AUTH_LOGIN_IP_MAX_ATTEMPTS:50} - max-attempts: ${AUTH_LOGIN_MAX_ATTEMPTS:5} - window-seconds: ${AUTH_LOGIN_WINDOW_SECONDS:900} + ip-max-attempts: ${AUTH_LOGIN_IP_MAX_ATTEMPTS:300} + max-attempts: ${AUTH_LOGIN_MAX_ATTEMPTS:30} + window-seconds: ${AUTH_LOGIN_WINDOW_SECONDS:300} password-reset: enabled: ${AUTH_PASSWORD_RESET_ENABLED} max-attempts: ${AUTH_PASSWORD_RESET_MAX_ATTEMPTS} diff --git a/systems/identity/identity-bootstrap/src/test/resources/application-test.yml b/systems/identity/identity-bootstrap/src/test/resources/application-test.yml index 045991889..cae7b7d40 100644 --- a/systems/identity/identity-bootstrap/src/test/resources/application-test.yml +++ b/systems/identity/identity-bootstrap/src/test/resources/application-test.yml @@ -32,9 +32,9 @@ auth: password: bcrypt-strength: 4 login: - ip-max-attempts: 50 - max-attempts: 5 - window-seconds: 900 + ip-max-attempts: 300 + max-attempts: 30 + window-seconds: 300 password-reset: enabled: false max-attempts: 5