[java-idp-plugin-duo] 01/01: JDUO-80 - Work in progress

Scott Cantor cantor.2 at osu.edu
Wed Dec 20 20:57:12 UTC 2023


This is an automated email from the git hooks/post-receive script.

scantor pushed a commit to branch dev/JDUO-80
in repository java-idp-plugin-duo.

View the commit online:
http://git.shibboleth.net/view/?p=java-idp-plugin-duo.git;a=commit;h=6ee51462daabc1c1c5b602907e987cb213b14644

commit 6ee51462daabc1c1c5b602907e987cb213b14644
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Wed Dec 20 15:57:09 2023 -0500

    JDUO-80 - Work in progress
---
 .../authn/duo/context/DuoPasswordlessContext.java  |  85 ++++++++
 .../duo/impl/ExtractPasswordlessUsername.java      | 230 +++++++++++++++++++++
 .../flows/authn/DuoOIDC/duo-oidc-authn-beans.xml   |  16 +-
 .../flows/authn/DuoOIDC/duo-oidc-authn-flow.xml    |  41 +++-
 .../idp/plugin/authn/duo/nimbus/views/username.vm  |  93 +++++++++
 5 files changed, 460 insertions(+), 5 deletions(-)

diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/authn/duo/context/DuoPasswordlessContext.java b/idp-duo-api/src/main/java/net/shibboleth/idp/authn/duo/context/DuoPasswordlessContext.java
new file mode 100644
index 00000000..9eab6c28
--- /dev/null
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/authn/duo/context/DuoPasswordlessContext.java
@@ -0,0 +1,85 @@
+/*
+ * 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 net.shibboleth.idp.authn.duo.context;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.BaseContext;
+
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+
+/**
+ * Context that tracks username and signals usage of Duo in a passwordless mode.
+ * 
+ * <p>This is used for a more specialized use of the Duo service as a single factor.
+ * The presence of the context acts as a signal of this behavior, and the username
+ * is tracked here since it is typicall set by calling code or collected from a view.</p>
+ * 
+ * @parent {@link AuthenticationContext}
+ * @added By configuration to signal username collection and enrollment checking
+ * 
+ * @since 2.1.0
+ */
+public final class DuoPasswordlessContext extends BaseContext {
+
+    /** Username. */
+    @Nullable private String username;
+    
+    /** Whether user has appropriate devices enrolled. */
+    boolean enrolled;
+    
+    /**
+     * Get the username.
+     * 
+     * @return username
+     */
+    @Nullable public String getUsername() {
+        return username;
+    }
+    
+    /**
+     * Set the username.
+     * 
+     * @param name username
+     * 
+     * @return this context
+     */
+    @Nonnull public DuoPasswordlessContext setUsername(@Nullable final String name) {
+        username = name;
+        return this;
+    }
+    
+    /**
+     * Gets whether the user is determined to have appropriate devices enrolled.
+     * 
+     * @return whether the user is determined to have appropriate devices enrolled
+     */
+    public boolean isEnrolled() {
+        return enrolled;
+    }
+    
+    /**
+     * Sets whether the user is determined to have appropriate devices enrolled.
+     * 
+     * @param flag flag to set
+     * 
+     * @return this context
+     */
+    @Nonnull public DuoPasswordlessContext setEnrolled(final boolean flag) {
+        enrolled = flag;
+        return this;
+    }
+}
\ No newline at end of file
diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ExtractPasswordlessUsername.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ExtractPasswordlessUsername.java
new file mode 100644
index 00000000..1fae5dfc
--- /dev/null
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ExtractPasswordlessUsername.java
@@ -0,0 +1,230 @@
+/*
+ * 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 net.shibboleth.idp.plugin.authn.duo.impl;
+
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.google.common.net.UrlEscapers;
+
+import jakarta.servlet.http.HttpServletRequest;
+import net.shibboleth.idp.authn.AbstractExtractionAction;
+import net.shibboleth.idp.authn.AuthnEventIds;
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.authn.duo.context.DuoPasswordlessContext;
+import net.shibboleth.idp.session.IdPSession;
+import net.shibboleth.idp.session.context.SessionContext;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.net.CookieManager;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
+import net.shibboleth.shared.security.DataSealer;
+import net.shibboleth.shared.security.DataSealerException;
+
+/**
+ * An action to populate a username into a {@link DuoPasswordlessContext}, either from a form
+ * submission, a cookie, or an existing session.
+ * 
+ * <p>If no username is found, then {@link AuthnEventIds#UNKNOWN_USERNAME} is signaled.</p>
+ * 
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_PROFILE_CTX}
+ * @event {@link AuthnEventIds#UNKNOWN_USERNAME}
+ * @post {@link DuoPasswordlessContext#setUsername(String)} is called with an existing value if found.
+ * 
+ * @since 2.1.0
+ */
+public class ExtractPasswordlessUsername extends AbstractExtractionAction {
+
+    /** Cookie name to cache username. */
+    @Nonnull public static final String COOKIE_NAME = "_shibidp_duo_username";
+    
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(ExtractPasswordlessUsername.class);
+    
+    /** Strategy used to locate the {@link DuoPasswordlessContext} to populate. */
+    @Nonnull private Function<ProfileRequestContext,DuoPasswordlessContext> duoPasswordlessContextLookupStrategy;
+
+    /** Form parameter name to carry username. */
+    @Nonnull private String usernameFieldName = "j_username";
+    
+    /** Optional cookie manager to use. */
+    @Nullable private CookieManager cookieManager;
+
+    /** Optional data sealer to use. */
+    @Nullable private DataSealer dataSealer;
+    
+    /** Constructor.*/
+    public ExtractPasswordlessUsername() {
+        duoPasswordlessContextLookupStrategy =
+                new ChildContextLookup<>(DuoPasswordlessContext.class, true).compose(
+                        new ChildContextLookup<>(AuthenticationContext.class));
+    }
+    
+    /**
+     * Set the strategy used to locate the {@link DuoPasswordlessContext} to operate on.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setDuoContextCreationStrategy(
+            @Nonnull final Function<ProfileRequestContext,DuoPasswordlessContext> strategy) {
+        checkSetterPreconditions();
+
+        duoPasswordlessContextLookupStrategy =
+                Constraint.isNotNull(strategy, "DuoPasswordlessContext lookup strategy cannot be null");
+    }
+    
+    /**
+     * Sets the name of the form field to carry the username.
+     * 
+     * @param name field name
+     */
+    public void setUsernameFieldName(@Nonnull final String name) {
+        usernameFieldName = Constraint.isNotNull(StringSupport.trimOrNull(name) ,
+                "Username form field name cannot be null or empty");
+    }
+
+    /**
+     * Sets optional {@link CookieManager} to use.
+     * 
+     * @param manager cookie manager
+     */
+    public void setCookieManager(@Nullable final CookieManager manager) {
+        checkSetterPreconditions();
+        
+        cookieManager = manager;
+    }
+
+    /**
+     * Sets optional {@link DataSealer} to use.
+     * 
+     * @param sealer data sealer
+     */
+    public void setDataSealer(@Nullable final DataSealer sealer) {
+        checkSetterPreconditions();
+        
+        dataSealer = sealer;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final AuthenticationContext authenticationContext) {
+        
+        final DuoPasswordlessContext context = duoPasswordlessContextLookupStrategy.apply(profileRequestContext);
+        if (context == null) {
+            log.error("{} Error locating DuoPasswordlessContext", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return;
+        }
+        
+        String username = getUsernameFromForm();
+        if (username != null) {
+            log.debug("{} Populating username '{}' from form submission into Duo passwordless context", getLogPrefix(),
+                    username);
+            context.setUsername(username);
+            return;
+        }
+        
+        username = getUsernameFromCookie(profileRequestContext);
+        if (username != null) {
+            log.debug("{} Populating cached username '{}' from cookie into Duo passwordless context", getLogPrefix(),
+                    username);
+            context.setUsername(username);
+            return;
+        }
+
+        username = getUsernameFromSession(profileRequestContext);
+        if (username != null) {
+            log.debug("{} Populating username '{}' from session into Duo passwordless context", getLogPrefix(),
+                    username);
+            context.setUsername(username);
+            return;
+        }
+
+        if (context.getUsername() == null) {
+            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.UNKNOWN_USERNAME);
+        }
+    }
+    
+    /**
+     * Gets the username from a form submission.
+     * 
+     * @return submitted username, after applying any configured transforms
+     */
+    @Nullable private String getUsernameFromForm() {
+        
+        final HttpServletRequest request = getHttpServletRequest();
+        if (request != null) {
+            return applyTransforms(request.getParameter(usernameFieldName));
+        }
+        
+        return null;
+    }
+    
+    /**
+     * Gets the username from an existing sealed cookie, if any.
+     * 
+     * @param profileRequestContext profile request context
+     * 
+     * @return username from existing sealed cookie, or null
+     */
+    @Nullable private String getUsernameFromCookie(@Nonnull final ProfileRequestContext profileRequestContext) {
+        
+        if (cookieManager != null && dataSealer != null) {
+            final String cookie = cookieManager.getCookieValue(COOKIE_NAME, null);
+            if (cookie != null) {
+                try {
+                    assert dataSealer != null;
+                    return dataSealer.unwrap(UrlEscapers.urlFormParameterEscaper().escape(cookie));
+                } catch (final DataSealerException e) {
+                    log.warn("{} Unable to unwrap sealed username cookie", getLogPrefix(), e);
+                    assert cookieManager != null;
+                    cookieManager.unsetCookie(COOKIE_NAME);
+                }
+            }
+        }
+        
+        return null;
+    }
+    
+    /**
+     * Gets the username from an existing {@link IdPSession}, if any.
+     * 
+     * @param profileRequestContext profile request context
+     * 
+     * @return username from existing session, or null
+     */
+    @Nullable private String getUsernameFromSession(@Nonnull final ProfileRequestContext profileRequestContext) {
+        final SessionContext sessionContext = profileRequestContext.getSubcontext(SessionContext.class);
+        if (sessionContext != null) {
+            final IdPSession idpSession = sessionContext.getIdPSession();
+            if (idpSession != null) {
+                return idpSession.getPrincipalName();
+            }
+        }
+        
+        return null;
+    }
+
+}
\ No newline at end of file
diff --git a/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-beans.xml b/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-beans.xml
index e19fca86..62402c59 100644
--- a/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-beans.xml
+++ b/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-beans.xml
@@ -64,9 +64,7 @@
     to change the location of the user config file. -->
     <import resource="conditional:%{idp.home}/conf/authn/%{idp.duo.oidc.user.config:duo-oidc-authn-config.xml}" />
 
-    <!--
-    Non-Browser actions and beans. Code lives in idp-authn-impl for now, may migrate here later.
-    -->
+    <!-- Non-Browser actions and beans -->
     <bean id="ExtractDuoAuthenticationFromHeaders" scope="prototype"
         class="net.shibboleth.idp.plugin.authn.duo.impl.ExtractDuoAuthenticationFromHeaders"
         p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
@@ -99,7 +97,17 @@
         p:classifiedMessages="#{getObject('shibboleth.authn.DuoOIDC.ClassifiedMessageMap')}"
         p:resultCachingPredicate="#{getObject('shibboleth.authn.DuoOIDC.resultCachingPredicate')}" />
 
-    <!-- Duo OIDC AuthAPI beans -->
+    <!-- Passwordless beans  -->
+    <bean id="ExtractPasswordlessUsername"
+        class="net.shibboleth.idp.plugin.authn.duo.impl.ExtractPasswordlessUsername" scope="prototype"
+        p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
+        p:usernameFieldName="#{%{idp.authn.DuoOIDC.usernameFieldName:j_username}'.trim()}"
+        p:lowercase="%{idp.authn.DuoOIDC.lowercase:false}"
+        p:uppercase="%{idp.authn.DuoOIDC.uppercase:false}"
+        p:trim="%{idp.authn.Password.trim:true}"
+        p:transforms="#{getObject('shibboleth.authn.DuoOIDC.Transforms')}" />
+
+    <!-- Duo OIDC beans -->
     <bean id="PopulateDuoAuthenticationContext" scope="prototype"
         class="net.shibboleth.idp.plugin.authn.duo.impl.PopulateDuoAuthenticationContext"
         p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
diff --git a/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-flow.xml b/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-flow.xml
index 6956f4d1..d00ced49 100644
--- a/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-flow.xml
+++ b/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-flow.xml
@@ -13,7 +13,7 @@
         <!-- Fall through to a different flow if header extract fails on a passive or non-browser request. -->
         <transition on="#{ opensamlProfileRequestContext.getSubcontext(T(net.shibboleth.idp.authn.context.AuthenticationContext)).isPassive() || !opensamlProfileRequestContext.isBrowserProfile() }" to="ReselectFlow" />
         
-        <transition on="NoCredentials" to="CheckDuoOIDCAuthAPI" />
+        <transition on="NoCredentials" to="CheckForPasswordless" />
     </action-state>
 
     <action-state id="ValidateDuoAuthAPI">
@@ -23,6 +23,45 @@
         <transition on="proceed" to="proceed" />
     </action-state>
     
+    <decision-state id="CheckForPasswordless">
+        <if test="opensamlProfileRequestContext.containsSubcontext(T(net.shibboleth.idp.authn.duo.context.DuoPasswordlessContext))"
+            then="ExtractPasswordlessUsername"
+            else="CheckDuoOIDCAuthAPI" />
+    </decision-state>
+    
+    <action-state id="ExtractPasswordlessUsername">
+        <evaluate expression="ExtractPasswordlessUsername" />
+        <evaluate expression="'proceed'" />
+        
+        <transition on="proceed" to="CheckEnrollmentStatus" />
+        <transition on="UnknownUsername" to="UsernameCollectionView" />
+    </action-state>
+
+    <action-state id="CheckEnrollmentStatus">
+        <evaluate expression="CheckEnrollmentStatus" />
+        <evaluate expression="'proceed'" />
+        
+        <transition on="proceed" to="UsernameCollectionView" />
+    </action-state>
+    
+    <view-state id="UsernameCollectionView" view="username">
+        <on-render>
+            <evaluate expression="environment" result="viewScope.environment" />
+            <evaluate expression="opensamlProfileRequestContext" result="viewScope.profileRequestContext" />
+            <evaluate expression="opensamlProfileRequestContext.getSubcontext(T(net.shibboleth.idp.authn.context.AuthenticationContext))" result="viewScope.authenticationContext" />
+            <evaluate expression="authenticationContext.getSubcontext(T(net.shibboleth.idp.ui.context.RelyingPartyUIContext))" result="viewScope.rpUIContext" />
+            <evaluate expression="T(net.shibboleth.shared.codec.HTMLEncoder)" result="viewScope.encoder" />
+            <evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().getBean('shibboleth.CSPDigester')" result="viewScope.cspDigester" />
+            <evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().getBean('shibboleth.CSPNonce')" result="viewScope.cspNonce" />
+            <evaluate expression="flowRequestContext.getExternalContext().getNativeRequest()" result="viewScope.request" />
+            <evaluate expression="flowRequestContext.getExternalContext().getNativeResponse()" result="viewScope.response" />
+            <evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().containsBean('shibboleth.CustomViewContext') ? flowRequestContext.getActiveFlow().getApplicationContext().getBean('shibboleth.CustomViewContext') : null" result="viewScope.custom" />
+        </on-render>
+
+        <transition on="proceed" to="CheckDuoOIDCAuthAPI" />
+        <transition on="CheckEnrollmentStatus" to="CheckEnrollmentStatus" />
+    </view-state>
+    
     <action-state id="CheckDuoOIDCAuthAPI">
         <evaluate expression="PopulateDuoAuthenticationContext" />
         <evaluate expression="HealthCheckDuoOIDCAuthAPI" />
diff --git a/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/views/username.vm b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/views/username.vm
new file mode 100644
index 00000000..6a7c9845
--- /dev/null
+++ b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/views/username.vm
@@ -0,0 +1,93 @@
+##
+## Velocity Template for collection of username for Duo Passwordless use
+##
+## Velocity context will contain the following properties
+## flowExecutionUrl - the form action location
+## flowRequestContext - the Spring Web Flow RequestContext
+## flowExecutionKey - the SWF execution key (this is built into the flowExecutionUrl)
+## profileRequestContext - root of context tree
+## authenticationContext - context with authentication request information
+## rpUIContext - the context with SP UI information from the metadata
+## encoder - HTMLEncoder class
+## cspDigester - Calculates base64-encoded SHA-2 hashes (call apply)
+## cspNonce - Calculates secure nonces (call generateIdentifier)
+## request - HttpServletRequest
+## response - HttpServletResponse
+## environment - Spring Environment object for property resolution
+## custom - arbitrary object injected by deployer
+##
+#set ($rpContext = $profileRequestContext.getSubcontext('net.shibboleth.profile.context.RelyingPartyContext'))
+#set ($username = $authenticationContext.getSubcontext('net.shibboleth.idp.authn.duo.context.DuoPasswordlessContext').getUsername())
+##
+<!DOCTYPE html>
+<html>
+    <head>
+        <title>#springMessageText("idp.title", "Web Login Service")</title>
+        <meta charset="UTF-8" />
+        <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
+        <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0">
+        <link rel="stylesheet" type="text/css" href="$request.getContextPath()#springMessageText("idp.css", "/css/placeholder.css")">
+    </head>
+    <body>
+        <main class="main">
+            <header>
+                <img class="main-logo" src="$request.getContextPath()#springMessageText("idp.logo", "/images/placeholder-logo.png")" alt="#springMessageText("idp.logo.alt-text", "logo")" />
+                
+                #set ($serviceName = $rpUIContext.serviceName)
+                #if ($serviceName && !$rpContext.getRelyingPartyId().contains($serviceName))
+                    <h1>#springMessageText("idp.login.loginTo", "Login to") $encoder.encodeForHTML($serviceName)</h1>
+                #end
+            </header>
+            
+            <section>
+                <form action="$flowExecutionUrl" method="post">
+                    #parse("csrf/csrf.vm")
+
+                    #*
+                    //
+                    //    SP Description & Logo (optional)
+                    //    These idpui lines will display added information (if available
+                    //    in the metadata) about the Service Provider (SP) that requested
+                    //    authentication. These idpui lines are "active" in this example
+                    //    (not commented out) - this extra SP info will be displayed.
+                    //    Remove or comment out these lines to stop the display of the
+                    //    added SP information.
+                    //
+                    *#
+                    #set ($logo = $rpUIContext.getLogo())
+                    #if ($logo)
+                        <img class="service-logo" src= "$encoder.encodeForHTMLAttribute($logo)" alt="$encoder.encodeForHTMLAttribute($serviceName)">
+                    #end
+                    #set ($desc = $rpUIContext.getServiceDescription())
+                    #if ($desc)
+                        <p>$encoder.encodeForHTML($desc)</p>
+                    #end
+                            
+                    <label for="username">#springMessageText("idp.login.username", "Username")</label>
+                    <input id="username" name="j_username" type="text"
+                        value="#if($username)$encoder.encodeForHTML($username)#end" />
+                        
+                    <input type="checkbox" name="donotcache" value="1" id="donotcache" />
+                    <label for="donotcache">#springMessageText("idp.duo.donotcache", "Don't Remember Me")</label>
+    
+                    <div class="grid">
+                        <div class="grid-item">
+                            <button type="submit" name="_eventId_proceed"
+                                >#springMessageText("idp.duo.continue", "Continue")</button>
+                        </div>
+                    </div>
+                </form>
+    
+                <ul>
+                    <li><a href="#springMessageText("idp.url.password.reset", '#')">#springMessageText("idp.login.forgotPassword", "Forgot your password?")</a></li>
+                    <li><a href="#springMessageText("idp.url.helpdesk", '#')">#springMessageText("idp.login.needHelp", "Need Help?")</a></li>
+                </ul>
+            </section>
+        </main>
+        <footer class="footer">
+            <div class="cc">
+                <p>#springMessageText("idp.footer", "Insert your footer text here.")</p>
+            </div>
+        </footer>
+     </body>
+</html>
\ No newline at end of file

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.


More information about the commits mailing list