Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f47dea3e24 |
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
|
||||
backend/target/
|
||||
build/
|
||||
@@ -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 | <http://localhost:8080> |
|
||||
| Spring Boot API | <http://localhost:8081> |
|
||||
| nginx | <http://localhost:8088> |
|
||||
|
||||
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를
|
||||
사용해야 합니다.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
target/
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.5.16</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>keycloak-pattern-api</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>keycloak-pattern-api</name>
|
||||
<description>Shared resource API for the Keycloak authentication patterns</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>keycloak-pattern-api</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -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<String, String> publicEndpoint() {
|
||||
return Map.of("status", "ok", "service", "keycloak-pattern-api");
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public Map<String, Object> currentUser(@AuthenticationPrincipal Jwt jwt) {
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Keycloak Authentication Patterns</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
body {
|
||||
max-width: 48rem;
|
||||
margin: 8vh auto;
|
||||
padding: 0 1.5rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
code {
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 0.25rem;
|
||||
background: color-mix(in srgb, CanvasText 10%, Canvas);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Keycloak Authentication Patterns</h1>
|
||||
<p>공통 Docker Compose baseline이 실행 중입니다.</p>
|
||||
<p>
|
||||
공개 API는 <code>/api/public</code>, 보호 API는
|
||||
<code>/api/me</code>에서 확인할 수 있습니다.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Executable
+65
@@ -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"
|
||||
Reference in New Issue
Block a user