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