Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
c0d57d2
Fixed session cookie gap for deactivated users
varmar05 Jun 15, 2026
26a0698
Add configurable bcrypt cost factor
varmar05 Jun 15, 2026
ce00ed3
Add temporary account lockout
varmar05 Jun 17, 2026
3373220
Fix tests
varmar05 Jun 19, 2026
f2708ce
Add missing import
varmar05 Jun 19, 2026
022e1a9
Support for whitelisting extensions/mimetype
harminius Jun 22, 2026
c001c7a
rm debugging residue
harminius Jun 22, 2026
956e01b
Remove locked_until from response and other minor fixes
varmar05 Jul 1, 2026
8efd371
Merge pull request #640 from MerginMaps/auth_fixes
MarcelGeo Jul 1, 2026
64a3844
address @MarcelGeo comments - use check_skip_validation -> also skips…
harminius Jul 2, 2026
4ddde36
Merge pull request #643 from MerginMaps/whitelisting_extensions_mimet…
MarcelGeo Jul 3, 2026
f83a49d
Display whitespaces in delete dialogs
harminius Jul 22, 2026
f0ca92f
Merge pull request #653 from MerginMaps/dialog_display_whitespaces
MarcelGeo Jul 22, 2026
c57d2e7
Merge branch 'master' into backport_master
varmar05 Jul 23, 2026
4419742
Merge pull request #654 from MerginMaps/backport_master
MarcelGeo Jul 23, 2026
de772e5
Send email notification with self-service unlock link on account lockout
varmar05 Jul 24, 2026
56626f6
fix account lockout component
varmar05 Jul 27, 2026
5f7aefe
address review comments
varmar05 Jul 27, 2026
ce69919
Merge pull request #656 from MerginMaps/account_locked_emails
varmar05 Jul 28, 2026
243d4f9
Count lockout failures over a rolling window via LoginHistory
varmar05 Aug 4, 2026
2c68b50
Fix axios error handling in user store
varmar05 Aug 6, 2026
0f07ecb
Merge pull request #657 from MerginMaps/locked_account_fixes
MarcelGeo Aug 12, 2026
579896d
Remove special response for locked account error
varmar05 Aug 17, 2026
48a57df
Make response uniform regardless of account existence
varmar05 Aug 17, 2026
cab687f
Ensure the same timing for all auth routes
varmar05 Aug 18, 2026
7ea1865
Added outlined severty handling in css
MarcelGeo Aug 18, 2026
c374a52
Merge pull request #663 from MerginMaps/fix_auth_responses
MarcelGeo Aug 18, 2026
d380168
Merge pull request #664 from MerginMaps/confirm-dialog-cancel-buttons
MarcelGeo Aug 19, 2026
57d37b2
Fix locked account page in case of invalid link
varmar05 Aug 24, 2026
f526f28
Successful login cleans failed attempts history for the lockout window
varmar05 Aug 24, 2026
8aebc22
Trigger lockout only on tier's border
varmar05 Aug 25, 2026
72c59e9
Tweak unlock account page
varmar05 Aug 25, 2026
c07ee6d
Merge pull request #665 from MerginMaps/fix_locked_account_issues
MarcelGeo Aug 25, 2026
7cbf024
added data-cy for tests compatibility
MarcelGeo Aug 27, 2026
215b96f
Merge pull request #667 from MerginMaps/styles-tunning
MarcelGeo Aug 27, 2026
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
6 changes: 6 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 Expand Up @@ -109,6 +112,9 @@ LOCAL_PROJECTS=/data

#BLACKLIST='.mergin/, .DS_Store, .directory' # cast=Csv()

# extra file extensions to permit beyond the default block-list, e.g. '.py, .sh'
#UPLOAD_EXTENSIONS_WHITELIST=

#FILE_EXPIRATION=48 * 3600 # for clean up of old files where diffs were applied, in seconds

#LOCKFILE_EXPIRATION=300 # 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
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
24 changes: 20 additions & 4 deletions server/mergin/auth/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -428,10 +428,6 @@ paths:
description: OK
"400":
$ref: "#/components/responses/BadStatusResp"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFoundResp"
/app/auth/reset-password/{token}:
post:
summary: Confirm reset password
Expand Down Expand Up @@ -470,6 +466,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
100 changes: 95 additions & 5 deletions server/mergin/auth/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial

import functools
import logging
from typing import Optional
from blinker import signal
from flask import current_app, render_template
from flask import current_app, render_template, Flask
from flask_login import current_user
from itsdangerous import URLSafeTimedSerializer
from itsdangerous import URLSafeTimedSerializer, BadData
from sqlalchemy import func

from .commands import add_commands
from .config import Configuration
from .models import User
from .models import User, _check_dummy_password

# signal for other versions to listen to
user_account_closed = signal("user_account_closed")
Expand Down Expand Up @@ -61,7 +63,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 +93,39 @@ 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:
_check_dummy_password(password)
return None
if user.is_locked_out():
logging.info(f"Rejected login attempt for locked-out user {user.id}")
_check_dummy_password(password)
return None
needs_commit = False
# reset non-null locked_until as it has already expired
if user.locked_until is not None:
user.locked_until = None
needs_commit = True

if user.check_password(password):
if user.needs_rehash():
user.assign_password(password)
needs_commit = True
if needs_commit:
db.session.commit()
return user
else:
duration = user.record_failed_login()
db.session.commit()
if duration is not None:
send_account_locked_email(current_app, user, duration)
return None


def generate_confirmation_token(app, email, salt):
Expand Down Expand Up @@ -134,3 +166,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: Flask, user: User) -> str:
"""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: str, expiration: int = 24 * 3600) -> Optional[dict]:
serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"])
try:
payload = serializer.loads(
token, salt=current_app.config["SECURITY_UNLOCK_SALT"], max_age=expiration
)
except BadData:
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: Flask, user: User, duration_seconds: int) -> None:
"""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)
6 changes: 6 additions & 0 deletions server/mergin/auth/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ 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
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")
# trailing window in seconds over which failed login attempts are counted
LOCKOUT_WINDOW = config("LOCKOUT_WINDOW", default=3600, cast=int)
48 changes: 32 additions & 16 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 @@ -42,6 +43,7 @@


EMAIL_CONFIRMATION_EXPIRATION = 12 * 3600
ACCOUNT_UNLOCK_TOKEN_EXPIRATION = 24 * 3600


# public endpoints
Expand Down Expand Up @@ -242,6 +244,7 @@ def admin_login(): # pylint: disable=W0613,W0612
if user:
if user.active and user.is_admin:
login_user(user)
LoginHistory.add_record(user.id, request)
return "", 200
else:
abort(403, "You do not have permissions")
Expand Down Expand Up @@ -288,25 +291,18 @@ def password_reset(): # pylint: disable=W0613,W0612
if not form.validate():
return jsonify(form.errors), 400

# respond the same regardless of account existence/state (enumeration)
user = User.query.filter(
func.lower(User.email) == func.lower(form.email.data.strip())
).one_or_none()
if not user:
return jsonify({"email": ["Account with given email does not exist"]}), 404
if not user.active:
# user should confirm email first
return jsonify({"email": ["Account is not active"]}), 400
if not user.can_edit_profile:
# using SSO
abort(403, CANNOT_EDIT_PROFILE_MSG)

send_confirmation_email(
current_app,
user,
"change-password",
"email/password_reset.html",
"Password reset",
)
if user and user.active and user.can_edit_profile:
send_confirmation_email(
current_app,
user,
"change-password",
"email/password_reset.html",
"Password reset",
)
return "", 200


Expand All @@ -324,6 +320,7 @@ def confirm_new_password(token): # pylint: disable=W0613,W0612
form = UserPasswordForm.from_json(request.json)
if form.validate():
user.assign_password(form.password.data)
user.reset_lockout()
db.session.add(user)
db.session.commit()
return "", 200
Expand Down Expand Up @@ -353,6 +350,25 @@ def confirm_email(token): # pylint: disable=W0613,W0612
return "", 200


def unlock_account(token: str): # 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
Loading
Loading