Merge pull request #19363 from shariquerik/login-without-password

feat: Login With Email Link
This commit is contained in:
Ankush Menat 2022-12-30 14:05:39 +05:30 committed by GitHub
commit b414fff09f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 235 additions and 12 deletions

View file

@ -39,6 +39,8 @@
"allow_login_using_mobile_number",
"allow_login_using_user_name",
"disable_user_pass_login",
"login_with_email_link",
"login_with_email_link_expiry",
"allow_error_traceback",
"strip_exif_metadata_from_uploaded_images",
"allow_older_web_view_links",
@ -416,11 +418,11 @@
"label": "Send document Web View link in email"
},
{
"collapsible": 1,
"fieldname": "prepared_report_section",
"fieldtype": "Section Break",
"label": "Reports"
},
"collapsible": 1,
"fieldname": "prepared_report_section",
"fieldtype": "Section Break",
"label": "Reports"
},
{
"default": "Frappe",
"description": "The application name will be used in the Login page.",
@ -504,12 +506,26 @@
"fieldname": "disable_user_pass_login",
"fieldtype": "Check",
"label": "Disable Username/Password Login"
},
{
"default": "0",
"description": "Allow users to log in without a password, using a login link sent to their email",
"fieldname": "login_with_email_link",
"fieldtype": "Check",
"label": "Login with email link"
},
{
"default": "10",
"depends_on": "login_with_email_link",
"fieldname": "login_with_email_link_expiry",
"fieldtype": "Int",
"label": "Login with email link expiry (in minutes)"
}
],
"icon": "fa fa-cog",
"issingle": 1,
"links": [],
"modified": "2022-11-28 17:57:05.099512",
"modified": "2022-12-20 21:45:37.651668",
"modified_by": "Administrator",
"module": "Core",
"name": "System Settings",

View file

@ -7,6 +7,7 @@ body {
}
.for-forgot,
.for-login-with-email-link,
.for-signup,
.for-email-login {
display: none;
@ -14,6 +15,7 @@ body {
.for-login,
.for-forgot,
.for-login-with-email-link,
.for-signup,
.for-email-login {
padding: max(10vh, 60px) 0;

View file

@ -0,0 +1,41 @@
{% macro table(content, table_class) %}
<table class="{{ table_class or '' }}" cellpadding="0" cellspacing="0" width="100%" align="center">
<tbody>
<tr>
<td align="center">
{{ content }}
</td>
</tr>
</tbody>
</table>
{% endmacro %}
{% macro body() %}
<table width="100%" cellspacing="0" cellpadding="0">
<tbody>
<tr>
<td align="center">
<div class="email-header-title">
{{ _('Click on the button to log in to {0}').format(app_name) }}
</div>
<div>{{ _('The link will expire in {0} minutes').format(minutes) }}</div>
</td>
</tr>
<tr>
<td align="center">
<div class="btn btn-primary" style="margin-top: 30px;">
<a href="{{ link or '#'}}" style="color: #fff; text-decoration: none;">
{{ _('Log In To {0}').format(app_name) }}
</a>
</div>
</td>
</tr>
</tbody>
</table>
{% endmacro %}
<div class="body-table with-container">
<div class="body-content">
{{ table(table(body(), 'email-body'), 'email-container') }}
</div>
</div>

View file

@ -55,6 +55,25 @@ login.bind_events = function () {
return false;
});
$(".form-login-with-email-link").on("submit", function (event) {
event.preventDefault();
var args = {};
args.cmd = "frappe.www.login.send_login_link";
args.email = ($("#login_with_email_link_email").val() || "").trim();
if (!args.email) {
login.set_status('{{ _("Valid Login id required.") }}', 'red');
return false;
}
login.call(args).then(() => {
login.set_status('{{ _("Login link sent to your email") }}', 'blue');
$("#login_with_email_link_email").val("");
}).catch(() => {
login.set_status('{{ _("Send login link") }}', 'blue');
});
return false;
});
$(".toggle-password").click(function () {
var input = $($(this).attr("toggle"));
if (input.attr("type") == "password") {
@ -86,6 +105,7 @@ login.bind_events = function () {
login.route = function () {
var route = window.location.hash.slice(1);
if (!route) route = "login";
route = route.replaceAll("-", "_");
login[route]();
}
@ -94,6 +114,7 @@ login.reset_sections = function (hide) {
$("section.for-login").toggle(false);
$("section.for-email-login").toggle(false);
$("section.for-forgot").toggle(false);
$("section.for-login-with-email-link").toggle(false);
$("section.for-signup").toggle(false);
}
$('section:not(.signup-disabled) .indicator').each(function () {
@ -121,10 +142,22 @@ login.steptwo = function () {
login.forgot = function () {
login.reset_sections();
if ($("#login_email").val()) {
$("#forgot_email").val($("#login_email").val());
}
$(".for-forgot").toggle(true);
$("#forgot_email").focus();
}
login.login_with_email_link = function () {
login.reset_sections();
if ($("#login_email").val()) {
$("#login_with_email_link_email").val($("#login_email").val());
}
$(".for-login-with-email-link").toggle(true);
$("#login_with_email_link_email").focus();
}
login.signup = function () {
login.reset_sections();
$(".for-signup").toggle(true);
@ -270,7 +303,7 @@ frappe.ready(function () {
$(window).trigger("hashchange");
}
$(".form-signup, .form-forgot").removeClass("hide");
$(".form-signup, .form-forgot, .form-login-with-email-link").removeClass("hide");
$(document).trigger('login_rendered');
});

View file

@ -2,11 +2,14 @@
# License: MIT. See LICENSE
import time
import requests
import frappe
import frappe.utils
from frappe.auth import LoginAttemptTracker
from frappe.frappeclient import AuthError, FrappeClient
from frappe.tests.utils import FrappeTestCase
from frappe.www.login import _generate_temporary_login_link
def add_user(email, password, username=None, mobile_no=None):
@ -42,6 +45,9 @@ class TestAuth(FrappeTestCase):
@classmethod
def tearDownClass(cls):
frappe.delete_doc("User", cls.test_user_email, force=True)
frappe.local.request_ip = None
frappe.form_dict.email = None
frappe.local.response["http_status_code"] = None
def set_system_settings(self, k, v):
frappe.db.set_value("System Settings", "System Settings", k, v)
@ -123,6 +129,32 @@ class TestAuth(FrappeTestCase):
with self.assertRaises(Exception):
FrappeClient(self.HOST_NAME, self.test_user_email, self.test_user_password).get_list("ToDo")
def test_login_with_email_link(self):
user = self.test_user_email
# Logs in
res = requests.get(_generate_temporary_login_link(user, 10))
self.assertEqual(res.status_code, 200)
self.assertTrue(res.cookies.get("sid"))
self.assertNotEqual(res.cookies.get("sid"), "Guest")
# Random incorrect URL
res = requests.get(_generate_temporary_login_link(user, 10) + "aa")
self.assertEqual(res.cookies.get("sid"), "Guest")
# POST doesn't work
res = requests.post(_generate_temporary_login_link(user, 10))
self.assertEqual(res.status_code, 403)
# Rate limiting
for _ in range(6):
res = requests.get(_generate_temporary_login_link(user, 10))
if res.status_code == 417:
break
else:
self.fail("Rate limting not working")
class TestLoginAttemptTracker(FrappeTestCase):
def test_account_lock(self):

View file

@ -13,9 +13,6 @@ from frappe.utils import cint
class TestRateLimiter(FrappeTestCase):
def setUp(self):
pass
def test_apply_with_limit(self):
frappe.conf.rate_limit = {"window": 86400, "limit": 1}
frappe.rate_limiter.apply()

View file

@ -80,7 +80,7 @@
<div class="login-content page-card">
{{ logo_section() }}
<form class="form-signin form-login" role="form">
{%- if social_login -%}
{%- if social_login or login_with_email_link -%}
<div class="page-card-body">
<form class="form-signin form-login" role="form">
{{ email_login_body() }}
@ -101,6 +101,15 @@
</div>
{% endfor %}
</div>
{% if login_with_email_link %}
<div class="login-with-email-link">
<div class="login-button-wrapper">
<a href="#login-with-email-link"
class="btn btn-block btn-default btn-sm btn-login-option btn-login-with-email-link">
{{ _("Login with Email Link") }}</a>
</div>
</div>
{% endif %}
</div>
</div>
{% else %}
@ -181,6 +190,38 @@
</form>
</div>
</section>
<section class='for-login-with-email-link'>
<div class="login-content page-card">
<form class="form-signin form-login-with-email-link hide" role="form">
<div class="page-card-head">
<h4>{{ _('Login With Email Link') }}</h4>
</div>
<div class="page-card-body">
<div class="email-field">
<input type="email" id="login_with_email_link_email" class="form-control"
placeholder="{{ _('Email Address') }}" required autofocus autocomplete="username">
<svg class="field-icon email-icon" width="20" height="20" viewBox="0 0 20 20" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M2.5 7.65149V15.0757C2.5 15.4374 2.64367 15.7842 2.8994 16.04C3.15513 16.2957 3.50198 16.4394 3.86364 16.4394H16.1364C16.498 16.4394 16.8449 16.2957 17.1006 16.04C17.3563 15.7842 17.5 15.4374 17.5 15.0757V7.65149"
stroke="#74808B" stroke-miterlimit="10" stroke-linecap="square" />
<path
d="M17.5 7.57572V5.53026C17.5 5.1686 17.3563 4.82176 17.1006 4.56603C16.8449 4.31029 16.498 4.16663 16.1364 4.16663H3.86364C3.50198 4.16663 3.15513 4.31029 2.8994 4.56603C2.64367 4.82176 2.5 5.1686 2.5 5.53026V7.57572L10 10.8333L17.5 7.57572Z"
stroke="#74808B" stroke-miterlimit="10" stroke-linecap="square" />
</svg>
</div>
</div>
<div class="page-card-actions">
<button class="btn btn-sm btn-primary btn-block btn-login-with-email-link"
type="submit">{{ _("Send login link") }}</button>
<p class="text-center sign-up-message">
<a href="#login">{{ _("Back to Login") }}</a>
</p>
</div>
</form>
</div>
</section>
</div>
{% endblock %}

View file

@ -7,7 +7,8 @@ from frappe import _
from frappe.auth import LoginManager
from frappe.integrations.doctype.ldap_settings.ldap_settings import LDAPSettings
from frappe.integrations.oauth2_logins import decoder_compat
from frappe.utils import cint
from frappe.rate_limiter import rate_limit
from frappe.utils import cint, get_url
from frappe.utils.html_utils import get_icon_html
from frappe.utils.jinja import guess_is_path
from frappe.utils.oauth import (
@ -102,6 +103,8 @@ def get_context(context):
context["login_label"] = f" {_('or')} ".join(login_label)
context["login_with_email_link"] = frappe.get_system_settings("login_with_email_link")
return context
@ -143,3 +146,61 @@ def login_via_token(login_token):
redirect_post_login(
desk_user=frappe.db.get_value("User", frappe.session.user, "user_type") == "System User"
)
@frappe.whitelist(allow_guest=True)
@rate_limit(limit=5, seconds=60 * 60)
def send_login_link(email: str):
expiry = frappe.get_system_settings("login_with_email_link_expiry") or 10
link = _generate_temporary_login_link(email, expiry)
app_name = (
frappe.get_website_settings("app_name") or frappe.get_system_settings("app_name") or _("Frappe")
)
subject = _("Login To {0}").format(app_name)
frappe.sendmail(
subject=subject,
recipients=email,
template="login_with_email_link",
args={"link": link, "minutes": expiry, "app_name": app_name},
now=True,
)
def _generate_temporary_login_link(email: str, expiry: int):
assert isinstance(email, str)
if not frappe.db.exists("User", email):
frappe.throw(
_("User with email address {0} does not exist").format(email), frappe.DoesNotExistError
)
key = frappe.generate_hash()
frappe.cache().set_value(f"one_time_login_key:{key}", email, expires_in_sec=expiry * 60)
return get_url(f"/api/method/frappe.www.login.login_via_key?key={key}")
@frappe.whitelist(allow_guest=True, methods=["GET"])
@rate_limit(limit=5, seconds=60 * 60)
def login_via_key(key: str):
cache_key = f"one_time_login_key:{key}"
email = frappe.cache().get_value(cache_key)
if email:
frappe.cache().delete_value(cache_key)
frappe.local.login_manager.login_as(email)
redirect_post_login(
desk_user=frappe.db.get_value("User", frappe.session.user, "user_type") == "System User"
)
else:
frappe.respond_as_web_page(
_("Not Permitted"),
_("The link you trying to login is invalid or expired."),
http_status_code=403,
indicator_color="red",
)