Passwords stored salt and some change is password logic

This commit is contained in:
2026-08-26 14:51:40 +02:00
parent b3383e29a7
commit dd44b380ce
12 changed files with 374 additions and 18 deletions
+2 -2
View File
@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from pydantic import BaseModel
from backend.app.api.dependencies import get_current_user
from backend.app.database import AVATARS_DIR, get_connection, hash_password
from backend.app.database import AVATARS_DIR, get_connection, hash_password, verify_password
from backend.app.services.link_service import create_label, delete_label, list_user_labels, update_label
from backend.app.services.otp_service import create_secret, provisioning_uri, verify_code
@@ -86,7 +86,7 @@ def update_current_user_profile(
def update_password(payload: PasswordUpdate, user: dict = Depends(get_current_user)):
if len(payload.new_password) < 8:
raise HTTPException(status_code=422, detail='New password must be at least 8 characters')
if hash_password(payload.current_password) != user['password_hash']:
if not verify_password(payload.current_password, user['password_hash']):
raise HTTPException(status_code=400, detail='Current password is incorrect')
with get_connection() as conn:
+24 -2
View File
@@ -3,7 +3,8 @@
import sqlite3
import os
from hashlib import sha256
from hashlib import scrypt, sha256
import hmac
from pathlib import Path
from uuid import uuid4
@@ -15,7 +16,28 @@ AVATARS_DIR.mkdir(parents=True, exist_ok=True)
def hash_password(password: str) -> str:
return sha256(password.encode('utf-8')).hexdigest()
salt = os.urandom(16)
digest = scrypt(password.encode('utf-8'), salt=salt, n=16_384, r=8, p=1, dklen=32)
return f'scrypt$16384$8$1${salt.hex()}${digest.hex()}'
def verify_password(password: str, stored_hash: str) -> bool:
if stored_hash.startswith('scrypt$'):
try:
algorithm, cost, block_size, parallelism, salt_hex, digest_hex = stored_hash.split('$')
if algorithm != 'scrypt':
return False
digest = scrypt(
password.encode('utf-8'), salt=bytes.fromhex(salt_hex),
n=int(cost), r=int(block_size), p=int(parallelism), dklen=32,
)
return hmac.compare_digest(digest.hex(), digest_hex)
except (ValueError, TypeError):
return False
if len(stored_hash) == 64:
legacy_digest = sha256(password.encode('utf-8')).hexdigest()
return hmac.compare_digest(legacy_digest, stored_hash)
return False
DEFAULT_TAGS = ('#Internet', '#Cybersecurity', '#Fediverse', '#Food', '#Photography', '#Music', '#AI')
+15 -13
View File
@@ -2,23 +2,25 @@
## SPDX-License-Identifier: GPL-3.0-or-later
from datetime import datetime, timezone
from hashlib import sha256
from backend.app.database import get_connection
def hash_password(password: str) -> str:
return sha256(password.encode('utf-8')).hexdigest()
from backend.app.database import get_connection, hash_password, verify_password
def authenticate_user(username: str, password: str):
password_hash = hash_password(password)
with get_connection() as conn:
row = conn.execute(
'SELECT * FROM users WHERE username = ? AND password_hash = ?',
(username, password_hash),
).fetchone()
return dict(row) if row else None
row = conn.execute('SELECT * FROM users WHERE username = ?', (username,)).fetchone()
if row is None or not verify_password(password, row['password_hash']):
return None
user = dict(row)
if not row['password_hash'].startswith('scrypt$'):
conn.execute(
'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
(hash_password(password), row['id']),
)
conn.commit()
user['password_hash'] = conn.execute(
'SELECT password_hash FROM users WHERE id = ?', (row['id'],)
).fetchone()['password_hash']
return user
def find_user(username: str):
+27
View File
@@ -52,6 +52,32 @@ def test_login_returns_token():
assert user_session.json()['is_admin'] is False
def test_password_hashes_are_salted_and_legacy_hashes_upgrade_on_login():
from hashlib import sha256
from backend.app.database import hash_password
first = hash_password('same-password')
second = hash_password('same-password')
assert first.startswith('scrypt$16384$8$1$')
assert first != second
legacy_username = f'legacy-{uuid4().hex}'
legacy_hash = sha256('legacy-password'.encode('utf-8')).hexdigest()
with get_connection() as conn:
conn.execute(
'''INSERT INTO users
(id, username, email, password_hash, is_admin, email_verified)
VALUES (?, ?, ?, ?, 0, 1)''',
(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'})
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']
assert upgraded.startswith('scrypt$16384$8$1$')
def test_configuration_requires_authentication_and_admin_role():
assert client.get('/api/user/me').status_code == 401
assert client.get('/api/admin/plugins').status_code == 401
@@ -515,6 +541,7 @@ def test_public_and_admin_pages_render_html():
assert client.get('/login').status_code == 200
login_page = client.get('/login').text
assert 'Sign in' in login_page
assert 'name="otp"' in login_page
assert 'src="/static/logo.svg"' in login_page
assert 'id="auth-session" class="auth-session hidden"' in login_page
assert 'logout.js?v=3' in login_page
+1
View File
@@ -44,6 +44,7 @@ def test_user_config_api_and_profile_page():
assert 'id="auth-profile-link" class="hidden"' in page_response.text
assert '<a id="auth-username" class="user-name" href="/">' in page_response.text
assert 'id="auth-avatar"' not in page_response.text
assert 'name="new_password_confirmation"' in page_response.text
bob_login = client.post('/api/auth/login', json={
'username': 'bob',