-
Notifications
You must be signed in to change notification settings - Fork 37
Commit
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
# OTP (OneTimePassword) Authentifizierung (2FA) | ||
|
||
## Allgemeine Konfiguration | ||
|
||
Zunächst muss die Konfiguration in den YCom Einstellungen vorgenommen werden. Die entsprechenden Artikel für das OTP Setup und die OTP Überprüfung müssen angelegt werden und über die YCom Permission nur für eingeloggte User verfügbar gemacht werden. | ||
|
||
## Benötigte Artikel | ||
|
||
Es wird 1 REDAXO Artikel benötigt. Dieser führt das initale SetUp und auch die Verifizierung durch. Dieser Artikel muss in der Einstellungseite verlinkt sein | ||
|
||
### Artikel für das OTP Setup | ||
|
||
Im OTP Artikel das YForm Builder Modul verwenden und diesen YFormCode einsetzen. Rechte müssen auf "Zugriff für eingeloggte User" gesetzt sein. | ||
|
||
``` | ||
ycom_auth_otp|setup | ||
``` | ||
|
||
## Einleitung | ||
|
||
Die 2-Faktor-Authentifizierung (2FA) ist eine zusätzliche Sicherheitsebene für Ihr Konto. Sie schützt Ihr Konto vor unbefugtem Zugriff, selbst wenn Ihr Passwort kompromittiert wurde. | ||
|
||
Die 2FA ist eine Authentifizierungsmethode, bei der zwei verschiedene Faktoren verwendet werden, um die Identität einer Person zu bestätigen. | ||
|
||
Hier werden 2 Möglichkeiten angeboten. | ||
|
||
* One Time Passwort über E-Mail. | ||
* One Time Passwort über (Google) Authenticator. | ||
|
||
Die 2FA ist eine der besten Möglichkeiten, um Ihr Konto zu schützen, da sie sicherstellt, dass nur Sie auf Ihr Konto zugreifen können, selbst wenn jemand Ihr Passwort kennt. | ||
|
||
## Einrichtung | ||
|
||
Die 2FA kann in Ihrem Konto aktiviert werden. Dazu müssen Sie sich zunächst anmelden und die 2FA in den Einstellungen aktivieren. Anschließend müssen Sie einen Sicherheitsschlüssel hinzufügen, der zur Authentifizierung verwendet wird. | ||
|
||
Die 2FA kann auf verschiedene Arten aktiviert werden, z.B. durch die Eingabe eines Codes, der per SMS oder E-Mail gesendet wird, oder durch die Verwendung einer Authentifizierungs-App wie Google Authenticator oder Authy. | ||
|
||
## Verwendung | ||
|
||
Nachdem die 2FA aktiviert wurde, müssen Sie sich bei jedem Anmeldeversuch zusätzlich zur Eingabe Ihres Passworts auch mit einem Sicherheitsschlüssel authentifizieren. Dies kann z.B. durch die Eingabe eines Codes aus der Authentifizierungs-App erfolgen. | ||
|
||
Die 2FA bietet eine zusätzliche Sicherheitsebene für Ihr Konto und schützt es vor unbefugtem Zugriff. Wir empfehlen daher, die 2FA zu aktivieren, um Ihr Konto zu schützen. | ||
|
||
## Umsetzung in REDAXO | ||
|
||
In REDAXO kann die 2FA z.B. mit dem AddOn YCom umgesetzt werden. Dazu müssen Sie zunächst die 2FA in den Einstellungen von YCom aktivieren und einen Sicherheitsschlüssel hinzufügen. Anschließend müssen Sie sich bei jedem Anmeldeversuch zusätzlich zur Eingabe Ihres Passworts auch mit einem Sicherheitsschlüssel authentifizieren. | ||
|
||
Die 2FA bietet eine zusätzliche Sicherheitsebene für Ihr REDAXO-Konto und schützt es vor unbefugtem Zugriff. Wir empfehlen daher, die 2FA zu aktivieren, um Ihr Konto zu schützen. | ||
|
||
## E-Mail Template | ||
|
||
Standardmäßig wird eine vorbereitete E-Mail mit festem Text verschickt. | ||
|
||
Es kann aber ein eigenes YForm-Template für die 2FA verwendet. Mit dem Key ```ycom_otp_code_template``` kann ein eigenes Template gesetzt wird. Folgende Werte sind verfügbar: ```name, email, firstname, code```. |
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,150 @@ | ||
(function (global, factory) { | ||
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : | ||
typeof define === 'function' && define.amd ? define(factory) : | ||
(global = global || self, global.ClipboardCopyElement = factory()); | ||
}(this, function () { 'use strict'; | ||
|
||
function createNode(text) { | ||
const node = document.createElement('pre'); | ||
node.style.width = '1px'; | ||
node.style.height = '1px'; | ||
node.style.position = 'fixed'; | ||
node.style.top = '5px'; | ||
node.textContent = text; | ||
return node; | ||
} | ||
|
||
function copyNode(node) { | ||
if ('clipboard' in navigator) { | ||
// eslint-disable-next-line flowtype/no-flow-fix-me-comments | ||
// $FlowFixMe Clipboard is not defined in Flow yet. | ||
return navigator.clipboard.writeText(node.textContent); | ||
} | ||
|
||
const selection = getSelection(); | ||
|
||
if (selection == null) { | ||
return Promise.reject(new Error()); | ||
} | ||
|
||
selection.removeAllRanges(); | ||
const range = document.createRange(); | ||
range.selectNodeContents(node); | ||
selection.addRange(range); | ||
document.execCommand('copy'); | ||
selection.removeAllRanges(); | ||
return Promise.resolve(); | ||
} | ||
function copyText(text) { | ||
if ('clipboard' in navigator) { | ||
// eslint-disable-next-line flowtype/no-flow-fix-me-comments | ||
// $FlowFixMe Clipboard is not defined in Flow yet. | ||
return navigator.clipboard.writeText(text); | ||
} | ||
|
||
const body = document.body; | ||
|
||
if (!body) { | ||
return Promise.reject(new Error()); | ||
} | ||
|
||
const node = createNode(text); | ||
body.appendChild(node); | ||
copyNode(node); | ||
body.removeChild(node); | ||
return Promise.resolve(); | ||
} | ||
|
||
function copy(button) { | ||
const id = button.getAttribute('for'); | ||
const text = button.getAttribute('value'); | ||
|
||
function trigger() { | ||
button.dispatchEvent(new CustomEvent('clipboard-copy', { | ||
bubbles: true | ||
})); | ||
} | ||
|
||
if (text) { | ||
copyText(text).then(trigger); | ||
} else if (id) { | ||
const root = 'getRootNode' in Element.prototype ? button.getRootNode() : button.ownerDocument; | ||
if (!(root instanceof Document || 'ShadowRoot' in window && root instanceof ShadowRoot)) return; | ||
const node = root.getElementById(id); | ||
if (node) copyTarget(node).then(trigger); | ||
} | ||
} | ||
|
||
function copyTarget(content) { | ||
if (content instanceof HTMLInputElement || content instanceof HTMLTextAreaElement) { | ||
return copyText(content.value); | ||
} else if (content instanceof HTMLAnchorElement && content.hasAttribute('href')) { | ||
return copyText(content.href); | ||
} else { | ||
return copyNode(content); | ||
} | ||
} | ||
|
||
function clicked(event) { | ||
const button = event.currentTarget; | ||
|
||
if (button instanceof HTMLElement) { | ||
copy(button); | ||
} | ||
} | ||
|
||
function keydown(event) { | ||
if (event.key === ' ' || event.key === 'Enter') { | ||
const button = event.currentTarget; | ||
|
||
if (button instanceof HTMLElement) { | ||
event.preventDefault(); | ||
copy(button); | ||
} | ||
} | ||
} | ||
|
||
function focused(event) { | ||
event.currentTarget.addEventListener('keydown', keydown); | ||
} | ||
|
||
function blurred(event) { | ||
event.currentTarget.removeEventListener('keydown', keydown); | ||
} | ||
|
||
class ClipboardCopyElement extends HTMLElement { | ||
constructor() { | ||
super(); | ||
this.addEventListener('click', clicked); | ||
this.addEventListener('focus', focused); | ||
this.addEventListener('blur', blurred); | ||
} | ||
|
||
connectedCallback() { | ||
if (!this.hasAttribute('tabindex')) { | ||
this.setAttribute('tabindex', '0'); | ||
} | ||
|
||
if (!this.hasAttribute('role')) { | ||
this.setAttribute('role', 'button'); | ||
} | ||
} | ||
|
||
get value() { | ||
return this.getAttribute('value') || ''; | ||
} | ||
|
||
set value(text) { | ||
this.setAttribute('value', text); | ||
} | ||
|
||
} | ||
|
||
if (!window.customElements.get('clipboard-copy')) { | ||
window.ClipboardCopyElement = ClipboardCopyElement; | ||
window.customElements.define('clipboard-copy', ClipboardCopyElement); | ||
} | ||
|
||
return ClipboardCopyElement; | ||
|
||
})); |
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
<?php | ||
|
||
abstract class rex_ycom_injection_abtract | ||
{ | ||
abstract public function getRewrite(): bool|string; | ||
|
||
abstract public function getSettingsContent(): string; | ||
|
||
abstract public function triggerSaveSettings(): void; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,164 @@ | ||
<?php | ||
|
||
class rex_ycom_injection_otp extends rex_ycom_injection_abtract | ||
{ | ||
public function getRewrite(): bool|string | ||
{ | ||
$user = rex_ycom_auth::getUser(); | ||
|
||
if (!$user) { | ||
return false; | ||
} | ||
|
||
$otp_article_id = (int) rex_addon::get('ycom')->getConfig('otp_article_id'); | ||
|
||
// 1. User ist eingeloggt | ||
// 2. OTP Article vorhanden => OTP aktiviert ? | ||
if (0 == $otp_article_id) { | ||
return false; | ||
} | ||
|
||
// 1. User ist eingeloggt | ||
// 2. Keine OTP Konfiguration/Article vorhanden | ||
// 3. Session OTP Check bereits durchgeführt? | ||
$SessionInstance = rex_ycom_user_session::getInstance()->getCurrentSession($user); | ||
if (1 == $SessionInstance['otp_verified']) { | ||
return false; | ||
} | ||
|
||
// - user hat überprüfung und keine OTP Session -> zwingend auf otp-article | ||
// - OTP ist erzwungen. | ||
$config = rex_ycom_otp_password_config::forCurrentUser(); | ||
$otp_auth_enforce = rex_addon::get('ycom')->getConfig('otp_auth_enforce'); | ||
$enforcedAll = rex_ycom_otp_password::ENFORCED_ALL == $otp_auth_enforce ? true : false; | ||
if (!($enforcedAll || $config->enabled)) { | ||
return false; | ||
} | ||
|
||
if (rex_article::getCurrentId() == $otp_article_id) { | ||
return false; | ||
} | ||
|
||
return rex_getUrl($otp_article_id, '', [], '&'); | ||
} | ||
|
||
public function getSettingsContent(): string | ||
{ | ||
$addon = rex_addon::get('ycom'); | ||
|
||
$selectEnforce = new rex_select(); | ||
$selectEnforce->setId('otp_auth_enforce'); | ||
$selectEnforce->setName('otp_auth_enforce'); | ||
$selectEnforce->setAttribute('class', 'form-control selectpicker'); | ||
$selectEnforce->setSelected($addon->getConfig('otp_auth_enforce')); | ||
|
||
$selectEnforce->addOption($addon->i18n('otp_auth_enforce_' . rex_ycom_otp_password::ENFORCED_ALL), rex_ycom_otp_password::ENFORCED_ALL); | ||
$selectEnforce->addOption($addon->i18n('otp_auth_enforce_' . rex_ycom_otp_password::ENFORCED_DISABLED), rex_ycom_otp_password::ENFORCED_DISABLED); | ||
|
||
$selectOption = new rex_select(); | ||
$selectOption->setId('otp_auth_option'); | ||
$selectOption->setName('otp_auth_option'); | ||
$selectOption->setAttribute('class', 'form-control selectpicker'); | ||
$selectOption->setSelected($addon->getConfig('otp_auth_option')); | ||
|
||
$selectOption->addOption($addon->i18n('otp_auth_option_' . rex_ycom_otp_password::OPTION_ALL), rex_ycom_otp_password::OPTION_ALL); | ||
$selectOption->addOption($addon->i18n('otp_auth_option_' . rex_ycom_otp_password::OPTION_TOTP), rex_ycom_otp_password::OPTION_TOTP); | ||
$selectOption->addOption($addon->i18n('otp_auth_option_' . rex_ycom_otp_password::OPTION_EMAIL), rex_ycom_otp_password::OPTION_EMAIL); | ||
|
||
$selectEmailPeriod = new rex_select(); | ||
$selectEmailPeriod->setId('otp_auth_email_period'); | ||
$selectEmailPeriod->setName('otp_auth_email_period'); | ||
$selectEmailPeriod->setAttribute('class', 'form-control selectpicker'); | ||
$selectEmailPeriod->setSelected($addon->getConfig('otp_auth_email_period')); | ||
|
||
$selectEmailPeriod->addOption('5 ' . $addon->i18n('minutes'), 300); | ||
$selectEmailPeriod->addOption('10 ' . $addon->i18n('minutes'), 600); | ||
$selectEmailPeriod->addOption('15 ' . $addon->i18n('minutes'), 900); | ||
$selectEmailPeriod->addOption('30 ' . $addon->i18n('minutes'), 1800); | ||
|
||
$selectTOTPPeriod = new rex_select(); | ||
$selectTOTPPeriod->setAttribute('class', 'form-control selectpicker'); | ||
$selectTOTPPeriod->setDisabled(true); | ||
$selectTOTPPeriod->addOption($addon->i18n('otp_auth_totp_period_info', rex_ycom_otp_method_totp::getPeriod()), 30); | ||
|
||
$selectLoginTries = new rex_select(); | ||
$selectLoginTries->setAttribute('class', 'form-control selectpicker'); | ||
$selectLoginTries->setDisabled(true); | ||
$selectLoginTries->addOption($addon->i18n('otp_auth_logintries_info', rex_ycom_otp_method_totp::getloginTries()), 30); | ||
|
||
return ' | ||
<fieldset> | ||
<legend>' . $addon->i18n('otp_auth_config') . '</legend> | ||
<div class="row abstand"> | ||
<div class="col-xs-12 col-sm-6"> | ||
<label for="rex-form-otp_article_id">' . $addon->i18n('otp_article_id') . '</label> | ||
</div> | ||
<div class="col-xs-12 col-sm-6"> | ||
' . rex_var_link::getWidget(17, 'otp_article_id', (int) $addon->getConfig('otp_article_id')) . ' | ||
<small>[otp_article_id]</small> | ||
</div> | ||
</div> | ||
<div class="row abstand"> | ||
<div class="col-xs-12 col-sm-6"> | ||
<label for="rex_ycom_otp_auth_enforce">' . $addon->i18n('otp_auth_enforce') . '</label> | ||
</div> | ||
<div class="col-xs-12 col-sm-6"> | ||
' . $selectEnforce->get() . ' | ||
<small>[otp_auth_enforce]</small> | ||
</div> | ||
</div> | ||
<div class="row abstand"> | ||
<div class="col-xs-12 col-sm-6"> | ||
<label for="2factor_auth_options">' . $addon->i18n('otp_auth_options') . '</label> | ||
</div> | ||
<div class="col-xs-12 col-sm-6"> | ||
' . $selectOption->get() . ' | ||
<small>[otp_auth_option]</small> | ||
</div> | ||
</div> | ||
<div class="row abstand"> | ||
<div class="col-xs-12 col-sm-6"> | ||
<label for="2factor_auth_email_period">' . $addon->i18n('otp_auth_email_period') . '</label> | ||
</div> | ||
<div class="col-xs-12 col-sm-6"> | ||
' . $selectEmailPeriod->get() . ' | ||
<small>[otp_auth_email_period]</small> | ||
</div> | ||
</div> | ||
<div class="row abstand"> | ||
<div class="col-xs-12 col-sm-6"> | ||
<label for="2factor_auth_email_period">' . $addon->i18n('otp_auth_totp_period') . '</label> | ||
</div> | ||
<div class="col-xs-12 col-sm-6"> | ||
' . $selectTOTPPeriod->get() . ' | ||
</div> | ||
</div> | ||
<div class="row"> | ||
<div class="col-xs-12 col-sm-6"> | ||
<label for="2factor_auth_email_period">' . $addon->i18n('otp_auth_logintries') . '</label> | ||
</div> | ||
<div class="col-xs-12 col-sm-6"> | ||
' . $selectLoginTries->get() . ' | ||
</div> | ||
</div> | ||
</fieldset>'; | ||
} | ||
|
||
public function triggerSaveSettings(): void | ||
{ | ||
$addon = rex_addon::get('ycom'); | ||
$addon->setConfig('otp_article_id', rex_request('otp_article_id', 'int')); | ||
$addon->setConfig('otp_auth_enforce', rex_request('otp_auth_enforce', 'string')); | ||
$addon->setConfig('otp_auth_option', rex_request('otp_auth_option', 'string')); | ||
$addon->setConfig('otp_auth_email_period', rex_request('otp_auth_email_period', 'int', 300)); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
<?php | ||
|
||
class rex_ycom_injection_passwordchange extends rex_ycom_injection_abtract | ||
{ | ||
public function getRewrite(): bool|string | ||
{ | ||
$user = rex_ycom_auth::getUser(); | ||
if ($user) { | ||
$article_id_password = (int) rex_plugin::get('ycom', 'auth')->getConfig('article_id_jump_password', 0); | ||
if (0 != $article_id_password && 1 == $user->getValue('new_password_required')) { | ||
if ($article_id_password != rex_article::getCurrentId()) { | ||
return rex_getUrl($article_id_password, '', [], '&'); | ||
} | ||
} | ||
} | ||
return false; | ||
} | ||
|
||
public function getSettingsContent(): string | ||
{ | ||
$addon = rex_plugin::get('ycom', 'auth'); | ||
return ' | ||
<fieldset> | ||
<legend>' . $addon->i18n('ycom_auth_config_passwordchange') . '</legend> | ||
<div class="row abstand"> | ||
<div class="col-xs-12 col-sm-6"> | ||
<label for="rex-form-article_password">' . $addon->i18n('ycom_auth_config_id_jump_password') . '</label> | ||
</div> | ||
<div class="col-xs-12 col-sm-6"> | ||
' . rex_var_link::getWidget(9, 'article_id_jump_password', (int) $addon->getConfig('article_id_jump_password')) . ' | ||
<small>[article_id_jump_password]</small> | ||
</div> | ||
</div> | ||
</fieldset> | ||
'; | ||
} | ||
|
||
public function triggerSaveSettings(): void | ||
{ | ||
$addon = rex_plugin::get('ycom', 'auth'); | ||
$addon->setConfig('article_id_jump_password', rex_request('article_id_jump_password', 'int')); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
<?php | ||
|
||
class rex_ycom_injection_termsofuse extends rex_ycom_injection_abtract | ||
{ | ||
public function getRewrite(): bool|string | ||
{ | ||
$user = rex_ycom_auth::getUser(); | ||
if ($user) { | ||
$article_id_termsofuse = (int) rex_plugin::get('ycom', 'auth')->getConfig('article_id_jump_termsofuse', 0); | ||
if (0 != $article_id_termsofuse && 1 != $user->getValue('termsofuse_accepted')) { | ||
if ($article_id_termsofuse != rex_article::getCurrentId()) { | ||
return rex_getUrl($article_id_termsofuse, '', [], '&'); | ||
} | ||
} | ||
} | ||
return false; | ||
} | ||
|
||
public function getSettingsContent(): string | ||
{ | ||
$addon = rex_plugin::get('ycom', 'auth'); | ||
|
||
return ' | ||
<fieldset> | ||
<legend>' . $addon->i18n('ycom_auth_config_termsofuse') . '</legend> | ||
<div class="row abstand"> | ||
<div class="col-xs-12 col-sm-6"> | ||
<label for="rex-form-article_termsofuse">' . $addon->i18n('ycom_auth_config_id_jump_termsofuse') . '</label> | ||
</div> | ||
<div class="col-xs-12 col-sm-6"> | ||
' . rex_var_link::getWidget(10, 'article_id_jump_termsofuse', (int) $addon->getConfig('article_id_jump_termsofuse')) . ' | ||
<small>[article_id_jump_termsofuse]</small> | ||
</div> | ||
</div> | ||
</fieldset> | ||
'; | ||
} | ||
|
||
public function triggerSaveSettings(): void | ||
{ | ||
$addon = rex_plugin::get('ycom', 'auth'); | ||
$addon->setConfig('article_id_jump_termsofuse', rex_request('article_id_jump_termsofuse', 'int', 0)); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
<?php | ||
|
||
class rex_ycom_otp_exception extends rex_exception {} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
<?php | ||
|
||
use OTPHP\Factory; | ||
use OTPHP\TOTP; | ||
|
||
final class rex_ycom_otp_method_email implements rex_ycom_otp_method_interface | ||
{ | ||
public static string $yform_email_template_key = 'ycom_otp_code_template'; | ||
|
||
public function challenge(string $provisioningUrl, rex_ycom_user $user): void | ||
{ | ||
$otp = Factory::loadFromProvisioningUri($provisioningUrl); | ||
$otpCode = $otp->at(time()); | ||
|
||
if ($ycom_otp_code_template = rex_yform_email_template::getTemplate(self::$yform_email_template_key)) { | ||
$values = []; | ||
$values['email'] = $user->getValue('email'); | ||
$values['name'] = $user->getValue('name'); | ||
$values['firstname'] = $user->getValue('firstname'); | ||
$values['code'] = $otpCode; | ||
|
||
$yform_email_template = rex_yform_email_template::replaceVars($ycom_otp_code_template, $values); | ||
$yform_email_template['mail_to'] = $user->getValue('email'); | ||
$yform_email_template['mail_to_name'] = $user->getValue('name'); | ||
|
||
if (!rex_yform_email_template::sendMail($yform_email_template, self::$yform_email_template_key)) { | ||
throw new Exception('Unable to send email via Template. Make sure to setup the phpmailer AddOn.'); | ||
} | ||
} else { | ||
$mail = new rex_mailer(); | ||
$mail->addAddress($user->getValue('email')); | ||
$mail->Subject = 'OTP-Code: (' . $_SERVER['HTTP_HOST'] . ')'; | ||
$mail->isHTML(); | ||
$mail->Body = '<style>body { font-size: 1.2em; text-align: center;}</style><h2>' . rex::getServerName() . ' Login verification</h2><br><h3><strong>' . $otpCode . '</strong></h3><br> is your 2 factor authentication code.'; | ||
$mail->AltBody = " Login verification \r\n ------------------ \r\n" . $otpCode . "\r\n ------------------ \r\nis your 2 factor authentication code."; | ||
|
||
if (!$mail->send()) { | ||
throw new Exception('Unable to send email. Make sure to setup the phpmailer AddOn.'); | ||
} | ||
} | ||
} | ||
|
||
public static function getPeriod(): int | ||
{ | ||
return (int) rex_addon::get('2factor_auth')->getConfig('email_period', 300); | ||
} | ||
|
||
public static function getloginTries(): int | ||
{ | ||
return 10; | ||
} | ||
|
||
public function verify(string $provisioningUrl, string $otp): bool | ||
{ | ||
$TOTP = Factory::loadFromProvisioningUri($provisioningUrl); | ||
|
||
// re-create from an existant uri | ||
if ($TOTP->verify($otp)) { | ||
return true; | ||
} | ||
|
||
$lastOTPCode = $TOTP->at(time() - self::getPeriod()); | ||
if ($lastOTPCode == $otp) { | ||
return Factory::loadFromProvisioningUri($provisioningUrl)->verify($TOTP->at(time())); | ||
} | ||
return false; | ||
} | ||
|
||
public function getProvisioningUri(rex_ycom_user $user): string | ||
{ | ||
// create a uri with a random secret | ||
$otp = TOTP::create(null, self::getPeriod()); | ||
|
||
// the label rendered in "Google Authenticator" or similar app | ||
$label = $user->getValue('login') . '@' . rex::getServerName() . ' (' . $_SERVER['HTTP_HOST'] . ')'; | ||
$label = str_replace(':', '_', $label); // colon is forbidden | ||
$otp->setLabel($label); | ||
$otp->setParameter('period', self::getPeriod()); | ||
$otp->setIssuer(str_replace(':', '_', $user->getValue('login'))); | ||
|
||
return $otp->getProvisioningUri(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
<?php | ||
|
||
interface rex_ycom_otp_method_interface | ||
{ | ||
/** | ||
* @throws exception | ||
*/ | ||
public function challenge(string $provisioningUrl, rex_ycom_user $user): void; | ||
|
||
/** | ||
* @throws exception | ||
*/ | ||
public function verify(string $provisioningUrl, string $otp): bool; | ||
|
||
public function getProvisioningUri(rex_ycom_user $user): string; | ||
|
||
public static function getPeriod(): int; | ||
|
||
public static function getloginTries(); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
<?php | ||
|
||
use OTPHP\Factory; | ||
use OTPHP\TOTP; | ||
|
||
use function str_replace; | ||
|
||
final class rex_ycom_otp_method_totp implements rex_ycom_otp_method_interface | ||
{ | ||
public function challenge(string $provisioningUrl, rex_ycom_user $user): void | ||
{ | ||
// nothing todo | ||
} | ||
|
||
public function verify(string $provisioningUrl, string $otp): bool | ||
{ | ||
// re-create from an existant uri | ||
return Factory::loadFromProvisioningUri($provisioningUrl)->verify($otp); | ||
} | ||
|
||
public static function getPeriod(): int | ||
{ | ||
// default period is 30s and digest is sha1. Google Authenticator is restricted to this settings | ||
return 30; | ||
} | ||
|
||
public static function getloginTries(): int | ||
{ | ||
return 10; | ||
} | ||
|
||
public function getProvisioningUri(rex_ycom_user $user): string | ||
{ | ||
// create a uri with a random secret | ||
$otp = TOTP::create(null, self::getPeriod()); | ||
|
||
// the label rendered in "Google Authenticator" or similar app | ||
$label = $user->getValue('login') . '@' . rex::getServerName() . ' (' . $_SERVER['HTTP_HOST'] . ')'; | ||
$label = str_replace(':', '_', $label); // colon is forbidden | ||
$otp->setLabel($label); | ||
$otp->setIssuer(str_replace(':', '_', $user->getValue('login'))); | ||
|
||
return $otp->getProvisioningUri(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
<?php | ||
|
||
use InvalidArgumentException; | ||
use rex; | ||
use rex_config; | ||
use rex_singleton_trait; | ||
|
||
final class rex_ycom_otp_password | ||
{ | ||
use rex_singleton_trait; | ||
|
||
public const ENFORCED_ALL = 'all'; | ||
public const ENFORCED_DISABLED = 'disabled'; | ||
|
||
public const OPTION_ALL = 'all'; | ||
public const OPTION_TOTP = 'totp_only'; | ||
public const OPTION_EMAIL = 'email_only'; | ||
|
||
/** @var rex_ycom_otp_method_interface|null */ | ||
private $method; | ||
|
||
public function challenge(): void | ||
{ | ||
$user = rex_ycom_auth::getUser(); | ||
$uri = str_replace('&', '&', (string) rex_ycom_otp_password_config::forCurrentUser()->getProvisioningUri()); | ||
$this->getMethod()->challenge($uri, $user); | ||
} | ||
|
||
public function verify(string $otp): bool | ||
{ | ||
$uri = str_replace('&', '&', (string) rex_ycom_otp_password_config::forCurrentUser()->getProvisioningUri()); | ||
$verified = $this->getMethod()->verify($uri, $otp); | ||
return $verified; | ||
} | ||
|
||
public function isVerified(): bool | ||
{ | ||
return rex_session('otp_verified', 'boolean', false); | ||
} | ||
|
||
public function isEnabled(): bool | ||
{ | ||
return rex_ycom_otp_password_config::forCurrentUser()->enabled; | ||
} | ||
|
||
/** | ||
* @param self::ENFORCE* $enforce | ||
*/ | ||
public function enforce($enforce): void | ||
{ | ||
rex_config::set('ycom', 'otp_auth_enforce', $enforce); | ||
} | ||
|
||
/** | ||
* @return self::ENFORCE* | ||
*/ | ||
public function isEnforced() | ||
{ | ||
return rex_config::get('ycom', 'otp_auth_enforce', self::ENFORCED_DISABLED); | ||
} | ||
|
||
/** | ||
* @return self::OPTION* | ||
*/ | ||
public function getAuthOption() | ||
{ | ||
return rex_config::get('ycom', 'otp_auth_option', self::OPTION_ALL); | ||
} | ||
|
||
public function setAuthOption(string $option): void | ||
{ | ||
rex_config::set('ycom', 'otp_auth_option', $option); | ||
} | ||
|
||
/** | ||
* @return rex_ycom_otp_method_interface | ||
*/ | ||
public function getMethod() | ||
{ | ||
if (null === $this->method) { | ||
$methodType = rex_ycom_otp_password_config::forCurrentUser()->getMethod(); | ||
|
||
if ('totp' === $methodType) { | ||
$this->method = new rex_ycom_otp_method_totp(); | ||
} elseif ('email' === $methodType) { | ||
$this->method = new rex_ycom_otp_method_email(); | ||
} else { | ||
throw new InvalidArgumentException("Unknown method: $methodType"); | ||
} | ||
} | ||
|
||
return $this->method; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,137 @@ | ||
<?php | ||
|
||
/** | ||
* @internal | ||
*/ | ||
final class rex_ycom_otp_password_config | ||
{ | ||
/** @var string|null */ | ||
public $provisioningUri; | ||
/** @var bool */ | ||
public $enabled = false; | ||
/** @var 'totp'|'email'|null */ | ||
public $method; | ||
/** @var rex_ycom_user */ | ||
public $user; | ||
|
||
public function __construct(rex_ycom_user $user) | ||
{ | ||
$this->user = $user; | ||
} | ||
|
||
public static function forCurrentUser(): self | ||
{ | ||
$user = rex_ycom_auth::getUser(); | ||
return self::forUser($user); | ||
} | ||
|
||
public static function forUser(rex_ycom_user $user): self | ||
{ | ||
return self::fromJson($user->getValue('otp_config'), $user); | ||
} | ||
|
||
public static function loadFromDb(rex_ycom_otp_method_interface $method, rex_ycom_user $user): self | ||
{ | ||
// get non-cached values | ||
$userSql = rex_sql::factory(); | ||
$userSql->setTable(rex::getTablePrefix() . 'ycom_user'); | ||
$userSql->setWhere(['id' => $user->getId()]); | ||
$userSql->select(); | ||
|
||
$json = (string) $userSql->getValue('otp_config'); | ||
$config = self::fromJson($json, $user); | ||
$config->method = $method instanceof rex_ycom_otp_method_email ? 'email' : 'totp'; | ||
if (null === $config->getProvisioningUri()) { | ||
$config->setProvisioningUri($method->getProvisioningUri($user)); | ||
} | ||
return $config; | ||
} | ||
|
||
private static function fromJson(?string $json, rex_ycom_user $user): self | ||
{ | ||
if (is_string($json)) { | ||
$configArr = json_decode($json, true); | ||
|
||
if (is_array($configArr)) { | ||
// compat with older versions, which did not yet define a method | ||
if (!array_key_exists('method', $configArr)) { | ||
$configArr['method'] = 'totp'; | ||
} | ||
|
||
$config = new self($user); | ||
$config->provisioningUri = $configArr['provisioningUri']; | ||
$config->enabled = $configArr['enabled']; | ||
$config->method = $configArr['method']; | ||
return $config; | ||
} | ||
} | ||
|
||
$method = new rex_ycom_otp_method_totp(); | ||
|
||
$default = new self($user); | ||
$default->method = $method instanceof rex_ycom_otp_method_email ? 'email' : 'totp'; | ||
$default->provisioningUri = $method->getProvisioningUri($user); | ||
|
||
return $default; | ||
} | ||
|
||
public function isEnabled(): bool | ||
{ | ||
return $this->enabled ? true : false; | ||
} | ||
|
||
public function enable(): self | ||
{ | ||
$this->enabled = true; | ||
return $this; | ||
} | ||
|
||
public function disable(): self | ||
{ | ||
$this->enabled = false; | ||
$this->provisioningUri = null; | ||
return $this; | ||
} | ||
|
||
public function updateMethod(rex_ycom_otp_method_interface $method): self | ||
{ | ||
$this->method = $method instanceof rex_ycom_otp_method_email ? 'email' : 'totp'; | ||
$this->provisioningUri = $method->getProvisioningUri($this->user); | ||
return $this; | ||
} | ||
|
||
public function getProvisioningUri() | ||
{ | ||
return $this->provisioningUri; | ||
} | ||
|
||
public function setProvisioningUri($provisioningUri): self | ||
{ | ||
$this->provisioningUri = $provisioningUri; | ||
return $this; | ||
} | ||
|
||
public function getMethod() | ||
{ | ||
return $this->method; | ||
} | ||
|
||
public function save(): void | ||
{ | ||
echo '<pre>'; | ||
debug_print_backtrace(); | ||
echo '</pre>'; | ||
|
||
$userSql = rex_sql::factory(); | ||
$userSql->setTable(rex::getTablePrefix() . 'ycom_user'); | ||
$userSql->setWhere(['id' => $this->user->getId()]); | ||
$userSql->setValue('otp_config', json_encode( | ||
[ | ||
'provisioningUri' => $this->provisioningUri, | ||
'method' => $this->method, | ||
'enabled' => $this->enabled, | ||
], | ||
)); | ||
$userSql->update(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
<?php | ||
|
||
class rex_yform_value_ycom_auth_otp extends rex_yform_value_abstract | ||
{ | ||
public function enterObject(): void | ||
{ | ||
// User ist hier, weil er will oder muss | ||
// 1. User ist eingeloggt | ||
|
||
$user = rex_ycom_auth::getUser(); | ||
if (!$user) { | ||
return; | ||
} | ||
|
||
$otp_article_id = (int) rex_addon::get('ycom')->getConfig('otp_article_id'); | ||
if (0 == $otp_article_id) { | ||
return; | ||
} | ||
|
||
// es gibt hier folgende Seiten | ||
|
||
// Setup - Auswahl der Methode | ||
// setup, bestätigung der Methode mit Code | ||
// -> hier wird, wenn erfolgereich, auf die UserStartseite geleitet | ||
|
||
// -> wenn user enabled ist dann auf setup und es eventuell zu deaktivieren | ||
|
||
// Verify - Eingabe des Codes | ||
// -> hier wird, wenn erfolgereich, auf die UserStartseite geleitet | ||
|
||
$page = 'setup'; | ||
$SessionInstance = null; | ||
$config = rex_ycom_otp_password_config::forCurrentUser(); | ||
if ($config->enabled) { | ||
$SessionInstance = rex_ycom_user_session::getInstance()->getCurrentSession($user); | ||
if (1 != $SessionInstance['otp_verified']) { | ||
$page = 'verify'; | ||
} | ||
} | ||
|
||
switch ($page) { | ||
case 'verify': | ||
$this->params['form_output'][$this->getId()] = $this->parse( | ||
['value.ycom_auth_otp_verify.tpl.php'], | ||
[ | ||
'user' => $user, | ||
'SessionInstance' => $SessionInstance, | ||
'config' => $config, | ||
'otp_article_id' => $otp_article_id, | ||
], | ||
); | ||
break; | ||
default: | ||
// setup | ||
$this->params['form_output'][$this->getId()] = $this->parse( | ||
['value.ycom_auth_otp_setup.tpl.php'], | ||
[ | ||
'user' => $user, | ||
'SessionInstance' => $SessionInstance, | ||
'config' => $config, | ||
'otp_article_id' => $otp_article_id, | ||
], | ||
); | ||
} | ||
} | ||
|
||
public function getDescription(): string | ||
{ | ||
return 'ycom_auth_otp -> Beispiel: ycom_auth_otp'; | ||
} | ||
|
||
/** | ||
* @return array<string, mixed> | ||
*/ | ||
public function getDefinitions(): array | ||
{ | ||
return [ | ||
'type' => 'value', | ||
'name' => 'ycom_auth_otp', | ||
'values' => [ | ||
'name' => ['type' => 'name', 'label' => 'Feld'], | ||
'label' => ['type' => 'text', 'label' => 'Bezeichnung'], | ||
], | ||
'description' => '', | ||
'famous' => false, | ||
]; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,190 @@ | ||
<?php | ||
|
||
/** @var rex_yform_value_abstract $this */ | ||
/** @var rex_ycom_user $user */ | ||
/** @var array|null $SessionInstance */ | ||
/** @var rex_ycom_otp_password_config $config */ | ||
/** @var int $otp_article_id */ | ||
|
||
$addon = rex_plugin::get('ycom', 'auth'); | ||
|
||
$otp = rex_ycom_otp_password::getInstance(); | ||
$otp_options = $otp->getAuthOption(); | ||
|
||
$func = rex_request('otp-func', 'string'); | ||
$myOTP = rex_post('otp', 'string', null); | ||
|
||
$otpOptions = []; | ||
|
||
switch ($otp_options) { | ||
case rex_ycom_otp_password::OPTION_ALL: | ||
$otpOptions[] = 'email'; | ||
$otpOptions[] = 'totp'; | ||
$defaultOption = 'email'; | ||
break; | ||
case rex_ycom_otp_password::OPTION_EMAIL: | ||
$defaultOption = 'email'; | ||
$otpOptions[] = $defaultOption; | ||
break; | ||
case rex_ycom_otp_password::OPTION_TOTP: | ||
$defaultOption = 'totp'; | ||
$otpOptions[] = $defaultOption; | ||
break; | ||
} | ||
|
||
// 1. Setup neu durchlaufen wenn man schon verified ist | ||
// 1.1. Authcode um OTP zu deaktivieren -> Schritt 2 | ||
|
||
if ($otp->isEnabled() && $config->enabled) { | ||
// TODO: AuthCode zum deaktivieren abfragen | ||
|
||
if ('disable' == $func) { | ||
$OTPInstance = rex_ycom_otp_password::getInstance(); | ||
$OTPMethod = $OTPInstance->getMethod(); | ||
rex_ycom_otp_password_config::loadFromDb($OTPMethod, $user) | ||
->disable() | ||
->save(); | ||
$func = ''; | ||
|
||
$this->params['warning'][$this->getId()] = $this->params['error_class']; | ||
$this->params['warning_messages'][$this->getId()] = '{ ycom_otp_diabled }'; | ||
} else { | ||
echo ' | ||
<div class="form-check"> | ||
<input class="form-check" type="radio" name="otp-func" id="otp-func" value="disable" checked="checked" /> | ||
<label class="form-check" for="otp-func-">{ ycom_otp_disable_info }</label> | ||
</div>'; | ||
} | ||
|
||
return; | ||
} | ||
|
||
// 2. Setup starten | ||
// 2.1 jeweile Methode auswählen | ||
// 2.2 -> Codeseite /email oder totp | ||
|
||
if (in_array($func, $otpOptions)) { | ||
switch ($func) { | ||
case 'email': | ||
$defaultOption = 'email'; | ||
$otpMethod = new rex_ycom_otp_method_email(); | ||
break; | ||
case 'totp': | ||
default: | ||
$defaultOption = 'totp'; | ||
$otpMethod = new rex_ycom_otp_method_totp(); | ||
break; | ||
} | ||
|
||
if (null === $myOTP) { | ||
rex_ycom_otp_password_config::loadFromDb($otpMethod, $user) | ||
->updateMethod($otpMethod) | ||
->save(); | ||
$user->loadData(); // Refresh OTP with new DB Data | ||
$this->params['warning'][$this->getId()] = $this->params['error_class']; | ||
} | ||
|
||
if ('email' === $func && (null === $myOTP || 'resend' == rex_request('otp-func-email', 'string'))) { | ||
$this->params['warning'][$this->getId()] = $this->params['error_class']; | ||
$this->params['warning_messages'][$this->getId()] = '{ ycom_otp_email_check }'; | ||
rex_ycom_otp_password::getInstance()->challenge(); | ||
} | ||
|
||
// initial starten wenn beim user nicht vorhanden oder noch nicht enabled. | ||
if (is_string($myOTP) && '' !== $myOTP) { | ||
if ($otp->verify($myOTP)) { | ||
rex_ycom_otp_password_config::loadFromDb($otpMethod, $user) | ||
->enable() | ||
->save(); | ||
$user->loadData(); | ||
$user | ||
->resetOTPTries() | ||
->save(); | ||
rex_ycom_user_session::getInstance() | ||
->setOTPverified($user); | ||
$article_jump_ok = (int) rex_plugin::get('ycom', 'auth')->getConfig('article_id_jump_ok'); | ||
rex_response::sendRedirect(rex_getUrl($article_jump_ok, rex_clang::getCurrentId())); | ||
} else { | ||
$this->params['warning'][$this->getId()] = $this->params['error_class']; | ||
$this->params['warning_messages'][$this->getId()] = '{ ycom_otp_code_error }'; | ||
} | ||
} | ||
|
||
if ('totp' == $func) { | ||
$uri = rex_ycom_otp_password_config::loadFromDb($otpMethod, $user)->getProvisioningUri(); | ||
|
||
?> | ||
<div class="row"> | ||
<div class="col-lg-6"> | ||
<div class="panel panel-default"> | ||
<div class="panel-heading"> | ||
<h3 class="panel-title">{ ycom_otp_setup_scan }</h3> | ||
</div> | ||
<div class="panel-body"> | ||
<div class="form-group"> | ||
<canvas id="ycom-auth-otp-qr-code"></canvas> | ||
</div> | ||
<div class="form-group"> | ||
<div class="input-group"> | ||
<label for="ycom-auth-otp-uri">{ ycom_otp_setup_uri }</label> | ||
<input type="text" class="form-control" value="<?= $uri ?>" id="ycom-auth-otp-uri" readonly /> | ||
</div> | ||
</div> | ||
</div> | ||
</div> | ||
</div> | ||
</div> | ||
<script src="<?= $addon->getAssetsUrl('qrious.min.js') ?>" nonce="<?= rex_response::getNonce() ?>"></script> | ||
<script nonce="<?= rex_response::getNonce() ?>"> | ||
new QRious({ | ||
element: document.getElementById("ycom-auth-otp-qr-code"), | ||
value: document.getElementById("ycom-auth-otp-uri").value, | ||
size: 300 | ||
}); | ||
</script> | ||
<?php | ||
} | ||
|
||
?> | ||
<div class="row"> | ||
<div class="col-lg-6"> | ||
<div class="panel panel-default"> | ||
<div class="panel-heading"> | ||
<h3 class="panel-title">{ ycom_otp_setup_title }</h3> | ||
</div> | ||
<div class="panel-body"> | ||
<input type="hidden" name="func" value="verify-totp" /> | ||
<div class="form-group"> | ||
<div class="input-group"> | ||
<label for="otp-setup-code">{ ycom_otp_setup_code }</label> | ||
<input type="text" class="form-control" name="otp" id="otp-setup-code" /> | ||
</div> | ||
</div> | ||
</div> | ||
</div> | ||
</div> | ||
</div> | ||
|
||
<?php | ||
|
||
if ('email' == $func) { | ||
?> | ||
<div class="row"> | ||
<div class="col-lg-6"> | ||
<div class="form-check"> | ||
<input class="form-check" type="checkbox" name="otp-func-email" id="otp-func-email-resend" value="resend" /> | ||
<label class="form-check" for="otp-func-email-resend">{ ycom_otp_email_resend_info }</label> | ||
</div> | ||
</div> | ||
</div><?php | ||
|
||
} | ||
} | ||
|
||
foreach ($otpOptions as $option) { | ||
echo ' | ||
<div class="form-check"> | ||
<input class="form-check" type="radio" name="otp-func" id="otp-func-' . $option . '" value="' . $option . '" ' . ($defaultOption == $option ? 'checked="checked"' : '') . ' /> | ||
<label class="form-check" for="otp-func-' . $option . '">{ ycom_otp_' . $option . '_info }</label> | ||
</div>'; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
<?php | ||
|
||
/** @var rex_yform_value_abstract $this */ | ||
/** @var rex_ycom_user $user */ | ||
/** @var array|null $SessionInstance */ | ||
/** @var rex_ycom_otp_password_config $config */ | ||
/** @var int $otp_article_id */ | ||
|
||
$addon = rex_plugin::get('ycom', 'auth'); | ||
|
||
$OTPInstance = rex_ycom_otp_password::getInstance(); | ||
$OTPMethod = $OTPInstance->getMethod(); | ||
$blockTime = (int) ($OTPMethod::getPeriod() / 10); | ||
$loginTriesAllowed = $OTPMethod::getloginTries(); | ||
$loginTries = (int) $user->getValue('otp_tries'); | ||
$loginLastTry = (int) $user->getValue('otp_last_try_time'); | ||
|
||
$myOTP = rex_post('otp', 'string', null); | ||
|
||
if (null !== $myOTP) { | ||
if ($loginTries >= $loginTriesAllowed && ($loginLastTry > time() - $blockTime)) { | ||
$this->params['warning'][$this->getId()] = $this->params['error_class']; | ||
$this->params['warning_messages'][$this->getId()] = '{{ ycom_otp_code_error_blocked }}'; | ||
$countdownTime = $loginLastTry - time() + $blockTime; | ||
|
||
echo '<script nonce="' . rex_response::getNonce() . '"> | ||
let countdown = ' . $countdownTime . '; | ||
let countdownElement = document.getElementById("otp_countdown"); | ||
let interval = setInterval(() => { | ||
countdown--; | ||
countdownElement.innerHTML = countdown; | ||
if (countdown <= 0) { | ||
clearInterval(interval); | ||
} | ||
}, 1000); | ||
</script>'; | ||
} else { | ||
if ($OTPInstance->verify($myOTP)) { | ||
$user->resetOTPTries()->save(); | ||
rex_ycom_user_session::getInstance()->setOTPVerified($user); | ||
$article_jump_ok = (int) rex_plugin::get('ycom', 'auth')->getConfig('article_id_jump_ok'); | ||
rex_response::sendRedirect(rex_getUrl($article_jump_ok, rex_clang::getCurrentId())); | ||
} else { | ||
$user->increaseOTPTries()->save(); | ||
$this->params['warning'][$this->getId()] = $this->params['error_class']; | ||
$this->params['warning_messages'][$this->getId()] = '{ ycom_otp_code_error }'; | ||
} | ||
} | ||
} else { | ||
if ('rex_ycom_otp_method_email' === $OTPMethod::class) { | ||
rex_ycom_otp_password::getInstance()->challenge(); | ||
} | ||
} | ||
|
||
?> | ||
|
||
<div class="row"> | ||
<div class="col-lg-6"> | ||
<div class="panel panel-default"> | ||
<div class="panel-heading"> | ||
<h3 class="panel-title">{ ycom_otp_verify_title }</h3> | ||
</div> | ||
<div class="panel-body"> | ||
<input type="hidden" name="func" value="verify-totp" /> | ||
<div class="form-group"> | ||
<div class="input-group"> | ||
<label for="otp_verify_code">{ ycom_otp_verify_code }</label> | ||
<input type="text" class="form-control" name="otp" id="otp_verify_code" /> | ||
</div> | ||
</div> | ||
</div> | ||
</div> | ||
</div> | ||
</div> |
This file was deleted.
This file was deleted.
This file was deleted.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.