Skip to content

Commit b558b52

Browse files
authored
Merge pull request #86 from cslzchen/feature/handle-mulitple-sso-email-error
[ENG-8755] Handle institution SSO multiple emails not supported error (#86)
2 parents 92ff0ad + 2cca406 commit b558b52

9 files changed

Lines changed: 162 additions & 7 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package io.cos.cas.osf.authentication.exception;
2+
3+
import lombok.NoArgsConstructor;
4+
5+
import javax.security.auth.login.AccountException;
6+
7+
/**
8+
* Describes an authentication error condition where institution SSO has failed
9+
* due to OSF API not supporting multiple SSO emails.
10+
*
11+
* @author Longze Chen
12+
* @since 25.0.0
13+
*/
14+
@NoArgsConstructor
15+
public class InstitutionSsoMultipleEmailsNotSupportedException extends AccountException {
16+
17+
/**
18+
* Serialization metadata.
19+
*/
20+
private static final long serialVersionUID = -7703550523317297865L;
21+
22+
/**
23+
* Instantiates a new {@link InstitutionSsoMultipleEmailsNotSupportedException}.
24+
*
25+
* @param msg the msg
26+
*/
27+
public InstitutionSsoMultipleEmailsNotSupportedException(final String msg) {
28+
super(msg);
29+
}
30+
}

src/main/java/io/cos/cas/osf/authentication/support/OsfApiPermissionDenied.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ public enum OsfApiPermissionDenied {
1010

1111
DEFAULT("PermissionDenied"),
1212

13+
INSTITUTION_SSO_ACCOUNT_INACTIVE("InstitutionSsoAccountInactive"),
14+
1315
INSTITUTION_SSO_DUPLICATE_IDENTITY("InstitutionSsoDuplicateIdentity"),
1416

15-
INSTITUTION_SSO_ACCOUNT_INACTIVE("InstitutionSsoAccountInactive"),
17+
INSTITUTION_SSO_MULTIPLE_EMAILS_NOT_SUPPORTED("InstitutionSsoMultipleEmailsNotSupported"),
1618

1719
INSTITUTION_SSO_SELECTIVE_LOGIN_DENIED("InstitutionSsoSelectiveLoginDenied");
1820

src/main/java/io/cos/cas/osf/web/flow/config/OsfCasCoreWebflowConfiguration.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import io.cos.cas.osf.authentication.exception.InstitutionSsoAttributeParsingException;
88
import io.cos.cas.osf.authentication.exception.InstitutionSsoDuplicateIdentityException;
99
import io.cos.cas.osf.authentication.exception.InstitutionSsoFailedException;
10+
import io.cos.cas.osf.authentication.exception.InstitutionSsoMultipleEmailsNotSupportedException;
1011
import io.cos.cas.osf.authentication.exception.InstitutionSsoOsfApiFailedException;
1112
import io.cos.cas.osf.authentication.exception.InstitutionSsoSelectiveLoginDeniedException;
1213
import io.cos.cas.osf.authentication.exception.InvalidOneTimePasswordException;
@@ -53,6 +54,7 @@ public Set<Class<? extends Throwable>> handledAuthenticationExceptions() {
5354
errors.add(InstitutionSsoAttributeParsingException.class);
5455
errors.add(InstitutionSsoDuplicateIdentityException.class);
5556
errors.add(InstitutionSsoFailedException.class);
57+
errors.add(InstitutionSsoMultipleEmailsNotSupportedException.class);
5658
errors.add(InstitutionSsoOsfApiFailedException.class);
5759
errors.add(InstitutionSsoSelectiveLoginDeniedException.class);
5860
errors.add(InvalidOneTimePasswordException.class);

src/main/java/io/cos/cas/osf/web/flow/configurer/OsfCasLoginWebflowConfigurer.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import io.cos.cas.osf.authentication.exception.InstitutionSsoAttributeParsingException;
99
import io.cos.cas.osf.authentication.exception.InstitutionSsoDuplicateIdentityException;
1010
import io.cos.cas.osf.authentication.exception.InstitutionSsoFailedException;
11+
import io.cos.cas.osf.authentication.exception.InstitutionSsoMultipleEmailsNotSupportedException;
1112
import io.cos.cas.osf.authentication.exception.InstitutionSsoOsfApiFailedException;
1213
import io.cos.cas.osf.authentication.exception.InstitutionSsoSelectiveLoginDeniedException;
1314
import io.cos.cas.osf.authentication.exception.InvalidOneTimePasswordException;
@@ -270,6 +271,11 @@ protected void createHandleAuthenticationFailureAction(final Flow flow) {
270271
InstitutionSsoSelectiveLoginDeniedException.class.getSimpleName(),
271272
OsfCasWebflowConstants.VIEW_ID_INSTITUTION_SSO_SELECTIVE_LOGIN_DENIED
272273
);
274+
createTransitionForState(
275+
handler,
276+
InstitutionSsoMultipleEmailsNotSupportedException.class.getSimpleName(),
277+
OsfCasWebflowConstants.VIEW_ID_INSTITUTION_SSO_MULTIPLE_EMAILS_NOT_SUPPORTED
278+
);
273279
createTransitionForState(
274280
handler,
275281
InvalidUserStatusException.class.getSimpleName(),
@@ -470,6 +476,11 @@ private void createOsfCasAuthenticationExceptionViewStates(final Flow flow) {
470476
OsfCasWebflowConstants.VIEW_ID_INSTITUTION_SSO_SELECTIVE_LOGIN_DENIED,
471477
OsfCasWebflowConstants.VIEW_ID_INSTITUTION_SSO_SELECTIVE_LOGIN_DENIED
472478
);
479+
createViewState(
480+
flow,
481+
OsfCasWebflowConstants.VIEW_ID_INSTITUTION_SSO_MULTIPLE_EMAILS_NOT_SUPPORTED,
482+
OsfCasWebflowConstants.VIEW_ID_INSTITUTION_SSO_MULTIPLE_EMAILS_NOT_SUPPORTED
483+
);
473484
}
474485

475486
/**

src/main/java/io/cos/cas/osf/web/flow/login/OsfPrincipalFromNonInteractiveCredentialsAction.java

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import io.cos.cas.osf.authentication.exception.InstitutionSsoAttributeParsingException;
1313
import io.cos.cas.osf.authentication.exception.InstitutionSsoDuplicateIdentityException;
1414
import io.cos.cas.osf.authentication.exception.InstitutionSsoFailedException;
15+
import io.cos.cas.osf.authentication.exception.InstitutionSsoMultipleEmailsNotSupportedException;
1516
import io.cos.cas.osf.authentication.exception.InstitutionSsoSelectiveLoginDeniedException;
1617
import io.cos.cas.osf.authentication.exception.InstitutionSsoOsfApiFailedException;
1718
import io.cos.cas.osf.authentication.support.DelegationProtocol;
@@ -97,9 +98,12 @@
9798
import java.io.IOException;
9899
import java.io.StringWriter;
99100
import java.util.Collections;
101+
import java.util.Arrays;
100102
import java.util.Date;
103+
import java.util.HashSet;
101104
import java.util.List;
102105
import java.util.Map;
106+
import java.util.Set;
103107
import java.util.concurrent.TimeUnit;
104108

105109
/**
@@ -165,6 +169,8 @@ public class OsfPrincipalFromNonInteractiveCredentialsAction extends AbstractNon
165169

166170
private static final String ATTRIBUTE_PREFIX = "auth-";
167171

172+
private static final String MULTIPLE_ATTRIBUTE_DELIMITER = ";";
173+
168174
private static final String SHIBBOLETH_SESSION_HEADER = ATTRIBUTE_PREFIX + "shib-session-id";
169175

170176
private static final String SHIBBOLETH_COOKIE_PREFIX = "_shibsession_";
@@ -719,7 +725,7 @@ private OsfApiInstitutionAuthenticationResult notifyOsfApiOfInstnAuthnSuccess(
719725
// CAS expects OSF API to return HTTP 204 OK with no content if authentication succeeds
720726
if (statusCode == HttpStatus.SC_NO_CONTENT) {
721727
LOGGER.info("[OSF API] Success - API request succeeded: {}, attempt={}, status={}", ssoUser, retry, statusCode);
722-
return new OsfApiInstitutionAuthenticationResult(institutionId, ssoEmail, ssoIdentity);
728+
return new OsfApiInstitutionAuthenticationResult(institutionId, deduplicateSsoEmail(ssoEmail, ssoUser), ssoIdentity);
723729
}
724730
if (OSF_API_RETRY_STATUS.contains(statusCode)) {
725731
LOGGER.error("[OSF API] Failure - Server Error: {}, attempt={}, status={}", ssoUser, retry, statusCode);
@@ -791,6 +797,10 @@ private OsfApiInstitutionAuthenticationResult notifyOsfApiOfInstnAuthnSuccess(
791797
LOGGER.error("[OSF API] Failure - Inactive Account: {}", ssoUser);
792798
throw new InstitutionSsoAccountInactiveException("OSF API denies inactive account");
793799
}
800+
if (OsfApiPermissionDenied.INSTITUTION_SSO_MULTIPLE_EMAILS_NOT_SUPPORTED.getId().equals(errorDetail)) {
801+
LOGGER.error("[OSF API] Failure - Multiple SSO Emails Error: {}", ssoUser);
802+
throw new InstitutionSsoMultipleEmailsNotSupportedException("OSF API can't process multiple SSO emails");
803+
}
794804
}
795805
// Handle unidentified HTTP 403 FORBIDDEN failures
796806
LOGGER.error("[OSF API] Failure - HTTP 403 FORBIDDEN: {}, statusCode={}", ssoUser, statusCode);
@@ -864,4 +874,32 @@ private void setSsoErrorContext(
864874
);
865875
context.getFlowScope().put(PARAMETER_SSO_ERROR_CONTEXT, ssoErrorContext);
866876
}
877+
878+
/**
879+
* Attempt to deduplicate the {@code ssoEmail} attribute. This method is only called after OSF API has already
880+
* successfully deduplicated the attribute and thus should throw {@link InstitutionSsoFailedException} if failed.
881+
*
882+
* @param ssoEmail the SSO email to deduplicate
883+
* @param ssoUser the SSO user information
884+
* @return deduplicated SSO email on success
885+
* @throws InstitutionSsoFailedException if deduplication failed
886+
*/
887+
private String deduplicateSsoEmail(
888+
final String ssoEmail,
889+
final String ssoUser
890+
) throws InstitutionSsoFailedException {
891+
if (StringUtils.isBlank(ssoEmail)) {
892+
LOGGER.error("[OSF CAS] Critical Error: SSO email should not be blank after OSF API success: [{}]", ssoUser);
893+
throw new InstitutionSsoFailedException("SSO email should not be blank");
894+
}
895+
if (!ssoEmail.contains(MULTIPLE_ATTRIBUTE_DELIMITER)) {
896+
return ssoEmail;
897+
}
898+
Set<String> ssoEmailSet = new HashSet<>(Arrays.asList(ssoEmail.split(MULTIPLE_ATTRIBUTE_DELIMITER)));
899+
if (ssoEmailSet.size() != 1) {
900+
LOGGER.error("[OSF CAS] Critical Error: SSO email should not fail deduplication after OSF API success: [{}]", ssoUser);
901+
throw new InstitutionSsoFailedException("SSO email should not fail deduplication");
902+
}
903+
return ssoEmailSet.iterator().next();
904+
}
867905
}

src/main/java/io/cos/cas/osf/web/flow/support/OsfCasWebflowConstants.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ public interface OsfCasWebflowConstants {
7676

7777
String VIEW_ID_INSTITUTION_SSO_SELECTIVE_LOGIN_DENIED = "casInstitutionSsoSelectiveLoginDeniedView";
7878

79+
String VIEW_ID_INSTITUTION_SSO_MULTIPLE_EMAILS_NOT_SUPPORTED = "casInstitutionSsoMultipleEmailsNotSupportedView";
80+
7981
// Exception Views for OAuth 2.0 Authorization Flow
8082

8183
String VIEW_ID_OAUTH_20_ERROR_VIEW = "casOAuth20ErrorView";

src/main/java/io/cos/cas/osf/web/support/OsfApiInstitutionAuthenticationResult.java

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
@Slf4j
2626
public class OsfApiInstitutionAuthenticationResult implements Serializable {
2727

28+
/**
29+
* Serialization metadata.
30+
*/
2831
private static final long serialVersionUID = 3971349776123204760L;
2932

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

4548
/**
4649
* Verify that the SSO email comes from one of the three attributes in Shibboleth SSO headers.
47-
*
48-
* Note: From OSF API's perspective, the email provided by SSO is stored in {@link #ssoEmail} which doesn't have to be
49-
* the {@code username} f a candidate OSF user. From CAS's perspective, this {@link #ssoEmail} comes from three
50-
* SSO attributes provided by Shibboleth's authn request: {@code eppn}, {@code mail} and {@code mailOther}.
50+
* From OSF API's perspective, the email provided by SSO is stored in {@link #ssoEmail} which doesn't have to be
51+
* the {@code username} f a candidate OSF user. From CAS's perspective, this {@link #ssoEmail} comes from three
52+
* SSO attributes provided by Shibboleth's authn request: {@code eppn}, {@code mail} and {@code mailOther}.
53+
* Since {@link #ssoEmail} may have already been deduplicated after successful OSF API request, this method does
54+
* substring check instead of equality check.
5155
*
5256
* @param eppn the eppn attribute
5357
* @param mail the mail attribute
@@ -59,6 +63,18 @@ public Boolean verifyOsfSsoEmail(final String eppn, final String mail, final Str
5963
LOGGER.error("[CAS XSLT] SSO Email cannot be blank!");
6064
return false;
6165
}
62-
return ssoEmail.equalsIgnoreCase(eppn) || ssoEmail.equalsIgnoreCase(mail) || ssoEmail.equalsIgnoreCase(mailOther);
66+
boolean isPartOfEppn = Boolean.FALSE;
67+
boolean isPartOfMail = Boolean.FALSE;
68+
boolean isPartOfMailOther = Boolean.FALSE;
69+
if (!StringUtils.isBlank(eppn)) {
70+
isPartOfEppn = eppn.toLowerCase().contains(ssoEmail.toLowerCase());
71+
}
72+
if (!StringUtils.isBlank(mail)) {
73+
isPartOfMail = mail.toLowerCase().contains(ssoEmail.toLowerCase());
74+
}
75+
if (!StringUtils.isBlank(mailOther)) {
76+
isPartOfMailOther = mailOther.toLowerCase().contains(ssoEmail.toLowerCase());
77+
}
78+
return isPartOfEppn || isPartOfMail || isPartOfMailOther;
6379
}
6480
}

src/main/resources/messages.properties

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,11 @@ screen.institutionssoosfapifailed.message=\
721721
Your request cannot be completed at this time due to an unexpected error. \
722722
Please <a style="white-space: nowrap" href="{0}">return to OSF</a> and try again later. \
723723
If the issue persists, contact <a style="white-space: nowrap" href="mailto:support@osf.io">Support</a> for help.
724+
screen.institutionssomultipleemailsnotsupported.message=\
725+
Your request cannot be completed at this time. \
726+
The system received multiple SSO emails from your institution. \
727+
Please <a style="white-space: nowrap" href="{0}">return to OSF</a> and try again later. \
728+
If the issue persists, contact <a style="white-space: nowrap" href="mailto:support@osf.io">Support</a> for help.
724729

725730
#
726731
# OAuth 2.0 Views and Error Views
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<!DOCTYPE html>
2+
<html xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layoutosf}">
3+
4+
<head>
5+
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
7+
8+
<title th:text="#{screen.institutionssofailed.title}"></title>
9+
<link href="../../static/css/cas.css" rel="stylesheet" th:remove="tag" />
10+
</head>
11+
12+
<body class="mdc-typography">
13+
<div layout:fragment="content" class="d-flex justify-content-center">
14+
15+
<div class="d-flex justify-content-center flex-md-row flex-column mdc-card mdc-card-content w-lg-30">
16+
<section class="login-error-card">
17+
<section>
18+
<div th:replace="fragments/osfbannerui :: osfBannerUI">
19+
<a href="fragments/osfbannerui.html"></a>
20+
</div>
21+
</section>
22+
<section class="text-without-mdi text-center text-bold text-large margin-large-vertical title-danger">
23+
<span th:utext="#{screen.authnerror.tips}"></span>
24+
</section>
25+
<hr class="my-4" />
26+
<section class="card-message">
27+
<h1 th:utext="#{screen.institutionssofailed.heading}"></h1>
28+
<p th:utext="#{screen.institutionssomultipleemailsnotsupported.message}"></p>
29+
</section>
30+
<section class="form-button">
31+
<a class="mdc-button mdc-button--raised button-osf-blue" th:href="@{/logout(service=${osfUrl.logout})}">
32+
<span class="mdc-button__label" th:utext="#{screen.authnerror.button.backtoosf}"></span>
33+
</a>
34+
</section>
35+
<hr class="my-4" />
36+
<section class="text-with-mdi" th:with="loginUrl=@{${@casServerLoginUrl}(casRedirectSource=cas)}">
37+
<span><a th:href="@{/logout(service=${loginUrl})}" th:utext="#{screen.error.page.loginagain}"></a></span>
38+
</section>
39+
</section>
40+
</div>
41+
42+
<script type="text/javascript">
43+
disableSignUpButton();
44+
</script>
45+
46+
</div>
47+
</body>
48+
49+
</html>

0 commit comments

Comments
 (0)