Skip to content
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 12 commits into from May 8, 2024
@@ -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();
}
}
23 changes: 15 additions & 8 deletions gate-saml/gate-saml.gradle
@@ -1,13 +1,20 @@
dependencies{
dependencies {
constraints {
implementation 'org.opensaml:opensaml-core:4.1.0'
Copy link
Contributor Author

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).

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'
}
@@ -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();
}
}
@@ -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);
}
}
}
@@ -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;
}
}