diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2a32a59 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +# VCS and editor +.git +.gitignore +.idea +.vscode + +# Build outputs +**/target/ + +# Local logs and runtime files +logs/ +**/*.log + +# OS files +.DS_Store + diff --git a/.gitignore b/.gitignore index e331b61..ad5a90a 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,8 @@ build/ ### VS Code ### .vscode/ +.air/ + .env application-*.properties diff --git a/bundle/.gitattributes b/bundle/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/bundle/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/bundle/.gitignore b/bundle/.gitignore new file mode 100644 index 0000000..667aaef --- /dev/null +++ b/bundle/.gitignore @@ -0,0 +1,33 @@ +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/ diff --git a/bundle/.mvn/wrapper/maven-wrapper.properties b/bundle/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..216df05 --- /dev/null +++ b/bundle/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip diff --git a/bundle/mvnw b/bundle/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/bundle/mvnw @@ -0,0 +1,295 @@ +#!/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.4 +# +# 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:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# 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 <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.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${scriptName#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 + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/bundle/mvnw.cmd b/bundle/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/bundle/mvnw.cmd @@ -0,0 +1,189 @@ +<# : 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.4 +@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 -eq $False) { "/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_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::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 + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -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/bundle/pom.xml b/bundle/pom.xml new file mode 100644 index 0000000..511d955 --- /dev/null +++ b/bundle/pom.xml @@ -0,0 +1,165 @@ + + + 4.0.0 + + io.theurl + parent + 1.0 + ../pom.xml + + + bundle + bundle + bundle + + false + + + + + io.theurl + framework + ${project.version} + compile + + + io.theurl + shared + ${project.version} + compile + + + org.springframework.boot + spring-boot-starter-amqp + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 3.0.3 + + + org.springframework.cloud + spring-cloud-starter-config + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + com.mysql + mysql-connector-j + runtime + + + org.postgresql + postgresql + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-amqp-test + test + + + org.springframework.boot + spring-boot-starter-data-jpa-test + test + + + org.springframework.boot + spring-boot-starter-security-test + test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-compile + compile + + compile + + + + + org.projectlombok + lombok + + + + + + default-testCompile + test-compile + + testCompile + + + + + org.projectlombok + lombok + + + + + + + + + + diff --git a/bundle/src/main/java/io/theurl/bundle/BundleApplication.java b/bundle/src/main/java/io/theurl/bundle/BundleApplication.java new file mode 100644 index 0000000..db134cb --- /dev/null +++ b/bundle/src/main/java/io/theurl/bundle/BundleApplication.java @@ -0,0 +1,15 @@ +package io.theurl.bundle; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; + +@EnableAsync +@SpringBootApplication +public class BundleApplication { + + public static void main(String[] args) { + SpringApplication.run(BundleApplication.class, args); + } + +} diff --git a/bundle/src/main/java/io/theurl/bundle/configure/ModelMapperConfiguration.java b/bundle/src/main/java/io/theurl/bundle/configure/ModelMapperConfiguration.java new file mode 100644 index 0000000..9870694 --- /dev/null +++ b/bundle/src/main/java/io/theurl/bundle/configure/ModelMapperConfiguration.java @@ -0,0 +1,24 @@ +package io.theurl.bundle.configure; + +import org.modelmapper.ModelMapper; +import org.modelmapper.config.Configuration.AccessLevel; +import org.modelmapper.convention.MatchingStrategies; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ModelMapperConfiguration { + @Bean + public ModelMapper modelMapper() { + ModelMapper mapper = new ModelMapper(); + + mapper.getConfiguration() + .setMatchingStrategy(MatchingStrategies.STRICT) + .setFieldMatchingEnabled(true) + .setFieldAccessLevel(AccessLevel.PRIVATE) + .setCollectionsMergeEnabled(true) + .setSkipNullEnabled(true); + + return mapper; + } +} diff --git a/bundle/src/main/java/io/theurl/bundle/configure/OpenApiSecurityConfiguration.java b/bundle/src/main/java/io/theurl/bundle/configure/OpenApiSecurityConfiguration.java new file mode 100644 index 0000000..3b02f52 --- /dev/null +++ b/bundle/src/main/java/io/theurl/bundle/configure/OpenApiSecurityConfiguration.java @@ -0,0 +1,27 @@ +package io.theurl.bundle.configure; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiSecurityConfiguration { + + @Bean + public OpenAPI customOpenAPI() { + final String bearerSchemeName = "bearerAuth"; + + return new OpenAPI() + .components(new Components().addSecuritySchemes( + bearerSchemeName, + new SecurityScheme() + .name(bearerSchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + )); + } +} + diff --git a/bundle/src/main/java/io/theurl/bundle/configure/RabbitMqConfiguration.java b/bundle/src/main/java/io/theurl/bundle/configure/RabbitMqConfiguration.java new file mode 100644 index 0000000..2cabb30 --- /dev/null +++ b/bundle/src/main/java/io/theurl/bundle/configure/RabbitMqConfiguration.java @@ -0,0 +1,23 @@ +package io.theurl.bundle.configure; + +import com.rabbitmq.client.ConnectionFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RabbitMqConfiguration { + @Bean + public ConnectionFactory connectionFactory(@Value("${spring.rabbitmq.host}") String host, + @Value("${spring.rabbitmq.port}") int port, + @Value("${spring.rabbitmq.username}") String username, + @Value("${spring.rabbitmq.password}") String password) { + ConnectionFactory factory = new ConnectionFactory(); + factory.setHost(host); + factory.setPort(port); + factory.setUsername(username); + factory.setPassword(password); + factory.setVirtualHost("/"); + return factory; + } +} diff --git a/bundle/src/main/java/io/theurl/bundle/configure/SecurityConfiguration.java b/bundle/src/main/java/io/theurl/bundle/configure/SecurityConfiguration.java new file mode 100644 index 0000000..85e17e7 --- /dev/null +++ b/bundle/src/main/java/io/theurl/bundle/configure/SecurityConfiguration.java @@ -0,0 +1,46 @@ +package io.theurl.bundle.configure; + +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +/** + * The security configuration for the application, defining the security filter chain and authentication rules. + *

+ * This configuration disables CSRF, form login, and HTTP basic authentication, and sets the session management to stateless. + * It also configures exception handling to return a 401 Unauthorized response for unauthenticated requests. + * The authorization rules allow unauthenticated access to specific endpoints (e.g., authentication and registration endpoints, API documentation) while requiring authentication for all other requests. + * The JwtAuthenticationFilter is added to the filter chain before the UsernamePasswordAuthenticationFilter to handle JWT token parsing and authentication. + *

+ */ +@Configuration +public class SecurityConfiguration { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, + io.theurl.framework.security.JwtAuthenticationFilter jwtAuthenticationFilter) { + http.csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(ex -> ex.authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"))) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/error" + ).permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} + diff --git a/bundle/src/main/resources/application.yaml b/bundle/src/main/resources/application.yaml new file mode 100644 index 0000000..962d792 --- /dev/null +++ b/bundle/src/main/resources/application.yaml @@ -0,0 +1,72 @@ +server: + port: 8903 + +spring: + profiles: + active: ${SPRING_PROFILES_ACTIVE:dev} + application: + name: identity + config: + import: optional:file:.env[.properties] + cloud: + config: + enabled: false + uri: ${CONFIG_SERVER_URI:http://localhost:8900} + datasource: + url: ${DB_URL:jdbc:postgresql://localhost:5432/linkyou?currentSchema=public} + username: ${DB_USERNAME:postgres} + password: ${DB_PASSWORD:postgres} + driver-class-name: ${DB_DRIVER:org.postgresql.Driver} + jpa: + hibernate: + ddl-auto: update + dialect: ${DB_DIALECT:org.hibernate.dialect.PostgreSQLDialect} + show-sql: true + properties: + hibernate: + multiTenancy: SCHEMA + format_sql: true + data: + redis: + url: ${REDIS_URL:redis://localhost:6379} + mongodb: + uri: ${MONGO_URI:mongodb://localhost:27017/linkyou} + web: + error: + include-message: ALWAYS + include-exception: true + include-stacktrace: ALWAYS + rabbitmq: + host: ${RABBITMQ_HOST:127.0.0.1} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USERNAME:guest} + password: ${RABBITMQ_PASSWORD:guest} + +external-auth: + redirect-uri: "https://theurl.io/auth/callback" + google: + client-id: ${GOOGLE_CLIENT_ID:your-google-client-id} + client-secret: ${GOOGLE_CLIENT_SECRET:your-google-client-secret} + github: + client-id: ${GITHUB_CLIENT_ID:your-github-client-id} + client-secret: ${GITHUB_CLIENT_SECRET:your-github-client-secret} + facebook: + client-id: ${FACEBOOK_CLIENT_ID:your-facebook-client-id} + client-secret: ${FACEBOOK_CLIENT_SECRET:your-facebook-client-secret} + microsoft: + client-id: ${MICROSOFT_CLIENT_ID:your-microsoft-client-id} + client-secret: ${MICROSOFT_CLIENT_SECRET:your-microsoft-client-secret} + +jwt: + secret: ${JWT_SECRET:your-jwt-secret} + issuer: theurl.io + expiration: 3600 # in seconds + +logging: + file: + name: #{level}-{T(java.time.LocalDate).now()}.log + path: logs + level: + io.theurl.identity: debug + org.springframework: debug + root: info diff --git a/bundle/src/main/resources/logback-spring.xml b/bundle/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..5306e44 --- /dev/null +++ b/bundle/src/main/resources/logback-spring.xml @@ -0,0 +1,107 @@ + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n + utf8 + + + + + ${LOG_PATH}/bundle/info/current.log + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + ${LOG_PATH}/bundle/info/%d{yyyy-MM-dd}.log + 100MB + 30 + + + + + ${LOG_PATH}/bundle/error/current.log + + ERROR + ACCEPT + DENY + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + ${LOG_PATH}/bundle/error/%d{yyyy-MM-dd}.log + 100MB + 30 + + + + + ${LOG_PATH}/bundle/warn/current.log + + WARN + ACCEPT + DENY + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + ${LOG_PATH}/bundle/warn/%d{yyyy-MM-dd}.log + 100MB + 30 + + + + + ${LOG_PATH}/bundle/debug/current.log + + DEBUG + ACCEPT + DENY + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + ${LOG_PATH}/bundle/debug/%d{yyyy-MM-dd}.log + 100MB + 30 + + + + + ${LOG_PATH}/bundle/sql/current.log + + DEBUG + ACCEPT + DENY + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + ${LOG_PATH}/bundle/sql/%d{yyyy-MM-dd}.log + 100MB + 30 + + + + + + + + + + + + + + + + + + + diff --git a/bundle/src/test/java/io/theurl/bundle/BundleApplicationTests.java b/bundle/src/test/java/io/theurl/bundle/BundleApplicationTests.java new file mode 100644 index 0000000..748603e --- /dev/null +++ b/bundle/src/test/java/io/theurl/bundle/BundleApplicationTests.java @@ -0,0 +1,13 @@ +package io.theurl.bundle; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class BundleApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/config/Dockerfile b/config/Dockerfile index 34f27ba..9888131 100644 --- a/config/Dockerfile +++ b/config/Dockerfile @@ -1,15 +1,34 @@ -# Stage 1: Build the application +# Stage 1: Build the config module from the multi-module root context. FROM maven:3.9.15-eclipse-temurin-25 AS build -WORKDIR /app -COPY pom.xml . -COPY src ./src -RUN mvn clean package -DskipTests +WORKDIR /workspace + +# Build with repository root as context: docker build -t theurl.io/config -f config/Dockerfile . +COPY pom.xml ./pom.xml +COPY config/pom.xml ./config/pom.xml + +# Resolve dependencies in a separate layer to speed up rebuilds. +RUN mvn -f config/pom.xml -DskipTests dependency:go-offline + +COPY config/src ./config/src + +# Force Spring Boot repackage and override parent skip flag to guarantee fat JAR output. +RUN mvn -f config/pom.xml clean package spring-boot:repackage -DskipTests -Dspring-boot.run.skip=false # Stage 2: Run the application FROM eclipse-temurin:25-jre-alpine WORKDIR /app -COPY --from=build /app/target/*.jar app.jar -EXPOSE 8900 -ENTRYPOINT ["java", "-jar", "app.jar"] + +RUN mkdir -p /app/config + +ENV JAVA_OPTS="-Xms512m -Xmx1024m" +ENV CONFIG_SERVER_USERNAME=theurl +ENV CONFIG_SERVER_PASSWORD=Qwer.1234 +ENV SPRING_PROFILES_ACTIVE=native +ENV CONFIG_SEARCH_LOCATIONS=file:./config + +COPY --from=build /workspace/config/target/config-1.0.jar /app/app.jar LABEL maintainer="damon " + +EXPOSE 8900 +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/config/README.md b/config/README.md new file mode 100644 index 0000000..3f35ddd --- /dev/null +++ b/config/README.md @@ -0,0 +1,11 @@ +## Build Docker image + +```bash +docker build -t theurl.io/config -f config/Dockerfile . +``` + +## Run Docker container + +```bash +docker run -d --restart always --name linkyou-config-server -p 8900:8900 -v $(pwd)/config:/app/config theurl.io/config +``` diff --git a/config/pom.xml b/config/pom.xml index 285f24f..c82af21 100644 --- a/config/pom.xml +++ b/config/pom.xml @@ -19,16 +19,20 @@ org.springframework.cloud spring-cloud-config-server - - com.alibaba.cloud - spring-cloud-starter-alibaba-nacos-discovery - - org.springframework.boot spring-boot-starter-test test + + org.springframework.boot + spring-boot-maven-plugin + 4.0.6 + + + org.springframework.boot + spring-boot-starter-security + @@ -37,6 +41,19 @@ org.springframework.boot spring-boot-maven-plugin + + org.apache.maven.plugins + maven-jar-plugin + 3.5.0 + + + + true + io.theurl.config.ConfigApplication + + + + diff --git a/config/src/main/resources/application.yaml b/config/src/main/resources/application.yaml index cd86cbe..2d9490b 100644 --- a/config/src/main/resources/application.yaml +++ b/config/src/main/resources/application.yaml @@ -5,9 +5,13 @@ spring: application: name: config profiles: - active: native + active: ${SPRING_PROFILES_ACTIVE:native} + security: + user: + name: ${CONFIG_SERVER_USERNAME:config-user} + password: ${CONFIG_SERVER_PASSWORD:config-password} cloud: config: server: native: - search-locations: file:./config-repo/ + search-locations: ${CONFIG_SEARCH_LOCATIONS:file:./config} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..c52b171 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,142 @@ +version: "1.0" + +services: + theurl-config-server: + build: -t theurl/config-server -f /config/Dockerfile . + ports: + - "8900" + environment: + - SPRING_PROFILES_ACTIVE=navive + - CONFIG_SERVER_USERNAME=theurl + - CONFIG_SERVER_PASSWORD=Qwer.1234 + - CONFIG_SEARCH_LOCATIONS=file:./config + volumes: + - theurl-config-data:/app/config + + theurl-identity-service: + build: -t theurl/identity-service -f /identity/Dockerfile . + ports: + - "8901:8901" + environment: + - CONFIG_SERVER_URI=http://theurl-config-server:8900 + - CONFIG_SERVER_USERNAME=theurl + - CONFIG_SERVER_PASSWORD=Qwer.1234 + - DB_URL=jdbc:postgresql://theurl-db:5432/linkyou?currentSchema=public + - DB_USERNAME=postgres + - DB_PASSWORD=postgres + - DB_DRIVER=org.postgresql.Driver + - DB_DIALECT=org.hibernate.dialect.PostgreSQLDialect + - REDIS_URL=redis://theurl-redis:6379 + - MONGO_URI=mongodb://theurl-mongo:27017/linkyou + - RABBITMQ_HOST=theurl-rabbitmq + - RABBITMQ_PORT=5672 + - RABBITMQ_USERNAME=guest + - RABBITMQ_PASSWORD=guest + - JWT_SECRET=GImXbOaM2RNkzHhJ7/q3NpYMa1j/xqlahPg9KwX99qI= + - EXTERNAL_AUTH_REDIRECTURI=https://theurl.io/auth/callback + - GOOGLE_CLIENT_ID=your-google-client-id + - GOOGLE_CLIENT_SECRET=your-google-client-secret + - GITHUB_CLIENT_ID=your-github-client-id + - GITHUB_CLIENT_SECRET=your-github-client-secret + - FACEBOOK_CLIENT_ID=your-facebook-client-id + - FACEBOOK_CLIENT_SECRET=your-facebook-client-secret + - MICROSOFT_CLIENT_ID=your-microsoft-client-id + - MICROSOFT_CLIENT_SECRET=your-microsoft-client-secret + depends_on: + - theurl-db + - theurl-redis + - theurl-rabbitmq + + theurl-message-service: + build: -t theurl/message-service -f /message/Dockerfile . + ports: + - "8902:8902" + environment: + - CONFIG_SERVER_URI=http://theurl-config-server:8900 + - CONFIG_SERVER_USERNAME=theurl + - CONFIG_SERVER_PASSWORD=Qwer.1234 + - DB_URL=jdbc:postgresql://theurl-db:5432/linkyou?currentSchema=public + - DB_USERNAME=postgres + - DB_PASSWORD=postgres + - DB_DRIVER=org.postgresql.Driver + - DB_DIALECT=org.hibernate.dialect.PostgreSQLDialect + - REDIS_URL=redis://theurl-redis:6379 + - MONGO_URI=mongodb://theurl-mongo:27017/linkyou + - RABBITMQ_HOST=theurl-rabbitmq + - RABBITMQ_PORT=5672 + - RABBITMQ_USERNAME=guest + - RABBITMQ_PASSWORD=guest + depends_on: + - theurl-db + - theurl-redis + - theurl-rabbitmq + + theurl-bundle-service: + build: -t theurl/bundle-service -f /bundle/Dockerfile . + ports: + - "8903:8903" + environment: + - CONFIG_SERVER_URI=http://theurl-config-server:8900 + - CONFIG_SERVER_USERNAME=theurl + - CONFIG_SERVER_PASSWORD=Qwer.1234 + - DB_URL=jdbc:postgresql://theurl-db:5432/linkyou?currentSchema=public + - DB_USERNAME=postgres + - DB_PASSWORD=postgres + - DB_DRIVER=org.postgresql.Driver + - DB_DIALECT=org.hibernate.dialect.PostgreSQLDialect + - REDIS_URL=redis://theurl-redis:6379 + - MONGO_URI=mongodb://theurl-mongo:27017/linkyou + - RABBITMQ_HOST=theurl-rabbitmq + - RABBITMQ_PORT=5672 + - RABBITMQ_USERNAME=guest + - RABBITMQ_PASSWORD=guest + depends_on: + - theurl-db + - theurl-redis + - theurl-rabbitmq + + theurl-db: + image: postgres:latest + environment: + - POSTGRES_DB=linkyou + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + ports: + - "5432" + + theurl-redis: + image: redis:latest + ports: + - "6379" + + theurl-mongo: + image: mongo:latest + ports: + - "27017" + + theurl-rabbitmq: + image: rabbitmq:management + ports: + - "15671" + - "15672" + - "15691" + - "15692" + - "25672" + - "4369" + - "5671" + - "5672" + +networks: + theurl-network: + driver: bridge + +volumes: + theurl-db-data: + theurl-redis-data: + theurl-mongo-data: + theurl-config-data: + driver: local + driver_opts: + type: none + device: ./theurl/config + o: bind diff --git a/framework/pom.xml b/framework/pom.xml index f8ddb63..ac6df7e 100644 --- a/framework/pom.xml +++ b/framework/pom.xml @@ -22,6 +22,22 @@ mediator ${neroyun.mediator.version} + + io.jsonwebtoken + jjwt-api + 0.13.0 + + + io.jsonwebtoken + jjwt-impl + 0.13.0 + + + io.jsonwebtoken + jjwt-jackson + 0.13.0 + runtime + org.springframework spring-core @@ -46,6 +62,10 @@ org.springframework.boot spring-boot + + org.springframework.boot + spring-boot-security + org.apache.tomcat.embed tomcat-embed-core diff --git a/framework/src/main/java/io/theurl/framework/configure/GlobalExceptionHandler.java b/framework/src/main/java/io/theurl/framework/configure/GlobalExceptionHandler.java index 47db6e6..d1b277f 100644 --- a/framework/src/main/java/io/theurl/framework/configure/GlobalExceptionHandler.java +++ b/framework/src/main/java/io/theurl/framework/configure/GlobalExceptionHandler.java @@ -83,10 +83,11 @@ public ResponseEntity> handleCompletionException(CompletionE } private ResponseEntity> handleGeneralException(Throwable exception) { - if (exception == null) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); - } + return switch (exception) { + case null -> ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + case CompletionException completionException -> handleGeneralException(completionException.getCause()); + case AggregateException aggregateException -> handleGeneralException(aggregateException.getCause()); case AccountException accountException -> handleAccountException(accountException); case EntityNotFoundException entityNotFoundException -> handleEntityNotFoundException(entityNotFoundException); diff --git a/framework/src/main/java/io/theurl/framework/core/PriorityValueFinder.java b/framework/src/main/java/io/theurl/framework/core/PriorityValueFinder.java index e1268a3..1f61400 100644 --- a/framework/src/main/java/io/theurl/framework/core/PriorityValueFinder.java +++ b/framework/src/main/java/io/theurl/framework/core/PriorityValueFinder.java @@ -1,7 +1,9 @@ package io.theurl.framework.core; import java.util.PriorityQueue; +import java.util.function.Consumer; import java.util.function.Predicate; +import java.util.function.Supplier; /** * Utility class for finding a value in a priority queue based on a filter predicate. @@ -22,7 +24,7 @@ public class PriorityValueFinder { */ public static T find(PriorityQueue values, Predicate filter, T defaultValue) { - if (values == null || values.isEmpty()) { + if (values == null) { throw new IllegalArgumentException("Values queue cannot be null or empty"); } @@ -30,6 +32,10 @@ public static T find(PriorityQueue values, Predicate filter, T default throw new IllegalArgumentException("Filter queue cannot be null"); } + if (values.isEmpty()) { + return defaultValue; + } + while (!values.isEmpty()) { T value = values.poll(); if (filter.test(value)) { @@ -38,4 +44,19 @@ public static T find(PriorityQueue values, Predicate filter, T default } return defaultValue; } + + public static T find(Supplier> supplier, Predicate filter, T defaultValue) { + if (supplier == null) { + throw new IllegalArgumentException("Supplier cannot be null"); + } + + PriorityQueue values = supplier.get(); + return find(values, filter, defaultValue); + } + + public static T find(Consumer> queueConsumer, Predicate filter, T defaultValue) { + var queue = new PriorityQueue(); + queueConsumer.accept(queue); + return find(queue, filter, defaultValue); + } } diff --git a/framework/src/main/java/io/theurl/framework/domain/AggregateRoot.java b/framework/src/main/java/io/theurl/framework/domain/AggregateRoot.java index e7a5f03..cb9ceaa 100644 --- a/framework/src/main/java/io/theurl/framework/domain/AggregateRoot.java +++ b/framework/src/main/java/io/theurl/framework/domain/AggregateRoot.java @@ -1,5 +1,7 @@ package io.theurl.framework.domain; +import java.beans.PropertyChangeListener; +import java.beans.PropertyChangeSupport; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -12,6 +14,8 @@ public class AggregateRoot> extends Entity impleme private final Map, Consumer> eventHandlers = new HashMap<>(); + protected final PropertyChangeSupport support = new PropertyChangeSupport(this); + /** * Initializes the aggregate with the given id. * @@ -27,6 +31,10 @@ public List getEvents() { return List.copyOf(events); } + public boolean hasEvents() { + return events != null && !events.isEmpty(); + } + @Override public void clearEvents() { events.clear(); @@ -56,4 +64,22 @@ public void attachEvent() { event.attach(this); } } + + /** + * Adds a property change listener to the aggregate root. + * + * @param listener the listener to be added + */ + public void addPropertyChangeListener(PropertyChangeListener listener) { + support.addPropertyChangeListener(listener); + } + + /** + * Removes a property change listener from the aggregate root. + * + * @param listener the listener to be removed + */ + public void removePropertyChangeListener(PropertyChangeListener listener) { + support.removePropertyChangeListener(listener); + } } diff --git a/identity/src/main/java/io/theurl/identity/configure/JwtAuthenticationFilter.java b/framework/src/main/java/io/theurl/framework/security/JwtAuthenticationFilter.java similarity index 92% rename from identity/src/main/java/io/theurl/identity/configure/JwtAuthenticationFilter.java rename to framework/src/main/java/io/theurl/framework/security/JwtAuthenticationFilter.java index bdc8a66..f37e9b8 100644 --- a/identity/src/main/java/io/theurl/identity/configure/JwtAuthenticationFilter.java +++ b/framework/src/main/java/io/theurl/framework/security/JwtAuthenticationFilter.java @@ -1,4 +1,4 @@ -package io.theurl.identity.configure; +package io.theurl.framework.security; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; @@ -6,8 +6,9 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -26,10 +27,11 @@ * If the token is valid, it extracts the user ID from the token claims and creates an authentication object, which is then set in the SecurityContext for downstream processing. * If token parsing fails, it logs the error and allows the request to proceed without authentication, which will be handled by subsequent security filters. */ -@Slf4j @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationFilter.class); + @Value("${jwt.secret}") private String signingKey; @@ -58,7 +60,7 @@ protected void doFilterInternal(HttpServletRequest request, } } catch (Exception e) { // Don't throw an exception when token parsing fails, let the subsequent authentication process handle it and return 401. - log.debug("JWT parse failed: {}", e.getMessage()); + LOGGER.debug("JWT parse failed: {}", e.getMessage()); } } diff --git a/framework/src/main/java/io/theurl/framework/utility/RandomUtility.java b/framework/src/main/java/io/theurl/framework/utility/RandomUtility.java index 3560d65..25d51c6 100644 --- a/framework/src/main/java/io/theurl/framework/utility/RandomUtility.java +++ b/framework/src/main/java/io/theurl/framework/utility/RandomUtility.java @@ -4,18 +4,80 @@ public class RandomUtility { private static final java.util.Random random = new java.util.Random(); + private static final String ALPHA_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + private static final String NUMERIC_CHARS = "0123456789"; + private static final String MIXED_CHARS = ALPHA_CHARS + NUMERIC_CHARS; + /** - * Generates a random alphanumeric string of the specified length. + * Generates a random string of the specified length using the provided characters. * - * @param length The length of the random string to generate. - * @return A random alphanumeric string of the specified length. + * @param length The length of the random string to generate. + * @param characters The characters to use for generating the random string. + * @return A random string of the specified length using the provided characters. + * @throws IllegalArgumentException if the length is less than or equal to 0 or if the characters string is shorter than the specified length. */ - public static String randomString(int length) { - String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + public static String randomString(int length, String characters) { + + if (length <= 0) { + throw new IllegalArgumentException("Length must be greater than 0"); + } + + if (characters.length() < length) { + throw new IllegalArgumentException("The length of the characters must be at least " + length); + } + StringBuilder sb = new StringBuilder(); for (int i = 0; i < length; i++) { - sb.append(chars.charAt(random.nextInt(chars.length()))); + sb.append(characters.charAt(random.nextInt(characters.length()))); } return sb.toString(); } + + /** + * Generates a random alphanumeric string of the specified length. + * + * @param length The length of the random string to generate. + * @return A random alphanumeric string of the specified length. + */ + public static String randomString(int length) { + return randomString(length, MIXED_CHARS); + } + + /** + * Generates a random string of the specified length and mode. + * + * @param length The length of the random string to generate. + * @param mode The mode of the random string (ALPHA, NUMERIC, or MIXED). + * @return A random string of the specified length and mode. + */ + public static String randomString(int length, RandomUtility.Mode mode) { + + String characters = switch (mode) { + case ALPHA -> ALPHA_CHARS; + case NUMERIC -> NUMERIC_CHARS; + case MIXED -> MIXED_CHARS; + }; + + return randomString(length, characters); + } + + /** + * Enumeration representing the mode of random string generation. + */ + public enum Mode { + /** + * Generates a random string consisting of alphabetic characters (both uppercase and lowercase). + */ + ALPHA, + + /** + * Generates a random string consisting of numeric characters (digits 0-9). + */ + NUMERIC, + + /** + * Generates a random string consisting of both alphabetic and numeric characters. + */ + MIXED + } } diff --git a/identity/pom.xml b/identity/pom.xml index 451c7a5..5689fbb 100644 --- a/identity/pom.xml +++ b/identity/pom.xml @@ -24,38 +24,23 @@ compile - com.neroyun - mediator - ${neroyun.mediator.version} - - - io.jsonwebtoken - jjwt-api - 0.13.0 - - - io.jsonwebtoken - jjwt-impl - 0.13.0 + io.theurl + shared + ${project.version} + compile - io.jsonwebtoken - jjwt-jackson - 0.13.0 - runtime + org.springframework.boot + spring-boot-starter-amqp - - - - org.springframework.boot spring-boot-starter-data-jpa - - - - + + + + org.springframework.boot spring-boot-starter-web @@ -89,11 +74,11 @@ lombok true - - - - - + + + + + org.springframework.boot spring-boot-starter-data-jpa-test @@ -104,11 +89,11 @@ spring-boot-starter-test test - - - - - + + + + + org.modelmapper modelmapper diff --git a/identity/src/main/java/io/theurl/identity/application/command/AuthlogCreateCommand.java b/identity/src/main/java/io/theurl/identity/application/command/AuthlogCreateCommand.java index 3df3c09..284ed56 100644 --- a/identity/src/main/java/io/theurl/identity/application/command/AuthlogCreateCommand.java +++ b/identity/src/main/java/io/theurl/identity/application/command/AuthlogCreateCommand.java @@ -5,8 +5,12 @@ import java.time.LocalDateTime; +/** + * Command to create an authentication log entry, capturing details of an authentication attempt. + * This command is typically used after an authentication attempt (successful or failed) to record the event in the system for auditing and monitoring purposes. + */ @Data -public final class AuthlogCreateCommand implements Command { +public class AuthlogCreateCommand implements Command { private Long userId; private String username; private String grantType; diff --git a/identity/src/main/java/io/theurl/identity/application/command/OnetimePasswordCreateCommand.java b/identity/src/main/java/io/theurl/identity/application/command/OnetimePasswordCreateCommand.java new file mode 100644 index 0000000..69de824 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/command/OnetimePasswordCreateCommand.java @@ -0,0 +1,20 @@ +package io.theurl.identity.application.command; + +import com.neroyun.mediator.Command; +import lombok.Data; + +@Data +public class OnetimePasswordCreateCommand implements Command { + private final String requestId; + private final String recipient; + private final String code; + + private String usage; + private Integer duration; + + public OnetimePasswordCreateCommand(String requestId, String recipient, String code) { + this.requestId = requestId; + this.recipient = recipient; + this.code = code; + } +} diff --git a/identity/src/main/java/io/theurl/identity/application/command/TokenRevokeCommand.java b/identity/src/main/java/io/theurl/identity/application/command/TokenRevokeCommand.java index b4238ce..305cf88 100644 --- a/identity/src/main/java/io/theurl/identity/application/command/TokenRevokeCommand.java +++ b/identity/src/main/java/io/theurl/identity/application/command/TokenRevokeCommand.java @@ -1,6 +1,7 @@ package io.theurl.identity.application.command; import com.neroyun.mediator.Command; +import io.theurl.identity.domain.enums.TokenStatus; -public record TokenRevokeCommand(String jti, String reason) implements Command { +public record TokenRevokeCommand(String jti, TokenStatus status) implements Command { } diff --git a/identity/src/main/java/io/theurl/identity/application/command/UserAccessFailureCountCommand.java b/identity/src/main/java/io/theurl/identity/application/command/UserAccessFailureCountCommand.java index 15f17a5..73b7007 100644 --- a/identity/src/main/java/io/theurl/identity/application/command/UserAccessFailureCountCommand.java +++ b/identity/src/main/java/io/theurl/identity/application/command/UserAccessFailureCountCommand.java @@ -8,5 +8,4 @@ * The command contains the user ID and the new failure count to be set. */ public record UserAccessFailureCountCommand(Long userId, String action) implements Command { - } diff --git a/identity/src/main/java/io/theurl/identity/application/command/UserCreateCommand.java b/identity/src/main/java/io/theurl/identity/application/command/UserCreateCommand.java index ea6b9aa..d48761a 100644 --- a/identity/src/main/java/io/theurl/identity/application/command/UserCreateCommand.java +++ b/identity/src/main/java/io/theurl/identity/application/command/UserCreateCommand.java @@ -4,7 +4,7 @@ import lombok.Data; @Data -public class UserCreateCommand implements Command { +public final class UserCreateCommand implements Command { private String username; private String password; private String nickname; diff --git a/identity/src/main/java/io/theurl/identity/application/command/UserUpdateCommand.java b/identity/src/main/java/io/theurl/identity/application/command/UserUpdateCommand.java index d65aa82..e6783a3 100644 --- a/identity/src/main/java/io/theurl/identity/application/command/UserUpdateCommand.java +++ b/identity/src/main/java/io/theurl/identity/application/command/UserUpdateCommand.java @@ -6,7 +6,7 @@ import java.util.HashMap; import java.util.Map; -public class UserUpdateCommand implements Command { +public final class UserUpdateCommand implements Command { @Getter private final Long id; @@ -16,6 +16,4 @@ public class UserUpdateCommand implements Command { public UserUpdateCommand(Long id) { this.id = id; } - - } diff --git a/identity/src/main/java/io/theurl/identity/application/contract/AuthApplicationService.java b/identity/src/main/java/io/theurl/identity/application/contract/AuthApplicationService.java index 562a723..4dcaabc 100644 --- a/identity/src/main/java/io/theurl/identity/application/contract/AuthApplicationService.java +++ b/identity/src/main/java/io/theurl/identity/application/contract/AuthApplicationService.java @@ -5,6 +5,23 @@ import java.util.concurrent.CompletableFuture; +/** + * AuthApplicationService defines the contract for authentication-related operations in the application layer. It provides a method to handle token granting requests, which typically involve validating user credentials and issuing access tokens for authenticated users. + */ public interface AuthApplicationService { + /** + * Handle a token granting request asynchronously. + * + * @param request The token grant request data. + * @return A CompletableFuture containing the token grant response data. + */ CompletableFuture grant(TokenGrantRequestDto request); + + /** + * Revoke a token asynchronously. + * + * @param jti The token identifier to revoke. + * @return A CompletableFuture representing the asynchronous operation. + */ + CompletableFuture revoke(String jti); } diff --git a/identity/src/main/java/io/theurl/identity/application/contract/OnetimePasswordApplicationService.java b/identity/src/main/java/io/theurl/identity/application/contract/OnetimePasswordApplicationService.java new file mode 100644 index 0000000..bc03860 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/contract/OnetimePasswordApplicationService.java @@ -0,0 +1,20 @@ +package io.theurl.identity.application.contract; + +import io.theurl.framework.application.ApplicationService; + +import java.util.concurrent.CompletableFuture; + +/** + * Application service for handling one-time password (OTP) related operations, such as sending OTPs to users for various usages (e.g., authentication, password reset). + */ +public interface OnetimePasswordApplicationService extends ApplicationService { + + /** + * Send a one-time password to the specified recipient for the given usage. + * + * @param recipient the recipient to whom the one-time password should be sent (e.g., email address, phone number) + * @param usage the intended usage of the one-time password (e.g., "authentication", "password_reset") + * @return a CompletableFuture that will complete with the sent OTP + */ + CompletableFuture sendAsync(String recipient, String usage); +} diff --git a/identity/src/main/java/io/theurl/identity/application/contract/UserApplicationService.java b/identity/src/main/java/io/theurl/identity/application/contract/UserApplicationService.java index 6a9f57e..1088e67 100644 --- a/identity/src/main/java/io/theurl/identity/application/contract/UserApplicationService.java +++ b/identity/src/main/java/io/theurl/identity/application/contract/UserApplicationService.java @@ -3,9 +3,15 @@ import io.theurl.framework.application.ApplicationService; import io.theurl.identity.application.dto.UserCreateRequestDto; import io.theurl.identity.application.dto.UserProfileResponseDto; +import io.theurl.identity.application.dto.UserUpdateRequestDto; import java.util.concurrent.CompletableFuture; +/** + * UserApplicationService defines the contract for user-related operations in the application layer. + * It provides asynchronous methods for creating users, retrieving user profiles, and updating user information such as password, email, phone number, and nickname. + * Additionally, it includes methods for connecting and removing authorities associated with the user. + */ public interface UserApplicationService extends ApplicationService { /** @@ -35,18 +41,18 @@ public interface UserApplicationService extends ApplicationService { /** * Change the email of the currently authenticated user asynchronously. * - * @param email The new email to be set for the user. + * @param data The user update request data containing the new email to be set for the user. * @return A CompletableFuture representing the asynchronous operation. */ - CompletableFuture changeEmailAsync(String email); + CompletableFuture changeEmailAsync(UserUpdateRequestDto data); /** * Change the phone number of the currently authenticated user asynchronously. * - * @param phone The new phone number to be set for the user. + * @param data The user update request data containing the new phone number to be set for the user. * @return A CompletableFuture representing the asynchronous operation. */ - CompletableFuture changePhoneAsync(String phone); + CompletableFuture changePhoneAsync(UserUpdateRequestDto data); /** * Change the nickname of the currently authenticated user asynchronously. @@ -56,7 +62,21 @@ public interface UserApplicationService extends ApplicationService { */ CompletableFuture changeNicknameAsync(String nickname); + /** + * Connect an authority to the currently authenticated user asynchronously. + * + * @param provider The authority provider to connect. + * @param code The authorization code for the authority. + * @return A CompletableFuture representing the asynchronous operation. + */ CompletableFuture connectAuthorityAsync(String provider, String code); + /** + * Remove an authority from the currently authenticated user asynchronously. + * + * @param provider The authority provider to remove. + * @param openId The open ID of the authority to remove. + * @return A CompletableFuture representing the asynchronous operation. + */ CompletableFuture removeAuthorityAsync(String provider, String openId); } diff --git a/identity/src/main/java/io/theurl/identity/application/dto/OnetimePasswordSendRequestDto.java b/identity/src/main/java/io/theurl/identity/application/dto/OnetimePasswordSendRequestDto.java new file mode 100644 index 0000000..2c00393 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/dto/OnetimePasswordSendRequestDto.java @@ -0,0 +1,4 @@ +package io.theurl.identity.application.dto; + +public record OnetimePasswordSendRequestDto(String recipient) { +} diff --git a/identity/src/main/java/io/theurl/identity/application/dto/UserUpdateRequestDto.java b/identity/src/main/java/io/theurl/identity/application/dto/UserUpdateRequestDto.java index 5cb698f..3c46568 100644 --- a/identity/src/main/java/io/theurl/identity/application/dto/UserUpdateRequestDto.java +++ b/identity/src/main/java/io/theurl/identity/application/dto/UserUpdateRequestDto.java @@ -7,4 +7,6 @@ public class UserUpdateRequestDto { private String email; private String phone; private String nickname; + private String code; + private String requestId; } diff --git a/identity/src/main/java/io/theurl/identity/application/handler/AuthlogCreateCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/AuthlogCreateCommandHandler.java index 6c9e733..d3831ef 100644 --- a/identity/src/main/java/io/theurl/identity/application/handler/AuthlogCreateCommandHandler.java +++ b/identity/src/main/java/io/theurl/identity/application/handler/AuthlogCreateCommandHandler.java @@ -6,16 +6,17 @@ import io.theurl.identity.domain.repository.AuthlogRepository; import io.theurl.identity.domain.aggregate.Authlog; import org.modelmapper.ModelMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.stereotype.Component; import java.util.concurrent.CompletableFuture; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(BeanScope.PROTOTYPE) public class AuthlogCreateCommandHandler implements Handler { - + private static final Logger LOGGER = LoggerFactory.getLogger(AuthlogCreateCommandHandler.class); private final AuthlogRepository repository; private final ModelMapper mapper; @@ -26,10 +27,14 @@ public AuthlogCreateCommandHandler(AuthlogRepository repository, ModelMapper map @Override public CompletableFuture handleAsync(AuthlogCreateCommand message) { - - var authlog = Authlog.create(message.getRequestId(), message.getUsername(), message.isSuccess()); - mapper.map(message, authlog); - repository.save(authlog); - return CompletableFuture.completedFuture(null); + try { + var authlog = Authlog.create(message.getRequestId(), message.getUsername(), message.isSuccess()); + mapper.map(message, authlog); + repository.save(authlog); + return CompletableFuture.completedFuture(null); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + throw exception; + } } } diff --git a/identity/src/main/java/io/theurl/identity/application/handler/OnetimePasswordCreateCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/OnetimePasswordCreateCommandHandler.java new file mode 100644 index 0000000..2e8f917 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/handler/OnetimePasswordCreateCommandHandler.java @@ -0,0 +1,36 @@ +package io.theurl.identity.application.handler; + +import com.neroyun.mediator.Event; +import com.neroyun.mediator.Handler; +import com.neroyun.mediator.Mediator; +import io.theurl.framework.core.BeanScope; +import io.theurl.identity.application.command.OnetimePasswordCreateCommand; +import io.theurl.identity.domain.aggregate.OnetimePassword; +import io.theurl.identity.domain.repository.OnetimePasswordRepository; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +import java.util.concurrent.CompletableFuture; + +@Component +@Scope(BeanScope.PROTOTYPE) +public class OnetimePasswordCreateCommandHandler implements Handler { + private final OnetimePasswordRepository repository; + private final Mediator mediator; + + public OnetimePasswordCreateCommandHandler(OnetimePasswordRepository repository, Mediator mediator) { + this.repository = repository; + this.mediator = mediator; + } + + @Override + public CompletableFuture handleAsync(OnetimePasswordCreateCommand message) { + var aggregate = OnetimePassword.create(message.getRequestId(), message.getRecipient(), message.getCode(), message.getDuration()); + aggregate.setUsage(message.getUsage()); + repository.save(aggregate); + if (aggregate.hasEvents()) { + aggregate.getEvents().parallelStream().forEach(event -> mediator.publishAsync((Event) event)); + } + return CompletableFuture.completedFuture(null); + } +} diff --git a/identity/src/main/java/io/theurl/identity/application/handler/TokenCreateCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/TokenCreateCommandHandler.java index 5d89019..19e121d 100644 --- a/identity/src/main/java/io/theurl/identity/application/handler/TokenCreateCommandHandler.java +++ b/identity/src/main/java/io/theurl/identity/application/handler/TokenCreateCommandHandler.java @@ -5,26 +5,38 @@ import io.theurl.identity.application.command.TokenCreateCommand; import io.theurl.identity.domain.aggregate.Token; import io.theurl.identity.domain.repository.TokenRepository; -import jakarta.annotation.Resource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.stereotype.Component; import java.util.concurrent.CompletableFuture; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(BeanScope.PROTOTYPE) public class TokenCreateCommandHandler implements Handler { - @Resource - private TokenRepository tokenRepository; + private static final Logger LOGGER = LoggerFactory.getLogger(TokenCreateCommandHandler.class); + + private final TokenRepository repository; + + public TokenCreateCommandHandler(TokenRepository repository) { + this.repository = repository; + } @Override public CompletableFuture handleAsync(TokenCreateCommand message) { - var token = Token.create(message.getJti(), message.getContent(), message.getSubject()); - token.setExpiresAt(message.getExpiresAt()); - token.setIssuedAt(message.getIssuedAt()); - tokenRepository.save(token); - return CompletableFuture.completedFuture(null); + try { + + + var token = Token.create(message.getJti(), message.getContent(), message.getSubject()); + token.setExpiresAt(message.getExpiresAt()); + token.setIssuedAt(message.getIssuedAt()); + repository.save(token); + return CompletableFuture.completedFuture(null); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + throw exception; + } } } diff --git a/identity/src/main/java/io/theurl/identity/application/handler/TokenRevokeCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/TokenRevokeCommandHandler.java index 5b5351c..e1d0c66 100644 --- a/identity/src/main/java/io/theurl/identity/application/handler/TokenRevokeCommandHandler.java +++ b/identity/src/main/java/io/theurl/identity/application/handler/TokenRevokeCommandHandler.java @@ -3,17 +3,19 @@ import com.neroyun.mediator.Handler; import io.theurl.framework.core.BeanScope; import io.theurl.identity.application.command.TokenRevokeCommand; -import io.theurl.identity.domain.enums.TokenStatus; import io.theurl.identity.domain.repository.TokenRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.stereotype.Component; import java.util.concurrent.CompletableFuture; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(BeanScope.PROTOTYPE) public class TokenRevokeCommandHandler implements Handler { + private static final Logger LOGGER = LoggerFactory.getLogger(TokenRevokeCommandHandler.class); + private final TokenRepository repository; public TokenRevokeCommandHandler(TokenRepository repository) { @@ -22,11 +24,16 @@ public TokenRevokeCommandHandler(TokenRepository repository) { @Override public CompletableFuture handleAsync(TokenRevokeCommand message) { - var token = repository.findByJti(message.jti()); - if (token != null) { - token.revoke(TokenStatus.valueOf(message.reason().toLowerCase())); - repository.save(token); + try { + var token = repository.findByJti(message.jti()); + if (token != null) { + token.revoke(message.status()); + repository.save(token); + } + return CompletableFuture.completedFuture(null); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + throw exception; } - return CompletableFuture.completedFuture(null); } } diff --git a/identity/src/main/java/io/theurl/identity/application/handler/UserAccessFailureCountCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/UserAccessFailureCountCommandHandler.java index 21ad01e..b198bf8 100644 --- a/identity/src/main/java/io/theurl/identity/application/handler/UserAccessFailureCountCommandHandler.java +++ b/identity/src/main/java/io/theurl/identity/application/handler/UserAccessFailureCountCommandHandler.java @@ -4,17 +4,17 @@ import io.theurl.framework.core.BeanScope; import io.theurl.identity.application.command.UserAccessFailureCountCommand; import io.theurl.identity.domain.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.stereotype.Component; -import org.springframework.web.context.WebApplicationContext; import java.util.concurrent.CompletableFuture; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(BeanScope.PROTOTYPE) public class UserAccessFailureCountCommandHandler implements Handler { - + private static final Logger LOGGER = LoggerFactory.getLogger(UserAccessFailureCountCommandHandler.class); private final UserRepository repository; public UserAccessFailureCountCommandHandler(UserRepository repository) { @@ -23,17 +23,22 @@ public UserAccessFailureCountCommandHandler(UserRepository repository) { @Override public CompletableFuture handleAsync(UserAccessFailureCountCommand message) { - var user = repository.findById(message.userId()); - if (user == null) { + try { + var user = repository.findById(message.userId()); + if (user == null) { + return CompletableFuture.completedFuture(null); + } + + switch (message.action()) { + case "increase" -> user.increaseAccessFailedCount(); + case "reset" -> user.resetAccessFailedCount(); + } + + repository.save(user); return CompletableFuture.completedFuture(null); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + throw exception; } - - switch (message.action()) { - case "increase" -> user.increaseAccessFailedCount(); - case "reset" -> user.resetAccessFailedCount(); - } - - repository.save(user); - return CompletableFuture.completedFuture(null); } } diff --git a/identity/src/main/java/io/theurl/identity/application/handler/UserAuthorityCreateCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/UserAuthorityCreateCommandHandler.java index fe82790..3de988a 100644 --- a/identity/src/main/java/io/theurl/identity/application/handler/UserAuthorityCreateCommandHandler.java +++ b/identity/src/main/java/io/theurl/identity/application/handler/UserAuthorityCreateCommandHandler.java @@ -5,16 +5,17 @@ import io.theurl.identity.application.command.UserAuthorityCreateCommand; import io.theurl.identity.domain.repository.UserRepository; import jakarta.persistence.EntityNotFoundException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.stereotype.Component; import java.util.concurrent.CompletableFuture; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(BeanScope.PROTOTYPE) public class UserAuthorityCreateCommandHandler implements Handler { - + private static final Logger LOGGER = LoggerFactory.getLogger(UserAuthorityCreateCommandHandler.class); private final UserRepository repository; public UserAuthorityCreateCommandHandler(UserRepository repository) { @@ -23,13 +24,18 @@ public UserAuthorityCreateCommandHandler(UserRepository repository) { @Override public CompletableFuture handleAsync(UserAuthorityCreateCommand message) { - var user = repository.findById(message.id()); - if (user == null) { - throw new EntityNotFoundException("User not found"); - } + try { + var user = repository.findById(message.id()); + if (user == null) { + throw new EntityNotFoundException("User not found"); + } - user.addAuthority(message.provider(), message.openId(), message.name()); - repository.save(user); - return CompletableFuture.completedFuture(null); + user.addAuthority(message.provider(), message.openId(), message.name()); + repository.save(user); + return CompletableFuture.completedFuture(null); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + throw exception; + } } } diff --git a/identity/src/main/java/io/theurl/identity/application/handler/UserAuthorityRemoveCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/UserAuthorityRemoveCommandHandler.java index 90e6af6..26e4e16 100644 --- a/identity/src/main/java/io/theurl/identity/application/handler/UserAuthorityRemoveCommandHandler.java +++ b/identity/src/main/java/io/theurl/identity/application/handler/UserAuthorityRemoveCommandHandler.java @@ -5,15 +5,17 @@ import io.theurl.identity.application.command.UserAuthorityRemoveCommand; import io.theurl.identity.domain.repository.UserRepository; import jakarta.persistence.EntityNotFoundException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.stereotype.Component; import java.util.concurrent.CompletableFuture; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(BeanScope.PROTOTYPE) public class UserAuthorityRemoveCommandHandler implements Handler { + private static final Logger LOGGER = LoggerFactory.getLogger(UserAuthorityRemoveCommandHandler.class); private final UserRepository repository; public UserAuthorityRemoveCommandHandler(UserRepository repository) { @@ -22,13 +24,18 @@ public UserAuthorityRemoveCommandHandler(UserRepository repository) { @Override public CompletableFuture handleAsync(UserAuthorityRemoveCommand message) { - var user = repository.findById(message.id()); - if (user == null) { - throw new EntityNotFoundException("User not found"); - } + try { + var user = repository.findById(message.id()); + if (user == null) { + throw new EntityNotFoundException("User not found"); + } - user.removeAuthority(message.provider(), message.openId()); - repository.save(user); - return CompletableFuture.completedFuture(null); + user.removeAuthority(message.provider(), message.openId()); + repository.save(user); + return CompletableFuture.completedFuture(null); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + throw exception; + } } } diff --git a/identity/src/main/java/io/theurl/identity/application/handler/UserCreateCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/UserCreateCommandHandler.java index ffb9fb8..cca7ba9 100644 --- a/identity/src/main/java/io/theurl/identity/application/handler/UserCreateCommandHandler.java +++ b/identity/src/main/java/io/theurl/identity/application/handler/UserCreateCommandHandler.java @@ -5,18 +5,19 @@ import io.theurl.identity.application.command.UserCreateCommand; import io.theurl.identity.domain.aggregate.User; import io.theurl.identity.domain.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.context.WebApplicationContext; import java.util.concurrent.CompletableFuture; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(BeanScope.PROTOTYPE) public class UserCreateCommandHandler implements Handler { + private static final Logger LOGGER = LoggerFactory.getLogger(UserCreateCommandHandler.class); private final UserRepository repository; public UserCreateCommandHandler(UserRepository repository) { @@ -27,15 +28,20 @@ public UserCreateCommandHandler(UserRepository repository) { @Transactional @Override public CompletableFuture handleAsync(UserCreateCommand message) { - var exists = repository.findByAnyOf(message.getUsername(), message.getEmail(), message.getPhone()); + try { + var exists = repository.findByAnyOf(message.getUsername(), message.getEmail(), message.getPhone()); - if (exists != null) { - throw new IllegalArgumentException("User with the same username, email or phone already exists."); - } + if (exists != null) { + throw new IllegalArgumentException("User with the same username, email or phone already exists."); + } - var user = User.create(message.getUsername(), message.getNickname(), message.getEmail(), message.getPhone()); - user.setPassword(message.getPassword(), "init"); - repository.save(user); - return CompletableFuture.completedFuture(null); + var user = User.create(message.getUsername(), message.getNickname(), message.getEmail(), message.getPhone()); + user.setPassword(message.getPassword(), "init"); + repository.save(user); + return CompletableFuture.completedFuture(null); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + throw exception; + } } } diff --git a/identity/src/main/java/io/theurl/identity/application/handler/UserPasswordChangeCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/UserPasswordChangeCommandHandler.java index 8571769..2967fd7 100644 --- a/identity/src/main/java/io/theurl/identity/application/handler/UserPasswordChangeCommandHandler.java +++ b/identity/src/main/java/io/theurl/identity/application/handler/UserPasswordChangeCommandHandler.java @@ -4,17 +4,17 @@ import io.theurl.framework.core.BeanScope; import io.theurl.identity.application.command.UserPasswordChangeCommand; import io.theurl.identity.domain.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.stereotype.Component; -import org.springframework.web.context.WebApplicationContext; import java.util.concurrent.CompletableFuture; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(BeanScope.PROTOTYPE) public class UserPasswordChangeCommandHandler implements Handler { - + private static final Logger LOGGER = LoggerFactory.getLogger(UserPasswordChangeCommandHandler.class); private final UserRepository repository; public UserPasswordChangeCommandHandler(UserRepository repository) { @@ -23,13 +23,18 @@ public UserPasswordChangeCommandHandler(UserRepository repository) { @Override public CompletableFuture handleAsync(UserPasswordChangeCommand message) { - var user = repository.findById(message.userId()); - if (user == null) { + try { + var user = repository.findById(message.userId()); + if (user == null) { + return CompletableFuture.completedFuture(null); + } + + user.setPassword(message.password(), message.changeType()); + repository.save(user); return CompletableFuture.completedFuture(null); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + throw exception; } - - user.setPassword(message.password(), message.changeType()); - repository.save(user); - return CompletableFuture.completedFuture(null); } } diff --git a/identity/src/main/java/io/theurl/identity/application/handler/UserUpdateCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/UserUpdateCommandHandler.java index 3ad2c93..4e6c5c5 100644 --- a/identity/src/main/java/io/theurl/identity/application/handler/UserUpdateCommandHandler.java +++ b/identity/src/main/java/io/theurl/identity/application/handler/UserUpdateCommandHandler.java @@ -5,15 +5,17 @@ import io.theurl.identity.application.command.UserUpdateCommand; import io.theurl.identity.domain.repository.UserRepository; import jakarta.persistence.EntityNotFoundException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.stereotype.Component; import java.util.concurrent.CompletableFuture; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(BeanScope.PROTOTYPE) public class UserUpdateCommandHandler implements Handler { + private static final Logger LOGGER = LoggerFactory.getLogger(UserUpdateCommandHandler.class); private final UserRepository repository; public UserUpdateCommandHandler(UserRepository repository) { @@ -22,21 +24,26 @@ public UserUpdateCommandHandler(UserRepository repository) { @Override public CompletableFuture handleAsync(UserUpdateCommand message) { - var user = repository.findById(message.getId()); + try { + var user = repository.findById(message.getId()); - if (user == null) { - throw new EntityNotFoundException("User with ID " + message.getId() + " not found"); - } - - message.getModifications().forEach((key, value) -> { - switch (key) { - case "email" -> user.setEmail((String) value); - case "phone" -> user.setPhone((String) value); - case "nickname" -> user.setNickname((String) value); - default -> throw new IllegalArgumentException("Unsupported modification key: " + key); + if (user == null) { + throw new EntityNotFoundException("User with ID " + message.getId() + " not found"); } - }); - repository.save(user); - return CompletableFuture.completedFuture(null); + + message.getModifications().forEach((key, value) -> { + switch (key) { + case "email" -> user.setEmail((String) value); + case "phone" -> user.setPhone((String) value); + case "nickname" -> user.setNickname((String) value); + default -> throw new IllegalArgumentException("Unsupported modification key: " + key); + } + }); + repository.save(user); + return CompletableFuture.completedFuture(null); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + throw exception; + } } } diff --git a/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java b/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java index 7f73c50..31146b5 100644 --- a/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java +++ b/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java @@ -10,11 +10,13 @@ import io.theurl.framework.utility.Cryptography; import io.theurl.framework.utility.DateTimeUtility; import io.theurl.framework.utility.RegexUtility; +import io.theurl.identity.application.command.TokenRevokeCommand; import io.theurl.identity.application.contract.AuthApplicationService; import io.theurl.identity.application.event.TokenGrantedEvent; import io.theurl.identity.application.event.TokenRefreshedEvent; import io.theurl.identity.application.event.UserAuthFailureEvent; import io.theurl.identity.application.event.UserAuthSuccessEvent; +import io.theurl.identity.domain.enums.TokenStatus; import io.theurl.identity.external.ExternalAuthProvider; import io.theurl.identity.external.ExternalAuthResult; import io.theurl.identity.application.dto.TokenGrantRequestDto; @@ -60,12 +62,7 @@ public CompletableFuture grant(TokenGrantRequestDto reque var events = new ArrayList(); try { UserAuthInfoQuery query = switch (request.grantType().toLowerCase()) { - case "password" -> { - if (request.username() == null || request.username().isEmpty()) { - throw new IllegalArgumentException("Username is required for username grant type"); - } - yield new UserAuthInfoQuery("username", request.username()); - } + case "password" -> new UserAuthInfoQuery("username", request.username()); case "email", "phone" -> // For email and phone grant types, we should check OTP or other verification methods before querying user info. checkCodeAsync(request).thenApply(_ -> new UserAuthInfoQuery(request.grantType(), request.username())).join(); @@ -164,6 +161,7 @@ public Map getDetails() { break; } }); + events.add(event); log.error("Error while processing request", e); throw new AggregateException(List.of(e)); } finally { @@ -173,6 +171,19 @@ public Map getDetails() { } } + @Override + public CompletableFuture revoke(String jti) { + var command = new TokenRevokeCommand(jti, TokenStatus.LOGOUT); + return mediator.sendAsync(command); + } + + /** + * Check the one-time password (OTP) for phone or email verification. + * The method retrieves the OTP details using the request ID and validates the OTP against the provided code, recipient, and expiration time. If any validation fails, a CredentialIncorrectException is thrown with an appropriate message. + * + * @param request The token grant request containing the OTP details. + * @return A CompletableFuture that completes when the OTP is successfully validated. + */ CompletableFuture checkCodeAsync(TokenGrantRequestDto request) { return mediator.executeAsync(new OnetimePasswordDetailQuery(request.requestId())).thenAccept(otp -> { if (otp == null) { @@ -193,6 +204,14 @@ CompletableFuture checkCodeAsync(TokenGrantRequestDto request) { }); } + /** + * Authenticate the user with an external provider based on the grant type and username (which could be an OAuth code or other identifier). + * The method retrieves the appropriate ExternalAuthProvider bean from the application context and uses it to authenticate the user asynchronously. The result is expected to contain the user ID, which can then be used to link to an internal user account. + * + * @param grantType The type of grant used for authentication (e.g., OAuth, SAML). + * @param username The username or identifier used for authentication. + * @return A CompletableFuture containing the user ID associated with the authenticated user. + */ CompletableFuture authWithExternalAsync(String grantType, String username) { var provider = applicationContext.getBean(("external-auth-provider-" + grantType).toLowerCase(), ExternalAuthProvider.class); // Here we should check if the external auth result is linked to an internal user account, and return the user ID. @@ -200,6 +219,13 @@ CompletableFuture authWithExternalAsync(String grantType, String usernam return provider.authenticateAsync(username).thenApply(ExternalAuthResult::getId); } + /** + * Refresh the token by validating the provided refresh token (jti) and returning the associated user ID if valid. + * The method checks the token's status, expiration, and existence in the database before allowing a new access token to be issued. + * + * @param jti The unique identifier for the refresh token. + * @return A CompletableFuture containing the user ID associated with the valid refresh token. + */ CompletableFuture refreshToken(String jti) { var query = new TokenDetailQuery(jti); return mediator.executeAsync(query) @@ -212,10 +238,28 @@ public Map getDetails() { } }; } + + if (TokenStatus.valueOf(tokenDetail.getStatus()) == TokenStatus.REFRESHED) { + throw new CredentialIncorrectException(tokenDetail.getSubject(), "Refresh token has been revoked."); + } + + if (tokenDetail.getIssuedAt().plusDays(30).isBefore(LocalDateTime.now())) { + throw new CredentialExpiredException(tokenDetail.getSubject(), "Invalid refresh token."); + } + return tokenDetail.getSubject(); }); } + /** + * Generate a JWT token for the authenticated user with the specified claims and signing key. + * + * @param id The unique identifier for the token. + * @param user The authenticated user's information. + * @param issuedAt The token's issuance date. + * @param expiresAt The token's expiration date. + * @return The generated JWT token as a string. + */ private String generateToken(String id, UserAuthInfo user, Date issuedAt, Date expiresAt) { Assert.notNull(user, "user cannot be null"); //var signingKey = environment.getProperty("JwtAuthenticationOptions.SigningKey"); @@ -238,6 +282,11 @@ private String generateToken(String id, UserAuthInfo user, Date issuedAt, Date e return builder.compact(); } + /** + * Validate the token grant request based on the grant type and required fields. + * + * @param request The token grant request to validate. + */ private void checkRequest(TokenGrantRequestDto request) { Assert.notNull(request, "request cannot be null"); Assert.notNull(request.grantType(), "grantType cannot be null"); diff --git a/identity/src/main/java/io/theurl/identity/application/implement/OnetimePasswordApplicationServiceImpl.java b/identity/src/main/java/io/theurl/identity/application/implement/OnetimePasswordApplicationServiceImpl.java new file mode 100644 index 0000000..a4ba2a6 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/implement/OnetimePasswordApplicationServiceImpl.java @@ -0,0 +1,60 @@ +package io.theurl.identity.application.implement; + +import io.theurl.framework.application.BaseApplicationService; +import io.theurl.framework.core.ObjectId; +import io.theurl.framework.utility.RandomUtility; +import io.theurl.framework.utility.RegexUtility; +import io.theurl.framework.security.CredentialNotFoundException; +import io.theurl.identity.application.command.OnetimePasswordCreateCommand; +import io.theurl.identity.application.contract.OnetimePasswordApplicationService; +import io.theurl.identity.persistence.query.UserAuthInfoQuery; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Service; +import org.springframework.web.context.annotation.RequestScope; + +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +@Service +@RequestScope +public class OnetimePasswordApplicationServiceImpl extends BaseApplicationService implements OnetimePasswordApplicationService { + public OnetimePasswordApplicationServiceImpl(ApplicationContext applicationContext) { + super(applicationContext); + } + + @Override + public CompletableFuture sendAsync(String recipient, String usage) { + if (recipient == null || recipient.isBlank()) { + throw new IllegalArgumentException("Recipient cannot be null or blank"); + } + + boolean isEmail = RegexUtility.isEmail(recipient); + boolean isPhone = RegexUtility.isPhone(recipient); + + if (!isEmail && !isPhone) { + throw new IllegalArgumentException("Email or Phone number is invalid"); + } + + if (Objects.equals(usage, "authentication")) { + UserAuthInfoQuery query; + if (isEmail) { + query = new UserAuthInfoQuery("email", recipient); + } else { + query = new UserAuthInfoQuery("phone", recipient); + } + + var user = mediator.executeAsync(query).join(); + if (user == null) { + throw new CredentialNotFoundException(recipient, recipient + " not registered."); + } + } + + var requestId = ObjectId.guid().toString(); + var code = RandomUtility.randomString(6, RandomUtility.Mode.NUMERIC); + + var command = new OnetimePasswordCreateCommand(requestId, recipient, code); + command.setUsage(usage); + command.setDuration(15); + return mediator.sendAsync(command).thenApply(_ -> requestId); + } +} diff --git a/identity/src/main/java/io/theurl/identity/application/implement/UserApplicationServiceImpl.java b/identity/src/main/java/io/theurl/identity/application/implement/UserApplicationServiceImpl.java index 9b7fa3f..a5da9d3 100644 --- a/identity/src/main/java/io/theurl/identity/application/implement/UserApplicationServiceImpl.java +++ b/identity/src/main/java/io/theurl/identity/application/implement/UserApplicationServiceImpl.java @@ -10,7 +10,9 @@ import io.theurl.identity.application.contract.UserApplicationService; import io.theurl.identity.application.dto.UserCreateRequestDto; import io.theurl.identity.application.dto.UserProfileResponseDto; +import io.theurl.identity.application.dto.UserUpdateRequestDto; import io.theurl.identity.external.ExternalAuthProvider; +import io.theurl.identity.persistence.query.OnetimePasswordDetailQuery; import io.theurl.identity.persistence.query.UserAuthInfoQuery; import io.theurl.identity.persistence.query.UserDetailQuery; import org.modelmapper.ModelMapper; @@ -18,6 +20,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.context.annotation.RequestScope; +import java.time.LocalDateTime; import java.util.Locale; import java.util.concurrent.CompletableFuture; @@ -68,16 +71,21 @@ public CompletableFuture changePasswordAsync(String oldPassword, String ne } @Override - public CompletableFuture changeEmailAsync(String email) { + public CompletableFuture changeEmailAsync(UserUpdateRequestDto data) { + checkCodeAsync(data.getRequestId(), data.getPhone(), data.getCode()) + .join(); var command = new UserUpdateCommand(currentUserId()); - command.getModifications().put("email", email); + command.getModifications().put("email", data.getEmail()); return mediator.sendAsync(command); } @Override - public CompletableFuture changePhoneAsync(String phone) { + public CompletableFuture changePhoneAsync(UserUpdateRequestDto data) { + checkCodeAsync(data.getRequestId(), data.getPhone(), data.getCode()) + .join(); + var command = new UserUpdateCommand(currentUserId()); - command.getModifications().put("phone", phone); + command.getModifications().put("phone", data.getPhone()); return mediator.sendAsync(command); } @@ -105,4 +113,24 @@ public CompletableFuture connectAuthorityAsync(String provider, String cod public CompletableFuture removeAuthorityAsync(String provider, String openId) { return null; } + + CompletableFuture checkCodeAsync(String requestId, String recipient, String code) { + return mediator.executeAsync(new OnetimePasswordDetailQuery(requestId)).thenAccept(otp -> { + if (otp == null) { + throw new CredentialIncorrectException("Invalid verify code."); + } + if (otp.getExpiration().isBefore(LocalDateTime.now())) { + throw new CredentialIncorrectException("Verify code has expired."); + } + if (otp.getChecked() != null) { + throw new CredentialIncorrectException("Verify code has already been used."); + } + if (!otp.getRecipient().equals(recipient)) { + throw new CredentialIncorrectException("Invalid verify code recipient."); + } + if (otp.getCode() == null || !otp.getCode().equals(code)) { + throw new CredentialIncorrectException("Invalid verify code."); + } + }); + } } diff --git a/identity/src/main/java/io/theurl/identity/application/subscriber/AuthlogEventSubscriber.java b/identity/src/main/java/io/theurl/identity/application/subscriber/AuthlogEventSubscriber.java index f2bba1c..06fd39a 100644 --- a/identity/src/main/java/io/theurl/identity/application/subscriber/AuthlogEventSubscriber.java +++ b/identity/src/main/java/io/theurl/identity/application/subscriber/AuthlogEventSubscriber.java @@ -2,6 +2,7 @@ import com.neroyun.mediator.Mediator; import io.theurl.framework.core.BeanScope; +import io.theurl.framework.core.PriorityValueFinder; import io.theurl.identity.application.command.AuthlogCreateCommand; import io.theurl.identity.application.event.UserAuthFailureEvent; import io.theurl.identity.application.event.UserAuthSuccessEvent; @@ -9,7 +10,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @@ -17,9 +17,17 @@ import org.springframework.web.context.request.ServletRequestAttributes; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(BeanScope.PROTOTYPE) public class AuthlogEventSubscriber { + private static final String[] ipHeaders = { + "X-Forwarded-For", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_CLIENT_IP", + "HTTP_X_FORWARDED_FOR" + }; + private final Logger LOGGER = LoggerFactory.getLogger(AuthlogEventSubscriber.class); private final Mediator mediator; @@ -28,7 +36,7 @@ public AuthlogEventSubscriber(Mediator mediator) { this.mediator = mediator; } - @Async("taskExecutor") + @Async() @EventListener public void handleUserAuthSuccess(UserAuthSuccessEvent event) { try { @@ -39,13 +47,13 @@ public void handleUserAuthSuccess(UserAuthSuccessEvent event) { command.setUserId(event.getUserId()); command.setGrantType(event.getGrantType()); if (request != null) { - command.setRequestId(request.getRequestId()); + command.setRequestId(request.getSession().getId()); command.setIpAddress(request.getRemoteAddr()); command.setUserAgent(request.getHeader("User-Agent")); command.setReferrer(request.getHeader("Referer")); command.setAppName(request.getHeader("X-App-Name")); command.setAppVersion(request.getHeader("X-App-Version")); - command.setOsPlatform(request.getHeader("X-OS-Platform")); + command.setOsPlatform(resolveOsPlatform(request.getHeader("User-Agent"))); command.setSource(request.getHeader("X-Source")); } command.setSuccess(true); @@ -60,26 +68,58 @@ public void handleUserAuthSuccess(UserAuthSuccessEvent event) { @Async @EventListener public void handleUserAuthFailure(UserAuthFailureEvent event) { - var request = getRequest(); - var command = new AuthlogCreateCommand(); - command.setUsername(event.getUsername()); - command.setUserId(event.getUserId()); - command.setGrantType(event.getGrantType()); - if (request != null) { - command.setRequestId(request.getRequestId()); - command.setIpAddress(request.getRemoteAddr()); - command.setUserAgent(request.getHeader("User-Agent")); - command.setReferrer(request.getHeader("Referer")); - command.setAppName(request.getHeader("X-App-Name")); - command.setAppVersion(request.getHeader("X-App-Version")); - command.setOsPlatform(request.getHeader("X-OS-Platform")); - command.setSource(request.getHeader("X-Source")); + try { + var request = getRequest(); + + var command = new AuthlogCreateCommand(); + command.setUsername(event.getUsername()); + command.setUserId(event.getUserId()); + command.setGrantType(event.getGrantType()); + if (request != null) { + command.setRequestId(request.getSession().getId()); + command.setIpAddress(getClientIp(request)); + command.setUserAgent(request.getHeader("User-Agent")); + command.setReferrer(request.getHeader("Referer")); + command.setAppName(request.getHeader("X-App-Name")); + command.setAppVersion(request.getHeader("X-App-Version")); + command.setOsPlatform(resolveOsPlatform(request.getHeader("User-Agent"))); + command.setSource(request.getHeader("X-Source")); + } + command.setSuccess(false); + command.setRemark(event.getError()); + command.setTimestamp(event.getGrantTime()); + mediator.sendAsync(command) + .join(); + } catch (Exception exception) { + LOGGER.error("Failed to log authentication failure event for user: {}, error: {}", event.getUsername(), exception.getMessage(), exception); + } + } + + private static String resolveOsPlatform(String userAgent) { + // Implement OS platform resolution logic based on the user agent string + + try { + var os = userAgent.replaceAll(".*(?(?:Windows NT|Mac OS X|Android|iPhone OS|iPad OS|Linux|Ubuntu)[^;)]*).*", "$1"); + if (os.contains("Windows")) { + return os.replace("Windows NT", "Windows"); + } + if (os.contains("Mac OS X")) { + return "macOS"; + } + if (os.contains("Android")) { + return "Android"; + } + if (os.contains("iPad OS") || os.contains("iPhone OS")) { + return "iOS"; + } + if (os.contains("Linux")) { + return "Linux"; + } + + return os; + } catch (Exception exception) { + return "Unknown"; } - command.setSuccess(false); - command.setRemark(event.getError()); - command.setTimestamp(event.getGrantTime()); - mediator.sendAsync(command) - .join(); } private HttpServletRequest getRequest() { @@ -91,4 +131,16 @@ private HttpServletRequest getRequest() { return request.getRequest(); } + + public static String getClientIp(HttpServletRequest request) { + return PriorityValueFinder.find(queue -> { + for (String header : ipHeaders) { + var value = request.getHeader(header); + if (value == null) { + continue; + } + queue.offer(value); + } + }, value -> !value.isEmpty(), "127.0.0.1"); + } } diff --git a/identity/src/main/java/io/theurl/identity/application/subscriber/DistributedEventBus.java b/identity/src/main/java/io/theurl/identity/application/subscriber/DistributedEventBus.java deleted file mode 100644 index 3423aaa..0000000 --- a/identity/src/main/java/io/theurl/identity/application/subscriber/DistributedEventBus.java +++ /dev/null @@ -1,7 +0,0 @@ -package io.theurl.identity.application.subscriber; - -import org.springframework.stereotype.Component; - -@Component -public class DistributedEventBus { -} diff --git a/identity/src/main/java/io/theurl/identity/application/subscriber/DistributedEventPublisher.java b/identity/src/main/java/io/theurl/identity/application/subscriber/DistributedEventPublisher.java new file mode 100644 index 0000000..d2f477d --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/subscriber/DistributedEventPublisher.java @@ -0,0 +1,95 @@ +package io.theurl.identity.application.subscriber; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.rabbitmq.client.AMQP; +import com.rabbitmq.client.ConnectionFactory; +import io.theurl.identity.domain.aggregate.User; +import io.theurl.identity.domain.event.*; +import io.theurl.shared.constant.EventConstant; +import io.theurl.shared.event.OnetimePasswordCreatedEto; +import io.theurl.shared.event.UserLockedEto; +import org.modelmapper.ModelMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Component +public class DistributedEventPublisher { + private final Logger LOGGER = LoggerFactory.getLogger(DistributedEventPublisher.class); + + private final ModelMapper mapper; + + private final ConnectionFactory factory; + + public DistributedEventPublisher(ModelMapper mapper, ConnectionFactory factory) { + this.mapper = mapper; + this.factory = factory; + } + + @Async + @EventListener + public void handleOnetimePasswordCreatedEvent(OnetimePasswordCreatedEvent event) { + try (var connection = factory.newConnection()) { + var eto = mapper.map(event, OnetimePasswordCreatedEto.class); + + var message = new ObjectMapper().writeValueAsString(eto); + + var channel = connection.createChannel(); + channel.exchangeDeclare(EventConstant.OTP_CREATED, "fanout", true); + var properties = new AMQP.BasicProperties().builder() + .contentType("application/json") + .build(); + channel.basicPublish(EventConstant.OTP_CREATED, "", properties, message.getBytes()); + + } catch (Exception exception) { + LOGGER.error(exception.getMessage(), exception); + } + } + + @Async + @EventListener + public void handleUserEmailChangedEvent(UserEmailChangedEvent event) { + LOGGER.debug("收到用户邮箱变更事件,当前尚未接入分布式发布:{}", event.getClass().getSimpleName()); + } + + @Async + @EventListener + public void handleUserPasswordChangedEvent(UserPasswordChangedEvent event) { + LOGGER.debug("收到用户密码变更事件,当前尚未接入分布式发布:{}", event.getClass().getSimpleName()); + } + + @Async + @EventListener + public void handleUserPhoneChangedEvent(UserPhoneChangedEvent event) { + LOGGER.debug("收到用户手机号变更事件,当前尚未接入分布式发布:{}", event.getClass().getSimpleName()); + } + + @Async + @EventListener + public void handleUserLockedEvent(UserLockedEvent event) { + try (var connection = factory.newConnection()) { + var eto = mapper.map(event, UserLockedEto.class); + + var aggregate = event.getAggregate(User.class); + if (aggregate != null) { + eto.setPhone(aggregate.getPhone()); + eto.setEmail(aggregate.getEmail()); + eto.setUsername(aggregate.getUsername()); + } + + var message = new ObjectMapper().writeValueAsString(eto); + + var channel = connection.createChannel(); + channel.exchangeDeclare(EventConstant.USER_LOCKED, "fanout", true); + var properties = new AMQP.BasicProperties().builder() + .contentType("application/json") + .build(); + channel.basicPublish(EventConstant.USER_LOCKED, "", properties, message.getBytes()); + + } catch (Exception exception) { + LOGGER.error(exception.getMessage(), exception); + } + } +} diff --git a/identity/src/main/java/io/theurl/identity/application/subscriber/TokenEventSubscriber.java b/identity/src/main/java/io/theurl/identity/application/subscriber/TokenEventSubscriber.java index 06a5d23..305e8bb 100644 --- a/identity/src/main/java/io/theurl/identity/application/subscriber/TokenEventSubscriber.java +++ b/identity/src/main/java/io/theurl/identity/application/subscriber/TokenEventSubscriber.java @@ -6,15 +6,18 @@ import io.theurl.identity.application.command.TokenRevokeCommand; import io.theurl.identity.application.event.TokenGrantedEvent; import io.theurl.identity.application.event.TokenRefreshedEvent; +import io.theurl.identity.domain.enums.TokenStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(BeanScope.PROTOTYPE) public class TokenEventSubscriber { + private static final Logger LOGGER = LoggerFactory.getLogger(TokenEventSubscriber.class); private final Mediator mediator; public TokenEventSubscriber(Mediator mediator) { @@ -24,23 +27,30 @@ public TokenEventSubscriber(Mediator mediator) { @Async @EventListener public void handleUserAuthSucceedEvent(TokenGrantedEvent event) { - var command = new TokenCreateCommand() {{ - setJti(event.getJti()); - setContent(event.getContent()); - setSubject(event.getUserId()); - setExpiresAt(event.getExpiresAt()); - setIssuedAt(event.getIssuedAt()); - }}; - - mediator.sendAsync(command) - .join(); + try { + var command = new TokenCreateCommand() {{ + setJti(event.getJti()); + setContent(event.getContent()); + setSubject(event.getUserId()); + setExpiresAt(event.getExpiresAt()); + setIssuedAt(event.getIssuedAt()); + }}; + mediator.sendAsync(command) + .join(); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + } } @Async @EventListener public void handleTokenRefreshedEvent(TokenRefreshedEvent event) { - var command = new TokenRevokeCommand(event.getJti(), "refreshed"); - mediator.sendAsync(command) - .join(); + try { + var command = new TokenRevokeCommand(event.getJti(), TokenStatus.REFRESHED); + mediator.sendAsync(command) + .join(); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + } } } diff --git a/identity/src/main/java/io/theurl/identity/application/subscriber/UserEventSubscriber.java b/identity/src/main/java/io/theurl/identity/application/subscriber/UserEventSubscriber.java index 7f4cc1c..1ce45bc 100644 --- a/identity/src/main/java/io/theurl/identity/application/subscriber/UserEventSubscriber.java +++ b/identity/src/main/java/io/theurl/identity/application/subscriber/UserEventSubscriber.java @@ -5,38 +5,49 @@ import io.theurl.identity.application.command.UserAccessFailureCountCommand; import io.theurl.identity.application.event.UserAuthFailureEvent; import io.theurl.identity.application.event.UserAuthSuccessEvent; -import lombok.AllArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) -@AllArgsConstructor +@Scope(BeanScope.PROTOTYPE) public class UserEventSubscriber { - + private final Logger LOGGER = LoggerFactory.getLogger(UserEventSubscriber.class); private final Mediator mediator; + public UserEventSubscriber(Mediator mediator) { + this.mediator = mediator; + } + @Async @EventListener - public void listen(UserAuthFailureEvent event) { - if (event.getUserId() == null || event.getUserId() <= 0) { - return; - } + public void handleUserAuthFailureEvent(UserAuthFailureEvent event) { + try { + if (event.getUserId() == null || event.getUserId() <= 0) { + return; + } - mediator.sendAsync(new UserAccessFailureCountCommand(event.getUserId(), "increase")) - .join(); + mediator.sendAsync(new UserAccessFailureCountCommand(event.getUserId(), "increase")) + .join(); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + } } @Async @EventListener - public void listen(UserAuthSuccessEvent event) { - if (event.getUserId() == null || event.getUserId() <= 0) { - return; + public void handleUserAuthSuccessEvent(UserAuthSuccessEvent event) { + try { + if (event.getUserId() == null || event.getUserId() <= 0) { + return; + } + mediator.sendAsync(new UserAccessFailureCountCommand(event.getUserId(), "reset")) + .join(); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); } - mediator.sendAsync(new UserAccessFailureCountCommand(event.getUserId(), "reset")) - .join(); } } diff --git a/identity/src/main/java/io/theurl/identity/configure/RabbitMqConfiguration.java b/identity/src/main/java/io/theurl/identity/configure/RabbitMqConfiguration.java new file mode 100644 index 0000000..0587410 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/configure/RabbitMqConfiguration.java @@ -0,0 +1,23 @@ +package io.theurl.identity.configure; + +import com.rabbitmq.client.ConnectionFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RabbitMqConfiguration { + @Bean + public ConnectionFactory connectionFactory(@Value("${spring.rabbitmq.host}") String host, + @Value("${spring.rabbitmq.port}") int port, + @Value("${spring.rabbitmq.username}") String username, + @Value("${spring.rabbitmq.password}") String password) { + ConnectionFactory factory = new ConnectionFactory(); + factory.setHost(host); + factory.setPort(port); + factory.setUsername(username); + factory.setPassword(password); + factory.setVirtualHost("/"); + return factory; + } +} diff --git a/identity/src/main/java/io/theurl/identity/configure/SecurityConfiguration.java b/identity/src/main/java/io/theurl/identity/configure/SecurityConfiguration.java index e9b8e1d..31bd4c0 100644 --- a/identity/src/main/java/io/theurl/identity/configure/SecurityConfiguration.java +++ b/identity/src/main/java/io/theurl/identity/configure/SecurityConfiguration.java @@ -23,7 +23,7 @@ public class SecurityConfiguration { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, - JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { + io.theurl.framework.security.JwtAuthenticationFilter jwtAuthenticationFilter) { http.csrf(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) @@ -34,6 +34,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, "/auth/**", "/account/register", "/account/password/reset", + "/otp/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", diff --git a/identity/src/main/java/io/theurl/identity/domain/aggregate/OnetimePassword.java b/identity/src/main/java/io/theurl/identity/domain/aggregate/OnetimePassword.java new file mode 100644 index 0000000..daccfad --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/domain/aggregate/OnetimePassword.java @@ -0,0 +1,110 @@ +package io.theurl.identity.domain.aggregate; + +import io.theurl.framework.domain.AggregateRoot; +import io.theurl.framework.utility.SnowflakeId; +import io.theurl.identity.domain.event.OnetimePasswordCreatedEvent; + +import java.time.LocalDateTime; + +@SuppressWarnings({"LombokGetterMayBeUsed", "LombokSetterMayBeUsed"}) +public class OnetimePassword extends AggregateRoot { + + /** + * Initializes the aggregate with the given id. + * + * @param id the identifier of the aggregate + */ + public OnetimePassword(Long id) { + super(id); + } + + public OnetimePassword(Long id, String requestId, String recipient, String code) { + this(id); + this.requestId = requestId; + this.recipient = recipient; + this.code = code; + } + + private String requestId; + private String code; + private String recipient; + private LocalDateTime expiration; + private LocalDateTime checked; + private Integer duration; + private String usage; + + public static OnetimePassword create(String requestId, String recipient, String code, Integer duration) { + OnetimePassword otp = new OnetimePassword(SnowflakeId.getInstance().nextId(), requestId, recipient, code); + otp.duration = duration; + if (duration != null) { + otp.expiration = LocalDateTime.now().plusMinutes(duration); + } + otp.raiseEvent(new OnetimePasswordCreatedEvent() {{ + setRequestId(requestId); + setRecipient(recipient); + setCode(code); + setDuration(duration); + }}); + return otp; + } + + public String getRequestId() { + return requestId; + } + + public void setRequestId(String requestId) { + this.requestId = requestId; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getRecipient() { + return recipient; + } + + public void setRecipient(String recipient) { + this.recipient = recipient; + } + + public LocalDateTime getExpiration() { + return expiration; + } + + public void setExpiration(LocalDateTime expiration) { + this.expiration = expiration; + } + + public LocalDateTime getChecked() { + return checked; + } + + public void setChecked(LocalDateTime checked) { + this.checked = checked; + } + + public Integer getDuration() { + return duration; + } + + public void setDuration(Integer duration) { + this.duration = duration; + } + + public String getUsage() { + return usage; + } + + public void setUsage(String usage) { + this.usage = usage; + } + + public void checkOff() { + setChecked(LocalDateTime.now()); + } +} diff --git a/identity/src/main/java/io/theurl/identity/domain/aggregate/Token.java b/identity/src/main/java/io/theurl/identity/domain/aggregate/Token.java index 9138c7b..35127e4 100644 --- a/identity/src/main/java/io/theurl/identity/domain/aggregate/Token.java +++ b/identity/src/main/java/io/theurl/identity/domain/aggregate/Token.java @@ -57,7 +57,7 @@ public LocalDateTime getRevokedAt() { } public void setIssuedAt(LocalDateTime issuedAt) { - if (issuedAt != null && issuedAt.isBefore(LocalDateTime.now())) { + if (issuedAt != null && issuedAt.isAfter(LocalDateTime.now())) { throw new IllegalArgumentException("issuedAt must be in the future"); } this.issuedAt = issuedAt; @@ -70,6 +70,14 @@ public void setExpiresAt(LocalDateTime expiresAt) { this.expiresAt = expiresAt; } + public void setRevokedAt(LocalDateTime revokedAt) { + this.revokedAt = revokedAt; + } + + public void setStatus(TokenStatus status) { + this.status = status; + } + public void revoke(TokenStatus reason) { this.revokedAt = LocalDateTime.now(); status = reason; diff --git a/identity/src/main/java/io/theurl/identity/domain/event/OnetimePasswordCreatedEvent.java b/identity/src/main/java/io/theurl/identity/domain/event/OnetimePasswordCreatedEvent.java new file mode 100644 index 0000000..ca825b4 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/domain/event/OnetimePasswordCreatedEvent.java @@ -0,0 +1,16 @@ +package io.theurl.identity.domain.event; + +import com.neroyun.mediator.Event; +import io.theurl.framework.domain.DomainEvent; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@EqualsAndHashCode(callSuper = true) +@Data +public class OnetimePasswordCreatedEvent extends DomainEvent implements Event { + private String requestId; + private String recipient; + private String code; + private Integer duration; + private String usage; +} diff --git a/identity/src/main/java/io/theurl/identity/domain/repository/OnetimePasswordRepository.java b/identity/src/main/java/io/theurl/identity/domain/repository/OnetimePasswordRepository.java new file mode 100644 index 0000000..61c1564 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/domain/repository/OnetimePasswordRepository.java @@ -0,0 +1,11 @@ +package io.theurl.identity.domain.repository; + +import io.theurl.identity.domain.aggregate.OnetimePassword; + +public interface OnetimePasswordRepository { + void save(OnetimePassword aggregate); + + OnetimePassword findById(Long id); + + OnetimePassword findByRequestId(String requestId); +} diff --git a/identity/src/main/java/io/theurl/identity/interfaces/controller/AccountController.java b/identity/src/main/java/io/theurl/identity/interfaces/controller/AccountController.java index 85d9af9..5a76543 100644 --- a/identity/src/main/java/io/theurl/identity/interfaces/controller/AccountController.java +++ b/identity/src/main/java/io/theurl/identity/interfaces/controller/AccountController.java @@ -46,7 +46,7 @@ public ResponseEntity changePassword(@RequestBody UserPasswordChangeReques @PutMapping("/email") @Operation(summary = "Change user email", security = @SecurityRequirement(name = "bearerAuth")) public ResponseEntity changeEmail(@RequestBody UserUpdateRequestDto data) { - service.changeEmailAsync(data.getEmail()) + service.changeEmailAsync(data) .join(); return ResponseEntity.ok().build(); } @@ -54,7 +54,7 @@ public ResponseEntity changeEmail(@RequestBody UserUpdateRequestDto data) @PutMapping("/phone") @Operation(summary = "Change user phone", security = @SecurityRequirement(name = "bearerAuth")) public ResponseEntity changePhone(@RequestBody UserUpdateRequestDto data) { - service.changePhoneAsync(data.getPhone()) + service.changePhoneAsync(data) .join(); return ResponseEntity.ok().build(); } diff --git a/identity/src/main/java/io/theurl/identity/interfaces/controller/AuthController.java b/identity/src/main/java/io/theurl/identity/interfaces/controller/AuthController.java index 36c342e..d19dbe3 100644 --- a/identity/src/main/java/io/theurl/identity/interfaces/controller/AuthController.java +++ b/identity/src/main/java/io/theurl/identity/interfaces/controller/AuthController.java @@ -26,7 +26,7 @@ public AuthController(AuthApplicationService service) { * @return A response containing the access token and refresh token. */ @PostMapping("token/grant") - @Operation(summary = "Grant access token", security = {}) + @Operation(summary = "Grant access token") public TokenGrantResponseDto grantToken(@RequestBody TokenGrantRequestDto request) { return service.grant(request).join(); } @@ -40,9 +40,22 @@ public TokenGrantResponseDto grantToken(@RequestBody TokenGrantRequestDto reques * @return A response containing the new access token and refresh token. */ @PostMapping("token/refresh") - @Operation(summary = "Refresh access token", security = {}) + @Operation(summary = "Refresh access token") public TokenGrantResponseDto refreshToken(@RequestParam String token) { var request = new TokenGrantRequestDto(token, null, "refresh_token", null); return service.grant(request).join(); } + + /** + * Revoke an access token based on the provided token identifier (jti). + * This endpoint allows clients to revoke an access token, effectively invalidating it and preventing its further use for authentication and authorization. + * The client must provide the token identifier (jti) of the access token to be revoked, and if the token is valid, it will be marked as revoked in the system. + * + * @param jti The token identifier (jti) of the access token to be revoked. + */ + @PostMapping("token/revoke") + @Operation(summary = "Revoke access token") + public void revokeToken(@RequestParam String jti) { + service.revoke(jti).join(); + } } diff --git a/identity/src/main/java/io/theurl/identity/interfaces/controller/OnetimePasswordController.java b/identity/src/main/java/io/theurl/identity/interfaces/controller/OnetimePasswordController.java new file mode 100644 index 0000000..29b1ee4 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/interfaces/controller/OnetimePasswordController.java @@ -0,0 +1,32 @@ +package io.theurl.identity.interfaces.controller; + +import io.theurl.identity.application.contract.OnetimePasswordApplicationService; +import io.theurl.identity.application.dto.OnetimePasswordSendRequestDto; +import org.springframework.web.bind.annotation.*; + +import java.util.concurrent.CompletableFuture; + +@RestController +@RequestMapping("otp") +public class OnetimePasswordController { + private final OnetimePasswordApplicationService service; + + public OnetimePasswordController(OnetimePasswordApplicationService service) { + this.service = service; + } + + @PostMapping("authentication") + public CompletableFuture sendAuthOtp(@RequestBody OnetimePasswordSendRequestDto request) { + return service.sendAsync(request.recipient(), "authentication"); + } + + @PostMapping("change-email") + public CompletableFuture sendChangeEmailOtp(@RequestBody OnetimePasswordSendRequestDto request) { + return service.sendAsync(request.recipient(), "change-email"); + } + + @PostMapping("reset-password") + public CompletableFuture sendResetPasswordOtp(@RequestBody OnetimePasswordSendRequestDto request) { + return service.sendAsync(request.recipient(), "reset-password"); + } +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/entity/OnetimePassword.java b/identity/src/main/java/io/theurl/identity/persistence/entity/OnetimePassword.java index 9179bda..fdb979c 100644 --- a/identity/src/main/java/io/theurl/identity/persistence/entity/OnetimePassword.java +++ b/identity/src/main/java/io/theurl/identity/persistence/entity/OnetimePassword.java @@ -34,8 +34,8 @@ public class OnetimePassword implements Persistable { @Column(name = "duration") private Integer duration; - @Column(name = "usage") - private int usage; + @Column(name = "usage", length = 20) + private String usage; @Column(name = "created_at") private LocalDateTime createdAt; diff --git a/identity/src/main/java/io/theurl/identity/persistence/model/TokenDetail.java b/identity/src/main/java/io/theurl/identity/persistence/model/TokenDetail.java index 23e2641..4577afd 100644 --- a/identity/src/main/java/io/theurl/identity/persistence/model/TokenDetail.java +++ b/identity/src/main/java/io/theurl/identity/persistence/model/TokenDetail.java @@ -12,6 +12,6 @@ public class TokenDetail { private Long subject; private LocalDateTime issuedAt; private LocalDateTime expiresAt; - private LocalDateTime refreshAt; private LocalDateTime revokedAt; + private String status; } diff --git a/identity/src/main/java/io/theurl/identity/persistence/profile/OnetimePasswordMapProfile.java b/identity/src/main/java/io/theurl/identity/persistence/profile/OnetimePasswordMapProfile.java new file mode 100644 index 0000000..029db76 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/profile/OnetimePasswordMapProfile.java @@ -0,0 +1,24 @@ +package io.theurl.identity.persistence.profile; + +import jakarta.annotation.PostConstruct; +import org.modelmapper.ModelMapper; +import org.modelmapper.Provider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class OnetimePasswordMapProfile { + @Autowired + private ModelMapper mapper; + + @PostConstruct + public void configure() { + Provider provider = request -> { + var source = (io.theurl.identity.persistence.entity.OnetimePassword) request.getSource(); + return new io.theurl.identity.domain.aggregate.OnetimePassword(source.getId()); + }; + + mapper.createTypeMap(io.theurl.identity.persistence.entity.OnetimePassword.class, io.theurl.identity.domain.aggregate.OnetimePassword.class) + .setProvider(provider); + } +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/profile/TokenMapProfile.java b/identity/src/main/java/io/theurl/identity/persistence/profile/TokenMapProfile.java new file mode 100644 index 0000000..91a83a4 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/profile/TokenMapProfile.java @@ -0,0 +1,31 @@ +package io.theurl.identity.persistence.profile; + +import io.theurl.identity.domain.aggregate.Token; +import jakarta.annotation.PostConstruct; +import org.modelmapper.ModelMapper; +import org.modelmapper.Provider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class TokenMapProfile { + @Autowired + private ModelMapper mapper; + + @PostConstruct + public void configure() { + Provider tokenProvider = request -> { + var source = (io.theurl.identity.persistence.entity.Token) request.getSource(); + return new Token(source.getId(), source.getJti(), source.getContent(), source.getSubject()); + }; + + mapper.createTypeMap(io.theurl.identity.persistence.entity.Token.class, Token.class) + .setProvider(tokenProvider) + .addMappings(expression -> { + expression.map(io.theurl.identity.persistence.entity.Token::getExpiresAt, Token::setExpiresAt); + expression.map(io.theurl.identity.persistence.entity.Token::getIssuedAt, Token::setIssuedAt); + expression.map(io.theurl.identity.persistence.entity.Token::getRevokedAt, Token::setRevokedAt); + expression.map(io.theurl.identity.persistence.entity.Token::getStatus, Token::setStatus); + }); + } +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/repository/JpaOnetimePasswordRepository.java b/identity/src/main/java/io/theurl/identity/persistence/repository/JpaOnetimePasswordRepository.java new file mode 100644 index 0000000..aba59db --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/repository/JpaOnetimePasswordRepository.java @@ -0,0 +1,12 @@ +package io.theurl.identity.persistence.repository; + +import io.theurl.identity.persistence.entity.OnetimePassword; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface JpaOnetimePasswordRepository extends CrudRepository { + Optional findByRequestId(String requestId); +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/repository/OnetimePasswordRepositoryImpl.java b/identity/src/main/java/io/theurl/identity/persistence/repository/OnetimePasswordRepositoryImpl.java new file mode 100644 index 0000000..82735f1 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/repository/OnetimePasswordRepositoryImpl.java @@ -0,0 +1,36 @@ +package io.theurl.identity.persistence.repository; + +import io.theurl.identity.domain.aggregate.OnetimePassword; +import io.theurl.identity.domain.repository.OnetimePasswordRepository; +import org.modelmapper.ModelMapper; +import org.springframework.stereotype.Repository; + +@Repository +public class OnetimePasswordRepositoryImpl implements OnetimePasswordRepository { + + private final JpaOnetimePasswordRepository repository; + private final ModelMapper mapper; + + public OnetimePasswordRepositoryImpl(JpaOnetimePasswordRepository repository, ModelMapper mapper) { + this.repository = repository; + this.mapper = mapper; + } + + @Override + public void save(OnetimePassword aggregate) { + var entity = mapper.map(aggregate, io.theurl.identity.persistence.entity.OnetimePassword.class); + repository.save(entity); + } + + @Override + public OnetimePassword findById(Long id) { + var entity = repository.findById(id).orElse(null); + return mapper.map(entity, OnetimePassword.class); + } + + @Override + public OnetimePassword findByRequestId(String requestId) { + var entity = repository.findByRequestId(requestId).orElse(null); + return mapper.map(entity, OnetimePassword.class); + } +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/repository/UserRepositoryImpl.java b/identity/src/main/java/io/theurl/identity/persistence/repository/UserRepositoryImpl.java index 83dfca6..fce7577 100644 --- a/identity/src/main/java/io/theurl/identity/persistence/repository/UserRepositoryImpl.java +++ b/identity/src/main/java/io/theurl/identity/persistence/repository/UserRepositoryImpl.java @@ -70,10 +70,7 @@ public User findByPhone(String phone) { @Override public User findByAnyOf(String username, String email, String phone) { return repository.findByAnyOf(username, email, phone) - .map(entity ->{ - System.out.println(entity); - return mapper.map(entity, User.class); - }) + .map(entity -> mapper.map(entity, User.class)) .orElse(null); } } diff --git a/identity/src/main/resources/application.yaml b/identity/src/main/resources/application.yaml index 46bae52..425f01e 100644 --- a/identity/src/main/resources/application.yaml +++ b/identity/src/main/resources/application.yaml @@ -10,13 +10,15 @@ spring: import: optional:file:.env[.properties] cloud: config: - enabled: false + enabled: true uri: ${CONFIG_SERVER_URI:http://localhost:8900} + password: ${CONFIG_SERVER_PASSWORD:Qwer.1234} + username: ${CONFIG_SERVER_USERNAME:theurl} datasource: url: ${DB_URL:jdbc:postgresql://localhost:5432/linkyou?currentSchema=public} username: ${DB_USERNAME:postgres} password: ${DB_PASSWORD:postgres} - driver-class-name: ${DB_DRIVER:org.postgresql.Driver} + driver-class-name: ${DB_DRIVER:"org.postgresql.Driver"} jpa: hibernate: ddl-auto: update @@ -36,9 +38,14 @@ spring: include-message: ALWAYS include-exception: true include-stacktrace: ALWAYS + rabbitmq: + host: ${RABBITMQ_HOST:127.0.0.1} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USERNAME:guest} + password: ${RABBITMQ_PASSWORD:guest} external-auth: - redirect-uri: "https://theurl.io/auth/callback" + redirect-uri: ${EXTERNAL_AUTH_REDIRECTURI:https://theurl.io/auth/callback} google: client-id: ${GOOGLE_CLIENT_ID:your-google-client-id} client-secret: ${GOOGLE_CLIENT_SECRET:your-google-client-secret} diff --git a/message/pom.xml b/message/pom.xml index 62db91c..fe0cede 100644 --- a/message/pom.xml +++ b/message/pom.xml @@ -18,11 +18,6 @@ ${project.version} compile - - com.neroyun - mediator - ${neroyun.mediator.version} - org.springframework.boot spring-boot-starter-amqp diff --git a/message/src/main/java/io/theurl/message/configure/RabbitMqConfiguration.java b/message/src/main/java/io/theurl/message/configure/RabbitMqConfiguration.java new file mode 100644 index 0000000..b9339d4 --- /dev/null +++ b/message/src/main/java/io/theurl/message/configure/RabbitMqConfiguration.java @@ -0,0 +1,23 @@ +package io.theurl.message.configure; + +import com.rabbitmq.client.ConnectionFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RabbitMqConfiguration { + @Bean + public ConnectionFactory connectionFactory(@Value("${spring.rabbitmq.host}") String host, + @Value("${spring.rabbitmq.port}") int port, + @Value("${spring.rabbitmq.username}") String username, + @Value("${spring.rabbitmq.password}") String password) { + ConnectionFactory factory = new ConnectionFactory(); + factory.setHost(host); + factory.setPort(port); + factory.setUsername(username); + factory.setPassword(password); + factory.setVirtualHost("/"); + return factory; + } +} diff --git a/message/src/main/resources/application.yaml b/message/src/main/resources/application.yaml index cefa816..443e4bc 100644 --- a/message/src/main/resources/application.yaml +++ b/message/src/main/resources/application.yaml @@ -27,3 +27,8 @@ spring: url: ${REDIS_URL:redis://localhost:6379} mongodb: uri: ${MONGO_URI:mongodb://localhost:27017/linkyou} + rabbitmq: + host: ${RABBITMQ_HOST:127.0.0.1} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USERNAME:guest} + password: ${RABBITMQ_PASSWORD:guest} diff --git a/pom.xml b/pom.xml index 39780c0..540d9dd 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,7 @@ theurl root pom https://theurl.io + bundle config framework identity diff --git a/shared/pom.xml b/shared/pom.xml index 4e51e70..049686a 100644 --- a/shared/pom.xml +++ b/shared/pom.xml @@ -12,4 +12,56 @@ shared + + + org.projectlombok + lombok + true + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + true + + + + default-compile + compile + + compile + + + + + org.projectlombok + lombok + + + + + + default-testCompile + test-compile + + testCompile + + + + + org.projectlombok + lombok + + + + + + + + + diff --git a/shared/src/main/java/io/theurl/shared/constant/EventConstant.java b/shared/src/main/java/io/theurl/shared/constant/EventConstant.java new file mode 100644 index 0000000..6a0a504 --- /dev/null +++ b/shared/src/main/java/io/theurl/shared/constant/EventConstant.java @@ -0,0 +1,6 @@ +package io.theurl.shared.constant; + +public class EventConstant { + public static final String USER_LOCKED = "io.theurl.identity.user.locked"; + public static final String OTP_CREATED = "io.theurl.identity.otp.created"; +} diff --git a/shared/src/main/java/io/theurl/shared/event/OnetimePasswordCreatedEto.java b/shared/src/main/java/io/theurl/shared/event/OnetimePasswordCreatedEto.java new file mode 100644 index 0000000..82e04c4 --- /dev/null +++ b/shared/src/main/java/io/theurl/shared/event/OnetimePasswordCreatedEto.java @@ -0,0 +1,12 @@ +package io.theurl.shared.event; + +import lombok.Data; + +@Data +public class OnetimePasswordCreatedEto { + private String requestId; + private String recipient; + private String code; + private Integer duration; + private String usage; +} diff --git a/shared/src/main/java/io/theurl/shared/event/UserLockedEto.java b/shared/src/main/java/io/theurl/shared/event/UserLockedEto.java new file mode 100644 index 0000000..028af04 --- /dev/null +++ b/shared/src/main/java/io/theurl/shared/event/UserLockedEto.java @@ -0,0 +1,13 @@ +package io.theurl.shared.event; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class UserLockedEto { + private String username; + private String email; + private String phone; + private LocalDateTime lockedUntil; +}