Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions deployment/community/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ SECURITY_EMAIL_SALT=fixme
#SECURITY_PASSWORD_SALT=NODEFAULT
SECURITY_PASSWORD_SALT=fixme

#SECURITY_UNLOCK_SALT=NODEFAULT
SECURITY_UNLOCK_SALT=fixme

#WTF_CSRF_ENABLED=True

#WTF_CSRF_TIME_LIMIT=3600 * 24 # in seconds
Expand Down
3 changes: 3 additions & 0 deletions deployment/enterprise/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ SECURITY_EMAIL_SALT=fixme
#SECURITY_PASSWORD_SALT=NODEFAULT
SECURITY_PASSWORD_SALT=fixme

#SECURITY_UNLOCK_SALT=NODEFAULT
SECURITY_UNLOCK_SALT=fixme

#WTF_CSRF_ENABLED=True

#WTF_CSRF_TIME_LIMIT=3600 * 24 # in seconds
Expand Down
1 change: 1 addition & 0 deletions server/.test.env
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ GEODIFF_WORKING_DIR=/tmp/geodiff
SECURITY_BEARER_SALT='bearer'
SECURITY_EMAIL_SALT='email'
SECURITY_PASSWORD_SALT='password'
SECURITY_UNLOCK_SALT='unlock'
DIAGNOSTIC_LOGS_DIR=/tmp/diagnostic_logs
GEVENT_WORKER=0
OTEL_ENABLED=0
1 change: 1 addition & 0 deletions server/mergin/.env
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ SECRET_KEY='top-secret'
SECURITY_BEARER_SALT='top-secret'
SECURITY_EMAIL_SALT='top-secret'
SECURITY_PASSWORD_SALT='top-secret'
SECURITY_UNLOCK_SALT='top-secret'
MAIL_DEFAULT_SENDER=''
FLASK_DEBUG=0
20 changes: 20 additions & 0 deletions server/mergin/auth/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,26 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFoundResp"
/app/auth/unlock-account/{token}:
post:
summary: Unlock account
description: Clear an active lockout for the user encoded in the token
operationId: mergin.auth.controller.unlock_account
parameters:
- name: token
in: path
description: User token for account unlock verification
required: true
schema:
type: string
example: InRlc3RAbHV0cmFjb25zdWx0aW5nLmNvLnVrIg.YN2KRg.Vj1LSzSvQx9DcNnQFgZ0baS7LPU
responses:
"200":
description: OK
"400":
$ref: "#/components/responses/BadStatusResp"
"404":
$ref: "#/components/responses/NotFoundResp"
/app/auth/confirm-email/{token}:
post:
summary: Email verified
Expand Down
62 changes: 61 additions & 1 deletion server/mergin/auth/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,10 @@ def authenticate(login, password):
db.session.commit()
return user
else:
user.record_failed_login()
duration = user.record_failed_login()
db.session.commit()
if duration is not None:
send_account_locked_email(current_app, user, duration)
return None


Expand Down Expand Up @@ -163,3 +165,61 @@ def send_confirmation_email(app, user, url, template, header, **kwargs):
"sender": app.config["MAIL_DEFAULT_SENDER"],
}
send_email_async.delay(**email_data)


def generate_unlock_token(app, user):
"""Sign a token binding the current lock episode (email + locked_until) to the user."""
serializer = URLSafeTimedSerializer(app.config["SECRET_KEY"])
payload = {
"email": user.email,
"locked_until": user.locked_until.replace(microsecond=0).isoformat(),
}
return serializer.dumps(payload, salt=app.config["SECURITY_UNLOCK_SALT"])


def confirm_unlock_token(token, expiration=24 * 3600):
serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"])
try:
payload = serializer.loads(
token, salt=current_app.config["SECURITY_UNLOCK_SALT"], max_age=expiration
)
except Exception:
return None
return payload


def _format_lockout_duration(seconds: int) -> str:
"""Humanize a lockout duration, e.g. 300 -> "5 minutes", 3600 -> "1 hour"."""
minutes, secs = divmod(int(seconds), 60)
hours, minutes = divmod(minutes, 60)
parts = []
if hours:
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
if minutes:
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
if not parts:
parts.append(f"{secs} second{'s' if secs != 1 else ''}")
return " ".join(parts)


def send_account_locked_email(app, user, duration_seconds):
"""Notify user their account was locked out and give them a link to unlock it."""
from ..celery import send_email_async

token = generate_unlock_token(app, user)
confirm_url = f"unlock-account/{token}"
html = render_template(
"email/account_locked.html",
subject="Account locked",
confirm_url=confirm_url,
user=user,
lockout_duration=_format_lockout_duration(duration_seconds),
locked_until=user.locked_until,
)
email_data = {
"subject": "Account locked",
"html": html,
"recipients": [user.email],
"sender": app.config["MAIL_DEFAULT_SENDER"],
}
send_email_async.delay(**email_data)
1 change: 1 addition & 0 deletions server/mergin/auth/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class Configuration(object):
SECURITY_BEARER_SALT = config("SECURITY_BEARER_SALT")
SECURITY_EMAIL_SALT = config("SECURITY_EMAIL_SALT")
SECURITY_PASSWORD_SALT = config("SECURITY_PASSWORD_SALT")
SECURITY_UNLOCK_SALT = config("SECURITY_UNLOCK_SALT")
BEARER_TOKEN_EXPIRATION = config(
"BEARER_TOKEN_EXPIRATION", default=3600 * 12, cast=int
) # in seconds
Expand Down
21 changes: 21 additions & 0 deletions server/mergin/auth/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
send_confirmation_email,
confirm_token,
generate_confirmation_token,
confirm_unlock_token,
user_created,
user_account_closed,
edit_profile_enabled,
Expand All @@ -43,6 +44,7 @@


EMAIL_CONFIRMATION_EXPIRATION = 12 * 3600
ACCOUNT_UNLOCK_TOKEN_EXPIRATION = 24 * 3600


# public endpoints
Expand Down Expand Up @@ -363,6 +365,25 @@ def confirm_email(token): # pylint: disable=W0613,W0612
return "", 200


def unlock_account(token): # pylint: disable=W0613,W0612
payload = confirm_unlock_token(token, expiration=ACCOUNT_UNLOCK_TOKEN_EXPIRATION)
if not payload:
abort(400, "Invalid or expired link")

user = User.query.filter_by(email=payload["email"]).first_or_404()
stale = (
not user.is_locked_out()
or user.locked_until.replace(microsecond=0).isoformat()
!= payload["locked_until"]
)
if stale:
abort(400, "This unlock link is no longer valid")

user.reset_lockout()
db.session.commit()
return "", 200


@auth_required
@edit_profile_enabled
def update_user_profile(): # pylint: disable=W0613,W0612
Expand Down
8 changes: 6 additions & 2 deletions server/mergin/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,11 @@ def is_locked_out(self) -> bool:
return False
return self.locked_until > datetime.datetime.utcnow()

def record_failed_login(self) -> None:
"""Increment the failed-login counter and apply a lockout if a threshold is crossed."""
def record_failed_login(self) -> Optional[int]:
"""Increment the failed-login counter and apply a lockout if a threshold is crossed.

Returns the lockout duration in seconds if a new lock was just applied, else None.
"""
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")
Expand All @@ -117,6 +120,7 @@ def record_failed_login(self) -> None:
self.locked_until = datetime.datetime.utcnow() + datetime.timedelta(
seconds=duration
)
return duration

def reset_lockout(self) -> None:
"""Clear lockout state after a successful login."""
Expand Down
15 changes: 15 additions & 0 deletions server/mergin/templates/email/account_locked.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!--
Copyright (C) Lutra Consulting Limited

SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
-->

{% set base_url = config['MERGIN_BASE_URL'] %}
{% extends "email/components/content.html" %}
{% block html %}
<p>Dear {{ user.username }},</p> <br>
<p>Your account has been temporarily locked for {{ lockout_duration }} after several failed login attempts. If this wasn't you, someone may be trying to access your account - consider changing your password once you're back in.</p>
<p>You will be able to log in again at {{ locked_until.strftime('%Y-%m-%d %H:%M') }} UTC, or you can unlock your account right now by following this link:</p>
<p><a href="{{ base_url }}/{{ confirm_url }}">{{ base_url }}/{{ confirm_url }}</a></p>
{% endblock %}
{% block notifications_footer %}{% endblock %}
2 changes: 2 additions & 0 deletions server/mergin/templates/email/components/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ <h1 style="font-family: 'Cabin', sans-serif;">
</td>
</tr>

{% block notifications_footer %}
<tr>
<td align="left" style="font-size:0px;padding:0px 15px 15px 15px;word-break:break-word;">
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:11px;line-height:1.5;text-align:left;color:#000000;">
Expand All @@ -230,6 +231,7 @@ <h1 style="font-family: 'Cabin', sans-serif;">
</div>
</td>
</tr>
{% endblock %}
</table>

</td>
Expand Down
95 changes: 93 additions & 2 deletions server/mergin/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial

from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import time
import itsdangerous
import pytest
Expand All @@ -13,7 +14,11 @@

from ..auth.bearer import decode_token, encode_token
from ..auth.forms import ResetPasswordForm
from ..auth.app import generate_confirmation_token, confirm_token
from ..auth.app import (
generate_confirmation_token,
confirm_token,
generate_unlock_token,
)
from ..auth.models import User, LoginHistory
from ..auth.tasks import anonymize_removed_users
from ..app import db
Expand Down Expand Up @@ -94,7 +99,8 @@ def test_logout(client):
assert resp.status_code == 200


def test_login_lockout(client):
@patch("mergin.celery.send_email_async.apply_async")
def test_login_lockout(send_email_mock, client):
"""Test account lockout: progressive tiers, freeze during lock, reset on success.

policy: 3 failures → 60s lock, 4 failures → 3600s lock
Expand All @@ -120,6 +126,9 @@ def assert_locked():
)
assert resp.status_code == 401

# lockout email dispatched exactly once, at the moment the lock triggers
assert send_email_mock.call_count == 1

assert_locked()

# correct password is also blocked while locked
Expand All @@ -133,6 +142,9 @@ def assert_locked():
assert user.failed_login_attempts == 3
assert user.locked_until is not None

# no further emails while already locked out (attempts above were all 423s)
assert send_email_mock.call_count == 1

# 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

Expand All @@ -150,6 +162,9 @@ def assert_locked():
assert user.locked_until > datetime.utcnow() + timedelta(seconds=60)
assert user.failed_login_attempts == 4

# second lockout email dispatched for the tier-2 re-lock
assert send_email_mock.call_count == 2

# successful login after expiry resets everything
user.locked_until = datetime.utcnow() - timedelta(seconds=1)
db.session.commit()
Expand All @@ -161,6 +176,82 @@ def assert_locked():
assert user.failed_login_attempts == 0
assert user.locked_until is None

# no email on successful login
assert send_email_mock.call_count == 2


@patch("mergin.celery.send_email_async.apply_async")
def test_unlock_account(send_email_mock, client, app):
"""Test the self-service unlock-account link: valid use, reuse, natural
expiry, and cross-tier reuse, per the token-binding design."""
client.application.config["LOCKOUT_POLICY"] = "3:60,4:3600"
user = add_user("unlockuser", "correctpassword")

def unlock(token):
return client.post(
url_for("/.mergin_auth_controller_unlock_account", token=token)
)

def lock_out():
for _ in range(3):
client.post(
url_for("/.mergin_auth_controller_login"),
json={"login": "unlockuser", "password": "wrong"},
)

# unknown user -> 404
fake_user = SimpleNamespace(email="nope@x.com", locked_until=datetime.utcnow())
resp = unlock(generate_unlock_token(app, fake_user))
assert resp.status_code == 404

# tamper with a valid-looking token -> 400
resp = unlock("not-a-real-token")
assert resp.status_code == 400

# trigger tier-1 lock and capture its token
lock_out()
assert user.is_locked_out()
tier1_token = generate_unlock_token(app, user)

# valid token unlocks successfully
resp = unlock(tier1_token)
assert resp.status_code == 200
assert user.failed_login_attempts == 0
assert user.locked_until is None

# reuse of the same (now-consumed) token fails
resp = unlock(tier1_token)
assert resp.status_code == 400

# naturally-expired lock: token itself still cryptographically valid,
# but the lock episode it points to is no longer active
lock_out()
assert user.is_locked_out()
stale_token = generate_unlock_token(app, user)
user.locked_until = datetime.utcnow() - timedelta(seconds=1)
db.session.commit()
resp = unlock(stale_token)
assert resp.status_code == 400

# cross-tier reuse: a token minted for one lock episode must not unlock
# a later, different lock episode for the same user
user.locked_until = None
user.failed_login_attempts = 0
db.session.commit()
lock_out()
tier1_token_2 = generate_unlock_token(app, user)
# escalate to tier 2 with a new locked_until
user.locked_until = datetime.utcnow() - timedelta(seconds=1)
db.session.commit()
client.post(
url_for("/.mergin_auth_controller_login"),
json={"login": "unlockuser", "password": "wrong"},
)
assert user.failed_login_attempts == 4
assert user.locked_until > datetime.utcnow() + timedelta(seconds=60)
resp = unlock(tier1_token_2)
assert resp.status_code == 400


def test_bcrypt_lazy_rehash(app):
"""Password is transparently rehashed on login when the cost factor changes."""
Expand Down
Loading
Loading