commit 6b5f634c60de7c872ccf7b45bfa62f46165dfd7c Author: julsae Date: Wed Sep 17 22:16:57 2025 -0300 init commit 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 0000000..43fe295 Binary files /dev/null and b/ketrack.zip differ 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); + } + } + + + +}