Added custom InMemory SAML2 Authentication Request Repository
Added attributes mapping in application*.yml config Added attributes default values in application*.yml config Corrected MOCK-IDP to return InResponseTo and complex attribute names
This commit is contained in:
parent
f548a0b31e
commit
eb3a621b17
7 changed files with 458 additions and 75 deletions
|
@ -18,8 +18,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
|||
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer.FrameOptionsConfig;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.saml2.provider.service.authentication.AbstractSaml2AuthenticationRequest;
|
||||
import org.springframework.security.saml2.provider.service.metadata.OpenSamlMetadataResolver;
|
||||
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
|
||||
import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestRepository;
|
||||
import org.springframework.security.saml2.provider.service.web.Saml2MetadataFilter;
|
||||
import org.springframework.security.saml2.provider.service.web.authentication.Saml2WebSsoAuthenticationFilter;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
@ -38,6 +40,7 @@ import org.springframework.security.web.util.matcher.OrRequestMatcher;
|
|||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.servlet.handler.HandlerMappingIntrospector;
|
||||
|
||||
import com.oguerreiro.resilient.repository.security.ResilientSaml2AuthenticationRequestRepository;
|
||||
import com.oguerreiro.resilient.security.AuthoritiesConstants;
|
||||
import com.oguerreiro.resilient.security.saml2.Saml2AuthenticationHandler;
|
||||
import com.oguerreiro.resilient.security.saml2.Saml2ResponseLoggingFilter;
|
||||
|
@ -70,6 +73,11 @@ public class SecurityConfiguration {
|
|||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Saml2AuthenticationRequestRepository<AbstractSaml2AuthenticationRequest> authenticationRequestRepository() {
|
||||
return new ResilientSaml2AuthenticationRequestRepository();
|
||||
}
|
||||
|
||||
/**
|
||||
* For future knowledge in SAMLv2 config, its necessary to :
|
||||
* <ul>
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
package com.oguerreiro.resilient.repository.security;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.security.saml2.provider.service.authentication.AbstractSaml2AuthenticationRequest;
|
||||
import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestRepository;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* This replaces the standard Saml2 in-memory/session request cache. This is actually almost the same, storing the SAML2
|
||||
* requests in a HashMap. I was experiencing a problem that SAML2 Request were being lost between SAML request and
|
||||
* response (probably because of a Angular error that tries user+password login between calls). Nevertheless, the SAML
|
||||
* should work, but didn't.
|
||||
* </p>
|
||||
* <p>
|
||||
* THEN, I tried to implement SAML request persistence in database... WHAT A MESS. SpringSecurity 6.3+ SAML2 really
|
||||
* doesn't allow this. The needed AbstractSaml2AuthenticationRequest instance CAN'T be (re)created nor it can be
|
||||
* serialized. In the end, I'm unable to implement the repository loadAuthenticationRequest() method and return a valid
|
||||
* AbstractSaml2AuthenticationRequest.
|
||||
* Quick more stable solution, a custom in-memory/session request cache.
|
||||
* </p>
|
||||
* <p>
|
||||
* But this has limits. If I need a multi-instance server (load-balancing) this will fail. The proposed solution is a
|
||||
* distributed cache. It will work almost like this, but instead of a in-memory cache per instance, it will be
|
||||
* centralized. When needed, investigate : "centralized distributed cache (Redis, Hazelcast, etc.)"
|
||||
* </p>
|
||||
* <p>
|
||||
* NOTE: This is NOT a bean it self (@Component) because I need to register this in a special way for the SpringSecurity
|
||||
* SAML2 auto-binds. In SecurityConfiguration a bean is declared this way:
|
||||
*
|
||||
* <pre>
|
||||
* <code>
|
||||
@Bean
|
||||
Saml2AuthenticationRequestRepository<AbstractSaml2AuthenticationRequest> authenticationRequestRepository() {
|
||||
return new ResilientSaml2AuthenticationRequestRepository();
|
||||
}
|
||||
* </code>
|
||||
* </pre>
|
||||
* </p>
|
||||
*/
|
||||
//@Component - KEEP commented
|
||||
public class ResilientSaml2AuthenticationRequestRepository
|
||||
implements Saml2AuthenticationRequestRepository<AbstractSaml2AuthenticationRequest> {
|
||||
|
||||
// TODO: Add the creation timestamp, to improve security. Like a 5 second time resolver. Between initiation (request) and authentication (response)
|
||||
private Map<String, AbstractSaml2AuthenticationRequest> inMemory = new HashMap<String, AbstractSaml2AuthenticationRequest>();
|
||||
|
||||
public ResilientSaml2AuthenticationRequestRepository() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public AbstractSaml2AuthenticationRequest loadAuthenticationRequest(HttpServletRequest request) {
|
||||
String relayState = request.getParameter("RelayState");
|
||||
if (relayState != null) {
|
||||
AbstractSaml2AuthenticationRequest authRequest = inMemory.get(relayState);
|
||||
|
||||
return authRequest;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveAuthenticationRequest(AbstractSaml2AuthenticationRequest authenticationRequest,
|
||||
HttpServletRequest request, HttpServletResponse response) {
|
||||
String relayState = authenticationRequest.getRelayState();
|
||||
if (relayState != null) {
|
||||
inMemory.put(relayState, authenticationRequest);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public AbstractSaml2AuthenticationRequest removeAuthenticationRequest(HttpServletRequest request,
|
||||
HttpServletResponse response) {
|
||||
String relayState = request.getParameter("RelayState");
|
||||
if (relayState != null) {
|
||||
inMemory.remove(relayState);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
|
@ -58,12 +58,34 @@ public class ResilientSaml2Properties {
|
|||
private boolean wantAuthnSigned;
|
||||
private boolean wantAssertionSigned;
|
||||
|
||||
// Attributes
|
||||
private Map<String, String> attributes;
|
||||
|
||||
// Attributes defaults
|
||||
private Map<String, String> defaults;
|
||||
|
||||
// Getters and Setters
|
||||
|
||||
public Map<String, String> getDefaults() {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
public void setDefaults(Map<String, String> defaults) {
|
||||
this.defaults = defaults;
|
||||
}
|
||||
|
||||
public String getEntityId() {
|
||||
return entityId;
|
||||
}
|
||||
|
||||
public Map<String, String> getAttributes() {
|
||||
return attributes;
|
||||
}
|
||||
|
||||
public void setAttributes(Map<String, String> attributes) {
|
||||
this.attributes = attributes;
|
||||
}
|
||||
|
||||
public void setEntityId(String entityId) {
|
||||
this.entityId = entityId;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
package com.oguerreiro.resilient.security.saml2;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class Saml2AuthenticationAttributes {
|
||||
public static final String ATTR_NAME = "name";
|
||||
public static final String ATTR_USERNAME = "username";
|
||||
public static final String ATTR_EMAIL = "email";
|
||||
public static final String ATTR_ORGANIZATION_CODE = "organization-code";
|
||||
public static final String ATTR_SECURITY_GROUP_CODE = "security-group-code";
|
||||
public static final String ATTR_ROLE = "role";
|
||||
|
||||
private String name;
|
||||
private String username;
|
||||
private String email;
|
||||
private String organizationCode;
|
||||
private String securityGroupCode;
|
||||
private String role;
|
||||
private Map<String, String> additional = new HashMap<String, String>();
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getOrganizationCode() {
|
||||
return organizationCode;
|
||||
}
|
||||
|
||||
public void setOrganizationCode(String organizationCode) {
|
||||
this.organizationCode = organizationCode;
|
||||
}
|
||||
|
||||
public String getSecurityGroupCode() {
|
||||
return securityGroupCode;
|
||||
}
|
||||
|
||||
public void setSecurityGroupCode(String securityGroupCode) {
|
||||
this.securityGroupCode = securityGroupCode;
|
||||
}
|
||||
|
||||
public String getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
public void setRole(String role) {
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
public void addAttribute(String key, String value, String defaultValue) {
|
||||
// Apply default value, if needed an present
|
||||
if ((value == null || value.isBlank()) && (defaultValue != null && !defaultValue.isBlank())) {
|
||||
value = defaultValue;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case ATTR_NAME: {
|
||||
this.setName(value);
|
||||
break;
|
||||
}
|
||||
case ATTR_USERNAME: {
|
||||
this.setUsername(value);
|
||||
break;
|
||||
}
|
||||
case ATTR_EMAIL: {
|
||||
this.setEmail(value);
|
||||
break;
|
||||
}
|
||||
case ATTR_ORGANIZATION_CODE: {
|
||||
this.setOrganizationCode(value);
|
||||
break;
|
||||
}
|
||||
case ATTR_SECURITY_GROUP_CODE: {
|
||||
this.setSecurityGroupCode(value);
|
||||
break;
|
||||
}
|
||||
case ATTR_ROLE: {
|
||||
this.setRole(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Always add. Its a sugar code when using getAttribute(String key)
|
||||
this.additional.put(key, value);
|
||||
}
|
||||
|
||||
public String getAttribute(String key) {
|
||||
return this.additional.get(key);
|
||||
}
|
||||
}
|
|
@ -4,8 +4,12 @@ import java.io.IOException;
|
|||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
@ -20,10 +24,13 @@ import org.springframework.security.web.authentication.AuthenticationSuccessHand
|
|||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.oguerreiro.resilient.domain.Organization;
|
||||
import com.oguerreiro.resilient.domain.User;
|
||||
import com.oguerreiro.resilient.repository.OrganizationRepository;
|
||||
import com.oguerreiro.resilient.repository.UserRepository;
|
||||
import com.oguerreiro.resilient.repository.security.SecurityGroupRepository;
|
||||
import com.oguerreiro.resilient.security.custom.ResilientUserDetails;
|
||||
import com.oguerreiro.resilient.security.resillient.SecurityGroup;
|
||||
import com.oguerreiro.resilient.security.saml2.ResilientSaml2Properties.Registration;
|
||||
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
@ -31,11 +38,7 @@ import jakarta.servlet.http.HttpServletResponse;
|
|||
|
||||
@Component
|
||||
public class Saml2AuthenticationHandler implements AuthenticationSuccessHandler, AuthenticationFailureHandler {
|
||||
|
||||
private static final String ATTR_SECURITYGROUP = "security_group";
|
||||
private static final String ATTR_EMAIL = "email";
|
||||
private static final String ATTR_ORGANIZATION = "organization_code";
|
||||
private static final String ATTR_ROLES = "roles";
|
||||
protected final Logger log = LoggerFactory.getLogger(this.getClass());
|
||||
|
||||
@Autowired
|
||||
private Environment environment;
|
||||
|
@ -49,6 +52,9 @@ public class Saml2AuthenticationHandler implements AuthenticationSuccessHandler,
|
|||
@Autowired
|
||||
private ResilientSaml2Properties resilientSaml2Properties;
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
/**
|
||||
* AuthenticationSuccessHandler implementation
|
||||
*/
|
||||
|
@ -58,50 +64,87 @@ public class Saml2AuthenticationHandler implements AuthenticationSuccessHandler,
|
|||
String samlResponse = request.getParameter("SAMLResponse");
|
||||
String samlXMLResponse = Saml2ResponseLoggingFilter.decodeSamlResponse(samlResponse);
|
||||
|
||||
// Read needed information about user security environment
|
||||
Saml2AuthenticatedPrincipal principal = (Saml2AuthenticatedPrincipal) authentication.getPrincipal();
|
||||
String username = principal.getName();
|
||||
String email = getPrincipalAttribute(principal, ATTR_EMAIL);
|
||||
String securityGroupCode = getPrincipalAttribute(principal, ATTR_SECURITYGROUP);
|
||||
String organizationCode = getPrincipalAttribute(principal, ATTR_ORGANIZATION);
|
||||
List<String> roles = principal.getAttribute(ATTR_ROLES);
|
||||
// Declarations
|
||||
Collection<? extends GrantedAuthority> authorities;
|
||||
|
||||
if (roles == null || roles.isEmpty()) {
|
||||
authorities = authentication.getAuthorities();
|
||||
} else {
|
||||
authorities = roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
Organization userOrganization = null;
|
||||
SecurityGroup securityGroup = null;
|
||||
|
||||
// Organization exists?
|
||||
if (organizationCode == null || organizationCode.isBlank()) {
|
||||
// TODO: Make this configurable
|
||||
Long defaultOrg = 1L;
|
||||
userOrganization = organizationRepository.findById(defaultOrg).orElse(null);
|
||||
// read SAML2 attributes
|
||||
Saml2AuthenticatedPrincipal principal = (Saml2AuthenticatedPrincipal) authentication.getPrincipal();
|
||||
Saml2AuthenticationAttributes attr = this.readSaml2Attributes(principal);
|
||||
|
||||
// Search user. If the user (by username) is registered in Resilient, load definitions from Resilient and NOT the ones received from SAML2
|
||||
String username = attr.getUsername();
|
||||
if (username == null | username.isBlank()) {
|
||||
// This is NOT allowed. A username must be provided. If from SAML2, most probably, will be an email
|
||||
log.error("The attribute username is expected for SAML2 authentication, but it was NULL or Empty.");
|
||||
this.invalidateLogin(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
User user = userRepository.findOneByLogin(username).orElse(null);
|
||||
if (user != null) {
|
||||
//@formatter:off
|
||||
// User found. Setup permissions from resilient config
|
||||
authorities = user.getAuthorities().stream()
|
||||
.map(auth -> new SimpleGrantedAuthority(auth.getName()))
|
||||
.collect(Collectors.toList());
|
||||
//@formatter:on
|
||||
securityGroup = user.getSecurityGroup();
|
||||
userOrganization = organizationRepository.findOneByUser(user).orElse(null);
|
||||
if (userOrganization == null) {
|
||||
// This is NOT allowed. Bad user configuration.
|
||||
log.error(
|
||||
"The username is registered in Resilient, but without reference to an Organization. This is a configuration error.");
|
||||
this.invalidateLogin(request, response);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Find or NULL
|
||||
userOrganization = organizationRepository.findOneByCode(organizationCode).orElse(null);
|
||||
}
|
||||
if (userOrganization == null) {
|
||||
this.invalidateLogin(request, response);
|
||||
return;
|
||||
}
|
||||
// User not found? Use SAML2 values or defaults.
|
||||
if (attr.getRole() == null || attr.getRole().isBlank()) {
|
||||
// This is NOT allowed. SAML2 or Defaults must provide a Role.
|
||||
log.error(
|
||||
"A ROLE must be given to a user, but none was calculated. Nor by SAML2 XML, nor defined in defaults.");
|
||||
this.invalidateLogin(request, response);
|
||||
return;
|
||||
}
|
||||
authorities = List.of(attr.getRole()).stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
|
||||
|
||||
// SecurityGroup exists?
|
||||
String defaultSecurityGroupCode = "default"; // TODO: Make this configurable
|
||||
if (securityGroupCode == null || securityGroupCode.isBlank()) {
|
||||
securityGroupCode = defaultSecurityGroupCode;
|
||||
}
|
||||
// Organization not found ?
|
||||
if (attr.getOrganizationCode() == null || attr.getOrganizationCode().isBlank()) {
|
||||
// This is NOT allowed. SAML2 or Defaults must provide a Organization code.
|
||||
log.error(
|
||||
"An OrganizationCode must be given to a user, but none was calculated. Nor by SAML2 XML, nor defined in defaults.");
|
||||
this.invalidateLogin(request, response);
|
||||
return;
|
||||
} else {
|
||||
// Find or NULL
|
||||
userOrganization = organizationRepository.findOneByCode(attr.getOrganizationCode()).orElse(null);
|
||||
}
|
||||
if (userOrganization == null) {
|
||||
log.error("The Organization code [" + attr.getOrganizationCode() + "] wasn't found in database.");
|
||||
this.invalidateLogin(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find or fail
|
||||
securityGroup = securityGroupRepository.findOneByCodeWithEagerRelationships(securityGroupCode).orElse(null);
|
||||
// SecurityGroup exists?
|
||||
if (attr.getSecurityGroupCode() == null || attr.getSecurityGroupCode().isBlank()) {
|
||||
// This is NOT allowed. SAML2 or Defaults must provide a SecurityGroup code.
|
||||
log.error(
|
||||
"A SecurityGroup code must be given to a user, but none was calculated. Nor by SAML2 XML, nor defined in defaults.");
|
||||
this.invalidateLogin(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (securityGroup == null) {
|
||||
this.invalidateLogin(request, response);
|
||||
return;
|
||||
// Find or fail
|
||||
securityGroup = securityGroupRepository.findOneByCodeWithEagerRelationships(attr.getSecurityGroupCode()).orElse(
|
||||
null);
|
||||
|
||||
if (securityGroup == null) {
|
||||
log.error("The SecurityGroup code [" + attr.getSecurityGroupCode() + "] wasn't found in database.");
|
||||
this.invalidateLogin(request, response);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a ResilientUserDetails and replace Principal
|
||||
|
@ -173,4 +216,31 @@ public class Saml2AuthenticationHandler implements AuthenticationSuccessHandler,
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the expected user attributes from SAML2Response. There's some standard expected attributes, those will be
|
||||
* mapped to properties, the remaining will be put into a MAP.
|
||||
*/
|
||||
private Saml2AuthenticationAttributes readSaml2Attributes(Saml2AuthenticatedPrincipal principal) {
|
||||
// Get the RelyingParty ID (IDP)
|
||||
String registrationId = principal.getRelyingPartyRegistrationId();
|
||||
|
||||
// Get registration, from config properties for the RelyingParty ID
|
||||
Registration registration = this.resilientSaml2Properties.getRelyingparty().getRegistration().get(registrationId);
|
||||
|
||||
if (registration == null) {
|
||||
// This is VERY strange
|
||||
}
|
||||
|
||||
Saml2AuthenticationAttributes authAttr = new Saml2AuthenticationAttributes();
|
||||
Map<String, String> atributes = registration.getAssertingparty().getAttributes();
|
||||
Map<String, String> defaults = registration.getAssertingparty().getDefaults();
|
||||
|
||||
for (Entry<String, String> entry : atributes.entrySet()) {
|
||||
String attrValue = this.getPrincipalAttribute(principal, entry.getValue());
|
||||
authAttr.addAttribute(entry.getKey(), attrValue, defaults.get(entry.getKey()));
|
||||
}
|
||||
|
||||
return authAttr;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -144,14 +144,14 @@ resilient:
|
|||
enabled: false
|
||||
port: 8081
|
||||
mock-idp:
|
||||
enabled: true
|
||||
enabled: false
|
||||
path: classpath:mock-idp/idp.js
|
||||
security:
|
||||
saml2: # ADDED to support SAMLv2 authentication to IDP.
|
||||
# Metadata endpoint ${base-url}/saml2/service-provider-metadata/mock-idp
|
||||
enabled: true
|
||||
idp-id: unl-idp # The id of the IDP to use. One from the collection in relyingparty.registration
|
||||
base-url: https://resilient.localhost # old: https://localhost:8443
|
||||
idp-id: mock-idp # The id of the IDP to use. One from the collection in relyingparty.registration
|
||||
base-url: http://resilient.localhost # old: https://localhost:8443
|
||||
success-url: http://resilient.localhost/
|
||||
failure-url: http://resilient.localhost/login
|
||||
relyingparty:
|
||||
|
@ -163,10 +163,22 @@ resilient:
|
|||
url: http://mock-idp.localhost/saml/sso # old: http://localhost:3000/saml/sso
|
||||
# OPTIONAL. A list of query parameters to add to single-sign-on.url. This is usefull for mock-idp, to give instructions on how to behave
|
||||
query-parameters:
|
||||
spUrl: https://resilient.localhost # The callback to Service Provider, after IDP authentication (OK | KO). Appends the encoded url: acs=https%3A%2F%2Fresilient.localhost%2Flogin%2Fsaml2%2Fsso%2Fmock-idp
|
||||
spUrl: http://resilient.localhost # The callback to Service Provider, after IDP authentication (OK | KO). Appends the encoded url: acs=https%3A%2F%2Fresilient.localhost%2Flogin%2Fsaml2%2Fsso%2Fmock-idp
|
||||
issuerUrl: http://mock-idp.localhost/saml/metadata # The IDP entity-id. This is needed for mock-idp to build saml2 response
|
||||
single-logout:
|
||||
url: http://mock-idp.localhost/saml/slo # old: http://localhost:3000/saml/slo
|
||||
attributes: # This is a mapping between the needed attributes, and the names of the attributes in the SAML2 Response
|
||||
# Leave BLANK if SAML doesn't provide that attribute'
|
||||
name: name # the user display name [OPTIONAL]
|
||||
username: urn:mace:dir:attribute-def:mail # the username, typically for authentication. Fallsback to email. [MANDATORY]
|
||||
email: email # the user email [MANDATORY]
|
||||
organization-code: organization_code # organization unit code [OPTIONAL]
|
||||
security-group-code: security_group # security group code [OPTIONAL]
|
||||
role: roles # a single role is expected [OPTIONAL]
|
||||
defaults: # For some attributes defaults can be given. This will be used if SAML2 response doesn't have them
|
||||
organization-code: NOVA # default organization unit code
|
||||
security-group-code: GRP_USER # default security group code
|
||||
role: ROLE_USER # default role
|
||||
verification:
|
||||
credentials:
|
||||
- certificate-location: classpath:saml/idp-public.cert
|
||||
|
@ -183,15 +195,28 @@ resilient:
|
|||
url: http://unl-idp.localhost/saml/sso
|
||||
# OPTIONAL. A list of query parameters to add to single-sign-on.url. This is usefull for mock-idp, to give instructions on how to behave
|
||||
query-parameters:
|
||||
spUrl: https://resilient.localhost # The callback to Service Provider, after IDP authentication (OK | KO). Appends the encoded url: acs=https%3A%2F%2Fresilient.localhost%2Flogin%2Fsaml2%2Fsso%2Fmock-idp
|
||||
spUrl: http://resilient.localhost # The callback to Service Provider, after IDP authentication (OK | KO). Appends the encoded url: acs=https%3A%2F%2Fresilient.localhost%2Flogin%2Fsaml2%2Fsso%2Fmock-idp
|
||||
issuerUrl: http://unl-idp.localhost/saml/metadata # The IDP entity-id. This is needed for mock-idp to build saml2 response
|
||||
single-logout:
|
||||
url: http://unl-idp.localhost/saml/slo # old: http://localhost:3000/saml/slo
|
||||
attributes: # This is a mapping between the needed attributes, and the names of the attributes in the SAML2 Response
|
||||
# Leave BLANK if SAML doesn't provide that attribute'
|
||||
name: urn:mace:dir:attribute-def:displayName # the user display name [OPTIONAL]
|
||||
username: urn:mace:dir:attribute-def:mail # the username, typically for authentication. Fallsback to email. [MANDATORY]
|
||||
email: urn:mace:dir:attribute-def:mail # the user email [MANDATORY]
|
||||
organization-code: # organization unit code [OPTIONAL]
|
||||
security-group-code: # security group code [OPTIONAL]
|
||||
role: # a single role is expected [OPTIONAL]
|
||||
defaults: # For some attributes defaults can be given. This will be used if SAML2 response doesn't have them
|
||||
organization-code: NOVA # default organization unit code
|
||||
security-group-code: GRP_USER # default security group code
|
||||
role: ROLE_USER # default role
|
||||
verification:
|
||||
credentials:
|
||||
- certificate-location: classpath:saml/idp-public.cert
|
||||
want-authn-signed: false # Validate signature in entire message response (true-validates/false-doesn't validate)
|
||||
want-assertion-signed: true # Validate signature in assertions message response (true-validates/false-doesn't validate)
|
||||
check-in-response-to: false # The UNL IDP doesn't implement this. Must be false.
|
||||
signing:
|
||||
credentials:
|
||||
- private-key-location: classpath:saml/private.key
|
||||
|
|
|
@ -132,6 +132,91 @@ function createFakeSamlResponse(nameId, email, orgCode, securityGroupCode, roles
|
|||
console.log('SAML Destination :', destination);
|
||||
console.log('SAML Issuer :', issuer);
|
||||
|
||||
const assertion = `
|
||||
<saml:Assertion
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
|
||||
ID="${assertionId}"
|
||||
IssueInstant="${now}"
|
||||
Version="2.0">
|
||||
<saml:Issuer>${issuer}</saml:Issuer>
|
||||
<saml:Subject>
|
||||
<saml:NameID>${nameId}</saml:NameID>
|
||||
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
|
||||
<saml:SubjectConfirmationData NotOnOrAfter="${fiveMinutesLater}" Recipient="${recipient}" />
|
||||
</saml:SubjectConfirmation>
|
||||
</saml:Subject>
|
||||
<saml:Conditions NotBefore="${now}" NotOnOrAfter="${fiveMinutesLater}">
|
||||
<saml:AudienceRestriction>
|
||||
<saml:Audience>${audience}</saml:Audience>
|
||||
</saml:AudienceRestriction>
|
||||
</saml:Conditions>
|
||||
<saml:AuthnStatement AuthnInstant="${now}">
|
||||
<saml:AuthnContext>
|
||||
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
|
||||
</saml:AuthnContext>
|
||||
</saml:AuthnStatement>
|
||||
<saml:AttributeStatement>
|
||||
<saml:Attribute Name="email">
|
||||
<saml:AttributeValue>${email}</saml:AttributeValue>
|
||||
</saml:Attribute>
|
||||
<saml:Attribute Name="organization_code">
|
||||
<saml:AttributeValue>${orgCode}</saml:AttributeValue>
|
||||
</saml:Attribute>
|
||||
<saml:Attribute Name="security_group">
|
||||
<saml:AttributeValue>${securityGroupCode}</saml:AttributeValue>
|
||||
</saml:Attribute>
|
||||
<saml:Attribute Name="roles">
|
||||
<saml:AttributeValue>${roles}</saml:AttributeValue>
|
||||
</saml:Attribute>
|
||||
<saml:Attribute Name="urn:mace:dir:attribute-def:displayName" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
|
||||
<saml:AttributeValue xsi:type="xs:string">Pedro Reis</saml:AttributeValue>
|
||||
</saml:Attribute>
|
||||
<saml:Attribute Name="urn:oid:2.16.840.1.113730.3.1.241" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
|
||||
<saml:AttributeValue xsi:type="xs:string">Pedro Reis</saml:AttributeValue>
|
||||
</saml:Attribute>
|
||||
<saml:Attribute Name="urn:mace:dir:attribute-def:mail" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
|
||||
<saml:AttributeValue xsi:type="xs:string">pedro.reis@unl.pt</saml:AttributeValue>
|
||||
</saml:Attribute>
|
||||
<saml:Attribute Name="urn:oid:0.9.2342.19200300.100.1.3" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
|
||||
<saml:AttributeValue xsi:type="xs:string">pedro.reis@unl.pt</saml:AttributeValue>
|
||||
</saml:Attribute>
|
||||
</saml:AttributeStatement>
|
||||
</saml:Assertion>`;
|
||||
|
||||
const privateKey = fs.readFileSync(path.join(__dirname, 'certs', 'idp-private.key'), 'utf8');
|
||||
const certificate = fs.readFileSync(path.join(__dirname, 'certs', 'idp-public.cert'), 'utf8');
|
||||
const certBase64 = certificate
|
||||
.replace(/-----BEGIN CERTIFICATE-----/, '')
|
||||
.replace(/-----END CERTIFICATE-----/, '')
|
||||
.replace(/\r?\n|\r/g, '');
|
||||
|
||||
const doc = new DOMParser().parseFromString(assertion);
|
||||
const sig = new SignedXml();
|
||||
sig.prefix = 'ds';
|
||||
sig.addReference("//*[local-name(.)='Assertion']", [
|
||||
"http://www.w3.org/2000/09/xmldsig#enveloped-signature",
|
||||
"http://www.w3.org/2001/10/xml-exc-c14n#",
|
||||
], "http://www.w3.org/2000/09/xmldsig#sha1");
|
||||
sig.signingKey = privateKey;
|
||||
sig.keyInfoProvider = {
|
||||
getKeyInfo: () => `<ds:X509Data><ds:X509Certificate>${certBase64}</ds:X509Certificate></ds:X509Data>`
|
||||
};
|
||||
sig.computeSignature(assertion, {
|
||||
prefix: 'ds',
|
||||
location: { reference: "//*[local-name(.)='Issuer']", action: 'after' }
|
||||
});
|
||||
|
||||
const signedAssertion = sig.getSignedXml();
|
||||
const samlResponse = `<?xml version="1.0" encoding="UTF-8"?><samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="${responseId}" Version="2.0" IssueInstant="${now}" Destination="${destination}" InResponseTo="${inResponseTo}"><saml:Issuer>${issuer}</saml:Issuer><samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>${signedAssertion}</samlp:Response>`;
|
||||
|
||||
const samlResponseToUse = samlResponse.trim().replace(/^\uFEFF/, '');
|
||||
return Buffer.from(samlResponseToUse, 'utf-8').toString('base64');
|
||||
}
|
||||
|
||||
// OLD assertion string. This contains also some extra attributes
|
||||
function assertionOLD() {
|
||||
const assertion = `
|
||||
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="${assertionId}" IssueInstant="${now}" Version="2.0">
|
||||
<saml:Issuer>${issuer}</saml:Issuer>
|
||||
|
@ -166,35 +251,6 @@ function createFakeSamlResponse(nameId, email, orgCode, securityGroupCode, roles
|
|||
</saml:Attribute>
|
||||
</saml:AttributeStatement>
|
||||
</saml:Assertion>`;
|
||||
|
||||
const privateKey = fs.readFileSync(path.join(__dirname, 'certs', 'idp-private.key'), 'utf8');
|
||||
const certificate = fs.readFileSync(path.join(__dirname, 'certs', 'idp-public.cert'), 'utf8');
|
||||
const certBase64 = certificate
|
||||
.replace(/-----BEGIN CERTIFICATE-----/, '')
|
||||
.replace(/-----END CERTIFICATE-----/, '')
|
||||
.replace(/\r?\n|\r/g, '');
|
||||
|
||||
const doc = new DOMParser().parseFromString(assertion);
|
||||
const sig = new SignedXml();
|
||||
sig.prefix = 'ds';
|
||||
sig.addReference("//*[local-name(.)='Assertion']", [
|
||||
"http://www.w3.org/2000/09/xmldsig#enveloped-signature",
|
||||
"http://www.w3.org/2001/10/xml-exc-c14n#",
|
||||
], "http://www.w3.org/2000/09/xmldsig#sha1");
|
||||
sig.signingKey = privateKey;
|
||||
sig.keyInfoProvider = {
|
||||
getKeyInfo: () => `<ds:X509Data><ds:X509Certificate>${certBase64}</ds:X509Certificate></ds:X509Data>`
|
||||
};
|
||||
sig.computeSignature(assertion, {
|
||||
prefix: 'ds',
|
||||
location: { reference: "//*[local-name(.)='Issuer']", action: 'after' }
|
||||
});
|
||||
|
||||
const signedAssertion = sig.getSignedXml();
|
||||
const samlResponse = `<?xml version="1.0" encoding="UTF-8"?><samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="${responseId}" Version="2.0" IssueInstant="${now}" Destination="${destination}" InResponseTo="${inResponseTo}"><saml:Issuer>${issuer}</saml:Issuer><samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>${signedAssertion}</samlp:Response>`;
|
||||
|
||||
const samlResponseToUse = samlResponse.trim().replace(/^\uFEFF/, '');
|
||||
return Buffer.from(samlResponseToUse, 'utf-8').toString('base64');
|
||||
}
|
||||
|
||||
function createLoginFormOLD() {
|
||||
|
@ -350,5 +406,11 @@ function extractIssuerFromSAMLRequest(samlRequestBase64) {
|
|||
}
|
||||
|
||||
app.listen(port, () => {
|
||||
const now = new Date();
|
||||
const localISOTime = now.toLocaleString('pt-PT'); // ISO-like, local time
|
||||
|
||||
const nowUTC = new Date().toISOString();
|
||||
|
||||
console.log(`Mock IDP running at http://localhost:${port}`);
|
||||
console.log(`Started at ${localISOTime} (UTC is ${now})`);
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue