diff --git a/README.md b/README.md
index 9057696..e25f9df 100644
--- a/README.md
+++ b/README.md
@@ -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:
diff --git a/Security-audit.md b/Security-audit.md
index 85e84c4..aee7693 100644
--- a/Security-audit.md
+++ b/Security-audit.md
@@ -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.
diff --git a/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md
index 59081e3..d4b06a6 100644
--- a/VIBE/CHAT_LOG.md
+++ b/VIBE/CHAT_LOG.md
@@ -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, `emailLabel` should read `Email`.
+
+### 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)”.
diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md
index c885679..63234c3 100644
--- a/VIBE/PROMPTS.md
+++ b/VIBE/PROMPTS.md
@@ -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 emailLabel should read: Email
## Future entries
diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py
index ca5efdb..c75c0b1 100644
--- a/backend/app/api/auth.py
+++ b/backend/app/api/auth.py
@@ -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'])
diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py
index 24685e8..a4ee156 100644
--- a/backend/app/services/auth_service.py
+++ b/backend/app/services/auth_service.py
@@ -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
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 73c727b..774c989 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -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():
diff --git a/backend/tests/test_user_config.py b/backend/tests/test_user_config.py
index 92d8a53..234a308 100644
--- a/backend/tests/test_user_config.py
+++ b/backend/tests/test_user_config.py
@@ -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
diff --git a/frontend/static/login.js b/frontend/static/login.js
index 7af7529..f0dec70 100644
--- a/frontend/static/login.js
+++ b/frontend/static/login.js
@@ -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,
}),
});
diff --git a/frontend/templates/login.html b/frontend/templates/login.html
index 1408576..d706ec3 100644
--- a/frontend/templates/login.html
+++ b/frontend/templates/login.html
@@ -38,8 +38,8 @@