Public Access
Compare commits
46
Commits
c63b7e6aaa
...
af386bb8f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af386bb8f7 | ||
|
|
e04fd27b08 | ||
|
|
c9ae7853e5 | ||
|
|
779c4d296d | ||
|
|
2941e3f905 | ||
|
|
4579b05d2c | ||
|
|
d28147b2ab | ||
|
|
5512453774 | ||
|
|
3d21bb667d | ||
|
|
fe691b0558 | ||
|
|
a3aeb666a8 | ||
|
|
39f08c9563 | ||
|
|
73dfeb4e7d | ||
|
|
3159736c16 | ||
|
|
8505faa578 | ||
|
|
874f8dee58 | ||
|
|
f30e089739 | ||
|
|
2beb9aaddf | ||
|
|
3fd12637d6 | ||
|
|
6b8aa4458c | ||
|
|
c312d271ad | ||
|
|
dda7e50963 | ||
|
|
7d32c7e04a | ||
|
|
fd390ac072 | ||
|
|
815347325a | ||
|
|
fabab0ca45 | ||
|
|
0cd4d7328a | ||
|
|
d63fbd4155 | ||
|
|
3f80777e37 | ||
|
|
e21dea1e18 | ||
|
|
d498ef8827 | ||
|
|
ed98d703c6 | ||
|
|
8465524849 | ||
|
|
c224cbf679 | ||
|
|
dbc76e6158 | ||
|
|
f4b7ae75a9 | ||
|
|
5aee23f00e | ||
|
|
71e4f38357 | ||
|
|
41ce5d93e9 | ||
|
|
8af7170c46 | ||
|
|
64b84b1dc0 | ||
|
|
2e8ff1b079 | ||
|
|
be4d4df351 | ||
|
|
85e4b62f32 | ||
|
|
7e8a04d83c | ||
|
|
8dd17f84f6 |
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
# .env file for local development
|
||||||
|
DB_PASSWORD=my_super_secret_local_password
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/mvnw text eol=lf
|
||||||
|
*.cmd text eol=crlf
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
name: Build, Test & Push Auth Service
|
||||||
|
|
||||||
|
# Trigger the workflow
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# JOB 1: Code integrity and quality gates (CI)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
quality-check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Java 25
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
java-version: '25'
|
||||||
|
distribution: 'temurin'
|
||||||
|
# Automatically cache Maven dependencies to speed up builds
|
||||||
|
cache: 'maven'
|
||||||
|
|
||||||
|
- name: Make Maven Wrapper executable
|
||||||
|
# This fixes the "Permission denied" error on Linux runners
|
||||||
|
run: chmod +x mvnw
|
||||||
|
|
||||||
|
- name: Build and Test with Maven Wrapper
|
||||||
|
# Uses the wrapper to guarantee the correct Maven version
|
||||||
|
run: ./mvnw clean verify
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# JOB 2: Docker Build & Push (CD)
|
||||||
|
# Runs only if 'quality-check' is successful and we are on branch main
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
docker:
|
||||||
|
needs: quality-check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Prepare tag
|
||||||
|
id: prep
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# Sanitize branch name for valid Docker tags
|
||||||
|
BRANCH="${GITHUB_REF_NAME}"
|
||||||
|
BRANCH="${BRANCH//\//-}"
|
||||||
|
BRANCH="$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.andreas-dahm.eu
|
||||||
|
# Use your Gitea Secrets here
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.TOKEN_GITEA }}
|
||||||
|
|
||||||
|
- name: Build & Push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
git.andreas-dahm.eu/lobo_corp/tbp-auth-service:${{ steps.prep.outputs.branch }}
|
||||||
|
git.andreas-dahm.eu/lobo_corp/tbp-auth-service:${{ steps.prep.outputs.branch }}-${{ steps.prep.outputs.sha }}
|
||||||
|
git.andreas-dahm.eu/lobo_corp/tbp-auth-service:latest
|
||||||
+33
@@ -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/
|
||||||
+3
@@ -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.14/apache-maven-3.9.14-bin.zip
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
# ------------------------------------------------------------------
|
||||||
|
# STAGE 1: Build the application using Maven Wrapper
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
FROM eclipse-temurin:25-jdk-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the Maven wrapper and pom.xml first to cache dependencies
|
||||||
|
COPY .mvn/ .mvn
|
||||||
|
COPY mvnw pom.xml ./
|
||||||
|
|
||||||
|
# Make the wrapper executable (just in case) and download dependencies
|
||||||
|
RUN chmod +x mvnw
|
||||||
|
RUN ./mvnw dependency:go-offline
|
||||||
|
|
||||||
|
# Copy the actual source code and build the application (skip tests in Docker,
|
||||||
|
# as they will be run by the Gitea Action before the build)
|
||||||
|
COPY src ./src
|
||||||
|
RUN ./mvnw clean package -DskipTests
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# STAGE 2: Create the minimal runtime image
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
FROM eclipse-temurin:25-jre-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Expose the port your Spring Boot app runs on
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Copy the compiled jar file from the builder stage
|
||||||
|
COPY --from=builder /app/target/*.jar app.jar
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
container_name: tbp_auth_db_local
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=tbp_auth
|
||||||
|
- POSTGRES_USER=tbp_user
|
||||||
|
- POSTGRES_PASSWORD=local_secret_password
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# docker-compose.dev.yml
|
||||||
|
services:
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# LOCAL DATABASE FOR DEVELOPMENT
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
tbp-db-local:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
container_name: tbp_db_local
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
# Map the container port 5432 to your local machine port 5432
|
||||||
|
- "5432:5432"
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=tbp_auth
|
||||||
|
- POSTGRES_USER=tbp_user
|
||||||
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
# Use a local volume to persist data across container restarts
|
||||||
|
- tbp_local_db_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
tbp_local_db_data:
|
||||||
+10713
File diff suppressed because it is too large.
Load diff
@@ -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-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||||
|
[ -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 "$@"
|
||||||
@@ -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-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||||
|
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"
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>4.1.1</version>
|
||||||
|
<relativePath/> <!-- lookup parent from repository -->
|
||||||
|
</parent>
|
||||||
|
<groupId>eu.andreasdahm.tbp</groupId>
|
||||||
|
<artifactId>auth-service</artifactId>
|
||||||
|
<version>0.0.1-SNAPSHOT</version>
|
||||||
|
<name>To be Played - Auth</name>
|
||||||
|
<properties>
|
||||||
|
<java.version>25</java.version>
|
||||||
|
<jwt.version>0.13.0</jwt.version>
|
||||||
|
<testcontainers.version>1.21.4</testcontainers.version>
|
||||||
|
<archunit.version>1.5.0</archunit.version>
|
||||||
|
</properties>
|
||||||
|
<dependencies>
|
||||||
|
<!-- Spring -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-cache</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Rest Caching -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||||
|
<artifactId>caffeine</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- JWT -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-api</artifactId>
|
||||||
|
<version>${jwt.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-impl</artifactId>
|
||||||
|
<version>${jwt.version}</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
|
<version>${jwt.version}</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- Database -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-database-postgresql</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-flyway</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Observability -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.micrometer</groupId>
|
||||||
|
<artifactId>micrometer-registry-prometheus</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- testing -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-docker-compose</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.postgresql</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-devtools</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-testcontainers</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
<version>${testcontainers.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<version>${testcontainers.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.tngtech.archunit</groupId>
|
||||||
|
<artifactId>archunit-junit5</artifactId>
|
||||||
|
<version>${archunit.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<excludes>
|
||||||
|
<exclude>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</exclude>
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>spring-snapshots</id>
|
||||||
|
<name>Spring Snapshots</name>
|
||||||
|
<url>https://repo.spring.io/snapshot</url>
|
||||||
|
<releases>
|
||||||
|
<enabled>false</enabled>
|
||||||
|
</releases>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
|
<pluginRepositories>
|
||||||
|
<pluginRepository>
|
||||||
|
<id>spring-snapshots</id>
|
||||||
|
<name>Spring Snapshots</name>
|
||||||
|
<url>https://repo.spring.io/snapshot</url>
|
||||||
|
<releases>
|
||||||
|
<enabled>false</enabled>
|
||||||
|
</releases>
|
||||||
|
</pluginRepository>
|
||||||
|
</pluginRepositories>
|
||||||
|
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# To Be Played (TBP) - Authentication Service
|
||||||
|
|
||||||
|
This is the Authentication and User Management Service for the "To Be Played" project. It acts as the central identity provider, handling user authentication, JWT generation, and profile management for the TBP frontend.
|
||||||
|
|
||||||
|
## 🚀 Tech Stack
|
||||||
|
|
||||||
|
* **Language:** Java 25
|
||||||
|
* **Framework:** Spring Boot 4.1.x
|
||||||
|
* **Security:** Spring Security & JSON Web Tokens (JWT)
|
||||||
|
* **Database:** PostgreSQL 17
|
||||||
|
* **Migrations:** Flyway
|
||||||
|
* **Observability:** Spring Boot Actuator & Micrometer (Prometheus)
|
||||||
|
* **Build Tool:** Maven Wrapper (`mvnw`)
|
||||||
|
* **CI/CD:** Gitea Actions (Forgejo)
|
||||||
|
|
||||||
|
## 🏗️ Architecture (Clean Architecture)
|
||||||
|
|
||||||
|
The project strictly follows a Domain-Driven / Clean Architecture (Ports & Adapters) approach to ensure high maintainability and testability. The codebase is divided into three main layers to prevent framework lock-in and keep business logic isolated.
|
||||||
|
|
||||||
|
### Layer Breakdown
|
||||||
|
|
||||||
|
1. **API / Web Layer (`api`)**
|
||||||
|
* Contains REST endpoints, DTOs (`Request`/`Response` records), and Global Exception Handlers.
|
||||||
|
* Uses `ApiMapper` to map between DTOs and Domain Models.
|
||||||
|
* **Rule:** This layer delegates to the Service layer and never accesses persistence directly.
|
||||||
|
|
||||||
|
2. **Business / Core Layer (`model`, `service`, `exception`)**
|
||||||
|
* The heart of the application. Contains pure Java classes (Models) and Business Logic (Services).
|
||||||
|
* Throws specific business exceptions (e.g., `InvalidCredentialsException`).
|
||||||
|
* **Rule:** This layer has **no dependencies** on the API or Persistence layer. It defines interfaces that the persistence layer must implement.
|
||||||
|
|
||||||
|
3. **Persistence Layer (`persistence`)**
|
||||||
|
* Handles database operations. Contains JPA Entities (`UserEntity`), Spring Data Repositories, and the `EntityMapper`.
|
||||||
|
* Implements the interfaces defined in the Domain layer.
|
||||||
|
* **Rule:** Entities never leak into the API layer.
|
||||||
|
|
||||||
|
Architectural boundaries are enforced via ArchUnit (`src/test/java/auth_service/architecture/ArchitectureTest.java`), which runs as part of the standard `./mvnw test` quality gate. Abstract test bases live under `auth_service.shared.test` (`AbstractUnitTest`, `AbstractIntegrationTest`, `AbstractPersistenceTest`, `AbstractWebMvcTest`, `AbstractArchitectureTest`).
|
||||||
|
|
||||||
|
## ⚙️ Local Development
|
||||||
|
|
||||||
|
Running the application locally is fully automated via Spring Boot's Docker Compose Support.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
* Java 25 installed
|
||||||
|
* Docker & Docker Compose running
|
||||||
|
|
||||||
|
### How to start
|
||||||
|
|
||||||
|
1. Simply run the application from your IDE (e.g., IntelliJ) via the `AuthServiceApplication.java` main class.
|
||||||
|
2. Spring Boot will automatically detect the `compose.yaml` and spin up a local PostgreSQL container (`tbp_auth_db_local`) on port `5432`.
|
||||||
|
3. **Flyway** will automatically apply all database migrations located in `src/main/resources/db/migration`.
|
||||||
|
4. The application will be available at `http://localhost:8080`.
|
||||||
|
|
||||||
|
To stop the environment and clean the database volume (if needed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 API Versioning
|
||||||
|
|
||||||
|
All API endpoints utilize Query Parameter Versioning to keep URLs clean and manageable.
|
||||||
|
Example: GET /api/users/me?v=1
|
||||||
|
|
||||||
|
## 🐳 CI/CD Pipeline
|
||||||
|
|
||||||
|
The project uses Gitea Actions for Continuous Integration and Deployment.
|
||||||
|
|
||||||
|
Quality Gate: Runs ./mvnw clean verify to ensure tests and builds pass.
|
||||||
|
|
||||||
|
Docker Build: Packages the application using a multi-stage Dockerfile.
|
||||||
|
|
||||||
|
Registry: Pushes the lightweight Alpine-based image to the private Gitea container registry (git.andreas-dahm.eu/lobo_corp/tbp-auth-service:latest).
|
||||||
|
|
||||||
|
## 📈 Observability
|
||||||
|
|
||||||
|
Metrics and health checks are exposed out-of-the-box via Spring Boot Actuator:
|
||||||
|
|
||||||
|
Health: http://localhost:8080/actuator/health
|
||||||
|
|
||||||
|
Prometheus Metrics: http://localhost:8080/actuator/prometheus
|
||||||
|
|
||||||
|
## 🗺️ Roadmap / Open Topics
|
||||||
|
|
||||||
|
Tracking known work that is not yet implemented. Each item is independently actionable.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- ~~**Switch JWT signing from HS256 → EdDSA (Ed25519).**~~ Done. Auth signs with an Ed25519 private key (`JWT_PRIVATE_KEY`), Core verifies with the matching public key (`JWT_PUBLIC_KEY`). `kid` header (`JWT_KID`) is emitted to support future rotation. See `JwtService`, `JwtConfig`, `application.yml`, `JwtServiceTest`, and `ToBePlayed-Deployment/README.md` for the rollout / rotation procedure.
|
||||||
|
- **Refresh-token rotation.** Current access tokens last 24h with no revocation path. Split into a short-lived (5–15 min) access token and a long-lived (7–30 day) refresh token, rotated on every use, persisted server-side so logout / password change / "log out everywhere" actually revoke. Add a `token_version` column on `users` (or a `jti` blacklist table) checked on each refresh.
|
||||||
|
- **Move JWT delivery to an httpOnly `Secure` cookie.** Tokens are currently returned in the response body and stored by the frontend in `localStorage`, exposing them to any XSS payload. Set the cookie server-side, scope it to `.tbp.andreas-dahm.eu`, and add CSRF protection (Spring Security's `CookieCsrfTokenRepository.withHttpOnlyFalse()`).
|
||||||
|
- **Tighten CORS for cookie auth.** Restrict allowed origins to `https://tbp.andreas-dahm.eu` (plus the local dev origin) and set `allowCredentials=true`. Required before switching to cookie-based JWT.
|
||||||
|
- **Rate-limit `/api/auth/login` and `/api/registration/**`.** Currently no brake on credential stuffing or registration spam. Bucket4j or Spring Security's built-in `RateLimiter` are both options; key by IP + email.
|
||||||
|
|
||||||
|
### Observability and API
|
||||||
|
|
||||||
|
- **Publish a JWKS endpoint** (`GET /.well-known/jwks.json`) once on EdDSA. Lets Core (and any future verifier) fetch the public key automatically and supports key rotation without redeploying every consumer.
|
||||||
|
- **API versioning review.** Query-parameter versioning (`?v=1`) is unusual; path- or `Accept`-header-based versioning is more conventional and plays better with HTTP caches. Worth a deliberate decision before more endpoints accumulate.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- The abstract test bases under `auth_service.shared.test` already cover unit / integration / persistence / WebMvc slices. Continue adding controller-level `AbstractWebMvcTest` coverage for any new endpoint and persistence-layer tests for any new repository.
|
||||||
|
|
||||||
|
## 🎯 Product Roadmap (Auth-owned slices)
|
||||||
|
|
||||||
|
The full cross-service product roadmap lives in `ToBePlayed-Deployment/README.md`. This section lists only the slices that land in Auth.
|
||||||
|
|
||||||
|
### 1. ~~Username + public-profile opt-in~~ Done
|
||||||
|
|
||||||
|
- Display name (3–24 chars, unique case-insensitive) is the public handle; `/u/<display name>` in the frontend.
|
||||||
|
- `user_settings.public_profile` (default `false`), set via `PUT /api/users/settings` (`publicProfile`). A public
|
||||||
|
profile needs a display name (400 otherwise).
|
||||||
|
- `GET /api/public/profiles/{displayName}` → `{displayName, memberSince, ticket}` for enabled users with a public
|
||||||
|
profile; everything else is the same 404 `PROFILE_NOT_FOUND`.
|
||||||
|
- The `ticket` is an EdDSA JWT (`typ=public-profile`, `profileUserId`, **valid 10 minutes**) for Core's
|
||||||
|
`GET /api/public/profiles/library` (header `X-Profile-Ticket`). It is no login: `JwtService.isTokenValid` rejects
|
||||||
|
every token with a `typ` claim, in Auth and in Core. Accepted trade-off: after a user makes the profile private, a
|
||||||
|
ticket issued before still reads the public data for up to 10 minutes; Core stores no copy of the flag.
|
||||||
|
|
||||||
|
### 2. Friends / social graph
|
||||||
|
|
||||||
|
Once usernames exist:
|
||||||
|
|
||||||
|
- Schema: `user_friendship(user_id, friend_id, status, requested_at, accepted_at)` with `status` in `PENDING / ACCEPTED / BLOCKED`. Symmetric pair or single-row with a canonical ordering — pick one and stick with it.
|
||||||
|
- Endpoints: `POST /api/users/me/friends?username=...` (request), `POST /api/users/me/friends/{id}/accept`, `DELETE /api/users/me/friends/{id}`, `GET /api/users/me/friends`.
|
||||||
|
- Optional: discovery via the Steam friends API (Auth holds the `steam_id`; the friends list call itself can live in Core's `steam_adapter`, with Auth resolving Steam IDs back to TBP users).
|
||||||
|
|
||||||
|
### 3. Email-driven retention
|
||||||
|
|
||||||
|
The weekly digest, sale alerts, and friend-request notifications all need email infrastructure:
|
||||||
|
|
||||||
|
- Spring's `JavaMailSender` + templating (Thymeleaf or plain interpolation). Configure SMTP via env vars (see Deployment roadmap).
|
||||||
|
- Per-user notification preferences: `notify_digest_weekly`, `notify_sale_alerts`, `notify_friend_requests` — all booleans on `users` with sensible defaults.
|
||||||
|
- Endpoint to render `/api/users/me/notification-preferences` and update via `PUT`.
|
||||||
|
- Triggered by either Core scheduled jobs (digest, sale alerts) calling an internal Auth endpoint, or by a shared message queue if/when one is introduced.
|
||||||
|
|
||||||
|
### 4. Invitation system enhancements
|
||||||
|
|
||||||
|
Today registration validates an `INVALID_INVITATION_CODE` — so invites already exist. Worth surfacing:
|
||||||
|
|
||||||
|
- Endpoint: `POST /api/users/me/invitations` to generate a single-use code (rate-limited, expires after N days). Used for friend invites and onboarding referrals.
|
||||||
|
- Endpoint: `GET /api/users/me/invitations` to list outstanding codes.
|
||||||
|
- Optional: tie invitation acceptance to an auto-friend-request so referred users are already connected on first login.
|
||||||
|
|
||||||
|
### 5. Account deletion / GDPR
|
||||||
|
|
||||||
|
- `DELETE /api/users/me` that soft-deletes (sets `deleted_at`, anonymises PII, keeps the row for foreign-key integrity), or hard-deletes with cascade depending on policy.
|
||||||
|
- Data export endpoint: `GET /api/users/me/export` returning a downloadable JSON bundle of everything Auth knows about the user. Core should expose an equivalent and the Frontend can stitch them.
|
||||||
|
- Document the data-retention policy here once decided.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package auth_service;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class AuthServiceApplication {
|
||||||
|
static void main(String[] args) {
|
||||||
|
SpringApplication.run(AuthServiceApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package auth_service.configuration;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableAsync
|
||||||
|
public class AsyncConfig {
|
||||||
|
// This class enables Spring's @Async annotation functionality globally
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package auth_service.configuration;
|
||||||
|
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
import org.springframework.cache.annotation.EnableCaching;
|
||||||
|
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global configuration to enable Spring Boot caching (backed by Caffeine).
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableCaching
|
||||||
|
@Profile("!dev")
|
||||||
|
@Slf4j
|
||||||
|
public class CacheConfig {
|
||||||
|
@Bean
|
||||||
|
public CacheManager cacheManager() {
|
||||||
|
log.info("########### Productive cache active ###########");
|
||||||
|
CaffeineCacheManager cacheManager = new CaffeineCacheManager("usersByEmail", "publicStats");
|
||||||
|
cacheManager.setCaffeine(caffeineCacheBuilder());
|
||||||
|
return cacheManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
Caffeine<Object, Object> caffeineCacheBuilder() {
|
||||||
|
return Caffeine.newBuilder()
|
||||||
|
.initialCapacity(100)
|
||||||
|
.maximumSize(500)
|
||||||
|
.expireAfterAccess(10, TimeUnit.MINUTES)
|
||||||
|
.recordStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package auth_service.configuration;
|
||||||
|
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
import org.springframework.cache.annotation.EnableCaching;
|
||||||
|
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global configuration to enable Spring Boot caching (backed by Caffeine).
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableCaching
|
||||||
|
@Profile("dev")
|
||||||
|
@Slf4j
|
||||||
|
public class DevCacheConfig {
|
||||||
|
@Bean
|
||||||
|
public CacheManager cacheManager() {
|
||||||
|
log.error("########### DEVELOPMENT CACHE ACTIVE ###########");
|
||||||
|
CaffeineCacheManager cacheManager = new CaffeineCacheManager("usersByEmail", "publicStats");
|
||||||
|
cacheManager.setCaffeine(caffeineCacheBuilder());
|
||||||
|
return cacheManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
Caffeine<Object, Object> caffeineCacheBuilder() {
|
||||||
|
return Caffeine.newBuilder()
|
||||||
|
.initialCapacity(100)
|
||||||
|
.maximumSize(500)
|
||||||
|
.expireAfterAccess(1, TimeUnit.SECONDS)
|
||||||
|
.recordStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package auth_service.configuration;
|
||||||
|
|
||||||
|
import io.micrometer.core.aop.TimedAspect;
|
||||||
|
import io.micrometer.core.instrument.MeterRegistry;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for application observability and metrics.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class ObservabilityConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the TimedAspect bean to enable @Timed annotations.
|
||||||
|
* * @param registry The Micrometer MeterRegistry
|
||||||
|
*
|
||||||
|
* @return A new TimedAspect instance
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public TimedAspect timedAspect(MeterRegistry registry) {
|
||||||
|
return new TimedAspect(registry);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package auth_service.configuration.security;
|
||||||
|
|
||||||
|
import auth_service.shared.security.UserPrincipal;
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.jspecify.annotations.NonNull;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private final JwtService jwtService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(
|
||||||
|
HttpServletRequest request,
|
||||||
|
@NonNull HttpServletResponse response,
|
||||||
|
@NonNull FilterChain filterChain
|
||||||
|
) throws ServletException, IOException {
|
||||||
|
|
||||||
|
final String authHeader = request.getHeader("Authorization");
|
||||||
|
|
||||||
|
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String jwt = authHeader.substring(7);
|
||||||
|
if (!jwtService.isTokenValid(jwt)) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final String userEmail = jwtService.extractEmail(jwt);
|
||||||
|
final String userId = jwtService.extractUserId(jwt);
|
||||||
|
final Long steamId = jwtService.extractSteamId(jwt);
|
||||||
|
|
||||||
|
if (userEmail != null && userId != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||||
|
|
||||||
|
UserPrincipal principal = new UserPrincipal(userId, userEmail, steamId);
|
||||||
|
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
|
||||||
|
principal,
|
||||||
|
null,
|
||||||
|
Collections.emptyList()
|
||||||
|
);
|
||||||
|
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("JWT Validation failed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package auth_service.configuration.security;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Configuration
|
||||||
|
@ConfigurationProperties(prefix = "security.jwt")
|
||||||
|
public class JwtConfig {
|
||||||
|
|
||||||
|
/** Base64-encoded PKCS8 DER Ed25519 private key. Used to sign JWTs. */
|
||||||
|
private String privateKey;
|
||||||
|
|
||||||
|
/** Base64-encoded X.509 DER Ed25519 public key. Embedded so JWKS can publish it. */
|
||||||
|
private String publicKey;
|
||||||
|
|
||||||
|
/** Key identifier set in the JWS `kid` header. Enables future rotation. */
|
||||||
|
private String kid;
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package auth_service.configuration.security;
|
||||||
|
|
||||||
|
import auth_service.shared.security.ProfileTicketIssuer;
|
||||||
|
import auth_service.shared.security.TokenGenerator;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.security.KeyFactory;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.security.PublicKey;
|
||||||
|
import java.security.spec.PKCS8EncodedKeySpec;
|
||||||
|
import java.security.spec.X509EncodedKeySpec;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service responsible for generating and validating JSON Web Tokens (JWT).
|
||||||
|
* Signing: EdDSA (Ed25519). Auth holds the private key and signs; Core holds only the public key and verifies.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class JwtService implements TokenGenerator, ProfileTicketIssuer {
|
||||||
|
|
||||||
|
// Token validity: 24 hours in milliseconds
|
||||||
|
private static final long JWT_EXPIRATION_MS = 86400000;
|
||||||
|
private static final long PROFILE_TICKET_EXPIRATION_MS = 10 * 60 * 1000;
|
||||||
|
// Only non-login tokens carry this claim; isTokenValid rejects them.
|
||||||
|
private static final String TYPE_CLAIM = "typ";
|
||||||
|
private static final String PROFILE_TICKET_TYPE = "public-profile";
|
||||||
|
private static final String ED25519 = "Ed25519";
|
||||||
|
|
||||||
|
private final JwtConfig jwtConfig;
|
||||||
|
private PrivateKey privateKey;
|
||||||
|
private PublicKey publicKey;
|
||||||
|
|
||||||
|
public JwtService(JwtConfig jwtConfig) {
|
||||||
|
this.jwtConfig = jwtConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void init() {
|
||||||
|
this.privateKey = decodePrivateKey(jwtConfig.getPrivateKey());
|
||||||
|
this.publicKey = decodePublicKey(jwtConfig.getPublicKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String generateToken(String userId, String email, Long steamId) {
|
||||||
|
var builder = Jwts.builder()
|
||||||
|
.header().keyId(jwtConfig.getKid()).and()
|
||||||
|
.subject(email)
|
||||||
|
.claim("userId", userId)
|
||||||
|
.issuedAt(new Date(System.currentTimeMillis()))
|
||||||
|
.expiration(new Date(System.currentTimeMillis() + JWT_EXPIRATION_MS));
|
||||||
|
|
||||||
|
if (steamId != null) {
|
||||||
|
builder.claim("steamId", steamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder
|
||||||
|
.signWith(privateKey, Jwts.SIG.EdDSA)
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String generateProfileTicket(long userId) {
|
||||||
|
return Jwts.builder()
|
||||||
|
.header().keyId(jwtConfig.getKid()).and()
|
||||||
|
.subject(PROFILE_TICKET_TYPE)
|
||||||
|
.claim(TYPE_CLAIM, PROFILE_TICKET_TYPE)
|
||||||
|
.claim("profileUserId", String.valueOf(userId))
|
||||||
|
.issuedAt(new Date(System.currentTimeMillis()))
|
||||||
|
.expiration(new Date(System.currentTimeMillis() + PROFILE_TICKET_EXPIRATION_MS))
|
||||||
|
.signWith(privateKey, Jwts.SIG.EdDSA)
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String extractEmail(String token) {
|
||||||
|
return Jwts.parser()
|
||||||
|
.verifyWith(publicKey)
|
||||||
|
.build()
|
||||||
|
.parseSignedClaims(token)
|
||||||
|
.getPayload()
|
||||||
|
.getSubject();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String extractUserId(String token) {
|
||||||
|
return Jwts.parser()
|
||||||
|
.verifyWith(publicKey)
|
||||||
|
.build()
|
||||||
|
.parseSignedClaims(token)
|
||||||
|
.getPayload()
|
||||||
|
.get("userId", String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long extractSteamId(String token) {
|
||||||
|
return Jwts.parser()
|
||||||
|
.verifyWith(publicKey)
|
||||||
|
.build()
|
||||||
|
.parseSignedClaims(token)
|
||||||
|
.getPayload()
|
||||||
|
.get("steamId", Long.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for a login token with a valid signature. A profile ticket is signed with the same key, but is no login.
|
||||||
|
*/
|
||||||
|
public boolean isTokenValid(String token) {
|
||||||
|
try {
|
||||||
|
var claims = Jwts.parser()
|
||||||
|
.verifyWith(publicKey)
|
||||||
|
.build()
|
||||||
|
.parseSignedClaims(token)
|
||||||
|
.getPayload();
|
||||||
|
return claims.get(TYPE_CLAIM) == null;
|
||||||
|
} catch (Exception _) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PrivateKey decodePrivateKey(String base64Pkcs8Der) {
|
||||||
|
try {
|
||||||
|
byte[] keyBytes = Base64.getDecoder().decode(base64Pkcs8Der);
|
||||||
|
return KeyFactory.getInstance(ED25519).generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Failed to decode Ed25519 private key", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PublicKey decodePublicKey(String base64X509Der) {
|
||||||
|
try {
|
||||||
|
byte[] keyBytes = Base64.getDecoder().decode(base64X509Der);
|
||||||
|
return KeyFactory.getInstance(ED25519).generatePublic(new X509EncodedKeySpec(keyBytes));
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Failed to decode Ed25519 public key", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package auth_service.configuration.security;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global security configuration for the Authentication Service.
|
||||||
|
* Implements whitelist routing and environment-specific CORS.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Whitelist: Endpoints that do NOT require authentication.
|
||||||
|
*/
|
||||||
|
private static final String[] WHITE_LIST_URLS = {
|
||||||
|
"/api/auth/**", // Allow login, registration, etc.
|
||||||
|
"/api/registration/**", // Allow new user registration
|
||||||
|
"/api/stats/public", // Public landing-page counters (cached, no PII)
|
||||||
|
"/api/public/**", // Opt-in public profiles, see PublicProfileController
|
||||||
|
"/actuator/health", // Docker health check; details are off
|
||||||
|
"/error" // Default Spring Boot error page routing
|
||||||
|
};
|
||||||
|
|
||||||
|
private final JwtAuthenticationFilter jwtAuthFilter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects the allowed CORS origins from the active application.yml profile.
|
||||||
|
*/
|
||||||
|
@Value("${app.cors.allowed-origins}")
|
||||||
|
private String[] allowedOrigins;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the password encoder used for hashing passwords.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public PasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the global CORS configuration.
|
||||||
|
* Using a dedicated Bean ensures it's properly registered in the filter chain.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
|
|
||||||
|
// Use OriginPatterns instead of AllowedOrigins to safely support "*" wildcards
|
||||||
|
configuration.setAllowedOriginPatterns(Arrays.asList(allowedOrigins));
|
||||||
|
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||||
|
configuration.setAllowedHeaders(List.of("*"));
|
||||||
|
// Lets the login page read how long a rate-limit block lasts
|
||||||
|
configuration.setExposedHeaders(List.of("Retry-After"));
|
||||||
|
configuration.setAllowCredentials(false); // Set to true later if we use cookies
|
||||||
|
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
// Apply this CORS config to all endpoints
|
||||||
|
source.registerCorsConfiguration("/**", configuration);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) {
|
||||||
|
http
|
||||||
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
|
// Tell Spring Security to use the CorsConfigurationSource bean defined above
|
||||||
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers(WHITE_LIST_URLS).permitAll()
|
||||||
|
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||||
|
.anyRequest().authenticated()
|
||||||
|
)
|
||||||
|
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package auth_service.modules.login.api;
|
||||||
|
|
||||||
|
import auth_service.modules.login.api.dto.request.LoginRequest;
|
||||||
|
import auth_service.modules.login.api.dto.response.LoginResponse;
|
||||||
|
import auth_service.modules.login.api.mapper.ApiMapper;
|
||||||
|
import auth_service.modules.login.service.LoginService;
|
||||||
|
import io.micrometer.core.annotation.Timed;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API handling authentication requests.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/auth")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class LoginController {
|
||||||
|
|
||||||
|
private final LoginService loginService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates a user based on email and password.
|
||||||
|
*
|
||||||
|
* @param request The login credentials
|
||||||
|
* @return HTTP 200 on success, HTTP 401 on failure
|
||||||
|
*/
|
||||||
|
@PostMapping("/login")
|
||||||
|
@Timed(value = "auth.login.requests.time", description = "Time taken to process login requests")
|
||||||
|
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest) {
|
||||||
|
|
||||||
|
var user = ApiMapper.toModel(request);
|
||||||
|
var token = loginService.login(user, clientIp(httpRequest));
|
||||||
|
return ResponseEntity.ok(new LoginResponse("Login successful", token));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traefik appends the real client IP as the last X-Forwarded-For entry. Earlier entries come
|
||||||
|
* from the client and can be faked, so only the last one counts.
|
||||||
|
*/
|
||||||
|
private static String clientIp(HttpServletRequest request) {
|
||||||
|
String forwardedFor = request.getHeader("X-Forwarded-For");
|
||||||
|
if (forwardedFor == null || forwardedFor.isBlank()) {
|
||||||
|
return request.getRemoteAddr();
|
||||||
|
}
|
||||||
|
String[] hops = forwardedFor.split(",");
|
||||||
|
return hops[hops.length - 1].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package auth_service.modules.login.api;
|
||||||
|
|
||||||
|
import auth_service.shared.api.dto.response.ErrorResponse;
|
||||||
|
import auth_service.modules.login.exception.InvalidCredentialsException;
|
||||||
|
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
||||||
|
import lombok.NonNull;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
@Order(value = 1)
|
||||||
|
@Slf4j
|
||||||
|
public class LoginExceptionController {
|
||||||
|
private static @NonNull ResponseEntity<ErrorResponse> response(ErrorResponse body, HttpStatus status) {
|
||||||
|
return ResponseEntity.status(status.value())
|
||||||
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
|
.body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
######## CLIENT ERRORS (500-599)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
######## CLIENT ERRORS (400-499)
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(TooManyLoginAttemptsException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> clientError(TooManyLoginAttemptsException ex) {
|
||||||
|
var body = new ErrorResponse("TOO_MANY_ATTEMPTS", ex.getMessage());
|
||||||
|
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||||
|
.header(HttpHeaders.RETRY_AFTER, String.valueOf(ex.getRetryAfterSeconds()))
|
||||||
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
|
.body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(InvalidCredentialsException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> clientError(InvalidCredentialsException ex) {
|
||||||
|
var body = new ErrorResponse("INVALID_CREDENTIALS", ex.getMessage());
|
||||||
|
return response(body, HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package auth_service.modules.login.api.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO (Data Transfer Object) representing the incoming login request.
|
||||||
|
*/
|
||||||
|
public record LoginRequest(@NotNull String email, @NotNull @NotBlank String password) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package auth_service.modules.login.api.dto.response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO representing the outgoing login response.
|
||||||
|
* The email will be null if the login fails.
|
||||||
|
*/
|
||||||
|
public record LoginResponse(String message, String token) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package auth_service.modules.login.api.mapper;
|
||||||
|
|
||||||
|
import auth_service.modules.login.api.dto.request.LoginRequest;
|
||||||
|
import auth_service.modules.login.model.User;
|
||||||
|
|
||||||
|
public abstract class ApiMapper {
|
||||||
|
private ApiMapper() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static User toModel(LoginRequest loginRequest) {
|
||||||
|
return User.builder().email(loginRequest.email()).password(loginRequest.password()).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package auth_service.modules.login.exception;
|
||||||
|
|
||||||
|
public class InvalidCredentialsException extends RuntimeException {
|
||||||
|
public InvalidCredentialsException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package auth_service.modules.login.exception;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public class TooManyLoginAttemptsException extends RuntimeException {
|
||||||
|
private final long retryAfterSeconds;
|
||||||
|
|
||||||
|
public TooManyLoginAttemptsException(long retryAfterSeconds) {
|
||||||
|
super("Too many failed login attempts. Try again later.");
|
||||||
|
this.retryAfterSeconds = retryAfterSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package auth_service.modules.login.model;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
@Data
|
||||||
|
public class User {
|
||||||
|
private long id;
|
||||||
|
private String email;
|
||||||
|
private String password;
|
||||||
|
private Long steamId;
|
||||||
|
private boolean enabled;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package auth_service.modules.login.persistence;
|
||||||
|
|
||||||
|
import auth_service.modules.login.model.User;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for database operations regarding the User entity.
|
||||||
|
*/
|
||||||
|
public interface MinimalUserRepository {
|
||||||
|
/**
|
||||||
|
* Finds a user by their email address.
|
||||||
|
* * @param email The email to search for
|
||||||
|
*
|
||||||
|
* @return An Optional containing the user if found
|
||||||
|
*/
|
||||||
|
Optional<User> findByEmail(String email);
|
||||||
|
|
||||||
|
void updateLoginDate(long userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package auth_service.modules.login.persistence;
|
||||||
|
|
||||||
|
import auth_service.modules.login.model.User;
|
||||||
|
import auth_service.modules.login.persistence.entities.MinimalUserEntity;
|
||||||
|
import auth_service.modules.login.persistence.mapper.EntityMapper;
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import jakarta.persistence.PersistenceContext;
|
||||||
|
import jakarta.persistence.TypedQuery;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
@Transactional
|
||||||
|
public class MinimalUserRepositoryImpl implements MinimalUserRepository {
|
||||||
|
@PersistenceContext
|
||||||
|
private EntityManager entityManager;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<User> findByEmail(String email) {
|
||||||
|
var criteriaBuilder = entityManager.getCriteriaBuilder();
|
||||||
|
var userEntityCriteriaQuery = criteriaBuilder.createQuery(MinimalUserEntity.class);
|
||||||
|
var userEntity = userEntityCriteriaQuery.from(MinimalUserEntity.class);
|
||||||
|
|
||||||
|
// Build the query: SELECT u FROM UserEntity u WHERE u.email = :email
|
||||||
|
userEntityCriteriaQuery.select(userEntity).where(criteriaBuilder.equal(userEntity.get("email"), email));
|
||||||
|
|
||||||
|
TypedQuery<MinimalUserEntity> typedQuery = entityManager.createQuery(userEntityCriteriaQuery);
|
||||||
|
|
||||||
|
return typedQuery.getResultStream()
|
||||||
|
.findFirst()
|
||||||
|
.map(EntityMapper::mapToModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void updateLoginDate(long userId) {
|
||||||
|
var userEntity = entityManager.find(MinimalUserEntity.class, userId);
|
||||||
|
|
||||||
|
if (userEntity != null) {
|
||||||
|
userEntity.setLastLoginDate(LocalDateTime.now(Clock.systemUTC()));
|
||||||
|
entityManager.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package auth_service.modules.login.persistence.entities;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a user entity mapped to the 'users' table in PostgreSQL.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "users")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class MinimalUserEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(unique = true, nullable = false)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private boolean enabled;
|
||||||
|
|
||||||
|
@Column(name = "last_login_date")
|
||||||
|
private LocalDateTime lastLoginDate;
|
||||||
|
|
||||||
|
@Column(name = "steam_id")
|
||||||
|
private Long steamId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package auth_service.modules.login.persistence.mapper;
|
||||||
|
|
||||||
|
import auth_service.modules.login.model.User;
|
||||||
|
import auth_service.modules.login.persistence.entities.MinimalUserEntity;
|
||||||
|
|
||||||
|
public abstract class EntityMapper {
|
||||||
|
|
||||||
|
private EntityMapper() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static User mapToModel(MinimalUserEntity entity) {
|
||||||
|
User model = User.builder().build();
|
||||||
|
|
||||||
|
if (entity != null) {
|
||||||
|
model.setId(entity.getId());
|
||||||
|
model.setEmail(entity.getEmail());
|
||||||
|
model.setPassword(entity.getPassword());
|
||||||
|
model.setSteamId(entity.getSteamId());
|
||||||
|
model.setEnabled(entity.isEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package auth_service.modules.login.service;
|
||||||
|
|
||||||
|
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
||||||
|
import com.github.benmanes.caffeine.cache.Cache;
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts failed logins per email and per client IP in memory. A key is blocked after too many
|
||||||
|
* failures until LOCK_DURATION has passed since its last failure. A restart resets all counters.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class LoginAttemptService {
|
||||||
|
|
||||||
|
static final int MAX_FAILURES_PER_EMAIL = 5;
|
||||||
|
static final int MAX_FAILURES_PER_IP = 20;
|
||||||
|
static final Duration LOCK_DURATION = Duration.ofMinutes(15);
|
||||||
|
|
||||||
|
private final Cache<String, AtomicInteger> failures = Caffeine.newBuilder()
|
||||||
|
.expireAfterWrite(LOCK_DURATION)
|
||||||
|
.maximumSize(100_000)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
public void checkAllowed(String email, String clientIp) {
|
||||||
|
if (count(emailKey(email)) >= MAX_FAILURES_PER_EMAIL || count(ipKey(clientIp)) >= MAX_FAILURES_PER_IP) {
|
||||||
|
throw new TooManyLoginAttemptsException(LOCK_DURATION.toSeconds());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordFailure(String email, String clientIp) {
|
||||||
|
increment(emailKey(email));
|
||||||
|
increment(ipKey(clientIp));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordSuccess(String email) {
|
||||||
|
failures.invalidate(emailKey(email));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int count(String key) {
|
||||||
|
AtomicInteger counter = failures.getIfPresent(key);
|
||||||
|
return counter == null ? 0 : counter.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void increment(String key) {
|
||||||
|
// put() again so expireAfterWrite restarts the lock window with every failure
|
||||||
|
AtomicInteger counter = failures.get(key, k -> new AtomicInteger());
|
||||||
|
counter.incrementAndGet();
|
||||||
|
failures.put(key, counter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String emailKey(String email) {
|
||||||
|
return "email:" + (email == null ? "" : email.trim().toLowerCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String ipKey(String clientIp) {
|
||||||
|
return "ip:" + clientIp;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package auth_service.modules.login.service;
|
||||||
|
|
||||||
|
import auth_service.shared.security.TokenGenerator;
|
||||||
|
import auth_service.modules.login.exception.InvalidCredentialsException;
|
||||||
|
import auth_service.modules.login.model.User;
|
||||||
|
import auth_service.modules.login.persistence.MinimalUserRepository;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class LoginService {
|
||||||
|
private final MinimalUserRepository minimalUserRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final TokenGenerator tokenGenerator;
|
||||||
|
private final LoginAttemptService loginAttemptService;
|
||||||
|
|
||||||
|
// Checked against for unknown emails, so they cost the same bcrypt time as a wrong password.
|
||||||
|
private final String dummyHash;
|
||||||
|
|
||||||
|
public LoginService(MinimalUserRepository minimalUserRepository, PasswordEncoder passwordEncoder,
|
||||||
|
TokenGenerator tokenGenerator, LoginAttemptService loginAttemptService) {
|
||||||
|
this.minimalUserRepository = minimalUserRepository;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.tokenGenerator = tokenGenerator;
|
||||||
|
this.loginAttemptService = loginAttemptService;
|
||||||
|
this.dummyHash = passwordEncoder.encode(UUID.randomUUID().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates a user and returns a token. Unknown email, wrong password and disabled
|
||||||
|
* account all fail with the same InvalidCredentialsException.
|
||||||
|
*
|
||||||
|
* @return The generated token
|
||||||
|
*/
|
||||||
|
public String login(User userToLogin, String clientIp) {
|
||||||
|
String email = userToLogin.getEmail();
|
||||||
|
loginAttemptService.checkAllowed(email, clientIp);
|
||||||
|
|
||||||
|
Optional<User> foundUser = minimalUserRepository.findByEmail(email);
|
||||||
|
String hash = foundUser.map(User::getPassword).orElse(dummyHash);
|
||||||
|
boolean passwordMatches = passwordEncoder.matches(userToLogin.getPassword(), hash);
|
||||||
|
|
||||||
|
if (foundUser.isEmpty() || !passwordMatches || !foundUser.get().isEnabled()) {
|
||||||
|
loginAttemptService.recordFailure(email, clientIp);
|
||||||
|
throw new InvalidCredentialsException("Invalid email or password");
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = foundUser.get();
|
||||||
|
loginAttemptService.recordSuccess(email);
|
||||||
|
minimalUserRepository.updateLoginDate(user.getId());
|
||||||
|
return tokenGenerator.generateToken(String.valueOf(user.getId()), user.getEmail(), user.getSteamId());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package auth_service.modules.profile.api;
|
||||||
|
|
||||||
|
import auth_service.modules.profile.api.dto.PublicProfileResponse;
|
||||||
|
import auth_service.modules.profile.service.PublicProfileService;
|
||||||
|
import auth_service.shared.api.dto.response.ErrorResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/public/profiles")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PublicProfileController {
|
||||||
|
|
||||||
|
private final PublicProfileService publicProfileService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unknown, disabled and private profiles get the same 404, so the answer does not tell them apart.
|
||||||
|
*/
|
||||||
|
@GetMapping("/{displayName}")
|
||||||
|
public ResponseEntity<?> getPublicProfile(@PathVariable("displayName") String displayName) {
|
||||||
|
return publicProfileService.findPublicProfile(displayName)
|
||||||
|
.<ResponseEntity<?>>map(p -> ResponseEntity.ok(new PublicProfileResponse(
|
||||||
|
p.profile().displayName(), p.profile().memberSince(), p.ticket())))
|
||||||
|
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body(new ErrorResponse("PROFILE_NOT_FOUND", "No public profile with this name")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package auth_service.modules.profile.api.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param ticket for Core's public profile endpoint; valid for a few minutes
|
||||||
|
*/
|
||||||
|
public record PublicProfileResponse(String displayName, LocalDate memberSince, String ticket) {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package auth_service.modules.profile.model;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
public record PublicProfile(long userId, String displayName, LocalDate memberSince) {}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package auth_service.modules.profile.repo;
|
||||||
|
|
||||||
|
import auth_service.modules.profile.model.PublicProfile;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class PublicProfileRepository {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public PublicProfileRepository(JdbcTemplate jdbc) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only enabled users who switched the public profile on. The name ignores case, like its unique index.
|
||||||
|
*/
|
||||||
|
public Optional<PublicProfile> findPublicByDisplayName(String displayName) {
|
||||||
|
return jdbc.query("""
|
||||||
|
SELECT u.id, u.display_name, u.registration_date
|
||||||
|
FROM users u JOIN user_settings s ON s.user_id = u.id
|
||||||
|
WHERE LOWER(u.display_name) = LOWER(?) AND u.enabled AND s.public_profile
|
||||||
|
""",
|
||||||
|
(rs, row) -> new PublicProfile(rs.getLong("id"), rs.getString("display_name"),
|
||||||
|
rs.getTimestamp("registration_date") != null
|
||||||
|
? rs.getTimestamp("registration_date").toLocalDateTime().toLocalDate() : null),
|
||||||
|
displayName).stream().findFirst();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package auth_service.modules.profile.service;
|
||||||
|
|
||||||
|
import auth_service.modules.profile.model.PublicProfile;
|
||||||
|
import auth_service.modules.profile.repo.PublicProfileRepository;
|
||||||
|
import auth_service.shared.security.ProfileTicketIssuer;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PublicProfileService {
|
||||||
|
|
||||||
|
public record TicketedProfile(PublicProfile profile, String ticket) {}
|
||||||
|
|
||||||
|
private final PublicProfileRepository publicProfileRepository;
|
||||||
|
private final ProfileTicketIssuer profileTicketIssuer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return empty for an unknown, disabled or private profile
|
||||||
|
*/
|
||||||
|
public Optional<TicketedProfile> findPublicProfile(String displayName) {
|
||||||
|
return publicProfileRepository.findPublicByDisplayName(displayName)
|
||||||
|
.map(profile -> new TicketedProfile(profile, profileTicketIssuer.generateProfileTicket(profile.userId())));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package auth_service.modules.registration.api;
|
||||||
|
|
||||||
|
import auth_service.modules.registration.api.dto.mapper.ApiMapper;
|
||||||
|
import auth_service.modules.registration.api.dto.request.RegistrationRequest;
|
||||||
|
import auth_service.modules.registration.api.dto.response.RegistrationResponse;
|
||||||
|
import auth_service.modules.registration.service.RegistrationService;
|
||||||
|
import io.micrometer.core.annotation.Timed;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/registration")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RegistrationController {
|
||||||
|
|
||||||
|
private final RegistrationService registrationService;
|
||||||
|
|
||||||
|
@PostMapping(value = "/new", version = "1.0", produces = "application/json", consumes = "application/json")
|
||||||
|
@Timed(value = "auth.registration.new.requests.time", description = "Time taken to process registration requests")
|
||||||
|
public ResponseEntity<RegistrationResponse> registration(@Valid @RequestBody RegistrationRequest request) {
|
||||||
|
var userId = registrationService.register(ApiMapper.mapToModel(request));
|
||||||
|
return ResponseEntity.ok(RegistrationResponse.builder().userId(userId).build());
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
package auth_service.modules.registration.api;
|
||||||
|
|
||||||
|
import auth_service.shared.api.dto.response.ErrorResponse;
|
||||||
|
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
|
||||||
|
import auth_service.modules.registration.exception.InvalidInvitationCodeException;
|
||||||
|
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
|
||||||
|
import auth_service.modules.registration.exception.SteamIdAlreadyRegisteredException;
|
||||||
|
import lombok.NonNull;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
@Order(value = 1)
|
||||||
|
@Slf4j
|
||||||
|
public class RegistrationExceptionController {
|
||||||
|
|
||||||
|
private static @NonNull ResponseEntity<ErrorResponse> response(ErrorResponse body, HttpStatus status) {
|
||||||
|
return ResponseEntity.status(status.value())
|
||||||
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
|
.body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(EmailAlreadyInUseException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> clientError(EmailAlreadyInUseException ex) {
|
||||||
|
var body = new ErrorResponse("EMAIL_ALREADY_IN_USE", ex.getMessage());
|
||||||
|
return response(body, HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(InvalidInvitationCodeException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> clientError(InvalidInvitationCodeException ex) {
|
||||||
|
var body = new ErrorResponse("INVALID_INVITATION_CODE", ex.getMessage());
|
||||||
|
return response(body, HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(DisplayNameAlreadyRegisteredException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> clientError(DisplayNameAlreadyRegisteredException ex) {
|
||||||
|
var body = new ErrorResponse("DISPLAY_NAME_ALREADY_IN_USE", ex.getMessage());
|
||||||
|
return response(body, HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(SteamIdAlreadyRegisteredException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> clientError(SteamIdAlreadyRegisteredException ex) {
|
||||||
|
var body = new ErrorResponse("STEAM_ID_ALREADY_IN_USE", ex.getMessage());
|
||||||
|
return response(body, HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package auth_service.modules.registration.api.dto.mapper;
|
||||||
|
|
||||||
|
import auth_service.modules.registration.api.dto.request.RegistrationRequest;
|
||||||
|
import auth_service.modules.registration.model.Registration;
|
||||||
|
|
||||||
|
public abstract class ApiMapper {
|
||||||
|
private ApiMapper() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Registration mapToModel(RegistrationRequest registrationRequest) {
|
||||||
|
return Registration.builder().
|
||||||
|
email(registrationRequest.email()).
|
||||||
|
rawPassword(registrationRequest.password()).
|
||||||
|
inviteCode(registrationRequest.inviteCode()).
|
||||||
|
displayName(registrationRequest.displayName()).
|
||||||
|
steamId(registrationRequest.steamId() == null ? null : Long.valueOf(registrationRequest.steamId())).
|
||||||
|
build();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package auth_service.modules.registration.api.dto.request;
|
||||||
|
|
||||||
|
import auth_service.shared.api.validation.DisplayNameFormat;
|
||||||
|
import auth_service.shared.api.validation.SteamIdDeserializer;
|
||||||
|
import auth_service.shared.api.validation.SteamIdFormat;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import lombok.Builder;
|
||||||
|
import tools.jackson.databind.annotation.JsonDeserialize;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
public record RegistrationRequest(String email, String password, String inviteCode,
|
||||||
|
@Pattern(regexp = SteamIdFormat.PATTERN)
|
||||||
|
@JsonDeserialize(using = SteamIdDeserializer.class) String steamId,
|
||||||
|
@Pattern(regexp = DisplayNameFormat.PATTERN) String displayName) {
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package auth_service.modules.registration.api.dto.response;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
public record RegistrationResponse(long userId) {
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package auth_service.modules.registration.exception;
|
||||||
|
|
||||||
|
public class DisplayNameAlreadyRegisteredException extends RuntimeException {
|
||||||
|
public DisplayNameAlreadyRegisteredException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package auth_service.modules.registration.exception;
|
||||||
|
|
||||||
|
public class EmailAlreadyInUseException extends RuntimeException {
|
||||||
|
public EmailAlreadyInUseException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package auth_service.modules.registration.exception;
|
||||||
|
|
||||||
|
public class InvalidInvitationCodeException extends RuntimeException {
|
||||||
|
public InvalidInvitationCodeException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package auth_service.modules.registration.exception;
|
||||||
|
|
||||||
|
public class SteamIdAlreadyRegisteredException extends RuntimeException {
|
||||||
|
public SteamIdAlreadyRegisteredException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package auth_service.modules.registration.model;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
@Data
|
||||||
|
public class Registration {
|
||||||
|
private String email;
|
||||||
|
private String rawPassword;
|
||||||
|
private String encryptedPassword;
|
||||||
|
private String inviteCode;
|
||||||
|
private Long steamId;
|
||||||
|
private String displayName;
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package auth_service.modules.registration.persistence;
|
||||||
|
|
||||||
|
import auth_service.modules.registration.model.Registration;
|
||||||
|
|
||||||
|
public interface RegisteredUserRepository {
|
||||||
|
boolean existsByEmail(String email);
|
||||||
|
|
||||||
|
boolean existsBySteamId(long steamId);
|
||||||
|
|
||||||
|
boolean existsByDisplayName(String displayName);
|
||||||
|
|
||||||
|
long save(Registration registration);
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package auth_service.modules.registration.persistence;
|
||||||
|
|
||||||
|
import auth_service.modules.registration.model.Registration;
|
||||||
|
import auth_service.modules.registration.persistence.mapper.EntityMapper;
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import jakarta.persistence.PersistenceContext;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
@Transactional
|
||||||
|
public class RegisteredUserRepositoryImpl implements RegisteredUserRepository {
|
||||||
|
|
||||||
|
@PersistenceContext
|
||||||
|
private EntityManager entityManager;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean existsByEmail(String email) {
|
||||||
|
var jpql = "SELECT COUNT(u) FROM UserEntity u WHERE u.email = :email";
|
||||||
|
var count = entityManager.createQuery(jpql, Long.class)
|
||||||
|
.setParameter("email", email)
|
||||||
|
.getSingleResult();
|
||||||
|
|
||||||
|
return count != null && count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean existsBySteamId(long steamId) {
|
||||||
|
var jpql = "SELECT COUNT(u) FROM UserEntity u WHERE u.steamId = :steamId";
|
||||||
|
var count = entityManager.createQuery(jpql, Long.class)
|
||||||
|
.setParameter("steamId", steamId)
|
||||||
|
.getSingleResult();
|
||||||
|
|
||||||
|
return count != null && count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean existsByDisplayName(String displayName) {
|
||||||
|
var jpql = "SELECT COUNT(u) FROM UserEntity u WHERE LOWER(u.displayName) = LOWER(:displayName)";
|
||||||
|
var count = entityManager.createQuery(jpql, Long.class)
|
||||||
|
.setParameter("displayName", displayName)
|
||||||
|
.getSingleResult();
|
||||||
|
|
||||||
|
return count != null && count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long save(Registration registration) {
|
||||||
|
var userToRegisterEntity = EntityMapper.mapToPersistence(registration);
|
||||||
|
entityManager.persist(userToRegisterEntity);
|
||||||
|
return userToRegisterEntity.getId();
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
package auth_service.modules.registration.persistence.entities;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "users")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
|
||||||
|
public class RegisteredUserEntity {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(unique = true, nullable = false)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private boolean enabled;
|
||||||
|
|
||||||
|
@Column(name = "steam_id", unique = true)
|
||||||
|
private Long steamId;
|
||||||
|
|
||||||
|
@Column(name = "display_name", length = 24)
|
||||||
|
private String displayName;
|
||||||
|
|
||||||
|
@Column(name = "registration_date", nullable = false)
|
||||||
|
private LocalDateTime registrationDate;
|
||||||
|
|
||||||
|
@Column(name = "last_login_date")
|
||||||
|
private LocalDateTime lastLoginDate;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package auth_service.modules.registration.persistence.mapper;
|
||||||
|
|
||||||
|
import auth_service.modules.registration.model.Registration;
|
||||||
|
import auth_service.modules.registration.persistence.entities.RegisteredUserEntity;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public abstract class EntityMapper {
|
||||||
|
|
||||||
|
private EntityMapper() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RegisteredUserEntity mapToPersistence(Registration userModel) {
|
||||||
|
return RegisteredUserEntity.builder().
|
||||||
|
email(userModel.getEmail()).
|
||||||
|
registrationDate(LocalDateTime.now(Clock.systemUTC())).
|
||||||
|
password(userModel.getEncryptedPassword()).
|
||||||
|
enabled(true).
|
||||||
|
steamId(userModel.getSteamId()).
|
||||||
|
displayName(userModel.getDisplayName()).
|
||||||
|
lastLoginDate(null).
|
||||||
|
build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package auth_service.modules.registration.service;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class CodeValidationService {
|
||||||
|
|
||||||
|
@Value("${auth.registration.invitation-code}")
|
||||||
|
private String invitationCode;
|
||||||
|
|
||||||
|
public boolean isCodeValid(String code) {
|
||||||
|
if (code == null || code.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return code.equals(invitationCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package auth_service.modules.registration.service;
|
||||||
|
|
||||||
|
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
|
||||||
|
import auth_service.modules.registration.exception.InvalidInvitationCodeException;
|
||||||
|
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
|
||||||
|
import auth_service.modules.registration.exception.SteamIdAlreadyRegisteredException;
|
||||||
|
import auth_service.modules.registration.model.Registration;
|
||||||
|
import auth_service.modules.registration.persistence.RegisteredUserRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RegistrationService {
|
||||||
|
|
||||||
|
private final CodeValidationService codeValidationService;
|
||||||
|
private final RegisteredUserRepository registeredUserRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
public long register(Registration registration) {
|
||||||
|
if (!codeValidationService.isCodeValid(registration.getInviteCode())) {
|
||||||
|
throw new InvalidInvitationCodeException("The provided invite code is invalid or has already been used.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registeredUserRepository.existsByEmail(registration.getEmail())) {
|
||||||
|
throw new EmailAlreadyInUseException("The provided email address is already in use.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registration.getSteamId() != null && registeredUserRepository.existsBySteamId(registration.getSteamId())) {
|
||||||
|
throw new SteamIdAlreadyRegisteredException("The steam id is already in use.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registration.getDisplayName() != null && registeredUserRepository.existsByDisplayName(registration.getDisplayName())) {
|
||||||
|
throw new DisplayNameAlreadyRegisteredException("The display name is already in use.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawPassword = registration.getRawPassword();
|
||||||
|
registration.setEncryptedPassword(passwordEncoder.encode(rawPassword));
|
||||||
|
|
||||||
|
return registeredUserRepository.save(registration);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package auth_service.modules.stats.api;
|
||||||
|
|
||||||
|
import auth_service.modules.stats.api.dto.PublicStatsDto;
|
||||||
|
import auth_service.modules.stats.api.mapper.ApiMapper;
|
||||||
|
import auth_service.modules.stats.service.PublicStatsService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/stats")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PublicStatsController {
|
||||||
|
|
||||||
|
private final PublicStatsService publicStatsService;
|
||||||
|
|
||||||
|
@GetMapping("/public")
|
||||||
|
public ResponseEntity<PublicStatsDto> getPublicStats() {
|
||||||
|
return ResponseEntity.ok(ApiMapper.toApi(publicStatsService.getPublicStats()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package auth_service.modules.stats.api.dto;
|
||||||
|
|
||||||
|
public record PublicStatsDto(
|
||||||
|
long totalUsers,
|
||||||
|
long usersWithSteamLinked
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package auth_service.modules.stats.api.mapper;
|
||||||
|
|
||||||
|
import auth_service.modules.stats.api.dto.PublicStatsDto;
|
||||||
|
import auth_service.modules.stats.model.PublicStats;
|
||||||
|
|
||||||
|
public abstract class ApiMapper {
|
||||||
|
private ApiMapper() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PublicStatsDto toApi(PublicStats stats) {
|
||||||
|
return new PublicStatsDto(stats.totalUsers(), stats.usersWithSteamLinked());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package auth_service.modules.stats.model;
|
||||||
|
|
||||||
|
public record PublicStats(
|
||||||
|
long totalUsers,
|
||||||
|
long usersWithSteamLinked
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package auth_service.modules.stats.repo;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class StatsRepository {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public StatsRepository(JdbcTemplate jdbc) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countTotalUsers() {
|
||||||
|
Long v = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE enabled = TRUE", Long.class);
|
||||||
|
return v != null ? v : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countUsersWithSteamLinked() {
|
||||||
|
Long v = jdbc.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM users WHERE enabled = TRUE AND steam_id IS NOT NULL", Long.class);
|
||||||
|
return v != null ? v : 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package auth_service.modules.stats.service;
|
||||||
|
|
||||||
|
import auth_service.modules.stats.model.PublicStats;
|
||||||
|
import auth_service.modules.stats.repo.StatsRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PublicStatsService {
|
||||||
|
|
||||||
|
private final StatsRepository statsRepository;
|
||||||
|
|
||||||
|
@Cacheable(value = "publicStats", key = "'global'")
|
||||||
|
public PublicStats getPublicStats() {
|
||||||
|
return new PublicStats(
|
||||||
|
statsRepository.countTotalUsers(),
|
||||||
|
statsRepository.countUsersWithSteamLinked()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package auth_service.modules.user.api;
|
||||||
|
|
||||||
|
import auth_service.shared.security.UserPrincipal;
|
||||||
|
import auth_service.modules.user.api.dto.request.AppearanceRequest;
|
||||||
|
import auth_service.modules.user.api.dto.request.UserSettingsRequest;
|
||||||
|
import auth_service.modules.user.api.dto.response.UserProfileResponse;
|
||||||
|
import auth_service.modules.user.api.dto.response.UserSettingsResponse;
|
||||||
|
import auth_service.modules.user.api.mapper.ApiMapper;
|
||||||
|
import auth_service.modules.user.service.UserService;
|
||||||
|
import io.micrometer.core.annotation.Timed;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API handling user profile operations for the dashboard.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/users")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserController {
|
||||||
|
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Version 1 of the 'me' endpoint, accessed via /api/users/me?v=1
|
||||||
|
* * @return The user's dashboard data
|
||||||
|
*/
|
||||||
|
@GetMapping(value = "/me", version = "1.0", produces = "application/json")
|
||||||
|
@Timed(value = "user.me.v1.requests.time", description = "Time taken to process v1 user profile requests")
|
||||||
|
public ResponseEntity<UserProfileResponse> getCurrentUserV1(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
|
|
||||||
|
// this is the email from the login, if a user is not authenticated, it will not even come to this point.
|
||||||
|
var authenticatedEmail = principal.email();
|
||||||
|
var profileResponse = this.userService.getUserByEmail(authenticatedEmail);
|
||||||
|
return ResponseEntity.ok(ApiMapper.toApi(profileResponse));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping(value = "/settings", version = "1.0", produces = "application/json")
|
||||||
|
@Timed(value = "user.me.v1.requests.time", description = "Time taken to process v1 user profile requests")
|
||||||
|
public ResponseEntity<UserSettingsResponse> updateUserSettings(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@Valid @RequestBody UserSettingsRequest request) {
|
||||||
|
var token = this.userService.updateUserSettings(principal.getIdAsLong(), principal.email(),
|
||||||
|
ApiMapper.toSteamId(request), request.displayName(), ApiMapper.toDomain(request));
|
||||||
|
return ResponseEntity.ok(new UserSettingsResponse(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping(value = "/settings/appearance", version = "1.0")
|
||||||
|
public ResponseEntity<Void> updateAppearance(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@Valid @RequestBody AppearanceRequest request) {
|
||||||
|
this.userService.updateAppearance(principal.getIdAsLong(), ApiMapper.toDomain(request));
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package auth_service.modules.user.api;
|
||||||
|
|
||||||
|
import auth_service.shared.api.dto.response.ErrorResponse;
|
||||||
|
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException;
|
||||||
|
import auth_service.modules.user.exception.SteamIdAlreadyInUseException;
|
||||||
|
import auth_service.modules.user.exception.UserNotFoundException;
|
||||||
|
import lombok.NonNull;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
@RestControllerAdvice(assignableTypes = UserController.class)
|
||||||
|
@Order(value = 1)
|
||||||
|
@Slf4j
|
||||||
|
public class UserExceptionController {
|
||||||
|
private static final String STEAM_ID_CONSTRAINT = "uc_steam_id";
|
||||||
|
private static final String DISPLAY_NAME_CONSTRAINT = "uq_users_display_name_lower";
|
||||||
|
|
||||||
|
private static @NonNull ResponseEntity<ErrorResponse> response(ErrorResponse body, HttpStatus status) {
|
||||||
|
return ResponseEntity.status(status.value())
|
||||||
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
|
.body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
######## CLIENT ERRORS (500-599)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
######## CLIENT ERRORS (400-499)
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(UserNotFoundException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> clientError(UserNotFoundException ex) {
|
||||||
|
var body = new ErrorResponse("USER_NOT_FOUND", ex.getMessage());
|
||||||
|
return response(body, HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(SteamIdAlreadyInUseException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> clientError(SteamIdAlreadyInUseException ex) {
|
||||||
|
return steamIdInUse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A parallel request can save the same Steam ID or display name after the service check. Then the DB constraint fails.
|
||||||
|
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> dataIntegrityError(DataIntegrityViolationException ex) {
|
||||||
|
var cause = ex.getMostSpecificCause().getMessage();
|
||||||
|
if (cause != null && cause.contains(STEAM_ID_CONSTRAINT)) {
|
||||||
|
return steamIdInUse();
|
||||||
|
}
|
||||||
|
if (cause != null && cause.contains(DISPLAY_NAME_CONSTRAINT)) {
|
||||||
|
return displayNameInUse();
|
||||||
|
}
|
||||||
|
log.error("Data integrity violation in user request", ex);
|
||||||
|
return response(new ErrorResponse("INTERNAL_ERROR", "Unexpected database error."), HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(DisplayNameAlreadyInUseException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> clientError(DisplayNameAlreadyInUseException ex) {
|
||||||
|
return displayNameInUse();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResponseEntity<ErrorResponse> displayNameInUse() {
|
||||||
|
var body = new ErrorResponse("DISPLAY_NAME_ALREADY_IN_USE", "The display name is already in use.");
|
||||||
|
return response(body, HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResponseEntity<ErrorResponse> steamIdInUse() {
|
||||||
|
var body = new ErrorResponse("STEAM_ID_ALREADY_IN_USE", "The steam id is already in use.");
|
||||||
|
return response(body, HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package auth_service.modules.user.api.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Positive;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The frontend has a CSS preset for each allowed name.
|
||||||
|
*/
|
||||||
|
public record AppearanceRequest(
|
||||||
|
@NotNull @Pattern(regexp = "violet|blue|green|orange|pink|red") String accent,
|
||||||
|
@NotNull @Pattern(regexp = "sharp|default|round") String corners,
|
||||||
|
Boolean wideLayout,
|
||||||
|
@Positive Long backgroundAppid,
|
||||||
|
@Positive Long bannerAppid,
|
||||||
|
Boolean showBanner) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package auth_service.modules.user.api.dto.request;
|
||||||
|
|
||||||
|
import auth_service.shared.api.validation.DisplayNameFormat;
|
||||||
|
import auth_service.shared.api.validation.SteamIdDeserializer;
|
||||||
|
import auth_service.shared.api.validation.SteamIdFormat;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import jakarta.validation.constraints.AssertTrue;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import tools.jackson.databind.annotation.JsonDeserialize;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param steamId null removes the Steam ID
|
||||||
|
* @param displayName null removes the display name
|
||||||
|
* @param publicProfile missing counts as false, so an old client can only make a profile private
|
||||||
|
*/
|
||||||
|
public record UserSettingsRequest(boolean useDarkTheme, String language,
|
||||||
|
@Pattern(regexp = SteamIdFormat.PATTERN)
|
||||||
|
@JsonDeserialize(using = SteamIdDeserializer.class) String steamId,
|
||||||
|
@Pattern(regexp = DisplayNameFormat.PATTERN) String displayName,
|
||||||
|
Boolean publicProfile) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A public profile without a name has no link; the user would think it is off, and the next name would be
|
||||||
|
* public at once without a new opt-in.
|
||||||
|
*/
|
||||||
|
@AssertTrue(message = "A public profile needs a display name")
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isPublicProfileNamed() {
|
||||||
|
return !Boolean.TRUE.equals(publicProfile) || displayName != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package auth_service.modules.user.api.dto.response;
|
||||||
|
|
||||||
|
public record AppearanceResponse(String accent, String corners, boolean wideLayout, Long backgroundAppid,
|
||||||
|
Long bannerAppid, boolean showBanner) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package auth_service.modules.user.api.dto.response;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile,
|
||||||
|
AppearanceResponse appearance) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package auth_service.modules.user.api.dto.response;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
public record UserProfileResponse(long id, String email, String role, LocalDateTime lastLogin, LocalDateTime createdDate, String steamId, String displayName, SettingsResponse settings) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package auth_service.modules.user.api.dto.response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param token replaces the client's token, so the steamId claim matches the saved settings
|
||||||
|
*/
|
||||||
|
public record UserSettingsResponse(String token) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package auth_service.modules.user.api.mapper;
|
||||||
|
|
||||||
|
|
||||||
|
import auth_service.modules.user.api.dto.request.AppearanceRequest;
|
||||||
|
import auth_service.modules.user.api.dto.request.UserSettingsRequest;
|
||||||
|
import auth_service.modules.user.api.dto.response.AppearanceResponse;
|
||||||
|
import auth_service.modules.user.api.dto.response.SettingsResponse;
|
||||||
|
import auth_service.modules.user.api.dto.response.UserProfileResponse;
|
||||||
|
import auth_service.modules.user.model.Appearance;
|
||||||
|
import auth_service.modules.user.model.User;
|
||||||
|
import auth_service.modules.user.model.UserSettings;
|
||||||
|
|
||||||
|
public abstract class ApiMapper {
|
||||||
|
private ApiMapper() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static UserProfileResponse toApi(User user) {
|
||||||
|
return UserProfileResponse.builder()
|
||||||
|
.id(user.getId())
|
||||||
|
.email(user.getEmail())
|
||||||
|
.createdDate(user.getCreatedDate())
|
||||||
|
.lastLogin(user.getLastLogin())
|
||||||
|
.steamId(user.getSteamId() == null ? null : String.valueOf(user.getSteamId()))
|
||||||
|
.displayName(user.getDisplayName())
|
||||||
|
.role("USER")
|
||||||
|
.settings(SettingsResponse.builder()
|
||||||
|
.id(user.getId())
|
||||||
|
.useDarkTheme(user.getSettings().getTheme().equals("dark"))
|
||||||
|
.language(user.getSettings().getLanguage())
|
||||||
|
.publicProfile(user.getSettings().isPublicProfile())
|
||||||
|
.appearance(toApi(user.getSettings().getAppearance()))
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AppearanceResponse toApi(Appearance appearance) {
|
||||||
|
return new AppearanceResponse(appearance.accent(), appearance.corners(), appearance.wideLayout(),
|
||||||
|
appearance.backgroundAppid(), appearance.bannerAppid(), appearance.showBanner());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Appearance toDomain(AppearanceRequest request) {
|
||||||
|
// A missing showBanner keeps the banner on, the default.
|
||||||
|
return new Appearance(request.accent(), request.corners(), Boolean.TRUE.equals(request.wideLayout()),
|
||||||
|
request.backgroundAppid(), request.bannerAppid(), !Boolean.FALSE.equals(request.showBanner()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Long toSteamId(UserSettingsRequest request) {
|
||||||
|
return request.steamId() == null ? null : Long.valueOf(request.steamId());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static UserSettings toDomain(UserSettingsRequest request) {
|
||||||
|
return UserSettings.builder()
|
||||||
|
.theme(request.useDarkTheme() ? "dark" : "light")
|
||||||
|
.language(request.language())
|
||||||
|
.publicProfile(Boolean.TRUE.equals(request.publicProfile()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package auth_service.modules.user.exception;
|
||||||
|
|
||||||
|
public class DisplayNameAlreadyInUseException extends RuntimeException {
|
||||||
|
public DisplayNameAlreadyInUseException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package auth_service.modules.user.exception;
|
||||||
|
|
||||||
|
public class SteamIdAlreadyInUseException extends RuntimeException {
|
||||||
|
public SteamIdAlreadyInUseException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package auth_service.modules.user.exception;
|
||||||
|
|
||||||
|
public class UserNotFoundException extends RuntimeException {
|
||||||
|
public UserNotFoundException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package auth_service.modules.user.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param backgroundAppid the Steam app whose art fills the page background; null for none
|
||||||
|
* @param bannerAppid the Steam app whose art is the dashboard banner; null for the most played game of the last
|
||||||
|
* two weeks
|
||||||
|
*/
|
||||||
|
public record Appearance(String accent, String corners, boolean wideLayout, Long backgroundAppid,
|
||||||
|
Long bannerAppid, boolean showBanner) {
|
||||||
|
|
||||||
|
public static final Appearance DEFAULT = new Appearance("violet", "default", false, null, null, true);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package auth_service.modules.user.model;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
@Data
|
||||||
|
public class User {
|
||||||
|
private long id;
|
||||||
|
private String email;
|
||||||
|
private String encryptedPassword;
|
||||||
|
private LocalDateTime lastLogin;
|
||||||
|
private LocalDateTime createdDate;
|
||||||
|
private Long steamId;
|
||||||
|
private String displayName;
|
||||||
|
|
||||||
|
private UserSettings settings;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package auth_service.modules.user.model;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class UserSettings {
|
||||||
|
private String language;
|
||||||
|
private String theme;
|
||||||
|
private boolean publicProfile;
|
||||||
|
@Builder.Default
|
||||||
|
private Appearance appearance = Appearance.DEFAULT;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package auth_service.modules.user.persistence;
|
||||||
|
|
||||||
|
|
||||||
|
import auth_service.modules.user.model.Appearance;
|
||||||
|
import auth_service.modules.user.model.User;
|
||||||
|
import auth_service.modules.user.model.UserSettings;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for database operations regarding the User entity.
|
||||||
|
*/
|
||||||
|
public interface UserRepository {
|
||||||
|
/**
|
||||||
|
* Finds a user by their email address.
|
||||||
|
* * @param email The email to search for
|
||||||
|
*
|
||||||
|
* @return An Optional containing the user if found
|
||||||
|
*/
|
||||||
|
Optional<User> findByEmail(String email);
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
UserSettings findSettingsByUserId(String userId);
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
boolean updateSettings(long userId, Long steamId, String displayName, UserSettings settings);
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
void updateAppearance(long userId, Appearance appearance);
|
||||||
|
|
||||||
|
boolean isSteamIdUsedByOtherUser(long userId, long steamId);
|
||||||
|
|
||||||
|
boolean isDisplayNameUsedByOtherUser(long userId, String displayName);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package auth_service.modules.user.persistence;
|
||||||
|
|
||||||
|
import auth_service.modules.user.exception.UserNotFoundException;
|
||||||
|
import auth_service.modules.user.model.Appearance;
|
||||||
|
import auth_service.modules.user.model.User;
|
||||||
|
import auth_service.modules.user.model.UserSettings;
|
||||||
|
import auth_service.modules.user.persistence.entities.UserEntity;
|
||||||
|
import auth_service.modules.user.persistence.entities.UserSettingsEntity;
|
||||||
|
import auth_service.modules.user.persistence.mapper.EntityMapper;
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import jakarta.persistence.PersistenceContext;
|
||||||
|
import jakarta.persistence.TypedQuery;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class UserRepositoryImpl implements UserRepository {
|
||||||
|
@PersistenceContext
|
||||||
|
private EntityManager entityManager;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<User> findByEmail(String email) {
|
||||||
|
var criteriaBuilder = entityManager.getCriteriaBuilder();
|
||||||
|
var userEntityCriteriaQuery = criteriaBuilder.createQuery(UserEntity.class);
|
||||||
|
var userEntity = userEntityCriteriaQuery.from(UserEntity.class);
|
||||||
|
|
||||||
|
// Fetch the settings eagerly using a LEFT JOIN to prevent N+1 queries.
|
||||||
|
// A LEFT JOIN ensures the user is returned even if no settings exist yet.
|
||||||
|
userEntity.fetch("settings", jakarta.persistence.criteria.JoinType.LEFT);
|
||||||
|
|
||||||
|
// Build the query: SELECT u FROM UserEntity u LEFT JOIN FETCH u.settings WHERE u.email = :email
|
||||||
|
userEntityCriteriaQuery.select(userEntity).where(criteriaBuilder.equal(userEntity.get("email"), email));
|
||||||
|
|
||||||
|
TypedQuery<UserEntity> typedQuery = entityManager.createQuery(userEntityCriteriaQuery);
|
||||||
|
|
||||||
|
return typedQuery.getResultStream()
|
||||||
|
.findFirst()
|
||||||
|
.map(EntityMapper::mapToModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @NotNull UserSettings findSettingsByUserId(String userId) {
|
||||||
|
UserSettingsEntity entity = entityManager.find(UserSettingsEntity.class, UUID.fromString(userId));
|
||||||
|
|
||||||
|
if (entity == null) {
|
||||||
|
// Return defaults if none exist yet
|
||||||
|
return UserSettings.builder()
|
||||||
|
.language("")
|
||||||
|
.theme("light")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserSettings.builder()
|
||||||
|
.language(entity.getLanguage())
|
||||||
|
.theme(entity.getTheme())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public boolean updateSettings(long userId, Long steamId, String displayName, UserSettings settings) {
|
||||||
|
var userEntity = entityManager.find(UserEntity.class, userId);
|
||||||
|
if (userEntity == null) {
|
||||||
|
throw new UserNotFoundException("Could not find user with id " + userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var settingsEntity = userEntity.getSettings();
|
||||||
|
if (settingsEntity == null) {
|
||||||
|
settingsEntity = new UserSettingsEntity();
|
||||||
|
settingsEntity.setUserId(userId);
|
||||||
|
userEntity.setSettings(settingsEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsEntity.setLanguage(settings.getLanguage());
|
||||||
|
settingsEntity.setTheme(settings.getTheme());
|
||||||
|
settingsEntity.setPublicProfile(settings.isPublicProfile());
|
||||||
|
|
||||||
|
boolean steamIdChanged = !Objects.equals(userEntity.getSteamId(), steamId);
|
||||||
|
userEntity.setSteamId(steamId);
|
||||||
|
userEntity.setDisplayName(displayName);
|
||||||
|
|
||||||
|
return steamIdChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public void updateAppearance(long userId, Appearance appearance) {
|
||||||
|
var userEntity = entityManager.find(UserEntity.class, userId);
|
||||||
|
if (userEntity == null) {
|
||||||
|
throw new UserNotFoundException("Could not find user with id " + userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var settingsEntity = userEntity.getSettings();
|
||||||
|
if (settingsEntity == null) {
|
||||||
|
// Same values as the defaults of the user_settings columns.
|
||||||
|
settingsEntity = new UserSettingsEntity();
|
||||||
|
settingsEntity.setUserId(userId);
|
||||||
|
settingsEntity.setLanguage("");
|
||||||
|
settingsEntity.setTheme("light");
|
||||||
|
userEntity.setSettings(settingsEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsEntity.setAccent(appearance.accent());
|
||||||
|
settingsEntity.setCorners(appearance.corners());
|
||||||
|
settingsEntity.setWideLayout(appearance.wideLayout());
|
||||||
|
settingsEntity.setBackgroundAppid(appearance.backgroundAppid());
|
||||||
|
settingsEntity.setBannerAppid(appearance.bannerAppid());
|
||||||
|
settingsEntity.setShowBanner(appearance.showBanner());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isSteamIdUsedByOtherUser(long userId, long steamId) {
|
||||||
|
var count = entityManager.createQuery(
|
||||||
|
"SELECT COUNT(u) FROM UserEntity u WHERE u.steamId = :steamId AND u.id <> :userId", Long.class)
|
||||||
|
.setParameter("steamId", steamId)
|
||||||
|
.setParameter("userId", userId)
|
||||||
|
.getSingleResult();
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isDisplayNameUsedByOtherUser(long userId, String displayName) {
|
||||||
|
var count = entityManager.createQuery(
|
||||||
|
"SELECT COUNT(u) FROM UserEntity u WHERE LOWER(u.displayName) = LOWER(:displayName) AND u.id <> :userId", Long.class)
|
||||||
|
.setParameter("displayName", displayName)
|
||||||
|
.setParameter("userId", userId)
|
||||||
|
.getSingleResult();
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package auth_service.modules.user.persistence.entities;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a user entity mapped to the 'users' table in PostgreSQL.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "users")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class UserEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(unique = true, nullable = false)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private boolean enabled;
|
||||||
|
|
||||||
|
@Column(name = "steam_id", unique = true)
|
||||||
|
private Long steamId;
|
||||||
|
|
||||||
|
@Column(name = "display_name", length = 24)
|
||||||
|
private String displayName;
|
||||||
|
|
||||||
|
@Column(name = "registration_date", nullable = false)
|
||||||
|
private LocalDateTime registrationDate;
|
||||||
|
|
||||||
|
@Column(name = "last_login_date")
|
||||||
|
private LocalDateTime lastLoginDate;
|
||||||
|
|
||||||
|
@OneToOne(cascade = jakarta.persistence.CascadeType.ALL, fetch = jakarta.persistence.FetchType.LAZY)
|
||||||
|
@jakarta.persistence.PrimaryKeyJoinColumn
|
||||||
|
private UserSettingsEntity settings;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package auth_service.modules.user.persistence.entities;
|
||||||
|
|
||||||
|
import auth_service.modules.user.model.Appearance;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "user_settings")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class UserSettingsEntity {
|
||||||
|
|
||||||
|
// No @GeneratedValue! The ID is strictly the user_id from the users table.
|
||||||
|
@Id
|
||||||
|
@Column(name = "user_id")
|
||||||
|
private long userId;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String language;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String theme;
|
||||||
|
|
||||||
|
@Column(name = "public_profile", nullable = false)
|
||||||
|
private boolean publicProfile;
|
||||||
|
|
||||||
|
// Java defaults as well: a row created here, not by the migration, must not insert null.
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String accent = Appearance.DEFAULT.accent();
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String corners = Appearance.DEFAULT.corners();
|
||||||
|
|
||||||
|
@Column(name = "wide_layout", nullable = false)
|
||||||
|
private boolean wideLayout;
|
||||||
|
|
||||||
|
@Column(name = "background_appid")
|
||||||
|
private Long backgroundAppid;
|
||||||
|
|
||||||
|
@Column(name = "banner_appid")
|
||||||
|
private Long bannerAppid;
|
||||||
|
|
||||||
|
@Column(name = "show_banner", nullable = false)
|
||||||
|
private boolean showBanner = Appearance.DEFAULT.showBanner();
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package auth_service.modules.user.persistence.mapper;
|
||||||
|
|
||||||
|
import auth_service.modules.user.model.Appearance;
|
||||||
|
import auth_service.modules.user.model.User;
|
||||||
|
import auth_service.modules.user.model.UserSettings;
|
||||||
|
import auth_service.modules.user.persistence.entities.UserEntity;
|
||||||
|
import auth_service.modules.user.persistence.entities.UserSettingsEntity;
|
||||||
|
|
||||||
|
public abstract class EntityMapper {
|
||||||
|
|
||||||
|
private EntityMapper() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static User mapToModel(UserEntity entity) {
|
||||||
|
if (entity == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Map the settings if they exist, otherwise use defaults
|
||||||
|
UserSettings settingsModel;
|
||||||
|
if (entity.getSettings() != null) {
|
||||||
|
settingsModel = UserSettings.builder()
|
||||||
|
.language(entity.getSettings().getLanguage())
|
||||||
|
.theme(entity.getSettings().getTheme())
|
||||||
|
.publicProfile(entity.getSettings().isPublicProfile())
|
||||||
|
.appearance(new Appearance(entity.getSettings().getAccent(), entity.getSettings().getCorners(),
|
||||||
|
entity.getSettings().isWideLayout(), entity.getSettings().getBackgroundAppid(),
|
||||||
|
entity.getSettings().getBannerAppid(), entity.getSettings().isShowBanner()))
|
||||||
|
.build();
|
||||||
|
} else {
|
||||||
|
// Fallback default settings for users without an entry in the database
|
||||||
|
settingsModel = UserSettings.builder()
|
||||||
|
.language("en")
|
||||||
|
.theme("system")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Map the main user fields and attach the settings
|
||||||
|
|
||||||
|
return User.builder()
|
||||||
|
.id(entity.getId())
|
||||||
|
.email(entity.getEmail())
|
||||||
|
.encryptedPassword(entity.getPassword())
|
||||||
|
.lastLogin(entity.getLastLoginDate())
|
||||||
|
.createdDate(entity.getRegistrationDate())
|
||||||
|
.steamId(entity.getSteamId())
|
||||||
|
.displayName(entity.getDisplayName())
|
||||||
|
.settings(settingsModel)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static UserSettingsEntity toPersistence(long userId, UserSettings settings) {
|
||||||
|
UserSettingsEntity entity = new UserSettingsEntity();
|
||||||
|
entity.setUserId(userId);
|
||||||
|
entity.setLanguage(settings.getLanguage());
|
||||||
|
entity.setTheme(settings.getTheme());
|
||||||
|
entity.setPublicProfile(settings.isPublicProfile());
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package auth_service.modules.user.service;
|
||||||
|
|
||||||
|
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException;
|
||||||
|
import auth_service.modules.user.exception.SteamIdAlreadyInUseException;
|
||||||
|
import auth_service.modules.user.exception.UserNotFoundException;
|
||||||
|
import auth_service.modules.user.model.Appearance;
|
||||||
|
import auth_service.modules.user.model.User;
|
||||||
|
import auth_service.modules.user.model.UserSettings;
|
||||||
|
import auth_service.modules.user.persistence.UserRepository;
|
||||||
|
import auth_service.shared.security.TokenGenerator;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final TokenGenerator tokenGenerator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the user by email. Results are cached to ensure blazing-fast UI rendering.
|
||||||
|
*
|
||||||
|
* @param email The authenticated user's email
|
||||||
|
* @return Optional containing the User model if found
|
||||||
|
*/
|
||||||
|
@Cacheable(value = "usersByEmail", key = "#email")
|
||||||
|
public User getUserByEmail(String email) {
|
||||||
|
return userRepository.findByEmail(email).orElseThrow(() -> new UserNotFoundException("Could not find user with email " + email));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@CacheEvict(value = "usersByEmail", allEntries = true)
|
||||||
|
public String updateUserSettings(long userId, String email, Long steamId, String displayName, UserSettings userSettings) {
|
||||||
|
if (steamId != null && userRepository.isSteamIdUsedByOtherUser(userId, steamId)) {
|
||||||
|
throw new SteamIdAlreadyInUseException("The steam id is already in use.");
|
||||||
|
}
|
||||||
|
if (displayName != null && userRepository.isDisplayNameUsedByOtherUser(userId, displayName)) {
|
||||||
|
throw new DisplayNameAlreadyInUseException("The display name is already in use.");
|
||||||
|
}
|
||||||
|
var updated = userRepository.updateSettings(userId, steamId, displayName, userSettings);
|
||||||
|
if (updated) {
|
||||||
|
log.info("Steam id has changed for user {}", userId);
|
||||||
|
}
|
||||||
|
log.info("User settings updated for user {}", userId);
|
||||||
|
return tokenGenerator.generateToken(String.valueOf(userId), email, steamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@CacheEvict(value = "usersByEmail", allEntries = true)
|
||||||
|
public void updateAppearance(long userId, Appearance appearance) {
|
||||||
|
userRepository.updateAppearance(userId, appearance);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package auth_service.shared.api.dto.response;
|
||||||
|
|
||||||
|
import org.jspecify.annotations.NonNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared error response record for all modules.
|
||||||
|
*
|
||||||
|
* @param code A machine-readable error code.
|
||||||
|
* @param message A human-readable error message.
|
||||||
|
*/
|
||||||
|
public record ErrorResponse(@NonNull String code, @NonNull String message) {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package auth_service.shared.api.validation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3 to 24 letters, digits, spaces, "_", "." or "-". No space at the start or end.
|
||||||
|
*/
|
||||||
|
public final class DisplayNameFormat {
|
||||||
|
public static final String PATTERN = "[\\p{L}\\p{N}_.-][\\p{L}\\p{N} _.-]{1,22}[\\p{L}\\p{N}_.-]";
|
||||||
|
|
||||||
|
private DisplayNameFormat() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package auth_service.shared.api.validation;
|
||||||
|
|
||||||
|
import tools.jackson.core.JsonParser;
|
||||||
|
import tools.jackson.core.JsonToken;
|
||||||
|
import tools.jackson.databind.DeserializationContext;
|
||||||
|
import tools.jackson.databind.ValueDeserializer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejects a JSON number: an old client sends the Steam ID already rounded by JavaScript.
|
||||||
|
*/
|
||||||
|
public class SteamIdDeserializer extends ValueDeserializer<String> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String deserialize(JsonParser parser, DeserializationContext context) {
|
||||||
|
if (!parser.hasToken(JsonToken.VALUE_STRING)) {
|
||||||
|
return (String) context.handleUnexpectedToken(String.class, parser);
|
||||||
|
}
|
||||||
|
return parser.getString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package auth_service.shared.api.validation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A SteamID64 has 17 digits. It is sent as a JSON string because JavaScript numbers lose precision above 2^53.
|
||||||
|
*/
|
||||||
|
public final class SteamIdFormat {
|
||||||
|
public static final String PATTERN = "\\d{17}";
|
||||||
|
|
||||||
|
private SteamIdFormat() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package auth_service.shared.security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port for the short-lived ticket that lets Core show the public profile data of one user. The ticket is signed
|
||||||
|
* like a login token but is no login: the login filters of Auth and Core reject it.
|
||||||
|
*/
|
||||||
|
public interface ProfileTicketIssuer {
|
||||||
|
String generateProfileTicket(long userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package auth_service.shared.security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port interface for token generation.
|
||||||
|
* Decouples the login service from specific token implementations (e.g. JWT).
|
||||||
|
*/
|
||||||
|
public interface TokenGenerator {
|
||||||
|
/**
|
||||||
|
* Generates a token for the given user.
|
||||||
|
*
|
||||||
|
* @param userId The unique identifier of the user.
|
||||||
|
* @param email The email address of the user.
|
||||||
|
* @param steamId The Steam ID of the user.
|
||||||
|
* @return The generated token string.
|
||||||
|
*/
|
||||||
|
String generateToken(String userId, String email, Long steamId);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package auth_service.shared.security;
|
||||||
|
|
||||||
|
import java.security.Principal;
|
||||||
|
|
||||||
|
public record UserPrincipal(
|
||||||
|
String id,
|
||||||
|
String email,
|
||||||
|
Long steamId
|
||||||
|
) implements Principal {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getIdAsLong() {
|
||||||
|
return Long.parseLong(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# DEVELOPMENT CONFIGURATION
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Overrides properties from application.yml when the 'dev' profile is active
|
||||||
|
|
||||||
|
app:
|
||||||
|
cors:
|
||||||
|
# Local development: allow all origins to prevent CORS issues
|
||||||
|
allowed-origins: "*"
|
||||||
|
|
||||||
|
# Optional: Make SQL logs visible for debugging
|
||||||
|
spring:
|
||||||
|
jpa:
|
||||||
|
show-sql: true
|
||||||
|
|
||||||
|
security:
|
||||||
|
jwt:
|
||||||
|
# Dev-only Ed25519 keypair. DO NOT use in production — regenerate per-env with:
|
||||||
|
# openssl genpkey -algorithm Ed25519 -outform DER | base64 -w0
|
||||||
|
# openssl pkey -inform DER -in <(base64 -d <<< "$PRIV") -pubout -outform DER | base64 -w0
|
||||||
|
private-key: "MC4CAQAwBQYDK2VwBCIEIG9nxZFw9Fj7lGUqA7oi94oo/xollxwlZIZENlpfp+tc"
|
||||||
|
public-key: "MCowBQYDK2VwAyEAPSmUISg0IUO1LrRPhF8RZAT/Yjh9HEVf5hzR84AL1Zw="
|
||||||
|
kid: "tbp-auth-dev"
|
||||||
|
|
||||||
|
auth:
|
||||||
|
registration:
|
||||||
|
invitation-code: "Us*kksUuRpTE2CQRsv+^k0n)ksR}X}?#Fo2D4d8ZKG"
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# BASE CONFIGURATION (Production Defaults)
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: tbp-auth-service
|
||||||
|
profiles:
|
||||||
|
active: prod # Default profile for local startup
|
||||||
|
jpa:
|
||||||
|
open-in-view: true
|
||||||
|
flyway:
|
||||||
|
# Force Flyway to be enabled
|
||||||
|
enabled: true
|
||||||
|
# Create the schema history table if it does not exist
|
||||||
|
baseline-on-migrate: true
|
||||||
|
# Explicitly state where to look for scripts
|
||||||
|
locations: classpath:db/migration
|
||||||
|
threads:
|
||||||
|
virtual:
|
||||||
|
enabled: true
|
||||||
|
mvc:
|
||||||
|
apiversion:
|
||||||
|
use:
|
||||||
|
query-parameter: version
|
||||||
|
default: "1.0"
|
||||||
|
supported: 1.0, 2,0
|
||||||
|
|
||||||
|
|
||||||
|
# Actuator & Micrometer Settings
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
show-details: never
|
||||||
|
|
||||||
|
# Security stuff
|
||||||
|
security:
|
||||||
|
jwt:
|
||||||
|
private-key: ${JWT_PRIVATE_KEY}
|
||||||
|
public-key: ${JWT_PUBLIC_KEY}
|
||||||
|
kid: ${JWT_KID:tbp-auth-1}
|
||||||
|
|
||||||
|
# Custom properties for CORS
|
||||||
|
app:
|
||||||
|
cors:
|
||||||
|
# Production default: strictly allow only the frontend domain
|
||||||
|
allowed-origins: "https://tbp.andreas-dahm.eu"
|
||||||
|
|
||||||
|
# Registration settings
|
||||||
|
auth:
|
||||||
|
registration:
|
||||||
|
invitation-code: ${AUTH_INVITATION_CODE}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
88888888888 888888b. 8888888b. 888 888 d8888 888 888
|
||||||
|
888 888 "88b 888 Y88b 888 888 d88888 888 888
|
||||||
|
888 888 .88P 888 888 888 888 d88P888 888 888
|
||||||
|
888 .d88b. 8888888K. .d88b. 888 d88P 888 8888b. 888 888 .d88b. .d88888 d88P 888 888 888 888888 88888b.
|
||||||
|
888 d88""88b 888 "Y88b d8P Y8b 8888888P" 888 "88b 888 888 d8P Y8b d88" 888 d88P 888 888 888 888 888 "88b
|
||||||
|
888 888 888 888 888 88888888 888 888 .d888888 888 888 88888888 888 888 888888 d88P 888 888 888 888 888 888
|
||||||
|
888 Y88..88P 888 d88P Y8b. 888 888 888 888 Y88b 888 Y8b. Y88b 888 d8888888888 Y88b 888 Y88b. 888 888
|
||||||
|
888 "Y88P" 8888888P" "Y8888 888 888 "Y888888 "Y88888 "Y8888 "Y88888 d88P 888 "Y88888 "Y888 888 888
|
||||||
|
888
|
||||||
|
Y8b d88P
|
||||||
|
"Y88P"
|
||||||
|
${application.title} ${application.version}
|
||||||
|
Powered by Spring Boot ${spring-boot.version}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Create the users table
|
||||||
|
CREATE TABLE users
|
||||||
|
(
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
password VARCHAR(255) NOT NULL,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Insert a pragmatic test user for local development and testing
|
||||||
|
-- The password hash corresponds to 'password123'
|
||||||
|
INSERT INTO users (email, password, enabled)
|
||||||
|
VALUES ('test@example.com', '$2a$10$3FApyf15qVEvFLEvl/d7aecSMZlslQdXiYlDgbOa4DfGWu4l0VCY.', true);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Create the users table
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN steam_id BIGINT,
|
||||||
|
ADD COLUMN registration_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ADD COLUMN last_login_date TIMESTAMP;
|
||||||
|
|
||||||
|
ALTER TABLE users ADD CONSTRAINT uc_steam_id UNIQUE (steam_id);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE user_settings
|
||||||
|
(
|
||||||
|
user_id BIGSERIAL PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
language VARCHAR(10) NOT NULL DEFAULT '',
|
||||||
|
theme VARCHAR(20) NOT NULL DEFAULT 'light'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO user_settings (user_id, language, theme)
|
||||||
|
SELECT id, '', 'light'
|
||||||
|
FROM users;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Before V4 a missing Steam ID was stored as 0. NULL is the only "no Steam ID" value from now on.
|
||||||
|
UPDATE users SET steam_id = NULL WHERE steam_id = 0;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- V1 seeded test@example.com with the password 'password123' into every environment.
|
||||||
|
-- The hash check makes sure only that seed row goes; user_settings follows via ON DELETE CASCADE.
|
||||||
|
DELETE FROM users
|
||||||
|
WHERE email = 'test@example.com'
|
||||||
|
AND password = '$2a$10$3FApyf15qVEvFLEvl/d7aecSMZlslQdXiYlDgbOa4DfGWu4l0VCY.';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Player tag shown instead of the email prefix. Optional, unique ignoring case.
|
||||||
|
ALTER TABLE users ADD COLUMN display_name VARCHAR(24);
|
||||||
|
CREATE UNIQUE INDEX uq_users_display_name_lower ON users (LOWER(display_name));
|
||||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user