Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Passwordless login, password reset via email & 2FA support #2923

Merged
merged 5 commits into from Nov 17, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
18 changes: 18 additions & 0 deletions config/api/models/System.php
Expand Up @@ -35,6 +35,21 @@
'license' => function (System $system) {
return $system->license();
},
'loginMethods' => function (System $system) {
return array_keys($system->loginMethods());
},
'pendingChallenge' => function () {
if ($this->session()->get('kirby.challenge.email') === null) {
return null;
}

// fake the email challenge if no challenge was created
// to avoid leaking whether the user exists
return $this->session()->get('kirby.challenge.type', 'email');
},
'pendingEmail' => function () {
return $this->session()->get('kirby.challenge.email');
},
'requirements' => function (System $system) {
return $system->toArray();
},
Expand Down Expand Up @@ -86,6 +101,9 @@
'isOk',
'isInstallable',
'isInstalled',
'loginMethods',
'pendingChallenge',
'pendingEmail',
'title',
'translation'
],
Expand Down
67 changes: 61 additions & 6 deletions config/api/routes/auth.php
Expand Up @@ -19,7 +19,7 @@
}
],
[
'pattern' => 'auth/login',
'pattern' => 'auth/code',
'method' => 'POST',
'auth' => false,
'action' => function () {
Expand All @@ -30,11 +30,7 @@
throw new InvalidArgumentException('Invalid CSRF token');
}

$email = $this->requestBody('email');
$long = $this->requestBody('long');
$password = $this->requestBody('password');

$user = $this->kirby()->auth()->login($email, $password, $long);
$user = $auth->verifyChallenge($this->requestBody('code'));

return [
'code' => 200,
Expand All @@ -43,6 +39,65 @@
];
}
],
[
'pattern' => 'auth/login',
'method' => 'POST',
'auth' => false,
'action' => function () {
$auth = $this->kirby()->auth();
$methods = $this->kirby()->system()->loginMethods();

// csrf token check
if ($auth->type() === 'session' && $auth->csrf() === false) {
throw new InvalidArgumentException('Invalid CSRF token');
}

$email = $this->requestBody('email');
$long = $this->requestBody('long');
$password = $this->requestBody('password');

if ($password) {
if (isset($methods['password']) !== true) {
throw new InvalidArgumentException('Login with password is not enabled');
}

if (
isset($methods['password']['2fa']) === true &&
$methods['password']['2fa'] === true
) {
$challenge = $auth->login2fa($email, $password, $long);
} else {
$user = $auth->login($email, $password, $long);
}
} else {
if (isset($methods['code']) === true) {
$mode = 'login';
} elseif (isset($methods['password-reset']) === true) {
$mode = 'password-reset';
} else {
throw new InvalidArgumentException('Login without password is not enabled');
}

$challenge = $auth->createChallenge($email, $long, $mode);
}

if (isset($user)) {
return [
'code' => 200,
'status' => 'ok',
'user' => $this->resolve($user)->view('auth')->toArray()
];
} else {
return [
'code' => 200,
'status' => 'ok',

// don't leak users that don't exist at this point
'challenge' => $challenge ?? 'email'
];
}
}
],
[
'pattern' => 'auth/logout',
'method' => 'POST',
Expand Down
3 changes: 2 additions & 1 deletion config/templates.php
Expand Up @@ -2,6 +2,7 @@

// @codeCoverageIgnoreStart
return [

'emails/auth/login' => __DIR__ . '/templates/emails/auth/login.php',
'emails/auth/password-reset' => __DIR__ . '/templates/emails/auth/password-reset.php'
];
// @codeCoverageIgnoreEnd
3 changes: 3 additions & 0 deletions config/templates/emails/auth/login.php
@@ -0,0 +1,3 @@
<?php

echo I18n::template('login.email.login.body', null, compact('user', 'code', 'timeout'));
3 changes: 3 additions & 0 deletions config/templates/emails/auth/password-reset.php
@@ -0,0 +1,3 @@
<?php

echo I18n::template('login.email.password-reset.body', null, compact('user', 'code', 'timeout'));
14 changes: 14 additions & 0 deletions i18n/translations/en.json
Expand Up @@ -41,6 +41,7 @@
"email": "Email",
"email.placeholder": "mail@example.com",

"error.access.code": "Invalid code",
"error.access.login": "Invalid login",
"error.access.panel": "You are not allowed to access the panel",
"error.access.view": "You are not allowed to access this part of the panel",
Expand Down Expand Up @@ -283,7 +284,20 @@
"lock.isUnlocked": "Your unsaved changes have been overwritten by another user. You can download your changes to merge them manually.",

"login": "Login",
"login.code.label.login": "Login code",
"login.code.label.password-reset": "Password reset code",
"login.code.placeholder.email": "000 000",
"login.code.text.email": "If your email address is registered, the requested code was sent via email.",
"login.email.login.body": "Hi {user.nameOrEmail},\n\nYou recently requested a login code for the Kirby Panel.\nThe following login code will be valid for {timeout} minutes:\n\n{code}\n\nIf you did not request a login code, please ignore this email or contact your administrator if you have questions.\nFor security, please DO NOT forward this email.",
"login.email.login.subject": "Your login code",
"login.email.password-reset.body": "Hi {user.nameOrEmail},\n\nYou recently requested a password reset code for the Kirby Panel.\nThe following password reset code will be valid for {timeout} minutes:\n\n{code}\n\nIf you did not request a password reset code, please ignore this email or contact your administrator if you have questions.\nFor security, please DO NOT forward this email.",
"login.email.password-reset.subject": "Your password reset code",
"login.remember": "Keep me logged in",
"login.reset": "Reset password",
"login.toggleText.code.email": "Login via email",
"login.toggleText.code.email-password": "Login with password",
"login.toggleText.password-reset.email": "Forgot your password?",
"login.toggleText.password-reset.email-password": "← Back to login",

"logout": "Logout",

Expand Down
6 changes: 4 additions & 2 deletions panel/src/api/auth.js
Expand Up @@ -7,14 +7,16 @@ export default (api) => {
password: user.password
};

const auth = await api.post("auth/login", data);
return auth.user;
return await api.post("auth/login", data);
},
async logout() {
return api.post("auth/logout");
},
async user(params) {
return api.get("auth", params);
},
async verifyCode(code) {
return await api.post("auth/code", {code});
}
}
};
124 changes: 112 additions & 12 deletions panel/src/components/Forms/Login.vue
Expand Up @@ -7,10 +7,29 @@
<k-icon type="alert" />
</div>

<k-fieldset :novalidate="true" :fields="fields" v-model="user" />
<div class="k-login-fields">
<button
v-if="canToggle === true"
class="k-login-toggler"
type="button"
@click="toggleForm"
>
{{ toggleText }}
</button>

<k-fieldset
ref="fieldset"
v-model="user"
:novalidate="true"
:fields="fields"
/>
</div>

<div class="k-login-buttons">
<span class="k-login-checkbox">
<span
v-if="isResetForm === false"
class="k-login-checkbox"
>
<k-checkbox-input
:value="user.remember"
:label="$t('login.remember')"
Expand All @@ -22,7 +41,8 @@
icon="check"
type="submit"
>
{{ $t("login") }} <template v-if="isLoading">…</template>
{{ $t("login" + (isResetForm ? ".reset" : "")) }}
<template v-if="isLoading">…</template>
</k-button>
</div>
</form>
Expand All @@ -32,6 +52,7 @@
export default {
data() {
return {
currentForm: null,
isLoading: false,
issue: "",
user: {
Expand All @@ -42,43 +63,122 @@ export default {
};
},
computed: {
canToggle() {
let loginMethods = this.$store.state.system.info.loginMethods;

return (
this.codeMode !== null &&
loginMethods.includes("password") === true &&
(
loginMethods.includes("password-reset") === true ||
loginMethods.includes("code") === true
)
);
},
codeMode() {
let loginMethods = this.$store.state.system.info.loginMethods;

if (loginMethods.includes("password-reset") === true) {
return "password-reset";
} else if (loginMethods.includes("code") === true) {
return "code";
} else {
return null;
}
},
fields() {
return {
let fields = {
email: {
autofocus: true,
label: this.$t("email"),
type: "email",
required: true,
link: false,
},
password: {
}
};

if (this.form === "email-password") {
fields.password = {
label: this.$t("password"),
type: "password",
minLength: 8,
required: true,
autocomplete: "current-password",
counter: false
}
};
};
}

return fields;
},
form() {
if (this.currentForm) {
return this.currentForm;
} else if (this.$store.state.system.info.loginMethods[0] === "password") {
return "email-password";
} else {
return "email";
}
},
isResetForm() {
return (
this.codeMode === "password-reset" &&
this.form === "email"
);
},
toggleText() {
return this.$t(
"login.toggleText." +
this.codeMode + "." +
this.formOpposite(this.form)
);
}
},
methods: {
formOpposite(input) {
if (input === "email-password") {
return "email";
} else {
return "email-password";
}
},
async login() {
this.issue = null;
this.isLoading = true;

// clear field data that is not needed for login
let user = Object.assign({}, this.user);

if (this.currentForm === "email") {
user.password = null;
}

if (this.isResetForm === true) {
user.remember = false;
}

try {
await this.$store.dispatch("user/login", this.user);
await this.$store.dispatch("system/load", true);
const result = await this.$api.auth.login(user);

this.$store.dispatch("notification/success", this.$t("welcome"));
if (result.challenge) {
this.$store.dispatch("user/pending", {
email: user.email,
challenge: result.challenge
});
} else {
this.$store.dispatch("user/login", result.user);
await this.$store.dispatch("system/load", true);

this.$store.dispatch("notification/success", this.$t("welcome"));
}
} catch (error) {
this.issue = error.message;

} finally {
this.isLoading = false;
}
},
toggleForm() {
this.currentForm = this.formOpposite(this.form);
this.$refs.fieldset.focus("email");
}
}
};
Expand Down