Skip to content

Commit

Permalink
feat(saml): Update SAML to use Spring Security (#1744)
Browse files Browse the repository at this point in the history
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
jvz and jasonmcintosh committed May 8, 2024
1 parent 4809af3 commit 0c5e4bc
Show file tree
Hide file tree
Showing 14 changed files with 583 additions and 488 deletions.
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();
}
}
23 changes: 15 additions & 8 deletions gate-saml/gate-saml.gradle
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'
}
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();
}
}
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);
}
}
}
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;
}
}

0 comments on commit 0c5e4bc

Please sign in to comment.