Passwords stored salt and some change is password logic
This commit is contained in:
@@ -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
@@ -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')
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user