Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ package za.co.absa.loginsvc.rest
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.{Bean, Configuration}
import org.springframework.security.authentication.{AuthenticationManager, AuthenticationProvider}
import org.springframework.security.authentication.{AuthenticationManager, AuthenticationProvider, ProviderManager}
import org.springframework.security.config.annotation.ObjectPostProcessor
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import za.co.absa.loginsvc.rest.config.auth.{ActiveDirectoryLDAPConfig, ConfigOrdering, UsersConfig}
import za.co.absa.loginsvc.rest.config.provider.AuthConfigProvider
import za.co.absa.loginsvc.rest.config.validation.ConfigValidationException
Expand All @@ -36,23 +36,22 @@ import scala.collection.immutable.SortedMap
* @param authConfigsProvider
*/
@Configuration
class AuthManagerConfig @Autowired()(authConfigsProvider: AuthConfigProvider){
class AuthManagerConfig @Autowired()(authConfigsProvider: AuthConfigProvider, objectProcessor: ObjectPostProcessor[Object]){

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

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

@Bean
def authManager(http: HttpSecurity): AuthenticationManager = {

val authenticationManagerBuilder = http.getSharedObject(classOf[AuthenticationManagerBuilder]).parentAuthenticationManager(null)
def authManager(): AuthenticationManager = {
val configs: Array[ConfigOrdering] = Array(usersConfig, adLDAPConfig).flatten
val orderedProviders = createAuthProviders(configs)

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

val authenticationManagerBuilder = new AuthenticationManagerBuilder(objectProcessor)
orderedProviders.zipWithIndex.foreach { case (authProvider, index) =>
logger.info(s"Authentication method ${authProvider.getClass.getSimpleName} has been initialized at order ${index + 1}")
authenticationManagerBuilder.authenticationProvider(authProvider)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import org.slf4j.LoggerFactory
import org.springframework.http.{HttpStatus, ResponseEntity}
import org.springframework.web.bind.annotation.{ControllerAdvice, ExceptionHandler, RestController}
import za.co.absa.loginsvc.rest.model.RestMessage
import za.co.absa.loginsvc.rest.provider.ad.ldap.LdapConnectionException

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

@ExceptionHandler(value = Array(
// LDAP connection errors (during Kerberos lookup)
classOf[LdapConnectionException]
))
def handleLdapConnectionException(exception: Exception): ResponseEntity[RestMessage] = {
logger.error(exception.getMessage)
ResponseEntity.status(HttpStatus.GATEWAY_TIMEOUT).body(RestMessage(exception.getMessage))
}

@ExceptionHandler(value = Array(
classOf[java.security.SignatureException], // other signature exceptions, e.g signature mismatch, malformed, ...
classOf[io.jsonwebtoken.MalformedJwtException],
Expand Down
57 changes: 34 additions & 23 deletions api/src/main/scala/za/co/absa/loginsvc/rest/SecurityConfig.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,32 @@ package za.co.absa.loginsvc.rest

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.{Bean, Configuration}
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter
import org.springframework.security.web.{AuthenticationEntryPoint, SecurityFilterChain}
import za.co.absa.loginsvc.rest.config.provider.AuthConfigProvider
import za.co.absa.loginsvc.rest.provider.ad.ldap.LdapConnectionException
import za.co.absa.loginsvc.rest.provider.kerberos.KerberosSPNEGOAuthenticationProvider

import javax.servlet.http.{HttpServletRequest, HttpServletResponse}
import org.springframework.security.core.AuthenticationException

@Configuration
@EnableWebSecurity
class SecurityConfig @Autowired()(authConfigsProvider: AuthConfigProvider) {
class SecurityConfig @Autowired()(authConfigsProvider: AuthConfigProvider, authManager: AuthenticationManager) {

private val ldapConfig = authConfigsProvider.getLdapConfig.orNull
private val isKerberosEnabled = authConfigsProvider.getLdapConfig.exists(_.enableKerberos.isDefined)

@Bean
def filterChain(http: HttpSecurity): SecurityFilterChain = {
http
.exceptionHandling()
.authenticationEntryPoint(customAuthenticationEntryPoint)
.and()
.csrf()
.disable()
.cors()
Expand All @@ -57,29 +63,34 @@ class SecurityConfig @Autowired()(authConfigsProvider: AuthConfigProvider) {
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.httpBasic()
// like "httpBasic", but with special handling fo custom exceptions:
.addFilterAt(new BasicAuthenticationFilter(authManager, customAuthenticationEntryPoint), classOf[BasicAuthenticationFilter])

if(ldapConfig != null)
{
if(ldapConfig.enableKerberos.isDefined)
{
val kerberos = new KerberosSPNEGOAuthenticationProvider(ldapConfig)

http.addFilterBefore(
kerberos.spnegoAuthenticationProcessingFilter,
classOf[BasicAuthenticationFilter])
.exceptionHandling()
.authenticationEntryPoint((request: HttpServletRequest,
response: HttpServletResponse,
authException: AuthenticationException) => {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED)
response.addHeader("WWW-Authenticate", """Basic realm="Realm"""")
response.addHeader("WWW-Authenticate", "Negotiate")
})
}
}
if (isKerberosEnabled) {
val kerberos = new KerberosSPNEGOAuthenticationProvider(ldapConfig)
http.addFilterBefore(
kerberos.spnegoAuthenticationProcessingFilter,
classOf[BasicAuthenticationFilter])
}

http.build()
}

private def customAuthenticationEntryPoint: AuthenticationEntryPoint =
(request: HttpServletRequest, response: HttpServletResponse, authException: AuthenticationException) => {
if (isKerberosEnabled) {
response.addHeader("WWW-Authenticate", """Basic realm="Realm"""")
response.addHeader("WWW-Authenticate", "Negotiate")
}
authException match {
case LdapConnectionException(msg, _) =>
response.setStatus(HttpServletResponse.SC_GATEWAY_TIMEOUT);
response.setContentType("application/json");
response.getWriter.write(s"""{"error": "LDAP connection failed: $msg"}""");

case _ =>
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ package za.co.absa.loginsvc.rest.provider.ad.ldap

import org.slf4j.LoggerFactory
import org.springframework.ldap.core.DirContextOperations
import org.springframework.security.authentication.{AuthenticationProvider, BadCredentialsException, UsernamePasswordAuthenticationToken}
import org.springframework.security.authentication.{AuthenticationProvider, BadCredentialsException, InternalAuthenticationServiceException, UsernamePasswordAuthenticationToken}
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.{Authentication, GrantedAuthority}
import org.springframework.security.ldap.authentication.ad.{ActiveDirectoryLdapAuthenticationProvider => SpringSecurityActiveDirectoryLdapAuthenticationProvider}
Expand Down Expand Up @@ -89,11 +89,25 @@ class ActiveDirectoryLDAPAuthenticationProvider(config: ActiveDirectoryLDAPConfi
logger.error(s"AD authentication failed on attempt $n: ${ex.getMessage}. Retrying in ${delayMs * n}ms...")
Thread.sleep(delayMs * n)
Await.result(attempt(n + 1), Duration.Inf)

case Failure(ex: BadCredentialsException) =>
logger.error(s"Login of user ${authentication.getName}: ${ex.getMessage}", ex)
throw new BadCredentialsException(ex.getMessage)

case Failure(iase: InternalAuthenticationServiceException) =>
iase.getCause match {
case ce: org.springframework.ldap.CommunicationException =>
logger.error(s"InternalAuthenticationServiceException-CommunicationException: ${ce.getMessage}", ce)
ce.printStackTrace()
throw LdapConnectionException(s"LDAP connection issue: ${ce.getMessage}", ce)
case other =>
other.printStackTrace()
logger.error(s"InternalAuthenticationServiceException (other): ${other.getClass.getName}: ${other.getMessage}", other)
throw other
}

case Failure(ex) =>
logger.error(s"Login of user ${authentication.getName} failed after $n attempts: ${ex.getMessage}", ex)
logger.error(s"Login of user ${authentication.getName} failed after n attempts: ${ex.getMessage}", ex)
ex.printStackTrace()
throw ex
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright 2023 ABSA Group Limited
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package za.co.absa.loginsvc.rest.provider.ad.ldap

import org.springframework.security.core.AuthenticationException

case class LdapConnectionException(msg: String, cause: Throwable) extends AuthenticationException(msg, cause)
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright 2023 ABSA Group Limited
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package za.co.absa.loginsvc.rest.provider.kerberos

import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.authentication.AuthenticationFailureHandler
import za.co.absa.loginsvc.rest.provider.ad.ldap.LdapConnectionException

import javax.servlet.http.{HttpServletRequest, HttpServletResponse}

class KerberosFailureHandler extends AuthenticationFailureHandler {
override def onAuthenticationFailure(
request: HttpServletRequest,
response: HttpServletResponse,
exception: AuthenticationException): Unit = {
response.addHeader("WWW-Authenticate", """Basic realm="Realm"""")
response.addHeader("WWW-Authenticate", "Negotiate")
exception match {
case LdapConnectionException(msg, _) =>
response.setStatus(HttpServletResponse.SC_GATEWAY_TIMEOUT)
response.setContentType("application/json")
response.getWriter.write(s"""{"error": "LDAP connection failed: $msg"}""")
case _ =>
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class KerberosSPNEGOAuthenticationProvider(activeDirectoryLDAPConfig: ActiveDire
{
val filter: SpnegoAuthenticationProcessingFilter = new SpnegoAuthenticationProcessingFilter()
filter.setAuthenticationManager(new ProviderManager(kerberosServiceAuthenticationProvider))
filter.setFailureHandler(new KerberosFailureHandler)
filter.afterPropertiesSet()
filter
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import za.co.absa.loginsvc.model.User
import za.co.absa.loginsvc.rest.config.jwt.InMemoryKeyConfig
import za.co.absa.loginsvc.rest.config.provider.JwtConfigProvider
import za.co.absa.loginsvc.rest.model.{AccessToken, RefreshToken, Token}
import za.co.absa.loginsvc.rest.provider.ad.ldap.LdapConnectionException
import za.co.absa.loginsvc.rest.service.jwt.JWTService.{extractUserFrom, parseWithKeys}
import za.co.absa.loginsvc.rest.service.search.UserSearchService

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ package za.co.absa.loginsvc.rest.service.search
import org.slf4j.LoggerFactory
import za.co.absa.loginsvc.model.User
import za.co.absa.loginsvc.rest.config.auth.ActiveDirectoryLDAPConfig
import za.co.absa.loginsvc.rest.provider.ad.ldap.LdapConnectionException

import java.util
import javax.naming.Context
import javax.naming.{Context, NamingException}
import javax.naming.directory.{Attributes, DirContext, SearchControls, SearchResult}
import javax.naming.ldap.{Control, InitialLdapContext, PagedResultsControl}
import scala.collection.JavaConverters.enumerationAsScalaIteratorConverter
Expand Down Expand Up @@ -60,7 +61,16 @@ class LdapUserRepository(activeDirectoryLDAPConfig: ActiveDirectoryLDAPConfig)
env.put(Context.SECURITY_PRINCIPAL, principal)
env.put(Context.SECURITY_CREDENTIALS, credential)

new InitialLdapContext(env, Array[Control](new PagedResultsControl(1000, Control.CRITICAL)))
// converting LDAP connection errors into LdapConnectionException
try {
new InitialLdapContext(env, Array[Control](new PagedResultsControl(1000, Control.CRITICAL)))
} catch {
case ne: NamingException =>
throw LdapConnectionException(s"LDAP connection issue (LDAP init): ${ne.getMessage}", ne)
case other =>
throw other
}

}

private def getSimpleSearchControls: SearchControls = {
Expand Down Expand Up @@ -131,9 +141,9 @@ class LdapUserRepository(activeDirectoryLDAPConfig: ActiveDirectoryLDAPConfig)
context.close()
userList
} catch {
// while there should be no direct NamingExceptions from getDirContext (-> 504), there may still be some during context search -> 401
case re: Exception =>
logger.error(s"search of user $username: ${re.getMessage}", re)
re.printStackTrace()
logger.error(s"search of user $username (LDAP lookup): ${re.getMessage}", re)
throw re
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ import za.co.absa.loginsvc.model.User
import za.co.absa.loginsvc.rest.config.provider.ConfigProvider
import za.co.absa.loginsvc.rest.model.{AccessToken, RefreshToken}
import za.co.absa.loginsvc.rest.service.jwt.JWTService
import za.co.absa.loginsvc.rest.{FakeAuthentication, RestResponseEntityExceptionHandler, SecurityConfig}
import za.co.absa.loginsvc.rest.{AuthManagerConfig, FakeAuthentication, RestResponseEntityExceptionHandler, SecurityConfig}

import java.security.interfaces.RSAPublicKey
import java.util.Base64
import scala.concurrent.duration._

@TestPropertySource(properties = Array("spring.config.location=api/src/test/resources/application.yaml"))
@Import(Array(classOf[ConfigProvider], classOf[SecurityConfig], classOf[RestResponseEntityExceptionHandler]))
@Import(Array(classOf[ConfigProvider], classOf[SecurityConfig], classOf[RestResponseEntityExceptionHandler], classOf[AuthManagerConfig]))
@WebMvcTest(controllers = Array(classOf[TokenController]))
class TokenControllerTest extends AnyFlatSpec
with ControllerIntegrationTestBase {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
/*
* Copyright 2023 ABSA Group Limited
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package za.co.absa.loginclient.authorization

import io.jsonwebtoken.SignatureAlgorithm
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
/*
* Copyright 2023 ABSA Group Limited
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package za.co.absa.loginclient.publicKeyRetrieval.client

import org.scalatest.flatspec.AnyFlatSpec
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
/*
* Copyright 2023 ABSA Group Limited
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package za.co.absa.loginclient.tokenRetrieval.client

import org.scalatest.flatspec.AnyFlatSpec
Expand Down
Loading