Skip to content

Commit 0c5e4bc

Browse files
jvzjasonmcintosh
andauthored
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]>
1 parent 4809af3 commit 0c5e4bc

File tree

14 files changed

+583
-488
lines changed

14 files changed

+583
-488
lines changed
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* Copyright 2023 Apple, Inc.
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+
18+
package com.netflix.spinnaker.gate.services;
19+
20+
import com.netflix.spinnaker.fiat.shared.FiatPermissionEvaluator;
21+
import com.netflix.spinnaker.fiat.shared.FiatService;
22+
import com.netflix.spinnaker.fiat.shared.FiatStatus;
23+
import com.netflix.spinnaker.security.AuthenticatedRequest;
24+
import io.micrometer.core.annotation.Counted;
25+
import java.util.Collection;
26+
import java.util.Set;
27+
import lombok.RequiredArgsConstructor;
28+
import lombok.Setter;
29+
import lombok.extern.log4j.Log4j2;
30+
import org.springframework.beans.factory.annotation.Autowired;
31+
import org.springframework.beans.factory.annotation.Qualifier;
32+
import org.springframework.security.core.GrantedAuthority;
33+
import org.springframework.security.core.userdetails.UsernameNotFoundException;
34+
import org.springframework.stereotype.Service;
35+
36+
/** Facade for logging in an authenticated user and obtaining Fiat authorities. */
37+
@Log4j2
38+
@Service
39+
@RequiredArgsConstructor
40+
public class AuthenticationService {
41+
private final FiatStatus fiatStatus;
42+
private final FiatService fiatService;
43+
private final FiatPermissionEvaluator permissionEvaluator;
44+
45+
@Setter(
46+
onParam_ = {@Qualifier("fiatLoginService")},
47+
onMethod_ = {@Autowired(required = false)})
48+
private FiatService fiatLoginService;
49+
50+
private FiatService getFiatServiceForLogin() {
51+
return fiatLoginService != null ? fiatLoginService : fiatService;
52+
}
53+
54+
@Counted("fiat.login")
55+
public Collection<? extends GrantedAuthority> login(String userid) {
56+
if (!fiatStatus.isEnabled()) {
57+
return Set.of();
58+
}
59+
60+
return AuthenticatedRequest.allowAnonymous(
61+
() -> {
62+
getFiatServiceForLogin().loginUser(userid, "");
63+
return resolveAuthorities(userid);
64+
});
65+
}
66+
67+
@Counted("fiat.login")
68+
public Collection<? extends GrantedAuthority> loginWithRoles(
69+
String userid, Collection<String> roles) {
70+
if (!fiatStatus.isEnabled()) {
71+
return Set.of();
72+
}
73+
74+
return AuthenticatedRequest.allowAnonymous(
75+
() -> {
76+
getFiatServiceForLogin().loginWithRoles(userid, roles);
77+
return resolveAuthorities(userid);
78+
});
79+
}
80+
81+
@Counted("fiat.logout")
82+
public void logout(String userid) {
83+
if (!fiatStatus.isEnabled()) {
84+
return;
85+
}
86+
87+
getFiatServiceForLogin().logoutUser(userid);
88+
permissionEvaluator.invalidatePermission(userid);
89+
}
90+
91+
private Collection<? extends GrantedAuthority> resolveAuthorities(String userid) {
92+
permissionEvaluator.invalidatePermission(userid);
93+
var permission = permissionEvaluator.getPermission(userid);
94+
if (permission == null) {
95+
throw new UsernameNotFoundException(
96+
String.format("No user found in Fiat named '%s'", userid));
97+
}
98+
return permission.toGrantedAuthorities();
99+
}
100+
}

gate-saml/gate-saml.gradle

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1-
dependencies{
1+
dependencies {
2+
constraints {
3+
implementation 'org.opensaml:opensaml-core:4.1.0'
4+
implementation 'org.opensaml:opensaml-saml-api:4.1.0'
5+
implementation 'org.opensaml:opensaml-saml-impl:4.1.0'
6+
}
7+
28
implementation project(':gate-core')
3-
// RetrySupport is in kork-exceptions and not kork-core!
9+
implementation 'io.spinnaker.kork:kork-core'
10+
implementation 'io.spinnaker.kork:kork-crypto'
11+
implementation 'io.spinnaker.kork:kork-exceptions'
12+
implementation 'io.spinnaker.kork:kork-security'
413
implementation "io.spinnaker.fiat:fiat-api:$fiatVersion"
5-
implementation "io.spinnaker.kork:kork-exceptions"
6-
implementation "io.spinnaker.kork:kork-security"
7-
implementation 'org.springframework:spring-context'
14+
implementation 'org.springframework.boot:spring-boot-starter-security'
15+
implementation 'org.springframework.boot:spring-boot-starter-validation'
16+
implementation 'org.springframework.security:spring-security-saml2-service-provider'
817
implementation 'org.springframework.session:spring-session-core'
9-
implementation 'org.springframework.boot:spring-boot-autoconfigure'
1018

11-
implementation "org.springframework.security.extensions:spring-security-saml2-core"
12-
implementation "org.springframework.security.extensions:spring-security-saml-dsl-core"
19+
testImplementation 'org.springframework.boot:spring-boot-starter-test'
1320
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright 2023 Apple, Inc.
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+
18+
package com.netflix.spinnaker.gate.security.saml;
19+
20+
import lombok.RequiredArgsConstructor;
21+
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal;
22+
23+
/**
24+
* Default implementation for extracting the user id from an authenticated SAML user. This uses the
25+
* settings in {@link SecuritySamlProperties.UserAttributeMapping#getUsername()}
26+
*/
27+
@RequiredArgsConstructor
28+
public class DefaultUserIdentifierExtractor implements UserIdentifierExtractor {
29+
private final SecuritySamlProperties properties;
30+
31+
@Override
32+
public String fromPrincipal(Saml2AuthenticatedPrincipal principal) {
33+
String userid = principal.getFirstAttribute(properties.getUserAttributeMapping().getUsername());
34+
return userid != null ? userid : principal.getName();
35+
}
36+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Copyright 2023 Apple, Inc.
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+
18+
package com.netflix.spinnaker.gate.security.saml;
19+
20+
import com.netflix.spinnaker.kork.exceptions.ConfigurationException;
21+
import java.util.LinkedHashSet;
22+
import java.util.List;
23+
import java.util.Locale;
24+
import java.util.Set;
25+
import java.util.stream.Collectors;
26+
import java.util.stream.Stream;
27+
import javax.naming.InvalidNameException;
28+
import javax.naming.ldap.LdapName;
29+
import lombok.RequiredArgsConstructor;
30+
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal;
31+
32+
/**
33+
* Default implementation for extracting roles from an authenticated SAML user. This uses the
34+
* settings in {@link SecuritySamlProperties} related to roles. If role names appear to be
35+
* distinguished names (i.e., they contain the substring {@code CN=}), then they will be parsed as
36+
* DNs to extract the common name (CN) attribute.
37+
*/
38+
@RequiredArgsConstructor
39+
public class DefaultUserRolesExtractor implements UserRolesExtractor {
40+
private final SecuritySamlProperties properties;
41+
42+
@Override
43+
public Set<String> getRoles(Saml2AuthenticatedPrincipal principal) {
44+
var userAttributeMapping = properties.getUserAttributeMapping();
45+
List<String> roles = principal.getAttribute(userAttributeMapping.getRoles());
46+
Stream<String> roleStream = roles != null ? roles.stream() : Stream.empty();
47+
String delimiter = userAttributeMapping.getRolesDelimiter();
48+
roleStream =
49+
delimiter != null
50+
? roleStream.flatMap(role -> Stream.of(role.split(delimiter)))
51+
: roleStream;
52+
roleStream = roleStream.map(DefaultUserRolesExtractor::parseRole);
53+
if (properties.isForceLowercaseRoles()) {
54+
roleStream = roleStream.map(role -> role.toLowerCase(Locale.ROOT));
55+
}
56+
if (properties.isSortRoles()) {
57+
roleStream = roleStream.sorted();
58+
}
59+
return roleStream.collect(Collectors.toCollection(LinkedHashSet::new));
60+
}
61+
62+
private static String parseRole(String role) {
63+
if (!role.contains("CN=")) {
64+
return role;
65+
}
66+
try {
67+
return new LdapName(role)
68+
.getRdns().stream()
69+
.filter(rdn -> rdn.getType().equals("CN"))
70+
.map(rdn -> (String) rdn.getValue())
71+
.findFirst()
72+
.orElseThrow(
73+
() ->
74+
new ConfigurationException(
75+
String.format(
76+
"SAML role '%s' contains 'CN=' but cannot be parsed as a DN", role)));
77+
} catch (InvalidNameException e) {
78+
throw new ConfigurationException(
79+
String.format("Unable to parse SAML role name '%s'", role), e);
80+
}
81+
}
82+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright 2023 Apple, Inc.
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+
18+
package com.netflix.spinnaker.gate.security.saml;
19+
20+
import com.netflix.spinnaker.gate.services.AuthenticationService;
21+
import com.netflix.spinnaker.security.User;
22+
import java.util.Collection;
23+
import java.util.Collections;
24+
import java.util.Set;
25+
import lombok.RequiredArgsConstructor;
26+
import lombok.extern.log4j.Log4j2;
27+
import org.springframework.beans.factory.ObjectFactory;
28+
import org.springframework.core.convert.converter.Converter;
29+
import org.springframework.security.authentication.BadCredentialsException;
30+
import org.springframework.security.core.GrantedAuthority;
31+
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider;
32+
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider.ResponseToken;
33+
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal;
34+
import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
35+
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
36+
import org.springframework.util.CollectionUtils;
37+
38+
/** Handles conversion of an authenticated SAML user into a Spinnaker user and populating Fiat. */
39+
@Log4j2
40+
@RequiredArgsConstructor
41+
public class ResponseAuthenticationConverter
42+
implements Converter<ResponseToken, PreAuthenticatedAuthenticationToken> {
43+
private final SecuritySamlProperties properties;
44+
private final ObjectFactory<UserIdentifierExtractor> userIdentifierExtractorFactory;
45+
private final ObjectFactory<UserRolesExtractor> userRolesExtractorFactory;
46+
private final ObjectFactory<AuthenticationService> authenticationServiceFactory;
47+
48+
@Override
49+
public PreAuthenticatedAuthenticationToken convert(ResponseToken source) {
50+
UserIdentifierExtractor userIdentifierExtractor = userIdentifierExtractorFactory.getObject();
51+
UserRolesExtractor userRolesExtractor = userRolesExtractorFactory.getObject();
52+
AuthenticationService loginService = authenticationServiceFactory.getObject();
53+
log.debug("Decoding SAML response: {}", source.getToken());
54+
55+
Saml2Authentication authentication = convertToken(source);
56+
@SuppressWarnings("deprecation")
57+
var user = new User();
58+
Saml2AuthenticatedPrincipal principal =
59+
(Saml2AuthenticatedPrincipal) authentication.getPrincipal();
60+
String principalName = principal.getName();
61+
var userAttributeMapping = properties.getUserAttributeMapping();
62+
String email = principal.getFirstAttribute(userAttributeMapping.getEmail());
63+
user.setEmail(email != null ? email : principalName);
64+
String userid = userIdentifierExtractor.fromPrincipal(principal);
65+
user.setUsername(userid);
66+
user.setFirstName(principal.getFirstAttribute(userAttributeMapping.getFirstName()));
67+
user.setLastName(principal.getFirstAttribute(userAttributeMapping.getLastName()));
68+
69+
Set<String> roles = userRolesExtractor.getRoles(principal);
70+
user.setRoles(roles);
71+
72+
if (!CollectionUtils.isEmpty(properties.getRequiredRoles())) {
73+
var requiredRoles = Set.copyOf(properties.getRequiredRoles());
74+
// check for at least one common role in both sets
75+
if (Collections.disjoint(roles, requiredRoles)) {
76+
throw new BadCredentialsException(
77+
String.format("User %s is not in any required role from %s", email, requiredRoles));
78+
}
79+
}
80+
81+
Collection<? extends GrantedAuthority> authorities = loginService.loginWithRoles(userid, roles);
82+
return new PreAuthenticatedAuthenticationToken(user, principal, authorities);
83+
}
84+
85+
private static final Converter<ResponseToken, Saml2Authentication> DEFAULT_CONVERTER =
86+
OpenSaml4AuthenticationProvider.createDefaultResponseAuthenticationConverter();
87+
88+
private static Saml2Authentication convertToken(ResponseToken token) {
89+
Saml2Authentication authentication = DEFAULT_CONVERTER.convert(token);
90+
if (authentication == null) {
91+
throw new IllegalArgumentException("Response token could not be converted");
92+
}
93+
return authentication;
94+
}
95+
}

0 commit comments

Comments
 (0)