diff --git a/.env b/.env new file mode 100644 index 0000000..3309f31 --- /dev/null +++ b/.env @@ -0,0 +1,31 @@ +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 + +USER_SERVICE_PORT=8500 +USER_SERVICE_DB_PORT=9500 + +GATEWAY_SERVICE_PORT=8600 + +####################################### + +# 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= +USER_SERVICE_SPRING_PROFILES_ACTIVE= +GATEWAY_SERVICE_SPRING_PROFILES_ACTIVE= + +####################################### + +COMPOSE_PROFILES=payment-service,order-service,cart-service,product-service,gateway-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/.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/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-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: 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/mvnw b/mvnw old mode 100644 new mode 100755 diff --git a/pom.xml b/pom.xml index f0dcaaf..9aba7d2 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,7 @@ 21 2025.0.0 + 1.63.0 @@ -39,10 +40,42 @@ org.springframework.boot spring-boot-starter-validation + + com.h2database + h2 + test + + + net.devh + grpc-spring-boot-starter + 3.1.0.RELEASE + + + org.mindrot + jbcrypt + 0.4 + org.springframework.boot spring-boot-starter-web + + 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 @@ -66,11 +99,52 @@ test - org.springframework.cloud - spring-cloud-starter-contract-verifier + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 test + + org.springframework.kafka + spring-kafka + 3.3.4 + + + 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 + 1.0.0-20251105.185705-41 + + @@ -80,6 +154,13 @@ pom import + + io.grpc + grpc-bom + ${grpc.version} + pom + import + @@ -97,15 +178,6 @@ - - org.springframework.cloud - spring-cloud-contract-maven-plugin - 4.3.0 - true - - JUNIT5 - - org.springframework.boot spring-boot-maven-plugin @@ -121,4 +193,21 @@ + + + central + Maven Central + https://repo.maven.apache.org/maven2 + + + 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/ + + diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..9d3c4ec --- /dev/null +++ b/readme.md @@ -0,0 +1,38 @@ +# 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 + ``` +**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 +``` +POST http://localhost:8500/api/users +Content-Type: application/json + +{ + "login": "testuser", + "password": "testpassword" +} +``` + +### Authenticate User +``` +POST http://localhost:8500/api/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..759bfbb 100644 --- a/src/main/java/com/ecmsp/userservice/UserServiceApplication.java +++ b/src/main/java/com/ecmsp/userservice/UserServiceApplication.java @@ -2,9 +2,16 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +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/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/api/rest/UserController.java b/src/main/java/com/ecmsp/userservice/api/rest/UserController.java new file mode 100644 index 0000000..10297d3 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/api/rest/UserController.java @@ -0,0 +1,67 @@ +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.*; + +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@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(); + } + + @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/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/rest/auth/AuthController.java b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthController.java new file mode 100644 index 0000000..b2248cf --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthController.java @@ -0,0 +1,30 @@ +package com.ecmsp.userservice.api.rest.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("/api/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 AuthResponse(success.token().value())); + case AuthenticationResult.Failure ignored -> ResponseEntity.badRequest().build(); + }; + } + +} diff --git a/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthRequest.java b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthRequest.java new file mode 100644 index 0000000..e6072a4 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthRequest.java @@ -0,0 +1,4 @@ +package com.ecmsp.userservice.api.rest.auth; + +public record AuthRequest(String login, String password) { +} diff --git a/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthResponse.java b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthResponse.java new file mode 100644 index 0000000..1f6f34e --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/api/rest/auth/AuthResponse.java @@ -0,0 +1,4 @@ +package com.ecmsp.userservice.api.rest.auth; + +public record AuthResponse(String token) { +} 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/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/application/config/LocalPostgreSQLConfiguration.java b/src/main/java/com/ecmsp/userservice/application/config/LocalPostgreSQLConfiguration.java new file mode 100644 index 0000000..fc87579 --- /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(9500), + /* containerPort = */ new ExposedPort(5432) + ) + ) + ) + .withUsername("admin") + .withPassword("admin"); + } + +} \ No newline at end of file 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..296c780 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/auth/adapter/generator/JwtTokenGenerator.java @@ -0,0 +1,49 @@ +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.Permission; +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; +import java.util.Set; +import java.util.stream.Collectors; + +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); + + // 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) + .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/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/DbUserRepository.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepository.java new file mode 100644 index 0000000..ddc439b --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepository.java @@ -0,0 +1,66 @@ +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 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(); + 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)); + } + + @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); + } + + @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/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/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 new file mode 100644 index 0000000..9ebed41 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntity.java @@ -0,0 +1,80 @@ +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 = "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; + + @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; + } + + 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; + } + + 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 new file mode 100644 index 0000000..c16ce74 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityMapper.java @@ -0,0 +1,56 @@ +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 com.ecmsp.userservice.user.domain.UserView; + +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(), + roles + ); + } + + 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) + .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/adapter/repository/db/UserEntityRepository.java b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityRepository.java new file mode 100644 index 0000000..1f0bead --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/db/UserEntityRepository.java @@ -0,0 +1,23 @@ +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; + +@Repository +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 new file mode 100644 index 0000000..bb8e340 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/adapter/repository/inmemory/InMemoryUserRepository.java @@ -0,0 +1,68 @@ +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 com.ecmsp.userservice.user.domain.UserView; +import org.mindrot.jbcrypt.BCrypt; + +import java.util.*; +import java.util.stream.Collectors; + +class InMemoryUserRepository implements UserRepository { + 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) { + users.put(user.id(), 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(); + } + + @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/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..0090dd9 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/config/UserConfig.java @@ -0,0 +1,15 @@ +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; +import org.springframework.context.annotation.Configuration; + +@Configuration +class UserConfig { + @Bean + public UserFacade userFacade(UserRepository userRepository, RoleRepository roleRepository){ + return new UserFacade(userRepository, roleRepository); + } +} 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/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 new file mode 100644 index 0000000..97c3eb4 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/domain/User.java @@ -0,0 +1,7 @@ +package com.ecmsp.userservice.user.domain; + +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 new file mode 100644 index 0000000..bd52c57 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/domain/UserFacade.java @@ -0,0 +1,81 @@ +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; + +public class UserFacade { + private final UserRepository userRepository; + private final RoleRepository roleRepository; + private final PasswordHasher passwordHasher = new PasswordHasher(); + + public UserFacade(UserRepository userRepository, RoleRepository roleRepository) { + this.userRepository = userRepository; + this.roleRepository = roleRepository; + } + + 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){ + return userRepository.findById(userId); + } + + 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)); + 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/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..7e110f0 --- /dev/null +++ b/src/main/java/com/ecmsp/userservice/user/domain/UserRepository.java @@ -0,0 +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/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/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-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..ef24ef5 --- /dev/null +++ b/src/main/resources/application-dev.properties @@ -0,0 +1,13 @@ +spring.datasource.url=jdbc:postgresql://localhost:9500/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-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..b3c8937 --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,9 @@ +spring: + datasource: + url: jdbc:postgresql://localhost:9500/user-service-db + username: admin + password: admin + +user: + repository: + type: db \ 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 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..826d744 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,32 @@ +spring: + application: + name: User service + jpa: + hibernate.ddl-auto: validate + properties.hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect + show-sql: true + datasource: + 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 + secret-key-file: classpath:local/secrets/local.private.pkcs8.key +user: + repository: + type: db + + +server: + 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/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..90db716 --- /dev/null +++ b/src/main/resources/db/migration/V2__Insert_example_data.sql @@ -0,0 +1,10 @@ +-- 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 + ('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/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..54f67a2 --- /dev/null +++ b/src/main/resources/db/migration/V3__Create_roles_and_permissions.sql @@ -0,0 +1,41 @@ +-- 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 + ('WRITE_PRODUCTS'), + ('DELETE_PRODUCTS'), + ('READ_ORDERS'), + ('WRITE_ORDERS'), + ('CANCEL_ORDERS'), + ('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); 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 diff --git a/src/test/java/com/ecmsp/userservice/UserServiceApplicationTests.java b/src/test/java/com/ecmsp/userservice/UserServiceApplicationTests.java index e9f320f..6a5d977 100644 --- a/src/test/java/com/ecmsp/userservice/UserServiceApplicationTests.java +++ b/src/test/java/com/ecmsp/userservice/UserServiceApplicationTests.java @@ -2,12 +2,13 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class UserServiceApplicationTests { - - @Test - void contextLoads() { - } - -} +import org.springframework.test.context.ActiveProfiles; + +//@ActiveProfiles("test") +//@SpringBootTest +//class UserServiceApplicationTests { +// @Test +// void contextLoads() { +// } +// +//} 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..a3cc0da --- /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.Set; +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", Set.of()); + + Token token = jwtTokenGenerator.generate(user); + + 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", Set.of()); + + 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", Set.of()); + + 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", Set.of()); + + 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", Set.of()); + + 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", Set.of()); + + 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", Set.of()); + + 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", Set.of()); + User user2 = new User(new UserId(UUID.randomUUID()), "user2", "password2", Set.of()); + + 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", Set.of()); + + 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", Set.of()); + + 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 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..55996ce --- /dev/null +++ b/src/test/java/com/ecmsp/userservice/user/adapter/repository/db/DbUserRepositoryTest.java @@ -0,0 +1,214 @@ +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.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.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +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")); + 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", + /* roles = */ Set.of() + ); + + private static final User USER_2 = new User( + /* id = */ USER_2_ID, + /* login = */ "user2", + /* passwordHash = */ "hashedPassword2", + Set.of() + ); + + @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") + .roles(Set.of()) + .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") + .roles(Set.of()) + .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") + .roles(Set.of()) + .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") + .roles(Set.of()) + .build() + ); + testEntityManager.persist( + UserEntity.builder() + .userId(USER_2_ID.value()) + .login("user2") + .password("hashedPassword2") + .roles(Set.of()) + .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 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 +