-
Notifications
You must be signed in to change notification settings - Fork 740
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(saml): Update SAML to use Spring Security #1744
Merged
Merged
Changes from 2 commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
52b39b7
feat(saml): Update SAML to use Spring Security
jvz 7ac77e5
Merge branch 'master' into gate-saml2
jvz 7278d52
Merge branch 'master' into gate-saml2
jasonmcintosh caac3db
Merge branch 'master' into gate-saml2
jasonmcintosh 5700cba
Merge branch 'master' into gate-saml2
jasonmcintosh 4f43381
Merge branch 'master' into gate-saml2
jasonmcintosh 08bcdd0
Merge branch 'master' into gate-saml2
jasonmcintosh e71a011
Merge branch 'master' into gate-saml2
jasonmcintosh a670326
Merge branch 'master' into gate-saml2
jasonmcintosh 381c3ef
Merge branch 'master' into gate-saml2
jasonmcintosh cdfd5bb
Merge branch 'master' into gate-saml2
jvz b1e1858
Merge branch 'master' into gate-saml2
jasonmcintosh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll note that I tried using a newer release of OpenSAML, but it didn't seem to work with the version of Spring Security I was testing against at the time. With a new enough version of Spring Security, we shouldn't need to override this dependency anymore (this older version of Spring Security supports both OpenSAML 3 and 4).