The goal of this project is to create a simple Spring Boot
REST API, called simple-service
, and secure it with Spring Security LDAP
module. We will use Testcontainers
for integration testing.
-
Spring Boot
Java Web application that exposes two endpoints:GET /api/public
: that can be access by anyone, it is not secured;GET /api/private
: that can just be accessed by users authenticated with valid LDAP credentials.
Open a terminal and inside springboot-ldap-testcontainers
root folder run
docker-compose up -d
The LDIF
file we will use, simple-service/src/main/resources/ldap-mycompany-com.ldif
, contains a pre-defined structure for mycompany.com
. Basically, it has 2 groups (employees
and clients
) and 3 users (Bill Gates
, Steve Jobs
and Mark Cuban
). Besides, it's defined that Bill Gates
and Steve Jobs
belong to employees
group and Mark Cuban
belongs to clients
group.
Bill Gates > username: bgates, password: 123
Steve Jobs > username: sjobs, password: 123
Mark Cuban > username: mcuban, password: 123
There are two ways to import those users: by running a script; or by using phpldapadmin
-
In a terminal, make use you are in
springboot-ldap-testcontainers
root folder -
Run the following script
./import-openldap-users.sh
-
Check users imported using
ldapsearch
ldapsearch -x -D "cn=admin,dc=mycompany,dc=com" \ -w admin -H ldap://localhost:389 \ -b "ou=users,dc=mycompany,dc=com" \ -s sub "(uid=*)"
-
Access https://localhost:6443
-
Login with the following credentials
Login DN: cn=admin,dc=mycompany,dc=com Password: admin
-
Import the file
simple-service/src/main/resources/ldap-mycompany-com.ldif
-
You should see something like
-
In a terminal, make use you are in
springboot-ldap-testcontainers
root folder -
Run the following command to start
simple-service
./mvnw clean spring-boot:run --projects simple-service
-
In a terminal, make sure you are in
springboot-ldap-testcontainers
root folder -
Build Docker Image
- JVM
./docker-build.sh
- Native
./docker-build.sh native
- JVM
-
Environment Variables
Environment Variable Description LDAP_HOST
Specify host of the LDAP
to use (defaultlocalhost
)LDAP_PORT
Specify port of the LDAP
to use (default389
) -
Run Docker Container
Warning: Native is not working yet, see Issues
docker run --rm --name simple-service -p 8080:8080 \ -e LDAP_HOST=openldap \ --network springboot-ldap-testcontainers_default \ ivanfranchin/simple-service:1.0.0
-
Open a terminal
-
Call the endpoint
/api/public
curl -i localhost:8080/api/public
It should return
HTTP/1.1 200 It is public.
-
Try to call the endpoint
/api/private
without credentialscurl -i localhost:8080/api/private
It should return
HTTP/1.1 401 { "timestamp": "...", "status": 401, "error": "Unauthorized", "message": "Unauthorized", "path": "/api/private" }
-
Call the endpoint
/api/private
again. This time informingusername
andpassword
curl -i -u bgates:123 localhost:8080/api/private
It should return
HTTP/1.1 200 bgates, it is private.
-
Call the endpoint
/api/private
informing an invalid passwordcurl -i -u bgates:124 localhost:8080/api/private
It should return
HTTP/1.1 401
-
Call the endpoint
/api/private
informing a non-existing usercurl -i -u cslim:123 localhost:8080/api/private
It should return
HTTP/1.1 401
-
Click
GET /api/public
to open it; then, clickTry it out
button and, finally,Execute
button.It should return
Code: 200 Response Body: It is public.
-
Click
Authorize
button (green-white one, located at top-right of the page) -
In the form that opens, provide the
Bill Gates
credentials, i.e, usernamebgates
and password123
. Then, clickAuthorize
button, and to finalize, clickClose
button -
Click
GET /api/private
to open it; then clickTry it out
button and, finally,Execute
button.It should return
Code: 200 Response Body: bgates, it is private.
- To stop
simple-service
application, go to the terminal where it is running and pressCtrl+C
- To stop and remove docker-compose containers, network and volumes, in a terminal and inside
springboot-ldap-testcontainers
root folder, run the following commanddocker-compose down -v
-
In a terminal, make sure you are inside
springboot-ldap-testcontainers
root folder -
Run the command below to start the Unit Tests
./mvnw clean test --projects simple-service
-
Run the command below to start the Unit and Integration Tests
Note 1:
Testcontainers
will start automaticallyOpenLDAP
Docker container before some tests begin and will shut it down when the tests finish.Note 2:
TESTCONTAINERS_CHECKS_DISABLE
is set totrue
because the startup check is getting stuck on subsequent runs after the first one that runs fine. It leaves behindalpine
Docker containers with statusCreated
. In order to solve it in Mac, we need to restartDocker Desktop
.TESTCONTAINERS_CHECKS_DISABLE=true && ./mvnw clean verify --projects simple-service
To remove the Docker image created by this project, go to a terminal and run the following command
docker rmi ivanfranchin/simple-service:1.0.0
The Docker native image is built and starts up successfully. However, when calling the private endpoint informing valid or invalid credentials for the basic authentication, the application returns 401
and logs the following exception
ERROR 1 --- [nio-8080-exec-5] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Filter execution threw an exception] with root cause
com.oracle.svm.core.jdk.UnsupportedFeatureError: JDK11OrLater: Target_java_lang_ClassLoader.trySetObjectField(String name, Object obj)
at com.oracle.svm.core.util.VMError.unsupportedFeature(VMError.java:88) ~[na:na]
at java.lang.ClassLoader.trySetObjectField(ClassLoader.java:253) ~[na:na]
at java.lang.ClassLoader.createOrGetClassLoaderValueMap(ClassLoader.java:2993) ~[na:na]
at java.lang.System$2.createOrGetClassLoaderValueMap(System.java:2126) ~[na:na]
at jdk.internal.loader.AbstractClassLoaderValue.map(AbstractClassLoaderValue.java:266) ~[na:na]
at jdk.internal.loader.AbstractClassLoaderValue.computeIfAbsent(AbstractClassLoaderValue.java:189) ~[na:na]
at javax.naming.spi.NamingManager.getInitialContext(NamingManager.java:722) ~[na:na]
at javax.naming.InitialContext.getDefaultInitCtx(InitialContext.java:305) ~[na:na]
at javax.naming.InitialContext.init(InitialContext.java:236) ~[na:na]
at javax.naming.ldap.InitialLdapContext.<init>(InitialLdapContext.java:154) ~[na:na]
at org.springframework.ldap.core.support.LdapContextSource.getDirContextInstance(LdapContextSource.java:42) ~[com.mycompany.simpleservice.SimpleServiceApplication:2.3.4.RELEASE]
at org.springframework.ldap.core.support.AbstractContextSource.createContext(AbstractContextSource.java:350) ~[na:na]
at org.springframework.ldap.core.support.AbstractContextSource.doGetContext(AbstractContextSource.java:146) ~[na:na]
at org.springframework.ldap.core.support.AbstractContextSource.getContext(AbstractContextSource.java:137) ~[na:na]
at org.springframework.security.ldap.authentication.BindAuthenticator.bindWithDn(BindAuthenticator.java:104) ~[na:na]
at org.springframework.security.ldap.authentication.BindAuthenticator.bindWithDn(BindAuthenticator.java:93) ~[na:na]
at org.springframework.security.ldap.authentication.BindAuthenticator.authenticate(BindAuthenticator.java:74) ~[na:na]
at org.springframework.security.ldap.authentication.LdapAuthenticationProvider.doAuthentication(LdapAuthenticationProvider.java:174) ~[na:na]
at org.springframework.security.ldap.authentication.AbstractLdapAuthenticationProvider.authenticate(AbstractLdapAuthenticationProvider.java:81) ~[na:na]
at org.springframework.security.authentication.ProviderManager.authenticate(ProviderManager.java:182) ~[na:na]
at org.springframework.security.authentication.ProviderManager.authenticate(ProviderManager.java:201) ~[na:na]
at org.springframework.security.web.authentication.www.BasicAuthenticationFilter.doFilterInternal(BasicAuthenticationFilter.java:155) ~[na:na]
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) ~[na:na]
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:336) ~[na:na]
at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:103) ~[na:na]
at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:89) ~[na:na]
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:336) ~[na:na]
at org.springframework.web.filter.CorsFilter.doFilterInternal(CorsFilter.java:91) ~[na:na]
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) ~[na:na]
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:336) ~[na:na]
at org.springframework.security.web.header.HeaderWriterFilter.doHeadersAfter(HeaderWriterFilter.java:90) ~[na:na]
at org.springframework.security.web.header.HeaderWriterFilter.doFilterInternal(HeaderWriterFilter.java:75) ~[na:na]
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) ~[na:na]
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:336) ~[na:na]
at org.springframework.security.web.context.SecurityContextPersistenceFilter.doFilter(SecurityContextPersistenceFilter.java:110) ~[na:na]
at org.springframework.security.web.context.SecurityContextPersistenceFilter.doFilter(SecurityContextPersistenceFilter.java:80) ~[na:na]
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:336) ~[na:na]
at org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter.doFilterInternal(WebAsyncManagerIntegrationFilter.java:55) ~[na:na]
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) ~[na:na]
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:336) ~[na:na]
at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:211) ~[na:na]
at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:183) ~[na:na]
at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:358) ~[na:na]
at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:271) ~[na:na]
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) ~[na:na]
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[na:na]
at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) ~[com.mycompany.simpleservice.SimpleServiceApplication:5.3.9]
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) ~[na:na]
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) ~[na:na]
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[na:na]
at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) ~[com.mycompany.simpleservice.SimpleServiceApplication:5.3.9]
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) ~[na:na]
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) ~[na:na]
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[na:na]
at org.springframework.boot.actuate.metrics.web.servlet.WebMvcMetricsFilter.doFilterInternal(WebMvcMetricsFilter.java:96) ~[na:na]
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) ~[na:na]
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) ~[na:na]
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[na:na]
at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) ~[com.mycompany.simpleservice.SimpleServiceApplication:5.3.9]
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) ~[na:na]
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) ~[na:na]
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[na:na]
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:197) ~[na:na]
at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:97) ~[na:na]
at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:542) ~[na:na]
at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:135) ~[na:na]
at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92) ~[com.mycompany.simpleservice.SimpleServiceApplication:9.0.52]
at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:78) ~[na:na]
at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:357) ~[na:na]
at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:382) ~[na:na]
at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65) ~[na:na]
at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:893) ~[na:na]
at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1726) ~[na:na]
at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49) ~[na:na]
at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191) ~[na:na]
at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) ~[na:na]
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61) ~[na:na]
at java.lang.Thread.run(Thread.java:829) ~[na:na]
at com.oracle.svm.core.thread.JavaThreads.threadStartRoutine(JavaThreads.java:567) ~[na:na]
at com.oracle.svm.core.posix.thread.PosixJavaThreads.pthreadStartRoutine(PosixJavaThreads.java:192) ~[na:na]