Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion server/mergin/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,9 @@ def create_app(public_keys: List[str] = None) -> Flask:
# adjust login manager
@login_manager.user_loader
def load_user(user_id): # pylint: disable=W0613,W0612
return User.query.get(user_id)
user = User.query.get(user_id)
if user and user.active:
return user

@login_manager.header_loader
def load_user_from_header(header_val): # pylint: disable=W0613,W0612
Expand Down
8 changes: 8 additions & 0 deletions server/mergin/auth/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,8 @@ paths:
$ref: "#/components/responses/BadStatusResp"
"401":
$ref: "#/components/responses/UnauthorizedError"
"423":
$ref: "#/components/responses/LockedResp"
/app/auth/logout:
get:
summary: Logout
Expand Down Expand Up @@ -617,6 +619,8 @@ paths:
$ref: "#/components/responses/NotFoundResp"
"415":
$ref: "#/components/responses/UnsupportedMediaType"
"423":
$ref: "#/components/responses/LockedResp"
x-openapi-router-controller: mergin.auth.controller
/app/admin/login:
post:
Expand Down Expand Up @@ -646,6 +650,8 @@ paths:
$ref: "#/components/responses/UnauthorizedError"
"403":
$ref: "#/components/responses/Forbidden"
"423":
$ref: "#/components/responses/LockedResp"
/v2/users:
post:
tags:
Expand Down Expand Up @@ -718,6 +724,8 @@ components:
description: Request could not be processed becuase of conflict in resources
UnprocessableEntity:
description: Request was correct and yet server could not process it
LockedResp:
description: Account is temporarily locked due to too many failed login attempts.
NoContent:
description: Success. No content returned.
schemas:
Expand Down
28 changes: 26 additions & 2 deletions server/mergin/auth/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .commands import add_commands
from .config import Configuration
from .models import User
from .errors import AccountLockedError

# signal for other versions to listen to
user_account_closed = signal("user_account_closed")
Expand Down Expand Up @@ -61,7 +62,11 @@ def auth_required(f=None, permissions=None):

@functools.wraps(f)
def wrapped_func(*args, **kwargs):
if not current_user or not current_user.is_authenticated:
if (
not current_user
or not current_user.is_authenticated
or not current_user.is_active
):
return "Authentication information is missing or invalid.", 401
if permissions:
for check_permission in permissions:
Expand All @@ -87,13 +92,32 @@ def wrapped_func(*args, **kwargs):


def authenticate(login, password):
from ..app import db

if "@" in login:
query = func.lower(User.email) == func.lower(login)
else:
query = func.lower(User.username) == func.lower(login)
user = User.query.filter(query).one_or_none()
if user and user.check_password(password):
if user is None:
return None
needs_commit = False
if user.is_locked_out():
raise AccountLockedError(user.locked_until)
if user.check_password(password):
if user.failed_login_attempts or user.locked_until:
user.reset_lockout()
needs_commit = True
if user.needs_rehash():
user.assign_password(password)
needs_commit = True
if needs_commit:
db.session.commit()
return user
else:
Comment thread
MarcelGeo marked this conversation as resolved.
user.record_failed_login()
db.session.commit()
return None


def generate_confirmation_token(app, email, salt):
Expand Down
3 changes: 3 additions & 0 deletions server/mergin/auth/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@ class Configuration(object):
"BEARER_TOKEN_EXPIRATION", default=3600 * 12, cast=int
) # in seconds
ACCOUNT_EXPIRATION = config("ACCOUNT_EXPIRATION", default=5, cast=int) # in days
BCRYPT_LOG_ROUNDS = config("BCRYPT_LOG_ROUNDS", default=12, cast=int)
# Comma-separated "attempts:seconds" pairs, e.g. "5:300,10:3600"
LOCKOUT_POLICY = config("LOCKOUT_POLICY", default="5:300,10:3600")
16 changes: 13 additions & 3 deletions server/mergin/auth/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
)
from .bearer import encode_token
from .models import User, LoginHistory
from .errors import AccountLockedError
from .schemas import UserSchema, UserSearchSchema, UserProfileSchema, UserInfoSchema
from .forms import (
LoginForm,
Expand Down Expand Up @@ -137,7 +138,10 @@ def login_public(): # noqa: E501
"""
form = ApiLoginForm()
if form.validate():
user = authenticate(form.login.data, form.password.data)
try:
user = authenticate(form.login.data, form.password.data)
except AccountLockedError as e:
return e.response(423)
if user and user.active:
expire = datetime.now(pytz.utc) + timedelta(
seconds=current_app.config["BEARER_TOKEN_EXPIRATION"]
Expand Down Expand Up @@ -221,7 +225,10 @@ def search_users(): # pylint: disable=W0613,W0612
def login(): # pylint: disable=W0613,W0612
form = LoginForm()
if form.validate():
user = authenticate(form.login.data, form.password.data)
try:
user = authenticate(form.login.data, form.password.data)
except AccountLockedError as e:
return e.response(423)
if user and user.active:
login_user(user)
if not os.path.isfile(current_app.config["MAINTENANCE_FILE"]):
Expand All @@ -238,7 +245,10 @@ def admin_login(): # pylint: disable=W0613,W0612
if not form.validate():
return jsonify(form.errors), 400

user = authenticate(form.login.data, form.password.data)
try:
user = authenticate(form.login.data, form.password.data)
except AccountLockedError as e:
abort(423, f"Account temporarily locked until {e.locked_until.isoformat()}")
Comment thread
varmar05 marked this conversation as resolved.
Outdated
if user:
if user.active and user.is_admin:
login_user(user)
Expand Down
21 changes: 21 additions & 0 deletions server/mergin/auth/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Copyright (C) Lutra Consulting Limited
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial

import datetime
from typing import Dict

from ..app import ResponseError


class AccountLockedError(Exception, ResponseError):
code = "AccountLocked"
detail = "Account temporarily locked due to too many failed login attempts"

def __init__(self, locked_until: datetime.datetime):
self.locked_until = locked_until

def to_dict(self) -> Dict:
data = super().to_dict()
data["locked_until"] = self.locked_until.isoformat()
Comment thread
varmar05 marked this conversation as resolved.
Outdated
return data
62 changes: 61 additions & 1 deletion server/mergin/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,20 @@
from ..app import db
from ..sync.models import ProjectUser
from ..sync.utils import get_user_agent, get_ip, get_device_id, is_reserved_word
from .errors import AccountLockedError
Comment thread
varmar05 marked this conversation as resolved.
Outdated

MAX_USERNAME_LENGTH = 50


def _parse_lockout_policy(policy_str: str) -> list:
"""Parse "5:300,10:3600" into [(5, 300), (10, 3600)] sorted ascending by threshold."""
result = []
for part in policy_str.split(","):
threshold, seconds = part.strip().split(":")
result.append((int(threshold), int(seconds)))
return sorted(result, key=lambda x: x[0])


class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), info={"label": "Username"})
Expand All @@ -33,6 +43,10 @@ class User(db.Model):
default=datetime.datetime.utcnow,
)
last_signed_in = db.Column(db.DateTime(), nullable=True)
failed_login_attempts = db.Column(
db.Integer, default=0, nullable=False, server_default="0"
)
locked_until = db.Column(db.DateTime(), nullable=True)
receive_notifications = db.Column(
db.Boolean, default=True, nullable=False, index=True
)
Expand Down Expand Up @@ -64,12 +78,57 @@ def check_password(self, password):
def assign_password(self, password):
if isinstance(password, str):
password = password.encode("utf-8")
rounds = current_app.config.get("BCRYPT_LOG_ROUNDS", 12)
self.passwd = (
bcrypt.hashpw(password, bcrypt.gensalt()).decode("utf-8")
bcrypt.hashpw(password, bcrypt.gensalt(rounds)).decode("utf-8")
if password
else None
)

def needs_rehash(self):
"""Return True if the stored hash was generated with a different cost factor than configured."""
if self.passwd is None:
return False
rounds = current_app.config.get("BCRYPT_LOG_ROUNDS", 12)
try:
# bcrypt hash format: $2b$<rounds>$<salt+hash>
hash_rounds = int(self.passwd.split("$")[2])
return hash_rounds != rounds
Comment thread
varmar05 marked this conversation as resolved.
Outdated
except (IndexError, ValueError):
return False

def is_locked_out(self) -> bool:
"""Return True if the account is currently under a temporary lockout."""
if self.locked_until is None:
return False
now = datetime.datetime.utcnow()
if self.locked_until <= now:
# lockout has expired — clear it so subsequent queries see a clean state
self.locked_until = None
Comment thread
varmar05 marked this conversation as resolved.
Outdated
return False
return True

def record_failed_login(self) -> None:
"""Increment the failed-login counter and apply a lockout if a threshold is crossed."""
self.failed_login_attempts = (self.failed_login_attempts or 0) + 1
policy = _parse_lockout_policy(
current_app.config.get("LOCKOUT_POLICY", "5:300,10:3600")
)
# find the highest applicable tier
duration = None
for threshold, seconds in policy:
if self.failed_login_attempts >= threshold:
duration = seconds
if duration is not None:
self.locked_until = datetime.datetime.utcnow() + datetime.timedelta(
seconds=duration
)

def reset_lockout(self) -> None:
"""Clear lockout state after a successful login."""
self.failed_login_attempts = 0
self.locked_until = None

@property
def is_authenticated(self):
"""For Flask-Login"""
Expand Down Expand Up @@ -185,6 +244,7 @@ def anonymize(self):
"""Anonymize user object in database - remove personal information"""
ts = round(datetime.datetime.utcnow().timestamp() * 1000)
del_str = f"deleted_{ts}"
self.active = False
self.username = del_str
self.email = None
self.passwd = None
Expand Down
111 changes: 110 additions & 1 deletion server/mergin/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,114 @@ def test_logout(client):
assert resp.status_code == 200


def test_login_lockout(client):
"""Test account lockout: progressive tiers, freeze during lock, reset on success.

policy: 3 failures → 60s lock, 4 failures → 3600s lock
counter is never reset between lockouts, so tier-2 is reached after one
extra failure following the first expired tier-1 lock
"""
client.application.config["LOCKOUT_POLICY"] = "3:60,4:3600"
user = add_user("lockoutuser", "correctpassword")

def assert_locked():
resp = client.post(
url_for("/.mergin_auth_controller_login"),
json={"login": "lockoutuser", "password": "wrong"},
)
assert resp.status_code == 423
assert resp.json["code"] == "AccountLocked"
assert "locked_until" in resp.json

# tier 1: 3 failures → 60s lock
for _ in range(3):
resp = client.post(
url_for("/.mergin_auth_controller_login"),
json={"login": "lockoutuser", "password": "wrong"},
)
assert resp.status_code == 401

assert_locked()

# correct password is also blocked while locked
resp = client.post(
url_for("/.mergin_auth_controller_login"),
json={"login": "lockoutuser", "password": "correctpassword"},
)
assert resp.status_code == 423

# counter stays frozen during lockout
assert user.failed_login_attempts == 3
assert user.locked_until is not None

# tier 2 escalation: one more failure after tier-1 expiry
# counter was at 3; one new failure pushes it to 4, crossing tier-2 threshold

# expire_lock
user.locked_until = datetime.utcnow() - timedelta(seconds=1)
db.session.commit()

resp = client.post(
url_for("/.mergin_auth_controller_login"),
json={"login": "lockoutuser", "password": "wrong"},
)
# returns 401 (wrong password), but now locked for 3600s
assert resp.status_code == 401
assert_locked()
assert user.locked_until > datetime.utcnow() + timedelta(seconds=60)
assert user.failed_login_attempts == 4

# successful login after expiry resets everything
user.locked_until = datetime.utcnow() - timedelta(seconds=1)
db.session.commit()
resp = client.post(
url_for("/.mergin_auth_controller_login"),
json={"login": "lockoutuser", "password": "correctpassword"},
)
assert resp.status_code == 200
assert user.failed_login_attempts == 0
assert user.locked_until is None


def test_bcrypt_lazy_rehash(app):
"""Password is transparently rehashed on login when the cost factor changes."""
import bcrypt
from ..auth.app import authenticate

user = add_user("rehashuser", "rehashpassword")
# Store a hash with a low cost factor (4 is the minimum bcrypt allows)
low_rounds_hash = bcrypt.hashpw(b"rehashpassword", bcrypt.gensalt(4)).decode(
"utf-8"
)
user.passwd = low_rounds_hash
db.session.commit()

app.config["BCRYPT_LOG_ROUNDS"] = 5
result = authenticate("rehashuser", "rehashpassword")
assert result is not None

db.session.refresh(user)
hash_rounds = int(user.passwd.split("$")[2])
assert hash_rounds == 5


def test_deactivated_user_session_rejected(client):
"""Session cookie for a deactivated account must be rejected."""
user = add_user("testdeactivate", "testpassword")
login(client, "testdeactivate", "testpassword")

# session works before deactivation
resp = client.get(f"/v1/user/{user.username}")
assert resp.status_code == 200

user.active = False
db.session.commit()

# same session must now be rejected
resp = client.get(f"/v1/user/{user.username}")
assert resp.status_code == 401


# user registration tests
test_user_reg_data = [
("test@test.com", "#pwd1234", 201), # success
Expand Down Expand Up @@ -469,7 +577,8 @@ def test_update_user(client):
data=json.dumps(data),
headers=json_headers,
)
assert resp.status_code == 403
# user is deactivated, so session is rejected before permission check
assert resp.status_code == 401


def test_update_user_profile(client):
Expand Down
Loading
Loading