diff --git a/cas-server-support-osf/src/main/java/io/cos/cas/authentication/exceptions/InstitutionLoginFailedOsfApiLoAException.java b/cas-server-support-osf/src/main/java/io/cos/cas/authentication/exceptions/InstitutionLoginFailedOsfApiLoAException.java new file mode 100644 index 00000000..f5be5dc5 --- /dev/null +++ b/cas-server-support-osf/src/main/java/io/cos/cas/authentication/exceptions/InstitutionLoginFailedOsfApiLoAException.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2020. Center for Open Science + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.cos.cas.authentication.exceptions; + +/** + * Describes an error condition where institution login fails when communicating with OSF API. + * + * @author Longze Chen + * @since 20.1.0 + */ +public class InstitutionLoginFailedOsfApiLoAException extends InstitutionLoginFailedException { + + private static final long serialVersionUID = 1737367176204402913L; + + /** Instantiates a new exception (default). */ + public InstitutionLoginFailedOsfApiLoAException() { + super(); + } + + /** + * Instantiates a new exception with a given message. + * + * @param message the message + */ + public InstitutionLoginFailedOsfApiLoAException(final String message) { + super(message); + } +} diff --git a/cas-server-support-osf/src/main/java/io/cos/cas/authentication/handler/support/OpenScienceFrameworkPrincipalFromRequestRemoteUserNonInteractiveCredentialsAction.java b/cas-server-support-osf/src/main/java/io/cos/cas/authentication/handler/support/OpenScienceFrameworkPrincipalFromRequestRemoteUserNonInteractiveCredentialsAction.java index c47bf3fb..f15bbd6b 100644 --- a/cas-server-support-osf/src/main/java/io/cos/cas/authentication/handler/support/OpenScienceFrameworkPrincipalFromRequestRemoteUserNonInteractiveCredentialsAction.java +++ b/cas-server-support-osf/src/main/java/io/cos/cas/authentication/handler/support/OpenScienceFrameworkPrincipalFromRequestRemoteUserNonInteractiveCredentialsAction.java @@ -1,6 +1,6 @@ /* * Copyright (c) 2015. Center for Open Science - * + * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at @@ -35,6 +35,7 @@ import io.cos.cas.authentication.exceptions.InstitutionLoginFailedAttributesMissingException; import io.cos.cas.authentication.exceptions.InstitutionLoginFailedAttributesParsingException; import io.cos.cas.authentication.exceptions.InstitutionLoginFailedOsfApiException; +import io.cos.cas.authentication.exceptions.InstitutionLoginFailedOsfApiLoAException; // @R2022-48 loa import io.cos.cas.authentication.OpenScienceFrameworkCredential; import org.apache.http.client.fluent.Request; @@ -58,6 +59,7 @@ import org.jasig.cas.ticket.TicketGrantingTicket; import org.jasig.cas.web.support.WebUtils; import org.json.JSONObject; +import org.json.JSONException; import org.json.XML; import org.pac4j.oauth.client.OrcidClient; @@ -140,16 +142,19 @@ public static class PrincipalAuthenticationResult { private String username; private String institutionId; + private String context; /** * Creates a new instance with the given parameters. * * @param username The username * @param institutionId The institution id + * @param context The context */ - public PrincipalAuthenticationResult(final String username, final String institutionId) { + public PrincipalAuthenticationResult(final String username, final String institutionId, final String context) { this.username = username; this.institutionId = institutionId; + this.context = context; } public String getUsername() { @@ -159,6 +164,10 @@ public String getUsername() { public String getInstitutionId() { return institutionId; } + + public String getContext() { + return context; + } } private static final String CONST_CREDENTIAL = "credential"; @@ -322,6 +331,7 @@ protected OpenScienceFrameworkCredential constructCredential( ) throws AccountException, FailedLoginException { final HttpServletRequest request = WebUtils.getHttpServletRequest(context); + final HttpServletResponse response = WebUtils.getHttpServletResponse(context); // WARN: Do not use `WebUtils.getCredential(RequestContext context)`, it will make the credential `null`. // TODO: Check both `FlowScope` and `RequestScope`. Write a `.getCredential(RequestContext context)` which @@ -372,22 +382,61 @@ protected OpenScienceFrameworkCredential constructCredential( for (final String headerName : Collections.list(request.getHeaderNames())) { if (headerName.startsWith(ATTRIBUTE_PREFIX)) { final String headerValue = request.getHeader(headerName); + String decodedValue; + if (headerValue == null) { + decodedValue = headerValue; + } else { + try { + decodedValue = new String(headerValue.getBytes("ISO-8859-1"), "UTF-8"); + } catch (final java.io.UnsupportedEncodingException e) { + decodedValue = headerValue; // UTF-8 is always supported, this never happens + } + } logger.debug( "[SAML Shibboleth] User's institutional identity '{}' - auth header '{}': '{}'", remoteUser, headerName, - headerValue + decodedValue ); credential.getDelegationAttributes().put( headerName.substring(ATTRIBUTE_PREFIX.length()), - headerValue + decodedValue ); } } + logger.info("[SAML Shibboleth] credential : '{}'", credential); + // Parse the attributes and notify OSF API of the remote principal authentication final PrincipalAuthenticationResult remoteUserInfo = notifyRemotePrincipalAuthenticated(credential); - + final String remoteUserContext = remoteUserInfo.getContext(); + final JSONObject json; + logger.info("[SAML Shibboleth] context : '{}'", remoteUserContext); + if (StringUtils.hasText(remoteUserContext)) { + try { + json = new JSONObject(remoteUserContext); + } catch (final JSONException e) { + logger.error( + "[OSF API] Notify Remote Principal Authenticated Failed: Communication Error - {}", + e.getMessage() + ); + throw new InstitutionLoginFailedOsfApiException("Communication Error between OSF CAS and OSF API"); + } + final String mfaUrl = json.optString("mfa_url"); + if (StringUtils.hasText(mfaUrl)) { + try { + logger.info("[OSF API] Redirect MFA URL: '{}'", mfaUrl); + response.sendRedirect(mfaUrl); + return null; + } catch (final IOException e) { + logger.error( + "[OSF API] Notify Remote Principal Authenticated Failed: Communication Error - {}", + e.getMessage() + ); + throw new InstitutionLoginFailedOsfApiException("Communication Error between OSF CAS and OSF API"); + } + } + } // Build and return the OSF-specific credential credential.setUsername(remoteUserInfo.getUsername()); credential.setInstitutionId(remoteUserInfo.getInstitutionId()); @@ -505,7 +554,34 @@ protected OpenScienceFrameworkCredential constructCredential( // Parse the attributes and notify OSF API of the remote principal authentication final PrincipalAuthenticationResult remoteUserInfo = notifyRemotePrincipalAuthenticated(credential); - + final String remoteUserContext = remoteUserInfo.getContext(); + final JSONObject json; + logger.info("[CAS PAC4J] context : '{}'", remoteUserContext); + if (StringUtils.hasText(remoteUserContext)) { + try { + json = new JSONObject(remoteUserContext); + } catch (final JSONException e) { + logger.error( + "[OSF API] Notify Remote Principal Authenticated Failed: Communication Error - {}", + e.getMessage() + ); + throw new InstitutionLoginFailedOsfApiException("Communication Error between OSF CAS and OSF API"); + } + final String mfaUrl = json.optString("mfa_url"); + if (StringUtils.hasText(mfaUrl)) { + try { + logger.info("[OSF API] Redirect MFA URL: '{}'", mfaUrl); + response.sendRedirect(mfaUrl); + return null; + } catch (final IOException e) { + logger.error( + "[OSF API] Notify Remote Principal Authenticated Failed: Communication Error - {}", + e.getMessage() + ); + throw new InstitutionLoginFailedOsfApiException("Communication Error between OSF CAS and OSF API"); + } + } + } credential.setUsername(remoteUserInfo.getUsername()); credential.setInstitutionId(remoteUserInfo.getInstitutionId()); @@ -567,6 +643,8 @@ protected PrincipalAuthenticationResult notifyRemotePrincipalAuthenticated( logger.error("[CAS XSLT] Missing institutional user"); throw new InstitutionLoginFailedAttributesMissingException("Missing institutional user"); } + final String givenNameTmp = user.optString("givenName"); + logger.info("[CAS XSLT] All attributes checked: givenNameTmp={}", givenNameTmp); final String username = user.optString("username").trim(); final String fullname = user.optString("fullname").trim(); final String givenName = user.optString("givenName").trim(); @@ -579,9 +657,10 @@ protected PrincipalAuthenticationResult notifyRemotePrincipalAuthenticated( logger.error("[CAS XSLT] Missing names: username={}, institution={}", username, institutionId); throw new InstitutionLoginFailedAttributesMissingException("Missing user's names"); } - + logger.info("[CAS XSLT] All attributes checked: givenName={}", givenName); // Call Login Availability API final String entitlement = user.optString("entitlement").trim(); + logger.info("[CAS XSLT] All attributes checked: entitlement={}", entitlement); if (!StringUtils.isEmpty(entitlement)) { // send post method to RDM API final JSONObject bodyObj = new JSONObject(); @@ -589,7 +668,11 @@ protected PrincipalAuthenticationResult notifyRemotePrincipalAuthenticated( bodyObj.put("institution_id", institutionId); bodyObj.put("entitlements", getEntitlements(normalizeEntitlement)); user.put("entitlement", normalizeEntitlement); // normalize entitlement in payload - + logger.info( + "[CAS XSLT] All attributes checked: institution_id={}, normalizeEntitlement={}", + institutionId, + normalizeEntitlement + ); HttpResponse httpResponse; try { httpResponse = callLoginAvailabilityAPI(bodyObj); @@ -658,30 +741,36 @@ protected PrincipalAuthenticationResult notifyRemotePrincipalAuthenticated( .execute() .returnResponse(); final int statusCode = httpResponse.getStatusLine().getStatusCode(); + final String context = new BasicResponseHandler().handleResponse(httpResponse); logger.info( - "[OSF API] Notify Remote Principal Authenticated Response: username={} statusCode={}", + "[OSF API] Notify Remote Principal Authenticated Response: username={} statusCode={} context={}", username, - statusCode + statusCode, + context ); // The OSF API institution authentication endpoint always returns the HTTP 204 No Content if successful. - if (statusCode != HttpStatus.SC_NO_CONTENT) { - final String responseString = new BasicResponseHandler().handleResponse(httpResponse); + //if (statusCode != HttpStatus.SC_NO_CONTENT) { + if (statusCode != HttpStatus.SC_OK && statusCode != HttpStatus.SC_NO_CONTENT) { logger.error( - "[OSF API] Notify Remote Principal Authenticated Failed: statusCode={}, body={}", + "[OSF API] Notify Remote Principal Authenticated Failed: statusCode={}, context={}", statusCode, - responseString + context ); throw new InstitutionLoginFailedOsfApiException("OSF API failed to process CAS request"); } - // Return user's username and the institution ID to build the OSF credential - return new PrincipalAuthenticationResult(username, institutionId); + return new PrincipalAuthenticationResult(username, institutionId, context); } catch (final IOException e) { + final String errmsg = e.getMessage(); logger.error( "[OSF API] Notify Remote Principal Authenticated Failed: Communication Error - {}", e.getMessage() ); - throw new InstitutionLoginFailedOsfApiException("Communication Error between OSF CAS and OSF API"); + if ("Bad Request".equals(errmsg)) { + throw new InstitutionLoginFailedOsfApiLoAException("Communication Error between OSF CAS and OSF API"); + } else { + throw new InstitutionLoginFailedOsfApiException("Communication Error between OSF CAS and OSF API"); + } } } diff --git a/cas-server-support-osf/src/main/java/io/cos/cas/web/flow/OpenScienceFrameworkAuthenticationExceptionHandler.java b/cas-server-support-osf/src/main/java/io/cos/cas/web/flow/OpenScienceFrameworkAuthenticationExceptionHandler.java index 73d1e044..5d1981ba 100644 --- a/cas-server-support-osf/src/main/java/io/cos/cas/web/flow/OpenScienceFrameworkAuthenticationExceptionHandler.java +++ b/cas-server-support-osf/src/main/java/io/cos/cas/web/flow/OpenScienceFrameworkAuthenticationExceptionHandler.java @@ -29,6 +29,7 @@ import io.cos.cas.authentication.exceptions.InstitutionLoginFailedAttributesMissingException; import io.cos.cas.authentication.exceptions.InstitutionLoginFailedAttributesParsingException; import io.cos.cas.authentication.exceptions.InstitutionLoginFailedOsfApiException; +import io.cos.cas.authentication.exceptions.InstitutionLoginFailedOsfApiLoAException; // @R2022-48 loa import io.cos.cas.authentication.exceptions.InvalidUserStatusException; import io.cos.cas.authentication.exceptions.InvalidVerificationKeyException; import io.cos.cas.authentication.exceptions.OneTimePasswordFailedLoginException; @@ -90,6 +91,7 @@ public class OpenScienceFrameworkAuthenticationExceptionHandler extends Authenti DEFAULT_ERROR_LIST.add(InstitutionLoginFailedAttributesMissingException.class); DEFAULT_ERROR_LIST.add(InstitutionLoginFailedAttributesParsingException.class); DEFAULT_ERROR_LIST.add(InstitutionLoginFailedOsfApiException.class); + DEFAULT_ERROR_LIST.add(InstitutionLoginFailedOsfApiLoAException.class); // @R2022-48 loa DEFAULT_ERROR_LIST.add(InvalidVerificationKeyException.class); DEFAULT_ERROR_LIST.add(InvalidUserStatusException.class); DEFAULT_ERROR_LIST.add(OneTimePasswordFailedLoginException.class); diff --git a/cas-server-support-osf/src/main/java/io/cos/cas/web/flow/OpenScienceFrameworkTerminateSessionAction.java b/cas-server-support-osf/src/main/java/io/cos/cas/web/flow/OpenScienceFrameworkTerminateSessionAction.java index 40d5fbe5..fdfc6605 100644 --- a/cas-server-support-osf/src/main/java/io/cos/cas/web/flow/OpenScienceFrameworkTerminateSessionAction.java +++ b/cas-server-support-osf/src/main/java/io/cos/cas/web/flow/OpenScienceFrameworkTerminateSessionAction.java @@ -91,9 +91,11 @@ public Event terminate(final RequestContext context) { String institutionId = null; Boolean remotePrincipal = Boolean.FALSE; + final HttpServletRequest request = WebUtils.getHttpServletRequest(context); + final String serviceUrl = request.getParameter("service"); + logger.info("[serviceUrl] Param: '{}'", serviceUrl); // for logout, we need to get the cookie's value if (tgtId == null) { - final HttpServletRequest request = WebUtils.getHttpServletRequest(context); tgtId = this.ticketGrantingTicketCookieGenerator.retrieveCookieValue(request); } // for institution logout, get the institutionId stored in TGT @@ -122,9 +124,14 @@ public Event terminate(final RequestContext context) { this.ticketGrantingTicketCookieGenerator.removeCookie(response); this.warnCookieGenerator.removeCookie(response); + final String institutionLogoutUrl; // if logged in through institutions, redirect to institution logout endpoint if (remotePrincipal && institutionId != null) { - final String institutionLogoutUrl = institutionHandler.findInstitutionLogoutUrlById(institutionId); + if (serviceUrl != null) { + institutionLogoutUrl = serviceUrl; + } else { + institutionLogoutUrl = institutionHandler.findInstitutionLogoutUrlById(institutionId); + } if (institutionLogoutUrl == null) { logger.warn("Institution {} does not have a dedicated logout url, use default logout redirection instead", institutionId); } else { @@ -132,6 +139,9 @@ public Event terminate(final RequestContext context) { // return `finish` event to prevent `logoutRedirectUrl` being overwritten return new Event(this, "finish"); } + } else if (serviceUrl != null) { + context.getFlowScope().put("logoutRedirectUrl", serviceUrl); + return new Event(this, "finish"); } return this.eventFactorySupport.success(this); diff --git a/cas-server-support-osf/src/test/java/io/cos/cas/AbstractTestUtils.java b/cas-server-support-osf/src/test/java/io/cos/cas/AbstractTestUtils.java index 1daa3c67..944ba9db 100644 --- a/cas-server-support-osf/src/test/java/io/cos/cas/AbstractTestUtils.java +++ b/cas-server-support-osf/src/test/java/io/cos/cas/AbstractTestUtils.java @@ -45,6 +45,8 @@ public abstract class AbstractTestUtils { public static final String[] CONST_SINGLE_ENTITLEMENTS_OUTPUT = {"value1-1", "value1-2", "value1-3"}; + public static final String CONST_JSON_STRING = "{\"key1-1\":\"value1-1\"}"; + private static final String REMOTE_USER = "REMOTE_USER"; private static final String ATTRIBUTE_PREFIX = "AUTH-"; diff --git a/cas-server-support-osf/src/test/java/io/cos/cas/authentication/handler/support/OpenScienceFrameworkPrincipalFromRequestRemoteUserNonInteractiveCredentialsActionTests.java b/cas-server-support-osf/src/test/java/io/cos/cas/authentication/handler/support/OpenScienceFrameworkPrincipalFromRequestRemoteUserNonInteractiveCredentialsActionTests.java index 2bd48c19..64ea4cb3 100644 --- a/cas-server-support-osf/src/test/java/io/cos/cas/authentication/handler/support/OpenScienceFrameworkPrincipalFromRequestRemoteUserNonInteractiveCredentialsActionTests.java +++ b/cas-server-support-osf/src/test/java/io/cos/cas/authentication/handler/support/OpenScienceFrameworkPrincipalFromRequestRemoteUserNonInteractiveCredentialsActionTests.java @@ -10,6 +10,7 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.Enumeration; import java.util.List; import javax.security.auth.login.AccountException; @@ -301,4 +302,269 @@ public void verifyLoginAvailabilityExceptionFlow() throws Exception { .setInstitutionsLoginAvailabilityUrl(AbstractTestUtils.CONST_INSTITUTION_LOGIN_AVAILABILITY_URL); osfRemoteAuthenticate.notifyRemotePrincipalAuthenticated(osfCredential); } + + /** + * Verifies that a Shibboleth (SAML) authentication flow correctly handles an {@code AUTH-} prefixed request header + * whose value is {@code null}. + */ + @Test + public void verifyInstitutionSamlShibbolethFlowWithNullHeaderValue() throws Exception { + + // The name of the AUTH- prefixed header whose value will be forced to null. + final String nullAttributeHeaderName = "AUTH-NullAttribute"; + final String nullAttributeKey = "NullAttribute"; // stripped prefix + + // Build a MockHttpServletRequest that reports nullAttributeHeaderName in getHeaderNames() but + // returns null from getHeader() for that specific header, triggering the null-branch in the loop. + final MockHttpServletRequest mockHttpServletRequest = new MockHttpServletRequest() { + @Override + public String getHeader(final String name) { + if (nullAttributeHeaderName.equalsIgnoreCase(name)) { + return null; + } + return super.getHeader(name); + } + + @Override + public Enumeration getHeaderNames() { + final List names = new ArrayList<>(Collections.list(super.getHeaderNames())); + if (!names.contains(nullAttributeHeaderName)) { + names.add(nullAttributeHeaderName); + } + return Collections.enumeration(names); + } + }; + + // Add standard Shibboleth headers so the SAML Shibboleth branch is entered. + mockHttpServletRequest.addHeader("AUTH-Shib-Session-ID", AbstractTestUtils.CONST_NOT_EMPTY_STRING); + mockHttpServletRequest.addHeader("REMOTE_USER", AbstractTestUtils.CONST_NOT_EMPTY_STRING); + mockHttpServletRequest.addHeader("AUTH-Shib-Identity-Provider", AbstractTestUtils.CONST_INSTITUTION_IDP); + mockHttpServletRequest.addHeader("AUTH-displayName", AbstractTestUtils.CONST_DISPLAY_NAME); + mockHttpServletRequest.addHeader("AUTH-givenName", "James"); + mockHttpServletRequest.addHeader("AUTH-familyName", "Steward"); + mockHttpServletRequest.addHeader("AUTH-mail", AbstractTestUtils.CONST_MAIL); + + final MockRequestContext mockContext = AbstractTestUtils.getContextWithCredentials(mockHttpServletRequest); + + final CentralAuthenticationService centralAuthenticationService = mock(CentralAuthenticationService.class); + final MockNotifyRemotePrincipalAuthenticated osfRemoteAuthenticate + = new MockNotifyRemotePrincipalAuthenticated(centralAuthenticationService); + + final Event event = osfRemoteAuthenticate.doExecute(mockContext); + + final OpenScienceFrameworkCredential credential + = (OpenScienceFrameworkCredential) mockContext.getFlowScope().get(AbstractTestUtils.CONST_CREDENTIAL); + + // The flow must still complete successfully. + assertEquals("success", event.getId()); + assertTrue(credential.isRemotePrincipal()); + assertEquals(DelegationProtocol.SAML_SHIB, credential.getDelegationProtocol()); + + // The null-valued AUTH- header must appear in delegationAttributes with a null value, + // confirming that the `if (headerValue == null) { decodedValue = headerValue; }` branch was taken. + assertTrue( + "delegationAttributes must contain the key for the null-valued header", + credential.getDelegationAttributes().containsKey(nullAttributeKey) + ); + assertNull( + "delegationAttributes value for the null-valued header must be null", + credential.getDelegationAttributes().get(nullAttributeKey) + ); + } + + /** + * Verifies that a Shibboleth (SAML) authentication flow correctly re-encodes ALL Japanese {@code AUTH-*} headers + * (displayName, givenName, familyName, organizationName / o, organizationalUnit / ou, + * jaOrganizationName / jao, jaOrganizationalUnitName / jaou) + * delivered as ISO-8859-1 mojibake back to the original UTF-8 strings. + * + *

Apache / the Servlet container reads HTTP header bytes and constructs Java {@code String} objects using + * ISO-8859-1 (Latin-1), which is the HTTP/1.1 default for header values. When the IdP sends UTF-8 encoded + * Japanese text, the bytes are misinterpreted and the resulting Java Strings are "mojibake". + * The fix re-encodes every AUTH-* header: {@code new String(headerValue.getBytes("ISO-8859-1"), "UTF-8")}.

+ */ + @Test + public void verifyInstitutionSamlShibbolethFlowWithJapaneseDisplayName() throws Exception { + + // Original Japanese values as they should appear after correct decoding. + final String originalDisplayName = "\u5c71\u7530 \u592a\u90ce"; // 山田 太郎 + final String originalGivenName = "\u592a\u90ce"; // 太郎 + final String originalFamilyName = "\u5c71\u7530"; // 山田 + final String originalOrganizationName = "\u5927\u962a\u5927\u5b66"; // 大阪大学 (o) + final String originalOrganizationalUnit = "\u7406\u5b66\u90e8"; // 理学部 (ou) + final String originalJaOrganizationName = "\u5927\u962a\u5927\u5b66"; // 大阪大学 (jao) + final String originalJaOrganizationalUnit = "\u7406\u5b66\u90e8"; // 理学部 (jaou) + + // Simulate the mojibake that Apache/Servlet creates: + // the IdP sends UTF-8 bytes; the container interprets them as ISO-8859-1 (Latin-1). + final String mojibakeDisplayName = new String(originalDisplayName.getBytes("UTF-8"), "ISO-8859-1"); + final String mojibakeGivenName = new String(originalGivenName.getBytes("UTF-8"), "ISO-8859-1"); + final String mojibakeFamilyName = new String(originalFamilyName.getBytes("UTF-8"), "ISO-8859-1"); + final String mojibakeOrganizationName = new String(originalOrganizationName.getBytes("UTF-8"), "ISO-8859-1"); + final String mojibakeOrganizationalUnit = new String(originalOrganizationalUnit.getBytes("UTF-8"), "ISO-8859-1"); + final String mojibakeJaOrganizationName = new String(originalJaOrganizationName.getBytes("UTF-8"), "ISO-8859-1"); + final String mojibakeJaOrganizationalUnit = new String(originalJaOrganizationalUnit.getBytes("UTF-8"), "ISO-8859-1"); + + final MockHttpServletRequest mockHttpServletRequest = new MockHttpServletRequest(); + mockHttpServletRequest.addHeader("AUTH-Shib-Session-ID", AbstractTestUtils.CONST_NOT_EMPTY_STRING); + mockHttpServletRequest.addHeader("REMOTE_USER", AbstractTestUtils.CONST_NOT_EMPTY_STRING); + mockHttpServletRequest.addHeader("AUTH-Shib-Identity-Provider", AbstractTestUtils.CONST_INSTITUTION_IDP); + mockHttpServletRequest.addHeader("AUTH-displayName", mojibakeDisplayName); + mockHttpServletRequest.addHeader("AUTH-givenName", mojibakeGivenName); + mockHttpServletRequest.addHeader("AUTH-familyName", mojibakeFamilyName); + mockHttpServletRequest.addHeader("AUTH-o", mojibakeOrganizationName); // organizationName + mockHttpServletRequest.addHeader("AUTH-ou", mojibakeOrganizationalUnit); // organizationalUnit + mockHttpServletRequest.addHeader("AUTH-jao", mojibakeJaOrganizationName); // jaOrganizationName + mockHttpServletRequest.addHeader("AUTH-jaou", mojibakeJaOrganizationalUnit); // jaOrganizationalUnitName + mockHttpServletRequest.addHeader("AUTH-mail", AbstractTestUtils.CONST_MAIL); + + final MockRequestContext mockContext = AbstractTestUtils.getContextWithCredentials(mockHttpServletRequest); + + final CentralAuthenticationService centralAuthenticationService = mock(CentralAuthenticationService.class); + final MockNotifyRemotePrincipalAuthenticated osfRemoteAuthenticate + = new MockNotifyRemotePrincipalAuthenticated(centralAuthenticationService); + + final Event event = osfRemoteAuthenticate.doExecute(mockContext); + + final OpenScienceFrameworkCredential credential + = (OpenScienceFrameworkCredential) mockContext.getFlowScope().get(AbstractTestUtils.CONST_CREDENTIAL); + + assertEquals("success", event.getId()); + assertTrue(credential.isRemotePrincipal()); + assertEquals(DelegationProtocol.SAML_SHIB, credential.getDelegationProtocol()); + + // All AUTH-* Japanese fields must be re-encoded from ISO-8859-1 mojibake to original UTF-8. + final String decodedDisplayName = (String) credential.getDelegationAttributes().get("displayName"); + final String decodedGivenName = (String) credential.getDelegationAttributes().get("givenName"); + final String decodedFamilyName = (String) credential.getDelegationAttributes().get("familyName"); + final String decodedOrganizationName = (String) credential.getDelegationAttributes().get("o"); + final String decodedOrganizationalUnit = (String) credential.getDelegationAttributes().get("ou"); + final String decodedJaOrganizationName = (String) credential.getDelegationAttributes().get("jao"); + final String decodedJaOrganizationalUnit = (String) credential.getDelegationAttributes().get("jaou"); + + assertEquals( + "AUTH-displayName must be re-encoded from mojibake to original Japanese UTF-8", + originalDisplayName, decodedDisplayName + ); + assertEquals( + "AUTH-givenName must be re-encoded from mojibake to original Japanese UTF-8", + originalGivenName, decodedGivenName + ); + assertEquals( + "AUTH-familyName must be re-encoded from mojibake to original Japanese UTF-8", + originalFamilyName, decodedFamilyName + ); + assertEquals( + "AUTH-o (organizationName) must be re-encoded from mojibake to original Japanese UTF-8", + originalOrganizationName, decodedOrganizationName + ); + assertEquals( + "AUTH-ou (organizationalUnit) must be re-encoded from mojibake to original Japanese UTF-8", + originalOrganizationalUnit, decodedOrganizationalUnit + ); + assertEquals( + "AUTH-jao (jaOrganizationName) must be re-encoded from mojibake to original Japanese UTF-8", + originalJaOrganizationName, decodedJaOrganizationName + ); + assertEquals( + "AUTH-jaou (jaOrganizationalUnitName) must be re-encoded from mojibake to original Japanese UTF-8", + originalJaOrganizationalUnit, decodedJaOrganizationalUnit + ); + } + + /** + * Verifies that a Shibboleth (SAML) authentication flow correctly re-encodes ALL Japanese {@code AUTH-*} headers + * (displayName, givenName, familyName, organizationName / o, organizationalUnit / ou, + * jaOrganizationName / jao, jaOrganizationalUnitName / jaou) + * when the display name contains a full-width organization prefix followed by a full-width Japanese personal name + * — a common real-world format from Japanese IdPs. + */ + @Test + public void verifyInstitutionSamlShibbolethFlowWithJapaneseMultibyteDisplayName() throws Exception { + + // Full-width organization prefix + full-width personal name (real-world IdP format). + final String originalDisplayName + = "\u56fd\u7acb\u60c5\u5831\u5b66\u7814\u7a76\u6240\u3000\u9234\u6728\u4e00\u90ce"; // 国立情報学研究所 鈴木一郎 + final String originalGivenName = "\u4e00\u90ce"; // 一郎 + final String originalFamilyName = "\u9234\u6728"; // 鈴木 + final String originalOrganizationName = "\u56fd\u7acb\u60c5\u5831\u5b66\u7814\u7a76\u6240"; // 国立情報学研究所 (o) + final String originalOrganizationalUnit = "\u30b3\u30f3\u30d4\u30e5\u30fc\u30bf\u79d1\u5b66"; // コンピュータ科学 (ou) + final String originalJaOrganizationName = "\u56fd\u7acb\u60c5\u5831\u5b66\u7814\u7a76\u6240"; // 国立情報学研究所 (jao) + final String originalJaOrganizationalUnit = "\u30b3\u30f3\u30d4\u30e5\u30fc\u30bf\u79d1\u5b66"; // コンピュータ科学 (jaou) + + // Simulate Apache / Servlet container mojibake: UTF-8 bytes read as ISO-8859-1. + final String mojibakeDisplayName = new String(originalDisplayName.getBytes("UTF-8"), "ISO-8859-1"); + final String mojibakeGivenName = new String(originalGivenName.getBytes("UTF-8"), "ISO-8859-1"); + final String mojibakeFamilyName = new String(originalFamilyName.getBytes("UTF-8"), "ISO-8859-1"); + final String mojibakeOrganizationName = new String(originalOrganizationName.getBytes("UTF-8"), "ISO-8859-1"); + final String mojibakeOrganizationalUnit = new String(originalOrganizationalUnit.getBytes("UTF-8"), "ISO-8859-1"); + final String mojibakeJaOrganizationName = new String(originalJaOrganizationName.getBytes("UTF-8"), "ISO-8859-1"); + final String mojibakeJaOrganizationalUnit = new String(originalJaOrganizationalUnit.getBytes("UTF-8"), "ISO-8859-1"); + + final MockHttpServletRequest mockHttpServletRequest = new MockHttpServletRequest(); + mockHttpServletRequest.addHeader("AUTH-Shib-Session-ID", AbstractTestUtils.CONST_NOT_EMPTY_STRING); + mockHttpServletRequest.addHeader("REMOTE_USER", AbstractTestUtils.CONST_NOT_EMPTY_STRING); + mockHttpServletRequest.addHeader("AUTH-Shib-Identity-Provider", AbstractTestUtils.CONST_INSTITUTION_IDP); + mockHttpServletRequest.addHeader("AUTH-displayName", mojibakeDisplayName); + mockHttpServletRequest.addHeader("AUTH-givenName", mojibakeGivenName); + mockHttpServletRequest.addHeader("AUTH-familyName", mojibakeFamilyName); + mockHttpServletRequest.addHeader("AUTH-o", mojibakeOrganizationName); + mockHttpServletRequest.addHeader("AUTH-ou", mojibakeOrganizationalUnit); + mockHttpServletRequest.addHeader("AUTH-jao", mojibakeJaOrganizationName); + mockHttpServletRequest.addHeader("AUTH-jaou", mojibakeJaOrganizationalUnit); + mockHttpServletRequest.addHeader("AUTH-mail", AbstractTestUtils.CONST_MAIL); + + final MockRequestContext mockContext = AbstractTestUtils.getContextWithCredentials(mockHttpServletRequest); + + final CentralAuthenticationService centralAuthenticationService = mock(CentralAuthenticationService.class); + final MockNotifyRemotePrincipalAuthenticated osfRemoteAuthenticate + = new MockNotifyRemotePrincipalAuthenticated(centralAuthenticationService); + + final Event event = osfRemoteAuthenticate.doExecute(mockContext); + + final OpenScienceFrameworkCredential credential + = (OpenScienceFrameworkCredential) mockContext.getFlowScope().get(AbstractTestUtils.CONST_CREDENTIAL); + + assertEquals("success", event.getId()); + assertTrue(credential.isRemotePrincipal()); + assertEquals(DelegationProtocol.SAML_SHIB, credential.getDelegationProtocol()); + + // All AUTH-* Japanese fields must be re-encoded from ISO-8859-1 mojibake to original UTF-8. + final String decodedDisplayName = (String) credential.getDelegationAttributes().get("displayName"); + final String decodedGivenName = (String) credential.getDelegationAttributes().get("givenName"); + final String decodedFamilyName = (String) credential.getDelegationAttributes().get("familyName"); + final String decodedOrganizationName = (String) credential.getDelegationAttributes().get("o"); + final String decodedOrganizationalUnit = (String) credential.getDelegationAttributes().get("ou"); + final String decodedJaOrganizationName = (String) credential.getDelegationAttributes().get("jao"); + final String decodedJaOrganizationalUnit = (String) credential.getDelegationAttributes().get("jaou"); + + assertEquals( + "AUTH-displayName with full-width org prefix must be re-encoded correctly to original Japanese UTF-8", + originalDisplayName, decodedDisplayName + ); + assertEquals( + "AUTH-givenName must be re-encoded from mojibake to original Japanese UTF-8", + originalGivenName, decodedGivenName + ); + assertEquals( + "AUTH-familyName must be re-encoded from mojibake to original Japanese UTF-8", + originalFamilyName, decodedFamilyName + ); + assertEquals( + "AUTH-o (organizationName) must be re-encoded from mojibake to original Japanese UTF-8", + originalOrganizationName, decodedOrganizationName + ); + assertEquals( + "AUTH-ou (organizationalUnit) must be re-encoded from mojibake to original Japanese UTF-8", + originalOrganizationalUnit, decodedOrganizationalUnit + ); + assertEquals( + "AUTH-jao (jaOrganizationName) must be re-encoded from mojibake to original Japanese UTF-8", + originalJaOrganizationName, decodedJaOrganizationName + ); + assertEquals( + "AUTH-jaou (jaOrganizationalUnitName) must be re-encoded from mojibake to original Japanese UTF-8", + originalJaOrganizationalUnit, decodedJaOrganizationalUnit + ); + } } diff --git a/cas-server-support-osf/src/test/java/io/cos/cas/mock/MockNotifyRemotePrincipalAuthenticated.java b/cas-server-support-osf/src/test/java/io/cos/cas/mock/MockNotifyRemotePrincipalAuthenticated.java index a878d4fa..d70330fb 100644 --- a/cas-server-support-osf/src/test/java/io/cos/cas/mock/MockNotifyRemotePrincipalAuthenticated.java +++ b/cas-server-support-osf/src/test/java/io/cos/cas/mock/MockNotifyRemotePrincipalAuthenticated.java @@ -23,6 +23,9 @@ public MockNotifyRemotePrincipalAuthenticated(final CentralAuthenticationService @Override protected PrincipalAuthenticationResult notifyRemotePrincipalAuthenticated( final OpenScienceFrameworkCredential credential) throws AccountException { - return new PrincipalAuthenticationResult(AbstractTestUtils.CONST_MAIL, AbstractTestUtils.CONST_INSTITUTION_ID); + return new PrincipalAuthenticationResult( + AbstractTestUtils.CONST_MAIL, + AbstractTestUtils.CONST_INSTITUTION_ID, + AbstractTestUtils.CONST_JSON_STRING); } } diff --git a/cas-server-webapp/src/main/resources/messages.properties b/cas-server-webapp/src/main/resources/messages.properties index 8c3a4194..f3407fd1 100644 --- a/cas-server-webapp/src/main/resources/messages.properties +++ b/cas-server-webapp/src/main/resources/messages.properties @@ -164,6 +164,13 @@ screen.institutionloginfailed.message=Your request cannot be completed at this t is in error, please contact Support for help and \ include the error code below. +# Institution Login Failure(LoA) Page +screen.institutionloginfailedloa.heading=Institution login failed +screen.institutionloginfailedloa.message=Does not meet the required AAL and IAL.

\ + If you believe this is in error,\ + please contact Support for help and \ + include the error code below. + # OAuth screen.oauth.confirm.header=Authorize application screen.oauth.confirm.message=

{0}

has asked for the following permission(s) to access your GakuNin RDM account. diff --git a/cas-server-webapp/src/main/webapp/WEB-INF/view/jsp/default/ui/casInstitutionLoginFailedLoAView.jsp b/cas-server-webapp/src/main/webapp/WEB-INF/view/jsp/default/ui/casInstitutionLoginFailedLoAView.jsp new file mode 100644 index 00000000..b9bc6f2c --- /dev/null +++ b/cas-server-webapp/src/main/webapp/WEB-INF/view/jsp/default/ui/casInstitutionLoginFailedLoAView.jsp @@ -0,0 +1,42 @@ +<%-- + + Copyright (c) 2016. Center for Open Science + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--%> + +<%-- Institution login exception page --%> + + + +
+

+

+

errorCode=${casViewErrorCode}

+
+ + + + + + + + + + diff --git a/cas-server-webapp/src/main/webapp/WEB-INF/webflow/login/login-webflow.xml b/cas-server-webapp/src/main/webapp/WEB-INF/webflow/login/login-webflow.xml index 11c45267..ae648b2a 100644 --- a/cas-server-webapp/src/main/webapp/WEB-INF/webflow/login/login-webflow.xml +++ b/cas-server-webapp/src/main/webapp/WEB-INF/webflow/login/login-webflow.xml @@ -209,6 +209,7 @@ + @@ -312,6 +313,12 @@ + + + + + + diff --git a/cas-server-webapp/src/main/webapp/WEB-INF/webflow/logout/logout-webflow.xml b/cas-server-webapp/src/main/webapp/WEB-INF/webflow/logout/logout-webflow.xml index 2108fc00..5e7f22fa 100644 --- a/cas-server-webapp/src/main/webapp/WEB-INF/webflow/logout/logout-webflow.xml +++ b/cas-server-webapp/src/main/webapp/WEB-INF/webflow/logout/logout-webflow.xml @@ -25,6 +25,10 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/webflow http://www.springframework.org/schema/webflow/spring-webflow.xsd"> + + + + @@ -48,9 +52,14 @@ - + + + + + +