diff --git a/src/onegov/swissvotes/locale/de_CH/LC_MESSAGES/onegov.swissvotes.po b/src/onegov/swissvotes/locale/de_CH/LC_MESSAGES/onegov.swissvotes.po index fdd96de421..3c5d66a22c 100644 --- a/src/onegov/swissvotes/locale/de_CH/LC_MESSAGES/onegov.swissvotes.po +++ b/src/onegov/swissvotes/locale/de_CH/LC_MESSAGES/onegov.swissvotes.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"POT-Creation-Date: 2024-04-28 15:08+0200\n" +"POT-Creation-Date: 2024-08-08 12:56+0200\n" "PO-Revision-Date: 2021-01-07 09:13+0100\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: \n" @@ -2029,6 +2029,21 @@ msgstr "Passwort geändert." msgid "Wrong username or password reset link not valid any more." msgstr "Ungültige E-Mail oder abgelaufener Link." +msgid "Failed to continue login, please ensure cookies are allowed." +msgstr "" +"Das Fortsetzen des Logins ist fehlgeschlagen, bitte stellen Sie sicher, dass " +"Sie Cookies erlauben." + +msgid "Invalid or expired TOTP provided." +msgstr "Ungültige oder abgelaufenes TOTP eingegeben." + +msgid "Please enter the six digit code from your authenticator app" +msgstr "" +"Bitte geben Sie den sechsstelligen Code aus ihrer Authenticator App ein" + +msgid "Enter TOTP" +msgstr "TOTP eingeben" + msgid "Access Denied" msgstr "Zugriff verweigert" diff --git a/src/onegov/swissvotes/locale/en_US/LC_MESSAGES/onegov.swissvotes.po b/src/onegov/swissvotes/locale/en_US/LC_MESSAGES/onegov.swissvotes.po index 7e188e396b..03e5a034eb 100644 --- a/src/onegov/swissvotes/locale/en_US/LC_MESSAGES/onegov.swissvotes.po +++ b/src/onegov/swissvotes/locale/en_US/LC_MESSAGES/onegov.swissvotes.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"POT-Creation-Date: 2024-04-28 15:08+0200\n" +"POT-Creation-Date: 2024-08-08 12:56+0200\n" "PO-Revision-Date: 2021-01-07 09:11+0100\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: \n" @@ -2073,6 +2073,18 @@ msgstr "Password changed." msgid "Wrong username or password reset link not valid any more." msgstr "Wrong username or password reset link has expired." +msgid "Failed to continue login, please ensure cookies are allowed." +msgstr "Failed to continue login, please ensure cookies are allowed." + +msgid "Invalid or expired TOTP provided." +msgstr "Invalid or expired TOTP provided." + +msgid "Please enter the six digit code from your authenticator app" +msgstr "Please enter the six digit code from your authenticator app" + +msgid "Enter TOTP" +msgstr "Enter TOTP" + msgid "Access Denied" msgstr "Access denied" diff --git a/src/onegov/swissvotes/locale/fr_CH/LC_MESSAGES/onegov.swissvotes.po b/src/onegov/swissvotes/locale/fr_CH/LC_MESSAGES/onegov.swissvotes.po index 160a8fe319..cdd7bf4d7a 100644 --- a/src/onegov/swissvotes/locale/fr_CH/LC_MESSAGES/onegov.swissvotes.po +++ b/src/onegov/swissvotes/locale/fr_CH/LC_MESSAGES/onegov.swissvotes.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"POT-Creation-Date: 2024-04-28 15:08+0200\n" +"POT-Creation-Date: 2024-08-08 12:56+0200\n" "PO-Revision-Date: 2021-01-07 09:13+0100\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: \n" @@ -2033,6 +2033,22 @@ msgstr "Le mot de passe a été changé." msgid "Wrong username or password reset link not valid any more." msgstr "E-Mail non valide ou expiration de la durée de validité du lien." +msgid "Failed to continue login, please ensure cookies are allowed." +msgstr "" +"Échec de la poursuite de la connexion, veuillez vous assurer que les cookies " +"sont autorisés." + +msgid "Invalid or expired TOTP provided." +msgstr "TOTP fourni non valide ou expiré." + +msgid "Please enter the six digit code from your authenticator app" +msgstr "" +"Veuillez saisir le code à six chiffres de votre application " +"d'authentification" + +msgid "Enter TOTP" +msgstr "Entrer TOTP" + msgid "Access Denied" msgstr "Accès refusé" diff --git a/src/onegov/swissvotes/views/auth.py b/src/onegov/swissvotes/views/auth.py index 91eccad838..fc23aeb103 100644 --- a/src/onegov/swissvotes/views/auth.py +++ b/src/onegov/swissvotes/views/auth.py @@ -1,3 +1,4 @@ +from morepath import redirect from onegov.core.security import Personal from onegov.core.security import Public from onegov.core.templates import render_template @@ -9,10 +10,13 @@ from onegov.swissvotes.layouts import MailLayout from onegov.user import Auth from onegov.user import UserCollection +from onegov.user.auth.second_factor import TOTPFactor from onegov.user.forms import LoginForm from onegov.user.forms import PasswordResetForm from onegov.user.forms import RequestPasswordResetForm +from onegov.user.forms import TOTPForm from onegov.user.utils import password_reset_url +from webob import exc from typing import TYPE_CHECKING @@ -171,3 +175,61 @@ def handle_password_reset( 'form': form, 'button_text': _("Submit"), } + + +@SwissvotesApp.form( + model=Auth, + name='totp', + template='form.pt', + permission=Public, + form=TOTPForm +) +def handle_totp_second_factor( + self: Auth, + request: 'SwissvotesRequest', + form: TOTPForm +) -> 'RenderData | Response': + + if not request.app.totp_enabled: + raise exc.HTTPNotFound() + + @request.after + def respond_with_no_index(response: 'Response') -> None: + response.headers['X-Robots-Tag'] = 'noindex' + + users = UserCollection(request.session) + username = request.browser_session.get('pending_username') + user = users.by_username(username) if username else None + if user is None: + if request.is_logged_in: + # redirect already logged in users to the redirect_to + return self.redirect(request, self.to) + + request.alert( + _("Failed to continue login, please ensure cookies are allowed.") + ) + return redirect(request.link(self, name='login')) + + if form.submitted(request): + assert form.totp.data is not None + factor = self.factors['totp'] + assert isinstance(factor, TOTPFactor) + + if factor.is_valid(request, user, form.totp.data): + del request.browser_session['pending_username'] + return self.complete_login(user, request) + else: + request.alert(_('Invalid or expired TOTP provided.')) + client = request.client_addr or 'unknown' + log.info(f'Failed login by {client} (TOTP)') + else: + request.info( + _('Please enter the six digit code from your authenticator app') + ) + + return { + 'layout': DefaultLayout(self, request), + 'title': _('Enter TOTP'), + 'form': form, + 'form_width': 'small' + } diff --git a/tests/onegov/swissvotes/test_views_auth.py b/tests/onegov/swissvotes/test_views_auth.py index 6350eab953..ac3c2ed515 100644 --- a/tests/onegov/swissvotes/test_views_auth.py +++ b/tests/onegov/swissvotes/test_views_auth.py @@ -1,6 +1,11 @@ import onegov import os +import pyotp +import transaction + from lxml.html import document_fromstring +from onegov.user import UserCollection +from sqlalchemy.orm.session import close_all_sessions from tests.shared import Client, utils @@ -87,3 +92,35 @@ def test_view_reset_password(swissvotes_app): login_page.form['password'] = 'password2' login_page = login_page.form.submit().maybe_follow() assert "Abmelden" in login_page.maybe_follow() + + +def test_login_totp(swissvotes_app): + swissvotes_app.totp_enabled = True + client = Client(swissvotes_app) + + totp_secret = pyotp.random_base32() + totp = pyotp.TOTP(totp_secret) + + # configure TOTP for admin user + users = UserCollection(client.app.session()) + admin = users.by_username('admin@example.org') + admin.second_factor = {'type': 'totp', 'data': totp_secret} + transaction.commit() + close_all_sessions() + + login_page = client.get('/auth/login') + login_page.form['username'] = 'admin@example.org' + login_page.form['password'] = 'hunter2' + + totp_page = login_page.form.submit().maybe_follow() + assert "Bitte geben Sie den sechsstelligen Code" in totp_page.text + totp_page.form['totp'] = 'bogus' + totp_page = totp_page.form.submit() + assert "Ungültige oder abgelaufenes TOTP eingegeben." in totp_page.text + + totp_page.form['totp'] = totp.now() + page = totp_page.form.submit().maybe_follow() + assert 'Abmelden' in page + + page = client.get('/').maybe_follow().click('Abmelden').maybe_follow() + assert 'Abmelden' not in page