From f85ec6e360c3728795a42f806d410de81ed6bc8d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= <brousek@ics.muni.cz>
Date: Sat, 20 Aug 2022 15:26:11 +0200
Subject: [PATCH] feat: template for privacyidea

---
 locales/cs/LC_MESSAGES/campusmultiauth.po |   6 +
 locales/en/LC_MESSAGES/campusmultiauth.po |   6 +
 themes/campus/privacyidea/LoginForm.php   | 211 ++++++++++++++++++++++
 themes/campus/privacyidea/LoginForm.twig  | 145 +++++++++++++++
 www/resources/privacyidea.css             |  13 ++
 www/resources/privacyidea.js              |  27 +++
 6 files changed, 408 insertions(+)
 create mode 100644 themes/campus/privacyidea/LoginForm.php
 create mode 100644 themes/campus/privacyidea/LoginForm.twig
 create mode 100644 www/resources/privacyidea.css
 create mode 100644 www/resources/privacyidea.js

diff --git a/locales/cs/LC_MESSAGES/campusmultiauth.po b/locales/cs/LC_MESSAGES/campusmultiauth.po
index 80ad7a3..eab25a8 100644
--- a/locales/cs/LC_MESSAGES/campusmultiauth.po
+++ b/locales/cs/LC_MESSAGES/campusmultiauth.po
@@ -66,3 +66,9 @@ msgstr "Přihlášení uživatelským jménem a heslem"
 
 msgid "{campusmultiauth:localLogin_capslock}"
 msgstr "Pozor, máte zapnutý Caps lock."
+
+msgid "{campusmultiauth:close}"
+msgstr "zavřít"
+
+msgid "{campusmultiauth:otp_help}"
+msgstr "Vložte jednorázový kód, například z TOTP aplikace."
diff --git a/locales/en/LC_MESSAGES/campusmultiauth.po b/locales/en/LC_MESSAGES/campusmultiauth.po
index bec0f72..9db11c7 100644
--- a/locales/en/LC_MESSAGES/campusmultiauth.po
+++ b/locales/en/LC_MESSAGES/campusmultiauth.po
@@ -66,3 +66,9 @@ msgstr "Login with username and password"
 
 msgid "{campusmultiauth:localLogin_capslock}"
 msgstr "Warning! Caps lock is ON."
+
+msgid "{campusmultiauth:close}"
+msgstr "close"
+
+msgid "{campusmultiauth:otp_help}"
+msgstr "Enter a one time code, e.g. from a TOTP app."
diff --git a/themes/campus/privacyidea/LoginForm.php b/themes/campus/privacyidea/LoginForm.php
new file mode 100644
index 0000000..24bed3d
--- /dev/null
+++ b/themes/campus/privacyidea/LoginForm.php
@@ -0,0 +1,211 @@
+<?php declare(strict_types=1);
+
+use SimpleSAML\Module;
+
+$this->data['header'] = $this->t('{privacyidea:privacyidea:login_title_challenge}');
+
+$this->data['head'] .= '<link rel="stylesheet" href="'
+    . htmlspecialchars(Module::getModuleUrl('privacyidea/css/loginform.css'), ENT_QUOTES)
+    . '" media="screen" />';
+$this->data['head'] .= '<link rel="stylesheet" href="'
+    . htmlspecialchars(Module::getModuleUrl('campusmultiauth/resources/privacyidea.css'), ENT_QUOTES)
+    . '" media="screen" />';
+
+$this->includeAtTemplateBase('includes/header.php');
+
+// Prepare error case to show it in UI if needed
+if ($this->data['errorCode'] !== null) {
+    ?>
+
+    <div class="message message--common message--common-error" role="alert">
+        <a href="#" class="message__close icon icon-times" title="<?php echo $this->t('{campusmultiauth:close}'); ?>">
+            <span class="vhide"><?php echo $this->t('{campusmultiauth:close}'); ?></span>
+        </a>
+        <span class="message__icon icon icon-exclamation-triangle"></span>
+        <h2 class="message__title"><?php echo $this->t('{login:error_header}'); ?></h2>
+        <p class="message__desc">
+    <?php
+    echo htmlspecialchars(
+        sprintf('%s%s: %s', $this->t(
+            '{privacyidea:privacyidea:error}'
+        ), $this->data['errorCode'] ? (' ' . $this->data['errorCode']) : '', $this->data['errorMessage'])
+    ); ?>
+        </p>
+    </div>
+
+    <?php
+}  // end of errorcode
+?>
+
+    <div class="container">
+        <div class="login">
+            <form action="FormReceiver.php" method="POST" id="piLoginForm" name="piLoginForm" class="loginForm">
+                <div class="form-panel first valid" id="gaia_firstform">
+                    <div class="slide-out ">
+                        <div class="input-wrapper focused">
+                            <div class="identifier-shown grid">
+                                <?php if ($this->data['webauthnAvailable']) { ?>
+                                <div class="grid__cell size--m--2-4">
+                                    <h2><?php echo $this->t('{privacyidea:privacyidea:webauthn}'); ?></h2>
+                                    <p id="message" role="alert"><?php
+                                        $messageOverride = $this->data['messageOverride'] ?? null;
+                                        if ($messageOverride === null || is_string($messageOverride)) {
+                                            echo htmlspecialchars(
+                                                $messageOverride ?? $this->data['message'] ?? '',
+                                                ENT_QUOTES
+                                            );
+                                        } elseif (is_callable($messageOverride)) {
+                                            echo call_user_func($messageOverride, $this->data['message'] ?? '');
+                                        }
+                                    ?></p>
+                                    <p>
+                                        <button id="useWebAuthnButton" name="useWebAuthnButton" class="btn btn-primary btn-s" type="button">
+                                            <span><?php echo $this->t('{privacyidea:privacyidea:webauthn}'); ?></span>
+                                        </button>
+                                    </p>
+                                </div>
+                                <?php } ?>
+
+                                <?php if ($this->data['otpAvailable'] ?? true) { ?>
+                                <div class="grid__cell size--m--2-4">
+                                    <h2><?php echo $this->t('{privacyidea:privacyidea:otp}'); ?></h2>
+                                    <p><?php echo $this->t('{campusmultiauth:otp_help}'); ?></p>
+                                    <div class="form-inline">
+                                        <p class="size--m--4-4 size--l--6-12">
+                                            <label for="otp" class="sr-only"><?php echo $this->t('{privacyidea:privacyidea:otp}'); ?></label>
+                                            <span class="inp-fix">
+                                                <input id="otp" name="otp" tabindex="1" value="" class="text inp-text" autocomplete="one-time-code" type="number" inputmode="numeric" pattern="[0-9]{6,}" required placeholder="<?php echo htmlspecialchars($otpHint, ENT_QUOTES); ?>"<?php if ($this->data['noAlternatives']) {
+                                        echo ' autofocus';
+                                    } ?> />
+                                            </span>
+                                        </p>
+                                        <p>
+                                            <button id="submitButton" tabindex="1" class="rc-button rc-button-submit btn btn-primary btn-s nowrap" type="submit" name="Submit">
+                                                <span><?php echo htmlspecialchars($this->t('{login:login_button}'), ENT_QUOTES); ?></span>
+                                            </button>
+                                        </p>
+                                    </div>
+                                </div>
+                                <?php } ?>
+
+                                <!-- Undefined index is suppressed and the default is used for these values -->
+                                <input id="mode" type="hidden" name="mode" value="otp"
+                                       data-preferred="<?php echo htmlspecialchars($this->data['mode'], ENT_QUOTES); ?>"/>
+
+                                <input id="pushAvailable" type="hidden" name="pushAvailable"
+                                       value="<?php echo ($this->data['pushAvailable'] ?? false) ? 'true' : ''; ?>"/>
+
+                                <input id="otpAvailable" type="hidden" name="otpAvailable"
+                                       value="<?php echo ($this->data['otpAvailable'] ?? true) ? 'true' : ''; ?>"/>
+
+                                <input id="webAuthnSignRequest" type="hidden" name="webAuthnSignRequest"
+                                       value='<?php echo htmlspecialchars($this->data['webAuthnSignRequest'] ?? '', ENT_QUOTES); ?>'/>
+
+                                <input id="u2fSignRequest" type="hidden" name="u2fSignRequest"
+                                       value='<?php echo htmlspecialchars($this->data['u2fSignRequest'] ?? '', ENT_QUOTES); ?>'/>
+
+                                <input id="modeChanged" type="hidden" name="modeChanged" value=""/>
+                                <input id="step" type="hidden" name="step"
+                                       value="<?php echo htmlspecialchars(strval(($this->data['step'] ?? null) ?: 2), ENT_QUOTES); ?>"/>
+
+                                <input id="webAuthnSignResponse" type="hidden" name="webAuthnSignResponse" value=""/>
+                                <input id="u2fSignResponse" type="hidden" name="u2fSignResponse" value=""/>
+                                <input id="origin" type="hidden" name="origin" value=""/>
+                                <input id="loadCounter" type="hidden" name="loadCounter"
+                                       value="<?php echo htmlspecialchars(strval(($this->data['loadCounter'] ?? null) ?: 1), ENT_QUOTES); ?>"/>
+
+                                <!-- Additional input to persist the message -->
+                                <input type="hidden" name="message"
+                                       value="<?php echo htmlspecialchars($this->data['message'] ?? '', ENT_QUOTES); ?>"/>
+
+                                <?php
+                                    // If enrollToken load QR Code
+                                    if (isset($this->data['tokenQR'])) {
+                                        echo htmlspecialchars($this->t('{privacyidea:privacyidea:scan_token_qr}')); ?>
+                                    <div class="tokenQR">
+                                        <?php echo '<img src="' . $this->data['tokenQR'] . '" />'; ?>
+                                    </div>
+                                    <?php
+                                    }
+?>
+                            </div>
+
+                            <?php
+                            // Organizations
+                            if (array_key_exists('organizations', $this->data)) {
+                                ?>
+                                <div class="identifier-shown">
+                                    <label for="organization"><?php echo htmlspecialchars($this->t('{login:organization}')); ?></label>
+                                    <select id="organization" name="organization" tabindex="3">
+                                        <?php
+                                        if (array_key_exists('selectedOrg', $this->data)) {
+                                            $selectedOrg = $this->data['selectedOrg'];
+                                        } else {
+                                            $selectedOrg = null;
+                                        }
+
+                                foreach ($this->data['organizations'] as $orgId => $orgDesc) {
+                                    if (is_array($orgDesc)) {
+                                        $orgDesc = $this->t($orgDesc);
+                                    }
+
+                                    if ($orgId === $selectedOrg) {
+                                        $selected = 'selected="selected" ';
+                                    } else {
+                                        $selected = '';
+                                    }
+
+                                    echo '<option ' . $selected . 'value="' . htmlspecialchars(
+                                        $orgId,
+                                        ENT_QUOTES
+                                    ) . '">' . htmlspecialchars($orgDesc) . '</option>';
+                                } ?>
+                                    </select>
+                                </div>
+                            <?php
+                            } ?>
+                        </div> <!-- focused -->
+                    </div> <!-- slide-out-->
+                </div> <!-- form-panel -->
+            </form>
+
+            <?php
+            // Logout
+            if (($this->data['showLogout'] ?? true) && isset($this->data['LogoutURL'])) { ?>
+                <p>
+                    <a href="<?php echo htmlspecialchars($this->data['LogoutURL']); ?>"><?php echo $this->t('{status:logout}'); ?></a>
+                </p>
+            <?php } ?>
+        </div>  <!-- End of login -->
+    </div>  <!-- End of container -->
+
+<?php
+if (!empty($this->data['links'])) {
+    echo '<ul class="links">';
+    foreach ($this->data['links'] as $l) {
+        echo '<li><a href="' . htmlspecialchars($l['href'], ENT_QUOTES) . '">' . htmlspecialchars(
+            $this->t($l['text'])
+        ) . '</a></li>';
+    }
+    echo '</ul>';
+}
+?>
+
+    <script src="<?php echo htmlspecialchars(Module::getModuleUrl('privacyidea/js/pi-webauthn.js'), ENT_QUOTES); ?>">
+    </script>
+
+    <script src="<?php echo htmlspecialchars(Module::getModuleUrl('privacyidea/js/u2f-api.js'), ENT_QUOTES); ?>">
+    </script>
+
+    <meta id="privacyidea-step" name="privacyidea-step" content="<?php echo $this->data['step']; ?>">
+
+    <meta id="privacyidea-translations" name="privacyidea-translations" content="<?php echo htmlspecialchars(json_encode($this->data['translations'])); ?>">
+
+    <script src="<?php echo htmlspecialchars(Module::getModuleUrl('privacyidea/js/loginform.js'), ENT_QUOTES); ?>">
+    </script>
+    <script src="<?php echo htmlspecialchars(Module::getModuleUrl('campusmultiauth/resources/privacyidea.js'), ENT_QUOTES); ?>">
+    </script>
+
+<?php
+$this->includeAtTemplateBase('includes/footer.php');
+?>
diff --git a/themes/campus/privacyidea/LoginForm.twig b/themes/campus/privacyidea/LoginForm.twig
new file mode 100644
index 0000000..37a1856
--- /dev/null
+++ b/themes/campus/privacyidea/LoginForm.twig
@@ -0,0 +1,145 @@
+{% set pagetitle = '{privacyidea:privacyidea:login_title_challenge}'|trans %}
+{% extends "base.twig" %}
+
+{% block preload %}
+<link rel="stylesheet" href="/{{ baseurlpath }}module.php/privacyidea/css/loginform.css" media="screen">
+<link rel="stylesheet" href="/{{ baseurlpath }}module.php/campusmultiauth/resources/privacyidea.css" media="screen">
+{% endblock %}
+
+{% block content %}
+{% if errorCode %}
+    <div class="message message--common message--common-error" role="alert">
+        <a href="#" class="message__close icon icon-times" title="{{ '{campusmultiauth:close}'|trans }}">
+            <span class="vhide">{{ '{campusmultiauth:close}'|trans }}</span>
+        </a>
+        <span class="message__icon icon icon-exclamation-triangle"></span>
+        <h2 class="message__title">{{ '{login:error_header}'|trans }}</h2>
+        <p class="message__desc">
+            {{ '{privacyidea:privacyidea:error}'|trans }}{% if errorCode %} {{ errorCode }}{% endif %}: {{ errorMessage }}
+        </p>
+    </div>
+{% endif %}
+
+    <div class="container">
+        <div class="login">
+            <form action="FormReceiver.php" method="POST" id="piLoginForm" name="piLoginForm" class="loginForm">
+                <div class="form-panel first valid" id="gaia_firstform">
+                    <div class="slide-out">
+                        <div class="input-wrapper focused">
+                            <div class="identifier-shown grid">
+                                {% if webauthnAvailable %}
+                                    <div class="grid__cell size--m--2-4">
+                                        <h2>{{ '{privacyidea:privacyidea:webauthn}'|trans }}</h2>
+                                        <p id="message" role="alert">
+                                            {{ messageOverride }}
+                                        </p>
+                                        <p>
+                                            <button id="useWebAuthnButton" name="useWebAuthnButton" class="btn btn-primary btn-s" type="button">
+                                                <span>{{ '{privacyidea:privacyidea:webauthn}'|trans }}</span>
+                                            </button>
+                                        </p>
+                                    </div>
+                                {% endif %}
+
+                                {% if otpAvailable %}
+                                    <div class="grid__cell size--m--2-4">
+                                        <h2>{{ '{privacyidea:privacyidea:otp}'|trans }}</h2>
+                                        <p>{{ '{campusmultiauth:otp_help}'|trans }}</p>
+                                        <div class="form-inline">
+                                            <p class="size--m--4-4 size--l--6-12">
+                                                <label for="otp" class="sr-only">{{ '{privacyidea:privacyidea:otp}'|trans }}</label>
+                                                <span class="inp-fix">
+                                                    <input id="otp" name="otp" tabindex="1" value="" class="text inp-text" autocomplete="one-time-code" type="number" inputmode="numeric" pattern="[0-9]{6,}" required placeholder="{{ otpHint }}"{% if otpAvailable is defined and otpAvailable and noAlternatives %} autofocus{% endif %} />
+                                                </span>
+                                            </p>
+                                            <p>
+                                                <button id="submitButton" tabindex="1" class="rc-button rc-button-submit btn btn-primary btn-s nowrap" type="submit" name="Submit">
+                                                    <span>{{ '{login:login_button}'|trans }}</span>
+                                                </button>
+                                            </p>
+                                        </div>
+                                    </div>
+                                {% endif %}
+
+                                {# Undefined index is suppressed and the default is used for these values #}
+                                <input id="mode" type="hidden" name="mode" value="otp" data-preferred="{{ mode }}"/>
+
+                                <input id="pushAvailable" type="hidden" name="pushAvailable" value="{% if pushAvailable %}true{% endif %}"/>
+
+                                <input id="otpAvailable" type="hidden" name="otpAvailable" value="{% if otpAvailable %}true{% endif %}"/>
+
+                                <input id="webAuthnSignRequest" type="hidden" name="webAuthnSignRequest"
+                                       value='{% if webAuthnSignRequest is defined %}{{ webAuthnSignRequest }}{% endif %}'/>
+
+                                <input id="u2fSignRequest" type="hidden" name="u2fSignRequest"
+                                       value='{% if u2fSignRequest is defined %}{{ u2fSignRequest }}{% endif %}'/>
+
+                                <input id="modeChanged" type="hidden" name="modeChanged" value=""/>
+                                <input id="step" type="hidden" name="step"
+                                       value="{% if step is defined and step %}{{ step }}{% else %}2{% endif %}"/>
+
+                                <input id="webAuthnSignResponse" type="hidden" name="webAuthnSignResponse" value=""/>
+                                <input id="u2fSignResponse" type="hidden" name="u2fSignResponse" value=""/>
+                                <input id="origin" type="hidden" name="origin" value=""/>
+                                <input id="loadCounter" type="hidden" name="loadCounter"
+                                       value="{% if loadCounter is defined and loadCounter %}{{ loadCounter }}{% else %}1{% endif %}"/>
+
+                                {# Additional input to persist the message #}
+                                <input type="hidden" name="message"
+                                       value="{% if message is defined %}{{ message }}{% endif %}"/>
+
+                                {# If enrollToken load QR Code #}
+                                {% if tokenQR is defined %}
+                                    {{ '{privacyidea:privacyidea:scan_token_qr}'|trans }}
+                                    <div class="tokenQR">
+                                        <img src="{{ tokenQR }}" />
+                                    </div>
+                                {% endif %}
+                            </div>
+
+                            {# Organizations #}
+                            {% if organizations is defined %}
+                                <div class="identifier-shown">
+                                    <label for="organization">{{ '{login:organization}'|trans }}</label>
+                                    <select id="organization" name="organization" tabindex="3">
+                                        {% for orgId, orgDesc in organizations %}
+                                            <option {% if selectedOrg is defined and orgId == selectedOrg %}selected {% endif %}value="{{ orgId }}">
+                                                {{ orgDesc|trans }}
+                                            </option>
+                                        {% endfor %}                                    
+                                    </select>
+                                </div>
+                            {% endif %}
+                        </div> <!-- focused -->
+                    </div> <!-- slide-out-->
+                </div> <!-- form-panel -->
+            </form>
+
+            {# Logout #}
+            {% if showLogout is defined and showLogout and LogoutURL is defined %}
+                <p>
+                    <a href="{{ LogoutURL }}">{{ '{status:logout}'|trans }}</a>
+                </p>
+            {% endif %}
+        </div>  <!-- End of login -->
+    </div>  <!-- End of container -->
+
+{% if links is defined and links %}
+    <ul class="links">
+        {% for l in links %}
+            <li><a href="{{ l['href'] }}">{{ l['text']|trans }}</a></li>
+        {% endfor %}
+    </ul>
+{% endif %}
+
+    <script src="/{{ baseurlpath }}module.php/privacyidea/js/pi-webauthn.js"></script>
+
+    <script src="/{{ baseurlpath }}module.php/privacyidea/js/u2f-api.js"></script>
+
+    <meta id="privacyidea-step" name="privacyidea-step" content="{{ step }}">
+
+    <meta id="privacyidea-translations" name="privacyidea-translations" content="{{ translations | json_encode }}">
+
+    <script src="/{{ baseurlpath }}module.php/privacyidea/js/loginform.js"></script>
+    <script src="/{{ baseurlpath }}module.php/campusmultiauth/resources/privacyidea.js"></script>
+{% endblock %}
diff --git a/www/resources/privacyidea.css b/www/resources/privacyidea.css
new file mode 100644
index 0000000..3ac84dc
--- /dev/null
+++ b/www/resources/privacyidea.css
@@ -0,0 +1,13 @@
+html #useWebAuthnButton,
+html #usePushButton,
+html #useOTPButton,
+html #useU2FButton,
+html #submitButton {
+  margin: 0;
+  width: auto;
+}
+
+html #otp {
+  margin: 0;
+  width: 100%;
+}
diff --git a/www/resources/privacyidea.js b/www/resources/privacyidea.js
new file mode 100644
index 0000000..935a4e8
--- /dev/null
+++ b/www/resources/privacyidea.js
@@ -0,0 +1,27 @@
+const closeMessage = (e) => {
+  e.target.parentElement.classList.add('hide');
+  e.preventDefault();
+  return false;
+};
+
+document.addEventListener('DOMContentLoaded', () => {
+  // close buttons
+  document.querySelectorAll('.message__close').forEach((closeButton) => {
+    closeButton.addEventListener('click', closeMessage);
+  });
+
+  // allow WebAuthn and OTP on one page
+  ['otp', 'submitButton'].forEach((id) => {
+    const el = document.getElementById(id);
+    if (el) {
+      el.classList.remove('hidden');
+    }
+  });
+
+  const piLoginForm = document.getElementById('piLoginForm');
+  if (piLoginForm) {
+    piLoginForm.addEventListener('submit', () => {
+      document.getElementById('mode').value = 'otp';
+    });
+  }
+});
-- 
GitLab