Skip to content

Commit 7c94e10

Browse files
dk1844TheLydonKing
andauthored
#133: on ldap connection error, communicate 504 (#134)
* #133: on ldap connection error, communicate 504 Co-authored-by: Lydon da Rocha <[email protected]>
1 parent 78b6de6 commit 7c94e10

File tree

13 files changed

+195
-38
lines changed

13 files changed

+195
-38
lines changed

api/src/main/scala/za/co/absa/loginsvc/rest/AuthManagerConfig.scala

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ package za.co.absa.loginsvc.rest
1919
import org.slf4j.LoggerFactory
2020
import org.springframework.beans.factory.annotation.Autowired
2121
import org.springframework.context.annotation.{Bean, Configuration}
22-
import org.springframework.security.authentication.{AuthenticationManager, AuthenticationProvider}
22+
import org.springframework.security.authentication.{AuthenticationManager, AuthenticationProvider, ProviderManager}
23+
import org.springframework.security.config.annotation.ObjectPostProcessor
2324
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
24-
import org.springframework.security.config.annotation.web.builders.HttpSecurity
2525
import za.co.absa.loginsvc.rest.config.auth.{ActiveDirectoryLDAPConfig, ConfigOrdering, UsersConfig}
2626
import za.co.absa.loginsvc.rest.config.provider.AuthConfigProvider
2727
import za.co.absa.loginsvc.rest.config.validation.ConfigValidationException
@@ -36,23 +36,22 @@ import scala.collection.immutable.SortedMap
3636
* @param authConfigsProvider
3737
*/
3838
@Configuration
39-
class AuthManagerConfig @Autowired()(authConfigsProvider: AuthConfigProvider){
39+
class AuthManagerConfig @Autowired()(authConfigsProvider: AuthConfigProvider, objectProcessor: ObjectPostProcessor[Object]){
4040

4141
private val usersConfig: Option[UsersConfig] = authConfigsProvider.getUsersConfig
4242
private val adLDAPConfig: Option[ActiveDirectoryLDAPConfig] = authConfigsProvider.getLdapConfig
4343

4444
private val logger = LoggerFactory.getLogger(classOf[AuthManagerConfig])
4545

4646
@Bean
47-
def authManager(http: HttpSecurity): AuthenticationManager = {
48-
49-
val authenticationManagerBuilder = http.getSharedObject(classOf[AuthenticationManagerBuilder]).parentAuthenticationManager(null)
47+
def authManager(): AuthenticationManager = {
5048
val configs: Array[ConfigOrdering] = Array(usersConfig, adLDAPConfig).flatten
5149
val orderedProviders = createAuthProviders(configs)
5250

5351
if(orderedProviders.isEmpty)
5452
throw ConfigValidationException("No authentication method enabled in config")
5553

54+
val authenticationManagerBuilder = new AuthenticationManagerBuilder(objectProcessor)
5655
orderedProviders.zipWithIndex.foreach { case (authProvider, index) =>
5756
logger.info(s"Authentication method ${authProvider.getClass.getSimpleName} has been initialized at order ${index + 1}")
5857
authenticationManagerBuilder.authenticationProvider(authProvider)

api/src/main/scala/za/co/absa/loginsvc/rest/RestResponseEntityExceptionHandler.scala

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import org.slf4j.LoggerFactory
2020
import org.springframework.http.{HttpStatus, ResponseEntity}
2121
import org.springframework.web.bind.annotation.{ControllerAdvice, ExceptionHandler, RestController}
2222
import za.co.absa.loginsvc.rest.model.RestMessage
23+
import za.co.absa.loginsvc.rest.provider.ad.ldap.LdapConnectionException
2324

2425
@ControllerAdvice(annotations = Array(classOf[RestController]))
2526
class RestResponseEntityExceptionHandler {
@@ -37,6 +38,15 @@ class RestResponseEntityExceptionHandler {
3738
ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(RestMessage(exception.getMessage))
3839
}
3940

41+
@ExceptionHandler(value = Array(
42+
// LDAP connection errors (during Kerberos lookup)
43+
classOf[LdapConnectionException]
44+
))
45+
def handleLdapConnectionException(exception: Exception): ResponseEntity[RestMessage] = {
46+
logger.error(exception.getMessage)
47+
ResponseEntity.status(HttpStatus.GATEWAY_TIMEOUT).body(RestMessage(exception.getMessage))
48+
}
49+
4050
@ExceptionHandler(value = Array(
4151
classOf[java.security.SignatureException], // other signature exceptions, e.g signature mismatch, malformed, ...
4252
classOf[io.jsonwebtoken.MalformedJwtException],

api/src/main/scala/za/co/absa/loginsvc/rest/SecurityConfig.scala

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,26 +18,32 @@ package za.co.absa.loginsvc.rest
1818

1919
import org.springframework.beans.factory.annotation.Autowired
2020
import org.springframework.context.annotation.{Bean, Configuration}
21+
import org.springframework.security.authentication.AuthenticationManager
2122
import org.springframework.security.config.annotation.web.builders.HttpSecurity
2223
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
2324
import org.springframework.security.config.http.SessionCreationPolicy
24-
import org.springframework.security.web.SecurityFilterChain
25+
import org.springframework.security.core.AuthenticationException
2526
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter
27+
import org.springframework.security.web.{AuthenticationEntryPoint, SecurityFilterChain}
2628
import za.co.absa.loginsvc.rest.config.provider.AuthConfigProvider
29+
import za.co.absa.loginsvc.rest.provider.ad.ldap.LdapConnectionException
2730
import za.co.absa.loginsvc.rest.provider.kerberos.KerberosSPNEGOAuthenticationProvider
2831

2932
import javax.servlet.http.{HttpServletRequest, HttpServletResponse}
30-
import org.springframework.security.core.AuthenticationException
3133

3234
@Configuration
3335
@EnableWebSecurity
34-
class SecurityConfig @Autowired()(authConfigsProvider: AuthConfigProvider) {
36+
class SecurityConfig @Autowired()(authConfigsProvider: AuthConfigProvider, authManager: AuthenticationManager) {
3537

3638
private val ldapConfig = authConfigsProvider.getLdapConfig.orNull
39+
private val isKerberosEnabled = authConfigsProvider.getLdapConfig.exists(_.enableKerberos.isDefined)
3740

3841
@Bean
3942
def filterChain(http: HttpSecurity): SecurityFilterChain = {
4043
http
44+
.exceptionHandling()
45+
.authenticationEntryPoint(customAuthenticationEntryPoint)
46+
.and()
4147
.csrf()
4248
.disable()
4349
.cors()
@@ -57,29 +63,34 @@ class SecurityConfig @Autowired()(authConfigsProvider: AuthConfigProvider) {
5763
.sessionManagement()
5864
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
5965
.and()
60-
.httpBasic()
66+
// like "httpBasic", but with special handling fo custom exceptions:
67+
.addFilterAt(new BasicAuthenticationFilter(authManager, customAuthenticationEntryPoint), classOf[BasicAuthenticationFilter])
6168

62-
if(ldapConfig != null)
63-
{
64-
if(ldapConfig.enableKerberos.isDefined)
65-
{
66-
val kerberos = new KerberosSPNEGOAuthenticationProvider(ldapConfig)
67-
68-
http.addFilterBefore(
69-
kerberos.spnegoAuthenticationProcessingFilter,
70-
classOf[BasicAuthenticationFilter])
71-
.exceptionHandling()
72-
.authenticationEntryPoint((request: HttpServletRequest,
73-
response: HttpServletResponse,
74-
authException: AuthenticationException) => {
75-
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED)
76-
response.addHeader("WWW-Authenticate", """Basic realm="Realm"""")
77-
response.addHeader("WWW-Authenticate", "Negotiate")
78-
})
79-
}
80-
}
69+
if (isKerberosEnabled) {
70+
val kerberos = new KerberosSPNEGOAuthenticationProvider(ldapConfig)
71+
http.addFilterBefore(
72+
kerberos.spnegoAuthenticationProcessingFilter,
73+
classOf[BasicAuthenticationFilter])
74+
}
8175

8276
http.build()
8377
}
8478

79+
private def customAuthenticationEntryPoint: AuthenticationEntryPoint =
80+
(request: HttpServletRequest, response: HttpServletResponse, authException: AuthenticationException) => {
81+
if (isKerberosEnabled) {
82+
response.addHeader("WWW-Authenticate", """Basic realm="Realm"""")
83+
response.addHeader("WWW-Authenticate", "Negotiate")
84+
}
85+
authException match {
86+
case LdapConnectionException(msg, _) =>
87+
response.setStatus(HttpServletResponse.SC_GATEWAY_TIMEOUT);
88+
response.setContentType("application/json");
89+
response.getWriter.write(s"""{"error": "LDAP connection failed: $msg"}""");
90+
91+
case _ =>
92+
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
93+
}
94+
}
95+
8596
}

api/src/main/scala/za/co/absa/loginsvc/rest/provider/ad/ldap/ActiveDirectoryLDAPAuthenticationProvider.scala

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ package za.co.absa.loginsvc.rest.provider.ad.ldap
1818

1919
import org.slf4j.LoggerFactory
2020
import org.springframework.ldap.core.DirContextOperations
21-
import org.springframework.security.authentication.{AuthenticationProvider, BadCredentialsException, UsernamePasswordAuthenticationToken}
21+
import org.springframework.security.authentication.{AuthenticationProvider, BadCredentialsException, InternalAuthenticationServiceException, UsernamePasswordAuthenticationToken}
2222
import org.springframework.security.core.userdetails.UserDetails
2323
import org.springframework.security.core.{Authentication, GrantedAuthority}
2424
import org.springframework.security.ldap.authentication.ad.{ActiveDirectoryLdapAuthenticationProvider => SpringSecurityActiveDirectoryLdapAuthenticationProvider}
@@ -89,11 +89,25 @@ class ActiveDirectoryLDAPAuthenticationProvider(config: ActiveDirectoryLDAPConfi
8989
logger.error(s"AD authentication failed on attempt $n: ${ex.getMessage}. Retrying in ${delayMs * n}ms...")
9090
Thread.sleep(delayMs * n)
9191
Await.result(attempt(n + 1), Duration.Inf)
92+
9293
case Failure(ex: BadCredentialsException) =>
9394
logger.error(s"Login of user ${authentication.getName}: ${ex.getMessage}", ex)
9495
throw new BadCredentialsException(ex.getMessage)
96+
97+
case Failure(iase: InternalAuthenticationServiceException) =>
98+
iase.getCause match {
99+
case ce: org.springframework.ldap.CommunicationException =>
100+
logger.error(s"InternalAuthenticationServiceException-CommunicationException: ${ce.getMessage}", ce)
101+
ce.printStackTrace()
102+
throw LdapConnectionException(s"LDAP connection issue: ${ce.getMessage}", ce)
103+
case other =>
104+
other.printStackTrace()
105+
logger.error(s"InternalAuthenticationServiceException (other): ${other.getClass.getName}: ${other.getMessage}", other)
106+
throw other
107+
}
108+
95109
case Failure(ex) =>
96-
logger.error(s"Login of user ${authentication.getName} failed after $n attempts: ${ex.getMessage}", ex)
110+
logger.error(s"Login of user ${authentication.getName} failed after n attempts: ${ex.getMessage}", ex)
97111
ex.printStackTrace()
98112
throw ex
99113
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* Copyright 2023 ABSA Group Limited
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package za.co.absa.loginsvc.rest.provider.ad.ldap
18+
19+
import org.springframework.security.core.AuthenticationException
20+
21+
case class LdapConnectionException(msg: String, cause: Throwable) extends AuthenticationException(msg, cause)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Copyright 2023 ABSA Group Limited
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package za.co.absa.loginsvc.rest.provider.kerberos
18+
19+
import org.springframework.security.core.AuthenticationException
20+
import org.springframework.security.web.authentication.AuthenticationFailureHandler
21+
import za.co.absa.loginsvc.rest.provider.ad.ldap.LdapConnectionException
22+
23+
import javax.servlet.http.{HttpServletRequest, HttpServletResponse}
24+
25+
class KerberosFailureHandler extends AuthenticationFailureHandler {
26+
override def onAuthenticationFailure(
27+
request: HttpServletRequest,
28+
response: HttpServletResponse,
29+
exception: AuthenticationException): Unit = {
30+
response.addHeader("WWW-Authenticate", """Basic realm="Realm"""")
31+
response.addHeader("WWW-Authenticate", "Negotiate")
32+
exception match {
33+
case LdapConnectionException(msg, _) =>
34+
response.setStatus(HttpServletResponse.SC_GATEWAY_TIMEOUT)
35+
response.setContentType("application/json")
36+
response.getWriter.write(s"""{"error": "LDAP connection failed: $msg"}""")
37+
case _ =>
38+
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED)
39+
}
40+
}
41+
}

api/src/main/scala/za/co/absa/loginsvc/rest/provider/kerberos/KerberosSPNEGOAuthenticationProvider.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class KerberosSPNEGOAuthenticationProvider(activeDirectoryLDAPConfig: ActiveDire
4545
{
4646
val filter: SpnegoAuthenticationProcessingFilter = new SpnegoAuthenticationProcessingFilter()
4747
filter.setAuthenticationManager(new ProviderManager(kerberosServiceAuthenticationProvider))
48+
filter.setFailureHandler(new KerberosFailureHandler)
4849
filter.afterPropertiesSet()
4950
filter
5051
}

api/src/main/scala/za/co/absa/loginsvc/rest/service/jwt/JWTService.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import za.co.absa.loginsvc.model.User
2626
import za.co.absa.loginsvc.rest.config.jwt.InMemoryKeyConfig
2727
import za.co.absa.loginsvc.rest.config.provider.JwtConfigProvider
2828
import za.co.absa.loginsvc.rest.model.{AccessToken, RefreshToken, Token}
29+
import za.co.absa.loginsvc.rest.provider.ad.ldap.LdapConnectionException
2930
import za.co.absa.loginsvc.rest.service.jwt.JWTService.{extractUserFrom, parseWithKeys}
3031
import za.co.absa.loginsvc.rest.service.search.UserSearchService
3132

@@ -137,7 +138,8 @@ class JWTService @Autowired()(jwtConfigProvider: JwtConfigProvider, authSearchSe
137138
val prefixedGroups = searchedUser.groups.intersect(userFromOldAccessToken.groups) // only keep groups that were in old token
138139
User(searchedUser.name, prefixedGroups, searchedUser.optionalAttributes)
139140
} catch {
140-
case _: Throwable => throw new UnsupportedJwtException(s"User ${userFromOldAccessToken.name} not found")
141+
case lc: LdapConnectionException => throw lc
142+
case _ => throw new UnsupportedJwtException(s"User ${userFromOldAccessToken.name} not found")
141143
}
142144
} // check if user still exists
143145

api/src/main/scala/za/co/absa/loginsvc/rest/service/search/LdapUserRepository.scala

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ package za.co.absa.loginsvc.rest.service.search
1919
import org.slf4j.LoggerFactory
2020
import za.co.absa.loginsvc.model.User
2121
import za.co.absa.loginsvc.rest.config.auth.ActiveDirectoryLDAPConfig
22+
import za.co.absa.loginsvc.rest.provider.ad.ldap.LdapConnectionException
2223

2324
import java.util
24-
import javax.naming.Context
25+
import javax.naming.{Context, NamingException}
2526
import javax.naming.directory.{Attributes, DirContext, SearchControls, SearchResult}
2627
import javax.naming.ldap.{Control, InitialLdapContext, PagedResultsControl}
2728
import scala.collection.JavaConverters.enumerationAsScalaIteratorConverter
@@ -60,7 +61,16 @@ class LdapUserRepository(activeDirectoryLDAPConfig: ActiveDirectoryLDAPConfig)
6061
env.put(Context.SECURITY_PRINCIPAL, principal)
6162
env.put(Context.SECURITY_CREDENTIALS, credential)
6263

63-
new InitialLdapContext(env, Array[Control](new PagedResultsControl(1000, Control.CRITICAL)))
64+
// converting LDAP connection errors into LdapConnectionException
65+
try {
66+
new InitialLdapContext(env, Array[Control](new PagedResultsControl(1000, Control.CRITICAL)))
67+
} catch {
68+
case ne: NamingException =>
69+
throw LdapConnectionException(s"LDAP connection issue (LDAP init): ${ne.getMessage}", ne)
70+
case other =>
71+
throw other
72+
}
73+
6474
}
6575

6676
private def getSimpleSearchControls: SearchControls = {
@@ -131,9 +141,9 @@ class LdapUserRepository(activeDirectoryLDAPConfig: ActiveDirectoryLDAPConfig)
131141
context.close()
132142
userList
133143
} catch {
144+
// while there should be no direct NamingExceptions from getDirContext (-> 504), there may still be some during context search -> 401
134145
case re: Exception =>
135-
logger.error(s"search of user $username: ${re.getMessage}", re)
136-
re.printStackTrace()
146+
logger.error(s"search of user $username (LDAP lookup): ${re.getMessage}", re)
137147
throw re
138148
}
139149
}

api/src/test/scala/za/co/absa/loginsvc/rest/controller/TokenControllerTest.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,14 @@ import za.co.absa.loginsvc.model.User
3232
import za.co.absa.loginsvc.rest.config.provider.ConfigProvider
3333
import za.co.absa.loginsvc.rest.model.{AccessToken, RefreshToken}
3434
import za.co.absa.loginsvc.rest.service.jwt.JWTService
35-
import za.co.absa.loginsvc.rest.{FakeAuthentication, RestResponseEntityExceptionHandler, SecurityConfig}
35+
import za.co.absa.loginsvc.rest.{AuthManagerConfig, FakeAuthentication, RestResponseEntityExceptionHandler, SecurityConfig}
3636

3737
import java.security.interfaces.RSAPublicKey
3838
import java.util.Base64
3939
import scala.concurrent.duration._
4040

4141
@TestPropertySource(properties = Array("spring.config.location=api/src/test/resources/application.yaml"))
42-
@Import(Array(classOf[ConfigProvider], classOf[SecurityConfig], classOf[RestResponseEntityExceptionHandler]))
42+
@Import(Array(classOf[ConfigProvider], classOf[SecurityConfig], classOf[RestResponseEntityExceptionHandler], classOf[AuthManagerConfig]))
4343
@WebMvcTest(controllers = Array(classOf[TokenController]))
4444
class TokenControllerTest extends AnyFlatSpec
4545
with ControllerIntegrationTestBase {

0 commit comments

Comments
 (0)