feat: harden single-user account authentication
This commit is contained in:
@@ -21,16 +21,22 @@ cookie = APIKeyCookie(name=COOKIE, auto_error=False, scheme_name="AccountSession
|
||||
def password_hash(password: str) -> str:
|
||||
salt = secrets.token_hex(16)
|
||||
digest = hashlib.scrypt(
|
||||
password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1
|
||||
password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=5
|
||||
).hex()
|
||||
return f"scrypt${salt}${digest}"
|
||||
return f"scrypt-v2${salt}${digest}"
|
||||
|
||||
|
||||
def verify_password(password: str, encoded: str) -> bool:
|
||||
try:
|
||||
_, salt, expected = encoded.split("$")
|
||||
algorithm, salt, expected = encoded.split("$")
|
||||
if algorithm not in {"scrypt", "scrypt-v2"}:
|
||||
return False
|
||||
actual = hashlib.scrypt(
|
||||
password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1
|
||||
password.encode(),
|
||||
salt=bytes.fromhex(salt),
|
||||
n=16384,
|
||||
r=8,
|
||||
p=5 if algorithm == "scrypt-v2" else 1,
|
||||
).hex()
|
||||
return hmac.compare_digest(actual, expected)
|
||||
except (ValueError, TypeError):
|
||||
@@ -57,6 +63,7 @@ def seed_accounts(db, config_dir: Path):
|
||||
db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS users (username TEXT PRIMARY KEY, password_hash TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1)"
|
||||
)
|
||||
db.execute("CREATE UNIQUE INDEX IF NOT EXISTS single_browser_account ON users((1))")
|
||||
db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, username TEXT NOT NULL, csrf TEXT NOT NULL, expires_at REAL NOT NULL)"
|
||||
)
|
||||
@@ -65,7 +72,13 @@ def seed_accounts(db, config_dir: Path):
|
||||
)
|
||||
path = config_dir / "accounts.json"
|
||||
if path.exists():
|
||||
for user in json.loads(path.read_text()).get("users", []):
|
||||
users = json.loads(path.read_text()).get("users", [])
|
||||
if len(users) > 1:
|
||||
raise ValueError("Only one browser account is supported")
|
||||
for user in users:
|
||||
existing = db.one("SELECT username FROM users LIMIT 1")
|
||||
if existing and existing["username"] != user["username"]:
|
||||
raise ValueError("A browser account already exists")
|
||||
db.execute(
|
||||
"INSERT OR IGNORE INTO users VALUES(?,?,1)",
|
||||
(user["username"], user["password_hash"]),
|
||||
@@ -83,7 +96,7 @@ def session_user(request: Request):
|
||||
)
|
||||
if not row or not row["enabled"] or row["expires_at"] <= time.time():
|
||||
return None
|
||||
if request.method in {"POST", "PUT", "DELETE"}:
|
||||
if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
|
||||
if not hmac.compare_digest(
|
||||
request.headers.get("X-CSRF-Token", ""), row["csrf"]
|
||||
):
|
||||
@@ -94,60 +107,88 @@ def session_user(request: Request):
|
||||
def install_accounts(app, db):
|
||||
@app.post("/api/v1/auth/login", response_model=AccountView, tags=["Accounts"])
|
||||
def login(payload: Login, request: Request, response: Response):
|
||||
identity = hashlib.sha256(
|
||||
(request.client.host if request.client else "unknown").encode()
|
||||
).hexdigest()
|
||||
attempt = db.one("SELECT * FROM login_attempts WHERE identity=?", (identity,))
|
||||
if attempt and attempt["blocked_until"] > time.time():
|
||||
raise FlowError(
|
||||
E.RATE_LIMITED, "Too many login attempts; try again later", 429
|
||||
with db.lock:
|
||||
# Reject login CSRF before checking credentials. Never trust forwarded hosts.
|
||||
origin = request.headers.get("origin")
|
||||
if request.headers.get("sec-fetch-site") == "cross-site" or (
|
||||
origin and origin != str(request.base_url).rstrip("/")
|
||||
):
|
||||
raise FlowError(E.FORBIDDEN, "Cross-origin login is not allowed", 403)
|
||||
identity = hashlib.sha256(
|
||||
(request.client.host if request.client else "unknown").encode()
|
||||
).hexdigest()
|
||||
# Account throttling also applies when an attacker changes source IPs.
|
||||
identities = (
|
||||
identity,
|
||||
"account:" + hashlib.sha256(payload.username.encode()).hexdigest(),
|
||||
)
|
||||
row = db.one(
|
||||
"SELECT * FROM users WHERE username=? AND enabled=1", (payload.username,)
|
||||
)
|
||||
# Perform a password calculation even for unknown usernames.
|
||||
fallback = "scrypt$" + "00" * 16 + "$" + "00" * 64
|
||||
valid = verify_password(
|
||||
payload.password, row["password_hash"] if row else fallback
|
||||
)
|
||||
if not row or not valid:
|
||||
failures = (
|
||||
attempt["failures"] if attempt and attempt["blocked_until"] == 0 else 0
|
||||
) + 1
|
||||
attempts = [
|
||||
db.one("SELECT * FROM login_attempts WHERE identity=?", (key,))
|
||||
for key in identities
|
||||
]
|
||||
attempt = next(
|
||||
(a for a in attempts if a and a["blocked_until"] > time.time()), None
|
||||
)
|
||||
if attempt and attempt["blocked_until"] > time.time():
|
||||
raise FlowError(
|
||||
E.RATE_LIMITED, "Too many login attempts; try again later", 429
|
||||
)
|
||||
row = db.one(
|
||||
"SELECT * FROM users WHERE username=? AND enabled=1",
|
||||
(payload.username,),
|
||||
)
|
||||
# Perform a password calculation even for unknown usernames.
|
||||
fallback = "scrypt-v2$" + "00" * 16 + "$" + "00" * 64
|
||||
valid = verify_password(
|
||||
payload.password, row["password_hash"] if row else fallback
|
||||
)
|
||||
if not row or not valid:
|
||||
for key, previous in zip(identities, attempts, strict=True):
|
||||
failures = (
|
||||
previous["failures"]
|
||||
if previous and previous["blocked_until"] == 0
|
||||
else 0
|
||||
) + 1
|
||||
db.execute(
|
||||
"INSERT OR REPLACE INTO login_attempts VALUES(?,?,?)",
|
||||
(key, failures, time.time() + 300 if failures >= 5 else 0),
|
||||
)
|
||||
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401)
|
||||
for key in identities:
|
||||
db.execute("DELETE FROM login_attempts WHERE identity=?", (key,))
|
||||
if row["password_hash"].startswith("scrypt$"):
|
||||
db.execute(
|
||||
"UPDATE users SET password_hash=? WHERE username=?",
|
||||
(password_hash(payload.password), row["username"]),
|
||||
)
|
||||
raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
|
||||
lifetime = 30 * 86400 if payload.remember else 12 * 3600
|
||||
db.execute("DELETE FROM sessions WHERE expires_at<=?", (time.time(),))
|
||||
old = request.cookies.get(COOKIE)
|
||||
if old:
|
||||
db.execute(
|
||||
"DELETE FROM sessions WHERE token_hash=?",
|
||||
(hashlib.sha256(old.encode()).hexdigest(),),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT OR REPLACE INTO login_attempts VALUES(?,?,?)",
|
||||
(identity, failures, time.time() + 300 if failures >= 5 else 0),
|
||||
"INSERT INTO sessions VALUES(?,?,?,?)",
|
||||
(
|
||||
hashlib.sha256(raw.encode()).hexdigest(),
|
||||
row["username"],
|
||||
csrf,
|
||||
time.time() + lifetime,
|
||||
),
|
||||
)
|
||||
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401)
|
||||
db.execute("DELETE FROM login_attempts WHERE identity=?", (identity,))
|
||||
raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
|
||||
lifetime = 30 * 86400 if payload.remember else 12 * 3600
|
||||
db.execute("DELETE FROM sessions WHERE expires_at<=?", (time.time(),))
|
||||
old = request.cookies.get(COOKIE)
|
||||
if old:
|
||||
db.execute(
|
||||
"DELETE FROM sessions WHERE token_hash=?",
|
||||
(hashlib.sha256(old.encode()).hexdigest(),),
|
||||
response.set_cookie(
|
||||
COOKIE,
|
||||
raw,
|
||||
max_age=lifetime if payload.remember else None,
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
secure=request.url.scheme == "https",
|
||||
path="/",
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO sessions VALUES(?,?,?,?)",
|
||||
(
|
||||
hashlib.sha256(raw.encode()).hexdigest(),
|
||||
row["username"],
|
||||
csrf,
|
||||
time.time() + lifetime,
|
||||
),
|
||||
)
|
||||
response.set_cookie(
|
||||
COOKIE,
|
||||
raw,
|
||||
max_age=lifetime if payload.remember else None,
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
secure=request.url.scheme == "https",
|
||||
path="/",
|
||||
)
|
||||
return AccountView(username=row["username"], csrf_token=csrf)
|
||||
return AccountView(username=row["username"], csrf_token=csrf)
|
||||
|
||||
def current(request):
|
||||
row = session_user(request)
|
||||
@@ -187,9 +228,12 @@ def install_accounts(app, db):
|
||||
user = db.one("SELECT * FROM users WHERE username=?", (session["username"],))
|
||||
if not verify_password(payload.current_password, user["password_hash"]):
|
||||
raise FlowError(E.UNAUTHORIZED, "Current password is incorrect", 401)
|
||||
db.execute(
|
||||
"UPDATE users SET password_hash=? WHERE username=?",
|
||||
(password_hash(payload.new_password), session["username"]),
|
||||
)
|
||||
db.execute("DELETE FROM sessions WHERE username=?", (session["username"],))
|
||||
with db.transaction() as connection:
|
||||
connection.execute(
|
||||
"UPDATE users SET password_hash=? WHERE username=?",
|
||||
(password_hash(payload.new_password), session["username"]),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM sessions WHERE username=?", (session["username"],)
|
||||
)
|
||||
response.delete_cookie(COOKIE, path="/")
|
||||
|
||||
Reference in New Issue
Block a user