diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4757a00 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.gradle +**/build +**/target +node_modules +.idea +.vscode +*.log \ No newline at end of file diff --git a/.env.example b/.env.example index 3622b6e..826f025 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,20 @@ POSTGRES_DB= POSTGRES_USER= POSTGRES_PASSWORD= + +DB_HOST= +DB_PORT= +DB_NAME= +DB_USER= +DB_PASSWORD= + +KEYCLOAK_REALM= +KEYCLOAK_REALM_USER= +KEYCLOAK_ADMIN_USERNAME= +KEYCLOAK_ADMIN_PASSWORD= +KEYCLOAK_LOGIN_CLIENT_ID= +KEYCLOAK_ADMIN_CLIENT_ID= +KEYCLOAK_DB_PASSWORD= + RABBITMQ_USERNAME= RABBITMQ_PASSWORD= \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9064eb7..fbb880c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,18 @@ jobs: RABBITMQ_USERNAME: ${{ secrets.RABBITMQ_USERNAME }} RABBITMQ_PASSWORD: ${{ secrets.RABBITMQ_PASSWORD }} + KEYCLOAK_DB_PASSWORD: ${{ secrets.KEYCLOAK_DB_PASSWORD }} + KEYCLOAK_ADMIN_USERNAME: ${{ secrets.KEYCLOAK_ADMIN_USERNAME }} + KEYCLOAK_ADMIN_PASSWORD: ${{ secrets.KEYCLOAK_ADMIN_PASSWORD }} + KEYCLOAK_REALM: ${{ secrets.KEYCLOAK_REALM }} + KEYCLOAK_REALM_USER: ${{ secrets.KEYCLOAK_REALM_USER }} + KEYCLOAK_ADMIN_CLIENT_ID: ${{ secrets.KEYCLOAK_ADMIN_CLIENT_ID }} + KEYCLOAK_LOGIN_CLIENT_ID: ${{ secrets.KEYCLOAK_LOGIN_CLIENT_ID }} + + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + + steps: - name: Checkout uses: actions/checkout@v4 @@ -24,9 +36,9 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Build and start services - run: docker compose up -d --build --wait --wait-timeout 180 + run: docker compose up -d --build --wait --wait-timeout 500 timeout-minutes: 25 - name: Tear down if: always() - run: docker compose down -v \ No newline at end of file + run: docker compose down -v diff --git a/docker-compose.yml b/docker-compose.yml index 648c753..3608755 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -174,8 +174,16 @@ services: - "8080" environment: <<: *service-env + KEYCLOAK_REALM: ${KEYCLOAK_REALM} + KEYCLOAK_REALM_USER: ${KEYCLOAK_REALM_USER} + KEYCLOAK_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USERNAME} + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} + KEYCLOAK_ADMIN_CLIENT_ID: ${KEYCLOAK_ADMIN_CLIENT_ID} + KEYCLOAK_LOGIN_CLIENT_ID: ${KEYCLOAK_LOGIN_CLIENT_ID} depends_on: <<: *service-depends-on + keycloak : + condition: service_healthy rabbitmq: image: rabbitmq:3-management @@ -207,6 +215,12 @@ services: - "5433:5432" volumes: - shipflow_keycloak_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s keycloak: build: @@ -216,8 +230,12 @@ services: container_name: shipflow-keycloak restart: unless-stopped environment: - KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN} + realm: ${KEYCLOAK_REALM} + user-realm: ${KEYCLOAK_REALM_USER} + KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN_USERNAME} KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} + admin-client-id: ${KEYCLOAK_ADMIN_CLIENT_ID} + login-client-id: ${KEYCLOAK_LOGIN_CLIENT_ID} KC_DB: postgres KC_DB_URL: jdbc:postgresql://keycloak-postgres:5432/keycloak KC_DB_USERNAME: keycloak @@ -227,7 +245,14 @@ services: ports: - "9001:8080" depends_on: - - keycloak-postgres + keycloak-postgres: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "bash -c ' oauth - .jwt(Customizer.withDefaults()) + .jwt(jwt -> jwt.jwtAuthenticationConverter(grantedAuthoritiesExtractor())) ) .build(); } + + @Bean + public Converter> grantedAuthoritiesExtractor() { + Converter> delegate = jwt -> { + JwtGrantedAuthoritiesConverter defaultConverter = new JwtGrantedAuthoritiesConverter(); + Collection defaultAuthorities = defaultConverter.convert(jwt); + + Map realmAccess = jwt.getClaimAsMap("realm_access"); + List realmRoles = List.of(); + + if (realmAccess != null && realmAccess.get("roles") instanceof List roles) { + realmRoles = roles.stream() + .map(Object::toString) + .map(role -> role.startsWith("ROLE_") ? role : "ROLE_" + role) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } + + return Stream.concat(defaultAuthorities.stream(), realmRoles.stream()) + .collect(Collectors.toSet()); + }; + + return new ReactiveJwtAuthenticationConverterAdapter(jwt -> { + Collection authorities = delegate.convert(jwt); + return new JwtAuthenticationToken(jwt, authorities, jwt.getSubject()); + }); + } } \ No newline at end of file diff --git a/gateway-server/src/main/java/com/shipflow/gatewayserver/config/UserHeaderFilter.java b/gateway-server/src/main/java/com/shipflow/gatewayserver/config/UserHeaderFilter.java index d207643..3d4de37 100644 --- a/gateway-server/src/main/java/com/shipflow/gatewayserver/config/UserHeaderFilter.java +++ b/gateway-server/src/main/java/com/shipflow/gatewayserver/config/UserHeaderFilter.java @@ -1,5 +1,7 @@ package com.shipflow.gatewayserver.config; +import com.shipflow.gatewayserver.exception.BusinessException; +import com.shipflow.gatewayserver.exception.GateErrorCode; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.http.server.reactive.ServerHttpRequest; @@ -13,8 +15,7 @@ import java.util.List; import java.util.Map; -import com.shipflow.gatewayserver.exception.BusinessException; -import com.shipflow.gatewayserver.exception.GateErrorCode; +import static reactor.netty.http.HttpConnectionLiveness.log; @Component public class UserHeaderFilter implements GlobalFilter { @@ -25,50 +26,56 @@ public class UserHeaderFilter implements GlobalFilter { @Override public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { - // 모든 요청에서 먼저 헤더 제거 (핵심!) - ServerWebExchange sanitizedExchange = exchange.mutate() - .request(exchange.getRequest().mutate() - .headers(headers -> { - headers.remove(USER_ID_HEADER); - headers.remove(USER_ROLE_HEADER); - }) - .build()) - .build(); - - return sanitizedExchange.getPrincipal() - .ofType(Authentication.class) - .flatMap(auth -> { - - if (auth instanceof JwtAuthenticationToken jwtAuth) { - Jwt jwt = jwtAuth.getToken(); + log.info("[UserHeaderFilter] entered. path={}, method={}", + exchange.getRequest().getURI().getPath(), + exchange.getRequest().getMethod()); - String userId = jwt.getSubject(); - String role = extractRole(jwt); - - if (userId == null || userId.isBlank() || role == null || role.isBlank()) { - return Mono.error(new BusinessException(GateErrorCode.MISSING_ROLES)); - } - - // JWT 값으로만 재주입 - ServerHttpRequest mutated = sanitizedExchange.getRequest().mutate() + ServerWebExchange sanitizedExchange = exchange.mutate() + .request(exchange.getRequest().mutate() .headers(headers -> { - headers.add(USER_ID_HEADER, userId); - headers.add(USER_ROLE_HEADER, role); + headers.remove(USER_ID_HEADER); + headers.remove(USER_ROLE_HEADER); }) - .build(); + .build()) + .build(); - return chain.filter( - sanitizedExchange.mutate().request(mutated).build() - ); - } + return sanitizedExchange.getPrincipal() + .doOnNext(p -> log.info("[UserHeaderFilter] principal={}", p.getClass().getName())) + .switchIfEmpty(Mono.fromRunnable(() -> + log.warn("[UserHeaderFilter] principal is empty") + )) + .ofType(Authentication.class) + .doOnNext(auth -> log.info("[UserHeaderFilter] auth class={}", auth.getClass().getName())) + .flatMap(auth -> { + if (auth instanceof JwtAuthenticationToken jwtAuth) { + Jwt jwt = jwtAuth.getToken(); + + String userId = jwt.getSubject(); + String role = extractRole(jwt); + + if (userId == null || userId.isBlank() || role == null || role.isBlank()) { + return Mono.error(new BusinessException(GateErrorCode.MISSING_ROLES)); + } + + ServerHttpRequest mutated = sanitizedExchange.getRequest().mutate() + .headers(headers -> { + headers.add(USER_ID_HEADER, userId); + headers.add(USER_ROLE_HEADER, role); + }) + .build(); + + return chain.filter( + sanitizedExchange.mutate().request(mutated).build() + ); + } - return chain.filter(sanitizedExchange); - }) - .switchIfEmpty(chain.filter(sanitizedExchange)); + log.warn("[UserHeaderFilter] Authentication exists but not JwtAuthenticationToken"); + return chain.filter(sanitizedExchange); + }) + .switchIfEmpty(chain.filter(sanitizedExchange)); } - - private String extractRole(Jwt jwt) { //role 추출 + private String extractRole(Jwt jwt) { Map realmAccess = jwt.getClaimAsMap("realm_access"); if (realmAccess == null) { throw new BusinessException(GateErrorCode.MISSING_REALM_ACCESS); diff --git a/gateway-server/src/main/resources/application.yaml b/gateway-server/src/main/resources/application.yaml index 81670a0..99ff34e 100644 --- a/gateway-server/src/main/resources/application.yaml +++ b/gateway-server/src/main/resources/application.yaml @@ -49,7 +49,7 @@ spring: oauth2: resourceserver: jwt: - issuer-uri: http://localhost:9001/realms/shipflow + issuer-uri: http://keycloak:8080/realms/shipflow eureka: client: diff --git a/user-service/src/main/java/com/shipflow/userservice/domain/entity/User.java b/user-service/src/main/java/com/shipflow/userservice/domain/entity/User.java index 349c947..83161af 100644 --- a/user-service/src/main/java/com/shipflow/userservice/domain/entity/User.java +++ b/user-service/src/main/java/com/shipflow/userservice/domain/entity/User.java @@ -58,8 +58,12 @@ public User(UUID id, String username, String name, String slackId) { this.name = name; this.slackId = slackId; this.status = UserStatus.PENDING; - this.createdAt = LocalDateTime.now(); + + LocalDateTime now = LocalDateTime.now(); + this.createdAt = now; this.createdBy = id; + this.updatedAt = now; + this.updatedBy = id; } public User(UUID id, String username, String name, String slackId, UUID hubId, UUID companyId) { @@ -68,6 +72,12 @@ public User(UUID id, String username, String name, String slackId, UUID hubId, U this.companyId = companyId; } + public User(UUID id, String username, String name, String slackId, UserRole role, UserStatus status) { + this(id, username, name, slackId); + this.status = status; + this.role = role; + } + public void approve(UserRole role){ //승인 if (this.status != UserStatus.PENDING) { throw new BusinessException(UserErrorCode.INVALID_USER_STATUS); diff --git a/user-service/src/main/java/com/shipflow/userservice/infrastructure/client/ShipmentFeignClient.java b/user-service/src/main/java/com/shipflow/userservice/infrastructure/client/ShipmentFeignClient.java index e543f9b..4333da8 100644 --- a/user-service/src/main/java/com/shipflow/userservice/infrastructure/client/ShipmentFeignClient.java +++ b/user-service/src/main/java/com/shipflow/userservice/infrastructure/client/ShipmentFeignClient.java @@ -3,11 +3,12 @@ import java.util.UUID; import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @FeignClient(name = "shipment-service") public interface ShipmentFeignClient { - @PatchMapping("/internal/shipments/{userId}") + @DeleteMapping("/internal/shipment-managers/users/{userId}") ClientApiResponse patchManager(@PathVariable UUID userId); } diff --git a/user-service/src/main/java/com/shipflow/userservice/infrastructure/config/JpaAuditingConfig.java b/user-service/src/main/java/com/shipflow/userservice/infrastructure/config/JpaAuditingConfig.java new file mode 100644 index 0000000..3a83bdd --- /dev/null +++ b/user-service/src/main/java/com/shipflow/userservice/infrastructure/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package com.shipflow.userservice.infrastructure.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} \ No newline at end of file diff --git a/user-service/src/main/java/com/shipflow/userservice/infrastructure/config/SecurityConfig.java b/user-service/src/main/java/com/shipflow/userservice/infrastructure/config/SecurityConfig.java index a89be54..90b0dfc 100644 --- a/user-service/src/main/java/com/shipflow/userservice/infrastructure/config/SecurityConfig.java +++ b/user-service/src/main/java/com/shipflow/userservice/infrastructure/config/SecurityConfig.java @@ -28,9 +28,4 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti return http.build(); } - - @Bean - public JwtDecoder jwtDecoder() { - return JwtDecoders.fromIssuerLocation("http://localhost:9001/realms/shipflow"); - } } \ No newline at end of file diff --git a/user-service/src/main/java/com/shipflow/userservice/infrastructure/init/MasterUserInitializer.java b/user-service/src/main/java/com/shipflow/userservice/infrastructure/init/MasterUserInitializer.java new file mode 100644 index 0000000..68f3de7 --- /dev/null +++ b/user-service/src/main/java/com/shipflow/userservice/infrastructure/init/MasterUserInitializer.java @@ -0,0 +1,49 @@ +package com.shipflow.userservice.infrastructure.init; + +import com.shipflow.userservice.domain.entity.User; +import com.shipflow.userservice.domain.model.UserRole; +import com.shipflow.userservice.domain.model.UserStatus; +import com.shipflow.userservice.domain.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +@Configuration +@RequiredArgsConstructor +public class MasterUserInitializer { + + private final UserRepository userRepository; + + @Bean + @Transactional + public ApplicationRunner initMasterUser() { + return args -> { + String username = "master"; + if (userRepository.findByUsername(username).isPresent()) { + return; + } + UUID masterId = UUID.fromString("0c6a758d-afe4-47a4-9f09-df82c6e99653"); + User master = new User(masterId, "master", "master", "master-admin", UserRole.MASTER, UserStatus.APPROVED); + userRepository.save(master); + }; + } + + @Transactional + public void createMasterUserIfNotExists() { + String username = "master"; + + boolean exists = userRepository.findByUsername(username).isPresent(); + if (exists) { + return; + } + + UUID masterId = UUID.fromString("0c6a758d-afe4-47a4-9f09-df82c6e99653"); + + User master = new User(masterId, "master", "master", "master-admin", UserRole.MASTER, UserStatus.APPROVED); + userRepository.save(master); + } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/shipflow/userservice/presentation/controller/AuthController.java b/user-service/src/main/java/com/shipflow/userservice/presentation/controller/AuthController.java index b4ed089..b189a96 100644 --- a/user-service/src/main/java/com/shipflow/userservice/presentation/controller/AuthController.java +++ b/user-service/src/main/java/com/shipflow/userservice/presentation/controller/AuthController.java @@ -48,7 +48,6 @@ public ResponseEntity login(@RequestBody LoginReqDto request) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - System.out.println("[UserService] login endpoint called"); MultiValueMap form = new LinkedMultiValueMap<>(); form.add("grant_type", "password"); diff --git a/user-service/src/main/resources/application.yaml b/user-service/src/main/resources/application.yaml index 4e87287..25b48d8 100644 --- a/user-service/src/main/resources/application.yaml +++ b/user-service/src/main/resources/application.yaml @@ -20,7 +20,7 @@ spring: oauth2: resourceserver: jwt: - issuer-uri: http://localhost:9001/realms/shipflow + issuer-uri: http://keycloak:8080/realms/shipflow cloud: openfeign: @@ -31,13 +31,13 @@ spring: readTimeout: 5000 keycloak: - server-url: http://localhost:9001 - realm: master - user-realm: shipflow - admin-client-id: admin-cli - admin-username: ${KEYCLOAK_ADMIN} + server-url: http://keycloak:8080 + realm: ${KEYCLOAK_REALM} + user-realm: ${KEYCLOAK_REALM_USER} + admin-client-id: ${KEYCLOAK_ADMIN_CLIENT_ID} + admin-username: ${KEYCLOAK_ADMIN_USERNAME} admin-password: ${KEYCLOAK_ADMIN_PASSWORD} - login-client-id: shipflow-api + login-client-id: ${KEYCLOAK_LOGIN_CLIENT_ID} eureka: client: