Compare commits

...
48 Commits
Author SHA1 Message Date
lobo c63b7e6aaa test: generate the JWT test keys, drop application-dev.yml
Build, Test & Push Auth Service / docker (push) Successful in 1m20s
Build, Test & Push Auth Service / quality-check (push) Successful in 28s
The dev config held real keys. Local dev config now lives outside the repo; integration tests create a fresh Ed25519 keypair per run.
2026-09-25 08:37:55 +02:00
lobo 9df7389fab docs: rewrite the readme for the public showcase
Also ignore local secrets and stop tracking the generated effective-pom.
2026-09-25 08:22:56 +02:00
lobo 508e628d86 feat(user): pick or turn off the dashboard banner 2026-09-24 15:35:48 +02:00
lobo 1d88c30d48 feat(user): synced appearance settings 2026-09-24 15:06:16 +02:00
lobo 973b980e05 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 07ccb489ec 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 05e87b4396 feat(user): optional display name (player tag)
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 4ebca75be4 fix(cors): expose Retry-After so the login page can show the block time 2026-09-24 10:29:57 +02:00
lobo f3b6d67b5b 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 da7c3ed069 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 3b6acb2b34 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 0f8c724556 chore(deps): update Spring Boot to 4.1.1 and ArchUnit to 1.5.0 2026-09-24 08:56:45 +02:00
lobo 0e830aca72 fix(user): send Steam ID as string and refresh token on settings save
JavaScript rounds 17-digit Steam IDs, so 76561198000000001 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 faed2d3411 fix(user): null-safe steamId comparison in updateSettings 2026-05-12 10:09:33 +02:00
Andreas Dahm 3c3cda9409 Small fixed 2026-05-12 09:51:09 +02:00
lobo 89fdc7841b Merge pull request 'Changed from JWT token to public/privat key' (#2) from feat/jwt-eddsa into main
Reviewed-on: #2
2026-05-12 08:58:57 +02:00
Andreas Dahm 9114eb096b Changed from JWT token to public/privat key 2026-05-12 08:56:51 +02:00
Andreas DahmandClaude Opus 4.7 4859e74d45 Add Auth-owned product roadmap to README
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 34fa22c2b9 Changed readme.md 2026-05-11 14:40:54 +02:00
Andreas Dahm 6f63179ec2 Fixed problems with update of settings also added public area 2026-05-04 15:16:14 +02:00
Andreas Dahm 40135550f8 Extended pricipal to add steam id 2026-04-28 15:43:27 +02:00
Andreas Dahm bcbf761aa5 Added registration endpoint to whitelist 2026-04-24 10:22:15 +02:00
lobo 2dc3cda51c Support now null as not initialized for SteamId 2026-04-19 14:59:27 +02:00
lobo 2d4fb5bef7 Support now null as not initialized for SteamId 2026-04-19 14:35:02 +02:00
lobo c7ec8c2f7c Added new db migration to set default SteamId to 2026-04-19 14:17:49 +02:00
lobo 2a5839b736 Refactor package structure: domain -> modules
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 3fcba271a6 Merge pull request 'feature-cleanup' (#1) from feature-cleanup into main
Reviewed-on: #1
2026-04-18 11:52:46 +02:00
lobo 37b0fcdd21 Fixed tests 2026-04-18 11:49:09 +02:00
lobo f90b674ed2 Fixed tests 2026-04-18 11:47:58 +02:00
lobo 20402eb7c4 Added new tests fixed some problem 2026-04-18 11:28:06 +02:00
lobo 14a4ccfb87 Fixed tests 2026-04-11 12:32:32 +02:00
lobo 03d5a011ae 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 6e194b9c9a Added transactional 2026-04-06 09:57:39 +02:00
lobo 954669d1a4 changed default profile to prod 2026-04-06 09:35:31 +02:00
lobo afbe950ba4 Finished registration Process and added tests 2026-04-06 09:12:45 +02:00
lobo 5dcdf7bd61 Started with:
- Registration logic for new players
- Extended User table with more information
2026-04-04 17:11:13 +02:00
lobo ad30de4190 Added tests for all services and controller 2026-04-04 16:01:05 +02:00
lobo c028e8d382 Added arch unit tests 2026-04-04 11:22:26 +02:00
lobo 7ba38fbbce Fixed problem with token response 2026-04-04 10:55:39 +02:00
lobo 5dc3632687 Cleaned a little bit up 2026-04-04 10:43:18 +02:00
lobo afd71b82be Implemented user service 2026-04-04 09:58:09 +02:00
lobo 3ee636253b Disabled test for now until db config is ready for testing 2026-04-03 12:32:40 +02:00
lobo a94b2bf9e7 Added jwt token validation and api endpoint versioning 2026-04-03 12:25:03 +02:00
lobo 161381ebe5 Cleaned up the architecture 2026-04-03 11:35:53 +02:00
lobo cc24c43ab8 Updated pipeline 2026-03-31 19:54:02 +02:00
lobo 1ce56590d1 Updated pipeline 2026-03-31 19:52:09 +02:00
lobo 132bb6bb64 Login should work now, tests are missing 2026-03-31 19:48:23 +02:00
lobo eecda99d22 Setup of springboot 2026-03-31 18:57:06 +02:00
120 changed files with 5550 additions and 0 deletions

No files matched your search

+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
+41
View File
@@ -0,0 +1,41 @@
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/
### Local secrets and generated files ###
.env
effective-pom
### Local dev config, never committed ###
application-dev.yml
application-dev.yaml
+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:
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>
+110
View File
@@ -0,0 +1,110 @@
# To Be Played – Auth Service
Identity service of **To Be Played**, a Steam backlog tracker. It handles registration, login, user settings and the
public profile, and it signs the JWTs that the Core service trusts.
To Be Played is split into three repositories:
| Repository | Role |
|---|---|
| **ToBePlayed-Auth** (this one) | Users, login, settings, signs JWTs |
| ToBePlayed-Core | Steam library, backlog, wishlist, stats; verifies JWTs |
| ToBePlayed-Frontend | React single-page app |
## Features
- Registration with an invitation code, login with email and password (BCrypt).
- JWT signed with **EdDSA (Ed25519)**. Auth holds the private key, Core only the public key, so Core can verify
tokens but never create them. Tokens carry a `kid` header for key rotation.
- User settings: display name (unique, case-insensitive), Steam ID, language, dark mode.
- Appearance settings synced across devices: accent color, corner style, wide layout, background and dashboard
banner from the user's own game art. Only whitelisted presets and Steam app ids are stored, never raw CSS or URLs.
- Opt-in public profile at `/u/<display name>`. Auth answers with a short-lived **profile ticket** (EdDSA JWT,
10 minutes, `typ=public-profile`) that Core accepts for the public library data only. The ticket is not a login:
both services reject every token with a `typ` claim on normal endpoints.
## API
| Method | Path | Auth |
|---|---|---|
| `POST` | `/api/registration/new` | public |
| `POST` | `/api/auth/login` | public |
| `GET` | `/api/users/me` | JWT |
| `PUT` | `/api/users/settings` | JWT |
| `PUT` | `/api/users/settings/appearance` | JWT |
| `GET` | `/api/public/profiles/{displayName}` | public |
| `GET` | `/api/stats/public` | public |
Endpoints use Spring's API versioning with a query parameter: `?version=1.0` (default `1.0`).
## Tech stack
- Java 25, Spring Boot 4.1 (Web MVC, Security, Data JPA, Validation, Cache with Caffeine), virtual threads
- PostgreSQL 17, Flyway migrations
- JJWT with Ed25519 keys
- Spring Boot Actuator + Micrometer (Prometheus)
- Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit
- CI: Forgejo Actions, multi-stage Docker image
## Architecture
Package by feature (`modules/login`, `modules/registration`, `modules/user`, ...), and inside each feature three
layers:
| Layer | Package | Rule |
|---|---|---|
| API | `api` | REST controllers, request/response records, `ApiMapper`. Calls services, never repositories. |
| Domain | `model`, `service`, `exception` | Plain Java models and business logic. No dependency on API or persistence. |
| Persistence | `persistence` | JPA entities, Spring Data repositories, `EntityMapper`. Entities never reach the API layer. |
ArchUnit enforces these rules in `src/test/java/auth_service/architecture/ArchitectureTest.java`, so a layer
violation fails the build. Test base classes (`AbstractUnitTest`, `AbstractWebMvcTest`, `AbstractPersistenceTest`,
`AbstractIntegrationTest`) live in `auth_service.shared.test`; all integration tests share one PostgreSQL container.
## Run locally
Needs Java 25 and Docker.
```bash
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
```
Spring Boot's Docker Compose support starts PostgreSQL from `compose.yaml`, Flyway applies the migrations, and the
service listens on `http://localhost:8080`. Health: `/actuator/health`, metrics: `/actuator/prometheus`.
Configuration comes from environment variables:
| Variable | Purpose |
|---|---|
| `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY` | Ed25519 keypair, Base64 of the DER bytes (PKCS#8 / X.509) |
| `JWT_KID` | Key id written into the token header |
| `AUTH_INVITATION_CODE` | Code required for registration |
| `DB_PASSWORD` | Local PostgreSQL password |
Generate a keypair:
```bash
openssl genpkey -algorithm ed25519 -out private.pem
openssl pkey -in private.pem -outform DER | base64 -w0 # JWT_PRIVATE_KEY
openssl pkey -in private.pem -pubout -outform DER | base64 -w0 # JWT_PUBLIC_KEY
```
## Tests
```bash
./mvnw clean verify
```
Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Needs Docker for Testcontainers.
## Roadmap
- Rate limit `/api/auth/login` and `/api/registration/**`.
- JWKS endpoint (`/.well-known/jwks.json`), so Core fetches the public key and keys rotate without a redeploy.
- Short-lived access token plus rotating refresh token in an httpOnly cookie, with CSRF protection. Today the token
lives 24 h in `localStorage` and cannot be revoked.
- Account deletion and data export.
## License
All rights reserved. The source is public for reading only.
@@ -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);
}
}
+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));
@@ -0,0 +1,2 @@
-- Opt-in for the public profile page /u/<display name>. Off for everyone until they switch it on.
ALTER TABLE user_settings ADD COLUMN public_profile BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1,6 @@
-- How the frontend looks for the user. The API accepts only known preset names, never raw CSS.
ALTER TABLE user_settings
ADD COLUMN accent VARCHAR(16) NOT NULL DEFAULT 'violet',
ADD COLUMN corners VARCHAR(16) NOT NULL DEFAULT 'default',
ADD COLUMN wide_layout BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN background_appid BIGINT;
@@ -0,0 +1,4 @@
-- The dashboard banner: a picked game, or the most played game of the last two weeks when NULL.
ALTER TABLE user_settings
ADD COLUMN banner_appid BIGINT,
ADD COLUMN show_banner BOOLEAN NOT NULL DEFAULT TRUE;
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more