Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
8e63090
Initial commit for passwordless login
RK206 Apr 28, 2025
f65e3f3
Merge branch 'develop' of https://github.com/DOAJ/doaj into feature/3…
RK206 May 13, 2025
6559ba8
Handle link url and login code
RK206 May 14, 2025
2c6e37b
Added link to login
RK206 May 19, 2025
e8c7a84
Implementation to handle the link
RK206 May 19, 2025
1dcf561
Added code verify form
RK206 May 20, 2025
8d6f2a7
Merge branch 'develop' of https://github.com/DOAJ/doaj into feature/3…
RK206 Jun 11, 2025
3ea215d
Added testcases and some fixes
RK206 Jun 11, 2025
6a3fa3b
Merge branch 'develop' of https://github.com/DOAJ/doaj into feature/3…
RK206 Jun 23, 2025
a7b7c36
Test steps and some fixes
RK206 Jun 24, 2025
426cfea
Fixed a testcase
RK206 Jun 24, 2025
75410dc
add UI to login page
amdomanska Jun 26, 2025
26bd685
fix layout of the code page
amdomanska Jun 26, 2025
a91d78e
fix layout in forgot
amdomanska Jun 26, 2025
277d416
add option to send email as html
amdomanska Jun 26, 2025
86ca694
make sure plaintext, md and html emails work correctly
amdomanska Jun 26, 2025
996ca01
style an email
amdomanska Jun 26, 2025
c305350
Merge branch 'develop' of https://github.com/DOAJ/doaj into feature/3…
RK206 Aug 28, 2025
d6a91ee
Encrypt the url parameters for security reasons
RK206 Sep 1, 2025
193b784
Merge branch 'develop' of https://github.com/DOAJ/doaj into feature/3…
RK206 Sep 1, 2025
d10ab45
Fix testcases with login changes
RK206 Sep 2, 2025
eb3780e
Add cryptography to required packages
RK206 Sep 2, 2025
f2aecdf
Added encryption key
RK206 Sep 11, 2025
ef414f2
Refactored the files
RK206 Sep 23, 2025
606359a
Updated account service and redirect page
RK206 Sep 23, 2025
a6c41d2
Merge branch 'develop' of https://github.com/DOAJ/doaj into feature/3…
RK206 Sep 23, 2025
60bc833
resend login code
RK206 Sep 24, 2025
8542429
Added messages to messages constants
RK206 Sep 24, 2025
5a1b522
support to send email with plain text along with html
RK206 Sep 24, 2025
e77d86b
refactor the email template code
RK206 Sep 25, 2025
acd0827
Merge branch 'develop' of https://github.com/DOAJ/doaj into feature/3…
RK206 Sep 25, 2025
57bb251
Allow user to resend code for 3 times
RK206 Sep 25, 2025
b801541
Merge branch 'develop' into feature/3942_passwordless_login
amdomanska Oct 3, 2025
a4b011a
Merge branch 'develop' of https://github.com/DOAJ/doaj into feature/3…
RK206 Nov 27, 2025
f0bdec8
changed two columns to a single column layout
RK206 Dec 3, 2025
f298a36
Merge branch 'develop' into feature/3942_passwordless_login
amdomanska Mar 10, 2026
969c24d
add ALLOW_OTHER_FIELDS to settings
amdomanska Mar 10, 2026
24ba88f
Merge branch 'feature/allow_other_fields_setting' into feature/3942_p…
amdomanska Mar 10, 2026
6dbd707
unify background at the login page and the homepage
amdomanska Mar 10, 2026
e3db073
revert __SEAMLESS_ALLOW_OTHER_FIELDS__ default value
amdomanska Mar 10, 2026
05ca240
Add blank line for code readability in seamless.py
amdomanska Mar 10, 2026
a6bc247
revert seamless setting
amdomanska Mar 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cms/sass/main.scss
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"pages/homepage",
"pages/journal-details",
"pages/journal-toc-articles",
"pages/login",
"pages/search",
"pages/sponsors",
"pages/uploadmetadata",
Expand Down
16 changes: 9 additions & 7 deletions cms/sass/pages/_homepage.scss
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
}

.secondary-nav {
margin-bottom: 25px;
margin-bottom: 1.5rem;
}

a {
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions cms/sass/pages/_login.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
2 changes: 1 addition & 1 deletion doajtest/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
44 changes: 42 additions & 2 deletions doajtest/testbook/user_management/login_and_registration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
269 changes: 269 additions & 0 deletions doajtest/unit/test_account_passwordless.py
Original file line number Diff line number Diff line change
@@ -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)
Loading