Login now based on email
Build LinkLog Development Image / development-image (push) Successful in 10s

This commit is contained in:
2026-08-26 15:03:48 +02:00
parent dd44b380ce
commit 079eb146f6
12 changed files with 64 additions and 40 deletions
+2 -2
View File
@@ -108,7 +108,7 @@ Every push to `main` also runs `.gitea/workflows/development.yml` and publishes
Appending a username to the root URL, such as `/alice`, opens that user's public feed and profile information.
Configuration APIs require a bearer token returned by the login endpoint. User configuration uses the identity in that token. Plugin administration additionally requires an administrator account; the development `alice` account is seeded as an administrator, while `bob` is a standard user.
Configuration APIs require a bearer token returned by the login endpoint. Users authenticate with their email address; the username remains the public presentation identity used in profiles and feed URLs. User configuration uses the identity in that token. Plugin administration additionally requires an administrator account.
Users can change their password from the profile page. The current password is required, new passwords must contain at least 8 characters, and the endpoint is `PUT /api/user/password`.
@@ -235,7 +235,7 @@ Login:
```sh
curl -X POST http://localhost:8000/api/auth/login \\
-H 'Content-Type: application/json' \\
-d '{"username":"alice","password":"secret123"}'
-d '{"email":"alice@example.com","password":"secret123"}'
```
Submit a link using the returned access token:
+1 -1
View File
@@ -194,7 +194,7 @@ These findings are prioritized below. Severity describes the potential security
## Authentication and Authorization Review
- **Authentication transport:** Bearer headers are used by most APIs, but query-string tokens remain a leakage risk. There is no cookie session, which reduces CSRF exposure for current bearer-only API calls.
- **Authentication transport:** Bearer headers are used by most APIs, but query-string tokens remain a leakage risk. There is no cookie session, which reduces CSRF exposure for current bearer-only API calls. Credentials are now email-based; usernames remain presentation identities.
- **Password policy:** New and reset passwords require at least eight characters. This is better than no policy but should be replaced with a longer passphrase-oriented policy and breached-password screening after a proper password hash migration.
- **Email verification:** New administrator-created users cannot log in until verified. The setup-created first administrator is marked verified, which is appropriate for bootstrap but should be protected by the setup controls above.
- **Password reset:** Tokens are random, hashed, expiring, single-use, and revoke existing access tokens after reset. Reset-email generation errors are intentionally swallowed to preserve generic login behavior, but this should be paired with server-side monitoring.
+18
View File
@@ -825,6 +825,24 @@ Remove `DEFAULT_BACKEND` setting in the plugin.
### Assistant outcome
Removed the hard-coded backend URL fallback from the Firefox extension. The backend URL is now empty until the user enters it, and the README setup instructions reflect that no default server is assumed.
### User
Change authentication to use email addresses rather than usernames while keeping usernames for presentation.
### Assistant outcome
Switched backend, web login, and Firefox extension login to use trimmed email addresses as credentials. Returned usernames remain available for display, profiles, feed URLs, and authenticated session presentation. Updated login callers, documentation, and tests.
### User
Make sure the web plugin follows the same logic.
### Assistant outcome
Aligned the web and Firefox plugin flows with email-based authentication, including email-form fields, trimmed email/OTP submission, and retention of the returned username for display.
### User
In the plugin, `<span data-i18n="emailLabel">emailLabel</span>` should read `<span data-i18n="emailLabel">Email</span>`.
### Assistant outcome
Changed the Firefox plugin settings email field fallback text to `Email` while retaining the existing `emailLabel` localization key.
### User
Change the title of the field “One-time password” to “One-time password (when configured)”.
+5
View File
@@ -152,6 +152,11 @@
143. Address issue 1. and improve password storage
144. The login page should ask for OTP password
145. For the new password in the user setting add a validation field to make sure they are the same before submitting
146. Change so that authentication is based on email address rather than username - maintain the username for presentation purposes
147. Make sure the web plugin follows same logic
146. Change so that authentication is based on email address rather than username - maintain the username for presentation purposes
147. Make sure the web plugin follows same logic
148. In the plugin <span data-i18n="emailLabel">emailLabel</span> should read: <span data-i18n="emailLabel">Email</span>
## Future entries
+3 -3
View File
@@ -21,7 +21,7 @@ init_db()
class LoginRequest(BaseModel):
username: str
email: str
password: str
otp: str | None = None
@@ -33,9 +33,9 @@ class PasswordResetRequest(BaseModel):
@router.post('/login')
def login(payload: LoginRequest):
user = authenticate_user(payload.username, payload.password)
user = authenticate_user(payload.email.strip(), payload.password)
if user is None:
reset_user = find_user(payload.username)
reset_user = find_user(payload.email.strip())
if reset_user and reset_user['email_verified'] and smtp_configured():
try:
token = create_reset_token(reset_user['id'])
+4 -4
View File
@@ -5,9 +5,9 @@ from datetime import datetime, timezone
from backend.app.database import get_connection, hash_password, verify_password
def authenticate_user(username: str, password: str):
def authenticate_user(email: str, password: str):
with get_connection() as conn:
row = conn.execute('SELECT * FROM users WHERE username = ?', (username,)).fetchone()
row = conn.execute('SELECT * FROM users WHERE email = ?', (email,)).fetchone()
if row is None or not verify_password(password, row['password_hash']):
return None
user = dict(row)
@@ -23,7 +23,7 @@ def authenticate_user(username: str, password: str):
return user
def find_user(username: str):
def find_user(email: str):
with get_connection() as conn:
row = conn.execute('SELECT * FROM users WHERE username = ?', (username,)).fetchone()
row = conn.execute('SELECT * FROM users WHERE email = ?', (email,)).fetchone()
return dict(row) if row else None
+8 -8
View File
@@ -22,7 +22,7 @@ client = TestClient(app)
def login_headers(username='alice'):
token = client.post('/api/auth/login', json={
'username': username,
'email': 'alice@example.com' if username == 'alice' else f'{username}@example.com',
'password': 'secret123',
}).json()['access_token']
return {'Authorization': f'Bearer {token}'}
@@ -31,7 +31,7 @@ def login_headers(username='alice'):
def test_login_returns_token():
assert app.version == '0.1.0'
response = client.post('/api/auth/login', json={
'username': 'alice',
'email': 'alice@example.com',
'password': 'secret123',
})
assert response.status_code == 200
@@ -44,7 +44,7 @@ def test_login_returns_token():
assert admin_session.json()['is_admin'] is True
user_token = client.post('/api/auth/login', json={
'username': 'bob',
'email': 'bob@example.com',
'password': 'secret123',
}).json()['access_token']
user_session = client.get('/api/auth/me', params={'token': user_token})
@@ -71,7 +71,7 @@ def test_password_hashes_are_salted_and_legacy_hashes_upgrade_on_login():
(str(uuid4()), legacy_username, f'{legacy_username}@example.com', legacy_hash),
)
conn.commit()
response = client.post('/api/auth/login', json={'username': legacy_username, 'password': 'legacy-password'})
response = client.post('/api/auth/login', json={'email': legacy_username + '@example.com', 'password': 'legacy-password'})
assert response.status_code == 200
with get_connection() as conn:
upgraded = conn.execute('SELECT password_hash FROM users WHERE username = ?', (legacy_username,)).fetchone()['password_hash']
@@ -203,7 +203,7 @@ def test_new_user_must_verify_email_before_login():
assert created.status_code == 201
assert created.json()['email_verified'] is False
login = client.post('/api/auth/login', json={'username': username, 'password': 'secret123'})
login = client.post('/api/auth/login', json={'email': f'{username}@example.com', 'password': 'secret123'})
assert login.status_code == 403
assert login.json()['detail'] == 'Email address is not verified'
@@ -223,7 +223,7 @@ def test_email_verification_link_enables_login():
verified = client.get('/api/auth/verify-email', params={'token': verification_token})
assert verified.status_code == 200
assert client.post('/api/auth/login', json={'username': username, 'password': 'secret123'}).status_code == 200
assert client.post('/api/auth/login', json={'email': f'{username}@example.com', 'password': 'secret123'}).status_code == 200
assert client.get('/api/auth/verify-email', params={'token': verification_token}).status_code == 400
@@ -242,7 +242,7 @@ def test_mistyped_password_sends_reset_link_without_changing_login_error():
with patch('backend.app.api.auth.smtp_configured', return_value=True), \
patch('backend.app.api.auth.create_reset_token', return_value='reset-token') as create_token, \
patch('backend.app.api.auth.send_password_reset_email') as send_email:
response = client.post('/api/auth/login', json={'username': username, 'password': 'wrong-password'})
response = client.post('/api/auth/login', json={'email': f'{username}@example.com', 'password': 'wrong-password'})
assert response.status_code == 401
assert response.json()['detail'] == 'Invalid username or password'
@@ -267,7 +267,7 @@ def test_password_reset_is_single_use_and_revokes_sessions():
reset = client.post('/api/auth/reset-password', json={'token': token, 'password': 'new-secret123'})
assert reset.status_code == 200
assert client.post('/api/auth/reset-password', json={'token': token, 'password': 'another-secret'}).status_code == 400
assert client.post('/api/auth/login', json={'username': username, 'password': 'new-secret123'}).status_code == 200
assert client.post('/api/auth/login', json={'email': f'{username}@example.com', 'password': 'new-secret123'}).status_code == 200
def test_admin_can_remove_user_with_owned_data():
+6 -6
View File
@@ -12,7 +12,7 @@ client = TestClient(app)
def test_user_config_api_and_profile_page():
login = client.post('/api/auth/login', json={
'username': 'alice',
'email': 'alice@example.com',
'password': 'secret123',
}).json()
headers = {'Authorization': f"Bearer {login['access_token']}"}
@@ -47,7 +47,7 @@ def test_user_config_api_and_profile_page():
assert 'name="new_password_confirmation"' in page_response.text
bob_login = client.post('/api/auth/login', json={
'username': 'bob',
'email': 'bob@example.com',
'password': 'secret123',
}).json()
bob_headers = {'Authorization': f"Bearer {bob_login['access_token']}"}
@@ -57,7 +57,7 @@ def test_user_config_api_and_profile_page():
}, headers=bob_headers)
assert password_response.status_code == 200
assert client.post('/api/auth/login', json={
'username': 'bob',
'email': 'bob@example.com',
'password': 'new-secret-123',
}).status_code == 200
assert client.put('/api/user/password', json={
@@ -80,7 +80,7 @@ def test_user_config_api_and_profile_page():
def test_user_can_enable_and_use_otp():
login = client.post('/api/auth/login', json={'username': 'alice', 'password': 'secret123'}).json()
login = client.post('/api/auth/login', json={'email': 'alice@example.com', 'password': 'secret123'}).json()
headers = {'Authorization': f"Bearer {login['access_token']}"}
setup = client.post('/api/user/otp/setup', headers=headers)
assert setup.status_code == 200
@@ -92,10 +92,10 @@ def test_user_can_enable_and_use_otp():
})
assert enabled.status_code == 200
assert enabled.json()['enabled'] is True
assert client.post('/api/auth/login', json={'username': 'alice', 'password': 'secret123'}).status_code == 401
assert client.post('/api/auth/login', json={'email': 'alice@example.com', 'password': 'secret123'}).status_code == 401
otp_login = client.post('/api/auth/login', json={
'username': 'alice', 'password': 'secret123', 'otp': current_code(secret),
'email': 'alice@example.com', 'password': 'secret123', 'otp': current_code(secret),
})
assert otp_login.status_code == 200
+3 -3
View File
@@ -3,7 +3,6 @@
const form = document.querySelector('#login-form');
const status = document.querySelector('#login-status');
const otpInput = document.querySelector('#otp');
form.addEventListener('submit', async (event) => {
event.preventDefault();
@@ -14,8 +13,9 @@ form.addEventListener('submit', async (event) => {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
...Object.fromEntries(new FormData(form)),
otp: otpInput.value.trim() || null,
email: form.elements.email.value.trim(),
password: form.elements.password.value,
otp: form.elements.otp.value.trim() || null,
}),
});
+2 -2
View File
@@ -38,8 +38,8 @@
<section class="link-item settings-panel">
<form id="login-form">
<label>
Username
<input id="username" name="username" type="text" autocomplete="username" required />
Email address
<input id="email" name="email" type="email" autocomplete="username" required />
</label>
<label>
Password
+2 -2
View File
@@ -28,8 +28,8 @@
</label>
<label>
<span data-i18n="usernameLabel">Username</span>
<input id="username" type="text" data-i18n-placeholder="usernamePlaceholder" placeholder="alice" />
<span data-i18n="emailLabel">Email</span>
<input id="email" type="email" data-i18n-placeholder="emailPlaceholder" placeholder="alice@example.com" />
</label>
<label>
+10 -9
View File
@@ -4,7 +4,7 @@
const statusEl = document.getElementById('status');
const form = document.getElementById('settings-form');
const backendUrlInput = document.getElementById('backend-url');
const usernameInput = document.getElementById('username');
const emailInput = document.getElementById('email');
const passwordInput = document.getElementById('password');
const otpInput = document.getElementById('otp');
const session = document.getElementById('logged-in');
@@ -21,9 +21,9 @@ function setStatus(message, isError = false) {
}
async function loadSettings() {
const settings = await browser.storage.local.get(['backendUrl', 'username', 'accessToken']);
const settings = await browser.storage.local.get(['backendUrl', 'email', 'username', 'accessToken']);
backendUrlInput.value = settings.backendUrl || '';
usernameInput.value = settings.username || '';
emailInput.value = settings.email || '';
if (settings.accessToken && settings.backendUrl) {
try {
@@ -32,7 +32,7 @@ async function loadSettings() {
);
if (response.ok) {
const user = await response.json();
showLoggedIn(user.username || settings.username, settings.backendUrl);
showLoggedIn(user.username || settings.email, settings.backendUrl);
return;
}
} catch (error) {
@@ -61,11 +61,11 @@ async function clearSession() {
async function saveSettingsAndLogin(event) {
event.preventDefault();
const backendUrl = backendUrlInput.value.trim();
const username = usernameInput.value.trim();
const email = emailInput.value.trim();
const password = passwordInput.value;
const otp = otpInput.value.trim();
if (!backendUrl || !username || !password) {
if (!backendUrl || !email || !password) {
setStatus(t('fillAllFields'), true);
return;
}
@@ -74,7 +74,7 @@ async function saveSettingsAndLogin(event) {
const response = await fetch(`${backendUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, otp: otp || null })
body: JSON.stringify({ email, password, otp: otp || null })
});
if (!response.ok) {
@@ -84,14 +84,15 @@ async function saveSettingsAndLogin(event) {
const data = await response.json();
await browser.storage.local.set({
backendUrl,
username,
email,
username: data.user?.username || email,
accessToken: data.access_token,
tokenType: data.token_type,
tokenExpiresAt: data.expires_at,
refreshToken: data.refresh_token,
});
showLoggedIn(data.user?.username || username, backendUrl);
showLoggedIn(data.user?.username || email, backendUrl);
passwordInput.value = '';
otpInput.value = '';
setStatus(t('loggedInSuccessfully'));