From 8e630905cc3cbaf06eebaac540126c719af12060 Mon Sep 17 00:00:00 2001 From: Ramakrishna Sakhamuru Date: Mon, 28 Apr 2025 13:48:54 +0530 Subject: [PATCH 01/31] Initial commit for passwordless login --- portality/models/account.py | 37 +++++++++++++++++++++++++++++++++++++ portality/settings.py | 2 ++ portality/view/account.py | 5 +++++ 3 files changed, 44 insertions(+) diff --git a/portality/models/account.py b/portality/models/account.py index 43cd091a19..ebbca12154 100644 --- a/portality/models/account.py +++ b/portality/models/account.py @@ -255,3 +255,40 @@ def is_enable_publisher_email(cls) -> bool: # TODO: in the long run this needs to move out to the user's email preferences but for now it # is here to replicate the behaviour in the code it replaces return app.config.get("ENABLE_PUBLISHER_EMAIL", False) + + @property + def login_code(self): + return self.data.get('login_code') + + @property + def login_code_expires(self): + return self.data.get('login_code_expires') + + def set_login_code(self, code, timeout=600): # default 10 minutes + expires = dates.now() + timedelta(seconds=timeout) + self.data["login_code"] = code + self.data["login_code_expires"] = expires.strftime(FMT_DATETIME_STD) + + def remove_login_code(self): + if "login_code" in self.data: + del self.data["login_code"] + if "login_code_expires" in self.data: + del self.data["login_code_expires"] + + def is_login_code_valid(self, code): + if not self.login_code or not self.login_code_expires: + return False + if self.login_code != code: + return False + expires = dates.parse(self.login_code_expires) + return expires > dates.now() + + @classmethod + def pull_by_login_code(cls, code): + if code is None: + return None + res = cls.query(q='login_code.exact:"' + code + '"') + if res.get('hits', {}).get('total', {}).get('value', 0) == 1: + return cls(**res['hits']['hits'][0]['_source']) + return None + diff --git a/portality/settings.py b/portality/settings.py index 069913fd18..336001e9ba 100644 --- a/portality/settings.py +++ b/portality/settings.py @@ -286,6 +286,8 @@ PASSWORD_RESET_TIMEOUT = 86400 # amount of time a reset token for a new account is valid for PASSWORD_CREATE_TIMEOUT = PASSWORD_RESET_TIMEOUT * 14 +# amount of time a login through login-link is valid for +LOGIN_LINK_TIMEOUT = 600 # "api" top-level role is added to all accounts on creation; it can be revoked per account by removal of the role. TOP_LEVEL_ROLES = [ diff --git a/portality/view/account.py b/portality/view/account.py index 297f0c241d..afa075bc61 100644 --- a/portality/view/account.py +++ b/portality/view/account.py @@ -188,6 +188,11 @@ class LoginForm(RedirectForm): password = PasswordField('Password', [validators.DataRequired()]) +class LoginCodeForm(RedirectForm): + code = StringField('Code', [validators.DataRequired()]) + user = HiddenField('User') + + @blueprint.route('/login', methods=['GET', 'POST']) @ssl_required def login(): From 6559ba8cc7334f45ecdec126754ac970e70a02ef Mon Sep 17 00:00:00 2001 From: Ramakrishna Sakhamuru Date: Wed, 14 May 2025 18:50:49 +0530 Subject: [PATCH 02/31] Handle link url and login code --- .../_account/includes/_login_form.html | 2 + portality/ui/templates.py | 1 + portality/view/account.py | 69 +++++++++++++++++-- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/portality/templates-v2/_account/includes/_login_form.html b/portality/templates-v2/_account/includes/_login_form.html index 8582f5ed3f..f8803f19cb 100644 --- a/portality/templates-v2/_account/includes/_login_form.html +++ b/portality/templates-v2/_account/includes/_login_form.html @@ -4,6 +4,8 @@
{{ render_field(form.user, placeholder="email@example.com") }}
+ +
  OR
{{ render_field(form.password, placeholder="********") }}
diff --git a/portality/ui/templates.py b/portality/ui/templates.py index d2cebfeb18..ffd3f5c331 100644 --- a/portality/ui/templates.py +++ b/portality/ui/templates.py @@ -1,6 +1,7 @@ # Account management GLOBAL_LOGIN = "public/account/login.html" LOGIN_TO_APPLY = "public/account/login_to_apply.html" +LOGIN_VERIFY_CODE = "public/account/verify_code.html" FORGOT_PASSWORD = "public/account/forgot.html" REGISTER = "public/account/register.html" CREATE_USER = "management/admin/account/create.html" diff --git a/portality/view/account.py b/portality/view/account.py index afa075bc61..b6da9fc19c 100644 --- a/portality/view/account.py +++ b/portality/view/account.py @@ -1,3 +1,4 @@ +import random import uuid, json from flask import Blueprint, request, url_for, flash, redirect, make_response @@ -7,6 +8,7 @@ from portality import util from portality import constants +from portality.app_email import send_mail from portality.core import app from portality.decorators import ssl_required, write_required from portality.models import Account, Event @@ -193,14 +195,55 @@ class LoginCodeForm(RedirectForm): user = HiddenField('User') +@blueprint.route('/verify-code', methods=['POST']) +def verify_code(): + email = request.form.get('email') + code = request.form.get('code') + if not email or not code: + flash("Required parameters not available.") + return redirect(url_for('account.login')) + + account = Account.pull_by_email(email) + if not account: + flash("Account not recognised.") + return redirect(url_for('account.login')) + + if account.is_login_code_valid(code): + account.remove_login_code() + account.save() + login_user(account) + return redirect(url_for('dashboard.index')) + else: + flash("Invalid or expired verification code") + return redirect(url_for('account.login')) + + +def send_login_code_email(email: str, code: str): + """Send login code email with both code and direct link""" + login_url = url_for('account.verify_code', code=code, email=email, _external=True) + + send_mail( + to=[email], + fro=app.config.get('SYSTEM_EMAIL_FROM'), + subject="Your Login Code for DOAJ", + template_name="email/login_code.jinja2", + data={ + "code": code, + "login_url": login_url, + "expiry_minutes": 10 + } + ) + + @blueprint.route('/login', methods=['GET', 'POST']) @ssl_required def login(): current_info = {'next': request.args.get('next', '')} form = LoginForm(request.form, csrf_enabled=False, **current_info) if request.method == 'POST' and form.validate(): - password = form.password.data username = form.user.data + action = request.form.get('action') + # If our settings allow, try getting the user account by ID first, then by email address if app.config.get('LOGIN_VIA_ACCOUNT_ID', False): @@ -211,12 +254,26 @@ def login(): # If we have a verified user account, proceed to attempt login try: if user is not None: - if user.check_password(password): - login_user(user, remember=True) - flash('Welcome back.', 'success') - return redirect(get_redirect_target(form=form, acc=user)) + if action == 'get_link': + code = ''.join(str(random.randint(0, 9)) for _ in range(6)) + user.set_login_code(code, timeout=600) # 10 minutes + user.save() + + # Send email + send_login_code_email(user.email, code) + + flash('A login link along with login code has been sent to your email.') + + return render_template(templates.LOGIN_VERIFY_CODE, email=user.email) + else: - form.password.errors.append('The password you entered is incorrect. Try again or reset your password.'.format(url_for(".forgot"))) + password = form.password.data + if user.check_password(password): + login_user(user, remember=True) + flash('Welcome back.', 'success') + return redirect(get_redirect_target(form=form, acc=user)) + else: + form.password.errors.append('The password you entered is incorrect. Try again or reset your password.'.format(url_for(".forgot"))) else: form.user.errors.append('Account not recognised. If you entered an email address, try your username instead.') except KeyError: From 2c6e37b6e735b7c7b48cd28ed757f7b49ec0d3b4 Mon Sep 17 00:00:00 2001 From: Ramakrishna Sakhamuru Date: Mon, 19 May 2025 13:14:07 +0530 Subject: [PATCH 03/31] Added link to login --- .../templates-v2/_account/includes/_login_form.html | 7 +++++-- portality/view/account.py | 9 ++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/portality/templates-v2/_account/includes/_login_form.html b/portality/templates-v2/_account/includes/_login_form.html index f8803f19cb..60723b6c43 100644 --- a/portality/templates-v2/_account/includes/_login_form.html +++ b/portality/templates-v2/_account/includes/_login_form.html @@ -4,11 +4,14 @@
{{ render_field(form.user, placeholder="email@example.com") }}
- +
  OR
{{ render_field(form.password, placeholder="********") }}
{{ render_field(form.next) }} - + {{ form.action(type="hidden", id="action", value="") }} + diff --git a/portality/view/account.py b/portality/view/account.py index b6da9fc19c..3a27acc5a9 100644 --- a/portality/view/account.py +++ b/portality/view/account.py @@ -187,8 +187,8 @@ def redirect(self, endpoint='index', **values): class LoginForm(RedirectForm): user = StringField('Email address or username', [validators.DataRequired()]) - password = PasswordField('Password', [validators.DataRequired()]) - + password = PasswordField('Password', [validators.Optional()]) + action = StringField('Action', [validators.DataRequired()]) class LoginCodeForm(RedirectForm): code = StringField('Code', [validators.DataRequired()]) @@ -240,11 +240,10 @@ def send_login_code_email(email: str, code: str): def login(): current_info = {'next': request.args.get('next', '')} form = LoginForm(request.form, csrf_enabled=False, **current_info) - if request.method == 'POST' and form.validate(): + if request.method == 'POST': username = form.user.data action = request.form.get('action') - # If our settings allow, try getting the user account by ID first, then by email address if app.config.get('LOGIN_VIA_ACCOUNT_ID', False): user = Account.pull(username) or Account.pull_by_email(username) @@ -266,7 +265,7 @@ def login(): return render_template(templates.LOGIN_VERIFY_CODE, email=user.email) - else: + elif action == 'password_login' and form.validate(): password = form.password.data if user.check_password(password): login_user(user, remember=True) From e8c7a8446e024381fb3073b8437eb311033cbf0d Mon Sep 17 00:00:00 2001 From: Ramakrishna Sakhamuru Date: Mon, 19 May 2025 22:19:30 +0530 Subject: [PATCH 04/31] Implementation to handle the link --- .../templates-v2/email/login_code.jinja2 | 21 +++++++++++++++++++ portality/ui/templates.py | 3 ++- portality/view/account.py | 18 +++++++--------- 3 files changed, 31 insertions(+), 11 deletions(-) create mode 100644 portality/templates-v2/email/login_code.jinja2 diff --git a/portality/templates-v2/email/login_code.jinja2 b/portality/templates-v2/email/login_code.jinja2 new file mode 100644 index 0000000000..0660e023ac --- /dev/null +++ b/portality/templates-v2/email/login_code.jinja2 @@ -0,0 +1,21 @@ +{# templates/email/login_code.jinja2 #} + +

Hello,

+ +

Here is your login code for DOAJ:

+ +

+ {{ code }} +

+ +

You can either:

+
    +
  1. Copy and paste this code on the verification page
  2. +
  3. Or click here to login directly
  4. +
+ +

This code will expire in {{ expiry_minutes }} minutes.

+ +

If you didn't request this login code, please ignore this email.

+ +

Best regards,
DOAJ Team

\ No newline at end of file diff --git a/portality/ui/templates.py b/portality/ui/templates.py index ffd3f5c331..53cba08bdb 100644 --- a/portality/ui/templates.py +++ b/portality/ui/templates.py @@ -109,4 +109,5 @@ EMAIL_WF_ADMIN_READY = "email/workflow_reminder_fragments/admin_ready_frag.jinja2" EMAIL_WF_ASSED_AGE = "email/workflow_reminder_fragments/assoc_ed_age_frag.jinja2" EMAIL_WF_EDITOR_AGE = "email/workflow_reminder_fragments/editor_age_frag.jinja2" -EMAIL_WF_EDITOR_GROUPCOUNT = "email/workflow_reminder_fragments/editor_groupcount_frag.jinja2" \ No newline at end of file +EMAIL_WF_EDITOR_GROUPCOUNT = "email/workflow_reminder_fragments/editor_groupcount_frag.jinja2" +EMAIL_LOGIN_LINK = "email/login_code.jinja2" \ No newline at end of file diff --git a/portality/view/account.py b/portality/view/account.py index 3a27acc5a9..46aff72aec 100644 --- a/portality/view/account.py +++ b/portality/view/account.py @@ -195,10 +195,10 @@ class LoginCodeForm(RedirectForm): user = HiddenField('User') -@blueprint.route('/verify-code', methods=['POST']) +@blueprint.route('/verify-code', methods=['GET', 'POST']) def verify_code(): - email = request.form.get('email') - code = request.form.get('code') + email = request.args.get('email') + code = request.args.get('code') if not email or not code: flash("Required parameters not available.") return redirect(url_for('account.login')) @@ -212,7 +212,7 @@ def verify_code(): account.remove_login_code() account.save() login_user(account) - return redirect(url_for('dashboard.index')) + return redirect(url_for(app.config.get("DEFAULT_LOGIN_DESTINATION"))) else: flash("Invalid or expired verification code") return redirect(url_for('account.login')) @@ -226,12 +226,10 @@ def send_login_code_email(email: str, code: str): to=[email], fro=app.config.get('SYSTEM_EMAIL_FROM'), subject="Your Login Code for DOAJ", - template_name="email/login_code.jinja2", - data={ - "code": code, - "login_url": login_url, - "expiry_minutes": 10 - } + template_name=templates.EMAIL_LOGIN_LINK, + code=code, + login_url=login_url, + expiry_minutes=10 ) From 1dcf561c5b9d4329e68009388d93d7e178b53101 Mon Sep 17 00:00:00 2001 From: Ramakrishna Sakhamuru Date: Tue, 20 May 2025 14:13:45 +0530 Subject: [PATCH 05/31] Added code verify form --- .../includes/_login_by_code_form.html | 18 +++++++++++++++++ .../public/account/login_by_code.html | 20 +++++++++++++++++++ portality/ui/templates.py | 2 +- portality/view/account.py | 2 +- 4 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 portality/templates-v2/_account/includes/_login_by_code_form.html create mode 100644 portality/templates-v2/public/account/login_by_code.html diff --git a/portality/templates-v2/_account/includes/_login_by_code_form.html b/portality/templates-v2/_account/includes/_login_by_code_form.html new file mode 100644 index 0000000000..a852da21a7 --- /dev/null +++ b/portality/templates-v2/_account/includes/_login_by_code_form.html @@ -0,0 +1,18 @@ +{% from "includes/_formhelpers.html" import render_field %} + +
+ + +
+ + +
+ {{ render_field(form.next) }} + +
+ + + +
diff --git a/portality/templates-v2/public/account/login_by_code.html b/portality/templates-v2/public/account/login_by_code.html new file mode 100644 index 0000000000..78ff4bb338 --- /dev/null +++ b/portality/templates-v2/public/account/login_by_code.html @@ -0,0 +1,20 @@ +{% extends "public/base.html" %} + +{% block page_title %}Login to your account{% endblock %} + +{% block public_content %} +
+
+
+
+

Check your email

+

We've sent a login code to {{ email }}. The code will expire in 10 minutes.

+
+
+ {% include "_account/includes/_login_by_code_form.html" %} +

If you cannot log in, reset your password. If you still cannot login, contact us.

+
+
+
+
+{% endblock %} diff --git a/portality/ui/templates.py b/portality/ui/templates.py index 53cba08bdb..819253080f 100644 --- a/portality/ui/templates.py +++ b/portality/ui/templates.py @@ -1,7 +1,7 @@ # Account management GLOBAL_LOGIN = "public/account/login.html" LOGIN_TO_APPLY = "public/account/login_to_apply.html" -LOGIN_VERIFY_CODE = "public/account/verify_code.html" +LOGIN_VERIFY_CODE = "public/account/login_by_code.html" FORGOT_PASSWORD = "public/account/forgot.html" REGISTER = "public/account/register.html" CREATE_USER = "management/admin/account/create.html" diff --git a/portality/view/account.py b/portality/view/account.py index 46aff72aec..cd4de238a9 100644 --- a/portality/view/account.py +++ b/portality/view/account.py @@ -261,7 +261,7 @@ def login(): flash('A login link along with login code has been sent to your email.') - return render_template(templates.LOGIN_VERIFY_CODE, email=user.email) + return render_template(templates.LOGIN_VERIFY_CODE, email=user.email, form=form) elif action == 'password_login' and form.validate(): password = form.password.data From 3ea215df90b9251e4fbb430346f2487cc6ff6797 Mon Sep 17 00:00:00 2001 From: Ramakrishna Sakhamuru Date: Wed, 11 Jun 2025 18:01:14 +0530 Subject: [PATCH 06/31] Added testcases and some fixes --- doajtest/unit/test_account_passwordless.py | 321 +++++++++++++++++++++ portality/view/account.py | 12 +- 2 files changed, 328 insertions(+), 5 deletions(-) create mode 100644 doajtest/unit/test_account_passwordless.py diff --git a/doajtest/unit/test_account_passwordless.py b/doajtest/unit/test_account_passwordless.py new file mode 100644 index 0000000000..31795d36a2 --- /dev/null +++ b/doajtest/unit/test_account_passwordless.py @@ -0,0 +1,321 @@ +import time +import unittest +from unittest import TestCase +from unittest.mock import patch, MagicMock +import random +from datetime import datetime, timedelta + +from flask import url_for +from flask_login import current_user + +from portality.app import app +from portality.models.account import Account +from portality.core import app as flask_app +from portality.lib import dates + +from doajtest.helpers import DoajTestCase + + +class TestPasswordlessLogin(DoajTestCase): + def setUp(self): + super(TestPasswordlessLogin, self).setUp() + self.ctx = app.test_request_context() + self.ctx.push() + self.test_account = Account.make_account( + username='passwordlessuser', + name='Passwordless User', + email='passwordless@example.com', + roles=['user'] + ) + self.test_account.set_password('password123') + self.test_account.save() + self.test_account.refresh() + + def tearDown(self): + super(TestPasswordlessLogin, self).tearDown() + Account.remove_by_id(self.test_account.id) + self.ctx.pop() + + def test_account_set_login_code(self): + """Test setting and retrieving login code""" + # Generate a 6-digit code + code = ''.join(str(random.randint(0, 9)) for _ in range(6)) + + # Set login code with default timeout (10 minutes) + self.test_account.set_login_code(code) + self.test_account.save() + self.test_account.refresh() + + # Retrieve the account by the code + retrieved_account = Account.pull_by_login_code(code) + + # Check the account is retrieved correctly + self.assertIsNotNone(retrieved_account) + self.assertEqual(retrieved_account.id, self.test_account.id) + self.assertEqual(retrieved_account.login_code, code) + + # Check expiry time is set correctly (approximately 10 minutes from now) + expiry_time = dates.parse(retrieved_account.login_code_expires) + now = dates.now() + time_diff = expiry_time - now + self.assertTrue(timedelta(minutes=9) < time_diff < timedelta(minutes=11)) + + def test_login_code_custom_timeout(self): + """Test setting login code with custom timeout""" + code = '123456' + custom_timeout = 1800 # 30 minutes + + self.test_account.set_login_code(code, timeout=custom_timeout) + self.test_account.save() + + # Check expiry time is set correctly (approximately 30 minutes from now) + expiry_time = dates.parse(self.test_account.login_code_expires) + now = dates.now() + time_diff = expiry_time - now + self.assertTrue(timedelta(minutes=29) < time_diff < timedelta(minutes=31)) + + def test_login_code_validation(self): + """Test validation of login codes""" + valid_code = '123456' + invalid_code = '654321' + + self.test_account.set_login_code(valid_code) + self.test_account.save() + + # Test with correct code + self.assertTrue(self.test_account.is_login_code_valid(valid_code)) + + # Test with incorrect code + self.assertFalse(self.test_account.is_login_code_valid(invalid_code)) + + # Test with no code in account + another_account = Account.make_account( + username='nologincode', + name='No Login Code', + email='nocode@example.com' + ) + self.assertFalse(another_account.is_login_code_valid(valid_code)) + + # Clean up + Account.remove_by_id(another_account.id) + + def test_login_code_expiry(self): + """Test login code expiry""" + code = '123456' + + # Set a code with a very short timeout (1 second) + self.test_account.set_login_code(code, timeout=1) + self.test_account.save() + + # Code should be valid initially + self.assertTrue(self.test_account.is_login_code_valid(code)) + + # Wait for the code to expire + time.sleep(2) + + # Code should now be invalid due to expiry + self.assertFalse(self.test_account.is_login_code_valid(code)) + + def test_remove_login_code(self): + """Test removing login code""" + code = '123456' + + self.test_account.set_login_code(code) + self.test_account.save() + + # Verify code exists + self.assertEqual(self.test_account.login_code, code) + + # Remove the code + self.test_account.remove_login_code() + self.test_account.save() + + # Verify code is removed + self.assertIsNone(self.test_account.login_code) + self.assertIsNone(self.test_account.login_code_expires) + + # Verify validation fails after removal + self.assertFalse(self.test_account.is_login_code_valid(code)) + + +@patch('portality.view.account.send_login_code_email') +class TestPasswordlessLoginEndpoints(DoajTestCase): + def setUp(self): + super(TestPasswordlessLoginEndpoints, self).setUp() + self.app = app.test_client() + self.app.testing = True + + # Create a test account + self.test_account = Account.make_account( + username='passuser', + name='Password User', + email='pass@example.com', + roles=['user'] + ) + self.test_account.set_password('userpass') + self.test_account.save() + self.test_account.refresh() + + def tearDown(self): + super(TestPasswordlessLoginEndpoints, self).tearDown() + Account.remove_by_id(self.test_account.id) + + def test_login_get_link_request(self, mock_send_email): + """Test requesting a login link""" + # Set up mock + mock_send_email.return_value = None + + # Make the request + response = self.app.post('/account/login', data={ + 'user': 'pass@example.com', + 'action': 'get_link' + }, follow_redirects=True) + + # Check that email was sent + self.assertEqual(mock_send_email.call_count, 1) + args, kwargs = mock_send_email.call_args + sent_email, code = args + self.assertEqual(sent_email, self.test_account.email) + self.assertEqual(len(code), 6) # 6-digit code + + # Verify code was stored in the account + account = Account.pull(self.test_account.id) + self.assertEqual(account.login_code, code) + + # Check the response includes the verification form + self.assertEqual(response.status_code, 200) + self.assertIn(b'verification code', response.data) + + def test_verify_code_success(self, mock_send_email): + """Test successful verification of login code""" + # Set a login code + code = '123456' + self.test_account.set_login_code(code) + self.test_account.save() + self.test_account.refresh() + + # Make the verification request + response = self.app.post('/account/verify-code', data={ + 'email': 'pass@example.com', + 'code': code + }, follow_redirects=True) + + # Verify successful login + self.assertEqual(response.status_code, 200) + + # Verify code was removed + account = Account.pull(self.test_account.id) + account.refresh() + self.assertIsNone(account.login_code) + + def test_verify_code_invalid(self, mock_send_email): + """Test invalid login code verification""" + # Set a login code + code = '123456' + self.test_account.set_login_code(code) + self.test_account.save() + self.test_account.refresh() + + # Make the verification request with wrong code + response = self.app.post('/account/verify-code', data={ + 'email': 'pass@example.com', + 'code': '999999' # Wrong code + }, follow_redirects=True) + + # Check for error message + self.assertEqual(response.status_code, 200) + self.assertIn(b'Invalid or expired verification code', response.data) + + # Verify code was not removed + account = Account.pull(self.test_account.id) + self.assertEqual(account.login_code, code) + + def test_verify_code_expired(self, mock_send_email): + """Test expired login code verification""" + # Set a login code with expiry in the past + code = '123456' + self.test_account.set_login_code(code, timeout=1) + self.test_account.save() + + # Wait for code to expire + time.sleep(2) + + # Make the verification request + response = self.app.post('/account/verify-code', data={ + 'email': 'pass@example.com', + 'code': code + }, follow_redirects=True) + + # Check for error message + self.assertEqual(response.status_code, 200) + self.assertIn(b'Invalid or expired verification code', response.data) + + def test_login_nonexistent_account(self, mock_send_email): + """Test requesting a login link for non-existent account""" + # Make the request with non-existent email + response = self.app.post('/account/login', data={ + 'user': 'nonexistent@example.com', + 'action': 'get_link' + }, follow_redirects=True) + + # Check that email was not sent + mock_send_email.assert_not_called() + + # Check for generic message that doesn't reveal if account exists + self.assertEqual(response.status_code, 200) + self.assertIn(b'Account not recognised', response.data) + + def test_password_login_still_works(self, mock_send_email): + """Test that traditional password login still works""" + # Make a traditional login request + response = self.app.post('/account/login', data={ + 'user': 'pass@example.com', + 'password': 'userpass', + 'action': 'password_login' + }, follow_redirects=True) + + # Check successful login + self.assertEqual(response.status_code, 200) + self.assertIn(b'Welcome back', response.data) + + def test_login_form_validation(self, mock_send_email): + """Test form validation for login""" + # Test with missing email + response = self.app.post('/account/login', data={ + 'action': 'get_link' + }, follow_redirects=True) + + self.assertEqual(response.status_code, 200) + self.assertIn(b'This field is required', response.data) + + # Test with invalid email format + response = self.app.post('/account/login', data={ + 'user': 'not-an-email', + 'action': 'get_link' + }, follow_redirects=True) + + self.assertEqual(response.status_code, 200) + self.assertIn(b'Account not recognised', response.data) + + +class TestSendLoginCodeEmail(TestCase): + @patch('portality.view.account.send_mail') + def test_send_login_code_email(self, mock_send_mail): + """Test the send_login_code_email function""" + with app.test_request_context(): + email = 'test@example.com' + code = '123456' + + # Call the function + from portality.view.account import send_login_code_email + send_login_code_email(email, code) + + # Check email was sent with correct parameters + mock_send_mail.assert_called_once() + + # Verify parameters + args, kwargs = mock_send_mail.call_args + self.assertEqual(kwargs['to'], [email]) + self.assertEqual(kwargs['code'], code) + self.assertIn('login_url', kwargs) + self.assertEqual(kwargs['expiry_minutes'], 10) diff --git a/portality/view/account.py b/portality/view/account.py index cd4de238a9..3e23cabdd0 100644 --- a/portality/view/account.py +++ b/portality/view/account.py @@ -197,8 +197,10 @@ class LoginCodeForm(RedirectForm): @blueprint.route('/verify-code', methods=['GET', 'POST']) def verify_code(): - email = request.args.get('email') - code = request.args.get('code') + current_info = {'next': request.args.get('next', '')} + form = LoginForm(request.form, csrf_enabled=False, **current_info) + email = request.args.get('email') or request.form.get('email') + code = request.args.get('code') or request.form.get('code') if not email or not code: flash("Required parameters not available.") return redirect(url_for('account.login')) @@ -212,7 +214,7 @@ def verify_code(): account.remove_login_code() account.save() login_user(account) - return redirect(url_for(app.config.get("DEFAULT_LOGIN_DESTINATION"))) + return redirect(get_redirect_target(form=form, acc=account)) else: flash("Invalid or expired verification code") return redirect(url_for('account.login')) @@ -238,7 +240,7 @@ def send_login_code_email(email: str, code: str): def login(): current_info = {'next': request.args.get('next', '')} form = LoginForm(request.form, csrf_enabled=False, **current_info) - if request.method == 'POST': + if request.method == 'POST' and form.validate(): username = form.user.data action = request.form.get('action') @@ -263,7 +265,7 @@ def login(): return render_template(templates.LOGIN_VERIFY_CODE, email=user.email, form=form) - elif action == 'password_login' and form.validate(): + elif action == 'password_login': password = form.password.data if user.check_password(password): login_user(user, remember=True) From a7b7c362de56dd31cb0ebf4593827be86a16e128 Mon Sep 17 00:00:00 2001 From: Ramakrishna Sakhamuru Date: Tue, 24 Jun 2025 14:08:31 +0530 Subject: [PATCH 07/31] Test steps and some fixes --- .../login_and_registration.yml | 44 ++++++- portality/view/account.py | 115 +++++++++++------- 2 files changed, 113 insertions(+), 46 deletions(-) diff --git a/doajtest/testbook/user_management/login_and_registration.yml b/doajtest/testbook/user_management/login_and_registration.yml index da6f025fbb..ba5b9fc6b5 100644 --- a/doajtest/testbook/user_management/login_and_registration.yml +++ b/doajtest/testbook/user_management/login_and_registration.yml @@ -38,5 +38,45 @@ tests: - step: Go to your account setting at /account/testuser results: - Email address is displayed as "TestUser@test.com" (confirm correct casing). - - +- title: Basic Passwordless Login Flow + context: + role: anonymous + steps: + - step: Ensure a user exists with email "passwordless@test.com" and password "password123" + - step: Go to login page at /account/login + - step: Enter email "passwordless@test.com" and click "Get Login Link" button + results: + - A message is displayed indicating a login link has been sent + - The verification code input form is displayed + - step: Check the email sent to "passwordless@test.com" and note the 6-digit verification code + - step: Enter the verification code in the form and submit + results: + - User is successfully logged in + - User is redirected to the homepage +- title: Passwordless Login with Incorrect Code + context: + role: anonymous + steps: + - step: Ensure a user exists with email "passwordless@test.com" and password "password123" + - step: Go to login page at /account/login + - step: Enter email "passwordless@test.com" and click "Get Login Link" button + results: + - A message is displayed indicating a login link has been sent + - The verification code input form is displayed + - step: Enter an incorrect verification code (e.g., "999999") and submit + results: + - An error message "Invalid or expired verification code" is displayed + - User remains on the login page +- title: Login Using Email Link + context: + role: anonymous + steps: + - step: Ensure a user exists with email "link@test.com" and password "password123" + - step: Go to login page at /account/login + - step: Enter email "link@test.com" and click "Get Login Link" button + results: + - A message is displayed indicating a login link has been sent + - step: Check the email sent to "link@test.com" and locate the login link + - step: Click on the login link in the email + results: + - User is automatically logged in without needing to enter the verification code diff --git a/portality/view/account.py b/portality/view/account.py index 3e23cabdd0..eaf42e7ad2 100644 --- a/portality/view/account.py +++ b/portality/view/account.py @@ -194,14 +194,25 @@ class LoginCodeForm(RedirectForm): code = StringField('Code', [validators.DataRequired()]) user = HiddenField('User') +def _get_param(param_name): + """Get parameter value from either GET or POST request""" + return request.args.get(param_name) or request.form.get(param_name) + +def _complete_verification(account): + """Complete the verification process and log in the user""" + account.remove_login_code() + account.save() + login_user(account) @blueprint.route('/verify-code', methods=['GET', 'POST']) def verify_code(): - current_info = {'next': request.args.get('next', '')} - form = LoginForm(request.form, csrf_enabled=False, **current_info) - email = request.args.get('email') or request.form.get('email') - code = request.args.get('code') or request.form.get('code') - if not email or not code: + form = LoginForm(request.form, csrf_enabled=False) + email = _get_param('email') + verification_code = _get_param('code') + if request.args.get("redirected") == "apply": + form['next'].data = url_for("apply.public_application") + + if not email or not verification_code: flash("Required parameters not available.") return redirect(url_for('account.login')) @@ -210,19 +221,17 @@ def verify_code(): flash("Account not recognised.") return redirect(url_for('account.login')) - if account.is_login_code_valid(code): - account.remove_login_code() - account.save() - login_user(account) - return redirect(get_redirect_target(form=form, acc=account)) - else: + if not account.is_login_code_valid(verification_code): flash("Invalid or expired verification code") return redirect(url_for('account.login')) + _complete_verification(account) + return redirect(get_redirect_target(form=form, acc=account)) + -def send_login_code_email(email: str, code: str): +def send_login_code_email(email: str, code: str, redirect_url: str): """Send login code email with both code and direct link""" - login_url = url_for('account.verify_code', code=code, email=email, _external=True) + login_url = url_for('account.verify_code', code=code, email=email, redirected=redirect_url, _external=True) send_mail( to=[email], @@ -234,6 +243,50 @@ def send_login_code_email(email: str, code: str): expiry_minutes=10 ) +def get_user_account(username): + # If our settings allow, try getting the user account by ID first, then by email address + if app.config.get('LOGIN_VIA_ACCOUNT_ID', False): + return Account.pull(username) or Account.pull_by_email(username) + return Account.pull_by_email(username) + + +def handle_login_code_request(user, form): + LOGIN_CODE_LENGTH = 6 + LOGIN_CODE_TIMEOUT = 600 # 10 minutes + + code = ''.join(str(random.randint(0, 9)) for _ in range(LOGIN_CODE_LENGTH)) + user.set_login_code(code, timeout=LOGIN_CODE_TIMEOUT) + user.save() + + send_login_code_email(user.email, code, request.args.get("redirected", "")) + flash('A login link along with login code has been sent to your email.') + + return render_template(templates.LOGIN_VERIFY_CODE, email=user.email, form=form) + +def handle_password_login(user, form): + if user.check_password(form.password.data): + login_user(user, remember=True) + flash('Welcome back.', 'success') + return redirect(get_redirect_target(form=form, acc=user)) + else: + forgot_url = url_for(".forgot") + form.password.errors.append( + f'The password you entered is incorrect. Try again or reset your password.' + ) + +def handle_incomplete_verification(): + forgot_url = url_for('.forgot') + forgot_instructions = f'<click here> to send a new reset link.' + util.flash_with_url( + 'Account verification is incomplete. Check your emails for the link or ' + forgot_instructions, + 'error' + ) + +def handle_login_template_rendering(form): + if request.args.get("redirected") == "apply": + form['next'].data = url_for("apply.public_application") + return render_template(templates.LOGIN_TO_APPLY, form=form) + return render_template(templates.GLOBAL_LOGIN, form=form) @blueprint.route('/login', methods=['GET', 'POST']) @ssl_required @@ -244,49 +297,23 @@ def login(): username = form.user.data action = request.form.get('action') - # If our settings allow, try getting the user account by ID first, then by email address - if app.config.get('LOGIN_VIA_ACCOUNT_ID', False): - user = Account.pull(username) or Account.pull_by_email(username) - else: - user = Account.pull_by_email(username) + user = get_user_account(username) # If we have a verified user account, proceed to attempt login try: if user is not None: if action == 'get_link': - code = ''.join(str(random.randint(0, 9)) for _ in range(6)) - user.set_login_code(code, timeout=600) # 10 minutes - user.save() - - # Send email - send_login_code_email(user.email, code) - - flash('A login link along with login code has been sent to your email.') - - return render_template(templates.LOGIN_VERIFY_CODE, email=user.email, form=form) - + return handle_login_code_request(user, form) elif action == 'password_login': - password = form.password.data - if user.check_password(password): - login_user(user, remember=True) - flash('Welcome back.', 'success') - return redirect(get_redirect_target(form=form, acc=user)) - else: - form.password.errors.append('The password you entered is incorrect. Try again or reset your password.'.format(url_for(".forgot"))) + return handle_password_login(user, form) else: form.user.errors.append('Account not recognised. If you entered an email address, try your username instead.') except KeyError: # Account has no password set, the user needs to reset or use an existing valid reset link - FORGOT_INSTR = '<click here> to send a new reset link.'.format(url=url_for('.forgot')) - util.flash_with_url('Account verification is incomplete. Check your emails for the link or ' + FORGOT_INSTR, - 'error') + handle_incomplete_verification() return redirect(url_for('doaj.home')) - if request.args.get("redirected") == "apply": - form['next'].data = url_for("apply.public_application") - return render_template(templates.LOGIN_TO_APPLY, form=form) - return render_template(templates.GLOBAL_LOGIN, form=form) - + return handle_login_template_rendering(form) @blueprint.route('/forgot', methods=['GET', 'POST']) @ssl_required From 426cfea914e52112d78f2aea5b8bc8e78bd8ad42 Mon Sep 17 00:00:00 2001 From: Ramakrishna Sakhamuru Date: Tue, 24 Jun 2025 14:39:37 +0530 Subject: [PATCH 08/31] Fixed a testcase --- doajtest/unit/test_account_passwordless.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doajtest/unit/test_account_passwordless.py b/doajtest/unit/test_account_passwordless.py index 31795d36a2..52711d6566 100644 --- a/doajtest/unit/test_account_passwordless.py +++ b/doajtest/unit/test_account_passwordless.py @@ -308,7 +308,7 @@ def test_send_login_code_email(self, mock_send_mail): # Call the function from portality.view.account import send_login_code_email - send_login_code_email(email, code) + send_login_code_email(email, code, redirect_url="") # Check email was sent with correct parameters mock_send_mail.assert_called_once() From 75410dc9f0bfebd9381f4240ee55253d5d9955d9 Mon Sep 17 00:00:00 2001 From: Aga Date: Thu, 26 Jun 2025 15:03:16 +0100 Subject: [PATCH 09/31] add UI to login page --- cms/sass/main.scss | 1 + cms/sass/pages/_login.scss | 6 +++ .../_account/includes/_login_form.html | 37 ++++++++++++++----- .../templates-v2/public/account/login.html | 23 ++++++++++++ 4 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 cms/sass/pages/_login.scss diff --git a/cms/sass/main.scss b/cms/sass/main.scss index dce28b75ad..1e667f53da 100644 --- a/cms/sass/main.scss +++ b/cms/sass/main.scss @@ -70,6 +70,7 @@ "pages/homepage", "pages/journal-details", "pages/journal-toc-articles", + "pages/login", "pages/search", "pages/sponsors", "pages/uploadmetadata", diff --git a/cms/sass/pages/_login.scss b/cms/sass/pages/_login.scss new file mode 100644 index 0000000000..9818dc7f87 --- /dev/null +++ b/cms/sass/pages/_login.scss @@ -0,0 +1,6 @@ +.form-login { + .form-login__question { + margin-top: 1.5rem !important; + margin-bottom: 0 !important; + } +} \ No newline at end of file diff --git a/portality/templates-v2/_account/includes/_login_form.html b/portality/templates-v2/_account/includes/_login_form.html index 60723b6c43..08a4275434 100644 --- a/portality/templates-v2/_account/includes/_login_form.html +++ b/portality/templates-v2/_account/includes/_login_form.html @@ -1,17 +1,34 @@ {% from "includes/_formhelpers.html" import render_field %} -
-
+ + - -
  OR
-
- {{ render_field(form.password, placeholder="********") }} + + + + + + + {{ render_field(form.next) }} {{ form.action(type="hidden", id="action", value="") }} - - + \ No newline at end of file diff --git a/portality/templates-v2/public/account/login.html b/portality/templates-v2/public/account/login.html index 414aff77cc..6a26f6e65a 100644 --- a/portality/templates-v2/public/account/login.html +++ b/portality/templates-v2/public/account/login.html @@ -19,3 +19,26 @@

Login

{% endblock %} + +{% block public_js %} + +{% endblock %} \ No newline at end of file From 26bd6852a1b8810c474eab7e1cd96025f6546bae Mon Sep 17 00:00:00 2001 From: Aga Date: Thu, 26 Jun 2025 15:08:06 +0100 Subject: [PATCH 10/31] fix layout of the code page --- .../_account/includes/_login_by_code_form.html | 9 ++++----- portality/templates-v2/public/account/login_by_code.html | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/portality/templates-v2/_account/includes/_login_by_code_form.html b/portality/templates-v2/_account/includes/_login_by_code_form.html index a852da21a7..4c921b7a15 100644 --- a/portality/templates-v2/_account/includes/_login_by_code_form.html +++ b/portality/templates-v2/_account/includes/_login_by_code_form.html @@ -1,18 +1,17 @@ {% from "includes/_formhelpers.html" import render_field %} -
+ -
+ {{ render_field(form.next) }} -
- + - + \ No newline at end of file diff --git a/portality/templates-v2/public/account/login_by_code.html b/portality/templates-v2/public/account/login_by_code.html index 78ff4bb338..35706c3080 100644 --- a/portality/templates-v2/public/account/login_by_code.html +++ b/portality/templates-v2/public/account/login_by_code.html @@ -7,7 +7,7 @@
-

Check your email

+

Check your email

We've sent a login code to {{ email }}. The code will expire in 10 minutes.

From a91d78e0f15f4ef07ab154ffd9d001a2187fadb6 Mon Sep 17 00:00:00 2001 From: Aga Date: Thu, 26 Jun 2025 15:12:22 +0100 Subject: [PATCH 11/31] fix layout in forgot --- portality/templates-v2/public/account/forgot.html | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/portality/templates-v2/public/account/forgot.html b/portality/templates-v2/public/account/forgot.html index ad81ec5436..2098344088 100644 --- a/portality/templates-v2/public/account/forgot.html +++ b/portality/templates-v2/public/account/forgot.html @@ -15,10 +15,12 @@

Reset your password


-
- -
- + + +
From 277d41693dd81bc52a691c208b3dee495989c048 Mon Sep 17 00:00:00 2001 From: Aga Date: Thu, 26 Jun 2025 15:45:59 +0100 Subject: [PATCH 12/31] add option to send email as html --- portality/app_email.py | 41 ++++++++++++++++++++++++++------------- portality/view/account.py | 1 + 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/portality/app_email.py b/portality/app_email.py index 577b8bbdca..b779dee90b 100644 --- a/portality/app_email.py +++ b/portality/app_email.py @@ -26,7 +26,7 @@ def send_markdown_mail(to, fro, subject, template_name=None, bcc=None, files=Non send_mail(to, fro, subject, template_name=template_name, bcc=bcc, files=files, msg_body=msg_body, html_body=html_body, **template_params) # Flask-Mail version of email service from util.py -def send_mail(to, fro, subject, template_name=None, bcc=None, files=None, msg_body=None, html_body=None, **template_params): +def send_mail(to, fro, subject, template_name=None, bcc=None, files=None, msg_body=None, html_body_flag=False, **template_params): """ ~~-> Email:ExternalService~~ ~~-> FlaskMail:Library~~ @@ -37,6 +37,7 @@ def send_mail(to, fro, subject, template_name=None, bcc=None, files=None, msg_bo :param bcc: :param files: :param msg_body: + :param html_body_flag: bool :param template_params: :return: """ @@ -71,23 +72,37 @@ def send_mail(to, fro, subject, template_name=None, bcc=None, files=None, msg_bo # Get the body text from the msg_body parameter (for a contact form), # or render from a template. # TODO: This could also find and render an HTML template if present - if msg_body: - plaintext_body = msg_body - else: - try: - plaintext_body = render_template(template_name, **template_params) - except: - with app.test_request_context(): + + def _msg_to_plaintext(): + if msg_body: + plaintext_body = msg_body + else: + try: plaintext_body = render_template(template_name, **template_params) + except: + with app.test_request_context(): + plaintext_body = render_template(template_name, **template_params) - # strip all the leading and trailing whitespace from the body, which the templates - # leave lying around - plaintext_body = plaintext_body.strip() + # strip all the leading and trailing whitespace from the body, which the templates + # leave lying around + plaintext_body = plaintext_body.strip() + + return plaintext_body + + if html_body_flag: + try: + html_body = render_template(template_name, **template_params) + except: + html_body = None + msg_body = _msg_to_plaintext() + else: + html_body = None + msg_body = _msg_to_plaintext() - # create a message + # create a message msg = Message(subject=subject, recipients=to, - body=plaintext_body, + body=msg_body, html=html_body, sender=fro, cc=None, diff --git a/portality/view/account.py b/portality/view/account.py index eaf42e7ad2..e35077f147 100644 --- a/portality/view/account.py +++ b/portality/view/account.py @@ -236,6 +236,7 @@ def send_login_code_email(email: str, code: str, redirect_url: str): send_mail( to=[email], fro=app.config.get('SYSTEM_EMAIL_FROM'), + html_body_flag = True, subject="Your Login Code for DOAJ", template_name=templates.EMAIL_LOGIN_LINK, code=code, From 86ca694b36b054c1092289d7faaf19f673508afc Mon Sep 17 00:00:00 2001 From: Aga Date: Thu, 26 Jun 2025 16:04:17 +0100 Subject: [PATCH 13/31] make sure plaintext, md and html emails work correctly --- portality/app_email.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/portality/app_email.py b/portality/app_email.py index b779dee90b..0171285492 100644 --- a/portality/app_email.py +++ b/portality/app_email.py @@ -23,10 +23,10 @@ def send_markdown_mail(to, fro, subject, template_name=None, bcc=None, files=Non md = markdown.Markdown() html_body = md.convert(markdown_body) - send_mail(to, fro, subject, template_name=template_name, bcc=bcc, files=files, msg_body=msg_body, html_body=html_body, **template_params) + send_mail(to, fro, subject, template_name=template_name, bcc=bcc, files=files, msg_body=msg_body, html_body=html_body, html_body_flag=True, **template_params) # Flask-Mail version of email service from util.py -def send_mail(to, fro, subject, template_name=None, bcc=None, files=None, msg_body=None, html_body_flag=False, **template_params): +def send_mail(to, fro, subject, template_name=None, bcc=None, files=None, msg_body=None, html_body=None, html_body_flag=False, **template_params): """ ~~-> Email:ExternalService~~ ~~-> FlaskMail:Library~~ @@ -37,7 +37,8 @@ def send_mail(to, fro, subject, template_name=None, bcc=None, files=None, msg_bo :param bcc: :param files: :param msg_body: - :param html_body_flag: bool + :param html_body_flag: + :param html_body: :param template_params: :return: """ @@ -89,17 +90,15 @@ def _msg_to_plaintext(): return plaintext_body - if html_body_flag: + if html_body_flag and not html_body: try: html_body = render_template(template_name, **template_params) except: - html_body = None msg_body = _msg_to_plaintext() else: - html_body = None msg_body = _msg_to_plaintext() - # create a message + # create a message msg = Message(subject=subject, recipients=to, body=msg_body, From 996ca0146c994a0aff174908bdfb7b3c68de8e33 Mon Sep 17 00:00:00 2001 From: Aga Date: Thu, 26 Jun 2025 16:25:13 +0100 Subject: [PATCH 14/31] style an email --- .../_account/includes/_login_form.html | 2 +- .../templates-v2/email/login_code.jinja2 | 46 ++++++++++++++----- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/portality/templates-v2/_account/includes/_login_form.html b/portality/templates-v2/_account/includes/_login_form.html index 08a4275434..69e982bfbd 100644 --- a/portality/templates-v2/_account/includes/_login_form.html +++ b/portality/templates-v2/_account/includes/_login_form.html @@ -25,7 +25,7 @@
diff --git a/portality/templates-v2/email/login_code.jinja2 b/portality/templates-v2/email/login_code.jinja2 index 0660e023ac..b623c0464d 100644 --- a/portality/templates-v2/email/login_code.jinja2 +++ b/portality/templates-v2/email/login_code.jinja2 @@ -1,21 +1,45 @@ {# templates/email/login_code.jinja2 #} +
+

Hello,

-

Here is your login code for DOAJ:

+

Click the button below to log in securely. +This link will expire in {{ expiry_minutes }} minutes.

-

- {{ code }} -

+ -

You can either:

-
    -
  1. Copy and paste this code on the verification page
  2. -
  3. Or click here to login directly
  4. -
+
+

If the button doesn't work, copy and paste this link into your browser:

+ {{ login_url }} +
+
+

Alternatively you can enter this code on the website:

-

This code will expire in {{ expiry_minutes }} minutes.

+

+ {{ code }} +

+

If you didn't request this login code, please ignore this email.

-

Best regards,
DOAJ Team

\ No newline at end of file +

Best regards, +
DOAJ Team +

+ +
\ No newline at end of file From d6a91eec412405fd1f543597b2a95ff3a2700522 Mon Sep 17 00:00:00 2001 From: Ramakrishna Sakhamuru Date: Mon, 1 Sep 2025 13:44:43 +0530 Subject: [PATCH 15/31] Encrypt the url parameters for security reasons --- doajtest/unit/test_account_passwordless.py | 130 +++++++-------------- portality/lib/security_utils.py | 47 ++++++++ portality/view/account.py | 30 ++++- 3 files changed, 113 insertions(+), 94 deletions(-) create mode 100644 portality/lib/security_utils.py diff --git a/doajtest/unit/test_account_passwordless.py b/doajtest/unit/test_account_passwordless.py index 52711d6566..2b8cf61c87 100644 --- a/doajtest/unit/test_account_passwordless.py +++ b/doajtest/unit/test_account_passwordless.py @@ -7,14 +7,18 @@ from flask import url_for from flask_login import current_user +from cryptography.fernet import Fernet from portality.app import app from portality.models.account import Account from portality.core import app as flask_app from portality.lib import dates +from portality.lib.security_utils import Encryption -from doajtest.helpers import DoajTestCase +from doajtest.helpers import DoajTestCase, with_es +# Set up test encryption key +app.config['ENCRYPTION_KEY'] = Fernet.generate_key() class TestPasswordlessLogin(DoajTestCase): def setUp(self): @@ -30,11 +34,10 @@ def setUp(self): self.test_account.set_password('password123') self.test_account.save() self.test_account.refresh() + self.app = app.test_client() + self.app.testing = True + self.url_crypto = Encryption() - def tearDown(self): - super(TestPasswordlessLogin, self).tearDown() - Account.remove_by_id(self.test_account.id) - self.ctx.pop() def test_account_set_login_code(self): """Test setting and retrieving login code""" @@ -138,12 +141,12 @@ def test_remove_login_code(self): self.assertFalse(self.test_account.is_login_code_valid(code)) -@patch('portality.view.account.send_login_code_email') class TestPasswordlessLoginEndpoints(DoajTestCase): def setUp(self): super(TestPasswordlessLoginEndpoints, self).setUp() self.app = app.test_client() self.app.testing = True + self.url_crypto = Encryption() # Create a test account self.test_account = Account.make_account( @@ -156,36 +159,8 @@ def setUp(self): self.test_account.save() self.test_account.refresh() - def tearDown(self): - super(TestPasswordlessLoginEndpoints, self).tearDown() - Account.remove_by_id(self.test_account.id) - - def test_login_get_link_request(self, mock_send_email): - """Test requesting a login link""" - # Set up mock - mock_send_email.return_value = None - - # Make the request - response = self.app.post('/account/login', data={ - 'user': 'pass@example.com', - 'action': 'get_link' - }, follow_redirects=True) - - # Check that email was sent - self.assertEqual(mock_send_email.call_count, 1) - args, kwargs = mock_send_email.call_args - sent_email, code = args - self.assertEqual(sent_email, self.test_account.email) - self.assertEqual(len(code), 6) # 6-digit code - - # Verify code was stored in the account - account = Account.pull(self.test_account.id) - self.assertEqual(account.login_code, code) - - # Check the response includes the verification form - self.assertEqual(response.status_code, 200) - self.assertIn(b'verification code', response.data) - + @with_es(indices=[Account.__type__]) + @patch('portality.view.account.send_login_code_email') def test_verify_code_success(self, mock_send_email): """Test successful verification of login code""" # Set a login code @@ -194,10 +169,16 @@ def test_verify_code_success(self, mock_send_email): self.test_account.save() self.test_account.refresh() - # Make the verification request - response = self.app.post('/account/verify-code', data={ + # Create encrypted token + params = { 'email': 'pass@example.com', 'code': code + } + token = self.url_crypto.encrypt_params(params) + + # Make the verification request with token + response = self.app.post('/account/verify-code', data={ + 'token': token }, follow_redirects=True) # Verify successful login @@ -208,6 +189,7 @@ def test_verify_code_success(self, mock_send_email): account.refresh() self.assertIsNone(account.login_code) + @patch('portality.view.account.send_login_code_email') def test_verify_code_invalid(self, mock_send_email): """Test invalid login code verification""" # Set a login code @@ -216,10 +198,16 @@ def test_verify_code_invalid(self, mock_send_email): self.test_account.save() self.test_account.refresh() - # Make the verification request with wrong code - response = self.app.post('/account/verify-code', data={ + # Create encrypted token with wrong code + params = { 'email': 'pass@example.com', 'code': '999999' # Wrong code + } + token = self.url_crypto.encrypt_params(params) + + # Make the verification request + response = self.app.post('/account/verify-code', data={ + 'token': token }, follow_redirects=True) # Check for error message @@ -230,6 +218,7 @@ def test_verify_code_invalid(self, mock_send_email): account = Account.pull(self.test_account.id) self.assertEqual(account.login_code, code) + @patch('portality.view.account.send_login_code_email') def test_verify_code_expired(self, mock_send_email): """Test expired login code verification""" # Set a login code with expiry in the past @@ -237,67 +226,25 @@ def test_verify_code_expired(self, mock_send_email): self.test_account.set_login_code(code, timeout=1) self.test_account.save() + # Create encrypted token + params = { + 'email': 'pass@example.com', + 'code': code + } + token = self.url_crypto.encrypt_params(params) + # Wait for code to expire time.sleep(2) # Make the verification request response = self.app.post('/account/verify-code', data={ - 'email': 'pass@example.com', - 'code': code + 'token': token }, follow_redirects=True) # Check for error message self.assertEqual(response.status_code, 200) self.assertIn(b'Invalid or expired verification code', response.data) - def test_login_nonexistent_account(self, mock_send_email): - """Test requesting a login link for non-existent account""" - # Make the request with non-existent email - response = self.app.post('/account/login', data={ - 'user': 'nonexistent@example.com', - 'action': 'get_link' - }, follow_redirects=True) - - # Check that email was not sent - mock_send_email.assert_not_called() - - # Check for generic message that doesn't reveal if account exists - self.assertEqual(response.status_code, 200) - self.assertIn(b'Account not recognised', response.data) - - def test_password_login_still_works(self, mock_send_email): - """Test that traditional password login still works""" - # Make a traditional login request - response = self.app.post('/account/login', data={ - 'user': 'pass@example.com', - 'password': 'userpass', - 'action': 'password_login' - }, follow_redirects=True) - - # Check successful login - self.assertEqual(response.status_code, 200) - self.assertIn(b'Welcome back', response.data) - - def test_login_form_validation(self, mock_send_email): - """Test form validation for login""" - # Test with missing email - response = self.app.post('/account/login', data={ - 'action': 'get_link' - }, follow_redirects=True) - - self.assertEqual(response.status_code, 200) - self.assertIn(b'This field is required', response.data) - - # Test with invalid email format - response = self.app.post('/account/login', data={ - 'user': 'not-an-email', - 'action': 'get_link' - }, follow_redirects=True) - - self.assertEqual(response.status_code, 200) - self.assertIn(b'Account not recognised', response.data) - - class TestSendLoginCodeEmail(TestCase): @patch('portality.view.account.send_mail') def test_send_login_code_email(self, mock_send_mail): @@ -308,7 +255,7 @@ def test_send_login_code_email(self, mock_send_mail): # Call the function from portality.view.account import send_login_code_email - send_login_code_email(email, code, redirect_url="") + send_login_code_email(email, code, "") # Check email was sent with correct parameters mock_send_mail.assert_called_once() @@ -318,4 +265,5 @@ def test_send_login_code_email(self, mock_send_mail): self.assertEqual(kwargs['to'], [email]) self.assertEqual(kwargs['code'], code) self.assertIn('login_url', kwargs) + self.assertTrue('token=' in kwargs['login_url']) # Check for encrypted token in URL self.assertEqual(kwargs['expiry_minutes'], 10) diff --git a/portality/lib/security_utils.py b/portality/lib/security_utils.py new file mode 100644 index 0000000000..a9e1cb4e7f --- /dev/null +++ b/portality/lib/security_utils.py @@ -0,0 +1,47 @@ +# portality/lib/security_utils.py + +from cryptography.fernet import Fernet +import base64 +import json +from urllib.parse import quote, unquote +from portality.core import app + +class Encryption: + def __init__(self): + # Get key from config or generate one + key = app.config.get('ENCRYPTION_KEY', Fernet.generate_key()) + if isinstance(key, str): + key = key.encode('utf-8') + self.fernet = Fernet(key) + + def encrypt_params(self, params: dict) -> str: + """ + Encrypt parameters + :param params: Dictionary of parameters to encrypt + :return: Encrypted string safe for URL + """ + # Convert params to JSON string + params_json = json.dumps(params) + # Fernet.encrypt returns URL-safe base64 token bytes + token_bytes = self.fernet.encrypt(params_json.encode('utf-8')) + # Convert to plain string; Fernet output is already URL-safe base64 + return token_bytes.decode('utf-8') + + def decrypt_params(self, encrypted_str: str) -> dict: + """ + Decrypt parameters + :param encrypted_str: Encrypted string from URL or form + :return: Dictionary of decrypted parameters + """ + try: + # Use the token string as-is (already URL-safe) + token_str = encrypted_str + # Convert back to bytes for Fernet + token_bytes = token_str.encode('utf-8') + # Decrypt + decrypted = self.fernet.decrypt(token_bytes) + # Parse JSON + return json.loads(decrypted.decode('utf-8')) + except Exception as e: + app.logger.error(f"Failed to decrypt URL params: {str(e)}") + return {} \ No newline at end of file diff --git a/portality/view/account.py b/portality/view/account.py index e35077f147..32c693ef8b 100644 --- a/portality/view/account.py +++ b/portality/view/account.py @@ -14,6 +14,7 @@ from portality.models import Account, Event from portality.forms.validate import DataOptional, EmailAvailable, ReservedUsernames, IdAvailable, IgnoreUnchanged from portality.bll import DOAJ +from portality.lib.security_utils import Encryption from portality.ui.messages import Messages from portality.ui import templates @@ -207,8 +208,21 @@ def _complete_verification(account): @blueprint.route('/verify-code', methods=['GET', 'POST']) def verify_code(): form = LoginForm(request.form, csrf_enabled=False) - email = _get_param('email') - verification_code = _get_param('code') + + # get encrypted token + encrypted_token = _get_param('token') + if encrypted_token: + # Decrypt parameters using a fresh Encryption instance (ensures current key) + params = Encryption().decrypt_params(encrypted_token) + email = params.get('email') + verification_code = params.get('code') + redirected = params.get('redirected') + else: + # Fall back to plain parameters (for backward compatibility or form submission) + email = _get_param('email') + verification_code = _get_param('code') + redirected = _get_param('redirected') + if request.args.get("redirected") == "apply": form['next'].data = url_for("apply.public_application") @@ -231,7 +245,17 @@ def verify_code(): def send_login_code_email(email: str, code: str, redirect_url: str): """Send login code email with both code and direct link""" - login_url = url_for('account.verify_code', code=code, email=email, redirected=redirect_url, _external=True) + # Encrypt parameters + params = { + 'email': email, + 'code': code + } + if redirect_url: + params['redirected'] = redirect_url + + encrypted = Encryption().encrypt_params(params) + + login_url = url_for('account.verify_code', token=encrypted, _external=True) send_mail( to=[email], From d10ab45ff15a2e3425a91931f7df502839fed52b Mon Sep 17 00:00:00 2001 From: Ramakrishna Sakhamuru Date: Tue, 2 Sep 2025 09:23:28 +0530 Subject: [PATCH 16/31] Fix testcases with login changes --- doajtest/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doajtest/helpers.py b/doajtest/helpers.py index fefa7432e7..52539c7295 100644 --- a/doajtest/helpers.py +++ b/doajtest/helpers.py @@ -495,7 +495,7 @@ def assert_expected_dict(test_case: TestCase, target, expected: dict): def login(app_client, email, password, follow_redirects=True): return app_client.post(url_for('account.login'), - data=dict(user=email, password=password), + data=dict(user=email, password=password, action='password_login'), follow_redirects=follow_redirects) From eb3780e6f9bb6f7b4160c5d1d285f9ce877b726a Mon Sep 17 00:00:00 2001 From: Ramakrishna Sakhamuru Date: Tue, 2 Sep 2025 12:11:58 +0530 Subject: [PATCH 17/31] Add cryptography to required packages --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6992360deb..fb866c058b 100644 --- a/setup.py +++ b/setup.py @@ -12,6 +12,7 @@ "bagit==1.8.1", "beautifulsoup4", "boto3==1.35.25", + "cryptography~=42.0", "elastic-apm==5.2.2", "elasticsearch==7.13.0", "Faker==2.0.3", @@ -72,7 +73,6 @@ "combinatrix @ git+https://github.com/CottageLabs/combinatrix.git@c96e6035244e29d4709fff23103405c17cd04a13#egg=combinatrix", "bs4==0.0.2", # beautifulsoup for HTML parsing 'openapi-spec-validator~=0.5', - "cryptography~=42.0", # for ad-hoc https ], # additional test dependencies for the test-extras target From f2aecdf91d699369db1ba1d87b3cb39568c5bc85 Mon Sep 17 00:00:00 2001 From: Ramakrishna Sakhamuru Date: Thu, 11 Sep 2025 13:45:08 +0530 Subject: [PATCH 18/31] Added encryption key --- portality/settings.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/portality/settings.py b/portality/settings.py index be2ad3e00e..748369e567 100644 --- a/portality/settings.py +++ b/portality/settings.py @@ -289,6 +289,8 @@ PASSWORD_CREATE_TIMEOUT = PASSWORD_RESET_TIMEOUT * 14 # amount of time a login through login-link is valid for LOGIN_LINK_TIMEOUT = 600 +# Encryption key for passwordless login +ENCRYPTION_KEY = "Passwordless login encryption key" # "api" top-level role is added to all accounts on creation; it can be revoked per account by removal of the role. TOP_LEVEL_ROLES = [ From ef414f2552129cbcf61e1297b863e8138384536d Mon Sep 17 00:00:00 2001 From: Ramakrishna Sakhamuru Date: Tue, 23 Sep 2025 12:02:24 +0530 Subject: [PATCH 19/31] Refactored the files --- portality/bll/doaj.py | 5 ++ portality/bll/services/account.py | 59 +++++++++++++++++++ .../lib/{security_utils.py => security.py} | 9 ++- portality/models/account.py | 28 +++++++-- portality/settings.py | 2 +- portality/view/account.py | 37 +++++------- 6 files changed, 106 insertions(+), 34 deletions(-) create mode 100644 portality/bll/services/account.py rename portality/lib/{security_utils.py => security.py} (89%) diff --git a/portality/bll/doaj.py b/portality/bll/doaj.py index d9305049a7..a77063e0a1 100644 --- a/portality/bll/doaj.py +++ b/portality/bll/doaj.py @@ -165,3 +165,8 @@ def hueyJobService(cls): """ from portality.bll.services import huey_job return huey_job.HueyJobService() + + @classmethod + def accountService(cls): + from portality.bll.services import account + return account.AccountService() diff --git a/portality/bll/services/account.py b/portality/bll/services/account.py new file mode 100644 index 0000000000..9f04b68609 --- /dev/null +++ b/portality/bll/services/account.py @@ -0,0 +1,59 @@ +from typing import Optional, Tuple + +from portality.core import app +from portality.models import Account +from portality.bll import exceptions +from portality.lib.security import Encryption + +class AccountService: + """Business logic for account login verification.""" + + def parse_login_token(self, encrypted_token: Optional[str]) -> dict: + """Decrypt token to params; return {} on failure or if not provided.""" + if not encrypted_token: + return {} + enc = Encryption(app.config.get('PASSWORDLESS_ENCRYPTION_KEY')) + return enc.decrypt_params(encrypted_token) or {} + + def verify_login_code( + self, + *, + encrypted_token: Optional[str] = None, + email: Optional[str] = None, + code: Optional[str] = None, + ) -> Tuple[Account, Optional[str]]: + """ + Verify a login either from an encrypted token or plain email/code. + + Returns: (account, redirected) + Raises: + - exceptions.ArgumentException if params missing + - exceptions.NoSuchObjectException if account not found + - exceptions.IllegalStatusException if code invalid/expired + """ + params = {} + if encrypted_token: + params = self.parse_login_token(encrypted_token) + tok_email = params.get('email') + tok_code = params.get('code') + redirected = params.get('redirected') + else: + tok_email = email + tok_code = code + redirected = None + + if not tok_email or not tok_code: + raise exceptions.ArgumentException("Required parameters not available.") + + account = Account.pull_by_email(tok_email) + if not account: + raise exceptions.NoSuchObjectException("Account not recognised.") + + if not account.is_login_code_valid(tok_code): + raise exceptions.IllegalStatusException("Invalid or expired verification code") + + # Business state change: clear and persist the code + account.remove_login_code() + account.save() + + return account, redirected \ No newline at end of file diff --git a/portality/lib/security_utils.py b/portality/lib/security.py similarity index 89% rename from portality/lib/security_utils.py rename to portality/lib/security.py index a9e1cb4e7f..f2af86f040 100644 --- a/portality/lib/security_utils.py +++ b/portality/lib/security.py @@ -1,5 +1,3 @@ -# portality/lib/security_utils.py - from cryptography.fernet import Fernet import base64 import json @@ -7,9 +5,10 @@ from portality.core import app class Encryption: - def __init__(self): - # Get key from config or generate one - key = app.config.get('ENCRYPTION_KEY', Fernet.generate_key()) + def __init__(self, key=None): + # Use provided key or generate a new one + if key is None: + key = Fernet.generate_key() if isinstance(key, str): key = key.encode('utf-8') self.fernet = Fernet(key) diff --git a/portality/models/account.py b/portality/models/account.py index ebbca12154..75e627c05b 100644 --- a/portality/models/account.py +++ b/portality/models/account.py @@ -265,9 +265,10 @@ def login_code_expires(self): return self.data.get('login_code_expires') def set_login_code(self, code, timeout=600): # default 10 minutes - expires = dates.now() + timedelta(seconds=timeout) - self.data["login_code"] = code - self.data["login_code_expires"] = expires.strftime(FMT_DATETIME_STD) + if code: + expires = dates.now() + timedelta(seconds=timeout) + self.data["login_code"] = code + self.data["login_code_expires"] = expires.strftime(FMT_DATETIME_STD) def remove_login_code(self): if "login_code" in self.data: @@ -276,6 +277,8 @@ def remove_login_code(self): del self.data["login_code_expires"] def is_login_code_valid(self, code): + if code is None: + return False if not self.login_code or not self.login_code_expires: return False if self.login_code != code: @@ -287,8 +290,21 @@ def is_login_code_valid(self, code): def pull_by_login_code(cls, code): if code is None: return None - res = cls.query(q='login_code.exact:"' + code + '"') - if res.get('hits', {}).get('total', {}).get('value', 0) == 1: - return cls(**res['hits']['hits'][0]['_source']) + q = LoginCodeQuery(code) + res = cls.object_query(q.query()) + if len(res) > 0: + return res[0] return None +class LoginCodeQuery: + def __init__(self, code): + self.code = code + + def query(self): + return { + "query": { + "term": { + "login_code.exact": self.code + } + } + } diff --git a/portality/settings.py b/portality/settings.py index 748369e567..ac4c20f306 100644 --- a/portality/settings.py +++ b/portality/settings.py @@ -290,7 +290,7 @@ # amount of time a login through login-link is valid for LOGIN_LINK_TIMEOUT = 600 # Encryption key for passwordless login -ENCRYPTION_KEY = "Passwordless login encryption key" +PASSWORDLESS_ENCRYPTION_KEY = "Passwordless login encryption key" # "api" top-level role is added to all accounts on creation; it can be revoked per account by removal of the role. TOP_LEVEL_ROLES = [ diff --git a/portality/view/account.py b/portality/view/account.py index 32c693ef8b..6a09accb05 100644 --- a/portality/view/account.py +++ b/portality/view/account.py @@ -14,7 +14,8 @@ from portality.models import Account, Event from portality.forms.validate import DataOptional, EmailAvailable, ReservedUsernames, IdAvailable, IgnoreUnchanged from portality.bll import DOAJ -from portality.lib.security_utils import Encryption +from portality.bll import exceptions as bll_exc +from portality.lib.security import Encryption from portality.ui.messages import Messages from portality.ui import templates @@ -209,33 +210,25 @@ def _complete_verification(account): def verify_code(): form = LoginForm(request.form, csrf_enabled=False) - # get encrypted token - encrypted_token = _get_param('token') - if encrypted_token: - # Decrypt parameters using a fresh Encryption instance (ensures current key) - params = Encryption().decrypt_params(encrypted_token) - email = params.get('email') - verification_code = params.get('code') - redirected = params.get('redirected') - else: - # Fall back to plain parameters (for backward compatibility or form submission) - email = _get_param('email') - verification_code = _get_param('code') - redirected = _get_param('redirected') - + # Preserve existing UI behavior for application redirect if request.args.get("redirected") == "apply": form['next'].data = url_for("apply.public_application") - if not email or not verification_code: + svc = DOAJ.accountService() + + try: + account, _redirected = svc.verify_login_code( + encrypted_token=_get_param('token'), + email=_get_param('email'), + code=_get_param('code'), + ) + except bll_exc.ArgumentException: flash("Required parameters not available.") return redirect(url_for('account.login')) - - account = Account.pull_by_email(email) - if not account: + except bll_exc.NoSuchObjectException: flash("Account not recognised.") return redirect(url_for('account.login')) - - if not account.is_login_code_valid(verification_code): + except bll_exc.IllegalStatusException: flash("Invalid or expired verification code") return redirect(url_for('account.login')) @@ -253,7 +246,7 @@ def send_login_code_email(email: str, code: str, redirect_url: str): if redirect_url: params['redirected'] = redirect_url - encrypted = Encryption().encrypt_params(params) + encrypted = Encryption(app.config.get('PASSWORDLESS_ENCRYPTION_KEY')).encrypt_params(params) login_url = url_for('account.verify_code', token=encrypted, _external=True) From 606359af140d1c6d5897ac615e07d16c4366ffc4 Mon Sep 17 00:00:00 2001 From: Ramakrishna Sakhamuru Date: Tue, 23 Sep 2025 20:43:53 +0530 Subject: [PATCH 20/31] Updated account service and redirect page --- portality/bll/services/account.py | 91 +++++++++++++++++++++++++++++-- portality/view/account.py | 89 +++++++++++++++--------------- 2 files changed, 131 insertions(+), 49 deletions(-) diff --git a/portality/bll/services/account.py b/portality/bll/services/account.py index 9f04b68609..ffc0331dc3 100644 --- a/portality/bll/services/account.py +++ b/portality/bll/services/account.py @@ -1,19 +1,100 @@ from typing import Optional, Tuple +import random + +from flask import url_for from portality.core import app from portality.models import Account from portality.bll import exceptions from portality.lib.security import Encryption +from portality.app_email import send_mail +from portality.ui import templates class AccountService: - """Business logic for account login verification.""" + """Business logic for account login and verification.""" + + # Defaults for passwordless login code + LOGIN_CODE_LENGTH = 6 + LOGIN_CODE_TIMEOUT = 600 # seconds (10 minutes) + + def resolve_user(self, username: str) -> Optional[Account]: + """Resolve a user by account id or email, depending on config.""" + if username is None or username == "": + return None + if app.config.get('LOGIN_VIA_ACCOUNT_ID', False): + return Account.pull(username) or Account.pull_by_email(username) + return Account.pull_by_email(username) + + def initiate_login_code(self, user: Account) -> str: + """Generate and persist a passwordless login code for the user; return the code.""" + if user is None or user.email is None: + raise exceptions.ArgumentException("User account is not available for login code.") + code = ''.join(str(random.randint(0, 9)) for _ in range(self.LOGIN_CODE_LENGTH)) + user.set_login_code(code, timeout=self.LOGIN_CODE_TIMEOUT) + user.save() + return code + + def send_login_code_email(self, user: Account, code: str, redirected: Optional[str] = "") -> None: + """Compose and send the passwordless login email with code and a direct link. + """ + if user is None or not user.email: + raise exceptions.ArgumentException("Cannot send login code email: user/email missing") + + params = {"email": user.email, "code": code} + if redirected: + params["redirected"] = redirected + + login_url = None + try: + encrypted = Encryption(app.config.get('PASSWORDLESS_ENCRYPTION_KEY')).encrypt_params(params) + login_url = url_for('account.verify_code', token=encrypted, _external=True) + except (ValueError, TypeError, Exception) as e: + # login_url is mandatory; propagate as an argument/config problem + app.logger.error(f"Failed to create encrypted login URL: {e.__class__.__name__}: {str(e)}") + raise exceptions.ArgumentException("Invalid encryption key or login url creation error") + + send_mail( + to=[user.email], + fro=app.config.get('SYSTEM_EMAIL_FROM'), + html_body_flag=True, + subject="Your Login Code for DOAJ", + template_name=templates.EMAIL_LOGIN_LINK, + code=code, + login_url=login_url, + expiry_minutes=10 + ) + + def verify_password_login(self, user: Account, password: Optional[str]) -> Account: + """ + Check the user's password. + Raises IllegalStatusException('no_password') if no password set. + Raises IllegalStatusException('incorrect_password') if password does not match. + Returns the user on success. + """ + if user is None: + raise exceptions.NoSuchObjectException("Account not recognised.") + try: + if user.check_password(password): + return user + # wrong password + raise exceptions.IllegalStatusException("incorrect_password") + except KeyError: + # account has no password set + raise exceptions.IllegalStatusException("no_password") def parse_login_token(self, encrypted_token: Optional[str]) -> dict: - """Decrypt token to params; return {} on failure or if not provided.""" + """Decrypt token to params; raise ArgumentException on failure or if not provided.""" if not encrypted_token: - return {} - enc = Encryption(app.config.get('PASSWORDLESS_ENCRYPTION_KEY')) - return enc.decrypt_params(encrypted_token) or {} + raise exceptions.ArgumentException("login token is required") + try: + enc = Encryption(app.config.get('PASSWORDLESS_ENCRYPTION_KEY')) + params = enc.decrypt_params(encrypted_token) or {} + except ValueError as e: + app.logger.error(f"Invalid encryption key: {e.__class__.__name__}: {str(e)}") + raise exceptions.ArgumentException("Invalid encryption key") + if not isinstance(params, dict) or not params: + raise exceptions.ArgumentException("invalid login token") + return params def verify_login_code( self, diff --git a/portality/view/account.py b/portality/view/account.py index 6a09accb05..1c5ec444b8 100644 --- a/portality/view/account.py +++ b/portality/view/account.py @@ -8,14 +8,12 @@ from portality import util from portality import constants -from portality.app_email import send_mail from portality.core import app from portality.decorators import ssl_required, write_required from portality.models import Account, Event from portality.forms.validate import DataOptional, EmailAvailable, ReservedUsernames, IdAvailable, IgnoreUnchanged from portality.bll import DOAJ from portality.bll import exceptions as bll_exc -from portality.lib.security import Encryption from portality.ui.messages import Messages from portality.ui import templates @@ -210,10 +208,6 @@ def _complete_verification(account): def verify_code(): form = LoginForm(request.form, csrf_enabled=False) - # Preserve existing UI behavior for application redirect - if request.args.get("redirected") == "apply": - form['next'].data = url_for("apply.public_application") - svc = DOAJ.accountService() try: @@ -232,34 +226,15 @@ def verify_code(): flash("Invalid or expired verification code") return redirect(url_for('account.login')) + # Preserve existing UI behavior for application redirect + redirected_page = request.args.get("redirected") or _redirected + if redirected_page == "apply": + form['next'].data = url_for("apply.public_application") + _complete_verification(account) return redirect(get_redirect_target(form=form, acc=account)) -def send_login_code_email(email: str, code: str, redirect_url: str): - """Send login code email with both code and direct link""" - # Encrypt parameters - params = { - 'email': email, - 'code': code - } - if redirect_url: - params['redirected'] = redirect_url - - encrypted = Encryption(app.config.get('PASSWORDLESS_ENCRYPTION_KEY')).encrypt_params(params) - - login_url = url_for('account.verify_code', token=encrypted, _external=True) - - send_mail( - to=[email], - fro=app.config.get('SYSTEM_EMAIL_FROM'), - html_body_flag = True, - subject="Your Login Code for DOAJ", - template_name=templates.EMAIL_LOGIN_LINK, - code=code, - login_url=login_url, - expiry_minutes=10 - ) def get_user_account(username): # If our settings allow, try getting the user account by ID first, then by email address @@ -276,7 +251,8 @@ def handle_login_code_request(user, form): user.set_login_code(code, timeout=LOGIN_CODE_TIMEOUT) user.save() - send_login_code_email(user.email, code, request.args.get("redirected", "")) + svc = DOAJ.accountService() + svc.send_login_code_email(user, code, request.args.get("redirected", "")) flash('A login link along with login code has been sent to your email.') return render_template(templates.LOGIN_VERIFY_CODE, email=user.email, form=form) @@ -315,21 +291,46 @@ def login(): username = form.user.data action = request.form.get('action') - user = get_user_account(username) - - # If we have a verified user account, proceed to attempt login + svc = DOAJ.accountService() try: - if user is not None: - if action == 'get_link': - return handle_login_code_request(user, form) - elif action == 'password_login': - return handle_password_login(user, form) + user = svc.resolve_user(username) + if user is None: + raise bll_exc.NoSuchObjectException() + + if action == 'get_link': + # Generate and persist code in BLL, then send email and show verify template + code = svc.initiate_login_code(user) + svc.send_login_code_email(user, code, request.args.get("redirected", "")) + flash('A login link along with login code has been sent to your email.') + return render_template(templates.LOGIN_VERIFY_CODE, email=user.email, form=form) + + elif action == 'password_login': + account = svc.verify_password_login(user, form.password.data) + login_user(account, remember=True) + flash('Welcome back.', 'success') + return redirect(get_redirect_target(form=form, acc=account)) + else: - form.user.errors.append('Account not recognised. If you entered an email address, try your username instead.') - except KeyError: - # Account has no password set, the user needs to reset or use an existing valid reset link - handle_incomplete_verification() - return redirect(url_for('doaj.home')) + # Unknown action + raise bll_exc.ArgumentException("Unknown login action") + + except bll_exc.NoSuchObjectException: + form.user.errors.append('Account not recognised. If you entered an email address, try your username instead.') + except bll_exc.IllegalStatusException as e: + msg = str(e) if e.args else "" + if msg == 'incomplete_verification': + handle_incomplete_verification() + return redirect(url_for('doaj.home')) + elif msg == 'incorrect_password': + forgot_url = url_for(".forgot") + form.password.errors.append( + f'The password you entered is incorrect. Try again or reset your password.' + ) + else: + # Generic illegal status + flash('Login could not be completed due to account status.', 'error') + except bll_exc.ArgumentException: + flash('There was a problem with your request.', 'error') return handle_login_template_rendering(form) From 60bc8335a0b4abc34cf7e1947a42ff9e5caec3cf Mon Sep 17 00:00:00 2001 From: Ramakrishna Sakhamuru Date: Wed, 24 Sep 2025 17:24:21 +0530 Subject: [PATCH 21/31] resend login code --- .../bll/services/concurrency_prevention.py | 52 ++++++++++++++- portality/lib/dates.py | 3 + .../includes/_login_by_code_form.html | 43 ++++++++++++- .../public/account/login_by_code.html | 1 + portality/view/account.py | 63 +++++++++++++++++-- 5 files changed, 154 insertions(+), 8 deletions(-) diff --git a/portality/bll/services/concurrency_prevention.py b/portality/bll/services/concurrency_prevention.py index 4fe329353e..144690cd3d 100644 --- a/portality/bll/services/concurrency_prevention.py +++ b/portality/bll/services/concurrency_prevention.py @@ -1,6 +1,8 @@ from portality.core import app +from portality.lib import dates import redis - +import json +import hashlib class ConcurrencyPreventionService: def __init__(self): @@ -19,3 +21,51 @@ def store_concurrency(self, key, _id, timeout=None): timeout = app.config.get("UR_CONCURRENCY_TIMEOUT", 10) if timeout > 0: self.rs.set(key, _id, ex=timeout) + + # Passwordless login resend backoff tracking + def record_pwless_resend(self, email: str, now: int | None = None): + """ + Check and record a passwordless login code resend for the given email with exponential backoff. + + Returns a tuple: (allowed: bool, wait_remaining: int, current_interval: int) + - allowed: whether a resend is permitted right now + - wait_remaining: seconds to wait until next allowed resend (0 if allowed) + - current_interval: the interval applied/returned for UI cooldown + """ + if not email: + return False, 60, 60 + + email_key = hashlib.sha1(email.lower().encode("utf-8")).hexdigest() + key = f"pwless_resend:{email_key}" + now = now or dates.now_in_sec() + + min_interval = int(app.config.get("PWLESS_RESEND_MIN_INTERVAL", 60)) + max_interval = int(app.config.get("PWLESS_RESEND_MAX_INTERVAL", 86400)) # 24 hours + factor = float(app.config.get("PWLESS_RESEND_BACKOFF_FACTOR", 2.0)) + ttl = int(app.config.get("PWLESS_RESEND_RECORD_TTL", max_interval * 2)) + + raw = self.rs.get(key) + record = json.loads(raw.decode('utf-8')) if raw else None + + if record: + next_allowed_at = int(record.get("next_allowed_at", 0)) + count = int(record.get("count", 0)) + current_interval = int(record.get("current_interval", min_interval)) + if now < next_allowed_at: + return False, next_allowed_at - now, current_interval + # allowed: back off interval for next time + new_interval = int(min(max_interval, max(min_interval, current_interval * factor))) + new_count = count + 1 + else: + new_interval = min_interval + new_count = 1 + + next_allowed_at = now + new_interval + new_record = { + "last_request": now, + "count": new_count, + "current_interval": new_interval, + "next_allowed_at": next_allowed_at, + } + self.rs.set(key, json.dumps(new_record), ex=ttl) + return True, 0, new_interval diff --git a/portality/lib/dates.py b/portality/lib/dates.py index 783072816e..fba52db445 100644 --- a/portality/lib/dates.py +++ b/portality/lib/dates.py @@ -85,6 +85,9 @@ def now() -> datetime: """ standard now function for DOAJ """ return datetime.utcnow() +def now_in_sec() -> int: + return int(now().timestamp()) + def now_str(fmt=FMT_DATETIME_STD) -> str: return format(now(), format=fmt) diff --git a/portality/templates-v2/_account/includes/_login_by_code_form.html b/portality/templates-v2/_account/includes/_login_by_code_form.html index 4c921b7a15..5bea752a18 100644 --- a/portality/templates-v2/_account/includes/_login_by_code_form.html +++ b/portality/templates-v2/_account/includes/_login_by_code_form.html @@ -1,6 +1,6 @@ {% from "includes/_formhelpers.html" import render_field %} -