From 6b5f634c60de7c872ccf7b45bfa62f46165dfd7c Mon Sep 17 00:00:00 2001 From: julsae Date: Wed, 17 Sep 2025 22:16:57 -0300 Subject: [PATCH] init commit --- .env | 14 + .gitattributes | 2 + .gitignore | 37 +++ .mvn/wrapper/maven-wrapper.properties | 19 ++ Dockerfile | 31 +++ Makefile | 51 ++++ docker-compose-hml.yml | 9 + docker-compose-prd.yml | 10 + docker-compose.yml | 37 +++ ketrack.zip | Bin 0 -> 17239 bytes mvnw | 259 ++++++++++++++++++ mvnw.cmd | 149 ++++++++++ pom.xml | 148 ++++++++++ .../Domain/Security/RefreshTokenEntity.java | 36 +++ .../ketrack/Core/Domain/User/UserEntity.java | 23 ++ .../ketrack/Core/Port/In/IAuthService.java | 10 + .../ketrack/Core/Port/In/IUserService.java | 4 + .../Core/UseCase/Security/AuthService.java | 129 +++++++++ .../Core/UseCase/User/UserService.java | 50 ++++ .../Infrastructure/API/V1/AuthController.java | 46 ++++ .../API/V1/DTO/Security/AuthRequest.java | 11 + .../API/V1/DTO/Security/AuthResponse.java | 10 + .../V1/DTO/Security/RefreshTokenRequest.java | 9 + .../Infrastructure/API/V1/UserController.java | 28 ++ .../Config/ApplicationConfig.java | 38 +++ .../Infrastructure/Config/SecurityConfig.java | 64 +++++ .../Infrastructure/Config/SwaggerConfig.java | 55 ++++ .../Security/IRefreshTokenRepository.java | 12 + .../Persistence/User/IUserRepository.java | 13 + .../InvalidRefreshTokenException.java | 7 + .../Security/JWT/JwtAuthenticationFilter.java | 52 ++++ .../Security/JWT/JwtService.java | 97 +++++++ .../treecode/ketrack/KetrackApplication.java | 13 + .../Shared/Exception/BusinessException.java | 29 ++ .../Exception/Handler/ExceptionHandler.java | 99 +++++++ .../Shared/Exception/Handler/Issue.java | 11 + .../Exception/ModelNotFoundException.java | 9 + .../Shared/Persistence/ICRUDService.java | 21 ++ .../IPostgreSQLGenericRepository.java | 7 + .../Shared/Persistence/PostgreSQLImpl.java | 36 +++ .../ketrack/Shared/Util/DateTimeUtil.java | 74 +++++ .../com/treecode/ketrack/Shared/Web/CORS.java | 31 +++ .../ketrack/Shared/Web/ResponseHandler.java | 89 ++++++ src/main/resources/application.yml | 54 ++++ src/main/resources/banner.txt | 10 + .../db/migration/V001__init-tables.sql | 30 ++ .../V002__insert_user_role_permission.sql | 34 +++ .../V003__create_refresh_token_table.sql | 10 + .../ketrack/KetrackApplicationTests.java | 13 + .../ketrack/Security/AuthServiceTest.java | 195 +++++++++++++ 50 files changed, 2225 insertions(+) create mode 100644 .env create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .mvn/wrapper/maven-wrapper.properties create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 docker-compose-hml.yml create mode 100644 docker-compose-prd.yml create mode 100644 docker-compose.yml create mode 100644 ketrack.zip create mode 100644 mvnw create mode 100644 mvnw.cmd create mode 100644 pom.xml create mode 100644 src/main/java/co/com/treecode/ketrack/Core/Domain/Security/RefreshTokenEntity.java create mode 100644 src/main/java/co/com/treecode/ketrack/Core/Domain/User/UserEntity.java create mode 100644 src/main/java/co/com/treecode/ketrack/Core/Port/In/IAuthService.java create mode 100644 src/main/java/co/com/treecode/ketrack/Core/Port/In/IUserService.java create mode 100644 src/main/java/co/com/treecode/ketrack/Core/UseCase/Security/AuthService.java create mode 100644 src/main/java/co/com/treecode/ketrack/Core/UseCase/User/UserService.java create mode 100644 src/main/java/co/com/treecode/ketrack/Infrastructure/API/V1/AuthController.java create mode 100644 src/main/java/co/com/treecode/ketrack/Infrastructure/API/V1/DTO/Security/AuthRequest.java create mode 100644 src/main/java/co/com/treecode/ketrack/Infrastructure/API/V1/DTO/Security/AuthResponse.java create mode 100644 src/main/java/co/com/treecode/ketrack/Infrastructure/API/V1/DTO/Security/RefreshTokenRequest.java create mode 100644 src/main/java/co/com/treecode/ketrack/Infrastructure/API/V1/UserController.java create mode 100644 src/main/java/co/com/treecode/ketrack/Infrastructure/Config/ApplicationConfig.java create mode 100644 src/main/java/co/com/treecode/ketrack/Infrastructure/Config/SecurityConfig.java create mode 100644 src/main/java/co/com/treecode/ketrack/Infrastructure/Config/SwaggerConfig.java create mode 100644 src/main/java/co/com/treecode/ketrack/Infrastructure/Persistence/Security/IRefreshTokenRepository.java create mode 100644 src/main/java/co/com/treecode/ketrack/Infrastructure/Persistence/User/IUserRepository.java create mode 100644 src/main/java/co/com/treecode/ketrack/Infrastructure/Security/Exception/InvalidRefreshTokenException.java create mode 100644 src/main/java/co/com/treecode/ketrack/Infrastructure/Security/JWT/JwtAuthenticationFilter.java create mode 100644 src/main/java/co/com/treecode/ketrack/Infrastructure/Security/JWT/JwtService.java create mode 100644 src/main/java/co/com/treecode/ketrack/KetrackApplication.java create mode 100644 src/main/java/co/com/treecode/ketrack/Shared/Exception/BusinessException.java create mode 100644 src/main/java/co/com/treecode/ketrack/Shared/Exception/Handler/ExceptionHandler.java create mode 100644 src/main/java/co/com/treecode/ketrack/Shared/Exception/Handler/Issue.java create mode 100644 src/main/java/co/com/treecode/ketrack/Shared/Exception/ModelNotFoundException.java create mode 100644 src/main/java/co/com/treecode/ketrack/Shared/Persistence/ICRUDService.java create mode 100644 src/main/java/co/com/treecode/ketrack/Shared/Persistence/IPostgreSQLGenericRepository.java create mode 100644 src/main/java/co/com/treecode/ketrack/Shared/Persistence/PostgreSQLImpl.java create mode 100644 src/main/java/co/com/treecode/ketrack/Shared/Util/DateTimeUtil.java create mode 100644 src/main/java/co/com/treecode/ketrack/Shared/Web/CORS.java create mode 100644 src/main/java/co/com/treecode/ketrack/Shared/Web/ResponseHandler.java create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/banner.txt create mode 100644 src/main/resources/db/migration/V001__init-tables.sql create mode 100644 src/main/resources/db/migration/V002__insert_user_role_permission.sql create mode 100644 src/main/resources/db/migration/V003__create_refresh_token_table.sql create mode 100644 src/test/java/co/com/treecode/ketrack/KetrackApplicationTests.java create mode 100644 src/test/java/co/com/treecode/ketrack/Security/AuthServiceTest.java diff --git a/.env b/.env new file mode 100644 index 0000000..ed840f2 --- /dev/null +++ b/.env @@ -0,0 +1,14 @@ +PROFILE=dsv +SERVER_PORT=8089 + +DATASOURCE_URL=jdbc:postgresql://db:5432/ketrack-dsv + +DATASOURCE_NAME=ketrack-dsv +DATASOURCE_USER=treecode +DATASOURCE_PASSWORD=123456 + +ADDITIONAL_OPTS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -Xmx1G -Xms128m -XX:MaxMetaspaceSize=128m + +FLYWAY_ENABLED=true + +SECURITY_SIGNING_KEY=R2XgxeRKBboPkXH/x/LAq+7KrMkmoS7FkAzCwwsP0Jw= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f98cf7a --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Project ### +.env.dsv +.env.* diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..d58dfb7 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +wrapperVersion=3.3.2 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4052935 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# --------------------------- +# Build Stage +# --------------------------- +FROM maven:3.9.5-eclipse-temurin-21 AS build +WORKDIR /app + +COPY pom.xml . +COPY src ./src +RUN mvn clean package -DskipTests + +# --------------------------- +# Run Stage +# --------------------------- +FROM eclipse-temurin:21-jdk-alpine + +# Timezone setup +ENV TZ=America/Bogota +RUN apk update && apk add --no-cache tzdata && cp /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# Runtime envs +ARG PROFILE=dsv +ARG ADDITIONAL_OPTS="" +ENV SPRING_PROFILES_ACTIVE=$PROFILE +ENV JAVA_OPTS=$ADDITIONAL_OPTS + +WORKDIR /app +COPY --from=build /app/target/*.jar app.jar + +EXPOSE 8080 5005 + +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1d3a435 --- /dev/null +++ b/Makefile @@ -0,0 +1,51 @@ +# Variables comunes +COMPOSE=docker compose + +# Comandos para levantar la app en diferentes entornos +up-dsv: + cp .env.dsv .env + $(COMPOSE) up --build --force-recreate -d + +up-dsv-fast: + cp .env.dsv .env + $(COMPOSE) up -d + +up-hml: + cp .env.hml .env + $(COMPOSE) -f docker-compose.yml -f docker-compose.hml.yml up --build --force-recreate -d + +up-prd: + cp .env.prd .env + $(COMPOSE) -f docker-compose.yml -f docker-compose.prd.yml up --build --force-recreate -d + +# Bajar contenedores +down: + $(COMPOSE) down + +# Reiniciar solo la API +restart-api: + $(COMPOSE) restart api + +# Ver logs en tiempo real +logs: + $(COMPOSE) logs -f + +# Ver últimos X logs del contenedor api +logs-api-tail: + @read -p "¿Cuántas líneas deseas ver? " N && $(COMPOSE) logs --tail=$$N api + +logs-api: + $(COMPOSE) logs -f api + +# Ver estado de los contenedores +ps: + $(COMPOSE) ps + +# Eliminar todo el entorno de Docker Compose: contenedores, volúmenes y redes +nuke: + @echo "Esto eliminará todos los contenedores, volúmenes y redes creados por docker-compose" + @read -p "¿Estás seguro? (s/N): " CONFIRM && [ "$$CONFIRM" = "s" ] && \ + $(COMPOSE) down -v --remove-orphans && \ + docker volume prune -f && \ + docker network prune -f && \ + echo "Entorno eliminado completamente." || echo "Cancelado." diff --git a/docker-compose-hml.yml b/docker-compose-hml.yml new file mode 100644 index 0000000..5ebb980 --- /dev/null +++ b/docker-compose-hml.yml @@ -0,0 +1,9 @@ +services: + db: + entrypoint: ["echo", "Database managed externally (RDS HML)"] + volumes: [] + ports: [] + + api: + environment: + PROFILE: hml \ No newline at end of file diff --git a/docker-compose-prd.yml b/docker-compose-prd.yml new file mode 100644 index 0000000..53175ca --- /dev/null +++ b/docker-compose-prd.yml @@ -0,0 +1,10 @@ +services: + db: + image: postgres:15 + entrypoint: ["echo", "Database managed externally (RDS PRD)"] + volumes: [] + ports: [] + + api: + environment: + PROFILE: prd \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1a4259e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +services: + db: + container_name: db + image: postgres:15-alpine + environment: + POSTGRES_DB: ${DATASOURCE_NAME} + POSTGRES_USER: ${DATASOURCE_USER} + POSTGRES_PASSWORD: ${DATASOURCE_PASSWORD} + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + networks: + - treecode-network + + api: + container_name: api + build: + context: . + dockerfile: Dockerfile + env_file: + - .env + environment: + JAVA_OPTS: "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005" + ports: + - "8089:8089" + - "5005:5005" + depends_on: + - db + networks: + - treecode-network + +volumes: + pgdata: + +networks: + treecode-network: \ No newline at end of file diff --git a/ketrack.zip b/ketrack.zip new file mode 100644 index 0000000000000000000000000000000000000000..43fe295365feeff8bd8ca82e9b3431049e1a8c4f GIT binary patch literal 17239 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 literal 0 HcmV?d00001 diff --git a/mvnw b/mvnw new file mode 100644 index 0000000..19529dd --- /dev/null +++ b/mvnw @@ -0,0 +1,259 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.2 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..249bdf3 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,149 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.2 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' +$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" +if ($env:MAVEN_USER_HOME) { + $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" +} +$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..58bf36e --- /dev/null +++ b/pom.xml @@ -0,0 +1,148 @@ + + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.0 + + + + co.com.treecode + ketrack + 0.0.1 + ketrack + Demo project for Spring Boot + + + 21 + 1.18.30 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.springframework.boot + spring-boot-starter-security + + + + + org.postgresql + postgresql + runtime + + + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + + + + org.projectlombok + lombok + true + + + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + runtime + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.8 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + + org.springframework.boot + spring-boot-docker-compose + runtime + true + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/src/main/java/co/com/treecode/ketrack/Core/Domain/Security/RefreshTokenEntity.java b/src/main/java/co/com/treecode/ketrack/Core/Domain/Security/RefreshTokenEntity.java new file mode 100644 index 0000000..7b4d2ee --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Core/Domain/Security/RefreshTokenEntity.java @@ -0,0 +1,36 @@ +package co.com.treecode.ketrack.Core.Domain.Security; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "refresh_token") +public class RefreshTokenEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "id_user", nullable = false) + private Long idUser; + + @Column(name = "token_hash", nullable = false, unique = true) + private String tokenHash; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "revoked", nullable = false) + private Boolean revoked; +} diff --git a/src/main/java/co/com/treecode/ketrack/Core/Domain/User/UserEntity.java b/src/main/java/co/com/treecode/ketrack/Core/Domain/User/UserEntity.java new file mode 100644 index 0000000..efd567f --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Core/Domain/User/UserEntity.java @@ -0,0 +1,23 @@ +package co.com.treecode.ketrack.Core.Domain.User; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "user") +public class UserEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long idUser; + private String username; + private String email; + private String password; + private Boolean isActive; +} \ No newline at end of file diff --git a/src/main/java/co/com/treecode/ketrack/Core/Port/In/IAuthService.java b/src/main/java/co/com/treecode/ketrack/Core/Port/In/IAuthService.java new file mode 100644 index 0000000..70be218 --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Core/Port/In/IAuthService.java @@ -0,0 +1,10 @@ +package co.com.treecode.ketrack.Core.Port.In; + +import co.com.treecode.ketrack.Infrastructure.API.V1.DTO.Security.AuthRequest; +import co.com.treecode.ketrack.Infrastructure.API.V1.DTO.Security.AuthResponse; +import co.com.treecode.ketrack.Infrastructure.API.V1.DTO.Security.RefreshTokenRequest; + +public interface IAuthService { + AuthResponse login(AuthRequest request); + AuthResponse refresh(RefreshTokenRequest request); +} diff --git a/src/main/java/co/com/treecode/ketrack/Core/Port/In/IUserService.java b/src/main/java/co/com/treecode/ketrack/Core/Port/In/IUserService.java new file mode 100644 index 0000000..1d35789 --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Core/Port/In/IUserService.java @@ -0,0 +1,4 @@ +package co.com.treecode.ketrack.Core.Port.In; + +public interface IUserService { +} diff --git a/src/main/java/co/com/treecode/ketrack/Core/UseCase/Security/AuthService.java b/src/main/java/co/com/treecode/ketrack/Core/UseCase/Security/AuthService.java new file mode 100644 index 0000000..7f13f24 --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Core/UseCase/Security/AuthService.java @@ -0,0 +1,129 @@ +package co.com.treecode.ketrack.Core.UseCase.Security; + +import co.com.treecode.ketrack.Core.Domain.Security.RefreshTokenEntity; +import co.com.treecode.ketrack.Core.Domain.User.UserEntity; +import co.com.treecode.ketrack.Core.Port.In.IAuthService; +import co.com.treecode.ketrack.Infrastructure.API.V1.DTO.Security.AuthRequest; +import co.com.treecode.ketrack.Infrastructure.API.V1.DTO.Security.AuthResponse; +import co.com.treecode.ketrack.Infrastructure.API.V1.DTO.Security.RefreshTokenRequest; +import co.com.treecode.ketrack.Infrastructure.Persistence.Security.IRefreshTokenRepository; +import co.com.treecode.ketrack.Infrastructure.Persistence.User.IUserRepository; +import co.com.treecode.ketrack.Infrastructure.Security.Exception.InvalidRefreshTokenException; +import co.com.treecode.ketrack.Infrastructure.Security.JWT.JwtService; +import co.com.treecode.ketrack.Shared.Exception.ModelNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AuthService implements IAuthService { + private final AuthenticationManager authManager; + + private final UserDetailsService userDetailsService; + private final JwtService jwtService; + + private final IUserRepository userRepository; + private final IRefreshTokenRepository refreshTokenRepository; + + @Value("${security.token.refresh-expiration}") + private Long REFRESH_TIME_TO_EXPIRATION_MS; + + private static final int MAXIMUM_TOKENS_PER_USER = 3; + + @Override + @Transactional + public AuthResponse login(AuthRequest request) { + var userCredentials = new UsernamePasswordAuthenticationToken(request.username(), request.password()); + authManager.authenticate(userCredentials); + + UserDetails user = userDetailsService.loadUserByUsername(request.username()); + UserEntity userEntity = userRepository.findByUsername(request.username()) + .orElseThrow(() -> new ModelNotFoundException("User not found")); + + String accessToken = jwtService.generateToken(user); + String refreshToken = jwtService.generateRefreshToken(user); + + storeRefreshToken(userEntity.getIdUser(), refreshToken); + + return AuthResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + @Override + @Transactional + public AuthResponse refresh(RefreshTokenRequest request) { + String token = request.refreshToken(); + String tokenHash = hash(token); + + RefreshTokenEntity entity = refreshTokenRepository.findByTokenHash(tokenHash) + .orElseThrow(() -> new InvalidRefreshTokenException("Refresh token is invalid")); + + if (entity.getRevoked() || entity.getExpiresAt().isBefore(LocalDateTime.now())) { + throw new InvalidRefreshTokenException("Refresh token is expired or revoked"); + } + + UserEntity userEntity = userRepository.findById(entity.getIdUser()).orElseThrow(); + UserDetails user = userDetailsService.loadUserByUsername(userEntity.getUsername()); + + // Revoke current token + entity.setRevoked(true); + refreshTokenRepository.save(entity); + + String newAccessToken = jwtService.generateToken(user); + String newRefreshToken = jwtService.generateRefreshToken(user); + + storeRefreshToken(userEntity.getIdUser(), newRefreshToken); + + return AuthResponse.builder() + .accessToken(newAccessToken) + .refreshToken(newRefreshToken) + .build(); + } + + private void storeRefreshToken(Long userId, String refreshToken) { + String newTokenHash = hash(refreshToken); + List activeTokens = refreshTokenRepository + .findAllByIdUserAndRevokedFalseOrderByCreatedAtAsc(userId); + + if (activeTokens.size() >= MAXIMUM_TOKENS_PER_USER) { + RefreshTokenEntity oldest = activeTokens.get(0); + oldest.setRevoked(true); + refreshTokenRepository.delete(oldest); + } + + RefreshTokenEntity newToken = RefreshTokenEntity.builder() + .idUser(userId) + .tokenHash(newTokenHash) + .createdAt(LocalDateTime.now()) + .expiresAt(LocalDateTime.now().plusSeconds(REFRESH_TIME_TO_EXPIRATION_MS)) + .revoked(false) + .build(); + + refreshTokenRepository.save(newToken); + } + + private String hash(String token) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashed = digest.digest(token.getBytes()); + return Base64.getEncoder().encodeToString(hashed); + } catch (Exception e) { + throw new RuntimeException("Error hashing token", e); + } + } +} + diff --git a/src/main/java/co/com/treecode/ketrack/Core/UseCase/User/UserService.java b/src/main/java/co/com/treecode/ketrack/Core/UseCase/User/UserService.java new file mode 100644 index 0000000..9c2057a --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Core/UseCase/User/UserService.java @@ -0,0 +1,50 @@ +package co.com.treecode.ketrack.Core.UseCase.User; + +import co.com.treecode.ketrack.Core.Domain.User.UserEntity; +import co.com.treecode.ketrack.Core.Port.In.IUserService; +import co.com.treecode.ketrack.Infrastructure.Persistence.User.IUserRepository; +import co.com.treecode.ketrack.Shared.Persistence.IPostgreSQLGenericRepository; +import co.com.treecode.ketrack.Shared.Persistence.PostgreSQLImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +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.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class UserService extends PostgreSQLImpl implements IUserService, UserDetailsService { + private final IUserRepository userRepository; + @Override + protected IPostgreSQLGenericRepository getRepository() { + return userRepository; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + var user = userRepository.findUserByUsernameOrEmail(username, username) + .orElseThrow(() -> new UsernameNotFoundException("user does not exist " + username)); + + //var role = userRoleRepository.findByUser(user).orElseThrow(); + + List roles = new ArrayList<>(); + roles.add(new SimpleGrantedAuthority("ADMIN")); // todo: mudar para dinamico + //roles.add(new SimpleGrantedAuthority(role.getRole().getDescription())); + + + return new User( + user.getUsername(), + user.getPassword(), + user.getIsActive(), + true, + true, + true, + roles); + } +} diff --git a/src/main/java/co/com/treecode/ketrack/Infrastructure/API/V1/AuthController.java b/src/main/java/co/com/treecode/ketrack/Infrastructure/API/V1/AuthController.java new file mode 100644 index 0000000..31410d8 --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Infrastructure/API/V1/AuthController.java @@ -0,0 +1,46 @@ +package co.com.treecode.ketrack.Infrastructure.API.V1; + +import co.com.treecode.ketrack.Core.Port.In.IAuthService; +import co.com.treecode.ketrack.Infrastructure.API.V1.DTO.Security.AuthRequest; +import co.com.treecode.ketrack.Infrastructure.API.V1.DTO.Security.RefreshTokenRequest; +import co.com.treecode.ketrack.Shared.Web.ResponseHandler; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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; + +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +@Tag(name = "Authentication", description = "Operations related to authorization and authentication.") +public class AuthController { + + private final IAuthService authService; + + @PostMapping("/login") + @Operation(summary = "Authenticate user", description = "Authenticates a user and returns access and refresh tokens") + @ApiResponse(responseCode = "200", description = "Authentication successful") + @ApiResponse(responseCode = "400", description = "Bad request") + @ApiResponse(responseCode = "401", description = "Invalid credentials") + public ResponseEntity getToken(@RequestBody AuthRequest request) { + var response = authService.login(request); + return ResponseHandler.success(response); + } + + @PostMapping("/refresh-token") + @Operation(summary = "Refresh access token", description = "Generates a new access token using a valid refresh token") + @ApiResponse(responseCode = "200", description = "Token refreshed successfully") + @ApiResponse(responseCode = "400", description = "Bad request") + @ApiResponse(responseCode = "401", description = "Invalid or expired refresh token") + public ResponseEntity refreshToken(@RequestBody @Valid RefreshTokenRequest request) { + var response = authService.refresh(request); + return ResponseHandler.success(HttpStatus.CREATED, response); + } +} diff --git a/src/main/java/co/com/treecode/ketrack/Infrastructure/API/V1/DTO/Security/AuthRequest.java b/src/main/java/co/com/treecode/ketrack/Infrastructure/API/V1/DTO/Security/AuthRequest.java new file mode 100644 index 0000000..a98ad28 --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Infrastructure/API/V1/DTO/Security/AuthRequest.java @@ -0,0 +1,11 @@ +package co.com.treecode.ketrack.Infrastructure.API.V1.DTO.Security; + +import jakarta.validation.constraints.NotBlank; + +public record AuthRequest( + @NotBlank + String username, + @NotBlank + String password +) { +} diff --git a/src/main/java/co/com/treecode/ketrack/Infrastructure/API/V1/DTO/Security/AuthResponse.java b/src/main/java/co/com/treecode/ketrack/Infrastructure/API/V1/DTO/Security/AuthResponse.java new file mode 100644 index 0000000..a105823 --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Infrastructure/API/V1/DTO/Security/AuthResponse.java @@ -0,0 +1,10 @@ +package co.com.treecode.ketrack.Infrastructure.API.V1.DTO.Security; + +import lombok.Builder; + +@Builder +public record AuthResponse( + String accessToken, + String refreshToken +) { +} diff --git a/src/main/java/co/com/treecode/ketrack/Infrastructure/API/V1/DTO/Security/RefreshTokenRequest.java b/src/main/java/co/com/treecode/ketrack/Infrastructure/API/V1/DTO/Security/RefreshTokenRequest.java new file mode 100644 index 0000000..99d44f0 --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Infrastructure/API/V1/DTO/Security/RefreshTokenRequest.java @@ -0,0 +1,9 @@ +package co.com.treecode.ketrack.Infrastructure.API.V1.DTO.Security; + +import jakarta.validation.constraints.NotBlank; + +public record RefreshTokenRequest( + @NotBlank(message = "Refresh token is required") + String refreshToken +) { +} diff --git a/src/main/java/co/com/treecode/ketrack/Infrastructure/API/V1/UserController.java b/src/main/java/co/com/treecode/ketrack/Infrastructure/API/V1/UserController.java new file mode 100644 index 0000000..4f02451 --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Infrastructure/API/V1/UserController.java @@ -0,0 +1,28 @@ +package co.com.treecode.ketrack.Infrastructure.API.V1; + +import co.com.treecode.ketrack.Shared.Web.ResponseHandler; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + + +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +@Tag(name = "User", description = "Operations related to users") +public class UserController { + @PostMapping + @Operation(description = "Create a new user with role.") + public ResponseEntity createUser(@Validated @RequestBody String input) { + //UserEntity obj = service.SaveUserWithRole(input).orElseThrow(); + //URI location = ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(obj.getIdUser()).toUri(); + return ResponseHandler.success(HttpStatus.CREATED, input); + } +} diff --git a/src/main/java/co/com/treecode/ketrack/Infrastructure/Config/ApplicationConfig.java b/src/main/java/co/com/treecode/ketrack/Infrastructure/Config/ApplicationConfig.java new file mode 100644 index 0000000..bfaa3c2 --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Infrastructure/Config/ApplicationConfig.java @@ -0,0 +1,38 @@ +package co.com.treecode.ketrack.Infrastructure.Config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +@RequiredArgsConstructor +public class ApplicationConfig { + + private final UserDetailsService userDetailService; + + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailService); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + +} \ No newline at end of file diff --git a/src/main/java/co/com/treecode/ketrack/Infrastructure/Config/SecurityConfig.java b/src/main/java/co/com/treecode/ketrack/Infrastructure/Config/SecurityConfig.java new file mode 100644 index 0000000..dd20803 --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Infrastructure/Config/SecurityConfig.java @@ -0,0 +1,64 @@ +package co.com.treecode.ketrack.Infrastructure.Config; + +import co.com.treecode.ketrack.Infrastructure.Security.JWT.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +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.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthFilter; + private final AuthenticationProvider authenticationProvider; + + private static final String[] ALLOW_LIST_TO_ALL_ROLES = { + "/api/v1/auth/login", + "/api/v1/auth/refresh-token", + "/v3/api-docs/**", + "/v3/api-docs.yaml", + "/swagger-ui/**", + "/swagger-ui.html", + "/swagger" + }; + private static final String[] ROUTES_TO_DEV = { }; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{ + http + .cors(Customizer.withDefaults()) + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests( + authorizeRequests -> authorizeRequests + // todo: check if rules are correct + .requestMatchers(ALLOW_LIST_TO_ALL_ROLES).permitAll() + //.requestMatchers(ROUTES_TO_DEV).hasRole(Role.DEVELOPER.name()) + .anyRequest().authenticated() + ).exceptionHandling( + e -> e.accessDeniedHandler( + (request, response, accessDeniedException) -> response.setStatus(HttpStatus.FORBIDDEN.value()) + ) + .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) + ) + .sessionManagement( + s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authenticationProvider(authenticationProvider) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/src/main/java/co/com/treecode/ketrack/Infrastructure/Config/SwaggerConfig.java b/src/main/java/co/com/treecode/ketrack/Infrastructure/Config/SwaggerConfig.java new file mode 100644 index 0000000..ac20b1a --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Infrastructure/Config/SwaggerConfig.java @@ -0,0 +1,55 @@ +package co.com.treecode.ketrack.Infrastructure.Config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + private static final String SECURITY_SCHEME_NAME = "Bearer Authentication"; + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .addSecurityItem(new SecurityRequirement().addList(SECURITY_SCHEME_NAME)) + .components(new Components().addSecuritySchemes(SECURITY_SCHEME_NAME, createAPIKeyScheme())) + .info( + new io.swagger.v3.oas.models.info.Info() + .title("APP NAME") + .version("1") + .description("APP_NAME by Tree Code") + .termsOfService("https://treecode.com.co") + .contact(new io.swagger.v3.oas.models.info.Contact() + .email("julian.saenz@treecode.com.co") + ) + ); + } + + private SecurityScheme createAPIKeyScheme() { + return new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .bearerFormat("JWT") + .scheme("bearer"); + } + + @Bean + public GroupedOpenApi version1() { + return GroupedOpenApi.builder() + .group("v1") + .pathsToMatch("/api/v1/**") + .build(); + } + //@Bean + //public GroupedOpenApi version2() { + // return GroupedOpenApi.builder() + // .group("v2") + // .pathsToMatch("/api/v2/**") + // .build(); + //} + +} diff --git a/src/main/java/co/com/treecode/ketrack/Infrastructure/Persistence/Security/IRefreshTokenRepository.java b/src/main/java/co/com/treecode/ketrack/Infrastructure/Persistence/Security/IRefreshTokenRepository.java new file mode 100644 index 0000000..56e136c --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Infrastructure/Persistence/Security/IRefreshTokenRepository.java @@ -0,0 +1,12 @@ +package co.com.treecode.ketrack.Infrastructure.Persistence.Security; + +import co.com.treecode.ketrack.Core.Domain.Security.RefreshTokenEntity; +import co.com.treecode.ketrack.Shared.Persistence.IPostgreSQLGenericRepository; + +import java.util.List; +import java.util.Optional; + +public interface IRefreshTokenRepository extends IPostgreSQLGenericRepository { + Optional findByTokenHash(String tokenHash); + List findAllByIdUserAndRevokedFalseOrderByCreatedAtAsc(Long userId); +} diff --git a/src/main/java/co/com/treecode/ketrack/Infrastructure/Persistence/User/IUserRepository.java b/src/main/java/co/com/treecode/ketrack/Infrastructure/Persistence/User/IUserRepository.java new file mode 100644 index 0000000..0271b2c --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Infrastructure/Persistence/User/IUserRepository.java @@ -0,0 +1,13 @@ +package co.com.treecode.ketrack.Infrastructure.Persistence.User; + +import co.com.treecode.ketrack.Core.Domain.User.UserEntity; +import co.com.treecode.ketrack.Shared.Persistence.IPostgreSQLGenericRepository; + +import java.util.Optional; + +public interface IUserRepository extends IPostgreSQLGenericRepository { + Optional findByUsername(String username); + Optional findByEmail(String email); + + Optional findUserByUsernameOrEmail(String username, String email); +} diff --git a/src/main/java/co/com/treecode/ketrack/Infrastructure/Security/Exception/InvalidRefreshTokenException.java b/src/main/java/co/com/treecode/ketrack/Infrastructure/Security/Exception/InvalidRefreshTokenException.java new file mode 100644 index 0000000..3473850 --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Infrastructure/Security/Exception/InvalidRefreshTokenException.java @@ -0,0 +1,7 @@ +package co.com.treecode.ketrack.Infrastructure.Security.Exception; + +public class InvalidRefreshTokenException extends RuntimeException { + public InvalidRefreshTokenException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/co/com/treecode/ketrack/Infrastructure/Security/JWT/JwtAuthenticationFilter.java b/src/main/java/co/com/treecode/ketrack/Infrastructure/Security/JWT/JwtAuthenticationFilter.java new file mode 100644 index 0000000..0232a85 --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Infrastructure/Security/JWT/JwtAuthenticationFilter.java @@ -0,0 +1,52 @@ +package co.com.treecode.ketrack.Infrastructure.Security.JWT; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +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.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService service; + private final UserDetailsService userDetailsService; + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + final String authHeader = request.getHeader("Authorization"); + final String jwt; + final String username; + if(authHeader == null || !authHeader.startsWith("Bearer ")){ + filterChain.doFilter(request, response); + return; + } + jwt = authHeader.substring(7); // "Bearer " + username = service.extractUsername(jwt); + if(username != null && SecurityContextHolder.getContext().getAuthentication() == null){ + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + if(service.isTokenValid(jwt, userDetails)){ + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + authToken.setDetails( + new WebAuthenticationDetailsSource().buildDetails(request) + ); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + filterChain.doFilter(request, response); + } + } +} diff --git a/src/main/java/co/com/treecode/ketrack/Infrastructure/Security/JWT/JwtService.java b/src/main/java/co/com/treecode/ketrack/Infrastructure/Security/JWT/JwtService.java new file mode 100644 index 0000000..520f858 --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Infrastructure/Security/JWT/JwtService.java @@ -0,0 +1,97 @@ +package co.com.treecode.ketrack.Infrastructure.Security.JWT; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +import java.security.Key; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class JwtService { + @Value("${security.signing-key}") private String SIGN_IN_KEY; + @Value("${security.token.access-expiration}") private Long ACCESS_TIME_TO_EXPIRATION_MS; + @Value("${security.token.refresh-expiration}") private Long REFRESH_TIME_TO_EXPIRATION_MS; + + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + public String generateToken(UserDetails userDetails) { + return generateToken(new HashMap<>(), userDetails); + } + + public String generateToken(Map extraClaims, UserDetails userDetails) { + extraClaims.put("roles", userDetails.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList())); + return Jwts + .builder() + .setClaims(extraClaims) + .setSubject(userDetails.getUsername()) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + ACCESS_TIME_TO_EXPIRATION_MS)) + .signWith(getSignInKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public String generateRefreshToken(UserDetails userDetails) { + return Jwts.builder() + .setSubject(userDetails.getUsername()) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + REFRESH_TIME_TO_EXPIRATION_MS)) + .signWith(getSignInKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public boolean isTokenValid(String token, UserDetails userDetails) { + final String username = extractUsername(token); + return (username.equals(userDetails.getUsername())) && !isTokenExpired(token); + } + + private boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + private Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } + + public List extractRoles(String token) { + Claims claims = extractAllClaims(token); + return claims.get("roles", List.class); + } + + private Claims extractAllClaims(String token){ + try { + return Jwts + .parserBuilder() + .setSigningKey(getSignInKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (JwtException e) { + throw new JwtException("Invalid JWT token", e); + } + } + + private Key getSignInKey() { + byte[] keyBytes = Decoders.BASE64.decode(SIGN_IN_KEY); + return Keys.hmacShaKeyFor(keyBytes); + } +} diff --git a/src/main/java/co/com/treecode/ketrack/KetrackApplication.java b/src/main/java/co/com/treecode/ketrack/KetrackApplication.java new file mode 100644 index 0000000..8562096 --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/KetrackApplication.java @@ -0,0 +1,13 @@ +package co.com.treecode.ketrack; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class KetrackApplication { + + public static void main(String[] args) { + SpringApplication.run(KetrackApplication.class, args); + } + +} diff --git a/src/main/java/co/com/treecode/ketrack/Shared/Exception/BusinessException.java b/src/main/java/co/com/treecode/ketrack/Shared/Exception/BusinessException.java new file mode 100644 index 0000000..f5be903 --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Shared/Exception/BusinessException.java @@ -0,0 +1,29 @@ +package co.com.treecode.ketrack.Shared.Exception; + +public class BusinessException extends Exception { + private final String code; + + public BusinessException(String message) { + super(message); + this.code = null; + } + + public BusinessException(String message, String code) { + super(message); + this.code = code; + } + + public BusinessException(String message, Throwable cause) { + super(message, cause); + this.code = null; + } + + public BusinessException(String message, String code, Throwable cause) { + super(message, cause); + this.code = code; + } + + public String getCode() { + return code; + } +} diff --git a/src/main/java/co/com/treecode/ketrack/Shared/Exception/Handler/ExceptionHandler.java b/src/main/java/co/com/treecode/ketrack/Shared/Exception/Handler/ExceptionHandler.java new file mode 100644 index 0000000..b595469 --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Shared/Exception/Handler/ExceptionHandler.java @@ -0,0 +1,99 @@ +package co.com.treecode.ketrack.Shared.Exception.Handler; + +import co.com.treecode.ketrack.Infrastructure.Security.Exception.InvalidRefreshTokenException; +import co.com.treecode.ketrack.Shared.Exception.ModelNotFoundException; +import co.com.treecode.ketrack.Shared.Web.ResponseHandler; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.WebRequest; + +import java.util.ArrayList; +import java.util.List; + +@ControllerAdvice +@RestController +public class ExceptionHandler { + + @org.springframework.web.bind.annotation.ExceptionHandler(Exception.class) + public ResponseEntity handlerGenericException(Exception ex, WebRequest request) { + var e = List.of( + Issue.builder() + .title("Exception") + .detail(ex.getMessage()) + .build() + ); + return ResponseHandler.fail(HttpStatus.INTERNAL_SERVER_ERROR, e); + } + + @org.springframework.web.bind.annotation.ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handlerValidationException(MethodArgumentNotValidException ex) { + List fields = new ArrayList<>( + ex.getBindingResult() + .getFieldErrors() + .stream() + .map(error -> Issue.builder() + .title(error.getField()) + .detail(error.getDefaultMessage()) + .build() + ) + .toList() + ); + return ResponseHandler.fail(HttpStatus.BAD_REQUEST, fields); + } + + @org.springframework.web.bind.annotation.ExceptionHandler(ModelNotFoundException.class) + public ResponseEntity handlerModelNotFoundException(ModelNotFoundException ex, WebRequest request) { + var e = List.of("Resource not found"); + return ResponseHandler.fail(HttpStatus.NOT_FOUND, e); + } + + @org.springframework.web.bind.annotation.ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handlerAccessDeniedException(AccessDeniedException ex, WebRequest request) { + var e = List.of( + Issue.builder() + .title("Access denied") + .detail(ex.getMessage()) + .build() + ); + return ResponseHandler.fail(HttpStatus.FORBIDDEN, e); + } + + @org.springframework.web.bind.annotation.ExceptionHandler(BadCredentialsException.class) + public ResponseEntity handleBadCredentials(BadCredentialsException ex) { + var e = List.of( + Issue.builder() + .title("Authentication failed") + .detail("Username or password is incorrect") + .build() + ); + return ResponseHandler.fail(HttpStatus.UNAUTHORIZED, e); + } + + @org.springframework.web.bind.annotation.ExceptionHandler(UsernameNotFoundException.class) + public ResponseEntity handleUserNotFound(UsernameNotFoundException ex) { + var e = List.of( + Issue.builder() + .title("User not found") + .detail(ex.getMessage()) + .build() + ); + return ResponseHandler.fail(HttpStatus.UNAUTHORIZED, e); + } + + @org.springframework.web.bind.annotation.ExceptionHandler(InvalidRefreshTokenException.class) + public ResponseEntity handleInvalidRefresh(InvalidRefreshTokenException ex) { + var e = List.of( + Issue.builder() + .title("Invalid refresh token") + .detail(ex.getMessage()) + .build() + ); + return ResponseHandler.fail(HttpStatus.FORBIDDEN, e); + } +} diff --git a/src/main/java/co/com/treecode/ketrack/Shared/Exception/Handler/Issue.java b/src/main/java/co/com/treecode/ketrack/Shared/Exception/Handler/Issue.java new file mode 100644 index 0000000..f201901 --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Shared/Exception/Handler/Issue.java @@ -0,0 +1,11 @@ +package co.com.treecode.ketrack.Shared.Exception.Handler; + +import lombok.Builder; +import lombok.Data; + +@Builder +@Data +public class Issue { + private String title; + private String detail; +} diff --git a/src/main/java/co/com/treecode/ketrack/Shared/Exception/ModelNotFoundException.java b/src/main/java/co/com/treecode/ketrack/Shared/Exception/ModelNotFoundException.java new file mode 100644 index 0000000..eaa80aa --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Shared/Exception/ModelNotFoundException.java @@ -0,0 +1,9 @@ +package co.com.treecode.ketrack.Shared.Exception; + +public class ModelNotFoundException extends RuntimeException{ + public ModelNotFoundException() { super(); } + + public ModelNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/co/com/treecode/ketrack/Shared/Persistence/ICRUDService.java b/src/main/java/co/com/treecode/ketrack/Shared/Persistence/ICRUDService.java new file mode 100644 index 0000000..feb56d2 --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Shared/Persistence/ICRUDService.java @@ -0,0 +1,21 @@ +package co.com.treecode.ketrack.Shared.Persistence; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +public interface ICRUDService { + T create(T t) throws Exception; + + List read() throws Exception; + + Page read(Pageable pageable) throws Exception; + + Optional readById(ID id) throws Exception; + + T update (T t) throws Exception; + + void delete(ID id) throws Exception; +} \ No newline at end of file diff --git a/src/main/java/co/com/treecode/ketrack/Shared/Persistence/IPostgreSQLGenericRepository.java b/src/main/java/co/com/treecode/ketrack/Shared/Persistence/IPostgreSQLGenericRepository.java new file mode 100644 index 0000000..074ea5b --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Shared/Persistence/IPostgreSQLGenericRepository.java @@ -0,0 +1,7 @@ +package co.com.treecode.ketrack.Shared.Persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.NoRepositoryBean; + +@NoRepositoryBean +public interface IPostgreSQLGenericRepository extends JpaRepository { } diff --git a/src/main/java/co/com/treecode/ketrack/Shared/Persistence/PostgreSQLImpl.java b/src/main/java/co/com/treecode/ketrack/Shared/Persistence/PostgreSQLImpl.java new file mode 100644 index 0000000..d800752 --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Shared/Persistence/PostgreSQLImpl.java @@ -0,0 +1,36 @@ +package co.com.treecode.ketrack.Shared.Persistence; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +public abstract class PostgreSQLImpl implements ICRUDService { + protected abstract IPostgreSQLGenericRepository getRepository(); + @Override + public T create(T t) { + return getRepository().save(t); + } + + @Override + public List read() { + return getRepository().findAll(); + } + + @Override + public Page read(Pageable pageable) { + return getRepository().findAll(pageable); + } + + @Override + public Optional readById(ID id) { + return getRepository().findById(id); + } + + @Override + public T update(T t) throws Exception { return getRepository().save(t); } + + @Override + public void delete(ID id) throws Exception { getRepository().deleteById(id); } +} diff --git a/src/main/java/co/com/treecode/ketrack/Shared/Util/DateTimeUtil.java b/src/main/java/co/com/treecode/ketrack/Shared/Util/DateTimeUtil.java new file mode 100644 index 0000000..6dcd7d0 --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Shared/Util/DateTimeUtil.java @@ -0,0 +1,74 @@ +package co.com.treecode.ketrack.Shared.Util; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; + +/** + * Utility class for handling date and time operations with timezone support. + * Default timezone is configured through APP_ZONE environment variable or defaults to America/Bogota. + */ +public final class DateTimeUtil { + + private static final ZoneId DEFAULT_ZONE = ZoneId.of(System.getenv().getOrDefault("APP_ZONE", "America/Bogota")); + private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_DATE_TIME; + + private DateTimeUtil() { + throw new AssertionError("Utility class should not be instantiated"); + } + + /** + * Converts an Instant to ZonedDateTime using the application's default timezone. + * + * @param instant The instant to convert + * @return ZonedDateTime in the default timezone + */ + public static ZonedDateTime toLocalZone(Instant instant) { + return instant != null ? instant.atZone(DEFAULT_ZONE) : null; + } + + /** + * Converts an Instant to ZonedDateTime using the specified timezone. + * + * @param instant The instant to convert + * @param zoneId String representation of the desired timezone + * @return ZonedDateTime in the specified timezone + */ + public static ZonedDateTime toZone(Instant instant, String zoneId) { + if (instant == null || zoneId == null) { + return null; + } + return instant.atZone(ZoneId.of(zoneId)); + } + + /** + * Gets the current date-time in the application's default timezone. + * + * @return Current ZonedDateTime in default timezone + */ + public static ZonedDateTime now() { + return ZonedDateTime.now(DEFAULT_ZONE); + } + + /** + * Converts ZonedDateTime to LocalDateTime in default timezone. + * + * @param zonedDateTime The ZonedDateTime to convert + * @return LocalDateTime in default timezone + */ + public static LocalDateTime toLocalDateTime(ZonedDateTime zonedDateTime) { + return zonedDateTime != null ? zonedDateTime.withZoneSameInstant(DEFAULT_ZONE).toLocalDateTime() : null; + } + + /** + * Formats a ZonedDateTime to ISO-8601 string. + * + * @param zonedDateTime The ZonedDateTime to format + * @return ISO-8601 formatted string + */ + public static String format(ZonedDateTime zonedDateTime) { + return zonedDateTime != null ? zonedDateTime.format(ISO_FORMATTER) : null; + } +} diff --git a/src/main/java/co/com/treecode/ketrack/Shared/Web/CORS.java b/src/main/java/co/com/treecode/ketrack/Shared/Web/CORS.java new file mode 100644 index 0000000..a66a966 --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Shared/Web/CORS.java @@ -0,0 +1,31 @@ +package co.com.treecode.ketrack.Shared.Web; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CORS{ + + @Value("${cors.allowed-origins}") + private String[] allowedOrigins; + + @Bean + public WebMvcConfigurer corsConfigurer() { + return new WebMvcConfigurer() { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins(allowedOrigins) + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") + .allowedHeaders("*") + .exposedHeaders("Authorization") + .allowCredentials(true) + .maxAge(3600); + } + }; + } + +} diff --git a/src/main/java/co/com/treecode/ketrack/Shared/Web/ResponseHandler.java b/src/main/java/co/com/treecode/ketrack/Shared/Web/ResponseHandler.java new file mode 100644 index 0000000..45f84eb --- /dev/null +++ b/src/main/java/co/com/treecode/ketrack/Shared/Web/ResponseHandler.java @@ -0,0 +1,89 @@ +package co.com.treecode.ketrack.Shared.Web; + +import co.com.treecode.ketrack.Shared.Util.DateTimeUtil; +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Getter; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.UUID; + +@Getter +@Builder +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class ResponseHandler { + private UUID id; + private ZonedDateTime timestamp; + private int status; + private String message; + private Boolean error; + private D data; + private E errors; + + /** + * Create a success response with data. + */ + @SuppressWarnings("unchecked") + public static ResponseEntity success(HttpStatus status, D data, String... customMessage) { + var response = ResponseHandler.builder() + .id(UUID.randomUUID()) + .timestamp(DateTimeUtil.now()) + .status(status.value()) + .message((customMessage != null && customMessage.length > 0) ? Arrays.toString(customMessage) : null) + .error(null) + .data(data) + .build(); + return (ResponseEntity) new ResponseEntity<>(response, status); + } + + /** + * Create a 200 success response with data. + */ + @SuppressWarnings("unchecked") + public static ResponseEntity success(D data, String... customMessage) { + var response = ResponseHandler.builder() + .id(UUID.randomUUID()) + .timestamp(DateTimeUtil.now()) + .status(HttpStatus.OK.value()) + .message((customMessage != null && customMessage.length > 0) ? Arrays.toString(customMessage) : null) + .error(null) + .data(data) + .build(); + return (ResponseEntity) new ResponseEntity<>(response, HttpStatus.OK); + } + + /** + * Create a success response without data. + */ + @SuppressWarnings("unchecked") + public static ResponseEntity success(HttpStatus status, String... customMessage) { + var response = ResponseHandler.builder() + .id(UUID.randomUUID()) + .timestamp(DateTimeUtil.now()) + .status(status.value()) + .message((customMessage != null && customMessage.length > 0) ? Arrays.toString(customMessage) : null) + .error(false) + .data(null) + .build(); + return (ResponseEntity) new ResponseEntity<>(response, status); + } + + /** + * Create a failure response with error details. + */ + @SuppressWarnings("unchecked") + public static ResponseEntity fail(HttpStatus status, E errors) { + var response = ResponseHandler.builder() + .id(UUID.randomUUID()) + .timestamp(DateTimeUtil.now()) + .status(status.value()) + .message(status.getReasonPhrase()) + .error(true) + .errors(errors) + .build(); + return (ResponseEntity) new ResponseEntity<>(response, status); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..3a63fb2 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,54 @@ +# app listener port +server: + port: 8089 + +# data persistence +spring: + application: + name: ketrack + datasource: + url: ${DATASOURCE_URL} + username: ${DATASOURCE_USER} + password: ${DATASOURCE_PASSWORD} + flyway: # database migrations + enabled: ${FLYWAY_ENABLED:false} + locations: classpath:db/migration + jpa: + hibernate: + ddl-auto: none + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + globally_quoted_identifiers: true + +# logging (dev env) +logging: + pattern: + correlation: '[%X{traceId:-}-%X{spanId:-}] [%X{tenantId:-}] ' + level: + org: + springframework: + web: TRACE + flywaydb: DEBUG + +# security config +# https://acte.ltd/utils/randomkeygen +# signing key should be on hex +security: + signing-key: ${SECURITY_SIGNING_KEY} + token: + access-expiration: 3600000 # 1 hora en milisegundos + refresh-expiration: 604800000 # 7 días en milisegundos + +# swagger configs +springdoc: + api-docs: + enabled: true + swagger-ui: + path: /swagger + enabled: true + +# cors config +cors: + allowed-origins: "http://localhost:8089,http://localhost:8080" \ No newline at end of file diff --git a/src/main/resources/banner.txt b/src/main/resources/banner.txt new file mode 100644 index 0000000..14aaba0 --- /dev/null +++ b/src/main/resources/banner.txt @@ -0,0 +1,10 @@ +░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +░░████████░█████▄░▄█████░▄█████░░░▄█████░▄█████▄░██████▄░▄█████░░ +░░░░░██░░░░██░░██░██░░░░░██░░░░░░░██░░░░░██░░░██░██░░░██░██░░░░░░ +░░░░░██░░░░█████▀░█████░░█████░░░░██░░░░░██░░░██░██░░░██░█████░░░ +░░░░░██░░░░██░░██░██░░░░░██░░░░░░░██░░░░░██░░░██░██░░░██░██░░░░░░ +░░░░░██░░░░██░░██░▀█████░▀█████░░░▀█████░▀█████▀░██████▀░▀█████░░ +░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +${application.title} | v${application.version} +Spring Boot ${spring-boot.version} +Powered by Tree Code | KeTrack Platform \ No newline at end of file diff --git a/src/main/resources/db/migration/V001__init-tables.sql b/src/main/resources/db/migration/V001__init-tables.sql new file mode 100644 index 0000000..c3274de --- /dev/null +++ b/src/main/resources/db/migration/V001__init-tables.sql @@ -0,0 +1,30 @@ +create table public."user" ( + id_user bigserial primary key, + username varchar(255) not null unique, + email varchar(255) not null unique, + password text not null, + is_active boolean default true, + created_at timestamp default current_timestamp +); + +create table public."role" ( + id_role bigserial primary key, + name varchar(255) not null unique +); + +create table public."permission"( + id_permission bigserial primary key, + name varchar(255) not null unique +); + +create table public."role_permission"( + id_role bigserial references "role"(id_role) on delete cascade, + id_permission bigserial references permission(id_permission) on delete cascade, + primary key (id_role, id_permission) +); + +create table public."user_role"( + id_user bigserial references "user"(id_user) on delete cascade, + id_role bigserial references "role"(id_role) on delete cascade, + primary key (id_user, id_role) +); \ No newline at end of file diff --git a/src/main/resources/db/migration/V002__insert_user_role_permission.sql b/src/main/resources/db/migration/V002__insert_user_role_permission.sql new file mode 100644 index 0000000..f903546 --- /dev/null +++ b/src/main/resources/db/migration/V002__insert_user_role_permission.sql @@ -0,0 +1,34 @@ +insert into public."user" (username, email, password, is_active) values +('juan', 'juan@example.com', '$2y$10$ECZhUKlKRtjWCNxNo57bV.wGws6fNsAsc36vc5JLewAfydmIDJ8GS', true), +('maria', 'maria@example.com', '$2y$10$ECZhUKlKRtjWCNxNo57bV.wGws6fNsAsc36vc5JLewAfydmIDJ8GS', true), +('pedro', 'pedro@example.com', '$2y$10$ECZhUKlKRtjWCNxNo57bV.wGws6fNsAsc36vc5JLewAfydmIDJ8GS', true); + +insert into public."role" (name) values +('admin'), +('editor'), +('viewer'); + +insert into public."permission" (name) values +('create'), +('read'), +('update'), +('delete'); + +insert into public."role_permission" (id_role, id_permission) values +((select id_role from "role" where name = 'admin'), (select id_permission from "permission" where name = 'create')), +((select id_role from "role" where name = 'admin'), (select id_permission from "permission" where name = 'read')), +((select id_role from "role" where name = 'admin'), (select id_permission from "permission" where name = 'update')), +((select id_role from "role" where name = 'admin'), (select id_permission from "permission" where name = 'delete')); + +insert into public."role_permission" (id_role, id_permission) values +((select id_role from "role" where name = 'editor'), (select id_permission from "permission" where name = 'create')), +((select id_role from "role" where name = 'editor'), (select id_permission from "permission" where name = 'read')), +((select id_role from "role" where name = 'editor'), (select id_permission from "permission" where name = 'update')); + +insert into public."role_permission" (id_role, id_permission) values +((select id_role from "role" where name = 'viewer'), (select id_permission from "permission" where name = 'read')); + +insert into public."user_role" (id_user, id_role) values +((select id_user from "user" where username = 'juan'), (select id_role from "role" where name = 'editor')), +((select id_user from "user" where username = 'maria'), (select id_role from "role" where name = 'viewer')), +((select id_user from "user" where username = 'pedro'), (select id_role from "role" where name = 'admin')); \ No newline at end of file diff --git a/src/main/resources/db/migration/V003__create_refresh_token_table.sql b/src/main/resources/db/migration/V003__create_refresh_token_table.sql new file mode 100644 index 0000000..ab4a176 --- /dev/null +++ b/src/main/resources/db/migration/V003__create_refresh_token_table.sql @@ -0,0 +1,10 @@ +create table public."refresh_token" ( + id bigserial primary key, + id_user bigserial not null, + token_hash text not null unique, + created_at timestamp without time zone not null, + expires_at timestamp without time zone not null, + revoked boolean not null default false, + + constraint fk_user_refresh_token foreign key (id_user) references "user"(id_user) on delete cascade +); \ No newline at end of file diff --git a/src/test/java/co/com/treecode/ketrack/KetrackApplicationTests.java b/src/test/java/co/com/treecode/ketrack/KetrackApplicationTests.java new file mode 100644 index 0000000..e881d51 --- /dev/null +++ b/src/test/java/co/com/treecode/ketrack/KetrackApplicationTests.java @@ -0,0 +1,13 @@ +package co.com.treecode.ketrack; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class KetrackApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/co/com/treecode/ketrack/Security/AuthServiceTest.java b/src/test/java/co/com/treecode/ketrack/Security/AuthServiceTest.java new file mode 100644 index 0000000..bfffe8c --- /dev/null +++ b/src/test/java/co/com/treecode/ketrack/Security/AuthServiceTest.java @@ -0,0 +1,195 @@ +package co.com.treecode.ketrack.Security; + +import co.com.treecode.ketrack.Core.Domain.Security.RefreshTokenEntity; +import co.com.treecode.ketrack.Core.Domain.User.UserEntity; +import co.com.treecode.ketrack.Core.UseCase.Security.AuthService; +import co.com.treecode.ketrack.Infrastructure.API.V1.DTO.Security.AuthRequest; +import co.com.treecode.ketrack.Infrastructure.API.V1.DTO.Security.AuthResponse; +import co.com.treecode.ketrack.Infrastructure.API.V1.DTO.Security.RefreshTokenRequest; +import co.com.treecode.ketrack.Infrastructure.Persistence.Security.IRefreshTokenRepository; +import co.com.treecode.ketrack.Infrastructure.Persistence.User.IUserRepository; +import co.com.treecode.ketrack.Infrastructure.Security.Exception.InvalidRefreshTokenException; +import co.com.treecode.ketrack.Infrastructure.Security.JWT.JwtService; +import co.com.treecode.ketrack.Shared.Exception.ModelNotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.test.util.ReflectionTestUtils; + +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.*; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class AuthServiceTest { + @Mock + private AuthenticationManager authManager; + @Mock + private UserDetailsService userDetailsService; + @Mock + private JwtService jwtService; + @Mock + private IUserRepository userRepository; + @Mock + private IRefreshTokenRepository refreshTokenRepository; + + @InjectMocks + private AuthService authService; + + @BeforeEach + void setup() { + ReflectionTestUtils.setField(authService, "REFRESH_TIME_TO_EXPIRATION_MS", 3600L); + } + + @Test + void loginSuccessfully() { + AuthRequest request = new AuthRequest("user", "pass"); + UserEntity userEntity = UserEntity.builder().idUser(1L).username("user").build(); + UserDetails userDetails = User.builder().username("user").password("pass").authorities(Collections.emptyList()).build(); + + when(userDetailsService.loadUserByUsername("user")).thenReturn(userDetails); + when(userRepository.findByUsername("user")).thenReturn(Optional.of(userEntity)); + when(jwtService.generateToken(userDetails)).thenReturn("access-token"); + when(jwtService.generateRefreshToken(userDetails)).thenReturn("refresh-token"); + + AuthResponse response = authService.login(request); + + assertThat(response.accessToken()).isEqualTo("access-token"); + assertThat(response.refreshToken()).isEqualTo("refresh-token"); + verify(refreshTokenRepository).save(any()); + } + + @Test + void loginWithInvalidCredentialsThrowsException() { + AuthRequest request = new AuthRequest("user", "wrong"); + doThrow(BadCredentialsException.class) + .when(authManager) + .authenticate(any()); + + assertThrows(BadCredentialsException.class, () -> authService.login(request)); + } + + @Test + void loginWithNonExistentUserThrowsException() { + AuthRequest request = new AuthRequest("unknown", "pass"); + UserDetails userDetails = User.builder().username("unknown").password("pass").authorities(Collections.emptyList()).build(); + + when(userDetailsService.loadUserByUsername("unknown")).thenReturn(userDetails); + when(userRepository.findByUsername("unknown")).thenReturn(Optional.empty()); + + assertThrows(ModelNotFoundException.class, () -> authService.login(request)); + } + + @Test + void refreshTokenSuccessfully() { + RefreshTokenRequest request = new RefreshTokenRequest("valid-refresh"); + UserEntity userEntity = UserEntity.builder().idUser(1L).username("user").build(); + UserDetails userDetails = User.builder().username("user").password("pass").authorities(Collections.emptyList()).build(); + RefreshTokenEntity tokenEntity = RefreshTokenEntity.builder() + .idUser(1L) + .tokenHash(hash("valid-refresh")) + .revoked(false) + .expiresAt(LocalDateTime.now().plusHours(1)) + .build(); + + when(refreshTokenRepository.findByTokenHash(any())).thenReturn(Optional.of(tokenEntity)); + when(userRepository.findById(1L)).thenReturn(Optional.of(userEntity)); + when(userDetailsService.loadUserByUsername("user")).thenReturn(userDetails); + when(jwtService.generateToken(userDetails)).thenReturn("new-access-token"); + when(jwtService.generateRefreshToken(userDetails)).thenReturn("new-refresh-token"); + + AuthResponse response = authService.refresh(request); + + assertThat(response.accessToken()).isEqualTo("new-access-token"); + assertThat(response.refreshToken()).isEqualTo("new-refresh-token"); + verify(refreshTokenRepository, times(2)).save(any()); + } + + @Test + void refreshWithInvalidTokenThrowsException() { + RefreshTokenRequest request = new RefreshTokenRequest("invalid-refresh"); + + when(refreshTokenRepository.findByTokenHash(any())).thenReturn(Optional.empty()); + + assertThrows(InvalidRefreshTokenException.class, () -> authService.refresh(request)); + } + + @Test + void refreshWithExpiredTokenThrowsException() { + RefreshTokenRequest request = new RefreshTokenRequest("expired-refresh"); + + RefreshTokenEntity tokenEntity = RefreshTokenEntity.builder() + .idUser(1L) + .tokenHash(hash("expired-refresh")) + .revoked(false) + .expiresAt(LocalDateTime.now().minusHours(1)) + .build(); + + when(refreshTokenRepository.findByTokenHash(any())).thenReturn(Optional.of(tokenEntity)); + + assertThrows(InvalidRefreshTokenException.class, () -> authService.refresh(request)); + } + + @Test + void refreshWithRevokedTokenThrowsException() { + RefreshTokenRequest request = new RefreshTokenRequest("revoked-refresh"); + + RefreshTokenEntity tokenEntity = RefreshTokenEntity.builder() + .idUser(1L) + .tokenHash(hash("revoked-refresh")) + .revoked(true) + .expiresAt(LocalDateTime.now().plusHours(1)) + .build(); + + when(refreshTokenRepository.findByTokenHash(any())).thenReturn(Optional.of(tokenEntity)); + + assertThrows(InvalidRefreshTokenException.class, () -> authService.refresh(request)); + } + + @Test + void maximumTokensPerUserEnforced() { + AuthRequest request = new AuthRequest("user", "pass"); + UserEntity userEntity = UserEntity.builder().idUser(1L).username("user").build(); + UserDetails userDetails = User.builder().username("user").password("pass").authorities(Collections.emptyList()).build(); + List existingTokens = Arrays.asList( + RefreshTokenEntity.builder().idUser(1L).createdAt(LocalDateTime.now().minusHours(2)).build(), + RefreshTokenEntity.builder().idUser(1L).createdAt(LocalDateTime.now().minusHours(1)).build(), + RefreshTokenEntity.builder().idUser(1L).createdAt(LocalDateTime.now()).build() + ); + + when(userDetailsService.loadUserByUsername("user")).thenReturn(userDetails); + when(userRepository.findByUsername("user")).thenReturn(Optional.of(userEntity)); + when(refreshTokenRepository.findAllByIdUserAndRevokedFalseOrderByCreatedAtAsc(1L)).thenReturn(existingTokens); + + authService.login(request); + + verify(refreshTokenRepository).delete(existingTokens.get(0)); + verify(refreshTokenRepository).save(any()); + } + + private String hash(String token) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashed = digest.digest(token.getBytes()); + return Base64.getEncoder().encodeToString(hashed); + } catch (Exception e) { + throw new RuntimeException("Error hashing token", e); + } + } + + + +}