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 %} + +
+ + +
+ + +
+ {{ render_field(form.next) }} + + + + +
+ +
+ + {{ render_field(form.next) }} + + + +
+ + \ 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 %} -
-
+ + -
- {{ 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/email/login_code.jinja2 b/portality/templates-v2/email/login_code.jinja2 new file mode 100644 index 0000000000..b623c0464d --- /dev/null +++ b/portality/templates-v2/email/login_code.jinja2 @@ -0,0 +1,45 @@ +{# templates/email/login_code.jinja2 #} + +
+ +

Hello,

+ +

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

+ +
+ + Log in to your account + +
+ +
+

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:

+ +

+ {{ code }} +

+
+ +

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/templates-v2/email/login_code.txt.jinja2 b/portality/templates-v2/email/login_code.txt.jinja2 new file mode 100644 index 0000000000..89450e07c5 --- /dev/null +++ b/portality/templates-v2/email/login_code.txt.jinja2 @@ -0,0 +1,18 @@ +{# templates/email/login_code.txt.jinja2 #} + +Hello, + +Click the link below to log in securely. This link will expire in {{ expiry_minutes }} minutes. + +Log in to your account ( {{ login_url }} ) + +If the link above doesn't work, copy and paste this link into your browser. + +Alternatively you can enter this code on the website: + +{{ code }} + +If you didn't request this login code, please ignore this email. + +Best regards, +DOAJ Team 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


-
- -
- + + +
diff --git a/portality/templates-v2/public/account/login.html b/portality/templates-v2/public/account/login.html index a44a9e5c32..89d6354f0e 100644 --- a/portality/templates-v2/public/account/login.html +++ b/portality/templates-v2/public/account/login.html @@ -1,8 +1,9 @@ {% extends "public/base.html" %} - +{% block body_class %}passwordless-login{% endblock %} {% block page_title %}Login to your account{% endblock %} {% block public_content %} +
@@ -10,12 +11,35 @@

Login

DOAJ is open to use without logging in.

You only need an account if you are submitting an application, if you have a journal in DOAJ or if you are a volunteer.

-
-
+ {% include "_account/includes/_login_form.html" %}

If you cannot log in, reset your password. If that doesn't work, contact us.

+
{% endblock %} + +{% block public_js %} + +{% endblock %} \ 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 new file mode 100644 index 0000000000..6edce6c942 --- /dev/null +++ b/portality/templates-v2/public/account/login_by_code.html @@ -0,0 +1,21 @@ +{% 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.

+

If you don't receive the email, check your spam folder.

+
+
+ {% 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/templates-v2/public/account/register.html b/portality/templates-v2/public/account/register.html index d119bbdbc3..e3275cd499 100644 --- a/portality/templates-v2/public/account/register.html +++ b/portality/templates-v2/public/account/register.html @@ -7,6 +7,7 @@ {% block public_content %}
+

Register

@@ -16,12 +17,12 @@

Register

DOAJ is free to use without logging in.

You only need an account if you wish to create an application for a journal’s inclusion in the DOAJ or you are a volunteer.

{% endif %} -
-
+ {% include "_account/includes/_register_form.html" %}

If you have difficulty registering, contact us.

+
{% endblock %} diff --git a/portality/ui/messages.py b/portality/ui/messages.py index e487f09ed6..54a5ea7350 100644 --- a/portality/ui/messages.py +++ b/portality/ui/messages.py @@ -18,6 +18,46 @@ class Messages(object): ARTICLE_METADATA_UPDATE_CONFLICT = ("""Article could not be updated, as it matches another existing article. Please check your metadata, and contact us if you cannot resolve the issue yourself.""", "error") + # Account-related flash messages (from portality/view/account.py) + ACCOUNT__PWLESS__RESEND_RATE_LIMIT = ( + "You requested a code recently. Please wait {wait} before trying again.", + "error", + ) + ACCOUNT__PWLESS__EMAIL_SENT = ( + "A login link along with login code has been sent to your email.", + "success", + ) + ACCOUNT__PWLESS__EMAIL_ERROR = ( + "There was a problem generating the login email.", + "error", + ) + ACCOUNT__EMAIL_REQUIRED_FOR_RESEND = ( + "Email address is required to resend the code.", + "error", + ) + ACCOUNT__NOT_RECOGNISED = "Account not recognised." + ACCOUNT__REQUIRED_PARAMS_NOT_AVAILABLE = "Required parameters not available." + ACCOUNT__INVALID_OR_EXPIRED_CODE = "Invalid or expired verification code" + ACCOUNT__WELCOME_BACK = ("Welcome back.", "success") + ACCOUNT__STATUS_LOGIN_FAILED = ("Login could not be completed due to account status.", "error") + ACCOUNT__REQUEST_PROBLEM = ("There was a problem with your request.", "error") + ACCOUNT__RESET_EMAIL_SENT = "Instructions to reset your password have been sent to you. Please check your emails." + ACCOUNT__PASSWORDS_NOT_MATCH = ("Passwords do not match - please try again", "error") + ACCOUNT__PASSWORD_SET_AND_LOGGED_IN = ("New password has been set and you're now logged in.", "success") + ACCOUNT__LOGGED_OUT = ("You are now logged out", "success") + ACCOUNT__VERIFY_EMAIL_TO_SET_PASSWORD = ( + "Thank you, please verify email address {email} to set your password and verify your account.", + "success", + ) + ACCOUNT__PLEASE_CORRECT_ERRORS = ("Please correct the errors", "error") + ACCOUNT__CONFIRM_CHECKBOX_REQUIRED = ("Check the box to confirm you really mean it!", "error") + ACCOUNT__DELETED = "Account {id} deleted" + ACCOUNT__RECORD_UPDATED = "Record updated" + ACCOUNT__EMAIL_UPDATED_LOGGED_OUT = ( + "Email address updated. You have been logged out for email address verification.", + "success", + ) + CONCURRENT_UPDATE_REQUEST = """You have submitted an Update Request for the same journal in a short period of time. If this is in error, you don't need to do anything, your first request is being processed. If this was intentional, please try again in a moment.""" SENT_ACCEPTED_APPLICATION_EMAIL = """Sent notification to '{user}' to tell them that their journal was accepted.""" diff --git a/portality/ui/templates.py b/portality/ui/templates.py index d539cd6cf7..c164a0eaa4 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/login_by_code.html" FORGOT_PASSWORD = "public/account/forgot.html" REGISTER = "public/account/register.html" CREATE_USER = "management/admin/account/create.html" @@ -113,4 +114,6 @@ 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" +EMAIL_LOGIN_LINK_PLAINTEXT = "email/login_code.txt.jinja2" \ No newline at end of file diff --git a/portality/view/account.py b/portality/view/account.py index b94f1f216f..aa639ab150 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 @@ -12,6 +13,7 @@ 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.ui.messages import Messages from portality.ui import templates @@ -71,10 +73,10 @@ def username(username): else: conf = request.values.get("delete_confirm") if conf is None or conf != "delete_confirm": - flash('Check the box to confirm you really mean it!', "error") + Messages.flash(Messages.ACCOUNT__CONFIRM_CHECKBOX_REQUIRED) return render_template(template, account=acc, form=UserEditForm(obj=acc)) acc.delete() - flash('Account ' + acc.id + ' deleted') + Messages.flash(Messages.ACCOUNT__DELETED.format(id=acc.id)) return redirect(url_for('.index')) elif request.method == 'POST': @@ -121,7 +123,7 @@ def username(username): events_svc = DOAJ.eventsService() events_svc.trigger(Event(constants.EVENT_ACCOUNT_PASSWORD_RESET, acc.id, context={"account" : acc.data})) - flash("Email address updated. You have been logged out for email address verification.") + Messages.flash(Messages.ACCOUNT__EMAIL_UPDATED_LOGGED_OUT) logout_user() @@ -132,7 +134,7 @@ def username(username): return redirect(url_for('doaj.home')) acc.save() - flash("Record updated") + Messages.flash(Messages.ACCOUNT__RECORD_UPDATED) return render_template(template, account=acc, form=form) else: # GET @@ -185,8 +187,151 @@ 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()]) + 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) + + +def get_wait_period(secs: int) -> str: + secs = int(secs or 0) + if secs >= 7200: + return f"{(secs + 3599) // 3600} hours" + if secs >= 3600: + return "1 hour" + if secs >= 120: + return f"{(secs + 59) // 60} minutes" + if secs >= 60: + return "1 minute" + return f"{secs} seconds" + + +def _handle_pwless_login(user, form, redirected: str = ""): + """ Handler for passwordless login backoff + email sending. + + Returns a rendered verify_code template with appropriate resend_wait. + """ + cps = DOAJ.concurrencyPreventionService() + allowed, wait_remaining, interval = cps.record_pwless_resend(user.email) + + if not allowed: + tpl = Messages.ACCOUNT__PWLESS__RESEND_RATE_LIMIT + Messages.flash((tpl[0].format(wait=get_wait_period(wait_remaining)), tpl[1])) + return render_template(templates.LOGIN_VERIFY_CODE, email=user.email, form=form, resend_wait=wait_remaining) + + try: + svc = DOAJ.accountService() + code = svc.initiate_login_code(user) + svc.send_login_code_email(user, code, redirected or "") + Messages.flash(Messages.ACCOUNT__PWLESS__EMAIL_SENT) + except bll_exc.ArgumentException: + Messages.flash(Messages.ACCOUNT__PWLESS__EMAIL_ERROR) + + return render_template(templates.LOGIN_VERIFY_CODE, email=user.email, form=form, resend_wait=interval) + +@blueprint.route('/verify-code', methods=['GET', 'POST']) +def verify_code(): + form = LoginForm(request.form, csrf_enabled=False) + + # Handle resend requests posted from the code entry page + if request.method == 'POST' and (request.form.get('action') == 'resend'): + email = _get_param('email') + if not email: + Messages.flash(Messages.ACCOUNT__EMAIL_REQUIRED_FOR_RESEND) + return redirect(url_for('account.login')) + + user = Account.pull_by_email(email) + if not user: + Messages.flash(Messages.ACCOUNT__NOT_RECOGNISED) + return redirect(url_for('account.login')) + + return _handle_pwless_login(user, form, _get_param('redirected') or '') + + 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: + Messages.flash(Messages.ACCOUNT__REQUIRED_PARAMS_NOT_AVAILABLE) + return redirect(url_for('account.login')) + except bll_exc.NoSuchObjectException: + Messages.flash(Messages.ACCOUNT__NOT_RECOGNISED) + return redirect(url_for('account.login')) + except bll_exc.IllegalStatusException: + Messages.flash(Messages.ACCOUNT__INVALID_OR_EXPIRED_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 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() + svc = DOAJ.accountService() + svc.send_login_code_email(user, code, request.args.get("redirected", "")) + Messages.flash(Messages.ACCOUNT__PWLESS__EMAIL_SENT) + + 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) + Messages.flash(Messages.ACCOUNT__WELCOME_BACK) + 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 @@ -194,38 +339,47 @@ 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): - user = Account.pull(username) or Account.pull_by_email(username) - else: - user = Account.pull_by_email(username) - - # If we have a verified user account, proceed to attempt login + svc = DOAJ.accountService() 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)) - 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: - # 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') - return redirect(url_for('doaj.home')) + user = svc.resolve_user(username) + if user is None: + raise bll_exc.NoSuchObjectException() - 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) + if action == 'get_link': + return _handle_pwless_login(user, form, request.args.get("redirected", "")) + + elif action == 'password_login': + account = svc.verify_password_login(user, form.password.data) + login_user(account, remember=True) + Messages.flash(Messages.ACCOUNT__WELCOME_BACK) + return redirect(get_redirect_target(form=form, acc=account)) + + else: + # 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 + Messages.flash(Messages.ACCOUNT__STATUS_LOGIN_FAILED) + except bll_exc.ArgumentException: + Messages.flash(Messages.ACCOUNT__REQUEST_PROBLEM) + return handle_login_template_rendering(form) @blueprint.route('/forgot', methods=['GET', 'POST']) @ssl_required @@ -257,7 +411,7 @@ def forgot(): events_svc = DOAJ.eventsService() events_svc.trigger(Event(constants.EVENT_ACCOUNT_PASSWORD_RESET, account.id, context={"account": account.data})) - flash('Instructions to reset your password have been sent to you. Please check your emails.') + Messages.flash(Messages.ACCOUNT__RESET_EMAIL_SENT) if app.config.get('DEBUG', False): util.flash_with_url('Debug mode - url for reset is {0}'.format( @@ -288,14 +442,14 @@ def reset(reset_token): pw = request.values.get("password") conf = request.values.get("confirm") if pw != conf: - flash("Passwords do not match - please try again", "error") + Messages.flash(Messages.ACCOUNT__PASSWORDS_NOT_MATCH) return render_template(templates.RESET_PASSWORD, account=account, form=form) # update the user's account account.set_password(pw) account.remove_reset_token() account.save() - flash("New password has been set and you're now logged in.", "success") + Messages.flash(Messages.ACCOUNT__PASSWORD_SET_AND_LOGGED_IN) # log the user in login_user(account, remember=True) @@ -308,7 +462,7 @@ def reset(reset_token): @ssl_required def logout(): logout_user() - flash('You are now logged out', 'success') + Messages.flash(Messages.ACCOUNT__LOGGED_OUT) return redirect('/') @@ -367,13 +521,13 @@ def register(template=templates.REGISTER): util.flash_with_url('Account created for {0}. View Account: {1}'.format(account.email, url_for('.username', username=account.id))) return redirect(url_for('.index')) else: - flash('Thank you, please verify email address ' + form.sender_email.data + ' to set your password and login.', - 'success') + tpl = Messages.ACCOUNT__VERIFY_EMAIL_TO_SET_PASSWORD + Messages.flash((tpl[0].format(email=form.sender_email.data), tpl[1])) # We must redirect home because the user now needs to verify their email address. return redirect(url_for('doaj.home')) else: - flash('Please correct the errors', 'error') + Messages.flash(Messages.ACCOUNT__PLEASE_CORRECT_ERRORS) return render_template(template, form=form) diff --git a/setup.py b/setup.py index 62410078ac..cfc2df457a 100644 --- a/setup.py +++ b/setup.py @@ -13,6 +13,7 @@ "bagit==1.8.1", "beautifulsoup4", "boto3==1.35.25", + "cryptography~=42.0", "elastic-apm==6.24.0", "elasticsearch==7.13.0", "Faker==2.0.3",