diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a9e4c5a80b950e33977b0135243475ad33d461e2..ff6359afd6d3dff947f499a8656866b23aeb0a0d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,14 @@ All notable changes to this project will be documented in this file. The format is based on `Keep a Changelog`_, and this project adheres to `Semantic Versioning`_. +`3.0b1` - 2023-02-27 +-------------------- + +Added +~~~~~ + +* Support for two factor authentication via email codes and Webauthn. + `3.0b0` - 2023-02-15 -------------------- @@ -54,7 +62,18 @@ Changed * Introduce PBKDF2-SHA1 password hashing * Persistent database connections are now health-checked as to not fail requests +<<<<<<< HEAD * Use write-through cache for sessions to retain them on clear_cache +======= +* Incorporate SPDX license list for app licenses on About page +* [Dev] The undocumented field `check` on `DataCheckResult` was renamed to `data_check` +* Frontend bundling migrated from Webpack to Vite +* Get dashboard widgets and data checks from apps with new registration mechanism. +* Use write-through cache for sessions to retain on clear_cache +* Better error page with redirect option to login page when user has no permission to access a route. +* Users now can setup as many 2FA devices as they want. +* The 2FA profile overview was completely redesigned. +>>>>>>> origin/master Fixed ~~~~~ @@ -1037,3 +1056,4 @@ Fixed .. _2.12.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.12.1 .. _2.12.2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.12.2 .. _3.0b0: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/3.0b0 +.. _3.0b1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/3.0b0 diff --git a/aleksis/core/frontend/components/two_factor/TwoFactor.vue b/aleksis/core/frontend/components/two_factor/TwoFactor.vue new file mode 100644 index 0000000000000000000000000000000000000000..b6c023a55ffa3d9cf467d88109b46d78cf630ab0 --- /dev/null +++ b/aleksis/core/frontend/components/two_factor/TwoFactor.vue @@ -0,0 +1,123 @@ +<template> + <div> + <h1 class="mb-4">{{ $t("accounts.two_factor.title") }}</h1> + <div v-if="$apollo.queries.twoFactor.loading"> + <v-skeleton-loader type="card,card"></v-skeleton-loader> + </div> + <div v-else-if="twoFactor && twoFactor.activated"> + <v-card class="mb-4"> + <v-card-title> + {{ $t("accounts.two_factor.primary_device_title") }} + </v-card-title> + <v-card-text> + {{ $t("accounts.two_factor.primary_device_description") }} + </v-card-text> + <v-list three-line> + <two-factor-device :device="twoFactor.defaultDevice" primary /> + </v-list> + </v-card> + + <v-card class="mb-4"> + <v-card-title> + {{ $t("accounts.two_factor.other_devices_title") }} + </v-card-title> + <v-card-text> + {{ $t("accounts.two_factor.other_devices_description") }} + </v-card-text> + <v-list three-line> + <div v-for="(device, index) in twoFactor.otherDevices" :key="index"> + <two-factor-device :device="device" /> + <v-divider /> + </div> + + <two-factor-device-base icon="mdi-backup-restore"> + <template #title> + {{ $t("accounts.two_factor.backup_codes_title") }} + </template> + <template #subtitles> + <v-list-item-subtitle> + {{ $t("accounts.two_factor.backup_codes_description") }} + </v-list-item-subtitle> + <v-list-item-subtitle> + {{ + $tc( + "accounts.two_factor.backup_codes_count", + twoFactor.backupTokensCount, + { counter: twoFactor.backupTokensCount } + ) + }} + </v-list-item-subtitle> + </template> + <template #action> + <v-btn icon :to="{ name: 'core.twoFactor.backupTokens' }"> + <v-icon>mdi-chevron-right</v-icon> + </v-btn> + </template> + </two-factor-device-base> + </v-list> + <v-card-actions> + <v-btn text color="primary" :to="{ name: 'core.twoFactor.add' }"> + <v-icon left>mdi-key-plus</v-icon> + {{ $t("accounts.two_factor.add_authentication_method") }} + </v-btn> + </v-card-actions> + </v-card> + + <v-card class="mb-4"> + <v-card-title>{{ + $t("accounts.two_factor.disable_title") + }}</v-card-title> + <v-card-text> + {{ $t("accounts.two_factor.disable_description") }} + </v-card-text> + <v-card-actions> + <v-btn + color="red" + class="white--text" + :to="{ name: 'core.twoFactor.disable' }" + > + <v-icon left>mdi-power</v-icon> + {{ $t("accounts.two_factor.disable_button") }} + </v-btn> + </v-card-actions> + </v-card> + </div> + <div v-else> + <v-card class="mb-4"> + <v-card-title> + {{ $t("accounts.two_factor.enable_title") }} + </v-card-title> + <v-card-text> + {{ $t("accounts.two_factor.enable_description") }} + </v-card-text> + <v-card-actions> + <v-btn + color="green" + class="white--text" + :to="{ name: 'core.twoFactor.setup' }" + > + <v-icon left>mdi-power</v-icon> + {{ $t("accounts.two_factor.enable_button") }} + </v-btn> + </v-card-actions> + </v-card> + </div> + </div> +</template> + +<script> +import gqlTwoFactor from "./twoFactor.graphql"; +import TwoFactorDevice from "./TwoFactorDevice.vue"; +import TwoFactorDeviceBase from "./TwoFactorDeviceBase.vue"; + +export default { + name: "TwoFactor", + components: { TwoFactorDeviceBase, TwoFactorDevice }, + apollo: { + twoFactor: { + query: gqlTwoFactor, + fetchPolicy: "network-only", + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/two_factor/TwoFactorDevice.vue b/aleksis/core/frontend/components/two_factor/TwoFactorDevice.vue new file mode 100644 index 0000000000000000000000000000000000000000..7af3fbe6452262e305575b4f7c1852ae79e039c5 --- /dev/null +++ b/aleksis/core/frontend/components/two_factor/TwoFactorDevice.vue @@ -0,0 +1,58 @@ +<template> + <two-factor-device-base :icon="icon"> + <template #title>{{ device.methodVerboseName }}</template> + <template #subtitles> + <v-list-item-subtitle> + {{ $t("accounts.two_factor.methods." + device.methodCode) }} + </v-list-item-subtitle> + <v-list-item-subtitle + v-if="device.methodCode === 'call' || device.methodCode === 'sms'" + class="black--text" + > + {{ device.verboseName }} + <v-icon class="ml-1" color="green" small v-if="device.confirmed"> + mdi-check-circle-outline + </v-icon> + </v-list-item-subtitle> + </template> + <template #action> + <!-- <v-btn icon color="red" v-if="!primary">--> + <!-- <v-icon>mdi-delete</v-icon>--> + <!-- </v-btn>--> + </template> + </two-factor-device-base> +</template> + +<script> +import TwoFactorDeviceBase from "./TwoFactorDeviceBase.vue"; + +const iconMap = { + sms: "mdi-message-text-outline", + call: "mdi-phone-outline", + webauthn: "mdi-key-outline", + email: "mdi-email-outline", + yubikey: "mdi-key", +}; +export default { + name: "TwoFactorDevice", + components: { TwoFactorDeviceBase }, + computed: { + icon() { + if (this.device && this.device.methodCode in iconMap) { + return iconMap[this.device.methodCode]; + } + return "mdi-two-factor-authentication"; + }, + }, + props: { + device: { + type: Object, + required: true, + }, + primary: { + type: Boolean, + default: false, + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/two_factor/TwoFactorDeviceBase.vue b/aleksis/core/frontend/components/two_factor/TwoFactorDeviceBase.vue new file mode 100644 index 0000000000000000000000000000000000000000..f8244e95a4ac593525bf92bf238ca9b86a69c1ed --- /dev/null +++ b/aleksis/core/frontend/components/two_factor/TwoFactorDeviceBase.vue @@ -0,0 +1,28 @@ +<template> + <v-list-item> + <v-list-item-icon> + <v-icon color="grey darken-2" x-large>{{ icon }}</v-icon> + </v-list-item-icon> + <v-list-item-content> + <v-list-item-title class="font-weight-medium" + ><slot name="title" + /></v-list-item-title> + <slot name="subtitles" /> + </v-list-item-content> + <v-list-item-action> + <slot name="action" /> + </v-list-item-action> + </v-list-item> +</template> + +<script> +export default { + name: "TwoFactorDeviceBase", + props: { + icon: { + type: String, + required: true, + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/two_factor/twoFactor.graphql b/aleksis/core/frontend/components/two_factor/twoFactor.graphql new file mode 100644 index 0000000000000000000000000000000000000000..431215ed1840f9ad06e61e1c8c63cf6fe0af35b7 --- /dev/null +++ b/aleksis/core/frontend/components/two_factor/twoFactor.graphql @@ -0,0 +1,26 @@ +{ + twoFactor { + activated + backupTokensCount + defaultDevice { + persistentId + name + confirmed + action + verboseAction + verboseName + methodCode + methodVerboseName + } + otherDevices { + persistentId + name + confirmed + action + verboseAction + verboseName + methodCode + methodVerboseName + } + } +} diff --git a/aleksis/core/frontend/messages/en.json b/aleksis/core/frontend/messages/en.json index 1ae46aa9117adb9aef2a0ebb3eb6262814942e8d..3adabc47bc60d1bfca2dfd74b0c4b31591c2ea88 100644 --- a/aleksis/core/frontend/messages/en.json +++ b/aleksis/core/frontend/messages/en.json @@ -45,7 +45,30 @@ "menu_title": "Third-party Accounts" }, "two_factor": { - "menu_title": "2FA" + "menu_title": "2FA", + "title": "Two-Factor Authentication", + "primary_device_title": "Primary Authentication Device", + "primary_device_description": "While logging in, AlekSIS will ask you to confirm the login with the following device. If this device is not available, you can use a backup device.", + "other_devices_title": "Other Authentication Devices", + "other_devices_description": "If your primary authentication device is not available during logging in, you can use one of these devices:", + "methods": { + "generator": "You generate one-time codes using a code generator.", + "email": "We will send you one-time codes to your e-mail address.", + "sms": "We will send you one-time codes to your mobile phone number.", + "call": "We will call you at your mobile phone and tell you a one-time code.", + "webauthn": "You use a security key (either as external device or integrated in your personal device).", + "yubikey": "You use a Yubikey to generate one-time codes." + }, + "add_authentication_method": "Add Authentication Method", + "backup_codes_title": "Backup Codes", + "backup_codes_description": "If you can't use any of your devices, you can access your account using backup codes.", + "backup_codes_count": "You have no backup codes remaining.|You have only one backup code remaining.|You have {counter} backup codes remaining.", + "disable_title": "Disable Two-Factor Authentication", + "disable_description": "However we strongly discourage you to do so, you can also disable two-factor authentication for your account.", + "disable_button": "Disable Two-Factor Authentication", + "enable_title": "Two-Factor Authentication Currently Disabled", + "enable_description": "Two-factor authentication is not enabled for your account. Enable two-factor authentication for enhanced account security.", + "enable_button": "Enable Two-Factor Authentication" } }, "actions": { diff --git a/aleksis/core/frontend/routes.js b/aleksis/core/frontend/routes.js index f165248e00cf9e802e7706a220f26e3c14e4b85e..215f4d1485644883eef42f14216735547abb041a 100644 --- a/aleksis/core/frontend/routes.js +++ b/aleksis/core/frontend/routes.js @@ -729,10 +729,7 @@ const routes = [ }, { path: "/account/two_factor/", - component: () => import("./components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, + component: () => import("./components/two_factor/TwoFactor.vue"), name: "core.twoFactor", meta: { inAccountMenu: true, @@ -749,6 +746,14 @@ const routes = [ }, name: "core.twoFactor.setup", }, + { + path: "/account/two_factor/add/", + component: () => import("./components/LegacyBaseTemplate.vue"), + props: { + byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, + }, + name: "core.twoFactor.add", + }, { path: "/account/two_factor/qrcode/", component: () => import("./components/LegacyBaseTemplate.vue"), diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py index c0c53f0add9d6690c866e2e178a106ed0faff0e9..deab0e28b5d93e854a020aad687a618817196536 100644 --- a/aleksis/core/schema/__init__.py +++ b/aleksis/core/schema/__init__.py @@ -24,6 +24,7 @@ from .person import PersonMutation, PersonType from .school_term import SchoolTermType # noqa from .search import SearchResultType from .system_properties import SystemPropertiesType +from .two_factor import TwoFactorType from .user import UserType @@ -56,6 +57,8 @@ class Query(graphene.ObjectType): dynamic_routes = graphene.List(DynamicRouteType) + two_factor = graphene.Field(TwoFactorType) + def resolve_ping(root, info, payload) -> str: return payload @@ -149,6 +152,11 @@ class Query(graphene.ObjectType): return dynamic_routes + def resolve_two_factor(root, info, **kwargs): + if info.context.user.is_anonymous: + return None + return info.context.user + class Mutation(graphene.ObjectType): update_person = PersonMutation.Field() diff --git a/aleksis/core/schema/two_factor.py b/aleksis/core/schema/two_factor.py new file mode 100644 index 0000000000000000000000000000000000000000..6f5f81e7860b791db7434debd27150dd14644bf0 --- /dev/null +++ b/aleksis/core/schema/two_factor.py @@ -0,0 +1,96 @@ +import graphene +from graphene import ObjectType +from two_factor.plugins.email.utils import mask_email +from two_factor.plugins.phonenumber.utils import ( + backup_phones, + format_phone_number, + get_available_phone_methods, + mask_phone_number, +) +from two_factor.plugins.registry import registry +from two_factor.utils import default_device + + +class TwoFactorDeviceType(ObjectType): + persistent_id = graphene.ID() + name = graphene.String() + method_code = graphene.String() + verbose_name = graphene.String() + confirmed = graphene.Boolean() + method_verbose_name = graphene.String() + + action = graphene.String() + verbose_action = graphene.String() + + def get_method(root, info, **kwargs): + if getattr(root, "method", None): + return registry.get_method(root.method) + return registry.method_from_device(root) + + def resolve_action(root, info, **kwargs): + method = TwoFactorDeviceType.get_method(root, info, **kwargs) + return method.get_action(root) + + def resolve_verbose_action(root, info, **kwargs): + method = TwoFactorDeviceType.get_method(root, info, **kwargs) + return method.get_verbose_action(root) + + def resolve_verbose_name(root, info, **kwargs): + method = TwoFactorDeviceType.get_method(root, info, **kwargs) + if method.code in ["sms", "call"]: + return mask_phone_number(format_phone_number(root.number)) + elif method.code == "email": + email = root.email or root.user.email + if email: + return mask_email(email) + + return method.verbose_name + + def resolve_method_verbose_name(root, info, **kwargs): + method = TwoFactorDeviceType.get_method(root, info, **kwargs) + return method.verbose_name + + def resolve_method_code(root, info, **kwargs): + method = TwoFactorDeviceType.get_method(root, info, **kwargs) + + return method.code + + +class PhoneTwoFactorDeviceType(TwoFactorDeviceType): + number = graphene.String + + +class TwoFactorType(ObjectType): + activated = graphene.Boolean() + default_device = graphene.Field(TwoFactorDeviceType) + backup_phones = graphene.List(PhoneTwoFactorDeviceType) + other_devices = graphene.List(TwoFactorDeviceType) + backup_tokens_count = graphene.Int() + phone_methods_available = graphene.Boolean() + + def resolve_backup_tokens_count(root, info, **kwargs): + try: + backup_tokens = root.staticdevice_set.all()[0].token_set.count() + except Exception: + backup_tokens = 0 + return backup_tokens + + def resolve_phone_methods_available(root, info, **kwargs): + return bool(get_available_phone_methods()) + + def resolve_default_device(root, info, **kwargs): + return default_device(root) + + def resolve_activated(root, info, **kwargs): + return bool(default_device(root)) + + def resolve_other_devices(root, info, **kwargs): + main_device = TwoFactorType.resolve_default_device(root, info, **kwargs) + other_devices = [] + for method in registry.get_methods(): + other_devices += list(method.get_other_authentication_devices(root, main_device)) + + return other_devices + + def resolve_backup_phones(root, info, **kwargs): + return backup_phones(root) diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index 1f62450df2f2278426617a90d5f1a41fd02afde9..c12ae3371376a0836bdd9898753e7f8d28317daa 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -122,6 +122,7 @@ INSTALLED_APPS = [ "html2text", "django_otp.plugins.otp_totp", "django_otp.plugins.otp_static", + "django_otp.plugins.otp_email", "django_otp", "otp_yubikey", "aleksis.core", @@ -139,7 +140,10 @@ INSTALLED_APPS = [ "dynamic_preferences.users.apps.UserPreferencesConfig", "impersonate", "two_factor", + "two_factor.plugins.email", "two_factor.plugins.phonenumber", + "two_factor.plugins.yubikey", + "two_factor.plugins.webauthn", "material", "ckeditor", "ckeditor_uploader", @@ -756,6 +760,8 @@ if _settings.get("twilio.sid", None): TWILIO_AUTH_TOKEN = _settings.get("twilio.token") TWILIO_CALLER_ID = _settings.get("twilio.callerid") +TWO_FACTOR_WEBAUTHN_RP_NAME = _settings.get("2fa.webauthn.rp_name", "AlekSIS") + CELERY_BROKER_URL = _settings.get("celery.broker", REDIS_URL) CELERY_RESULT_BACKEND = "django-db" CELERY_CACHE_BACKEND = "django-cache" diff --git a/aleksis/core/templates/two_factor/core/login.html b/aleksis/core/templates/two_factor/core/login.html index 6145855229d9fec9f15edbd8ee597811250e2140..223c2e5237b09f7babe6cc0fa3f003a4fc3b1266 100644 --- a/aleksis/core/templates/two_factor/core/login.html +++ b/aleksis/core/templates/two_factor/core/login.html @@ -1,11 +1,15 @@ {# -*- engine:django -*- #} {% extends "two_factor/_base_focus.html" %} -{% load i18n phonenumber account socialaccount %} +{% load i18n phonenumber account socialaccount two_factor_tags %} {% block browser_title %} {% trans "Login" %} {% endblock %} +{% block extra_head %} + {{ wizard.form.media.css }} +{% endblock %} + {% block content %} {% get_providers as socialaccount_providers %} @@ -68,13 +72,19 @@ {% endblocktrans %} {% elif device.method == 'sms' %} {% blocktrans %} - We sent you a text message, please enter the tokens we - sent. + We sent you a text message, please enter the code we sent. + {% endblocktrans %} + {% elif device.method == 'email' %} + {% blocktrans %} + We sent you an email, please enter the code we sent. + {% endblocktrans %} + {% elif device.method == 'webauthn' %} + {% blocktrans %} + Please use your Webauthn-compatible device to authenticate. {% endblocktrans %} {% else %} {% blocktrans %} - Please enter the tokens generated by your token - generator. + Please enter the code generated by your code generator. {% endblocktrans %} {% endif %} {% elif wizard.steps.current == 'backup' %} @@ -108,12 +118,13 @@ <div class="card-content"> <div class="card-title">{% trans "Device currently not available?" %}</div> {% if other_devices %} - <p>{% trans "Or, alternatively, use one of your backup phones:" %}</p> + <p>{% trans "Alternatively, use one of your other authentication methods:" %}</p> <p> {% for other in other_devices %} - <button name="challenge_device" value="{{ other.persistent_id }}" class="btn margin-bottom" + <button name="challenge_device" value="{{ other.persistent_id }}" + class="btn waves-effect waves-light margin-bottom" type="submit"> - {{ other|device_action }} + {{ other|as_action }} </button> {% endfor %} </p> @@ -121,7 +132,7 @@ {% if backup_tokens %} <p>{% trans "As a last resort, you can use a backup token:" %}</p> <p> - <button name="wizard_goto_step" type="submit" value="backup" class="btn"> + <button name="wizard_goto_step" type="submit" value="backup" class="btn waves-effect waves-light"> {% trans "Use Backup Token" %} </button> </p> @@ -143,4 +154,5 @@ </div> </form> + {{ wizard.form.media.js }} {% endblock %} diff --git a/aleksis/core/templates/two_factor/core/otp_required.html b/aleksis/core/templates/two_factor/core/otp_required.html index 734b0a9c8193eedef6989cae00ffe9bacf1277d6..a5f92b3e6b8e62cea5b7c3b5b1a1cecabdbaf470 100644 --- a/aleksis/core/templates/two_factor/core/otp_required.html +++ b/aleksis/core/templates/two_factor/core/otp_required.html @@ -7,18 +7,15 @@ <div class="card-content white-text"> <i class="material-icons small left">error_outline</i> <span class="card-title">{% blocktrans %}Permission Denied{% endblocktrans %}</span> - <p>{% blocktrans %}The page you requested, enforces users to verify using - two-factor authentication for security reasons. You need to enable these - security features in order to access this page.{% endblocktrans %}</p> + <p>{% blocktrans %}The page you requested enforces users to verify using + two-factor authentication for security reasons. You need to enable this + security feature in order to access this page.{% endblocktrans %}</p> - <p>{% blocktrans %}Two-factor authentication is not enabled for your - account. Enable two-factor authentication for enhanced account - security.{% endblocktrans %}</p> <p> <a href="javascript:history.go(-1)" class="pull-right btn waves-effect waves-light"> {% trans "Go back" %} </a> - <a href="{% url 'two_factor:setup' %}" class="btn green waves-effect waves-light"> + <a href="{% url 'setup_two_factor_auth' %}" class="btn green waves-effect waves-light"> {% trans "Enable Two-Factor Authentication" %}</a> </p> </div> diff --git a/aleksis/core/templates/two_factor/core/setup.html b/aleksis/core/templates/two_factor/core/setup.html index a0e30472db11566a58b1c00e06b5ad6689b6e681..deccda77c3cd14b009ee5ea83654f4d37c6969fc 100644 --- a/aleksis/core/templates/two_factor/core/setup.html +++ b/aleksis/core/templates/two_factor/core/setup.html @@ -1,15 +1,19 @@ {% extends "two_factor/_base_focus.html" %} {% load i18n %} +{% block extra_head %} + {{ wizard.form.media.css }} +{% endblock %} + {% block content %} - <h1>{% block title %}{% trans "Enable Two-Factor Authentication" %}{% endblock %}</h1> + <h1>{% block title %}{% trans "Add Two-Factor Authentication Method" %}{% endblock %}</h1> {% if wizard.steps.current == 'welcome' %} <p class="flow-text"> {% blocktrans %} You are about to take your account security to the - next level. Follow the steps in this wizard to enable two-factor - authentication. + next level. Follow the steps in this wizard to add + a two-factor authentication method to your account. {% endblocktrans %} </p> {% elif wizard.steps.current == 'method' %} @@ -21,9 +25,9 @@ {% elif wizard.steps.current == 'generator' %} <p> {% blocktrans %} - To start using a token generator, please use your - favourite two factor authentication (TOTP) app to scan the QR code below. - Then, enter the token generated by the app. + To start using a code generator, please use your + favourite two-factor authentication (TOTP) app to scan the QR code below. + Then enter the token generated by the app. {% endblocktrans %} </p> <p> @@ -43,6 +47,12 @@ This number will be validated in the next step. {% endblocktrans %} </p> + {% elif wizard.steps.current == 'email' %} + <p> + {% blocktrans %} + We sent you an email, please enter the token we sent. + {% endblocktrans %} + </p> {% elif wizard.steps.current == 'validation' %} {% if challenge_succeeded %} {% if device.method == 'call' %} @@ -54,19 +64,19 @@ {% elif device.method == 'sms' %} <p> {% blocktrans %} - We sent you a text message, please enter the tokens we sent. + We sent you a text message, please enter the code we sent. {% endblocktrans %} </p> {% endif %} {% else %} - <p class="alert warning" role="alert"> + <figure class="alert warning"> {% blocktrans %} We've encountered an issue with the selected authentication method. Please go back and verify that you entered your information correctly, try again, or use a different authentication method instead. If the issue persists, contact the site administrator. {% endblocktrans %} - </p> + </figure> {% endif %} {% elif wizard.steps.current == 'yubikey' %} <p> @@ -90,4 +100,6 @@ {% include "two_factor/_wizard_actions.html" %} </form> + + {{ wizard.form.media.js }} {% endblock %} diff --git a/aleksis/core/templates/two_factor/core/setup_complete.html b/aleksis/core/templates/two_factor/core/setup_complete.html index afd9f1722b168bfe30807f5e8a4d4cb49c4bc900..87774a97c79a8693720cdb32bf829edcb11321a8 100644 --- a/aleksis/core/templates/two_factor/core/setup_complete.html +++ b/aleksis/core/templates/two_factor/core/setup_complete.html @@ -8,14 +8,12 @@ {% block content %} <h1>{% block title %}{% trans "Two-Factor Authentication successfully enabled" %}{% endblock %}</h1> - <div class="alert success"> - <p> + <figure class="alert success"> <i class="material-icons iconify left" data-icon="mdi:check-circle-outline"></i> {% blocktrans %} Congratulations, you've successfully enabled two-factor authentication. {% endblocktrans %} - </p> - </div> + </figure> {% if not phone_methods %} <a href="{% url 'two_factor:profile' %}" @@ -28,16 +26,14 @@ {% trans "Generate backup codes" %} </a> {% else %} - <div class="warning"> - <p> + <figure class="alert warning"> <i class="material-icons iconify left" data-icon="mdi:alert-outline"></i> {% blocktrans %} However, it might happen that you don't have access to - your primary token device. To enable account recovery, generate backup codes - or add a phone number. + your primary device. To enable account recovery, generate backup codes + or add other authentication methods. {% endblocktrans %} - </p> - </div> + </figure> <a href="{% url 'two_factor:profile' %}" class="btn btn-primary waves-effect waves-light"> <i class="material-icons iconify left" data-icon="mdi:arrow-left"></i> @@ -47,9 +43,9 @@ <i class="material-icons iconify left" data-icon="mdi:key-outline"></i> {% trans "Generate backup codes" %} </a> - <a href="{% url 'two_factor:phone_create' %}" class="btn green waves-effect waves-light"> - <i class="material-icons iconify left" data-icon="mdi:phone-plus"></i> - {% trans "Add Phone Number" %} + <a href="{% url 'setup_two_factor_auth' %}" class="btn green waves-effect waves-light"> + <i class="material-icons iconify left" data-icon="mdi:key-plus"></i> + {% trans "Add Another Authentication Method" %} </a> {% endif %} diff --git a/aleksis/core/templates/two_factor/profile/profile.html b/aleksis/core/templates/two_factor/profile/profile.html deleted file mode 100644 index fe96135d0dfae6fd16ffad4fece50148115c8b69..0000000000000000000000000000000000000000 --- a/aleksis/core/templates/two_factor/profile/profile.html +++ /dev/null @@ -1,92 +0,0 @@ -{% extends "two_factor/_base_focus.html" %} -{% load i18n phonenumber %} - -{% block browser_title %} - {% trans "Account Security" %} -{% endblock %} - -{% block content %} - <h1> - {% block title %}{% trans "Account Security" %}{% endblock %} - </h1> - - {% if default_device %} - {% if default_device_type == 'TOTPDevice' %} - <p>{% trans "Tokens will be generated by your token generator." %}</p> - {% elif default_device_type == 'PhoneDevice' %} - <p>{% blocktrans with primary=default_device|device_action %}Primary method: {{ primary }}{% endblocktrans %}</p> - {% elif default_device_type == 'RemoteYubikeyDevice' %} - <p>{% blocktrans %}Tokens will be generated by your YubiKey.{% endblocktrans %}</p> - {% endif %} - - {% if available_phone_methods %} - <h2>{% trans "Backup Phone Numbers" %}</h2> - <p>{% blocktrans %}If your primary method is not available, we are able to - send backup tokens to the phone numbers listed below.{% endblocktrans %}</p> - <ul class="collection"> - {% for phone in backup_phones %} - <li class="collection-item"> - {{ phone|device_action }} - <form method="post" action="{% url 'two_factor:phone_delete' phone.id %}" - onsubmit="return confirm('Are you sure?')"> - {% csrf_token %} - <button class="btn-flat red-text waves-effect waves-red" type="submit">{% trans "Unregister" %}</button> - </form> - </li> - {% endfor %} - </ul> - <p> - <a href="{% url 'two_factor:phone_create' %}" class="btn green waves-effect waves-light"> - <i class="material-icons iconify left" data-icon="mdi:phone-plus"></i> - {% trans "Add Phone Number" %} - </a> - </p> - {% endif %} - - <h2>{% trans "Backup Tokens" %}</h2> - <p> - {% blocktrans %}If you don't have any device with you, you can access - your account using backup tokens.{% endblocktrans %} - {% blocktrans count counter=backup_tokens %} - You have only one backup token remaining. - {% plural %} - You have {{ counter }} backup tokens remaining. - {% endblocktrans %} - </p> - <p> - <a href="{% url 'two_factor:backup_tokens' %}" class="btn primary waves-effect waves-light"> - <i class="material-icons iconify left" data-icon="mdi:key-outline"></i> - {% trans "Show Codes" %} - </a> - </p> - - <h2>{% trans "Disable Two-Factor Authentication" %}</h2> - <p> - {% blocktrans %} - However we strongly discourage you to do so, you can - also disable two-factor authentication for your account. - {% endblocktrans %} - </p> - <p> - <a class="btn red waves-effect waves-light" href="{% url 'two_factor:disable' %}"> - <i class="material-icons iconify left" data-icon="mdi:power"></i> - {% trans "Disable Two-Factor Authentication" %} - </a> - </p> - {% else %} - <p class="flow-text"> - {% blocktrans %} - Two-factor authentication is not enabled for your - account. Enable two-factor authentication for enhanced account - security. - {% endblocktrans %} - </p> - - <p> - <a href="{% url 'two_factor:setup' %}" class="green btn waves-effect waves-light "> - <i class="material-icons iconify left" data-icon="mdi:key-outline"></i> - {% trans "Enable Two-Factor Authentication" %} - </a> - </p> - {% endif %} -{% endblock %} diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index 00286829a4fc09d6bf8e2da4a44733281a749f98..d2c02ed4a5815bfb113fa0aff19700b153b20eb4 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -81,6 +81,12 @@ urlpatterns = [ path("invitations/", include("invitations.urls")), path("status/", views.SystemStatus.as_view(), name="system_status"), path("", include(tf_urls)), + path("account/login/", views.TwoFactorLoginView.as_view()), + path( + "account/two_factor/add/", + views.TwoFactorSetupView.as_view(), + name="setup_two_factor_auth", + ), path("school_terms/", views.SchoolTermListView.as_view(), name="school_terms"), path( "school_terms/create/", diff --git a/aleksis/core/views.py b/aleksis/core/views.py index 4779fd8a650045436b71c17d9fa262d625ece8ea..e9536003df379d5f97d1cf895d27c2a7b1623600 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -58,6 +58,8 @@ from oauth2_provider.views import AuthorizationView from reversion import set_user from reversion.views import RevisionMixin from rules.contrib.views import PermissionRequiredMixin, permission_required +from two_factor import views as two_factor_views +from two_factor.utils import devices_for_user from two_factor.views.core import LoginView as AllAuthLoginView from aleksis.core.data_checks import DataCheck, check_data @@ -1513,3 +1515,31 @@ class CustomAuthorizationView(AuthorizationView): context = super().get_context_data(**kwargs) context["no_menu"] = True return context + + +class TwoFactorSetupView(two_factor_views.SetupView): + def get(self, request, *args, **kwargs): + return super(two_factor_views.SetupView, self).get(request, *args, **kwargs) + + def get_device(self, **kwargs): + device = super().get_device(**kwargs) + + # Ensure that the device is named "backup" if it is a phone device + # to ensure compatibility with django_two_factor_auth + method = self.get_method() + if device and method.code in ("call", "sms"): + device.name = "backup" + return device + + +class TwoFactorLoginView(two_factor_views.LoginView): + def get_devices(self): + user = self.get_user() + + return devices_for_user(user) + + def get_other_devices(self, main_device): + other_devices = self.get_devices() + other_devices = list(filter(lambda x: not isinstance(x, type(main_device)), other_devices)) + + return other_devices diff --git a/docs/admin/16_config_options.rst b/docs/admin/16_config_options.rst index d87d2a3c6a7054ae847684019817f74a1324bab5..15e474708a76f1261efa820c9129ca204800d4c6 100644 --- a/docs/admin/16_config_options.rst +++ b/docs/admin/16_config_options.rst @@ -35,11 +35,6 @@ Example configuration file:: [maintenance] debug = true - # Two factor authentication with yubikey enabled, optional - [2fa] - enabled = true - yubikey = { enabled = true } - # Authentication via LDAP, optional [ldap] uri = "ldaps://ldap.myschool.edu" diff --git a/docs/user/02_personal_account.rst b/docs/user/02_personal_account.rst index b1b0a596890a75ba4cdce866d9be5d2d4829d518..fbfdd346ba3de0ee56119c17ecf598f7190d5d74 100644 --- a/docs/user/02_personal_account.rst +++ b/docs/user/02_personal_account.rst @@ -27,7 +27,8 @@ Setup two-factor authentication :alt: Configure two factor authentication AlekSIS provides two factor authentication using hardware tokens such as -yubikeys which can generate OTPs or OTP application. +yubikeys which can generate OTPs or OTP application. Additionally, +all devices are supported that make use of FIDO U2F. To configure the second factor, visit `Account → 2FA` and follow the instructions. diff --git a/pyproject.toml b/pyproject.toml index b8ad66f8135b545cce2ee196f9a3db7d4c1e033e..045ef0f53bd2d9e5ac75c004c5a767edf8ae2bdf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,7 @@ django-ipware = "^4.0" django-impersonate = "^1.4" psycopg2 = "^2.8" django_select2 = "^8.0" -django-two-factor-auth = { version = "^1.14.0", extras = [ "yubikey", "phonenumbers", "call", "sms" ] } +django-two-factor-auth = { version = "^1.15.1", extras = [ "yubikey", "phonenumbers", "call", "sms", "webauthn" ] } django-yarnpkg = "^6.0" django-material = "^1.6.0" django-dynamic-preferences = "^1.11"