Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config/areas/account/drawers.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
...$drawers['user.security.method.code'],
'pattern' => '(account)/security/method/code',
],
'account.security.challenge.email' => [
...$drawers['user.security.challenge.email'],
'pattern' => '(account)/security/challenge/email',
],
'account.security.challenge.totp' => [
...$drawers['user.security.challenge.totp'],
'pattern' => '(account)/security/challenge/totp',
Expand Down
5 changes: 5 additions & 0 deletions config/areas/users/drawers.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use Kirby\Panel\Controller\Drawer\FieldDrawerController;
use Kirby\Panel\Controller\Drawer\SectionDrawerController;
use Kirby\Panel\Controller\Drawer\UserEmailChallengeDrawerController;
use Kirby\Panel\Controller\Drawer\UserSecurityCodeMethodDrawerController;
use Kirby\Panel\Controller\Drawer\UserSecurityDrawerController;
use Kirby\Panel\Controller\Drawer\UserTotpDrawerController;
Expand All @@ -16,6 +17,10 @@
'pattern' => 'users/(:any)/security/method/code',
'action' => UserSecurityCodeMethodDrawerController::class
],
'user.security.challenge.email' => [
'pattern' => 'users/(:any)/security/challenge/email',
'action' => UserEmailChallengeDrawerController::class
],
'user.security.challenge.totp' => [
'pattern' => 'users/(:any)/security/challenge/totp',
'action' => UserTotpDrawerController::class
Expand Down
6 changes: 6 additions & 0 deletions i18n/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,12 @@
"lock.isUnlocked": "Was unlocked by another user",

"login": "Log in",
"login.challenge.email.description": "Send a one‑time code to your email address that is used as a second factor when signing into your account.",
"login.challenge.email.disable.confirm": "Do you really want to disable codes via email for {user}?",
"login.challenge.email.disable.label": "Disable codes via email",
"login.challenge.email.empty": "Codes via email not set up yet",
"login.challenge.email.enable.label": "Enable codes via email",
"login.challenge.email.help": "Enter the code just sent to your email inbox.",
"login.challenge.email.label": "Code via email",
"login.challenge.totp.label": "Authenticator app",
"login.challenges.help": "Two-factor methods can add an additional layer of security by requiring more than just a password to sign in.",
Expand Down
168 changes: 168 additions & 0 deletions panel/src/components/Drawers/UserEmailChallengeDrawer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
<template>
<k-drawer
ref="drawer"
v-bind="$props"
class="k-user-email-challenge-drawer"
@cancel="$emit('cancel')"
@submit="$emit('cancel')"
>
<form
ref="form"
class="k-stack"
style="gap: var(--spacing-6)"
@submit.prevent="onSubmit"
>
<k-user-info :label="$t('account')" :user="user" />

<k-drawer-text :text="$t('login.challenge.email.description')" />

<template v-if="isAccount">
<!-- 1. start the process, which sends the code -->
<k-button
v-if="hasCode === false"
:disabled="isLoading"
:icon="isLoading ? 'loader' : actionIcon"
:text="actionText"
:theme="actionTheme"
variant="filled"
@click="start"
/>

<!-- 2. confirm with the code that was sent -->
<template v-else>
<k-text-field
:counter="false"
:help="$t('login.challenge.email.help')"
:label="codeLabel"
:placeholder="$t('login.code.placeholder.email')"
:required="true"
:value="code"
autocomplete="one-time-code"
font="monospace"
@input="code = $event"
/>

<k-button
:disabled="isLoading"
:icon="isLoading ? 'loader' : 'check'"
:text="actionText"
:theme="actionTheme"
variant="filled"
@click="confirm"
/>
Comment thread
lukasbestle marked this conversation as resolved.
</template>
</template>

<!-- an admin managing another user re-enters their own password -->
<k-button
v-else-if="isEnabled"
:disabled="isLoading"
:icon="isLoading ? 'loader' : 'unlock'"
:text="$t('disable')"
theme="negative"
variant="filled"
@click="disableAsAdmin"
/>

<k-empty v-else icon="email-unread">
{{ $t("login.challenge.email.empty") }}
</k-empty>
</form>
</k-drawer>
</template>

<script>
import UserCredentialDrawer from "./UserCredentialDrawer.vue";

/**
* Drawer to enable/disable codes via email as a second factor
*
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 6.0.0
*/
export default {
extends: UserCredentialDrawer,
props: {
isEnabled: Boolean
},
emits: ["cancel", "submit"],
data() {
return {
code: "",
hasCode: false
};
},
computed: {
actionIcon() {
return this.isEnabled ? "unlock" : "lock";
},
actionText() {
return this.isEnabled ? this.$t("disable") : this.$t("activate");
},
actionTheme() {
return this.isEnabled ? "negative" : "positive";
},
codeLabel() {
if (this.isEnabled === true) {
return this.$t("login.challenge.email.disable.label");
}

return this.$t("login.challenge.email.enable.label");
}
},
watch: {
isEnabled() {
// the challenge was just enabled or disabled, so the
// completed step must not stay on screen
this.reset();
}
},
methods: {
async confirm() {
if (this.$refs.form.reportValidity()) {
await this.request(this.isEnabled ? "remove" : "create", {
authorization: this.code
});
}
},
disableAsAdmin() {
// an admin managing another user re-enters their own password
this.confirmPassword({
text: this.$t("login.challenge.email.disable.confirm", {
user: this.$helper.string.escapeHTML(this.user.email)
}),
button: {
text: this.$t("disable"),
icon: "unlock"
},
onSubmit: (password) => this.request("remove", { password })
});
},
onSubmit() {
if (this.isAccount === true && this.hasCode === true) {
this.confirm();
}
},
reset() {
this.code = "";
this.hasCode = false;
},
async start() {
this.isLoading = true;

try {
const response = await this.$panel.drawer.post({ action: "code" });

if (response === false) {
return;
}

this.hasCode = true;
} finally {
this.isLoading = false;
}
}
}
};
</script>
14 changes: 13 additions & 1 deletion panel/src/components/Drawers/UserTotpDrawer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
@cancel="$emit('cancel')"
@submit="$emit('cancel')"
>
<form ref="form" class="k-stack" style="gap: var(--spacing-6)">
<form
ref="form"
class="k-stack"
style="gap: var(--spacing-6)"
@submit.prevent="onSubmit"
>
<k-user-info :label="$t('account')" :user="user" />

<k-drawer-text :text="$t('login.totp.description')" />
Expand Down Expand Up @@ -149,6 +154,13 @@ export default {
},
onSubmit: (password) => this.request("remove", { password })
});
},
onSubmit() {
if (this.isAccount === false) {
return;
}

this.isEnabled ? this.disable() : this.create();
}
}
};
Expand Down
12 changes: 11 additions & 1 deletion panel/src/components/Drawers/UserWebauthnDrawer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
@cancel="$emit('cancel')"
@submit="$emit('cancel')"
>
<form ref="form" class="k-stack" style="gap: var(--spacing-6)">
<form
ref="form"
class="k-stack"
style="gap: var(--spacing-6)"
@submit.prevent="onSubmit"
>
<k-user-info :label="$t('account')" :user="user" />

<k-box
Expand Down Expand Up @@ -149,6 +154,11 @@ export default {
(authorization) => this.request("remove", { id, authorization }),
(error) => this.$panel.notification.error(error)
);
},
onSubmit() {
if (this.isAccount === true) {
this.create();
}
}
}
};
Expand Down
2 changes: 2 additions & 0 deletions panel/src/components/Drawers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import StateDrawer from "./StateDrawer.vue";
import FormDrawer from "./FormDrawer.vue";
import StructureDrawer from "./StructureDrawer.vue";
import TextDrawer from "./TextDrawer.vue";
import UserEmailChallengeDrawer from "./UserEmailChallengeDrawer.vue";
import UserSecurityDrawer from "./UserSecurityDrawer.vue";
import UserTotpDrawer from "./UserTotpDrawer.vue";
import UserWebauthnDrawer from "./UserWebauthnDrawer.vue";
Expand All @@ -22,6 +23,7 @@ export default {
app.component("k-form-drawer", FormDrawer);
app.component("k-structure-drawer", StructureDrawer);
app.component("k-text-drawer", TextDrawer);
app.component("k-user-email-challenge-drawer", UserEmailChallengeDrawer);
app.component("k-user-security-drawer", UserSecurityDrawer);
app.component("k-user-totp-drawer", UserTotpDrawer);
app.component("k-user-webauthn-drawer", UserWebauthnDrawer);
Expand Down
27 changes: 23 additions & 4 deletions src/Auth/Challenge/EmailChallenge.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,26 @@ public static function icon(): string
return 'email-unread';
}

/**
* As a second factor, the email challenge is opt-in per user,
* so that users are not forced into it just by having an email
* address. For every other purpose it stays always available.
*/
public static function isAvailable(User $user, string $mode): bool
{
if ($mode !== '2fa') {
return true;
}

if ($user->secret('email') === true) {
return true;
}

// enforced 2FA needs a factor for every user, so email stays
// the baseline for those who have not set up anything else
return $user->kirby()->auth()->methods()->hasAnyRequiring2FA();
}

/**
* Sends the email with the code to the user
*/
Expand Down Expand Up @@ -105,10 +125,9 @@ public static function settings(User $user): array
{
return [
new Button(
icon: static::icon(),
text: static::i18n('login.challenge.email.label'),
dialog: $user->panel()->url(true) . '/changeEmail',
disabled: !$user->permissions()->can('changeEmail')
icon: static::icon(),
text: static::i18n('login.challenge.email.label'),
drawer: $user->panel()->url(true) . '/security/challenge/email'
)
];
}
Expand Down
9 changes: 9 additions & 0 deletions src/Auth/Challenges.php
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,15 @@ public function get(
);
}

/**
* Checks whether at least one challenge is available
* for the user and purpose/mode
*/
public function hasAvailable(User $user, string $mode): bool
{
return $this->available($user, $mode) !== [];
}

/**
* Writes challenge state into the session
*/
Expand Down
10 changes: 0 additions & 10 deletions src/Auth/Method.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,6 @@ public static function isEnabled(
return true;
}

/**
* Checks if this method uses challenges
*/
public static function isUsingChallenges(
Auth $auth,
array $options = []
): bool {
return false;
}

/**
* Returns the config options for this method
*/
Expand Down
Loading
Loading