Login now based on email
Build LinkLog Development Image / development-image (push) Successful in 10s
Build LinkLog Development Image / development-image (push) Successful in 10s
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)”.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user