forked from Github-Mirrors/canaille
feat: Added email OTP authentication
This commit is contained in:
parent
c8e774ab46
commit
6d48ce9043
28 changed files with 600 additions and 71 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,5 +1,6 @@
|
|||
.env
|
||||
*.sqlite
|
||||
*.sqlite-journal
|
||||
*.pyc
|
||||
*.mo
|
||||
*.prof
|
||||
|
|
|
@ -79,3 +79,10 @@ def get_b64encoded_qr_image(data):
|
|||
buffered = BytesIO()
|
||||
img.save(buffered)
|
||||
return b64encode(buffered.getvalue()).decode("utf-8")
|
||||
|
||||
|
||||
def mask_email(email):
|
||||
atpos = email.find("@")
|
||||
if atpos > 0:
|
||||
return email[0] + "#####" + email[atpos - 1 :]
|
||||
return None
|
||||
|
|
|
@ -176,6 +176,7 @@ def validate(config, validate_remote=False):
|
|||
validate_theme(config["CANAILLE"])
|
||||
validate_admin_email(config["CANAILLE"])
|
||||
validate_otp_method(config["CANAILLE"])
|
||||
validate_mail_otp(config["CANAILLE"])
|
||||
|
||||
if not validate_remote:
|
||||
return
|
||||
|
@ -249,3 +250,10 @@ def validate_admin_email(config):
|
|||
def validate_otp_method(config):
|
||||
if config["OTP_METHOD"] not in [None, "TOTP", "HOTP"]:
|
||||
raise ConfigurationException("Invalid OTP method")
|
||||
|
||||
|
||||
def validate_mail_otp(config):
|
||||
if config["EMAIL_OTP"] and not config["SMTP"]:
|
||||
raise ConfigurationException(
|
||||
"Cannot activate email one-time password authentication without SMTP"
|
||||
)
|
||||
|
|
|
@ -22,6 +22,10 @@ class Features:
|
|||
def has_otp(self):
|
||||
return bool(self.app.config["CANAILLE"]["OTP_METHOD"])
|
||||
|
||||
@property
|
||||
def has_email_otp(self):
|
||||
return bool(self.app.config["CANAILLE"]["EMAIL_OTP"])
|
||||
|
||||
@property
|
||||
def has_registration(self):
|
||||
return self.app.config["CANAILLE"]["ENABLE_REGISTRATION"]
|
||||
|
|
|
@ -4,6 +4,7 @@ import json
|
|||
import typing
|
||||
|
||||
import click
|
||||
from flask import current_app
|
||||
from flask.cli import AppGroup
|
||||
from flask.cli import with_appcontext
|
||||
|
||||
|
@ -78,7 +79,7 @@ def register(cli):
|
|||
@cli.command(cls=ModelCommand, factory=factory, name=name, help=command_help)
|
||||
def factory_command(): ...
|
||||
|
||||
cli.add_command(reset_mfa)
|
||||
cli.add_command(reset_otp)
|
||||
|
||||
|
||||
def serialize(instance):
|
||||
|
@ -290,23 +291,26 @@ def delete_factory(model):
|
|||
@with_appcontext
|
||||
@with_backendcontext
|
||||
@click.argument("identifier")
|
||||
def reset_mfa(identifier):
|
||||
"""Reset multi-factor authentication for a user and display the
|
||||
def reset_otp(identifier):
|
||||
"""Reset one-time password authentication for a user and display the
|
||||
edited user in JSON format in the standard output.
|
||||
|
||||
IDENTIFIER should be a user id or user_name
|
||||
"""
|
||||
|
||||
instance = Backend.instance.get(models.User, identifier)
|
||||
if not instance:
|
||||
user = Backend.instance.get(models.User, identifier)
|
||||
if not user:
|
||||
raise click.ClickException(f"No user with id '{identifier}'")
|
||||
|
||||
instance.initialize_otp()
|
||||
user.initialize_otp()
|
||||
current_app.logger.security(
|
||||
f"Reset one-time password authentication from CLI for {user.user_name}"
|
||||
)
|
||||
|
||||
try:
|
||||
Backend.instance.save(instance)
|
||||
Backend.instance.save(user)
|
||||
except Exception as exc: # pragma: no cover
|
||||
raise click.ClickException(exc) from exc
|
||||
|
||||
output = json.dumps(serialize(instance))
|
||||
output = json.dumps(serialize(user))
|
||||
click.echo(output)
|
||||
|
|
|
@ -38,6 +38,8 @@ class User(canaille.core.models.User, LDAPObject):
|
|||
"secret_token": "oathSecret",
|
||||
"last_otp_login": "oathLastLogin",
|
||||
"hotp_counter": "oathHOTPCounter",
|
||||
"one_time_password": "oathTokenPIN",
|
||||
"one_time_password_emission_date": "oathSecretTime",
|
||||
}
|
||||
|
||||
def match_filter(self, filter):
|
||||
|
|
|
@ -106,6 +106,10 @@ class User(canaille.core.models.User, Base, SqlAlchemyModel):
|
|||
)
|
||||
secret_token: Mapped[str] = mapped_column(String, nullable=True, unique=True)
|
||||
hotp_counter: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||
one_time_password: Mapped[str] = mapped_column(String, nullable=True)
|
||||
one_time_password_emission_date: Mapped[datetime.datetime] = mapped_column(
|
||||
TZDateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
def save(self):
|
||||
if current_app.features.has_otp and not self.secret_token:
|
||||
|
|
|
@ -69,6 +69,11 @@ SECRET_KEY = "change me before you go in production"
|
|||
# This option is deactivated by default.
|
||||
# OTP_METHOD = "TOTP"
|
||||
|
||||
# If EMAIL_OTP is true, then users will need to authenticate themselves
|
||||
# via a one-time password sent to their primary email address.
|
||||
# This option is false by default.
|
||||
# EMAIL_OTP = false
|
||||
|
||||
# The validity duration of registration invitations, in seconds.
|
||||
# Defaults to 2 days
|
||||
# INVITATION_EXPIRATION = 172800
|
||||
|
|
|
@ -254,6 +254,10 @@ class CoreSettings(BaseModel):
|
|||
If set to :py:data:`TOTP`, the application will use time one-time passwords,
|
||||
If set to :py:data:`HOTP`, the application will use HMAC-based one-time passwords."""
|
||||
|
||||
EMAIL_OTP: bool = False
|
||||
"""If :py:data:`True`, then users will need to authenticate themselves
|
||||
via a one-time password sent to their primary email address."""
|
||||
|
||||
INVITATION_EXPIRATION: int = 172800
|
||||
"""The validity duration of registration invitations, in seconds.
|
||||
|
||||
|
|
|
@ -754,13 +754,17 @@ def profile_settings(user, edited_user):
|
|||
return profile_settings_edit(user, edited_user)
|
||||
|
||||
if (
|
||||
request.form.get("action") == "confirm-reset-mfa"
|
||||
request.form.get("action") == "confirm-reset-otp"
|
||||
and current_app.features.has_otp
|
||||
):
|
||||
return render_template("modals/reset-mfa.html", edited_user=edited_user)
|
||||
return render_template("modals/reset-otp.html", edited_user=edited_user)
|
||||
|
||||
if request.form.get("action") == "reset-mfa" and current_app.features.has_otp:
|
||||
flash(_("Multi-factor authentication has been reset"), "success")
|
||||
if request.form.get("action") == "reset-otp" and current_app.features.has_otp:
|
||||
flash(_("One-time password authentication has been reset"), "success")
|
||||
request_ip = request.remote_addr or "unknown IP"
|
||||
current_app.logger.security(
|
||||
f"Reset one-time password authentication for {edited_user.user_name} by {user.user_name} from {request_ip}"
|
||||
)
|
||||
edited_user.initialize_otp()
|
||||
Backend.instance.save(edited_user)
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ from flask import url_for
|
|||
|
||||
from canaille.app import build_hash
|
||||
from canaille.app import get_b64encoded_qr_image
|
||||
from canaille.app import mask_email
|
||||
from canaille.app.flask import smtp_needed
|
||||
from canaille.app.i18n import gettext as _
|
||||
from canaille.app.session import current_user
|
||||
|
@ -107,7 +108,38 @@ def password():
|
|||
"password.html", form=form, username=session["attempt_login"]
|
||||
)
|
||||
|
||||
if not current_app.features.has_otp:
|
||||
if current_app.features.has_otp:
|
||||
session["attempt_login_with_correct_password"] = session.pop("attempt_login")
|
||||
if not user.last_otp_login:
|
||||
flash(
|
||||
"You have not enabled Two-Factor Authentication. Please enable it first to login.",
|
||||
"info",
|
||||
)
|
||||
return redirect(url_for("core.auth.setup_two_factor_auth"))
|
||||
flash(
|
||||
"Please enter the one-time password from your authenticator app.",
|
||||
"info",
|
||||
)
|
||||
return redirect(url_for("core.auth.verify_two_factor_auth"))
|
||||
elif current_app.features.has_email_otp:
|
||||
try:
|
||||
user.generate_otp_mail()
|
||||
Backend.instance.save(user)
|
||||
session["attempt_login_with_correct_password"] = session.pop(
|
||||
"attempt_login"
|
||||
)
|
||||
flash(
|
||||
f"A one-time password has been sent to your email address {mask_email(user.emails[0])}. Please enter it below to login.",
|
||||
"info",
|
||||
)
|
||||
current_app.logger.security(
|
||||
f'Sent one-time password for {session["attempt_login_with_correct_password"]} to {user.emails[0]} from {request_ip}'
|
||||
)
|
||||
except Exception: # pragma: no cover
|
||||
flash("One-time password generation failed. Please try again.", "danger")
|
||||
return redirect(url_for("core.auth.password"))
|
||||
return redirect(url_for("core.auth.verify_two_factor_auth"))
|
||||
else:
|
||||
current_app.logger.security(
|
||||
f'Succeed login attempt for {session["attempt_login"]} from {request_ip}'
|
||||
)
|
||||
|
@ -120,15 +152,6 @@ def password():
|
|||
return redirect(
|
||||
session.pop("redirect-after-login", url_for("core.account.index"))
|
||||
)
|
||||
else:
|
||||
session["attempt_login_with_correct_password"] = session.pop("attempt_login")
|
||||
if not user.last_otp_login:
|
||||
flash(
|
||||
"You have not enabled Two-Factor Authentication. Please enable it first to login.",
|
||||
"info",
|
||||
)
|
||||
return redirect(url_for("core.auth.setup_two_factor_auth"))
|
||||
return redirect(url_for("core.auth.verify_two_factor_auth"))
|
||||
|
||||
|
||||
@bp.route("/logout")
|
||||
|
@ -299,7 +322,7 @@ def setup_two_factor_auth():
|
|||
|
||||
@bp.route("/verify-2fa", methods=["GET", "POST"])
|
||||
def verify_two_factor_auth():
|
||||
if not current_app.features.has_otp:
|
||||
if not current_app.features.has_otp and not current_app.features.has_email_otp:
|
||||
abort(404)
|
||||
|
||||
if current_user():
|
||||
|
@ -315,10 +338,12 @@ def verify_two_factor_auth():
|
|||
form.render_field_macro_file = "partial/login_field.html"
|
||||
|
||||
if not request.form or form.form_control():
|
||||
method = "mail" if current_app.features.has_email_otp else "authenticator"
|
||||
return render_template(
|
||||
"verify-2fa.html",
|
||||
form=form,
|
||||
username=session["attempt_login_with_correct_password"],
|
||||
method=method,
|
||||
)
|
||||
|
||||
user = Backend.instance.get_user_from_login(
|
||||
|
@ -327,9 +352,9 @@ def verify_two_factor_auth():
|
|||
|
||||
if form.validate() and user.is_otp_valid(form.otp.data):
|
||||
welcome_message = (
|
||||
"Connection successful."
|
||||
if user.last_otp_login
|
||||
else "Two-factor authentication setup successful."
|
||||
"Two-factor authentication setup successful."
|
||||
if current_app.features.has_otp and user.last_otp_login is None
|
||||
else "Connection successful."
|
||||
)
|
||||
try:
|
||||
user.last_otp_login = datetime.datetime.now(datetime.timezone.utc)
|
||||
|
@ -364,3 +389,39 @@ def verify_two_factor_auth():
|
|||
f'Failed login attempt (wrong OTP) for {session["attempt_login_with_correct_password"]} from {request_ip}'
|
||||
)
|
||||
return redirect(url_for("core.auth.verify_two_factor_auth"))
|
||||
|
||||
|
||||
@bp.route("/send-mail-otp", methods=["POST"])
|
||||
def send_mail_otp():
|
||||
if not current_app.features.has_email_otp:
|
||||
abort(404)
|
||||
|
||||
if current_user():
|
||||
return redirect(
|
||||
url_for("core.account.profile_edition", edited_user=current_user())
|
||||
)
|
||||
|
||||
if "attempt_login_with_correct_password" not in session:
|
||||
flash(_("Cannot remember the login you attempted to sign in with"), "warning")
|
||||
return redirect(url_for("core.auth.login"))
|
||||
|
||||
user = Backend.instance.get_user_from_login(
|
||||
session["attempt_login_with_correct_password"]
|
||||
)
|
||||
|
||||
try:
|
||||
user.generate_otp_mail()
|
||||
Backend.instance.save(user)
|
||||
request_ip = request.remote_addr or "unknown IP"
|
||||
current_app.logger.security(
|
||||
f'Sent one-time password for {session["attempt_login_with_correct_password"]} to {user.emails[0]} from {request_ip}'
|
||||
)
|
||||
flash(
|
||||
"Code successfully sent!",
|
||||
"success",
|
||||
)
|
||||
except Exception: # pragma: no cover
|
||||
flash("One-time password generation failed. Please try again.", "danger")
|
||||
return redirect(url_for("core.auth.verify_two_factor_auth"))
|
||||
|
||||
return redirect(url_for("core.auth.verify_two_factor_auth"))
|
||||
|
|
|
@ -249,3 +249,34 @@ def send_compromised_password_check_failure_mail(
|
|||
html=html_body,
|
||||
attachments=[(logo_cid, logo_filename, logo_raw)] if logo_filename else None,
|
||||
)
|
||||
|
||||
|
||||
def send_one_time_password_mail(mail, otp):
|
||||
base_url = url_for("core.account.index", _external=True)
|
||||
logo_cid, logo_filename, logo_raw = logo()
|
||||
|
||||
subject = _("One-time password authentication on {website_name}").format(
|
||||
website_name=current_app.config["CANAILLE"]["NAME"]
|
||||
)
|
||||
text_body = render_template(
|
||||
"mails/email_otp.txt",
|
||||
site_name=current_app.config["CANAILLE"]["NAME"],
|
||||
site_url=base_url,
|
||||
otp=otp,
|
||||
)
|
||||
html_body = render_template(
|
||||
"mails/email_otp.html",
|
||||
site_name=current_app.config["CANAILLE"]["NAME"],
|
||||
site_url=base_url,
|
||||
otp=otp,
|
||||
logo=f"cid:{logo_cid[1:-1]}" if logo_cid else None,
|
||||
title=subject,
|
||||
)
|
||||
|
||||
return send_email(
|
||||
subject=subject,
|
||||
recipient=mail,
|
||||
text=text_body,
|
||||
html=html_body,
|
||||
attachments=[(logo_cid, logo_filename, logo_raw)] if logo_filename else None,
|
||||
)
|
||||
|
|
|
@ -8,8 +8,11 @@ from flask import current_app
|
|||
|
||||
from canaille.backends.models import Model
|
||||
from canaille.core.configuration import Permission
|
||||
from canaille.core.mails import send_one_time_password_mail
|
||||
|
||||
HOTP_LOOK_AHEAD_WINDOW = 10
|
||||
OTP_DIGITS = 6
|
||||
OTP_VALIDITY = 600
|
||||
|
||||
|
||||
class User(Model):
|
||||
|
@ -247,7 +250,7 @@ class User(Model):
|
|||
|
||||
last_otp_login: datetime.datetime | None = None
|
||||
"""A DateTime indicating when the user last logged in with a one-time password.
|
||||
This attribute is currently used to check whether the user has activated multi-factor authentication or not."""
|
||||
This attribute is currently used to check whether the user has activated one-time password authentication or not."""
|
||||
|
||||
secret_token: str | None = None
|
||||
"""Unique token generated for each user, used for
|
||||
|
@ -257,6 +260,12 @@ class User(Model):
|
|||
"""HMAC-based One Time Password counter, used for
|
||||
two-factor authentication."""
|
||||
|
||||
one_time_password: str | None = None
|
||||
"""One time password used for email two-factor authentication."""
|
||||
|
||||
one_time_password_emission_date: datetime.datetime | None = None
|
||||
"""A DateTime indicating when the user last emitted an email one-time password."""
|
||||
|
||||
_readable_fields = None
|
||||
_writable_fields = None
|
||||
_permissions = None
|
||||
|
@ -348,8 +357,17 @@ class User(Model):
|
|||
elif method == "HOTP":
|
||||
hotp = otpauth.HOTP(bytes(self.secret_token, "utf-8"))
|
||||
return hotp.string_code(hotp.generate(self.hotp_counter + counter_delta))
|
||||
else: # pragma: no cover
|
||||
raise RuntimeError("Invalid one-time password method")
|
||||
|
||||
raise RuntimeError("Invalid one-time password method") # pragma: no cover
|
||||
def generate_otp_mail(self):
|
||||
otp = string_code(secrets.randbelow(10**OTP_DIGITS), OTP_DIGITS)
|
||||
self.one_time_password = otp
|
||||
self.one_time_password_emission_date = datetime.datetime.now(
|
||||
datetime.timezone.utc
|
||||
)
|
||||
send_one_time_password_mail(self.emails[0], otp)
|
||||
return otp
|
||||
|
||||
def get_otp_authentication_setup_uri(self):
|
||||
method = current_app.features.otp_method
|
||||
|
@ -363,28 +381,54 @@ class User(Model):
|
|||
issuer=current_app.config["CANAILLE"]["NAME"],
|
||||
counter=self.hotp_counter,
|
||||
)
|
||||
|
||||
raise RuntimeError("Invalid one-time password method") # pragma: no cover
|
||||
else: # pragma: no cover
|
||||
raise RuntimeError("Invalid one-time password method")
|
||||
|
||||
def is_otp_valid(self, user_otp):
|
||||
method = current_app.features.otp_method
|
||||
if method == "TOTP":
|
||||
return otpauth.TOTP(bytes(self.secret_token, "utf-8")).verify(user_otp)
|
||||
elif method == "HOTP":
|
||||
counter = self.hotp_counter
|
||||
is_valid = False
|
||||
# if user token's counter is ahead of canaille's, try to catch up to it
|
||||
while counter - self.hotp_counter <= HOTP_LOOK_AHEAD_WINDOW:
|
||||
is_valid = otpauth.HOTP(bytes(self.secret_token, "utf-8")).verify(
|
||||
user_otp, counter
|
||||
)
|
||||
counter += 1
|
||||
if is_valid:
|
||||
self.hotp_counter = counter
|
||||
return True
|
||||
return False
|
||||
method = None
|
||||
if current_app.features.has_otp:
|
||||
method = current_app.features.otp_method
|
||||
elif current_app.features.has_email_otp: # pragma: no branch
|
||||
method = "EMAIL_OTP"
|
||||
|
||||
raise RuntimeError("Invalid one-time password method") # pragma: no cover
|
||||
if method == "TOTP":
|
||||
return self.is_totp_valid(user_otp)
|
||||
elif method == "HOTP":
|
||||
return self.is_hotp_valid(user_otp)
|
||||
elif method == "EMAIL_OTP":
|
||||
return self.is_email_otp_valid(user_otp)
|
||||
else: # pragma: no cover
|
||||
raise RuntimeError("Invalid one-time password method")
|
||||
|
||||
def is_totp_valid(self, user_otp):
|
||||
return otpauth.TOTP(bytes(self.secret_token, "utf-8")).verify(user_otp)
|
||||
|
||||
def is_hotp_valid(self, user_otp):
|
||||
counter = self.hotp_counter
|
||||
is_valid = False
|
||||
# if user token's counter is ahead of canaille's, try to catch up to it
|
||||
while counter - self.hotp_counter <= HOTP_LOOK_AHEAD_WINDOW:
|
||||
is_valid = otpauth.HOTP(bytes(self.secret_token, "utf-8")).verify(
|
||||
user_otp, counter
|
||||
)
|
||||
counter += 1
|
||||
if is_valid:
|
||||
self.hotp_counter = counter
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_email_otp_valid(self, user_otp):
|
||||
return (
|
||||
user_otp == self.one_time_password
|
||||
and self.is_one_time_password_still_valid()
|
||||
)
|
||||
|
||||
def is_one_time_password_still_valid(self):
|
||||
return datetime.datetime.now(
|
||||
datetime.timezone.utc
|
||||
) - self.one_time_password_emission_date < datetime.timedelta(
|
||||
seconds=OTP_VALIDITY
|
||||
)
|
||||
|
||||
|
||||
class Group(Model):
|
||||
|
@ -415,3 +459,15 @@ class Group(Model):
|
|||
"""
|
||||
|
||||
description: str | None = None
|
||||
|
||||
|
||||
def string_code(code: int, digit: int) -> str:
|
||||
"""Add leading 0 if the code length does not match the defined length.
|
||||
|
||||
For instance, parameter ``digit=6``, but ``code=123``, this method would
|
||||
return ``000123``::
|
||||
|
||||
>>> otp.string_code(123)
|
||||
'000123'
|
||||
"""
|
||||
return f"{code:0{digit}}"
|
||||
|
|
43
canaille/core/templates/mails/email_otp.html
Normal file
43
canaille/core/templates/mails/email_otp.html
Normal file
|
@ -0,0 +1,43 @@
|
|||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "https://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||
<html xmlns="https://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width"/>
|
||||
<style type="text/css" style="font-weight: 300">@import url({{ url_for('static', filename='fonts/lato.css', _external=True) }});</style>
|
||||
<title>{{ title }}</title>
|
||||
</head>
|
||||
<body style="color: rgba(0,0,0,.87); padding: 1em; margin: auto; width: 700px; font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif; font-weight: 400; font-size: 14px;">
|
||||
|
||||
<table cellspacing="0" cellpadding="0" border="0" style="font-weight: 400; background: #fff; font-size: 1rem; margin-top: 0; margin-bottom: 0; width: 700px;">
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<h3 style="font-weight: 700; line-height: 1.3em; font-size: 1.3rem; padding: .8rem 1rem; margin: 0; box-shadow: none; border: 1px solid #d4d4d5; border-radius: .3rem .3rem 0 0;">
|
||||
{% if logo %}
|
||||
<img src="{{ logo }}" alt="{{ site_name }}" style="font-size: 1.3rem; border-style: none; width: 50px; display: inline-block; margin-top: .14em; vertical-align: middle;">
|
||||
{% endif %}
|
||||
<div style="font-size: 1.3rem; display: inline-block; padding-left: .75rem; vertical-align: middle;">
|
||||
{% trans %}One-time password authentication{% endtrans %}
|
||||
</div>
|
||||
</h3>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td colspan="2" style="background: #f8f8f9; padding: 1em 1.5em; line-height: 1.4em; font-size: 1em; margin: 0; border-radius: 0; text-align: justify; border-left: 1px solid #d4d4d5; border-right: 1px solid #d4d4d5;">
|
||||
{% trans %}
|
||||
Someone, probably you, asked for a one-time password at {{ site_name }}. If you did not ask for this email, please ignore it. If you did, please enter the one-time password below at {{ site_name }} to complete your authentication.
|
||||
{% endtrans %}
|
||||
<p style="text-align: center; line-height: 2em;">{% trans %}Your one-time password{% endtrans %}:
|
||||
<br><b style="font-size: 2em">{{ otp }}</b>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr style="margin: 0; border-radius: 0; text-align:center; border-left: 1px solid #d4d4d5; border-right: 1px solid #d4d4d5; border-bottom: 1px solid #d4d4d5;">
|
||||
<td style="background: #e0e1e2; width:50%; border-radius: 0 0 0 .3rem;">
|
||||
<a href="{{ site_url }}" style="width: 100%; display: inline-block; vertical-align: middle; color: rgba(0,0,0,.6); padding: .8em 0; font-weight: 700; line-height: 1em; text-align: center; text-decoration: none; font-size: 1rem;margin: 0; border-left: none; border-radius: 0; box-shadow: 0 0 0 1px transparent inset,0 0 0 0 rgba(34,36,38,.15) inset;">{{ site_name }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
6
canaille/core/templates/mails/email_otp.txt
Normal file
6
canaille/core/templates/mails/email_otp.txt
Normal file
|
@ -0,0 +1,6 @@
|
|||
# {% trans %}One-time password authentication{% endtrans %}
|
||||
|
||||
{% trans %}Please enter the one-time password below at {{ site_name }} to complete your authentication.{% endtrans %}
|
||||
|
||||
{% trans %}One-time password{% endtrans %}: {{ otp }}
|
||||
{{ site_name }}: {{ site_url }}
|
|
@ -1,22 +1,22 @@
|
|||
{% extends theme('base.html') %}
|
||||
|
||||
{% block content %}
|
||||
<div id="modal-reset-mfa" class="ui warning message">
|
||||
<div id="modal-reset-otp" class="ui warning message">
|
||||
<form method="post" action="{{ request.url }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.form.get("csrf_token") }}">
|
||||
<div class="ui icon header">
|
||||
<i class="key icon"></i>
|
||||
{% trans %}Multi-factor authentication reset{% endtrans %}
|
||||
{% trans %}One-time password authentication reset{% endtrans %}
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>
|
||||
{% if user != edited_user %}
|
||||
{% trans user_name=(edited_user.formatted_name or edited_user.identifier) %}
|
||||
Are you sure you want to reset multi-factor authentication (MFA) for {{ user_name }} ? The user will have to perform MFA setup again at next login.
|
||||
Are you sure you want to reset one-time password (OTP) authentication for {{ user_name }} ? The user will have to perform OTP setup again at next login.
|
||||
{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}
|
||||
Are you sure you want to reset multi-factor authentication (MFA)? You will have to perform MFA setup again at next login.
|
||||
Are you sure you want to reset one-time password (OTP) authentication? You will have to perform OTP setup again at next login.
|
||||
{% endtrans %}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
@ -24,7 +24,7 @@
|
|||
<div class="ui center aligned container">
|
||||
<div class="ui stackable buttons">
|
||||
<a class="ui cancel button" href="{{ request.url }}">{% trans %}Cancel{% endtrans %}</a>
|
||||
<button type="submit" name="action" value="reset-mfa" class="ui red approve button">{% trans %}Reset multi-factor authentication{% endtrans %}</button>
|
||||
<button type="submit" name="action" value="reset-otp" class="ui red approve button">{% trans %}Reset one-time password authentication{% endtrans %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
|
@ -141,8 +141,8 @@
|
|||
<div class="ui right aligned container">
|
||||
<div class="ui stackable buttons">
|
||||
{% if features.has_otp and user.can_manage_users %}
|
||||
<button type="submit" class="ui right floated basic negative button confirm" name="action" value="confirm-reset-mfa" id="reset-mfa" formnovalidate>
|
||||
{% trans %}Reset multi-factor authentication{% endtrans %}
|
||||
<button type="submit" class="ui right floated basic negative button confirm" name="action" value="confirm-reset-otp" id="reset-otp" formnovalidate>
|
||||
{% trans %}Reset one-time password authentication{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
@ -31,7 +31,7 @@
|
|||
<div class="ui segment">
|
||||
<h3 class="ui header">Instructions:</h3>
|
||||
<ol class="ui list">
|
||||
<li class="item">Download <a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2" target="_blank">Google Authenticator</a> on your mobile.</li>
|
||||
<li class="item">Install a One-Time Password (OTP) generator application on your mobile.</li>
|
||||
<li class="item">Set up a new authenticator.</li>
|
||||
<li class="item">Scan the QR code below and click "Continue".</li>
|
||||
</ol>
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
<div class="content">
|
||||
{{ _("Sign in as %(username)s", username=username) }}
|
||||
</div>
|
||||
<div class="sub header">{% trans %}Please enter the one-time password from your authenticator app.{% endtrans %}</div>
|
||||
<div class="sub header">{% trans %}One-time password authentication.{% endtrans %}</div>
|
||||
</h2>
|
||||
{% endblock %}
|
||||
|
||||
|
@ -42,6 +42,12 @@
|
|||
</div>
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% if method == "mail" %}
|
||||
<form action="{{ url_for('core.auth.send_mail_otp') }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="send-code-button" type="submit">Didn't receive the mail? Click here to send a new code.</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -265,4 +265,13 @@ select.ui.multiple.dropdown option[selected] {
|
|||
.ui.label.token-label {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.send-code-button {
|
||||
background: none!important;
|
||||
border: none;
|
||||
padding: 0!important;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
color: rgba(255,255,255,.87) !important;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -74,3 +74,12 @@ DYNAMIC_CLIENT_REGISTRATION_OPEN = true
|
|||
DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
|
||||
"xxxxxxx-yyyyyyy-zzzzzz",
|
||||
]
|
||||
|
||||
[CANAILLE.SMTP]
|
||||
HOST = "mailhog.yaal.coop"
|
||||
PORT = 1025
|
||||
TLS = false
|
||||
SSL = false
|
||||
LOGIN = ""
|
||||
PASSWORD = ""
|
||||
FROM_ADDR = "admin@mydomain.tld"
|
||||
|
|
|
@ -181,7 +181,8 @@ Multi-factor authentication
|
|||
|
||||
If the :attr:`one-time password feature <canaille.core.configuration.CoreSettings.OTP_METHOD>` is set, then users will need to authenticate themselves using a one-time password via an authenticator app.
|
||||
Two options are supported : "TOTP" for time one-time password, and "HOTP" for HMAC-based one-time password.
|
||||
In case of lost token, multi-factor authentication can be reset by users with :attr:`user management permission <canaille.core.configuration.Permission.MANAGE_USERS>`.
|
||||
In case of lost token, one-time password authentication can be reset by users with :attr:`user management permission <canaille.core.configuration.Permission.MANAGE_USERS>`.
|
||||
If the :attr:`email multi-factor authentication feature <canaille.core.configuration.CoreSettings.EMAIL_OTP>` is enabled, then users will need to authenticate themselves via a one-time password sent to their primary email address.
|
||||
|
||||
Web interface
|
||||
*************
|
||||
|
@ -288,6 +289,8 @@ The following security events are logged with the log level "security" for easy
|
|||
- Password update
|
||||
- Email update
|
||||
- Forgotten password mail sent to user
|
||||
- One-time password mail sent to user
|
||||
- Multi-factor authentication reset
|
||||
- Token emission
|
||||
- Token refresh
|
||||
- Token revokation
|
||||
|
|
|
@ -59,8 +59,8 @@ For the sake of readability, it is omitted in the following examples.
|
|||
:prog: canaille delete
|
||||
:nested: full
|
||||
|
||||
.. _cli_reset_mfa:
|
||||
.. _cli_reset_otp:
|
||||
|
||||
.. click:: doc.commands:reset-mfa
|
||||
:prog: canaille reset-mfa
|
||||
.. click:: doc.commands:reset-otp
|
||||
:prog: canaille reset-otp
|
||||
:nested: full
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
import logging
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
@ -7,8 +8,8 @@ from canaille.commands import cli
|
|||
|
||||
|
||||
@pytest.mark.parametrize("otp_method", ["TOTP", "HOTP"])
|
||||
def test_reset_mfa_by_id(testclient, backend, user_otp, otp_method):
|
||||
"""Reset multi-factor authentication for a user by its id."""
|
||||
def test_reset_otp_by_id(testclient, backend, caplog, user_otp, otp_method):
|
||||
"""Reset one-time password authentication for a user by its id."""
|
||||
|
||||
testclient.app.config["CANAILLE"]["OTP_METHOD"] = otp_method
|
||||
|
||||
|
@ -20,7 +21,7 @@ def test_reset_mfa_by_id(testclient, backend, user_otp, otp_method):
|
|||
res = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"reset-mfa",
|
||||
"reset-otp",
|
||||
user_otp.id,
|
||||
],
|
||||
)
|
||||
|
@ -47,6 +48,11 @@ def test_reset_mfa_by_id(testclient, backend, user_otp, otp_method):
|
|||
"hotp_counter": 1,
|
||||
"secret_token": mock.ANY,
|
||||
}
|
||||
assert (
|
||||
"canaille",
|
||||
logging.SECURITY,
|
||||
"Reset one-time password authentication from CLI for user",
|
||||
) in caplog.record_tuples
|
||||
backend.reload(user_otp)
|
||||
assert user_otp.secret_token is not None
|
||||
assert user_otp.secret_token != old_token
|
||||
|
@ -55,14 +61,14 @@ def test_reset_mfa_by_id(testclient, backend, user_otp, otp_method):
|
|||
assert user_otp.hotp_counter == 1
|
||||
|
||||
|
||||
def test_reset_mfa_unknown_id(testclient):
|
||||
"""Error case for trying to reset multi-factor authentication for an invalid user."""
|
||||
def test_reset_otp_unknown_id(testclient):
|
||||
"""Error case for trying to reset one-time password authentication for an invalid user."""
|
||||
|
||||
runner = testclient.app.test_cli_runner()
|
||||
res = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"reset-mfa",
|
||||
"reset-otp",
|
||||
"invalid",
|
||||
],
|
||||
)
|
||||
|
|
|
@ -245,3 +245,20 @@ def test_invalid_otp_option(configuration, backend):
|
|||
config_obj = settings_factory(configuration)
|
||||
config_dict = config_obj.model_dump()
|
||||
validate(config_dict, validate_remote=False)
|
||||
|
||||
|
||||
def test_email_otp_without_smtp(configuration, backend):
|
||||
config_obj = settings_factory(configuration)
|
||||
config_dict = config_obj.model_dump()
|
||||
|
||||
validate(config_dict, validate_remote=False)
|
||||
|
||||
with pytest.raises(
|
||||
ConfigurationException,
|
||||
match=r"Cannot activate email one-time password authentication without SMTP",
|
||||
):
|
||||
configuration["CANAILLE"]["SMTP"] = None
|
||||
configuration["CANAILLE"]["EMAIL_OTP"] = True
|
||||
config_obj = settings_factory(configuration)
|
||||
config_dict = config_obj.model_dump()
|
||||
validate(config_dict, validate_remote=False)
|
||||
|
|
228
tests/core/test_email_otp.py
Normal file
228
tests/core/test_email_otp.py
Normal file
|
@ -0,0 +1,228 @@
|
|||
import datetime
|
||||
import logging
|
||||
|
||||
import time_machine
|
||||
|
||||
from canaille.app import mask_email
|
||||
from canaille.core.models import OTP_VALIDITY
|
||||
|
||||
|
||||
def test_email_otp_disabled(testclient):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = None
|
||||
testclient.app.config["WTF_CSRF_ENABLED"] = False
|
||||
|
||||
testclient.get("/verify-2fa", status=404)
|
||||
testclient.post("/send-mail-otp", status=404)
|
||||
|
||||
|
||||
def test_signin_and_out_with_email_otp(smtpd, testclient, backend, user, caplog):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
|
||||
|
||||
with testclient.session_transaction() as session:
|
||||
assert not session.get("user_id")
|
||||
|
||||
res = testclient.get("/login", status=200)
|
||||
|
||||
res.form["login"] = "user"
|
||||
res = res.form.submit(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
with testclient.session_transaction() as session:
|
||||
assert "user" == session.get("attempt_login")
|
||||
|
||||
res.form["password"] = "correct horse battery staple"
|
||||
res = res.form.submit(status=302)
|
||||
assert (
|
||||
"info",
|
||||
"A one-time password has been sent to your email address j#####n@doe.com. Please enter it below to login.",
|
||||
) in res.flashes
|
||||
assert (
|
||||
"canaille",
|
||||
logging.SECURITY,
|
||||
"Sent one-time password for user to john@doe.com from unknown IP",
|
||||
) in caplog.record_tuples
|
||||
res = res.follow(status=200)
|
||||
with testclient.session_transaction() as session:
|
||||
assert "attempt_login" not in session
|
||||
assert "user" == session.get("attempt_login_with_correct_password")
|
||||
|
||||
res = testclient.get("/verify-2fa")
|
||||
|
||||
backend.reload(user)
|
||||
otp = user.one_time_password
|
||||
assert len(smtpd.messages) == 1
|
||||
email_content = str(smtpd.messages[0].get_payload()[0]).replace("=\n", "")
|
||||
assert otp in email_content
|
||||
|
||||
main_form = res.forms[0]
|
||||
main_form["otp"] = otp
|
||||
res = main_form.submit(status=302)
|
||||
|
||||
assert (
|
||||
"success",
|
||||
"Connection successful. Welcome John (johnny) Doe",
|
||||
) in res.flashes
|
||||
assert (
|
||||
"canaille",
|
||||
logging.SECURITY,
|
||||
"Succeed login attempt for user from unknown IP",
|
||||
) in caplog.record_tuples
|
||||
res = res.follow(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
with testclient.session_transaction() as session:
|
||||
assert [user.id] == session.get("user_id")
|
||||
assert "attempt_login" not in session
|
||||
assert "attempt_login_with_correct_password" not in session
|
||||
|
||||
res = testclient.get("/login", status=302)
|
||||
|
||||
res = testclient.get("/logout")
|
||||
assert (
|
||||
"success",
|
||||
"You have been disconnected. See you next time John (johnny) Doe",
|
||||
) in res.flashes
|
||||
assert (
|
||||
"canaille",
|
||||
logging.SECURITY,
|
||||
"Logout user from unknown IP",
|
||||
) in caplog.record_tuples
|
||||
res = res.follow(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
|
||||
def test_signin_wrong_email_otp(testclient, user, caplog):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
|
||||
|
||||
with testclient.session_transaction() as session:
|
||||
assert not session.get("user_id")
|
||||
|
||||
res = testclient.get("/login", status=200)
|
||||
|
||||
res.form["login"] = "user"
|
||||
res = res.form.submit(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
res.form["password"] = "correct horse battery staple"
|
||||
res = res.form.submit(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
main_form = res.forms[0]
|
||||
main_form["otp"] = 111111
|
||||
res = main_form.submit()
|
||||
|
||||
assert (
|
||||
"error",
|
||||
"The one-time password you entered is invalid. Please try again",
|
||||
) in res.flashes
|
||||
assert (
|
||||
"canaille",
|
||||
logging.SECURITY,
|
||||
"Failed login attempt (wrong OTP) for user from unknown IP",
|
||||
) in caplog.record_tuples
|
||||
|
||||
|
||||
def test_signin_expired_email_otp(testclient, user, caplog):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
|
||||
|
||||
with time_machine.travel("2020-01-01 01:00:00+00:00", tick=False) as traveller:
|
||||
with testclient.session_transaction() as session:
|
||||
assert not session.get("user_id")
|
||||
|
||||
res = testclient.get("/login", status=200)
|
||||
|
||||
res.form["login"] = "user"
|
||||
res = res.form.submit(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
res.form["password"] = "correct horse battery staple"
|
||||
res = res.form.submit(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
main_form = res.forms[0]
|
||||
main_form["otp"] = user.generate_otp_mail()
|
||||
traveller.shift(datetime.timedelta(seconds=OTP_VALIDITY))
|
||||
res = main_form.submit()
|
||||
|
||||
assert (
|
||||
"error",
|
||||
"The one-time password you entered is invalid. Please try again",
|
||||
) in res.flashes
|
||||
assert (
|
||||
"canaille",
|
||||
logging.SECURITY,
|
||||
"Failed login attempt (wrong OTP) for user from unknown IP",
|
||||
) in caplog.record_tuples
|
||||
|
||||
|
||||
def test_verify_mail_otp_page_without_signin_in_redirects_to_login_page(
|
||||
testclient, user
|
||||
):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
|
||||
|
||||
res = testclient.get("/verify-2fa", status=302)
|
||||
assert res.location == "/login"
|
||||
assert res.flashes == [
|
||||
("warning", "Cannot remember the login you attempted to sign in with")
|
||||
]
|
||||
|
||||
|
||||
def test_verify_mail_otp_page_already_logged_in(testclient, logged_user):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
|
||||
|
||||
res = testclient.get("/verify-2fa", status=302)
|
||||
assert res.location == "/profile/user"
|
||||
|
||||
|
||||
def test_mask_email():
|
||||
email = "foo@bar.com"
|
||||
assert mask_email(email) == "f#####o@bar.com"
|
||||
|
||||
email = "hello"
|
||||
assert mask_email(email) is None
|
||||
|
||||
|
||||
def test_send_new_mail_otp_without_signin_in_redirects_to_login_page(testclient, user):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
|
||||
testclient.app.config["WTF_CSRF_ENABLED"] = False
|
||||
|
||||
res = testclient.post("/send-mail-otp", status=302)
|
||||
assert res.location == "/login"
|
||||
assert res.flashes == [
|
||||
("warning", "Cannot remember the login you attempted to sign in with")
|
||||
]
|
||||
|
||||
|
||||
def test_send_new_mail_otp_already_logged_in(testclient, logged_user):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
|
||||
testclient.app.config["WTF_CSRF_ENABLED"] = False
|
||||
|
||||
res = testclient.post("/send-mail-otp", status=302)
|
||||
assert res.location == "/profile/user"
|
||||
|
||||
|
||||
def test_send_new_mail_otp(smtpd, testclient, backend, user, caplog):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
|
||||
testclient.app.config["WTF_CSRF_ENABLED"] = False
|
||||
with testclient.session_transaction() as session:
|
||||
session["attempt_login_with_correct_password"] = user.user_name
|
||||
|
||||
res = testclient.post("/send-mail-otp", status=302)
|
||||
|
||||
backend.reload(user)
|
||||
otp = user.one_time_password
|
||||
assert len(smtpd.messages) == 1
|
||||
email_content = str(smtpd.messages[0].get_payload()[0]).replace("=\n", "")
|
||||
assert otp in email_content
|
||||
|
||||
assert (
|
||||
"success",
|
||||
"Code successfully sent!",
|
||||
) in res.flashes
|
||||
assert (
|
||||
"canaille",
|
||||
logging.SECURITY,
|
||||
"Sent one-time password for user to john@doe.com from unknown IP",
|
||||
) in caplog.record_tuples
|
||||
|
||||
assert res.location == "/verify-2fa"
|
|
@ -737,7 +737,9 @@ def test_edition_invalid_group(testclient, logged_admin, user, foo_group):
|
|||
|
||||
|
||||
@pytest.mark.parametrize("otp_method", ["TOTP", "HOTP"])
|
||||
def test_account_reset_mfa(testclient, backend, logged_admin, user_otp, otp_method):
|
||||
def test_account_reset_otp(
|
||||
testclient, backend, caplog, logged_admin, user_otp, otp_method
|
||||
):
|
||||
testclient.app.config["CANAILLE"]["OTP_METHOD"] = otp_method
|
||||
|
||||
old_token = user_otp.secret_token
|
||||
|
@ -745,14 +747,19 @@ def test_account_reset_mfa(testclient, backend, logged_admin, user_otp, otp_meth
|
|||
assert user_otp.last_otp_login is not None
|
||||
|
||||
res = testclient.get("/profile/user/settings")
|
||||
res.mustcontain("Reset multi-factor authentication")
|
||||
res.mustcontain("Reset one-time password authentication")
|
||||
|
||||
res = res.form.submit(name="action", value="confirm-reset-mfa")
|
||||
res = res.form.submit(name="action", value="reset-mfa")
|
||||
res = res.form.submit(name="action", value="confirm-reset-otp")
|
||||
res = res.form.submit(name="action", value="reset-otp")
|
||||
user = backend.get(models.User, id=user_otp.id)
|
||||
assert user.secret_token is not None
|
||||
assert user.secret_token != old_token
|
||||
assert user.last_otp_login is None
|
||||
if otp_method == "HOTP":
|
||||
assert user.hotp_counter == 1
|
||||
res.mustcontain("Multi-factor authentication has been reset")
|
||||
res.mustcontain("One-time password authentication has been reset")
|
||||
assert (
|
||||
"canaille",
|
||||
logging.SECURITY,
|
||||
"Reset one-time password authentication for user by admin from unknown IP",
|
||||
) in caplog.record_tuples
|
||||
|
|
|
@ -33,6 +33,10 @@ def test_signin_and_out_with_otp(testclient, user_otp, caplog, otp_method):
|
|||
|
||||
res.form["password"] = "correct horse battery staple"
|
||||
res = res.form.submit(status=302)
|
||||
assert (
|
||||
"info",
|
||||
"Please enter the one-time password from your authenticator app.",
|
||||
) in res.flashes
|
||||
res = res.follow(status=200)
|
||||
|
||||
with testclient.session_transaction() as session:
|
Loading…
Reference in a new issue