Skip to content
Merged
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
67 changes: 64 additions & 3 deletions server/mergin/auth/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial

import functools
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
Expand Down Expand Up @@ -120,8 +121,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 +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)
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: 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
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
Loading
Loading