diff --git a/.gitignore b/.gitignore index 02b08bc..0e46b82 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -HELP.md +JOURNAL.md target/ .mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ @@ -32,3 +32,23 @@ build/ ### VS Code ### .vscode/ +hydro-backend.tar + +### Keys ### +*.jks +*.key +*.pub +*.pem +*.p12 +*.der + +### Dev database files ### +*.mv.db +*.trace.db + +### Env files ### +.env +.env.test +.env.production +.env.development +.env.local \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ac6ae71 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +# syntax=docker/dockerfile:1 + +FROM eclipse-temurin:21-jdk-jammy AS deps +WORKDIR /build +COPY --chmod=0755 mvnw mvnw +COPY .mvn .mvn +COPY pom.xml . +RUN --mount=type=cache,target=/root/.m2 ./mvnw dependency:go-offline -DskipTests + +# Compile and package the application +FROM deps AS package +WORKDIR /build +ARG CACHEBUST +COPY src src +RUN --mount=type=cache,target=/root/.m2 ./mvnw package -DskipTests && \ + mv target/$(./mvnw help:evaluate -Dexpression=project.artifactId -q -DforceStdout)-\ +$(./mvnw help:evaluate -Dexpression=project.version -q -DforceStdout).jar target/app.jar + +# Extract layers from the packaged jar +FROM package AS extract +WORKDIR /build +RUN java -Djarmode=layertools -jar target/app.jar extract --destination target/extracted + +# DEVELOPMENT: Use the JDK and enable remote debugging. +FROM extract AS development +WORKDIR /build +RUN cp -r /build/target/extracted/dependencies/ ./ +RUN cp -r /build/target/extracted/spring-boot-loader/ ./ +RUN cp -r /build/target/extracted/snapshot-dependencies/ ./ +RUN cp -r /build/target/extracted/application/ ./ +ENV JAVA_TOOL_OPTIONS -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8000 +CMD ["java", "-Dspring.profiles.active=default", "org.springframework.boot.loader.launch.JarLauncher"] + +# PRODUCTION: Use the JRE and a non-root user. +FROM eclipse-temurin:21-jre-jammy AS production +ARG UID=10001 +RUN adduser \ + --disabled-password \ + --gecos "" \ + --home "/nonexistent" \ + --shell "/sbin/nologin" \ + --no-create-home \ + --uid "${UID}" \ + appuser +USER appuser +COPY --from=extract /build/target/extracted/dependencies/ ./ +COPY --from=extract /build/target/extracted/spring-boot-loader/ ./ +COPY --from=extract /build/target/extracted/snapshot-dependencies/ ./ +COPY --from=extract /build/target/extracted/application/ ./ + +ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"] \ No newline at end of file diff --git a/build-dockerized.sh b/build-dockerized.sh deleted file mode 100755 index d7cbf2b..0000000 --- a/build-dockerized.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash -export $(grep -v '^#' ./src/main/resources/.env | xargs) -# Build the Docker image -# to skip tests: -Dmaven.test.skip -./mvnw spring-boot:build-image \ No newline at end of file diff --git a/deploy-image.sh b/deploy-image.sh deleted file mode 100755 index 5cded85..0000000 --- a/deploy-image.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -# Get the latest hydro-backend image tag -IMAGE_TAG=$(docker images --format '{{.Repository}}:{{.Tag}} {{.CreatedAt}}' | grep '^hydro-backend:' | sort -k2 -r | head -n1 | awk '{print $1}') - -# Save the Docker image to a tar file -docker save "$IMAGE_TAG" > /tmp/hydro-backend.tar - -# Copy the tar file to the remote server -scp /tmp/hydro-backend.tar admin@netoasis.app:/home/admin/hydro-backend.tar - -# SSH into the remote server and execute commands with a pseudo-terminal -ssh -t admin@netoasis.app "docker load -i /home/admin/hydro-backend.tar && cd /home/admin/ && docker-compose up" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..06f12fa --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,76 @@ +services: + api: + build: + context: . + args: + CACHEBUST: $(date +%s) + ports: + - "8080:8080" + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/postgres + SPRING_DATA_REDIS_HOST: redis + VAULT_URI: ${VAULT_URI} + VAULT_ROLE_ID: ${VAULT_ROLE_ID} + VAULT_SECRET_ID: ${VAULT_SECRET_ID} + JWT_REFRESH_EXPIRATION_MS: 2592000000 + JWT_ACCESS_EXPIRATION_MS: 900000 + MQTT_JWT_PRIVATE_KEY_PATH: /app/private_key.der + SPRING_PROFILES_ACTIVE: dev + SERVER_PORT: 8080 + volumes: + - ./private_key.der:/app/private_key.der:ro,z + depends_on: + - postgres + - mosquitto + - redis + networks: + - hydro-network + + postgres: + image: postgres:16 + volumes: + - pgdata:/var/lib/postgresql/data + environment: + POSTGRES_USER: admin + POSTGRES_PASSWORD: supersecret + POSTGRES_DB: hydrodb + networks: + - hydro-network + + redis: + image: redis:latest + restart: always + ports: + - '6379:6379' + command: redis-server --save 20 1 --loglevel warning --requirepass supersecret + volumes: + - cache:/data + networks: + - hydro-network + + mosquitto: + build: + context: ./mosquitto + ports: + - "1883:1883" + networks: + - hydro-network + + # + # rabbitmq: + # image: 'rabbitmq:latest' + # environment: + # - 'RABBITMQ_DEFAULT_PASS=supersecret' + # - 'RABBITMQ_DEFAULT_USER=myuser' + # ports: + # - '5672:5672' + # + + +volumes: + pgdata: + cache: + +networks: + hydro-network: + driver: bridge \ No newline at end of file diff --git a/hydro-backend.tar b/hydro-backend.tar deleted file mode 100644 index d0bcafb..0000000 Binary files a/hydro-backend.tar and /dev/null differ diff --git a/mosquitto/Dockerfile b/mosquitto/Dockerfile new file mode 100644 index 0000000..0bb4275 --- /dev/null +++ b/mosquitto/Dockerfile @@ -0,0 +1,22 @@ +FROM eclipse-mosquitto:2.0 + +RUN apk add --no-cache \ + rust \ + cargo \ + build-base \ + openssl-dev \ + mosquitto-dev \ + git + +RUN git clone https://github.com/wiomoc/mosquitto-jwt-auth.git /jwt-auth +WORKDIR /jwt-auth +RUN cargo build --release + +RUN mkdir -p /usr/lib/mosquitto && \ + cp target/release/libmosquitto_jwt_auth.so /usr/lib/mosquitto/ + +RUN apk del build-base git + +COPY mosquitto.conf /mosquitto/config/mosquitto.conf +COPY public_key.pem /mosquitto/config/public_key.pem +RUN chown -R mosquitto:mosquitto /mosquitto/config diff --git a/mosquitto/mosquitto.conf b/mosquitto/mosquitto.conf new file mode 100644 index 0000000..768af34 --- /dev/null +++ b/mosquitto/mosquitto.conf @@ -0,0 +1,9 @@ +listener 1883 +allow_anonymous false + +auth_plugin /usr/lib/mosquitto/libmosquitto_jwt_auth.so +auth_opt_jwt_alg RS256 +auth_opt_jwt_sec_file /mosquitto/config/public_key.pem +auth_opt_jwt_iss HydroAPI +auth_opt_jwt_validate_exp true +auth_opt_jwt_validate_sub_match_username false \ No newline at end of file diff --git a/mvnw b/mvnw index e9cf8d3..a6440a1 100755 --- a/mvnw +++ b/mvnw @@ -256,10 +256,10 @@ else tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" fi -# Find the actual extracted directory name (handles snapshots where filename != directory name) +# Find the actual extracted directory friendlyName (handles snapshots where filename != directory friendlyName) actualDistributionDir="" -# First try the expected directory name (for regular distributions) +# First try the expected directory friendlyName (for regular distributions) if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then actualDistributionDir="$distributionUrlNameMain" diff --git a/pom.xml b/pom.xml index a1cb3ca..0066cab 100644 --- a/pom.xml +++ b/pom.xml @@ -1,177 +1,229 @@ - - 4.0.0 - - org.springframework.boot - spring-boot-starter-parent - 3.5.5 - - - dev.ivfrost - hydro-backend - 0.0.1-SNAPSHOT - hydro-backend - Backend API for Hydro UI: user accounts, device linking, QR code verification - - 17 - 2025.0.0 - - - - - org.springframework.cloud - spring-cloud-dependencies - ${spring-cloud.version} - pom - import - - - - - - org.springframework.boot - spring-boot-starter-data-jpa - - - org.springframework.boot - spring-boot-starter-security - - - org.springframework.boot - spring-boot-starter-web - - - org.postgresql - postgresql - runtime - - - org.springframework.boot - spring-boot-starter-test - test - - - org.springframework.security - spring-security-test - test - - - - - com.fasterxml.jackson.core - jackson-core - 2.20.0 - - - - org.springframework.cloud - spring-cloud-starter-openfeign - - - - org.projectlombok - lombok - 1.18.38 - - - - org.springframework.security - spring-security-crypto - 6.5.3 - - - - jakarta.validation - jakarta.validation-api - 3.0.2 - - - - org.springframework.boot - spring-boot-starter-validation - - - - org.springdoc - springdoc-openapi-starter-webmvc-ui - 2.8.9 - - - org.springframework.boot - spring-boot-starter-mail - - - - io.jsonwebtoken - jjwt-api - 0.12.6 - - - - com.auth0 - java-jwt - 4.5.0 - - - - com.bucket4j - bucket4j_jdk17-core - 8.15.0 - + + hydro-api + + + + maven-compiler-plugin + + + + lombok + org.projectlombok + + + + org.apache.maven.plugins + + + native-maven-plugin + org.graalvm.buildtools + + + spring-boot-maven-plugin + + + + lombok + org.projectlombok + + + + org.springframework.boot + + + springdoc-openapi-maven-plugin + + http://localhost:8080/v3/api-docs + ${project.build.directory} + openapi.json + + + + + generate + + integration-test + + + org.springdoc + 1.5 + + + + + + spring-boot-starter-data-jpa + org.springframework.boot + + + spring-boot-starter-security + org.springframework.boot + + + spring-boot-starter-webmvc + org.springframework.boot + + + spring-cloud-starter-vault-config + org.springframework.cloud + + + spring-modulith-starter-core + org.springframework.modulith + + + spring-modulith-starter-jdbc + org.springframework.modulith + + + spring-modulith-starter-test + org.springframework.modulith + test + + + + postgresql + org.postgresql + runtime + + + lombok + org.projectlombok + true + + + spring-boot-starter-security-test + org.springframework.boot + test + + + spring-boot-starter-webmvc-test + org.springframework.boot + test + + + spring-modulith-starter-test + org.springframework.modulith + test + + + spring-modulith-events-amqp + org.springframework.modulith + + + rest-assured + io.rest-assured + test + 6.0.0 + + + hamcrest + org.hamcrest + test + 3.0 + + + + jackson-core + com.fasterxml.jackson.core + 2.20.0 + + + spring-boot-starter-validation + org.springframework.boot + + + java-jwt + com.auth0 + compile + 4.5.0 + + + + springdoc-openapi-starter-webmvc-ui + org.springdoc + compile + 3.0.1 + + + swagger-annotations + io.swagger.core.v3 + compile + 2.2.41 + + + spring-boot-starter-data-redis + org.springframework.boot + + + spring-boot-starter-cache + org.springframework.boot + + + spring-boot-starter-amqp + org.springframework.boot + + + spring-rabbit-test + org.springframework.amqp + test + + + h2 + com.h2database + runtime + + + + + + spring-cloud-dependencies + org.springframework.cloud + import + pom + ${spring-cloud.version} + + + spring-modulith-bom + org.springframework.modulith + import + pom + ${spring-modulith.version} + + + + Hydro API + + + + dev.ivfrost + + + + 4.0.0 + hydro-api + + spring-boot-starter-parent + org.springframework.boot + + 4.0.1 + + + 21 + 2025.1.0 + 2.0.1 + + + + + + + + + + 0.0.1-SNAPSHOT - - com.vladmihalcea - hibernate-types-60 - 2.21.1 - - - org.hibernate.orm - hibernate-core - - - - - - org.graalvm.buildtools - native-maven-plugin - - - org.apache.maven.plugins - maven-compiler-plugin - 3.11.0 - - - - org.projectlombok - lombok - 1.18.38 - - - - - - - - - native - - - - org.springframework.boot - spring-boot-maven-plugin - - - paketobuildpacks/builder-jammy-buildpackless-tiny - - paketobuildpacks/oracle - paketobuildpacks/java-native-image - - - - - - - - diff --git a/build-dockerized-native.sh b/scripts/build-dockerized-native.sh similarity index 100% rename from build-dockerized-native.sh rename to scripts/build-dockerized-native.sh diff --git a/scripts/build-dockerized.sh b/scripts/build-dockerized.sh new file mode 100755 index 0000000..c020cee --- /dev/null +++ b/scripts/build-dockerized.sh @@ -0,0 +1,5 @@ +#!/bin/bash +export $(grep -v '^#' .env | xargs) +# Build the Docker image +# to skip tests: -Dmaven.test.skip +./mvnw spring-boot:build-image \ No newline at end of file diff --git a/scripts/deploy-image.sh b/scripts/deploy-image.sh new file mode 100755 index 0000000..b165039 --- /dev/null +++ b/scripts/deploy-image.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# Build docker image +./mvnw spring-boot:build-image -Dmaven.test.skip + +# Get the latest hydro-backend image tag +IMAGE_TAG=$(docker images --format '{{.Repository}}:{{.Tag}} {{.CreatedAt}}' | grep '^hydro-api:' | sort -k2 -r | head -n1 | awk '{print $1}') +IMAGE_PATH="/tmp/hydro-api.tar" +PRODUCTION_HOST="root@192.168.1.214" +PRODUCTION_PATH="/srv/hydro/" +PRODUCTION_IMAGE_PATH="/srv/hydro/hydro-api.tar" + +# Save the Docker image to a tar file +docker save "$IMAGE_TAG" > $IMAGE_PATH + +# Copy the tar file to the remote server +scp $IMAGE_PATH "$PRODUCTION_HOST:$PRODUCTION_PATH" + +# SSH into the remote server and execute commands with a pseudo-terminal +ssh -t $PRODUCTION_HOST "docker load -i $PRODUCTION_IMAGE_PATH && cd $PRODUCTION_PATH && docker compose up" diff --git a/src/main/java/dev/ivfrost/hydro_backend/ApiResponse.java b/src/main/java/dev/ivfrost/hydro_backend/ApiResponse.java new file mode 100644 index 0000000..cc403ea --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/ApiResponse.java @@ -0,0 +1,67 @@ +package dev.ivfrost.hydro_backend; + +import java.time.LocalDateTime; +import org.springframework.http.HttpStatus; + +public record ApiResponse(LocalDateTime timestamp, int status, String error, String message, + T details) { + + public static ApiResponse success(HttpStatus status, String message) { + return new ApiResponse<>( + LocalDateTime.now(), + status.value(), + null, + message, + null + ); + } + + public static ApiResponse success(HttpStatus status, String message, T details) { + return new ApiResponse<>( + LocalDateTime.now(), + status.value(), + null, + message, + details + ); + } + + public static ApiResponse error(HttpStatus status, String message) { + return new ApiResponse<>( + LocalDateTime.now(), + status.value(), + status.getReasonPhrase(), + message, + null + ); + } + + public static ApiResponse error(HttpStatus status, String message, T details) { + return new ApiResponse<>( + LocalDateTime.now(), + status.value(), + status.getReasonPhrase(), + message, + details + ); + } + + public String toJson() { + return String.format( + """ + { + "timestamp": "%s", + "status": %d, + "error": "%s", + "message": "%s", + "details": "%s" + } + """, + timestamp, + status, + error, + message, + details != null ? details.toString() : "null" + ); + } +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/HydroBackendApplication.java b/src/main/java/dev/ivfrost/hydro_backend/HydroApiApplication.java similarity index 59% rename from src/main/java/dev/ivfrost/hydro_backend/HydroBackendApplication.java rename to src/main/java/dev/ivfrost/hydro_backend/HydroApiApplication.java index c2dbd96..d843bb2 100644 --- a/src/main/java/dev/ivfrost/hydro_backend/HydroBackendApplication.java +++ b/src/main/java/dev/ivfrost/hydro_backend/HydroApiApplication.java @@ -3,16 +3,16 @@ package dev.ivfrost.hydro_backend; import dev.ivfrost.hydro_backend.config.MyRuntimeHints; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.cloud.openfeign.EnableFeignClients; - +import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.ImportRuntimeHints; @SpringBootApplication -@EnableFeignClients +@EnableCaching @ImportRuntimeHints(MyRuntimeHints.class) -public class HydroBackendApplication { +public class HydroApiApplication { + + public static void main(String[] args) { + SpringApplication.run(HydroApiApplication.class, args); + } - public static void main(String[] args) { - SpringApplication.run(HydroBackendApplication.class, args); - } } diff --git a/src/main/java/dev/ivfrost/hydro_backend/config/AmqpConfig.java b/src/main/java/dev/ivfrost/hydro_backend/config/AmqpConfig.java new file mode 100644 index 0000000..01237f5 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/config/AmqpConfig.java @@ -0,0 +1,31 @@ +package dev.ivfrost.hydro_backend.config; + +import org.springframework.amqp.core.Binding; +import org.springframework.amqp.core.BindingBuilder; +import org.springframework.amqp.core.Exchange; +import org.springframework.amqp.core.ExchangeBuilder; +import org.springframework.amqp.core.Queue; +import org.springframework.amqp.core.QueueBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AmqpConfig { + + public static final String HYDRO_Q = "hydro_q"; + + @Bean + Exchange exchange() { + return ExchangeBuilder.directExchange(HYDRO_Q).build(); + } + + @Bean + Queue queue() { + return QueueBuilder.durable(HYDRO_Q).build(); + } + + @Bean + Binding binding(Queue queue, Exchange exchange) { + return BindingBuilder.bind(queue).to(exchange).with(HYDRO_Q).noargs(); + } +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/config/AuthRequestCountFilter.java b/src/main/java/dev/ivfrost/hydro_backend/config/AuthRequestCountFilter.java new file mode 100644 index 0000000..05c6197 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/config/AuthRequestCountFilter.java @@ -0,0 +1,50 @@ +package dev.ivfrost.hydro_backend.config; + +import dev.ivfrost.hydro_backend.devices.DeviceLoadEvent; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.NonNull; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +@RequiredArgsConstructor +@Slf4j +public class AuthRequestCountFilter extends OncePerRequestFilter { + + private final ApplicationEventPublisher events; + private int count = 0; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) + throws ServletException, IOException { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + log.trace("No authentication found in security context."); + } else if (authentication.isAuthenticated()) { + count++; + log.trace("Authenticated request count: {} (principal={})", count, authentication.getName()); + // Publish only when we have a numeric user id as principal + String principalName = authentication.getName(); + if (principalName != null) { + try { + long userId = Long.parseLong(principalName); + if (count == 1) { + events.publishEvent(new DeviceLoadEvent(userId)); + } + } catch (NumberFormatException ex) { + log.debug("Skipping DeviceLoadEvent: principal '{}' is not a numeric user id.", + principalName); + } + } + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/config/EndpointRegistry.java b/src/main/java/dev/ivfrost/hydro_backend/config/EndpointRegistry.java new file mode 100644 index 0000000..d433749 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/config/EndpointRegistry.java @@ -0,0 +1,49 @@ +package dev.ivfrost.hydro_backend.config; + +import java.util.List; +import java.util.stream.Stream; +import org.springframework.util.AntPathMatcher; + + +public class EndpointRegistry { + + static final List APP_PUBLIC = List.of( + "/v1/users", + "/v1/users/auth", + "/v1/users/recover", + "/v1/users/password/reset", + "/v1/validation/**", + "/v1/health" + ); + static final List SWAGGER = List.of( + "/v3/api-docs", + "/v3/api-docs/**", + "/swagger-ui.html", + "/swagger-ui/**" + ); + static final List APP_AUTHENTICATED = List.of( + "/v1/users/**", + "/v1/me/**", + "/v1/users/auth/refresh" + ); + static final List H2_CONSOLE = List.of( + "/h2-console/**", + "/h2-console" + ); + private static final AntPathMatcher pathMatcher = new AntPathMatcher(); + private static final List PUBLIC_ENDPOINTS; + + static { + PUBLIC_ENDPOINTS = Stream.of(APP_PUBLIC, SWAGGER, H2_CONSOLE) + .flatMap(List::stream) + .toList(); + } + + private EndpointRegistry() { + + } + + public static boolean isPublicEndpoint(String path) { + return PUBLIC_ENDPOINTS.stream().anyMatch(pattern -> pathMatcher.match(pattern, path)); + } +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/config/GlobalExceptionHandler.java b/src/main/java/dev/ivfrost/hydro_backend/config/GlobalExceptionHandler.java new file mode 100644 index 0000000..2f2185d --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/config/GlobalExceptionHandler.java @@ -0,0 +1,147 @@ +package dev.ivfrost.hydro_backend.config; + +import com.auth0.jwt.exceptions.JWTCreationException; +import com.auth0.jwt.exceptions.JWTVerificationException; +import dev.ivfrost.hydro_backend.ApiResponse; +import dev.ivfrost.hydro_backend.exception.DeviceFetchException; +import dev.ivfrost.hydro_backend.exception.DeviceLinkException; +import dev.ivfrost.hydro_backend.exception.DeviceNotFoundException; +import dev.ivfrost.hydro_backend.exception.DuplicateMacAddressException; +import dev.ivfrost.hydro_backend.exception.ExpiredVerificationToken; +import dev.ivfrost.hydro_backend.exception.RecoveryTokenMismatchException; +import dev.ivfrost.hydro_backend.exception.RecoveryTokenNotFoundException; +import dev.ivfrost.hydro_backend.exception.TokenNotFoundException; +import dev.ivfrost.hydro_backend.exception.UserDisabledException; +import dev.ivfrost.hydro_backend.exception.UserNotAuthenticatedException; +import dev.ivfrost.hydro_backend.exception.UsernameTakenException; +import java.util.HashMap; +import java.util.Map; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(UserDisabledException.class) + public ResponseEntity> handleUserDisabledException( + UserDisabledException ex) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.error(HttpStatus.UNAUTHORIZED, ex.getMessage())); + } + + @ExceptionHandler(AuthenticationCredentialsNotFoundException.class) + public ResponseEntity> handleUserNotFoundException( + AuthenticationCredentialsNotFoundException ex) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.error(HttpStatus.UNAUTHORIZED, ex.getMessage())); + } + + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity> handleBadCredentialsException( + BadCredentialsException ex) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.error(HttpStatus.UNAUTHORIZED, ex.getMessage())); + } + + @ExceptionHandler(UserNotAuthenticatedException.class) + public ResponseEntity> handleUserNotAuthenticatedException( + UserNotAuthenticatedException ex) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.error(HttpStatus.UNAUTHORIZED, ex.getMessage())); + } + + @ExceptionHandler(UsernameTakenException.class) + public ResponseEntity> handleUsernameTakenException( + UsernameTakenException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(ApiResponse.error(HttpStatus.CONFLICT, ex.getMessage())); + } + + @ExceptionHandler(TokenNotFoundException.class) + public ResponseEntity> handleTokenNotFoundException( + TokenNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error(HttpStatus.NOT_FOUND, ex.getMessage())); + } + + @ExceptionHandler(ExpiredVerificationToken.class) + public ResponseEntity> handleExpiredVerificationToken( + ExpiredVerificationToken ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(HttpStatus.BAD_REQUEST, ex.getMessage())); + } + + @ExceptionHandler(JWTCreationException.class) + public ResponseEntity> handleJWTCreationException( + JWTCreationException ex) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage())); + } + + @ExceptionHandler(JWTVerificationException.class) + public ResponseEntity> handleJWTVerificationException( + JWTVerificationException ex) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.error(HttpStatus.UNAUTHORIZED, ex.getMessage())); + } + + @ExceptionHandler(DeviceNotFoundException.class) + public ResponseEntity> handleDeviceNotFoundException( + DeviceNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error(HttpStatus.NOT_FOUND, ex.getMessage())); + } + + @ExceptionHandler(DeviceLinkException.class) + public ResponseEntity> handleDeviceLinkException( + DeviceLinkException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(HttpStatus.BAD_REQUEST, ex.getMessage())); + } + + @ExceptionHandler(DeviceFetchException.class) + public ResponseEntity> handleDeviceFetchException( + DeviceFetchException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error(HttpStatus.NOT_FOUND, ex.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity>> handleValidationException( + MethodArgumentNotValidException ex) { + Map errors = new HashMap<>(); + ex.getBindingResult() + .getFieldErrors() + .forEach(error -> errors.put(error.getField(), error.getDefaultMessage())); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + ApiResponse.error(HttpStatus.BAD_REQUEST, "Validation failed for one or more fields.", + errors)); + } + + @ExceptionHandler(RecoveryTokenNotFoundException.class) + public ResponseEntity> handleRecoveryCodeNotFoundException( + RecoveryTokenNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error(HttpStatus.NOT_FOUND, ex.getMessage())); + } + + @ExceptionHandler(RecoveryTokenMismatchException.class) + public ResponseEntity> handleRecoveryCodeMismatchException( + RecoveryTokenMismatchException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(HttpStatus.BAD_REQUEST, ex.getMessage())); + } + + @ExceptionHandler(DuplicateMacAddressException.class) + public ResponseEntity> handleDuplicateMacAddressException( + DuplicateMacAddressException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(ApiResponse.error(HttpStatus.CONFLICT, ex.getMessage())); + } +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/config/JWTFilter.java b/src/main/java/dev/ivfrost/hydro_backend/config/JWTFilter.java new file mode 100644 index 0000000..7172f6a --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/config/JWTFilter.java @@ -0,0 +1,134 @@ +package dev.ivfrost.hydro_backend.config; + +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.auth0.jwt.interfaces.Claim; +import dev.ivfrost.hydro_backend.ApiResponse; +import dev.ivfrost.hydro_backend.tokens.JWTUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.NonNull; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@AllArgsConstructor +@Component +public class JWTFilter extends OncePerRequestFilter { + + private final UserDetailsService userDetailsService; + private final JWTUtil jwtUtil; + private final ApplicationEventPublisher events; + + @Override + protected void doFilterInternal(HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) + throws ServletException, IOException { + String path = request.getRequestURI(); + log.trace("JWTFilter processing path: {}", path); + + // Bypass filter for authentication and validation endpoints + if (EndpointRegistry.isPublicEndpoint(path)) { + log.debug("Bypassing JWTFilter for public path: {}", path); + filterChain.doFilter(request, response); + return; + } + + // Extract Authorization header + String authHeader = request.getHeader("Authorization"); + log.trace("Authorization header: {}", authHeader); + // Check for Bearer token + if (authHeader != null && !authHeader.isBlank() && authHeader.startsWith("Bearer ")) { + // Extract JWT token + String jwt = authHeader.substring(7); + log.trace("Extracted JWT token (trimmed length = {}): {}", jwt.length(), + jwt.isBlank() ? "" : ""); + if (jwt.isBlank()) { + log.warn("JWT is blank or null"); + response.sendError(HttpServletResponse.SC_BAD_REQUEST, + "Invalid JWT Token in Bearer Header"); + return; + } + try { + // Validate token and retrieve claims + Map claims = jwtUtil.validateTokenAndRetrieveClaims(jwt); + if (claims == null) { + log.warn("JWT contains no claims"); + throw new JWTVerificationException("No claims present"); + } + + log.trace("JWT claims keys: {}", claims.keySet()); + String username = claims.get("username").asString(); + if (username == null || username.isBlank()) { + log.warn("JWT contains no username/subject"); + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid JWT Token"); + return; + } + String role = claims.get("role") != null ? claims.get("role").asString() : null; + log.trace("JWT username: {}, role: {}", username, role); + + // Load User Details + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + log.trace("Loaded userDetails for {}. Authorities: {}", username, + userDetails.getAuthorities()); + + // If the user is disabled (soft-deleted or inactive), deny access now + if (!userDetails.isEnabled()) { + log.info("Rejected authentication for disabled/deleted user: {}", username); + response.sendError(HttpServletResponse.SC_FORBIDDEN, "User account is disabled"); + return; + } + + // Update user's last online timestamp +// events.publishEvent(new UserUpdateLastOnlineEvent(username)); + + // Use authorities from userDetails (database), not JWT + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + userDetails, + null, userDetails.getAuthorities()); + + // Set authentication in security context + if (SecurityContextHolder.getContext().getAuthentication() == null) { + SecurityContextHolder.getContext().setAuthentication(authToken); + log.debug("Authentication set in security context for user: {}", username); + } else { + log.debug("Authentication already present in security context for user: {}", username); + } + } catch (JWTVerificationException e) { + log.warn("JWT verification failed: {}", e.getMessage()); + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType("application/json"); + PrintWriter out = response.getWriter(); + out.print(ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body( + ApiResponse.error(HttpStatus.UNAUTHORIZED, "Invalid or expired JWT token").toJson()) + .getBody()); + out.flush(); + return; + } catch (Exception e) { + log.error("Unexpected error in JWTFilter", e); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authentication failed"); + return; + } + } else { + log.trace("No Bearer token found in Authorization header"); + } + + filterChain.doFilter(request, response); + } + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/config/MyRuntimeHints.java b/src/main/java/dev/ivfrost/hydro_backend/config/MyRuntimeHints.java index 09c8345..9438a99 100644 --- a/src/main/java/dev/ivfrost/hydro_backend/config/MyRuntimeHints.java +++ b/src/main/java/dev/ivfrost/hydro_backend/config/MyRuntimeHints.java @@ -1,51 +1,47 @@ package dev.ivfrost.hydro_backend.config; -import dev.ivfrost.hydro_backend.entity.User; -import org.springframework.aot.hint.MemberCategory; -import org.springframework.aot.hint.RuntimeHintsRegistrar; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import dev.ivfrost.hydro_backend.dto.UserRegisterRequest; -import dev.ivfrost.hydro_backend.dto.UserLoginRequest; -import dev.ivfrost.hydro_backend.dto.DeviceLinkRequest; -import dev.ivfrost.hydro_backend.dto.DeviceProvisionRequest; +import dev.ivfrost.hydro_backend.devices.DeviceLinkRequest; +import dev.ivfrost.hydro_backend.devices.DeviceProvisionRequest; +import dev.ivfrost.hydro_backend.users.UserLoginRequest; +import dev.ivfrost.hydro_backend.users.UserRegisterRequest; +import dev.ivfrost.hydro_backend.users.internal.User; +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHintsRegistrar; import org.springframework.aot.hint.TypeReference; // Specify to Spring AOT that these classes will be need to be accessed via reflection public class MyRuntimeHints implements RuntimeHintsRegistrar { - @Override - public void registerHints(org.springframework.aot.hint.RuntimeHints hints, ClassLoader classLoader) { - hints.reflection().registerType(JsonNode.class); - hints.reflection().registerType(ObjectMapper.class); - hints.reflection().registerType( - TypeReference.of("org.springframework.core.annotation.TypeMappedAnnotation[]"), - MemberCategory.INVOKE_DECLARED_CONSTRUCTORS - ); - // Register DTOs for reflection - hints.reflection().registerType(DeviceLinkRequest.class, - MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, - MemberCategory.INVOKE_DECLARED_METHODS, - MemberCategory.DECLARED_FIELDS); - hints.reflection().registerType(DeviceProvisionRequest.class, - MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, - MemberCategory.INVOKE_DECLARED_METHODS, - MemberCategory.DECLARED_FIELDS); - hints.reflection().registerType(UserRegisterRequest.class, - MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, - MemberCategory.INVOKE_DECLARED_METHODS, - MemberCategory.DECLARED_FIELDS); - hints.reflection().registerType(UserLoginRequest.class, - MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, - MemberCategory.INVOKE_DECLARED_METHODS, - MemberCategory.DECLARED_FIELDS); + @Override + public void registerHints(org.springframework.aot.hint.RuntimeHints hints, + ClassLoader classLoader) { + hints.reflection().registerType(JsonNode.class); + hints.reflection().registerType(ObjectMapper.class); + hints.reflection() + .registerType( + TypeReference.of("org.springframework.core.annotation.TypeMappedAnnotation[]"), + MemberCategory.INVOKE_DECLARED_CONSTRUCTORS); + + // Register DTOs for reflection + hints.reflection() + .registerType(DeviceLinkRequest.class, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, + MemberCategory.INVOKE_DECLARED_METHODS, MemberCategory.DECLARED_FIELDS); + hints.reflection() + .registerType(DeviceProvisionRequest.class, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, + MemberCategory.INVOKE_DECLARED_METHODS, MemberCategory.DECLARED_FIELDS); + hints.reflection() + .registerType(UserRegisterRequest.class, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, + MemberCategory.INVOKE_DECLARED_METHODS, MemberCategory.DECLARED_FIELDS); + hints.reflection() + .registerType(UserLoginRequest.class, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, + MemberCategory.INVOKE_DECLARED_METHODS, MemberCategory.DECLARED_FIELDS); + + // Validation of individual entity fields with reflection + hints.reflection() + .registerType(User.class, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, + MemberCategory.INVOKE_DECLARED_METHODS, MemberCategory.DECLARED_FIELDS); + } - // Validation of individual entity fields with reflection - hints.reflection().registerType( - User.class, - MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, - MemberCategory.INVOKE_DECLARED_METHODS, - MemberCategory.DECLARED_FIELDS - ); - } } diff --git a/src/main/java/dev/ivfrost/hydro_backend/config/OpenApiConfig.java b/src/main/java/dev/ivfrost/hydro_backend/config/OpenApiConfig.java index 3485010..cbcbb33 100644 --- a/src/main/java/dev/ivfrost/hydro_backend/config/OpenApiConfig.java +++ b/src/main/java/dev/ivfrost/hydro_backend/config/OpenApiConfig.java @@ -5,23 +5,17 @@ import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; import io.swagger.v3.oas.annotations.info.Info; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityScheme; -import io.swagger.v3.oas.annotations.servers.Server; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; + @Configuration @OpenAPIDefinition( - info = @Info(title = "Hydro Backend API", version = "v1"), - security = @SecurityRequirement(name = "bearerAuth"), - servers = {@Server(url = "${server.servlet.context-path}", description = "Default Server URL")} -) -@SecurityScheme( - name = "bearerAuth", - type = SecuritySchemeType.HTTP, - scheme = "bearer", - bearerFormat = "JWT" + info = @Info(title = "Hydro API", + version = "v1"), + security = @SecurityRequirement(name = "bearerAuth") ) +@SecurityScheme(name = "bearerAuth", type = SecuritySchemeType.HTTP, scheme = "bearer", bearerFormat = "JWT") @EnableMethodSecurity(prePostEnabled = true) public class OpenApiConfig { -} - +} \ No newline at end of file diff --git a/src/main/java/dev/ivfrost/hydro_backend/config/RateLimitConfig.java b/src/main/java/dev/ivfrost/hydro_backend/config/RateLimitConfig.java deleted file mode 100644 index a6ff578..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/config/RateLimitConfig.java +++ /dev/null @@ -1,17 +0,0 @@ -package dev.ivfrost.hydro_backend.config; - -import io.github.bucket4j.Bucket; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; - -@Configuration -public class RateLimitConfig { - - @Bean - public ConcurrentMap buckets() { - return new ConcurrentHashMap<>(); - } -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/config/SecurityConfig.java b/src/main/java/dev/ivfrost/hydro_backend/config/SecurityConfig.java index 5be405a..039140a 100644 --- a/src/main/java/dev/ivfrost/hydro_backend/config/SecurityConfig.java +++ b/src/main/java/dev/ivfrost/hydro_backend/config/SecurityConfig.java @@ -1,128 +1,94 @@ package dev.ivfrost.hydro_backend.config; -import dev.ivfrost.hydro_backend.security.JWTFilter; -import dev.ivfrost.hydro_backend.service.MyUserDetailsService; -import jakarta.servlet.http.HttpServletResponse; -import lombok.AllArgsConstructor; -import lombok.NonNull; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.web.servlet.config.annotation.CorsRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - import static org.springframework.security.config.Customizer.withDefaults; -@AllArgsConstructor +import dev.ivfrost.hydro_backend.users.MyUserDetailsService; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Arrays; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + + +@Slf4j @Configuration +@AllArgsConstructor @EnableWebSecurity public class SecurityConfig { - private final MyUserDetailsService userDetailsService; - private final JWTFilter jwtFilter; - private final String[] allowedOrigins = { - "https://netoasis.app", - "87.223.194.213", - "http://localhost:5173" - }; + private final MyUserDetailsService userDetailsService; + private final JWTFilter jwtFilter; + @Value("${cors.allowed-origins}") + private String[] allowedOrigins; - @Bean - public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception { - System.out.println("Configuring security filter chain"); - http - .csrf(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - .cors(withDefaults()) - .addFilterBefore(this.jwtFilter, UsernamePasswordAuthenticationFilter.class) - .authorizeHttpRequests(req -> req - .requestMatchers( - "/docs/", - "/docs/**", - "/v1/api/**", - "/v2/api-docs", - "/v3/api-docs", - "/v3/api-docs/**", - "/swagger-resources", - "/swagger-resources/**", - "/configuration/ui", - "/configuration/security", - "/swagger-ui/**", - "/webjars/**", - "/swagger-ui.html", - "/v1/users", - "/v1/users/auth", - "/v1/users/password/reset", - "/v1/validation", - "/v1/validation/**", - "/v1/health" - ).permitAll() - .requestMatchers( - "/v1/me/**", - "/v1/users/**", - "/v1/devices/**" - ).hasAnyRole("USER", "ADMIN") - .anyRequest().authenticated() - ) - .userDetailsService(this.userDetailsService) - .exceptionHandling(e -> e.authenticationEntryPoint((request, response, authException) -> - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"))) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); - System.out.println("Security filter chain configured"); - return http.build(); - } + @Bean + public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception { + log.info("Configuring security filter chain..."); + http.csrf(AbstractHttpConfigurer::disable) + .httpBasic(HttpBasicConfigurer::disable) + // Enable CORS + .cors(withDefaults()) + // Disable frame options for H2 console in dev + .headers(h -> h.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) + // Run JWTFilter in place of UsernamePasswordAuthenticationFilter +// .addFilterBefore(authRequestCountFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) + .authorizeHttpRequests(req -> req + .requestMatchers(EndpointRegistry.H2_CONSOLE.toArray(new String[0])) + .permitAll() + .requestMatchers(EndpointRegistry.SWAGGER.toArray(new String[0])) + .permitAll() + .requestMatchers(EndpointRegistry.APP_PUBLIC.toArray(new String[0])) + .permitAll() + .requestMatchers(EndpointRegistry.APP_AUTHENTICATED.toArray(new String[0])) + .hasAnyRole("USER", "ADMIN") + .anyRequest() + .authenticated()) + .userDetailsService(this.userDetailsService) + .exceptionHandling( + e -> e.authenticationEntryPoint( + (request, response, authException) -> + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"))) + .sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + log.info("Security filter chain configured successfully."); + return http.build(); + } - // Authentication manager bean to be used in AuthController - @Bean - public AuthenticationManager authenticationManager(final AuthenticationConfiguration authenticationConfiguration) throws Exception { - return authenticationConfiguration.getAuthenticationManager(); - } + // Conform to the best password encoding practices (bcrypt) + @Bean + PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } - // Password encoder bean (BCrypt) - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } + // Provide the CorsConfigurationSource bean referenced by http.cors(withDefaults()). +// Allow front-end to make requests to the API. + @Bean + CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Arrays.asList(allowedOrigins)); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } - // CORS configuration - @Bean - public WebMvcConfigurer corsConfigurer() { - return new WebMvcConfigurer() { - @Override - public void addCorsMappings(@NonNull final CorsRegistry registry) { - registry.addMapping("/v1/validation") - .allowedOrigins(allowedOrigins) - .allowedMethods("*"); - registry.addMapping("/v1/validation/**") - .allowedOrigins(allowedOrigins) - .allowedMethods("*"); - registry.addMapping("/v1/api/**") - .allowedOrigins(allowedOrigins) - .allowedMethods("*"); - registry.addMapping("/v1/users") - .allowedOrigins(allowedOrigins) - .allowedMethods("*"); - registry.addMapping("/v1/users/**") - .allowedOrigins(allowedOrigins) - .allowedMethods("*"); - registry.addMapping("/v1/me/**") - .allowedOrigins(allowedOrigins) - .allowedMethods("*"); - registry.addMapping("/v1/devices/**") - .allowedOrigins(allowedOrigins) - .allowedMethods("*"); - registry.addMapping("/v1/health") - .allowedOrigins(allowedOrigins) - .allowedMethods("*"); - } - }; - } } diff --git a/src/main/java/dev/ivfrost/hydro_backend/controller/AuthController.java b/src/main/java/dev/ivfrost/hydro_backend/controller/AuthController.java deleted file mode 100644 index 24113e4..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/controller/AuthController.java +++ /dev/null @@ -1,86 +0,0 @@ -package dev.ivfrost.hydro_backend.controller; - -import dev.ivfrost.hydro_backend.dto.*; -import dev.ivfrost.hydro_backend.service.UserService; -import dev.ivfrost.hydro_backend.util.RateLimitUtils; -import io.github.bucket4j.Bucket; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.Valid; -import lombok.AllArgsConstructor; -import org.springdoc.webmvc.core.service.RequestService; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.util.Optional; - -@Tag(name = "User Authentication", description = "API endpoints for user authentication") -@AllArgsConstructor -@RestController -@RequestMapping("/v1/") -public class AuthController { - - private final UserService userService; - private final RateLimitUtils rateLimitUtils; - private final RequestService requestService; - - //======= NON-AUTHENTICATED USERS ENDPOINTS =======// - - // Data provision - @Operation( - summary = "Register a new user", - description = "Creates a new user account." - ) - @PostMapping("/users") - public ResponseEntity> registerUser( - @Valid @RequestBody UserRegisterRequest userRegisterRequest, HttpServletRequest req) { - - Optional bucketOpt = rateLimitUtils - .getBucketByUserOrIp(userService.getCurrentUser(), RateLimitUtils.extractClientIp(req)); - if (bucketOpt.isEmpty() || !bucketOpt.get().tryConsume(5)) { - return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) - .body(ApiResponse.build(HttpStatus.TOO_MANY_REQUESTS, "Too many requests - rate limit exceeded", null)); - } - - UserRegisterResponse recoveryCodes = userService.addUser(userRegisterRequest); - return ResponseEntity - .status(HttpStatus.CREATED) - .body(ApiResponse.build(HttpStatus.CREATED, "User registered successfully", recoveryCodes)); - } - - @Operation( - summary = "Authenticate user", - description = "Authenticates a user and returns a JWT token." - ) - @PostMapping("/users/auth") - public ResponseEntity> authenticateUser( - @Valid @RequestBody UserLoginRequest userLoginRequest, HttpServletRequest req) { - - Optional bucketOpt = rateLimitUtils - .getBucketByUserOrIp(userService.getCurrentUser(), RateLimitUtils.extractClientIp(req)); - if (bucketOpt.isEmpty() || !bucketOpt.get().tryConsume(2)) { - return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) - .body(ApiResponse.build(HttpStatus.TOO_MANY_REQUESTS, "", null)); - } - - AuthResponse authResponse = userService.authenticateUser(userLoginRequest); - ApiResponse response = ApiResponse.build(HttpStatus.OK, "User authenticated successfully", authResponse); - return ResponseEntity.ok(response); - } - - @Operation( - summary = "Refresh JWT token", - description = "Refreshes the JWT token for an authenticated user." - ) - @PostMapping("/users/refresh") - public ResponseEntity> refreshToken() { - AuthResponse authResponse = userService.refreshTokens(); - ApiResponse response = ApiResponse.build(HttpStatus.OK, "Token refreshed successfully", authResponse); - return ResponseEntity.ok(response); - } -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/controller/DeviceAuthController.java b/src/main/java/dev/ivfrost/hydro_backend/controller/DeviceAuthController.java deleted file mode 100644 index eb12e6d..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/controller/DeviceAuthController.java +++ /dev/null @@ -1,45 +0,0 @@ -package dev.ivfrost.hydro_backend.controller; - -import dev.ivfrost.hydro_backend.dto.ApiResponse; -import dev.ivfrost.hydro_backend.dto.DeviceLinkRequest; -import dev.ivfrost.hydro_backend.dto.MqttCredentialsResponse; -import dev.ivfrost.hydro_backend.service.DeviceService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.AllArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@AllArgsConstructor -@Tag(name = "Device Authentication", description = "API endpoints for proving device ownership and getting credentials") -@RestController -@RequestMapping("/v1") -public class DeviceAuthController { - - private final DeviceService deviceService; - - @Operation( - summary = "Link device to authenticated user", - description = "Links a device to the currently authenticated user using the device's ownership hash." - ) - @PostMapping("/me/devices/link") - public ResponseEntity> linkDevice(@RequestParam String hash) { - deviceService.linkDevice(new DeviceLinkRequest(hash)); - return ResponseEntity - .status(HttpStatus.OK) - .body(ApiResponse.build(HttpStatus.OK, "Device linked to user successfully", null)); - } - - @Operation( - summary = "Get MQTT credentials", - description = "Retrieves MQTT credentials for the currently authenticated user." - ) - @GetMapping("/me/devices/credentials") - public ResponseEntity> getMqttCredentials() { - MqttCredentialsResponse credentials = deviceService.getMqttCredentials(); - return ResponseEntity - .status(HttpStatus.OK) - .body(ApiResponse.build(HttpStatus.OK, "MQTT credentials retrieved successfully", credentials)); - } -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/controller/DeviceController.java b/src/main/java/dev/ivfrost/hydro_backend/controller/DeviceController.java deleted file mode 100644 index 6d22d24..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/controller/DeviceController.java +++ /dev/null @@ -1,149 +0,0 @@ -package dev.ivfrost.hydro_backend.controller; - -import dev.ivfrost.hydro_backend.dto.*; -import dev.ivfrost.hydro_backend.service.DeviceService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.AllArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -@Tag(name = "Device Management", description = "API endpoints for managing devices") -@AllArgsConstructor -@RestController -@RequestMapping("/v1") -public class DeviceController { - - private final DeviceService deviceService; - - - @PreAuthorize("hasRole('ADMIN')") - @Operation( - summary = "Link device to user by ID (Admin only)", - description = "Links a device to a specific user by their unique ID using the device's ownership hash. Access restricted to administrators." - ) - @PostMapping("/users/{userId}/devices/link") - public ResponseEntity> linkDeviceById( - @RequestBody @Valid DeviceLinkRequest linkDeviceRequest, - @PathVariable Long userId) { - - deviceService.linkDevice(linkDeviceRequest, userId); - return ResponseEntity - .status(HttpStatus.OK) - .body(ApiResponse.build(HttpStatus.OK, "Device linked to user successfully", null)); - } - - @Operation( - summary = "Unlink device from authenticated user", - description = "Unlinks a device from the currently authenticated user using the device's unique ID." - ) - @DeleteMapping("/me/devices/{deviceId}/unlink") - public ResponseEntity> unlinkDevice(@PathVariable Long deviceId) { - deviceService.unlinkDevice(deviceId); - return ResponseEntity - .status(HttpStatus.OK) - .body(ApiResponse.build(HttpStatus.OK, "Device unlinked from user successfully", null)); - } - - @Operation( - summary = "Get linked devices", - description = "Retrieves all devices linked to the currently authenticated user." - ) - @GetMapping("/me/devices") - public ResponseEntity>> getUserDevices() { - List response = deviceService.getUserDevices(); - return ResponseEntity - .status(HttpStatus.OK) - .body(ApiResponse.build(HttpStatus.OK, "Devices retrieved successfully", response)); - } - - @PreAuthorize("hasRole('ADMIN')") - @GetMapping("/users/{userId}/devices") - @Operation( - summary = "Get devices by user ID (Admin only)", - description = "Retrieves all devices linked to a specific user by their unique ID." - ) - public ResponseEntity>> getUserDevicesById(@PathVariable Long userId) { - List response = deviceService.getUserDevicesById(userId); - return ResponseEntity - .status(HttpStatus.OK) - .body(ApiResponse.build(HttpStatus.OK, "Devices retrieved successfully", response)); - } - - @PreAuthorize("hasRole('ADMIN')") - @Operation( - summary = "Get all provisioned devices (Admin only)", - description = "Retrieves all devices provisioned in the system." - ) - @GetMapping("/devices") - public ResponseEntity>> getAllDevices() { - List response = deviceService.getAllDevices(); - return ResponseEntity - .status(HttpStatus.OK) - .body(ApiResponse.build(HttpStatus.OK, "All devices retrieved successfully", response)); - } - - @PreAuthorize("hasRole('ADMIN')") - @Operation( - summary = "Provision new device (Admin only)", - description = "Provisions a new device in the system." - ) - @PostMapping("/devices") - public ResponseEntity> provisionDevice(@RequestBody @Valid DeviceProvisionRequest req) { - DeviceResponse device = deviceService.provisionDevice(req); - return ResponseEntity - .status(HttpStatus.CREATED) - .body(ApiResponse.build(HttpStatus.CREATED, "Device provisioned successfully", device)); - } - - @Operation( - summary = "Update order or user-defined name of a device", - description = "Updates the display order or user-defined name of a device linked to the authenticated user." - ) - @PutMapping("/me/devices/{deviceId}") - public ResponseEntity> updateUserDeviceById( - @PathVariable Long deviceId, - @RequestBody @Valid DeviceUpdateRequest req) { - - DeviceResponse updatedDevice = deviceService.updateUserDeviceById(deviceId, req); - return ResponseEntity - .status(HttpStatus.OK) - .body(ApiResponse.build(HttpStatus.OK, "Device updated successfully", updatedDevice)); - } - - @PreAuthorize("hasRole('ADMIN')") - @Operation( - summary = "Update device by ID (Admin only)", - description = "Updates the details of a device by its unique ID." - ) - @PutMapping("/devices/{deviceId}") - public ResponseEntity> updateDeviceById( - @PathVariable Long deviceId, - @RequestBody @Valid DeviceUpdateRequest req, - @RequestParam String technicalName, - @RequestParam String firmware) { - - DeviceResponse updatedDevice = deviceService.updateDeviceById(deviceId, req, technicalName, firmware); - return ResponseEntity - .status(HttpStatus.OK) - .body(ApiResponse.build(HttpStatus.OK, "Device updated successfully", updatedDevice)); - } - - @PreAuthorize("hasRole('ADMIN')") - @Operation( - summary = "Delete device by ID (Admin only)", - description = "Deletes a device from the system by its unique ID." - ) - @DeleteMapping("/devices/{deviceId}") - public ResponseEntity> deleteDeviceById(@PathVariable Long deviceId) { - deviceService.deleteDeviceById(deviceId); - return ResponseEntity - .status(HttpStatus.OK) - .body(ApiResponse.build(HttpStatus.OK, "Device deleted successfully", null)); - } -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/controller/GlobalExceptionHandler.java b/src/main/java/dev/ivfrost/hydro_backend/controller/GlobalExceptionHandler.java deleted file mode 100644 index d404e06..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/controller/GlobalExceptionHandler.java +++ /dev/null @@ -1,113 +0,0 @@ -package dev.ivfrost.hydro_backend.controller; - -import com.auth0.jwt.exceptions.JWTCreationException; -import com.auth0.jwt.exceptions.JWTVerificationException; -import dev.ivfrost.hydro_backend.dto.ApiResponse; -import dev.ivfrost.hydro_backend.exception.*; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.Map; - -@RestControllerAdvice -public class GlobalExceptionHandler { - - @ExceptionHandler(UserDeletedException.class) - public ResponseEntity> handleUserDeletedException(UserDeletedException ex) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).body( - ApiResponse.build(HttpStatus.FORBIDDEN, ex.getMessage(), LocalDateTime.now()) - ); - } - - @ExceptionHandler(UserNotFoundException.class) - public ResponseEntity> handleUserNotFoundException(UserNotFoundException ex) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body( - ApiResponse.build(HttpStatus.NOT_FOUND, ex.getMessage(), LocalDateTime.now()) - ); - } - - @ExceptionHandler(UserNotAuthenticatedException.class) - public ResponseEntity> handleUserNotAuthenticatedException(UserNotAuthenticatedException ex) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body( - ApiResponse.build(HttpStatus.UNAUTHORIZED, ex.getMessage(), LocalDateTime.now()) - ); - } - - @ExceptionHandler(TokenNotFoundException.class) - public ResponseEntity> handleTokenNotFoundException(TokenNotFoundException ex) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body( - ApiResponse.build(HttpStatus.NOT_FOUND, ex.getMessage(), LocalDateTime.now()) - ); - } - - @ExceptionHandler(ExpiredVerificationToken.class) - public ResponseEntity> handleExpiredVerificationToken(ExpiredVerificationToken ex) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body( - ApiResponse.build(HttpStatus.BAD_REQUEST, ex.getMessage(), LocalDateTime.now()) - ); - } - - @ExceptionHandler(JWTCreationException.class) - public ResponseEntity> handleJWTCreationException(JWTCreationException ex) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( - ApiResponse.build(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage(), LocalDateTime.now()) - ); - } - - @ExceptionHandler(JWTVerificationException.class) - public ResponseEntity> handleJWTVerificationException(JWTVerificationException ex) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body( - ApiResponse.build(HttpStatus.UNAUTHORIZED, ex.getMessage(), LocalDateTime.now()) - ); - } - - @ExceptionHandler(DeviceLinkException.class) - public ResponseEntity> handleDeviceLinkException(DeviceLinkException ex) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body( - ApiResponse.build(HttpStatus.BAD_REQUEST, ex.getMessage(), LocalDateTime.now()) - ); - } - - @ExceptionHandler(DeviceFetchException.class) - public ResponseEntity> handleDeviceFetchException(DeviceFetchException ex) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body( - ApiResponse.build(HttpStatus.NOT_FOUND, ex.getMessage(), LocalDateTime.now()) - ); - } - - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidationException(MethodArgumentNotValidException ex) { - // Collect field errors into a map - Map errors = new HashMap<>(); - ex.getBindingResult().getFieldErrors().forEach(error -> - errors.put(error.getField(), error.getDefaultMessage()) - ); - String message = "Validation failed for one or more fields."; - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body( - ApiResponse.build(HttpStatus.BAD_REQUEST, message, errors) - ); - } - - @ExceptionHandler(RecoveryTokenNotFoundException.class) - public ResponseEntity> handleRecoveryCodeNotFoundException( - RecoveryTokenNotFoundException ex) { - - return ResponseEntity.status(HttpStatus.NOT_FOUND).body( - ApiResponse.build(HttpStatus.NOT_FOUND, ex.getMessage(), LocalDateTime.now()) - ); - } - - @ExceptionHandler(RecoveryTokenMismatchException.class) - public ResponseEntity> handleRecoveryCodeMismatchException( - RecoveryTokenMismatchException ex) { - - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body( - ApiResponse.build(HttpStatus.BAD_REQUEST, ex.getMessage(), LocalDateTime.now()) - ); - } -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/controller/UserController.java b/src/main/java/dev/ivfrost/hydro_backend/controller/UserController.java deleted file mode 100644 index 6d745cc..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/controller/UserController.java +++ /dev/null @@ -1,153 +0,0 @@ -package dev.ivfrost.hydro_backend.controller; - -import dev.ivfrost.hydro_backend.dto.*; -import dev.ivfrost.hydro_backend.entity.User; -import dev.ivfrost.hydro_backend.service.UserService; -import dev.ivfrost.hydro_backend.util.RateLimitUtils; -import io.github.bucket4j.Bucket; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.Valid; -import lombok.AllArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; - -import java.util.Optional; - -@Tag(name = "User Management", description = "API endpoints for managing users") -@AllArgsConstructor -@RestController -@RequestMapping("/v1") -public class UserController { - - private final UserService userService; - private final RateLimitUtils rateLimitUtils; - - //======= NON-AUTHENTICATED USERS ENDPOINTS =======// - - // Data modification - @Operation( - summary = "Reset user password", - description = "Resets the user's password using one of the recovery codes provided on registration" - ) - @PutMapping("/users/password/reset") - public ResponseEntity> resetPassword( - @Valid @RequestBody PasswordResetRequest passwordResetConfirmRequest, HttpServletRequest req) { - - Optional bucketOpt = rateLimitUtils - .getBucketByUserOrIp(userService.getCurrentUser(), RateLimitUtils.extractClientIp(req)); - if (bucketOpt.isEmpty() || !bucketOpt.get().tryConsume(3)) { - return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) - .body(ApiResponse.build(HttpStatus.TOO_MANY_REQUESTS, "", null)); - } - userService.resetPassword(passwordResetConfirmRequest); - return ResponseEntity - .status(HttpStatus.OK) - .body(ApiResponse.build(HttpStatus.OK, "Password has been reset successfully", null)); - } - - - //======= AUTHENTICATED USERS ENDPOINTS =======// - - // Data retrieval - @Operation( - summary = "Get authenticated user's profile", - description = "Retrieves the profile of the currently authenticated user." - ) - @GetMapping("/me") - public ResponseEntity> getCurrentUserProfile() { - UserResponse userResponse = userService.getCurrentUserProfile(); - return ResponseEntity - .status(HttpStatus.OK) - .body(ApiResponse.build(HttpStatus.OK, "User profile retrieved successfully", userResponse)); - } - - // Data modification - @Operation( - summary = "Update user's account settings", - description = "Updates the account settings of the currently authenticated user." - ) - @PutMapping("/me") - public ResponseEntity> updateCurrentUser( - @Valid @RequestBody UserUpdateRequest userUpdateRequest, HttpServletRequest req) { - Optional bucketOpt = rateLimitUtils - .getBucketByUserOrIp(userService.getCurrentUser(), RateLimitUtils.extractClientIp(req)); - if (bucketOpt.isEmpty() || !bucketOpt.get().tryConsume(1)) { - return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) - .body(ApiResponse.build(HttpStatus.TOO_MANY_REQUESTS, - "", null)); - } - UserResponse updatedUser = userService.updateCurrentUser(userUpdateRequest); - return ResponseEntity - .status(HttpStatus.OK) - .body(ApiResponse.build(HttpStatus.OK, "User profile updated successfully", updatedUser)); - } - - // Data removal - @Operation( - summary = "Delete authenticated user", - description = "Deletes the currently authenticated user (soft delete)." - ) - @DeleteMapping("/me") - public ResponseEntity> deleteCurrentUser(HttpServletRequest req) { - - Optional bucketOpt = rateLimitUtils - .getBucketByUserOrIp(userService.getCurrentUser(), RateLimitUtils.extractClientIp(req)); - if (bucketOpt.isEmpty() || !bucketOpt.get().tryConsume(2)) { - return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) - .body(ApiResponse.build(HttpStatus.TOO_MANY_REQUESTS, - "", null)); - } - userService.deleteCurrentUser(); - return ResponseEntity - .status(HttpStatus.NO_CONTENT) - .body(ApiResponse.build(HttpStatus.NO_CONTENT, "User deleted successfully", null)); - } - - //======= ADMIN-ONLY ENDPOINTS =======// - - // Data provision - @PreAuthorize("hasRole('ADMIN')") - @Operation( - summary = "Register a new user (Admin only)", - description = "Creates a new user account at admin's discretion and returns a JWT token. Allows setting user role." - ) - @PostMapping("/users/new") - public ResponseEntity> registerUsersAdmin( - @Valid @RequestBody UserRegisterRequest req, @RequestParam(required = false) User.Role role) { - return ResponseEntity - .status(HttpStatus.CREATED) - .body(ApiResponse.build(HttpStatus.CREATED, "User registered successfully", null)); - } - - // Data retrieval - @PreAuthorize("hasRole('ADMIN')") - @Operation( - summary = "Get user profile by ID (Admin only)", - description = "Retrieves a user profile by ID." - ) - @GetMapping("/users/{userId}") - public ResponseEntity> getUserProfileById(@PathVariable Long userId) { - UserResponse userResponse = userService.getUserProfileById(userId); - return ResponseEntity - .status(HttpStatus.OK) - .body(ApiResponse.build(HttpStatus.OK, "User profile retrieved successfully", userResponse)); - } - - // Data removal - @PreAuthorize("hasRole('ADMIN')") - @Operation( - summary = "Delete user by ID (Admin only)", - description = "Deletes a user by ID (soft delete)." - ) - @DeleteMapping("/users/{userId}") - public ResponseEntity> deleteUserById(@PathVariable Long userId) { - userService.deleteUserById(userId); - return ResponseEntity - .status(HttpStatus.NO_CONTENT) - .body(ApiResponse.build(HttpStatus.NO_CONTENT, "User deleted successfully", null)); - } -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/controller/ValidationController.java b/src/main/java/dev/ivfrost/hydro_backend/controller/ValidationController.java deleted file mode 100644 index 8311c3e..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/controller/ValidationController.java +++ /dev/null @@ -1,123 +0,0 @@ -package dev.ivfrost.hydro_backend.controller; - -import dev.ivfrost.hydro_backend.dto.*; -import dev.ivfrost.hydro_backend.repository.UserTokenRepository; -import dev.ivfrost.hydro_backend.service.UserService; -import dev.ivfrost.hydro_backend.service.UserTokenService; -import dev.ivfrost.hydro_backend.util.RateLimitUtils; -import dev.ivfrost.hydro_backend.util.ValidationUtils; -import io.github.bucket4j.Bucket; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import lombok.AllArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -@Tag(name = "Validation ", description = "API endpoints for validating data") -@AllArgsConstructor -@RequestMapping("/v1/validation") -@RestController -public class ValidationController { - - private final UserTokenRepository userTokenRepository; - ValidationUtils validationUtils; - HashMap buckets; - RateLimitUtils rateLimitUtils; - UserService userService; - UserTokenService userTokenService; - - @Operation(summary = "Get validation rules for a specific class") - @GetMapping("/rules") - public ResponseEntity getClassValidationRules(@RequestParam String className, HttpServletRequest req) { - Optional bucketOpt = rateLimitUtils - .getBucketByUserOrIp(userService.getCurrentUser(), RateLimitUtils.extractClientIp(req)); - if (bucketOpt.isEmpty() || !bucketOpt.get().tryConsume(1)) { - return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) - .body(ApiResponse.build(HttpStatus.TOO_MANY_REQUESTS, "", null)); - } - - Map rules; - String message; - switch (className) { - case "UserRegisterRequest" -> { - rules = validationUtils.getClassValidationRules(UserRegisterRequest.class); - message = "User register validation rules"; - } - case "UserLoginRequest" -> { - rules = validationUtils.getClassValidationRules(UserLoginRequest.class); - message = "User login validation rules"; - } - case "DeviceProvisionRequest" -> { - rules = validationUtils.getClassValidationRules(DeviceProvisionRequest.class); - message = "Device provision validation rules"; - } - case "DeviceLinkRequest" -> { - rules = validationUtils.getClassValidationRules(DeviceLinkRequest.class); - message = "Device link validation rules"; - } - default -> { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(ApiResponse.build(HttpStatus.BAD_REQUEST, "Invalid field", null)); - } - } - return ResponseEntity.ok(ApiResponse.build(HttpStatus.OK, message, rules)); - } - - @Operation(summary = "Get availability of a username or email") - @GetMapping("/availability") - public ResponseEntity checkUsernameEmailAvailability( - @RequestParam(required = false) String username, - @RequestParam(required = false) String email, - HttpServletRequest req) { - - if (username == null && email == null) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(ApiResponse.build(HttpStatus.BAD_REQUEST, "Either username or email must be provided", null)); - } - if (username != null && email != null) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(ApiResponse.build(HttpStatus.BAD_REQUEST, "Only one of username or email must be provided", null)); - } - boolean isAvailable; - String field; - if (username != null) { - isAvailable = validationUtils.isUsernameAvailable(username); - field = "username"; - } else { - isAvailable = validationUtils.isEmailAvailable(email); - field = "email"; - } - String message = isAvailable ? field + " is available" : field + " is already taken"; - return ResponseEntity.ok(ApiResponse.build(HttpStatus.OK, message, isAvailable)); - } - - @Operation(summary = "Get validity of recovery code for a given email") - @PostMapping("/recovery-code") - public ResponseEntity checkRecoveryCodeValidity(@RequestParam String rawCode, @RequestParam String email, - HttpServletRequest req) { - - Optional bucketOpt = rateLimitUtils - .getBucketByUserOrIp(userService.getCurrentUser(), RateLimitUtils.extractClientIp(req)); - if (bucketOpt.isEmpty() || !bucketOpt.get().tryConsume(1)) { - return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) - .body(ApiResponse.build(HttpStatus.TOO_MANY_REQUESTS, "Too many requests - rate limit exceeded", null)); - } - - boolean isValid = userTokenService.isRecoveryCodeValid(rawCode, email); - String message = isValid ? "Recovery code is valid" : "Invalid recovery code or email"; - return ResponseEntity.ok(ApiResponse.build(HttpStatus.OK, message, isValid)); - } - - @Operation(summary = "Health check endpoint") - @GetMapping("/health") - @ResponseBody - public ResponseEntity health() { - return ResponseEntity.ok(Map.of("status", "ok")); - } -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/converter/JsonNodeConverter.java b/src/main/java/dev/ivfrost/hydro_backend/converter/JsonNodeConverter.java deleted file mode 100644 index fdd455f..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/converter/JsonNodeConverter.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.ivfrost.hydro_backend.converter; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.persistence.AttributeConverter; -import jakarta.persistence.Converter; - -@Converter -public class JsonNodeConverter implements AttributeConverter { - private final ObjectMapper objectMapper = new ObjectMapper(); - - @Override - public String convertToDatabaseColumn(JsonNode attribute) { - try { - return objectMapper.writeValueAsString(attribute); - } catch (Exception e) { - throw new IllegalArgumentException("Error converting JsonNode to String", e); - } - } - - @Override - public JsonNode convertToEntityAttribute(String dbData) { - try { - return objectMapper.readTree(dbData); - } catch (Exception e) { - throw new IllegalArgumentException("Error converting String to JsonNode", e); - } - } -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceAuthRequest.java b/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceAuthRequest.java new file mode 100644 index 0000000..de8911c --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceAuthRequest.java @@ -0,0 +1,13 @@ +package dev.ivfrost.hydro_backend.devices; + +import jakarta.validation.constraints.NotBlank; + +public record DeviceAuthRequest( + @NotBlank(message = "Device ID is required") + Long deviceId, + + @NotBlank(message = "Device secret is required") + String secret +) { + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceLinkEvent.java b/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceLinkEvent.java new file mode 100644 index 0000000..749d254 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceLinkEvent.java @@ -0,0 +1,9 @@ +package dev.ivfrost.hydro_backend.devices; + +import dev.ivfrost.hydro_backend.config.AmqpConfig; +import org.springframework.modulith.events.Externalized; + +@Externalized(target = AmqpConfig.HYDRO_Q) +public record DeviceLinkEvent(DeviceLinkRequest req, Long userId, boolean unlink) { + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceLinkProvider.java b/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceLinkProvider.java new file mode 100644 index 0000000..a31b952 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceLinkProvider.java @@ -0,0 +1,20 @@ +package dev.ivfrost.hydro_backend.devices; + +public interface DeviceLinkProvider { + + /** + * Links a device to a user + * + * @param req the device link request containing the device secret + * @param userId the user ID to link the device to + */ + void linkDevice(DeviceLinkRequest req, Long userId); + + /** + * Unlinks a device from a user + * + * @param req the device link request containing the device secret + * @param userId the user ID to unlink from + */ + void unlinkDevice(DeviceLinkRequest req, Long userId); +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceLinkRequest.java b/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceLinkRequest.java new file mode 100644 index 0000000..b893792 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceLinkRequest.java @@ -0,0 +1,8 @@ +package dev.ivfrost.hydro_backend.devices; + +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotNull; + +public record DeviceLinkRequest(@NotNull Long deviceId, @Nullable String secret) { + +} \ No newline at end of file diff --git a/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceLoadEvent.java b/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceLoadEvent.java new file mode 100644 index 0000000..105a705 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceLoadEvent.java @@ -0,0 +1,9 @@ +package dev.ivfrost.hydro_backend.devices; + +import dev.ivfrost.hydro_backend.config.AmqpConfig; +import org.springframework.modulith.events.Externalized; + +@Externalized(target = AmqpConfig.HYDRO_Q) +public record DeviceLoadEvent(Long userId) { + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceOrderResponse.java b/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceOrderResponse.java new file mode 100644 index 0000000..a710e07 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceOrderResponse.java @@ -0,0 +1,7 @@ +package dev.ivfrost.hydro_backend.devices; + +import java.util.Map; + +public record DeviceOrderResponse(Map deviceOrder) { + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceProvisionRequest.java b/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceProvisionRequest.java new file mode 100644 index 0000000..e202b20 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceProvisionRequest.java @@ -0,0 +1,13 @@ +package dev.ivfrost.hydro_backend.devices; + +import jakarta.validation.constraints.Size; + +public record DeviceProvisionRequest( + @Size(max = 20) + String firmware, + @Size(max = 40) + String technicalName, + @Size(max = 17, min = 17) + String macAddress) { + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceResponse.java b/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceResponse.java new file mode 100644 index 0000000..96c2a93 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceResponse.java @@ -0,0 +1,19 @@ +package dev.ivfrost.hydro_backend.devices; + +import java.time.Instant; + +public record DeviceResponse( + Long id, + String name, + String location, + String firmware, + String technicalName, + String ip, + Instant createdAt, + Instant updatedAt, + Instant linkedAt, + Instant lastSeen, + Long userId, + Long order) { + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceTopicProvider.java b/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceTopicProvider.java new file mode 100644 index 0000000..56b837c --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceTopicProvider.java @@ -0,0 +1,14 @@ +package dev.ivfrost.hydro_backend.devices; + +import java.util.List; + +public interface DeviceTopicProvider { + + /** + * Gets MQTT topics for a user's devices + * + * @param userId the user ID + * @return list of topics in format "hydro/{SECRET}/#" + */ + List getTopicsForUser(Long userId); +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceUpdateRequest.java b/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceUpdateRequest.java new file mode 100644 index 0000000..f41cc41 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/devices/DeviceUpdateRequest.java @@ -0,0 +1,13 @@ +package dev.ivfrost.hydro_backend.devices; + +public record DeviceUpdateRequest( + + Long id, + String friendlyName, + String technicalName, + String firmware, + Long userId, + Long displayOrder +) { + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/devices/internal/Device.java b/src/main/java/dev/ivfrost/hydro_backend/devices/internal/Device.java new file mode 100644 index 0000000..c4bc56d --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/devices/internal/Device.java @@ -0,0 +1,96 @@ +package dev.ivfrost.hydro_backend.devices.internal; + +import dev.ivfrost.hydro_backend.tokens.DeviceSecretConverter; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.io.Serial; +import java.io.Serializable; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(name = "devices") +@Entity +public class Device implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Size(max = 17, min = 17) + @Column(nullable = false, name = "mac_address", unique = true) + private String macAddress; + + @Size(min = 1, max = 40) + @Column(name = "friendly_name") + private String friendlyName; + + + @Size(max = 255) + private String location; + + @Size(max = 20) + @Column(nullable = false) + private String firmware; + + @Size(max = 40) + @Column(name = "technical_name", nullable = false) + private String technicalName; + + + @Size(max = 255) + @Column(name = "secret") + @Convert(converter = DeviceSecretConverter.class) + private String secret; + + @Pattern(regexp = "^((25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}(25[0-5]|2[0-4]\\d|[01]?\\d\\d?)$") + @Size(max = 15) + @Column + private String ip; + + @CreationTimestamp + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Column(name = "linked_at") + private Instant linkedAt; + + @Column(name = "last_seen", nullable = false) + private Instant lastSeen; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "display_order") + private Long displayOrder; + + @PrePersist + protected void onCreate() { + this.createdAt = Instant.now(); + this.updatedAt = Instant.now(); + this.lastSeen = Instant.now(); + } +} \ No newline at end of file diff --git a/src/main/java/dev/ivfrost/hydro_backend/devices/internal/DeviceController.java b/src/main/java/dev/ivfrost/hydro_backend/devices/internal/DeviceController.java new file mode 100644 index 0000000..e30b37a --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/devices/internal/DeviceController.java @@ -0,0 +1,176 @@ +package dev.ivfrost.hydro_backend.devices.internal; + +import dev.ivfrost.hydro_backend.ApiResponse; +import dev.ivfrost.hydro_backend.devices.DeviceAuthRequest; +import dev.ivfrost.hydro_backend.devices.DeviceLinkRequest; +import dev.ivfrost.hydro_backend.devices.DeviceProvisionRequest; +import dev.ivfrost.hydro_backend.devices.DeviceResponse; +import dev.ivfrost.hydro_backend.devices.DeviceUpdateRequest; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import java.util.Map; +import lombok.AllArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Devices Module", description = "API endpoints for device management") +@AllArgsConstructor +@RestController +@RequestMapping("/v1") +public class DeviceController { + + private final DeviceService deviceService; + + @Hidden + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Link device to user by ID (Admin only)", + description = "Links a device to a specific user by their unique ID using the device's secret as ownership proof.") + @PostMapping("/users/{userId}/devices/link") + public ResponseEntity> linkDeviceById( + @RequestBody @Valid DeviceLinkRequest linkDeviceRequest, + @PathVariable Long userId) { + deviceService.linkDevice(linkDeviceRequest, userId, false); + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(HttpStatus.OK, "Device linked to user successfully")); + } + + @Hidden + @PreAuthorize("hasRole('ADMIN')") + @GetMapping("/users/{userId}/devices") + @Operation(summary = "Get devices by user ID (Admin only)", + description = "Retrieves all devices linked to a specific user by their unique ID.") + public ResponseEntity>> getUserDevicesById( + @PathVariable Long userId) { + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(HttpStatus.OK, "User devices retrieved successfully", + deviceService.getDevicesByUserId(userId))); + } + + @Hidden + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Get all provisioned devices (Admin only)", + description = "Retrieves all devices provisioned in the system.") + @GetMapping("/devices") + public ResponseEntity>> getAllDevices( + @ParameterObject Pageable pageable) { + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(HttpStatus.OK, "All devices retrieved successfully", + deviceService.getAllDevices(pageable))); + } + + @Hidden + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Provision new device (Admin only)", description = "Provisions a new device in the system.") + @PostMapping("/devices") + public ResponseEntity> provisionDevice( + @RequestBody @Valid DeviceProvisionRequest req) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(HttpStatus.CREATED, "Device provisioned successfully", + deviceService.provisionDevice(req))); + } + + @Hidden + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Update device by ID (Admin only)", + description = "Updates the details of a device by its unique ID.") + @PutMapping("/devices/{deviceId}") + public ResponseEntity> updateDeviceDetails + (@RequestBody @Valid DeviceUpdateRequest req, @PathVariable Long deviceId) { + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(HttpStatus.OK, "Device updated successfully", + deviceService.updateDeviceDetails(req))); + } + + @Hidden + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Delete device by ID (Admin only)", + description = "Deletes a device from the system by its unique ID.") + @DeleteMapping("/devices/{deviceId}") + public ResponseEntity> deleteDeviceById(@PathVariable Long deviceId) { + deviceService.deleteDeviceById(deviceId); + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(HttpStatus.OK, "Device deleted successfully")); + } + + @Hidden + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Get device secret by ID", + description = "Retrieves the decrypted device secret for a specific device by its ID.") + @GetMapping("/devices/{deviceId}/secret") + public ResponseEntity>> getDeviceSecret( + @PathVariable Long deviceId) { + String secret = deviceService.getSecretByDeviceId(deviceId); + Map response = Map.of( + "deviceId", deviceId.toString(), + "secret", secret != null ? secret : "" + ); + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(HttpStatus.OK, "Device secret retrieved successfully", response)); + } + + @Hidden + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Regenerate device secret (Admin only)", + description = "Generates a new secret for a device, replacing the old one.") + @PostMapping("/devices/{deviceId}/secret/regenerate") + public ResponseEntity>> regenerateDeviceSecret( + @PathVariable Long deviceId) { + String newSecret = deviceService.regenerateDeviceSecret(deviceId); + Map response = Map.of( + "deviceId", deviceId.toString(), + "newSecret", newSecret + ); + return ResponseEntity.status(HttpStatus.OK).body( + ApiResponse.success(HttpStatus.OK, "Device secret regenerated successfully", response)); + } + +// @Operation(summary = "Get device order from Redis for authenticated user", +// description = "Retrieves the device order stored in Redis for the currently authenticated user.") +// @GetMapping("/me/devices/order") +// public ResponseEntity> getDeviceOrderFromCurrentUser() { +// Map deviceOrderMap = deviceStateService.getDeviceOrderFromCurrentUser(); +// return ResponseEntity.status(HttpStatus.OK) +// .body(ApiResponse.success(HttpStatus.OK, "Device order retrieved successfully", +// new DeviceOrderResponse(deviceOrderMap))); +// } + + @Operation(summary = "Update device friendly friendlyName", + description = "Updates the friendly friendlyName of a device linked to the currently authenticated user.") + @PutMapping("/me/devices/{deviceId}") + public ResponseEntity> updateDeviceNickname( + @RequestBody @Valid DeviceUpdateRequest req) { + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(HttpStatus.OK, "Device nickname updated successfully", + deviceService.updateDeviceFriendlyName(req))); + } + + @Operation(summary = "Authenticate device for MQTT", + description = "Authenticates a device and returns a signed JWT token for MQTT broker authentication. The device uses its ID and secret as credentials.") + @PostMapping("/devices/auth/mqtt") + public ResponseEntity>> authenticateDeviceForMqtt( + @RequestBody @Valid DeviceAuthRequest req) { + String mqttToken = deviceService.getMqttAuthToken(req); + Map response = Map.of( + "deviceId", req.deviceId().toString(), + "token", mqttToken + ); + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(HttpStatus.OK, "Device MQTT token generated successfully", + response)); + } +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/devices/internal/DeviceRepository.java b/src/main/java/dev/ivfrost/hydro_backend/devices/internal/DeviceRepository.java new file mode 100644 index 0000000..766a8f2 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/devices/internal/DeviceRepository.java @@ -0,0 +1,14 @@ +package dev.ivfrost.hydro_backend.devices.internal; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DeviceRepository extends JpaRepository { + + boolean existsByMacAddress(String macAddress); + + List findAllByUserIdIsNull(); + + List findAllByUserId(Long userId); + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/devices/internal/DeviceService.java b/src/main/java/dev/ivfrost/hydro_backend/devices/internal/DeviceService.java new file mode 100644 index 0000000..b5844e1 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/devices/internal/DeviceService.java @@ -0,0 +1,405 @@ +package dev.ivfrost.hydro_backend.devices.internal; + +import dev.ivfrost.hydro_backend.devices.DeviceAuthRequest; +import dev.ivfrost.hydro_backend.devices.DeviceLinkRequest; +import dev.ivfrost.hydro_backend.devices.DeviceLoadEvent; +import dev.ivfrost.hydro_backend.devices.DeviceProvisionRequest; +import dev.ivfrost.hydro_backend.devices.DeviceResponse; +import dev.ivfrost.hydro_backend.devices.DeviceUpdateRequest; +import dev.ivfrost.hydro_backend.exception.DeviceFetchException; +import dev.ivfrost.hydro_backend.exception.DeviceLinkException; +import dev.ivfrost.hydro_backend.exception.DeviceNotFoundException; +import dev.ivfrost.hydro_backend.exception.DuplicateMacAddressException; +import dev.ivfrost.hydro_backend.tokens.EncryptionUtil; +import dev.ivfrost.hydro_backend.tokens.MqttTokenProvider; +import dev.ivfrost.hydro_backend.users.UserMqttTokenPayload; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.modulith.events.ApplicationModuleListener; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Service +public class DeviceService { + + private final DeviceRepository deviceRepository; + private final DeviceCacheService deviceCacheService; + private final MqttTokenProvider mqttTokenProvider; + + /** + * Provisions a new device and generates a secret for ownership verification. + * + * @param req the device provision request DTO + * @return the provisioned device response DTO + * @throws DuplicateMacAddressException if a device with the same MAC address already exists + */ + @CacheEvict(value = "allDevicesCache", allEntries = true) + @Transactional + public DeviceResponse provisionDevice(DeviceProvisionRequest req) { + // Check for duplicate MAC address before attempting save + if (deviceRepository.existsByMacAddress(req.macAddress())) { + throw new DuplicateMacAddressException(req.macAddress()); + } + + Device device = convertRequestToDevice(req); + + // Generate and encrypt device secret (serves as ownership proof) + device.setSecret( + EncryptionUtil.generateRandomString(32)); // Will be auto-encrypted by converter + + return DeviceUtil.convertDeviceToResponse(deviceRepository.save(device)); + } + + /** + * Links an unlinked device to a user using the device secret as ownership proof + * + * @param req the device link request DTO (contains device secret) + * @throws DeviceLinkException if the device is already linked + * @throws DeviceNotFoundException if the device is not found + */ + @CacheEvict(value = "allDevicesCache", allEntries = true) + @Transactional + public void linkDevice(DeviceLinkRequest req, Long userId, boolean unlink) { + // Get device by ID from request, then verify secret matches + Device device = deviceRepository.findById(req.deviceId()) + .orElseThrow(() -> new DeviceNotFoundException("Device not found")); + + // Verify the secret matches (converter automatically decrypts) + if (device.getSecret() == null || !device.getSecret().equals(req.secret())) { + throw new DeviceNotFoundException("Device secret does not match"); + } + + if (!unlink) { + // Linking device + if (device.getUserId() != null) { + throw new DeviceLinkException("Device is already linked to a user"); + } + device.setUserId(userId); + device.setLinkedAt(Instant.now()); + device.setDisplayOrder(calculateDeviceOrder(userId)); + deviceRepository.save(device); + } else { + // Unlinking device + if (device.getUserId() == null || !Objects.equals(device.getUserId(), userId)) { + throw new DeviceLinkException("Device is not linked to this user"); + } + device.setUserId(null); + device.setDisplayOrder(0L); + deviceRepository.save(device); + } + } + + /** + * Verify device ownership + * + * @param userId the user to verify ownership against + * @param deviceId the ID of the device to verify + * @throws DeviceNotFoundException if the device is not found + * @throws IllegalArgumentException if the device does not belong to the specified user + */ + public void verifyDeviceOwnership(Long userId, Long deviceId) { + Device device = deviceRepository.findById(deviceId) + .orElseThrow(() -> new DeviceNotFoundException(deviceId)); + if (!Objects.equals(device.getUserId(), userId)) { + throw new IllegalArgumentException("Device does not belong to the specified user"); + } + } + + /** + * Retrieves devices owned by a specific user, by user ID (Admin only). + * + * @param userId the ID of the user whose devices are to be retrieved + * @return a list of device response DTOs + * @throws DeviceFetchException if no devices are found for the user + */ + public List getDevicesByUserId(Long userId) { + List devices = deviceRepository.findAllByUserId(userId); + log.debug("Fetched {} devices for user ID {}", devices.size(), userId); + + if (devices.isEmpty()) { + throw new DeviceFetchException("No devices found for user"); + } + return devices + .stream() + .map(DeviceUtil::convertDeviceToResponse) + .sorted(Comparator.comparing(DeviceResponse::order)) + .toList(); + } + + /** + * Retrieves all devices provisioned in the system (Admin only, paginated). + * + * @return a list of all device response DTOs + * @throws DeviceFetchException if no devices are found + */ + public Page getAllDevices(Pageable pageable) { + Page devices = deviceCacheService.getAllDevices(pageable); + if (devices.isEmpty()) { + throw new DeviceFetchException("No devices found in the system"); + } + return devices.map(DeviceUtil::convertDeviceToResponse); + } + + /** + * Updates the friendly friendlyName of a specific device by its ID. + */ + @Transactional + public DeviceResponse updateDeviceFriendlyName(DeviceUpdateRequest req) { + Long deviceId = req.id(); + Device device = deviceRepository.findById(deviceId) + .orElseThrow(() -> new DeviceNotFoundException(deviceId)); + verifyDeviceOwnership(req.userId(), deviceId); + device.setFriendlyName(req.friendlyName()); + return DeviceUtil.convertDeviceToResponse(deviceRepository.save(device)); + } + + + /** + * Updates fields of a specific device by its ID. + * + * @param req the device update request DTO + * @return the updated device response DTO + * @throws DeviceNotFoundException if the device is not found + * @throws IllegalArgumentException if the device does not belong to the user + */ + public DeviceResponse updateDeviceDetails(DeviceUpdateRequest req) { + Long deviceId = req.id(); + Device device = deviceRepository.findById(deviceId).orElseThrow( + () -> new DeviceNotFoundException(deviceId)); + + // Verify ownership + if (!Objects.equals(device.getUserId(), req.userId())) { + throw new IllegalArgumentException("Device does not belong to the user"); + } + String technicalName = req.technicalName(); + String firmware = req.firmware(); + String name = req.friendlyName(); + + if (technicalName != null && !technicalName.isEmpty()) { + device.setTechnicalName(technicalName); + } + if (firmware != null && !firmware.isEmpty()) { + device.setFirmware(firmware); + } + if (name != null && !name.isEmpty()) { + device.setFriendlyName(name); + } + + return DeviceUtil.convertDeviceToResponse(deviceRepository.save(device)); + } + + /** + * Delete a device by its ID (Admin only). + * + * @param deviceId the ID of the device to delete + * @throws DeviceNotFoundException if the device is not found + */ + public void deleteDeviceById(Long deviceId) { + Device device = deviceRepository.findById(deviceId) + .orElseThrow(() -> new DeviceNotFoundException(deviceId)); + deviceRepository.delete(device); + } + + /** + * Persists the device order stored in Redis to the main database for the authenticated user. + */ + @Transactional + public void saveDeviceOrder(DeviceUpdateRequest req) { + Device device = deviceRepository.findById(req.id()) + .orElseThrow(() -> new DeviceNotFoundException(req.id())); + verifyDeviceOwnership(req.userId(), req.id()); + device.setDisplayOrder(req.displayOrder()); + deviceRepository.save(device); + } + + /** + * Authenticates a device and returns an MQTT JWT token. Device publishes to the topic: + * hydro/{device-secret}/* + */ + public String getMqttAuthToken(DeviceAuthRequest req) { + // Load device by ID and verify secret matches + Device device = deviceRepository.findById(req.deviceId()) + .orElseThrow(() -> new DeviceNotFoundException("Device not found")); + + // Verify the secret matches (converter automatically decrypts) + if (device.getSecret() == null || !device.getSecret().equals(req.secret())) { + throw new DeviceNotFoundException("Device secret does not match"); + } + + // Build topics list containing only this device's publish topic + List topics = List.of("hydro/" + device.getSecret() + "/#"); + + log.debug("Generating MQTT token for device {}, topics: {}", device.getId(), topics); + return mqttTokenProvider.generateMqttToken( + new UserMqttTokenPayload(device.getId(), topics) + ); + } + + /** + * Authenticates a device (using DeviceLinkRequest) and returns an MQTT JWT token. + */ + public String getMqttAuthToken(DeviceLinkRequest req) { + return getMqttAuthToken(new DeviceAuthRequest(req.deviceId(), req.secret())); + } + + /*--------------------------*/ + /* Helper Methods */ + /*--------------------------*/ + + /** + * Converts a DeviceProvisionRequest DTO to a Device entity. + * + * @param req the device provision request DTO + * @return the device entity + */ + private Device convertRequestToDevice(DeviceProvisionRequest req) { + Device device = new Device(); + device.setTechnicalName(req.technicalName()); + device.setFirmware(req.firmware()); + device.setMacAddress(req.macAddress()); + return device; + } + + /** + * Calculates the next display order for a user's devices. + * + * @param userId the user whose devices are being ordered + * @return the next display order + */ + private long calculateDeviceOrder(Long userId) { + List devices = deviceCacheService.getDevicesByUserId(userId); + return devices.stream() + .map(Device::getDisplayOrder) + .filter(Objects::nonNull) + .max(Comparator.naturalOrder()) + .map(maxOrder -> maxOrder + 1) + .orElse(1L); + } + + /** + * Retrieves a device by its ID. + * + * @param deviceId the ID of the device to retrieve + * @return the device entity + * @throws DeviceNotFoundException if the device is not found + */ + public Device getDeviceById(Long deviceId) { + return deviceCacheService.getDeviceById(deviceId); + } + + /** + * Regenerates a device's secret. + * + * @param deviceId the ID of the device + * @return the new secret (decrypted) + * @throws DeviceNotFoundException if the device is not found + */ + @Transactional + public String regenerateDeviceSecret(Long deviceId) { + Device device = getDeviceById(deviceId); + String newSecret = EncryptionUtil.generateRandomString(32); + device.setSecret(newSecret); // Will be auto-encrypted by converter + deviceRepository.save(device); + return newSecret; // Return decrypted secret + } + + /** + * Retrieves the secret for a device by its ID (decrypted). The secret is automatically decrypted + * by the JPA converter. + * + * @param deviceId the ID of the device + * @return the device's secret (decrypted) + * @throws DeviceNotFoundException if the device is not found + */ + public String getSecretByDeviceId(Long deviceId) { + Device device = getDeviceById(deviceId); + return device.getSecret(); + } + + /** + * Builds the list of MQTT topics the current user can access based on their devices. + * + * @return the list of MQTT topics + */ + public List getUserDeviceTopics(Long userId) { + List devices = deviceRepository.findAllByUserId(userId); + return devices.stream() + .map(device -> "hydro/" + device.getSecret() + "/#") + .toList(); + } + + @ApplicationModuleListener + public void on(DeviceLoadEvent e) { + getDevicesByUserId(e.userId()); + log.info("Loaded devices for user ID {} into cache", e.userId()); + } +} + + +/** + * Service for caching device queries using Spring Cache abstraction. Reduces database load for + * frequently accessed device data. + */ +@Slf4j +@AllArgsConstructor +@Service +class DeviceCacheService { + + private final DeviceRepository deviceRepository; + + /** + * Retrieves a device by its ID from cache. Cache is invalidated when the device is updated. + * + * @param deviceId the ID of the device to retrieve + * @return the device if found + * @throws DeviceNotFoundException if the device is not found + */ + @Cacheable( + value = "deviceByIdCache", + key = "#deviceId" + ) + public Device getDeviceById(Long deviceId) { + return deviceRepository.findById(deviceId) + .orElseThrow(() -> new DeviceNotFoundException(deviceId)); + } + + + /** + * Retrieves all devices for a specific user from cache + * + * @param userId the ID of the user whose devices are to be retrieved + * @return list of devices owned by the user + */ + @Cacheable( + value = "devicesByUserIdCache", + key = "#userId" + ) + public List getDevicesByUserId(Long userId) { + return deviceRepository.findAllByUserId(userId); + } + + /** + * Retrieves all devices in the system from cache, with pagination + * + * @return list of all devices + */ + @Cacheable( + value = "allDevicesCache", + key = "#pageable.pageNumber + '-' + #pageable.pageSize" + ) + public Page getAllDevices(Pageable pageable) { + return deviceRepository.findAll(pageable); + } + +} \ No newline at end of file diff --git a/src/main/java/dev/ivfrost/hydro_backend/devices/internal/DeviceUtil.java b/src/main/java/dev/ivfrost/hydro_backend/devices/internal/DeviceUtil.java new file mode 100644 index 0000000..836327f --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/devices/internal/DeviceUtil.java @@ -0,0 +1,44 @@ +package dev.ivfrost.hydro_backend.devices.internal; + +import dev.ivfrost.hydro_backend.devices.DeviceResponse; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +class DeviceUtil { + + private DeviceUtil() { + } + + /** + * Converts a Device entity to a DeviceResponse DTO. + * + * @param device the device entity + * @return the device response DTO + */ + public static DeviceResponse convertDeviceToResponse(Device device) { + if (device == null) { + return null; + } + // Extract userId from user object (user is lazily loaded but ID is already known) + return new DeviceResponse(device.getId(), device.getFriendlyName(), device.getLocation(), + device.getFirmware(), + device.getTechnicalName(), device.getIp(), device.getCreatedAt(), device.getUpdatedAt(), + device.getLinkedAt(), device.getLastSeen(), device.getUserId(), + device.getDisplayOrder()); + } + + /** + * Converts a list of Device entities to a list of DeviceResponse DTOs. + * + * @param devices the list of device entities + * @return the list of device response DTOs + */ + public static List convertDevicesToResponse(List devices) { + if (devices == null) { + return List.of(); + } + return devices.stream().map(DeviceUtil::convertDeviceToResponse).toList(); + } + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/devices/internal/EncoderService.java b/src/main/java/dev/ivfrost/hydro_backend/devices/internal/EncoderService.java new file mode 100644 index 0000000..ffb6232 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/devices/internal/EncoderService.java @@ -0,0 +1,30 @@ +package dev.ivfrost.hydro_backend.devices.internal; + +import dev.ivfrost.hydro_backend.exception.HmacEncodingException; +import java.util.Base64; +import java.util.function.BiFunction; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.stereotype.Service; + +@Service +public class EncoderService { + + public BiFunction hmacSha512Encoder() { + return (secretKey, toBeEncoded) -> { + try { + if (toBeEncoded == null || toBeEncoded.isEmpty()) { + throw new IllegalArgumentException("Input string to be encoded cannot be null or empty"); + } + Mac mac = Mac.getInstance("HmacSHA512"); + SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getBytes(), "HmacSHA512"); + mac.init(secretKeySpec); + byte[] hmac = mac.doFinal(toBeEncoded.getBytes()); + return Base64.getEncoder().encodeToString(hmac); + } catch (Exception e) { + throw new HmacEncodingException("Error while encoding string using HMAC-SHA512"); + } + }; + } + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/devices/internal/adapter/DeviceLinkProviderImpl.java b/src/main/java/dev/ivfrost/hydro_backend/devices/internal/adapter/DeviceLinkProviderImpl.java new file mode 100644 index 0000000..db74634 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/devices/internal/adapter/DeviceLinkProviderImpl.java @@ -0,0 +1,24 @@ +package dev.ivfrost.hydro_backend.devices.internal.adapter; + +import dev.ivfrost.hydro_backend.devices.DeviceLinkProvider; +import dev.ivfrost.hydro_backend.devices.DeviceLinkRequest; +import dev.ivfrost.hydro_backend.devices.internal.DeviceService; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; + +@AllArgsConstructor +@Service +public class DeviceLinkProviderImpl implements DeviceLinkProvider { + + private final DeviceService deviceService; + + @Override + public void linkDevice(DeviceLinkRequest req, Long userId) { + deviceService.linkDevice(req, userId, false); + } + + @Override + public void unlinkDevice(DeviceLinkRequest req, Long userId) { + deviceService.linkDevice(req, userId, true); + } +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/devices/internal/adapter/DeviceTopicProviderImpl.java b/src/main/java/dev/ivfrost/hydro_backend/devices/internal/adapter/DeviceTopicProviderImpl.java new file mode 100644 index 0000000..af4e478 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/devices/internal/adapter/DeviceTopicProviderImpl.java @@ -0,0 +1,21 @@ +package dev.ivfrost.hydro_backend.devices.internal.adapter; + +import dev.ivfrost.hydro_backend.devices.internal.DeviceService; +import dev.ivfrost.hydro_backend.users.DeviceTopicProvider; +import java.util.List; +import org.springframework.stereotype.Service; + +@Service +class DeviceTopicProviderImpl implements DeviceTopicProvider { + + private final DeviceService deviceService; + + public DeviceTopicProviderImpl(DeviceService deviceService) { + this.deviceService = deviceService; + } + + @Override + public List getTopicsForUser(Long userId) { + return deviceService.getUserDeviceTopics(userId); + } +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/devices/internal/adapter/UserDeviceProviderImpl.java b/src/main/java/dev/ivfrost/hydro_backend/devices/internal/adapter/UserDeviceProviderImpl.java new file mode 100644 index 0000000..009a9e7 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/devices/internal/adapter/UserDeviceProviderImpl.java @@ -0,0 +1,28 @@ +package dev.ivfrost.hydro_backend.devices.internal.adapter; + +import dev.ivfrost.hydro_backend.devices.DeviceResponse; +import dev.ivfrost.hydro_backend.devices.DeviceUpdateRequest; +import dev.ivfrost.hydro_backend.devices.internal.DeviceService; +import dev.ivfrost.hydro_backend.users.UserDeviceProvider; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +class UserDeviceProviderImpl implements UserDeviceProvider { + + private final DeviceService deviceService; + + UserDeviceProviderImpl(DeviceService deviceService) { + this.deviceService = deviceService; + } + + @Override + public List getUserDevices(Long userId) { + return deviceService.getDevicesByUserId(userId); + } + + @Override + public DeviceResponse updateUserDevice(DeviceUpdateRequest req) { + return deviceService.updateDeviceDetails(req); + } +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/dto/ApiResponse.java b/src/main/java/dev/ivfrost/hydro_backend/dto/ApiResponse.java deleted file mode 100644 index f8465d4..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/dto/ApiResponse.java +++ /dev/null @@ -1,19 +0,0 @@ -package dev.ivfrost.hydro_backend.dto; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.http.HttpStatus; - -@AllArgsConstructor -@NoArgsConstructor -@Data -public class ApiResponse { - private int status; - private String message; - private T data; - - public static ApiResponse build(HttpStatus status, String message, T data) { - return new ApiResponse<>(status.value(), message, data); - } -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/dto/AuthResponse.java b/src/main/java/dev/ivfrost/hydro_backend/dto/AuthResponse.java deleted file mode 100644 index 20fa473..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/dto/AuthResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.ivfrost.hydro_backend.dto; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public class AuthResponse { - private final String token; - private final String refreshToken; - private final String message; -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/dto/DeviceLinkRequest.java b/src/main/java/dev/ivfrost/hydro_backend/dto/DeviceLinkRequest.java deleted file mode 100644 index 3baf2f2..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/dto/DeviceLinkRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package dev.ivfrost.hydro_backend.dto; - -import jakarta.persistence.Column; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class DeviceLinkRequest { - - @Column(length = 44, nullable = false) - private String hash; -} \ No newline at end of file diff --git a/src/main/java/dev/ivfrost/hydro_backend/dto/DeviceProvisionRequest.java b/src/main/java/dev/ivfrost/hydro_backend/dto/DeviceProvisionRequest.java deleted file mode 100644 index 3170c18..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/dto/DeviceProvisionRequest.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.ivfrost.hydro_backend.dto; - -import jakarta.validation.constraints.Size; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class DeviceProvisionRequest { - - @Size(max = 20, min = 4) - private String firmware; - - @Size(max = 40, min = 4) - private String technicalName; - - @Size(max = 100, min = 4) - private String macAddress; -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/dto/DeviceResponse.java b/src/main/java/dev/ivfrost/hydro_backend/dto/DeviceResponse.java deleted file mode 100644 index 9292796..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/dto/DeviceResponse.java +++ /dev/null @@ -1,28 +0,0 @@ -package dev.ivfrost.hydro_backend.dto; - -import dev.ivfrost.hydro_backend.entity.User; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.Instant; - -@NoArgsConstructor -@AllArgsConstructor -@Data -public class DeviceResponse { - private Long id; - private String name; - private String location; - private String firmware; - private String technicalName; - private String ip; - private Instant createdAt; - private Instant updatedAt; - private Instant linkedAt; - private Instant lastSeen; - private User user; - private Integer displayOrder; -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/dto/DeviceUpdateRequest.java b/src/main/java/dev/ivfrost/hydro_backend/dto/DeviceUpdateRequest.java deleted file mode 100644 index fbc2097..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/dto/DeviceUpdateRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.ivfrost.hydro_backend.dto; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class DeviceUpdateRequest { - - private String name; - private Integer displayOrder; -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/dto/MqttCredentialsResponse.java b/src/main/java/dev/ivfrost/hydro_backend/dto/MqttCredentialsResponse.java deleted file mode 100644 index e43ef95..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/dto/MqttCredentialsResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.ivfrost.hydro_backend.dto; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@AllArgsConstructor -@NoArgsConstructor -public class MqttCredentialsResponse { - public String username; - public String password; -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/dto/PasswordResetRequest.java b/src/main/java/dev/ivfrost/hydro_backend/dto/PasswordResetRequest.java deleted file mode 100644 index ebbe39b..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/dto/PasswordResetRequest.java +++ /dev/null @@ -1,20 +0,0 @@ -package dev.ivfrost.hydro_backend.dto; - -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.Size; -import lombok.AllArgsConstructor; -import lombok.Data; - -@Data -@AllArgsConstructor -public class PasswordResetRequest { - - @Email(message = "Invalid email format") - private final String email; - - @Size(min = 11, max = 11, message = "Recovery code must be exactly 11 characters long") - private final String recoveryCode; - - @Size(min = 8, max = 60, message = "Password must be between 8 and 60 characters long") - private final String newPassword; -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/dto/ResetPasswordRequest.java b/src/main/java/dev/ivfrost/hydro_backend/dto/ResetPasswordRequest.java deleted file mode 100644 index eb3a8ee..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/dto/ResetPasswordRequest.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.ivfrost.hydro_backend.dto; - -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; -import lombok.AllArgsConstructor; -import lombok.Data; - -@Data -@AllArgsConstructor -public class ResetPasswordRequest { - - @NotBlank - @Email - String email; - - @NotBlank - @Size(min = 16, max = 16) - String recoveryCode; - - @NotBlank - @Size(min = 8, max = 60) - String newPassword; -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/dto/UserLoginRequest.java b/src/main/java/dev/ivfrost/hydro_backend/dto/UserLoginRequest.java deleted file mode 100644 index c519421..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/dto/UserLoginRequest.java +++ /dev/null @@ -1,22 +0,0 @@ -package dev.ivfrost.hydro_backend.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import jakarta.persistence.Column; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.Size; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class UserLoginRequest { - - @Email - @Size(min = 5, max = 60) - private String email; - - @Size(min = 8, max = 60) - private String password; -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/dto/UserRegisterRequest.java b/src/main/java/dev/ivfrost/hydro_backend/dto/UserRegisterRequest.java deleted file mode 100644 index 1af231b..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/dto/UserRegisterRequest.java +++ /dev/null @@ -1,34 +0,0 @@ -package dev.ivfrost.hydro_backend.dto; - -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class UserRegisterRequest { - - @NotBlank - @Email - @Size(min = 5, max = 60) - private String email; - - @NotBlank - @Size(min = 5, max = 20) - private String username; - - @NotBlank - @Size(min = 6, max = 40) - private String fullName; - - @NotBlank - @Size(min = 8, max = 60) - private String password; - - @Size(min = 2, max = 2) - private String preferredLanguage; -} \ No newline at end of file diff --git a/src/main/java/dev/ivfrost/hydro_backend/dto/UserRegisterResponse.java b/src/main/java/dev/ivfrost/hydro_backend/dto/UserRegisterResponse.java deleted file mode 100644 index 1c47652..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/dto/UserRegisterResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package dev.ivfrost.hydro_backend.dto; - -import lombok.AllArgsConstructor; -import lombok.Data; - -@AllArgsConstructor -@Data -public class UserRegisterResponse { - - String[] recoveryCodes; -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/dto/UserResponse.java b/src/main/java/dev/ivfrost/hydro_backend/dto/UserResponse.java deleted file mode 100644 index cc4c073..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/dto/UserResponse.java +++ /dev/null @@ -1,44 +0,0 @@ -package dev.ivfrost.hydro_backend.dto; - -import com.fasterxml.jackson.databind.JsonNode; -import dev.ivfrost.hydro_backend.entity.User; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.Instant; -import java.util.List; - -@NoArgsConstructor -@AllArgsConstructor -@Data -public class UserResponse { - - private Long id; - - private String username; - - private String fullName; - - private String email; - - private String profilePictureUrl; - - private String phoneNumber; - - private String address; - - private Instant createdAt; - - private Instant updatedAt; - - private Instant lastLogin; - - private User.Role role; - - private String preferredLanguage; - - private JsonNode settings; - - private List devices; -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/dto/UserUpdateRequest.java b/src/main/java/dev/ivfrost/hydro_backend/dto/UserUpdateRequest.java deleted file mode 100644 index 52798d2..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/dto/UserUpdateRequest.java +++ /dev/null @@ -1,39 +0,0 @@ -package dev.ivfrost.hydro_backend.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; -import lombok.AllArgsConstructor; -import lombok.Data; - -@Data -@AllArgsConstructor -public class UserUpdateRequest { - @Size(min = 5, max = 20) - private String username; - - @Size(min = 4, max = 40) - private String fullName; - - @Email(message = "Invalid email format") - @Size(min = 8, max = 50) - private String email; - - @Size(max = 255) - private String profilePictureUrl; - - @Pattern(regexp = "^\\+?[0-9\\-\\s]{7,20}$", message = "Invalid phone number format") - @Size(max = 20) - private String phoneNumber; - - @Size(max = 100) - private String address; - - @Size(min = 2, max = 2) - private String preferredLanguage = "es"; - - private JsonNode settings = new ObjectMapper().createObjectNode(); -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/entity/Device.java b/src/main/java/dev/ivfrost/hydro_backend/entity/Device.java deleted file mode 100644 index b214b23..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/entity/Device.java +++ /dev/null @@ -1,77 +0,0 @@ -package dev.ivfrost.hydro_backend.entity; - -import jakarta.persistence.*; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.hibernate.annotations.UpdateTimestamp; - -import java.time.Instant; - -// Non-nullable: firmware, technicalName - -@Data -@NoArgsConstructor -@AllArgsConstructor -@Table(name = "devices") -@Entity -public class Device { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Size(max = 17, min = 17) - @Column(nullable = false, name = "mac_address", unique = true) - private String macAddress; - - @Size(min = 1, max = 40) - @Column(nullable = true) - private String name; // User defined name - - @Size(max = 20) - @Column(length = 20) - private String location; // Latest known location - - @Size(max = 20) - @Column(nullable = false) - private String firmware; // Firmware version - - @Size(max = 40) - @Column(name = "technical_name", nullable = false) - private String technicalName; // Technical name - - @Column(length = 44, unique = true) - private String hash; // Unique device hash for verification - - @Pattern(regexp = "^((25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}(25[0-5]|2[0-4]\\d|[01]?\\d\\d?)$") - @Size(max = 15) - @Column(nullable = true) - private String ip; - - @Column(name = "created_at") - private Instant createdAt; // Timestamp when the device was created - - @UpdateTimestamp - @Column(name = "updated_at") - private Instant updatedAt; - - private Instant linkedAt; - - private Instant lastSeen; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - private User user; - - @Column(name = "display_order") - private Integer displayOrder; // User-defined order for display purposes - - @PrePersist - protected void onCreate() { - Instant now = Instant.now(); - this.createdAt = now; - } -} \ No newline at end of file diff --git a/src/main/java/dev/ivfrost/hydro_backend/entity/MqttCredentials.java b/src/main/java/dev/ivfrost/hydro_backend/entity/MqttCredentials.java deleted file mode 100644 index f2702d7..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/entity/MqttCredentials.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.ivfrost.hydro_backend.entity; - -import jakarta.persistence.*; -import jakarta.validation.constraints.Size; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@NoArgsConstructor -@AllArgsConstructor -@Data -@Entity -public class MqttCredentials { - - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Id - private Long id; - - @Size(min = 5, max = 20) - @Column(nullable = false, unique = true) - private String username; - - @Column(length = 44, nullable = false) - private String password; - - @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - private User user; -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/entity/User.java b/src/main/java/dev/ivfrost/hydro_backend/entity/User.java deleted file mode 100644 index 159f6f8..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/entity/User.java +++ /dev/null @@ -1,127 +0,0 @@ -package dev.ivfrost.hydro_backend.entity; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import dev.ivfrost.hydro_backend.converter.JsonNodeConverter; -import jakarta.persistence.*; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.Size; -import lombok.Data; -import lombok.AllArgsConstructor; -import lombok.NoArgsConstructor; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.annotations.UpdateTimestamp; -import org.hibernate.type.SqlTypes; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; - -@Data -@NoArgsConstructor -@AllArgsConstructor -@Table(name = "users") -@Entity -public class User { - - public static enum Role { - USER, - ADMIN - } - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Size(min = 5, max = 20) - @Column(unique = true, nullable = false) - private String username; - - // Password is write-only to prevent it from being serialized in responses - @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) - @Size(min = 12, max = 60) - @Column(nullable = false) - private String password; - - @Size(min = 4, max = 40) - @Column(name = "full_name", nullable = false) - private String fullName; - - @Email(message = "Invalid email format") - @Size(min = 8, max = 50) - @Column(unique = true, nullable = false) - private String email; - - @Column(name = "profile_pic") - @Size(max = 255) - private String profilePictureUrl; - - @Size(max = 20) - @Column(name = "phone_number") - private String phoneNumber; - - @Size(max = 100) - @Column(columnDefinition = "text") - private String address; - - @CreationTimestamp - @Column(name = "created_at", nullable = false, updatable = false) - private Instant createdAt; - - @UpdateTimestamp - @Column(name = "updated_at", nullable = false) - private Instant updatedAt; - - @Column(name = "deleted_at", nullable = true) - private Instant deletedAt; - - @Column(name = "last_login") - private Instant lastLogin; - - @Column(name = "is_active", nullable = false) - private boolean isActive = true; - - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private Role role = Role.USER; - - @Size(min = 2, max = 2) - @Column(name = "preferred_language", nullable = false) - private String preferredLanguage = "es"; - - @Convert(converter = JsonNodeConverter.class) - @JdbcTypeCode(SqlTypes.JSON) - @Column(name = "settings", columnDefinition = "jsonb") - private JsonNode settings = new ObjectMapper().createObjectNode(); - - @Column(columnDefinition = "text") - private String notes; - - @OneToMany(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - private List devices = new ArrayList<>(); - - @Column(name = "is_deleted", nullable = false) - private boolean isDeleted = false; - - // Enforce preferredLanguage to be a 2-letter ISO code in lowercase - public void setPreferredLanguage(String preferredLanguage) { - if (preferredLanguage == null || preferredLanguage.length() != 2) { - throw new IllegalArgumentException("Preferred language must be a 2-letter ISO code."); - } - this.preferredLanguage = preferredLanguage.toLowerCase(); - } - - @PrePersist - protected void onCreate() { - if (profilePictureUrl == null) profilePictureUrl = ""; - if (phoneNumber == null) phoneNumber = ""; - if (address == null) address = ""; - if (lastLogin == null) lastLogin = Instant.now(); - if (settings == null) settings = new ObjectMapper().createObjectNode(); - if (notes == null) notes = ""; - if (devices == null) devices = new ArrayList<>(); - } -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/entity/UserToken.java b/src/main/java/dev/ivfrost/hydro_backend/entity/UserToken.java deleted file mode 100644 index 52855fe..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/entity/UserToken.java +++ /dev/null @@ -1,34 +0,0 @@ -package dev.ivfrost.hydro_backend.entity; - -import jakarta.persistence.*; -import lombok.Data; - -import java.time.LocalDateTime; - -@Data -@Entity -@Table(name = "tokens") -public class UserToken { - - public static enum TokenType { - RECOVERY_CODE, - } - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, length = 44) - private String token; - - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 20) - private TokenType type; - - @Column(name = "expiry_date", nullable = true) - private LocalDateTime expiryDate; - - @ManyToOne - @JoinColumn(name = "user_id", nullable = false) - private User user; -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/exception/AuthWrongPasswordException.java b/src/main/java/dev/ivfrost/hydro_backend/exception/AuthWrongPasswordException.java deleted file mode 100644 index 9f1ae0e..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/exception/AuthWrongPasswordException.java +++ /dev/null @@ -1,7 +0,0 @@ -package dev.ivfrost.hydro_backend.exception; - -public class AuthWrongPasswordException extends RuntimeException { - public AuthWrongPasswordException(String email) { - super("Wrong password for user with email: " + email); - } -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/exception/DeviceFetchException.java b/src/main/java/dev/ivfrost/hydro_backend/exception/DeviceFetchException.java index ab69d9f..1e8bcf8 100644 --- a/src/main/java/dev/ivfrost/hydro_backend/exception/DeviceFetchException.java +++ b/src/main/java/dev/ivfrost/hydro_backend/exception/DeviceFetchException.java @@ -2,7 +2,8 @@ package dev.ivfrost.hydro_backend.exception; public class DeviceFetchException extends RuntimeException { - public DeviceFetchException(String message) { - super(message); - } + public DeviceFetchException(String message) { + super(message); + } + } diff --git a/src/main/java/dev/ivfrost/hydro_backend/exception/DeviceLinkException.java b/src/main/java/dev/ivfrost/hydro_backend/exception/DeviceLinkException.java index 54904b6..ac1e37a 100644 --- a/src/main/java/dev/ivfrost/hydro_backend/exception/DeviceLinkException.java +++ b/src/main/java/dev/ivfrost/hydro_backend/exception/DeviceLinkException.java @@ -2,7 +2,8 @@ package dev.ivfrost.hydro_backend.exception; public class DeviceLinkException extends RuntimeException { - public DeviceLinkException(String hash) { - super("Device with hash " + hash + " is already linked to a user."); - } + public DeviceLinkException(String message) { + super(message); + } + } diff --git a/src/main/java/dev/ivfrost/hydro_backend/exception/DeviceNotFoundException.java b/src/main/java/dev/ivfrost/hydro_backend/exception/DeviceNotFoundException.java index e2fa04e..ede315e 100644 --- a/src/main/java/dev/ivfrost/hydro_backend/exception/DeviceNotFoundException.java +++ b/src/main/java/dev/ivfrost/hydro_backend/exception/DeviceNotFoundException.java @@ -1,11 +1,13 @@ package dev.ivfrost.hydro_backend.exception; public class DeviceNotFoundException extends RuntimeException { - public DeviceNotFoundException(Long deviceId) { - super("Device with ID " + deviceId + " not found."); - } - public DeviceNotFoundException(String hash) { - super("Device with hash " + hash + " not found."); - } + public DeviceNotFoundException(Long deviceId) { + super("Device with ID " + deviceId + " not found."); + } + + public DeviceNotFoundException(String message) { + super(message); + } + } diff --git a/src/main/java/dev/ivfrost/hydro_backend/exception/DuplicateMacAddressException.java b/src/main/java/dev/ivfrost/hydro_backend/exception/DuplicateMacAddressException.java new file mode 100644 index 0000000..0c0c569 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/exception/DuplicateMacAddressException.java @@ -0,0 +1,13 @@ +package dev.ivfrost.hydro_backend.exception; + +public class DuplicateMacAddressException extends RuntimeException { + + public DuplicateMacAddressException(String macAddress) { + super("Device with MAC address '" + macAddress + "' already exists"); + } + + public DuplicateMacAddressException(String macAddress, Throwable cause) { + super("Device with MAC address '" + macAddress + "' already exists", cause); + } +} + diff --git a/src/main/java/dev/ivfrost/hydro_backend/exception/EncryptionException.java b/src/main/java/dev/ivfrost/hydro_backend/exception/EncryptionException.java new file mode 100644 index 0000000..4bf44a5 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/exception/EncryptionException.java @@ -0,0 +1,11 @@ +package dev.ivfrost.hydro_backend.exception; + +/** + * Custom exception for encryption/decryption errors + */ +public class EncryptionException extends RuntimeException { + + public EncryptionException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/dev/ivfrost/hydro_backend/exception/ExpiredVerificationToken.java b/src/main/java/dev/ivfrost/hydro_backend/exception/ExpiredVerificationToken.java index 3b60d54..58910cc 100644 --- a/src/main/java/dev/ivfrost/hydro_backend/exception/ExpiredVerificationToken.java +++ b/src/main/java/dev/ivfrost/hydro_backend/exception/ExpiredVerificationToken.java @@ -1,7 +1,9 @@ package dev.ivfrost.hydro_backend.exception; public class ExpiredVerificationToken extends RuntimeException { - public ExpiredVerificationToken(String message) { - super(message); - } + + public ExpiredVerificationToken(String message) { + super(message); + } + } diff --git a/src/main/java/dev/ivfrost/hydro_backend/exception/HmacEncodingException.java b/src/main/java/dev/ivfrost/hydro_backend/exception/HmacEncodingException.java new file mode 100644 index 0000000..76a0894 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/exception/HmacEncodingException.java @@ -0,0 +1,9 @@ +package dev.ivfrost.hydro_backend.exception; + +public class HmacEncodingException extends RuntimeException { + + public HmacEncodingException(String s) { + super(s); + } + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/exception/RecoveryTokenMismatchException.java b/src/main/java/dev/ivfrost/hydro_backend/exception/RecoveryTokenMismatchException.java index ff4415d..ac103cb 100644 --- a/src/main/java/dev/ivfrost/hydro_backend/exception/RecoveryTokenMismatchException.java +++ b/src/main/java/dev/ivfrost/hydro_backend/exception/RecoveryTokenMismatchException.java @@ -1,8 +1,9 @@ package dev.ivfrost.hydro_backend.exception; public class RecoveryTokenMismatchException extends RuntimeException { - public RecoveryTokenMismatchException(String message) { - super(message); - } -} + public RecoveryTokenMismatchException(String message) { + super(message); + } + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/exception/RecoveryTokenNotFoundException.java b/src/main/java/dev/ivfrost/hydro_backend/exception/RecoveryTokenNotFoundException.java index 19d845e..e877be6 100644 --- a/src/main/java/dev/ivfrost/hydro_backend/exception/RecoveryTokenNotFoundException.java +++ b/src/main/java/dev/ivfrost/hydro_backend/exception/RecoveryTokenNotFoundException.java @@ -1,7 +1,9 @@ package dev.ivfrost.hydro_backend.exception; public class RecoveryTokenNotFoundException extends RuntimeException { - public RecoveryTokenNotFoundException(String message) { - super(message); - } + + public RecoveryTokenNotFoundException(String message) { + super(message); + } + } diff --git a/src/main/java/dev/ivfrost/hydro_backend/exception/TokenNotFoundException.java b/src/main/java/dev/ivfrost/hydro_backend/exception/TokenNotFoundException.java index 06214a6..eabb51c 100644 --- a/src/main/java/dev/ivfrost/hydro_backend/exception/TokenNotFoundException.java +++ b/src/main/java/dev/ivfrost/hydro_backend/exception/TokenNotFoundException.java @@ -1,9 +1,11 @@ package dev.ivfrost.hydro_backend.exception; -import dev.ivfrost.hydro_backend.entity.UserToken; +import dev.ivfrost.hydro_backend.tokens.internal.Token; public class TokenNotFoundException extends RuntimeException { - public TokenNotFoundException(UserToken.TokenType type) { - super("Token of type " + type + " not found or invalid."); - } + + public TokenNotFoundException(Token.TokenType type) { + super("Token of type " + type + " not found or invalid."); + } + } diff --git a/src/main/java/dev/ivfrost/hydro_backend/exception/UserDeletedException.java b/src/main/java/dev/ivfrost/hydro_backend/exception/UserDeletedException.java deleted file mode 100644 index edf9759..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/exception/UserDeletedException.java +++ /dev/null @@ -1,8 +0,0 @@ -package dev.ivfrost.hydro_backend.exception; - -public class UserDeletedException extends RuntimeException { - - public UserDeletedException(Long userId) { - super("User with ID " + userId + " is deleted."); - } -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/exception/UserDisabledException.java b/src/main/java/dev/ivfrost/hydro_backend/exception/UserDisabledException.java new file mode 100644 index 0000000..b6e6bc0 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/exception/UserDisabledException.java @@ -0,0 +1,9 @@ +package dev.ivfrost.hydro_backend.exception; + +public class UserDisabledException extends RuntimeException { + + public UserDisabledException(Long userId) { + super("User with ID " + userId + " is disabled."); + } + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/exception/UserNotAuthenticatedException.java b/src/main/java/dev/ivfrost/hydro_backend/exception/UserNotAuthenticatedException.java index 6ff5a6f..813fedb 100644 --- a/src/main/java/dev/ivfrost/hydro_backend/exception/UserNotAuthenticatedException.java +++ b/src/main/java/dev/ivfrost/hydro_backend/exception/UserNotAuthenticatedException.java @@ -1,7 +1,9 @@ package dev.ivfrost.hydro_backend.exception; public class UserNotAuthenticatedException extends Exception { - public UserNotAuthenticatedException(String message) { - super(message); - } + + public UserNotAuthenticatedException(String message) { + super(message); + } + } diff --git a/src/main/java/dev/ivfrost/hydro_backend/exception/UserNotFoundException.java b/src/main/java/dev/ivfrost/hydro_backend/exception/UserNotFoundException.java index 4615173..5d38674 100644 --- a/src/main/java/dev/ivfrost/hydro_backend/exception/UserNotFoundException.java +++ b/src/main/java/dev/ivfrost/hydro_backend/exception/UserNotFoundException.java @@ -1,13 +1,13 @@ package dev.ivfrost.hydro_backend.exception; -import jakarta.validation.constraints.Size; - public class UserNotFoundException extends RuntimeException { - public UserNotFoundException(Long userId) { - super("User with ID " + userId + " not found."); - } - public UserNotFoundException(String email) { - super("User with email '" + email + "' not found."); - } + public UserNotFoundException(Long userId) { + super("User with ID " + userId + " not found."); + } + + public UserNotFoundException(String email) { + super("User with email '" + email + "' not found."); + } + } diff --git a/src/main/java/dev/ivfrost/hydro_backend/exception/UsernameTakenException.java b/src/main/java/dev/ivfrost/hydro_backend/exception/UsernameTakenException.java index 467e75c..ed9aa57 100644 --- a/src/main/java/dev/ivfrost/hydro_backend/exception/UsernameTakenException.java +++ b/src/main/java/dev/ivfrost/hydro_backend/exception/UsernameTakenException.java @@ -1,7 +1,9 @@ package dev.ivfrost.hydro_backend.exception; public class UsernameTakenException extends RuntimeException { - public UsernameTakenException(String username) { - super("Username '" + username + "' is already taken."); - } + + public UsernameTakenException(String username) { + super("Username '" + username + "' is already taken."); + } + } diff --git a/src/main/java/dev/ivfrost/hydro_backend/repository/DeviceRepository.java b/src/main/java/dev/ivfrost/hydro_backend/repository/DeviceRepository.java deleted file mode 100644 index af92ecb..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/repository/DeviceRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.ivfrost.hydro_backend.repository; - -import dev.ivfrost.hydro_backend.entity.Device; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; -import java.util.Optional; - -public interface DeviceRepository extends JpaRepository { - Optional findByHash(String hash); - - List findAllByUserId(Long userId); -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/repository/MqttCredentialsRepository.java b/src/main/java/dev/ivfrost/hydro_backend/repository/MqttCredentialsRepository.java deleted file mode 100644 index c7704ba..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/repository/MqttCredentialsRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.ivfrost.hydro_backend.repository; - -import dev.ivfrost.hydro_backend.entity.MqttCredentials; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface MqttCredentialsRepository extends JpaRepository { - - boolean existsByUserId(Long userId); - Optional findByUserId(Long userId); -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/repository/UserRepository.java b/src/main/java/dev/ivfrost/hydro_backend/repository/UserRepository.java deleted file mode 100644 index 19f9350..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/repository/UserRepository.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.ivfrost.hydro_backend.repository; - -import dev.ivfrost.hydro_backend.entity.User; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.Size; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; - -import java.util.Optional; - -public interface UserRepository extends JpaRepository { - Optional findByUsername(String username); - Optional findByEmail(String email); - - @Query("SELECT u FROM User u LEFT JOIN FETCH u.devices WHERE u.id = :id") - Optional findByIdWithDevices(Long id); - - boolean existsByUsername(String username); - boolean existsByEmail(String email); - -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/repository/UserTokenRepository.java b/src/main/java/dev/ivfrost/hydro_backend/repository/UserTokenRepository.java deleted file mode 100644 index 9479e1f..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/repository/UserTokenRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package dev.ivfrost.hydro_backend.repository; - -import dev.ivfrost.hydro_backend.entity.UserToken; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface UserTokenRepository extends JpaRepository { - Optional findByTokenAndType(String token, UserToken.TokenType type); -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/security/JWTFilter.java b/src/main/java/dev/ivfrost/hydro_backend/security/JWTFilter.java deleted file mode 100644 index 335dc72..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/security/JWTFilter.java +++ /dev/null @@ -1,100 +0,0 @@ -package dev.ivfrost.hydro_backend.security; - -import com.auth0.jwt.exceptions.JWTVerificationException; -import com.auth0.jwt.interfaces.Claim; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.util.Map; - -@Slf4j -@AllArgsConstructor -@Component -public class JWTFilter extends OncePerRequestFilter { - - private final UserDetailsService userDetailsService; - private final JWTUtil jwtUtil; - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - String path = request.getRequestURI(); - log.info("JWTFilter processing path: {}", path); - - // Bypass filter for authentication and validation endpoints - if (path.startsWith("/v1/users/auth/") || path.equals("/v1/users") || - path.equals("/v1/users/recover") || path.equals("/v1/users/password/reset") || - path.startsWith("/v1/validation") || path.startsWith("/v1/users/verify") || - path.equals("/v1/validation/recovery-code")) { - - log.info("Bypassing JWTFilter for path: {}", path); - filterChain.doFilter(request, response); - return; - } - - // Extract Authorization header - String authHeader = request.getHeader("Authorization"); - log.info("Authorization header: {}", authHeader); - // Check for Bearer token - if (authHeader != null && !authHeader.isBlank() && authHeader.startsWith("Bearer ")) { - // Extract JWT token - String jwt = authHeader.substring(7); - log.info("Extracted JWT: {}", jwt); - if (jwt == null || jwt.isBlank()) { - log.warn("JWT is blank or null"); - response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid JWT Token in Bearer Header"); - return; - } - try { - // Validate token and retrieve claims - Map claims = jwtUtil.validateTokenAndRetrieveClaims(jwt); - log.info("JWT claims: {}", claims); - String username = claims.get("username").asString(); - String role = claims.get("role") != null ? claims.get("role").asString() : null; - log.info("JWT username: {}, role: {}", username, role); - - // Load User Details - UserDetails userDetails = userDetailsService.loadUserByUsername(username); - log.info("Loaded userDetails: {}", userDetails); - log.info("User authorities: {}", userDetails.getAuthorities()); - - // Use authorities from userDetails (database), not JWT - UsernamePasswordAuthenticationToken authToken = - new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); - - // Set authentication in security context - if (SecurityContextHolder.getContext().getAuthentication() == null) { - SecurityContextHolder.getContext().setAuthentication(authToken); - log.info("Authentication set in security context for user: {}", username); - } else { - log.info("Authentication already present in security context"); - } - } catch (JWTVerificationException e) { - log.error("JWT verification failed", e); - response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid JWT Token in Bearer Header"); - } catch (Exception e) { - log.error("Unexpected error in JWTFilter", e); - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authentication failed"); - } - } else { - log.info("No Bearer token found in Authorization header"); - } - - // Continue filter chain - filterChain.doFilter(request, response); - } -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/security/JWTUtil.java b/src/main/java/dev/ivfrost/hydro_backend/security/JWTUtil.java deleted file mode 100644 index 869201e..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/security/JWTUtil.java +++ /dev/null @@ -1,65 +0,0 @@ -package dev.ivfrost.hydro_backend.security; - -import com.auth0.jwt.JWT; -import com.auth0.jwt.algorithms.Algorithm; -import com.auth0.jwt.exceptions.JWTCreationException; -import com.auth0.jwt.exceptions.JWTVerificationException; -import com.auth0.jwt.interfaces.Claim; -import com.auth0.jwt.interfaces.DecodedJWT; -import dev.ivfrost.hydro_backend.entity.User; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import java.util.Date; -import java.util.Map; - -@Component -public class JWTUtil { - - // Inject JWT secret from application properties - @Value("${jwt.secret}") - private String jwtSecret; - @Value("${jwt.expiration-ms}") - private Long jwtExpirationMs; - @Value("${jwt.refresh-expiration-ms}") - private Long jwtRefreshExpirationMs; - - // Create JWT token using the injected secret - public String generateToken(User user) throws JWTCreationException { - return JWT.create() - .withSubject("User Details") - .withClaim("username", user.getUsername()) - .withClaim("email", user.getEmail()) - .withClaim("role", user.getRole().toString()) - .withClaim("preferredLanguage", user.getPreferredLanguage()) - .withExpiresAt(new Date(System.currentTimeMillis() + jwtExpirationMs)) - .withIssuedAt(new Date()) - .withIssuer("HydroBackend") - .sign(Algorithm.HMAC256(jwtSecret)); - } - - // Create JWT refresh token using the injected secret and longer expiration - public String generateRefreshToken(User user) throws JWTCreationException { - return JWT.create() - .withSubject("User Details") - .withClaim("username", user.getUsername()) - .withClaim("email", user.getEmail()) - .withClaim("role", user.getRole().toString()) - .withClaim("preferredLanguage", user.getPreferredLanguage()) - .withExpiresAt(new Date(System.currentTimeMillis() + jwtRefreshExpirationMs)) - .withIssuedAt(new Date()) - .withIssuer("HydroBackend") - .sign(Algorithm.HMAC256(jwtSecret)); - } - - public Map validateTokenAndRetrieveClaims(String token) throws JWTVerificationException { - DecodedJWT jwt = JWT.require(Algorithm.HMAC256(jwtSecret)) - .withSubject("User Details") - .withIssuer("HydroBackend") - .build() - .verify(token); - - return jwt.getClaims(); - } -} \ No newline at end of file diff --git a/src/main/java/dev/ivfrost/hydro_backend/security/MyUserDetails.java b/src/main/java/dev/ivfrost/hydro_backend/security/MyUserDetails.java deleted file mode 100644 index 5aa231a..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/security/MyUserDetails.java +++ /dev/null @@ -1,52 +0,0 @@ -package dev.ivfrost.hydro_backend.security; - -import dev.ivfrost.hydro_backend.entity.User; -import lombok.AllArgsConstructor; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; - -import java.util.Collection; -import java.util.Collections; - -@AllArgsConstructor -public class MyUserDetails implements UserDetails { - - private final User user; - - @Override - public Collection getAuthorities() { - return Collections.singletonList( - new SimpleGrantedAuthority("ROLE_" + user.getRole().name())); - } - - @Override - public String getPassword() { - return user.getPassword(); - } - - @Override - public String getUsername() { - return String.valueOf(user.getId()); - } - - @Override - public boolean isEnabled() { - return !user.isDeleted() && user.isActive(); - } - - @Override - public boolean isAccountNonExpired() { - return true; - } - - @Override - public boolean isAccountNonLocked() { - return true; - } - - @Override - public boolean isCredentialsNonExpired() { - return true; - } -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/service/DeviceService.java b/src/main/java/dev/ivfrost/hydro_backend/service/DeviceService.java deleted file mode 100644 index 6b9b4b7..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/service/DeviceService.java +++ /dev/null @@ -1,326 +0,0 @@ -// language: java -package dev.ivfrost.hydro_backend.service; - -import dev.ivfrost.hydro_backend.dto.*; -import dev.ivfrost.hydro_backend.entity.Device; -import dev.ivfrost.hydro_backend.entity.MqttCredentials; -import dev.ivfrost.hydro_backend.entity.User; -import dev.ivfrost.hydro_backend.exception.DeviceFetchException; -import dev.ivfrost.hydro_backend.exception.DeviceLinkException; -import dev.ivfrost.hydro_backend.exception.DeviceNotFoundException; -import dev.ivfrost.hydro_backend.repository.DeviceRepository; -import dev.ivfrost.hydro_backend.repository.MqttCredentialsRepository; -import dev.ivfrost.hydro_backend.util.DeviceDtoUtil; -import jakarta.transaction.Transactional; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -import java.util.Comparator; -import java.util.List; -import java.util.Objects; -import java.util.stream.Collectors; - -@Service -public class DeviceService { - private final DeviceRepository deviceRepository; - private final MqttCredentialsRepository mqttCredentialsRepository; - private final EncoderService encoderService; - private final String privateKey; - private final UserService userService; - - public DeviceService( - DeviceRepository deviceRepository, - MqttCredentialsRepository mqttCredentialsRepository, - EncoderService encoderService, - @Value("${device.secret}") String privateKey, - UserService userService) { - - this.deviceRepository = deviceRepository; - this.mqttCredentialsRepository = mqttCredentialsRepository; - this.encoderService = encoderService; - this.privateKey = privateKey; - if (this.privateKey == null || this.privateKey.isEmpty()) { - throw new IllegalStateException("Environment variable DEVICE_SECRET is not set."); - } - this.userService = userService; - } - - /** - * Provisions a new device and generates an ownership hash using the database ID. - * - * @param req the device provision request DTO - * @return the provisioned device response DTO - */ - @Transactional - public DeviceResponse provisionDevice(DeviceProvisionRequest req) { - Device device = convertRequestToDevice(req); - - // Persist to get generated ID - Device savedDevice = deviceRepository.save(device); - - // Compute and set hash based on generated ID - String hash = encoderService.hmacSha256Encoder().apply(privateKey, savedDevice.getId().toString()); - savedDevice.setHash(hash); - - // Persist updated record with hash - return DeviceDtoUtil.convertDeviceToResponse(deviceRepository.save(savedDevice)); - } - - /** - * Links an unlinked device to a user; creates MQTT credentials if user's first device; sets device order. - * - * @param req the device link request DTO - * @param userId the ID of the user to link the device to (null for authenticated user) - * @throws DeviceLinkException if the device is already linked - * @throws DeviceNotFoundException if the device is not found - */ - @Transactional - public void linkDevice(DeviceLinkRequest req, Long userId) { - // Determine the user (either from provided ID or current authentication context) - User user = (userId != null) - ? userService.getUserByIdWithoutDevices(userId) - : userService.getCurrentUserWithoutDevices(); - - // Find the device by hash - Device device = deviceRepository.findByHash(req.getHash()) - .orElseThrow(() -> new DeviceNotFoundException(req.getHash())); - - // Check if device is already linked - if (device.getUser() != null) { - throw new DeviceLinkException(req.getHash()); - } - - ensureMqttCredentialsForUser(user); - - // Link device to user - device.setUser(user); - // Set device order to be the highest order + 1 for initial display at the bottom of the list - device.setDisplayOrder(calculateDeviceDisplayOrder(user)); - deviceRepository.save(device); - } - - /** - * Overloaded method for device linking requests coming from authenticated users. - * - * @param req the device link request DTO - * @throws DeviceLinkException if the device is already linked - * @throws DeviceNotFoundException if the device is not found - */ - @Transactional - public void linkDevice(DeviceLinkRequest req) { - linkDevice(req, null); - } - - /** - * Unlinks a device from the currently authenticated user. - * - * @param deviceId the ID of the device to unlink - * @throws DeviceNotFoundException if the device is not found - * @throws IllegalArgumentException if the device does not belong to the authenticated user - */ - @Transactional - public void unlinkDevice(Long deviceId) { - Device device = deviceRepository.findById(deviceId) - .orElseThrow(() -> new DeviceNotFoundException(deviceId)); - User currentUser = userService.getCurrentUserWithoutDevices(); - if (!Objects.equals(device.getUser(), currentUser)) { - throw new IllegalArgumentException("Device does not belong to the authenticated user"); - } - device.setUser(null); - deviceRepository.save(device); - } - - /** - * Retrieves existing MQTT credentials for the currently authenticated user. - * The password is returned as stored in the database (hash generated from user ID and private key). - * - * @return the MQTT credentials response DTO - * @throws IllegalArgumentException if credentials are not found for the user - */ - public MqttCredentialsResponse getMqttCredentials() { - User user = userService.getCurrentUserWithoutDevices(); - MqttCredentials mqttCredentials = mqttCredentialsRepository.findByUserId(user.getId()) - .orElseThrow(() -> new IllegalArgumentException("MQTT credentials not found for user")); - // Return the password as stored in the database - return new MqttCredentialsResponse(mqttCredentials.getUsername(), mqttCredentials.getPassword()); - } - - /** - * Retrieves devices owned by the currently authenticated user. - * - * @return a list of device response DTOs - * @throws DeviceFetchException if no devices are found for the user - */ - public List getUserDevices() { - User user = userService.getCurrentUser(); - - if (user.getDevices() == null || user.getDevices().isEmpty()) { - throw new DeviceFetchException("No devices found for user"); - } - - return user.getDevices() - .stream() - .map(DeviceDtoUtil::convertDeviceToResponse) - .sorted(Comparator.comparing(DeviceResponse::getDisplayOrder)) - .collect(Collectors.toList()); - } - - /** - * Retrieves devices owned by a specific user, by user ID (Admin only). - * - * @param userId the ID of the user whose devices are to be retrieved - * @return a list of device response DTOs - * @throws DeviceFetchException if no devices are found for the user - */ - public List getUserDevicesById(Long userId) { - User user = userService.getUserById(userId); - if (user.getDevices() == null || user.getDevices().isEmpty()) { - throw new DeviceFetchException("No devices found for user"); - } - return user - .getDevices() - .stream() - .map(DeviceDtoUtil::convertDeviceToResponse) - .sorted(Comparator.comparing(DeviceResponse::getDisplayOrder)) - .collect(Collectors.toList()); - } - - /** - * Retrieves all devices provisioned in the system (Admin only). - * - * @return a list of all device response DTOs - * @throws DeviceFetchException if no devices are found - */ - public List getAllDevices() { - List devices = deviceRepository.findAll(); - if (devices.isEmpty()) { - throw new DeviceFetchException("No devices found"); - } - return DeviceDtoUtil.convertDevicesToResponse(devices); - } - - /** - * Updates fields of a specific device by its ID. - * Admins can additionally update the technical name and firmware version. - * - * @param deviceId the ID of the device to update - * @param req the device update request DTO - * @param technicalName (Admin only) the new technical name for the device - * @param firmware (Admin only) the new firmware version for the device - * @return the updated device response DTO - * @throws DeviceNotFoundException if the device is not found - * @throws IllegalArgumentException if the device does not belong to the authenticated user - */ - public DeviceResponse updateDeviceById( - Long deviceId, DeviceUpdateRequest req, String technicalName, String firmware) { - - Device device = deviceRepository.findById(deviceId) - .orElseThrow(() -> new DeviceNotFoundException(deviceId)); - - // If either technicalName or firmware is null, it's a user request; verify ownership - if (technicalName == null || firmware == null) { - User currentUser = userService.getCurrentUserWithoutDevices(); - if (!Objects.equals(device.getUser(), currentUser)) { - throw new IllegalArgumentException("Device does not belong to the authenticated user"); - } - } - - if (technicalName != null && !technicalName.isEmpty()) { - device.setTechnicalName(technicalName); - } - if (firmware != null && !firmware.isEmpty()) { - device.setFirmware(firmware); - } - if (req.getName() != null && !req.getName().isEmpty()) { - device.setName(req.getName()); - } - if (req.getDisplayOrder() != null && req.getDisplayOrder() >= 0) { - device.setDisplayOrder(req.getDisplayOrder()); - } - - return DeviceDtoUtil.convertDeviceToResponse(deviceRepository.save(device)); - } - - /** - * Overloaded method for updating a device by its ID for authenticated users. - * Users can only update the user-defined name and display order of their own devices. - * - * @param deviceId the ID of the device to update - * @param req the device update request DTO - * @return the updated device response DTO - */ - public DeviceResponse updateUserDeviceById(Long deviceId, DeviceUpdateRequest req) { - return updateDeviceById(deviceId, req, null, null); - } - - /** - * Delete a device by its ID (Admin only). - * - * @param deviceId the ID of the device to delete - * @throws DeviceNotFoundException if the device is not found - */ - public void deleteDeviceById(Long deviceId) { - Device device = deviceRepository.findById(deviceId).orElseThrow(() -> new DeviceNotFoundException(deviceId)); - deviceRepository.delete(device); - } - - /*--------------------------*/ - /* Helper Methods */ - /*--------------------------*/ - - /** - * Converts a DeviceProvisionRequest DTO to a Device entity. - * - * @param req the device provision request DTO - * @return the device entity - */ - private Device convertRequestToDevice(DeviceProvisionRequest req) { - Device device = new Device(); - device.setTechnicalName(req.getTechnicalName()); - device.setFirmware(req.getFirmware()); - device.setMacAddress(req.getMacAddress()); - return device; - } - - /** - * Converts MqttCredentials entity to MqttCredentialsResponse DTO. - * - * @param mqttCredentials the MQTT credentials entity - * @return the MQTT credentials response DTO - */ - private MqttCredentialsResponse convertMqttCredentialsToResponse(MqttCredentials mqttCredentials) { - return new MqttCredentialsResponse(mqttCredentials.getUsername(), mqttCredentials.getPassword()); - } - - /** - * Ensures MQTT credentials exist for the user, creating them if not present. - * - * @param user the user to check/create credentials for - */ - private void ensureMqttCredentialsForUser(User user) { - if (!mqttCredentialsRepository.existsByUserId(user.getId())) { - String encodedPassword = encoderService.hmacSha256Encoder().apply(privateKey, user.getId().toString()); - MqttCredentials mqttCredentials = new MqttCredentials(); - mqttCredentials.setUsername(user.getUsername()); - mqttCredentials.setPassword(encodedPassword); - mqttCredentials.setUser(user); - mqttCredentialsRepository.save(mqttCredentials); - } - } - - /** - * Calculates the next display order for a user's devices. - * - * @param user the user whose devices are being ordered - * @return the next display order - */ - private int calculateDeviceDisplayOrder(User user) { - return user.getDevices() != null && !user.getDevices().isEmpty() - ? user.getDevices().stream() - .mapToInt(Device::getDisplayOrder) - .max() - .orElse(0) + 1 - : 1; - } - -} \ No newline at end of file diff --git a/src/main/java/dev/ivfrost/hydro_backend/service/EncoderService.java b/src/main/java/dev/ivfrost/hydro_backend/service/EncoderService.java deleted file mode 100644 index be06f1a..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/service/EncoderService.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.ivfrost.hydro_backend.service; - -import org.springframework.stereotype.Service; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.util.Base64; -import java.util.function.BiFunction; - -@Service -public class EncoderService { - - public BiFunction hmacSha256Encoder() { - return (secretKey, toBeEncoded) -> { - try { - if (toBeEncoded == null || toBeEncoded.isEmpty()) { - throw new IllegalArgumentException("Input string to be encoded cannot be null or empty"); - } - Mac mac = Mac.getInstance("HmacSHA256"); - SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getBytes(), "HmacSHA256"); - mac.init(secretKeySpec); - byte[] hmac = mac.doFinal(toBeEncoded.getBytes()); - return Base64.getEncoder().encodeToString(hmac); - } catch (Exception e) { - throw new HmacEncodingException("Error while encoding string using HMAC-SHA256"); - } - }; - } -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/service/HmacEncodingException.java b/src/main/java/dev/ivfrost/hydro_backend/service/HmacEncodingException.java deleted file mode 100644 index ec2c582..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/service/HmacEncodingException.java +++ /dev/null @@ -1,7 +0,0 @@ -package dev.ivfrost.hydro_backend.service; - -public class HmacEncodingException extends RuntimeException { - public HmacEncodingException(String s) { - super(s); - } -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/service/MyUserDetailsService.java b/src/main/java/dev/ivfrost/hydro_backend/service/MyUserDetailsService.java deleted file mode 100644 index 0e2ffad..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/service/MyUserDetailsService.java +++ /dev/null @@ -1,31 +0,0 @@ -package dev.ivfrost.hydro_backend.service; - -import dev.ivfrost.hydro_backend.entity.User; -import dev.ivfrost.hydro_backend.repository.UserRepository; -import dev.ivfrost.hydro_backend.security.MyUserDetails; -import lombok.AllArgsConstructor; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.stereotype.Service; - -import java.util.Optional; - -@AllArgsConstructor -@Service -public class MyUserDetailsService implements UserDetailsService { - - private final UserRepository userRepository; - - @Override - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - Optional userOpt = userRepository.findByUsername(username); - if (userOpt.isEmpty()) { - throw new UsernameNotFoundException("User not found: " + username); - } - User user = userOpt.get(); - return new MyUserDetails(user); - } - - -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/service/UserService.java b/src/main/java/dev/ivfrost/hydro_backend/service/UserService.java deleted file mode 100644 index 1ced9f6..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/service/UserService.java +++ /dev/null @@ -1,394 +0,0 @@ -package dev.ivfrost.hydro_backend.service; - -import dev.ivfrost.hydro_backend.entity.UserToken; -import dev.ivfrost.hydro_backend.exception.*; -import dev.ivfrost.hydro_backend.dto.*; -import dev.ivfrost.hydro_backend.entity.User; -import dev.ivfrost.hydro_backend.repository.UserTokenRepository; -import dev.ivfrost.hydro_backend.repository.UserRepository; -import dev.ivfrost.hydro_backend.security.JWTUtil; -import dev.ivfrost.hydro_backend.util.DeviceDtoUtil; -import dev.ivfrost.hydro_backend.util.RecoveryCodeUtil; -import jakarta.transaction.Transactional; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.util.Collections; -import java.util.List; - -@Service -public class UserService { - - private final UserRepository userRepository; - private final UserTokenRepository userTokenRepository; - private final PasswordEncoder passwordEncoder; - private final EncoderService encoderService; - private final JWTUtil jwtUtil; - @Value("${security-code.secret}") - private String securityCodeSecret; - - public UserService(UserRepository userRepository, UserTokenRepository userTokenRepository, PasswordEncoder passwordEncoder, EncoderService encoderService, JWTUtil jwtUtil) { - this.userRepository = userRepository; - this.userTokenRepository = userTokenRepository; - this.passwordEncoder = passwordEncoder; - this.encoderService = encoderService; - this.jwtUtil = jwtUtil; - } - - /** - * Adds a new user with a specified role (admin only). - * Recovery codes are generated and the user is prompted to save them securely. - * @param req the user registration request DTO - * @param role the role to assign to the user - * @throws UsernameTakenException if the username is already taken - * @return the user registration response containing recovery codes - */ - @Transactional - public UserRegisterResponse addUser(UserRegisterRequest req, User.Role role) { - if (userRepository.findByUsername(req.getUsername()).isPresent()) { - throw new UsernameTakenException(req.getUsername()); - } - User user = convertRequestToUser(req); - user.setRole(role != null ? role : User.Role.USER); // Default to USER role if not specified - userRepository.save(user); - String[] recoveryCodes = RecoveryCodeUtil.generateRecoveryCodes(5); - for (String code : recoveryCodes) { - String encodedCode = encoderService.hmacSha256Encoder().apply(securityCodeSecret, code); - - UserToken token = new UserToken(); - token.setType(UserToken.TokenType.RECOVERY_CODE); - token.setToken(encodedCode); - token.setExpiryDate(null); - token.setUser(user); - userTokenRepository.save(token); - } - return new UserRegisterResponse(recoveryCodes); - } - - /** - * Adds a new user with the default USER role (for self-registration). - * Recovery codes are generated and the user is prompted to save them securely. - * The first registered user is assigned the ADMIN role - * @param req the user registration request DTO - * @throws UsernameTakenException if the username is already taken - * @return the user registration response containing recovery codes - */ - @Transactional - public UserRegisterResponse addUser(UserRegisterRequest req) { - boolean isFirstUser = userRepository.count() == 0; - User.Role role = isFirstUser ? User.Role.ADMIN : User.Role.USER; // First user is ADMIN, others are USER - return addUser(req, role); - } - - /** - * Authenticates a user by email and password, returning a JWT token and refresh token if successful. - * @param req the user login request DTO - * @return the authentication response containing the JWT token and refresh token - * @throws UserNotFoundException if the user is not found - * @throws AuthWrongPasswordException if the password is incorrect - */ - public AuthResponse authenticateUser(UserLoginRequest req) { - User user = userRepository.findByEmail(req.getEmail()) - .orElseThrow(() -> new UserNotFoundException(req.getEmail())); - if (!passwordEncoder.matches(req.getPassword(), user.getPassword())) { - throw new AuthWrongPasswordException(req.getEmail()); - } - - user.setLastLogin(LocalDateTime.now().atZone(ZoneOffset.UTC).toInstant()); - user.setActive(true); - userRepository.save(user); - return buildAuthResponse(user, "JWT token and refresh token generated successfully."); - } - - /** - * Refreshes the JWT token and rotates the refresh token for the currently authenticated user. - * @return the authentication response containing the new JWT token and refresh token - * @throws UserNotFoundException if the user is not found - */ - public AuthResponse refreshTokens() throws UserNotFoundException { - User user = getCurrentUserWithoutDevices(); - return buildAuthResponse(user, "JWT token and refresh token refreshed successfully."); - } - - /** - * Retrieves the currently authenticated user without devices (for performance). - * @return the authenticated user entity - * @throws UserNotFoundException if the user is not found - */ - public User getCurrentUserWithoutDevices() { - Long userId = getCurrentUserId(); - return userRepository.findById(userId) - .orElseThrow(() -> new UserNotFoundException(userId)); - } - - /** - * Retrieves the currently authenticated user with devices, or null if not authenticated. - * @return the authenticated user entity with devices, or null if not authenticated - */ - public User getCurrentUser() { - if (!isUserAuthenticated()) { - return null; - } - try { - Long userId = getCurrentUserId(); - return userRepository.findByIdWithDevices(userId).orElse(null); - } catch (Exception e) { - return null; - } - } - - /** - * Retrieves the profile of the currently authenticated user as a response DTO. - * @return the user response DTO, or null if not authenticated - */ - public UserResponse getCurrentUserProfile() { - return convertUserToResponse(getCurrentUser()); - } - - /** - * Retrieves a user by their unique ID (admin only, without devices). - * @param userId the ID of the user to retrieve - * @return the user entity - * @throws UserNotFoundException if the user is not found - */ - public User getUserByIdWithoutDevices(Long userId) { - return userRepository.findById(userId).orElseThrow(() -> new UserNotFoundException(userId)); - } - - /** - * Retrieves a user by their unique ID with devices (admin only). - * @param userId the ID of the user to retrieve - * @return the user entity with devices - * @throws UserNotFoundException if the user is not found - */ - public User getUserById(Long userId) { - return userRepository.findByIdWithDevices(userId).orElseThrow(() -> new UserNotFoundException(userId)); - } - - /** - * Retrieves a user profile by their unique ID as a response DTO (admin only). - * @param userId the ID of the user to retrieve - * @return the user response DTO - * @throws UserNotFoundException if the user is not found - */ - public UserResponse getUserProfileById(Long userId) throws UserNotFoundException { - User user = getUserById(userId); - return convertUserToResponse(user); - } - - /** - * Deletes the currently authenticated user (soft delete). - * @throws IllegalStateException if no authenticated user is found - */ - public void deleteCurrentUser() throws IllegalStateException { - Long userId = getCurrentUserId(); - deleteUserById(userId); - } - - /** - * Deletes a user by their unique ID (admin only, soft delete). - * @param userId the ID of the user to delete - * @throws UserDeletedException if the user is already deleted - * @throws UserNotFoundException if the user is not found - */ - public void deleteUserById(Long userId) { - User user = userRepository.findById(userId).orElseThrow(() -> new UserNotFoundException(userId)); - if (user.isDeleted()) { - throw new UserDeletedException(userId); - } - user.setDeleted(true); - userRepository.save(user); - } - - /** - * Resets the user's password using a recovery code provided to the user on registration. - * @param req the password reset request DTO containing the user email, recovery code and new password - * @throws RecoveryTokenNotFoundException if the recovery token is not found - * @throws UserNotFoundException if the user is not found - * @throws UserDeletedException if the user is deleted - */ - @Transactional - public void resetPassword(PasswordResetRequest req) { - String encodedCode = encoderService.hmacSha256Encoder().apply(securityCodeSecret, req.getRecoveryCode()); - UserToken recoveryToken = userTokenRepository - .findByTokenAndType(encodedCode, UserToken.TokenType.RECOVERY_CODE) - .orElseThrow(() -> new RecoveryTokenNotFoundException("Invalid recovery code.")); - - User user = recoveryToken.getUser(); - if (user == null) { - throw new UserNotFoundException("User associated with the token not found."); - } - if (user.isDeleted()) { - throw new UserDeletedException(user.getId()); - } - if (!user.getEmail().equals(req.getEmail())) { - throw new RecoveryTokenMismatchException("Recovery code does not match the provided email."); - } - user.setPassword(passwordEncoder.encode(req.getNewPassword())); - userRepository.save(user); - // Invalidate the used token - userTokenRepository.delete(recoveryToken); - } - - /** - * Update the currently authenticated user's account settings. - * @param req the user update request DTO containing the fields to update - * @return the updated user response DTO - * @throws IllegalStateException if no authenticated user is found - * @throws UserNotFoundException if the user is not found - * @throws UserDeletedException if the user is deleted - */ - @Transactional - public UserResponse updateCurrentUser(UserUpdateRequest req) { - Long userId = getCurrentUserId(); - User user = userRepository.findById(userId).orElseThrow(() -> new UserNotFoundException(userId)); - if (user.isDeleted()) { - throw new UserDeletedException(userId); - } - if (req.getFullName() != null && !req.getFullName().isBlank()) { - user.setFullName(req.getFullName()); - } - if (req.getEmail() != null && !req.getEmail().isBlank()) { - user.setEmail(req.getEmail()); - } - if (req.getPhoneNumber() != null) { - user.setPhoneNumber(req.getPhoneNumber()); - } - if (req.getAddress() != null) { - user.setAddress(req.getAddress()); - } - if (req.getProfilePictureUrl() != null) { - user.setProfilePictureUrl(req.getProfilePictureUrl()); - } - if (req.getPreferredLanguage() != null && !req.getPreferredLanguage().isBlank()) { - user.setPreferredLanguage(req.getPreferredLanguage()); - } - if (req.getSettings() != null) { - user.setSettings(req.getSettings()); - } - userRepository.save(user); - return convertUserToResponse(user); - } - - /*--------------------------*/ - /* Helper Methods */ - /*--------------------------*/ - - /** - * Builds an AuthResponse with both tokens and a message. - * @param user the user entity - * @param message the message to include - * @return the AuthResponse containing both tokens and the message - */ - private AuthResponse buildAuthResponse(User user, String message) { - String token = generateToken(user); - String refreshToken = generateRefreshToken(user); - return new AuthResponse(token, refreshToken, message); - } - - /** - * Checks if a user is authenticated in the security context. - * @return true if a user is authenticated, false otherwise - */ - private boolean isUserAuthenticated() { - SecurityContext context = SecurityContextHolder.getContext(); - return context.getAuthentication() != null && context.getAuthentication().isAuthenticated(); - } - - /** - * Retrieves the ID of the currently authenticated user from the security context. - * @return the user ID - * @throws IllegalStateException if no authenticated user is found - */ - private Long getCurrentUserId() { - if (isUserAuthenticated()) { - String name = SecurityContextHolder.getContext().getAuthentication().getName(); - if ("anonymousUser".equals(name)) { - throw new IllegalStateException("No authenticated user found in security context."); - } - return Long.parseLong(name); - } else { - throw new IllegalStateException("No authenticated user found in security context."); - } - } - - /** - * Converts a UserRegisterRequest DTO to a User entity. - * @param req the user registration request DTO - * @return the user entity - */ - private User convertRequestToUser(UserRegisterRequest req) { - String encodedPassword = passwordEncoder.encode(req.getPassword()); // Bcrypt password encoding - User user = new User(); - user.setUsername(req.getUsername()); - user.setPassword(encodedPassword); - user.setEmail(req.getEmail()); - user.setFullName(req.getFullName()); - user.setPreferredLanguage(req.getPreferredLanguage()); - return user; - } - - /** - * Converts a User entity to a UserResponse DTO. - * @param user the user entity - * @return the user response DTO - */ - private UserResponse convertUserToResponse(User user) { - if (user == null) { - return null; - } - - UserResponse response = new UserResponse(); - response.setId(user.getId()); - response.setUsername(user.getUsername()); - response.setFullName(user.getFullName()); - response.setEmail(user.getEmail()); - response.setProfilePictureUrl(user.getProfilePictureUrl()); - response.setPhoneNumber(user.getPhoneNumber()); - response.setAddress(user.getAddress()); - response.setCreatedAt(user.getCreatedAt()); - response.setUpdatedAt(user.getUpdatedAt()); - response.setLastLogin(user.getLastLogin()); - response.setRole(user.getRole()); - response.setPreferredLanguage(user.getPreferredLanguage()); - response.setSettings(user.getSettings()); - - List userDevices = user.getDevices() != null ? - DeviceDtoUtil.convertDevicesToResponse(user.getDevices()) : - Collections.emptyList(); - response.setDevices(userDevices); - - return response; - } - - /** - * Generates a JWT token for a given user. - * @param user the user entity - * @return a String containing the JWT token - * @throws UserDeletedException if the user is deleted - */ - private String generateToken(User user) { - if (user.isDeleted()) { - throw new UserDeletedException(user.getId()); - } - return jwtUtil.generateToken(user); - } - - /** - * Generates a refresh JWT token for a given user. - * @param user the user entity - * @return a String containing the refresh JWT token - * @throws UserDeletedException if the user is deleted - */ - private String generateRefreshToken(User user) { - if (user.isDeleted()) { - throw new UserDeletedException(user.getId()); - } - return jwtUtil.generateRefreshToken(user); - } -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/service/UserTokenService.java b/src/main/java/dev/ivfrost/hydro_backend/service/UserTokenService.java deleted file mode 100644 index 1593dc3..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/service/UserTokenService.java +++ /dev/null @@ -1,35 +0,0 @@ -package dev.ivfrost.hydro_backend.service; - -import dev.ivfrost.hydro_backend.entity.UserToken; -import dev.ivfrost.hydro_backend.exception.RecoveryTokenMismatchException; -import dev.ivfrost.hydro_backend.exception.RecoveryTokenNotFoundException; -import dev.ivfrost.hydro_backend.repository.UserTokenRepository; -import lombok.Data; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -@Data -@Service -public class UserTokenService { - - private final UserTokenRepository userTokenRepository; - private final EncoderService encoderService; - - @Value("${security-code.secret}") - private String securityCodeSecret; - - public UserTokenService(UserTokenRepository userTokenRepository, EncoderService encoderService) { - this.userTokenRepository = userTokenRepository; - this.encoderService = encoderService; - } - - public boolean isRecoveryCodeValid(String rawCode, String email) { - String encodedCode = encoderService.hmacSha256Encoder().apply(securityCodeSecret, rawCode); - UserToken userToken = userTokenRepository.findByTokenAndType(encodedCode, UserToken.TokenType.RECOVERY_CODE) - .orElseThrow(() -> new RecoveryTokenNotFoundException("Recovery code not found")); - if (!userToken.getUser().getEmail().equals(email)) { - throw new RecoveryTokenMismatchException("Recovery code does not match the provided email"); - } - return true; - } -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/tokens/DeviceSecretConverter.java b/src/main/java/dev/ivfrost/hydro_backend/tokens/DeviceSecretConverter.java new file mode 100644 index 0000000..e13ef25 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/tokens/DeviceSecretConverter.java @@ -0,0 +1,58 @@ +package dev.ivfrost.hydro_backend.tokens; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + + +/** + * JPA converter that automatically encrypts recovery secrets when storing to database and decrypts + * when retrieving from database. + */ +@Slf4j +@RequiredArgsConstructor +@Converter +@Component +public class DeviceSecretConverter implements AttributeConverter { + + private final EncryptionUtil encryptionUtil; + @Value("${device.secret}") + private String deviceSecret; + + @Override + public String convertToDatabaseColumn(String attribute) { + if (attribute == null) { + return null; + } + checkInitialized(); + String encrypted = encryptionUtil.encrypt(attribute, deviceSecret); + log.info("DEBUG CONVERTER - Encrypting '{}' -> '{}'", attribute, encrypted); + return encrypted; + } + + @Override + public String convertToEntityAttribute(String dbData) { + if (dbData == null) { + return null; + } + checkInitialized(); + String decrypted = encryptionUtil.decrypt(dbData, deviceSecret); + log.info("DEBUG CONVERTER - Decrypting '{}' -> '{}'", dbData, decrypted); + return decrypted; + } + + private void checkInitialized() { + if (encryptionUtil == null) { + log.error("EncryptionUtil not initialized - cannot encrypt"); + throw new IllegalStateException("EncryptionUtil not initialized - cannot encrypt"); + } + if (deviceSecret == null) { + log.error("Recovery secret not initialized - cannot encrypt"); + throw new IllegalStateException("Device secret not initialized - cannot encrypt"); + } + } +} + diff --git a/src/main/java/dev/ivfrost/hydro_backend/tokens/EncryptionUtil.java b/src/main/java/dev/ivfrost/hydro_backend/tokens/EncryptionUtil.java new file mode 100644 index 0000000..de1da6e --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/tokens/EncryptionUtil.java @@ -0,0 +1,68 @@ +package dev.ivfrost.hydro_backend.tokens; + +import java.security.SecureRandom; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.codec.Hex; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class EncryptionUtil { + + private static final SecureRandom secureRandom = new SecureRandom(); + private static final String ALGORITHM = "AES"; + + public static String generateRandomString(int length) { + byte[] randomBytes = new byte[length]; + secureRandom.nextBytes(randomBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes).substring(0, length); + } + + /** + * Encrypts data deterministically using AES ECB mode. Same input + same secret = same output + * (required for DB queries). + */ + public String encrypt(String raw, String secret) { + try { + byte[] keyBytes = deriveKey(secret); + SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM); + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); + cipher.init(Cipher.ENCRYPT_MODE, keySpec); + byte[] encrypted = cipher.doFinal(raw.getBytes()); + return new String(Hex.encode(encrypted)); + } catch (Exception e) { + throw new RuntimeException("Encryption failed", e); + } + } + + /** + * Decrypts data. + */ + public String decrypt(String encrypted, String secret) { + try { + byte[] keyBytes = deriveKey(secret); + SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM); + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); + cipher.init(Cipher.DECRYPT_MODE, keySpec); + byte[] decrypted = cipher.doFinal(Hex.decode(encrypted)); + return new String(decrypted); + } catch (Exception e) { + throw new RuntimeException("Decryption failed", e); + } + } + + /** + * Derives a 16-byte AES key from the secret. + */ + private byte[] deriveKey(String secret) { + byte[] secretBytes = secret.getBytes(); + byte[] key = new byte[16]; + System.arraycopy(secretBytes, 0, key, 0, Math.min(secretBytes.length, 16)); + return key; + } +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/tokens/JWTUtil.java b/src/main/java/dev/ivfrost/hydro_backend/tokens/JWTUtil.java new file mode 100644 index 0000000..16430be --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/tokens/JWTUtil.java @@ -0,0 +1,194 @@ +package dev.ivfrost.hydro_backend.tokens; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTCreator; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTCreationException; +import com.auth0.jwt.exceptions.JWTDecodeException; +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.auth0.jwt.interfaces.Claim; +import com.auth0.jwt.interfaces.DecodedJWT; +import dev.ivfrost.hydro_backend.users.UserMqttTokenPayload; +import dev.ivfrost.hydro_backend.users.UserTokenPayload; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.RSAPrivateKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JWTUtil { + + private static final String AUTH_TOKEN_SUBJECT = "UserDetails"; + private static final String ISSUER = "HydroAPI"; + @Value("${jwt.secret}") + private String jwtSecret; + @Value("${jwt.access.expiration.ms}") + private Long jwtAccessExpirationMs; + @Value("${jwt.refresh.expiration.ms}") + private Long jwtRefreshExpirationMs; + @Value("${mqtt.jwt.private.key.path}") + private String mqttJwtPrivateKeyPath; + @Value("${mqtt.jwt.expiration-ms}") + private Long mqttJwtExpirationMs; + + // Build JWT token for authentication + public JWTCreator.Builder buildAccessToken(UserTokenPayload payload) throws JWTCreationException { + List roles = (payload.roles() == null) ? List.of() : payload.roles() + .stream() + .map(String::toUpperCase) + .toList(); + + return JWT.create() + .withSubject(AUTH_TOKEN_SUBJECT) + .withClaim("username", payload.username()) + .withClaim("email", payload.email()) + .withClaim("roles", roles) + .withClaim("preferredLanguage", payload.preferredLanguage()) + .withIssuer(ISSUER); + } + + // Sign auth JWT token with HMAC using SHA-512 + private String signAccessToken(JWTCreator.Builder builder, Long expirationMs) + throws JWTCreationException { + try { + Instant now = Instant.now(); + Instant expiresAt = now.plus(expirationMs, ChronoUnit.MILLIS); + return builder + .withIssuedAt(now) + .withExpiresAt(expiresAt) + .sign(getAuthAlgorithm()); + } catch (JWTCreationException e) { + log.error("Error signing auth token", e); + throw e; + } + } + + // Create auth JWT token + public String generateAccessToken(UserTokenPayload payload) { + JWTCreator.Builder builder; + try { + builder = buildAccessToken(payload); + } catch (JWTCreationException e) { + log.error("Error building JWT token", e); + throw e; + } + return signAccessToken(builder, jwtAccessExpirationMs); + } + + private Algorithm getAuthAlgorithm() { + byte[] secretBytes = jwtSecret.getBytes(); + return Algorithm.HMAC512(secretBytes); + } + + private Algorithm getMqttAlgorithm() { + RSAPrivateKey privateKey = loadPrivateKeyFromFile(mqttJwtPrivateKeyPath); + return Algorithm.RSA256(null, privateKey); + } + + // Build JWT token for MQTT authentication + public JWTCreator.Builder buildMqttToken(UserMqttTokenPayload payload) + throws JWTCreationException { + log.debug("Building MQTT token for userId: {}, topics: {}", payload.userId(), payload.topics()); + return JWT.create() + .withSubject(payload.userId().toString()) + .withClaim("subs", payload.topics()) + .withClaim("publ", payload.topics()) + .withIssuer(ISSUER); + } + + // Sign auth MQTT JWT token with RSA using SHA-256 + private String signMqttToken(JWTCreator.Builder builder, Long expirationMs) + throws JWTCreationException { + try { + Instant now = Instant.now(); + Instant expiresAt = now.plus(expirationMs, ChronoUnit.MILLIS); + return builder + .withIssuedAt(now) + .withExpiresAt(expiresAt) + .sign(getMqttAlgorithm()); + } catch (JWTCreationException e) { + log.error("Error signing MQTT token", e); + throw e; + } + } + + // Create long-lived auth refresh JWT token for obtaining new short-lived tokens + public String generateRefreshToken(UserTokenPayload payload) { + JWTCreator.Builder builder = buildAccessToken(payload); + return signAccessToken(builder, jwtRefreshExpirationMs); + } + + // Create a short-lived MQTT auth JWT token + public String generateMqttToken(UserMqttTokenPayload payload) { + JWTCreator.Builder builder; + try { + builder = buildMqttToken(payload); + } catch (JWTCreationException e) { + log.error("Error building MQTT JWT token", e); + throw e; + } + return signMqttToken(builder, mqttJwtExpirationMs); + } + + public Map validateTokenAndRetrieveClaims(String token) + throws JWTVerificationException, IllegalArgumentException { + if (token == null || token.isBlank()) { + throw new IllegalArgumentException("Token cannot be null or blank"); + } + DecodedJWT jwt; + try { + jwt = JWT.require(getAuthAlgorithm()) + .withSubject(AUTH_TOKEN_SUBJECT) + .withIssuer(ISSUER) + .build() + .verify(token); + } catch (JWTDecodeException e) { + log.error("Invalid JWT format: {}", e.getMessage()); + throw new JWTVerificationException("Invalid JWT token format", e); + } catch (JWTVerificationException e) { + log.error("Error verifying JWT token: {}", e.getMessage()); + throw e; + } + return jwt.getClaims(); + } + + public Instant getAccessTokenExpiryDate() { + return Instant.now().plus(jwtAccessExpirationMs, ChronoUnit.MILLIS); + } + + public Instant getRefreshTokenExpiryDate() { + return Instant.now().plus(jwtRefreshExpirationMs, ChronoUnit.MILLIS); + } + + private RSAPrivateKey loadPrivateKeyFromFile(String path) { + if (path == null || path.isBlank()) { + throw new IllegalArgumentException("Private key file path cannot be null or blank"); + } + File keyFile = new File(path); + if (!keyFile.exists() || !keyFile.isFile()) { + throw new IllegalArgumentException("Invalid private key file path: " + path); + } + try { + byte[] keyBytes = Files.readAllBytes(keyFile.toPath()); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); + return (RSAPrivateKey) keyFactory.generatePrivate(keySpec); + } catch (IOException | NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/ivfrost/hydro_backend/tokens/MqttTokenProvider.java b/src/main/java/dev/ivfrost/hydro_backend/tokens/MqttTokenProvider.java new file mode 100644 index 0000000..1280358 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/tokens/MqttTokenProvider.java @@ -0,0 +1,14 @@ +package dev.ivfrost.hydro_backend.tokens; + +import dev.ivfrost.hydro_backend.users.UserMqttTokenPayload; + +public interface MqttTokenProvider { + + /** + * Generates an MQTT authentication JWT token for a device + * + * @param payload the MQTT token payload containing device ID and topics + * @return the signed JWT token + */ + String generateMqttToken(UserMqttTokenPayload payload); +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/tokens/RecoveryCodeUtil.java b/src/main/java/dev/ivfrost/hydro_backend/tokens/RecoveryCodeUtil.java new file mode 100644 index 0000000..3e70170 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/tokens/RecoveryCodeUtil.java @@ -0,0 +1,32 @@ +package dev.ivfrost.hydro_backend.tokens; + +import java.security.SecureRandom; + +public class RecoveryCodeUtil { + + public static final int RECOVERY_CODE_LENGTH = 16; + public static final int RECOVERY_CODE_COUNT = 5; + private static final String CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*"; + private static final SecureRandom RANDOM = new SecureRandom(); + + private RecoveryCodeUtil() { + } + + public static String generateRecoveryCode() { + StringBuilder code = new StringBuilder(RECOVERY_CODE_LENGTH); + for (int i = 0; i < RECOVERY_CODE_LENGTH; i++) { + int idx = RANDOM.nextInt(CHARSET.length()); + code.append(CHARSET.charAt(idx)); + } + return code.toString(); + } + + public static String[] generateRecoveryCodes() { + String[] codes = new String[RECOVERY_CODE_COUNT]; + for (int i = 0; i < RECOVERY_CODE_COUNT; i++) { + codes[i] = generateRecoveryCode(); + } + return codes; + } + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/tokens/TokenResponse.java b/src/main/java/dev/ivfrost/hydro_backend/tokens/TokenResponse.java new file mode 100644 index 0000000..eb9cd17 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/tokens/TokenResponse.java @@ -0,0 +1,11 @@ +package dev.ivfrost.hydro_backend.tokens; + +import java.time.Instant; + +public record TokenResponse( + String value, + String type, + Instant expiryDate, + long userId) { + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/tokens/TokenWithExpiry.java b/src/main/java/dev/ivfrost/hydro_backend/tokens/TokenWithExpiry.java new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/dev/ivfrost/hydro_backend/tokens/internal/Token.java b/src/main/java/dev/ivfrost/hydro_backend/tokens/internal/Token.java new file mode 100644 index 0000000..cb4882a --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/tokens/internal/Token.java @@ -0,0 +1,43 @@ +package dev.ivfrost.hydro_backend.tokens.internal; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; +import lombok.Data; + +@Data +@Entity +@Table(name = "tokens") +public class Token { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + @Convert(converter = TokenValueConverter.class) + @Size(max = 255) + @Column(nullable = false) + private String value; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private TokenType type; + @Column(name = "expiry_date", nullable = true) + private LocalDateTime expiryDate; + @Column(name = "user_id", nullable = false) + private long userId; + + public enum TokenType { + RECOVERY_CODE, + AUTH_ACCESS_TOKEN, + AUTH_REFRESH_TOKEN + + } + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/tokens/internal/TokenRepository.java b/src/main/java/dev/ivfrost/hydro_backend/tokens/internal/TokenRepository.java new file mode 100644 index 0000000..47adad0 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/tokens/internal/TokenRepository.java @@ -0,0 +1,12 @@ +package dev.ivfrost.hydro_backend.tokens.internal; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TokenRepository extends JpaRepository { + + Token findTokenByValueAndUserId(String value, long userId); +} + + diff --git a/src/main/java/dev/ivfrost/hydro_backend/tokens/internal/TokenService.java b/src/main/java/dev/ivfrost/hydro_backend/tokens/internal/TokenService.java new file mode 100644 index 0000000..dac73ec --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/tokens/internal/TokenService.java @@ -0,0 +1,85 @@ +package dev.ivfrost.hydro_backend.tokens.internal; + +import com.auth0.jwt.interfaces.Claim; +import dev.ivfrost.hydro_backend.tokens.JWTUtil; +import dev.ivfrost.hydro_backend.tokens.RecoveryCodeUtil; +import dev.ivfrost.hydro_backend.tokens.TokenResponse; +import dev.ivfrost.hydro_backend.tokens.internal.Token.TokenType; +import dev.ivfrost.hydro_backend.users.UserTokenPayload; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class TokenService { + + private final TokenRepository tokenRepository; + private final JWTUtil jWTUtil; + + public boolean isTokenValidForUserId(String token, long userId) { + Token foundToken = tokenRepository.findTokenByValueAndUserId(token, userId); + if (foundToken == null) { + return false; + } + tokenRepository.delete(foundToken); + return true; + } + + public List generateAccessTokens(UserTokenPayload payload) { + return List.of( + new TokenResponse( + jWTUtil.generateAccessToken(payload), + TokenType.AUTH_ACCESS_TOKEN.toString(), + jWTUtil.getAccessTokenExpiryDate(), + payload.userId() + ), + new TokenResponse( + jWTUtil.generateRefreshToken(payload), + TokenType.AUTH_REFRESH_TOKEN.toString(), + jWTUtil.getRefreshTokenExpiryDate(), + payload.userId() + ) + ); + } + + public List generateRecoveryTokens(Long userId) { + String[] recoveryCodes = RecoveryCodeUtil.generateRecoveryCodes(); + + // Save recovery codes to the database + List tokens = Arrays.stream(recoveryCodes) + .map(code -> { + Token token = new Token(); + token.setValue(code); + token.setType(TokenType.RECOVERY_CODE); + token.setUserId(userId); + token.setExpiryDate(null); + return token; + }) + .toList(); + tokenRepository.saveAll(tokens); + + return Arrays.stream(recoveryCodes) + .map(code -> new TokenResponse(code, TokenType.RECOVERY_CODE.toString(), null, userId)) + .toList(); + } + + public Map validateTokenAndRetrieveClaims(String token) { + Map claims = jWTUtil.validateTokenAndRetrieveClaims(token); + return claims.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> { + if (entry.getValue() == null || entry.getValue().isMissing() || entry.getValue() + .isNull()) { + return ""; + } + String strValue = entry.getValue().asString(); + return strValue != null ? strValue : String.valueOf(entry.getValue()); + } + )); + } +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/tokens/internal/TokenValueConverter.java b/src/main/java/dev/ivfrost/hydro_backend/tokens/internal/TokenValueConverter.java new file mode 100644 index 0000000..0c02769 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/tokens/internal/TokenValueConverter.java @@ -0,0 +1,55 @@ +package dev.ivfrost.hydro_backend.tokens.internal; + +import dev.ivfrost.hydro_backend.tokens.EncryptionUtil; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + + +/** + * JPA converter that automatically encrypts token value when storing to database and decrypts when + * retrieving from database. + */ +@Slf4j +@RequiredArgsConstructor +@Converter +@Component +class TokenValueConverter implements AttributeConverter { + + private final EncryptionUtil encryptionUtil; + @Value("${recovery.secret}") + private String recoverySecret; + + @Override + public String convertToDatabaseColumn(String attribute) { + if (attribute == null) { + return null; + } + checkInitialized(); + return encryptionUtil.encrypt(attribute, recoverySecret); + } + + @Override + public String convertToEntityAttribute(String dbData) { + if (dbData == null) { + return null; + } + checkInitialized(); + return encryptionUtil.decrypt(dbData, recoverySecret); + } + + private void checkInitialized() { + if (encryptionUtil == null) { + log.error("EncryptionUtil not initialized - cannot encrypt"); + throw new IllegalStateException("EncryptionUtil not initialized - cannot encrypt"); + } + if (recoverySecret == null) { + log.error("Recovery secret not initialized - cannot encrypt"); + throw new IllegalStateException("Recovery secret not initialized - cannot encrypt"); + } + } +} + diff --git a/src/main/java/dev/ivfrost/hydro_backend/tokens/internal/adapter/DeviceTokenProviderImpl.java b/src/main/java/dev/ivfrost/hydro_backend/tokens/internal/adapter/DeviceTokenProviderImpl.java new file mode 100644 index 0000000..b824a05 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/tokens/internal/adapter/DeviceTokenProviderImpl.java @@ -0,0 +1,5 @@ +package dev.ivfrost.hydro_backend.tokens.internal.adapter; + +public class DeviceTokenProviderImpl { + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/tokens/internal/adapter/MqttTokenProviderImpl.java b/src/main/java/dev/ivfrost/hydro_backend/tokens/internal/adapter/MqttTokenProviderImpl.java new file mode 100644 index 0000000..893a743 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/tokens/internal/adapter/MqttTokenProviderImpl.java @@ -0,0 +1,19 @@ +package dev.ivfrost.hydro_backend.tokens.internal.adapter; + +import dev.ivfrost.hydro_backend.tokens.JWTUtil; +import dev.ivfrost.hydro_backend.tokens.MqttTokenProvider; +import dev.ivfrost.hydro_backend.users.UserMqttTokenPayload; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class MqttTokenProviderImpl implements MqttTokenProvider { + + private final JWTUtil jwtUtil; + + @Override + public String generateMqttToken(UserMqttTokenPayload payload) { + return jwtUtil.generateMqttToken(payload); + } +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/tokens/internal/adapter/UserTokenProviderImpl.java b/src/main/java/dev/ivfrost/hydro_backend/tokens/internal/adapter/UserTokenProviderImpl.java new file mode 100644 index 0000000..1b9cb45 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/tokens/internal/adapter/UserTokenProviderImpl.java @@ -0,0 +1,39 @@ +package dev.ivfrost.hydro_backend.tokens.internal.adapter; + +import dev.ivfrost.hydro_backend.tokens.TokenResponse; +import dev.ivfrost.hydro_backend.tokens.internal.TokenService; +import dev.ivfrost.hydro_backend.users.UserTokenPayload; +import dev.ivfrost.hydro_backend.users.UserTokenProvider; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Service; + +@Service +public class UserTokenProviderImpl implements UserTokenProvider { + + private final TokenService tokenService; + + public UserTokenProviderImpl(TokenService tokenService) { + this.tokenService = tokenService; + } + + @Override + public boolean isTokenValidForUserId(String token, long userId) { + return tokenService.isTokenValidForUserId(token, userId); + } + + @Override + public List generateRecoveryTokens(long userId) { + return tokenService.generateRecoveryTokens(userId); + } + + @Override + public List generateAccessTokens(UserTokenPayload payload) { + return tokenService.generateAccessTokens(payload); + } + + @Override + public Map validateTokenAndRetrieveClaims(String token) { + return tokenService.validateTokenAndRetrieveClaims(token); + } +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/users/DeviceTopicProvider.java b/src/main/java/dev/ivfrost/hydro_backend/users/DeviceTopicProvider.java new file mode 100644 index 0000000..e4f7e6f --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/users/DeviceTopicProvider.java @@ -0,0 +1,9 @@ +package dev.ivfrost.hydro_backend.users; + +import java.util.List; + +public interface DeviceTopicProvider { + + List getTopicsForUser(Long userId); + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/users/MyUserDetails.java b/src/main/java/dev/ivfrost/hydro_backend/users/MyUserDetails.java new file mode 100644 index 0000000..807f92d --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/users/MyUserDetails.java @@ -0,0 +1,44 @@ +package dev.ivfrost.hydro_backend.users; + +import dev.ivfrost.hydro_backend.users.internal.User; +import java.util.Collection; +import java.util.List; +import lombok.AllArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +@AllArgsConstructor +public class MyUserDetails implements UserDetails { + + private final User user; + + @Override + public Collection getAuthorities() { + if (user == null || user.getRoles() == null) { + return List.of(); + } + return user.getRoles().stream() + .map(role -> { + String rn = role.name(); + return new SimpleGrantedAuthority(rn.startsWith("ROLE_") ? rn : "ROLE_" + rn); + }) + .toList(); + } + + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public String getUsername() { + return String.valueOf(user.getId()); + } + + @Override + public boolean isEnabled() { + return user.isEnabled(); + } + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/users/MyUserDetailsService.java b/src/main/java/dev/ivfrost/hydro_backend/users/MyUserDetailsService.java new file mode 100644 index 0000000..753a2bf --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/users/MyUserDetailsService.java @@ -0,0 +1,26 @@ +package dev.ivfrost.hydro_backend.users; + +import dev.ivfrost.hydro_backend.users.internal.User; +import dev.ivfrost.hydro_backend.users.internal.UserRepository; +import java.util.Optional; +import lombok.AllArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@AllArgsConstructor +@Service +public class MyUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + Optional userOpt = userRepository.findByUsername(username); + if (userOpt.isEmpty()) { + throw new UsernameNotFoundException("User not found: " + username); + } + return new MyUserDetails(userOpt.get()); + } +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/users/UserAuthRequest.java b/src/main/java/dev/ivfrost/hydro_backend/users/UserAuthRequest.java new file mode 100644 index 0000000..f6a0c2a --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/users/UserAuthRequest.java @@ -0,0 +1,17 @@ +package dev.ivfrost.hydro_backend.users; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record UserAuthRequest( + + @NotNull(message = "Email is required") + @Email(message = "Invalid email") + String email, + + @NotNull(message = "Password is required") + @NotBlank + String password) { + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/users/UserDeviceProvider.java b/src/main/java/dev/ivfrost/hydro_backend/users/UserDeviceProvider.java new file mode 100644 index 0000000..6945269 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/users/UserDeviceProvider.java @@ -0,0 +1,12 @@ +package dev.ivfrost.hydro_backend.users; + +import dev.ivfrost.hydro_backend.devices.DeviceResponse; +import dev.ivfrost.hydro_backend.devices.DeviceUpdateRequest; +import java.util.List; + +public interface UserDeviceProvider { + + List getUserDevices(Long userId); + + DeviceResponse updateUserDevice(DeviceUpdateRequest request); +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/users/UserLoginRequest.java b/src/main/java/dev/ivfrost/hydro_backend/users/UserLoginRequest.java new file mode 100644 index 0000000..eb5544b --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/users/UserLoginRequest.java @@ -0,0 +1,15 @@ +package dev.ivfrost.hydro_backend.users; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; + +public record UserLoginRequest( + @Email + @Size(min = 5, max = 60) + String email, + @Size(min = 8, max = 42) + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + String password) { + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/users/UserMqttResponse.java b/src/main/java/dev/ivfrost/hydro_backend/users/UserMqttResponse.java new file mode 100644 index 0000000..af517a1 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/users/UserMqttResponse.java @@ -0,0 +1,5 @@ +package dev.ivfrost.hydro_backend.users; + +public record UserMqttResponse(Long userId, String mqttToken) { + +} \ No newline at end of file diff --git a/src/main/java/dev/ivfrost/hydro_backend/users/UserMqttTokenPayload.java b/src/main/java/dev/ivfrost/hydro_backend/users/UserMqttTokenPayload.java new file mode 100644 index 0000000..280e105 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/users/UserMqttTokenPayload.java @@ -0,0 +1,8 @@ +package dev.ivfrost.hydro_backend.users; + +import java.util.List; + +public record UserMqttTokenPayload(Long userId, List topics) { + +} + diff --git a/src/main/java/dev/ivfrost/hydro_backend/users/UserRecoveryRequest.java b/src/main/java/dev/ivfrost/hydro_backend/users/UserRecoveryRequest.java new file mode 100644 index 0000000..7d84bc7 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/users/UserRecoveryRequest.java @@ -0,0 +1,13 @@ +package dev.ivfrost.hydro_backend.users; + +import dev.ivfrost.hydro_backend.tokens.RecoveryCodeUtil; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; + +public record UserRecoveryRequest(@Email(message = "Invalid email format") String email, @Size( + min = RecoveryCodeUtil.RECOVERY_CODE_LENGTH, + max = RecoveryCodeUtil.RECOVERY_CODE_LENGTH, + message = "Wrong recovery code length") String recoveryCode, + @Size(min = 8, max = 60, message = "New password must be between 8 and 60 characters long") String newPassword) { + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/users/UserRefreshRequest.java b/src/main/java/dev/ivfrost/hydro_backend/users/UserRefreshRequest.java new file mode 100644 index 0000000..2667fa1 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/users/UserRefreshRequest.java @@ -0,0 +1,10 @@ +package dev.ivfrost.hydro_backend.users; + +import jakarta.validation.constraints.NotBlank; + +public record UserRefreshRequest( + @NotBlank(message = "Refresh token is required") + String refreshToken) { + +} + diff --git a/src/main/java/dev/ivfrost/hydro_backend/users/UserRegisterRequest.java b/src/main/java/dev/ivfrost/hydro_backend/users/UserRegisterRequest.java new file mode 100644 index 0000000..7283c13 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/users/UserRegisterRequest.java @@ -0,0 +1,30 @@ +package dev.ivfrost.hydro_backend.users; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record UserRegisterRequest( + @Email + @NotNull + @Size(min = 5, max = 60) + String email, + + @NotNull + @Size(min = 5, max = 20) + String username, + + @NotNull + @Size(min = 6, max = 40) + String fullName, + + @NotNull + @Size(min = 8, max = 42) + String password, + + @NotNull + @Size(min = 2, max = 2) + String preferredLanguage +) { + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/users/UserRegisterResponse.java b/src/main/java/dev/ivfrost/hydro_backend/users/UserRegisterResponse.java new file mode 100644 index 0000000..d862827 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/users/UserRegisterResponse.java @@ -0,0 +1,8 @@ +package dev.ivfrost.hydro_backend.users; + +import java.util.List; + +public record UserRegisterResponse( + List recoveryCodes) { + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/users/UserResponse.java b/src/main/java/dev/ivfrost/hydro_backend/users/UserResponse.java new file mode 100644 index 0000000..eb886d1 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/users/UserResponse.java @@ -0,0 +1,28 @@ +package dev.ivfrost.hydro_backend.users; + +import java.io.Serial; +import java.io.Serializable; +import java.time.Instant; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class UserResponse implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + Long id; + String username; + String fullName; + String email; + String profilePictureUrl; + String phoneNumber; + String address; + Instant createdAt; + Instant updatedAt; + List roles; + String preferredLanguage; + String settings; +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/users/UserTokenPayload.java b/src/main/java/dev/ivfrost/hydro_backend/users/UserTokenPayload.java new file mode 100644 index 0000000..39f0500 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/users/UserTokenPayload.java @@ -0,0 +1,8 @@ +package dev.ivfrost.hydro_backend.users; + +import java.util.List; + +public record UserTokenPayload(String username, String email, List roles, + String preferredLanguage, long userId) { + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/users/UserTokenProvider.java b/src/main/java/dev/ivfrost/hydro_backend/users/UserTokenProvider.java new file mode 100644 index 0000000..f07cccc --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/users/UserTokenProvider.java @@ -0,0 +1,16 @@ +package dev.ivfrost.hydro_backend.users; + +import dev.ivfrost.hydro_backend.tokens.TokenResponse; +import java.util.List; +import java.util.Map; + +public interface UserTokenProvider { + + boolean isTokenValidForUserId(String token, long userId); + + List generateRecoveryTokens(long userId); + + List generateAccessTokens(UserTokenPayload payload); + + Map validateTokenAndRetrieveClaims(String token); +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/users/UserUpdateLastOnlineEvent.java b/src/main/java/dev/ivfrost/hydro_backend/users/UserUpdateLastOnlineEvent.java new file mode 100644 index 0000000..9b30a6d --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/users/UserUpdateLastOnlineEvent.java @@ -0,0 +1,9 @@ +package dev.ivfrost.hydro_backend.users; + +import dev.ivfrost.hydro_backend.config.AmqpConfig; +import org.springframework.modulith.events.Externalized; + +@Externalized(target = AmqpConfig.HYDRO_Q) +public record UserUpdateLastOnlineEvent(String username) { + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/users/UserUpdateRequest.java b/src/main/java/dev/ivfrost/hydro_backend/users/UserUpdateRequest.java new file mode 100644 index 0000000..dfd80f8 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/users/UserUpdateRequest.java @@ -0,0 +1,39 @@ +package dev.ivfrost.hydro_backend.users; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record UserUpdateRequest( + @Size(min = 5, max = 20) + String username, + + @Size(min = 8, max = 42) + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + String password, + + @Size(min = 6, max = 40) + String fullName, + + @Email(message = "Invalid email format") + @Size(min = 8, max = 50) + String email, + + @Size(max = 255) + String profilePictureUrl, + + @Pattern(regexp = "^\\+?[0-9\\-\\s]{7,20}$", message = "Invalid phone number format") + @Size(max = 20) + String phoneNumber, + + @Size(max = 100) + String address, + + @Size(min = 2, max = 2) + String preferredLanguage, + + String settings +) { + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/users/internal/User.java b/src/main/java/dev/ivfrost/hydro_backend/users/internal/User.java new file mode 100644 index 0000000..bd0b06d --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/users/internal/User.java @@ -0,0 +1,104 @@ +package dev.ivfrost.hydro_backend.users.internal; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Table; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; +import java.io.Serial; +import java.io.Serializable; +import java.time.Instant; +import java.util.List; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.Fetch; +import org.hibernate.annotations.FetchMode; +import org.hibernate.annotations.UpdateTimestamp; + +@NoArgsConstructor +@Data +@Table(name = "users") +@Entity +public class User implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Size(min = 5, max = 20) + @Column(unique = true, nullable = false) + private String username; + + @Size(max = 255) + @Column(nullable = false) + private String password; + + @Size(min = 4, max = 40) + @Column(name = "full_name", nullable = false) + private String fullName; + + @Email(message = "Invalid email format") + @Size(min = 8, max = 50) + @Column(unique = true, nullable = false) + private String email; + + @Column(name = "profile_pic") + @Size(max = 255) + private String profilePictureUrl; + + @Size(max = 20) + @Column(name = "phone_number") + private String phoneNumber; + + @Size(max = 100) + @Column(columnDefinition = "text") + private String address; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Column(name = "deleted_at") + private Instant deletedAt; + + @ElementCollection + @Enumerated(EnumType.STRING) + @Fetch(FetchMode.JOIN) + @CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id")) + @Column(name = "role", nullable = false) + private List roles; + + @Size(min = 2, max = 2) + @Column(name = "preferred_language", nullable = false) + private String preferredLanguage = "es"; + + @Column(name = "settings", columnDefinition = "text") + private String settings; + + @Column(columnDefinition = "text") + private String notes; + + @Column(name = "is_enabled", nullable = false) + private boolean isEnabled = true; + + public enum Role { + USER, ADMIN + } + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/users/internal/UserController.java b/src/main/java/dev/ivfrost/hydro_backend/users/internal/UserController.java new file mode 100644 index 0000000..ad4fea0 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/users/internal/UserController.java @@ -0,0 +1,249 @@ +package dev.ivfrost.hydro_backend.users.internal; + +import dev.ivfrost.hydro_backend.ApiResponse; +import dev.ivfrost.hydro_backend.devices.DeviceLinkRequest; +import dev.ivfrost.hydro_backend.devices.DeviceResponse; +import dev.ivfrost.hydro_backend.tokens.TokenResponse; +import dev.ivfrost.hydro_backend.users.UserAuthRequest; +import dev.ivfrost.hydro_backend.users.UserMqttResponse; +import dev.ivfrost.hydro_backend.users.UserRecoveryRequest; +import dev.ivfrost.hydro_backend.users.UserRefreshRequest; +import dev.ivfrost.hydro_backend.users.UserRegisterRequest; +import dev.ivfrost.hydro_backend.users.UserResponse; +import dev.ivfrost.hydro_backend.users.UserUpdateRequest; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.AllArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Users Module", description = "API endpoints for user management and authentication") +@AllArgsConstructor +@RestController +@RequestMapping("/v1") +public class UserController { + + private final UserService userService; + + // ======= NON-AUTHENTICATED USERS ENDPOINTS ======= + + @Operation( + summary = "Authenticate user", + description = "Authenticates a user and returns auth and refresh JWT tokens." + ) + @PostMapping("/users/auth") + public ResponseEntity>> authenticateUser( + @Valid @RequestBody UserAuthRequest userAuthRequest) { + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(HttpStatus.OK, "User authenticated successfully", + userService.authenticateUser(userAuthRequest) + )); + } + + @Operation( + summary = "Register user", + description = "Registers a user and returns an array of recovery codes" + ) + @PostMapping("/users") + public ResponseEntity>> registerUser( + @Valid @RequestBody UserRegisterRequest userRegisterRequest) { + + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(HttpStatus.CREATED, "User registered successfully", + userService.addUser(userRegisterRequest) + )); + } + + // ======= AUTHENTICATED USERS ENDPOINTS ======= + + /** + * Resets the user's password using one of the recovery codes provided on registration. + */ + @Operation(summary = "Reset user password", + description = "Resets the user's password using one of the recovery codes provided on registration.") + @PutMapping("/users/password/reset") + public ResponseEntity> resetPassword( + @Valid @RequestBody UserRecoveryRequest passwordResetConfirmRequest) { + userService.resetPassword(passwordResetConfirmRequest); + return ResponseEntity.ok() + .body(ApiResponse.success(HttpStatus.OK, "Password reset successfully")); + } + + @Operation(summary = "Get authenticated user's profile", + description = "Retrieves the profile of the currently authenticated user.") + @GetMapping("/me") + public ResponseEntity> getCurrentUserProfile() { + return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.success(HttpStatus.OK, + "User profile retrieved successfully", userService.getCurrentUserProfile())); + } + + /* + * Updates the account settings of the currently authenticated user. + */ + @Operation(summary = "Update user's account settings", + description = "Updates the account settings of the currently authenticated user.") + @PutMapping("/me") + public ResponseEntity> updateCurrentUser( + @Valid @RequestBody UserUpdateRequest userUpdateRequest) { + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(HttpStatus.OK, "User profile updated successfully", + userService.updateCurrentUser(userUpdateRequest))); + } + + /** + * Deletes the currently authenticated user (soft delete). + */ + @Operation(summary = "Delete authenticated user", + description = "Deletes the currently authenticated user (soft delete).") + @DeleteMapping("/me") + public ResponseEntity> deleteCurrentUser() { + userService.deleteCurrentUser(); + return ResponseEntity.status(HttpStatus.NO_CONTENT) + .body(ApiResponse.success(HttpStatus.NO_CONTENT, "User deleted successfully")); + } + + /* + * Retrieves new auth and refresh tokens if current refresh token is valid. + */ + @Operation( + summary = "Get user auth JWT token", + description = "Refreshes the JWT tokens for an authenticated user.") + @PostMapping("/users/auth/refresh") + public ResponseEntity>> refreshToken( + @Valid @RequestBody UserRefreshRequest tokenRefreshRequest) { + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(HttpStatus.OK, "Tokens refreshed successfully", + userService.refreshTokens(tokenRefreshRequest) + )); + } + + /* + * Retrieves a RS256 signed JWT token for MQTT authentication. + */ + @Operation( + summary = "Retrieve MQTT auth JWT token", + description = "Retrieves a RS256 signed JWT token for MQTT authentication.") + @GetMapping("/users/auth/mqtt") + public ResponseEntity> getMqttAuthToken() { + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(HttpStatus.OK, "MQTT auth token retrieved successfully", + userService.getMqttAuthToken() + )); + } + + /* + * Link device to the currently authenticated user. + */ + @Operation( + summary = "Link device to current user", + description = "Links a device to the currently authenticated user.") + @PostMapping("/me/devices/link") + public ResponseEntity> linkDeviceToCurrentUser( + @RequestBody DeviceLinkRequest req) { + userService.linkDeviceToCurrentUser(req); + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(HttpStatus.OK, "Device linked successfully")); + } + + /* + * Unlink device from the currently authenticated user. + */ + @Operation( + summary = "Unlink device from current user", + description = "Unlinks a device from the currently authenticated user.") + @DeleteMapping("/me/devices/link") + public ResponseEntity> unlinkDeviceFromCurrentUser( + @Valid @RequestBody DeviceLinkRequest req) { + userService.unlinkDeviceFromCurrentUser(req); + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(HttpStatus.OK, "Device unlinked successfully")); + } + + /* + * Retrieves all devices linked to the currently authenticated user. + */ + @Operation( + summary = "Get devices linked to current user", + description = "Retrieves all devices linked to the currently authenticated user.") + @GetMapping("/me/devices") + public ResponseEntity>> getDevicesForCurrentUser() { + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(HttpStatus.OK, "User devices retrieved successfully", + userService.getDevicesForCurrentUser())); + } + + // ======= ADMIN-ONLY ENDPOINTS ======= + + /** + * Creates a new user account. Allows setting user roles. + */ + @Hidden + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Register a new user (Admin only)", + description = "Creates a new user account. Allows setting user roles.") + @PostMapping("/users/new") + public ResponseEntity>> registerUsersAdmin( + @Valid @RequestBody UserRegisterRequest req, + @RequestBody List roles) { + return ResponseEntity.status(HttpStatus.CREATED) + .body( + ApiResponse.success(HttpStatus.CREATED, "User registered successfully", + userService.addUser(req, roles))); + } + + /** + * Retrieves all user profiles. + */ + @Hidden + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Get all user profiles (Admin only)") + @GetMapping(value = "/users/", params = {"page", "size"}) + public ResponseEntity>> getAllUserProfiles( + @ParameterObject Pageable pageable) { + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(HttpStatus.OK, "User profiles retrieved successfully", + userService.getAllUserProfiles(pageable))); + } + + /** + * Retrieves user profile by ID. + */ + @Hidden + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Get user profile by ID (Admin only)") + @GetMapping("/users/{userId}") + public ResponseEntity> getUserProfileById(@PathVariable Long userId) { + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(HttpStatus.OK, "User profile retrieved successfully", + userService.getUserProfileById(userId))); + } + + /** + * Deletes user by ID (soft delete) + */ + @Hidden + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Disable user by ID (Admin only)") + @DeleteMapping("/users/{userId}") + public ResponseEntity> deleteUserById(@PathVariable Long userId) { + userService.deleteUserById(userId); + return ResponseEntity.status(HttpStatus.NO_CONTENT) + .body(ApiResponse.success(HttpStatus.NO_CONTENT, "User deleted successfully")); + } + +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/users/internal/UserRepository.java b/src/main/java/dev/ivfrost/hydro_backend/users/internal/UserRepository.java new file mode 100644 index 0000000..5b9fbc2 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/users/internal/UserRepository.java @@ -0,0 +1,11 @@ +package dev.ivfrost.hydro_backend.users.internal; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { + + Optional findByUsername(String username); + + Optional findByEmail(String email); +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/users/internal/UserService.java b/src/main/java/dev/ivfrost/hydro_backend/users/internal/UserService.java new file mode 100644 index 0000000..c576379 --- /dev/null +++ b/src/main/java/dev/ivfrost/hydro_backend/users/internal/UserService.java @@ -0,0 +1,476 @@ +package dev.ivfrost.hydro_backend.users.internal; + +import dev.ivfrost.hydro_backend.devices.DeviceLinkRequest; +import dev.ivfrost.hydro_backend.devices.DeviceResponse; +import dev.ivfrost.hydro_backend.exception.UserDisabledException; +import dev.ivfrost.hydro_backend.exception.UsernameTakenException; +import dev.ivfrost.hydro_backend.tokens.JWTUtil; +import dev.ivfrost.hydro_backend.tokens.TokenResponse; +import dev.ivfrost.hydro_backend.users.DeviceTopicProvider; +import dev.ivfrost.hydro_backend.users.UserAuthRequest; +import dev.ivfrost.hydro_backend.users.UserDeviceProvider; +import dev.ivfrost.hydro_backend.users.UserMqttResponse; +import dev.ivfrost.hydro_backend.users.UserMqttTokenPayload; +import dev.ivfrost.hydro_backend.users.UserRecoveryRequest; +import dev.ivfrost.hydro_backend.users.UserRefreshRequest; +import dev.ivfrost.hydro_backend.users.UserRegisterRequest; +import dev.ivfrost.hydro_backend.users.UserRegisterResponse; +import dev.ivfrost.hydro_backend.users.UserResponse; +import dev.ivfrost.hydro_backend.users.UserTokenPayload; +import dev.ivfrost.hydro_backend.users.UserTokenProvider; +import dev.ivfrost.hydro_backend.users.UserUpdateLastOnlineEvent; +import dev.ivfrost.hydro_backend.users.UserUpdateRequest; +import jakarta.transaction.Transactional; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.modulith.events.ApplicationModuleListener; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Slf4j +@AllArgsConstructor +@Service +public class UserService { + + private static final long ONLINE_THRESHOLD_MS = 300_000; // 5 minutes + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JWTUtil jwtUtil; + private final DeviceTopicProvider userDeviceTopicProvider; + private final UserTokenProvider userTokenProvider; + private final RedisTemplate redisTemplate; + private final ApplicationEventPublisher events; + private final UserDeviceProvider userDeviceProvider; + private final dev.ivfrost.hydro_backend.devices.DeviceLinkProvider deviceLinkProvider; + + /** + * Authenticates a user by email and password. + * + * @param req the user authentication request DTO + * @return a list of {@link TokenResponse} containing access and refresh tokens + * @throws AuthenticationCredentialsNotFoundException if the user is not found + * @throws DisabledException if the user is disabled + * @throws BadCredentialsException if the password is incorrect + */ + List authenticateUser(UserAuthRequest req) { + String email = req.email(); + String password = req.password(); + Optional userOpt = userRepository.findByEmail(email); + if (userOpt.isEmpty()) { + log.debug("User not found with email: {}", email); + throw new AuthenticationCredentialsNotFoundException( + "Either email or password is incorrect."); + } + User user = userOpt.get(); + log.debug("Authenticating user with email: {}", email); + if (!user.isEnabled()) { + throw new DisabledException(email); + } + if (!passwordEncoder.matches(password, user.getPassword())) { + log.debug("Password mismatch for user with email: {}", email); + throw new BadCredentialsException("Either email or password is incorrect."); + } + return userTokenProvider.generateAccessTokens(new UserTokenPayload( + user.getUsername(), + user.getEmail(), + user.getRoles().stream().map(Enum::name).toList(), + user.getPreferredLanguage(), + user.getId() + )); + } + + /** + * Registers a new user with specified roles (admin only). + * + * @param req the user registration request DTO + * @param roles the roles to assign to the user (defaults to USER if null) + * @return {@link UserRegisterResponse} containing recovery tokens + * @throws UsernameTakenException if the username is already taken + */ + @Transactional + List addUser(UserRegisterRequest req, List roles) { + if (isUserAuthenticated()) { + throw new IllegalStateException("Cannot register new user while authenticated."); + } + if (userRepository.findByUsername(req.username()).isPresent()) { + throw new UsernameTakenException(req.username()); + } + if (userRepository.findByEmail(req.email()).isPresent()) { + throw new UsernameTakenException(req.email()); + } + User user = convertRequestToUser(req); + user.setRoles(roles != null ? roles : List.of(User.Role.USER)); + User savedUser = userRepository.save(user); + + return userTokenProvider.generateRecoveryTokens(savedUser.getId()); + } + + + /** + * Registers a new user with default roles (self-registration). + * + *

- First user is assigned ADMIN and USER roles. + * + * @param req the user registration request DTO + * @return {@link UserRegisterResponse} containing recovery tokens + * @throws UsernameTakenException if the username is already taken + */ + @Transactional + List addUser(UserRegisterRequest req) { + boolean isFirstUser = userRepository.count() == 0; + List roles = isFirstUser + ? List.of(User.Role.ADMIN, User.Role.USER) + : List.of(User.Role.USER); + return addUser(req, roles); + } + + /** + * Retrieves the authenticated user + * + * @return authenticated {@link User} entity + * @throws AuthenticationCredentialsNotFoundException if the user is not found + * @throws UserDisabledException if the user is disabled + */ + private User getCurrentUser() { + Long userId = getCurrentUserId(); + User user = userRepository.findById(userId).orElse(null); + if (user == null) { + throw new AuthenticationCredentialsNotFoundException( + "User with ID " + userId + " not found."); + } else if (!user.isEnabled()) { + throw new UserDisabledException(userId); + } + return user; + } + + /** + * Retrieves the profile of the authenticated user. + * + * @return {@link UserResponse} containing user profile information + */ + UserResponse getCurrentUserProfile() { + return convertUserToResponse(getCurrentUser()); + } + + /** + * Retrieves a user by ID. + * + * @param userId the user ID + * @return {@link User} entity + * @throws AuthenticationCredentialsNotFoundException if the user is not found + */ + private User getUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new AuthenticationCredentialsNotFoundException( + "User with ID " + userId + " not found.")); + } + + /** + * Retrieves a user profile by ID (admin only). + * + * @param userId the user ID + * @return {@link UserResponse} containing user profile information + */ + UserResponse getUserProfileById(Long userId) { + return convertUserToResponse(getUserById(userId)); + } + + /** + * Retrieves all user profiles (admin only, cached, paginated). + * + * @param pageable the pagination information + * @return a page of {@link UserResponse} containing user profile information + */ + @Cacheable( + value = "allUsersCache", + key = "'allUsers:' + #pageable.pageNumber + ':' + #pageable.pageSize + ':' + #pageable.sort" + ) + public Page getAllUserProfiles(Pageable pageable) { + return convertUsersToResponses(userRepository.findAll(pageable)); + } + + /** + * Disables a user by ID. + * + * @param userId the user ID + * @throws UserDisabledException if the user is already disabled + * @throws AuthenticationCredentialsNotFoundException if the user is not found + */ + void deleteUserById(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new AuthenticationCredentialsNotFoundException( + "User with ID " + userId + " not found.")); + if (!user.isEnabled()) { + throw new UserDisabledException(userId); + } + user.setEnabled(false); + userRepository.save(user); + } + + /** + * Disables the authenticated user. + * + */ + void deleteCurrentUser() { + deleteUserById(getCurrentUserId()); + } + + /** + * Resets the user's password using a recovery token. + * + *

Validates the recovery token and ensures it belongs to the provided email. If valid, + * updates the user's password and invalidates the used token. + * + * @param req the user recovery request DTO containing email, recovery code, and new password + * @throws AuthenticationCredentialsNotFoundException if the user is not found + * @throws UserDisabledException if the user is disabled + * @throws BadCredentialsException if the recovery code is invalid + */ + @Transactional + void resetPassword(UserRecoveryRequest req) { + User user = userRepository.findByEmail(req.email()).orElseThrow( + () -> new AuthenticationCredentialsNotFoundException( + "User with email " + req.email() + " not found.")); + if (!user.isEnabled()) { + throw new UserDisabledException(user.getId()); + } + if (!userTokenProvider.isTokenValidForUserId(req.recoveryCode(), user.getId())) { + throw new BadCredentialsException("Invalid recovery code."); + } + + user.setPassword(passwordEncoder.encode(req.newPassword())); + userRepository.save(user); + } + + /** + * Updates the authenticated user's account settings. + * + * @param req the user update request DTO containing fields to update + * @return {@link UserResponse} containing updated user profile information + * @throws IllegalStateException if no authenticated user is found + * @throws AuthenticationCredentialsNotFoundException if the user is not found + * @throws UserDisabledException if the user is disabled + */ + @Transactional + UserResponse updateCurrentUser(UserUpdateRequest req) { + Long userId = getCurrentUser().getId(); + User user = userRepository.findById(userId) + .orElseThrow(() -> new AuthenticationCredentialsNotFoundException( + "User with ID " + userId + " not found.")); + if (!user.isEnabled()) { + throw new UserDisabledException(userId); + } + if (req.fullName() != null && !req.fullName().isBlank()) { + user.setFullName(req.fullName()); + } + if (req.email() != null && !req.email().isBlank()) { + user.setEmail(req.email()); + } + if (req.phoneNumber() != null) { + user.setPhoneNumber(req.phoneNumber()); + } + if (req.address() != null) { + user.setAddress(req.address()); + } + if (req.profilePictureUrl() != null) { + user.setProfilePictureUrl(req.profilePictureUrl()); + } + if (req.preferredLanguage() != null && !req.preferredLanguage().isBlank()) { + user.setPreferredLanguage(req.preferredLanguage()); + } + if (req.settings() != null) { + user.setSettings(req.settings()); + } + userRepository.save(user); + return convertUserToResponse(user); + } + + /** + * Refreshes access and refresh tokens using a valid refresh token. + * + * @param req the user refresh request DTO containing the refresh token + * @return a list of {@link TokenResponse} containing new access and refresh tokens + * @throws BadCredentialsException if the refresh token does not belong to the authenticated user + */ + List refreshTokens(UserRefreshRequest req) { + User user = getCurrentUser(); + + Map claims = userTokenProvider.validateTokenAndRetrieveClaims( + req.refreshToken()); + String tokenUsername = claims.get("username"); + + if (!user.getUsername().equals(tokenUsername)) { + throw new BadCredentialsException("Refresh token does not belong to authenticated user"); + } + return userTokenProvider.generateAccessTokens(new UserTokenPayload( + user.getUsername(), + user.getEmail(), + user.getRoles().stream().map(Enum::name).toList(), + user.getPreferredLanguage(), + user.getId() + )); + } + + /** + * Get short-lived RS256 signed JWT token for MQTT authentication. + * + * @return the MQTT authentication response containing the JWT token + * @throws AuthenticationCredentialsNotFoundException if the user is not found + */ + UserMqttResponse getMqttAuthToken() throws AuthenticationCredentialsNotFoundException { + User user = getCurrentUser(); + List topics = userDeviceTopicProvider.getTopicsForUser(user.getId()); + log.debug("Retrieved {} topics for user {}: {}", topics.size(), user.getId(), topics); + return new UserMqttResponse(user.getId(), + jwtUtil.generateMqttToken(new UserMqttTokenPayload(user.getId(), topics))); + } + + /** + * Links a device to the currently authenticated user. + */ + void linkDeviceToCurrentUser(DeviceLinkRequest req) { + deviceLinkProvider.linkDevice(req, getCurrentUserId()); + } + + /* + * Unlink a device from the currently authenticated user. + */ + void unlinkDeviceFromCurrentUser(DeviceLinkRequest req) { + deviceLinkProvider.unlinkDevice(req, getCurrentUserId()); + } + + /* + * Retrieves devices linked to the currently authenticated user. + */ + List getDevicesForCurrentUser() { + return userDeviceProvider.getUserDevices(getCurrentUserId()); + } + + /*====== REDIS STATE ======*/ + + /** + * Updates the currently authenticated user's last online value. + * + * @param username the username of the user to update the last online timestamp for + */ + public void updateLastOnline(String username) { + redisTemplate.opsForValue() + .set("user:lastOnline:" + username, String.valueOf(System.currentTimeMillis())); + } + + @ApplicationModuleListener + void on(UserUpdateLastOnlineEvent e) { + updateLastOnline(e.username()); + } + + /** + * Retrieves the currently authenticated user's last online value. + * + * @param username the username of the user to retrieve the last online timestamp for + * @return the last online timestamp in milliseconds, or null if not found + */ + public Long getLastOnline(String username) { + String lastOnlineStr = (String) redisTemplate.opsForValue().get("user:lastOnline:" + username); + + if (lastOnlineStr != null) { + return Long.parseLong(lastOnlineStr); + } + return null; + } + + public boolean isOnline(String username) { + Long lastOnline = getLastOnline(username); + if (lastOnline == null) { + return false; + } + long currentTime = System.currentTimeMillis(); + return (currentTime - lastOnline) <= ONLINE_THRESHOLD_MS; + } + + /*====== HELPERS ======*/ + + /** + * Checks if a user is authenticated in the security context. + * + * @return true if a user is authenticated, false otherwise + */ + public boolean isUserAuthenticated() { + SecurityContext context = SecurityContextHolder.getContext(); + var auth = context.getAuthentication(); + return auth != null && auth.isAuthenticated() + && !(auth instanceof AnonymousAuthenticationToken); + } + + public Long getCurrentUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated() + || authentication instanceof AnonymousAuthenticationToken) { + throw new AuthenticationCredentialsNotFoundException("No authenticated user found."); + } + return Long.parseLong(authentication.getName()); + } + + /** + * Converts a UserRegisterRequest DTO to a User entity. + * + * @param req the user registration request DTO + * @return the user entity + */ + public User convertRequestToUser(UserRegisterRequest req) { + String encodedPassword = passwordEncoder.encode(req.password()); + User user = new User(); + user.setUsername(req.username()); + user.setPassword(encodedPassword); + user.setEmail(req.email()); + user.setFullName(req.fullName()); + user.setPreferredLanguage(req.preferredLanguage()); + return user; + } + + /** + * Converts a User entity to a UserResponse DTO. + * + * @param user the user entity + * @return the user response DTO + */ + public UserResponse convertUserToResponse(User user) { + if (user == null) { + return null; + } + List roleList = user.getRoles().stream() + .map(Enum::name) + .toList(); + + return new UserResponse( + user.getId(), user.getUsername(), user.getFullName(), user.getEmail(), + user.getProfilePictureUrl(), + user.getPhoneNumber(), user.getAddress(), user.getCreatedAt(), user.getUpdatedAt(), + roleList, user.getPreferredLanguage(), user.getSettings() + ); + } + + /** + * Converts a list of User entities to a list of UserResponse DTOs. + * + * @param users the list of user entities + * @return the list of user response DTOs + */ + public Page convertUsersToResponses(Page users) { + return users.map(this::convertUserToResponse); + } +} diff --git a/src/main/java/dev/ivfrost/hydro_backend/util/DeviceDtoUtil.java b/src/main/java/dev/ivfrost/hydro_backend/util/DeviceDtoUtil.java deleted file mode 100644 index d5c5ad9..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/util/DeviceDtoUtil.java +++ /dev/null @@ -1,48 +0,0 @@ -package dev.ivfrost.hydro_backend.util; - -import dev.ivfrost.hydro_backend.dto.DeviceResponse; -import dev.ivfrost.hydro_backend.entity.Device; - -import java.util.Comparator; -import java.util.List; -import java.util.stream.Collectors; - -public class DeviceDtoUtil { - - /** - * Converts a Device entity to a DeviceResponse DTO. - * - * @param device the device entity - * @return the device response DTO - */ - public static DeviceResponse convertDeviceToResponse(Device device) { - if (device == null) return null; - return new DeviceResponse( - device.getId(), - device.getName(), - device.getLocation(), - device.getFirmware(), - device.getTechnicalName(), - device.getIp(), - device.getCreatedAt(), - device.getUpdatedAt(), - device.getLinkedAt(), - device.getLastSeen(), - device.getUser(), - device.getDisplayOrder() != null ? device.getDisplayOrder() : 0 - ); - } - - /** - * Converts a list of Device entities to a list of DeviceResponse DTOs. - * - * @param devices the list of device entities - * @return the list of device response DTOs - */ - public static List convertDevicesToResponse(List devices) { - return devices.stream() - .map(DeviceDtoUtil::convertDeviceToResponse) - .collect(Collectors.toList()); - } -} - diff --git a/src/main/java/dev/ivfrost/hydro_backend/util/RateLimitUtils.java b/src/main/java/dev/ivfrost/hydro_backend/util/RateLimitUtils.java deleted file mode 100644 index 91f7d05..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/util/RateLimitUtils.java +++ /dev/null @@ -1,59 +0,0 @@ -package dev.ivfrost.hydro_backend.util; - -import dev.ivfrost.hydro_backend.entity.User; -import io.github.bucket4j.Bandwidth; -import io.github.bucket4j.Bucket; -import jakarta.servlet.http.HttpServletRequest; -import lombok.AllArgsConstructor; -import lombok.NoArgsConstructor; -import org.springframework.stereotype.Component; - -import java.time.Duration; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; - -@Component -@AllArgsConstructor -@NoArgsConstructor -public class RateLimitUtils { - - private ConcurrentHashMap buckets = new ConcurrentHashMap<>(); - - public Optional getBucketByUserOrIp(User user, String ipAddress) { - if (user != null) { - return Optional.of(getBucketByUserId(user.getId().toString())); - } else if (ipAddress != null && !ipAddress.isEmpty()) { - return Optional.of(getBucketByIp(ipAddress)); - } else { - return Optional.empty(); - } - } - - private Bucket getBucketByIp(String ipAddres) { - return buckets.computeIfAbsent(ipAddres, k -> { - Bandwidth limit = Bandwidth.builder() - .capacity(10) - .refillGreedy(10, Duration.ofMinutes(1)) - .build(); - return Bucket.builder().addLimit(limit).build(); - }); - } - - private Bucket getBucketByUserId(String userId) { - return buckets.computeIfAbsent(userId, k -> { - Bandwidth limit = Bandwidth.builder() - .capacity(20) - .refillGreedy(20, Duration.ofMinutes(1)) - .build(); - return Bucket.builder().addLimit(limit).build(); - }); - } - - public static String extractClientIp(HttpServletRequest request) { - String xff = request.getHeader("X-Forwarded-For"); - if (xff != null && !xff.isEmpty()) { - return xff.split(",")[0].trim(); - } - return request.getRemoteAddr(); - } -} \ No newline at end of file diff --git a/src/main/java/dev/ivfrost/hydro_backend/util/RecoveryCodeUtil.java b/src/main/java/dev/ivfrost/hydro_backend/util/RecoveryCodeUtil.java deleted file mode 100644 index 201f5f2..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/util/RecoveryCodeUtil.java +++ /dev/null @@ -1,32 +0,0 @@ -package dev.ivfrost.hydro_backend.util; - -import dev.ivfrost.hydro_backend.repository.UserTokenRepository; -import dev.ivfrost.hydro_backend.service.EncoderService; -import lombok.AllArgsConstructor; -import org.springframework.beans.factory.annotation.Value; - -import java.security.SecureRandom; - -@AllArgsConstructor -public class RecoveryCodeUtil { - private static final String CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*"; - private static final SecureRandom RANDOM = new SecureRandom(); - private static final int RECOVERY_CODE_LENGTH = 16; - - public static String generateRecoveryCode() { - StringBuilder code = new StringBuilder(RECOVERY_CODE_LENGTH); - for (int i = 0; i < RECOVERY_CODE_LENGTH; i++) { - int idx = RANDOM.nextInt(CHARSET.length()); - code.append(CHARSET.charAt(idx)); - } - return code.toString(); - } - - public static String[] generateRecoveryCodes(int count) { - String[] codes = new String[count]; - for (int i = 0; i < count; i++) { - codes[i] = generateRecoveryCode(); - } - return codes; - } -} diff --git a/src/main/java/dev/ivfrost/hydro_backend/util/ValidationUtils.java b/src/main/java/dev/ivfrost/hydro_backend/util/ValidationUtils.java deleted file mode 100644 index 4c59d96..0000000 --- a/src/main/java/dev/ivfrost/hydro_backend/util/ValidationUtils.java +++ /dev/null @@ -1,42 +0,0 @@ -package dev.ivfrost.hydro_backend.util; - -import dev.ivfrost.hydro_backend.repository.UserRepository; -import dev.ivfrost.hydro_backend.service.UserService; -import jakarta.validation.Validation; -import lombok.AllArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.Map; -import java.util.stream.Collectors; - -@AllArgsConstructor -@Service -public class ValidationUtils { - - private final UserRepository userRepository; - - public Map getClassValidationRules(Class className) { - var validator = Validation.buildDefaultValidatorFactory().getValidator(); - var beanDescriptor = validator.getConstraintsForClass(className); - return beanDescriptor.getConstrainedProperties() - .stream() - .collect(Collectors.toMap( - prop -> prop.getPropertyName(), - prop -> prop.getConstraintDescriptors() - .stream() - .map(descriptor -> Map.of( - "annotation", descriptor.getAnnotation().annotationType().getSimpleName(), - "attributes", descriptor.getAttributes() - )) - .collect(Collectors.toList()) - )); - } - - public boolean isUsernameAvailable(String username) { - return !userRepository.existsByUsername(username); - } - - public boolean isEmailAvailable(String email) { - return !userRepository.existsByEmail(email); - } -} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties new file mode 100644 index 0000000..57c4bbf --- /dev/null +++ b/src/main/resources/application-dev.properties @@ -0,0 +1,41 @@ +spring.config.import=optional:file:../.env[.properties],optional:file:.env[.properties] +# Secrets (provided by Vault in prod) +spring.cloud.vault.enabled=false +jwt.secret=XL1pqJURj0rFbdWXiHCwD0tdFlNy/hhDVLmjEl3GoKRtkjfqUYh7j7MAlsNCch0Ew6ParzBnuAt3E4bODCj5Eg== +recovery.secret=19rWp+tewwkvUJu1QGXXQ049OgaACoZi+y7UVNkGYpU= +device.secret=yr7WkTwDYdR4FmTihsUvTRfrWi9C2IPqBUNGj98l7IQ= +# MQTT JWT Private Key path +mqtt.jwt.private.key.path=./secrets/mqtt_jwt_private_key.pem +# Database settings +spring.datasource.driver-class-name=org.postgresql.Driver +spring.datasource.url=jdbc:postgresql://localhost:5433/hydrodb +spring.datasource.username=admin +spring.datasource.password=supersecret +spring.jpa.show-sql=true +spring.sql.init.mode=never +spring.jpa.hibernate.ddl-auto=update +spring.jpa.defer-datasource-initialization=true +# Redis settings +spring.data.redis.host=localhost +spring.data.redis.port=6379 +spring.data.redis.password=supersecret +spring.data.redis.ssl.enabled=false +# Cache settings +spring.cache.type=redis +# Hibernate settings +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +spring.jpa.properties.hibernate.physical_naming_strategy=org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy +spring.jpa.properties.hibernate.implicit_naming_strategy=org.hibernate.boot.model.naming.ImplicitNamingStrategyJpaCompliantImpl +# RabbitMQ settings +spring.rabbitmq.host=localhost +spring.rabbitmq.port=5672 +spring.rabbitmq.username=myuser +spring.rabbitmq.password=supersecret +# Modulith settings +spring.modulith.events.republish-outstanding-events-on-restart=true +spring.modulith.events.jdbc.schema-initialization.enabled=true +# CORS (Vite) +cors.allowed-origins=http://127.0.0.1:5173 +# Logging +logging.level.org.springframework.web=DEBUG +logging.level.dev.ivfrost.hydro_backend=DEBUG diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties new file mode 100644 index 0000000..9c3ec35 --- /dev/null +++ b/src/main/resources/application-prod.properties @@ -0,0 +1,24 @@ +spring.config.import=vault:// +spring.main.banner-mode=off +# HashiCorp Vault (provides db credentials, jwt.secret and recovery.secret) +spring.cloud.vault.uri=${VAULT_URI} +spring.cloud.vault.authentication=approle +spring.cloud.vault.kv.enabled=true +spring.cloud.vault.kv.backend=secret +spring.cloud.vault.kv.default-context=hydro-api +spring.cloud.vault.app-role.role-id=${VAULT_ROLE_ID} +spring.cloud.vault.app-role.secret-id=${VAULT_SECRET_ID} +# Database settings +spring.datasource.driver-class-name=org.postgresql.Driver +spring.jpa.show-sql=false +spring.jpa.hibernate.ddl-auto=validate +# Proxy awareness +server.forward-headers-strategy=framework +# CORS (Vite) +cors.allowed-origins=http://192.168.1.214:5173 +# Disable SwaggerUI +springdoc.api-docs.enabled=false +springdoc.swagger-ui.enabled=false +#management.endpoints.health.show-details=never +# Logging +logging.level.org.springframework=ERROR diff --git a/src/main/resources/application-staging.properties b/src/main/resources/application-staging.properties new file mode 100644 index 0000000..e80175a --- /dev/null +++ b/src/main/resources/application-staging.properties @@ -0,0 +1,24 @@ +spring.config.import=optional:file:../.env[.properties],optional:file:.env[.properties] +# Secrets (provided by Vault in prod) +spring.cloud.vault.enabled=false +jwt.secret=XL1pqJURj0rFbdWXiHCwD0tdFlNy/hhDVLmjEl3GoKRtkjfqUYh7j7MAlsNCch0Ew6ParzBnuAt3E4bODCj5Eg== +recovery.secret=CH45JyhYaMp/wWDD43DIfQ1RgFyGIo3IjOVkrugEw+uNmVHsODwe6bhyYxlomYta45x16FKmjmjIR8uCHuT2/w== +device.secret=CH45JyhYaMp0Ew6ParzBnuAt3E3DIfQ1RgFyGIo3IjOVkrugEw+uFlNy/hhDVLmjEYta45x16F0EarzBnuAt3Ew== +# Database settings +spring.datasource.driver-class-name=org.postgresql.Driver +spring.datasource.url=jdbc:postgresql://192.168.1.214:5433/hydrodb +spring.datasource.username=admin +spring.datasource.password=supersecret +spring.jpa.hibernate.ddl-auto=update +# Redis settings +spring.data.redis.host=redis +spring.data.redis.port=6379 +spring.data.redis.password=secret +spring.cache.redis.time-to-live=10m +# Proxy awareness +server.forward-headers-strategy=framework +# CORS (Vite) +cors.allowed-origins=http://192.168.1.214:5173 +#management.endpoints.health.show-details=never +# Logging +logging.level.org.springframework=ERROR diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties new file mode 100644 index 0000000..a7ef48c --- /dev/null +++ b/src/main/resources/application-test.properties @@ -0,0 +1,40 @@ +spring.config.import=optional:file:../.env[.properties],optional:file:.env[.properties] +# Secrets (provided by Vault in prod) +spring.cloud.vault.enabled=false +jwt.secret=XL1pqJURj0rFbdWXiHCwD0tdFlNy/hhDVLmjEl3GoKRtkjfqUYh7j7MAlsNCch0Ew6ParzBnuAt3E4bODCj5Eg== +mqtt.jwt.secret=XL1pqJURj0rFbdWXiHCwD0tdFlNy/hhDVLmjEl3GoKRtkjfqUYh7j7MAlsNCch0Ew6ParzBnuAt3E4bODCj5Eg== +recovery.secret=CH45JyhYaMp/wWDD43DIfQ1RgFyGIo3IjOVkrugEw+uNmVHsODwe6bhyYxlomYta45x16FKmjmjIR8uCHuT2/w== +device.secret=CH45JyhYaMp0Ew6ParzBnuAt3E3DIfQ1RgFyGIo3IjOVkrugEw+uFlNy/hhDVLmjEYta45x16F0EarzBnuAt3Ew== +# Database settings +spring.datasource.driver-class-name=org.postgresql.Driver +spring.datasource.url=jdbc:postgresql://localhost:5433/hydrotestdb +spring.datasource.username=admin +spring.datasource.password=supersecret +spring.jpa.show-sql=true +spring.sql.init.mode=always +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.defer-datasource-initialization=true +# Redis settings +spring.data.redis.host=localhost +spring.data.redis.port=6379 +spring.data.redis.password=supersecret +spring.data.redis.ssl.enabled=false +# Cache settings +spring.cache.type=redis +# Hibernate settings +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +spring.jpa.properties.hibernate.physical_naming_strategy=org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy +spring.jpa.properties.hibernate.implicit_naming_strategy=org.hibernate.boot.model.naming.ImplicitNamingStrategyJpaCompliantImpl +# RabbitMQ settings +spring.rabbitmq.host=localhost +spring.rabbitmq.port=5672 +spring.rabbitmq.username=myuser +spring.rabbitmq.password=supersecret +# Modulith settings +spring.modulith.events.republish-outstanding-events-on-restart=true +spring.modulith.events.jdbc.schema-initialization.enabled=true +# CORS (Vite) +cors.allowed-origins=http://127.0.0.1:5173 +# Logging +logging.level.org.springframework.web=DEBUG +logging.level.dev.ivfrost.hydro_backend=DEBUG \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..f40839f --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,9 @@ +spring.config.import=optional:file:../.env[.properties],optional:file:.env[.properties] +# JWT settings +jwt.expiration-ms=3600000 +jwt.refresh-expiration-ms=86400000 +mqtt.jwt.expiration-ms=300000 +# SwaggerUI +springdoc.api-docs.path=/v3/api-docs +springdoc.swagger-ui.path=/swagger-ui.html +springdoc.swagger-ui.operationsSorter=method diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml deleted file mode 100644 index 89a28bf..0000000 --- a/src/main/resources/application.yml +++ /dev/null @@ -1,31 +0,0 @@ -spring: - application: - name: hydro-backend - jpa: - hibernate: - ddl-auto: create-drop - datasource: - url: jdbc:postgresql://${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} - username: ${POSTGRES_USER} - password: ${POSTGRES_PASSWORD} - driver-class-name: org.postgresql.Driver - -security-code: - secret: ${SECURITY_CODE_SECRET} # used to store security codes in encrypted form -jwt: - secret: ${JWT_SECRET} - expiration-ms: 3600000 # 1 hour - refresh-expiration-ms: 86400000 # 24 hours - -device: - secret: ${DEVICE_SECRET} - -springdoc: - swagger-ui: - path: /docs - url: http://localhost:8080/v3/api-docs - -server: - forward-headers-strategy: framework - servlet: - context-path: / diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 0000000..3b531bb --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,8 @@ +ALTER TABLE IF EXISTS tokens + ADD CONSTRAINT fk_tokens_user + FOREIGN KEY (user_id) REFERENCES users (id) + ON DELETE CASCADE; + +ALTER TABLE IF EXISTS devices + ADD CONSTRAINT fk_devices_user + FOREIGN KEY (user_id) REFERENCES users (id); \ No newline at end of file diff --git a/src/test/java/dev/ivfrost/hydro_backend/HydroApiApplicationTests.java b/src/test/java/dev/ivfrost/hydro_backend/HydroApiApplicationTests.java new file mode 100644 index 0000000..00ca53c --- /dev/null +++ b/src/test/java/dev/ivfrost/hydro_backend/HydroApiApplicationTests.java @@ -0,0 +1,65 @@ +package dev.ivfrost.hydro_backend; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.notNullValue; + +import dev.ivfrost.hydro_backend.users.UserTokenProvider; +import dev.ivfrost.hydro_backend.users.UserRegisterRequest; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class HydroApiApplicationTests { + + @Autowired + private UserTokenProvider userTokenProvider; + + @LocalServerPort + private int port; + + @BeforeAll + static void setupBaseUri() { + RestAssured.baseURI = "http://localhost"; + } + + // Test user registration endpoint + @Test + void testUserRegistration() { + RestAssured.port = port; + UserRegisterRequest request = new UserRegisterRequest( + "test@mail.com", + "testuser", + "Test User", + "password123", + "es"); + + given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post("/v1/users") + .then() + .assertThat() + .statusCode(HttpStatus.CREATED.value()) + .body("message", equalTo("User registered successfully")) + .body("details", notNullValue()) + .body("details", hasSize(5)) + .body("details[0].type", equalTo("RECOVERY_CODE")) + .body("details[0].value", notNullValue()) + .body("details.findAll { it.type == 'RECOVERY_CODE' }.size()", equalTo(5)); + } + + @Test + void contextLoads() { + } +} diff --git a/src/test/java/dev/ivfrost/hydro_backend/HydroBackendApplicationTests.java b/src/test/java/dev/ivfrost/hydro_backend/HydroBackendApplicationTests.java deleted file mode 100644 index 772c55f..0000000 --- a/src/test/java/dev/ivfrost/hydro_backend/HydroBackendApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.ivfrost.hydro_backend; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class HydroBackendApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/dev/ivfrost/hydro_backend/ModularityTests.java b/src/test/java/dev/ivfrost/hydro_backend/ModularityTests.java new file mode 100644 index 0000000..ab114cf --- /dev/null +++ b/src/test/java/dev/ivfrost/hydro_backend/ModularityTests.java @@ -0,0 +1,20 @@ +package dev.ivfrost.hydro_backend; + +import org.junit.jupiter.api.Test; +import org.springframework.modulith.core.ApplicationModules; +import org.springframework.modulith.docs.Documenter; + +class ModularityTests { + + ApplicationModules modules = ApplicationModules.of(HydroApiApplication.class); + + @Test + void verifiesModularStructure() { + modules.verify(); + } + + @Test + void createModuleDocumentation() { + new Documenter(modules).writeDocumentation(); + } +} \ No newline at end of file diff --git a/test-build.sh b/test-build.sh deleted file mode 100755 index ff6b166..0000000 --- a/test-build.sh +++ /dev/null @@ -1,2 +0,0 @@ -docker run -p 8080:8080 --env-file .env hydro-backend:0.0.1-SNAPSHOT -