-
Notifications
You must be signed in to change notification settings - Fork 740
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(saml): Update SAML to use Spring Security (#1744)
This updates `gate-saml` to use the built-in SAML support in Spring Security which uses OpenSAML 4.x. Nearly all the previously supported properties for SAML are still supported, though a couple niche options no longer seem to be configurable. This also introduces `AuthenticationService`, a variation of `PermissionService` which can also return a user's granted authorities in one login call. It was also used for exception translation previously as retrofit exceptions are not serializable which would cause errors in Spring Security authentication failure error handlers, but the underlying exception being thrown has since been updated to avoid that problem. Co-authored-by: Jason <[email protected]>
- Loading branch information
1 parent
4809af3
commit 0c5e4bc
Showing
14 changed files
with
583 additions
and
488 deletions.
There are no files selected for viewing
100 changes: 100 additions & 0 deletions
100
gate-core/src/main/java/com/netflix/spinnaker/gate/services/AuthenticationService.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
/* | ||
* Copyright 2023 Apple, Inc. | ||
* | ||
* 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 com.netflix.spinnaker.gate.services; | ||
|
||
import com.netflix.spinnaker.fiat.shared.FiatPermissionEvaluator; | ||
import com.netflix.spinnaker.fiat.shared.FiatService; | ||
import com.netflix.spinnaker.fiat.shared.FiatStatus; | ||
import com.netflix.spinnaker.security.AuthenticatedRequest; | ||
import io.micrometer.core.annotation.Counted; | ||
import java.util.Collection; | ||
import java.util.Set; | ||
import lombok.RequiredArgsConstructor; | ||
import lombok.Setter; | ||
import lombok.extern.log4j.Log4j2; | ||
import org.springframework.beans.factory.annotation.Autowired; | ||
import org.springframework.beans.factory.annotation.Qualifier; | ||
import org.springframework.security.core.GrantedAuthority; | ||
import org.springframework.security.core.userdetails.UsernameNotFoundException; | ||
import org.springframework.stereotype.Service; | ||
|
||
/** Facade for logging in an authenticated user and obtaining Fiat authorities. */ | ||
@Log4j2 | ||
@Service | ||
@RequiredArgsConstructor | ||
public class AuthenticationService { | ||
private final FiatStatus fiatStatus; | ||
private final FiatService fiatService; | ||
private final FiatPermissionEvaluator permissionEvaluator; | ||
|
||
@Setter( | ||
onParam_ = {@Qualifier("fiatLoginService")}, | ||
onMethod_ = {@Autowired(required = false)}) | ||
private FiatService fiatLoginService; | ||
|
||
private FiatService getFiatServiceForLogin() { | ||
return fiatLoginService != null ? fiatLoginService : fiatService; | ||
} | ||
|
||
@Counted("fiat.login") | ||
public Collection<? extends GrantedAuthority> login(String userid) { | ||
if (!fiatStatus.isEnabled()) { | ||
return Set.of(); | ||
} | ||
|
||
return AuthenticatedRequest.allowAnonymous( | ||
() -> { | ||
getFiatServiceForLogin().loginUser(userid, ""); | ||
return resolveAuthorities(userid); | ||
}); | ||
} | ||
|
||
@Counted("fiat.login") | ||
public Collection<? extends GrantedAuthority> loginWithRoles( | ||
String userid, Collection<String> roles) { | ||
if (!fiatStatus.isEnabled()) { | ||
return Set.of(); | ||
} | ||
|
||
return AuthenticatedRequest.allowAnonymous( | ||
() -> { | ||
getFiatServiceForLogin().loginWithRoles(userid, roles); | ||
return resolveAuthorities(userid); | ||
}); | ||
} | ||
|
||
@Counted("fiat.logout") | ||
public void logout(String userid) { | ||
if (!fiatStatus.isEnabled()) { | ||
return; | ||
} | ||
|
||
getFiatServiceForLogin().logoutUser(userid); | ||
permissionEvaluator.invalidatePermission(userid); | ||
} | ||
|
||
private Collection<? extends GrantedAuthority> resolveAuthorities(String userid) { | ||
permissionEvaluator.invalidatePermission(userid); | ||
var permission = permissionEvaluator.getPermission(userid); | ||
if (permission == null) { | ||
throw new UsernameNotFoundException( | ||
String.format("No user found in Fiat named '%s'", userid)); | ||
} | ||
return permission.toGrantedAuthorities(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,13 +1,20 @@ | ||
dependencies{ | ||
dependencies { | ||
constraints { | ||
implementation 'org.opensaml:opensaml-core:4.1.0' | ||
implementation 'org.opensaml:opensaml-saml-api:4.1.0' | ||
implementation 'org.opensaml:opensaml-saml-impl:4.1.0' | ||
} | ||
|
||
implementation project(':gate-core') | ||
// RetrySupport is in kork-exceptions and not kork-core! | ||
implementation 'io.spinnaker.kork:kork-core' | ||
implementation 'io.spinnaker.kork:kork-crypto' | ||
implementation 'io.spinnaker.kork:kork-exceptions' | ||
implementation 'io.spinnaker.kork:kork-security' | ||
implementation "io.spinnaker.fiat:fiat-api:$fiatVersion" | ||
implementation "io.spinnaker.kork:kork-exceptions" | ||
implementation "io.spinnaker.kork:kork-security" | ||
implementation 'org.springframework:spring-context' | ||
implementation 'org.springframework.boot:spring-boot-starter-security' | ||
implementation 'org.springframework.boot:spring-boot-starter-validation' | ||
implementation 'org.springframework.security:spring-security-saml2-service-provider' | ||
implementation 'org.springframework.session:spring-session-core' | ||
implementation 'org.springframework.boot:spring-boot-autoconfigure' | ||
|
||
implementation "org.springframework.security.extensions:spring-security-saml2-core" | ||
implementation "org.springframework.security.extensions:spring-security-saml-dsl-core" | ||
testImplementation 'org.springframework.boot:spring-boot-starter-test' | ||
} |
36 changes: 36 additions & 0 deletions
36
...rc/main/java/com/netflix/spinnaker/gate/security/saml/DefaultUserIdentifierExtractor.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
/* | ||
* Copyright 2023 Apple, Inc. | ||
* | ||
* 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 com.netflix.spinnaker.gate.security.saml; | ||
|
||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; | ||
|
||
/** | ||
* Default implementation for extracting the user id from an authenticated SAML user. This uses the | ||
* settings in {@link SecuritySamlProperties.UserAttributeMapping#getUsername()} | ||
*/ | ||
@RequiredArgsConstructor | ||
public class DefaultUserIdentifierExtractor implements UserIdentifierExtractor { | ||
private final SecuritySamlProperties properties; | ||
|
||
@Override | ||
public String fromPrincipal(Saml2AuthenticatedPrincipal principal) { | ||
String userid = principal.getFirstAttribute(properties.getUserAttributeMapping().getUsername()); | ||
return userid != null ? userid : principal.getName(); | ||
} | ||
} |
82 changes: 82 additions & 0 deletions
82
...aml/src/main/java/com/netflix/spinnaker/gate/security/saml/DefaultUserRolesExtractor.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
/* | ||
* Copyright 2023 Apple, Inc. | ||
* | ||
* 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 com.netflix.spinnaker.gate.security.saml; | ||
|
||
import com.netflix.spinnaker.kork.exceptions.ConfigurationException; | ||
import java.util.LinkedHashSet; | ||
import java.util.List; | ||
import java.util.Locale; | ||
import java.util.Set; | ||
import java.util.stream.Collectors; | ||
import java.util.stream.Stream; | ||
import javax.naming.InvalidNameException; | ||
import javax.naming.ldap.LdapName; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; | ||
|
||
/** | ||
* Default implementation for extracting roles from an authenticated SAML user. This uses the | ||
* settings in {@link SecuritySamlProperties} related to roles. If role names appear to be | ||
* distinguished names (i.e., they contain the substring {@code CN=}), then they will be parsed as | ||
* DNs to extract the common name (CN) attribute. | ||
*/ | ||
@RequiredArgsConstructor | ||
public class DefaultUserRolesExtractor implements UserRolesExtractor { | ||
private final SecuritySamlProperties properties; | ||
|
||
@Override | ||
public Set<String> getRoles(Saml2AuthenticatedPrincipal principal) { | ||
var userAttributeMapping = properties.getUserAttributeMapping(); | ||
List<String> roles = principal.getAttribute(userAttributeMapping.getRoles()); | ||
Stream<String> roleStream = roles != null ? roles.stream() : Stream.empty(); | ||
String delimiter = userAttributeMapping.getRolesDelimiter(); | ||
roleStream = | ||
delimiter != null | ||
? roleStream.flatMap(role -> Stream.of(role.split(delimiter))) | ||
: roleStream; | ||
roleStream = roleStream.map(DefaultUserRolesExtractor::parseRole); | ||
if (properties.isForceLowercaseRoles()) { | ||
roleStream = roleStream.map(role -> role.toLowerCase(Locale.ROOT)); | ||
} | ||
if (properties.isSortRoles()) { | ||
roleStream = roleStream.sorted(); | ||
} | ||
return roleStream.collect(Collectors.toCollection(LinkedHashSet::new)); | ||
} | ||
|
||
private static String parseRole(String role) { | ||
if (!role.contains("CN=")) { | ||
return role; | ||
} | ||
try { | ||
return new LdapName(role) | ||
.getRdns().stream() | ||
.filter(rdn -> rdn.getType().equals("CN")) | ||
.map(rdn -> (String) rdn.getValue()) | ||
.findFirst() | ||
.orElseThrow( | ||
() -> | ||
new ConfigurationException( | ||
String.format( | ||
"SAML role '%s' contains 'CN=' but cannot be parsed as a DN", role))); | ||
} catch (InvalidNameException e) { | ||
throw new ConfigurationException( | ||
String.format("Unable to parse SAML role name '%s'", role), e); | ||
} | ||
} | ||
} |
95 changes: 95 additions & 0 deletions
95
...c/main/java/com/netflix/spinnaker/gate/security/saml/ResponseAuthenticationConverter.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
/* | ||
* Copyright 2023 Apple, Inc. | ||
* | ||
* 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 com.netflix.spinnaker.gate.security.saml; | ||
|
||
import com.netflix.spinnaker.gate.services.AuthenticationService; | ||
import com.netflix.spinnaker.security.User; | ||
import java.util.Collection; | ||
import java.util.Collections; | ||
import java.util.Set; | ||
import lombok.RequiredArgsConstructor; | ||
import lombok.extern.log4j.Log4j2; | ||
import org.springframework.beans.factory.ObjectFactory; | ||
import org.springframework.core.convert.converter.Converter; | ||
import org.springframework.security.authentication.BadCredentialsException; | ||
import org.springframework.security.core.GrantedAuthority; | ||
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider; | ||
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider.ResponseToken; | ||
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; | ||
import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication; | ||
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; | ||
import org.springframework.util.CollectionUtils; | ||
|
||
/** Handles conversion of an authenticated SAML user into a Spinnaker user and populating Fiat. */ | ||
@Log4j2 | ||
@RequiredArgsConstructor | ||
public class ResponseAuthenticationConverter | ||
implements Converter<ResponseToken, PreAuthenticatedAuthenticationToken> { | ||
private final SecuritySamlProperties properties; | ||
private final ObjectFactory<UserIdentifierExtractor> userIdentifierExtractorFactory; | ||
private final ObjectFactory<UserRolesExtractor> userRolesExtractorFactory; | ||
private final ObjectFactory<AuthenticationService> authenticationServiceFactory; | ||
|
||
@Override | ||
public PreAuthenticatedAuthenticationToken convert(ResponseToken source) { | ||
UserIdentifierExtractor userIdentifierExtractor = userIdentifierExtractorFactory.getObject(); | ||
UserRolesExtractor userRolesExtractor = userRolesExtractorFactory.getObject(); | ||
AuthenticationService loginService = authenticationServiceFactory.getObject(); | ||
log.debug("Decoding SAML response: {}", source.getToken()); | ||
|
||
Saml2Authentication authentication = convertToken(source); | ||
@SuppressWarnings("deprecation") | ||
var user = new User(); | ||
Saml2AuthenticatedPrincipal principal = | ||
(Saml2AuthenticatedPrincipal) authentication.getPrincipal(); | ||
String principalName = principal.getName(); | ||
var userAttributeMapping = properties.getUserAttributeMapping(); | ||
String email = principal.getFirstAttribute(userAttributeMapping.getEmail()); | ||
user.setEmail(email != null ? email : principalName); | ||
String userid = userIdentifierExtractor.fromPrincipal(principal); | ||
user.setUsername(userid); | ||
user.setFirstName(principal.getFirstAttribute(userAttributeMapping.getFirstName())); | ||
user.setLastName(principal.getFirstAttribute(userAttributeMapping.getLastName())); | ||
|
||
Set<String> roles = userRolesExtractor.getRoles(principal); | ||
user.setRoles(roles); | ||
|
||
if (!CollectionUtils.isEmpty(properties.getRequiredRoles())) { | ||
var requiredRoles = Set.copyOf(properties.getRequiredRoles()); | ||
// check for at least one common role in both sets | ||
if (Collections.disjoint(roles, requiredRoles)) { | ||
throw new BadCredentialsException( | ||
String.format("User %s is not in any required role from %s", email, requiredRoles)); | ||
} | ||
} | ||
|
||
Collection<? extends GrantedAuthority> authorities = loginService.loginWithRoles(userid, roles); | ||
return new PreAuthenticatedAuthenticationToken(user, principal, authorities); | ||
} | ||
|
||
private static final Converter<ResponseToken, Saml2Authentication> DEFAULT_CONVERTER = | ||
OpenSaml4AuthenticationProvider.createDefaultResponseAuthenticationConverter(); | ||
|
||
private static Saml2Authentication convertToken(ResponseToken token) { | ||
Saml2Authentication authentication = DEFAULT_CONVERTER.convert(token); | ||
if (authentication == null) { | ||
throw new IllegalArgumentException("Response token could not be converted"); | ||
} | ||
return authentication; | ||
} | ||
} |
Oops, something went wrong.