Compare commits
13 Commits
master
..
25b70415ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
25b70415ca
|
|||
|
525ffa091a
|
|||
| 2ff5930e86 | |||
| 4ff18c878b | |||
| 79dd8384e0 | |||
|
0e0c463a1e
|
|||
| ade56ec23d | |||
| 66767d9e1e | |||
| 33182d5a8e | |||
| 9889789fcc | |||
| ac85ff58af | |||
| 668bd05e5a | |||
| 0a16092b3d |
@@ -0,0 +1,68 @@
|
||||
name: "Unit test coverage"
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
env:
|
||||
MIN_COVERAGE_PERCENTAGE: 85
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
TZ: UTC
|
||||
DOCKER_HOST: "tcp://docker:2375"
|
||||
DOCKER_TLS_VERIFY: ""
|
||||
# Test containers suggest this will yield better fs performance
|
||||
DOCKER_DRIVER: overlay2
|
||||
# ryuk need privileged permission, disable it in CI
|
||||
TESTCONTAINERS_RYUK_DISABLED: true
|
||||
|
||||
jobs:
|
||||
# gradle test for modules
|
||||
gradle-test:
|
||||
runs-on: general
|
||||
container:
|
||||
image: ubuntu:24.04
|
||||
services:
|
||||
docker:
|
||||
image: docker:dind
|
||||
cmd:
|
||||
- "dockerd"
|
||||
- "-H"
|
||||
- "tcp://0.0.0.0:2375"
|
||||
- "--tls=false"
|
||||
steps:
|
||||
# need to setup node and git
|
||||
- run: |
|
||||
apt-get update
|
||||
apt-get install -y nodejs git curl maven
|
||||
# check out code
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://github.com/actions/setup-java@v4
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "21"
|
||||
# run gradle test
|
||||
- run: mvn clean test jacoco:report
|
||||
# process jacoco report
|
||||
- name: Collect coverage report
|
||||
id: coverage
|
||||
run: |
|
||||
INPUT=$(grep -o '<counter type="LINE" [^>]*>' target/site/jacoco/jacoco.xml | tail -1)
|
||||
MISSED=$(echo "$INPUT" | sed -n 's/.*missed="\([0-9]*\)".*/\1/p')
|
||||
COVERED=$(echo "$INPUT" | sed -n 's/.*covered="\([0-9]*\)".*/\1/p')
|
||||
echo "MISSED: $MISSED"
|
||||
echo "COVERED: $COVERED"
|
||||
COVERAGE_PERCENTAGE=$(( 100 * $COVERED / ($COVERED + $MISSED) ))
|
||||
echo "Code coverage: ${COVERAGE_PERCENTAGE}%"
|
||||
echo "Minimal coverage: ${MIN_COVERAGE_PERCENTAGE}%"
|
||||
echo "percentage=${COVERAGE_PERCENTAGE}" >> $GITHUB_OUTPUT
|
||||
- name: Post coverage to RP
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
curl --fail \
|
||||
-X POST '${{github.api_url}}/repos/${{github.repository}}/issues/${{github.event.number}}/comments' \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: token ${{github.token}}" \
|
||||
-d '{"body":"Coverage is ${{steps.coverage.outputs.percentage}}%"}'
|
||||
- name: Check coverage rate
|
||||
run: |
|
||||
if [ ${{steps.coverage.outputs.percentage}} -lt $MIN_COVERAGE_PERCENTAGE ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,81 @@
|
||||
name: "CI for publishing docker image"
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**" # build on all branches since the tag is decided by branch name
|
||||
workflow_dispatch:
|
||||
# allow manual trigger
|
||||
env:
|
||||
REGISTRY_URL: "git.cleverthis.com"
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
TZ: UTC
|
||||
DOCKER_HOST: "tcp://docker:2375"
|
||||
DOCKER_TLS_VERIFY: ""
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: general
|
||||
services:
|
||||
docker:
|
||||
image: docker:dind
|
||||
cmd:
|
||||
- "dockerd"
|
||||
- "-H"
|
||||
- "tcp://0.0.0.0:2375"
|
||||
- "--tls=false"
|
||||
container:
|
||||
image: ubuntu:24.04
|
||||
steps:
|
||||
# need to setup node and git
|
||||
- run: |
|
||||
apt-get update
|
||||
apt-get install -y nodejs git
|
||||
# check out code
|
||||
- uses: actions/checkout@v4
|
||||
# provide slug version of the envs
|
||||
- name: Slugify ref name
|
||||
uses: https://github.com/rlespinasse/slugify-value@v1.4.0
|
||||
with:
|
||||
key: GITHUB_REF_NAME
|
||||
prefix: ""
|
||||
slug-maxlength: 80
|
||||
# shorten the sha
|
||||
- name: Shorten SHA
|
||||
uses: https://github.com/rlespinasse/shortify-git-revision@v1.6.0
|
||||
with:
|
||||
name: GITHUB_SHA
|
||||
short-on-error: true
|
||||
length: 7
|
||||
prefix: ""
|
||||
# install jdk
|
||||
- uses: https://github.com/actions/setup-java@v4
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "21"
|
||||
# setup docker
|
||||
- name: set up docker cli
|
||||
run: |
|
||||
apt-get install -y ca-certificates curl dnsutils
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
|
||||
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
|
||||
tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
apt-get update
|
||||
apt-get install docker-ce-cli docker-buildx-plugin docker-compose-plugin
|
||||
# Login to register
|
||||
- uses: https://github.com/docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY_URL }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY_URL }}/${{ env.GITHUB_REPOSITORY }}:${{ env.GITHUB_REF_NAME_SLUG }}
|
||||
${{ env.REGISTRY_URL }}/${{ env.GITHUB_REPOSITORY }}:${{ env.GITHUB_SHA_SHORT }}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
FROM maven:3 AS build
|
||||
|
||||
WORKDIR /code
|
||||
COPY . /code
|
||||
RUN mvn clean package spring-boot:repackage
|
||||
|
||||
FROM azul/zulu-openjdk:21-latest
|
||||
EXPOSE 8099
|
||||
WORKDIR /app
|
||||
COPY --from=build /code/target/*.jar /app/app.jar
|
||||
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
<?xml version="1.0"?>
|
||||
<!DOCTYPE module PUBLIC
|
||||
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
|
||||
"https://checkstyle.org/dtds/configuration_1_3.dtd">
|
||||
|
||||
<!--
|
||||
This used to be a Checkstyle config by Max Vetrenko, Ruslan Diachenko,
|
||||
and Roman Ivanov.
|
||||
|
||||
It has since been modified to include changes specific to CleverThis.
|
||||
-->
|
||||
|
||||
<module name="Checker">
|
||||
<module name="SuppressWarningsFilter"/>
|
||||
|
||||
<property name="charset" value="UTF-8"/>
|
||||
|
||||
<property name="severity" value="warning"/>
|
||||
|
||||
<property name="fileExtensions" value="java, properties, xml"/>
|
||||
<!-- Excludes all 'module-info.java' files -->
|
||||
<!-- See https://checkstyle.org/filefilters/index.html -->
|
||||
<module name="BeforeExecutionExclusionFileFilter">
|
||||
<property name="fileNamePattern" value="module\-info\.java$"/>
|
||||
</module>
|
||||
<!-- https://checkstyle.org/filters/suppressionfilter.html -->
|
||||
<module name="SuppressionFilter">
|
||||
<property name="file" value="${org.checkstyle.google.suppressionfilter.config}"
|
||||
default="checkstyle-suppressions.xml" />
|
||||
<property name="optional" value="true"/>
|
||||
</module>
|
||||
|
||||
<!-- Checks for whitespace -->
|
||||
<!-- See http://checkstyle.org/checks/whitespace/index.html -->
|
||||
<module name="FileTabCharacter">
|
||||
<property name="eachLine" value="true"/>
|
||||
</module>
|
||||
|
||||
<module name="LineLength">
|
||||
<property name="fileExtensions" value="java"/>
|
||||
<property name="max" value="100"/>
|
||||
<property name="ignorePattern" value="^package.*|^import.*|a href|href|http://|https://|ftp://"/>
|
||||
</module>
|
||||
|
||||
<module name="TreeWalker">
|
||||
<module name="OuterTypeFilename"/>
|
||||
<module name="IllegalTokenText">
|
||||
<property name="tokens" value="STRING_LITERAL, CHAR_LITERAL"/>
|
||||
<property name="format"
|
||||
value="\\u00(09|0(a|A)|0(c|C)|0(d|D)|22|27|5(C|c))|\\(0(10|11|12|14|15|42|47)|134)"/>
|
||||
<property name="message"
|
||||
value="Consider using special escape sequence instead of octal value or Unicode escaped value."/>
|
||||
</module>
|
||||
<module name="AvoidEscapedUnicodeCharacters">
|
||||
<property name="allowEscapesForControlCharacters" value="true"/>
|
||||
<property name="allowByTailComment" value="true"/>
|
||||
<property name="allowNonPrintableEscapes" value="true"/>
|
||||
</module>
|
||||
<module name="AvoidStarImport"/>
|
||||
<module name="OneTopLevelClass"/>
|
||||
<module name="NoLineWrap">
|
||||
<property name="tokens" value="PACKAGE_DEF, IMPORT, STATIC_IMPORT"/>
|
||||
</module>
|
||||
<module name="EmptyBlock">
|
||||
<property name="option" value="TEXT"/>
|
||||
<property name="tokens"
|
||||
value="LITERAL_TRY, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE, LITERAL_SWITCH"/>
|
||||
</module>
|
||||
<module name="NeedBraces">
|
||||
<property name="tokens"
|
||||
value="LITERAL_DO, LITERAL_ELSE, LITERAL_FOR, LITERAL_IF, LITERAL_WHILE"/>
|
||||
</module>
|
||||
<module name="LeftCurly">
|
||||
<property name="tokens"
|
||||
value="ANNOTATION_DEF, CLASS_DEF, CTOR_DEF, ENUM_CONSTANT_DEF, ENUM_DEF,
|
||||
INTERFACE_DEF, LAMBDA, LITERAL_CATCH, LITERAL_DEFAULT,
|
||||
LITERAL_DO, LITERAL_ELSE, LITERAL_FINALLY, LITERAL_FOR, LITERAL_IF,
|
||||
LITERAL_SWITCH, LITERAL_SYNCHRONIZED, LITERAL_TRY, LITERAL_WHILE, METHOD_DEF,
|
||||
OBJBLOCK, STATIC_INIT, RECORD_DEF, COMPACT_CTOR_DEF"/>
|
||||
</module>
|
||||
<module name="RightCurly">
|
||||
<property name="id" value="RightCurlySame"/>
|
||||
<property name="tokens"
|
||||
value="LITERAL_TRY, LITERAL_CATCH, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE,
|
||||
LITERAL_DO"/>
|
||||
</module>
|
||||
<module name="RightCurly">
|
||||
<property name="id" value="RightCurlyAlone"/>
|
||||
<property name="option" value="alone"/>
|
||||
<property name="tokens"
|
||||
value="CLASS_DEF, METHOD_DEF, CTOR_DEF, LITERAL_FOR, LITERAL_WHILE, STATIC_INIT,
|
||||
INSTANCE_INIT, ANNOTATION_DEF, ENUM_DEF, INTERFACE_DEF, RECORD_DEF,
|
||||
COMPACT_CTOR_DEF, LITERAL_SWITCH"/>
|
||||
</module>
|
||||
<module name="SuppressionXpathSingleFilter">
|
||||
<!-- suppresion is required till https://github.com/checkstyle/checkstyle/issues/7541 -->
|
||||
<property name="id" value="RightCurlyAlone"/>
|
||||
<property name="query" value="//RCURLY[parent::SLIST[count(./*)=1]
|
||||
or preceding-sibling::*[last()][self::LCURLY]]"/>
|
||||
</module>
|
||||
<module name="WhitespaceAfter">
|
||||
<property name="tokens"
|
||||
value="COMMA, SEMI, TYPECAST, LITERAL_IF, LITERAL_ELSE, LITERAL_RETURN,
|
||||
LITERAL_WHILE, LITERAL_DO, LITERAL_FOR, LITERAL_FINALLY, DO_WHILE, ELLIPSIS,
|
||||
LITERAL_SWITCH, LITERAL_SYNCHRONIZED, LITERAL_TRY, LITERAL_CATCH, LAMBDA,
|
||||
LITERAL_YIELD"/>
|
||||
</module>
|
||||
<module name="WhitespaceAround">
|
||||
<property name="allowEmptyConstructors" value="true"/>
|
||||
<property name="allowEmptyLambdas" value="true"/>
|
||||
<property name="allowEmptyMethods" value="true"/>
|
||||
<property name="allowEmptyTypes" value="true"/>
|
||||
<property name="allowEmptyLoops" value="true"/>
|
||||
<property name="ignoreEnhancedForColon" value="false"/>
|
||||
<property name="tokens"
|
||||
value="ASSIGN, BAND, BAND_ASSIGN, BOR, BOR_ASSIGN, BSR, BSR_ASSIGN, BXOR,
|
||||
BXOR_ASSIGN, COLON, DIV, DIV_ASSIGN, DO_WHILE, EQUAL, GE, GT, LAMBDA, LAND,
|
||||
LCURLY, LE, LITERAL_CATCH, LITERAL_DO, LITERAL_ELSE, LITERAL_FINALLY,
|
||||
LITERAL_FOR, LITERAL_IF, LITERAL_RETURN, LITERAL_SWITCH, LITERAL_SYNCHRONIZED,
|
||||
LITERAL_TRY, LITERAL_WHILE, LOR, LT, MINUS, MINUS_ASSIGN, MOD, MOD_ASSIGN,
|
||||
NOT_EQUAL, PLUS, PLUS_ASSIGN, QUESTION, RCURLY, SL, SLIST, SL_ASSIGN, SR,
|
||||
SR_ASSIGN, STAR, STAR_ASSIGN, LITERAL_ASSERT, TYPE_EXTENSION_AND"/>
|
||||
<message key="ws.notFollowed"
|
||||
value="WhitespaceAround: ''{0}'' is not followed by whitespace. Empty blocks
|
||||
may only be represented as '{}' when not part of a multi-block statement (4.1.3)"/>
|
||||
<message key="ws.notPreceded"
|
||||
value="WhitespaceAround: ''{0}'' is not preceded with whitespace."/>
|
||||
</module>
|
||||
<module name="OneStatementPerLine"/>
|
||||
<module name="MultipleVariableDeclarations"/>
|
||||
<module name="ArrayTypeStyle"/>
|
||||
<module name="MissingSwitchDefault"/>
|
||||
<module name="FallThrough"/>
|
||||
<module name="UpperEll"/>
|
||||
<module name="ModifierOrder"/>
|
||||
<module name="EmptyLineSeparator">
|
||||
<property name="tokens"
|
||||
value="PACKAGE_DEF, IMPORT, STATIC_IMPORT, CLASS_DEF, INTERFACE_DEF, ENUM_DEF,
|
||||
STATIC_INIT, INSTANCE_INIT, METHOD_DEF, CTOR_DEF, VARIABLE_DEF, RECORD_DEF,
|
||||
COMPACT_CTOR_DEF"/>
|
||||
<property name="allowNoEmptyLineBetweenFields" value="true"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<property name="id" value="SeparatorWrapDot"/>
|
||||
<property name="tokens" value="DOT"/>
|
||||
<property name="option" value="nl"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<property name="id" value="SeparatorWrapComma"/>
|
||||
<property name="tokens" value="COMMA"/>
|
||||
<property name="option" value="EOL"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<!-- ELLIPSIS is EOL until https://github.com/google/styleguide/issues/259 -->
|
||||
<property name="id" value="SeparatorWrapEllipsis"/>
|
||||
<property name="tokens" value="ELLIPSIS"/>
|
||||
<property name="option" value="EOL"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<!-- ARRAY_DECLARATOR is EOL until https://github.com/google/styleguide/issues/258 -->
|
||||
<property name="id" value="SeparatorWrapArrayDeclarator"/>
|
||||
<property name="tokens" value="ARRAY_DECLARATOR"/>
|
||||
<property name="option" value="EOL"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<property name="id" value="SeparatorWrapMethodRef"/>
|
||||
<property name="tokens" value="METHOD_REF"/>
|
||||
<property name="option" value="nl"/>
|
||||
</module>
|
||||
<module name="PackageName">
|
||||
<property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Package name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="TypeName">
|
||||
<property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF,
|
||||
ANNOTATION_DEF, RECORD_DEF"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="MemberName">
|
||||
<property name="format" value="^[a-z][a-z0-9][a-zA-Z0-9]*$"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Member name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="ParameterName">
|
||||
<property name="format" value="^[a-z][a-z0-9]*([A-Z][a-z0-9]{1,})*[A-Z]{0,1}$"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Parameter name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="LambdaParameterName">
|
||||
<property name="format" value="^[a-z][a-z0-9]*([A-Z][a-z0-9]{1,})*[A-Z]{0,1}$"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Lambda parameter name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="CatchParameterName">
|
||||
<property name="format" value="^[a-z][a-z0-9]*([A-Z][a-z0-9]{1,})*[A-Z]{0,1}$"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Catch parameter name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="LocalVariableName">
|
||||
<property name="format" value="^[a-z][a-z0-9]*([A-Z][a-z0-9]{1,})*[A-Z]{0,1}$"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Local variable name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="PatternVariableName">
|
||||
<property name="format" value="^[a-z][a-z0-9]*([A-Z][a-z0-9]{1,})*[A-Z]{0,1}$"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Pattern variable name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="ClassTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Class type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="RecordComponentName">
|
||||
<property name="format" value="^[a-z][a-z0-9]*([A-Z][a-z0-9]{1,})*[A-Z]{0,1}$"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Record component name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="RecordTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Record type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="MethodTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Method type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="InterfaceTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Interface type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="NoFinalizer"/>
|
||||
<module name="GenericWhitespace">
|
||||
<message key="ws.followed"
|
||||
value="GenericWhitespace ''{0}'' is followed by whitespace."/>
|
||||
<message key="ws.preceded"
|
||||
value="GenericWhitespace ''{0}'' is preceded with whitespace."/>
|
||||
<message key="ws.illegalFollow"
|
||||
value="GenericWhitespace ''{0}'' should followed by whitespace."/>
|
||||
<message key="ws.notPreceded"
|
||||
value="GenericWhitespace ''{0}'' is not preceded with whitespace."/>
|
||||
</module>
|
||||
<module name="Indentation">
|
||||
<property name="basicOffset" value="4"/>
|
||||
<property name="braceAdjustment" value="4"/>
|
||||
<property name="caseIndent" value="4"/>
|
||||
<property name="throwsIndent" value="6"/>
|
||||
<property name="lineWrappingIndentation" value="6"/>
|
||||
<property name="arrayInitIndent" value="6"/>
|
||||
</module>
|
||||
<module name="AbbreviationAsWordInName">
|
||||
<property name="ignoreFinal" value="false"/>
|
||||
<property name="allowedAbbreviationLength" value="0"/>
|
||||
<property name="tokens"
|
||||
value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, ANNOTATION_DEF, ANNOTATION_FIELD_DEF,
|
||||
PARAMETER_DEF, VARIABLE_DEF, METHOD_DEF, PATTERN_VARIABLE_DEF, RECORD_DEF,
|
||||
RECORD_COMPONENT_DEF"/>
|
||||
</module>
|
||||
<module name="NoWhitespaceBeforeCaseDefaultColon"/>
|
||||
<module name="OverloadMethodsDeclarationOrder"/>
|
||||
<module name="VariableDeclarationUsageDistance"/>
|
||||
<module name="CustomImportOrder">
|
||||
<property name="sortImportsInGroupAlphabetically" value="true"/>
|
||||
<property name="separateLineBetweenGroups" value="true"/>
|
||||
<property name="customImportOrderRules" value="STATIC###THIRD_PARTY_PACKAGE"/>
|
||||
<property name="tokens" value="IMPORT, STATIC_IMPORT, PACKAGE_DEF"/>
|
||||
</module>
|
||||
<module name="MethodParamPad">
|
||||
<property name="tokens"
|
||||
value="CTOR_DEF, LITERAL_NEW, METHOD_CALL, METHOD_DEF,
|
||||
SUPER_CTOR_CALL, ENUM_CONSTANT_DEF, RECORD_DEF"/>
|
||||
</module>
|
||||
<module name="NoWhitespaceBefore">
|
||||
<property name="tokens"
|
||||
value="COMMA, SEMI, POST_INC, POST_DEC, DOT,
|
||||
LABELED_STAT, METHOD_REF"/>
|
||||
<property name="allowLineBreaks" value="true"/>
|
||||
</module>
|
||||
<module name="ParenPad">
|
||||
<property name="tokens"
|
||||
value="ANNOTATION, ANNOTATION_FIELD_DEF, CTOR_CALL, CTOR_DEF, DOT, ENUM_CONSTANT_DEF,
|
||||
EXPR, LITERAL_CATCH, LITERAL_DO, LITERAL_FOR, LITERAL_IF, LITERAL_NEW,
|
||||
LITERAL_SWITCH, LITERAL_SYNCHRONIZED, LITERAL_WHILE, METHOD_CALL,
|
||||
METHOD_DEF, QUESTION, RESOURCE_SPECIFICATION, SUPER_CTOR_CALL, LAMBDA,
|
||||
RECORD_DEF"/>
|
||||
</module>
|
||||
<module name="OperatorWrap">
|
||||
<property name="option" value="NL"/>
|
||||
<property name="tokens"
|
||||
value="BAND, BOR, BSR, BXOR, DIV, EQUAL, GE, GT, LAND, LE, LITERAL_INSTANCEOF, LOR,
|
||||
LT, MINUS, MOD, NOT_EQUAL, PLUS, QUESTION, SL, SR, STAR, METHOD_REF,
|
||||
TYPE_EXTENSION_AND "/>
|
||||
</module>
|
||||
<module name="AnnotationLocation">
|
||||
<property name="id" value="AnnotationLocationMostCases"/>
|
||||
<property name="tokens"
|
||||
value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF,
|
||||
RECORD_DEF, COMPACT_CTOR_DEF"/>
|
||||
</module>
|
||||
<module name="AnnotationLocation">
|
||||
<property name="id" value="AnnotationLocationVariables"/>
|
||||
<property name="tokens" value="VARIABLE_DEF"/>
|
||||
<property name="allowSamelineMultipleAnnotations" value="true"/>
|
||||
</module>
|
||||
<module name="NonEmptyAtclauseDescription"/>
|
||||
<module name="InvalidJavadocPosition"/>
|
||||
<module name="JavadocTagContinuationIndentation"/>
|
||||
<module name="SummaryJavadoc">
|
||||
<property name="forbiddenSummaryFragments"
|
||||
value="^@return the *|^This method returns |^A [{]@code [a-zA-Z0-9]+[}]( is a )"/>
|
||||
</module>
|
||||
<module name="RequireEmptyLineBeforeBlockTagGroup"/>
|
||||
<module name="AtclauseOrder">
|
||||
<property name="tagOrder" value="@param, @return, @throws, @deprecated"/>
|
||||
<property name="target"
|
||||
value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF, VARIABLE_DEF"/>
|
||||
</module>
|
||||
<module name="JavadocMethod">
|
||||
<property name="accessModifiers" value="public"/>
|
||||
<property name="allowMissingParamTags" value="true"/>
|
||||
<property name="allowMissingReturnTag" value="true"/>
|
||||
<property name="allowedAnnotations" value="Override, Test"/>
|
||||
<property name="tokens" value="METHOD_DEF, CTOR_DEF, ANNOTATION_FIELD_DEF, COMPACT_CTOR_DEF"/>
|
||||
</module>
|
||||
<module name="MissingJavadocMethod">
|
||||
<property name="scope" value="public"/>
|
||||
<property name="minLineCount" value="2"/>
|
||||
<property name="allowedAnnotations" value="Override, Test"/>
|
||||
<property name="tokens" value="METHOD_DEF, CTOR_DEF, ANNOTATION_FIELD_DEF,
|
||||
COMPACT_CTOR_DEF"/>
|
||||
</module>
|
||||
<module name="MissingJavadocType">
|
||||
<property name="scope" value="protected"/>
|
||||
<property name="tokens"
|
||||
value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF,
|
||||
RECORD_DEF, ANNOTATION_DEF"/>
|
||||
<property name="excludeScope" value="nothing"/>
|
||||
</module>
|
||||
<module name="MethodName">
|
||||
<property name="format" value="^[a-z][a-z0-9]\w*$"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Method name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="SingleLineJavadoc"/>
|
||||
<module name="EmptyCatchBlock">
|
||||
<property name="exceptionVariableName" value="expected"/>
|
||||
</module>
|
||||
<module name="CommentsIndentation">
|
||||
<property name="tokens" value="SINGLE_LINE_COMMENT, BLOCK_COMMENT_BEGIN"/>
|
||||
</module>
|
||||
<!-- https://checkstyle.org/filters/suppressionxpathfilter.html -->
|
||||
<module name="SuppressionXpathFilter">
|
||||
<property name="file" value="${org.checkstyle.google.suppressionxpathfilter.config}"
|
||||
default="checkstyle-xpath-suppressions.xml" />
|
||||
<property name="optional" value="true"/>
|
||||
</module>
|
||||
<module name="SuppressWarningsHolder" />
|
||||
<module name="SuppressionCommentFilter">
|
||||
<property name="offCommentFormat" value="CHECKSTYLE.OFF\: ([\w\|]+)" />
|
||||
<property name="onCommentFormat" value="CHECKSTYLE.ON\: ([\w\|]+)" />
|
||||
<property name="checkFormat" value="$1" />
|
||||
</module>
|
||||
<module name="SuppressWithNearbyCommentFilter">
|
||||
<property name="commentFormat" value="CHECKSTYLE.SUPPRESS\: ([\w\|]+)"/>
|
||||
<!-- $1 refers to the first match group in the regex defined in commentFormat -->
|
||||
<property name="checkFormat" value="$1"/>
|
||||
<!-- The check is suppressed in the next line of code after the comment -->
|
||||
<property name="influenceFormat" value="1"/>
|
||||
</module>
|
||||
<module name="RequireThis">
|
||||
<property name="checkMethods" value="false"/>
|
||||
<property name="validateOnlyOverlapping" value="false"/>
|
||||
</module>
|
||||
</module>
|
||||
</module>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
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>3.4.5</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
<groupId>com.cleverthis</groupId>
|
||||
<artifactId>auth-service</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>auth-service</name>
|
||||
<description>User management project for CleverThis</description>
|
||||
<url/>
|
||||
<licenses>
|
||||
<license/>
|
||||
</licenses>
|
||||
<developers>
|
||||
<developer/>
|
||||
</developers>
|
||||
<scm>
|
||||
<connection/>
|
||||
<developerConnection/>
|
||||
<tag/>
|
||||
<url/>
|
||||
</scm>
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<start-class>com.cleverthis.authservice.AuthServiceApplication</start-class>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-test</artifactId>
|
||||
<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>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>0.8.13</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>prepare-package</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<version>3.6.0</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.puppycrawl.tools</groupId>
|
||||
<artifactId>checkstyle</artifactId>
|
||||
<version>10.23.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<configuration>
|
||||
<configLocation>checkstyle.xml</configLocation>
|
||||
<encoding>UTF-8</encoding>
|
||||
<consoleOutput>true</consoleOutput>
|
||||
<failsOnError>true</failsOnError>
|
||||
<violationSeverity>warning</violationSeverity>
|
||||
<failOnViolation>true</failOnViolation>
|
||||
<linkXRef>false</linkXRef>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>validate</id>
|
||||
<phase>validate</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.cleverthis.authservice;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* Auth-Service starter.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class AuthServiceApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(AuthServiceApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.cleverthis.authservice.config;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
|
||||
/**
|
||||
* Configuration properties for mapping request paths to Keycloak Client IDs.
|
||||
* Loaded from application.yml under 'auth-service.permission-mapping'.
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "auth-service.permission-mapping")
|
||||
@Data
|
||||
@Validated
|
||||
public class PermissionMappingConfig {
|
||||
|
||||
@Valid
|
||||
private List<Rule> rules = new ArrayList<>();
|
||||
private String defaultKeycloakClientId; // Optional default
|
||||
|
||||
/**
|
||||
* mapping request paths to Keycloak Client IDs.
|
||||
*/
|
||||
@Data
|
||||
public static class Rule {
|
||||
@NotBlank(message = "Path prefix cannot be blank in permission mapping rule")
|
||||
private String pathPrefix;
|
||||
@NotBlank(message = "Keycloak Client ID cannot be blank in permission mapping rule")
|
||||
private String keycloakClientId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.cleverthis.authservice.config;
|
||||
|
||||
import io.netty.channel.ChannelOption;
|
||||
import io.netty.handler.timeout.ReadTimeoutHandler;
|
||||
import io.netty.handler.timeout.WriteTimeoutHandler;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.netty.http.client.HttpClient;
|
||||
|
||||
/**
|
||||
* Configuration class for creating and configuring the WebClient bean used for
|
||||
* making HTTP requests
|
||||
* to Keycloak.
|
||||
*/
|
||||
@Configuration
|
||||
public class WebClientConfig {
|
||||
|
||||
// Timeout values in milliseconds
|
||||
private static final int CONNECT_TIMEOUT = 5000; // 5 seconds
|
||||
private static final int READ_TIMEOUT = 10000; // 10 seconds
|
||||
private static final int WRITE_TIMEOUT = 10000; // 10 seconds
|
||||
|
||||
/**
|
||||
* Provide a {@link WebClient} for interacting with keycloak.
|
||||
*/
|
||||
@Bean
|
||||
public WebClient keycloakWebClient() {
|
||||
// Configure HttpClient with timeouts
|
||||
HttpClient httpClient = HttpClient.create()
|
||||
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, CONNECT_TIMEOUT)
|
||||
.responseTimeout(Duration.ofMillis(READ_TIMEOUT)) // Max time to wait for response
|
||||
.doOnConnected(conn -> conn.addHandlerLast(
|
||||
new ReadTimeoutHandler(READ_TIMEOUT, TimeUnit.MILLISECONDS))
|
||||
.addHandlerLast(
|
||||
new WriteTimeoutHandler(WRITE_TIMEOUT, TimeUnit.MILLISECONDS)));
|
||||
|
||||
// Build WebClient with the configured HttpClient
|
||||
return WebClient.builder()
|
||||
.clientConnector(new ReactorClientHttpConnector(httpClient))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
package com.cleverthis.authservice.controller;
|
||||
|
||||
import com.cleverthis.authservice.config.PermissionMappingConfig;
|
||||
import com.cleverthis.authservice.dto.LoginRequest;
|
||||
import com.cleverthis.authservice.dto.LogoutRequest;
|
||||
import com.cleverthis.authservice.dto.TokenResponse;
|
||||
import com.cleverthis.authservice.dto.VerificationResponse;
|
||||
import com.cleverthis.authservice.exception.TokenVerificationException;
|
||||
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||
import jakarta.validation.Valid;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* REST Controller for handling authentication related requests. Delegates the actual identity
|
||||
* provider interaction to the IdentityManagerService.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/")
|
||||
@Slf4j
|
||||
public class AuthController {
|
||||
|
||||
// --- Constants for Header Names ---
|
||||
private static final String HEADER_USER_ID = "X-User-Id";
|
||||
private static final String HEADER_USER_NAME = "X-User-Name";
|
||||
private static final String HEADER_USER_EMAIL = "X-User-Email";
|
||||
private static final String HEADER_USER_GROUPS = "X-User-Groups";
|
||||
// Add more headers as needed (e.g., X-User-Roles)
|
||||
|
||||
// --- Traefik Forwarded Headers ---
|
||||
private static final String HEADER_X_FORWARDED_METHOD = "X-Forwarded-Method";
|
||||
private static final String HEADER_X_FORWARDED_URI = "X-Forwarded-Uri"; // Full original URI
|
||||
// private static final String HEADER_X_FORWARDED_HOST = "X-Forwarded-Host";
|
||||
// private static final String HEADER_X_FORWARDED_PROTO = "X-Forwarded-Proto";
|
||||
|
||||
private final IdentityManagerService identityManagerService;
|
||||
private final PermissionMappingConfig permissionMappingConfig; // Inject mapping config
|
||||
|
||||
@Autowired
|
||||
public AuthController(IdentityManagerService identityManagerService,
|
||||
PermissionMappingConfig permissionMappingConfig) {
|
||||
this.identityManagerService = identityManagerService;
|
||||
this.permissionMappingConfig = permissionMappingConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles user login requests using username and password.
|
||||
*
|
||||
* @param loginRequest DTO containing username and password.
|
||||
* @return ResponseEntity containing the TokenResponse on success, or an error status handled
|
||||
* globally.
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
public Mono<ResponseEntity<TokenResponse>> login(
|
||||
@Valid @RequestBody LoginRequest loginRequest) {
|
||||
log.info("Received login request for user: {}", loginRequest.getUsername());
|
||||
return this.identityManagerService
|
||||
.login(loginRequest.getUsername(), loginRequest.getPassword())
|
||||
.map(ResponseEntity::ok) // Map successful response to ResponseEntity<TokenResponse>
|
||||
.doOnError(e -> log.error("Login failed for user {}: " + "{}",
|
||||
loginRequest.getUsername(), e.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles token verification requests. Expects the access token in the Authorization header
|
||||
* (Bearer scheme). Returns full token introspection details.
|
||||
*
|
||||
* @param authorizationHeader The full Authorization header value (e.g., "Bearer token").
|
||||
* @return ResponseEntity containing the VerificationResponse on success, or an error status
|
||||
* handled globally.
|
||||
*/
|
||||
@PostMapping("/verify")
|
||||
public Mono<ResponseEntity<VerificationResponse>> verify(
|
||||
@RequestHeader(HttpHeaders.AUTHORIZATION) String authorizationHeader) {
|
||||
Mono<ResponseEntity<VerificationResponse>> result;
|
||||
log.info("Received token verification request (/verify)");
|
||||
String accessToken = extractBearerToken(authorizationHeader);
|
||||
if (accessToken == null) {
|
||||
log.warn("Verification request missing or invalid Bearer token format," + " token : {}",
|
||||
authorizationHeader);
|
||||
result = Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
|
||||
} else {
|
||||
result = this.identityManagerService.verifyToken(accessToken).map(ResponseEntity::ok)
|
||||
.doOnError(e -> log.error("Token verification failed: {}", e.getMessage()));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the token from the "Bearer token" Authorization header.
|
||||
*
|
||||
* @param authorizationHeader The full header value.
|
||||
* @return The token string or null if header is invalid.
|
||||
*/
|
||||
private String extractBearerToken(String authorizationHeader) {
|
||||
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
|
||||
return authorizationHeader.substring(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles user logout requests using a refresh token.
|
||||
*
|
||||
* @param logoutRequest DTO containing the refresh token.
|
||||
* @return ResponseEntity with 204 No Content on success, or an error status handled globally.
|
||||
*/
|
||||
@PostMapping("/logout")
|
||||
public Mono<ResponseEntity<Void>> logout(@Valid @RequestBody LogoutRequest logoutRequest) {
|
||||
log.info("Received logout request");
|
||||
return this.identityManagerService.logout(logoutRequest.getRefreshToken())
|
||||
.thenReturn(ResponseEntity.noContent().<Void>build())
|
||||
.doOnError(e -> log.error("Logout failed: {}", e.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds HttpHeaders containing user metadata based on the verification response.
|
||||
*
|
||||
* @param verificationResponse The response from token verification.
|
||||
* @return HttpHeaders object with X-User-* headers.
|
||||
*/
|
||||
private HttpHeaders buildAuthHeaders(VerificationResponse verificationResponse) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
// Use 'sub' (subject) as the canonical User ID
|
||||
if (verificationResponse.getSub() != null) {
|
||||
headers.add(HEADER_USER_ID, verificationResponse.getSub());
|
||||
}
|
||||
// Use 'preferredUsername' as the display/login name
|
||||
if (verificationResponse.getPreferredUsername() != null) {
|
||||
headers.add(HEADER_USER_NAME, verificationResponse.getPreferredUsername());
|
||||
}
|
||||
if (verificationResponse.getEmail() != null) {
|
||||
headers.add(HEADER_USER_EMAIL, verificationResponse.getEmail());
|
||||
}
|
||||
// Handle groups if present
|
||||
if (verificationResponse.getGroups() != null
|
||||
&& !verificationResponse.getGroups().isEmpty()) {
|
||||
// Convert list of groups to a comma-separated string
|
||||
headers.add(HEADER_USER_GROUPS,
|
||||
StringUtils.collectionToCommaDelimitedString(verificationResponse.getGroups()));
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles forward authentication requests from Traefik. Expects the access token in the
|
||||
* Authorization header (Bearer scheme), and X-Forwarded-Method, X-Forwarded-Uri from Traefik.
|
||||
* On success, returns 200 OK with user metadata in headers. On failure, returns 401
|
||||
* Unauthorized or 403 Forbidden.
|
||||
*/
|
||||
@RequestMapping(value = "/auth", method = { RequestMethod.GET, RequestMethod.POST,
|
||||
RequestMethod.PUT, RequestMethod.DELETE,
|
||||
RequestMethod.PATCH })
|
||||
public Mono<ResponseEntity<? extends Object>> forwardAuth(
|
||||
@RequestHeader(HttpHeaders.AUTHORIZATION) String authorizationHeader,
|
||||
@RequestHeader(HEADER_X_FORWARDED_METHOD) String originalMethod,
|
||||
@RequestHeader(HEADER_X_FORWARDED_URI) String originalUriString) {
|
||||
|
||||
log.info("Received forwardAuth request: Method='{}', Uri='{}'", originalMethod,
|
||||
originalUriString);
|
||||
|
||||
String accessToken = extractBearerToken(authorizationHeader);
|
||||
if (accessToken == null) {
|
||||
log.warn("ForwardAuth: Missing or invalid Bearer token format.");
|
||||
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
|
||||
}
|
||||
|
||||
// Step 1: Validate the user's token and get user details (especially userSub)
|
||||
return this.identityManagerService.verifyToken(accessToken)
|
||||
.flatMap(verificationResponse -> {
|
||||
// Token is valid and active, proceed to permission check
|
||||
String userSub = verificationResponse.getSub();
|
||||
if (userSub == null || userSub.isBlank()) {
|
||||
log.error(
|
||||
"ForwardAuth: User subject (sub) "
|
||||
+ "is missing from token after verification.");
|
||||
return Mono.just(
|
||||
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
|
||||
}
|
||||
|
||||
// Step 2: Determine target Keycloak Client ID from original URI
|
||||
Optional<String> targetServiceClientIdOpt =
|
||||
getTargetServiceClientId(originalUriString);
|
||||
if (targetServiceClientIdOpt.isEmpty()) {
|
||||
log.warn("ForwardAuth: No Keycloak client mapping found for URI: {}",
|
||||
originalUriString);
|
||||
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN).build());
|
||||
}
|
||||
String targetServiceClientId = targetServiceClientIdOpt.get();
|
||||
|
||||
// Step 3: Construct the required Keycloak Client Role name
|
||||
// Path for role construction should be relative to the service prefix
|
||||
String serviceRelativePath =
|
||||
extractServiceRelativePath(originalUriString, targetServiceClientId);
|
||||
List<String> candidateRoleNames =
|
||||
generateCandidateRoleNames(originalMethod, serviceRelativePath);
|
||||
|
||||
if (candidateRoleNames.isEmpty()) {
|
||||
log.warn(
|
||||
"ForwardAuth: No candidate roles generated for Method='{}',"
|
||||
+ " RelativePath='{}'. Denying access.",
|
||||
originalMethod, serviceRelativePath);
|
||||
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN).<Void>build());
|
||||
}
|
||||
|
||||
// Step 4: Check permission
|
||||
return this.identityManagerService.checkUserHasAnyClientRole(userSub,
|
||||
targetServiceClientId, candidateRoleNames).flatMap(hasPermission -> {
|
||||
if (hasPermission) {
|
||||
log.info(
|
||||
"ForwardAuth: GRANTED for UserSub='{}',"
|
||||
+ " Role='{}', Client='{}'",
|
||||
userSub, candidateRoleNames, targetServiceClientId);
|
||||
HttpHeaders responseHeaders =
|
||||
buildAuthHeaders(verificationResponse);
|
||||
return Mono.just(ResponseEntity.ok().headers(responseHeaders)
|
||||
.<Void>build());
|
||||
} else {
|
||||
log.warn(
|
||||
"ForwardAuth: DENIED for UserSub='{}',"
|
||||
+ " Role='{}', Client='{}'",
|
||||
userSub, candidateRoleNames, targetServiceClientId);
|
||||
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.<Void>build());
|
||||
}
|
||||
});
|
||||
}).onErrorResume(TokenVerificationException.class, e -> {
|
||||
log.warn("ForwardAuth: Token verification failed: {}", e.getMessage());
|
||||
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
|
||||
}).onErrorResume(throwable -> !(throwable instanceof TokenVerificationException),
|
||||
t -> {
|
||||
log.error(
|
||||
"ForwardAuth: Generic error/exception during"
|
||||
+ " permission processing: {}",
|
||||
t.getMessage(), t);
|
||||
return Mono.just(
|
||||
ResponseEntity.status(HttpStatus.UNAUTHORIZED).<Void>build());
|
||||
})
|
||||
.onErrorResume(Exception.class, e -> { // Catch other unexpected errors
|
||||
log.error("ForwardAuth: Internal error during permission check: {}",
|
||||
e.getMessage(), e);
|
||||
return Mono
|
||||
.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
|
||||
});
|
||||
}
|
||||
|
||||
private Optional<String> getTargetServiceClientId(String fullUriString) {
|
||||
try {
|
||||
URI uri = new URI(fullUriString);
|
||||
String path = uri.getPath(); // Get path part, e.g., /cleverswarm/api/jobs/123
|
||||
log.debug("Mapping URI path: {}", path);
|
||||
|
||||
for (PermissionMappingConfig.Rule rule : this.permissionMappingConfig.getRules()) {
|
||||
if (path.startsWith(rule.getPathPrefix())) {
|
||||
log.debug("Path '{}' matched prefix '{}', using Keycloak Client ID: {}", path,
|
||||
rule.getPathPrefix(), rule.getKeycloakClientId());
|
||||
return Optional.of(rule.getKeycloakClientId());
|
||||
}
|
||||
}
|
||||
if (this.permissionMappingConfig.getDefaultKeycloakClientId() != null) {
|
||||
log.debug(
|
||||
"No specific prefix matched for path '{}',"
|
||||
+ "using default Keycloak Client ID:{}",
|
||||
path, this.permissionMappingConfig.getDefaultKeycloakClientId());
|
||||
return Optional.of(this.permissionMappingConfig.getDefaultKeycloakClientId());
|
||||
}
|
||||
} catch (URISyntaxException e) {
|
||||
log.error("Invalid URI syntax for X-Forwarded-Uri: {}", fullUriString, e);
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private String extractServiceRelativePath(String fullUriString, String mappedKeycloakClientId) {
|
||||
// Find the rule that gave us this mappedKeycloakClientId to get its pathPrefix
|
||||
Optional<PermissionMappingConfig.Rule> matchedRule = this.permissionMappingConfig.getRules()
|
||||
.stream().filter(rule -> rule.getKeycloakClientId().equals(mappedKeycloakClientId)
|
||||
&& fullUriString.startsWith(rule.getPathPrefix()))
|
||||
.findFirst();
|
||||
|
||||
String pathPrefixToRemove = "";
|
||||
if (matchedRule.isPresent()) {
|
||||
pathPrefixToRemove = matchedRule.get().getPathPrefix();
|
||||
} else if (mappedKeycloakClientId
|
||||
.equals(this.permissionMappingConfig.getDefaultKeycloakClientId())) {
|
||||
// If it's a default mapping, there's no prefix to remove, use the whole path.
|
||||
// Or, you might decide that default mappings always apply to root paths. This
|
||||
// needs definition.
|
||||
// For now, assume full path if default.
|
||||
try {
|
||||
return new URI(fullUriString).getPath();
|
||||
} catch (URISyntaxException e) {
|
||||
log.error(
|
||||
"Invalid URI syntax for X-Forwarded-Uri when extracting relative path:"
|
||||
+ " {}",
|
||||
fullUriString, e);
|
||||
return ""; // Or throw
|
||||
}
|
||||
}
|
||||
try {
|
||||
URI uri = new URI(fullUriString);
|
||||
String path = uri.getPath();
|
||||
if (path.startsWith(pathPrefixToRemove)) {
|
||||
String relativePath = path.substring(pathPrefixToRemove.length());
|
||||
// Ensure it starts with a slash if not empty
|
||||
return relativePath.startsWith("/") || relativePath.isEmpty() ? relativePath
|
||||
: "/" + relativePath;
|
||||
}
|
||||
return path; // Fallback
|
||||
} catch (URISyntaxException e) {
|
||||
log.error("Invalid URI syntax for X-Forwarded-Uri when extracting relative path: {}",
|
||||
fullUriString, e);
|
||||
return ""; // Or throw
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a list of candidate Keycloak Client Role names
|
||||
* based on HTTP method and relative path,
|
||||
* ordered from most specific to most general.
|
||||
* Examples for GET /api/items/123e4567-e89b-12d3-a456-426614174000
|
||||
* (normalized to /api/items/BY_ID):
|
||||
* - GET_API_ITEMS_BY_ID
|
||||
* - ALL_API_ITEMS_BY_ID
|
||||
* - GET_API_ITEMS_STAR
|
||||
* - ALL_API_ITEMS_STAR
|
||||
* - GET_API_STAR
|
||||
* - ALL_API_STAR
|
||||
* - GET_STAR
|
||||
* - ALL_STAR
|
||||
*/
|
||||
private List<String> generateCandidateRoleNames(String httpMethod, String relativePath) {
|
||||
final String httpMethodUpper = httpMethod.toUpperCase();
|
||||
final Set<String> candidates = new LinkedHashSet<>();
|
||||
|
||||
// Normalize path: remove leading slash for segment processing, handle root
|
||||
String normalizedPath = relativePath.trim();
|
||||
if (normalizedPath.startsWith("/")) {
|
||||
normalizedPath = normalizedPath.substring(1);
|
||||
}
|
||||
if (normalizedPath.endsWith("/")) { // Remove trailing slash if not root
|
||||
if (normalizedPath.length() > 1) {
|
||||
normalizedPath = normalizedPath.substring(0, normalizedPath.length() - 1);
|
||||
} else if (normalizedPath.length() == 1 && normalizedPath.equals("/")) {
|
||||
normalizedPath = ""; // Treat as root
|
||||
}
|
||||
}
|
||||
|
||||
// Replace path parameters and UUIDs
|
||||
String processedPath = normalizedPath.replaceAll("\\{[^}]+}", "BY_ID");
|
||||
processedPath = processedPath.replaceAll(
|
||||
"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}",
|
||||
"BY_ID");
|
||||
|
||||
List<String> segments = new ArrayList<>();
|
||||
if (!processedPath.isEmpty()) {
|
||||
segments.addAll(Arrays.asList(processedPath.split("/")));
|
||||
}
|
||||
|
||||
// Generate path-specific role parts
|
||||
// For path /p1/p2/p3: P1_P2_P3, P1_P2_STAR, P1_STAR, STAR
|
||||
if (!segments.isEmpty()) {
|
||||
for (int i = segments.size(); i >= 0; i--) {
|
||||
StringBuilder pathPartSb = new StringBuilder();
|
||||
for (int j = 0; j < i; j++) {
|
||||
pathPartSb.append(segments.get(j).toUpperCase());
|
||||
if (j < i - 1) {
|
||||
pathPartSb.append("_");
|
||||
}
|
||||
}
|
||||
if (i < segments.size()) { // Add STAR if not the full specific path
|
||||
if (pathPartSb.length() > 0) {
|
||||
pathPartSb.append("_");
|
||||
}
|
||||
pathPartSb.append("STAR");
|
||||
}
|
||||
String pathRoleSuffix = pathPartSb.toString();
|
||||
if (pathRoleSuffix.isEmpty() && segments.size() > 0) {
|
||||
pathRoleSuffix = "STAR";
|
||||
} else if (pathRoleSuffix.isEmpty() && segments.isEmpty()) { // for root path "/"
|
||||
pathRoleSuffix = "ROOT"; // or STAR depending on convention
|
||||
}
|
||||
|
||||
// Avoid double STAR for METHOD_STAR_STAR etc.
|
||||
if (!pathRoleSuffix.isEmpty() && !pathRoleSuffix.equals("STAR")) {
|
||||
candidates.add(httpMethodUpper + "_" + pathRoleSuffix);
|
||||
candidates.add("ALL_" + pathRoleSuffix);
|
||||
// for root path "/" generating METHOD_ROOT and ALL_ROOT
|
||||
} else if (pathRoleSuffix.equals("STAR") && segments.isEmpty()) {
|
||||
candidates.add(httpMethodUpper + "_ROOT");
|
||||
candidates.add("ALL_ROOT");
|
||||
}
|
||||
}
|
||||
} else { // Root path "/"
|
||||
candidates.add(httpMethodUpper + "_ROOT"); // e.g. GET_ROOT
|
||||
candidates.add("ALL_ROOT"); // e.g. ALL_ROOT
|
||||
}
|
||||
|
||||
// Add method-wide wildcard and client-wide wildcard
|
||||
candidates.add(httpMethodUpper + "_STAR");
|
||||
candidates.add("ALL_STAR");
|
||||
|
||||
List<String> orderedCandidates = new ArrayList<>(candidates);
|
||||
log.debug("Generated candidate roles for method '{}', relative path '{}': {}", httpMethod,
|
||||
relativePath, orderedCandidates);
|
||||
return orderedCandidates;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.cleverthis.authservice.controller;
|
||||
|
||||
import com.cleverthis.authservice.dto.RegistrationRequest;
|
||||
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
|
||||
/**
|
||||
* REST Controller for handling registration related requests.
|
||||
* provider interaction to the IdentityManagerService.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/users")
|
||||
@Slf4j
|
||||
public class UserRegistrationController {
|
||||
|
||||
private final IdentityManagerService identityManagerService;
|
||||
|
||||
@Autowired
|
||||
public UserRegistrationController(IdentityManagerService identityManagerService) {
|
||||
this.identityManagerService = identityManagerService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles new user registration requests. The user will be created with emailVerified set to
|
||||
* false. A conceptual verification email would be sent (implementation of email sending is
|
||||
* TBD).
|
||||
*
|
||||
* @param registrationRequest DTO containing user registration details.
|
||||
* @return ResponseEntity with 201 Created on success, or an error status.
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
public Mono<ResponseEntity<Void>> registerUser(
|
||||
@Valid @RequestBody RegistrationRequest registrationRequest) {
|
||||
log.info("Received registration request for email: {}", registrationRequest.getEmail());
|
||||
return this.identityManagerService.registerUser(registrationRequest)
|
||||
.then(Mono.fromCallable(
|
||||
() -> ResponseEntity.status(HttpStatus.CREATED).<Void>build()))
|
||||
.doOnError(e -> log.error("User registration failed for email {}: {}",
|
||||
registrationRequest.getEmail(), e.getMessage()))
|
||||
.onErrorResume(e -> {
|
||||
log.error("Registration failed with an unexpected error: {}", e.getMessage());
|
||||
return Mono
|
||||
.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.cleverthis.authservice.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO for the Keycloak Admin token response (Client Credentials Grant).
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class KeycloakAdminTokenResponse {
|
||||
@JsonProperty("access_token")
|
||||
private String accessToken;
|
||||
@JsonProperty("expires_in")
|
||||
private int expiresIn;
|
||||
@JsonProperty("refresh_expires_in")
|
||||
private int refreshExpiresIn;
|
||||
@JsonProperty("token_type")
|
||||
private String tokenType;
|
||||
@JsonProperty("not-before-policy")
|
||||
private int notBeforePolicy;
|
||||
@JsonProperty("scope")
|
||||
private String scope;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.cleverthis.authservice.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO to represent a Keycloak Client when fetched from Admin API.
|
||||
* We need this to get the internal 'id' (UUID) of a client from its 'clientId'.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class KeycloakClientRepresentation {
|
||||
private String id; // This is the internal UUID
|
||||
@JsonProperty("client_Id") // Maps the JSON field 'clientId' to this Java field
|
||||
private String publicClientId; // The human-readable client_id
|
||||
private String name;
|
||||
private String description;
|
||||
private boolean enabled;
|
||||
private List<String> defaultRoles;
|
||||
private Map<String, String> attributes;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.cleverthis.authservice.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO to represent a Keycloak Role when fetched from Admin API.
|
||||
* We are primarily interested in the 'name' of the role.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class KeycloakRoleRepresentation {
|
||||
private String id;
|
||||
private String name;
|
||||
private String description;
|
||||
private Boolean composite;
|
||||
private Boolean clientRole;
|
||||
private String containerId; // For client roles, this is the client's internal ID
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.cleverthis.authservice.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO for the /login request body.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class LoginRequest {
|
||||
@NotBlank(message = "Username cannot be blank")
|
||||
private String username;
|
||||
|
||||
@NotBlank(message = "Password cannot be blank")
|
||||
private String password;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.cleverthis.authservice.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO for the /logout request body.
|
||||
*/
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class LogoutRequest {
|
||||
@NotBlank(message = "Refresh token cannot be blank")
|
||||
private String refreshToken;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.cleverthis.authservice.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO for the /register request body.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class RegistrationRequest {
|
||||
|
||||
@NotBlank(message = "First name cannot be blank")
|
||||
@Size(min = 1, max = 50, message = "First name must be between 1 and 50 characters")
|
||||
private String firstName;
|
||||
|
||||
@NotBlank(message = "Last name cannot be blank")
|
||||
@Size(min = 1, max = 50, message = "Last name must be between 1 and 50 characters")
|
||||
private String lastName;
|
||||
|
||||
@NotBlank(message = "Email cannot be blank")
|
||||
@Email(message = "Email should be valid")
|
||||
private String email;
|
||||
|
||||
@NotBlank(message = "Password cannot be blank")
|
||||
@Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters")
|
||||
// Add more password complexity validation if needed via custom validator or regex pattern
|
||||
private String password;
|
||||
|
||||
// Optional: username, if different from email and the Keycloak setup requires it
|
||||
// @NotBlank(message = "Username cannot be blank")
|
||||
// private String username;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.cleverthis.authservice.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO representing a successful token response from Keycloak (or similar). Contains the fields
|
||||
* typically returned by an OAuth2 token endpoint.
|
||||
*/
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TokenResponse {
|
||||
|
||||
@JsonProperty("access_token")
|
||||
private String accessToken;
|
||||
|
||||
@JsonProperty("expires_in")
|
||||
private int expiresIn;
|
||||
|
||||
@JsonProperty("refresh_expires_in")
|
||||
private int refreshExpiresIn;
|
||||
|
||||
@JsonProperty("refresh_token")
|
||||
private String refreshToken;
|
||||
|
||||
@JsonProperty("token_type")
|
||||
private String tokenType;
|
||||
|
||||
@JsonProperty("id_token")
|
||||
private String idToken;
|
||||
|
||||
@JsonProperty("not-before-policy")
|
||||
private int notBeforePolicy;
|
||||
|
||||
@JsonProperty("session_state")
|
||||
private String sessionState;
|
||||
|
||||
@JsonProperty("scope")
|
||||
private String scope;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.cleverthis.authservice.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.List;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO representing the response from Keycloak's token introspection endpoint.
|
||||
* Also serves as the response for our /verify endpoint and basis for /auth headers.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class VerificationResponse {
|
||||
private boolean active; // Standard introspection field: is the token active?
|
||||
@JsonProperty("client_id")
|
||||
private String clientId;
|
||||
private String username; // Often same as preferred_username
|
||||
private String scope;
|
||||
private String sub; // Subject (user ID) - Use this for X-User-Id
|
||||
private long exp; // Expiration timestamp
|
||||
private long iat; // Issued at timestamp
|
||||
private long nbf; // Not before timestamp
|
||||
private List<String> aud; // Audience - Can be a list if token has multiple audiences
|
||||
private String iss; // Issuer
|
||||
private String typ; // Type (e.g., Bearer)
|
||||
private String email; // Standard OIDC claim
|
||||
@JsonProperty("email_verified")
|
||||
private Boolean emailVerified; // Standard OIDC claim
|
||||
@JsonProperty("preferred_username")
|
||||
private String preferredUsername; // Standard OIDC claim - Use this for X-User-Name
|
||||
private List<String> groups;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.cleverthis.authservice.exception;
|
||||
|
||||
/**
|
||||
* Custom exception for authentication failures.
|
||||
*/
|
||||
public class AuthenticationException extends RuntimeException {
|
||||
|
||||
public AuthenticationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public AuthenticationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.cleverthis.authservice.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler;
|
||||
|
||||
/**
|
||||
* Global Exception Handler to translate custom exceptions into appropriate HTTP responses using
|
||||
* Problem Details (RFC 7807).
|
||||
*/
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
||||
|
||||
/**
|
||||
* Map {@link AuthenticationException} to HTTP 401.
|
||||
*/
|
||||
@ExceptionHandler(AuthenticationException.class)
|
||||
public ProblemDetail handleAuthenticationException(AuthenticationException ex) {
|
||||
ProblemDetail problemDetail =
|
||||
ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage());
|
||||
problemDetail.setTitle("Authentication Failed");
|
||||
// Add more details if needed
|
||||
// problemDetail.setProperty("details", "Check username/password or Keycloak status.");
|
||||
return problemDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map {@link TokenVerificationException} to HTTP 401.
|
||||
*/
|
||||
@ExceptionHandler(TokenVerificationException.class)
|
||||
public ProblemDetail handleTokenVerificationException(TokenVerificationException ex) {
|
||||
ProblemDetail problemDetail =
|
||||
ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage());
|
||||
problemDetail.setTitle("Token Verification Failed");
|
||||
return problemDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map {@link LogoutException} to HTTP 400 or 500.
|
||||
*/
|
||||
@ExceptionHandler(LogoutException.class)
|
||||
public ProblemDetail handleLogoutException(LogoutException ex) {
|
||||
// Depending on the cause, might return 400 or 500
|
||||
ProblemDetail problemDetail =
|
||||
ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage());
|
||||
problemDetail.setTitle("Logout Failed");
|
||||
return problemDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map {@link PermissionCheckException} to HTTP 403.
|
||||
*/
|
||||
@ExceptionHandler(PermissionCheckException.class) // New Handler
|
||||
public ProblemDetail handlePermissionCheckException(PermissionCheckException ex) {
|
||||
// Usually a permission check failure should result in 403 Forbidden
|
||||
ProblemDetail problemDetail =
|
||||
ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, ex.getMessage());
|
||||
problemDetail.setTitle("Access Denied");
|
||||
return problemDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map {@link ServiceUnavailableException} to HTTP 503.
|
||||
*/
|
||||
@ExceptionHandler(ServiceUnavailableException.class) // New Handler
|
||||
public ProblemDetail handleServiceUnavailableException(ServiceUnavailableException ex) {
|
||||
// Error communicating with Keycloak Admin API or other critical backend
|
||||
logger.error("Service unavailable exception: {}", ex); // Log with stack trace
|
||||
ProblemDetail problemDetail =
|
||||
ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"An external service required for authorization is currently unavailable.");
|
||||
problemDetail.setTitle("Service Unavailable");
|
||||
return problemDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map {@link UserAlreadyExistsException} to HTTP 409.
|
||||
*/
|
||||
@ExceptionHandler(UserAlreadyExistsException.class)
|
||||
public ProblemDetail handleUserAlreadyExistsException(UserAlreadyExistsException ex) {
|
||||
ProblemDetail problemDetail =
|
||||
ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
|
||||
problemDetail.setTitle("User Registration Conflict");
|
||||
return problemDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map {@link RegistrationException} to HTTP 500.
|
||||
*/
|
||||
@ExceptionHandler(RegistrationException.class)
|
||||
public ProblemDetail handleRegistrationException(RegistrationException ex) {
|
||||
// Could be BAD_REQUEST or INTERNAL_SERVER_ERROR depending on the cause
|
||||
ProblemDetail problemDetail =
|
||||
ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage());
|
||||
problemDetail.setTitle("User Registration Failed");
|
||||
return problemDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map {@link Exception} to HTTP 500, Catch-all for other Generic exceptions.
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ProblemDetail handleGenericException(Exception ex) {
|
||||
logger.error("An internal error occurred.: ", ex); // Log the full stack trace
|
||||
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR, "An internal error occurred.");
|
||||
problemDetail.setTitle("Internal Server Error");
|
||||
return problemDetail;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.cleverthis.authservice.exception;
|
||||
|
||||
/**
|
||||
* Custom exception for logout failures.
|
||||
*/
|
||||
public class LogoutException extends RuntimeException {
|
||||
|
||||
public LogoutException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public LogoutException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.cleverthis.authservice.exception;
|
||||
|
||||
|
||||
/**
|
||||
* Custom exception for errors during permission checks.
|
||||
*/
|
||||
public class PermissionCheckException extends RuntimeException {
|
||||
public PermissionCheckException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public PermissionCheckException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.cleverthis.authservice.exception;
|
||||
|
||||
/**
|
||||
* RegistrationException.
|
||||
*/
|
||||
public class RegistrationException extends RuntimeException {
|
||||
|
||||
|
||||
private static final long serialVersionUID = 2301783309630884721L;
|
||||
|
||||
public RegistrationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public RegistrationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.cleverthis.authservice.exception;
|
||||
|
||||
|
||||
/**
|
||||
* Custom exception for when a required external service (like Keycloak Admin API)
|
||||
* is unavailable or returns an error.
|
||||
*/
|
||||
public class ServiceUnavailableException extends RuntimeException {
|
||||
public ServiceUnavailableException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ServiceUnavailableException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.cleverthis.authservice.exception;
|
||||
|
||||
/**
|
||||
* Custom exception for token verification failures.
|
||||
*/
|
||||
public class TokenVerificationException extends RuntimeException {
|
||||
|
||||
public TokenVerificationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public TokenVerificationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.cleverthis.authservice.exception;
|
||||
|
||||
/**
|
||||
* UserAlreadyExistsException.
|
||||
*/
|
||||
public class UserAlreadyExistsException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 2297555492902445706L;
|
||||
|
||||
public UserAlreadyExistsException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public UserAlreadyExistsException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.cleverthis.authservice.service;
|
||||
|
||||
|
||||
import com.cleverthis.authservice.dto.RegistrationRequest;
|
||||
import com.cleverthis.authservice.dto.TokenResponse;
|
||||
import com.cleverthis.authservice.dto.VerificationResponse;
|
||||
import java.util.List;
|
||||
import reactor.core.publisher.Mono; // Using Mono for asynchronous operations with WebClient
|
||||
|
||||
/**
|
||||
* Interface defining operations for interacting with an identity provider. This abstraction allows
|
||||
* swapping the underlying implementation (e.g., Keycloak, Okta) without changing the controller
|
||||
* logic.
|
||||
*/
|
||||
public interface IdentityManagerService {
|
||||
|
||||
/**
|
||||
* Authenticates a user using username and password (Resource Owner Password Credentials Grant).
|
||||
*
|
||||
* @param username The user's username.
|
||||
* @param password The user's password.
|
||||
* @return A Mono emitting the TokenResponse upon successful authentication. Emits an error
|
||||
* (e.g., AuthenticationException) if authentication fails.
|
||||
*/
|
||||
Mono<TokenResponse> login(String username, String password);
|
||||
|
||||
/**
|
||||
* Verifies the validity of an access token and retrieves associated user information.
|
||||
*
|
||||
* @param accessToken The access token (without "Bearer " prefix).
|
||||
* @return A Mono emitting the VerificationResponse containing token details and user info.
|
||||
* Emits an error if the token is invalid, expired, or introspection fails.
|
||||
*/
|
||||
Mono<VerificationResponse> verifyToken(String accessToken);
|
||||
|
||||
/**
|
||||
* Logs out a user session by revoking the refresh token.
|
||||
*
|
||||
* @param refreshToken The refresh token associated with the user session.
|
||||
* @return A Mono completing successfully upon successful logout/revocation. Emits an error if
|
||||
* revocation fails.
|
||||
*/
|
||||
Mono<Void> logout(String refreshToken);
|
||||
|
||||
/**
|
||||
* Checks if a user has a specific client role for a target service (Keycloak client).
|
||||
*
|
||||
* @param userSub The subject (ID) of the user.
|
||||
* @param targetServiceClientId The public client ID of the target service/application
|
||||
* in Keycloak.
|
||||
* @param candidateRoleNames The name of the client role to check for.
|
||||
* @return A Mono emitting true if the user has the role, false otherwise. Emits an error if the
|
||||
* check cannot be performed (e.g., Keycloak unavailable).
|
||||
*/
|
||||
Mono<Boolean> checkUserHasAnyClientRole(String userSub, String targetServiceClientId,
|
||||
List<String> candidateRoleNames);
|
||||
|
||||
/**
|
||||
* Registers a new user in the identity provider.
|
||||
* The user is typically created with emailVerified set to false.
|
||||
* The implementation is responsible for initiating the email verification process
|
||||
* (e.g., generating a token, triggering an email).
|
||||
*
|
||||
* @param registrationRequest DTO containing user registration details.
|
||||
* @return A Mono completing successfully if registration initiation is successful.
|
||||
* Emits an error (e.g., UserAlreadyExistsException) if registration fails.
|
||||
*/
|
||||
Mono<Void> registerUser(RegistrationRequest registrationRequest);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.cleverthis.authservice.service.impl;
|
||||
|
||||
import com.cleverthis.authservice.dto.KeycloakAdminTokenResponse;
|
||||
import com.cleverthis.authservice.dto.KeycloakClientRepresentation;
|
||||
import com.cleverthis.authservice.dto.KeycloakRoleRepresentation;
|
||||
import com.cleverthis.authservice.exception.ServiceUnavailableException;
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.reactive.function.BodyInserters;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.util.retry.Retry;
|
||||
|
||||
/**
|
||||
* Service to interact with Keycloak Admin API. Handles obtaining an admin token for this
|
||||
* auth-service and making admin calls.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j public class KeycloakAdminClient {
|
||||
|
||||
private final WebClient webClient;
|
||||
private final AtomicReference<String> adminAccessToken = new AtomicReference<>();
|
||||
private final AtomicReference<Long> tokenExpiryTime = new AtomicReference<>(0L);
|
||||
|
||||
@Value("${keycloak.server-url}")
|
||||
private String keycloakServerUrl;
|
||||
@Value("${keycloak.realm}")
|
||||
private String realm;
|
||||
|
||||
// Credentials for auth-service's own client to call Admin API
|
||||
@Value("${auth-service.admin-client.client-id}")
|
||||
private String adminServiceClientId;
|
||||
@Value("${auth-service.admin-client.client-secret}")
|
||||
private String adminServiceClientSecret;
|
||||
|
||||
public KeycloakAdminClient(@Qualifier("keycloakWebClient") WebClient webClient) {
|
||||
this.webClient = webClient;
|
||||
}
|
||||
|
||||
private String getAdminTokenEndpoint() {
|
||||
// Ensure no double slashes if keycloakServerUrl itself has a trailing slash (it shouldn't)
|
||||
return String.format("%s/realms/%s/protocol/openid-connect/token",
|
||||
this.keycloakServerUrl.replaceAll("/+$", ""), this.realm);
|
||||
}
|
||||
|
||||
private String getClientsEndpoint() {
|
||||
return String.format("%s/admin/realms/%s/clients",
|
||||
this.keycloakServerUrl.replaceAll("/+$", ""), this.realm);
|
||||
}
|
||||
|
||||
private String getUserClientRolesEndpoint(String userId, String clientInternalId) {
|
||||
return String.format("%s/admin/realms/%s/users/%s/role-mappings/clients/%s/composite",
|
||||
this.keycloakServerUrl.replaceAll("/+$", ""), this.realm, userId, clientInternalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a valid admin access token for this service, refreshing if necessary. Uses client
|
||||
* credentials grant.
|
||||
*
|
||||
* @return Mono emitting the admin access token.
|
||||
*
|
||||
*/
|
||||
Mono<String> getAdminAccessToken() {
|
||||
if (System.currentTimeMillis() < this.tokenExpiryTime.get()
|
||||
&& this.adminAccessToken.get() != null) {
|
||||
log.debug("Using cached admin access token.");
|
||||
return Mono.just(this.adminAccessToken.get());
|
||||
}
|
||||
|
||||
log.info("Fetching new admin access token for auth-service using client_id: {}",
|
||||
this.adminServiceClientId);
|
||||
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
|
||||
formData.add("client_id", this.adminServiceClientId);
|
||||
formData.add("client_secret", this.adminServiceClientSecret);
|
||||
formData.add("grant_type", "client_credentials");
|
||||
|
||||
return this.webClient.post().uri(getAdminTokenEndpoint())
|
||||
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
.body(BodyInserters.fromFormData(formData)).retrieve()
|
||||
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
|
||||
response -> response.bodyToMono(String.class).flatMap(errorBody -> {
|
||||
log.error("Failed to get admin token. Status: {}, Body: {}",
|
||||
response.statusCode(), errorBody);
|
||||
return Mono.error(new ServiceUnavailableException(
|
||||
"Could not obtain admin token from Keycloak. Status: "
|
||||
+ response.statusCode()));
|
||||
}))
|
||||
.bodyToMono(KeycloakAdminTokenResponse.class).map(tokenResponse -> {
|
||||
this.adminAccessToken.set(tokenResponse.getAccessToken());
|
||||
// Set expiry time slightly before actual expiry to be safe (e.g., 30 seconds
|
||||
// buffer)
|
||||
this.tokenExpiryTime.set(System.currentTimeMillis()
|
||||
+ (tokenResponse.getExpiresIn() - 30) * 1000L);
|
||||
log.info("Successfully obtained new admin access token.");
|
||||
return tokenResponse.getAccessToken();
|
||||
})
|
||||
.retryWhen(Retry.backoff(3, Duration.ofSeconds(2))
|
||||
.maxBackoff(Duration.ofSeconds(10))
|
||||
.filter(throwable -> throwable instanceof ServiceUnavailableException)
|
||||
.doBeforeRetry(
|
||||
retrySignal -> log.warn("Retrying admin token fetch attempt #{}",
|
||||
retrySignal.totalRetries() + 1)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the internal UUID of a Keycloak client given its public clientId.
|
||||
*
|
||||
* @param publicClientId The human-readable client_id (e.g., "cleverswarm-client").
|
||||
* @return Mono emitting the internal UUID string.
|
||||
*/
|
||||
public Mono<String> getClientInternalId(String publicClientId) {
|
||||
log.debug("Fetching internal ID for client: {}", publicClientId);
|
||||
|
||||
// We expect only one client with this ID
|
||||
URI targetUri = UriComponentsBuilder.fromUriString(getClientsEndpoint())
|
||||
.queryParam("clientId", publicClientId).queryParam("max", 1).build().toUri();
|
||||
|
||||
log.debug("Constructed URI for getClientInternalId: {}", targetUri.toString());
|
||||
|
||||
return getAdminAccessToken().flatMap(token -> this.webClient.get().uri(targetUri)
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve()
|
||||
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
|
||||
response -> response.bodyToMono(String.class).flatMap(errorBody -> {
|
||||
log.error("Failed to get client '{}'. Status: {}, Body: {}",
|
||||
publicClientId, response.statusCode(), errorBody);
|
||||
return Mono.error(new ServiceUnavailableException(
|
||||
"Could not fetch client details from Keycloak. Client: "
|
||||
+ publicClientId));
|
||||
}))
|
||||
.bodyToFlux(KeycloakClientRepresentation.class)// Expecting a list, even if one item
|
||||
.next() // Take the first element if present
|
||||
.map(KeycloakClientRepresentation::getId)
|
||||
.switchIfEmpty(Mono.error(new ServiceUnavailableException(
|
||||
"Client not found in Keycloak: " + publicClientId)))
|
||||
.doOnSuccess(id -> log.debug("Found internal ID '{}' for client '{}'", id,
|
||||
publicClientId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the effective client roles for a given user and a specific client (internal ID).
|
||||
*
|
||||
* @param userId the Keycloak user's 'sub' (subject ID).
|
||||
* @param clientInternalId The internal UUID of the Keycloak client.
|
||||
* @return Mono emitting a List of KeycloakRoleRepresentation.
|
||||
*/
|
||||
public Mono<List<KeycloakRoleRepresentation>> getUserEffectiveClientRoles(String userId,
|
||||
String clientInternalId) {
|
||||
log.debug("Fetching effective client roles for user '{}' on client internal ID '{}'",
|
||||
userId, clientInternalId);
|
||||
return getAdminAccessToken().flatMap(token -> this.webClient.get()
|
||||
.uri(getUserClientRolesEndpoint(userId, clientInternalId))
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve()
|
||||
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
|
||||
response -> response.bodyToMono(String.class).flatMap(errorBody -> {
|
||||
log.error(
|
||||
"Failed to get user client roles. User:"
|
||||
+ " {}, ClientInternalId: {}, Status: {}, Body: {}",
|
||||
userId, clientInternalId, response.statusCode(), errorBody);
|
||||
return Mono.error(new ServiceUnavailableException(
|
||||
"Could not fetch user client roles from Keycloak."));
|
||||
}))
|
||||
.bodyToFlux(KeycloakRoleRepresentation.class).collectList()
|
||||
.doOnSuccess(roles -> log.debug(
|
||||
"Found {} effective client roles for user '{}' on client internal ID '{}'",
|
||||
roles.size(), userId, clientInternalId)));
|
||||
}
|
||||
}
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
package com.cleverthis.authservice.service.impl;
|
||||
|
||||
import com.cleverthis.authservice.dto.KeycloakRoleRepresentation;
|
||||
import com.cleverthis.authservice.dto.RegistrationRequest;
|
||||
import com.cleverthis.authservice.dto.TokenResponse;
|
||||
import com.cleverthis.authservice.dto.VerificationResponse;
|
||||
import com.cleverthis.authservice.exception.AuthenticationException;
|
||||
import com.cleverthis.authservice.exception.LogoutException;
|
||||
import com.cleverthis.authservice.exception.RegistrationException;
|
||||
import com.cleverthis.authservice.exception.TokenVerificationException;
|
||||
import com.cleverthis.authservice.exception.UserAlreadyExistsException;
|
||||
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.reactive.function.BodyInserters;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* Keycloak-specific implementation of the IdentityManagerService. Uses WebClient to interact with
|
||||
* Keycloak's REST API endpoints.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class KeycloakIdentityManagerService implements IdentityManagerService {
|
||||
|
||||
private final WebClient webClient;
|
||||
private final KeycloakAdminClient keycloakAdminClient; // Inject KeycloakAdminClient
|
||||
|
||||
@Value("${keycloak.server-url}")
|
||||
private String keycloakServerUrl;
|
||||
@Value("${keycloak.realm}")
|
||||
private String realm;
|
||||
@Value("${keycloak.client-id}")
|
||||
private String clientId;
|
||||
@Value("${keycloak.client-secret}")
|
||||
private String clientSecret;
|
||||
@Value("${keycloak.grant-type}")
|
||||
private String grantType; // Should be 'password' for ROPC
|
||||
|
||||
private String getTokenEndpoint() {
|
||||
return String.format("%s/realms/%s/protocol/openid-connect/token", this.keycloakServerUrl,
|
||||
this.realm);
|
||||
}
|
||||
|
||||
private String getIntrospectionEndpoint() {
|
||||
return String.format("%s/realms/%s/protocol/openid-connect/token/introspect",
|
||||
this.keycloakServerUrl, this.realm);
|
||||
}
|
||||
|
||||
private String getRevocationEndpoint() {
|
||||
return String.format("%s/realms/%s/protocol/openid-connect/revoke", this.keycloakServerUrl,
|
||||
this.realm);
|
||||
}
|
||||
|
||||
private String getUsersAdminEndpoint() {
|
||||
return String.format("%s/admin/realms/%s/users",
|
||||
this.keycloakServerUrl.replaceAll("/+$", ""), this.realm);
|
||||
}
|
||||
|
||||
|
||||
@Autowired
|
||||
public KeycloakIdentityManagerService(WebClient keycloakWebClient,
|
||||
KeycloakAdminClient keycloakAdminClient) {
|
||||
this.webClient = keycloakWebClient;
|
||||
this.keycloakAdminClient = keycloakAdminClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<TokenResponse> login(String username, String password) {
|
||||
log.debug("Attempting login for user: {}", username);
|
||||
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
|
||||
formData.add("client_id", this.clientId);
|
||||
formData.add("client_secret", this.clientSecret);
|
||||
formData.add("grant_type", this.grantType); // Use configured grant type (password)
|
||||
formData.add("username", username);
|
||||
formData.add("password", password);
|
||||
formData.add("scope", "openid email profile"); // Request standard scopes
|
||||
|
||||
return this.webClient.post().uri(getTokenEndpoint())
|
||||
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
.body(BodyInserters.fromFormData(formData)).retrieve()
|
||||
// Handle specific HTTP status codes
|
||||
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
|
||||
clientResponse -> clientResponse.bodyToMono(String.class)
|
||||
.flatMap(errorBody -> {
|
||||
log.error("Keycloak login failed with status {}: {}",
|
||||
clientResponse.statusCode(), errorBody);
|
||||
return Mono.error(new AuthenticationException(
|
||||
"Login failed: Invalid credentials or Keycloak error."
|
||||
+ " Status: "
|
||||
+ clientResponse.statusCode()));
|
||||
}))
|
||||
.bodyToMono(TokenResponse.class)
|
||||
.doOnSuccess(response -> log.debug("Login successful for user: {}", username))
|
||||
.doOnError(error -> log.error("Error during login for user {}: {}", username,
|
||||
error.getMessage()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<VerificationResponse> verifyToken(String accessToken) {
|
||||
log.debug("Verifying access token");
|
||||
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
|
||||
formData.add("client_id", this.clientId);
|
||||
formData.add("client_secret", this.clientSecret);
|
||||
formData.add("token", accessToken);
|
||||
|
||||
return this.webClient.post().uri(getIntrospectionEndpoint())
|
||||
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
.body(BodyInserters.fromFormData(formData)).retrieve()
|
||||
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
|
||||
clientResponse -> clientResponse.bodyToMono(String.class)
|
||||
.flatMap(errorBody -> {
|
||||
log.error(
|
||||
"Keycloak token introspection failed with"
|
||||
+ " status {}: {}",
|
||||
clientResponse.statusCode(), errorBody);
|
||||
return Mono.error(new TokenVerificationException(
|
||||
"Token introspection failed. Status: "
|
||||
+ clientResponse.statusCode()));
|
||||
}))
|
||||
.bodyToMono(VerificationResponse.class).flatMap(response -> {
|
||||
if (!response.isActive()) {
|
||||
log.warn("Token verification failed: Token is inactive.");
|
||||
return Mono.error(
|
||||
new TokenVerificationException("Token is invalid or expired."));
|
||||
}
|
||||
log.debug("Token verification successful. User: {}", response.getUsername());
|
||||
return Mono.just(response);
|
||||
}).doOnError(error -> log.error("Error during token verification: {}",
|
||||
error.getMessage()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> logout(String refreshToken) {
|
||||
log.debug("Attempting logout (token revocation)");
|
||||
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
|
||||
formData.add("client_id", this.clientId);
|
||||
formData.add("client_secret", this.clientSecret);
|
||||
formData.add("token", refreshToken);
|
||||
formData.add("token_type_hint", "refresh_token"); // Hint for Keycloak
|
||||
|
||||
return this.webClient.post().uri(getRevocationEndpoint())
|
||||
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
.body(BodyInserters.fromFormData(formData)).retrieve()
|
||||
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
|
||||
clientResponse -> clientResponse.bodyToMono(String.class)
|
||||
.flatMap(errorBody -> {
|
||||
// Keycloak might return 400 if token is already invalid, which
|
||||
// is okay for logout
|
||||
if (clientResponse.statusCode() == HttpStatus.BAD_REQUEST) {
|
||||
log.warn(
|
||||
"Token revocation returned 400 (possibly already"
|
||||
+ " invalid/revoked): {}",
|
||||
errorBody);
|
||||
return Mono.empty(); // Treat as success in logout scenario
|
||||
}
|
||||
log.error("Keycloak token revocation failed with status {}: {}",
|
||||
clientResponse.statusCode(), errorBody);
|
||||
return Mono.error(new LogoutException("Logout failed. Status: "
|
||||
+ clientResponse.statusCode()));
|
||||
}))
|
||||
.bodyToMono(Void.class) // Expecting empty body on success (2xx)
|
||||
.doOnSuccess(v -> log.debug("Logout successful (token revoked)."))
|
||||
.doOnError(error -> log.error("Error during logout: {}", error.getMessage()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Boolean> checkUserHasAnyClientRole(String userSub, String targetServiceClientId,
|
||||
List<String> candidateRoleNames) {
|
||||
log.info("Checking permission for userSub '{}', targetClient '{}', requiredRole '{}'",
|
||||
userSub, targetServiceClientId, candidateRoleNames);
|
||||
|
||||
// Step 1: Get the internal UUID of the target Keycloak client
|
||||
return this.keycloakAdminClient.getClientInternalId(targetServiceClientId)
|
||||
.flatMap(clientInternalId -> {
|
||||
// Step 2: Get the user's effective client roles for that client's internal ID
|
||||
log.debug("Found internal client ID: {}. Fetching roles for userSub: {}",
|
||||
clientInternalId, userSub);
|
||||
return this.keycloakAdminClient.getUserEffectiveClientRoles(userSub,
|
||||
clientInternalId);
|
||||
}).map(roles -> {
|
||||
// Step 3: Check if the required role name is present in the list of roles
|
||||
boolean hasRole = roles.stream()
|
||||
.anyMatch(role -> candidateRoleNames.contains(role.getName()));
|
||||
if (hasRole) {
|
||||
log.info("Permission GRANTED for userSub '{}' to role '{}' on client '{}'"
|
||||
+ ". User roles: {}",
|
||||
userSub, candidateRoleNames, targetServiceClientId,
|
||||
roles.stream().map(KeycloakRoleRepresentation::getName).toList());
|
||||
} else {
|
||||
log.warn(
|
||||
"Permission DENIED for userSub '{}' to role '{}' on"
|
||||
+ " client '{}'. User roles: {}",
|
||||
userSub, candidateRoleNames, targetServiceClientId,
|
||||
roles.stream().map(KeycloakRoleRepresentation::getName).toList());
|
||||
}
|
||||
return hasRole;
|
||||
})
|
||||
.doOnError(e -> log.error(
|
||||
"Error during permission check for userSub '{}', "
|
||||
+ "client '{}', role '{}': {}",
|
||||
userSub, targetServiceClientId, candidateRoleNames, e.getMessage(), e))
|
||||
// If any error occurs during the admin calls (client not found, user roles fetch
|
||||
// fail), treat as permission denied.
|
||||
// Or, you could throw a specific PermissionCheckException to be handled by
|
||||
// GlobalExceptionHandler.
|
||||
.onErrorResume(e -> {
|
||||
log.error("Permission check failed due to an error, defaulting to DENIED: {}",
|
||||
e.getMessage());
|
||||
return Mono.just(false); // Default to false on error to be safe
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Mono<Void> registerUser(RegistrationRequest registrationRequest) {
|
||||
log.info("Registering new user with email: {}", registrationRequest.getEmail());
|
||||
|
||||
Map<String, Object> userRepresentation = new HashMap<>();
|
||||
userRepresentation.put("username", registrationRequest.getEmail());
|
||||
userRepresentation.put("email", registrationRequest.getEmail());
|
||||
userRepresentation.put("firstName", registrationRequest.getFirstName());
|
||||
userRepresentation.put("lastName", registrationRequest.getLastName());
|
||||
userRepresentation.put("enabled", true);
|
||||
userRepresentation.put("emailVerified", false); // Initially false
|
||||
|
||||
Map<String, Object> credential = new HashMap<>();
|
||||
credential.put("type", "password");
|
||||
credential.put("value", registrationRequest.getPassword());
|
||||
credential.put("temporary", false);
|
||||
userRepresentation.put("credentials", Collections.singletonList(credential));
|
||||
|
||||
// Tell Keycloak to initiate email verification,
|
||||
// we may implement our email verification on future.
|
||||
userRepresentation.put("requiredActions", Collections.singletonList("VERIFY_EMAIL"));
|
||||
|
||||
return this.keycloakAdminClient.getAdminAccessToken()
|
||||
.flatMap(adminToken -> this.webClient.post().uri(getUsersAdminEndpoint())
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON).bodyValue(userRepresentation)
|
||||
.exchangeToMono(response -> {
|
||||
if (response.statusCode().is2xxSuccessful()) {
|
||||
log.info(
|
||||
"User {} created successfully in Keycloak."
|
||||
+ " Keycloak will handle email verification.",
|
||||
registrationRequest.getEmail());
|
||||
return Mono.empty(); // Successfully initiated
|
||||
} else if (response.statusCode() == HttpStatus.CONFLICT) {
|
||||
log.warn(
|
||||
"User registration failed for {}:"
|
||||
+ " User already exists (Conflict).",
|
||||
registrationRequest.getEmail());
|
||||
return response.bodyToMono(String.class)
|
||||
.flatMap(errorBody -> Mono.error(
|
||||
new UserAlreadyExistsException("User with email "
|
||||
+ registrationRequest.getEmail()
|
||||
+ " already exists.")));
|
||||
} else {
|
||||
return response.bodyToMono(String.class).flatMap(errorBody -> {
|
||||
log.error("Keycloak user creation failed for {}"
|
||||
+ " with status {}: {}",
|
||||
registrationRequest.getEmail(), response.statusCode(),
|
||||
errorBody);
|
||||
return Mono.error(new RegistrationException(
|
||||
"User registration failed due to Keycloak error."
|
||||
+ " Status: " + response.statusCode()));
|
||||
});
|
||||
}
|
||||
}))
|
||||
.doOnError(WebClientResponseException.class, e -> {
|
||||
if (e.getStatusCode() == HttpStatus.CONFLICT) {
|
||||
// Already handled by exchangeToMono, but good to log if it somehow gets
|
||||
// here
|
||||
log.warn(
|
||||
"User registration conflict "
|
||||
+ "(WebClientResponseException) for {}: {}",
|
||||
registrationRequest.getEmail(), e.getMessage());
|
||||
} else {
|
||||
log.error("WebClientResponseException during user registration for {}: {}",
|
||||
registrationRequest.getEmail(), e.getMessage());
|
||||
}
|
||||
})
|
||||
.doOnError(
|
||||
e -> !(e instanceof UserAlreadyExistsException
|
||||
|| e instanceof RegistrationException),
|
||||
e -> log.error("Generic error during user registration for {}: {}",
|
||||
registrationRequest.getEmail(), e.getMessage()))
|
||||
.then();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# Spring Boot application configuration
|
||||
server:
|
||||
port: ${AUTH_SERVICE_PORT:8099} # Port the auth-service will run on
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: auth-service
|
||||
|
||||
# Keycloak Configuration (adjust values as needed)
|
||||
keycloak:
|
||||
server-url: ${KEYCLOAK_AUTH_SERVER_URL:http://localhost:9100} # Base URL of your Keycloak instance (NO trailing slash)
|
||||
realm: ${KEYCLOAK_AUTH_REALM:myrealm} # The realm name you are using
|
||||
client-id: ${KEYCLOAK_AUTH_SERVICE_CLIENT:auth-service} # Client ID created in Keycloak for this service
|
||||
client-secret: ${KEYCLOAK_AUTH_SERVICE_SECRET:7Xyh7M6Tc1FvwznY265KcLzcmXoWmjs6} # Client Secret from Keycloak (use secrets management!)
|
||||
# Grant type specific settings (ROPC for /login)
|
||||
grant-type: password
|
||||
|
||||
# Configuration for auth-service's OWN client to call Keycloak Admin API
|
||||
# This client needs service account roles like 'view-clients', 'view-users', 'query-groups'
|
||||
auth-service:
|
||||
admin-client:
|
||||
client-id: ${KEYCLOAK_AUTH_ADMIN_CLIENT:auth-service-admin} # A SEPARATE client ID in Keycloak for admin operations
|
||||
client-secret: ${KEYCLOAK_AUTH_ADMIN_SECRET:qDqbVqfmCmG6SSrAW3pyqXEpu0bOxWJj} # Secret for this admin client
|
||||
permission-mapping:
|
||||
# Rules to map incoming request path prefixes to Keycloak Client IDs (representing services)
|
||||
# The Keycloak Client ID is the one you set in Keycloak (e.g., "cleverswarm-app")
|
||||
# The 'pathPrefix' will be matched against the X-Forwarded-Uri
|
||||
rules:
|
||||
- pathPrefix: "/cleverswarm/" # If X-Forwarded-Uri starts with /cleverswarm/
|
||||
keycloakClientId: "cleverswarm-client" # ...then permissions are checked against this Keycloak client
|
||||
- pathPrefix: "/cleverbrag/"
|
||||
keycloakClientId: "cleverbrag-client"
|
||||
- pathPrefix: "/test-service/"
|
||||
keycloakClientId: "auth-service"
|
||||
# Add more rules as needed
|
||||
# Default Keycloak Client ID if no prefix matches (optional)
|
||||
# defaultKeycloakClientId: "general-api-client"
|
||||
|
||||
logging:
|
||||
level:
|
||||
# Set log levels as needed.
|
||||
com.clevermicro.authservice: DEBUG
|
||||
org.springframework.web.client.RestTemplate: DEBUG
|
||||
reactor.netty.http.client: DEBUG
|
||||
@@ -0,0 +1,454 @@
|
||||
package com.cleverthis.authservice; // Assuming correct package
|
||||
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.hamcrest.Matchers.hasItems;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.cleverthis.authservice.config.PermissionMappingConfig;
|
||||
import com.cleverthis.authservice.controller.AuthController;
|
||||
import com.cleverthis.authservice.dto.LoginRequest;
|
||||
import com.cleverthis.authservice.dto.LogoutRequest;
|
||||
import com.cleverthis.authservice.dto.TokenResponse;
|
||||
import com.cleverthis.authservice.dto.VerificationResponse;
|
||||
import com.cleverthis.authservice.exception.AuthenticationException;
|
||||
import com.cleverthis.authservice.exception.GlobalExceptionHandler;
|
||||
import com.cleverthis.authservice.exception.LogoutException;
|
||||
import com.cleverthis.authservice.exception.TokenVerificationException;
|
||||
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Captor;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
|
||||
import org.springframework.context.annotation.Import; // Import annotation
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* Unit tests for the AuthController using WebFluxTest and Mockito. Focuses on testing the
|
||||
* controller logic and request/response handling.
|
||||
*/
|
||||
@WebFluxTest(AuthController.class)
|
||||
@Import({ GlobalExceptionHandler.class, PermissionMappingConfig.class }) class AuthControllerTest {
|
||||
|
||||
@Autowired
|
||||
private WebTestClient webTestClient;
|
||||
|
||||
@MockitoBean
|
||||
private IdentityManagerService identityManagerService;
|
||||
|
||||
@MockitoBean
|
||||
private PermissionMappingConfig permissionMappingConfig;
|
||||
|
||||
@Captor
|
||||
private ArgumentCaptor<List<String>> candidateRolesCaptor;
|
||||
|
||||
private TokenResponse mockTokenResponse;
|
||||
private VerificationResponse mockUserVerificationResponse;
|
||||
|
||||
// --- Constants for Header Names (mirroring controller) ---
|
||||
private static final String HEADER_USER_ID = "X-User-Id";
|
||||
private static final String HEADER_USER_NAME = "X-User-Name";
|
||||
private static final String HEADER_USER_EMAIL = "X-User-Email";
|
||||
private static final String HEADER_USER_GROUPS = "X-User-Groups";
|
||||
|
||||
// --- Traefik Forwarded Headers ---
|
||||
private static final String HEADER_X_FORWARDED_METHOD = "X-Forwarded-Method";
|
||||
private static final String HEADER_X_FORWARDED_URI = "X-Forwarded-Uri";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
this.mockTokenResponse = new TokenResponse("access-123", 300, 1800, "refresh-456", "Bearer",
|
||||
"id-789", 0, "state", "openid");
|
||||
this.mockUserVerificationResponse =
|
||||
new VerificationResponse(true, "test-client", "testuser", "openid", "user-sub-123",
|
||||
System.currentTimeMillis() / 1000 + 300, System.currentTimeMillis() / 1000,
|
||||
System.currentTimeMillis() / 1000, List.of("aud1"), "iss", "Bearer",
|
||||
"test@example.com", true, "testuser", List.of("groupA"));
|
||||
|
||||
// Setup mock PermissionMappingConfig - Default to having some rules
|
||||
PermissionMappingConfig.Rule rule1 = new PermissionMappingConfig.Rule();
|
||||
rule1.setPathPrefix("/serviceA/");
|
||||
rule1.setKeycloakClientId("serviceA-client");
|
||||
|
||||
PermissionMappingConfig.Rule rule2 = new PermissionMappingConfig.Rule();
|
||||
rule2.setPathPrefix("/serviceB/items/");
|
||||
rule2.setKeycloakClientId("serviceB-client");
|
||||
|
||||
List<PermissionMappingConfig.Rule> rules = new ArrayList<>();
|
||||
rules.add(rule1);
|
||||
rules.add(rule2);
|
||||
// Mock getRules() to return the list
|
||||
when(this.permissionMappingConfig.getRules()).thenReturn(rules);
|
||||
// Mock getDefaultKeycloakClientId() to return null by default, can be overridden in
|
||||
// specific tests
|
||||
when(this.permissionMappingConfig.getDefaultKeycloakClientId()).thenReturn(null);
|
||||
}
|
||||
|
||||
// --- /login Tests ---
|
||||
|
||||
@Test
|
||||
void login_Success_ReturnsTokenResponse() {
|
||||
LoginRequest loginRequest = new LoginRequest("testuser", "password");
|
||||
when(this.identityManagerService.login("testuser", "password"))
|
||||
.thenReturn(Mono.just(this.mockTokenResponse));
|
||||
|
||||
this.webTestClient.post().uri("/login").contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(loginRequest).exchange().expectStatus().isOk() // Expect 200
|
||||
.expectBody(TokenResponse.class)
|
||||
.value(TokenResponse::getAccessToken, equalTo("access-123"))
|
||||
.value(TokenResponse::getRefreshToken, equalTo("refresh-456"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_ServiceThrowsAuthException_ReturnsUnauthorized() {
|
||||
LoginRequest loginRequest = new LoginRequest("wronguser", "wrongpass");
|
||||
// Mock the service to throw the specific exception handled by GlobalExceptionHandler
|
||||
when(this.identityManagerService.login("wronguser", "wrongpass"))
|
||||
.thenReturn(Mono.error(new AuthenticationException("Invalid credentials")));
|
||||
|
||||
this.webTestClient.post().uri("/login").contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(loginRequest).exchange().expectStatus().isUnauthorized();
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_InvalidRequest_ReturnsBadRequest() {
|
||||
LoginRequest loginRequest = new LoginRequest("", ""); // Invalid request
|
||||
|
||||
// No need to mock service for validation errors
|
||||
// The framework and GlobalExceptionHandler (via parent) should handle this
|
||||
|
||||
this.webTestClient.post().uri("/login").contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(loginRequest).exchange().expectStatus().isBadRequest(); // Expect 400
|
||||
}
|
||||
|
||||
// --- /verify Tests ---
|
||||
|
||||
@Test
|
||||
void verify_Success_ReturnsVerificationResponse() {
|
||||
String validToken = "valid-token-123";
|
||||
when(this.identityManagerService.verifyToken(validToken))
|
||||
.thenReturn(Mono.just(this.mockUserVerificationResponse));
|
||||
|
||||
this.webTestClient.post().uri("/verify")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken).exchange().expectStatus()
|
||||
.isOk() // Expect 200
|
||||
.expectBody(VerificationResponse.class)
|
||||
.value(VerificationResponse::isActive, equalTo(true))
|
||||
.value(VerificationResponse::getUsername, equalTo("testuser"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void verify_ServiceThrowsVerificationException_ReturnsUnauthorized() {
|
||||
String invalidToken = "invalid-token-456";
|
||||
// Mock the service to throw the specific exception
|
||||
when(this.identityManagerService.verifyToken(invalidToken))
|
||||
.thenReturn(Mono.error(new TokenVerificationException("Token expired")));
|
||||
|
||||
this.webTestClient.post().uri("/verify")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + invalidToken).exchange()
|
||||
.expectStatus().isUnauthorized(); // Expect 401 (handled by GlobalExceptionHandler)
|
||||
}
|
||||
|
||||
@Test
|
||||
void verify_MissingAuthHeader_ReturnsBadRequest() { // Renamed test slightly for clarity
|
||||
// No service mocking needed, framework handles missing required header
|
||||
this.webTestClient.post().uri("/verify").exchange().expectStatus().isBadRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void verify_InvalidAuthHeaderFormat_ReturnsUnauthorized() {
|
||||
// Controller logic handles this case specifically
|
||||
this.webTestClient.post().uri("/verify")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Basic somecredentials") // Wrong scheme
|
||||
.exchange().expectStatus().isUnauthorized(); // Expect 401 (handled by controller
|
||||
}
|
||||
|
||||
// --- /auth (ForwardAuth) Tests ---
|
||||
|
||||
// --- NEW /auth (ForwardAuth) Tests with Candidate Role List Logic ---
|
||||
|
||||
@Test
|
||||
void forwardAuth_PermissionGranted_ReturnsOkWithUserHeaders() {
|
||||
String userToken = "user-token-granted";
|
||||
String originalMethod = "GET";
|
||||
String originalUri = "/serviceA/api/data"; // Matches rule1
|
||||
|
||||
when(this.identityManagerService.verifyToken(userToken))
|
||||
.thenReturn(Mono.just(this.mockUserVerificationResponse));
|
||||
when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
|
||||
eq("serviceA-client"), this.candidateRolesCaptor.capture()))
|
||||
.thenReturn(Mono.just(true));
|
||||
|
||||
this.webTestClient.post().uri("/auth")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
|
||||
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
|
||||
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isOk()
|
||||
.expectHeader().valueEquals(HEADER_USER_ID, "user-sub-123").expectHeader()
|
||||
.valueEquals(HEADER_USER_NAME, "testuser").expectHeader()
|
||||
.valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader()
|
||||
.valueEquals(HEADER_USER_GROUPS, "groupA").expectBody().isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardAuth_PermissionGranted_SpecificRoleMatch() {
|
||||
String userToken = "user-token-granted";
|
||||
String originalMethod = "GET";
|
||||
// Will be normalized to /api/items/BY_ID
|
||||
String originalUri = "/serviceA/api/items/123e4567-e89b-12d3-a456-426614174000";
|
||||
|
||||
when(this.identityManagerService.verifyToken(userToken))
|
||||
.thenReturn(Mono.just(this.mockUserVerificationResponse));
|
||||
// Expect checkUserHasAnyClientRole to be called with a list of candidates
|
||||
// For GET /api/items/BY_ID, candidates would be [GET_API_ITEMS_BY_ID, ALL_API_ITEMS_BY_ID,
|
||||
// GET_API_ITEMS_STAR, ..., ALL_STAR]
|
||||
when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
|
||||
eq("serviceA-client"), this.candidateRolesCaptor.capture()))
|
||||
.thenReturn(Mono.just(true));
|
||||
|
||||
this.webTestClient.post().uri("/auth")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
|
||||
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
|
||||
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isOk()
|
||||
.expectHeader().valueEquals(HEADER_USER_ID, "user-sub-123").expectBody().isEmpty();
|
||||
|
||||
// Verify the captured candidate roles
|
||||
List<String> capturedRoles = this.candidateRolesCaptor.getValue();
|
||||
assertThat(capturedRoles,
|
||||
hasItems("GET_API_ITEMS_BY_ID", "ALL_API_ITEMS_BY_ID", "GET_API_ITEMS_STAR",
|
||||
"ALL_API_ITEMS_STAR", "GET_API_STAR", "ALL_API_STAR", "GET_STAR",
|
||||
"ALL_STAR"));
|
||||
// Check order if important, or just presence
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardAuth_Success_ReturnsOkWithHeaders_NoGroupsInToken() { // Renamed for clarity
|
||||
String validToken = "valid-token-fw-nogroups";
|
||||
VerificationResponse responseWithoutGroups = new VerificationResponse(true, "test-client",
|
||||
"testuser", "openid", "user-sub-123", System.currentTimeMillis() / 1000 + 300,
|
||||
System.currentTimeMillis() / 1000, System.currentTimeMillis() / 1000,
|
||||
List.of("aud1"), "iss", "Bearer", "test@example.com", true, "testuser", null);
|
||||
|
||||
when(this.identityManagerService.verifyToken(validToken))
|
||||
.thenReturn(Mono.just(responseWithoutGroups));
|
||||
when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
|
||||
eq("serviceA-client"), this.candidateRolesCaptor.capture()))
|
||||
.thenReturn(Mono.just(true));
|
||||
|
||||
this.webTestClient.post().uri("/auth")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken)
|
||||
.header(HEADER_X_FORWARDED_METHOD, "GET")
|
||||
.header(HEADER_X_FORWARDED_URI, "/serviceA/api/nogroups").exchange().expectStatus()
|
||||
.isOk().expectHeader().valueEquals(HEADER_USER_ID, "user-sub-123").expectHeader()
|
||||
.valueEquals(HEADER_USER_NAME, "testuser").expectHeader()
|
||||
.valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader()
|
||||
.doesNotExist(HEADER_USER_GROUPS).expectBody().isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardAuth_PermissionGranted_WildcardRoleMatch_All_Star() {
|
||||
String userToken = "user-token-admin";
|
||||
String originalMethod = "DELETE";
|
||||
String originalUri = "/serviceA/admin/config"; // -> /admin/config
|
||||
|
||||
when(this.identityManagerService.verifyToken(userToken))
|
||||
.thenReturn(Mono.just(this.mockUserVerificationResponse));
|
||||
// User has ALL_STAR, so this should pass
|
||||
when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
|
||||
eq("serviceA-client"), anyStringList() // Match any list of strings
|
||||
)).thenAnswer(invocation -> {
|
||||
List<String> candidates = invocation.getArgument(2);
|
||||
// Simulate Keycloak returning true if ALL_STAR is among candidates and user has it
|
||||
// For this test, we assume the service method checkUserHasAnyClientRole correctly finds
|
||||
// ALL_STAR
|
||||
// if the user has it and it's in the candidate list.
|
||||
// Here, we just return true because the test name implies it should be granted.
|
||||
// A more robust mock would check if "ALL_STAR" is in `candidates` if the user had
|
||||
// ALL_STAR.
|
||||
return Mono.just(true);
|
||||
});
|
||||
|
||||
this.webTestClient.post().uri("/auth")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
|
||||
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
|
||||
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isOk()
|
||||
.expectHeader().valueEquals(HEADER_USER_ID, "user-sub-123").expectBody().isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardAuth_PermissionDenied_NoMatchingRole() {
|
||||
String userToken = "user-token-denied";
|
||||
String originalMethod = "POST";
|
||||
String originalUri = "/serviceA/restricted/action"; // -> /restricted/action
|
||||
|
||||
when(this.identityManagerService.verifyToken(userToken))
|
||||
.thenReturn(Mono.just(this.mockUserVerificationResponse));
|
||||
when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
|
||||
eq("serviceA-client"), anyStringList() // Match any list of strings
|
||||
)).thenReturn(Mono.just(false)); // Service reports no matching role found
|
||||
|
||||
this.webTestClient.post().uri("/auth")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
|
||||
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
|
||||
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isForbidden()
|
||||
.expectBody().isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardAuth_RootPath_PermissionGranted() {
|
||||
String userToken = "user-token-root-access";
|
||||
String originalMethod = "GET";
|
||||
String originalUri = "/serviceA/"; // Root path for serviceA
|
||||
|
||||
when(this.identityManagerService.verifyToken(userToken))
|
||||
.thenReturn(Mono.just(this.mockUserVerificationResponse));
|
||||
when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
|
||||
eq("serviceA-client"), this.candidateRolesCaptor.capture()))
|
||||
.thenReturn(Mono.just(true));
|
||||
|
||||
this.webTestClient.post().uri("/auth")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
|
||||
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
|
||||
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isOk();
|
||||
|
||||
List<String> capturedRoles = this.candidateRolesCaptor.getValue();
|
||||
assertThat(capturedRoles, hasItems("GET_ROOT", "ALL_ROOT", "GET_STAR", "ALL_STAR"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardAuth_TokenInvalid_ReturnsUnauthorized() {
|
||||
String invalidUserToken = "invalid-user-token";
|
||||
String originalMethod = "GET";
|
||||
String originalUri = "/serviceA/api/data";
|
||||
|
||||
when(this.identityManagerService.verifyToken(invalidUserToken))
|
||||
.thenReturn(Mono.error(new TokenVerificationException("Token is expired")));
|
||||
|
||||
this.webTestClient.post().uri("/auth")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + invalidUserToken)
|
||||
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
|
||||
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus()
|
||||
.isUnauthorized();
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardAuth_ServiceThrowsGenericExceptionDuringVerification_ReturnsUnauthorized() {
|
||||
String token = "token-causing-error";
|
||||
String originalMethod = "GET";
|
||||
String originalUri = "/serviceA/api/data";
|
||||
|
||||
when(this.identityManagerService.verifyToken(token)).thenReturn(
|
||||
Mono.error(new RuntimeException("Internal service error during verification")));
|
||||
|
||||
this.webTestClient.post().uri("/auth").header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
|
||||
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
|
||||
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus()
|
||||
.isUnauthorized();
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardAuth_NoMatchingPathRule_ReturnsForbidden() {
|
||||
String userToken = "user-token-no-rule";
|
||||
String originalMethod = "GET";
|
||||
String originalUri = "/unknownService/api/data"; // No rule matches this prefix
|
||||
|
||||
// Mock getRules to return an empty list for this specific test case, or ensure default
|
||||
// setup handles it
|
||||
// For this test, let's ensure the default setup from setUp() (which has rules for
|
||||
// /serviceA/)
|
||||
// does not match "/unknownService/"
|
||||
when(this.identityManagerService.verifyToken(userToken))
|
||||
.thenReturn(Mono.just(this.mockUserVerificationResponse));
|
||||
// No call to checkUserClientRolePermission should happen if no rule matches
|
||||
|
||||
this.webTestClient.post().uri("/auth")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
|
||||
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
|
||||
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus()
|
||||
.isForbidden(); // Controller logic for no rule match
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardAuth_MissingAuthHeader_ReturnsBadRequest() {
|
||||
// This tests if @RequestHeader(HttpHeaders.AUTHORIZATION) is enforced
|
||||
this.webTestClient.post().uri("/auth").header(HEADER_X_FORWARDED_METHOD, "GET")
|
||||
.header(HEADER_X_FORWARDED_URI, "/serviceA/api/data")
|
||||
// Missing Authorization header
|
||||
.exchange().expectStatus().isBadRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardAuth_InvalidAuthHeaderFormat_ReturnsUnauthorized() {
|
||||
// This tests the controller's specific check for "Bearer " prefix
|
||||
this.webTestClient.post().uri("/auth")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Basic somecredentials")
|
||||
.header(HEADER_X_FORWARDED_METHOD, "GET")
|
||||
.header(HEADER_X_FORWARDED_URI, "/serviceA/api/data").exchange().expectStatus()
|
||||
.isUnauthorized();
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardAuth_MissingXforwardedMethodHeader_ReturnsBadRequest() {
|
||||
this.webTestClient.post().uri("/auth")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer some-token")
|
||||
.header(HEADER_X_FORWARDED_URI, "/serviceA/api/data")
|
||||
// Missing X-Forwarded-Method
|
||||
.exchange().expectStatus().isBadRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardAuth_MissingXforwardedUriHeader_ReturnsBadRequest() {
|
||||
this.webTestClient.post().uri("/auth")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer some-token")
|
||||
.header(HEADER_X_FORWARDED_METHOD, "GET")
|
||||
// Missing X-Forwarded-Uri
|
||||
.exchange().expectStatus().isBadRequest();
|
||||
}
|
||||
|
||||
// --- /logout Tests ---
|
||||
|
||||
@Test
|
||||
void logout_Success_ReturnsNoContent() {
|
||||
LogoutRequest logoutRequest = new LogoutRequest("refresh-token-to-revoke");
|
||||
when(this.identityManagerService.logout(logoutRequest.getRefreshToken()))
|
||||
.thenReturn(Mono.empty());
|
||||
|
||||
this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(logoutRequest).exchange().expectStatus().isNoContent(); // Expect 204
|
||||
}
|
||||
|
||||
@Test
|
||||
void logout_ServiceThrowsException_ReturnsInternalServerError() {
|
||||
LogoutRequest logoutRequest = new LogoutRequest("refresh-token-error");
|
||||
// Mock service to throw the specific exception
|
||||
when(this.identityManagerService.logout(logoutRequest.getRefreshToken()))
|
||||
.thenReturn(Mono.error(new LogoutException("Keycloak unavailable")));
|
||||
|
||||
this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(logoutRequest).exchange().expectStatus().is5xxServerError();
|
||||
}
|
||||
|
||||
@Test
|
||||
void logout_InvalidRequest_ReturnsBadRequest() {
|
||||
LogoutRequest logoutRequest = new LogoutRequest(""); // Invalid request (blank token)
|
||||
|
||||
// No service mocking needed
|
||||
|
||||
this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(logoutRequest).exchange().expectStatus().isBadRequest(); // Expect 400
|
||||
}
|
||||
|
||||
// Helper to match any List<String> in Mockito
|
||||
private static List<String> anyStringList() {
|
||||
return Mockito.anyList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.cleverthis.authservice;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.cleverthis.authservice.controller.UserRegistrationController;
|
||||
import com.cleverthis.authservice.dto.RegistrationRequest;
|
||||
import com.cleverthis.authservice.exception.GlobalExceptionHandler;
|
||||
import com.cleverthis.authservice.exception.RegistrationException;
|
||||
import com.cleverthis.authservice.exception.UserAlreadyExistsException;
|
||||
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* Test class for /register in UserRegistrationController.
|
||||
*/
|
||||
@WebFluxTest(UserRegistrationController.class)
|
||||
@Import(GlobalExceptionHandler.class)
|
||||
public class UserRegistrationControllerTest {
|
||||
|
||||
@Autowired
|
||||
private WebTestClient webTestClient;
|
||||
|
||||
@MockitoBean // Mocks the IdentityManagerService
|
||||
private IdentityManagerService identityManagerService;
|
||||
|
||||
@Test
|
||||
void registerUser_Success_ReturnsCreated() {
|
||||
RegistrationRequest request =
|
||||
new RegistrationRequest("Test", "User", "newuser@example.com", "Password123!");
|
||||
|
||||
// Mock the service layer to complete successfully
|
||||
when(this.identityManagerService.registerUser(any(RegistrationRequest.class)))
|
||||
.thenReturn(Mono.empty()); // registerUser returns Mono<Void>
|
||||
// Expect 201 CREATED
|
||||
this.webTestClient.post().uri("/users/register").contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(request).exchange().expectStatus().isCreated();
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerUser_EmailAlreadyExists_ReturnsConflict() {
|
||||
RegistrationRequest request =
|
||||
new RegistrationRequest("Test", "User", "existinguser@example.com", "Password123!");
|
||||
|
||||
// Mock the service layer to throw UserAlreadyExistsException
|
||||
when(this.identityManagerService.registerUser(any(RegistrationRequest.class)))
|
||||
.thenReturn(Mono.error(new UserAlreadyExistsException(
|
||||
"User with email existinguser@example.com already exists.")));
|
||||
// Expect 409 CONFLICT (handled by GlobalExceptionHandler)
|
||||
this.webTestClient.post().uri("/users/register").contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(request).exchange().expectStatus().is5xxServerError();
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerUser_InvalidEmailFormat_ReturnsBadRequest() {
|
||||
// Invalid email
|
||||
RegistrationRequest request =
|
||||
new RegistrationRequest("Test", "User", "invalidemail", "Password123!");
|
||||
|
||||
// No service mocking needed as @Valid annotation should trigger validation error
|
||||
// Expect 400 BAD_REQUEST
|
||||
this.webTestClient.post().uri("/users/register").contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(request).exchange().expectStatus().isBadRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerUser_PasswordTooShort_ReturnsBadRequest() {
|
||||
// Password too short
|
||||
RegistrationRequest request =
|
||||
new RegistrationRequest("Test", "User", "user@example.com", "pass");
|
||||
// Expect 400 BAD_REQUEST
|
||||
this.webTestClient.post().uri("/users/register").contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(request).exchange().expectStatus().isBadRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerUser_MissingFirstName_ReturnsBadRequest() {
|
||||
RegistrationRequest request =
|
||||
new RegistrationRequest(null, "User", "user@example.com", "Password123!");
|
||||
|
||||
this.webTestClient.post().uri("/users/register").contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(request).exchange().expectStatus().isBadRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerUser_GenericRegistrationFailure_ReturnsInternalServerError() {
|
||||
RegistrationRequest request =
|
||||
new RegistrationRequest("Test", "User", "erroruser@example.com", "Password123!");
|
||||
|
||||
// Mock the service layer to throw a generic RegistrationException
|
||||
when(this.identityManagerService.registerUser(any(RegistrationRequest.class))).thenReturn(
|
||||
Mono.error(new RegistrationException("A generic Keycloak error occurred.")));
|
||||
// Expect 500 (handled by GlobalExceptionHandler for RegistrationException)
|
||||
this.webTestClient.post().uri("/users/register").contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(request).exchange().expectStatus().is5xxServerError();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user