spring-projects / spring-security

Spring Security
http://spring.io/projects/spring-security
Apache License 2.0
8.81k stars 5.9k forks source link

SAML2: Support federation with multiple IdPs #10551

Closed OrangeDog closed 2 years ago

OrangeDog commented 2 years ago

A key feature of spring-security-saml2-core was the ability to easily configure multiple metadata providers to configure multiple IdPs from multiple sources and access their details to provide a custom discovery page.

In particular, you can configure a URI for an <EntitiesDescriptor> document, and all the <EntityDescriptor>s within would be configured including any shared keys information. For example, http://metadata.ukfederation.org.uk/ukfederation-metadata.xml describes all the participants in the UK Access Management Federation.

I can't see any way to do this with the current Spring 5 support. RelyingPartyRegistrations.fromMetadataLocation only results in a single RelyingPartyRegistration, and the full entity descriptor does not appear to be available from that - e.g. the <EntityAttributes> and <Extensions>.

It's not even clear that the model of a 1-to-1 binding of relying party (SP) to asserting party (IdP) going to work if there are multiple RelyingPartyRegistrations with the same entityId. I've no idea how the login filter is going to work, for example.

As with the OAuth2 support, I find myself disappointed that the excellent previous library is being EoL'd without sufficient replacement.

jzheaux commented 2 years ago

@OrangeDog, thanks for the feedback and the suggestions.

It seems like there are multiple asks in here. For this ticket, I'm going to focus on your comments about metadata.

In particular, you can configure a URI for an document, and all the s within would be configured including any shared keys information

By this, I think you are meaning that you'd like to see a component that can read multiple entity definitions from a single metadata endpoint and return multiple RelyingPartyRegistrations. Do I have that right? I think that's a feature worth looking into.

I'm having trouble with the URL you posted since it doesn't appear to work as an HTTPS link. Can you provide an HTTPS URL?

the full entity descriptor does not appear to be available from that

RelyingPartyRegistration is not intended to expose everything in an entity descriptor, only the things that Spring Security needs in order to operate its feature set. Perhaps we should find a way for the resulting RelyingPartyRegistration to contain the original OpenSAML entity descriptor instance so that other attributes can be retrieved.

Either way, this is feeling like a separate ticket. Will you please file a separate ticket for it or correct me if I've misunderstood?

It's not even clear that the model of a 1-to-1 binding of relying party (SP) to asserting party (IdP) going to work

For this comment, I'd recommend filing a ticket in Spring Security Samples. I'd be happy to iterate with you on a sample over there to better see where the gaps are.

OrangeDog commented 2 years ago

Do I have that right?

Correct.

Can you provide an HTTPS URL?

No, they do not provide one. Thus it's also vital that keys can be configured for signature verification.

contain the original OpenSAML entity descriptor instance

Yes please. That's what spring-security-saml does.

a separate ticket

This ticket was intended more as a (very common in my industry) concrete user story. I was going to let the team create whatever specific changes/tasks would be required to achieve it.

OrangeDog commented 2 years ago

I have thought of some other things that spring-security-saml does that block migration to the new version for people in SAML federations.

jzheaux commented 2 years ago

Great. I've added https://github.com/spring-projects/spring-security/issues/10782 and https://github.com/spring-projects/spring-security/issues/10781 related to metadata.

Provide the full SAMLCredential when constructing UserDetails instances

Agreed. I've also added https://github.com/spring-projects/spring-security/issues/10780 accordingly.

OrangeDog commented 2 years ago

Some more concrete examples of the basic requirements, using spring-security-oauth.

Loading multiple IdPs from multiple sources, each providing one or more. For example I have in my application properties:

saml:
  entity-id: https://sp.example.org/saml/metadata
  keystore-path: classpath:/saml/keystore.jks
  idp:
    - http://metadata.ukfederation.org.uk/ukfederation-metadata.xml
    - https://idp.ssocircle.com
    - https://sso.example.com/saml/metadata.sml
    - classpath://saml/internal.xml

And the main beans that load it:

@Bean
public KeyManager samlKeyManager(ResourceLoader loader) {
    return new JKSKeyManager(
            loader.getResource(properties.getKeystorePath()),
            properties.getKeystorePassword(),
            Collections.singletonMap(properties.getKeyAlias(), properties.getKeyPassword()),
            properties.getKeyAlias()
    );
}

@Bean
public MetadataManager metadataManager() throws MetadataProviderException, IOException {
    List<MetadataProvider> providers = new ArrayList<>(properties.getIdp().size());
    for (URI uri : properties.getIdp()) {
        AbstractReloadingMetadataProvider provider;
        switch (uri.getScheme()) {
            case "file":
                provider = new FilesystemMetadataProvider(backgroundTaskTimer, new File(uri));
                break;
            case "jar":
                // UrlConnectionResource is my own class, the implementation is trivial
                Resource resource = new UrlConnectionResource(uri.toURL().openConnection());
                provider = new ResourceBackedMetadataProvider(backgroundTaskTimer, resource);
                break;
            default:
                provider = new HTTPMetadataProvider(backgroundTaskTimer, httpClient(), uri.toString());
                break;
        }
        provider.setMaxRefreshDelay(Duration.ofDays(1).toMillis());
        provider.setMetadataFilter(idpOnlyFilter());
        provider.setParserPool(samlParserPool());

        ExtendedMetadataDelegate delegate = new ExtendedMetadataDelegate(provider);
        delegate.setMetadataRequireSignature("http".equals(uri.getScheme()));
        delegate.setMetadataTrustCheck(true);

        providers.add(delegate);
    }
    CachingMetadataManager manager = new CachingMetadataManager(providers);
    manager.setRefreshCheckInterval(properties.getMetadataRefreshMillis());
    manager.setRequireValidMetadata(true);
    return manager;
}

Mapping SAMLCredential to UserDetails. Different IdPs return different user attributes. Sometimes all people need is the opaque globally unique identifier, which is then associated to a new user during registration. Or they don't care about the user identity at all, only their entitlements (e.g. eduPersonEntitlement which they can map to granted authorities). However, I need to know their identity as understood by their home domain:

@Override
public UserDetails loadUserBySAML(SAMLCredential samlCredential) throws UsernameNotFoundException {
    String username;
    switch (samlCredential.getNameID().getFormat()) {
        case NameIDType.EMAIL:
        case NameIDType.KERBEROS:
            username = samlCredential.getNameID().getValue().toLowerCase();
            break;
        case NameIDType.WIN_DOMAIN_QUALIFIED:
            String[] parts = samlCredential.getNameID().getValue().toLowerCase().split("\\\\", 2);
            username = parts[1] + "@" + parts[0];
            break;
        default:
            username = Stream.of(EduPerson.PRINCIPAL_NAME, SchemaConstants.UID_AT_OID, "UserID")
                    .map(samlCredential::getAttributeAsString)
                    .filter(Objects::nonNull)
                    .findFirst()
                    .orElseThrow(() -> {
                        if (log.isInfoEnabled()) {
                            log.info("Available attributes: {}", samlCredential.getAttributes().stream()
                                    .map(Attribute::getFriendlyName)
                                    .collect(Collectors.toList()));
                        }
                        return new UsernameNotFoundException("No supported attribute in SAML response");
                    });
    }

    if (!username.contains("@")) {
        String qualifier = samlCredential.getNameID().getNameQualifier();
        if (qualifier == null || qualifier.length() == 0) {
            qualifier = samlCredential.getRemoteEntityID();
        }
        username = username + "@" + URI.create(qualifier).getHost();
    }

    Optional<? extends UserDetails> user = userRepository.findByUsername(username);
    if (user.isPresent()) {
        return user.get();
    } else {
        return new User(username, samlCredential.getAuthenticationAssertion().getID(), Collections.emptySet());
    }
}

Selecting which IdP to use during login. Many people would want to use an external discovery service, e.g. https://wayf.ukfederation.org.uk/DS. However, as I'm in more than a single federation, I have to do it myself:

@GetMapping(value = "/discovery")
public String idpSelection(
        HttpServletRequest request, Authentication auth, Model model, Locale locale,
        @RequestHeader("Accept-Language") Optional<String> acceptLanguage
) {
    if (auth == null || auth instanceof AnonymousAuthenticationToken) {
        List<Locale.LanguageRange> langPriority = Locale.LanguageRange.parse(acceptLanguage.orElse("en"));
        model.addAttribute("idps", metadata.getIDPEntityNames().stream()
                .filter(this::isDiscoverable)
                .map(entityId -> new Provider(entityId, getUIInfo(entityId), langPriority))
                .sorted(comparing(Provider::getDisplayName, nullsFirst(Collator.getInstance(locale))))
                .collect(Collectors.toList()));
        model.addAttribute("guess", guessProvider(request));
        return "discovery";
    } else {
        log.warn("The current user is already logged in");
        return "redirect:/";
    }
}

private boolean isDiscoverable(String entityId) {
    try {
        Extensions extensions = metadata.getEntityDescriptor(entityId).getExtensions();
        List<XMLObject> objects = extensions.getUnknownXMLObjects(EntityAttributes.DEFAULT_ELEMENT_NAME);
        return objects.stream()
                .map(EntityAttributes.class::cast)
                .flatMap(entityAttributes -> entityAttributes.getAttributes().stream())
                .flatMap(attribute -> attribute.getAttributeValues().stream())
                .filter(XSAny.class::isInstance)
                .map(value -> ((XSAny) value).getTextContent())
                .noneMatch(HIDE_FROM_DISCOVERY::equals);
    } catch (MetadataProviderException | NullPointerException | IndexOutOfBoundsException ex) {
        return true;
    }
}

@Value
@AllArgsConstructor
static class Provider {
    private String entityId;
    private String displayName;
    private String logoUrl;

    Provider(String entityId, UIInfo uiInfo, List<Locale.LanguageRange> langPriority) {
        this.entityId = entityId;
        if (uiInfo == null) {
            displayName = logoUrl = null;
            return;
        }

        Locale bestMatch = Locale.lookup(langPriority, uiInfo.getDisplayNames().stream()
                .map(name -> new Locale(name.getName().getLanguage()))
                .collect(Collectors.toList()));

        displayName = uiInfo.getDisplayNames().stream()
                .map(LocalizedName::getName)
                .filter(name -> bestMatch == null || name.getLanguage().equals(bestMatch.getLanguage()))
                .map(LocalizedString::getLocalString)
                .findAny()
                .orElse(null);
        logoUrl = uiInfo.getLogos().stream()
                .min(comparing(Logo::getHeight))
                .map(Logo::getURL)
                .orElse(null);
    }
}

Single Log Out depending on user's IdP. This feature I could live without, but is good security practice. If this is true then my page footers have an additional logout button. Any incoming SLO requests are handled by spring-security-saml's default filter. One of our partners may also decide we have to support it during an audit.

public boolean hasSLO() {
    try {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        SAMLCredential credential = (SAMLCredential) auth.getCredentials();
        EntityDescriptor descriptor = metadata.getEntityDescriptor(credential.getRemoteEntityID());
        return descriptor.getIDPSSODescriptor(SAMLConstants.SAML20P_NS).getSingleLogoutServices().size() > 0;
    } catch (ClassCastException | NullPointerException ex) {
        return false;
    } catch (MetadataProviderException ex) {
        log.warn("Failed to load metadata", ex);
        return false;
    }
}
jzheaux commented 2 years ago

@OrangeDog, I appreciate the effort you are putting in here to communicate features you'd like to see added.

Including the ideas you just posted, if you have more ideas, will you please file those into separate tickets? Each of these points will inevitably spark separate discussions, and I'd like those to be easy for the community to track and also to volunteer for.

Since it appears at this point that this ticket will otherwise become an informal location to post ideas, I'm going to close it.

jonellrz commented 7 months ago

I don't know if it can be done with Spring on a web site that has its users, external users need to log in with their own Active Directory users.

I would like to create a SAML SSO with Java Spring Multi-Client.

then, from our website you can enter in:

What we have achieved is to make an SSO with a single server, but not with several servers, and that they change depending on the URL

This is possible with Spring SAML.

Practical example:

There is a music website, and CocaCola workers want to enter and Pepsi workers want to enter, so both CocaCola and Pepsi have their own AD (Active directory) with the users.

then the music website has this URL: webmusic.com/sso/cocacola/login o webmusic.com/sso/pepsi/login (u or more urls, fanta, redbull)

and these routes redirect to the relevant AD, then when the AD gives the OK, it is created and redirected to the Musica web with the new token created from the data delivered by the AD.

OrangeDog commented 7 months ago

A more realistic example would be oed.com and its Sign in through your institution option. It used the ukfederation metadata I previously linked to allow any participating IdP to be used.

It was very easy to build a site like that with speing-security-saml2-core.

jonellrz commented 7 months ago

A more realistic example would be oed.com and its Sign in through your institution option. It used the ukfederation metadata I previously linked to allow any participating IdP to be used.

It was very easy to build a site like that with speing-security-saml2-core.

Hello, if I got something like that, too, but I would like to do it with the url and not by selecting from a list, what happens is that I don't see a way to do it 😢, I'm thinking that this is not possible to do this with Spring SAML, with OAuth2 I think it can, but nothing in SAML.

I must also say that I am new to Spring.