From f47dea3e24d687ffbcf94f04357dc0a5d936402a Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 13:14:58 +0900 Subject: [PATCH] feat: add shared Keycloak compose baseline --- .env.example | 10 ++ .gitignore | 7 ++ README.md | 65 ++++++++++- backend/.dockerignore | 1 + backend/Dockerfile | 19 +++ backend/pom.xml | 59 ++++++++++ .../keycloakpattern/ApiController.java | 30 +++++ .../KeycloakPatternApplication.java | 12 ++ .../keycloakpattern/SecurityConfig.java | 27 +++++ backend/src/main/resources/application.yml | 22 ++++ .../keycloakpattern/ApiSecurityTest.java | 43 +++++++ docker-compose.yml | 109 ++++++++++++++++++ frontend/Dockerfile | 4 + frontend/index.html | 33 ++++++ frontend/nginx.conf | 26 +++++ keycloak/import/.gitkeep | 1 + scripts/verify-stack.sh | 65 +++++++++++ 17 files changed, 532 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/pom.xml create mode 100644 backend/src/main/java/com/example/keycloakpattern/ApiController.java create mode 100644 backend/src/main/java/com/example/keycloakpattern/KeycloakPatternApplication.java create mode 100644 backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java create mode 100644 backend/src/main/resources/application.yml create mode 100644 backend/src/test/java/com/example/keycloakpattern/ApiSecurityTest.java create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 keycloak/import/.gitkeep create mode 100755 scripts/verify-stack.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a5752c7 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Copy this file to .env and replace every change-me value. +KC_BOOTSTRAP_ADMIN_USERNAME=admin +KC_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password + +POSTGRES_DB=keycloak +POSTGRES_USER=keycloak +POSTGRES_PASSWORD=change-me-postgres-password + +# Port 80 is the single-EC2 target. 8088 avoids common local port conflicts. +NGINX_PORT=8088 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f0f1cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +.idea/ +.vscode/ +*.iml + +backend/target/ +build/ diff --git a/README.md b/README.md index c96c3ce..334caa5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,65 @@ -# keycloak-pattern +# Keycloak Authentication Patterns +Keycloak을 중심으로 네 가지 브라우저 인증 통합 패턴을 같은 로컬 +인프라에서 비교하는 학습 프로젝트입니다. + +- AP1: Browser-based OAuth Client (SPA direct + Resource Server) +- AP2: Token-Mediating Backend +- AP3: Backend-for-Frontend (BFF) +- AP4: Edge forward-auth + +현재 `develop`의 공통 baseline은 Keycloak, PostgreSQL, Spring Boot API, +nginx를 Docker Compose로 실행하는 토대입니다. 패턴별 구현은 이 baseline +위에서 별도 브랜치로 진행합니다. + +## 요구 사항 + +- Docker Engine +- Docker Compose +- `curl` + +로컬 Java나 Maven은 필요하지 않습니다. Spring Boot 빌드와 테스트는 Maven +컨테이너에서 수행합니다. + +## 시작 + +```bash +cp .env.example .env +docker compose up --build -d +./scripts/verify-stack.sh +``` + +기본 주소는 다음과 같습니다. + +| 구성 요소 | 주소 | +|---|---| +| Keycloak | | +| Spring Boot API | | +| nginx | | + +host의 80번 포트를 쓸 수 있는 single-EC2 환경에서는 `.env`의 +`NGINX_PORT=80`으로 변경할 수 있습니다. + +## 상태 확인 + +```bash +docker compose ps +docker compose logs -f keycloak +curl http://localhost:8088/api/public +curl -i http://localhost:8088/api/me +``` + +`/api/public`은 `200`, 인증 정보가 없는 `/api/me`는 `401`이 정상입니다. + +## 환경 초기화 + +PostgreSQL과 Keycloak data volume을 제거하고 realm import부터 다시 +검증하려면 다음 한 줄을 사용합니다. + +```bash +docker compose down -v && docker compose up --build -d +``` + +`start-dev`와 로컬 HTTP 설정은 학습 전용입니다. 운영 환경에서는 +optimized Keycloak image, HTTPS, 엄격한 hostname 및 외부 secret store를 +사용해야 합니다. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1 @@ +target/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..5fc2129 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,19 @@ +FROM maven:3.9.11-eclipse-temurin-21-alpine AS build + +WORKDIR /workspace +COPY pom.xml . +RUN mvn --batch-mode dependency:go-offline + +COPY src src +RUN mvn --batch-mode verify + +FROM eclipse-temurin:21-jre-alpine + +RUN addgroup -S spring && adduser -S spring -G spring +USER spring:spring + +WORKDIR /app +COPY --from=build /workspace/target/keycloak-pattern-api.jar app.jar + +EXPOSE 8081 +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..9cc0738 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,59 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.16 + + + + com.example + keycloak-pattern-api + 0.0.1-SNAPSHOT + keycloak-pattern-api + Shared resource API for the Keycloak authentication patterns + + + 21 + + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + + keycloak-pattern-api + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/backend/src/main/java/com/example/keycloakpattern/ApiController.java b/backend/src/main/java/com/example/keycloakpattern/ApiController.java new file mode 100644 index 0000000..e234030 --- /dev/null +++ b/backend/src/main/java/com/example/keycloakpattern/ApiController.java @@ -0,0 +1,30 @@ +package com.example.keycloakpattern; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api") +public class ApiController { + + @GetMapping("/public") + public Map publicEndpoint() { + return Map.of("status", "ok", "service", "keycloak-pattern-api"); + } + + @GetMapping("/me") + public Map currentUser(@AuthenticationPrincipal Jwt jwt) { + Map response = new LinkedHashMap<>(); + response.put("subject", jwt.getSubject()); + response.put("username", jwt.getClaimAsString("preferred_username")); + response.put("issuer", jwt.getIssuer()); + response.put("audience", jwt.getAudience()); + return response; + } +} diff --git a/backend/src/main/java/com/example/keycloakpattern/KeycloakPatternApplication.java b/backend/src/main/java/com/example/keycloakpattern/KeycloakPatternApplication.java new file mode 100644 index 0000000..11087e4 --- /dev/null +++ b/backend/src/main/java/com/example/keycloakpattern/KeycloakPatternApplication.java @@ -0,0 +1,12 @@ +package com.example.keycloakpattern; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class KeycloakPatternApplication { + + public static void main(String[] args) { + SpringApplication.run(KeycloakPatternApplication.class, args); + } +} diff --git a/backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java b/backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java new file mode 100644 index 0000000..d4c2129 --- /dev/null +++ b/backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java @@ -0,0 +1,27 @@ +package com.example.keycloakpattern; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class SecurityConfig { + + @Bean + SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception { + return http + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/actuator/health", "/actuator/health/**", "/api/public") + .permitAll() + .anyRequest() + .authenticated()) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) + .build(); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..0046b63 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,22 @@ +server: + port: ${SERVER_PORT:8081} + +spring: + application: + name: keycloak-pattern-api + security: + oauth2: + resourceserver: + jwt: + issuer-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI:http://localhost:8080/realms/keycloak-patterns} + jwk-set-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI:http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/certs} + +management: + endpoint: + health: + probes: + enabled: true + endpoints: + web: + exposure: + include: health,info diff --git a/backend/src/test/java/com/example/keycloakpattern/ApiSecurityTest.java b/backend/src/test/java/com/example/keycloakpattern/ApiSecurityTest.java new file mode 100644 index 0000000..224287b --- /dev/null +++ b/backend/src/test/java/com/example/keycloakpattern/ApiSecurityTest.java @@ -0,0 +1,43 @@ +package com.example.keycloakpattern; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +class ApiSecurityTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void publicEndpointDoesNotRequireAuthentication() throws Exception { + mockMvc.perform(get("/api/public")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("ok")); + } + + @Test + void protectedEndpointRejectsAnonymousRequests() throws Exception { + mockMvc.perform(get("/api/me")) + .andExpect(status().isUnauthorized()); + } + + @Test + void protectedEndpointAcceptsJwtAuthentication() throws Exception { + mockMvc.perform(get("/api/me").with(jwt().jwt(token -> token + .subject("test-subject") + .claim("preferred_username", "regular-user")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.subject").value("test-subject")) + .andExpect(jsonPath("$.username").value("regular-user")); + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f9ff2a4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,109 @@ +name: keycloak-patterns + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-keycloak} + POSTGRES_USER: ${POSTGRES_USER:-keycloak} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?copy .env.example to .env and set POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: + - CMD-SHELL + - pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}" + interval: 5s + timeout: 5s + retries: 12 + networks: + - keycloak-net + restart: unless-stopped + + keycloak: + image: quay.io/keycloak/keycloak:26.7.0 + command: + - start-dev + - --import-realm + environment: + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-keycloak} + KC_DB_USERNAME: ${POSTGRES_USER:-keycloak} + KC_DB_PASSWORD: ${POSTGRES_PASSWORD:?copy .env.example to .env and set POSTGRES_PASSWORD} + KC_HOSTNAME: http://localhost:8080 + KC_HTTP_ENABLED: "true" + KC_HEALTH_ENABLED: "true" + KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME:?set KC_BOOTSTRAP_ADMIN_USERNAME in .env} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD:?set KC_BOOTSTRAP_ADMIN_PASSWORD in .env} + ports: + - "127.0.0.1:8080:8080" + volumes: + - keycloak_data:/opt/keycloak/data + - ./keycloak/import:/opt/keycloak/data/import:ro + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: + - CMD + - bash + - -c + - "{ printf 'HEAD /health/ready HTTP/1.0\r\n\r\n' >&0; grep 'HTTP/1.0 200'; } 0<>/dev/tcp/localhost/9000" + interval: 10s + timeout: 5s + retries: 18 + start_period: 60s + networks: + - keycloak-net + restart: unless-stopped + + app: + build: + context: ./backend + environment: + SERVER_PORT: "8081" + SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: http://localhost:8080/realms/keycloak-patterns + SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/certs + ports: + - "127.0.0.1:8081:8081" + depends_on: + keycloak: + condition: service_healthy + healthcheck: + test: + - CMD-SHELL + - wget -q -O - http://127.0.0.1:8081/actuator/health | grep -q '"status":"UP"' + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + networks: + - keycloak-net + restart: unless-stopped + + nginx: + build: + context: ./frontend + ports: + - "127.0.0.1:${NGINX_PORT:-8088}:80" + depends_on: + app: + condition: service_healthy + healthcheck: + test: + - CMD-SHELL + - wget -q -O - http://127.0.0.1/health | grep -q '^ok$' + interval: 10s + timeout: 5s + retries: 12 + networks: + - keycloak-net + restart: unless-stopped + +volumes: + keycloak_data: + postgres_data: + +networks: + keycloak-net: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..de7eb7e --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:1.29-alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY index.html /usr/share/nginx/html/index.html diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..8b1109a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,33 @@ + + + + + + Keycloak Authentication Patterns + + + +

Keycloak Authentication Patterns

+

공통 Docker Compose baseline이 실행 중입니다.

+

+ 공개 API는 /api/public, 보호 API는 + /api/me에서 확인할 수 있습니다. +

+ + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..731d30e --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,26 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location = /health { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + location /api/ { + proxy_pass http://app:8081; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/keycloak/import/.gitkeep b/keycloak/import/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/keycloak/import/.gitkeep @@ -0,0 +1 @@ + diff --git a/scripts/verify-stack.sh b/scripts/verify-stack.sh new file mode 100755 index 0000000..ee5026c --- /dev/null +++ b/scripts/verify-stack.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env sh +set -eu + +compose_file=${COMPOSE_FILE:-docker-compose.yml} +nginx_port=${NGINX_PORT:-8088} + +wait_for_url() { + name=$1 + url=$2 + attempts=30 + + while [ "$attempts" -gt 0 ]; do + if curl --fail --silent --show-error --output /dev/null "$url"; then + return 0 + fi + attempts=$((attempts - 1)) + sleep 2 + done + + echo "$name did not become ready: $url" >&2 + return 1 +} + +assert_healthy() { + service=$1 + attempts=30 + + while [ "$attempts" -gt 0 ]; do + container_id=$(docker compose -f "$compose_file" ps --quiet "$service") + + if [ -n "$container_id" ]; then + health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id") + if [ "$health" = "healthy" ]; then + return 0 + fi + else + health="not-running" + fi + + attempts=$((attempts - 1)) + sleep 2 + done + + echo "$service is not healthy: $health" >&2 + return 1 +} + +for service in postgres keycloak app nginx; do + assert_healthy "$service" +done + +wait_for_url "Keycloak discovery" \ + "http://localhost:8080/realms/master/.well-known/openid-configuration" +wait_for_url "Spring Boot health" "http://localhost:8081/actuator/health" +wait_for_url "nginx health" "http://localhost:${nginx_port}/health" +wait_for_url "public API" "http://localhost:${nginx_port}/api/public" + +protected_status=$(curl --silent --output /dev/null --write-out '%{http_code}' \ + "http://localhost:${nginx_port}/api/me") +if [ "$protected_status" != "401" ]; then + echo "expected unauthenticated /api/me to return 401, got $protected_status" >&2 + exit 1 +fi + +echo "baseline stack verified: 4 healthy services, public 200, protected 401"