-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/jwt 필터 추가 #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
The head ref may contain hidden characters: "feat/jwt-\uD544\uD130-\uCD94\uAC00"
Changes from 21 commits
937a57e
efe1bea
c339cb8
dee419f
abcbaa6
be9e8aa
a1091a5
517ea1f
e40c034
125663e
38f68a4
e0ef3d7
d7b4104
3eca4d9
64eeb74
a4e3b48
028e7c8
3903516
3aa5464
d7bf69d
477dd9d
1b99af8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| name: pinit-gateway CD | ||
|
|
||
| on: | ||
| push: | ||
| branches: [ "master" ] | ||
|
|
||
| permissions: | ||
| contents: read | ||
| packages: write | ||
|
|
||
| jobs: | ||
| build-test-push-deploy: | ||
| runs-on: [ arc-runner-set ] | ||
|
|
||
| env: | ||
| IMAGE_REPO: ghcr.io/pinit-scheduler/pinit-gateway/app | ||
| NAMESPACE: pinit | ||
| DEPLOYMENT_NAME: pinit-gateway | ||
| CONTAINER_NAME: app | ||
| MANIFEST_PATH: k8s/deployment.yaml | ||
|
|
||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: Setup JDK | ||
| uses: actions/setup-java@v4 | ||
| with: | ||
| distribution: temurin | ||
| java-version: "21" | ||
| cache: gradle | ||
|
|
||
| - name: Build & Test | ||
| run: ./gradlew clean test build | ||
|
|
||
| - name: Login to GHCR | ||
| uses: docker/login-action@v3 | ||
| with: | ||
| registry: ghcr.io | ||
| username: ${{ github.actor }} | ||
| password: ${{ secrets.GITHUB_TOKEN }} | ||
|
|
||
| - name: Set up Docker Buildx | ||
| uses: docker/setup-buildx-action@v3 | ||
|
|
||
| - name: Build & Push Image | ||
| uses: docker/build-push-action@v6 | ||
| with: | ||
| context: . | ||
| push: true | ||
| tags: ${{ env.IMAGE_REPO }}:${{ github.sha }} | ||
| cache-from: type=gha | ||
| cache-to: type=gha,mode=max | ||
|
|
||
| - name: Install kubectl (if needed) | ||
| uses: azure/setup-kubectl@v4 | ||
| with: | ||
| version: v1.33.6 | ||
|
|
||
| - name: Create kubeconfig from in-cluster ServiceAccount | ||
| shell: bash | ||
| run: | | ||
| TOKEN="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" | ||
| CA_PATH="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" | ||
|
|
||
| cat > kubeconfig <<EOF | ||
| apiVersion: v1 | ||
| kind: Config | ||
| clusters: | ||
| - name: in-cluster | ||
| cluster: | ||
| server: https://kubernetes.default.svc | ||
| certificate-authority: ${CA_PATH} | ||
| contexts: | ||
| - name: in-cluster | ||
| context: | ||
| cluster: in-cluster | ||
| namespace: ${NAMESPACE} | ||
| user: sa | ||
| current-context: in-cluster | ||
| users: | ||
| - name: sa | ||
| user: | ||
| token: ${TOKEN} | ||
| EOF | ||
|
|
||
| echo "KUBECONFIG=$PWD/kubeconfig" >> $GITHUB_ENV | ||
|
|
||
| - name: Install envsubst | ||
| run: sudo apt-get update && sudo apt-get install -y gettext-base | ||
|
|
||
| - name: Deploy (apply manifest with GITHUB_SHA substitution) | ||
| shell: bash | ||
| run: | | ||
| command -v envsubst >/dev/null 2>&1 || (echo "envsubst not found" && exit 1) | ||
|
|
||
| export GITHUB_SHA="${{ github.sha }}" | ||
| envsubst < "${MANIFEST_PATH}" | kubectl apply -f - | ||
|
|
||
| - name: Rollout status | ||
| run: kubectl rollout status deployment/${{ env.DEPLOYMENT_NAME }} -n ${{ env.NAMESPACE }} --timeout=180s |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| name: pinit-gateway CI | ||
|
|
||
| on: | ||
| pull_request: | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| build-test: | ||
| runs-on: [ arc-runner-set ] | ||
|
|
||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: Setup JDK | ||
| uses: actions/setup-java@v4 | ||
| with: | ||
| distribution: temurin | ||
| java-version: "21" | ||
| cache: gradle | ||
|
|
||
| - name: Build & Test | ||
| run: ./gradlew clean test build |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| apiVersion: apps/v1 | ||
| kind: Deployment | ||
| metadata: | ||
| name: pinit-gateway | ||
| namespace: pinit | ||
| labels: | ||
| app: pinit-gateway | ||
| spec: | ||
| replicas: 2 | ||
| revisionHistoryLimit: 3 | ||
| selector: | ||
| matchLabels: | ||
| app: pinit-gateway | ||
|
|
||
| strategy: | ||
| type: RollingUpdate | ||
| rollingUpdate: | ||
| maxSurge: 1 | ||
| maxUnavailable: 0 | ||
|
|
||
| template: | ||
| metadata: | ||
| labels: | ||
| app: pinit-gateway | ||
| spec: | ||
| imagePullSecrets: | ||
| - name: ghcr-pull-secret | ||
| terminationGracePeriodSeconds: 30 | ||
| volumes: | ||
| - name: keys | ||
| secret: | ||
| secretName: pinit-keys | ||
| defaultMode: 0444 | ||
| containers: | ||
| - name: app | ||
| image: ghcr.io/pinit-scheduler/pinit-gateway/app:${GITHUB_SHA} # GITHUB_SHA 환경변수는 GitHub Actions에서 설정되며 envsubst로 치환됩니다. | ||
| imagePullPolicy: IfNotPresent | ||
|
|
||
| env: | ||
| - name: SPRING_PROFILES_ACTIVE | ||
| value: prod | ||
|
|
||
| volumeMounts: | ||
| - mountPath: /etc/keys | ||
| name: keys | ||
| readOnly: true | ||
|
|
||
|
|
||
| ports: | ||
| - name: http | ||
| containerPort: 8080 | ||
|
|
||
| readinessProbe: | ||
| httpGet: | ||
| path: /actuator/health/readiness | ||
| port: http | ||
| initialDelaySeconds: 10 | ||
| periodSeconds: 5 | ||
| timeoutSeconds: 2 | ||
| failureThreshold: 6 | ||
|
|
||
| livenessProbe: | ||
| httpGet: | ||
| path: /actuator/health/liveness | ||
| port: http | ||
| initialDelaySeconds: 30 | ||
| periodSeconds: 10 | ||
| timeoutSeconds: 2 | ||
| failureThreshold: 3 | ||
|
|
||
| resources: | ||
| requests: | ||
| cpu: "100m" | ||
| memory: "256Mi" | ||
| limits: | ||
| cpu: "500m" | ||
| memory: "512Mi" | ||
|
|
||
| lifecycle: | ||
| preStop: | ||
| exec: | ||
| command: [ "sh", "-c", "sleep 5" ] | ||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,14 @@ | ||||||||
| apiVersion: v1 | ||||||||
| kind: Service | ||||||||
| metadata: | ||||||||
| name: gateway-service | ||||||||
| namespace: pinit | ||||||||
| spec: | ||||||||
| selector: | ||||||||
| app: pinit-gateway | ||||||||
| ports: | ||||||||
| - protocol: TCP | ||||||||
| port: 80 | ||||||||
| targetPort: 8080 | ||||||||
| type: ClusterIP | ||||||||
|
|
||||||||
|
Comment on lines
+13
to
+14
|
||||||||
| type: ClusterIP | |
| type: ClusterIP |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| package me.pinitgateway.filter; | ||
|
|
||
| import me.pinitgateway.jwt.JwtAuthenticationToken; | ||
| import org.springframework.cloud.gateway.filter.GatewayFilter; | ||
| import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; | ||
| import org.springframework.security.core.Authentication; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.web.server.ServerWebExchange; | ||
|
|
||
| @Component | ||
| public class JwtSubToMemberIdHeaderGatewayFilterFactory | ||
| extends AbstractGatewayFilterFactory<JwtSubToMemberIdHeaderGatewayFilterFactory.Config> { | ||
|
|
||
| public JwtSubToMemberIdHeaderGatewayFilterFactory() { | ||
| super(Config.class); | ||
| } | ||
|
|
||
| public static class Config { | ||
| } | ||
|
|
||
| @Override | ||
| public GatewayFilter apply(Config config) { | ||
| return (exchange, chain) -> | ||
| exchange.getPrincipal() | ||
| .filter(Authentication.class::isInstance) | ||
| .cast(Authentication.class) | ||
| .flatMap(auth -> { | ||
| String sub = extractSub(auth); | ||
|
|
||
| // JWT 검증은 SecurityWebFilterChain에서 이미 처리되었으므로, sub이 없으면 그대로 진행 | ||
|
||
| if (sub == null || sub.isBlank()) { | ||
| return chain.filter(exchange); | ||
| } | ||
|
|
||
| ServerWebExchange mutated = mutateHeader(exchange, sub); | ||
| return chain.filter(mutated); | ||
| }) | ||
| .switchIfEmpty(chain.filter(exchange)); | ||
| } | ||
|
|
||
| private String extractSub(Authentication auth) { | ||
| if (auth instanceof JwtAuthenticationToken jwtAuth) { | ||
| return String.valueOf(jwtAuth.getPrincipal()); // = sub | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| private ServerWebExchange mutateHeader(ServerWebExchange exchange, String sub) { | ||
| var request = exchange.getRequest().mutate() | ||
| // 외부에서 X-Member-Id를 임의로 넣어오는 spoofing 방지 | ||
|
||
| .headers(headers -> headers.remove("X-Member-Id")) | ||
| .header("X-Member-Id", sub) | ||
| .build(); | ||
|
|
||
| return exchange.mutate().request(request).build(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| package me.pinitgateway.jwt; | ||
|
|
||
| import org.springframework.http.HttpStatus; | ||
| import org.springframework.security.authentication.ReactiveAuthenticationManager; | ||
| import org.springframework.security.core.context.ReactiveSecurityContextHolder; | ||
| import org.springframework.util.StringUtils; | ||
| import org.springframework.web.server.ServerWebExchange; | ||
| import org.springframework.web.server.WebFilter; | ||
| import org.springframework.web.server.WebFilterChain; | ||
| import reactor.core.publisher.Mono; | ||
|
|
||
| public class JwtAuthenticationFilter implements WebFilter { | ||
|
|
||
| private final ReactiveAuthenticationManager authenticationManager; | ||
|
|
||
| public JwtAuthenticationFilter(ReactiveAuthenticationManager authenticationManager) { | ||
| this.authenticationManager = authenticationManager; | ||
| } | ||
|
|
||
| @Override | ||
| public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) { | ||
| String token = resolveToken(exchange); | ||
|
|
||
| if (!StringUtils.hasText(token)) { | ||
| return chain.filter(exchange); | ||
| } | ||
|
|
||
| return authenticationManager.authenticate(new JwtAuthenticationToken(token)) | ||
| .flatMap(authentication -> | ||
| chain.filter(exchange) | ||
| .contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication))) | ||
| .switchIfEmpty(chain.filter(exchange)) | ||
| .onErrorResume(ex -> handleAuthenticationError(exchange)); | ||
| } | ||
|
|
||
| private Mono<Void> handleAuthenticationError(ServerWebExchange exchange) { | ||
| exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); | ||
| return exchange.getResponse().setComplete(); | ||
|
Comment on lines
+36
to
+38
|
||
| } | ||
|
|
||
| private String resolveToken(ServerWebExchange exchange) { | ||
| String bearer = exchange.getRequest().getHeaders().getFirst("Authorization"); | ||
| if(StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) { | ||
| return bearer.substring(7); | ||
| } | ||
| return null; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment is in Korean while the code uses English. For consistency, code comments should be in English.