diff --git a/cms/sass/main.scss b/cms/sass/main.scss index 5b1efd2925..4ba6f35dc9 100644 --- a/cms/sass/main.scss +++ b/cms/sass/main.scss @@ -71,6 +71,7 @@ "pages/homepage", "pages/journal-details", "pages/journal-toc-articles", + "pages/login", "pages/search", "pages/sponsors", "pages/uploadmetadata", diff --git a/cms/sass/pages/_homepage.scss b/cms/sass/pages/_homepage.scss index 644d4194a1..d1de9addb5 100644 --- a/cms/sass/pages/_homepage.scss +++ b/cms/sass/pages/_homepage.scss @@ -27,7 +27,7 @@ } .secondary-nav { - margin-bottom: 25px; + margin-bottom: 1.5rem; } a { @@ -74,15 +74,17 @@ &__background { background: url("data:image/svg+xml,%3Csvg width='673' height='1723' viewBox='0 0 673 1723' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M159.5 1022V1022.5H160C248.642 1022.5 320.5 950.642 320.5 862C320.5 773.358 248.642 701.5 160 701.5C71.3589 701.5 -0.5 773.358 -0.5 862V862.5H0H159.5V1022Z' fill='%23F9D950' stroke='%23F9D950'/%3E%3Cpath d='M352.5 862V862.5H352.501C352.77 950.912 424.525 1022.5 513 1022.5H673H673.5V1022C673.5 937.654 608.438 868.504 525.763 862C608.438 855.496 673.5 786.346 673.5 702V701.5H673H513C424.525 701.5 352.77 773.088 352.501 861.5H352.5V862Z' fill='%23F9D950' stroke='%23F9D950'/%3E%3Cpath d='M320.5 160V160.5H320H160C71.3579 160.5 -0.5 88.6421 -0.5 0V-0.5H0H160C248.642 -0.5 320.5 71.3579 320.5 160Z' fill='%23282624' stroke='%23282624'/%3E%3Cpath d='M320 159.5H320.5V160C320.5 248.642 248.642 320.5 160 320.5H0H-0.5V320C-0.5 231.358 71.3579 159.5 160 159.5H320Z' fill='%23282624' stroke='%23282624'/%3E%3Cpath d='M673.5 0V-0.5H673H353H352.5V0C352.5 82.5511 414.822 150.545 494.983 159.5H353H352.5V160C352.5 248.642 424.358 320.5 513 320.5C601.642 320.5 673.5 248.642 673.5 160V159.5H673H531.017C611.178 150.545 673.5 82.5511 673.5 0Z' fill='%23282624' stroke='%23282624'/%3E%3Cpath d='M-0.5 1564V1564.5H0H159.5H160H160.5H320H320.5V1564C320.5 1475.52 248.912 1403.77 160.5 1403.5V1403.5H160H159.5V1403.5C71.0878 1403.77 -0.5 1475.52 -0.5 1564Z' fill='%233A5959' stroke='%233A5959'/%3E%3Cpath d='M0 1724.5C84.3464 1724.5 153.496 1659.44 160 1576.76C166.504 1659.44 235.654 1724.5 320 1724.5H320.5V1724V1564V1563.5H320H160.5H160H159.5H0H-0.5V1564V1724V1724.5H0Z' fill='%2347A178' stroke='%2347A178'/%3E%3Cpath d='M353 1563.5H352.5V1564C352.5 1652.65 424.358 1724.5 513 1724.5C601.642 1724.5 673.5 1652.65 673.5 1564C673.5 1475.35 601.642 1403.5 513 1403.5H512.5V1404V1563.5H353Z' fill='%23A3C386' stroke='%23A3C386'/%3E%3Cpath d='M320 1373.5H320.5V1373C320.5 1288.66 255.438 1219.5 172.762 1213C255.438 1206.5 320.5 1137.35 320.5 1053V1052.5H320H0H-0.5V1053C-0.5 1137.35 64.5621 1206.5 147.238 1213C64.562 1219.5 -0.5 1288.66 -0.5 1373V1373.5H0H320Z' fill='%233A5959' stroke='%233A5959'/%3E%3Cpath d='M673.5 1213V1212.5H673H513H512.5V1213V1373V1373.5H513C601.642 1373.5 673.5 1301.65 673.5 1213Z' fill='%2347A178' stroke='%2347A178'/%3E%3Cpath d='M352.5 1373V1373.5H353H513H513.5V1373V1213V1212.5H513C424.358 1212.5 352.5 1284.36 352.5 1373Z' fill='%233A5959' stroke='%233A5959'/%3E%3Cpath d='M673.5 1053V1052.5H673H513H512.5V1053V1213V1213.5H513C601.642 1213.5 673.5 1141.65 673.5 1053Z' fill='%2347A178' stroke='%2347A178'/%3E%3Cpath d='M352.5 1213V1213.5H353H513H513.5V1213V1053V1052.5H513C424.358 1052.5 352.5 1124.36 352.5 1213Z' fill='%233A5959' stroke='%233A5959'/%3E%3Cpath d='M320.5 511C320.5 422.358 248.642 350.5 160 350.5C71.3579 350.5 -0.5 422.358 -0.5 511C-0.5 599.642 71.3579 671.5 160 671.5C248.642 671.5 320.5 599.642 320.5 511Z' fill='%23FD5A3B' stroke='%23FD5A3B'/%3E%3Cpath d='M352.5 351V350.5H353C441.642 350.5 513.5 422.358 513.5 511C513.5 599.642 441.642 671.5 353 671.5H352.5V671V351Z' fill='%23FD5A3B' stroke='%23FD5A3B'/%3E%3Cpath d='M673 671.5H673.5V671V351V350.5H673C584.358 350.5 512.5 422.358 512.5 511C512.5 599.642 584.358 671.5 673 671.5Z' fill='%23982E0A' stroke='%23982E0A'/%3E%3C/svg%3E%0A") no-repeat right top; background-size: auto 100%; + > * { + transition: all 0.35s; + background-color: rgba(255, 255, 255, 0.65); + + @media screen and (max-width: 1279px) { + background-color: rgba($white, 0.85); + } + } } &__search { - transition: all 0.35s; - background-color: rgba(255, 255, 255, 0.65); - - @media screen and (max-width: 1279px) { - background-color: rgba($white, 0.85); - } .container { padding-top: $spacing-05; diff --git a/cms/sass/pages/_login.scss b/cms/sass/pages/_login.scss new file mode 100644 index 0000000000..b20bbf0887 --- /dev/null +++ b/cms/sass/pages/_login.scss @@ -0,0 +1,11 @@ +.passwordless-login { + .secondary-nav { + margin-bottom: 1.5rem; + } +} +.form-login { + .form-login__question { + margin-top: 1.5rem !important; + margin-bottom: 0 !important; + } +} \ No newline at end of file diff --git a/doajtest/helpers.py b/doajtest/helpers.py index dfa5f44870..0b6f2d1044 100644 --- a/doajtest/helpers.py +++ b/doajtest/helpers.py @@ -501,7 +501,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) 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/doajtest/unit/test_account_passwordless.py b/doajtest/unit/test_account_passwordless.py new file mode 100644 index 0000000000..2b8cf61c87 --- /dev/null +++ b/doajtest/unit/test_account_passwordless.py @@ -0,0 +1,269 @@ +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 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, with_es + +# Set up test encryption key +app.config['ENCRYPTION_KEY'] = Fernet.generate_key() + +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() + self.app = app.test_client() + self.app.testing = True + self.url_crypto = Encryption() + + + 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)) + + +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( + 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() + + @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 + code = '123456' + self.test_account.set_login_code(code) + self.test_account.save() + self.test_account.refresh() + + # 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 + self.assertEqual(response.status_code, 200) + + # Verify code was removed + account = Account.pull(self.test_account.id) + 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 + code = '123456' + self.test_account.set_login_code(code) + self.test_account.save() + self.test_account.refresh() + + # 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 + 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) + + @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 + code = '123456' + 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={ + '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) + +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.assertTrue('token=' in kwargs['login_url']) # Check for encrypted token in URL + self.assertEqual(kwargs['expiry_minutes'], 10) diff --git a/portality/app_email.py b/portality/app_email.py index 577b8bbdca..7427951a88 100644 --- a/portality/app_email.py +++ b/portality/app_email.py @@ -1,4 +1,6 @@ # ~~Email:Library~~ +import logging + from flask import render_template, flash from flask_mail import Mail, Message, Attachment from portality.core import app @@ -23,20 +25,23 @@ 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=None, **template_params): +def send_mail(to, fro, subject, template_name=None, plaintext_template_name=None, bcc=None, files=None, msg_body=None, html_body=None, html_body_flag=False, **template_params): """ ~~-> Email:ExternalService~~ ~~-> FlaskMail:Library~~ :param to: :param fro: :param subject: - :param template_name: + :param template_name: HTML template name (used for HTML body when html_body_flag is True and html_body not provided) + :param plaintext_template_name: Plain text template name (used for the plain text body if provided) :param bcc: :param files: :param msg_body: + :param html_body_flag: + :param html_body: :param template_params: :return: """ @@ -71,23 +76,39 @@ 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: + + def _msg_to_plaintext(): + if msg_body: + plaintext_body = msg_body + else: + tn = plaintext_template_name if plaintext_template_name else template_name + try: + plaintext_body = render_template(tn, **template_params) + except: + with app.test_request_context(): + plaintext_body = render_template(tn, **template_params) + + # 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 and not html_body: try: - plaintext_body = render_template(template_name, **template_params) - except: - with app.test_request_context(): - plaintext_body = render_template(template_name, **template_params) + html_body = render_template(template_name, **template_params) + except Exception as e: + logging.WARNING(f"Could not render HTML template for email: {str(e)}") + html_body = None # Explicitly set to None to make the state clear - # strip all the leading and trailing whitespace from the body, which the templates - # leave lying around - plaintext_body = plaintext_body.strip() + # Always ensure we have a plaintext body for clients that don't support HTML + # or in case HTML rendering failed + msg_body = _msg_to_plaintext() # 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/bll/doaj.py b/portality/bll/doaj.py index 587f01a638..500edaa2a0 100644 --- a/portality/bll/doaj.py +++ b/portality/bll/doaj.py @@ -173,4 +173,13 @@ def adminAlertsService(cls): :return: AdminAlertsService """ from portality.bll.services import admin_alerts - return admin_alerts.AdminAlertsService() \ No newline at end of file + return admin_alerts.AdminAlertsService() + + @classmethod + def accountService(cls): + """ + Obtain an instance of the AccountSrvice ~~->AccountService:Service~~ + :return: AccountService + """ + 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..9be372e7e0 --- /dev/null +++ b/portality/bll/services/account.py @@ -0,0 +1,141 @@ +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 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, + plaintext_template_name=templates.EMAIL_LOGIN_LINK_PLAINTEXT, + 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; raise ArgumentException on failure or if not provided.""" + if not encrypted_token: + 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, + *, + 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/bll/services/concurrency_prevention.py b/portality/bll/services/concurrency_prevention.py index 4fe329353e..0576964f89 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,56 @@ 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)) + if count > 2: # allow resend code for 3 times + 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))) + else: + # set a minimum interval for 3 times + new_interval = min_interval + + 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 01ebf7946c..aac3a2b647 100644 --- a/portality/lib/dates.py +++ b/portality/lib/dates.py @@ -88,6 +88,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/lib/security.py b/portality/lib/security.py new file mode 100644 index 0000000000..f2af86f040 --- /dev/null +++ b/portality/lib/security.py @@ -0,0 +1,46 @@ +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, 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) + + 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/models/account.py b/portality/models/account.py index f930bb0dcb..fd50e72a64 100644 --- a/portality/models/account.py +++ b/portality/models/account.py @@ -266,3 +266,56 @@ 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 + 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: + 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 code is None: + return False + 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 + 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 0b0c4fef8c..0aa710cbe7 100644 --- a/portality/settings.py +++ b/portality/settings.py @@ -286,6 +286,10 @@ 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 +# Encryption key for passwordless login +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/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..5bea752a18 --- /dev/null +++ b/portality/templates-v2/_account/includes/_login_by_code_form.html @@ -0,0 +1,56 @@ +{% from "includes/_formhelpers.html" import render_field %} + +
+ + + + \ 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 8582f5ed3f..69e982bfbd 100644 --- a/portality/templates-v2/_account/includes/_login_form.html +++ b/portality/templates-v2/_account/includes/_login_form.html @@ -1,12 +1,34 @@ {% from "includes/_formhelpers.html" import render_field %} -