From 9db246f9df2358037389c988d5dd8a2861111667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Paw=C5=82owicz?= <117346592+pawlowiczf@users.noreply.github.com> Date: Wed, 20 Aug 2025 19:56:38 +0200 Subject: [PATCH 01/17] add support for downloading gRPC stubs from remote Nexus repository (#1) --- pom.xml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/pom.xml b/pom.xml index f0dcaaf..a441778 100644 --- a/pom.xml +++ b/pom.xml @@ -70,6 +70,12 @@ spring-cloud-starter-contract-verifier test + + + com.ecmsp + protos + 1.0.0-SNAPSHOT + @@ -121,4 +127,16 @@ + + + nexus-ecmsp-releases + nexus-ecmsp-releases + https://nexus.ecmsp.pl/repository/maven-releases/ + + + nexus-ecmsp-snapshots + nexus-ecmsp-snapshots + https://nexus.ecmsp.pl/repository/maven-snapshots/ + + From c3e2c27d35e4a143981165729e283144612c745f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Paw=C5=82owicz?= <117346592+pawlowiczf@users.noreply.github.com> Date: Mon, 25 Aug 2025 18:08:18 +0200 Subject: [PATCH 02/17] Create workflows to validate, build and push service to remote GCP registry (#2) * create workflows to validate, build and push service to remote GCP registry * create workflows to validate, build and push service to remote GCP registry * create workflows to validate, build and push service to remote GCP registry --- .env | 24 ++++++ .../build-java-push-gcp-registry.yaml | 68 +++++++++++++++++ .../workflows/build-test-java-project.yaml | 45 +++++++++++ Dockerfile | 11 +++ docker/app-env.txt | 2 + docker/database-env.txt | 2 + docker/database-password.txt | 1 + docker/docker-compose.yml | 75 +++++++++++++++++++ docker/init.sql | 0 pom.xml | 33 +++++--- .../resources/application-compose.properties | 11 +++ src/main/resources/application-dev.properties | 13 ++++ .../resources/application-prod.properties | 13 ++++ 13 files changed, 287 insertions(+), 11 deletions(-) create mode 100644 .env create mode 100644 .github/workflows/build-java-push-gcp-registry.yaml create mode 100644 .github/workflows/build-test-java-project.yaml create mode 100644 Dockerfile create mode 100644 docker/app-env.txt create mode 100644 docker/database-env.txt create mode 100644 docker/database-password.txt create mode 100644 docker/docker-compose.yml create mode 100644 docker/init.sql create mode 100644 src/main/resources/application-compose.properties create mode 100644 src/main/resources/application-dev.properties create mode 100644 src/main/resources/application-prod.properties diff --git a/.env b/.env new file mode 100644 index 0000000..4200663 --- /dev/null +++ b/.env @@ -0,0 +1,24 @@ +CART_SERVICE_PORT=8100 +CART_SERVICE_DB_PORT=9100 + +PAYMENT_SERVICE_PORT=8200 +PAYMENT_SERVICE_DB_PORT=9200 + +ORDER_SERVICE_PORT=8300 +ORDER_SERVICE_DB_PORT=9300 + +PRODUCT_SERVICE_PORT=8400 +PRODUCT_SERVICE_DB_PORT=9400 + +####################################### + +# intentionally left blank (it is automatically set to 'compose' profile) +PAYMENT_SERVICE_SPRING_PROFILES_ACTIVE= +ORDER_SERVICE_SPRING_PROFILES_ACTIVE= +PRODUCT_SERVICE_SPRING_PROFILES_ACTIVE= +CART_SERVICE_SPRING_PROFILES_ACTIVE= + +####################################### + +COMPOSE_PROFILES=payment-service,order-service,cart-service,product-service,kafka + diff --git a/.github/workflows/build-java-push-gcp-registry.yaml b/.github/workflows/build-java-push-gcp-registry.yaml new file mode 100644 index 0000000..2447053 --- /dev/null +++ b/.github/workflows/build-java-push-gcp-registry.yaml @@ -0,0 +1,68 @@ +name: Build and deploy Docker images to GCP Artifact Registry + +on: + push: + branches: + - main + workflow_dispatch: + +env: + GCP_REGISTRY: ${{ secrets.ECMSP_GCP_ARTIFACT_REGISTRY }} + IMAGE_TAG: ${{ github.sha }} + IMAGE_NAME: ${{ github.event.repository.name }} + +jobs: + build_deploy: + runs-on: ubuntu-latest + + steps: + - + name: Check out code + uses: actions/checkout@v3 + + - + name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + cache: 'maven' + + - + name: Update Maven snapshots + run: mvn -U dependency:resolve + + - + name: Authenticate to Google Cloud + uses: google-github-actions/auth@v1 + with: + credentials_json: ${{ secrets.ECMSP_REGISTRY_PUSHER_GCP_SA_KEY }} + + - + name: Setup gcloud CLI + uses: google-github-actions/setup-gcloud@v2 + with: + version: "latest" + project_id: ecmsp + + - + name: Install gcloud auth + run: gcloud components install gke-gcloud-auth-plugin + + - + name: Configure Docker for Artifact Registry + run: gcloud auth configure-docker europe-west1-docker.pkg.dev + + - + name: Build, tag, and push backend Docker image to GCP Artifact Registry + run: | + docker build \ + -t $GCP_REGISTRY/$IMAGE_NAME:$IMAGE_TAG \ + -t $GCP_REGISTRY/$IMAGE_NAME:latest \ + -f ./Dockerfile . + + docker push $GCP_REGISTRY/$IMAGE_NAME:$IMAGE_TAG + docker push $GCP_REGISTRY/$IMAGE_NAME:latest + + + diff --git a/.github/workflows/build-test-java-project.yaml b/.github/workflows/build-test-java-project.yaml new file mode 100644 index 0000000..406cfdf --- /dev/null +++ b/.github/workflows/build-test-java-project.yaml @@ -0,0 +1,45 @@ +name: Build and test Java project + +on: + pull_request: + workflow_dispatch: + +jobs: + build_test: + runs-on: ubuntu-latest + steps: + - + name: Checkout repository + uses: actions/checkout@v2 + + - + name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + cache: 'maven' + + - + name: Run tests with Maven + run: mvn clean test -U + +# - +# name: Build Docker image for Trivy +# if: github.event_name == 'push' && (github.event.pull_request.base.ref == 'test' || github.event.pull_request.base.ref == 'development') +# run: | +# docker build \ +# -t service:latest \ +# -f ./Dockerfile . +# +# - +# name: Run Trivy vulnerability scanner +# if: github.event_name == 'push' && (github.event.pull_request.base.ref == 'test' || github.event.pull_request.base.ref == 'development') +# uses: aquasecurity/trivy-action@0.28.0 +# with: +# image-ref: "service:latest" +# format: table +# exit-code: 1 +# ignore-unfixed: true +# vuln-type: os,library +# severity: CRITICAL,HIGH \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..580b49f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM maven:3.9-eclipse-temurin-21 AS builder +WORKDIR /app +COPY pom.xml . +COPY src ./src +RUN mvn clean package + +FROM eclipse-temurin:21-jdk AS runtime +WORKDIR /app + +COPY --from=builder /app/target/*.jar ./app.jar +CMD ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/docker/app-env.txt b/docker/app-env.txt new file mode 100644 index 0000000..d1158d6 --- /dev/null +++ b/docker/app-env.txt @@ -0,0 +1,2 @@ +SPRING_DATASOURCE_USERNAME=admin +SPRING_DATASOURCE_PASSWORD=admin \ No newline at end of file diff --git a/docker/database-env.txt b/docker/database-env.txt new file mode 100644 index 0000000..801e32f --- /dev/null +++ b/docker/database-env.txt @@ -0,0 +1,2 @@ +POSTGRES_DB=user-service-db +POSTGRES_USER=admin \ No newline at end of file diff --git a/docker/database-password.txt b/docker/database-password.txt new file mode 100644 index 0000000..f77b004 --- /dev/null +++ b/docker/database-password.txt @@ -0,0 +1 @@ +admin \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..4b9cd64 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,75 @@ +name: ecmsp-dev + +services: + user-service-db-dev: + image: postgres:15-alpine + container_name: user-service-db-dev + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""] + interval: 10s + retries: 5 + start_period: 30s + timeout: 10s + + ports: + - "5432:5432" + volumes: + - user-service-db-dev:/var/lib/postgresql/data + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + secrets: + - user-service-db-password + environment: + - POSTGRES_USER=admin + - POSTGRES_DB=user-service-db + - POSTGRES_PASSWORD_FILE=/run/secrets/user-service-db-password + env_file: + - + path: ./database-env.txt + required: false + + kafka: + image: apache/kafka:3.7.0 + container_name: broker + environment: + KAFKA_NODE_ID: 1 + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:9094 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://broker:9092,EXTERNAL://localhost:9094 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@broker:9093 + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + ports: + - "9094:9094" + healthcheck: + test: + [ + "CMD-SHELL", + "./opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 > /dev/null 2>&1", + ] + interval: 10s + timeout: 10s + retries: 5 + + kafka-ui: + image: provectuslabs/kafka-ui:latest + container_name: kafka-ui + ports: + - "8088:8080" + environment: + KAFKA_CLUSTERS_0_NAME: local + KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: broker:9092 + KAFKA_CLUSTERS_0_READONLY: "false" + # KAFKA_CLUSTERS_0_SCHEMAREGISTRY: http://schema-registry:8081 + depends_on: + kafka: + condition: service_healthy + +volumes: + user-service-db-dev: + +secrets: + user-service-db-password: + file: ./database-password.txt \ No newline at end of file diff --git a/docker/init.sql b/docker/init.sql new file mode 100644 index 0000000..e69de29 diff --git a/pom.xml b/pom.xml index a441778..a971294 100644 --- a/pom.xml +++ b/pom.xml @@ -66,8 +66,22 @@ test - org.springframework.cloud - spring-cloud-starter-contract-verifier + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.kafka + spring-kafka + 3.3.4 + + + io.hypersistence + hypersistence-utils-hibernate-63 + 3.10.1 + + + com.h2database + h2 test @@ -76,6 +90,7 @@ protos 1.0.0-SNAPSHOT + @@ -103,15 +118,6 @@ - - org.springframework.cloud - spring-cloud-contract-maven-plugin - 4.3.0 - true - - JUNIT5 - - org.springframework.boot spring-boot-maven-plugin @@ -128,6 +134,11 @@ + + central + Maven Central + https://repo.maven.apache.org/maven2 + nexus-ecmsp-releases nexus-ecmsp-releases diff --git a/src/main/resources/application-compose.properties b/src/main/resources/application-compose.properties new file mode 100644 index 0000000..818d5dd --- /dev/null +++ b/src/main/resources/application-compose.properties @@ -0,0 +1,11 @@ +spring.datasource.url=jdbc:postgresql://user-service-db:5432/user-service-db + +spring.kafka.bootstrap-servers=kafka:9092 + +spring.kafka.consumer.group-id=user-service +spring.kafka.consumer.auto-offset-reset=earliest +spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer + +spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer +spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer \ No newline at end of file diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties new file mode 100644 index 0000000..849915f --- /dev/null +++ b/src/main/resources/application-dev.properties @@ -0,0 +1,13 @@ +spring.datasource.url=jdbc:postgresql://localhost:5432/user-service-db +spring.datasource.username=admin +spring.datasource.password=admin + +spring.kafka.bootstrap-servers=localhost:9094 + +spring.kafka.consumer.group-id=user-service +spring.kafka.consumer.auto-offset-reset=earliest +spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer + +spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer +spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer \ No newline at end of file diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties new file mode 100644 index 0000000..3c82733 --- /dev/null +++ b/src/main/resources/application-prod.properties @@ -0,0 +1,13 @@ +spring.datasource.url= +spring.datasource.username= +spring.datasource.password= + +spring.kafka.bootstrap-servers= + +spring.kafka.consumer.group-id=user-service +spring.kafka.consumer.auto-offset-reset=earliest +spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer + +spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer +spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer \ No newline at end of file From 2245b4abcaadcf7c18d49db5e0d7fe36074f3d6d Mon Sep 17 00:00:00 2001 From: Ariel Date: Mon, 22 Sep 2025 09:36:07 +0200 Subject: [PATCH 03/17] Add jwt token generation --- pom.xml | 22 ++++++++++ readme.md | 11 +++++ .../userservice/UserServiceApplication.java | 12 +++++- .../userservice/api/auth/AuthController.java | 30 +++++++++++++ .../userservice/api/auth/AuthRequest.java | 4 ++ .../userservice/api/auth/AuthResponseDto.java | 4 ++ .../application/config/ApplicationConfig.java | 16 +++++++ .../adapter/generator/JwtTokenGenerator.java | 39 +++++++++++++++++ .../generator/JwtTokenGeneratorConfig.java | 42 +++++++++++++++++++ .../userservice/auth/config/AuthConfig.java | 15 +++++++ .../userservice/auth/domain/AuthFacade.java | 28 +++++++++++++ .../auth/domain/AuthenticationResult.java | 6 +++ .../ecmsp/userservice/auth/domain/Token.java | 8 ++++ .../auth/domain/TokenGenerator.java | 7 ++++ .../inmemory/InMemoryUserRepository.java | 38 +++++++++++++++++ .../InMemoryUserRepositoryConfig.java | 15 +++++++ .../userservice/user/config/UserConfig.java | 14 +++++++ .../user/domain/PasswordHasher.java | 11 +++++ .../ecmsp/userservice/user/domain/User.java | 5 +++ .../userservice/user/domain/UserFacade.java | 31 ++++++++++++++ .../ecmsp/userservice/user/domain/UserId.java | 11 +++++ .../user/domain/UserRepository.java | 9 ++++ .../userservice/user/domain/UserToCreate.java | 4 ++ src/main/resources/application-local.yml | 3 ++ src/main/resources/application.properties | 1 - src/main/resources/application.yml | 7 ++++ src/main/resources/local/secrets/INSTALL | 4 ++ 27 files changed, 394 insertions(+), 3 deletions(-) create mode 100644 readme.md create mode 100644 src/main/java/com/ecmsp/userservice/api/auth/AuthController.java create mode 100644 src/main/java/com/ecmsp/userservice/api/auth/AuthRequest.java create mode 100644 src/main/java/com/ecmsp/userservice/api/auth/AuthResponseDto.java create mode 100644 src/main/java/com/ecmsp/userservice/application/config/ApplicationConfig.java create mode 100644 src/main/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGenerator.java create mode 100644 src/main/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGeneratorConfig.java create mode 100644 src/main/java/com/ecmsp/userservice/auth/config/AuthConfig.java create mode 100644 src/main/java/com/ecmsp/userservice/auth/domain/AuthFacade.java create mode 100644 src/main/java/com/ecmsp/userservice/auth/domain/AuthenticationResult.java create mode 100644 src/main/java/com/ecmsp/userservice/auth/domain/Token.java create mode 100644 src/main/java/com/ecmsp/userservice/auth/domain/TokenGenerator.java create mode 100644 src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepository.java create mode 100644 src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepositoryConfig.java create mode 100644 src/main/java/com/ecmsp/userservice/user/config/UserConfig.java create mode 100644 src/main/java/com/ecmsp/userservice/user/domain/PasswordHasher.java create mode 100644 src/main/java/com/ecmsp/userservice/user/domain/User.java create mode 100644 src/main/java/com/ecmsp/userservice/user/domain/UserFacade.java create mode 100644 src/main/java/com/ecmsp/userservice/user/domain/UserId.java create mode 100644 src/main/java/com/ecmsp/userservice/user/domain/UserRepository.java create mode 100644 src/main/java/com/ecmsp/userservice/user/domain/UserToCreate.java create mode 100644 src/main/resources/application-local.yml delete mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/local/secrets/INSTALL diff --git a/pom.xml b/pom.xml index f0dcaaf..7c14782 100644 --- a/pom.xml +++ b/pom.xml @@ -43,6 +43,28 @@ org.springframework.boot spring-boot-starter-web + + org.mindrot + jbcrypt + 0.4 + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + org.springframework.boot diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..4377312 --- /dev/null +++ b/readme.md @@ -0,0 +1,11 @@ +You can test the login endpoint using the following command. Just copy & paste it into HTTP request or any other REST client. +Added user is hardcoded in inMemoryRepository. +``` +POST http://localhost:8080/auth/authenticate +Content-Type: application/json + +{ + "login": "testuser", + "password": "testpassword" +} +``` \ No newline at end of file diff --git a/src/main/java/com/ecmsp/userservice/UserServiceApplication.java b/src/main/java/com/ecmsp/userservice/UserServiceApplication.java index 88b1482..87c9272 100644 --- a/src/main/java/com/ecmsp/userservice/UserServiceApplication.java +++ b/src/main/java/com/ecmsp/userservice/UserServiceApplication.java @@ -2,9 +2,17 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; -@SpringBootApplication -public class UserServiceApplication { +@SpringBootApplication( + exclude = { + DataSourceAutoConfiguration.class, + HibernateJpaAutoConfiguration.class, + } +) +public class UserServiceApplication { public static void main(String[] args) { SpringApplication.run(UserServiceApplication.class, args); diff --git a/src/main/java/com/ecmsp/userservice/api/auth/AuthController.java b/src/main/java/com/ecmsp/userservice/api/auth/AuthController.java new file mode 100644 index 0000000..ecb7cab --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/api/auth/AuthController.java @@ -0,0 +1,30 @@ +package com.ecmsp.userservice.api.auth; + +import com.ecmsp.userservice.auth.domain.AuthFacade; +import com.ecmsp.userservice.auth.domain.AuthenticationResult; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/auth") +public class AuthController { + + private final AuthFacade authFacade; + + public AuthController(AuthFacade authFacade) { + this.authFacade = authFacade; + } + + + @PostMapping("/authenticate") + public ResponseEntity authenticate(@RequestBody AuthRequest request) { + AuthenticationResult result = authFacade.authenticate(request.login(), request.password()); + return switch (result) { + case AuthenticationResult.Success success -> ResponseEntity.ok(new AuthResponseDto(success.token().value())); + case AuthenticationResult.Failure ignored -> ResponseEntity.badRequest().build(); + }; + } +} diff --git a/src/main/java/com/ecmsp/userservice/api/auth/AuthRequest.java b/src/main/java/com/ecmsp/userservice/api/auth/AuthRequest.java new file mode 100644 index 0000000..764d7e8 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/api/auth/AuthRequest.java @@ -0,0 +1,4 @@ +package com.ecmsp.userservice.api.auth; + +public record AuthRequest(String login, String password) { +} diff --git a/src/main/java/com/ecmsp/userservice/api/auth/AuthResponseDto.java b/src/main/java/com/ecmsp/userservice/api/auth/AuthResponseDto.java new file mode 100644 index 0000000..55e4a44 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/api/auth/AuthResponseDto.java @@ -0,0 +1,4 @@ +package com.ecmsp.userservice.api.auth; + +public record AuthResponseDto(String token) { +} diff --git a/src/main/java/com/ecmsp/userservice/application/config/ApplicationConfig.java b/src/main/java/com/ecmsp/userservice/application/config/ApplicationConfig.java new file mode 100644 index 0000000..c5c361c --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/application/config/ApplicationConfig.java @@ -0,0 +1,16 @@ +package com.ecmsp.userservice.application.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; + +@Configuration +class ApplicationConfig { + @Bean + public Clock clock() { + return Clock.systemUTC(); + } + + +} diff --git a/src/main/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGenerator.java b/src/main/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGenerator.java new file mode 100644 index 0000000..f416a9d --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGenerator.java @@ -0,0 +1,39 @@ +package com.ecmsp.userservice.auth.adapter.generator; + +import com.ecmsp.userservice.auth.domain.Token; +import com.ecmsp.userservice.auth.domain.TokenGenerator; +import com.ecmsp.userservice.user.domain.User; +import io.jsonwebtoken.Jwts; + +import java.security.PrivateKey; +import java.time.Clock; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; + +class JwtTokenGenerator implements TokenGenerator { + private final PrivateKey privateKey; + private final Clock clock; + + + public JwtTokenGenerator(PrivateKey privateKey, Clock clock) { + this.privateKey = privateKey; + this.clock = clock; + } + + @Override + public Token generate(User user) { + Instant now = clock.instant(); + Instant expiration = now.plus(1, ChronoUnit.HOURS); + + String jwt = Jwts.builder() + .subject(user.id().value().toString()) + .claim("login", user.login()) + .issuedAt(Date.from(now)) + .expiration(Date.from(expiration)) + .signWith(privateKey) + .compact(); + + return new Token(jwt); + } +} diff --git a/src/main/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGeneratorConfig.java b/src/main/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGeneratorConfig.java new file mode 100644 index 0000000..8f4b313 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGeneratorConfig.java @@ -0,0 +1,42 @@ +package com.ecmsp.userservice.auth.adapter.generator; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Clock; +import java.util.Base64; + +@Configuration +@ConditionalOnProperty( + name = "auth.token-generator.type", + havingValue = "jwt" +) +class JwtTokenGeneratorConfig { + + @Bean + public JwtTokenGenerator jwtTokenGenerator(Clock clock, @Value("${auth.token-generator.secret-key-file}") File secretKeyFile) + throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { + String keyString = Files.readString(secretKeyFile.toPath()).trim(); + + // Remove PEM headers and footers if present + keyString = keyString.replaceAll("-----BEGIN PRIVATE KEY-----", "") + .replaceAll("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + + byte[] keyBytes = Base64.getDecoder().decode(keyString); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + PrivateKey privateKey = keyFactory.generatePrivate(keySpec); + + return new JwtTokenGenerator(privateKey, clock); + } +} diff --git a/src/main/java/com/ecmsp/userservice/auth/config/AuthConfig.java b/src/main/java/com/ecmsp/userservice/auth/config/AuthConfig.java new file mode 100644 index 0000000..6fb5807 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/auth/config/AuthConfig.java @@ -0,0 +1,15 @@ +package com.ecmsp.userservice.auth.config; + +import com.ecmsp.userservice.auth.domain.AuthFacade; +import com.ecmsp.userservice.auth.domain.TokenGenerator; +import com.ecmsp.userservice.user.domain.UserFacade; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +class AuthConfig { + @Bean + public AuthFacade authFacade(UserFacade userFacade, TokenGenerator generator){ + return new AuthFacade(userFacade, generator); + } +} diff --git a/src/main/java/com/ecmsp/userservice/auth/domain/AuthFacade.java b/src/main/java/com/ecmsp/userservice/auth/domain/AuthFacade.java new file mode 100644 index 0000000..70b6e60 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/auth/domain/AuthFacade.java @@ -0,0 +1,28 @@ +package com.ecmsp.userservice.auth.domain; + +import com.ecmsp.userservice.user.domain.UserFacade; +import org.mindrot.jbcrypt.BCrypt; + +public class AuthFacade { + private final UserFacade userFacade; + private final TokenGenerator tokenGenerator; + + public AuthFacade(UserFacade userFacade, TokenGenerator tokenGenerator) { + this.userFacade = userFacade; + this.tokenGenerator = tokenGenerator; + } + + public AuthenticationResult authenticate(String login, String password) { + return userFacade.findUserByLogin(login) + .map(user -> { + boolean passwordMatches = BCrypt.checkpw(password, user.passwordHash()); + if (passwordMatches) { + return new AuthenticationResult.Success(tokenGenerator.generate(user)); + } + return new AuthenticationResult.Failure("Invalid credentials"); + }) + .orElse(new AuthenticationResult.Failure("User not found")); + } + + +} diff --git a/src/main/java/com/ecmsp/userservice/auth/domain/AuthenticationResult.java b/src/main/java/com/ecmsp/userservice/auth/domain/AuthenticationResult.java new file mode 100644 index 0000000..e291d81 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/auth/domain/AuthenticationResult.java @@ -0,0 +1,6 @@ +package com.ecmsp.userservice.auth.domain; + +public sealed interface AuthenticationResult { + record Success(Token token) implements AuthenticationResult {} + record Failure(String reason) implements AuthenticationResult {} +} diff --git a/src/main/java/com/ecmsp/userservice/auth/domain/Token.java b/src/main/java/com/ecmsp/userservice/auth/domain/Token.java new file mode 100644 index 0000000..455c0e8 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/auth/domain/Token.java @@ -0,0 +1,8 @@ +package com.ecmsp.userservice.auth.domain; + +public record Token(String value) { + @Override + public String toString() { + return value; + } +} diff --git a/src/main/java/com/ecmsp/userservice/auth/domain/TokenGenerator.java b/src/main/java/com/ecmsp/userservice/auth/domain/TokenGenerator.java new file mode 100644 index 0000000..20e4866 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/auth/domain/TokenGenerator.java @@ -0,0 +1,7 @@ +package com.ecmsp.userservice.auth.domain; + +import com.ecmsp.userservice.user.domain.User; + +public interface TokenGenerator { + Token generate(User user); +} diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepository.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepository.java new file mode 100644 index 0000000..0b36a41 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepository.java @@ -0,0 +1,38 @@ +package com.ecmsp.userservice.user.adapter.repository.inmemory; + +import com.ecmsp.userservice.user.domain.User; +import com.ecmsp.userservice.user.domain.UserId; +import com.ecmsp.userservice.user.domain.UserRepository; +import org.mindrot.jbcrypt.BCrypt; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +class InMemoryUserRepository implements UserRepository { + private final Map users = Map.of( + new UserId(UUID.fromString("3d1af02a-bba2-4df1-b551-e944bb60bc94")), + new User(new UserId(UUID.fromString("3d1af02a-bba2-4df1-b551-e944bb60bc94")), "testuser", BCrypt.hashpw("testpassword", BCrypt.gensalt())) + ); + + @Override + public User save(User user) { + users.put(user.id(), user); + return user; + } + + @Override + public Optional findById(UserId userId) { + User user = users.get(userId); + return Optional.ofNullable(user); + } + + @Override + public Optional findByLogin(String login) { + return users.values().stream() + .filter(user -> user.login().equals(login)) + .findFirst(); + } + + +} diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepositoryConfig.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepositoryConfig.java new file mode 100644 index 0000000..30bfcc4 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepositoryConfig.java @@ -0,0 +1,15 @@ +package com.ecmsp.userservice.user.adapter.repository.inmemory; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConditionalOnProperty(value = "user.repository.type", havingValue = "in-memory") +class InMemoryUserRepositoryConfig { + + @Bean + InMemoryUserRepository inMemoryUserRepository() { + return new InMemoryUserRepository(); + } +} diff --git a/src/main/java/com/ecmsp/userservice/user/config/UserConfig.java b/src/main/java/com/ecmsp/userservice/user/config/UserConfig.java new file mode 100644 index 0000000..4e0d976 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/config/UserConfig.java @@ -0,0 +1,14 @@ +package com.ecmsp.userservice.user.config; + +import com.ecmsp.userservice.user.domain.UserFacade; +import com.ecmsp.userservice.user.domain.UserRepository; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +class UserConfig { + @Bean + public UserFacade userFacade(UserRepository userRepository){ + return new UserFacade(userRepository); + } +} diff --git a/src/main/java/com/ecmsp/userservice/user/domain/PasswordHasher.java b/src/main/java/com/ecmsp/userservice/user/domain/PasswordHasher.java new file mode 100644 index 0000000..afc68ff --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/domain/PasswordHasher.java @@ -0,0 +1,11 @@ +package com.ecmsp.userservice.user.domain; + +import org.mindrot.jbcrypt.BCrypt; + +class PasswordHasher { + String hash(String password) { + return BCrypt.hashpw(password, BCrypt.gensalt()); + } + + +} diff --git a/src/main/java/com/ecmsp/userservice/user/domain/User.java b/src/main/java/com/ecmsp/userservice/user/domain/User.java new file mode 100644 index 0000000..1843e72 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/domain/User.java @@ -0,0 +1,5 @@ +package com.ecmsp.userservice.user.domain; + +public record User(UserId id, String login, String passwordHash) { + +} diff --git a/src/main/java/com/ecmsp/userservice/user/domain/UserFacade.java b/src/main/java/com/ecmsp/userservice/user/domain/UserFacade.java new file mode 100644 index 0000000..1b1d229 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/domain/UserFacade.java @@ -0,0 +1,31 @@ +package com.ecmsp.userservice.user.domain; + +import java.util.Optional; +import java.util.UUID; + +public class UserFacade { + private final UserRepository userRepository; + private final PasswordHasher passwordHasher = new PasswordHasher(); + + public UserFacade(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public User createUser(UserToCreate userToCreate){ + UserId userId = new UserId(UUID.randomUUID()); + String hashedPassword = passwordHasher.hash(userToCreate.password()); + User user = new User(userId, userToCreate.login(), hashedPassword); + return userRepository.save(user); + } + + public Optional findUserById(UserId userId){ + return userRepository.findById(userId); + } + + + public Optional findUserByLogin(String login) { + return userRepository.findByLogin(login); + } + + +} diff --git a/src/main/java/com/ecmsp/userservice/user/domain/UserId.java b/src/main/java/com/ecmsp/userservice/user/domain/UserId.java new file mode 100644 index 0000000..2ffc6bf --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/domain/UserId.java @@ -0,0 +1,11 @@ +package com.ecmsp.userservice.user.domain; + +import java.util.UUID; + +public record UserId(UUID value) { + + @Override + public String toString() { + return value.toString(); + } +} diff --git a/src/main/java/com/ecmsp/userservice/user/domain/UserRepository.java b/src/main/java/com/ecmsp/userservice/user/domain/UserRepository.java new file mode 100644 index 0000000..06ed51d --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/domain/UserRepository.java @@ -0,0 +1,9 @@ +package com.ecmsp.userservice.user.domain; + +import java.util.Optional; + +public interface UserRepository { + User save(User user); + Optional findById(UserId userId); + Optional findByLogin(String login); +} diff --git a/src/main/java/com/ecmsp/userservice/user/domain/UserToCreate.java b/src/main/java/com/ecmsp/userservice/user/domain/UserToCreate.java new file mode 100644 index 0000000..96f08ca --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/domain/UserToCreate.java @@ -0,0 +1,4 @@ +package com.ecmsp.userservice.user.domain; + +public record UserToCreate(String login, String password) { +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..e08da08 --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,3 @@ +user: + repository: + type: in-memory \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index f9c237e..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=User service diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..0305910 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,7 @@ +spring: + application: + name: User service +auth: + token-generator: + type: jwt + secret-key-file: classpath:local/secrets/local.private.key \ No newline at end of file diff --git a/src/main/resources/local/secrets/INSTALL b/src/main/resources/local/secrets/INSTALL new file mode 100644 index 0000000..49a1168 --- /dev/null +++ b/src/main/resources/local/secrets/INSTALL @@ -0,0 +1,4 @@ +Generate private/public RSA4096 key pair: + +openssl genrsa > local.private.key +openssl rsa -in local.private.key -pubout > local.public.key \ No newline at end of file From 03532ff56e77b8bf023b7903e3d0100fbfe4c337 Mon Sep 17 00:00:00 2001 From: Ariel Date: Tue, 23 Sep 2025 12:10:14 +0200 Subject: [PATCH 04/17] Add posgres db setup --- pom.xml | 22 +- .../repository/db/DbUserRepository.java | 38 ++++ .../db/DbUserRepositoryConfiguration.java | 26 +++ .../adapter/repository/db/UserEntity.java | 64 ++++++ .../repository/db/UserEntityMapper.java | 23 ++ .../repository/db/UserEntityRepository.java | 14 ++ .../inmemory/InMemoryUserRepository.java | 3 +- .../userservice/user/domain/UserFacade.java | 4 +- .../user/domain/UserRepository.java | 2 +- src/main/resources/application-local.yml | 1 + src/main/resources/application.yml | 13 +- .../repository/db/DbUserRepositoryTest.java | 202 ++++++++++++++++++ 12 files changed, 400 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepository.java create mode 100644 src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryConfiguration.java create mode 100644 src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntity.java create mode 100644 src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityMapper.java create mode 100644 src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityRepository.java create mode 100644 src/test/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryTest.java diff --git a/pom.xml b/pom.xml index 7c14782..70782e1 100644 --- a/pom.xml +++ b/pom.xml @@ -39,11 +39,13 @@ org.springframework.boot spring-boot-starter-validation - - org.springframework.boot - spring-boot-starter-web - - + + com.h2database + h2 + test + + + org.mindrot jbcrypt 0.4 @@ -90,9 +92,17 @@ org.springframework.cloud spring-cloud-starter-contract-verifier + + + com.h2database + h2 test - + + org.springframework.boot + spring-boot-starter-data-jpa + + diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepository.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepository.java new file mode 100644 index 0000000..20ea784 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepository.java @@ -0,0 +1,38 @@ +package com.ecmsp.userservice.user.adapter.repository.db; + +import com.ecmsp.userservice.user.domain.User; +import com.ecmsp.userservice.user.domain.UserId; +import com.ecmsp.userservice.user.domain.UserRepository; + +import java.util.Optional; + +class DbUserRepository implements UserRepository { + private final UserEntityMapper userMapper = new UserEntityMapper(); + private final UserEntityRepository userEntityRepository; + + DbUserRepository(UserEntityRepository userEntityRepository) { + this.userEntityRepository = userEntityRepository; + } + + + @Override + public void save(User user) { + if(userEntityRepository.existsById(user.id().value())) { + throw new RuntimeException("User already exists: " + user.id()); + } + userEntityRepository.save(userMapper.toUserEntity(user)); + } + + //TODO: should all these finds be transactional? + @Override + public Optional findById(UserId userId) { + return userEntityRepository.findById(userId.value()) + .map(userMapper::toUser); + } + + @Override + public Optional findByLogin(String login) { + return userEntityRepository.findByLogin(login) + .map(userMapper::toUser); + } +} diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryConfiguration.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryConfiguration.java new file mode 100644 index 0000000..71f0bfe --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryConfiguration.java @@ -0,0 +1,26 @@ +package com.ecmsp.userservice.user.adapter.repository.db; + +import com.ecmsp.userservice.user.domain.UserRepository; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@Configuration +@ConditionalOnProperty( + prefix = "user.repository", + name = "type", + havingValue = "db") +@Import({DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class}) +@EnableJpaRepositories(basePackages = "com.ecmsp.userservice.user.adapter.repository.db") +class DbUserRepositoryConfiguration { + @Bean + UserRepository dbUserRepository(UserEntityRepository userEntityRepository) { + return new DbUserRepository( + /* orderEntityRepository = */ userEntityRepository + ); + } +} diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntity.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntity.java new file mode 100644 index 0000000..63b51bb --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntity.java @@ -0,0 +1,64 @@ +package com.ecmsp.userservice.user.adapter.repository.db; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Entity +@Table(name = "users") +class UserEntity { + + @Id + @Column(name = "user_id") + private UUID userId; + + //TODO change it to username or email or keep both + @Column(name = "login") + private String login; + + //it's already hashed + @Column(name = "password") + private String password; + + public UUID getUserId() { + return userId; + } + + public void setUserId(UUID userId) { + this.userId = userId; + } + + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + @Override + public String toString() { + return "UserEntity{" + + "userId=" + userId + + ", login='" + login + '\'' + + ", password='" + password + '\'' + + '}'; + } +} diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityMapper.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityMapper.java new file mode 100644 index 0000000..be71e13 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityMapper.java @@ -0,0 +1,23 @@ +package com.ecmsp.userservice.user.adapter.repository.db; + +import com.ecmsp.userservice.user.domain.User; +import com.ecmsp.userservice.user.domain.UserId; + +class UserEntityMapper { + + public User toUser(UserEntity entity) { + return new User( + new UserId(entity.getUserId()), + entity.getLogin(), + entity.getPassword() + ); + } + + public UserEntity toUserEntity(User user) { + return UserEntity.builder() + .userId(user.id().value()) + .login(user.login()) + .password(user.passwordHash()) + .build(); + } +} diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityRepository.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityRepository.java new file mode 100644 index 0000000..06498dc --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityRepository.java @@ -0,0 +1,14 @@ +package com.ecmsp.userservice.user.adapter.repository.db; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +interface UserEntityRepository extends JpaRepository { + @Query("SELECT u FROM UserEntity u WHERE u.login = :login") + Optional findByLogin(String login); +} diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepository.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepository.java index 0b36a41..2ad6875 100644 --- a/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepository.java +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepository.java @@ -16,9 +16,8 @@ class InMemoryUserRepository implements UserRepository { ); @Override - public User save(User user) { + public void save(User user) { users.put(user.id(), user); - return user; } @Override diff --git a/src/main/java/com/ecmsp/userservice/user/domain/UserFacade.java b/src/main/java/com/ecmsp/userservice/user/domain/UserFacade.java index 1b1d229..d588f46 100644 --- a/src/main/java/com/ecmsp/userservice/user/domain/UserFacade.java +++ b/src/main/java/com/ecmsp/userservice/user/domain/UserFacade.java @@ -11,11 +11,11 @@ public UserFacade(UserRepository userRepository) { this.userRepository = userRepository; } - public User createUser(UserToCreate userToCreate){ + public void createUser(UserToCreate userToCreate){ UserId userId = new UserId(UUID.randomUUID()); String hashedPassword = passwordHasher.hash(userToCreate.password()); User user = new User(userId, userToCreate.login(), hashedPassword); - return userRepository.save(user); + userRepository.save(user); } public Optional findUserById(UserId userId){ diff --git a/src/main/java/com/ecmsp/userservice/user/domain/UserRepository.java b/src/main/java/com/ecmsp/userservice/user/domain/UserRepository.java index 06ed51d..86a90b6 100644 --- a/src/main/java/com/ecmsp/userservice/user/domain/UserRepository.java +++ b/src/main/java/com/ecmsp/userservice/user/domain/UserRepository.java @@ -3,7 +3,7 @@ import java.util.Optional; public interface UserRepository { - User save(User user); + void save(User user); Optional findById(UserId userId); Optional findByLogin(String login); } diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index e08da08..0efe0d1 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -1,3 +1,4 @@ + user: repository: type: in-memory \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0305910..d70dae6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,7 +1,18 @@ spring: application: name: User service + jpa: + hibernate.ddl-auto: create # TODO: set to none once flyway integration is finished + properties.hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect + show-sql: true + datasource: + url: jdbc:postgresql://localhost:5432/user-service-db + username: admin + password: admin auth: token-generator: type: jwt - secret-key-file: classpath:local/secrets/local.private.key \ No newline at end of file + secret-key-file: classpath:local/secrets/local.private.key +user: + repository: + type: db \ No newline at end of file diff --git a/src/test/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryTest.java b/src/test/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryTest.java new file mode 100644 index 0000000..b6327c8 --- /dev/null +++ b/src/test/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryTest.java @@ -0,0 +1,202 @@ +package com.ecmsp.userservice.user.adapter.repository.db; + +import com.ecmsp.userservice.user.domain.*; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DataJpaTest +class DbUserRepositoryTest { + + private static final UserId USER_1_ID = new UserId(UUID.fromString("123e4567-e89b-12d3-a456-426614174000")); + private static final UserId USER_2_ID = new UserId(UUID.fromString("9e349a18-1203-4224-829c-dc15700c68a5")); + + private static final User USER_1 = new User( + /* id = */ USER_1_ID, + /* login = */ "user1", + /* passwordHash = */ "hashedPassword1" + ); + + private static final User USER_2 = new User( + /* id = */ USER_2_ID, + /* login = */ "user2", + /* passwordHash = */ "hashedPassword2" + ); + + @Autowired + private UserEntityRepository userEntityRepository; + + @Autowired + private TestEntityManager testEntityManager; + + @Test + void should_save_new_user() { + // given: + UserRepository repository = new DbUserRepository(userEntityRepository); + + // when: + repository.save(USER_1); + + // then: + User savedUser = repository.findById(USER_1_ID).orElseThrow(); + assertThat(savedUser).isEqualTo(USER_1); + + // and: + UserEntity userEntity = testEntityManager.find(UserEntity.class, USER_1_ID.value()); + assertThat(userEntity).isNotNull(); + assertThat(userEntity.getUserId()).isEqualTo(USER_1_ID.value()); + assertThat(userEntity.getLogin()).isEqualTo("user1"); + assertThat(userEntity.getPassword()).isEqualTo("hashedPassword1"); + } + + @Test + void should_throw_exception_when_save_user_with_existing_id() { + // given: + UserRepository repository = new DbUserRepository(userEntityRepository); + + // and: + testEntityManager.persistAndFlush( + UserEntity.builder() + .userId(USER_1_ID.value()) + .login("user1") + .password("hashedPassword1") + .build() + ); + + // when: + var error = assertThatThrownBy(() -> { + repository.save(USER_1); // trying to save user with the same ID + }); + + // then: + error.isInstanceOf(RuntimeException.class); + error.hasMessageContaining("User already exists: %s".formatted(USER_1_ID)); + } + + @Test + void should_find_user_by_id() { + // given: + UserRepository repository = new DbUserRepository(userEntityRepository); + + // and: + testEntityManager.persistAndFlush( + UserEntity.builder() + .userId(USER_1_ID.value()) + .login("user1") + .password("hashedPassword1") + .build() + ); + + // when: + Optional user = repository.findById(USER_1_ID); + + // then: + assertThat(user).isPresent(); + assertThat(user.get()).isEqualTo(USER_1); + } + + @Test + void should_return_empty_optional_when_user_with_given_id_not_exist() { + // given: + UserRepository repository = new DbUserRepository(userEntityRepository); + + // and: + testEntityManager.persistAndFlush( + UserEntity.builder() + .userId(USER_1_ID.value()) + .login("user1") + .password("hashedPassword1") + .build() + ); + + // when: + Optional user = repository.findById(USER_2_ID); // USER_2_ID does not exist + + // then: + assertThat(user).isEmpty(); + } + + @Test + void should_find_user_by_login() { + // given: + UserRepository repository = new DbUserRepository(userEntityRepository); + + // and: + testEntityManager.persistAndFlush( + UserEntity.builder() + .userId(USER_1_ID.value()) + .login("user1") + .password("hashedPassword1") + .build() + ); + + // when: + Optional user = repository.findByLogin("user1"); + + // then: + assertThat(user).isPresent(); + assertThat(user.get()).isEqualTo(USER_1); + } + + @Test + void should_return_empty_optional_when_user_with_given_login_not_exist() { + // given: + UserRepository repository = new DbUserRepository(userEntityRepository); + + // and: + testEntityManager.persistAndFlush( + UserEntity.builder() + .userId(USER_1_ID.value()) + .login("user1") + .password("hashedPassword1") + .build() + ); + + // when: + Optional user = repository.findByLogin("nonexistentuser"); // login does not exist + + // then: + assertThat(user).isEmpty(); + } + + @Test + void should_find_correct_user_when_multiple_users_exist() { + // given: + UserRepository repository = new DbUserRepository(userEntityRepository); + + // and: + testEntityManager.persist( + UserEntity.builder() + .userId(USER_1_ID.value()) + .login("user1") + .password("hashedPassword1") + .build() + ); + testEntityManager.persist( + UserEntity.builder() + .userId(USER_2_ID.value()) + .login("user2") + .password("hashedPassword2") + .build() + ); + testEntityManager.flush(); + + // when: + Optional user1 = repository.findByLogin("user1"); + Optional user2 = repository.findByLogin("user2"); + + // then: + assertThat(user1).isPresent(); + assertThat(user1.get()).isEqualTo(USER_1); + assertThat(user2).isPresent(); + assertThat(user2.get()).isEqualTo(USER_2); + } + +} \ No newline at end of file From 4bbe47daa9af7ccea1c0837b28a1fc2b086fd7a6 Mon Sep 17 00:00:00 2001 From: pawlowiczf Date: Wed, 24 Sep 2025 19:36:25 +0200 Subject: [PATCH 05/17] add, refactor docker, docker compose files --- .env | 9 +- docker-compose.yaml | 308 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 docker-compose.yaml diff --git a/.env b/.env index 4200663..3309f31 100644 --- a/.env +++ b/.env @@ -10,6 +10,11 @@ ORDER_SERVICE_DB_PORT=9300 PRODUCT_SERVICE_PORT=8400 PRODUCT_SERVICE_DB_PORT=9400 +USER_SERVICE_PORT=8500 +USER_SERVICE_DB_PORT=9500 + +GATEWAY_SERVICE_PORT=8600 + ####################################### # intentionally left blank (it is automatically set to 'compose' profile) @@ -17,8 +22,10 @@ PAYMENT_SERVICE_SPRING_PROFILES_ACTIVE= ORDER_SERVICE_SPRING_PROFILES_ACTIVE= PRODUCT_SERVICE_SPRING_PROFILES_ACTIVE= CART_SERVICE_SPRING_PROFILES_ACTIVE= +USER_SERVICE_SPRING_PROFILES_ACTIVE= +GATEWAY_SERVICE_SPRING_PROFILES_ACTIVE= ####################################### -COMPOSE_PROFILES=payment-service,order-service,cart-service,product-service,kafka +COMPOSE_PROFILES=payment-service,order-service,cart-service,product-service,gateway-service,kafka diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..2f83061 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,308 @@ +name: ecmsp-full + +services: + gateway-service: + build: + context: ../gateway-service/ + dockerfile: Dockerfile + container_name: gateway-service + ports: + - "${GATEWAY_SERVICE_PORT:?error}:8080" + env_file: + - + path: ../gateway-service/docker/app-env.txt + required: false + environment: + - SPRING_PROFILES_ACTIVE=${GATEWAY_SERVICE_SPRING_PROFILES_ACTIVE:-compose} + profiles: [gateway-service] + + user-service: + build: + context: ../user-service/ + dockerfile: Dockerfile + container_name: user-service + ports: + - "${USER_SERVICE_PORT:?error}:8080" + env_file: + - + path: ../user-service/docker/app-env.txt + required: false + environment: + - SPRING_PROFILES_ACTIVE=${USER_SERVICE_SPRING_PROFILES_ACTIVE:-compose} + profiles: [user-service] + user-service-db: + image: postgres:15-alpine + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""] + interval: 10s + retries: 5 + start_period: 30s + timeout: 10s + ports: + - "${USER_SERVICE_DB_PORT:?error}:5432" + volumes: + - ../user-service/docker/init.sql:/docker-entrypoint-initdb.d/init.sql + - user-service-db:/var/lib/postgresql/data + secrets: + - user-service-db-password + environment: + - POSTGRES_USER=admin + - POSTGRES_DB=user-service-db + - POSTGRES_PASSWORD_FILE=/run/secrets/user-service-db + env_file: + - + path: ../user-service-db/docker/database-env.txt + required: false + profiles: [user-service] + + product-service: + build: + context: ../product-service + dockerfile: Dockerfile + container_name: product-service + ports: + - "${PRODUCT_SERVICE_PORT:?error}:8080" + env_file: + - + path: ../product-service/docker/app-env.txt + required: false + environment: + - SPRING_PROFILES_ACTIVE=${PRODUCT_SERVICE_SPRING_PROFILES_ACTIVE:-compose} + depends_on: + product-service-db: + restart: true + condition: service_healthy + kafka: + condition: service_healthy + profiles: [product-service] + # + product-service-db: + image: postgres:15-alpine + container_name: product-service-db + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""] + interval: 10s + retries: 5 + start_period: 30s + timeout: 10s + ports: + - "${PRODUCT_SERVICE_DB_PORT:?error}:5432" + volumes: + - ../product-service/docker/init.sql:/docker-entrypoint-initdb.d/init.sql + - product-service-db:/var/lib/postgresql/data + secrets: + - product-service-db-password + environment: + - POSTGRES_USER=admin + - POSTGRES_DB=product-service-db + - POSTGRES_PASSWORD_FILE=/run/secrets/product-service-db-password + env_file: + - + path: ../product-service/docker/database-env.txt + required: false + profiles: [product-service] + # + + order-service: + build: + context: ../order-service + dockerfile: Dockerfile + container_name: order-service + ports: + - "${ORDER_SERVICE_PORT:?error}:8080" + env_file: + - + path: ../order-service/docker/app-env.txt + required: false + environment: + - SPRING_PROFILES_ACTIVE=${ORDER_SERVICE_SPRING_PROFILES_ACTIVE:-compose} + depends_on: + order-service-db: + restart: true + condition: service_healthy + kafka: + condition: service_healthy + profiles: [order-service] + # + order-service-db: + image: postgres:15-alpine + container_name: order-service-db + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""] + interval: 10s + retries: 5 + start_period: 30s + timeout: 10s + ports: + - "${ORDER_SERVICE_DB_PORT:?error}:5432" + volumes: + - ../order-service/docker/init.sql:/docker-entrypoint-initdb.d/init.sql + - order-service-db:/var/lib/postgresql/data + secrets: + - order-service-db-password + environment: + - POSTGRES_USER=admin + - POSTGRES_DB=order-service-db + - POSTGRES_PASSWORD_FILE=/run/secrets/order-service-db-password + env_file: + - + path: ../order-service/docker/database-env.txt + required: false + profiles: [order-service] + # + + payment-service: + build: + context: ../payment-service + dockerfile: Dockerfile + container_name: payment-service + ports: + - "${PAYMENT_SERVICE_PORT:?error}:8080" + env_file: + - + path: ../payment-service/docker/app-env.txt + required: false + environment: + - SPRING_PROFILES_ACTIVE=${PAYMENT_SERVICE_SPRING_PROFILES_ACTIVE:-compose} + depends_on: + payment-service-db: + restart: true + condition: service_healthy + kafka: + condition: service_healthy + profiles: [payment-service] + # + payment-service-db: + image: postgres:15-alpine + container_name: payment-service-db + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""] + interval: 10s + retries: 5 + start_period: 30s + timeout: 10s + ports: + - "${PAYMENT_SERVICE_DB_PORT:?error}:5432" + volumes: + - ../payment-service/docker/init.sql:/docker-entrypoint-initdb.d/init.sql + - payment-service-db:/var/lib/postgresql/data + secrets: + - payment-service-db-password + environment: + - POSTGRES_USER=admin + - POSTGRES_DB=payment-service-db + - POSTGRES_PASSWORD_FILE=/run/secrets/payment-service-db-password + env_file: + - + path: ../payment-service/docker/database-env.txt + required: false + profiles: [payment-service] + # + + cart-service: + build: + context: ../cart-service + dockerfile: Dockerfile + container_name: cart-service + ports: + - "${CART_SERVICE_PORT:?error}:8080" + env_file: + - + path: ../cart-service/docker/app-env.txt + required: false + environment: + - SPRING_PROFILES_ACTIVE=${CART_SERVICE_SPRING_PROFILES_ACTIVE:-compose} + depends_on: + cart-service-db: + restart: true + condition: service_healthy + kafka: + condition: service_healthy + profiles: [cart-service] + # + cart-service-db: + image: postgres:15-alpine + container_name: cart-service-db + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""] + interval: 10s + retries: 5 + start_period: 30s + timeout: 10s + ports: + - "${CART_SERVICE_DB_PORT:?error}:5432" + volumes: + - ../cart-service/docker/init.sql:/docker-entrypoint-initdb.d/init.sql + - cart-service-db:/var/lib/postgresql/data + secrets: + - cart-service-db-password + environment: + - POSTGRES_USER=admin + - POSTGRES_DB=cart-service-db + - POSTGRES_PASSWORD_FILE=/run/secrets/cart-service-db-password + env_file: + - + path: ../cart-service/docker/database-env.txt + required: false + profiles: [cart-service] + # + + kafka: + image: apache/kafka:3.7.0 + container_name: broker + environment: + KAFKA_NODE_ID: 1 + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:9094 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://broker:9092,EXTERNAL://localhost:9094 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@broker:9093 + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + ports: + - "9094:9094" + healthcheck: + test: + [ + "CMD-SHELL", + "./opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 > /dev/null 2>&1", + ] + interval: 10s + timeout: 10s + retries: 5 + profiles: [kafka] + + kafka-ui: + image: provectuslabs/kafka-ui:latest + container_name: kafka-ui + ports: + - "8088:8080" + environment: + KAFKA_CLUSTERS_0_NAME: local + KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: broker:9092 + KAFKA_CLUSTERS_0_READONLY: "false" + # KAFKA_CLUSTERS_0_SCHEMAREGISTRY: http://schema-registry:8081 + depends_on: + kafka: + condition: service_healthy + profiles: [kafka] + +secrets: + product-service-db-password: + file: ../product-service/docker/database-password.txt + order-service-db-password: + file: ../order-service/docker/database-password.txt + payment-service-db-password: + file: ../payment-service/docker/database-password.txt + cart-service-db-password: + file: ../cart-service/docker/database-password.txt + user-service-db-password: + file: ../user-service/docker/database-password.txt +volumes: + product-service-db: + order-service-db: + payment-service-db: + cart-service-db: + user-service-db: From d8cb49f488e8ce6dc02e7ec471a16e815b038f81 Mon Sep 17 00:00:00 2001 From: Ariel Date: Wed, 24 Sep 2025 00:41:13 +0200 Subject: [PATCH 06/17] Add flyway for migrations --- pom.xml | 64 ++++++++++++++++--- .../userservice/UserServiceApplication.java | 1 - .../userservice/api/rest/UserController.java | 29 +++++++++ .../api/rest/UserCreateRequest.java | 4 ++ .../api/{ => rest}/auth/AuthController.java | 2 +- .../api/{ => rest}/auth/AuthRequest.java | 2 +- .../api/{ => rest}/auth/AuthResponseDto.java | 2 +- .../config/LocalPostgreSQLConfiguration.java | 35 ++++++++++ .../repository/db/DbUserRepository.java | 1 - src/main/resources/application-local.yml | 7 +- src/main/resources/application.yml | 12 ++-- .../migration/V1__Create_initial_schema.sql | 10 +++ .../db/migration/V2__Insert_example_data.sql | 9 +++ 13 files changed, 159 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/ecmsp/userservice/api/rest/UserController.java create mode 100644 src/main/java/com/ecmsp/userservice/api/rest/UserCreateRequest.java rename src/main/java/com/ecmsp/userservice/api/{ => rest}/auth/AuthController.java (96%) rename src/main/java/com/ecmsp/userservice/api/{ => rest}/auth/AuthRequest.java (57%) rename src/main/java/com/ecmsp/userservice/api/{ => rest}/auth/AuthResponseDto.java (52%) create mode 100644 src/main/java/com/ecmsp/userservice/application/config/LocalPostgreSQLConfiguration.java create mode 100644 src/main/resources/db/migration/V1__Create_initial_schema.sql create mode 100644 src/main/resources/db/migration/V2__Insert_example_data.sql diff --git a/pom.xml b/pom.xml index 70782e1..c0163f7 100644 --- a/pom.xml +++ b/pom.xml @@ -50,6 +50,10 @@ jbcrypt 0.4 + + org.springframework.boot + spring-boot-starter-web + io.jsonwebtoken jjwt-api @@ -102,6 +106,34 @@ org.springframework.boot spring-boot-starter-data-jpa + + org.springframework.boot + spring-boot-testcontainers + 3.5.3 + + + org.testcontainers + testcontainers + 1.21.3 + + + + org.testcontainers + postgresql + 1.21.3 + + + org.flywaydb + flyway-core + 11.11.2 + + + + org.flywaydb + flyway-database-postgresql + 11.11.2 + runtime + @@ -129,15 +161,6 @@ - - org.springframework.cloud - spring-cloud-contract-maven-plugin - 4.3.0 - true - - JUNIT5 - - org.springframework.boot spring-boot-maven-plugin @@ -150,6 +173,29 @@ + + org.flywaydb + flyway-maven-plugin + 11.11.2 + + jdbc:postgresql://localhost:5432/order-service-db + admin + admin + + public + + + classpath:db/migration + + + + + org.postgresql + postgresql + 42.7.4 + + + diff --git a/src/main/java/com/ecmsp/userservice/UserServiceApplication.java b/src/main/java/com/ecmsp/userservice/UserServiceApplication.java index 87c9272..759bfbb 100644 --- a/src/main/java/com/ecmsp/userservice/UserServiceApplication.java +++ b/src/main/java/com/ecmsp/userservice/UserServiceApplication.java @@ -3,7 +3,6 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; -import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; @SpringBootApplication( diff --git a/src/main/java/com/ecmsp/userservice/api/rest/UserController.java b/src/main/java/com/ecmsp/userservice/api/rest/UserController.java new file mode 100644 index 0000000..fa6a16e --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/api/rest/UserController.java @@ -0,0 +1,29 @@ +package com.ecmsp.userservice.api.rest; + + +import com.ecmsp.userservice.user.domain.UserFacade; +import com.ecmsp.userservice.user.domain.UserToCreate; +import org.springframework.cloud.contract.spec.internal.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/users") +public class UserController { + + private final UserFacade userFacade; + + public UserController(UserFacade userFacade) { + this.userFacade = userFacade; + } + + @PostMapping + public ResponseEntity createUser(@RequestBody UserCreateRequest request) { + UserToCreate user = new UserToCreate(request.login(), request.password()); + userFacade.createUser(user); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } +} diff --git a/src/main/java/com/ecmsp/userservice/api/rest/UserCreateRequest.java b/src/main/java/com/ecmsp/userservice/api/rest/UserCreateRequest.java new file mode 100644 index 0000000..b097445 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/api/rest/UserCreateRequest.java @@ -0,0 +1,4 @@ +package com.ecmsp.userservice.api.rest; + +public record UserCreateRequest(String login, String password) { +} diff --git a/src/main/java/com/ecmsp/userservice/api/auth/AuthController.java b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthController.java similarity index 96% rename from src/main/java/com/ecmsp/userservice/api/auth/AuthController.java rename to src/main/java/com/ecmsp/userservice/api/rest/auth/AuthController.java index ecb7cab..497aea5 100644 --- a/src/main/java/com/ecmsp/userservice/api/auth/AuthController.java +++ b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthController.java @@ -1,4 +1,4 @@ -package com.ecmsp.userservice.api.auth; +package com.ecmsp.userservice.api.rest.auth; import com.ecmsp.userservice.auth.domain.AuthFacade; import com.ecmsp.userservice.auth.domain.AuthenticationResult; diff --git a/src/main/java/com/ecmsp/userservice/api/auth/AuthRequest.java b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthRequest.java similarity index 57% rename from src/main/java/com/ecmsp/userservice/api/auth/AuthRequest.java rename to src/main/java/com/ecmsp/userservice/api/rest/auth/AuthRequest.java index 764d7e8..e6072a4 100644 --- a/src/main/java/com/ecmsp/userservice/api/auth/AuthRequest.java +++ b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthRequest.java @@ -1,4 +1,4 @@ -package com.ecmsp.userservice.api.auth; +package com.ecmsp.userservice.api.rest.auth; public record AuthRequest(String login, String password) { } diff --git a/src/main/java/com/ecmsp/userservice/api/auth/AuthResponseDto.java b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthResponseDto.java similarity index 52% rename from src/main/java/com/ecmsp/userservice/api/auth/AuthResponseDto.java rename to src/main/java/com/ecmsp/userservice/api/rest/auth/AuthResponseDto.java index 55e4a44..1fef208 100644 --- a/src/main/java/com/ecmsp/userservice/api/auth/AuthResponseDto.java +++ b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthResponseDto.java @@ -1,4 +1,4 @@ -package com.ecmsp.userservice.api.auth; +package com.ecmsp.userservice.api.rest.auth; public record AuthResponseDto(String token) { } diff --git a/src/main/java/com/ecmsp/userservice/application/config/LocalPostgreSQLConfiguration.java b/src/main/java/com/ecmsp/userservice/application/config/LocalPostgreSQLConfiguration.java new file mode 100644 index 0000000..46385f8 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/application/config/LocalPostgreSQLConfiguration.java @@ -0,0 +1,35 @@ +package com.ecmsp.userservice.application.config; + +import com.github.dockerjava.api.model.ExposedPort; +import com.github.dockerjava.api.model.PortBinding; +import com.github.dockerjava.api.model.Ports; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.testcontainers.containers.PostgreSQLContainer; + +@Configuration +@Profile("local") +public class LocalPostgreSQLConfiguration { + + @Bean + @ServiceConnection + PostgreSQLContainer postgresContainer() { + return new PostgreSQLContainer<>("postgres:15") + .withDatabaseName("user-service-db") + .withCreateContainerCmdModifier(cmd -> + cmd + .withName("postgres-user-db") + .getHostConfig().withPortBindings( + new PortBinding( + /* hostPort = */ Ports.Binding.bindPort(5432), + /* containerPort = */ new ExposedPort(5432) + ) + ) + ) + .withUsername("admin") + .withPassword("admin"); + } + +} \ No newline at end of file diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepository.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepository.java index 20ea784..e129363 100644 --- a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepository.java +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepository.java @@ -23,7 +23,6 @@ public void save(User user) { userEntityRepository.save(userMapper.toUserEntity(user)); } - //TODO: should all these finds be transactional? @Override public Optional findById(UserId userId) { return userEntityRepository.findById(userId.value()) diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 0efe0d1..528b1fe 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -1,4 +1,9 @@ +spring: + datasource: + url: jdbc:postgresql://localhost:5432/user-service-db + username: admin + password: admin user: repository: - type: in-memory \ No newline at end of file + type: db \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d70dae6..0d2bfef 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,13 +2,17 @@ spring: application: name: User service jpa: - hibernate.ddl-auto: create # TODO: set to none once flyway integration is finished + hibernate.ddl-auto: none # TODO: set to none once flyway integration is finished properties.hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect show-sql: true datasource: - url: jdbc:postgresql://localhost:5432/user-service-db - username: admin - password: admin + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: true auth: token-generator: type: jwt diff --git a/src/main/resources/db/migration/V1__Create_initial_schema.sql b/src/main/resources/db/migration/V1__Create_initial_schema.sql new file mode 100644 index 0000000..f9f8f27 --- /dev/null +++ b/src/main/resources/db/migration/V1__Create_initial_schema.sql @@ -0,0 +1,10 @@ + +CREATE TABLE users +( + user_id UUID PRIMARY KEY, + login VARCHAR(100) NOT NULL, + password VARCHAR(100) NOT NULL +); + + +CREATE INDEX idx_users_login ON users (login); \ No newline at end of file diff --git a/src/main/resources/db/migration/V2__Insert_example_data.sql b/src/main/resources/db/migration/V2__Insert_example_data.sql new file mode 100644 index 0000000..1238ba1 --- /dev/null +++ b/src/main/resources/db/migration/V2__Insert_example_data.sql @@ -0,0 +1,9 @@ +-- Insert example users for testing and development + +INSERT INTO users (user_id, login, password) VALUES + ('550e8400-e29b-41d4-a716-446655440000', 'john_doe', '$2a$10$N9qo8uLOickgx2ZMRZoMye7VPKRdQqYBBgOFGhqxfDGqCxo8Ckzq2'), -- password: password123 + ('550e8400-e29b-41d4-a716-446655440001', 'jane_smith', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.'), -- password: secret456 + ('550e8400-e29b-41d4-a716-446655440002', 'admin_user', '$2a$10$DOwJoMGp.NiaVisaQeK2eOhZYkyiwQQw2O.CzpG9k4CrcSKTF9q8e'); -- password: admin789 + +-- Note: Passwords are hashed using BCrypt with salt rounds = 10 +-- These are example hashes for demonstration purposes \ No newline at end of file From 94f079d5140c49726ca766cb66f0ed96237ad3ea Mon Sep 17 00:00:00 2001 From: Ariel Date: Wed, 24 Sep 2025 21:33:24 +0200 Subject: [PATCH 07/17] Update readme --- pom.xml | 2 -- readme.md | 27 +++++++++++++++++-- .../userservice/api/rest/UserController.java | 2 +- .../UserServiceApplicationTests.java | 16 +++++------ 4 files changed, 34 insertions(+), 13 deletions(-) diff --git a/pom.xml b/pom.xml index c057928..658e504 100644 --- a/pom.xml +++ b/pom.xml @@ -94,8 +94,6 @@ test - org.springframework.cloud - spring-cloud-starter-contract-verifier org.springframework.boot spring-boot-starter-data-jpa diff --git a/readme.md b/readme.md index 4377312..0742d0a 100644 --- a/readme.md +++ b/readme.md @@ -1,5 +1,28 @@ -You can test the login endpoint using the following command. Just copy & paste it into HTTP request or any other REST client. -Added user is hardcoded in inMemoryRepository. +# User Service + +## Installation + +**Generate RSA keys:** + ```bash + cd src/main/resources/local/secrets/ + openssl genrsa > local.private.key + openssl rsa -in local.private.key -pubout > local.public.key + ``` + +## User Controller API + +### Create User +``` +POST http://localhost:8080/api/users +Content-Type: application/json + +{ + "login": "newuser", + "password": "password123" +} +``` + +### Authenticate User ``` POST http://localhost:8080/auth/authenticate Content-Type: application/json diff --git a/src/main/java/com/ecmsp/userservice/api/rest/UserController.java b/src/main/java/com/ecmsp/userservice/api/rest/UserController.java index fa6a16e..b6219b9 100644 --- a/src/main/java/com/ecmsp/userservice/api/rest/UserController.java +++ b/src/main/java/com/ecmsp/userservice/api/rest/UserController.java @@ -3,7 +3,7 @@ import com.ecmsp.userservice.user.domain.UserFacade; import com.ecmsp.userservice.user.domain.UserToCreate; -import org.springframework.cloud.contract.spec.internal.HttpStatus; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; diff --git a/src/test/java/com/ecmsp/userservice/UserServiceApplicationTests.java b/src/test/java/com/ecmsp/userservice/UserServiceApplicationTests.java index e9f320f..00f2140 100644 --- a/src/test/java/com/ecmsp/userservice/UserServiceApplicationTests.java +++ b/src/test/java/com/ecmsp/userservice/UserServiceApplicationTests.java @@ -3,11 +3,11 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -@SpringBootTest -class UserServiceApplicationTests { - - @Test - void contextLoads() { - } - -} +//@SpringBootTest +//class UserServiceApplicationTests { +// +// @Test +// void contextLoads() { +// } +// +//} From ecb5bc0fd63ac4a6463d29191d2e74b9a618f5ac Mon Sep 17 00:00:00 2001 From: Ariel Date: Sun, 28 Sep 2025 18:26:24 +0200 Subject: [PATCH 08/17] Add Jwt token generation test --- readme.md | 4 +- .../api/rest/auth/AuthController.java | 4 +- src/main/resources/application.yml | 5 +- .../db/migration/V2__Insert_example_data.sql | 3 +- .../generator/JwtTokenGeneratorTest.java | 186 ++++++++++++++++++ 5 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 src/test/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGeneratorTest.java diff --git a/readme.md b/readme.md index 0742d0a..c3fc35e 100644 --- a/readme.md +++ b/readme.md @@ -17,8 +17,8 @@ POST http://localhost:8080/api/users Content-Type: application/json { - "login": "newuser", - "password": "password123" + "login": "testuser", + "password": "testpassword" } ``` diff --git a/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthController.java b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthController.java index 497aea5..a9d8402 100644 --- a/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthController.java +++ b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthController.java @@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/auth") +@RequestMapping("/api/auth") public class AuthController { private final AuthFacade authFacade; @@ -19,6 +19,7 @@ public AuthController(AuthFacade authFacade) { } + //TODO: reconsider rename to login @PostMapping("/authenticate") public ResponseEntity authenticate(@RequestBody AuthRequest request) { AuthenticationResult result = authFacade.authenticate(request.login(), request.password()); @@ -27,4 +28,5 @@ public ResponseEntity authenticate(@RequestBody AuthRequest req case AuthenticationResult.Failure ignored -> ResponseEntity.badRequest().build(); }; } + } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0d2bfef..f983943 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -19,4 +19,7 @@ auth: secret-key-file: classpath:local/secrets/local.private.key user: repository: - type: db \ No newline at end of file + type: db +#for testing +server: + port: 8500 \ No newline at end of file diff --git a/src/main/resources/db/migration/V2__Insert_example_data.sql b/src/main/resources/db/migration/V2__Insert_example_data.sql index 1238ba1..90db716 100644 --- a/src/main/resources/db/migration/V2__Insert_example_data.sql +++ b/src/main/resources/db/migration/V2__Insert_example_data.sql @@ -3,7 +3,8 @@ INSERT INTO users (user_id, login, password) VALUES ('550e8400-e29b-41d4-a716-446655440000', 'john_doe', '$2a$10$N9qo8uLOickgx2ZMRZoMye7VPKRdQqYBBgOFGhqxfDGqCxo8Ckzq2'), -- password: password123 ('550e8400-e29b-41d4-a716-446655440001', 'jane_smith', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.'), -- password: secret456 - ('550e8400-e29b-41d4-a716-446655440002', 'admin_user', '$2a$10$DOwJoMGp.NiaVisaQeK2eOhZYkyiwQQw2O.CzpG9k4CrcSKTF9q8e'); -- password: admin789 + ('550e8400-e29b-41d4-a716-446655440002', 'admin_user', '$2a$10$DOwJoMGp.NiaVisaQeK2eOhZYkyiwQQw2O.CzpG9k4CrcSKTF9q8e'), -- password: admin789 + ('123e4567-e89b-12d3-a456-426614174001', 'andy', '$2a$12$Oox0qAgn0eVRfyNEy1CGIeSRJ9lmMVl7kWaZ5/UPKkWz0c28ZAzDq'); -- password: password123 -- Note: Passwords are hashed using BCrypt with salt rounds = 10 -- These are example hashes for demonstration purposes \ No newline at end of file diff --git a/src/test/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGeneratorTest.java b/src/test/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGeneratorTest.java new file mode 100644 index 0000000..5fa3bd9 --- /dev/null +++ b/src/test/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGeneratorTest.java @@ -0,0 +1,186 @@ +package com.ecmsp.userservice.auth.adapter.generator; + +import com.ecmsp.userservice.auth.domain.Token; +import com.ecmsp.userservice.user.domain.User; +import com.ecmsp.userservice.user.domain.UserId; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class JwtTokenGeneratorTest { + + private JwtTokenGenerator jwtTokenGenerator; + private PrivateKey privateKey; + private PublicKey publicKey; + private Clock fixedClock; + private final Instant fixedInstant = Instant.now(); + + @BeforeEach + void setUp() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + privateKey = keyPair.getPrivate(); + publicKey = keyPair.getPublic(); + fixedClock = Clock.fixed(fixedInstant, ZoneOffset.UTC); + + jwtTokenGenerator = new JwtTokenGenerator(privateKey, fixedClock); + } + @Test + void shouldGenerateValidJwtToken() { + UUID userId = UUID.fromString("123e4567-e89b-12d3-a456-426614174001"); + String login = "andy"; + User user = new User(new UserId(userId), login, "hashedPassword"); + + Token token = jwtTokenGenerator.generate(user); + System.out.println("Generated Token: " + token.value()); + + assertNotNull(token); + assertNotNull(token.value()); + assertFalse(token.value().isEmpty()); + } + + @Test + void shouldIncludeCorrectSubjectInToken() { + UUID userId = UUID.randomUUID(); + String login = "testuser"; + User user = new User(new UserId(userId), login, "hashedPassword"); + + Token token = jwtTokenGenerator.generate(user); + + Claims claims = parseTokenClaims(token.value()); + assertEquals(userId.toString(), claims.getSubject()); + } + + @Test + void shouldIncludeLoginClaimInToken() { + UUID userId = UUID.randomUUID(); + String login = "testuser"; + User user = new User(new UserId(userId), login, "hashedPassword"); + + Token token = jwtTokenGenerator.generate(user); + + Claims claims = parseTokenClaims(token.value()); + assertEquals(login, claims.get("login")); + } + + @Test + void shouldSetCorrectIssuedAtTime() { + UUID userId = UUID.randomUUID(); + User user = new User(new UserId(userId), "testuser", "hashedPassword"); + + Token token = jwtTokenGenerator.generate(user); + + Claims claims = parseTokenClaims(token.value()); + long expectedTime = fixedInstant.getEpochSecond(); + long actualTime = claims.getIssuedAt().toInstant().getEpochSecond(); + assertEquals(expectedTime, actualTime); + } + + @Test + void shouldSetExpirationTimeOneHourFromIssuedAt() { + UUID userId = UUID.randomUUID(); + User user = new User(new UserId(userId), "testuser", "hashedPassword"); + + Token token = jwtTokenGenerator.generate(user); + + Claims claims = parseTokenClaims(token.value()); + long expectedExpirationTime = fixedInstant.plusSeconds(3600).getEpochSecond(); // 1 hour + long actualExpirationTime = claims.getExpiration().toInstant().getEpochSecond(); + assertEquals(expectedExpirationTime, actualExpirationTime); + } + + @Test + void shouldSignTokenWithProvidedPrivateKey() { + UUID userId = UUID.randomUUID(); + User user = new User(new UserId(userId), "testuser", "hashedPassword"); + + Token token = jwtTokenGenerator.generate(user); + + assertDoesNotThrow(() -> { + Jwts.parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(token.value()); + }); + } + + @Test + void shouldThrowExceptionWhenVerifyingWithWrongKey() throws Exception { + UUID userId = UUID.randomUUID(); + User user = new User(new UserId(userId), "testuser", "hashedPassword"); + + Token token = jwtTokenGenerator.generate(user); + + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + KeyPair wrongKeyPair = keyPairGenerator.generateKeyPair(); + PublicKey wrongPublicKey = wrongKeyPair.getPublic(); + + assertThrows(Exception.class, () -> Jwts.parser() + .verifyWith(wrongPublicKey) + .build() + .parseSignedClaims(token.value())); + } + + @Test + void shouldGenerateDifferentTokensForDifferentUsers() { + User user1 = new User(new UserId(UUID.randomUUID()), "user1", "password1"); + User user2 = new User(new UserId(UUID.randomUUID()), "user2", "password2"); + + Token token1 = jwtTokenGenerator.generate(user1); + Token token2 = jwtTokenGenerator.generate(user2); + + assertNotEquals(token1.value(), token2.value()); + } + + @Test + void shouldGenerateDifferentTokensAtDifferentTimes() { + UUID userId = UUID.randomUUID(); + User user = new User(new UserId(userId), "testuser", "hashedPassword"); + + Token token1 = jwtTokenGenerator.generate(user); + + Clock laterClock = Clock.fixed(fixedInstant.plusSeconds(60), ZoneOffset.UTC); + JwtTokenGenerator laterGenerator = new JwtTokenGenerator(privateKey, laterClock); + Token token2 = laterGenerator.generate(user); + + assertNotEquals(token1.value(), token2.value()); + } + + @Test + void shouldGenerateCompactJwtFormat() { + UUID userId = UUID.randomUUID(); + User user = new User(new UserId(userId), "testuser", "hashedPassword"); + + Token token = jwtTokenGenerator.generate(user); + + String[] parts = token.value().split("\\."); + assertEquals(3, parts.length, "JWT should have 3 parts separated by dots"); + + for (String part : parts) { + assertFalse(part.isEmpty(), "Each JWT part should not be empty"); + } + } + + private Claims parseTokenClaims(String token) { + return Jwts.parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} \ No newline at end of file From 526b57793d2cae5b571fc3d9c2a6a1c22fe7bac2 Mon Sep 17 00:00:00 2001 From: Ariel Date: Sun, 28 Sep 2025 22:04:23 +0200 Subject: [PATCH 09/17] Update readme --- readme.md | 4 ++-- .../com/ecmsp/userservice/api/rest/auth/AuthController.java | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/readme.md b/readme.md index c3fc35e..143dc55 100644 --- a/readme.md +++ b/readme.md @@ -13,7 +13,7 @@ ### Create User ``` -POST http://localhost:8080/api/users +POST http://localhost:8500/api/users Content-Type: application/json { @@ -24,7 +24,7 @@ Content-Type: application/json ### Authenticate User ``` -POST http://localhost:8080/auth/authenticate +POST http://localhost:8500/api/auth/authenticate Content-Type: application/json { diff --git a/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthController.java b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthController.java index a9d8402..0eb217d 100644 --- a/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthController.java +++ b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthController.java @@ -18,8 +18,6 @@ public AuthController(AuthFacade authFacade) { this.authFacade = authFacade; } - - //TODO: reconsider rename to login @PostMapping("/authenticate") public ResponseEntity authenticate(@RequestBody AuthRequest request) { AuthenticationResult result = authFacade.authenticate(request.login(), request.password()); From 72f5114587b905fe049a546f3badde4cb33d753b Mon Sep 17 00:00:00 2001 From: Ariel Date: Sun, 28 Sep 2025 22:13:18 +0200 Subject: [PATCH 10/17] Remove debug printing --- .../auth/adapter/generator/JwtTokenGeneratorTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGeneratorTest.java b/src/test/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGeneratorTest.java index 5fa3bd9..5060bb5 100644 --- a/src/test/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGeneratorTest.java +++ b/src/test/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGeneratorTest.java @@ -46,7 +46,6 @@ void shouldGenerateValidJwtToken() { User user = new User(new UserId(userId), login, "hashedPassword"); Token token = jwtTokenGenerator.generate(user); - System.out.println("Generated Token: " + token.value()); assertNotNull(token); assertNotNull(token.value()); From 5de765ded6e5aa9351fe4d073b27e85465e31432 Mon Sep 17 00:00:00 2001 From: Ariel Date: Mon, 29 Sep 2025 10:05:42 +0200 Subject: [PATCH 11/17] update db port --- pom.xml | 2 +- .../application/config/LocalPostgreSQLConfiguration.java | 2 +- src/main/resources/application-dev.properties | 2 +- src/main/resources/application-local.yml | 2 +- src/main/resources/application.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pom.xml b/pom.xml index 658e504..e1e540a 100644 --- a/pom.xml +++ b/pom.xml @@ -196,7 +196,7 @@ flyway-maven-plugin 11.11.2 - jdbc:postgresql://localhost:5432/order-service-db + jdbc:postgresql://localhost:9500/user-service-db admin admin diff --git a/src/main/java/com/ecmsp/userservice/application/config/LocalPostgreSQLConfiguration.java b/src/main/java/com/ecmsp/userservice/application/config/LocalPostgreSQLConfiguration.java index 46385f8..fc87579 100644 --- a/src/main/java/com/ecmsp/userservice/application/config/LocalPostgreSQLConfiguration.java +++ b/src/main/java/com/ecmsp/userservice/application/config/LocalPostgreSQLConfiguration.java @@ -23,7 +23,7 @@ PostgreSQLContainer postgresContainer() { .withName("postgres-user-db") .getHostConfig().withPortBindings( new PortBinding( - /* hostPort = */ Ports.Binding.bindPort(5432), + /* hostPort = */ Ports.Binding.bindPort(9500), /* containerPort = */ new ExposedPort(5432) ) ) diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 849915f..ef24ef5 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -1,4 +1,4 @@ -spring.datasource.url=jdbc:postgresql://localhost:5432/user-service-db +spring.datasource.url=jdbc:postgresql://localhost:9500/user-service-db spring.datasource.username=admin spring.datasource.password=admin diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 528b1fe..b3c8937 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -1,6 +1,6 @@ spring: datasource: - url: jdbc:postgresql://localhost:5432/user-service-db + url: jdbc:postgresql://localhost:9500/user-service-db username: admin password: admin diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f983943..7aad341 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -20,6 +20,6 @@ auth: user: repository: type: db -#for testing + server: port: 8500 \ No newline at end of file From 2abe476d6d88922ce44ce35081cd70e9d102beda Mon Sep 17 00:00:00 2001 From: Ariel Date: Thu, 2 Oct 2025 16:30:39 +0200 Subject: [PATCH 12/17] fix db tests --- .gitignore | 2 +- pom.xml | 110 +++++------------- .../api/rest/auth/AuthController.java | 4 +- ...AuthResponseDto.java => AuthResponse.java} | 2 +- src/main/resources/application.yml | 2 +- .../UserServiceApplicationTests.java | 3 +- .../repository/db/DbUserRepositoryTest.java | 4 + src/test/resources/application-test.yml | 25 ++++ 8 files changed, 64 insertions(+), 88 deletions(-) rename src/main/java/com/ecmsp/userservice/api/rest/auth/{AuthResponseDto.java => AuthResponse.java} (51%) create mode 100644 src/test/resources/application-test.yml diff --git a/.gitignore b/.gitignore index 667aaef..2bcbfd5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ target/ .mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ - +**/src/main/resources/local/secrets ### STS ### .apt_generated .classpath diff --git a/pom.xml b/pom.xml index e1e540a..d80a03e 100644 --- a/pom.xml +++ b/pom.xml @@ -28,7 +28,6 @@ 21 - 2025.0.0 @@ -39,13 +38,7 @@ org.springframework.boot spring-boot-starter-validation - - com.h2database - h2 - test - - - + org.mindrot jbcrypt 0.4 @@ -97,54 +90,42 @@ org.springframework.boot spring-boot-starter-data-jpa + + com.h2database + h2 + test + org.springframework.kafka spring-kafka 3.3.4 - io.hypersistence - hypersistence-utils-hibernate-63 - 3.10.1 + org.springframework.boot + spring-boot-testcontainers + 3.5.3 - com.h2database - h2 - test + org.testcontainers + testcontainers + 1.21.3 + + + org.testcontainers + postgresql + 1.21.3 + + + org.flywaydb + flyway-core + 11.11.2 + + + org.flywaydb + flyway-database-postgresql + 11.11.2 + runtime - - - org.springframework.boot - spring-boot-starter-data-jpa - - - org.springframework.boot - spring-boot-testcontainers - 3.5.3 - - - org.testcontainers - testcontainers - 1.21.3 - - - - org.testcontainers - postgresql - 1.21.3 - - - org.flywaydb - flyway-core - 11.11.2 - - - - org.flywaydb - flyway-database-postgresql - 11.11.2 - runtime - com.ecmsp protos @@ -153,18 +134,6 @@ - - - - org.springframework.cloud - spring-cloud-dependencies - ${spring-cloud.version} - pom - import - - - - @@ -191,29 +160,6 @@ - - org.flywaydb - flyway-maven-plugin - 11.11.2 - - jdbc:postgresql://localhost:9500/user-service-db - admin - admin - - public - - - classpath:db/migration - - - - - org.postgresql - postgresql - 42.7.4 - - - diff --git a/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthController.java b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthController.java index 0eb217d..b2248cf 100644 --- a/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthController.java +++ b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthController.java @@ -19,10 +19,10 @@ public AuthController(AuthFacade authFacade) { } @PostMapping("/authenticate") - public ResponseEntity authenticate(@RequestBody AuthRequest request) { + public ResponseEntity authenticate(@RequestBody AuthRequest request) { AuthenticationResult result = authFacade.authenticate(request.login(), request.password()); return switch (result) { - case AuthenticationResult.Success success -> ResponseEntity.ok(new AuthResponseDto(success.token().value())); + case AuthenticationResult.Success success -> ResponseEntity.ok(new AuthResponse(success.token().value())); case AuthenticationResult.Failure ignored -> ResponseEntity.badRequest().build(); }; } diff --git a/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthResponseDto.java b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthResponse.java similarity index 51% rename from src/main/java/com/ecmsp/userservice/api/rest/auth/AuthResponseDto.java rename to src/main/java/com/ecmsp/userservice/api/rest/auth/AuthResponse.java index 1fef208..1f6f34e 100644 --- a/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthResponseDto.java +++ b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthResponse.java @@ -1,4 +1,4 @@ package com.ecmsp.userservice.api.rest.auth; -public record AuthResponseDto(String token) { +public record AuthResponse(String token) { } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7aad341..206ce4c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,7 +2,7 @@ spring: application: name: User service jpa: - hibernate.ddl-auto: none # TODO: set to none once flyway integration is finished + hibernate.ddl-auto: validate properties.hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect show-sql: true datasource: diff --git a/src/test/java/com/ecmsp/userservice/UserServiceApplicationTests.java b/src/test/java/com/ecmsp/userservice/UserServiceApplicationTests.java index 00f2140..6a5d977 100644 --- a/src/test/java/com/ecmsp/userservice/UserServiceApplicationTests.java +++ b/src/test/java/com/ecmsp/userservice/UserServiceApplicationTests.java @@ -2,10 +2,11 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +//@ActiveProfiles("test") //@SpringBootTest //class UserServiceApplicationTests { -// // @Test // void contextLoads() { // } diff --git a/src/test/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryTest.java b/src/test/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryTest.java index b6327c8..aef5fee 100644 --- a/src/test/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryTest.java +++ b/src/test/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryTest.java @@ -3,8 +3,10 @@ import com.ecmsp.userservice.user.domain.*; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.ActiveProfiles; import java.util.Optional; import java.util.UUID; @@ -13,6 +15,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; @DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) class DbUserRepositoryTest { private static final UserId USER_1_ID = new UserId(UUID.fromString("123e4567-e89b-12d3-a456-426614174000")); diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..15116a7 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,25 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH + driver-class-name: org.h2.Driver + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + show-sql: true + flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: true + +user: + repository: + type: db + +auth: + token-generator: + type: jwt + secret-key-file: classpath:local/secrets/local.private.key + From ca4954fbc1b4edc736224267670fda7e30156513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wojciech=20Or=C5=82owski?= Date: Thu, 2 Oct 2025 20:48:54 +0200 Subject: [PATCH 13/17] add roles and permissions --- .../userservice/api/rest/UserController.java | 46 +++++++++- .../api/rest/role/AddPermissionRequest.java | 6 ++ .../api/rest/role/AssignRoleRequest.java | 6 ++ .../api/rest/role/RoleController.java | 91 +++++++++++++++++++ .../api/rest/role/RoleCreateRequest.java | 8 ++ .../api/rest/role/RoleResponse.java | 9 ++ .../adapter/generator/JwtTokenGenerator.java | 10 ++ .../userservice/role/config/RoleConfig.java | 14 +++ .../repository/db/DbRoleRepository.java | 52 +++++++++++ .../repository/db/PermissionEntity.java | 26 ++++++ .../adapter/repository/db/RoleEntity.java | 57 ++++++++++++ .../repository/db/RoleEntityMapper.java | 37 ++++++++ .../repository/db/RoleEntityRepository.java | 10 ++ .../adapter/repository/db/UserEntity.java | 24 ++++- .../repository/db/UserEntityMapper.java | 22 ++++- .../userservice/user/config/UserConfig.java | 5 +- .../userservice/user/domain/Permission.java | 22 +++++ .../ecmsp/userservice/user/domain/Role.java | 41 +++++++++ .../userservice/user/domain/RoleFacade.java | 50 ++++++++++ .../ecmsp/userservice/user/domain/RoleId.java | 6 ++ .../user/domain/RoleRepository.java | 12 +++ .../userservice/user/domain/RoleToCreate.java | 6 ++ .../ecmsp/userservice/user/domain/User.java | 4 +- .../userservice/user/domain/UserFacade.java | 35 ++++++- .../V3__Create_roles_and_permissions.sql | 44 +++++++++ 25 files changed, 628 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/ecmsp/userservice/api/rest/role/AddPermissionRequest.java create mode 100644 src/main/java/com/ecmsp/userservice/api/rest/role/AssignRoleRequest.java create mode 100644 src/main/java/com/ecmsp/userservice/api/rest/role/RoleController.java create mode 100644 src/main/java/com/ecmsp/userservice/api/rest/role/RoleCreateRequest.java create mode 100644 src/main/java/com/ecmsp/userservice/api/rest/role/RoleResponse.java create mode 100644 src/main/java/com/ecmsp/userservice/role/config/RoleConfig.java create mode 100644 src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbRoleRepository.java create mode 100644 src/main/java/com/ecmsp/userservice/user/adapter/repository/db/PermissionEntity.java create mode 100644 src/main/java/com/ecmsp/userservice/user/adapter/repository/db/RoleEntity.java create mode 100644 src/main/java/com/ecmsp/userservice/user/adapter/repository/db/RoleEntityMapper.java create mode 100644 src/main/java/com/ecmsp/userservice/user/adapter/repository/db/RoleEntityRepository.java create mode 100644 src/main/java/com/ecmsp/userservice/user/domain/Permission.java create mode 100644 src/main/java/com/ecmsp/userservice/user/domain/Role.java create mode 100644 src/main/java/com/ecmsp/userservice/user/domain/RoleFacade.java create mode 100644 src/main/java/com/ecmsp/userservice/user/domain/RoleId.java create mode 100644 src/main/java/com/ecmsp/userservice/user/domain/RoleRepository.java create mode 100644 src/main/java/com/ecmsp/userservice/user/domain/RoleToCreate.java create mode 100644 src/main/resources/db/migration/V3__Create_roles_and_permissions.sql diff --git a/src/main/java/com/ecmsp/userservice/api/rest/UserController.java b/src/main/java/com/ecmsp/userservice/api/rest/UserController.java index b6219b9..10297d3 100644 --- a/src/main/java/com/ecmsp/userservice/api/rest/UserController.java +++ b/src/main/java/com/ecmsp/userservice/api/rest/UserController.java @@ -1,14 +1,21 @@ package com.ecmsp.userservice.api.rest; +import com.ecmsp.userservice.api.rest.role.AssignRoleRequest; +import com.ecmsp.userservice.api.rest.role.RoleResponse; +import com.ecmsp.userservice.user.domain.Role; +import com.ecmsp.userservice.user.domain.RoleId; import com.ecmsp.userservice.user.domain.UserFacade; +import com.ecmsp.userservice.user.domain.UserId; import com.ecmsp.userservice.user.domain.UserToCreate; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; @RestController @RequestMapping("/api/users") @@ -26,4 +33,35 @@ public ResponseEntity createUser(@RequestBody UserCreateRequest request) { userFacade.createUser(user); return ResponseEntity.status(HttpStatus.CREATED).build(); } + + @PostMapping("/{userId}/roles") + public ResponseEntity assignRoleToUser( + @PathVariable UUID userId, + @RequestBody AssignRoleRequest request) { + userFacade.assignRoleToUser(new UserId(userId), new RoleId(request.roleId())); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{userId}/roles/{roleId}") + public ResponseEntity removeRoleFromUser( + @PathVariable UUID userId, + @PathVariable UUID roleId) { + userFacade.removeRoleFromUser(new UserId(userId), new RoleId(roleId)); + return ResponseEntity.ok().build(); + } + + @GetMapping("/{userId}/roles") + public ResponseEntity> getUserRoles(@PathVariable UUID userId) { + Set roles = userFacade.getUserRoles(new UserId(userId)); + + List roleResponses = roles.stream() + .map(role -> new RoleResponse( + role.id().value(), + role.name(), + role.permissions() + )) + .collect(Collectors.toList()); + + return ResponseEntity.ok(roleResponses); + } } diff --git a/src/main/java/com/ecmsp/userservice/api/rest/role/AddPermissionRequest.java b/src/main/java/com/ecmsp/userservice/api/rest/role/AddPermissionRequest.java new file mode 100644 index 0000000..c5e86ba --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/api/rest/role/AddPermissionRequest.java @@ -0,0 +1,6 @@ +package com.ecmsp.userservice.api.rest.role; + +import com.ecmsp.userservice.user.domain.Permission; + +public record AddPermissionRequest(Permission permission) { +} diff --git a/src/main/java/com/ecmsp/userservice/api/rest/role/AssignRoleRequest.java b/src/main/java/com/ecmsp/userservice/api/rest/role/AssignRoleRequest.java new file mode 100644 index 0000000..066b5da --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/api/rest/role/AssignRoleRequest.java @@ -0,0 +1,6 @@ +package com.ecmsp.userservice.api.rest.role; + +import java.util.UUID; + +public record AssignRoleRequest(UUID roleId) { +} diff --git a/src/main/java/com/ecmsp/userservice/api/rest/role/RoleController.java b/src/main/java/com/ecmsp/userservice/api/rest/role/RoleController.java new file mode 100644 index 0000000..a179c24 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/api/rest/role/RoleController.java @@ -0,0 +1,91 @@ +package com.ecmsp.userservice.api.rest.role; + +import com.ecmsp.userservice.user.domain.Role; +import com.ecmsp.userservice.user.domain.RoleFacade; +import com.ecmsp.userservice.user.domain.RoleId; +import com.ecmsp.userservice.user.domain.RoleToCreate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/roles") +public class RoleController { + + private final RoleFacade roleFacade; + + public RoleController(RoleFacade roleFacade) { + this.roleFacade = roleFacade; + } + + @PostMapping + public ResponseEntity createRole(@RequestBody RoleCreateRequest request) { + RoleToCreate roleToCreate = new RoleToCreate(request.name(), request.permissions()); + RoleId roleId = roleFacade.createRole(roleToCreate); + + Role createdRole = roleFacade.findRoleById(roleId) + .orElseThrow(() -> new IllegalStateException("Role was not created")); + + RoleResponse response = new RoleResponse( + createdRole.id().value(), + createdRole.name(), + createdRole.permissions() + ); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping("/{roleId}") + public ResponseEntity getRoleById(@PathVariable UUID roleId) { + return roleFacade.findRoleById(new RoleId(roleId)) + .map(role -> new RoleResponse( + role.id().value(), + role.name(), + role.permissions() + )) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @GetMapping + public ResponseEntity> getAllRoles() { + List roles = roleFacade.getAllRoles().stream() + .map(role -> new RoleResponse( + role.id().value(), + role.name(), + role.permissions() + )) + .collect(Collectors.toList()); + + return ResponseEntity.ok(roles); + } + + @PostMapping("/{roleId}/permissions") + public ResponseEntity addPermissionToRole( + @PathVariable UUID roleId, + @RequestBody AddPermissionRequest request) { + roleFacade.addPermissionToRole(new RoleId(roleId), request.permission()); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{roleId}/permissions/{permission}") + public ResponseEntity removePermissionFromRole( + @PathVariable UUID roleId, + @PathVariable String permission) { + roleFacade.removePermissionFromRole( + new RoleId(roleId), + com.ecmsp.userservice.user.domain.Permission.valueOf(permission) + ); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{roleId}") + public ResponseEntity deleteRole(@PathVariable UUID roleId) { + roleFacade.deleteRole(new RoleId(roleId)); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/ecmsp/userservice/api/rest/role/RoleCreateRequest.java b/src/main/java/com/ecmsp/userservice/api/rest/role/RoleCreateRequest.java new file mode 100644 index 0000000..8f90b94 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/api/rest/role/RoleCreateRequest.java @@ -0,0 +1,8 @@ +package com.ecmsp.userservice.api.rest.role; + +import com.ecmsp.userservice.user.domain.Permission; + +import java.util.Set; + +public record RoleCreateRequest(String name, Set permissions) { +} diff --git a/src/main/java/com/ecmsp/userservice/api/rest/role/RoleResponse.java b/src/main/java/com/ecmsp/userservice/api/rest/role/RoleResponse.java new file mode 100644 index 0000000..4fda4de --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/api/rest/role/RoleResponse.java @@ -0,0 +1,9 @@ +package com.ecmsp.userservice.api.rest.role; + +import com.ecmsp.userservice.user.domain.Permission; + +import java.util.Set; +import java.util.UUID; + +public record RoleResponse(UUID id, String name, Set permissions) { +} diff --git a/src/main/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGenerator.java b/src/main/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGenerator.java index f416a9d..296c780 100644 --- a/src/main/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGenerator.java +++ b/src/main/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGenerator.java @@ -2,6 +2,7 @@ import com.ecmsp.userservice.auth.domain.Token; import com.ecmsp.userservice.auth.domain.TokenGenerator; +import com.ecmsp.userservice.user.domain.Permission; import com.ecmsp.userservice.user.domain.User; import io.jsonwebtoken.Jwts; @@ -10,6 +11,8 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Date; +import java.util.Set; +import java.util.stream.Collectors; class JwtTokenGenerator implements TokenGenerator { private final PrivateKey privateKey; @@ -26,9 +29,16 @@ public Token generate(User user) { Instant now = clock.instant(); Instant expiration = now.plus(1, ChronoUnit.HOURS); + // Collect all permissions from all user roles + Set permissions = user.roles().stream() + .flatMap(role -> role.permissions().stream()) + .map(Permission::name) + .collect(Collectors.toSet()); + String jwt = Jwts.builder() .subject(user.id().value().toString()) .claim("login", user.login()) + .claim("permissions", permissions) .issuedAt(Date.from(now)) .expiration(Date.from(expiration)) .signWith(privateKey) diff --git a/src/main/java/com/ecmsp/userservice/role/config/RoleConfig.java b/src/main/java/com/ecmsp/userservice/role/config/RoleConfig.java new file mode 100644 index 0000000..51d3538 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/role/config/RoleConfig.java @@ -0,0 +1,14 @@ +package com.ecmsp.userservice.role.config; + +import com.ecmsp.userservice.user.domain.RoleFacade; +import com.ecmsp.userservice.user.domain.RoleRepository; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +class RoleConfig { + @Bean + public RoleFacade roleFacade(RoleRepository roleRepository) { + return new RoleFacade(roleRepository); + } +} diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbRoleRepository.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbRoleRepository.java new file mode 100644 index 0000000..161852f --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbRoleRepository.java @@ -0,0 +1,52 @@ +package com.ecmsp.userservice.user.adapter.repository.db; + +import com.ecmsp.userservice.user.domain.Role; +import com.ecmsp.userservice.user.domain.RoleId; +import com.ecmsp.userservice.user.domain.RoleRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Repository +class DbRoleRepository implements RoleRepository { + + private final RoleEntityRepository roleEntityRepository; + private final RoleEntityMapper roleEntityMapper; + + public DbRoleRepository(RoleEntityRepository roleEntityRepository) { + this.roleEntityRepository = roleEntityRepository; + this.roleEntityMapper = new RoleEntityMapper(); + } + + @Override + public void save(Role role) { + RoleEntity entity = roleEntityMapper.toRoleEntity(role); + roleEntityRepository.save(entity); + } + + @Override + public Optional findById(RoleId roleId) { + return roleEntityRepository.findById(roleId.value()) + .map(roleEntityMapper::toRole); + } + + @Override + public Optional findByName(String name) { + return roleEntityRepository.findByRoleName(name) + .map(roleEntityMapper::toRole); + } + + @Override + public List findAll() { + return roleEntityRepository.findAll().stream() + .map(roleEntityMapper::toRole) + .collect(Collectors.toList()); + } + + @Override + public void delete(RoleId roleId) { + roleEntityRepository.deleteById(roleId.value()); + } +} diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/PermissionEntity.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/PermissionEntity.java new file mode 100644 index 0000000..43fd845 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/PermissionEntity.java @@ -0,0 +1,26 @@ +package com.ecmsp.userservice.user.adapter.repository.db; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.NoArgsConstructor; + +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Entity +@Table(name = "permissions") +class PermissionEntity { + + @Id + @Column(name = "permission_name") + private String permissionName; + + public String getPermissionName() { + return permissionName; + } + + public void setPermissionName(String permissionName) { + this.permissionName = permissionName; + } +} diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/RoleEntity.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/RoleEntity.java new file mode 100644 index 0000000..6cf28ae --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/RoleEntity.java @@ -0,0 +1,57 @@ +package com.ecmsp.userservice.user.adapter.repository.db; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.NoArgsConstructor; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Entity +@Table(name = "roles") +class RoleEntity { + + @Id + @Column(name = "role_id") + private UUID roleId; + + @Column(name = "role_name", nullable = false, unique = true) + private String roleName; + + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable( + name = "role_permissions", + joinColumns = @JoinColumn(name = "role_id"), + inverseJoinColumns = @JoinColumn(name = "permission_name") + ) + private Set permissions = new HashSet<>(); + + public UUID getRoleId() { + return roleId; + } + + public void setRoleId(UUID roleId) { + this.roleId = roleId; + } + + public String getRoleName() { + return roleName; + } + + public void setRoleName(String roleName) { + this.roleName = roleName; + } + + public Set getPermissions() { + return permissions; + } + + public void setPermissions(Set permissions) { + this.permissions = permissions; + } +} diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/RoleEntityMapper.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/RoleEntityMapper.java new file mode 100644 index 0000000..1338f8e --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/RoleEntityMapper.java @@ -0,0 +1,37 @@ +package com.ecmsp.userservice.user.adapter.repository.db; + +import com.ecmsp.userservice.user.domain.Permission; +import com.ecmsp.userservice.user.domain.Role; +import com.ecmsp.userservice.user.domain.RoleId; + +import java.util.Set; +import java.util.stream.Collectors; + +class RoleEntityMapper { + + public Role toRole(RoleEntity entity) { + Set permissions = entity.getPermissions().stream() + .map(permissionEntity -> Permission.valueOf(permissionEntity.getPermissionName())) + .collect(Collectors.toSet()); + + return new Role( + new RoleId(entity.getRoleId()), + entity.getRoleName(), + permissions + ); + } + + public RoleEntity toRoleEntity(Role role) { + Set permissionEntities = role.permissions().stream() + .map(permission -> PermissionEntity.builder() + .permissionName(permission.name()) + .build()) + .collect(Collectors.toSet()); + + return RoleEntity.builder() + .roleId(role.id().value()) + .roleName(role.name()) + .permissions(permissionEntities) + .build(); + } +} diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/RoleEntityRepository.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/RoleEntityRepository.java new file mode 100644 index 0000000..1e8d548 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/RoleEntityRepository.java @@ -0,0 +1,10 @@ +package com.ecmsp.userservice.user.adapter.repository.db; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +interface RoleEntityRepository extends JpaRepository { + Optional findByRoleName(String roleName); +} diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntity.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntity.java index 63b51bb..9ebed41 100644 --- a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntity.java +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntity.java @@ -1,13 +1,12 @@ package com.ecmsp.userservice.user.adapter.repository.db; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.NoArgsConstructor; +import java.util.HashSet; +import java.util.Set; import java.util.UUID; @Builder @@ -29,6 +28,14 @@ class UserEntity { @Column(name = "password") private String password; + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable( + name = "user_roles", + joinColumns = @JoinColumn(name = "user_id"), + inverseJoinColumns = @JoinColumn(name = "role_id") + ) + private Set roles = new HashSet<>(); + public UUID getUserId() { return userId; } @@ -53,12 +60,21 @@ public void setPassword(String password) { this.password = password; } + public Set getRoles() { + return roles; + } + + public void setRoles(Set roles) { + this.roles = roles; + } + @Override public String toString() { return "UserEntity{" + "userId=" + userId + ", login='" + login + '\'' + ", password='" + password + '\'' + + ", roles=" + roles + '}'; } } diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityMapper.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityMapper.java index be71e13..f16b750 100644 --- a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityMapper.java +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityMapper.java @@ -1,23 +1,43 @@ package com.ecmsp.userservice.user.adapter.repository.db; +import com.ecmsp.userservice.user.domain.Role; import com.ecmsp.userservice.user.domain.User; import com.ecmsp.userservice.user.domain.UserId; +import java.util.Set; +import java.util.stream.Collectors; + class UserEntityMapper { + private final RoleEntityMapper roleEntityMapper; + + public UserEntityMapper() { + this.roleEntityMapper = new RoleEntityMapper(); + } + public User toUser(UserEntity entity) { + Set roles = entity.getRoles().stream() + .map(roleEntityMapper::toRole) + .collect(Collectors.toSet()); + return new User( new UserId(entity.getUserId()), entity.getLogin(), - entity.getPassword() + entity.getPassword(), + roles ); } public UserEntity toUserEntity(User user) { + Set roleEntities = user.roles().stream() + .map(roleEntityMapper::toRoleEntity) + .collect(Collectors.toSet()); + return UserEntity.builder() .userId(user.id().value()) .login(user.login()) .password(user.passwordHash()) + .roles(roleEntities) .build(); } } diff --git a/src/main/java/com/ecmsp/userservice/user/config/UserConfig.java b/src/main/java/com/ecmsp/userservice/user/config/UserConfig.java index 4e0d976..0090dd9 100644 --- a/src/main/java/com/ecmsp/userservice/user/config/UserConfig.java +++ b/src/main/java/com/ecmsp/userservice/user/config/UserConfig.java @@ -1,5 +1,6 @@ package com.ecmsp.userservice.user.config; +import com.ecmsp.userservice.user.domain.RoleRepository; import com.ecmsp.userservice.user.domain.UserFacade; import com.ecmsp.userservice.user.domain.UserRepository; import org.springframework.context.annotation.Bean; @@ -8,7 +9,7 @@ @Configuration class UserConfig { @Bean - public UserFacade userFacade(UserRepository userRepository){ - return new UserFacade(userRepository); + public UserFacade userFacade(UserRepository userRepository, RoleRepository roleRepository){ + return new UserFacade(userRepository, roleRepository); } } diff --git a/src/main/java/com/ecmsp/userservice/user/domain/Permission.java b/src/main/java/com/ecmsp/userservice/user/domain/Permission.java new file mode 100644 index 0000000..3ca403e --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/domain/Permission.java @@ -0,0 +1,22 @@ +package com.ecmsp.userservice.user.domain; + +public enum Permission { + // Product permissions + WRITE_PRODUCTS, + DELETE_PRODUCTS, + + // Order permissions + READ_ORDERS, + WRITE_ORDERS, + CANCEL_ORDERS, + + // User management permissions + MANAGE_USERS, + + // Role management permissions + MANAGE_ROLES, + + // Payment permissions + PROCESS_PAYMENTS, + REFUND_PAYMENTS +} diff --git a/src/main/java/com/ecmsp/userservice/user/domain/Role.java b/src/main/java/com/ecmsp/userservice/user/domain/Role.java new file mode 100644 index 0000000..9fbd364 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/domain/Role.java @@ -0,0 +1,41 @@ +package com.ecmsp.userservice.user.domain; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +public class Role { + private final RoleId id; + private final String name; + private final Set permissions; + + public Role(RoleId id, String name, Set permissions) { + this.id = id; + this.name = name; + this.permissions = new HashSet<>(permissions); + } + + public RoleId id() { + return id; + } + + public String name() { + return name; + } + + public Set permissions() { + return Collections.unmodifiableSet(permissions); + } + + public void addPermission(Permission permission) { + permissions.add(permission); + } + + public void removePermission(Permission permission) { + permissions.remove(permission); + } + + public boolean hasPermission(Permission permission) { + return permissions.contains(permission); + } +} diff --git a/src/main/java/com/ecmsp/userservice/user/domain/RoleFacade.java b/src/main/java/com/ecmsp/userservice/user/domain/RoleFacade.java new file mode 100644 index 0000000..3562ae9 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/domain/RoleFacade.java @@ -0,0 +1,50 @@ +package com.ecmsp.userservice.user.domain; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public class RoleFacade { + private final RoleRepository roleRepository; + + public RoleFacade(RoleRepository roleRepository) { + this.roleRepository = roleRepository; + } + + public RoleId createRole(RoleToCreate roleToCreate) { + RoleId roleId = new RoleId(UUID.randomUUID()); + Role role = new Role(roleId, roleToCreate.name(), roleToCreate.permissions()); + roleRepository.save(role); + return roleId; + } + + public void addPermissionToRole(RoleId roleId, Permission permission) { + Role role = roleRepository.findById(roleId) + .orElseThrow(() -> new IllegalArgumentException("Role not found: " + roleId)); + role.addPermission(permission); + roleRepository.save(role); + } + + public void removePermissionFromRole(RoleId roleId, Permission permission) { + Role role = roleRepository.findById(roleId) + .orElseThrow(() -> new IllegalArgumentException("Role not found: " + roleId)); + role.removePermission(permission); + roleRepository.save(role); + } + + public Optional findRoleById(RoleId roleId) { + return roleRepository.findById(roleId); + } + + public Optional findRoleByName(String name) { + return roleRepository.findByName(name); + } + + public List getAllRoles() { + return roleRepository.findAll(); + } + + public void deleteRole(RoleId roleId) { + roleRepository.delete(roleId); + } +} diff --git a/src/main/java/com/ecmsp/userservice/user/domain/RoleId.java b/src/main/java/com/ecmsp/userservice/user/domain/RoleId.java new file mode 100644 index 0000000..8ca483a --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/domain/RoleId.java @@ -0,0 +1,6 @@ +package com.ecmsp.userservice.user.domain; + +import java.util.UUID; + +public record RoleId(UUID value) { +} diff --git a/src/main/java/com/ecmsp/userservice/user/domain/RoleRepository.java b/src/main/java/com/ecmsp/userservice/user/domain/RoleRepository.java new file mode 100644 index 0000000..7ac24eb --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/domain/RoleRepository.java @@ -0,0 +1,12 @@ +package com.ecmsp.userservice.user.domain; + +import java.util.List; +import java.util.Optional; + +public interface RoleRepository { + void save(Role role); + Optional findById(RoleId roleId); + Optional findByName(String name); + List findAll(); + void delete(RoleId roleId); +} diff --git a/src/main/java/com/ecmsp/userservice/user/domain/RoleToCreate.java b/src/main/java/com/ecmsp/userservice/user/domain/RoleToCreate.java new file mode 100644 index 0000000..af8ec84 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/domain/RoleToCreate.java @@ -0,0 +1,6 @@ +package com.ecmsp.userservice.user.domain; + +import java.util.Set; + +public record RoleToCreate(String name, Set permissions) { +} diff --git a/src/main/java/com/ecmsp/userservice/user/domain/User.java b/src/main/java/com/ecmsp/userservice/user/domain/User.java index 1843e72..97c3eb4 100644 --- a/src/main/java/com/ecmsp/userservice/user/domain/User.java +++ b/src/main/java/com/ecmsp/userservice/user/domain/User.java @@ -1,5 +1,7 @@ package com.ecmsp.userservice.user.domain; -public record User(UserId id, String login, String passwordHash) { +import java.util.Set; + +public record User(UserId id, String login, String passwordHash, Set roles) { } diff --git a/src/main/java/com/ecmsp/userservice/user/domain/UserFacade.java b/src/main/java/com/ecmsp/userservice/user/domain/UserFacade.java index d588f46..8deaa9f 100644 --- a/src/main/java/com/ecmsp/userservice/user/domain/UserFacade.java +++ b/src/main/java/com/ecmsp/userservice/user/domain/UserFacade.java @@ -1,20 +1,24 @@ package com.ecmsp.userservice.user.domain; +import java.util.HashSet; import java.util.Optional; +import java.util.Set; import java.util.UUID; public class UserFacade { private final UserRepository userRepository; + private final RoleRepository roleRepository; private final PasswordHasher passwordHasher = new PasswordHasher(); - public UserFacade(UserRepository userRepository) { + public UserFacade(UserRepository userRepository, RoleRepository roleRepository) { this.userRepository = userRepository; + this.roleRepository = roleRepository; } public void createUser(UserToCreate userToCreate){ UserId userId = new UserId(UUID.randomUUID()); String hashedPassword = passwordHasher.hash(userToCreate.password()); - User user = new User(userId, userToCreate.login(), hashedPassword); + User user = new User(userId, userToCreate.login(), hashedPassword, new HashSet<>()); userRepository.save(user); } @@ -22,10 +26,35 @@ public Optional findUserById(UserId userId){ return userRepository.findById(userId); } - public Optional findUserByLogin(String login) { return userRepository.findByLogin(login); } + public void assignRoleToUser(UserId userId, RoleId roleId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId)); + Role role = roleRepository.findById(roleId) + .orElseThrow(() -> new IllegalArgumentException("Role not found: " + roleId)); + + Set updatedRoles = new HashSet<>(user.roles()); + updatedRoles.add(role); + User updatedUser = new User(user.id(), user.login(), user.passwordHash(), updatedRoles); + userRepository.save(updatedUser); + } + + public void removeRoleFromUser(UserId userId, RoleId roleId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId)); + Set updatedRoles = new HashSet<>(user.roles()); + updatedRoles.removeIf(role -> role.id().equals(roleId)); + User updatedUser = new User(user.id(), user.login(), user.passwordHash(), updatedRoles); + userRepository.save(updatedUser); + } + + public Set getUserRoles(UserId userId) { + return userRepository.findById(userId) + .map(User::roles) + .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId)); + } } diff --git a/src/main/resources/db/migration/V3__Create_roles_and_permissions.sql b/src/main/resources/db/migration/V3__Create_roles_and_permissions.sql new file mode 100644 index 0000000..2a463b9 --- /dev/null +++ b/src/main/resources/db/migration/V3__Create_roles_and_permissions.sql @@ -0,0 +1,44 @@ +-- Create permissions table +CREATE TABLE permissions ( + permission_name VARCHAR(100) PRIMARY KEY +); + +-- Create roles table +CREATE TABLE roles ( + role_id UUID PRIMARY KEY, + role_name VARCHAR(100) NOT NULL UNIQUE +); + +-- Create role_permissions join table +CREATE TABLE role_permissions ( + role_id UUID REFERENCES roles(role_id) ON DELETE CASCADE, + permission_name VARCHAR(100) REFERENCES permissions(permission_name) ON DELETE CASCADE, + PRIMARY KEY (role_id, permission_name) +); + +-- Create user_roles join table +CREATE TABLE user_roles ( + user_id UUID REFERENCES users(user_id) ON DELETE CASCADE, + role_id UUID REFERENCES roles(role_id) ON DELETE CASCADE, + PRIMARY KEY (user_id, role_id) +); + +-- Insert predefined permissions +INSERT INTO permissions (permission_name) VALUES + ('READ_PRODUCTS'), + ('WRITE_PRODUCTS'), + ('DELETE_PRODUCTS'), + ('READ_ORDERS'), + ('WRITE_ORDERS'), + ('CANCEL_ORDERS'), + ('READ_CARTS'), + ('WRITE_CARTS'), + ('MANAGE_USERS'), + ('MANAGE_ROLES'), + ('PROCESS_PAYMENTS'), + ('REFUND_PAYMENTS'); + +-- Create indexes for performance +CREATE INDEX idx_role_permissions_role_id ON role_permissions(role_id); +CREATE INDEX idx_user_roles_user_id ON user_roles(user_id); +CREATE INDEX idx_user_roles_role_id ON user_roles(role_id); From f9def4fcba4b0c82722623e5ad527bade7f48b2f Mon Sep 17 00:00:00 2001 From: Ariel Date: Sun, 5 Oct 2025 10:05:30 +0200 Subject: [PATCH 14/17] fix tests --- .../inmemory/InMemoryUserRepository.java | 3 ++- .../generator/JwtTokenGeneratorTest.java | 23 ++++++++++--------- .../repository/db/DbUserRepositoryTest.java | 7 ++++-- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepository.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepository.java index 2ad6875..cb42193 100644 --- a/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepository.java +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepository.java @@ -7,12 +7,13 @@ import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; class InMemoryUserRepository implements UserRepository { private final Map users = Map.of( new UserId(UUID.fromString("3d1af02a-bba2-4df1-b551-e944bb60bc94")), - new User(new UserId(UUID.fromString("3d1af02a-bba2-4df1-b551-e944bb60bc94")), "testuser", BCrypt.hashpw("testpassword", BCrypt.gensalt())) + new User(new UserId(UUID.fromString("3d1af02a-bba2-4df1-b551-e944bb60bc94")), "testuser", BCrypt.hashpw("testpassword", BCrypt.gensalt()), Set.of()) ); @Override diff --git a/src/test/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGeneratorTest.java b/src/test/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGeneratorTest.java index 5060bb5..a3cc0da 100644 --- a/src/test/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGeneratorTest.java +++ b/src/test/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGeneratorTest.java @@ -15,6 +15,7 @@ import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; +import java.util.Set; import java.util.UUID; import static org.junit.jupiter.api.Assertions.*; @@ -43,7 +44,7 @@ void setUp() throws Exception { void shouldGenerateValidJwtToken() { UUID userId = UUID.fromString("123e4567-e89b-12d3-a456-426614174001"); String login = "andy"; - User user = new User(new UserId(userId), login, "hashedPassword"); + User user = new User(new UserId(userId), login, "hashedPassword", Set.of()); Token token = jwtTokenGenerator.generate(user); @@ -56,7 +57,7 @@ void shouldGenerateValidJwtToken() { void shouldIncludeCorrectSubjectInToken() { UUID userId = UUID.randomUUID(); String login = "testuser"; - User user = new User(new UserId(userId), login, "hashedPassword"); + User user = new User(new UserId(userId), login, "hashedPassword", Set.of()); Token token = jwtTokenGenerator.generate(user); @@ -68,7 +69,7 @@ void shouldIncludeCorrectSubjectInToken() { void shouldIncludeLoginClaimInToken() { UUID userId = UUID.randomUUID(); String login = "testuser"; - User user = new User(new UserId(userId), login, "hashedPassword"); + User user = new User(new UserId(userId), login, "hashedPassword", Set.of()); Token token = jwtTokenGenerator.generate(user); @@ -79,7 +80,7 @@ void shouldIncludeLoginClaimInToken() { @Test void shouldSetCorrectIssuedAtTime() { UUID userId = UUID.randomUUID(); - User user = new User(new UserId(userId), "testuser", "hashedPassword"); + User user = new User(new UserId(userId), "testuser", "hashedPassword", Set.of()); Token token = jwtTokenGenerator.generate(user); @@ -92,7 +93,7 @@ void shouldSetCorrectIssuedAtTime() { @Test void shouldSetExpirationTimeOneHourFromIssuedAt() { UUID userId = UUID.randomUUID(); - User user = new User(new UserId(userId), "testuser", "hashedPassword"); + User user = new User(new UserId(userId), "testuser", "hashedPassword", Set.of()); Token token = jwtTokenGenerator.generate(user); @@ -105,7 +106,7 @@ void shouldSetExpirationTimeOneHourFromIssuedAt() { @Test void shouldSignTokenWithProvidedPrivateKey() { UUID userId = UUID.randomUUID(); - User user = new User(new UserId(userId), "testuser", "hashedPassword"); + User user = new User(new UserId(userId), "testuser", "hashedPassword", Set.of()); Token token = jwtTokenGenerator.generate(user); @@ -120,7 +121,7 @@ void shouldSignTokenWithProvidedPrivateKey() { @Test void shouldThrowExceptionWhenVerifyingWithWrongKey() throws Exception { UUID userId = UUID.randomUUID(); - User user = new User(new UserId(userId), "testuser", "hashedPassword"); + User user = new User(new UserId(userId), "testuser", "hashedPassword", Set.of()); Token token = jwtTokenGenerator.generate(user); @@ -137,8 +138,8 @@ void shouldThrowExceptionWhenVerifyingWithWrongKey() throws Exception { @Test void shouldGenerateDifferentTokensForDifferentUsers() { - User user1 = new User(new UserId(UUID.randomUUID()), "user1", "password1"); - User user2 = new User(new UserId(UUID.randomUUID()), "user2", "password2"); + User user1 = new User(new UserId(UUID.randomUUID()), "user1", "password1", Set.of()); + User user2 = new User(new UserId(UUID.randomUUID()), "user2", "password2", Set.of()); Token token1 = jwtTokenGenerator.generate(user1); Token token2 = jwtTokenGenerator.generate(user2); @@ -149,7 +150,7 @@ void shouldGenerateDifferentTokensForDifferentUsers() { @Test void shouldGenerateDifferentTokensAtDifferentTimes() { UUID userId = UUID.randomUUID(); - User user = new User(new UserId(userId), "testuser", "hashedPassword"); + User user = new User(new UserId(userId), "testuser", "hashedPassword", Set.of()); Token token1 = jwtTokenGenerator.generate(user); @@ -163,7 +164,7 @@ void shouldGenerateDifferentTokensAtDifferentTimes() { @Test void shouldGenerateCompactJwtFormat() { UUID userId = UUID.randomUUID(); - User user = new User(new UserId(userId), "testuser", "hashedPassword"); + User user = new User(new UserId(userId), "testuser", "hashedPassword", Set.of()); Token token = jwtTokenGenerator.generate(user); diff --git a/src/test/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryTest.java b/src/test/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryTest.java index b6327c8..3febdca 100644 --- a/src/test/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryTest.java +++ b/src/test/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryTest.java @@ -7,6 +7,7 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; import java.util.Optional; +import java.util.Set; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -21,13 +22,15 @@ class DbUserRepositoryTest { private static final User USER_1 = new User( /* id = */ USER_1_ID, /* login = */ "user1", - /* passwordHash = */ "hashedPassword1" + /* passwordHash = */ "hashedPassword1", + /* roles = */ Set.of() ); private static final User USER_2 = new User( /* id = */ USER_2_ID, /* login = */ "user2", - /* passwordHash = */ "hashedPassword2" + /* passwordHash = */ "hashedPassword2", + Set.of() ); @Autowired From 363f6e0fd7e49d34e0ef06d3b60c23b3d0002b2e Mon Sep 17 00:00:00 2001 From: Ariel Date: Thu, 16 Oct 2025 21:54:10 +0200 Subject: [PATCH 15/17] add example data --- ...ert_roles_and_permissions_example_data.sql | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/main/resources/db/migration/V4__Insert_roles_and_permissions_example_data.sql diff --git a/src/main/resources/db/migration/V4__Insert_roles_and_permissions_example_data.sql b/src/main/resources/db/migration/V4__Insert_roles_and_permissions_example_data.sql new file mode 100644 index 0000000..bd07d79 --- /dev/null +++ b/src/main/resources/db/migration/V4__Insert_roles_and_permissions_example_data.sql @@ -0,0 +1,67 @@ +-- Insert example roles + +INSERT INTO roles (role_id, role_name) VALUES + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'ADMIN'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', 'USER'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'MANAGER'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14', 'CUSTOMER_SUPPORT'); + +-- Assign permissions to ADMIN role (full access) +INSERT INTO role_permissions (role_id, permission_name) VALUES + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'READ_PRODUCTS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'WRITE_PRODUCTS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'DELETE_PRODUCTS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'READ_ORDERS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'WRITE_ORDERS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'CANCEL_ORDERS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'READ_CARTS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'WRITE_CARTS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'MANAGE_USERS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'MANAGE_ROLES'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'PROCESS_PAYMENTS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'REFUND_PAYMENTS'); + +-- Assign permissions to USER role (basic access) +INSERT INTO role_permissions (role_id, permission_name) VALUES + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', 'READ_PRODUCTS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', 'READ_ORDERS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', 'WRITE_ORDERS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', 'READ_CARTS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', 'WRITE_CARTS'); + +-- Assign permissions to MANAGER role (moderate access) +INSERT INTO role_permissions (role_id, permission_name) VALUES + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'READ_PRODUCTS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'WRITE_PRODUCTS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'READ_ORDERS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'WRITE_ORDERS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'CANCEL_ORDERS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'READ_CARTS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'PROCESS_PAYMENTS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'REFUND_PAYMENTS'); + +-- Assign permissions to CUSTOMER_SUPPORT role (support access) +INSERT INTO role_permissions (role_id, permission_name) VALUES + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14', 'READ_PRODUCTS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14', 'READ_ORDERS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14', 'CANCEL_ORDERS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14', 'READ_CARTS'), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14', 'REFUND_PAYMENTS'); + +-- Assign roles to existing users +-- admin_user gets ADMIN role +INSERT INTO user_roles (user_id, role_id) VALUES + ('550e8400-e29b-41d4-a716-446655440002', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'); + +-- john_doe gets USER role +INSERT INTO user_roles (user_id, role_id) VALUES + ('550e8400-e29b-41d4-a716-446655440000', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12'); + +-- jane_smith gets MANAGER role +INSERT INTO user_roles (user_id, role_id) VALUES + ('550e8400-e29b-41d4-a716-446655440001', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a13'); + +-- andy gets USER and CUSTOMER_SUPPORT roles (example of multiple roles) +INSERT INTO user_roles (user_id, role_id) VALUES + ('123e4567-e89b-12d3-a456-426614174001', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12'), + ('123e4567-e89b-12d3-a456-426614174001', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14'); \ No newline at end of file From 2d9107a4e6ab658c40b1931e518eeb063dd5c500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wojciech=20Or=C5=82owski?= Date: Wed, 22 Oct 2025 19:55:29 +0200 Subject: [PATCH 16/17] add readme.md for macos users --- readme.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 143dc55..9d3c4ec 100644 --- a/readme.md +++ b/readme.md @@ -8,7 +8,11 @@ openssl genrsa > local.private.key openssl rsa -in local.private.key -pubout > local.public.key ``` - +**For macOS (maybe windows) users run 1 more command:** + ```bash + openssl pkcs8 -topk8 -inform PEM -outform PEM -in local.private.key -out local.private.pkcs8.key -nocrypt + ``` +then change `secret-key-file` in application.yml to ```secret-key-file: classpath:local/secrets/local.private.pkcs8.key``` ## User Controller API ### Create User From 069aa85094605fab33f8fe4e0c2fba572195b3e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wojciech=20Or=C5=82owski?= Date: Sat, 8 Nov 2025 13:39:36 +0100 Subject: [PATCH 17/17] add roles and permissions management to user service --- mvnw | 0 pom.xml | 17 +- .../userservice/api/grpc/UserGrpcMapper.java | 62 +++++ .../userservice/api/grpc/UserGrpcService.java | 256 ++++++++++++++++++ .../api/grpc/context/UserContextData.java | 5 + .../grpc/context/UserContextGrpcHolder.java | 12 + .../context/UserContextGrpcInterceptor.java | 25 ++ .../repository/db/DbUserRepository.java | 29 ++ .../repository/db/UserEntityMapper.java | 13 + .../repository/db/UserEntityRepository.java | 9 + .../inmemory/InMemoryUserRepository.java | 46 +++- .../userservice/user/domain/UserFacade.java | 23 +- .../user/domain/UserRepository.java | 5 + .../userservice/user/domain/UserView.java | 6 + src/main/resources/application.yml | 11 +- .../V3__Create_roles_and_permissions.sql | 3 - ...ert_roles_and_permissions_example_data.sql | 67 ----- .../repository/db/DbUserRepositoryTest.java | 5 + 18 files changed, 510 insertions(+), 84 deletions(-) mode change 100644 => 100755 mvnw create mode 100644 src/main/java/com/ecmsp/userservice/api/grpc/UserGrpcMapper.java create mode 100644 src/main/java/com/ecmsp/userservice/api/grpc/UserGrpcService.java create mode 100644 src/main/java/com/ecmsp/userservice/api/grpc/context/UserContextData.java create mode 100644 src/main/java/com/ecmsp/userservice/api/grpc/context/UserContextGrpcHolder.java create mode 100644 src/main/java/com/ecmsp/userservice/api/grpc/context/UserContextGrpcInterceptor.java create mode 100644 src/main/java/com/ecmsp/userservice/user/domain/UserView.java delete mode 100644 src/main/resources/db/migration/V4__Insert_roles_and_permissions_example_data.sql diff --git a/mvnw b/mvnw old mode 100644 new mode 100755 diff --git a/pom.xml b/pom.xml index e1e540a..40c7dcf 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,7 @@ 21 2025.0.0 + 1.63.0 @@ -44,7 +45,11 @@ h2 test - + + net.devh + grpc-spring-boot-starter + 3.1.0.RELEASE + org.mindrot jbcrypt @@ -148,9 +153,8 @@ com.ecmsp protos - 1.0.0-SNAPSHOT + 1.0.0-20251105.185705-41 - @@ -162,6 +166,13 @@ pom import + + io.grpc + grpc-bom + ${grpc.version} + pom + import + diff --git a/src/main/java/com/ecmsp/userservice/api/grpc/UserGrpcMapper.java b/src/main/java/com/ecmsp/userservice/api/grpc/UserGrpcMapper.java new file mode 100644 index 0000000..202d055 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/api/grpc/UserGrpcMapper.java @@ -0,0 +1,62 @@ +package com.ecmsp.userservice.api.grpc; + +import com.ecmsp.user.v1.RoleId; +import com.ecmsp.user.v1.UserId; +import com.ecmsp.userservice.user.domain.Permission; +import com.ecmsp.userservice.user.domain.Role; +import com.ecmsp.userservice.user.domain.UserView; +import org.springframework.stereotype.Component; + +import java.util.UUID; +import java.util.stream.Collectors; + +@Component +public class UserGrpcMapper { + + public com.ecmsp.user.v1.User toProtoUser(UserView userView) { + return com.ecmsp.user.v1.User.newBuilder() + .setId(toProtoUserId(userView.id())) + .setLogin(userView.login()) + .addAllRoles(userView.roles().stream() + .map(this::toProtoRole) + .collect(Collectors.toList())) + .build(); + } + + public UserId toProtoUserId(com.ecmsp.userservice.user.domain.UserId domainUserId) { + return UserId.newBuilder() + .setValue(domainUserId.value().toString()) + .build(); + } + + public com.ecmsp.user.v1.Role toProtoRole(Role domainRole) { + return com.ecmsp.user.v1.Role.newBuilder() + .setId(toProtoRoleId(domainRole.id())) + .setName(domainRole.name()) + .addAllPermissions(domainRole.permissions().stream() + .map(this::toProtoPermission) + .collect(Collectors.toList())) + .build(); + } + + public RoleId toProtoRoleId(com.ecmsp.userservice.user.domain.RoleId domainRoleId) { + return RoleId.newBuilder() + .setValue(domainRoleId.value().toString()) + .build(); + } + + public String toProtoPermission(Permission permission) { + return permission.name(); + } + + public com.ecmsp.userservice.user.domain.UserId toDomainUserId(UserId protoUserId) { + return new com.ecmsp.userservice.user.domain.UserId(UUID.fromString(protoUserId.getValue())); + } + + public com.ecmsp.userservice.user.domain.UserToCreate toDomainUserToCreate(com.ecmsp.user.v1.UserToCreate protoUserToCreate) { + return new com.ecmsp.userservice.user.domain.UserToCreate( + protoUserToCreate.getLogin(), + protoUserToCreate.getPassword() + ); + } +} diff --git a/src/main/java/com/ecmsp/userservice/api/grpc/UserGrpcService.java b/src/main/java/com/ecmsp/userservice/api/grpc/UserGrpcService.java new file mode 100644 index 0000000..a0dc821 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/api/grpc/UserGrpcService.java @@ -0,0 +1,256 @@ +package com.ecmsp.userservice.api.grpc; + +import com.ecmsp.user.v1.*; +import com.ecmsp.userservice.user.domain.User; +import com.ecmsp.userservice.user.domain.UserFacade; +import com.ecmsp.userservice.user.domain.UserId; +import com.ecmsp.userservice.user.domain.UserView; +import io.grpc.Status; +import io.grpc.stub.StreamObserver; +import net.devh.boot.grpc.server.service.GrpcService; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@GrpcService +public class UserGrpcService extends UserServiceGrpc.UserServiceImplBase { + private final UserFacade userFacade; + private final UserGrpcMapper mapper; + + public UserGrpcService(UserFacade userFacade, UserGrpcMapper mapper) { + this.userFacade = userFacade; + this.mapper = mapper; + } + + @Override + public void getUser(GetUserRequest request, StreamObserver responseObserver) { + try { + com.ecmsp.user.v1.UserId protoUserId = com.ecmsp.user.v1.UserId.newBuilder() + .setValue(request.getUserId()) + .build(); + UserId userId = mapper.toDomainUserId(protoUserId); + Optional userOptional = userFacade.findUserById(userId); + + if (userOptional.isPresent()) { + User user = userOptional.get(); + UserView userView = new UserView(user.id(), user.login(), user.roles()); + com.ecmsp.user.v1.User protoUser = mapper.toProtoUser(userView); + + GetUserResponse response = GetUserResponse.newBuilder() + .setUser(protoUser) + .build(); + + responseObserver.onNext(response); + responseObserver.onCompleted(); + } else { + responseObserver.onError(Status.NOT_FOUND + .withDescription("User not found with id: " + request.getUserId()) + .asRuntimeException()); + } + } catch (IllegalArgumentException e) { + responseObserver.onError(Status.INVALID_ARGUMENT + .withDescription(e.getMessage()) + .asRuntimeException()); + } catch (Exception e) { + responseObserver.onError(Status.INTERNAL + .withDescription("Internal error: " + e.getMessage()) + .asRuntimeException()); + } + } + + @Override + public void createUser(CreateUserRequest request, StreamObserver responseObserver) { + try { + com.ecmsp.user.v1.UserToCreate protoUserToCreate = request.getUserToCreate(); + + // Validate input + if (protoUserToCreate.getLogin() == null || protoUserToCreate.getLogin().isBlank()) { + responseObserver.onError(Status.INVALID_ARGUMENT + .withDescription("Login cannot be empty") + .asRuntimeException()); + return; + } + + if (protoUserToCreate.getPassword() == null || protoUserToCreate.getPassword().isBlank()) { + responseObserver.onError(Status.INVALID_ARGUMENT + .withDescription("Password cannot be empty") + .asRuntimeException()); + return; + } + + com.ecmsp.userservice.user.domain.UserToCreate domainUserToCreate = mapper.toDomainUserToCreate(protoUserToCreate); + User createdUser = userFacade.createUser(domainUserToCreate); + + UserView userView = new UserView(createdUser.id(), createdUser.login(), createdUser.roles()); + com.ecmsp.user.v1.User protoUser = mapper.toProtoUser(userView); + + CreateUserResponse response = CreateUserResponse.newBuilder() + .setUser(protoUser) + .build(); + + responseObserver.onNext(response); + responseObserver.onCompleted(); + } catch (RuntimeException e) { + if (e.getMessage() != null && e.getMessage().contains("already exists")) { + responseObserver.onError(Status.ALREADY_EXISTS + .withDescription("User with this login already exists") + .asRuntimeException()); + } else { + responseObserver.onError(Status.INTERNAL + .withDescription("Internal error: " + e.getMessage()) + .asRuntimeException()); + } + } catch (Exception e) { + responseObserver.onError(Status.INTERNAL + .withDescription("Internal error: " + e.getMessage()) + .asRuntimeException()); + } + } + + @Override + public void updateUser(UpdateUserRequest request, StreamObserver responseObserver) { + try { + UserId userId = mapper.toDomainUserId(request.getUser().getId()); + String newLogin = request.getUser().getLogin(); + + if (newLogin == null || newLogin.isBlank()) { + responseObserver.onError(Status.INVALID_ARGUMENT + .withDescription("Login cannot be empty") + .asRuntimeException()); + return; + } + + userFacade.updateUserLogin(userId, newLogin); + + // Fetch updated user + Optional updatedUserOptional = userFacade.findUserById(userId); + if (updatedUserOptional.isPresent()) { + User updatedUser = updatedUserOptional.get(); + UserView userView = new UserView(updatedUser.id(), updatedUser.login(), updatedUser.roles()); + com.ecmsp.user.v1.User protoUser = mapper.toProtoUser(userView); + + UpdateUserResponse response = UpdateUserResponse.newBuilder() + .setUser(protoUser) + .build(); + + responseObserver.onNext(response); + responseObserver.onCompleted(); + } else { + responseObserver.onError(Status.NOT_FOUND + .withDescription("User not found after update") + .asRuntimeException()); + } + } catch (IllegalArgumentException e) { + responseObserver.onError(Status.NOT_FOUND + .withDescription(e.getMessage()) + .asRuntimeException()); + } catch (Exception e) { + responseObserver.onError(Status.INTERNAL + .withDescription("Internal error: " + e.getMessage()) + .asRuntimeException()); + } + } + + @Override + public void deleteUser(DeleteUserRequest request, StreamObserver responseObserver) { + try { + com.ecmsp.user.v1.UserId protoUserId = com.ecmsp.user.v1.UserId.newBuilder() + .setValue(request.getUserId()) + .build(); + UserId userId = mapper.toDomainUserId(protoUserId); + userFacade.deleteUser(userId); + + DeleteUserResponse response = DeleteUserResponse.newBuilder().build(); + responseObserver.onNext(response); + responseObserver.onCompleted(); + } catch (IllegalArgumentException e) { + responseObserver.onError(Status.NOT_FOUND + .withDescription(e.getMessage()) + .asRuntimeException()); + } catch (Exception e) { + responseObserver.onError(Status.INTERNAL + .withDescription("Internal error: " + e.getMessage()) + .asRuntimeException()); + } + } + + @Override + public void listUsers(ListUsersRequest request, StreamObserver responseObserver) { + try { + String filterLogin = request.getFilterLogin(); + List users = userFacade.listUsers(filterLogin); + + List protoUsers = users.stream() + .map(mapper::toProtoUser) + .collect(Collectors.toList()); + + ListUsersResponse response = ListUsersResponse.newBuilder() + .addAllUsers(protoUsers) + .build(); + + responseObserver.onNext(response); + responseObserver.onCompleted(); + } catch (Exception e) { + responseObserver.onError(Status.INTERNAL + .withDescription("Internal error: " + e.getMessage()) + .asRuntimeException()); + } + } + + @Override + public void createRole(CreateRoleRequest request, StreamObserver responseObserver) { + // TODO: Implement role management later + responseObserver.onError(Status.UNIMPLEMENTED + .withDescription("Role management not yet implemented") + .asRuntimeException()); + } + + @Override + public void updateRole(UpdateRoleRequest request, StreamObserver responseObserver) { + // TODO: Implement role management later + responseObserver.onError(Status.UNIMPLEMENTED + .withDescription("Role management not yet implemented") + .asRuntimeException()); + } + + @Override + public void deleteRole(DeleteRoleRequest request, StreamObserver responseObserver) { + // TODO: Implement role management later + responseObserver.onError(Status.UNIMPLEMENTED + .withDescription("Role management not yet implemented") + .asRuntimeException()); + } + + @Override + public void listRoles(ListRolesRequest request, StreamObserver responseObserver) { + // TODO: Implement role management later + responseObserver.onError(Status.UNIMPLEMENTED + .withDescription("Role management not yet implemented") + .asRuntimeException()); + } + + @Override + public void assignRoleToUsers(AssignRoleToUsersRequest request, StreamObserver responseObserver) { + // TODO: Implement role management later + responseObserver.onError(Status.UNIMPLEMENTED + .withDescription("Role management not yet implemented") + .asRuntimeException()); + } + + @Override + public void removeRoleFromUsers(RemoveRoleFromUsersRequest request, StreamObserver responseObserver) { + // TODO: Implement role management later + responseObserver.onError(Status.UNIMPLEMENTED + .withDescription("Role management not yet implemented") + .asRuntimeException()); + } + + @Override + public void listAllPermissions(ListAllPermissionsRequest request, StreamObserver responseObserver) { + // TODO: Implement role management later + responseObserver.onError(Status.UNIMPLEMENTED + .withDescription("Role management not yet implemented") + .asRuntimeException()); + } +} diff --git a/src/main/java/com/ecmsp/userservice/api/grpc/context/UserContextData.java b/src/main/java/com/ecmsp/userservice/api/grpc/context/UserContextData.java new file mode 100644 index 0000000..9ad440a --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/api/grpc/context/UserContextData.java @@ -0,0 +1,5 @@ +package com.ecmsp.userservice.api.grpc.context; + +public record UserContextData(String userId, + String login) { +} diff --git a/src/main/java/com/ecmsp/userservice/api/grpc/context/UserContextGrpcHolder.java b/src/main/java/com/ecmsp/userservice/api/grpc/context/UserContextGrpcHolder.java new file mode 100644 index 0000000..5819e47 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/api/grpc/context/UserContextGrpcHolder.java @@ -0,0 +1,12 @@ +package com.ecmsp.userservice.api.grpc.context; + +import io.grpc.Context; + +public class UserContextGrpcHolder { + + public static final Context.Key USER_CONTEXT_KEY = Context.key("user-context"); + + public static UserContextData getUserContext() { + return USER_CONTEXT_KEY.get(); + } +} diff --git a/src/main/java/com/ecmsp/userservice/api/grpc/context/UserContextGrpcInterceptor.java b/src/main/java/com/ecmsp/userservice/api/grpc/context/UserContextGrpcInterceptor.java new file mode 100644 index 0000000..d37de61 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/api/grpc/context/UserContextGrpcInterceptor.java @@ -0,0 +1,25 @@ +package com.ecmsp.userservice.api.grpc.context; + +import io.grpc.*; +import net.devh.boot.grpc.server.interceptor.GrpcGlobalServerInterceptor; + +@GrpcGlobalServerInterceptor +public class UserContextGrpcInterceptor implements ServerInterceptor { + + @Override + public ServerCall.Listener interceptCall( + ServerCall call, + Metadata headers, + ServerCallHandler next) { + + UserContextData userContextData = new UserContextData( + headers.get(Metadata.Key.of("X-User-ID", Metadata.ASCII_STRING_MARSHALLER)), + headers.get(Metadata.Key.of("X-Login", Metadata.ASCII_STRING_MARSHALLER)) + ); + + Context context = Context.current() + .withValue(UserContextGrpcHolder.USER_CONTEXT_KEY, userContextData); + + return Contexts.interceptCall(context, call, headers, next); + } +} diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepository.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepository.java index e129363..ddc439b 100644 --- a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepository.java +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepository.java @@ -3,8 +3,12 @@ import com.ecmsp.userservice.user.domain.User; import com.ecmsp.userservice.user.domain.UserId; import com.ecmsp.userservice.user.domain.UserRepository; +import com.ecmsp.userservice.user.domain.UserView; +import org.springframework.transaction.annotation.Transactional; +import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; class DbUserRepository implements UserRepository { private final UserEntityMapper userMapper = new UserEntityMapper(); @@ -34,4 +38,29 @@ public Optional findByLogin(String login) { return userEntityRepository.findByLogin(login) .map(userMapper::toUser); } + + @Override + public void deleteById(UserId userId) { + userEntityRepository.deleteById(userId.value()); + } + + @Override + public List findAll() { + return userEntityRepository.findAll().stream() + .map(userMapper::toUserView) + .collect(Collectors.toList()); + } + + @Override + public List findByLoginContaining(String loginFilter) { + return userEntityRepository.findByLoginContaining(loginFilter).stream() + .map(userMapper::toUserView) + .collect(Collectors.toList()); + } + + @Override + @Transactional + public void updateLogin(UserId userId, String newLogin) { + userEntityRepository.updateLogin(userId.value(), newLogin); + } } diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityMapper.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityMapper.java index f16b750..c16ce74 100644 --- a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityMapper.java +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityMapper.java @@ -3,6 +3,7 @@ import com.ecmsp.userservice.user.domain.Role; import com.ecmsp.userservice.user.domain.User; import com.ecmsp.userservice.user.domain.UserId; +import com.ecmsp.userservice.user.domain.UserView; import java.util.Set; import java.util.stream.Collectors; @@ -28,6 +29,18 @@ public User toUser(UserEntity entity) { ); } + public UserView toUserView(UserEntity entity) { + Set roles = entity.getRoles().stream() + .map(roleEntityMapper::toRole) + .collect(Collectors.toSet()); + + return new UserView( + new UserId(entity.getUserId()), + entity.getLogin(), + roles + ); + } + public UserEntity toUserEntity(User user) { Set roleEntities = user.roles().stream() .map(roleEntityMapper::toRoleEntity) diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityRepository.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityRepository.java index 06498dc..1f0bead 100644 --- a/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityRepository.java +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityRepository.java @@ -1,9 +1,11 @@ package com.ecmsp.userservice.user.adapter.repository.db; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -11,4 +13,11 @@ interface UserEntityRepository extends JpaRepository { @Query("SELECT u FROM UserEntity u WHERE u.login = :login") Optional findByLogin(String login); + + @Query("SELECT u FROM UserEntity u WHERE LOWER(u.login) LIKE LOWER(CONCAT('%', :loginFilter, '%'))") + List findByLoginContaining(String loginFilter); + + @Modifying + @Query("UPDATE UserEntity u SET u.login = :newLogin WHERE u.userId = :userId") + void updateLogin(UUID userId, String newLogin); } diff --git a/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepository.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepository.java index cb42193..bb8e340 100644 --- a/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepository.java +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepository.java @@ -3,18 +3,21 @@ import com.ecmsp.userservice.user.domain.User; import com.ecmsp.userservice.user.domain.UserId; import com.ecmsp.userservice.user.domain.UserRepository; +import com.ecmsp.userservice.user.domain.UserView; import org.mindrot.jbcrypt.BCrypt; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; +import java.util.*; +import java.util.stream.Collectors; class InMemoryUserRepository implements UserRepository { - private final Map users = Map.of( - new UserId(UUID.fromString("3d1af02a-bba2-4df1-b551-e944bb60bc94")), - new User(new UserId(UUID.fromString("3d1af02a-bba2-4df1-b551-e944bb60bc94")), "testuser", BCrypt.hashpw("testpassword", BCrypt.gensalt()), Set.of()) - ); + private final Map users = new HashMap<>(); + + public InMemoryUserRepository() { + // Initialize with test user + UserId testUserId = new UserId(UUID.fromString("3d1af02a-bba2-4df1-b551-e944bb60bc94")); + User testUser = new User(testUserId, "testuser", BCrypt.hashpw("testpassword", BCrypt.gensalt()), Set.of()); + users.put(testUserId, testUser); + } @Override public void save(User user) { @@ -34,5 +37,32 @@ public Optional findByLogin(String login) { .findFirst(); } + @Override + public void deleteById(UserId userId) { + users.remove(userId); + } + + @Override + public List findAll() { + return users.values().stream() + .map(user -> new UserView(user.id(), user.login(), user.roles())) + .collect(Collectors.toList()); + } + @Override + public List findByLoginContaining(String loginFilter) { + return users.values().stream() + .filter(user -> user.login().toLowerCase().contains(loginFilter.toLowerCase())) + .map(user -> new UserView(user.id(), user.login(), user.roles())) + .collect(Collectors.toList()); + } + + @Override + public void updateLogin(UserId userId, String newLogin) { + User user = users.get(userId); + if (user != null) { + User updatedUser = new User(user.id(), newLogin, user.passwordHash(), user.roles()); + users.put(userId, updatedUser); + } + } } diff --git a/src/main/java/com/ecmsp/userservice/user/domain/UserFacade.java b/src/main/java/com/ecmsp/userservice/user/domain/UserFacade.java index 8deaa9f..bd52c57 100644 --- a/src/main/java/com/ecmsp/userservice/user/domain/UserFacade.java +++ b/src/main/java/com/ecmsp/userservice/user/domain/UserFacade.java @@ -1,6 +1,7 @@ package com.ecmsp.userservice.user.domain; import java.util.HashSet; +import java.util.List; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -15,11 +16,12 @@ public UserFacade(UserRepository userRepository, RoleRepository roleRepository) this.roleRepository = roleRepository; } - public void createUser(UserToCreate userToCreate){ + public User createUser(UserToCreate userToCreate){ UserId userId = new UserId(UUID.randomUUID()); String hashedPassword = passwordHasher.hash(userToCreate.password()); User user = new User(userId, userToCreate.login(), hashedPassword, new HashSet<>()); userRepository.save(user); + return user; } public Optional findUserById(UserId userId){ @@ -30,6 +32,25 @@ public Optional findUserByLogin(String login) { return userRepository.findByLogin(login); } + public void updateUserLogin(UserId userId, String newLogin) { + userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId)); + userRepository.updateLogin(userId, newLogin); + } + + public void deleteUser(UserId userId) { + userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId)); + userRepository.deleteById(userId); + } + + public List listUsers(String filterLogin) { + if (filterLogin == null || filterLogin.isBlank()) { + return userRepository.findAll(); + } + return userRepository.findByLoginContaining(filterLogin); + } + public void assignRoleToUser(UserId userId, RoleId roleId) { User user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId)); diff --git a/src/main/java/com/ecmsp/userservice/user/domain/UserRepository.java b/src/main/java/com/ecmsp/userservice/user/domain/UserRepository.java index 86a90b6..7e110f0 100644 --- a/src/main/java/com/ecmsp/userservice/user/domain/UserRepository.java +++ b/src/main/java/com/ecmsp/userservice/user/domain/UserRepository.java @@ -1,9 +1,14 @@ package com.ecmsp.userservice.user.domain; +import java.util.List; import java.util.Optional; public interface UserRepository { void save(User user); Optional findById(UserId userId); Optional findByLogin(String login); + void deleteById(UserId userId); + List findAll(); + List findByLoginContaining(String loginFilter); + void updateLogin(UserId userId, String newLogin); } diff --git a/src/main/java/com/ecmsp/userservice/user/domain/UserView.java b/src/main/java/com/ecmsp/userservice/user/domain/UserView.java new file mode 100644 index 0000000..864841a --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/domain/UserView.java @@ -0,0 +1,6 @@ +package com.ecmsp.userservice.user.domain; + +import java.util.Set; + +public record UserView(UserId id, String login, Set roles) { +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7aad341..e85d34a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,10 +16,17 @@ spring: auth: token-generator: type: jwt - secret-key-file: classpath:local/secrets/local.private.key + secret-key-file: classpath:local/secrets/local.private.pkcs8.key user: repository: type: db + server: - port: 8500 \ No newline at end of file + port: 8500 + +grpc: + server: + port: 7500 + security: + enabled: false # Set to true to enable TLS \ No newline at end of file diff --git a/src/main/resources/db/migration/V3__Create_roles_and_permissions.sql b/src/main/resources/db/migration/V3__Create_roles_and_permissions.sql index 2a463b9..54f67a2 100644 --- a/src/main/resources/db/migration/V3__Create_roles_and_permissions.sql +++ b/src/main/resources/db/migration/V3__Create_roles_and_permissions.sql @@ -25,14 +25,11 @@ CREATE TABLE user_roles ( -- Insert predefined permissions INSERT INTO permissions (permission_name) VALUES - ('READ_PRODUCTS'), ('WRITE_PRODUCTS'), ('DELETE_PRODUCTS'), ('READ_ORDERS'), ('WRITE_ORDERS'), ('CANCEL_ORDERS'), - ('READ_CARTS'), - ('WRITE_CARTS'), ('MANAGE_USERS'), ('MANAGE_ROLES'), ('PROCESS_PAYMENTS'), diff --git a/src/main/resources/db/migration/V4__Insert_roles_and_permissions_example_data.sql b/src/main/resources/db/migration/V4__Insert_roles_and_permissions_example_data.sql deleted file mode 100644 index bd07d79..0000000 --- a/src/main/resources/db/migration/V4__Insert_roles_and_permissions_example_data.sql +++ /dev/null @@ -1,67 +0,0 @@ --- Insert example roles - -INSERT INTO roles (role_id, role_name) VALUES - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'ADMIN'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', 'USER'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'MANAGER'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14', 'CUSTOMER_SUPPORT'); - --- Assign permissions to ADMIN role (full access) -INSERT INTO role_permissions (role_id, permission_name) VALUES - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'READ_PRODUCTS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'WRITE_PRODUCTS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'DELETE_PRODUCTS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'READ_ORDERS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'WRITE_ORDERS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'CANCEL_ORDERS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'READ_CARTS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'WRITE_CARTS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'MANAGE_USERS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'MANAGE_ROLES'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'PROCESS_PAYMENTS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'REFUND_PAYMENTS'); - --- Assign permissions to USER role (basic access) -INSERT INTO role_permissions (role_id, permission_name) VALUES - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', 'READ_PRODUCTS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', 'READ_ORDERS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', 'WRITE_ORDERS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', 'READ_CARTS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', 'WRITE_CARTS'); - --- Assign permissions to MANAGER role (moderate access) -INSERT INTO role_permissions (role_id, permission_name) VALUES - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'READ_PRODUCTS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'WRITE_PRODUCTS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'READ_ORDERS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'WRITE_ORDERS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'CANCEL_ORDERS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'READ_CARTS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'PROCESS_PAYMENTS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'REFUND_PAYMENTS'); - --- Assign permissions to CUSTOMER_SUPPORT role (support access) -INSERT INTO role_permissions (role_id, permission_name) VALUES - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14', 'READ_PRODUCTS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14', 'READ_ORDERS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14', 'CANCEL_ORDERS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14', 'READ_CARTS'), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14', 'REFUND_PAYMENTS'); - --- Assign roles to existing users --- admin_user gets ADMIN role -INSERT INTO user_roles (user_id, role_id) VALUES - ('550e8400-e29b-41d4-a716-446655440002', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'); - --- john_doe gets USER role -INSERT INTO user_roles (user_id, role_id) VALUES - ('550e8400-e29b-41d4-a716-446655440000', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12'); - --- jane_smith gets MANAGER role -INSERT INTO user_roles (user_id, role_id) VALUES - ('550e8400-e29b-41d4-a716-446655440001', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a13'); - --- andy gets USER and CUSTOMER_SUPPORT roles (example of multiple roles) -INSERT INTO user_roles (user_id, role_id) VALUES - ('123e4567-e89b-12d3-a456-426614174001', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12'), - ('123e4567-e89b-12d3-a456-426614174001', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14'); \ No newline at end of file diff --git a/src/test/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryTest.java b/src/test/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryTest.java index 3febdca..a3f93db 100644 --- a/src/test/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryTest.java +++ b/src/test/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryTest.java @@ -94,6 +94,7 @@ void should_find_user_by_id() { .userId(USER_1_ID.value()) .login("user1") .password("hashedPassword1") + .roles(Set.of()) .build() ); @@ -116,6 +117,7 @@ void should_return_empty_optional_when_user_with_given_id_not_exist() { .userId(USER_1_ID.value()) .login("user1") .password("hashedPassword1") + .roles(Set.of()) .build() ); @@ -137,6 +139,7 @@ void should_find_user_by_login() { .userId(USER_1_ID.value()) .login("user1") .password("hashedPassword1") + .roles(Set.of()) .build() ); @@ -180,6 +183,7 @@ void should_find_correct_user_when_multiple_users_exist() { .userId(USER_1_ID.value()) .login("user1") .password("hashedPassword1") + .roles(Set.of()) .build() ); testEntityManager.persist( @@ -187,6 +191,7 @@ void should_find_correct_user_when_multiple_users_exist() { .userId(USER_2_ID.value()) .login("user2") .password("hashedPassword2") + .roles(Set.of()) .build() ); testEntityManager.flush();