diff --git a/src/main/java/io/cos/cas/osf/authentication/credential/OsfOrcidSsoCredential.java b/src/main/java/io/cos/cas/osf/authentication/credential/OsfOrcidSsoCredential.java new file mode 100644 index 00000000..16c6fe23 --- /dev/null +++ b/src/main/java/io/cos/cas/osf/authentication/credential/OsfOrcidSsoCredential.java @@ -0,0 +1,95 @@ +package io.cos.cas.osf.authentication.credential; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.val; + +import org.apache.commons.lang3.StringUtils; +import org.apereo.cas.authentication.credential.AbstractCredential; + +import org.springframework.binding.message.MessageBuilder; +import org.springframework.binding.validation.ValidationContext; + +/** + * This is {@link OsfOrcidSsoCredential}. + * + * @author Longze Chen + * @since 26.2.0 + */ +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OsfOrcidSsoCredential extends AbstractCredential { + + /** Serial version UID. */ + private static final long serialVersionUID = 7983138918562300147L; + + /** The prefix which is added to {@link #orcidId} in {@link #getId()}. */ + public static final String CREDENTIAL_ID_PREFIX = "OrcidProfile#"; + + /** Attribute name for ORCiD ID, which is released to OSF. */ + public static final String AUTHENTICATION_ATTRIBUTE_ORCID_ID = "orcidId"; + + /** Attribute name for Access Token, which is released to OSF. */ + public static final String AUTHENTICATION_ATTRIBUTE_ORCID_ACCESS_TOKEN = "orcidAccessToken"; + + /** Attribute name for Refresh Token, which is released to OSF. */ + public static final String AUTHENTICATION_ATTRIBUTE_ORCID_REFRESH_TOKEN = "orcidRefreshToken"; + + /** ORCiD ID. */ + private String orcidId; + + /** ORCiD Access Token. */ + private String orcidAccessToken; + + /** ORCiD Refresh Token. */ + private String orcidRefreshToken; + + /** + * Get the unique identifier for this credential. + * + * @return the credential ID, formed as {@code CREDENTIAL_ID_PREFIX + orcidId} + */ + @Override + public String getId() { + return CREDENTIAL_ID_PREFIX + this.getOrcidId(); + } + + /** + * Check if credential is valid. {@link #orcidId} and {@link #orcidAccessToken} must not be null or empty. + * + * @return {@code true} if both {@code orcidId} and {@code orcidAccessToken} are non-null and non-blank, + * {@code false} otherwise + */ + @Override + @JsonIgnore + public boolean isValid() { + return StringUtils.isNoneBlank(this.orcidId, this.orcidAccessToken); + } + + /** + * Validate this credential, adding an error message to the given context if it is not valid. + * + * @param context the validation context to which any error messages are added + */ + @Override + public void validate(final ValidationContext context) { + if (!isValid()) { + val messages = context.getMessageContext(); + messages.addMessage(new MessageBuilder() + .error() + .source("token") + .defaultText("Unable to accept credential with an empty or unspecified ORCiD ID and/or tokens") + .build()); + } + } +} diff --git a/src/main/java/io/cos/cas/osf/authentication/credential/OsfPostgresCredential.java b/src/main/java/io/cos/cas/osf/authentication/credential/OsfPostgresCredential.java index 0d5fa644..789346da 100644 --- a/src/main/java/io/cos/cas/osf/authentication/credential/OsfPostgresCredential.java +++ b/src/main/java/io/cos/cas/osf/authentication/credential/OsfPostgresCredential.java @@ -44,6 +44,8 @@ public class OsfPostgresCredential extends RememberMeUsernamePasswordCredential private static DelegationProtocol DEFAULT_DELEGATION_PROTOCOL = DelegationProtocol.NONE; + public static String AUTHENTICATION_ATTRIBUTE_ORCID_ACCESS_TOKEN = "orcidAccessToken"; + /** * The one-time and ephemeral OSF verification key. */ @@ -84,6 +86,10 @@ public class OsfPostgresCredential extends RememberMeUsernamePasswordCredential */ private Map delegationAttributes = new LinkedHashMap<>(); + private String orcidId; + + private String orcidAccessToken; + @Override public String getId() { return this.getUsername(); diff --git a/src/main/java/io/cos/cas/osf/authentication/handler/support/OsfOrcidSsoAuthenticationHandler.java b/src/main/java/io/cos/cas/osf/authentication/handler/support/OsfOrcidSsoAuthenticationHandler.java new file mode 100644 index 00000000..f489fa9b --- /dev/null +++ b/src/main/java/io/cos/cas/osf/authentication/handler/support/OsfOrcidSsoAuthenticationHandler.java @@ -0,0 +1,104 @@ +package io.cos.cas.osf.authentication.handler.support; + +import io.cos.cas.osf.authentication.credential.OsfOrcidSsoCredential; + +import lombok.extern.slf4j.Slf4j; +import lombok.Getter; +import lombok.Setter; + +import org.apereo.cas.authentication.AuthenticationHandlerExecutionResult; +import org.apereo.cas.authentication.Credential; +import org.apereo.cas.authentication.MessageDescriptor; +import org.apereo.cas.authentication.handler.support.AbstractPreAndPostProcessingAuthenticationHandler; +import org.apereo.cas.authentication.principal.Principal; +import org.apereo.cas.authentication.principal.PrincipalFactory; +import org.apereo.cas.services.ServicesManager; + +import org.apache.commons.lang3.StringUtils; + +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * This is {@link OsfOrcidSsoAuthenticationHandler}. + * + * @author Longze Chen + * @since 26.2.0 + */ +@Getter +@Setter +@Slf4j +public class OsfOrcidSsoAuthenticationHandler extends AbstractPreAndPostProcessingAuthenticationHandler { + + /** Constructor for all required args. */ + public OsfOrcidSsoAuthenticationHandler( + final String name, + final ServicesManager servicesManager, + final PrincipalFactory principalFactory, + final Integer order + ) { + super(name, servicesManager, principalFactory, order); + } + + /** Authenticate with no-op credential transform. */ + @Override + protected final AuthenticationHandlerExecutionResult doAuthentication( + Credential credential + ) throws GeneralSecurityException { + OsfOrcidSsoCredential osfOrcidSsoCredential = (OsfOrcidSsoCredential) credential; + LOGGER.debug("[ORCiD SSO] Attempting authentication internally for transformed credential [{}]", osfOrcidSsoCredential); + return authenticateOsfOrcidSsoInternal(osfOrcidSsoCredential); + } + + /** {@link OsfOrcidSsoAuthenticationHandler} only supports {@link OsfOrcidSsoCredential} */ + @Override + public boolean supports(final Class clazz) { + return OsfOrcidSsoCredential.class.isAssignableFrom(clazz); + } + + /** {@link OsfOrcidSsoAuthenticationHandler} only supports {@link OsfOrcidSsoCredential} */ + @Override + public boolean supports(final Credential credential) { + return credential instanceof OsfOrcidSsoCredential; + } + + /** Create {@link AuthenticationHandlerExecutionResult} from {@link OsfOrcidSsoCredential}. */ + protected final AuthenticationHandlerExecutionResult authenticateOsfOrcidSsoInternal( + final OsfOrcidSsoCredential credential + ) throws GeneralSecurityException { + + if (credential == null) { + LOGGER.error("[ORCiD SSO] Null/Empty ORCiD Credential."); + throw new GeneralSecurityException("Null/Empty ORCiD Credential."); + } + + final String credentialId = credential.getId(); + final String orcidId = credential.getOrcidId(); + final String orcidAccessToken = credential.getOrcidAccessToken(); + final String orcidRefreshToken = credential.getOrcidRefreshToken(); + + if (StringUtils.isBlank(orcidId)) { + LOGGER.error("[ORCiD SSO] Null/Empty ORCiD ID."); + throw new GeneralSecurityException("Null/Empty ORCiD ID."); + } else if (StringUtils.isBlank(orcidAccessToken)) { + LOGGER.error("[ORCiD SSO] Null/Empty ORCiD Access Token, orcidId=[{}]", orcidId); + throw new GeneralSecurityException("Null/Empty ORCiD Access Token."); + } + + LOGGER.info( + "[ORCiD SSO] Credential metadata: id=[{}], orcidId=[{}], hasAccessToken=[{}], hasRefreshToken=[{}]", + credentialId, + orcidId, + StringUtils.isNotBlank(orcidAccessToken), + StringUtils.isNotBlank(orcidRefreshToken) + ); + + final Map> attributes = new LinkedHashMap<>(); + final Principal principal = this.principalFactory.createPrincipal(credentialId, attributes); + final List warnings = new ArrayList<>(); + return createHandlerResult(credential, principal, warnings); + } +} diff --git a/src/main/java/io/cos/cas/osf/authentication/handler/support/OsfPostgresAuthenticationHandler.java b/src/main/java/io/cos/cas/osf/authentication/handler/support/OsfPostgresAuthenticationHandler.java index 3d43cf1d..ad9272d6 100644 --- a/src/main/java/io/cos/cas/osf/authentication/handler/support/OsfPostgresAuthenticationHandler.java +++ b/src/main/java/io/cos/cas/osf/authentication/handler/support/OsfPostgresAuthenticationHandler.java @@ -80,7 +80,7 @@ protected final AuthenticationHandlerExecutionResult doAuthentication( ) throws GeneralSecurityException { OsfPostgresCredential osfPostgresCredential = (OsfPostgresCredential) credential; transformUsername(osfPostgresCredential); - if (osfPostgresCredential.isRemotePrincipal()) { + if (osfPostgresCredential.isRemotePrincipal() || osfPostgresCredential.getOrcidId() != null) { osfPostgresCredential.setPassword(null); osfPostgresCredential.setVerificationKey(null); } else { @@ -114,6 +114,8 @@ protected final AuthenticationHandlerExecutionResult authenticateOsfPostgresInte final boolean isTermsOfServiceChecked = credential.isTermsOfServiceChecked(); final boolean isRemotePrincipal = credential.isRemotePrincipal(); final DelegationProtocol delegationProtocol = credential.getDelegationProtocol(); + final String orcidId = credential.getOrcidId(); + final String orcidAccessToken = credential.getOrcidAccessToken(); LOGGER.debug( "Credential metadata: username=[{}], password=[{}], verificationKey=[{}], oneTimePassword=[{}], " + @@ -128,6 +130,22 @@ protected final AuthenticationHandlerExecutionResult authenticateOsfPostgresInte delegationProtocol ); + if (StringUtils.isNoneBlank(orcidId)) { + final String principalId = "OrcidProfile#" + orcidId; + final Map> attributes = new LinkedHashMap<>(); + attributes.put("orcidAccessToken", Collections.singletonList(orcidAccessToken)); + LOGGER.debug(">>>> Authenticating credential for ORCiD SSO"); + LOGGER.debug(">>>> ---- username from credential = {}", username); + credential.setUsername(principalId); + LOGGER.debug(">>>> ---- username from credential updated = {}", credential.getUsername()); + LOGGER.debug(">>>> ---- orcidId = {}", orcidId); + LOGGER.debug(">>>> ---- orcidAccessToken = {}", orcidAccessToken); + LOGGER.debug(">>>> ---- principalId = {}", principalId); + final Principal principal = this.principalFactory.createPrincipal(principalId, attributes); + final List warnings = new ArrayList<>(); + return createHandlerResult(credential, principal, warnings); + } + final OsfUser osfUser = jpaOsfDao.findOneUserByEmail(username); if (osfUser == null) { if (StringUtils.isNoneBlank(plainTextPassword) || StringUtils.isNoneBlank(oneTimePassword)) { diff --git a/src/main/java/io/cos/cas/osf/authentication/metadata/OsfOrcidSsoAuthenticationMetaDataPopulator.java b/src/main/java/io/cos/cas/osf/authentication/metadata/OsfOrcidSsoAuthenticationMetaDataPopulator.java new file mode 100644 index 00000000..bc8b68b3 --- /dev/null +++ b/src/main/java/io/cos/cas/osf/authentication/metadata/OsfOrcidSsoAuthenticationMetaDataPopulator.java @@ -0,0 +1,62 @@ +package io.cos.cas.osf.authentication.metadata; + +import io.cos.cas.osf.authentication.credential.OsfOrcidSsoCredential; + +import lombok.Getter; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; + +import org.apache.commons.lang3.StringUtils; + +import org.apereo.cas.authentication.AuthenticationBuilder; +import org.apereo.cas.authentication.AuthenticationMetaDataPopulator; +import org.apereo.cas.authentication.AuthenticationTransaction; +import org.apereo.cas.authentication.Credential; + +/** + * This is {@link OsfOrcidSsoAuthenticationMetaDataPopulator}. + * + * @author Longze Chen + * @since 26.1.0 + */ +@Getter +@ToString(callSuper = true) +@Slf4j +public class OsfOrcidSsoAuthenticationMetaDataPopulator implements AuthenticationMetaDataPopulator { + + /** Add attribute to authentication metadata. */ + @Override + public void populateAttributes(final AuthenticationBuilder builder, final AuthenticationTransaction transaction) { + transaction.getPrimaryCredential().ifPresent(r -> { + final OsfOrcidSsoCredential credential = (OsfOrcidSsoCredential) r; + LOGGER.info( + "[ORCiD SSO] Credential is of type [{}], thus adding attributes [{}, {}, and optionally {} if not null/blank)]", + OsfOrcidSsoCredential.class.getSimpleName(), + OsfOrcidSsoCredential.AUTHENTICATION_ATTRIBUTE_ORCID_ID, + OsfOrcidSsoCredential.AUTHENTICATION_ATTRIBUTE_ORCID_ACCESS_TOKEN, + OsfOrcidSsoCredential.AUTHENTICATION_ATTRIBUTE_ORCID_REFRESH_TOKEN + ); + builder.addAttribute( + OsfOrcidSsoCredential.AUTHENTICATION_ATTRIBUTE_ORCID_ID, + credential.getOrcidId() + ); + builder.addAttribute( + OsfOrcidSsoCredential.AUTHENTICATION_ATTRIBUTE_ORCID_ACCESS_TOKEN, + credential.getOrcidAccessToken() + ); + final String refreshToken = credential.getOrcidRefreshToken(); + if (StringUtils.isNotBlank(refreshToken)) { + builder.addAttribute( + OsfOrcidSsoCredential.AUTHENTICATION_ATTRIBUTE_ORCID_REFRESH_TOKEN, + refreshToken + ); + } + }); + } + + /** {@link OsfOrcidSsoAuthenticationMetaDataPopulator} only supports {@link OsfOrcidSsoCredential} */ + @Override + public boolean supports(final Credential credential) { + return credential instanceof OsfOrcidSsoCredential; + } +} diff --git a/src/main/java/io/cos/cas/osf/authentication/metadata/OsfPostgresAuthenticationMetaDataPopulator.java b/src/main/java/io/cos/cas/osf/authentication/metadata/OsfPostgresAuthenticationMetaDataPopulator.java index de3b22fd..89ed58ba 100644 --- a/src/main/java/io/cos/cas/osf/authentication/metadata/OsfPostgresAuthenticationMetaDataPopulator.java +++ b/src/main/java/io/cos/cas/osf/authentication/metadata/OsfPostgresAuthenticationMetaDataPopulator.java @@ -27,13 +27,14 @@ public void populateAttributes(final AuthenticationBuilder builder, final Authen transaction.getPrimaryCredential().ifPresent(r -> { final OsfPostgresCredential credential = (OsfPostgresCredential) r; LOGGER.debug( - "Credential is of type [{}], thus adding attributes [{}, {}, {}, {}, {}]", + "Credential is of type [{}], thus adding attributes [{}, {}, {}, {}, {} {}]", OsfPostgresCredential.class.getSimpleName(), OsfPostgresCredential.AUTHENTICATION_ATTRIBUTE_REMEMBER_ME, OsfPostgresCredential.AUTHENTICATION_ATTRIBUTE_REMOTE_PRINCIPAL, OsfPostgresCredential.AUTHENTICATION_ATTRIBUTE_DELEGATION_PROTOCOL, OsfPostgresCredential.AUTHENTICATION_ATTRIBUTE_INSTITUTION_ID, - OsfPostgresCredential.AUTHENTICATION_ATTRIBUTE_TOS_CONSENT + OsfPostgresCredential.AUTHENTICATION_ATTRIBUTE_TOS_CONSENT, + OsfPostgresCredential.AUTHENTICATION_ATTRIBUTE_ORCID_ACCESS_TOKEN ); builder.addAttribute( OsfPostgresCredential.AUTHENTICATION_ATTRIBUTE_REMEMBER_ME, @@ -55,6 +56,10 @@ public void populateAttributes(final AuthenticationBuilder builder, final Authen OsfPostgresCredential.AUTHENTICATION_ATTRIBUTE_INSTITUTION_ID, credential.getInstitutionId() ); + builder.addAttribute( + OsfPostgresCredential.AUTHENTICATION_ATTRIBUTE_ORCID_ACCESS_TOKEN, + credential.getOrcidAccessToken() + ); }); } diff --git a/src/main/java/io/cos/cas/osf/config/OsfCasCoreAuthenticationMetadataConfiguration.java b/src/main/java/io/cos/cas/osf/config/OsfCasCoreAuthenticationMetadataConfiguration.java index cbeff9a9..42f06c3a 100644 --- a/src/main/java/io/cos/cas/osf/config/OsfCasCoreAuthenticationMetadataConfiguration.java +++ b/src/main/java/io/cos/cas/osf/config/OsfCasCoreAuthenticationMetadataConfiguration.java @@ -1,5 +1,6 @@ package io.cos.cas.osf.config; +import io.cos.cas.osf.authentication.metadata.OsfOrcidSsoAuthenticationMetaDataPopulator; import io.cos.cas.osf.authentication.metadata.OsfPostgresAuthenticationMetaDataPopulator; import lombok.extern.slf4j.Slf4j; @@ -17,7 +18,7 @@ /** * This is {@link OsfCasCoreAuthenticationMetadataConfiguration}. * - * Longze Chen + * @author Longze Chen * @since 20.0.0 */ @Configuration("osfCasCoreAuthenticationMetadataConfiguration") @@ -31,16 +32,31 @@ public AuthenticationMetaDataPopulator osfPostgresAuthenticationMetaDataPopulato return new OsfPostgresAuthenticationMetaDataPopulator(); } + @Bean + public AuthenticationMetaDataPopulator osfOrcidSsoAuthenticationMetaDataPopulator() { + return new OsfOrcidSsoAuthenticationMetaDataPopulator(); + } + @Bean public AuthenticationEventExecutionPlanConfigurer casCoreAuthenticationMetadataAuthenticationEventExecutionPlanConfigurer() { return plan -> { plan.registerAuthenticationMetadataPopulator(successfulHandlerMetaDataPopulator()); plan.registerAuthenticationMetadataPopulator(rememberMeAuthenticationMetaDataPopulator()); - LOGGER.debug( + + // Register OsfPostgresAuthenticationMetaDataPopulator + plan.registerAuthenticationMetadataPopulator(osfPostgresAuthenticationMetaDataPopulator()); + LOGGER.info( "Register [{}] to metadata authentication event execution plan", OsfPostgresAuthenticationMetaDataPopulator.class.getSimpleName() ); - plan.registerAuthenticationMetadataPopulator(osfPostgresAuthenticationMetaDataPopulator()); + + // Register OsfOrcidSsoAuthenticationMetaDataPopulator + plan.registerAuthenticationMetadataPopulator(osfOrcidSsoAuthenticationMetaDataPopulator()); + LOGGER.info( + "Register [{}] to metadata authentication event execution plan", + OsfOrcidSsoAuthenticationMetaDataPopulator.class.getSimpleName() + ); + plan.registerAuthenticationMetadataPopulator(authenticationCredentialTypeMetaDataPopulator()); plan.registerAuthenticationMetadataPopulator(authenticationDateMetaDataPopulator()); plan.registerAuthenticationMetadataPopulator(credentialCustomFieldsAttributeMetaDataPopulator()); diff --git a/src/main/java/io/cos/cas/osf/config/OsfOrcidSsoAuthenticationEventExecutionPlanConfiguration.java b/src/main/java/io/cos/cas/osf/config/OsfOrcidSsoAuthenticationEventExecutionPlanConfiguration.java new file mode 100644 index 00000000..213f8876 --- /dev/null +++ b/src/main/java/io/cos/cas/osf/config/OsfOrcidSsoAuthenticationEventExecutionPlanConfiguration.java @@ -0,0 +1,77 @@ +package io.cos.cas.osf.config; + +import io.cos.cas.osf.authentication.handler.support.OsfOrcidSsoAuthenticationHandler; +import io.cos.cas.osf.configuration.model.OsfOrcidSsoAuthenticationProperties; + +import org.apereo.cas.authentication.AuthenticationEventExecutionPlanConfigurer; +import org.apereo.cas.authentication.AuthenticationHandler; +import org.apereo.cas.authentication.principal.PrincipalFactory; +import org.apereo.cas.authentication.principal.PrincipalFactoryUtils; +import org.apereo.cas.authentication.principal.PrincipalResolver; +import org.apereo.cas.configuration.CasConfigurationProperties; +import org.apereo.cas.services.ServicesManager; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * This is {@link OsfOrcidSsoAuthenticationEventExecutionPlanConfiguration}. + * + * @author Longze Chen + * @since 26.2.0 + */ +@Configuration("osfOrcidSsoAuthenticationEventExecutionPlanConfiguration") +@EnableConfigurationProperties(CasConfigurationProperties.class) +public class OsfOrcidSsoAuthenticationEventExecutionPlanConfiguration { + + @Autowired + private ConfigurableApplicationContext applicationContext; + + @Autowired + @Qualifier("servicesManager") + private ObjectProvider servicesManager; + + @Autowired + private CasConfigurationProperties casProperties; + + @Autowired + @Qualifier("defaultPrincipalResolver") + private ObjectProvider defaultPrincipalResolver; + + @ConditionalOnMissingBean(name = "jsonPrincipalFactory") + @Bean + public PrincipalFactory jsonPrincipalFactory() { + return PrincipalFactoryUtils.newPrincipalFactory(); + } + + @ConditionalOnMissingBean(name = "osfOrcidSsoAuthenticationHandler") + @Bean + public AuthenticationHandler osfOrcidSsoAuthenticationHandler() { + OsfOrcidSsoAuthenticationProperties jsonProps = casProperties.getAuthn().getOsfOrcidSso(); + return new OsfOrcidSsoAuthenticationHandler( + jsonProps.getName(), + servicesManager.getObject(), + jsonPrincipalFactory(), + jsonProps.getOrder() + ); + } + + @ConditionalOnMissingBean(name = "osfOrcidSsoAuthenticationEventExecutionPlanConfigurer") + @Bean + public AuthenticationEventExecutionPlanConfigurer OsfOrcidSsoAuthenticationEventExecutionPlanConfigurer() { + return plan -> { + if (casProperties.getAuthn().getOsfOrcidSso().isEnabled()) { + plan.registerAuthenticationHandlerWithPrincipalResolver( + osfOrcidSsoAuthenticationHandler(), + defaultPrincipalResolver.getObject() + ); + } + }; + } +} diff --git a/src/main/java/io/cos/cas/osf/config/OsfPostgresAuthenticationEventExecutionPlanConfiguration.java b/src/main/java/io/cos/cas/osf/config/OsfPostgresAuthenticationEventExecutionPlanConfiguration.java index d54ab0c1..69324d00 100644 --- a/src/main/java/io/cos/cas/osf/config/OsfPostgresAuthenticationEventExecutionPlanConfiguration.java +++ b/src/main/java/io/cos/cas/osf/config/OsfPostgresAuthenticationEventExecutionPlanConfiguration.java @@ -24,7 +24,7 @@ /** * This is {@link OsfPostgresAuthenticationEventExecutionPlanConfiguration}. * - * Longze Chen + * @author Longze Chen * @since 20.0.0 */ @Configuration("osfPostgresAuthenticationEventExecutionPlanConfiguration") diff --git a/src/main/java/io/cos/cas/osf/configuration/model/OsfOrcidSsoAuthenticationProperties.java b/src/main/java/io/cos/cas/osf/configuration/model/OsfOrcidSsoAuthenticationProperties.java new file mode 100644 index 00000000..452c6945 --- /dev/null +++ b/src/main/java/io/cos/cas/osf/configuration/model/OsfOrcidSsoAuthenticationProperties.java @@ -0,0 +1,33 @@ +package io.cos.cas.osf.configuration.model; + +import io.cos.cas.osf.authentication.handler.support.OsfOrcidSsoAuthenticationHandler; + +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; + +import java.io.Serializable; + +/** + * This is {@link OsfOrcidSsoAuthenticationProperties}. + * + * @author Longze Chen + * @since 26.2.0 + */ +@Getter +@Setter +@Accessors(chain = true) +public class OsfOrcidSsoAuthenticationProperties implements Serializable { + + /** Serial version UID. */ + private static final long serialVersionUID = 4565930696065100663L; + + /** The name of the authentication handler. */ + private String name = OsfOrcidSsoAuthenticationHandler.class.getSimpleName(); + + /** The flag to enable / disable the authentication handler. */ + private boolean enabled = Boolean.TRUE; + + /** The order of the authentication handler. */ + private int order; +} diff --git a/src/main/java/io/cos/cas/osf/configuration/model/OsfPostgresAuthenticationProperties.java b/src/main/java/io/cos/cas/osf/configuration/model/OsfPostgresAuthenticationProperties.java index 6f318462..44507b8d 100644 --- a/src/main/java/io/cos/cas/osf/configuration/model/OsfPostgresAuthenticationProperties.java +++ b/src/main/java/io/cos/cas/osf/configuration/model/OsfPostgresAuthenticationProperties.java @@ -23,36 +23,25 @@ @Accessors(chain = true) public class OsfPostgresAuthenticationProperties implements Serializable { + /** Serial version UID. */ private static final long serialVersionUID = -6126944686676618138L; - /** - * The name of the authentication handler. - */ + /** The name of the authentication handler. */ private String name = OsfPostgresAuthenticationHandler.class.getSimpleName(); - /** - * The flag to enable / disable the authentication handler. - */ + /** The flag to enable / disable the authentication handler. */ private boolean enabled = Boolean.TRUE; - /** - * The order of the authentication handler. - */ + /** The order of the authentication handler. */ private int order; - /** - * Institution authentication delegation clients. - */ + /** Institution authentication delegation clients. */ private List institutionClients = new LinkedList<>(); - /** - * Non-institution authentication delegation clients. - */ + /** Non-institution authentication delegation clients. */ private List nonInstitutionClients = new LinkedList<>(); - /** - * Nested JPA properties for OSF PostgreSQL database. - */ + /** Nested JPA properties for OSF PostgreSQL database. */ @NestedConfigurationProperty private OsfPostgresJpaProperties jpa = new OsfPostgresJpaProperties(); } diff --git a/src/main/java/io/cos/cas/osf/web/flow/login/OsfPrincipalFromNonInteractiveCredentialsAction.java b/src/main/java/io/cos/cas/osf/web/flow/login/OsfPrincipalFromNonInteractiveCredentialsAction.java index 03f5c5f0..a89fcaa0 100644 --- a/src/main/java/io/cos/cas/osf/web/flow/login/OsfPrincipalFromNonInteractiveCredentialsAction.java +++ b/src/main/java/io/cos/cas/osf/web/flow/login/OsfPrincipalFromNonInteractiveCredentialsAction.java @@ -6,6 +6,7 @@ import com.google.gson.JsonParseException; import com.google.gson.JsonParser; +import io.cos.cas.osf.authentication.credential.OsfOrcidSsoCredential; import io.cos.cas.osf.authentication.credential.OsfPostgresCredential; import io.cos.cas.osf.authentication.exception.InstitutionSsoAccountInactiveException; import io.cos.cas.osf.authentication.exception.InstitutionSsoAttributeMissingException; @@ -70,6 +71,8 @@ import org.json.JSONObject; import org.json.XML; +import org.pac4j.oauth.profile.orcid.OrcidProfile; + import org.springframework.util.ResourceUtils; import org.springframework.webflow.action.AbstractAction; import org.springframework.webflow.core.collection.LocalAttributeMap; @@ -181,6 +184,8 @@ public class OsfPrincipalFromNonInteractiveCredentialsAction extends AbstractNon private static final String LDAP_DN_OU_PREFIX = "ou="; + private static final String ORCiD_CLIENT_NAME = "orcid"; + private static final int OSF_API_RETRY_LIMIT = 3; private static final List OSF_API_RETRY_STATUS = List.of( @@ -250,12 +255,22 @@ protected Credential constructCredentialsFromRequest(final RequestContext contex final String clientName = ((ClientCredential) credential).getClientName(); // Type 1: non-institution SSO (i.e. ORCiD) via pac4j authentication delegation using the OAuth protocol if (authnDelegationClients.get(NON_INSTITUTION_CLIENTS_PARAMETER_NAME).contains(clientName)) { - LOGGER.debug( - "Valid non-institution authn delegation client [{}] found with principal [{}]", + LOGGER.info( + "[PAC4J SSO] Valid non-institution authn delegation client [{}] found with principal [{}]", clientName, credential.getId() ); - return credential; + if (clientName.equalsIgnoreCase(ORCiD_CLIENT_NAME)) { + // Case 1: ORCiD Client will be handled by our customized credential and authn handler + final OrcidProfile orcidUserProfile = (OrcidProfile) ((ClientCredential) credential).getUserProfile(); + final String orcidId = orcidUserProfile.getId(); + final String orcidAccessToken = (String) orcidUserProfile.getAttribute("access_token"); + final String orcidRefreshToken = (String) orcidUserProfile.getAttribute("refresh_token"); + return new OsfOrcidSsoCredential(orcidId, orcidAccessToken, orcidRefreshToken); + } else { + // Case 2: Other Client will use built-in credential and authn handler by apereo/pac4j + return credential; + } } // Type 2: institution SSO via pac4j authentication delegation using the CAS protocol if (authnDelegationClients.get(INSTITUTION_CLIENTS_PARAMETER_NAME).contains(clientName)) { diff --git a/src/main/java/org/apereo/cas/configuration/model/core/authentication/AuthenticationProperties.java b/src/main/java/org/apereo/cas/configuration/model/core/authentication/AuthenticationProperties.java index d8d7b1fd..b9f4079b 100644 --- a/src/main/java/org/apereo/cas/configuration/model/core/authentication/AuthenticationProperties.java +++ b/src/main/java/org/apereo/cas/configuration/model/core/authentication/AuthenticationProperties.java @@ -1,6 +1,7 @@ package org.apereo.cas.configuration.model.core.authentication; import io.cos.cas.osf.configuration.model.OsfApiProperties; +import io.cos.cas.osf.configuration.model.OsfOrcidSsoAuthenticationProperties; import io.cos.cas.osf.configuration.model.OsfPostgresAuthenticationProperties; import io.cos.cas.osf.configuration.model.OsfUrlProperties; @@ -119,6 +120,12 @@ public class AuthenticationProperties implements Serializable { @NestedConfigurationProperty private OsfPostgresAuthenticationProperties osfPostgres = new OsfPostgresAuthenticationProperties(); + /** + * OSF ORCiD SSO authentication settings. + */ + @NestedConfigurationProperty + private OsfOrcidSsoAuthenticationProperties osfOrcidSso = new OsfOrcidSsoAuthenticationProperties(); + /** * Groovy authentication settings. */ diff --git a/src/main/java/org/pac4j/oauth/profile/creator/OAuth20ProfileCreator.java b/src/main/java/org/pac4j/oauth/profile/creator/OAuth20ProfileCreator.java new file mode 100644 index 00000000..9be14439 --- /dev/null +++ b/src/main/java/org/pac4j/oauth/profile/creator/OAuth20ProfileCreator.java @@ -0,0 +1,71 @@ +package org.pac4j.oauth.profile.creator; + +import com.github.scribejava.core.model.*; +import com.github.scribejava.core.oauth.OAuth20Service; + +import org.apache.commons.lang3.StringUtils; + +import org.pac4j.core.client.IndirectClient; +import org.pac4j.core.context.HttpConstants; +import org.pac4j.oauth.config.OAuth20Configuration; +import org.pac4j.oauth.config.OAuthConfiguration; +import org.pac4j.oauth.credentials.OAuth20Credentials; +import org.pac4j.oauth.profile.OAuth20Profile; + +/** + * OAuth 2.0 profile creator. + * + *

OSF CAS Customizations: modified {@link #addAccessTokenToProfile(OAuth20Profile, OAuth2AccessToken)} to include + * refresh token in profile attributes.

+ * + * @author Jerome Leleu + * @author Longze Chen + * @since 2.0.0 + * @version 4.1.0 + */ +public class OAuth20ProfileCreator + extends OAuthProfileCreator { + + private static final String REFRESH_TOKEN = "refresh_token"; + + public OAuth20ProfileCreator(final OAuth20Configuration configuration, final IndirectClient client) { + super(configuration, client); + } + + @Override + protected OAuth2AccessToken getAccessToken(final OAuth20Credentials credentials) { + return credentials.getAccessToken(); + } + + @Override + protected void addAccessTokenToProfile(final U profile, final OAuth2AccessToken accessToken) { + if (profile != null) { + // Add access token + final String access_token = accessToken.getAccessToken(); + logger.debug("[OAuth20 SSO] Add access token to profile: hasAccessToken=[{}]", StringUtils.isNotBlank(access_token)); + profile.setAccessToken(access_token); + + // Add refresh token manually instead of war-overlaying and customizing org.pac4j.oauth.profile.OAuth20Profile + final String refreshToken = accessToken.getRefreshToken(); + if (StringUtils.isNoneBlank(refreshToken)) { + logger.debug("[OAuth20 SSO] Refresh token found, adding it to profile"); + profile.addAttribute(REFRESH_TOKEN, refreshToken); + } else { + logger.debug("[OAuth20 SSO] Refresh token not found, adding empty value to profile"); + profile.addAttribute(REFRESH_TOKEN, StringUtils.EMPTY); + } + } + } + + @Override + protected void signRequest(final OAuth20Service service, final OAuth2AccessToken accessToken, + final OAuthRequest request) { + service.signRequest(accessToken, request); + if (this.configuration.isTokenAsHeader()) { + request.addHeader(HttpConstants.AUTHORIZATION_HEADER, HttpConstants.BEARER_HEADER_PREFIX + accessToken.getAccessToken()); + } + if (Verb.POST.equals(request.getVerb())) { + request.addParameter(OAuthConfiguration.OAUTH_TOKEN, accessToken.getAccessToken()); + } + } +} diff --git a/src/main/java/org/pac4j/oauth/profile/orcid/OrcidProfileDefinition.java b/src/main/java/org/pac4j/oauth/profile/orcid/OrcidProfileDefinition.java index 5e885096..04c99954 100644 --- a/src/main/java/org/pac4j/oauth/profile/orcid/OrcidProfileDefinition.java +++ b/src/main/java/org/pac4j/oauth/profile/orcid/OrcidProfileDefinition.java @@ -2,6 +2,8 @@ import static org.pac4j.core.profile.AttributeLocation.PROFILE_ATTRIBUTE; +import org.apache.commons.lang3.StringUtils; + import org.pac4j.core.profile.converter.Converters; import org.pac4j.core.util.CommonHelper; import org.pac4j.oauth.config.OAuth20Configuration; @@ -11,6 +13,8 @@ import com.github.scribejava.core.exceptions.OAuthException; import com.github.scribejava.core.model.OAuth2AccessToken; +import lombok.extern.slf4j.Slf4j; + /** * This class is the Orcid profile definition. * @@ -19,6 +23,7 @@ * @since 1.6.0 * @version 4.0.3 */ +@Slf4j public class OrcidProfileDefinition extends OAuth20ProfileDefinition { public static final String ORCID = "common:path"; @@ -41,8 +46,17 @@ public OrcidProfileDefinition() { @Override public String getProfileUrl(final OAuth2AccessToken accessToken, final OAuth20Configuration configuration) { if (accessToken instanceof OrcidToken) { + LOGGER.debug( + "[ORCiD SSO] Access token object: [type=\"{}\", scope=\"{}\", exp=\"{}\", at=\"{}\", rt=\"{}\"]", + accessToken.getTokenType(), + accessToken.getScope(), + accessToken.getExpiresIn(), + StringUtils.isNotBlank(accessToken.getAccessToken()), + StringUtils.isNotBlank(accessToken.getRefreshToken()) + ); return String.format("https://pub.orcid.org/v2.0/%s/record", ((OrcidToken) accessToken).getOrcid()); } else { + LOGGER.error("[ORCiD SSO] Token in getProfileUrl is not an OrcidToken"); throw new OAuthException("Token in getProfileUrl is not an OrcidToken"); } } diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories index c702c10a..a6fcaf07 100644 --- a/src/main/resources/META-INF/spring.factories +++ b/src/main/resources/META-INF/spring.factories @@ -3,6 +3,7 @@ org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ io.cos.cas.osf.config.JpaOsfDaoConfiguration,\ io.cos.cas.osf.config.OsfCasCoreAuthenticationMetadataConfiguration,\ io.cos.cas.osf.config.OsfPostgresAuthenticationEventExecutionPlanConfiguration,\ + io.cos.cas.osf.config.OsfOrcidSsoAuthenticationEventExecutionPlanConfiguration,\ io.cos.cas.osf.web.config.OsfCasSupportActionsConfiguration,\ io.cos.cas.osf.web.flow.config.OsfCasCoreWebflowConfiguration,\ io.cos.cas.osf.web.flow.config.OsfCasWebflowContextConfiguration