Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package io.cos.cas.osf.authentication.exception;

import lombok.NoArgsConstructor;

import javax.security.auth.login.AccountException;

/**
* Describes an authentication error condition where institution SSO has failed
* due to OSF API not supporting multiple SSO emails.
*
* @author Longze Chen
* @since 25.0.0
*/
@NoArgsConstructor
public class InstitutionSsoMultipleEmailsNotSupportedException extends AccountException {

/**
* Serialization metadata.
*/
private static final long serialVersionUID = -7703550523317297865L;

/**
* Instantiates a new {@link InstitutionSsoMultipleEmailsNotSupportedException}.
*
* @param msg the msg
*/
public InstitutionSsoMultipleEmailsNotSupportedException(final String msg) {
super(msg);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ public enum OsfApiPermissionDenied {

DEFAULT("PermissionDenied"),

INSTITUTION_SSO_ACCOUNT_INACTIVE("InstitutionSsoAccountInactive"),

INSTITUTION_SSO_DUPLICATE_IDENTITY("InstitutionSsoDuplicateIdentity"),

INSTITUTION_SSO_ACCOUNT_INACTIVE("InstitutionSsoAccountInactive"),
INSTITUTION_SSO_MULTIPLE_EMAILS_NOT_SUPPORTED("InstitutionSsoMultipleEmailsNotSupported"),

INSTITUTION_SSO_SELECTIVE_LOGIN_DENIED("InstitutionSsoSelectiveLoginDenied");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import io.cos.cas.osf.authentication.exception.InstitutionSsoAttributeParsingException;
import io.cos.cas.osf.authentication.exception.InstitutionSsoDuplicateIdentityException;
import io.cos.cas.osf.authentication.exception.InstitutionSsoFailedException;
import io.cos.cas.osf.authentication.exception.InstitutionSsoMultipleEmailsNotSupportedException;
import io.cos.cas.osf.authentication.exception.InstitutionSsoOsfApiFailedException;
import io.cos.cas.osf.authentication.exception.InstitutionSsoSelectiveLoginDeniedException;
import io.cos.cas.osf.authentication.exception.InvalidOneTimePasswordException;
Expand Down Expand Up @@ -53,6 +54,7 @@ public Set<Class<? extends Throwable>> handledAuthenticationExceptions() {
errors.add(InstitutionSsoAttributeParsingException.class);
errors.add(InstitutionSsoDuplicateIdentityException.class);
errors.add(InstitutionSsoFailedException.class);
errors.add(InstitutionSsoMultipleEmailsNotSupportedException.class);
errors.add(InstitutionSsoOsfApiFailedException.class);
errors.add(InstitutionSsoSelectiveLoginDeniedException.class);
errors.add(InvalidOneTimePasswordException.class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import io.cos.cas.osf.authentication.exception.InstitutionSsoAttributeParsingException;
import io.cos.cas.osf.authentication.exception.InstitutionSsoDuplicateIdentityException;
import io.cos.cas.osf.authentication.exception.InstitutionSsoFailedException;
import io.cos.cas.osf.authentication.exception.InstitutionSsoMultipleEmailsNotSupportedException;
import io.cos.cas.osf.authentication.exception.InstitutionSsoOsfApiFailedException;
import io.cos.cas.osf.authentication.exception.InstitutionSsoSelectiveLoginDeniedException;
import io.cos.cas.osf.authentication.exception.InvalidOneTimePasswordException;
Expand Down Expand Up @@ -270,6 +271,11 @@ protected void createHandleAuthenticationFailureAction(final Flow flow) {
InstitutionSsoSelectiveLoginDeniedException.class.getSimpleName(),
OsfCasWebflowConstants.VIEW_ID_INSTITUTION_SSO_SELECTIVE_LOGIN_DENIED
);
createTransitionForState(
handler,
InstitutionSsoMultipleEmailsNotSupportedException.class.getSimpleName(),
OsfCasWebflowConstants.VIEW_ID_INSTITUTION_SSO_MULTIPLE_EMAILS_NOT_SUPPORTED
);
createTransitionForState(
handler,
InvalidUserStatusException.class.getSimpleName(),
Expand Down Expand Up @@ -470,6 +476,11 @@ private void createOsfCasAuthenticationExceptionViewStates(final Flow flow) {
OsfCasWebflowConstants.VIEW_ID_INSTITUTION_SSO_SELECTIVE_LOGIN_DENIED,
OsfCasWebflowConstants.VIEW_ID_INSTITUTION_SSO_SELECTIVE_LOGIN_DENIED
);
createViewState(
flow,
OsfCasWebflowConstants.VIEW_ID_INSTITUTION_SSO_MULTIPLE_EMAILS_NOT_SUPPORTED,
OsfCasWebflowConstants.VIEW_ID_INSTITUTION_SSO_MULTIPLE_EMAILS_NOT_SUPPORTED
);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import io.cos.cas.osf.authentication.exception.InstitutionSsoAttributeParsingException;
import io.cos.cas.osf.authentication.exception.InstitutionSsoDuplicateIdentityException;
import io.cos.cas.osf.authentication.exception.InstitutionSsoFailedException;
import io.cos.cas.osf.authentication.exception.InstitutionSsoMultipleEmailsNotSupportedException;
import io.cos.cas.osf.authentication.exception.InstitutionSsoSelectiveLoginDeniedException;
import io.cos.cas.osf.authentication.exception.InstitutionSsoOsfApiFailedException;
import io.cos.cas.osf.authentication.support.DelegationProtocol;
Expand Down Expand Up @@ -97,9 +98,12 @@
import java.io.IOException;
import java.io.StringWriter;
import java.util.Collections;
import java.util.Arrays;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;

/**
Expand Down Expand Up @@ -165,6 +169,8 @@ public class OsfPrincipalFromNonInteractiveCredentialsAction extends AbstractNon

private static final String ATTRIBUTE_PREFIX = "auth-";

private static final String MULTIPLE_ATTRIBUTE_DELIMITER = ";";

private static final String SHIBBOLETH_SESSION_HEADER = ATTRIBUTE_PREFIX + "shib-session-id";

private static final String SHIBBOLETH_COOKIE_PREFIX = "_shibsession_";
Expand Down Expand Up @@ -719,7 +725,7 @@ private OsfApiInstitutionAuthenticationResult notifyOsfApiOfInstnAuthnSuccess(
// CAS expects OSF API to return HTTP 204 OK with no content if authentication succeeds
if (statusCode == HttpStatus.SC_NO_CONTENT) {
LOGGER.info("[OSF API] Success - API request succeeded: {}, attempt={}, status={}", ssoUser, retry, statusCode);
return new OsfApiInstitutionAuthenticationResult(institutionId, ssoEmail, ssoIdentity);
return new OsfApiInstitutionAuthenticationResult(institutionId, deduplicateSsoEmail(ssoEmail, ssoUser), ssoIdentity);
}
if (OSF_API_RETRY_STATUS.contains(statusCode)) {
LOGGER.error("[OSF API] Failure - Server Error: {}, attempt={}, status={}", ssoUser, retry, statusCode);
Expand Down Expand Up @@ -791,6 +797,10 @@ private OsfApiInstitutionAuthenticationResult notifyOsfApiOfInstnAuthnSuccess(
LOGGER.error("[OSF API] Failure - Inactive Account: {}", ssoUser);
throw new InstitutionSsoAccountInactiveException("OSF API denies inactive account");
}
if (OsfApiPermissionDenied.INSTITUTION_SSO_MULTIPLE_EMAILS_NOT_SUPPORTED.getId().equals(errorDetail)) {
LOGGER.error("[OSF API] Failure - Multiple SSO Emails Error: {}", ssoUser);
throw new InstitutionSsoMultipleEmailsNotSupportedException("OSF API can't process multiple SSO emails");
}
}
// Handle unidentified HTTP 403 FORBIDDEN failures
LOGGER.error("[OSF API] Failure - HTTP 403 FORBIDDEN: {}, statusCode={}", ssoUser, statusCode);
Expand Down Expand Up @@ -864,4 +874,32 @@ private void setSsoErrorContext(
);
context.getFlowScope().put(PARAMETER_SSO_ERROR_CONTEXT, ssoErrorContext);
}

/**
* Attempt to deduplicate the {@code ssoEmail} attribute. This method is only called after OSF API has already
* successfully deduplicated the attribute and thus should throw {@link InstitutionSsoFailedException} if failed.
*
* @param ssoEmail the SSO email to deduplicate
* @param ssoUser the SSO user information
* @return deduplicated SSO email on success
* @throws InstitutionSsoFailedException if deduplication failed
*/
private String deduplicateSsoEmail(
final String ssoEmail,
final String ssoUser
) throws InstitutionSsoFailedException {
if (StringUtils.isBlank(ssoEmail)) {
LOGGER.error("[OSF CAS] Critical Error: SSO email should not be blank after OSF API success: [{}]", ssoUser);
throw new InstitutionSsoFailedException("SSO email should not be blank");
}
if (!ssoEmail.contains(MULTIPLE_ATTRIBUTE_DELIMITER)) {
return ssoEmail;
}
Set<String> ssoEmailSet = new HashSet<>(Arrays.asList(ssoEmail.split(MULTIPLE_ATTRIBUTE_DELIMITER)));
if (ssoEmailSet.size() != 1) {
LOGGER.error("[OSF CAS] Critical Error: SSO email should not fail deduplication after OSF API success: [{}]", ssoUser);
throw new InstitutionSsoFailedException("SSO email should not fail deduplication");
}
return ssoEmailSet.iterator().next();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ public interface OsfCasWebflowConstants {

String VIEW_ID_INSTITUTION_SSO_SELECTIVE_LOGIN_DENIED = "casInstitutionSsoSelectiveLoginDeniedView";

String VIEW_ID_INSTITUTION_SSO_MULTIPLE_EMAILS_NOT_SUPPORTED = "casInstitutionSsoMultipleEmailsNotSupportedView";

// Exception Views for OAuth 2.0 Authorization Flow

String VIEW_ID_OAUTH_20_ERROR_VIEW = "casOAuth20ErrorView";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
@Slf4j
public class OsfApiInstitutionAuthenticationResult implements Serializable {

/**
* Serialization metadata.
*/
private static final long serialVersionUID = 3971349776123204760L;

/**
Expand All @@ -44,10 +47,11 @@ public class OsfApiInstitutionAuthenticationResult implements Serializable {

/**
* Verify that the SSO email comes from one of the three attributes in Shibboleth SSO headers.
*
* Note: From OSF API's perspective, the email provided by SSO is stored in {@link #ssoEmail} which doesn't have to be
* the {@code username} f a candidate OSF user. From CAS's perspective, this {@link #ssoEmail} comes from three
* SSO attributes provided by Shibboleth's authn request: {@code eppn}, {@code mail} and {@code mailOther}.
* From OSF API's perspective, the email provided by SSO is stored in {@link #ssoEmail} which doesn't have to be
* the {@code username} f a candidate OSF user. From CAS's perspective, this {@link #ssoEmail} comes from three
* SSO attributes provided by Shibboleth's authn request: {@code eppn}, {@code mail} and {@code mailOther}.
* Since {@link #ssoEmail} may have already been deduplicated after successful OSF API request, this method does
* substring check instead of equality check.
*
* @param eppn the eppn attribute
* @param mail the mail attribute
Expand All @@ -59,6 +63,18 @@ public Boolean verifyOsfSsoEmail(final String eppn, final String mail, final Str
LOGGER.error("[CAS XSLT] SSO Email cannot be blank!");
return false;
}
return ssoEmail.equalsIgnoreCase(eppn) || ssoEmail.equalsIgnoreCase(mail) || ssoEmail.equalsIgnoreCase(mailOther);
boolean isPartOfEppn = Boolean.FALSE;
boolean isPartOfMail = Boolean.FALSE;
boolean isPartOfMailOther = Boolean.FALSE;
if (!StringUtils.isBlank(eppn)) {
isPartOfEppn = eppn.toLowerCase().contains(ssoEmail.toLowerCase());
}
if (!StringUtils.isBlank(mail)) {
isPartOfMail = mail.toLowerCase().contains(ssoEmail.toLowerCase());
}
if (!StringUtils.isBlank(mailOther)) {
isPartOfMailOther = mailOther.toLowerCase().contains(ssoEmail.toLowerCase());
}
return isPartOfEppn || isPartOfMail || isPartOfMailOther;
}
}
5 changes: 5 additions & 0 deletions src/main/resources/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,11 @@ screen.institutionssoosfapifailed.message=\
Your request cannot be completed at this time due to an unexpected error. \
Please <a style="white-space: nowrap" href="{0}">return to OSF</a> and try again later. \
If the issue persists, contact <a style="white-space: nowrap" href="mailto:support@osf.io">Support</a> for help.
screen.institutionssomultipleemailsnotsupported.message=\
Your request cannot be completed at this time. \
The system received multiple SSO emails from your institution. \
Please <a style="white-space: nowrap" href="{0}">return to OSF</a> and try again later. \
If the issue persists, contact <a style="white-space: nowrap" href="mailto:support@osf.io">Support</a> for help.

#
# OAuth 2.0 Views and Error Views
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layoutosf}">

<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />

<title th:text="#{screen.institutionssofailed.title}"></title>
<link href="../../static/css/cas.css" rel="stylesheet" th:remove="tag" />
</head>

<body class="mdc-typography">
<div layout:fragment="content" class="d-flex justify-content-center">

<div class="d-flex justify-content-center flex-md-row flex-column mdc-card mdc-card-content w-lg-30">
<section class="login-error-card">
<section>
<div th:replace="fragments/osfbannerui :: osfBannerUI">
<a href="fragments/osfbannerui.html"></a>
</div>
</section>
<section class="text-without-mdi text-center text-bold text-large margin-large-vertical title-danger">
<span th:utext="#{screen.authnerror.tips}"></span>
</section>
<hr class="my-4" />
<section class="card-message">
<h1 th:utext="#{screen.institutionssofailed.heading}"></h1>
<p th:utext="#{screen.institutionssomultipleemailsnotsupported.message}"></p>
</section>
<section class="form-button">
<a class="mdc-button mdc-button--raised button-osf-blue" th:href="@{/logout(service=${osfUrl.logout})}">
<span class="mdc-button__label" th:utext="#{screen.authnerror.button.backtoosf}"></span>
</a>
</section>
<hr class="my-4" />
<section class="text-with-mdi" th:with="loginUrl=@{${@casServerLoginUrl}(casRedirectSource=cas)}">
<span><a th:href="@{/logout(service=${loginUrl})}" th:utext="#{screen.error.page.loginagain}"></a></span>
</section>
</section>
</div>

<script type="text/javascript">
disableSignUpButton();
</script>

</div>
</body>

</html>
Loading