Compare commits

..

13 Commits

Author SHA1 Message Date
Abed 25b70415ca rename the client id
Unit test coverage / gradle-test (push) Failing after 2m21s
Unit test coverage / gradle-test (pull_request) Failing after 2m26s
CI for publishing docker image / build-and-publish (push) Successful in 2m46s
2025-06-06 15:20:44 +08:00
Abed 525ffa091a Use the ALL keyword to represent any method (GET, POST, etc). 2025-06-06 15:20:43 +08:00
abed.alrahman 2ff5930e86 Implement New User Registration in auth-service #8 (#21)
Unit test coverage / gradle-test (push) Failing after 1m50s
CI for publishing docker image / build-and-publish (push) Successful in 2m20s
Co-authored-by: Abed <alrbeei@yahoo.com>
Reviewed-on: #21
Reviewed-by: Rui Hu <rui.hu@cleverthis.com>
Co-authored-by: Abed Alrahman <abed.alrahman@cleverthis.com>
Co-committed-by: Abed Alrahman <abed.alrahman@cleverthis.com>
2025-06-06 07:17:36 +00:00
Abed 4ff18c878b Merge branch 'Implement_Access_Control_in_auth-service#3' into develop
Unit test coverage / gradle-test (push) Failing after 1m20s
CI for publishing docker image / build-and-publish (push) Failing after 1m35s
2025-05-25 23:27:38 +02:00
abed.alrahman 79dd8384e0 Merge pull request 'Replace-User-Metadata-#5' (#16) from Replace-User-Metadata-#5 into develop
Unit test coverage / gradle-test (push) Failing after 1m25s
CI for publishing docker image / build-and-publish (push) Failing after 1m34s
Reviewed-on: #16
Reviewed-by: Rui Hu <rui.hu@cleverthis.com>
Reviewed-by: Stanislav Hejny <stanislav.hejny@cleverthis.com>
2025-05-22 10:07:50 +00:00
hurui200320 0e0c463a1e Add checkstyle and add pipelines for building docker image and coverage check
Unit test coverage / gradle-test (push) Failing after 2m42s
CI for publishing docker image / build-and-publish (push) Successful in 3m46s
ISSUES CLOSED: clevermicro/user-management#17
PR: clevermicro/user-management#19
2025-05-22 12:52:11 +08:00
Abed ade56ec23d Add more tests cases to test class, to test auth endpoint as well 2025-05-20 03:27:18 +02:00
Abed 66767d9e1e 1. Implement Access Control in auth-service via Traefik Forward Auth
2. Add keycloak admin client, to fetch as admin from keycloak
3. Add /auth endpoint, that will parse the request, and decide for auth
2025-05-13 00:56:35 +02:00
Abed 33182d5a8e add auth token to warn message 2025-05-11 00:29:05 +02:00
Abed 9889789fcc Group added to Verfication response, 2025-05-11 00:21:28 +02:00
stanislav.hejny ac85ff58af Merge pull request 'Feat#1 creating auth-service, with JUnit test' (#14) from Create-Auth-Microservice#1 into develop
Reviewed-on: #14
Reviewed-by: Stanislav Hejny <stanislav.hejny@cleverthis.com>
2025-05-07 08:38:05 +00:00
Abed 668bd05e5a Applying check style, and changing the log messages 2025-05-07 01:23:13 +02:00
Abed 0a16092b3d Feat#1 creating auth-service, with JUnit test 2025-05-05 02:35:20 +02:00
32 changed files with 2873 additions and 0 deletions
+68
View File
@@ -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
+81
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+139
View File
@@ -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)));
}
}
@@ -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();
}
}
+44
View File
@@ -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();
}
}