OpenLiberty / open-liberty

Open Liberty is a highly composable, fast to start, dynamic application server runtime environment
https://openliberty.io
Eclipse Public License 2.0
1.15k stars 590 forks source link

How to extract SAML attributes(groups) and set it to http session or jwt token #26030

Open uniquejava opened 1 year ago

uniquejava commented 1 year ago

Hello.

We are moving from Spring Boot to OpenLiberty. Spring Security has SAML support and after studying OpenLiberty documents, especially the SAML Web SSO part, I defined the following configuration file.

    <!-- https://www.ibm.com/docs/en/was-liberty/base?topic=liberty-configuring-saml-web-browser-sso-in -->
    <samlWebSso20 enabled="true" id="defaultSP" disableLtpaCookie="true" nameIDFormat="email"
                  inboundPropagation="none"
                  mapToUserRegistry="No"
                  groupIdentifier="groups"
                  spCookieName="mySpCookie"
                  wantAssertionsSigned="false" httpsRequired="false" spLogout="true"
    />

    <webApplication contextRoot="app" id="my-webapp" location="my-webapp.war" name="my-webapp">
        <application-bnd>
            <security-role name="AllAuthenticated">
                <special-subject type="ALL_AUTHENTICATED_USERS" />
            </security-role>
        </application-bnd>
    </webApplication>

Now when I access the Backend API - a JAX-RS project , browser redirects me to IDP login page, and I was able to get SAML Response after enter user credentials.

The SAML response likes the following, (As some test, I tried Okta and Keycloak as IDP provider, they are similar)

 <saml:Assertion xmlns="urn:oasis:names:tc:SAML:2.0:assertion" ID="ID_8ee82bb9-cdd5-419f-a292-1ab2a2cbde32" IssueInstant="2023-08-23T04:06:41.755Z" Version="2.0">
  <saml:Issuer>http://localhost:8080/realms/awag</saml:Issuer>
  <saml:Subject>
   <saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">adminx@example.com</saml:NameID>
   <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
    <saml:SubjectConfirmationData InResponseTo="_cP1dUJXr2f1XXjQmrYphywrb5ulVkLQ0" NotOnOrAfter="2023-08-23T04:11:39.755Z" Recipient="https://localhost:9443/ibm/saml20/defaultSP/acs"></saml:SubjectConfirmationData>
   </saml:SubjectConfirmation>
  </saml:Subject>
  <saml:Conditions NotBefore="2023-08-23T04:06:39.755Z" NotOnOrAfter="2023-08-23T04:07:39.755Z">
   <saml:AudienceRestriction>
    <saml:Audience>https://localhost:9443/ibm/saml20/defaultSP</saml:Audience>
   </saml:AudienceRestriction>
  </saml:Conditions>
  <saml:AuthnStatement AuthnInstant="2023-08-23T04:06:41.756Z" SessionIndex="03062b57-1a93-4883-ae0d-929f23424605::1d4007b4-548a-4271-85fb-049217d885e0" SessionNotOnOrAfter="2023-08-23T14:06:41.756Z">
   <saml:AuthnContext>
    <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml:AuthnContextClassRef>
   </saml:AuthnContext>
  </saml:AuthnStatement>
  <saml:AttributeStatement>
   <saml:Attribute Name="first" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
    <saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">Admin</saml:AttributeValue>
   </saml:Attribute>
   <saml:Attribute Name="age" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
    <saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">22</saml:AttributeValue>
   </saml:Attribute>
   <saml:Attribute Name="last" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
    <saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">X</saml:AttributeValue>
   </saml:Attribute>
   <saml:Attribute Name="email" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
    <saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">adminx@example.com</saml:AttributeValue>
   </saml:Attribute>
   <saml:Attribute Name="title" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
    <saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">Application Developer</saml:AttributeValue>
   </saml:Attribute>
   <saml:Attribute Name="groups" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
    <saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">admin</saml:AttributeValue>
   </saml:Attribute>
  </saml:AttributeStatement>
 </saml:Assertion>

My question is there any SAML ACS Url callback for us to Post Process SAML Token?

And also How can I get Custom User Attributes especially User Groups from SAML Token and put it into http session for wrap them into a JWT token?

I tried to inject SecurityContext into a Resource class like this.

For now, I can only get user's principal (email) in my case. I want to get other attributes like "first, last name, age, title, groups" etc.

@Path("/some-path")
public class SomeResource {
  @Context
  private SecurityContext securityContext;

 public void someMethod(){
    // prints out email
    System.out.println(securityContext.getUserPrincipal().getName());

    // false?
    System.out.println("context:" + securityContext.isUserInRole("admin"));
  }
}

Later, I found this class com.ibm.wsspi.security.saml2.UserCredentialResolver, the description looks quite promising to me.

I don't know where to find this class, I search maven repository and can't find any related maven dependency.

Would you please kindly shed me some light how to continue?

uniquejava commented 1 year ago

For spring boot, this is the dependency I added in pom.xml.

<!-- https://mvnrepository.com/artifact/org.springframework.security/spring-security-saml2-service-provider -->
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-saml2-service-provider</artifactId>
</dependency>

And then I was able to extract saml attributes by configuring a SecurityBean.

The assertion.getAttributeStatements().stream() part.

@EnableWebSecurity
public class BootSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    RelyingPartyRegistrationRepository relyingPartyRegistrationRepository;

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        // Create a filter to generate a SAML Metadata file for the Application
        Saml2MetadataFilter filter = new Saml2MetadataFilter(
                (RelyingPartyRegistrationResolver)new DefaultRelyingPartyRegistrationResolver(this.relyingPartyRegistrationRepository),
                new OpenSamlMetadataResolver());

        // No Transformation of Authorities done
        GrantedAuthoritiesMapper authoritiesMapper = (authCol -> authCol);

        // Actual code to extract the Authorities (or roles) from the Assertion
        Converter<Assertion, Collection<? extends GrantedAuthority>> authoritiesExtractor =  assertion -> {

            List<SimpleGrantedAuthority> userRoles 
                = assertion.getAttributeStatements().stream()
                        .map(AttributeStatement::getAttributes)
                        .flatMap(Collection::stream)
                        .filter(attr -> "groups".equalsIgnoreCase(attr.getName()))
                        .map(Attribute::getAttributeValues)
                        .flatMap(Collection::stream)
                        .map(xml -> new SimpleGrantedAuthority("ROLE_" + xml.getDOM().getTextContent()))
                        .toList();
            return userRoles;
        };

        http
            .saml2Login()
                .addObjectPostProcessor(new ObjectPostProcessor<OpenSamlAuthenticationProvider>() {
                    public <P extends OpenSamlAuthenticationProvider> P postProcess(
                            P samlAuthProvider) {

                        // Set the Authorities extractor 
                        samlAuthProvider.setAuthoritiesExtractor(authoritiesExtractor);
                        samlAuthProvider.setAuthoritiesMapper(authoritiesMapper);
                        return samlAuthProvider;
                    }
                });

        http
            .saml2Logout(withDefaults())
            .addFilterBefore(filter, Saml2WebSsoAuthenticationFilter.class)
            .authorizeRequests()
                .mvcMatchers("/", "/api").hasAnyRole("user","admin")
                .mvcMatchers("/edit/**").hasAnyRole("admin")
                .mvcMatchers("/css/**").permitAll()
                .anyRequest().denyAll();

    }

}

And then, use the Authentication object anywhere in Rest Controller to get user attributes/groups.

uniquejava commented 1 year ago

My intention is, from within in the PostProcessor or SAML ACS Url Callback class, if only I could optionally create a new user in our own app_users table if IDP authenticated user(email) not exist in our application.

I then can perform some permission control based on SQL joints like the following.

select * from business_table t left join app_users u on u.userid = t.userid and u.role='admin'

Or I can craft a JWT token in the callback class and send it to angular frontend. I am not sure how we could gracefully handle all this in the OpenLiberty world.

uniquejava commented 1 year ago

I managed to get the necessary dependencies later today. I am using wlp-webProfile8-22.0.0.13, not sure the following are using the correct version(I think it should, I searched inside the OL lib directory got the versions there), but if there were some BOM file defined for us(like what spring boot does), that would be easier.

<dependency>
            <groupId>com.ibm.websphere.appserver.api</groupId>
            <artifactId>com.ibm.websphere.appserver.api.security</artifactId>
            <version>1.3.72</version>
        </dependency>

        <dependency>
            <groupId>com.ibm.websphere.appserver.api</groupId>
            <artifactId>com.ibm.websphere.appserver.api.saml20</artifactId>
            <version>1.1.72</version>
        </dependency>

        <dependency>
            <groupId>com.ibm.websphere.appserver.spi</groupId>
            <artifactId>com.ibm.websphere.appserver.spi.saml20</artifactId>
            <version>1.0.72</version>
        </dependency>

        <dependency>
            <groupId>com.ibm.websphere.appserver.api</groupId>
            <artifactId>com.ibm.websphere.appserver.api.basics</artifactId>
            <version>1.4.72</version>
        </dependency>

Now I am able to extract user groups in side a Resource Class like the following:

@RequestScoped
@Path("/some")
public class SomeResource {
    @Context
    private SecurityContext securityContext;

    @GET
    public Response execute() throws Exception {
        System.out.println(securityContext.isUserInRole("admin"));
        System.out.println(securityContext.getUserPrincipal());

        // https://stackoverflow.com/a/34891405/2497876
        Subject subject = WSSubject.getRunAsSubject();
        // logger.info(subject.toString());

        Iterator authIterator = subject.getPrivateCredentials(Saml20Token.class).iterator();
        if (authIterator.hasNext()) {
            Saml20Token samlToken = (Saml20Token) authIterator.next();

            // prints out raw assertion xml
            // <?xml version="1.0" encoding="UTF-8"?><saml:Assertion ...
            // System.out.println(samlToken.getSAMLAsString());

            System.out.println("getSAMLNameID => " + samlToken.getSAMLNameID());

            // hooray!  prints out  [admin, login-user]
            List<String> groups = samlToken.getSAMLAttributes().stream()
                    .filter(attr -> "groups".equalsIgnoreCase(attr.getName())).map(attr -> attr.getValuesAsString())
                    .flatMap(Collection::stream)
                    .collect(Collectors.toList());

            System.out.println(groups);
        }
    ....

It prints out:

[INFO] false
[INFO] WSPrincipal:adminx@example.com
[INFO] getSAMLNameID => adminx@example.com
[INFO] [login-user, admin]

I am still wondering if there is a way for us to construct some Authentication object after SSO authentication is performed.

I wrote the following UserCredentialResolver class, it seems that it's not loaded and doesn't work.

So where can I activate this class? I can't find any document. :(

package some.package;

import com.ibm.wsspi.security.saml2.UserCredentialResolver;
import com.ibm.wsspi.security.saml2.UserIdentityException;

import java.util.Collection;
import java.util.List;
import java.util.logging.Logger;
import java.util.stream.Collectors;

public class MyUserCredentialResolver implements UserCredentialResolver {
    static Logger logger = Logger.getLogger(MyUserCredentialResolver.class.getName());
    public String mapSAMLAssertionToUser(com.ibm.websphere.security.saml2.Saml20Token saml20Token) throws UserIdentityException {
        logger.info("mapSAMLAssertionToUser");
        return saml20Token.getSAMLNameID();
    }

    public List<String> mapSAMLAssertionToGroups(com.ibm.websphere.security.saml2.Saml20Token saml20Token) throws UserIdentityException {
        logger.info("mapSAMLAssertionToGroups");

        List<String> groups = saml20Token.getSAMLAttributes().stream()
                .filter(attr -> "groups".equalsIgnoreCase(attr.getName())).map(attr->attr.getValuesAsString())
                .flatMap(Collection::stream)
                .collect(Collectors.toList());
        return groups;
    }

    public String mapSAMLAssertionToUserUniqueID(com.ibm.websphere.security.saml2.Saml20Token saml20Token) throws UserIdentityException {
        logger.info("mapSAMLAssertionToUserUniqueID");
        return this.mapSAMLAssertionToUser(saml20Token);
    }

    public String mapSAMLAssertionToRealm(com.ibm.websphere.security.saml2.Saml20Token saml20Token) throws UserIdentityException {
        return null;
    }
}

I am expecting some SSOAuthenticationSuccessEvent and/or SSOAuthenticationFailureEvent, then I can listen to it to do some auditing, for example, we should always insert a login record in some Login_History table and this kind of Event or Callback is much needed.

uniquejava commented 1 year ago

To conclude, my concern/questions are:

  1. How to map groups attribute from Saml Assertion to Liberty roles without using User Registry, so that I can use @RolesAllowed("admin") annotation to do authorization.
  2. How can we listen to SSOAuthenticationSuccess/FailureEvent to do auditing and/or optionally create new user in SP/Application's App_Users table
  3. How to activate UserCredentialResolver in Liberty's server.xml if this is a solution for Question number one.
  4. I somehow tried TAI(TrustAssociation) to contruct some TAIResult object, but even I set invokeAfterSSO="true" it's not being called.

I am new to OpenLiberty, any suggestion is much appreciated.