Compare commits

...
46 Commits
Author SHA1 Message Date
lobo af386bb8f7 feat(user): pick or turn off the dashboard banner
Build, Test & Push Auth Service / quality-check (push) Successful in 26s
Build, Test & Push Auth Service / docker (push) Successful in 1m26s
2026-09-24 15:35:48 +02:00
lobo e04fd27b08 feat(user): synced appearance settings 2026-09-24 15:06:16 +02:00
lobo c9ae7853e5 feat(profile): opt-in public profile with a ticket for Core
user_settings.public_profile (off by default, needs a display name).
GET /api/public/profiles/{displayName} answers only for enabled users with a
public profile and returns a 10-minute EdDSA ticket for Core. A token with a
typ claim is no login, so the ticket cannot be used as one.
2026-09-24 14:18:32 +02:00
lobo 779c4d296d test: share one Postgres container across test classes
The per-class container was stopped under the Spring context that the next
integration test class reused, so health answered 503 there.
2026-09-24 14:18:32 +02:00
lobo 2941e3f905 feat(user): optional display name (player tag)
Build, Test & Push Auth Service / quality-check (push) Successful in 37s
Build, Test & Push Auth Service / docker (push) Successful in 1m19s
users.display_name (V6), 3-24 letters, digits, space, _ . -, unique ignoring case. Set in settings (409 DISPLAY_NAME_ALREADY_IN_USE, also for the DB constraint race) and optionally at registration. Returned by /api/users/me.
2026-09-24 10:39:04 +02:00
lobo 4579b05d2c fix(cors): expose Retry-After so the login page can show the block time 2026-09-24 10:29:57 +02:00
lobo d28147b2ab fix(security): hide health details and close Prometheus to the public
/actuator/health showed database and disk details to anyone, and /actuator/prometheus was open. Only health stays public, as plain UP/DOWN for the Docker health check.
2026-09-24 10:26:53 +02:00
lobo 5512453774 fix(login): same error for every failed login, block disabled users, rate limit
Unknown email, wrong password and disabled account now all return 401 INVALID_CREDENTIALS; unknown emails still run bcrypt against a dummy hash so timing does not leak them. After 5 failures per email or 20 per client IP, login returns 429 with Retry-After for 15 minutes. LoginRequest is now validated.
2026-09-24 10:22:30 +02:00
lobo 3d21bb667d fix(db): remove the seeded test user from all environments
V1 inserted test@example.com / password123 everywhere, including prod. V5 deletes that row only when email and seed hash both match.
2026-09-24 10:14:58 +02:00
lobo fe691b0558 chore(deps): update Spring Boot to 4.1.1 and ArchUnit to 1.5.0
Build, Test & Push Auth Service / quality-check (push) Successful in 1m40s
Build, Test & Push Auth Service / docker (push) Successful in 1m33s
2026-09-24 08:56:45 +02:00
lobo a3aeb666a8 fix(user): send Steam ID as string and refresh token on settings save
JavaScript rounds 17-digit Steam IDs, so 76561198005679788 was stored as
76561198005679790. Steam IDs are now JSON strings (17 digits, numbers
rejected), and null means "no Steam ID" instead of 0 (V4 migrates old rows).

PUT /api/users/settings returns a new token, because Core reads the Steam ID
from the token. A Steam ID owned by another user returns 409.
2026-09-24 08:51:01 +02:00
Andreas Dahm 39f08c9563 fix(user): null-safe steamId comparison in updateSettings
Build, Test & Push Auth Service / quality-check (push) Successful in 37s
Build, Test & Push Auth Service / docker (push) Successful in 1m48s
2026-05-12 10:09:33 +02:00
Andreas Dahm 73dfeb4e7d Small fixed
Build, Test & Push Auth Service / quality-check (push) Successful in 38s
Build, Test & Push Auth Service / docker (push) Successful in 2m3s
2026-05-12 09:51:09 +02:00
lobo 3159736c16 Merge pull request 'Changed from JWT token to public/privat key' (#2) from feat/jwt-eddsa into main
Build, Test & Push Auth Service / quality-check (push) Failing after 38s
Build, Test & Push Auth Service / docker (push) Has been skipped
Reviewed-on: #2
2026-05-12 08:58:57 +02:00
Andreas Dahm 8505faa578 Changed from JWT token to public/privat key 2026-05-12 08:56:51 +02:00
Andreas DahmandClaude Opus 4.7 874f8dee58 Add Auth-owned product roadmap to README
Build, Test & Push Auth Service / quality-check (push) Failing after 37s
Build, Test & Push Auth Service / docker (push) Has been skipped
Captures the Auth slices of the cross-service product roadmap:
username + public-profile opt-in, friends graph, email/notification
preferences, invitation system enhancements, and GDPR delete/export.
Cross-links to the umbrella roadmap in ToBePlayed-Deployment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:51:24 +02:00
Andreas Dahm f30e089739 Changed readme.md
Build, Test & Push Auth Service / quality-check (push) Failing after 38s
Build, Test & Push Auth Service / docker (push) Has been skipped
2026-05-11 14:40:54 +02:00
Andreas Dahm 2beb9aaddf Fixed problems with update of settings also added public area
Build, Test & Push Auth Service / quality-check (push) Failing after 36s
Build, Test & Push Auth Service / docker (push) Has been skipped
2026-05-04 15:16:14 +02:00
Andreas Dahm 3fd12637d6 Extended pricipal to add steam id
Build, Test & Push Auth Service / quality-check (push) Successful in 38s
Build, Test & Push Auth Service / docker (push) Successful in 1m42s
2026-04-28 15:43:27 +02:00
Andreas Dahm 6b8aa4458c Added registration endpoint to whitelist
Build, Test & Push Auth Service / quality-check (push) Successful in 39s
Build, Test & Push Auth Service / docker (push) Successful in 1m43s
2026-04-24 10:22:15 +02:00
lobo c312d271ad Support now null as not initialized for SteamId
Build, Test & Push Auth Service / quality-check (push) Successful in 37s
Build, Test & Push Auth Service / docker (push) Successful in 1m50s
2026-04-19 14:59:27 +02:00
lobo dda7e50963 Support now null as not initialized for SteamId
Build, Test & Push Auth Service / quality-check (push) Successful in 37s
Build, Test & Push Auth Service / docker (push) Successful in 1m43s
2026-04-19 14:35:02 +02:00
lobo 7d32c7e04a Added new db migration to set default SteamId to
Build, Test & Push Auth Service / quality-check (push) Failing after 36s
Build, Test & Push Auth Service / docker (push) Has been skipped
2026-04-19 14:17:49 +02:00
lobo fd390ac072 Refactor package structure: domain -> modules
Build, Test & Push Auth Service / quality-check (push) Successful in 38s
Build, Test & Push Auth Service / docker (push) Successful in 1m41s
Move domain packages into a new modules.* hierarchy and rename controller packages to api.* across login, registration, and user modules. Update package declarations and imports (including shared.api DTOs and JwtService -> modules login TokenGenerator), adjust tests and persistence/mapper paths accordingly, and apply minor formatting fixes (missing EOF newlines). This reorganizes the project to better separate API surface and module boundaries.
2026-04-19 12:50:01 +02:00
lobo 815347325a Merge pull request 'feature-cleanup' (#1) from feature-cleanup into main
Build, Test & Push Auth Service / quality-check (push) Successful in 1m23s
Build, Test & Push Auth Service / docker (push) Successful in 1m57s
Reviewed-on: #1
2026-04-18 11:52:46 +02:00
lobo fabab0ca45 Fixed tests 2026-04-18 11:49:09 +02:00
lobo 0cd4d7328a Fixed tests 2026-04-18 11:47:58 +02:00
lobo d63fbd4155 Added new tests fixed some problem 2026-04-18 11:28:06 +02:00
lobo 3f80777e37 Fixed tests
Build, Test & Push Auth Service / quality-check (push) Successful in 21s
Build, Test & Push Auth Service / docker (push) Successful in 1m19s
2026-04-11 12:32:32 +02:00
lobo e21dea1e18 Added lots of new stuff
- extended the jwt and put userid into it
- completed update settings
- added settings also in me call
2026-04-11 12:07:27 +02:00
lobo d498ef8827 Added transactional
Build, Test & Push Auth Service / quality-check (push) Successful in 21s
Build, Test & Push Auth Service / docker (push) Successful in 1m15s
2026-04-06 09:57:39 +02:00
lobo ed98d703c6 changed default profile to prod
Build, Test & Push Auth Service / quality-check (push) Successful in 19s
Build, Test & Push Auth Service / docker (push) Successful in 1m18s
2026-04-06 09:35:31 +02:00
lobo 8465524849 Finished registration Process and added tests
Build, Test & Push Auth Service / quality-check (push) Successful in 24s
Build, Test & Push Auth Service / docker (push) Successful in 1m18s
2026-04-06 09:12:45 +02:00
lobo c224cbf679 Started with:
Build, Test & Push Auth Service / quality-check (push) Successful in 19s
Build, Test & Push Auth Service / docker (push) Successful in 1m21s
- Registration logic for new players
- Extended User table with more information
2026-04-04 17:11:13 +02:00
lobo dbc76e6158 Added tests for all services and controller
Build, Test & Push Auth Service / quality-check (push) Successful in 19s
Build, Test & Push Auth Service / docker (push) Successful in 1m18s
2026-04-04 16:01:05 +02:00
lobo f4b7ae75a9 Added arch unit tests
Build, Test & Push Auth Service / quality-check (push) Successful in 50s
Build, Test & Push Auth Service / docker (push) Successful in 1m15s
2026-04-04 11:22:26 +02:00
lobo 5aee23f00e Fixed problem with token response 2026-04-04 10:55:39 +02:00
lobo 71e4f38357 Cleaned a little bit up 2026-04-04 10:43:18 +02:00
lobo 41ce5d93e9 Implemented user service 2026-04-04 09:58:09 +02:00
lobo 8af7170c46 Disabled test for now until db config is ready for testing
Build, Test & Push Auth Service / quality-check (push) Successful in 15s
Build, Test & Push Auth Service / docker (push) Successful in 1m51s
2026-04-03 12:32:40 +02:00
lobo 64b84b1dc0 Added jwt token validation and api endpoint versioning
Build, Test & Push Auth Service / quality-check (push) Failing after 41s
Build, Test & Push Auth Service / docker (push) Has been skipped
2026-04-03 12:25:03 +02:00
lobo 2e8ff1b079 Cleaned up the architecture
Build, Test & Push Auth Service / quality-check (push) Failing after 30s
Build, Test & Push Auth Service / docker (push) Has been skipped
2026-04-03 11:35:53 +02:00
lobo be4d4df351 Updated pipeline
Build, Test & Push Auth Service / quality-check (push) Failing after 42s
Build, Test & Push Auth Service / docker (push) Has been skipped
2026-03-31 19:54:02 +02:00
lobo 85e4b62f32 Updated pipeline
Build, Test & Push Auth Service / quality-check (push) Failing after 3s
Build, Test & Push Auth Service / docker (push) Has been skipped
2026-03-31 19:52:09 +02:00
lobo 7e8a04d83c Login should work now, tests are missing
Build, Test & Push Auth Service / quality-check (push) Failing after 37s
Build, Test & Push Auth Service / docker (push) Has been skipped
2026-03-31 19:48:23 +02:00
lobo 8dd17f84f6 Setup of springboot 2026-03-31 18:57:06 +02:00
123 changed files with 16299 additions and 0 deletions

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
# .env file for local development
DB_PASSWORD=my_super_secret_local_password
+2
View File
@@ -0,0 +1,2 @@
/mvnw text eol=lf
*.cmd text eol=crlf
+75
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+10
View File
@@ -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
+22
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
Vendored Executable
+295
View File
@@ -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 "$@"
Vendored
+189
View File
@@ -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"
+212
View File
@@ -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>
+150
View File
@@ -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());
}
}
@@ -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();
}
}
@@ -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) {
}
@@ -0,0 +1,7 @@
package auth_service.modules.registration.api.dto.response;
import lombok.Builder;
@Builder
public record RegistrationResponse(long userId) {
}
@@ -0,0 +1,7 @@
package auth_service.modules.registration.exception;
public class DisplayNameAlreadyRegisteredException extends RuntimeException {
public DisplayNameAlreadyRegisteredException(String message) {
super(message);
}
}
@@ -0,0 +1,7 @@
package auth_service.modules.registration.exception;
public class EmailAlreadyInUseException extends RuntimeException {
public EmailAlreadyInUseException(String message) {
super(message);
}
}
@@ -0,0 +1,7 @@
package auth_service.modules.registration.exception;
public class InvalidInvitationCodeException extends RuntimeException {
public InvalidInvitationCodeException(String message) {
super(message);
}
}
@@ -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;
}
@@ -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);
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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);
}
}
+27
View File
@@ -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"
+55
View File
@@ -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}
+13
View File
@@ -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