Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision

Target

Select target project
  • uffd/uffd
  • rixx/uffd
  • thies/uffd
  • leona/uffd
  • enbewe/uffd
  • strifel/uffd
  • thies/uffd-2
7 results
Select Git revision
Show changes
Showing
with 3419 additions and 1110 deletions
{% extends 'base.html' %} {% extends 'base.html' %}
{% macro new_email_row(tmp_id) %}
<tr>
<td>
<input class="form-control form-control-sm" type="email" name="newemail-{{ tmp_id }}-address" placeholder="{{ _('New address') }}">
</td>
<td class="text-center">
<input type="checkbox" value="1" name="newemail-{{ tmp_id }}-verified">
</td>
<td class="text-center">
<button type="button" class="btn btn-sm delete-new-email-row d-none"><i class="fas fa-times"></i></button>
</td>
</tr>
{% endmacro %}
{% block body %} {% block body %}
<form action="{{ url_for("user.update", uid=user.uid) }}" method="POST"> {% if user.id %}
<form action="{{ url_for("user.update", id=user.id) }}" method="POST" autocomplete="off">
{% else %}
<form action="{{ url_for("user.create") }}" method="POST" autocomplete="off">
{% endif %}
<div class="align-self-center"> <div class="align-self-center">
<div class="float-sm-right pb-2"> {% if user.id and user.is_deactivated %}
<div class="alert alert-warning">
{{ _('This account is deactivated. The user cannot login and existing sessions are not usable. The user cannot log into services, but existing sessions on services might still be active.') }}
</div>
{% endif %}
<div class="clearfix pb-2"><div class="float-sm-right">
<button type="submit" class="btn btn-primary"><i class="fa fa-save" aria-hidden="true"></i> {{_("Save")}}</button> <button type="submit" class="btn btn-primary"><i class="fa fa-save" aria-hidden="true"></i> {{_("Save")}}</button>
<a href="{{ url_for("user.index") }}" class="btn btn-secondary">{{_("Cancel")}}</a> <a href="{{ url_for("user.index") }}" class="btn btn-secondary">{{_("Cancel")}}</a>
{% if user.uid %} {% if user.id and not user.is_deactivated and user != request.user %}
<a href="{{ url_for("mfa.admin_disable", uid=user.uid) }}" class="btn btn-secondary">{{_("Reset 2FA")}}</a> <a href="{{ url_for("user.deactivate", id=user.id) }}" class="btn btn-secondary">{{ _("Deactivate") }}</a>
<a href="{{ url_for("user.delete", uid=user.uid) }}" onClick='return confirm({{_("Are you sure?")|tojson}});' class="btn btn-danger"><i class="fa fa-trash" aria-hidden="true"></i> {{_("Delete")}}</a> {% elif user.id and user.is_deactivated %}
<a href="{{ url_for("user.activate", id=user.id) }}" class="btn btn-primary">{{ _("Activate") }}</a>
{% else %}
<a href="#" class="btn btn-secondary disabled">{{ _("Deactivate") }}</a>
{% endif %}
{% if user.id and user != request.user %}
<a href="{{ url_for("user.delete", id=user.id) }}" onClick='return confirm({{_("Are you sure?")|tojson}});' class="btn btn-danger"><i class="fa fa-trash" aria-hidden="true"></i> {{_("Delete")}}</a>
{% else %} {% else %}
<a href="#" class="btn btn-danger disabled"><i class="fa fa-trash" aria-hidden="true"></i> {{_("Delete")}}</a> <a href="#" class="btn btn-danger disabled"><i class="fa fa-trash" aria-hidden="true"></i> {{_("Delete")}}</a>
{% endif %} {% endif %}
</div> </div></div>
<ul class="nav nav-tabs pt-2 border-0" id="tablist" role="tablist"> <ul class="nav nav-tabs pt-2 border-0" id="tablist" role="tablist">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link active" id="profile-tab" data-toggle="tab" href="#profile" role="tab" aria-controls="profile" aria-selected="true">{{_("Profile")}}</a> <a class="nav-link active" id="profile-tab" data-toggle="tab" href="#profile" role="tab" aria-controls="profile" aria-selected="true">{{_("Profile")}}</a>
...@@ -30,13 +60,13 @@ ...@@ -30,13 +60,13 @@
<span class="badge badge-secondary">{{_('service')}}</span> <span class="badge badge-secondary">{{_('service')}}</span>
{% endif %} {% endif %}
</label> </label>
{% if user.uid %} {% if user.id %}
<input type="number" class="form-control" id="user-uid" name="uid" value="{{ user.uid or '' }}" readonly> <input type="number" class="form-control" id="user-uid" name="uid" value="{{ user.unix_uid }}" readonly>
{% else %} {% else %}
<input type="text" class="form-control" id="user-uid" name="uid" placeholder="{{_('will be choosen')}}" readonly> <input type="text" class="form-control" id="user-uid" name="uid" placeholder="{{_('will be choosen')}}" readonly>
{% endif %} {% endif %}
</div> </div>
{% if not user.uid %} {% if not user.id %}
<div class="form-group col"> <div class="form-group col">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="user-serviceaccount" name="serviceaccount" value="1" aria-label="enabled"> <input class="form-check-input" type="checkbox" id="user-serviceaccount" name="serviceaccount" value="1" aria-label="enabled">
...@@ -46,16 +76,16 @@ ...@@ -46,16 +76,16 @@
{% endif %} {% endif %}
<div class="form-group col"> <div class="form-group col">
<label for="user-loginname">{{_("Login Name")}}</label> <label for="user-loginname">{{_("Login Name")}}</label>
<input type="text" class="form-control" id="user-loginname" name="loginname" value="{{ user.loginname or '' }}" {% if user.uid %}readonly{% endif %}> <input type="text" class="form-control" id="user-loginname" name="loginname" value="{{ user.loginname or '' }}" {% if user.id %}readonly{% endif %}>
<small class="form-text text-muted"> <small class="form-text text-muted">
{{_("Only letters, numbers, dashes (\"-\") and underscores (\"_\") are allowed. At most 32, at least 2 characters. There is a word blacklist. Must be unique.")}} {{_("Only letters, numbers, dashes (\"-\") and underscores (\"_\") are allowed. At most 32, at least 2 characters. There is a word blocklist. Must be unique.")}}
</small> </small>
</div> </div>
{% if not user.uid %} {% if not user.id %}
<div class="form-group col"> <div class="form-group col">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="ignore-loginname-blacklist" name="ignore-loginname-blacklist" value="1" aria-label="enabled"> <input class="form-check-input" type="checkbox" id="ignore-loginname-blocklist" name="ignore-loginname-blocklist" value="1" aria-label="enabled">
<label class="form-check-label" for="ignore-loginname-blacklist">{{_('Ignore login name blacklist')}}</label> <label class="form-check-label" for="ignore-loginname-blocklist">{{_('Ignore login name blocklist')}}</label>
</div> </div>
</div> </div>
{% endif %} {% endif %}
...@@ -66,24 +96,111 @@ ...@@ -66,24 +96,111 @@
{{_("If you leave this empty it will be set to the login name. At most 128, at least 2 characters. No character restrictions.")}} {{_("If you leave this empty it will be set to the login name. At most 128, at least 2 characters. No character restrictions.")}}
</small> </small>
</div> </div>
{% if not user.id %}
<div class="form-group col"> <div class="form-group col">
<label for="user-mail">{{_("Mail")}}</label> <label for="user-email">{{_("E-Mail Address")}}</label>
<input type="email" class="form-control" id="user-mail" name="mail" value="{{ user.mail or '' }}"> <input type="email" class="form-control" id="user-email" name="email" value="">
<small class="form-text text-muted"> <small class="form-text text-muted">
{{_("Do a sanity check here. A user can take over another account if both have the same mail address set.")}} {{_("Make sure the address is correct! Services might use e-mail addresses as account identifiers and rely on them being unique and verified.")}}
</small> </small>
</div> </div>
{% else %}
<div class="form-group col">
<span>{{_("E-Mail Addresses")}}</span>
<table class="table table-sm mt-2">
<thead>
<tr>
<th scope="col">{{_("Address")}}</th>
<th scope="col" class="text-center">{{_("Verified")}}</th>
<th scope="col" class="text-center">{{_("Delete")}}</th>
</tr>
</thead>
<tbody id="email-rows">
{% for email in user.all_emails %}
<tr>
<td>
<input type="hidden" name="email-{{ email.id }}-present" value="1">
{{ email.address }}
{% if email == user.primary_email %}
<span class="badge badge-primary">{{ _('primary') }}</span>
{% endif %}
</td>
<td class="text-center">
<input type="checkbox" value="1" name="email-{{ email.id }}-verified"{{ ' checked disabled' if email.verified }}>
</td>
<td class="text-center">
<input type="checkbox" value="1" name="email-{{ email.id }}-delete"{{ ' disabled' if email == user.primary_email }}>
</td>
</tr>
{% endfor %}
{{ new_email_row(0) }}
</tbody>
</table>
<small class="form-text text-muted">
{{_("Make sure that addresses you add are correct! Services might use e-mail addresses as account identifiers and rely on them being unique and verified.")}}
</small>
</div>
<div class="form-group col">
<label>{{_("Primary E-Mail Address")}}</label>
<select name="primary_email" class="form-control">
{% for email in user.all_emails if email.verified %}
<option value="{{ email.id }}"{{ ' selected' if email == user.primary_email }}>{{ email.address }}</option>
{% endfor %}
</select>
</div>
<div class="form-group col">
<label>{{_("Recovery E-Mail Address")}}</label>
<select name="recovery_email" class="form-control">
<option value="primary"{{ ' selected' if not user.recovery_email }}>{{ _('Use primary address') }}</option>
{% for email in user.all_emails if email.verified %}
<option value="{{ email.id }}" {{ 'selected' if email == user.recovery_email }}>{{ email.address }}</option>
{% endfor %}
</select>
</div>
{% for service_user in user.service_users if service_user.has_email_preferences %}
<div class="form-group col">
<label>{{ _("Address for %(name)s", name=service_user.service.name) }}</label>
<select name="service_{{ service_user.service.id }}_email" class="form-control">
<option value="primary" {{ 'selected' if not service_user.service_email }}>{{ _('Use primary address') }}</option>
{% for email in user.all_emails if email.verified %}
<option value="{{ email.id }}" {{ 'selected' if email == service_user.service_email }}>{{ email.address }}</option>
{% endfor %}
</select>
</div>
{% endfor %}
{% endif %}
<div class="form-group col"> <div class="form-group col">
<label for="user-loginname">{{_("Password")}}</label> <label for="user-loginname">{{_("Password")}}</label>
{% if user.uid %} {% if user.id %}
<input type="password" class="form-control" id="user-password" name="password" placeholder="●●●●●●●●"> <input type="password" autocomplete="new-password" class="form-control" id="user-password" name="password" placeholder="●●●●●●●●" minlength={{ User.PASSWORD_MINLEN }} maxlength={{ User.PASSWORD_MAXLEN }} pattern="{{ User.PASSWORD_REGEX }}">
{% else %} {% else %}
<input type="password" class="form-control" id="user-password" name="password" placeholder="{{_("mail to set it will be sent")}}" readonly> <input type="text" class="form-control" id="user-password" name="password" placeholder="{{_("E-Mail to set it will be sent")}}" readonly>
{% endif %} {% endif %}
<small class="form-text text-muted"> <small class="form-text text-muted">
{{_("At least 8 and at most 256 characters, no other special requirements.")}} {{ User.PASSWORD_DESCRIPTION|safe }}
</small> </small>
</div> </div>
{% if user.id %}
<div class="form-group col">
<div class="mb-1">{{_("Two-Factor Authentication")}}</div>
<p>
{{ _("Status:") }} {{ _("Enabled") if user.mfa_enabled else _("Disabled") }}<br>
{{ user.mfa_recovery_codes|length }} {{ _("Recovery Codes") }}, {{ user.mfa_totp_methods|length }} {{ _("Authenticator Apps (TOTP)") }}, {{ user.mfa_webauthn_methods|length }} {{ _("U2F and FIDO2 Devices") }}
</p>
<a href="{{ url_for("user.disable_mfa", id=user.id) }}" onClick='return confirm({{_("Are you sure?")|tojson}});' class="btn btn-secondary">{{_("Reset 2FA")}}</a>
</div>
<div class="form-group col">
<div class="mb-1">{{_("Sessions")}}</div>
<p>{{ _("%(session_count)d active sessions", session_count=user.sessions|rejectattr('expired')|list|length) }}</p>
<a href="{{ url_for("user.revoke_sessions", id=user.id) }}" onClick='return confirm({{_("Are you sure?")|tojson}});' class="btn btn-secondary">{{_("Revoke all sessions")}}</a>
</div>
{% endif %}
</div> </div>
<div class="tab-pane fade" id="roles" role="tabpanel" aria-labelledby="roles-tab"> <div class="tab-pane fade" id="roles" role="tabpanel" aria-labelledby="roles-tab">
<div class="form-group col"> <div class="form-group col">
...@@ -130,9 +247,9 @@ ...@@ -130,9 +247,9 @@
</thead> </thead>
<tbody> <tbody>
{% for group in user.groups|sort(attribute="name") %} {% for group in user.groups|sort(attribute="name") %}
<tr id="group-{{ group.gid }}"> <tr id="group-{{ group.id }}">
<td> <td>
<a href="{{ url_for("group.show", gid=group.gid) }}"> <a href="{{ url_for("group.show", id=group.id) }}">
{{ group.name }} {{ group.name }}
</a> </a>
</td> </td>
...@@ -148,4 +265,17 @@ ...@@ -148,4 +265,17 @@
</div> </div>
</div> </div>
</form> </form>
<script>
$('#email-rows').on('click', '.delete-new-email-row', function () {
$(this).closest('tr').remove()
});
let new_email_id = 1;
$('#email-rows').on('input', 'tr:last input', function () {
$('#email-rows tr:last button.delete-new-email-row').removeClass('d-none');
$('#email-rows').append({{ new_email_row('TMPID')|tojson }}.replace(/TMPID/g, new_email_id));
new_email_id ++;
});
</script>
{% endblock %} {% endblock %}
# How to add new translations
Extract all translatable string from `.py` and `.html` files to a `.pot`.
```bash
pybabel extract -F babel.cfg -k lazy_gettext -o messages.pot .
```
Update the `messages.po` file to include the new / updated strings.
```bash
pybabel update -i messages.pot -d translations
```
Compile the `messages.po` file to a `messages.mo` file.
```bash
pybabel compile -d translations
```
Bonus:
Initialize a new language.
```bash
pybabel init -i messages.pot -d translations -l de
```
Complete Documentation of Flask-Babel: https://flask-babel.tkte.ch
\ No newline at end of file
No preview for this file type
...@@ -7,308 +7,408 @@ msgid "" ...@@ -7,308 +7,408 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2021-07-31 03:28+0200\n" "POT-Creation-Date: 2024-03-24 18:37+0100\n"
"PO-Revision-Date: 2021-05-25 21:18+0200\n" "PO-Revision-Date: 2021-05-25 21:18+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de\n" "Language: de\n"
"Language-Team: de <LL@li.org>\n" "Language-Team: de <LL@li.org>\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n" "Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.9.1\n" "Generated-By: Babel 2.10.3\n"
#: uffd/ratelimit.py:70 #: uffd/models/invite.py:84 uffd/models/invite.py:107 uffd/models/invite.py:112
msgid "Invite link is invalid"
msgstr "Einladungslink ist ungültig"
#: uffd/models/invite.py:86
msgid "Invite link does not grant any roles"
msgstr "Einladungslink weist keine Rollen zu"
#: uffd/models/invite.py:88
msgid "Invite link does not grant any new roles"
msgstr "Einladungslink weist keine neuen Rollen zu"
#: uffd/models/invite.py:93 uffd/models/signup.py:122
#: uffd/templates/mfa/setup.html:225
msgid "Success"
msgstr "Erfolgreich"
#: uffd/models/ratelimit.py:76
msgid "a few seconds" msgid "a few seconds"
msgstr "ein paar Sekunden" msgstr "ein paar Sekunden"
#: uffd/ratelimit.py:72 #: uffd/models/ratelimit.py:78
msgid "30 seconds" msgid "30 seconds"
msgstr "30 Sekunden" msgstr "30 Sekunden"
#: uffd/ratelimit.py:74 #: uffd/models/ratelimit.py:80
msgid "one minute" msgid "one minute"
msgstr "eine Minute" msgstr "eine Minute"
#: uffd/ratelimit.py:76 #: uffd/models/ratelimit.py:82
#, python-format #, python-format
msgid "%(minutes)d minutes" msgid "%(minutes)d minutes"
msgstr "%(minutes)d Minuten" msgstr "%(minutes)d Minuten"
#: uffd/ratelimit.py:78 #: uffd/models/ratelimit.py:84
msgid "one hour" msgid "one hour"
msgstr "eine Stunde" msgstr "eine Stunde"
#: uffd/ratelimit.py:79 #: uffd/models/ratelimit.py:85
#, python-format #, python-format
msgid "%(hours)d hours" msgid "%(hours)d hours"
msgstr "%(hours)d Stunden\"" msgstr "%(hours)d Stunden"
#: uffd/invite/views.py:56 #: uffd/models/session.py:62 uffd/models/session.py:74
msgid "Invites" #: uffd/models/session.py:80 uffd/models/session.py:90
msgstr "Einladungslinks" msgid "Unknown"
msgstr "Unbekannt"
#: uffd/invite/views.py:85 #: uffd/models/signup.py:78 uffd/models/signup.py:103
msgid "The \"Expires After\" date is too far in the future" msgid "Invalid signup request"
msgstr "Das Ablaufdatum liegt zu weit in der Zukunft" msgstr "Ungültiger Account-Registrierungs-Link"
#: uffd/invite/views.py:88 #: uffd/models/signup.py:80
msgid "You are not allowed to create invite links with these permissions" msgid "Login name is invalid"
msgstr "Dir fehlen Berechtigungen um diesen Einladungslink zu erstellen" msgstr "Anmeldename ist ungültig"
#: uffd/invite/views.py:91 #: uffd/models/signup.py:82
msgid "Invite link must either allow signup or grant at least one role" msgid "Display name is invalid"
msgstr "" msgstr "Anzeigename ist ungültig"
"Einladungslink must entweder Account-Registrierung erlauben oder Rollen "
"vergeben"
#: uffd/invite/views.py:119 uffd/invite/views.py:148 #: uffd/models/signup.py:84 uffd/views/selfservice.py:112 uffd/views/user.py:51
msgid "Invalid invite link" #: uffd/views/user.py:99
msgstr "Ungültiger Einladungslink" msgid "E-Mail address is invalid"
msgstr "Ungültige E-Mail-Adresse"
#: uffd/invite/views.py:136 #: uffd/models/signup.py:86 uffd/views/selfservice.py:49
msgid "Roles successfully updated" msgid "Invalid password"
msgstr "Rollen erfolgreich geändert" msgstr "Passwort ungültig"
#: uffd/invite/views.py:151 #: uffd/models/signup.py:88 uffd/models/signup.py:107
msgid "Invite link does not allow signup" msgid "A user with this login name already exists"
msgstr "Einladungslink erlaubt keine Account-Registrierung" msgstr "Ein Account mit diesem Anmeldenamen existiert bereits"
#: uffd/invite/views.py:173 uffd/selfservice/views.py:53 #: uffd/models/signup.py:89
#: uffd/signup/views.py:49 msgid "Valid"
msgid "Passwords do not match" msgstr "Gültig"
msgstr "Die Passwörter stimmen nicht überein"
#: uffd/invite/views.py:178 uffd/signup/views.py:54 #: uffd/models/signup.py:105 uffd/views/signup.py:104
msgid "Wrong password"
msgstr "Falsches Passwort"
#: uffd/models/signup.py:115 uffd/views/user.py:62
msgid "Login name or e-mail address is already in use"
msgstr "Der Anmeldename oder die E-Mail-Adresse wird bereits verwendet"
#: uffd/models/user.py:119
#, python-format #, python-format
msgid "Too many signup requests with this mail address! Please wait %(delay)s." msgid ""
"At least %(minlen)d and at most %(maxlen)d characters. Only letters, "
"digits, spaces and some symbols (<code>%(symbols)s</code>) allowed. "
"Please use a password manager."
msgstr "" msgstr ""
"Zu viele Account-Registrierungen mit dieser E-Mail-Adresse! Bitte warte " "%(minlen)d bis %(maxlen)d Zeichen. Nur Buchstaben, Ziffern, Leerzeichen "
"%(delay)s." "und manche Symbole (<code>%(symbols)s</code>), keine Umlaute. Bitte "
"verwende einen Passwort-Manager."
#: uffd/invite/views.py:180 uffd/signup/views.py:56 uffd/signup/views.py:92 #: uffd/templates/403.html:10
#, python-format msgid "Access Denied"
msgid "Too many requests! Please wait %(delay)s." msgstr "Zugriff verweigert"
msgstr "Zu viele Anfragen! Bitte warte %(delay)s."
#: uffd/invite/views.py:193 uffd/signup/views.py:68 #: uffd/templates/403.html:17
msgid "Cound not send mail" msgid "You don't have the permission to access this page."
msgstr "Mailversand fehlgeschlagen" msgstr "Du bist nicht berechtigt, auf diese Seite zuzugreifen."
#: uffd/templates/base.html:85
msgid "Change"
msgstr "Ändern"
#: uffd/templates/base.html:93 uffd/templates/session/deviceauth.html:12
msgid "Authorize Device Login"
msgstr "Gerätelogin erlauben"
#: uffd/templates/base.html:94 uffd/templates/session/devicelogin.html:6
msgid "Device Login"
msgstr "Gerätelogin"
#: uffd/templates/base.html:100 uffd/templates/oauth2/logout.html:5
msgid "Logout"
msgstr "Abmelden"
#: uffd/invite/templates/invite/list.html:6 #: uffd/templates/base.html:107 uffd/templates/service/overview.html:15
#: uffd/mail/templates/mail/list.html:8 uffd/role/templates/role/list.html:8 #: uffd/templates/session/login.html:6 uffd/templates/session/login.html:20
#: uffd/user/templates/user/list.html:8 msgid "Login"
msgstr "Anmelden"
#: uffd/templates/base.html:143
msgid "About uffd"
msgstr "Über uffd"
#: uffd/templates/group/list.html:8 uffd/templates/invite/list.html:6
#: uffd/templates/mail/list.html:8 uffd/templates/role/list.html:8
#: uffd/templates/service/index.html:8 uffd/templates/service/show.html:106
#: uffd/templates/service/show.html:134 uffd/templates/user/list.html:8
msgid "New" msgid "New"
msgstr "Neu" msgstr "Neu"
#: uffd/invite/templates/invite/list.html:12 #: uffd/templates/group/list.html:14
msgid "GID"
msgstr "GID"
#: uffd/templates/group/list.html:15 uffd/templates/group/show.html:26
#: uffd/templates/invite/new.html:35 uffd/templates/mail/list.html:14
#: uffd/templates/mail/show.html:7 uffd/templates/mfa/setup.html:98
#: uffd/templates/mfa/setup.html:99 uffd/templates/mfa/setup.html:107
#: uffd/templates/mfa/setup.html:157 uffd/templates/mfa/setup.html:158
#: uffd/templates/mfa/setup.html:169 uffd/templates/role/list.html:14
#: uffd/templates/rolemod/list.html:9 uffd/templates/rolemod/show.html:44
#: uffd/templates/selfservice/self.html:239
#: uffd/templates/service/index.html:14 uffd/templates/service/show.html:20
#: uffd/templates/service/show.html:140 uffd/templates/user/show.html:212
#: uffd/templates/user/show.html:244
msgid "Name"
msgstr "Name"
#: uffd/templates/group/list.html:16 uffd/templates/group/show.html:33
#: uffd/templates/invite/new.html:36 uffd/templates/role/list.html:15
#: uffd/templates/role/show.html:48 uffd/templates/rolemod/list.html:10
#: uffd/templates/rolemod/show.html:26 uffd/templates/selfservice/self.html:240
#: uffd/templates/user/show.html:213 uffd/templates/user/show.html:245
msgid "Description"
msgstr "Beschreibung"
#: uffd/templates/group/show.html:8 uffd/templates/mail/show.html:27
#: uffd/templates/role/show.html:13 uffd/templates/rolemod/show.html:8
#: uffd/templates/service/api.html:15 uffd/templates/service/oauth2.html:15
#: uffd/templates/service/show.html:16 uffd/templates/user/show.html:31
msgid "Save"
msgstr "Speichern"
#: uffd/templates/group/show.html:9 uffd/templates/invite/new.html:56
#: uffd/templates/mail/show.html:28 uffd/templates/mfa/auth.html:33
#: uffd/templates/role/show.html:14 uffd/templates/rolemod/show.html:9
#: uffd/templates/service/api.html:9 uffd/templates/service/oauth2.html:9
#: uffd/templates/service/show.html:10
#: uffd/templates/session/deviceauth.html:39
#: uffd/templates/session/deviceauth.html:49
#: uffd/templates/session/devicelogin.html:29 uffd/templates/user/show.html:32
msgid "Cancel"
msgstr "Abbrechen"
#: uffd/templates/group/show.html:11 uffd/templates/role/show.html:19
#: uffd/templates/role/show.html:21 uffd/templates/selfservice/self.html:61
#: uffd/templates/selfservice/self.html:210
#: uffd/templates/selfservice/self.html:254 uffd/templates/service/api.html:11
#: uffd/templates/service/oauth2.html:11 uffd/templates/service/show.html:12
#: uffd/templates/user/show.html:41 uffd/templates/user/show.html:193
#: uffd/templates/user/show.html:199
msgid "Are you sure?"
msgstr "Wirklich fortfahren?"
#: uffd/templates/group/show.html:11 uffd/templates/group/show.html:13
#: uffd/templates/mail/show.html:30 uffd/templates/mail/show.html:32
#: uffd/templates/mfa/setup.html:117 uffd/templates/mfa/setup.html:179
#: uffd/templates/role/show.html:21 uffd/templates/role/show.html:24
#: uffd/templates/selfservice/self.html:62 uffd/templates/service/api.html:12
#: uffd/templates/service/oauth2.html:12 uffd/templates/service/show.html:13
#: uffd/templates/user/show.html:41 uffd/templates/user/show.html:43
#: uffd/templates/user/show.html:116
msgid "Delete"
msgstr "Löschen"
#: uffd/templates/group/show.html:18
msgid "Group ID"
msgstr "Gruppen ID"
#: uffd/templates/group/show.html:29 uffd/templates/signup/start.html:18
msgid ""
"At least one and at most 32 lower-case characters, digits, dashes (\"-\")"
" or underscores (\"_\"). <b>Cannot be changed later!</b>"
msgstr ""
"1 bis 32 Kleinbuchstaben, Zahlen, Binde- (\"-\") und Unterstriche "
"(\"_\"). <b>Kann später nicht geändert werden!</b>"
#: uffd/templates/group/show.html:40 uffd/templates/role/show.html:71
#: uffd/templates/rolemod/show.html:16
msgid "Members"
msgstr "Mitglieder"
#: uffd/templates/invite/list.html:12
msgid "Link" msgid "Link"
msgstr "Link" msgstr "Link"
#: uffd/invite/templates/invite/list.html:13 #: uffd/templates/invite/list.html:13
msgid "Created by" msgid "Created by"
msgstr "Ersteller" msgstr "Erstellt durch"
#: uffd/invite/templates/invite/list.html:14 #: uffd/templates/invite/list.html:14 uffd/templates/service/api.html:34
#: uffd/templates/service/show.html:141
msgid "Permissions" msgid "Permissions"
msgstr "Berechtigungen" msgstr "Berechtigungen"
#: uffd/invite/templates/invite/list.html:15 #: uffd/templates/invite/list.html:15
msgid "Usages" msgid "Usages"
msgstr "Verwendungen" msgstr "Verwendungen"
#: uffd/invite/templates/invite/list.html:16 #: uffd/templates/invite/list.html:16
msgid "Status" msgid "Status"
msgstr "Status" msgstr "Status"
#: uffd/invite/templates/invite/list.html:26 #: uffd/templates/invite/list.html:26
msgid "Copy link to clipboard" msgid "Copy link to clipboard"
msgstr "Link kopieren" msgstr "Link kopieren"
#: uffd/invite/templates/invite/list.html:27 #: uffd/templates/invite/list.html:27
msgid "Show link as QR code" msgid "Show link as QR code"
msgstr "Link als QR-Code anzeigen" msgstr "Link als QR-Code anzeigen"
#: uffd/invite/templates/invite/list.html:42 #: uffd/templates/invite/list.html:40
msgid "Signup" msgid "Signup"
msgstr "Account-Registrierung" msgstr "Account-Registrierung"
#: uffd/invite/templates/invite/list.html:51 #: uffd/templates/invite/list.html:44
msgid "user signups"
msgstr "Account-Registrierungen"
#: uffd/templates/invite/list.html:49 uffd/templates/user/show.html:190
msgid "Disabled" msgid "Disabled"
msgstr "Deaktiviert" msgstr "Deaktiviert"
#: uffd/invite/templates/invite/list.html:53 #: uffd/templates/invite/list.html:51
msgid "Voided" msgid "Voided"
msgstr "Verbraucht" msgstr "Verbraucht"
#: uffd/invite/templates/invite/list.html:55 #: uffd/templates/invite/list.html:53
msgid "Expired" msgid "Expired"
msgstr "Abgelaufen" msgstr "Abgelaufen"
#: uffd/invite/templates/invite/list.html:57 #: uffd/templates/invite/list.html:55
msgid "Invalid, unpermitted creator" msgid "Invalid, unpermitted creator"
msgstr "Ungültig, unberechtigter Ersteller" msgstr "Ungültig, erstellt durch unberechtigten Account"
#: uffd/invite/templates/invite/list.html:59 #: uffd/templates/invite/list.html:57
msgid "Invalid" msgid "Invalid"
msgstr "Ungültig" msgstr "Ungültig"
#: uffd/invite/templates/invite/list.html:61 #: uffd/templates/invite/list.html:59
#, python-format #, python-format
msgid "Valid once, expires %(expiry_date)s" msgid "Valid once, expires %(expiry_date)s"
msgstr "Einmal verwendbar, gültig bis %(expiry_date)s" msgstr "Einmal verwendbar, gültig bis %(expiry_date)s"
#: uffd/invite/templates/invite/list.html:63 #: uffd/templates/invite/list.html:61
#, python-format #, python-format
msgid "Valid, expires %(expiry_date)s" msgid "Valid, expires %(expiry_date)s"
msgstr "Gültig bis %(expiry_date)s" msgstr "Gültig bis %(expiry_date)s"
#: uffd/invite/templates/invite/list.html:80 #: uffd/templates/invite/list.html:78
msgid "Invite Link Details" msgid "Invite Link Details"
msgstr "Details zum Einladungslink" msgstr "Details zum Einladungslink"
#: uffd/invite/templates/invite/list.html:87 #: uffd/templates/invite/list.html:85
msgid "Type:" msgid "Type:"
msgstr "Typ:" msgstr "Typ:"
#: uffd/invite/templates/invite/list.html:87 #: uffd/templates/invite/list.html:85
msgid "Single-use" msgid "Single-use"
msgstr "Einmal verwendbar" msgstr "Einmal verwendbar"
#: uffd/invite/templates/invite/list.html:87 #: uffd/templates/invite/list.html:85 uffd/templates/invite/new.html:9
#: uffd/invite/templates/invite/new.html:9
msgid "Multi-use" msgid "Multi-use"
msgstr "Mehrfach verwendbar" msgstr "Mehrfach verwendbar"
#: uffd/invite/templates/invite/list.html:88 #: uffd/templates/invite/list.html:86
msgid "Created:" msgid "Created:"
msgstr "Erstellt:" msgstr "Erstellt:"
#: uffd/invite/templates/invite/list.html:89 #: uffd/templates/invite/list.html:87
msgid "Expires:" msgid "Expires:"
msgstr "Ablaufdatum:" msgstr "Ablaufdatum:"
#: uffd/invite/templates/invite/list.html:90 #: uffd/templates/invite/list.html:88
msgid "Permissions:" msgid "Permissions:"
msgstr "Berechtigungen:" msgstr "Berechtigungen:"
#: uffd/invite/templates/invite/list.html:93 #: uffd/templates/invite/list.html:91 uffd/templates/invite/new.html:21
#: uffd/invite/templates/invite/new.html:21
msgid "Link allows account registration" msgid "Link allows account registration"
msgstr "Link erlaubt Account-Registrierung" msgstr "Link erlaubt Account-Registrierung"
#: uffd/invite/templates/invite/list.html:95 #: uffd/templates/invite/list.html:93 uffd/templates/invite/new.html:22
#: uffd/invite/templates/invite/new.html:22
msgid "No account registration allowed" msgid "No account registration allowed"
msgstr "Keine Account-Registrierung möglich" msgstr "Keine Account-Registrierung möglich"
#: uffd/invite/templates/invite/list.html:98 #: uffd/templates/invite/list.html:96
#, python-format #, python-format
msgid "Link grants users the role \"%(name)s\"" msgid "Link grants users the role \"%(name)s\""
msgstr "Link gibt Nutzern die Rolle \"%(name)s\"" msgstr "Link gibt Accounts die Rolle \"%(name)s\""
#: uffd/invite/templates/invite/list.html:104 #: uffd/templates/invite/list.html:102
msgid "Never used" msgid "Never used"
msgstr "Keine Verwendungen" msgstr "Keine Verwendungen"
#: uffd/invite/templates/invite/list.html:108 #: uffd/templates/invite/list.html:106
#, python-format #, python-format
msgid "Registration of user <a href=\"%(user_url)s\">%(user_name)s</a>" msgid "Registration of user <a href=\"%(user_url)s\">%(user_name)s</a>"
msgstr "Account-Registrierung von <a href=\"%(user_url)s\">%(user_name)s</a>" msgstr "Account-Registrierung von <a href=\"%(user_url)s\">%(user_name)s</a>"
#: uffd/invite/templates/invite/list.html:111 #: uffd/templates/invite/list.html:109
#, python-format #, python-format
msgid "Roles granted to <a href=\"%(user_url)s\">%(user_name)s</a>" msgid "Roles granted to <a href=\"%(user_url)s\">%(user_name)s</a>"
msgstr "Rollen an <a href=\"%(user_url)s\">%(user_name)s</a> vergeben" msgstr "Rollen an <a href=\"%(user_url)s\">%(user_name)s</a> vergeben"
#: uffd/invite/templates/invite/list.html:122 #: uffd/templates/invite/list.html:120
msgid "Disable Link" msgid "Disable Link"
msgstr "Link deaktivieren" msgstr "Link deaktivieren"
#: uffd/invite/templates/invite/list.html:126 #: uffd/templates/invite/list.html:124
msgid "Reenable Link" msgid "Reenable Link"
msgstr "Link reaktivieren" msgstr "Link reaktivieren"
#: uffd/invite/templates/invite/list.html:140 #: uffd/templates/invite/list.html:138
msgid "Invite" msgid "Invite"
msgstr "Einladungslink" msgstr "Einladungslink"
#: uffd/invite/templates/invite/new.html:6 #: uffd/templates/invite/new.html:6
msgid "Link Type" msgid "Link Type"
msgstr "Link Typ" msgstr "Link Typ"
#: uffd/invite/templates/invite/new.html:8 #: uffd/templates/invite/new.html:8
msgid "Valid for a single successful use" msgid "Valid for a single successful use"
msgstr "Für eine erfolgreiche Verwendung gültig" msgstr "Für eine erfolgreiche Verwendung gültig"
#: uffd/invite/templates/invite/new.html:13 #: uffd/templates/invite/new.html:13
msgid "Valid Until" msgid "Valid Until"
msgstr "Ablaufdatum" msgstr "Ablaufdatum"
#: uffd/invite/templates/invite/new.html:15 #: uffd/templates/invite/new.html:15
#, python-format #, python-format
msgid "Must be within the next %(max_valid_days)d days" msgid "Must be within the next %(max_valid_days)d days"
msgstr "Muss innerhalb der nächsten %(max_valid_days)d Tage liegen" msgstr "Muss innerhalb der nächsten %(max_valid_days)d Tage liegen"
#: uffd/invite/templates/invite/new.html:19 #: uffd/templates/invite/new.html:19 uffd/templates/signup/start.html:6
#: uffd/signup/templates/signup/start.html:11
msgid "Account Registration" msgid "Account Registration"
msgstr "Account-Registrierung" msgstr "Account-Registrierung"
#: uffd/invite/templates/invite/new.html:30 #: uffd/templates/invite/new.html:30
msgid "Granted Roles" msgid "Granted Roles"
msgstr "Enthaltene Rollen" msgstr "Enthaltene Rollen"
#: uffd/invite/templates/invite/new.html:35 #: uffd/templates/invite/new.html:55
#: uffd/mail/templates/mail/list.html:14 uffd/mail/templates/mail/show.html:7
#: uffd/mfa/templates/mfa/setup.html:98 uffd/mfa/templates/mfa/setup.html:99
#: uffd/mfa/templates/mfa/setup.html:107 uffd/mfa/templates/mfa/setup.html:157
#: uffd/mfa/templates/mfa/setup.html:158 uffd/mfa/templates/mfa/setup.html:169
#: uffd/role/templates/role/list.html:14
#: uffd/rolemod/templates/rolemod/list.html:9
#: uffd/rolemod/templates/rolemod/show.html:46
#: uffd/selfservice/templates/selfservice/self.html:107
#: uffd/user/templates/group/list.html:10
#: uffd/user/templates/group/show.html:11 uffd/user/templates/user/show.html:95
#: uffd/user/templates/user/show.html:127
msgid "Name"
msgstr "Name"
#: uffd/invite/templates/invite/new.html:36
#: uffd/role/templates/role/list.html:15 uffd/role/templates/role/show.html:48
#: uffd/rolemod/templates/rolemod/list.html:10
#: uffd/rolemod/templates/rolemod/show.html:28
#: uffd/selfservice/templates/selfservice/self.html:108
#: uffd/user/templates/group/list.html:11 uffd/user/templates/user/show.html:96
#: uffd/user/templates/user/show.html:128
msgid "Description"
msgstr "Beschreibung"
#: uffd/invite/templates/invite/new.html:55
msgid "Create Link" msgid "Create Link"
msgstr "Link erstellen" msgstr "Link erstellen"
#: uffd/invite/templates/invite/new.html:56 #: uffd/templates/invite/use.html:5
#: uffd/mail/templates/mail/show.html:28 uffd/mfa/templates/mfa/auth.html:39
#: uffd/role/templates/role/show.html:14
#: uffd/rolemod/templates/rolemod/show.html:11
#: uffd/session/templates/session/deviceauth.html:44
#: uffd/session/templates/session/deviceauth.html:54
#: uffd/session/templates/session/devicelogin.html:34
#: uffd/user/templates/user/show.html:8
msgid "Cancel"
msgstr "Abbrechen"
#: uffd/invite/templates/invite/use.html:11
msgid "Invite Link" msgid "Invite Link"
msgstr "Einladungslink" msgstr "Einladungslink"
#: uffd/invite/templates/invite/use.html:14 #: uffd/templates/invite/use.html:8
#, python-format #, python-format
msgid "Welcome to the %(org_name)s Single-Sign-On!" msgid "Welcome to the %(org_name)s Single-Sign-On!"
msgstr "Willkommen im %(org_name)s Single-Sign-On!" msgstr "Willkommen im %(org_name)s Single-Sign-On!"
#: uffd/invite/templates/invite/use.html:18 #: uffd/templates/invite/use.html:12
msgid "" msgid ""
"With this link you can register a new user account with the following " "With this link you can register a new user account with the following "
"roles or add the roles to an existing account:" "roles or add the roles to an existing account:"
...@@ -316,147 +416,149 @@ msgstr "" ...@@ -316,147 +416,149 @@ msgstr ""
"Mit diesem Link kannst du einen Account mit den folgenden Rollen " "Mit diesem Link kannst du einen Account mit den folgenden Rollen "
"erstellen oder diese Rollen zu einem existierenden Account hinzufügen:" "erstellen oder diese Rollen zu einem existierenden Account hinzufügen:"
#: uffd/invite/templates/invite/use.html:20 #: uffd/templates/invite/use.html:14
msgid "With this link you can add the following roles to an existing account:" msgid "With this link you can add the following roles to an existing account:"
msgstr "" msgstr ""
"Mit diesem Link kannst du die folgenden Rollen zu einem existierenden " "Mit diesem Link kannst du die folgenden Rollen zu einem existierenden "
"Account hinzufügen:" "Account hinzufügen:"
#: uffd/invite/templates/invite/use.html:22 #: uffd/templates/invite/use.html:16
msgid "With this link you can register a new user account." msgid "With this link you can register a new user account."
msgstr "Mit diesem Link kannst du einen Account registieren." msgstr "Mit diesem Link kannst du einen Account registieren."
#: uffd/invite/templates/invite/use.html:34 #: uffd/templates/invite/use.html:28
msgid "Add the roles to your account now" msgid "Add the roles to your account now"
msgstr "Rollen jetzt zu deinem Account hinzufügen" msgstr "Rollen jetzt zu deinem Account hinzufügen"
#: uffd/invite/templates/invite/use.html:36 #: uffd/templates/invite/use.html:30
msgid "Logout and switch to a different account" msgid "Logout and switch to a different account"
msgstr "Abmelden und zu einem anderen Account wechseln" msgstr "Abmelden und zu einem anderen Account wechseln"
#: uffd/invite/templates/invite/use.html:39 #: uffd/templates/invite/use.html:33
msgid "Logout to register a new account" msgid "Logout to register a new account"
msgstr "Abmelden um einen neuen Account zu registrieren" msgstr "Abmelden um einen neuen Account zu registrieren"
#: uffd/invite/templates/invite/use.html:43 #: uffd/templates/invite/use.html:37
msgid "Register a new account" msgid "Register a new account"
msgstr "Neuen Account registrieren" msgstr "Neuen Account registrieren"
#: uffd/invite/templates/invite/use.html:46 #: uffd/templates/invite/use.html:40
msgid "Login and add the roles to your account" msgid "Login and add the roles to your account"
msgstr "Anmelden und die Rollen zu deinem Account hinzufügen" msgstr "Anmelden und die Rollen zu deinem Account hinzufügen"
#: uffd/mail/views.py:23 #: uffd/templates/mail/list.html:15 uffd/templates/mail/show.html:13
msgid "Forwardings"
msgstr "Weiterleitungen"
#: uffd/mail/views.py:47
msgid "Mail mapping updated."
msgstr "Mailweiterleitung geändert."
#: uffd/mail/views.py:56
msgid "Deleted mail mapping."
msgstr "Mailweiterleitung gelöscht."
#: uffd/mail/templates/mail/list.html:15 uffd/mail/templates/mail/show.html:13
msgid "Receiving addresses" msgid "Receiving addresses"
msgstr "Empfangsadressen" msgstr "Empfangsadressen"
#: uffd/mail/templates/mail/list.html:16 uffd/mail/templates/mail/show.html:20 #: uffd/templates/mail/list.html:16 uffd/templates/mail/show.html:20
msgid "Destinations" msgid "Destinations"
msgstr "Zieladressen" msgstr "Zieladressen"
#: uffd/mail/templates/mail/show.html:16 uffd/mail/templates/mail/show.html:23 #: uffd/templates/mail/show.html:16
msgid "One address per line"
msgstr "Eine Adresse pro Zeile"
#: uffd/mail/templates/mail/show.html:27 uffd/role/templates/role/show.html:13
#: uffd/rolemod/templates/rolemod/show.html:10
#: uffd/user/templates/user/show.html:7
msgid "Save"
msgstr "Speichern"
#: uffd/mail/templates/mail/show.html:30 uffd/mail/templates/mail/show.html:32
#: uffd/mfa/templates/mfa/setup.html:117 uffd/mfa/templates/mfa/setup.html:179
#: uffd/role/templates/role/show.html:21 uffd/role/templates/role/show.html:24
#: uffd/user/templates/user/show.html:11 uffd/user/templates/user/show.html:13
msgid "Delete"
msgstr "Löschen"
#: uffd/mfa/views.py:54
msgid "Two-factor authentication was reset"
msgstr "Zwei-Faktor-Authentifizierung wurde zurückgesetzt"
#: uffd/mfa/views.py:83
msgid "Generate recovery codes first!"
msgstr "Generiere zuerst die Wiederherstellungscodes!"
#: uffd/mfa/views.py:92
msgid "Code is invalid"
msgstr "Wiederherstellungscode ist ungültig"
#: uffd/mfa/views.py:116
#, python-format
msgid ""
"2FA WebAuthn support disabled because import of the fido2 module failed "
"(%s)"
msgstr ""
"2FA WebAuthn Unterstützung deaktiviert, da das fido2 Modul nicht geladen "
"werden konnte (%s)"
#: uffd/mfa/views.py:225
#, python-format
msgid "We received too many invalid attempts! Please wait at least %s."
msgstr "Wir haben zu viele fehlgeschlagene Versuche! Bitte warte mindestens %s."
#: uffd/mfa/views.py:239
msgid "You have exhausted your recovery codes. Please generate new ones now!"
msgstr "Du hast keine Wiederherstellungscode mehr. Bitte generiere diese jetzt!"
#: uffd/mfa/views.py:242
msgid "" msgid ""
"You only have a few recovery codes remaining. Make sure to generate new " "One address pattern (local+ext@domain, local@domain, local, @domain) per "
"ones before they run out." "line. Only lower-case ASCII letters, digits and symbols."
msgstr "" msgstr ""
"Du hast nur noch wenige Wiederherstellungscodes übrig. Bitte generiere " "Ein Adressmuster (local+ext@domain, local@domain, local, @domain) pro "
"diese erneut bevor keine mehr übrig sind." "Zeile. Nur ASCII-Kleinbuchstaben, -Ziffern und -Symbole."
#: uffd/mfa/views.py:246 #: uffd/templates/mail/show.html:23
msgid "Two-factor authentication failed" msgid "One address per line"
msgstr "Zwei-Faktor-Authentifizierung fehlgeschlagen" msgstr "Eine Adresse pro Zeile"
#: uffd/mfa/templates/mfa/auth.html:12 #: uffd/templates/mfa/auth.html:6 uffd/templates/selfservice/self.html:159
#: uffd/selfservice/templates/selfservice/self.html:73 #: uffd/templates/user/show.html:188
msgid "Two-Factor Authentication" msgid "Two-Factor Authentication"
msgstr "Zwei-Faktor-Authentifizierung" msgstr "Zwei-Faktor-Authentifizierung"
#: uffd/mfa/templates/mfa/auth.html:17 #: uffd/templates/mfa/auth.html:11
msgid "Enable javascript for authentication with U2F/FIDO2 devices" msgid "Enable javascript for authentication with U2F/FIDO2 devices"
msgstr "Aktiviere Javascript zur Authentifizierung mit U2F/FIDO2 Geräten" msgstr "Aktiviere Javascript zur Authentifizierung mit U2F/FIDO2 Geräten"
#: uffd/mfa/templates/mfa/auth.html:21 #: uffd/templates/mfa/auth.html:15
msgid "Authentication with U2F/FIDO2 devices is not supported by your browser" msgid "Authentication with U2F/FIDO2 devices is not supported by your browser"
msgstr "" msgstr ""
"Authentifizierung mit U2F/FIDO2 Geräten wird von deinem Browser nicht " "Authentifizierung mit U2F/FIDO2 Geräten wird von deinem Browser nicht "
"unterstützt" "unterstützt"
#: uffd/mfa/templates/mfa/auth.html:27 #: uffd/templates/mfa/auth.html:21
msgid "Authenticate with U2F/FIDO2 device" msgid "Authenticate with U2F/FIDO2 device"
msgstr "Authentifiziere dich mit einem U2F/FIDO2 Gerät" msgstr "Authentifiziere dich mit einem U2F/FIDO2-Gerät"
#: uffd/mfa/templates/mfa/auth.html:30 #: uffd/templates/mfa/auth.html:24
msgid "or" msgid "or"
msgstr "oder" msgstr "oder"
#: uffd/mfa/templates/mfa/auth.html:33 #: uffd/templates/mfa/auth.html:27
msgid "Code from your authenticator app or recovery code" msgid "Code from your authenticator app or recovery code"
msgstr "Code aus deiner Authentifikator-App oder Wiederherstellungscode" msgstr "Code aus deiner Authentifikator-App oder Wiederherstellungscode"
#: uffd/mfa/templates/mfa/auth.html:36 #: uffd/templates/mfa/auth.html:30
msgid "Verify" msgid "Verify"
msgstr "Verifizieren" msgstr "Verifizieren"
#: uffd/mfa/templates/mfa/disable.html:6 #: uffd/templates/mfa/auth.html:43 uffd/templates/mfa/setup.html:199
msgid "Contacting server"
msgstr "Verbinde mit Server"
#: uffd/templates/mfa/auth.html:52 uffd/templates/mfa/auth.html:80
msgid "Session timed out"
msgstr "Sitzung abgelaufen"
#: uffd/templates/mfa/auth.html:54
msgid "You have not registered any U2F/FIDO2 devices for your account"
msgstr "Es sind keine U2F/FIDO2-Geräte für deinen Account registriert"
#: uffd/templates/mfa/auth.html:56 uffd/templates/mfa/setup.html:208
msgid "Server error"
msgstr "Serverfehler"
#: uffd/templates/mfa/auth.html:59 uffd/templates/mfa/setup.html:210
msgid "Waiting for device"
msgstr "Warte auf Gerät"
#: uffd/templates/mfa/auth.html:62
msgid "Verifing response"
msgstr "Überprüfe Antwort"
#: uffd/templates/mfa/auth.html:76
msgid "Success, redirecting"
msgstr "Erfolg, leite weiter"
#: uffd/templates/mfa/auth.html:82 uffd/templates/mfa/setup.html:228
msgid "Invalid response from device"
msgstr "Ungültige Antwort von Gerät"
#: uffd/templates/mfa/auth.html:88
msgid "Authentication timed out, was aborted or not allowed"
msgstr "Authentifikation abgelaufen, abgebrochen oder nicht erlaubt"
#: uffd/templates/mfa/auth.html:90
msgid "Device is not registered for your account"
msgstr "Gerät ist nicht für deinen Account registriert"
#: uffd/templates/mfa/auth.html:92
msgid "Authentication was aborted"
msgstr "Authentifikation abgebrochen"
#: uffd/templates/mfa/auth.html:94 uffd/templates/mfa/setup.html:240
#: uffd/templates/mfa/setup.html:264
msgid "U2F and FIDO2 devices are not supported by your browser"
msgstr "U2F- und FIDO2-Geräte werden vom Webbrowser nicht unterstüzt"
#: uffd/templates/mfa/auth.html:97 uffd/templates/mfa/setup.html:243
msgid "Could not connect to server"
msgstr "Verbindung zum Server fehlgeschlagen"
#: uffd/templates/mfa/auth.html:103
msgid "Authentication failed "
msgstr "Authentifikation fehlgeschlagen"
#: uffd/templates/mfa/auth.html:106
msgid "Retry authenticate with U2F/FIDO2 device"
msgstr "Authentifikation mit U2F/FIDO2-Gerät nochmal versuchen"
#: uffd/templates/mfa/disable.html:6
msgid "" msgid ""
"When you proceed, all recovery codes, registered authenticator " "When you proceed, all recovery codes, registered authenticator "
"applications and devices will be invalidated.\n" "applications and devices will be invalidated.\n"
...@@ -468,23 +570,21 @@ msgstr "" ...@@ -468,23 +570,21 @@ msgstr ""
"Wiederherstellungscodes generieren und das Setup der Anwendungen und " "Wiederherstellungscodes generieren und das Setup der Anwendungen und "
"Geräte erneut durchführen." "Geräte erneut durchführen."
#: uffd/mfa/templates/mfa/disable.html:11 uffd/mfa/templates/mfa/setup.html:32 #: uffd/templates/mfa/disable.html:11 uffd/templates/mfa/setup.html:32
msgid "Disable two-factor authentication" msgid "Disable two-factor authentication"
msgstr "Zwei-Faktor-Authentifizierung (2FA) deaktivieren" msgstr "Zwei-Faktor-Authentifizierung (2FA) deaktivieren"
#: uffd/mfa/templates/mfa/setup.html:18 #: uffd/templates/mfa/setup.html:18 uffd/templates/selfservice/self.html:165
#: uffd/selfservice/templates/selfservice/self.html:79
msgid "Two-factor authentication is currently <strong>enabled</strong>." msgid "Two-factor authentication is currently <strong>enabled</strong>."
msgstr "Die Zwei-Faktor-Authentifizierung ist derzeit <strong>aktiviert</strong>." msgstr "Die Zwei-Faktor-Authentifizierung ist derzeit <strong>aktiviert</strong>."
#: uffd/mfa/templates/mfa/setup.html:20 #: uffd/templates/mfa/setup.html:20 uffd/templates/selfservice/self.html:167
#: uffd/selfservice/templates/selfservice/self.html:81
msgid "Two-factor authentication is currently <strong>disabled</strong>." msgid "Two-factor authentication is currently <strong>disabled</strong>."
msgstr "" msgstr ""
"Die Zwei-Faktor-Authentifizierung ist derzeit " "Die Zwei-Faktor-Authentifizierung ist derzeit "
"<strong>deaktiviert</strong>." "<strong>deaktiviert</strong>."
#: uffd/mfa/templates/mfa/setup.html:23 #: uffd/templates/mfa/setup.html:23
msgid "" msgid ""
"You need to generate recovery codes and setup at least one authentication" "You need to generate recovery codes and setup at least one authentication"
" method to enable two-factor authentication." " method to enable two-factor authentication."
...@@ -493,7 +593,7 @@ msgstr "" ...@@ -493,7 +593,7 @@ msgstr ""
"Authentifizierungsmethode hinzufügen um Zwei-Faktor-Authentifizierung " "Authentifizierungsmethode hinzufügen um Zwei-Faktor-Authentifizierung "
"nutzen zu können." "nutzen zu können."
#: uffd/mfa/templates/mfa/setup.html:25 #: uffd/templates/mfa/setup.html:25
msgid "" msgid ""
"You need to setup at least one authentication method to enable two-factor" "You need to setup at least one authentication method to enable two-factor"
" authentication." " authentication."
...@@ -501,16 +601,16 @@ msgstr "" ...@@ -501,16 +601,16 @@ msgstr ""
"Du musst mindestens eine Authentifizierungsmethode hinzufügen um Zwei-" "Du musst mindestens eine Authentifizierungsmethode hinzufügen um Zwei-"
"Faktor-Authentifizierung nutzen zu können." "Faktor-Authentifizierung nutzen zu können."
#: uffd/mfa/templates/mfa/setup.html:36 #: uffd/templates/mfa/setup.html:36
msgid "Reset two-factor configuration" msgid "Reset two-factor configuration"
msgstr "Zwei-Faktor-Authentifizierung zurücksetzen" msgstr "Zwei-Faktor-Authentifizierung zurücksetzen"
#: uffd/mfa/templates/mfa/setup.html:46 #: uffd/templates/mfa/setup.html:46 uffd/templates/mfa/setup_recovery.html:5
#: uffd/mfa/templates/mfa/setup_recovery.html:5 #: uffd/templates/user/show.html:191
msgid "Recovery Codes" msgid "Recovery Codes"
msgstr "Wiederherstellungscodes" msgstr "Wiederherstellungscodes"
#: uffd/mfa/templates/mfa/setup.html:48 #: uffd/templates/mfa/setup.html:48
msgid "" msgid ""
"Recovery codes allow you to login and setup new two-factor methods when " "Recovery codes allow you to login and setup new two-factor methods when "
"you lost your registered second factor." "you lost your registered second factor."
...@@ -518,7 +618,7 @@ msgstr "" ...@@ -518,7 +618,7 @@ msgstr ""
"Wiederherstellungscodes erlauben die Anmeldung und das erneute Hinzufügen" "Wiederherstellungscodes erlauben die Anmeldung und das erneute Hinzufügen"
" einer Zwei-Faktor-Methode, falls der Zweite Faktor verloren geht." " einer Zwei-Faktor-Methode, falls der Zweite Faktor verloren geht."
#: uffd/mfa/templates/mfa/setup.html:52 #: uffd/templates/mfa/setup.html:52
msgid "" msgid ""
"You need to setup recovery codes before you can setup up authenticator " "You need to setup recovery codes before you can setup up authenticator "
"apps or U2F/FIDO2 devices." "apps or U2F/FIDO2 devices."
...@@ -526,33 +626,33 @@ msgstr "" ...@@ -526,33 +626,33 @@ msgstr ""
"Du musst Wiederherstellungscodes generieren bevor du einen " "Du musst Wiederherstellungscodes generieren bevor du einen "
"Authentifikator-App oder ein U2F/FIDO2 Gerät hinzufen kannst." "Authentifikator-App oder ein U2F/FIDO2 Gerät hinzufen kannst."
#: uffd/mfa/templates/mfa/setup.html:54 #: uffd/templates/mfa/setup.html:54
msgid "Each code can only be used once." msgid "Each code can only be used once."
msgstr "Jeder Code kann nur einmal verwendet werden." msgstr "Jeder Code kann nur einmal verwendet werden."
#: uffd/mfa/templates/mfa/setup.html:62 #: uffd/templates/mfa/setup.html:62
msgid "Generate recovery codes to enable two-factor authentication" msgid "Generate recovery codes to enable two-factor authentication"
msgstr "" msgstr ""
"Generiere Wiederherstellungscodes um die Zwei-Faktor-Authentifizierung zu" "Generiere Wiederherstellungscodes um die Zwei-Faktor-Authentifizierung zu"
" aktivieren" " aktivieren"
#: uffd/mfa/templates/mfa/setup.html:66 #: uffd/templates/mfa/setup.html:66
msgid "Generate new recovery codes" msgid "Generate new recovery codes"
msgstr "Generiere neue Wiederherstellungscodes" msgstr "Generiere neue Wiederherstellungscodes"
#: uffd/mfa/templates/mfa/setup.html:75 #: uffd/templates/mfa/setup.html:75
msgid "You have no remaining recovery codes." msgid "You have no remaining recovery codes."
msgstr "Du hast keine Wiederherstellungscodes übrig." msgstr "Du hast keine Wiederherstellungscodes übrig."
#: uffd/mfa/templates/mfa/setup.html:85 #: uffd/templates/mfa/setup.html:85 uffd/templates/user/show.html:191
msgid "Authenticator Apps (TOTP)" msgid "Authenticator Apps (TOTP)"
msgstr "Authentifikator-Apps (TOTP)" msgstr "Authentifikator-Apps (TOTP)"
#: uffd/mfa/templates/mfa/setup.html:87 #: uffd/templates/mfa/setup.html:87
msgid "Use an authenticator application on your mobile device as a second factor." msgid "Use an authenticator application on your mobile device as a second factor."
msgstr "Nutze eine Authentifikator-App auf deinem Mobilgerät als zweiten Faktor." msgstr "Nutze eine Authentifikator-App auf deinem Mobilgerät als zweiten Faktor."
#: uffd/mfa/templates/mfa/setup.html:90 #: uffd/templates/mfa/setup.html:90
msgid "" msgid ""
"The authenticator app generates a 6-digit one-time code each time you " "The authenticator app generates a 6-digit one-time code each time you "
"login.\n" "login.\n"
...@@ -562,27 +662,27 @@ msgstr "" ...@@ -562,27 +662,27 @@ msgstr ""
"jeden Login. Passende Apps sind kostenlos verfügbar für die meisten " "jeden Login. Passende Apps sind kostenlos verfügbar für die meisten "
"Mobilgeräte." "Mobilgeräte."
#: uffd/mfa/templates/mfa/setup.html:100 #: uffd/templates/mfa/setup.html:100
msgid "Setup new app" msgid "Setup new app"
msgstr "Neue App hinzufügen" msgstr "Neue App hinzufügen"
#: uffd/mfa/templates/mfa/setup.html:108 uffd/mfa/templates/mfa/setup.html:170 #: uffd/templates/mfa/setup.html:108 uffd/templates/mfa/setup.html:170
msgid "Registered On" msgid "Registered On"
msgstr "Registriert am" msgstr "Registriert am"
#: uffd/mfa/templates/mfa/setup.html:122 #: uffd/templates/mfa/setup.html:122
msgid "No authenticator apps registered yet" msgid "No authenticator apps registered yet"
msgstr "Bisher keine Authentifikator-Apps registriert" msgstr "Bisher keine Authentifikator-Apps registriert"
#: uffd/mfa/templates/mfa/setup.html:134 #: uffd/templates/mfa/setup.html:134 uffd/templates/user/show.html:191
msgid "U2F and FIDO2 Devices" msgid "U2F and FIDO2 Devices"
msgstr "U2F und FIDO2 Geräte" msgstr "U2F und FIDO2 Geräte"
#: uffd/mfa/templates/mfa/setup.html:136 #: uffd/templates/mfa/setup.html:136
msgid "Use an U2F or FIDO2 compatible hardware security key as a second factor." msgid "Use an U2F or FIDO2 compatible hardware security key as a second factor."
msgstr "Nutze einen U2F oder FIDO2 kompatiblen Key als zweiten Faktor." msgstr "Nutze einen U2F oder FIDO2 kompatiblen Key als zweiten Faktor."
#: uffd/mfa/templates/mfa/setup.html:139 #: uffd/templates/mfa/setup.html:139
msgid "" msgid ""
"U2F and FIDO2 devices are not supported by all browsers and can be " "U2F and FIDO2 devices are not supported by all browsers and can be "
"particularly difficult to use on mobile\n" "particularly difficult to use on mobile\n"
...@@ -595,25 +695,49 @@ msgstr "" ...@@ -595,25 +695,49 @@ msgstr ""
"wird dringend empfohlen ebenfalls eine Authentifikator-App " "wird dringend empfohlen ebenfalls eine Authentifikator-App "
"hinzuzufügen</strong> um einen Login mit allen Browsern zu ermöglichen." "hinzuzufügen</strong> um einen Login mit allen Browsern zu ermöglichen."
#: uffd/mfa/templates/mfa/setup.html:147 #: uffd/templates/mfa/setup.html:147
msgid "U2F/FIDO2 support not enabled" msgid "U2F/FIDO2 support not enabled"
msgstr "U2F/FIDO2 Unterstützung nicht aktiviert" msgstr "U2F/FIDO2 Unterstützung nicht aktiviert"
#: uffd/mfa/templates/mfa/setup.html:151 #: uffd/templates/mfa/setup.html:151
msgid "Enable javascript in your browser to use U2F and FIDO2 devices!" msgid "Enable javascript in your browser to use U2F and FIDO2 devices!"
msgstr "" msgstr ""
"Aktiviere Javascript in deinem Browser, um U2F und FIDO2 Geräte nutzen zu" "Aktiviere Javascript in deinem Browser, um U2F und FIDO2 Geräte nutzen zu"
" können!" " können!"
#: uffd/mfa/templates/mfa/setup.html:161 #: uffd/templates/mfa/setup.html:161
msgid "Setup new device" msgid "Setup new device"
msgstr "Neues Gerät hinzufügen" msgstr "Neues Gerät hinzufügen"
#: uffd/mfa/templates/mfa/setup.html:184 #: uffd/templates/mfa/setup.html:184
msgid "No U2F/FIDO2 devices registered yet" msgid "No U2F/FIDO2 devices registered yet"
msgstr "Bisher kein U2F/FIDO2 Gerät registriert" msgstr "Bisher kein U2F/FIDO2 Gerät registriert"
#: uffd/mfa/templates/mfa/setup_recovery.html:8 #: uffd/templates/mfa/setup.html:207
msgid "You need to generate recovery codes first"
msgstr "Du musst erst Wiederherstellungscodes generieren"
#: uffd/templates/mfa/setup.html:234
msgid "Registration timed out, was aborted or not allowed"
msgstr "Registrierung abgelaufen, abgebrochen oder nicht erlaubt"
#: uffd/templates/mfa/setup.html:236
msgid "Device already registered"
msgstr "Gerät bereits registriert"
#: uffd/templates/mfa/setup.html:238
msgid "Registration was aborted"
msgstr "Registrierung abgebrochen"
#: uffd/templates/mfa/setup.html:249
msgid "Registration failed"
msgstr "Registrierung fehlgeschlagen"
#: uffd/templates/mfa/setup.html:252
msgid "Retry registration"
msgstr "Registrierung nochmal versuchen"
#: uffd/templates/mfa/setup_recovery.html:8
msgid "" msgid ""
"Recovery codes allow you to login when you lose access to your " "Recovery codes allow you to login when you lose access to your "
"authenticator app or U2F/FIDO device. Each code can\n" "authenticator app or U2F/FIDO device. Each code can\n"
...@@ -623,7 +747,7 @@ msgstr "" ...@@ -623,7 +747,7 @@ msgstr ""
"Authentifikator-App oder das U2F/FIDO2 Gerät verloren geht. Jeder Code " "Authentifikator-App oder das U2F/FIDO2 Gerät verloren geht. Jeder Code "
"kann nur einmal verwendet werden." "kann nur einmal verwendet werden."
#: uffd/mfa/templates/mfa/setup_recovery.html:21 #: uffd/templates/mfa/setup_recovery.html:21
msgid "" msgid ""
"These are your new recovery codes. Make sure to store them in a safe " "These are your new recovery codes. Make sure to store them in a safe "
"place or you risk losing access to your\n" "place or you risk losing access to your\n"
...@@ -633,15 +757,21 @@ msgstr "" ...@@ -633,15 +757,21 @@ msgstr ""
"Ort, sonst könntest du den Zugriff auf dein Konto verlieren. Alle " "Ort, sonst könntest du den Zugriff auf dein Konto verlieren. Alle "
"vorherigen Wiederherstellungscodes sind nun ungültig." "vorherigen Wiederherstellungscodes sind nun ungültig."
#: uffd/mfa/templates/mfa/setup_recovery.html:28 #: uffd/templates/mfa/setup_recovery.html:26
#: uffd/templates/session/deviceauth.html:36
#: uffd/templates/session/devicelogin.html:25
msgid "Continue"
msgstr "Weiter"
#: uffd/templates/mfa/setup_recovery.html:28
msgid "Download codes" msgid "Download codes"
msgstr "Codes herunterladen" msgstr "Codes herunterladen"
#: uffd/mfa/templates/mfa/setup_recovery.html:30 #: uffd/templates/mfa/setup_recovery.html:30
msgid "Print codes" msgid "Print codes"
msgstr "Codes ausdrucken" msgstr "Codes ausdrucken"
#: uffd/mfa/templates/mfa/setup_totp.html:6 #: uffd/templates/mfa/setup_totp.html:6
msgid "" msgid ""
"Install an authenticator application on your mobile device like FreeOTP " "Install an authenticator application on your mobile device like FreeOTP "
"or Google Authenticator and scan this QR\n" "or Google Authenticator and scan this QR\n"
...@@ -651,7 +781,7 @@ msgstr "" ...@@ -651,7 +781,7 @@ msgstr ""
"oder Google Authenticator and scanne diesen QR Code. Auf Geräten von " "oder Google Authenticator and scanne diesen QR Code. Auf Geräten von "
"Apple kann die App \"Authenticator\" verwendet werden." "Apple kann die App \"Authenticator\" verwendet werden."
#: uffd/mfa/templates/mfa/setup_totp.html:18 #: uffd/templates/mfa/setup_totp.html:18
msgid "" msgid ""
"If you are on your mobile device and cannot scan the code, you can click " "If you are on your mobile device and cannot scan the code, you can click "
"on it to open it with your\n" "on it to open it with your\n"
...@@ -660,69 +790,55 @@ msgid "" ...@@ -660,69 +790,55 @@ msgid ""
"\t\t\tapp:" "\t\t\tapp:"
msgstr "" msgstr ""
"Falls du ein Mobilgerät verwendest und den Code nicht scannen kannst, " "Falls du ein Mobilgerät verwendest und den Code nicht scannen kannst, "
"kannst du drauf klick und direkt in der Authentifikator-App öffnen. Wenn " "kannst du drauf klicken um ihn damit direkt in der Authentifikator-App zu"
"das nicht funktioniert, gib die folgenden Angaben manuell in die " " öffnen. Wenn das nicht funktioniert, gib die folgenden Angaben manuell "
"Authentifikator-App ein:" "in die Authentifikator-App ein:"
#: uffd/mfa/templates/mfa/setup_totp.html:23 #: uffd/templates/mfa/setup_totp.html:23
msgid "Issuer" msgid "Issuer"
msgstr "Herausgeber" msgstr "Herausgeber"
#: uffd/mfa/templates/mfa/setup_totp.html:24 #: uffd/templates/mfa/setup_totp.html:24
msgid "Account" msgid "Account"
msgstr "Konto" msgstr "Konto"
#: uffd/mfa/templates/mfa/setup_totp.html:25 #: uffd/templates/mfa/setup_totp.html:25
msgid "Secret" msgid "Secret"
msgstr "Geheimnis" msgstr "Geheimnis"
#: uffd/mfa/templates/mfa/setup_totp.html:26 #: uffd/templates/mfa/setup_totp.html:26
msgid "Type" msgid "Type"
msgstr "Typ" msgstr "Typ"
#: uffd/mfa/templates/mfa/setup_totp.html:27 #: uffd/templates/mfa/setup_totp.html:27
msgid "Digits" msgid "Digits"
msgstr "Zeichen" msgstr "Zeichen"
#: uffd/mfa/templates/mfa/setup_totp.html:28 #: uffd/templates/mfa/setup_totp.html:28
msgid "Hash algorithm" msgid "Hash algorithm"
msgstr "Hash-Algorithmus" msgstr "Hash-Algorithmus"
#: uffd/mfa/templates/mfa/setup_totp.html:29 #: uffd/templates/mfa/setup_totp.html:29
msgid "Interval/period" msgid "Interval/period"
msgstr "Intervall/Dauer" msgstr "Intervall/Dauer"
#: uffd/mfa/templates/mfa/setup_totp.html:29 #: uffd/templates/mfa/setup_totp.html:29
msgid "seconds" msgid "seconds"
msgstr "Sekunden" msgstr "Sekunden"
#: uffd/mfa/templates/mfa/setup_totp.html:38 #: uffd/templates/mfa/setup_totp.html:37
msgid "Code"
msgstr "Code"
#: uffd/templates/mfa/setup_totp.html:38
msgid "Verify and complete setup" msgid "Verify and complete setup"
msgstr "Verifiziere und beende das Setup" msgstr "Verifiziere und beende das Setup"
#: uffd/oauth2/views.py:95 uffd/selfservice/views.py:79 #: uffd/templates/oauth2/logout.html:10
#: uffd/session/views.py:93
#, python-format
msgid ""
"We received too many requests from your ip address/network! Please wait "
"at least %(delay)s."
msgstr ""
"Wir haben zu viele Anfragen von deiner IP-Adresses bzw. aus deinem "
"Netzwerk empfangen! Bitte warte mindestens %(delay)s."
#: uffd/oauth2/views.py:103
msgid "Device login is currently not available. Try again later!"
msgstr "Geräte-Login ist gerade nicht verfügbar. Versuche es später nochmal!"
#: uffd/oauth2/templates/oauth2/logout.html:10 uffd/templates/base.html:99
msgid "Logout"
msgstr "Abmelden"
#: uffd/oauth2/templates/oauth2/logout.html:15
msgid "Javascript is required for automatic logout" msgid "Javascript is required for automatic logout"
msgstr "Für das automatische Abmelden muss Javascript aktiviert sein" msgstr "Für das automatische Abmelden muss Javascript aktiviert sein"
#: uffd/oauth2/templates/oauth2/logout.html:17 #: uffd/templates/oauth2/logout.html:12
msgid "" msgid ""
"While you successfully logged out of the Single-Sign-On service, you may " "While you successfully logged out of the Single-Sign-On service, you may "
"still be logged in on these services:" "still be logged in on these services:"
...@@ -730,7 +846,7 @@ msgstr "" ...@@ -730,7 +846,7 @@ msgstr ""
"Während du nun aus dem Single-Sign-On abgemeldet bist, bist du eventuell " "Während du nun aus dem Single-Sign-On abgemeldet bist, bist du eventuell "
"weiterhin in folgenden Diensten angemeldet:" "weiterhin in folgenden Diensten angemeldet:"
#: uffd/oauth2/templates/oauth2/logout.html:30 #: uffd/templates/oauth2/logout.html:25
msgid "" msgid ""
"Please wait until you have been automatically logged out of all services " "Please wait until you have been automatically logged out of all services "
"or make sure of this yourself." "or make sure of this yourself."
...@@ -738,45 +854,29 @@ msgstr "" ...@@ -738,45 +854,29 @@ msgstr ""
"Bitte warte, bis das automatische Abmelden bei allen Diensten " "Bitte warte, bis das automatische Abmelden bei allen Diensten "
"abgeschlossen ist oder melde dich überall manuell ab." "abgeschlossen ist oder melde dich überall manuell ab."
#: uffd/oauth2/templates/oauth2/logout.html:34 #: uffd/templates/oauth2/logout.html:29
msgid "Logging you out on all services ..." msgid "Logging you out on all services ..."
msgstr "Melde dich bei allen Diensten ab ..." msgstr "Abmeldung bei allen Diensten ..."
#: uffd/oauth2/templates/oauth2/logout.html:38 #: uffd/templates/oauth2/logout.html:33
msgid "Skip this and continue" msgid "Skip this and continue"
msgstr "Automatisches Abmelden überspringen" msgstr "Automatisches Abmelden überspringen"
#: uffd/oauth2/templates/oauth2/logout.html:82 #: uffd/templates/oauth2/logout.html:72
msgid "Done, redirecting ..." msgid "Done, redirecting ..."
msgstr "Abgeschlossen, leite weiter ..." msgstr "Abgeschlossen, leite weiter ..."
#: uffd/oauth2/templates/oauth2/logout.html:86 #: uffd/templates/oauth2/logout.html:76
msgid "Log out failed on some services. Retry?" msgid "Log out failed on some services. Retry?"
msgstr "" msgstr ""
"Automatisches Abmelden bei einigen Diensten fehlgeschlagen. Nochmal " "Automatisches Abmelden bei einigen Diensten fehlgeschlagen. Nochmal "
"versuchen?" "versuchen?"
#: uffd/role/views.py:44 uffd/rolemod/views.py:36 uffd/rolemod/views.py:45 #: uffd/templates/role/list.html:23
#: uffd/rolemod/views.py:60 uffd/session/views.py:130
#: uffd/user/views_group.py:14 uffd/user/views_user.py:22
msgid "Access denied"
msgstr "Zugriff verweigert"
#: uffd/role/views.py:51 uffd/selfservice/templates/selfservice/self.html:92
#: uffd/user/templates/user/list.html:20 uffd/user/templates/user/show.html:21
#: uffd/user/templates/user/show.html:90
msgid "Roles"
msgstr "Rollen"
#: uffd/role/views.py:101
msgid "Locked roles cannot be deleted"
msgstr "Gesperrte Rollen können nicht gelöscht werden"
#: uffd/role/templates/role/list.html:23
msgid "<empty name>" msgid "<empty name>"
msgstr "<leerer Name>" msgstr "<leerer Name>"
#: uffd/role/templates/role/show.html:6 #: uffd/templates/role/show.html:6
msgid "" msgid ""
"Name, moderator group, included roles and groups of this role are managed" "Name, moderator group, included roles and groups of this role are managed"
" externally." " externally."
...@@ -784,279 +884,244 @@ msgstr "" ...@@ -784,279 +884,244 @@ msgstr ""
"Name, Moderationsgruppe, enthaltene Rollen und Gruppen dieser Rolle " "Name, Moderationsgruppe, enthaltene Rollen und Gruppen dieser Rolle "
"werden extern verwaltet." "werden extern verwaltet."
#: uffd/role/templates/role/show.html:17 #: uffd/templates/role/show.html:17
msgid "" msgid ""
"All non-service users will be removed as members from this role and get " "All non-service users will be removed as members from this role and get "
"its permissions implicitly. Are you sure?" "its permissions implicitly. Are you sure?"
msgstr "" msgstr ""
"Alle Benutzer-Accounts, die keine Service-Accounts sind, verlieren diese " "Alle Nicht-Service-Accounts verlieren diese Rolle und erhalten dessen "
"Rolle und erhalten dessen Berechtigungen implizit." "Berechtigungen implizit."
#: uffd/role/templates/role/show.html:17 uffd/role/templates/role/show.html:23 #: uffd/templates/role/show.html:17 uffd/templates/role/show.html:23
msgid "Set as default" msgid "Set as default"
msgstr "Als Default setzen" msgstr "Als Default setzen"
#: uffd/role/templates/role/show.html:19 uffd/role/templates/role/show.html:21 #: uffd/templates/role/show.html:19
#: uffd/selfservice/templates/selfservice/self.html:123
#: uffd/user/templates/user/show.html:11
msgid "Are you sure?"
msgstr "Wirklich fortfahren?"
#: uffd/role/templates/role/show.html:19
msgid "Unset as default" msgid "Unset as default"
msgstr "Nicht mehr als Default setzen" msgstr "Nicht mehr als Default setzen"
#: uffd/role/templates/role/show.html:29 #: uffd/templates/role/show.html:29
msgid "Settings" msgid "Settings"
msgstr "Einstellungen" msgstr "Einstellungen"
#: uffd/role/templates/role/show.html:32 #: uffd/templates/role/show.html:32
msgid "Included roles" msgid "Included roles"
msgstr "Enthaltene Rollen" msgstr "Enthaltene Rollen"
#: uffd/role/templates/role/show.html:35 uffd/role/templates/role/show.html:122 #: uffd/templates/role/show.html:35 uffd/templates/role/show.html:122
msgid "Included groups" msgid "Included groups"
msgstr "Enthaltene Gruppen" msgstr "Enthaltene Gruppen"
#: uffd/role/templates/role/show.html:42 #: uffd/templates/role/show.html:42
msgid "Role Name" msgid "Role Name"
msgstr "Rollenname" msgstr "Rollenname"
#: uffd/role/templates/role/show.html:54 #: uffd/templates/role/show.html:54
msgid "Moderator Group" msgid "Moderator Group"
msgstr "Moderationsgruppe" msgstr "Moderationsgruppe"
#: uffd/role/templates/role/show.html:56 #: uffd/templates/role/show.html:56
msgid "No Moderator Group" msgid "No Moderator Group"
msgstr "Keine Moderationsgruppe" msgstr "Keine Moderationsgruppe"
#: uffd/role/templates/role/show.html:63 #: uffd/templates/role/show.html:63
msgid "Moderators" msgid "Moderators"
msgstr "Moderatoren" msgstr "Accounts mit Moderationsrechten"
#: uffd/role/templates/role/show.html:71 #: uffd/templates/role/show.html:81
#: uffd/rolemod/templates/rolemod/show.html:18
#: uffd/user/templates/group/show.html:15
msgid "Members"
msgstr "Mitglieder"
#: uffd/role/templates/role/show.html:81
msgid "Roles to include groups from recursively" msgid "Roles to include groups from recursively"
msgstr "Rollen, deren Gruppen rekursiv enthalten sein sollen" msgstr "Rollen, deren Gruppen rekursiv enthalten sein sollen"
#: uffd/role/templates/role/show.html:86 uffd/role/templates/role/show.html:127 #: uffd/templates/role/show.html:86 uffd/templates/role/show.html:127
msgid "name" msgid "name"
msgstr "Name" msgstr "Name"
#: uffd/role/templates/role/show.html:87 uffd/role/templates/role/show.html:128 #: uffd/templates/role/show.html:87 uffd/templates/role/show.html:128
msgid "description" msgid "description"
msgstr "Beschreibung" msgstr "Beschreibung"
#: uffd/role/templates/role/show.html:88 #: uffd/templates/role/show.html:88
msgid "currently includes groups" msgid "currently includes groups"
msgstr "derzeit enthaltene Gruppen" msgstr "derzeit enthaltene Gruppen"
#: uffd/role/templates/role/show.html:129 #: uffd/templates/role/show.html:129
msgid "2FA required" msgid "2FA required"
msgstr "2FA erforderlich" msgstr "2FA erforderlich"
#: uffd/rolemod/views.py:25 #: uffd/templates/rolemod/show.html:7
msgid "Moderation"
msgstr "Moderation"
#: uffd/rolemod/views.py:49
msgid "Description too long"
msgstr "Beschreibung zu lang"
#: uffd/rolemod/views.py:67
msgid "Member removed"
msgstr "Mitglied entfernt"
#: uffd/rolemod/templates/rolemod/show.html:8
msgid "Invite Members" msgid "Invite Members"
msgstr "Mitglieder einladen" msgstr "Mitglieder einladen"
#: uffd/rolemod/templates/rolemod/show.html:15 #: uffd/templates/rolemod/show.html:13
msgid "Overview" msgid "Overview"
msgstr "Übersicht" msgstr "Übersicht"
#: uffd/rolemod/templates/rolemod/show.html:24 #: uffd/templates/rolemod/show.html:22
msgid "Role name" msgid "Role name"
msgstr "Rollenname" msgstr "Rollenname"
#: uffd/rolemod/templates/rolemod/show.html:32 #: uffd/templates/rolemod/show.html:30
msgid "Moderators:" msgid "Moderators:"
msgstr "Moderatoren:" msgstr "Accounts mit Moderationsrechten:"
#: uffd/rolemod/templates/rolemod/show.html:42 #: uffd/templates/rolemod/show.html:40
msgid "Role members:" msgid "Role members:"
msgstr "Mitglieder:" msgstr "Mitglieder:"
#: uffd/rolemod/templates/rolemod/show.html:55 #: uffd/templates/rolemod/show.html:53
msgid "Remove" msgid "Remove"
msgstr "Entfernen" msgstr "Entfernen"
#: uffd/selfservice/views.py:25 #: uffd/templates/selfservice/forgot_password.html:6
msgid "Selfservice" msgid "Forgot password"
msgstr "Selfservice" msgstr "Passwort vergessen"
#: uffd/selfservice/views.py:37
msgid "Display name changed."
msgstr "Anzeigename geändert."
#: uffd/selfservice/views.py:39
msgid "Display name is not valid."
msgstr "Anzeigename ist nicht valide."
#: uffd/selfservice/views.py:42 #: uffd/templates/selfservice/forgot_password.html:9
msgid "We sent you an email, please verify your mail address." #: uffd/templates/selfservice/self.html:21 uffd/templates/session/login.html:12
msgstr "Wir haben dir eine E-Mail gesendet, bitte prüfe deine E-Mail-Adresse." #: uffd/templates/signup/start.html:9 uffd/templates/user/list.html:18
#: uffd/templates/user/show.html:78
msgid "Login Name"
msgstr "Anmeldename"
#: uffd/selfservice/views.py:56 #: uffd/templates/selfservice/forgot_password.html:13
msgid "Password changed" msgid "Mail Address"
msgstr "Passwort geändert" msgstr "E-Mail-Adresse"
#: uffd/selfservice/views.py:59 #: uffd/templates/selfservice/forgot_password.html:17
msgid "Invalid password" msgid "Send password reset mail"
msgstr "Passwort ungültig" msgstr "Passwort-Zurücksetzen-Mail versenden"
#: uffd/selfservice/views.py:77 #: uffd/templates/selfservice/self.html:7
#, python-format
msgid "" msgid ""
"We received too many password reset requests for this user! Please wait " "Some permissions require you to setup two-factor authentication.\n"
"at least %(delay)s." "\tThese permissions are not in effect until you do that."
msgstr "" msgstr ""
"Wir haben zu viele fehlgeschlagene Anmeldeversuche für diesen Account! " "Einige deiner Berechtigungen erfordern das Einrichten von Zwei-Faktor-"
"Bitte warte mindestens %(delay)s." "Authentifizierung.\n"
"\tDiese Berechtigungen werden erst aktiv, wenn du dies getan hast."
#: uffd/templates/selfservice/self.html:14 uffd/templates/user/show.html:48
msgid "Profile"
msgstr "Profil"
#: uffd/selfservice/views.py:83 #: uffd/templates/selfservice/self.html:15
msgid "" msgid ""
"We sent a mail to this user's mail address if you entered the correct " "Your profile information is used by all services that are integrated into"
"mail and login name combination" " the Single-Sign-On."
msgstr "" msgstr ""
"Falls E-Mail-Adresse und Benutzername richtig waren, wurde eine E-Mail an" "Deine Profilangaben werden von allen Diensten verwendet, die an das "
" die Adresse gesendet." "Single-Sign-On angeschlossen sind."
#: uffd/selfservice/views.py:93 uffd/selfservice/views.py:121 #: uffd/templates/selfservice/self.html:16
msgid "Token expired, please try again." msgid "Changes may take several minutes to be visible in all services."
msgstr "Link abgelaufen, bitte versuche es erneut." msgstr "Änderungen sind erst nach einigen Minuten in allen Diensten sichtbar."
#: uffd/selfservice/views.py:101 #: uffd/templates/selfservice/self.html:25 uffd/templates/signup/start.html:22
msgid "You need to set a password, please try again." #: uffd/templates/user/list.html:19 uffd/templates/user/show.html:93
msgstr "Password fehlt, bitte versuche es erneut." msgid "Display Name"
msgstr "Anzeigename"
#: uffd/selfservice/views.py:104 #: uffd/templates/selfservice/self.html:28
msgid "Passwords do not match, please try again." msgid "Update Profile"
msgstr "Die Passwörter stimmen nicht überein, bitte versuche es erneut" msgstr "Änderungen speichern"
#: uffd/selfservice/views.py:108 #: uffd/templates/selfservice/self.html:37 uffd/templates/user/show.html:110
msgid "Password ist not valid, please try again." msgid "E-Mail Addresses"
msgstr "Ungültiges Passwort, bitte versuche es erneut" msgstr "E-Mail-Adressen"
#: uffd/selfservice/views.py:111 #: uffd/templates/selfservice/self.html:38
msgid "New password set" msgid ""
msgstr "Passwort geändert" "Add and delete addresses associated with your account. You will need to "
"verify new addresses by opening a link set to them."
msgstr ""
"Füge neue Adressen zu deinem Account hinzu oder löschen vorhandene. Neue "
"Adressen müssen bestätigt werden, bevor sie verwendet werden können. Dazu"
" erhälst du eine E-Mail mit einem Bestätigungslink."
#: uffd/selfservice/views.py:129 #: uffd/templates/selfservice/self.html:43
msgid "New mail set" msgid "Email"
msgstr "E-Mail-Adresse geändert" msgstr "E-Mail"
#: uffd/selfservice/views.py:140 #: uffd/templates/selfservice/self.html:44
msgid "Leaving roles is disabled" msgid "New E-Mail Address"
msgstr "Verlassen von Rollen ist deaktiviert" msgstr "Neue E-Mail-Adresse"
#: uffd/selfservice/views.py:147 #: uffd/templates/selfservice/self.html:45
#, python-format msgid "Add address"
msgid "You left role %(role_name)s" msgstr "Adresse hinzufügen"
msgstr "Rolle %(role_name)s verlassen"
#: uffd/selfservice/views.py:211 #: uffd/templates/selfservice/self.html:55 uffd/templates/user/show.html:126
#, python-format msgid "primary"
msgid "Mail to \"%(mail_address)s\" could not be sent!" msgstr "primär"
msgstr "E-Mail an \"%(mail_address)s\" konnte nicht gesendet werden!"
#: uffd/selfservice/templates/selfservice/forgot_password.html:11 #: uffd/templates/selfservice/self.html:57
msgid "Forgot password" msgid "unverified"
msgstr "Passwort vergessen" msgstr "nicht bestätigt"
#: uffd/selfservice/templates/selfservice/forgot_password.html:14 #: uffd/templates/selfservice/self.html:62 uffd/views/selfservice.py:175
#: uffd/selfservice/templates/selfservice/self.html:22 msgid "Cannot delete primary e-mail address"
#: uffd/session/templates/session/login.html:14 msgstr "Primäre E-Mail-Adresse kann nicht gelöscht werden"
#: uffd/signup/templates/signup/start.html:19
#: uffd/user/templates/user/list.html:18 uffd/user/templates/user/show.html:48
msgid "Login Name"
msgstr "Benutzername"
#: uffd/selfservice/templates/selfservice/forgot_password.html:18 #: uffd/templates/selfservice/self.html:65
msgid "Mail Address" msgid "Retry verification"
msgstr "E-Mail-Adresse" msgstr "Bestätigungslink neusenden"
#: uffd/selfservice/templates/selfservice/forgot_password.html:22 #: uffd/templates/selfservice/self.html:81
msgid "Send password reset mail" msgid "E-Mail Preferences"
msgstr "Passwort-Zurücksetzen-Mail versenden" msgstr "E-Mail-Einstellungen"
#: uffd/selfservice/templates/selfservice/self.html:7 #: uffd/templates/selfservice/self.html:83
msgid "" msgid ""
"Some permissions require you to setup two-factor authentication.\n" "Choose your primary e-mail address and the address password recovery "
"\tThese permissions are not in effect until you do that." "e-mails will be sent to."
msgstr "" msgstr ""
"Einige deiner Berechtigungen erfordern das Einrichten von Zwei-Faktor-" "Wähle deine primäre Adresse und die Adresse für Passwort-"
"Authentifizierung.\n" "Zurücksetzen-E-Mails aus."
"\tDiese Berechtigungen werden erst aktiv, wenn du dies getan hast."
#: uffd/selfservice/templates/selfservice/self.html:14 #: uffd/templates/selfservice/self.html:85
#: uffd/user/templates/user/show.html:18 msgid "You can also select different addresses for different services."
msgid "Profile"
msgstr "Profile"
#: uffd/selfservice/templates/selfservice/self.html:15
msgid ""
"Your profile information is used by all services that are integrated into"
" the Single-Sign-On. Your e-mail address is also used for password "
"recovery."
msgstr "" msgstr ""
"Deine Profilangaben werden von allen Diensten verwendet, die an das " "Du kannst für unterschiedliche Dienste unterschiedliche Adressen "
"Single-Sign-On angeschlossen sind. Die E-Mail-Adresse wird außerdem für " "verwenden."
"die „Passwort vergessen“ genutzt."
#: uffd/selfservice/templates/selfservice/self.html:16 #: uffd/templates/selfservice/self.html:88
msgid "Changes may take serveral minutes to be visible in all services." msgid "Adresses must be verified before you can select them here."
msgstr "Änderungen sind erst nach einigen Minuten in allen Diensten sichtbar." msgstr "Adressen müssen bestätigt sein, damit du sie hier auswählen kannst."
#: uffd/selfservice/templates/selfservice/self.html:26 #: uffd/templates/selfservice/self.html:93
#: uffd/user/templates/user/show.html:28 msgid "Primary Address"
msgid "User ID" msgstr "Primäre Adresse"
msgstr "Benutzer ID"
#: uffd/selfservice/templates/selfservice/self.html:31 #: uffd/templates/selfservice/self.html:101
#: uffd/signup/templates/signup/start.html:32 msgid "Address for Password Reset E-Mails"
#: uffd/user/templates/user/list.html:19 uffd/user/templates/user/show.html:63 msgstr "Adresse für Passwort-Zurücksetzen-E-Mails"
msgid "Display Name"
msgstr "Anzeigename"
#: uffd/selfservice/templates/selfservice/self.html:35 #: uffd/templates/selfservice/self.html:103
#: uffd/signup/templates/signup/start.html:39 #: uffd/templates/selfservice/self.html:116 uffd/templates/user/show.html:155
msgid "E-Mail Address" #: uffd/templates/user/show.html:165
msgstr "E-Mail-Adresse" msgid "Use primary address"
msgstr "Primäre Adresse verwenden"
#: uffd/selfservice/templates/selfservice/self.html:38 #: uffd/templates/selfservice/self.html:114
msgid "We will send you a confirmation mail to this address if you change it" #, python-format
msgstr "" msgid "Address for Service \"%(name)s\""
"Wir werden dir eine Bestätigungsmail zum Setzen der neuen E-Mail-Adresse " msgstr "Adresse für Dienst „%(name)s“"
"senden."
#: uffd/selfservice/templates/selfservice/self.html:41 #: uffd/templates/selfservice/self.html:125
msgid "Update Profile" msgid "Show more settings ..."
msgstr "Änderungen speichern" msgstr "Weitere Einstellungen anzeigen ..."
#: uffd/selfservice/templates/selfservice/self.html:50 #: uffd/templates/selfservice/self.html:127
#: uffd/session/templates/session/login.html:18 msgid "Update E-Mail Preferences"
#: uffd/signup/templates/signup/start.html:46 msgstr "E-Mail-Einstellungen speichern"
#: uffd/user/templates/user/show.html:77
#: uffd/templates/selfservice/self.html:136
#: uffd/templates/session/login.html:16 uffd/templates/signup/start.html:36
#: uffd/templates/user/show.html:175
msgid "Password" msgid "Password"
msgstr "Passwort" msgstr "Passwort"
#: uffd/selfservice/templates/selfservice/self.html:51 #: uffd/templates/selfservice/self.html:137
msgid "" msgid ""
"Your login password for the Single-Sign-On. Only enter it on the Single-" "Your login password for the Single-Sign-On. Only enter it on the Single-"
"Sign-On login page! No other legit websites will ask you for this " "Sign-On login page! No other legit websites will ask you for this "
...@@ -1067,27 +1132,22 @@ msgstr "" ...@@ -1067,27 +1132,22 @@ msgstr ""
" Webseite wird dich nach diesem Passwort fragen. Es wird auch niemals für" " Webseite wird dich nach diesem Passwort fragen. Es wird auch niemals für"
" Support-Anfragen benötigt." " Support-Anfragen benötigt."
#: uffd/selfservice/templates/selfservice/self.html:56 #: uffd/templates/selfservice/self.html:142
#: uffd/selfservice/templates/selfservice/set_password.html:14 #: uffd/templates/selfservice/set_password.html:9
msgid "New Password" msgid "New Password"
msgstr "Neues Passwort" msgstr "Neues Passwort"
#: uffd/selfservice/templates/selfservice/self.html:58 #: uffd/templates/selfservice/self.html:148
#: uffd/user/templates/user/show.html:84 #: uffd/templates/selfservice/set_password.html:16
msgid "At least 8 and at most 256 characters, no other special requirements." #: uffd/templates/signup/start.html:43
msgstr "Mindestens 8 und maximal 256 Zeichen, keine weiteren Einschränkungen."
#: uffd/selfservice/templates/selfservice/self.html:62
#: uffd/selfservice/templates/selfservice/set_password.html:21
#: uffd/signup/templates/signup/start.html:53
msgid "Repeat Password" msgid "Repeat Password"
msgstr "Passwort wiederholen" msgstr "Passwort wiederholen"
#: uffd/selfservice/templates/selfservice/self.html:64 #: uffd/templates/selfservice/self.html:150
msgid "Change Password" msgid "Change Password"
msgstr "Passwort ändern" msgstr "Passwort ändern"
#: uffd/selfservice/templates/selfservice/self.html:74 #: uffd/templates/selfservice/self.html:160
msgid "" msgid ""
"Setting up Two-Factor Authentication (2FA) adds an additional step to the" "Setting up Two-Factor Authentication (2FA) adds an additional step to the"
" Single-Sign-On login and increases the security of your account " " Single-Sign-On login and increases the security of your account "
...@@ -1097,11 +1157,52 @@ msgstr "" ...@@ -1097,11 +1157,52 @@ msgstr ""
"Anmeldung im Single-Sign-On hinzu und verbessert damit die Sicherheit " "Anmeldung im Single-Sign-On hinzu und verbessert damit die Sicherheit "
"deines Accounts erheblich." "deines Accounts erheblich."
#: uffd/selfservice/templates/selfservice/self.html:84 #: uffd/templates/selfservice/self.html:170
msgid "Manage two-factor authentication" msgid "Manage two-factor authentication"
msgstr "Zwei-Faktor-Authentifizierung (2FA) verwalten" msgstr "Zwei-Faktor-Authentifizierung (2FA) verwalten"
#: uffd/selfservice/templates/selfservice/self.html:93 #: uffd/templates/selfservice/self.html:178
msgid "Active Sessions"
msgstr "Aktive Sitzungen"
#: uffd/templates/selfservice/self.html:179
msgid "Your active login sessions on this device and other devices."
msgstr "Deine aktiven Sitzungen auf diesem und anderen Geräten."
#: uffd/templates/selfservice/self.html:180
msgid ""
"Revoke a session to log yourself out on another device. Note that this is"
" limited to the Single-Sign-On session and <b>does not affect login "
"sessions on services.</b>"
msgstr ""
"Widerrufe eine Sitzung, um dich auf einem anderen Gerät abzumelden. "
"Beachte dass dies auf deine Sitzung im Single-Sign-On beschränkt ist und "
"sich <b>nicht auf Sitzungen an Diensten auswirkt.</b>"
#: uffd/templates/selfservice/self.html:186
msgid "Last used"
msgstr "Zuletzt verwendet"
#: uffd/templates/selfservice/self.html:187
msgid "Device"
msgstr "Gerät"
#: uffd/templates/selfservice/self.html:193
#: uffd/templates/selfservice/self.html:202
msgid "Just now"
msgstr "Gerade eben"
#: uffd/templates/selfservice/self.html:211
msgid "Revoke"
msgstr "Widerrufen"
#: uffd/templates/selfservice/self.html:227 uffd/templates/user/list.html:20
#: uffd/templates/user/show.html:51 uffd/templates/user/show.html:207
#: uffd/views/role.py:21
msgid "Roles"
msgstr "Rollen"
#: uffd/templates/selfservice/self.html:228
msgid "" msgid ""
"Aside from a set of base permissions, your roles determine the " "Aside from a set of base permissions, your roles determine the "
"permissions of your account." "permissions of your account."
...@@ -1109,7 +1210,7 @@ msgstr "" ...@@ -1109,7 +1210,7 @@ msgstr ""
"Deine Berechtigungen werden, von einigen Basis-Berechtigungen abgesehen, " "Deine Berechtigungen werden, von einigen Basis-Berechtigungen abgesehen, "
"von deinen Rollen bestimmt" "von deinen Rollen bestimmt"
#: uffd/selfservice/templates/selfservice/self.html:95 #: uffd/templates/selfservice/self.html:230
#, python-format #, python-format
msgid "" msgid ""
"See <a href=\"%(services_url)s\">Services</a> for an overview of your " "See <a href=\"%(services_url)s\">Services</a> for an overview of your "
...@@ -1118,15 +1219,13 @@ msgstr "" ...@@ -1118,15 +1219,13 @@ msgstr ""
"Auf <a href=\"%(services_url)s\">Dienste</a> erhälst du einen Überblick " "Auf <a href=\"%(services_url)s\">Dienste</a> erhälst du einen Überblick "
"über deine aktuellen Berechtigungen." "über deine aktuellen Berechtigungen."
#: uffd/selfservice/templates/selfservice/self.html:100 #: uffd/templates/selfservice/self.html:234
msgid "Administrators and role moderators can invite you to new roles." msgid "Administrators and role moderators can invite you to new roles."
msgstr "Administratoren und Rollen-Moderatoren können dich zu Rollen einladen." msgstr ""
"Accounts mit Adminrechten oder Rollen-Moderationsrechten können dich zu "
#: uffd/selfservice/templates/selfservice/self.html:102 "Rollen einladen."
msgid "Administrators can add new roles to your account."
msgstr "Administratoren können dich zu neuen Rollen hinzufügen."
#: uffd/selfservice/templates/selfservice/self.html:117 #: uffd/templates/selfservice/self.html:249
msgid "" msgid ""
"Some permissions in this role require you to setup two-factor " "Some permissions in this role require you to setup two-factor "
"authentication" "authentication"
...@@ -1134,36 +1233,81 @@ msgstr "" ...@@ -1134,36 +1233,81 @@ msgstr ""
"Einige Berechtigungen dieser Rolle erfordern das Einrichten von Zwei-" "Einige Berechtigungen dieser Rolle erfordern das Einrichten von Zwei-"
"Faktor-Authentifikation" "Faktor-Authentifikation"
#: uffd/selfservice/templates/selfservice/self.html:124 #: uffd/templates/selfservice/self.html:255
msgid "Leave" msgid "Leave"
msgstr "Verlassen" msgstr "Verlassen"
#: uffd/selfservice/templates/selfservice/self.html:132 #: uffd/templates/selfservice/self.html:262
msgid "You currently don't have any roles" msgid "You currently don't have any roles"
msgstr "Du hast derzeit keine Rollen" msgstr "Du hast derzeit keine Rollen"
#: uffd/selfservice/templates/selfservice/set_password.html:11 #: uffd/templates/selfservice/set_password.html:6
msgid "Reset password" msgid "Reset password"
msgstr "Passwort zurücksetzen" msgstr "Passwort zurücksetzen"
#: uffd/selfservice/templates/selfservice/set_password.html:17 #: uffd/templates/selfservice/set_password.html:20
#: uffd/signup/templates/signup/start.html:49
msgid ""
"At least 8 and at most 256 characters, no other special requirements. But"
" please don't be stupid, do use a password manager."
msgstr ""
"Mindestens 8 und maximal 256 Zeichen, keine weiteren Einschränkungen. "
"Bitte sei nicht dumm und verwende einen Passwort-Manager."
#: uffd/selfservice/templates/selfservice/set_password.html:25
msgid "Set password" msgid "Set password"
msgstr "Passwort setzen" msgstr "Passwort setzen"
#: uffd/services/views.py:83 #: uffd/templates/service/api.html:20
msgid "Services" msgid "Authentication Username"
msgstr "Dienste" msgstr "Authentifikations-Name"
#: uffd/templates/service/api.html:25
msgid "Authentication Password"
msgstr "Authentifikations-Passwort"
#: uffd/templates/service/api.html:37
msgid "Access user and group data"
msgstr "Zugriff auf Account- und Gruppen-Daten"
#: uffd/services/templates/services/overview.html:8 #: uffd/templates/service/api.html:41
msgid "Verify user passwords"
msgstr "Passwörter von Nutzeraccounts verifizieren"
#: uffd/templates/service/api.html:45
msgid "Access mail aliases"
msgstr "Zugriff auf Mail-Weiterleitungen"
#: uffd/templates/service/api.html:49
msgid "Resolve remailer addresses"
msgstr "Auflösen von Remailer-Adressen"
#: uffd/templates/service/api.html:51 uffd/templates/service/show.html:55
msgid "This option has no effect: Remailer config options are unset"
msgstr "Diese Option hat keine Auswirkung: Remailer ist nicht konfiguriert"
#: uffd/templates/service/api.html:56
msgid "Access uffd metrics"
msgstr "Zugriff auf uffd-Metriken"
#: uffd/templates/service/oauth2.html:20 uffd/templates/service/show.html:112
msgid "Client ID"
msgstr "Client-ID"
#: uffd/templates/service/oauth2.html:25
msgid "Client Secret"
msgstr "Client-Secret"
#: uffd/templates/service/oauth2.html:34
msgid "Redirect URIs"
msgstr "Redirect-URIs"
#: uffd/templates/service/oauth2.html:37
msgid "One URI per line"
msgstr "Eine URI pro Zeile"
#: uffd/templates/service/oauth2.html:42
msgid "Logout URIs"
msgstr "Abmelde-URIs"
#: uffd/templates/service/oauth2.html:49
msgid "One URI per line, prefixed with space-separated method (GET/POST)"
msgstr ""
"Eine URI pro Zeile, vorangestellt die mit Leerzeichen getrennte HTTP-"
"Methode (GET/POST)"
#: uffd/templates/service/overview.html:11
msgid "" msgid ""
"Some services may not be publicly listed! Log in to see all services you " "Some services may not be publicly listed! Log in to see all services you "
"have access to." "have access to."
...@@ -1171,70 +1315,119 @@ msgstr "" ...@@ -1171,70 +1315,119 @@ msgstr ""
"Einige Dienste sind eventuell nicht öffentlich aufgelistet! Melde dich an" "Einige Dienste sind eventuell nicht öffentlich aufgelistet! Melde dich an"
" um alle Dienste zu sehen, auf die du Zugriff hast." " um alle Dienste zu sehen, auf die du Zugriff hast."
#: uffd/services/templates/services/overview.html:44 #: uffd/templates/service/overview.html:36
#, python-format
msgid "Logo for %(service_title)s"
msgstr "Logo für %(service_title)s"
#: uffd/templates/service/overview.html:55
msgid "No access" msgid "No access"
msgstr "Kein Zugriff" msgstr "Kein Zugriff"
#: uffd/services/templates/services/overview.html:78 #: uffd/templates/service/overview.html:75
#: uffd/user/templates/user/list.html:58 uffd/user/templates/user/list.html:79 msgid "Manage OAuth2 and API clients"
msgstr "OAuth2- und API-Clients verwalten"
#: uffd/templates/service/overview.html:95 uffd/templates/user/list.html:61
#: uffd/templates/user/list.html:82
msgid "Close" msgid "Close"
msgstr "Schließen" msgstr "Schließen"
#: uffd/session/views.py:91 #: uffd/templates/service/show.html:24
msgid "Access Restriction"
msgstr "Zugriffsbeschränkungen"
#: uffd/templates/service/show.html:26
msgid "No user has access"
msgstr "Kein Account hat Zugriff"
#: uffd/templates/service/show.html:27
msgid "All users have access (legacy)"
msgstr "Alle Account haben Zugriff (veraltet)"
#: uffd/templates/service/show.html:29
#, python-format #, python-format
msgid "Members of group \"%(group_name)s\" have access"
msgstr "Mitglieder der Gruppe \"%(group_name)s\" haben Zugriff"
#: uffd/templates/service/show.html:37
msgid "Hide deactivated users from service"
msgstr "Deaktivierte Nutzer verstecken"
#: uffd/templates/service/show.html:44
msgid "" msgid ""
"We received too many invalid login attempts for this user! Please wait at" "Allow users with access to select a different e-mail address for this "
" least %(delay)s." "service"
msgstr "" msgstr ""
"Wir haben zu viele fehlgeschlagene Anmeldeversuche für diesen Account " "Ermögliche Nutzern mit Zugriff auf diesen Dienst eine andere E-Mail-"
"erhalten! Bitte warte mindestens %(delay)s." "Adresse auszuwählen"
#: uffd/session/views.py:99 #: uffd/templates/service/show.html:46
msgid "Login name or password is wrong" msgid "If disabled, the service always uses the primary e-mail address."
msgstr "Der Benutzername oder das Passwort ist falsch" msgstr "Wenn deaktiviert, wird immer die primäre E-Mail-Adresse verwendet."
#: uffd/session/views.py:102 #: uffd/templates/service/show.html:53
msgid "You do not have access to this service" msgid "Hide e-mail addresses with remailer"
msgstr "Du hast keinen Zugriff auf diesen Service" msgstr "E-Mail-Adressen mit Remailer verstecken"
#: uffd/session/views.py:114 uffd/session/views.py:125 #: uffd/templates/service/show.html:60 uffd/templates/service/show.html:83
msgid "You need to login first" msgid "Remailer disabled"
msgstr "Du musst dich erst anmelden" msgstr "Remailer deaktiviert"
#: uffd/session/views.py:147 uffd/session/views.py:157 #: uffd/templates/service/show.html:63 uffd/templates/service/show.html:86
msgid "Initiation code is no longer valid" msgid "Remailer enabled"
msgstr "Startcode ist nicht mehr gültig" msgstr "Remailer aktiviert"
#: uffd/session/views.py:161 #: uffd/templates/service/show.html:66 uffd/templates/service/show.html:89
msgid "Invalid confirmation code" msgid "Remailer enabled (deprecated, case-sensitive format)"
msgstr "Ungültiger Bestätigungscode" msgstr ""
"Remailer aktiviert (veraltetes, Groß-/Kleinschreibung-unterscheidendes "
"Format)"
#: uffd/session/views.py:173 uffd/session/views.py:184 #: uffd/templates/service/show.html:70
msgid "Invalid initiation code" msgid ""
msgstr "Ungültiger Startcode" "Some services notify users about changes to their e-mail address. "
"Modifying this setting immediatly affects the e-mail addresses of all "
"users and can cause masses of notification e-mails."
msgstr ""
"Einige Dienste benachrichtigen Nutzer bei Änderungen ihrer E-Mail-"
"Adresse. Diese Einstellung zu verändern wirkt sich unmittelbar auf die E"
"-Mail-Adressen aller Nutzer aus und kann zu massenhaftem Versand von "
"Benachrichtigungs-E-Mails führen."
#: uffd/session/templates/session/deviceauth.html:17 #: uffd/templates/service/show.html:76
#: uffd/templates/base.html:92 msgid "Overwrite remailer setting for specific users"
msgid "Authorize Device Login" msgstr "Überschreibe Remailer-Einstellung für ausgewählte Nutzer"
msgstr "Gerätelogin erlauben"
#: uffd/templates/service/show.html:79
msgid "Login names"
msgstr "Anmeldenamen"
#: uffd/session/templates/session/deviceauth.html:20 #: uffd/templates/service/show.html:94
msgid ""
"Useful for testing remailer before enabling it for all users. Specify "
"users as a comma-seperated list of login names."
msgstr ""
"Hilfreich zum Testen des Remailers vor dem Aktivieren für alle Nutzer. Um"
" Nutzer auszuwählen, liste ihre Anmeldenamen mit Komma getrennt auf."
#: uffd/templates/session/deviceauth.html:15
msgid "Log into a service on another device without entering your password." msgid "Log into a service on another device without entering your password."
msgstr "" msgstr ""
"Melde dich an einem Dienst auf einem anderen Gerät an ohne dein Password " "Melde dich an einem Dienst auf einem anderen Gerät an ohne dein Password "
"eingeben zu müssen." "eingeben zu müssen."
#: uffd/session/templates/session/deviceauth.html:23 #: uffd/templates/session/deviceauth.html:18
#: uffd/session/templates/session/devicelogin.html:18 #: uffd/templates/session/devicelogin.html:13
msgid "Initiation Code" msgid "Initiation Code"
msgstr "Startcode" msgstr "Startcode"
#: uffd/session/templates/session/deviceauth.html:32 #: uffd/templates/session/deviceauth.html:27
#: uffd/session/templates/session/devicelogin.html:23 #: uffd/templates/session/devicelogin.html:18
msgid "Confirmation Code" msgid "Confirmation Code"
msgstr "Bestätigungscode" msgstr "Bestätigungscode"
#: uffd/session/templates/session/deviceauth.html:38 #: uffd/templates/session/deviceauth.html:33
msgid "" msgid ""
"Start logging into a service on the other device and chose \"Device " "Start logging into a service on the other device and chose \"Device "
"Login\" on the login page. Enter the displayed initiation code in the box" "Login\" on the login page. Enter the displayed initiation code in the box"
...@@ -1244,21 +1437,16 @@ msgstr "" ...@@ -1244,21 +1437,16 @@ msgstr ""
"\"Gerätelogin\" auf der Anmeldeseite aus. Gib den angezeigten Startcode " "\"Gerätelogin\" auf der Anmeldeseite aus. Gib den angezeigten Startcode "
"oben ein." "oben ein."
#: uffd/session/templates/session/deviceauth.html:41 #: uffd/templates/session/deviceauth.html:43
#: uffd/session/templates/session/devicelogin.html:30
msgid "Continue"
msgstr "Weiter"
#: uffd/session/templates/session/deviceauth.html:48
#, python-format #, python-format
msgid "Authorize the login for service <b>%(service_name)s</b>?" msgid "Authorize the login for service <b>%(service_name)s</b>?"
msgstr "Anmeldung an Dienst <b>%(service_name)s</b> erlauben?" msgstr "Anmeldung an Dienst <b>%(service_name)s</b> erlauben?"
#: uffd/session/templates/session/deviceauth.html:51 #: uffd/templates/session/deviceauth.html:46
msgid "Authorize Login" msgid "Authorize Login"
msgstr "Anmeldung erlauben" msgstr "Anmeldung erlauben"
#: uffd/session/templates/session/deviceauth.html:58 #: uffd/templates/session/deviceauth.html:53
msgid "" msgid ""
"Enter the confirmation code on the other device and complete the login. " "Enter the confirmation code on the other device and complete the login. "
"Click <em>Finish</em> afterwards." "Click <em>Finish</em> afterwards."
...@@ -1266,16 +1454,11 @@ msgstr "" ...@@ -1266,16 +1454,11 @@ msgstr ""
"Gib den Bestätigungscode auf dem anderen Gerät ein und schließe die " "Gib den Bestätigungscode auf dem anderen Gerät ein und schließe die "
"Anmeldung ab. Clicke danach auf <em>Abschließen</em>." "Anmeldung ab. Clicke danach auf <em>Abschließen</em>."
#: uffd/session/templates/session/deviceauth.html:61 #: uffd/templates/session/deviceauth.html:56
msgid "Finish" msgid "Finish"
msgstr "Beenden" msgstr "Beenden"
#: uffd/session/templates/session/devicelogin.html:11 #: uffd/templates/session/devicelogin.html:9
#: uffd/session/templates/session/login.html:27 uffd/templates/base.html:93
msgid "Device Login"
msgstr "Geratelogin"
#: uffd/session/templates/session/devicelogin.html:14
msgid "" msgid ""
"Use a login session on another device (e.g. your laptop) to log into a " "Use a login session on another device (e.g. your laptop) to log into a "
"service without entering your password." "service without entering your password."
...@@ -1283,7 +1466,7 @@ msgstr "" ...@@ -1283,7 +1466,7 @@ msgstr ""
"Nutze eine Login-Sitzung auf einem anderen Gerät (z.B. deinem Laptop) um " "Nutze eine Login-Sitzung auf einem anderen Gerät (z.B. deinem Laptop) um "
"dich bei einem Dienst anzumelden." "dich bei einem Dienst anzumelden."
#: uffd/session/templates/session/devicelogin.html:27 #: uffd/templates/session/devicelogin.html:22
#, python-format #, python-format
msgid "" msgid ""
"Open <code><a href=\"%(deviceauth_url)s\">%(deviceauth_url)s</a></code> " "Open <code><a href=\"%(deviceauth_url)s\">%(deviceauth_url)s</a></code> "
...@@ -1294,73 +1477,47 @@ msgstr "" ...@@ -1294,73 +1477,47 @@ msgstr ""
"auf dem anderen Gerät und gib dort den obenstehenden Startcode ein. Geben" "auf dem anderen Gerät und gib dort den obenstehenden Startcode ein. Geben"
" anschließend den Bestätigungscode hier ein." " anschließend den Bestätigungscode hier ein."
#: uffd/session/templates/session/login.html:11 #: uffd/templates/session/login.html:23
#: uffd/session/templates/session/login.html:22
msgid "Login"
msgstr "Anmelden"
#: uffd/session/templates/session/login.html:25
msgid "- or -" msgid "- or -"
msgstr "- oder -" msgstr "- oder -"
#: uffd/session/templates/session/login.html:32 #: uffd/templates/session/login.html:25
msgid "Login with another device"
msgstr "Über anderes Gerät anmelden"
#: uffd/templates/session/login.html:30
msgid "Register" msgid "Register"
msgstr "Registrieren" msgstr "Registrieren"
#: uffd/session/templates/session/login.html:35 #: uffd/templates/session/login.html:32
msgid "Forgot Password?" msgid "Forgot Password?"
msgstr "Passwort vergessen?" msgstr "Passwort vergessen?"
#: uffd/signup/views.py:23 #: uffd/templates/signup/confirm.html:6
msgid "Singup not enabled"
msgstr "Account-Registrierung ist deaktiviert"
#: uffd/signup/views.py:77 uffd/signup/views.py:85
msgid "Invalid signup link"
msgstr "Ungültiger Account-Registrierungs-Link"
#: uffd/signup/views.py:90
#, python-format
msgid "Too many failed attempts! Please wait %(delay)s."
msgstr "Zu viele fehlgeschlagene Versuche! Bitte warte mindestens %(delay)s."
#: uffd/signup/views.py:96
msgid "Wrong password"
msgstr "Falsches Passwort"
#: uffd/signup/views.py:103
msgid "Your account was successfully created"
msgstr "Account erfolgreich erstellt"
#: uffd/signup/templates/signup/confirm.html:11
msgid "Complete Registration" msgid "Complete Registration"
msgstr "Account-Registrierung abschließen" msgstr "Account-Registrierung abschließen"
#: uffd/signup/templates/signup/confirm.html:17 #: uffd/templates/signup/confirm.html:9
msgid "Please enter your password to complete the account registration" msgid "Please enter your password to complete the account registration"
msgstr "Bitte gib dein Passwort ein, um die Account-Registrierung abzuschließen" msgstr "Bitte gib dein Passwort ein, um die Account-Registrierung abzuschließen"
#: uffd/signup/templates/signup/confirm.html:21 #: uffd/templates/signup/confirm.html:13
msgid "Complete Account Registration" msgid "Complete Account Registration"
msgstr "Account-Registrierung abschließen" msgstr "Account-Registrierung abschließen"
#: uffd/signup/templates/signup/start.html:23 #: uffd/templates/signup/start.html:13
msgid "Check" msgid "Check"
msgstr "Überprüfen" msgstr "Überprüfen"
#: uffd/signup/templates/signup/start.html:28 #: uffd/templates/signup/start.html:25
msgid ""
"At least one and at most 32 lower-case characters, digits, dashes (\"-\")"
" or underscores (\"_\"). <b>Cannot be changed later!</b>"
msgstr ""
"1 bis 32 Kleinbuchstaben, Zahlen, Binde- (\"-\") und Unterstriche "
"(\"_\"). <b>Kann später nicht geändert werden!</b>"
#: uffd/signup/templates/signup/start.html:35
msgid "At least one and at most 128 characters, no other special requirements." msgid "At least one and at most 128 characters, no other special requirements."
msgstr "Mindestens 1 und maximal 128 Zeichen, keine weiteren Einschränkungen." msgstr "Mindestens 1 und maximal 128 Zeichen, keine weiteren Einschränkungen."
#: uffd/signup/templates/signup/start.html:42 #: uffd/templates/signup/start.html:29 uffd/templates/user/show.html:102
msgid "E-Mail Address"
msgstr "E-Mail-Adresse"
#: uffd/templates/signup/start.html:32
msgid "" msgid ""
"We will send a confirmation mail to this address that you need to " "We will send a confirmation mail to this address that you need to "
"complete the registration." "complete the registration."
...@@ -1368,27 +1525,27 @@ msgstr "" ...@@ -1368,27 +1525,27 @@ msgstr ""
"Wir werden eine Bestätigungsmail an diese Adresse senden. Du benötigst " "Wir werden eine Bestätigungsmail an diese Adresse senden. Du benötigst "
"sie, um die Account-Registrierung abzuschließen." "sie, um die Account-Registrierung abzuschließen."
#: uffd/signup/templates/signup/start.html:57 #: uffd/templates/signup/start.html:47
msgid "Create Account" msgid "Create Account"
msgstr "Account registrieren" msgstr "Account registrieren"
#: uffd/signup/templates/signup/start.html:86 #: uffd/templates/signup/start.html:74
msgid "The name is already taken" msgid "The name is already taken"
msgstr "Dieser Name wird bereits verwendet" msgstr "Dieser Name wird bereits verwendet"
#: uffd/signup/templates/signup/start.html:89 #: uffd/templates/signup/start.html:77
msgid "Too many requests! Please wait a bit before trying again!" msgid "Too many requests! Please wait a bit before trying again!"
msgstr "Zu viele Anfragen! Bitte warte etwas, bevor du es erneut versuchst!" msgstr "Zu viele Anfragen! Bitte warte etwas, bevor du es erneut versuchst!"
#: uffd/signup/templates/signup/start.html:92 #: uffd/templates/signup/start.html:80
msgid "The name is invalid" msgid "The name is invalid"
msgstr "Name ungültig" msgstr "Name ungültig"
#: uffd/signup/templates/signup/submitted.html:11 #: uffd/templates/signup/submitted.html:5
msgid "Confirm your E-Mail Address" msgid "Confirm your E-Mail Address"
msgstr "E-Mail-Adresse bestätigen" msgstr "E-Mail-Adresse bestätigen"
#: uffd/signup/templates/signup/submitted.html:13 #: uffd/templates/signup/submitted.html:7
#, python-format #, python-format
msgid "" msgid ""
"We sent a confirmation mail to <b>%(signup_mail)s</b>. You need to " "We sent a confirmation mail to <b>%(signup_mail)s</b>. You need to "
...@@ -1399,7 +1556,7 @@ msgstr "" ...@@ -1399,7 +1556,7 @@ msgstr ""
"deine E-Mail-Adresse innerhalb von 48 Stunden bestätigen, um die Account-" "deine E-Mail-Adresse innerhalb von 48 Stunden bestätigen, um die Account-"
"Registrierung abzuschließen." "Registrierung abzuschließen."
#: uffd/signup/templates/signup/submitted.html:14 #: uffd/templates/signup/submitted.html:8
msgid "" msgid ""
"If you mistyped your mail address or don't receive the confirmation mail " "If you mistyped your mail address or don't receive the confirmation mail "
"for another reason, retry the registration procedure from the beginning." "for another reason, retry the registration procedure from the beginning."
...@@ -1408,77 +1565,27 @@ msgstr "" ...@@ -1408,77 +1565,27 @@ msgstr ""
" Gründen keine Bestätigungsmail erhalten hast, kannst du den Prozess " " Gründen keine Bestätigungsmail erhalten hast, kannst du den Prozess "
"einfach von Vorne beginnen." "einfach von Vorne beginnen."
#: uffd/templates/base.html:84 #: uffd/templates/user/list.html:11
msgid "Change"
msgstr "Ändern"
#: uffd/templates/base.html:135
msgid "About uffd"
msgstr "Über uffd"
#: uffd/user/views_group.py:21
msgid "Groups"
msgstr "Gruppen"
#: uffd/user/views_user.py:29
msgid "Users"
msgstr "Benutzer"
#: uffd/user/views_user.py:49
msgid "Login name does not meet requirements"
msgstr "Benutzername entspricht nicht den Anforderungen"
#: uffd/user/views_user.py:54
msgid "Mail is invalid"
msgstr "E-Mail-Adresse nicht valide"
#: uffd/user/views_user.py:58
msgid "Display name does not meet requirements"
msgstr "Anzeigename entspricht nicht den Anforderungen"
#: uffd/user/views_user.py:63
msgid "Password is invalid"
msgstr "Passwort ist ungültig"
#: uffd/user/views_user.py:77
msgid "Service user created"
msgstr "Service-Account erstellt"
#: uffd/user/views_user.py:80
msgid "User created. We sent the user a password reset link by mail"
msgstr ""
"Benutzer erstellt. E-Mail mit einem Link zum Setzen des Passworts wurde "
"versendet."
#: uffd/user/views_user.py:82
msgid "User updated"
msgstr "Benutzer aktualisiert"
#: uffd/user/views_user.py:93
msgid "Deleted user"
msgstr "Benutzer gelöscht"
#: uffd/user/templates/group/list.html:9 uffd/user/templates/group/show.html:7
msgid "GID"
msgstr "GID"
#: uffd/user/templates/user/list.html:11
msgid "CSV import" msgid "CSV import"
msgstr "CSV-Import" msgstr "CSV-Import"
#: uffd/user/templates/user/list.html:17 #: uffd/templates/user/list.html:17
msgid "UID" msgid "UID"
msgstr "UID" msgstr "UID"
#: uffd/user/templates/user/list.html:34 uffd/user/templates/user/show.html:30 #: uffd/templates/user/list.html:34 uffd/templates/user/show.html:60
msgid "service" msgid "service"
msgstr "service" msgstr "service"
#: uffd/user/templates/user/list.html:57 #: uffd/templates/user/list.html:37
msgid "deactivated"
msgstr "deaktiviert"
#: uffd/templates/user/list.html:60
msgid "Import a csv formated list of users" msgid "Import a csv formated list of users"
msgstr "Importiere eine als CSV formatierte Liste von Benutzern" msgstr "Importiere eine als CSV formatierte Liste von Accounts"
#: uffd/user/templates/user/list.html:64 #: uffd/templates/user/list.html:67
msgid "" msgid ""
"The format should be \"loginname,mailaddres,roleid1;roleid2\". Neither " "The format should be \"loginname,mailaddres,roleid1;roleid2\". Neither "
"setting the display name nor setting passwords is supported (yet). " "setting the display name nor setting passwords is supported (yet). "
...@@ -1488,62 +1595,536 @@ msgstr "" ...@@ -1488,62 +1595,536 @@ msgstr ""
"Anzeigename oder das Password können (derzeit) nicht gesetzt werden. " "Anzeigename oder das Password können (derzeit) nicht gesetzt werden. "
"Beispiel:" "Beispiel:"
#: uffd/user/templates/user/list.html:75 uffd/user/templates/user/show.html:58 #: uffd/templates/user/list.html:78 uffd/templates/user/show.html:88
msgid "Ignore login name blacklist" msgid "Ignore login name blocklist"
msgstr "Benutzernamen-Blacklist ignorieren" msgstr "Liste der nicht erlaubten Anmeldenamen ignorieren"
#: uffd/user/templates/user/list.html:80 #: uffd/templates/user/list.html:83
msgid "Import" msgid "Import"
msgstr "Importieren" msgstr "Importieren"
#: uffd/user/templates/user/show.html:10 #: uffd/templates/user/show.html:6
msgid "Reset 2FA" msgid "New address"
msgstr "2FA zurücksetzen" msgstr "Neue Adresse"
#: uffd/templates/user/show.html:27
msgid ""
"This account is deactivated. The user cannot login and existing sessions "
"are not usable. The user cannot log into services, but existing sessions "
"on services might still be active."
msgstr ""
"Dieser Account ist deaktiviert. Der Nutzer kann sich nicht neu anmelden. "
"Existierende Sitzungen sind nicht nutzbar. Eine Anmeldung bei Diensten "
"ist nicht möglich, allerdings könnten bestehende Sitzungen weiterhin "
"aktiv sein."
#: uffd/user/templates/user/show.html:36 #: uffd/templates/user/show.html:34 uffd/templates/user/show.html:38
msgid "Deactivate"
msgstr "Deaktivieren"
#: uffd/templates/user/show.html:36
msgid "Activate"
msgstr "Aktivieren"
#: uffd/templates/user/show.html:58
msgid "User ID"
msgstr "Account ID"
#: uffd/templates/user/show.html:66
msgid "will be choosen" msgid "will be choosen"
msgstr "wird automatisch bestimmt" msgstr "wird automatisch bestimmt"
#: uffd/user/templates/user/show.html:43 #: uffd/templates/user/show.html:73
msgid "Service User" msgid "Service User"
msgstr "Service-Account" msgstr "Service-Account"
#: uffd/user/templates/user/show.html:51 #: uffd/templates/user/show.html:81
msgid "" msgid ""
"Only letters, numbers, dashes (\"-\") and underscores (\"_\") are " "Only letters, numbers, dashes (\"-\") and underscores (\"_\") are "
"allowed. At most 32, at least 2 characters. There is a word blacklist. " "allowed. At most 32, at least 2 characters. There is a word blocklist. "
"Must be unique." "Must be unique."
msgstr "" msgstr ""
"Nur Buchstaben, Zahlen, Binde- (\"-\") und Unterstriche (\"_\") sind " "Nur Buchstaben, Zahlen, Binde- (\"-\") und Unterstriche (\"_\") sind "
"erlaubt. Maximal 32, mindestens 2 Zeichen. Es gibt eine Blacklist. Muss " "erlaubt. Maximal 32, mindestens 2 Zeichen. Es gibt eine Liste nicht "
"einmalig sein." "erlaubter Namen. Muss einmalig sein."
#: uffd/user/templates/user/show.html:66 #: uffd/templates/user/show.html:96
msgid "" msgid ""
"If you leave this empty it will be set to the login name. At most 128, at" "If you leave this empty it will be set to the login name. At most 128, at"
" least 2 characters. No character restrictions." " least 2 characters. No character restrictions."
msgstr "" msgstr ""
"Wenn das Feld leer bleibt, wird der Benutzername verwendet. Maximal 128, " "Wenn das Feld leer bleibt, wird der Anmeldename verwendet. Maximal 128, "
"mindestens 2 Zeichen. Keine Zeichenbeschränkung." "mindestens 2 Zeichen. Keine Zeichenbeschränkung."
#: uffd/user/templates/user/show.html:70 #: uffd/templates/user/show.html:105
msgid "Mail" msgid ""
msgstr "E-Mail-Adresse" "Make sure the address is correct! Services might use e-mail addresses as "
"account identifiers and rely on them being unique and verified."
msgstr ""
"Stelle sicher, dass die Adresse korrekt ist! Manche Dienste verwenden die"
" E-Mail-Adresse um Accounts zu identifizieren und verlassen sich darauf, "
"dass diese verifiziert und einzigartig sind."
#: uffd/user/templates/user/show.html:73 #: uffd/templates/user/show.html:114
msgid "Address"
msgstr "Adresse"
#: uffd/templates/user/show.html:115
msgid "Verified"
msgstr "Verifiziert"
#: uffd/templates/user/show.html:141
msgid "" msgid ""
"Do a sanity check here. A user can take over another account if both have" "Make sure that addresses you add are correct! Services might use e-mail "
" the same mail address set." "addresses as account identifiers and rely on them being unique and "
"verified."
msgstr "" msgstr ""
"Überprüfe, ob die E-Mail-Adresse plausibel ist! Ein Benutzer kann einen " "Stelle sicher, dass Adressen, die du hinzufügst, korrekt sind! Manche "
"anderen Account übernehmen, wenn beide die selbe E-Mail-Adresse " "Dienste verwenden die E-Mail-Adresse um Accounts zu identifizieren und "
"verwenden." "verlassen sich darauf, dass diese verifiziert und einzigartig sind."
#: uffd/templates/user/show.html:145
msgid "Primary E-Mail Address"
msgstr "Primäre E-Mail-Adresse"
#: uffd/templates/user/show.html:153
msgid "Recovery E-Mail Address"
msgstr "Wiederherstellungs-E-Mail-Adresse"
#: uffd/templates/user/show.html:163
#, python-format
msgid "Address for %(name)s"
msgstr "Adresse für %(name)s"
#: uffd/user/templates/user/show.html:81 #: uffd/templates/user/show.html:179
msgid "mail to set it will be sent" msgid "E-Mail to set it will be sent"
msgstr "Mail zum Setzen wird versendet" msgstr "Mail zum Setzen wird versendet"
#: uffd/user/templates/user/show.html:123 #: uffd/templates/user/show.html:190
msgid "Status:"
msgstr "Status:"
#: uffd/templates/user/show.html:190
msgid "Enabled"
msgstr "Aktiv"
#: uffd/templates/user/show.html:193
msgid "Reset 2FA"
msgstr "2FA zurücksetzen"
#: uffd/templates/user/show.html:197
msgid "Sessions"
msgstr "Sitzungen"
#: uffd/templates/user/show.html:198
#, python-format
msgid "%(session_count)d active sessions"
msgstr "%(session_count)d aktive Sitzungen"
#: uffd/templates/user/show.html:199
msgid "Revoke all sessions"
msgstr "Alle Sitzungen widerrufen"
#: uffd/templates/user/show.html:240
msgid "Resulting groups (only updated after save)" msgid "Resulting groups (only updated after save)"
msgstr "Resultierende Gruppen (wird nur aktualisiert beim Speichern)" msgstr "Resultierende Gruppen (wird nur aktualisiert beim Speichern)"
#: uffd/views/group.py:22
msgid "Groups"
msgstr "Gruppen"
#: uffd/views/group.py:42
msgid "GID is already in use or was used in the past"
msgstr "GID wird oder wurde bereits verwendet"
#: uffd/views/group.py:45
msgid "Invalid name"
msgstr "Ungültiger Name"
#: uffd/views/group.py:56
msgid "Group with this name or id already exists"
msgstr "Gruppe mit diesem Namen oder dieser ID existiert bereits"
#: uffd/views/group.py:61
msgid "Group created"
msgstr "Gruppe erstellt"
#: uffd/views/group.py:63
msgid "Group updated"
msgstr "Gruppe aktualisiert"
#: uffd/views/group.py:72
msgid "Deleted group"
msgstr "Gruppe gelöscht"
#: uffd/views/invite.py:43
msgid "Invites"
msgstr "Einladungslinks"
#: uffd/views/invite.py:75
msgid "The \"Expires After\" date is too far in the future"
msgstr "Das Ablaufdatum liegt zu weit in der Zukunft"
#: uffd/views/invite.py:78
msgid "You are not allowed to create invite links with these permissions"
msgstr "Dir fehlen Berechtigungen um diesen Einladungslink zu erstellen"
#: uffd/views/invite.py:81
msgid "Invite link must either allow signup or grant at least one role"
msgstr ""
"Einladungslink muss entweder Account-Registrierung erlauben oder Rollen "
"vergeben"
#: uffd/views/invite.py:111 uffd/views/invite.py:146
msgid "Invalid invite link"
msgstr "Ungültiger Einladungslink"
#: uffd/views/invite.py:129
msgid "Roles successfully updated"
msgstr "Rollen erfolgreich geändert"
#: uffd/views/invite.py:149
msgid "Invite link does not allow signup"
msgstr "Einladungslink erlaubt keine Account-Registrierung"
#: uffd/views/invite.py:175 uffd/views/selfservice.py:44
#: uffd/views/signup.py:47
msgid "Passwords do not match"
msgstr "Die Passwörter stimmen nicht überein"
#: uffd/views/invite.py:181 uffd/views/signup.py:53
#, python-format
msgid "Too many signup requests with this mail address! Please wait %(delay)s."
msgstr ""
"Zu viele Account-Registrierungen mit dieser E-Mail-Adresse! Bitte warte "
"%(delay)s."
#: uffd/views/invite.py:184 uffd/views/signup.py:56
#, python-format
msgid "Too many requests! Please wait %(delay)s."
msgstr "Zu viele Anfragen! Bitte warte %(delay)s."
#: uffd/views/invite.py:199 uffd/views/signup.py:74
msgid "Could not send mail"
msgstr "Mailversand fehlgeschlagen"
#: uffd/views/mail.py:21
msgid "Forwardings"
msgstr "Weiterleitungen"
#: uffd/views/mail.py:46
#, python-format
msgid "Invalid receive address: %(mail_address)s"
msgstr "Ungültige Empfangsadresse: %(mail_address)s"
#: uffd/views/mail.py:50
msgid "Mail mapping updated."
msgstr "Mailweiterleitung geändert."
#: uffd/views/mail.py:59
msgid "Deleted mail mapping."
msgstr "Mailweiterleitung gelöscht."
#: uffd/views/mfa.py:49
msgid "Two-factor authentication was reset"
msgstr "Zwei-Faktor-Authentifizierung wurde zurückgesetzt"
#: uffd/views/mfa.py:78
msgid "Generate recovery codes first!"
msgstr "Generiere zuerst die Wiederherstellungscodes!"
#: uffd/views/mfa.py:86
msgid "Code is invalid"
msgstr "Wiederherstellungscode ist ungültig"
#: uffd/views/mfa.py:105
#, python-format
msgid ""
"2FA WebAuthn support disabled because import of the fido2 module failed "
"(%s)"
msgstr ""
"2FA WebAuthn Unterstützung deaktiviert, da das fido2 Modul nicht geladen "
"werden konnte (%s)"
#: uffd/views/mfa.py:216
#, python-format
msgid "We received too many invalid attempts! Please wait at least %s."
msgstr "Wir haben zu viele fehlgeschlagene Versuche! Bitte warte mindestens %s."
#: uffd/views/mfa.py:231
msgid "You have exhausted your recovery codes. Please generate new ones now!"
msgstr "Du hast keine Wiederherstellungscode mehr. Bitte generiere diese jetzt!"
#: uffd/views/mfa.py:234
msgid ""
"You only have a few recovery codes remaining. Make sure to generate new "
"ones before they run out."
msgstr ""
"Du hast nur noch wenige Wiederherstellungscodes übrig. Bitte generiere "
"diese erneut bevor keine mehr übrig sind."
#: uffd/views/mfa.py:238
msgid "Two-factor authentication failed"
msgstr "Zwei-Faktor-Authentifizierung fehlgeschlagen"
#: uffd/views/oauth2.py:267 uffd/views/selfservice.py:66
#: uffd/views/session.py:86
#, python-format
msgid ""
"We received too many requests from your ip address/network! Please wait "
"at least %(delay)s."
msgstr ""
"Wir haben zu viele Anfragen von deiner IP-Adresses bzw. aus deinem "
"Netzwerk empfangen! Bitte warte mindestens %(delay)s."
#: uffd/views/oauth2.py:278
msgid "Device login is currently not available. Try again later!"
msgstr "Geräte-Login ist gerade nicht verfügbar. Versuche es später nochmal!"
#: uffd/views/oauth2.py:296
msgid "Device login failed"
msgstr "Gerätelogin fehlgeschlagen"
#: uffd/views/oauth2.py:304
msgid "You need to login to access this service"
msgstr "Du musst dich anmelden, um auf diesen Dienst zugreifen zu können"
#: uffd/views/oauth2.py:335
#, python-format
msgid ""
"You don't have the permission to access the service "
"<b>%(service_name)s</b>."
msgstr ""
"Du bist nicht berechtigt, auf den Dienst <b>%(service_name)s</b> "
"zuzugreifen."
#: uffd/views/role.py:68
msgid "Locked roles cannot be deleted"
msgstr "Gesperrte Rollen können nicht gelöscht werden"
#: uffd/views/rolemod.py:22
msgid "Moderation"
msgstr "Moderation"
#: uffd/views/rolemod.py:42
msgid "Description too long"
msgstr "Beschreibung zu lang"
#: uffd/views/rolemod.py:59
msgid "Member removed"
msgstr "Mitglied entfernt"
#: uffd/views/selfservice.py:22
msgid "Selfservice"
msgstr "Selfservice"
#: uffd/views/selfservice.py:33
msgid "Display name changed."
msgstr "Anzeigename geändert."
#: uffd/views/selfservice.py:35
msgid "Display name is not valid."
msgstr "Anzeigename ist nicht valide."
#: uffd/views/selfservice.py:47
msgid "Password changed"
msgstr "Passwort geändert"
#: uffd/views/selfservice.py:64
#, python-format
msgid ""
"We received too many password reset requests for this user! Please wait "
"at least %(delay)s."
msgstr ""
"Wir haben zu viele fehlgeschlagene Anmeldeversuche für diesen Account! "
"Bitte warte mindestens %(delay)s."
#: uffd/views/selfservice.py:70
msgid ""
"We sent a mail to this user's mail address if you entered the correct "
"mail and login name combination"
msgstr ""
"Falls E-Mail-Adresse und Anmeldename richtig waren, wurde eine E-Mail an "
"die Adresse gesendet."
#: uffd/views/selfservice.py:87 uffd/views/selfservice.py:138
#: uffd/views/selfservice.py:143
msgid "Link invalid or expired"
msgstr "Link ist ungültig oder abgelaufen"
#: uffd/views/selfservice.py:92
msgid "You need to set a password, please try again."
msgstr "Password fehlt, bitte versuche es erneut."
#: uffd/views/selfservice.py:95
msgid "Passwords do not match, please try again."
msgstr "Die Passwörter stimmen nicht überein, bitte versuche es erneut"
#: uffd/views/selfservice.py:100
msgid "Password ist not valid, please try again."
msgstr "Ungültiges Passwort, bitte versuche es erneut"
#: uffd/views/selfservice.py:104
msgid "New password set"
msgstr "Passwort geändert"
#: uffd/views/selfservice.py:117
msgid "E-Mail address already exists"
msgstr "E-Mail-Adresse existiert bereits"
#: uffd/views/selfservice.py:124 uffd/views/selfservice.py:162
#: uffd/views/selfservice.py:237
#, python-format
msgid "E-Mail to \"%(mail_address)s\" could not be sent!"
msgstr "E-Mail an \"%(mail_address)s\" konnte nicht gesendet werden!"
#: uffd/views/selfservice.py:126 uffd/views/selfservice.py:164
msgid "We sent you an email, please verify your mail address."
msgstr "Wir haben dir eine E-Mail gesendet, bitte prüfe deine E-Mail-Adresse."
#: uffd/views/selfservice.py:141
msgid ""
"This link was generated for another user. Login as the correct user to "
"continue."
msgstr ""
"Dieser Link wurde für einen anderen Account erstellt. Melde dich mit dem "
"richtigen Account an um Fortzufahren."
#: uffd/views/selfservice.py:150
msgid "E-Mail address is already used by another account"
msgstr "E-Mail-Adresse wird bereits von einem anderen Account verwendet"
#: uffd/views/selfservice.py:152
msgid "E-Mail address verified"
msgstr "E-Mail-Adresse verifiziert"
#: uffd/views/selfservice.py:177
msgid "E-Mail address deleted"
msgstr "E-Mail-Adresse gelöscht"
#: uffd/views/selfservice.py:198
msgid "E-Mail preferences updated"
msgstr "E-Mail-Einstellungen geändert"
#: uffd/views/selfservice.py:208
msgid "Session revoked"
msgstr "Sitzung widerrufen"
#: uffd/views/selfservice.py:219
#, python-format
msgid "You left role %(role_name)s"
msgstr "Rolle %(role_name)s verlassen"
#: uffd/views/service.py:34
msgid "Services"
msgstr "Dienste"
#: uffd/views/session.py:84
#, python-format
msgid ""
"We received too many invalid login attempts for this user! Please wait at"
" least %(delay)s."
msgstr ""
"Wir haben zu viele fehlgeschlagene Anmeldeversuche für diesen Account "
"erhalten! Bitte warte mindestens %(delay)s."
#: uffd/views/session.py:93
msgid "Login name or password is wrong"
msgstr "Der Anmeldename oder das Passwort ist falsch"
#: uffd/views/session.py:96
#, python-format
msgid "Your account is deactivated. Contact %(contact_email)s for details."
msgstr ""
"Dein Account ist deaktiviert. Kontaktiere %(contact_email)s für weitere "
"Informationen."
#: uffd/views/session.py:102
msgid "You do not have access to this service"
msgstr "Du hast keinen Zugriff auf diesen Service"
#: uffd/views/session.py:114 uffd/views/session.py:125
msgid "You need to login first"
msgstr "Du musst dich erst anmelden"
#: uffd/views/session.py:146 uffd/views/session.py:156
msgid "Initiation code is no longer valid"
msgstr "Startcode ist nicht mehr gültig"
#: uffd/views/session.py:160
msgid "Invalid confirmation code"
msgstr "Ungültiger Bestätigungscode"
#: uffd/views/session.py:172 uffd/views/session.py:183
msgid "Invalid initiation code"
msgstr "Ungültiger Startcode"
#: uffd/views/signup.py:21
msgid "Signup not enabled"
msgstr "Account-Registrierung ist deaktiviert"
#: uffd/views/signup.py:84 uffd/views/signup.py:92
msgid "Invalid signup link"
msgstr "Ungültiger Account-Registrierungs-Link"
#: uffd/views/signup.py:97
#, python-format
msgid "Too many failed attempts! Please wait %(delay)s."
msgstr "Zu viele fehlgeschlagene Versuche! Bitte warte mindestens %(delay)s."
#: uffd/views/signup.py:113
msgid "Your account was successfully created"
msgstr "Account erfolgreich erstellt"
#: uffd/views/user.py:30
msgid "Users"
msgstr "Accounts"
#: uffd/views/user.py:48
msgid "Login name does not meet requirements"
msgstr "Anmeldename entspricht nicht den Anforderungen"
#: uffd/views/user.py:55 uffd/views/user.py:129
msgid "Display name does not meet requirements"
msgstr "Anzeigename entspricht nicht den Anforderungen"
#: uffd/views/user.py:74
msgid "Service user created"
msgstr "Service-Account erstellt"
#: uffd/views/user.py:77
msgid "User created. We sent the user a password reset link by e-mail"
msgstr ""
"Account erstellt. E-Mail mit einem Link zum Setzen des Passworts wurde "
"versendet."
#: uffd/views/user.py:106
msgid "E-Mail address already exists or is used by another account"
msgstr ""
"E-Mail-Adresse existiert bereits oder wird von einem anderen Account "
"verwendet"
#: uffd/views/user.py:134
msgid "Password is invalid"
msgstr "Passwort ist ungültig"
#: uffd/views/user.py:146
msgid "User updated"
msgstr "Account aktualisiert"
#: uffd/views/user.py:155
msgid "User deactivated"
msgstr "Account deaktiviert"
#: uffd/views/user.py:164
msgid "User activated"
msgstr "Account aktiviert"
#: uffd/views/user.py:173
msgid "Sessions revoked"
msgstr "Sitzungen widerrufen"
#: uffd/views/user.py:183
msgid "Deleted user"
msgstr "Account gelöscht"
from .views_user import bp as bp_user
from .views_group import bp as bp_group
bp = [bp_user, bp_group]
import secrets
import string
import re
from flask import current_app
from ldap3.utils.hashed import hashed, HASHED_SALTED_SHA512
from uffd.ldap import ldap
from uffd.lazyconfig import lazyconfig_str, lazyconfig_list
def get_next_uid(service=False):
if service:
new_uid_min = current_app.config['LDAP_USER_SERVICE_MIN_UID']
new_uid_max = current_app.config['LDAP_USER_SERVICE_MAX_UID']
else:
new_uid_min = current_app.config['LDAP_USER_MIN_UID']
new_uid_max = current_app.config['LDAP_USER_MAX_UID']
next_uid = new_uid_min
for user in User.query.all():
if user.uid <= new_uid_max:
next_uid = max(next_uid, user.uid + 1)
if next_uid > new_uid_max:
raise Exception('No free uid found')
return next_uid
class ObjectAttributeDict:
def __init__(self, obj):
self.obj = obj
def __getitem__(self, key):
return getattr(self.obj, key)
def format_with_attributes(fmtstr, obj):
# Do str.format-style string formatting with the attributes of an object
# E.g. format_with_attributes("/home/{loginname}", obj) = "/home/foobar" if obj.loginname = "foobar"
return fmtstr.format_map(ObjectAttributeDict(obj))
class BaseUser(ldap.Model):
ldap_search_base = lazyconfig_str('LDAP_USER_SEARCH_BASE')
ldap_filter_params = lazyconfig_list('LDAP_USER_SEARCH_FILTER')
ldap_object_classes = lazyconfig_list('LDAP_USER_OBJECTCLASSES')
ldap_dn_base = lazyconfig_str('LDAP_USER_SEARCH_BASE')
ldap_dn_attribute = lazyconfig_str('LDAP_USER_DN_ATTRIBUTE')
uid = ldap.Attribute(lazyconfig_str('LDAP_USER_UID_ATTRIBUTE'), default=get_next_uid, aliases=lazyconfig_list('LDAP_USER_UID_ALIASES'))
loginname = ldap.Attribute(lazyconfig_str('LDAP_USER_LOGINNAME_ATTRIBUTE'), aliases=lazyconfig_list('LDAP_USER_LOGINNAME_ALIASES'))
displayname = ldap.Attribute(lazyconfig_str('LDAP_USER_DISPLAYNAME_ATTRIBUTE'), aliases=lazyconfig_list('LDAP_USER_DISPLAYNAME_ALIASES'))
mail = ldap.Attribute(lazyconfig_str('LDAP_USER_MAIL_ATTRIBUTE'), aliases=lazyconfig_list('LDAP_USER_MAIL_ALIASES'))
pwhash = ldap.Attribute('userPassword', default=lambda: hashed(HASHED_SALTED_SHA512, secrets.token_hex(128)))
groups = set() # Shuts up pylint, overwritten by back-reference
roles = set() # Shuts up pylint, overwritten by back-reference
@property
def group_dns(self):
return [group.dn for group in self.groups]
@property
def is_service_user(self):
if self.uid is None:
return None
return self.uid >= current_app.config['LDAP_USER_SERVICE_MIN_UID'] and self.uid <= current_app.config['LDAP_USER_SERVICE_MAX_UID']
@is_service_user.setter
def is_service_user(self, value):
assert self.uid is None
if value:
self.uid = get_next_uid(service=True)
def add_default_attributes(self):
for name, values in current_app.config['LDAP_USER_DEFAULT_ATTRIBUTES'].items():
if self.ldap_object.getattr(name):
continue
if not isinstance(values, list):
values = [values]
formatted_values = []
for value in values:
if isinstance(value, str):
value = format_with_attributes(value, self)
formatted_values.append(value)
self.ldap_object.setattr(name, formatted_values)
ldap_add_hooks = ldap.Model.ldap_add_hooks + (add_default_attributes,)
# Write-only property
def password(self, value):
self.pwhash = hashed(HASHED_SALTED_SHA512, value)
password = property(fset=password)
def is_in_group(self, name):
if not name:
return True
for group in self.groups:
if group.name == name:
return True
return False
def has_permission(self, required_group=None):
if not required_group:
return True
group_names = {group.name for group in self.groups}
group_sets = required_group
if isinstance(group_sets, str):
group_sets = [group_sets]
for group_set in group_sets:
if isinstance(group_set, str):
group_set = [group_set]
if set(group_set) - group_names == set():
return True
return False
def set_loginname(self, value, ignore_blacklist=False):
if len(value) > 32 or len(value) < 1:
return False
for char in value:
if not char in string.ascii_lowercase + string.digits + '_-':
return False
if not ignore_blacklist:
for expr in current_app.config['LOGINNAME_BLACKLIST']:
if re.match(expr, value):
return False
self.loginname = value
return True
def set_displayname(self, value):
if len(value) > 128 or len(value) < 1:
return False
self.displayname = value
return True
def set_password(self, value):
if len(value) < 8 or len(value) > 256:
return False
self.password = value
return True
def set_mail(self, value):
if len(value) < 3 or '@' not in value:
return False
self.mail = value
return True
User = BaseUser
class Group(ldap.Model):
ldap_search_base = lazyconfig_str('LDAP_GROUP_SEARCH_BASE')
ldap_filter_params = lazyconfig_list('LDAP_GROUP_SEARCH_FILTER')
gid = ldap.Attribute(lazyconfig_str('LDAP_GROUP_GID_ATTRIBUTE'))
name = ldap.Attribute(lazyconfig_str('LDAP_GROUP_NAME_ATTRIBUTE'))
description = ldap.Attribute(lazyconfig_str('LDAP_GROUP_DESCRIPTION_ATTRIBUTE'), default='')
members = ldap.Relationship(lazyconfig_str('LDAP_GROUP_MEMBER_ATTRIBUTE'), User, backref='groups')
roles = [] # Shuts up pylint, overwritten by back-reference
{% extends 'base.html' %}
{% block body %}
<form action="{{ url_for("group.show", gid=group.gid) }}" method="POST">
<div class="align-self-center">
<div class="form-group col">
<label for="group-gid">{{_("GID")}}</label>
<input type="number" class="form-control" id="group-gid" name="gid" value="{{ group.gid }}" readonly>
</div>
<div class="form-group col">
<label for="group-loginname">{{_("Name")}}</label>
<input type="text" class="form-control" id="group-loginname" name="loginname" value="{{ group.name }}" readonly>
</div>
<div class="col">
<span>{{_("Members")}}:</span>
<ul class="row">
{% for member in group.members|sort(attribute='loginname') %}
<li class="col-12 col-xs-6 col-sm-4 col-md-3 col-lg-2"><a href="{{ url_for("user.show", uid=member.uid) }}">{{ member.loginname }}</a></li>
{% endfor %}
</ul>
</div>
</div>
</form>
{% endblock %}
from flask import Blueprint, render_template, url_for, redirect, flash, current_app, request
from flask_babel import gettext as _, lazy_gettext
from uffd.navbar import register_navbar
from uffd.session import login_required
from .models import Group
bp = Blueprint("group", __name__, template_folder='templates', url_prefix='/group/')
@bp.before_request
@login_required()
def group_acl(): #pylint: disable=inconsistent-return-statements
if not group_acl_check():
flash(_('Access denied'))
return redirect(url_for('index'))
def group_acl_check():
return request.user and request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP'])
@bp.route("/")
@register_navbar(lazy_gettext('Groups'), icon='layer-group', blueprint=bp, visible=group_acl_check)
def index():
return render_template('group/list.html', groups=Group.query.all())
@bp.route("/<int:gid>")
def show(gid):
return render_template('group/show.html', group=Group.query.filter_by(gid=gid).first_or_404())
import secrets
import math
import base64
def token_with_alphabet(alphabet, nbytes=None):
'''Return random text token that consists of characters from `alphabet`'''
if nbytes is None:
nbytes = max(secrets.DEFAULT_ENTROPY, 32)
nbytes_per_char = math.log(len(alphabet), 256)
nchars = math.ceil(nbytes / nbytes_per_char)
return ''.join([secrets.choice(alphabet) for _ in range(nchars)])
def token_typeable(nbytes=None):
'''Return random text token that is easy to type (on mobile)'''
alphabet = '123456789abcdefghkmnopqrstuvwx' # No '0ijlyz'
return token_with_alphabet(alphabet, nbytes=nbytes)
def token_urlfriendly(nbytes=None):
'''Return random text token that is urlsafe and works around common parsing bugs'''
alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
return token_with_alphabet(alphabet, nbytes=nbytes)
def nopad_b32decode(value):
if isinstance(value, bytes):
value = value.decode()
return base64.b32decode(value + ('=' * (-len(value) % 8)))
def nopad_b32encode(value):
return base64.b32encode(value).rstrip(b'=')
def nopad_urlsafe_b64decode(value):
if isinstance(value, bytes):
value = value.decode()
return base64.urlsafe_b64decode(value + ('=' * (-len(value) % 4)))
def nopad_urlsafe_b64encode(value):
return base64.urlsafe_b64encode(value).rstrip(b'=')
from flask import redirect, url_for, request, render_template
from werkzeug.exceptions import Forbidden
from uffd.secure_redirect import secure_local_redirect
from . import session, selfservice, signup, oauth2, user, group, service, role, invite, api, mail, rolemod
def init_app(app):
@app.errorhandler(403)
def handle_403(error):
return render_template('403.html', description=error.description if error.description != Forbidden.description else None), 403
@app.route("/")
def index(): #pylint: disable=unused-variable
if app.config['DEFAULT_PAGE_SERVICES']:
return redirect(url_for('service.overview'))
return redirect(url_for('selfservice.index'))
@app.route('/lang', methods=['POST'])
def setlang(): #pylint: disable=unused-variable
resp = secure_local_redirect(request.values.get('ref', '/'))
if 'lang' in request.values:
resp.set_cookie('language', request.values['lang'])
return resp
app.register_blueprint(session.bp)
app.register_blueprint(selfservice.bp)
app.register_blueprint(signup.bp)
app.register_blueprint(oauth2.bp)
app.register_blueprint(user.bp)
app.register_blueprint(group.bp)
app.register_blueprint(service.bp)
app.register_blueprint(role.bp)
app.register_blueprint(invite.bp)
app.register_blueprint(api.bp)
app.register_blueprint(mail.bp)
app.register_blueprint(rolemod.bp)
app.add_url_rule("/metrics", view_func=api.prometheus_metrics)
import functools
from flask import Blueprint, jsonify, request, abort, Response
from uffd.database import db
from uffd.models import (
User, ServiceUser, Group, Mail, MailReceiveAddress, MailDestinationAddress, APIClient,
RecoveryCodeMethod, TOTPMethod, WebauthnMethod, Invite, Role, Service )
from .session import login_ratelimit
bp = Blueprint('api', __name__, template_folder='templates', url_prefix='/api/v1/')
def apikey_required(permission=None):
# pylint: disable=too-many-return-statements
if permission is not None:
assert APIClient.permission_exists(permission)
def wrapper(func):
@functools.wraps(func)
def decorator(*args, **kwargs):
if not request.authorization or not request.authorization.password:
return 'Unauthorized', 401, {'WWW-Authenticate': ['Basic realm="api"']}
client = APIClient.query.filter_by(auth_username=request.authorization.username).first()
if not client:
return 'Unauthorized', 401, {'WWW-Authenticate': ['Basic realm="api"']}
if not client.auth_password.verify(request.authorization.password):
return 'Unauthorized', 401, {'WWW-Authenticate': ['Basic realm="api"']}
if client.auth_password.needs_rehash:
client.auth_password = request.authorization.password
db.session.commit()
if permission is not None and not client.has_permission(permission):
return 'Forbidden', 403
request.api_client = client
return func(*args, **kwargs)
return decorator
return wrapper
def generate_group_dict(group):
return {
'id': group.unix_gid,
'name': group.name,
'members': [
user.loginname
for user in group.members
if not user.is_deactivated or not request.api_client.service.hide_deactivated_users
]
}
@bp.route('/getgroups', methods=['GET', 'POST'])
@apikey_required('users')
def getgroups():
if len(request.values) > 1:
abort(400)
key = (list(request.values.keys()) or [None])[0]
values = request.values.getlist(key)
query = Group.query
if key is None:
pass
elif key == 'id' and len(values) == 1:
query = query.filter(Group.unix_gid == values[0])
elif key == 'name' and len(values) == 1:
query = query.filter(Group.name == values[0])
elif key == 'member' and len(values) == 1:
query = query.join(Group.members).filter(User.loginname == values[0])
if request.api_client.service.hide_deactivated_users:
query = query.filter(db.not_(User.is_deactivated))
else:
abort(400)
# Single-result queries perform better without eager loading
if key is None or key == 'member':
query = query.options(db.selectinload(Group.members))
return jsonify([generate_group_dict(group) for group in query])
def generate_user_dict(service_user):
return {
'id': service_user.user.unix_uid,
'loginname': service_user.user.loginname,
'email': service_user.email,
'displayname': service_user.user.displayname,
'groups': [group.name for group in service_user.user.groups]
}
@bp.route('/getusers', methods=['GET', 'POST'])
@apikey_required('users')
def getusers():
if len(request.values) > 1:
abort(400)
key = (list(request.values.keys()) or [None])[0]
values = request.values.getlist(key)
query = ServiceUser.query.filter_by(service=request.api_client.service).join(ServiceUser.user)
if request.api_client.service.hide_deactivated_users:
query = query.filter(db.not_(User.is_deactivated))
if key is None:
pass
elif key == 'id' and len(values) == 1:
query = query.filter(User.unix_uid == values[0])
elif key == 'loginname' and len(values) == 1:
query = query.filter(User.loginname == values[0])
elif key == 'email' and len(values) == 1:
query = ServiceUser.filter_query_by_email(query, values[0])
elif key == 'group' and len(values) == 1:
query = query.join(User.groups).filter(Group.name == values[0])
else:
abort(400)
# Single-result queries perform better without eager loading
if key is None or key == 'group':
# pylint: disable=no-member
query = query.options(db.joinedload(ServiceUser.user).selectinload(User.groups))
query = query.options(db.joinedload(ServiceUser.user).joinedload(User.primary_email))
return jsonify([generate_user_dict(user) for user in query])
@bp.route('/checkpassword', methods=['POST'])
@apikey_required('checkpassword')
def checkpassword():
if set(request.values.keys()) != {'loginname', 'password'}:
abort(400)
username = request.form['loginname'].lower()
password = request.form['password']
login_delay = login_ratelimit.get_delay(username)
if login_delay:
return 'Too Many Requests', 429, {'Retry-After': '%d'%login_delay}
service_user = ServiceUser.query.join(User).filter(
ServiceUser.service == request.api_client.service,
User.loginname == username,
).one_or_none()
if service_user is None or not service_user.user.password.verify(password):
login_ratelimit.log(username)
return jsonify(None)
if service_user.user.is_deactivated:
return jsonify(None)
if service_user.user.password.needs_rehash:
service_user.user.password = password
db.session.commit()
return jsonify(generate_user_dict(service_user))
def generate_mail_dict(mail):
return {
'name': mail.uid,
'receive_addresses': list(mail.receivers),
'destination_addresses': list(mail.destinations)
}
@bp.route('/getmails', methods=['GET', 'POST'])
@apikey_required('mail_aliases')
def getmails():
if len(request.values) > 1:
abort(400)
key = (list(request.values.keys()) or [None])[0]
values = request.values.getlist(key)
query = Mail.query
if key is None:
pass
elif key == 'name' and len(values) == 1:
query = query.filter_by(uid=values[0])
elif key == 'receive_address' and len(values) == 1:
query = query.filter(Mail.receivers.any(MailReceiveAddress.address==values[0].lower()))
elif key == 'destination_address' and len(values) == 1:
query = query.filter(Mail.destinations.any(MailDestinationAddress.address==values[0]))
else:
abort(400)
return jsonify([generate_mail_dict(mail) for mail in query])
@bp.route('/resolve-remailer', methods=['GET', 'POST'])
@apikey_required('remailer')
def resolve_remailer():
if list(request.values.keys()) != ['orig_address']:
abort(400)
values = request.values.getlist('orig_address')
if len(values) != 1:
abort(400)
service_user = ServiceUser.get_by_remailer_email(values[0])
if not service_user:
return jsonify(address=None)
return jsonify(address=service_user.real_email)
@bp.route('/metrics_prometheus', methods=['GET'])
@apikey_required('metrics')
def prometheus_metrics():
import pkg_resources #pylint: disable=import-outside-toplevel
from prometheus_client.core import CollectorRegistry, CounterMetricFamily, InfoMetricFamily #pylint: disable=import-outside-toplevel
from prometheus_client import PLATFORM_COLLECTOR, generate_latest, CONTENT_TYPE_LATEST #pylint: disable=import-outside-toplevel
class UffdCollector():
def collect(self):
try:
uffd_version = str(pkg_resources.get_distribution('uffd').version)
except pkg_resources.DistributionNotFound:
uffd_version = "unknown"
yield InfoMetricFamily('uffd_version', 'Various version infos', value={"version": uffd_version})
user_metric = CounterMetricFamily('uffd_users_total', 'Number of users', labels=['user_type'])
user_metric.add_metric(['regular'], value=User.query.filter_by(is_service_user=False).count())
user_metric.add_metric(['service'], User.query.filter_by(is_service_user=True).count())
yield user_metric
mfa_auth_metric = CounterMetricFamily('uffd_users_auth_mfa_total', 'mfa stats', labels=['mfa_type'])
mfa_auth_metric.add_metric(['recoverycode'], value=RecoveryCodeMethod.query.count())
mfa_auth_metric.add_metric(['totp'], value=TOTPMethod.query.count())
mfa_auth_metric.add_metric(['webauthn'], value=WebauthnMethod.query.count())
yield mfa_auth_metric
yield CounterMetricFamily('uffd_roles_total', 'Number of roles', value=Role.query.count())
role_members_metric = CounterMetricFamily('uffd_role_members_total', 'Members of a role', labels=['role_name'])
for role in Role.query.all():
role_members_metric.add_metric([role.name], value=len(role.members))
yield role_members_metric
group_metric = CounterMetricFamily('uffd_groups_total', 'Total number of groups', value=Group.query.count())
yield group_metric
invite_metric = CounterMetricFamily('uffd_invites_total', 'Number of invites', labels=['invite_state'])
invite_metric.add_metric(['used'], value=Invite.query.filter_by(used=True).count())
invite_metric.add_metric(['expired'], value=Invite.query.filter_by(expired=True).count())
invite_metric.add_metric(['disabled'], value=Invite.query.filter_by(disabled=True).count())
invite_metric.add_metric(['voided'], value=Invite.query.filter_by(voided=True).count())
invite_metric.add_metric([], value=Invite.query.count())
yield invite_metric
yield CounterMetricFamily('uffd_services_total', 'Number of services', value=Service.query.count())
registry = CollectorRegistry(auto_describe=True)
registry.register(PLATFORM_COLLECTOR)
registry.register(UffdCollector())
return Response(response=generate_latest(registry=registry),content_type=CONTENT_TYPE_LATEST)
from flask import Blueprint, render_template, current_app, request, flash, redirect, url_for
from flask_babel import lazy_gettext, gettext as _
import sqlalchemy
from uffd.navbar import register_navbar
from uffd.csrf import csrf_protect
from uffd.database import db
from uffd.models import Group
from .session import login_required
bp = Blueprint("group", __name__, template_folder='templates', url_prefix='/group/')
def group_acl_check():
return request.user and request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP'])
@bp.before_request
@login_required(group_acl_check)
def group_acl():
pass
@bp.route("/")
@register_navbar(lazy_gettext('Groups'), icon='layer-group', blueprint=bp, visible=group_acl_check)
def index():
return render_template('group/list.html', groups=Group.query.all())
@bp.route("/<int:id>")
@bp.route("/new")
def show(id=None):
group = Group() if id is None else Group.query.get_or_404(id)
return render_template('group/show.html', group=group)
@bp.route("/<int:id>/update", methods=['POST'])
@bp.route("/new", methods=['POST'])
@csrf_protect(blueprint=bp)
def update(id=None):
if id is None:
group = Group()
if request.form['unix_gid']:
try:
group.unix_gid = int(request.form['unix_gid'])
except ValueError:
flash(_('GID is already in use or was used in the past'))
return render_template('group/show.html', group=group), 400
if not group.set_name(request.form['name']):
flash(_('Invalid name'))
return render_template('group/show.html', group=group), 400
else:
group = Group.query.get_or_404(id)
group.description = request.form['description']
db.session.add(group)
if id is None:
try:
db.session.commit()
except sqlalchemy.exc.IntegrityError:
db.session.rollback()
flash(_('Group with this name or id already exists'))
return render_template('group/show.html', group=group), 400
else:
db.session.commit()
if id is None:
flash(_('Group created'))
else:
flash(_('Group updated'))
return redirect(url_for('group.show', id=group.id))
@bp.route("/<int:id>/delete")
@csrf_protect(blueprint=bp)
def delete(id):
group = Group.query.get_or_404(id)
db.session.delete(group)
db.session.commit()
flash(_('Deleted group'))
return redirect(url_for('group.index'))
import datetime import datetime
import functools import secrets
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify, abort
from flask_babel import gettext as _, lazy_gettext from flask_babel import gettext as _, lazy_gettext, to_utc
import sqlalchemy import sqlalchemy
from uffd.csrf import csrf_protect from uffd.csrf import csrf_protect
from uffd.database import db
from uffd.ldap import ldap
from uffd.session import login_required
from uffd.role.models import Role
from uffd.invite.models import Invite, InviteSignup, InviteGrant
from uffd.user.models import User
from uffd.sendmail import sendmail from uffd.sendmail import sendmail
from uffd.navbar import register_navbar from uffd.navbar import register_navbar
from uffd.ratelimit import host_ratelimit, format_delay from uffd.database import db
from uffd.signup.views import signup_ratelimit from uffd.models import Role, User, Group, Invite, InviteSignup, InviteGrant, host_ratelimit, format_delay
from .session import login_required
from .signup import signup_ratelimit
from .selfservice import selfservice_acl_check
bp = Blueprint('invite', __name__, template_folder='templates', url_prefix='/invite/') bp = Blueprint('invite', __name__, template_folder='templates', url_prefix='/invite/')
def invite_acl(): def invite_acl_check():
if not request.user: if not request.user:
return False return False
if request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP']): if request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP']):
return True return True
if request.user.is_in_group(current_app.config['ACL_SIGNUP_GROUP']): if request.user.is_in_group(current_app.config['ACL_SIGNUP_GROUP']):
return True return True
if Role.query.filter(Role.moderator_group_dn.in_(request.user.group_dns)).count(): if Role.query.join(Role.moderator_group).join(Group.members).filter(User.id==request.user.id).count():
return True return True
return False return False
def invite_acl_required(func):
@functools.wraps(func)
@login_required()
def decorator(*args, **kwargs):
if not invite_acl():
flash('Access denied')
return redirect(url_for('index'))
return func(*args, **kwargs)
return decorator
def view_acl_filter(user): def view_acl_filter(user):
if user.is_in_group(current_app.config['ACL_ADMIN_GROUP']): if user.is_in_group(current_app.config['ACL_ADMIN_GROUP']):
return sqlalchemy.true() return sqlalchemy.true()
creator_filter = (Invite.creator_dn == user.dn) creator_filter = (Invite.creator == user)
rolemod_filter = Invite.roles.any(Role.moderator_group_dn.in_(user.group_dns)) rolemod_filter = Invite.roles.any(Role.moderator_group.has(Group.id.in_([group.id for group in user.groups])))
return creator_filter | rolemod_filter return creator_filter | rolemod_filter
def reset_acl_filter(user): def reset_acl_filter(user):
if user.is_in_group(current_app.config['ACL_ADMIN_GROUP']): if user.is_in_group(current_app.config['ACL_ADMIN_GROUP']):
return sqlalchemy.true() return sqlalchemy.true()
return Invite.creator_dn == user.dn return Invite.creator == user
@bp.route('/') @bp.route('/')
@register_navbar(lazy_gettext('Invites'), icon='link', blueprint=bp, visible=invite_acl) @register_navbar(lazy_gettext('Invites'), icon='link', blueprint=bp, visible=invite_acl_check)
@invite_acl_required @login_required(invite_acl_check)
def index(): def index():
invites = Invite.query.filter(view_acl_filter(request.user)).all() invites = Invite.query.filter(view_acl_filter(request.user)).all()
return render_template('invite/list.html', invites=invites) return render_template('invite/list.html', invites=invites)
@bp.route('/new') @bp.route('/new')
@invite_acl_required @login_required(invite_acl_check)
def new(): def new():
if request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP']): if request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP']):
allow_signup = True allow_signup = True
roles = Role.query.all() roles = Role.query.all()
else: else:
allow_signup = request.user.is_in_group(current_app.config['ACL_SIGNUP_GROUP']) allow_signup = request.user.is_in_group(current_app.config['ACL_SIGNUP_GROUP'])
roles = Role.query.filter(Role.moderator_group_dn.in_(request.user.group_dns)).all() roles = Role.query.join(Role.moderator_group).join(Group.members).filter(User.id==request.user.id).all()
return render_template('invite/new.html', roles=roles, allow_signup=allow_signup) return render_template('invite/new.html', roles=roles, allow_signup=allow_signup)
def parse_datetime_local_input(value):
return to_utc(datetime.datetime.fromisoformat(value))
@bp.route('/new', methods=['POST']) @bp.route('/new', methods=['POST'])
@invite_acl_required @login_required(invite_acl_check)
@csrf_protect(blueprint=bp) @csrf_protect(blueprint=bp)
def new_submit(): def new_submit():
invite = Invite(creator=request.user, invite = Invite(creator=request.user,
single_use=(request.values['single-use'] == '1'), single_use=(request.values['single-use'] == '1'),
valid_until=datetime.datetime.fromisoformat(request.values['valid-until']), valid_until=parse_datetime_local_input(request.values['valid-until']),
allow_signup=(request.values.get('allow-signup', '0') == '1')) allow_signup=(request.values.get('allow-signup', '0') == '1'))
for key, value in request.values.items(): for key, value in request.values.items():
if key.startswith('role-') and value == '1': if key.startswith('role-') and value == '1':
invite.roles.append(Role.query.get(key[5:])) invite.roles.append(Role.query.get(key[5:]))
if invite.valid_until > datetime.datetime.now() + datetime.timedelta(days=current_app.config['INVITE_MAX_VALID_DAYS']): if invite.valid_until > datetime.datetime.utcnow() + datetime.timedelta(days=current_app.config['INVITE_MAX_VALID_DAYS']):
flash(_('The "Expires After" date is too far in the future')) flash(_('The "Expires After" date is too far in the future'))
return redirect(url_for('invite.new')) return new()
if not invite.permitted: if not invite.permitted:
flash(_('You are not allowed to create invite links with these permissions')) flash(_('You are not allowed to create invite links with these permissions'))
return redirect(url_for('invite.new')) return new()
if not invite.allow_signup and not invite.roles: if not invite.allow_signup and not invite.roles:
flash(_('Invite link must either allow signup or grant at least one role')) flash(_('Invite link must either allow signup or grant at least one role'))
return redirect(url_for('invite.new')) return new()
db.session.add(invite) db.session.add(invite)
db.session.commit() db.session.commit()
return redirect(url_for('invite.index')) return redirect(url_for('invite.index'))
@bp.route('/<int:invite_id>/disable', methods=['POST']) @bp.route('/<int:invite_id>/disable', methods=['POST'])
@invite_acl_required @login_required(invite_acl_check)
@csrf_protect(blueprint=bp) @csrf_protect(blueprint=bp)
def disable(invite_id): def disable(invite_id):
invite = Invite.query.filter(view_acl_filter(request.user)).filter_by(id=invite_id).first_or_404() invite = Invite.query.filter(view_acl_filter(request.user)).filter_by(id=invite_id).first_or_404()
...@@ -104,7 +94,7 @@ def disable(invite_id): ...@@ -104,7 +94,7 @@ def disable(invite_id):
return redirect(url_for('.index')) return redirect(url_for('.index'))
@bp.route('/<int:invite_id>/reset', methods=['POST']) @bp.route('/<int:invite_id>/reset', methods=['POST'])
@invite_acl_required @login_required(invite_acl_check)
@csrf_protect(blueprint=bp) @csrf_protect(blueprint=bp)
def reset(invite_id): def reset(invite_id):
invite = Invite.query.filter(reset_acl_filter(request.user)).filter_by(id=invite_id).first_or_404() invite = Invite.query.filter(reset_acl_filter(request.user)).filter_by(id=invite_id).first_or_404()
...@@ -112,38 +102,46 @@ def reset(invite_id): ...@@ -112,38 +102,46 @@ def reset(invite_id):
db.session.commit() db.session.commit()
return redirect(url_for('.index')) return redirect(url_for('.index'))
@bp.route('/<token>') @bp.route('/<int:invite_id>/<token>')
def use(token): def use(invite_id, token):
invite = Invite.query.filter_by(token=token).first_or_404() invite = Invite.query.get(invite_id)
if not invite or not secrets.compare_digest(invite.token, token):
abort(404)
if not invite.active: if not invite.active:
flash(_('Invalid invite link')) flash(_('Invalid invite link'))
return redirect('/') return redirect('/')
return render_template('invite/use.html', invite=invite) return render_template('invite/use.html', invite=invite)
@bp.route('/<token>/grant', methods=['POST']) @bp.route('/<int:invite_id>/<token>/grant', methods=['POST'])
@login_required() @login_required(selfservice_acl_check)
@csrf_protect(blueprint=bp) @csrf_protect(blueprint=bp)
def grant(token): def grant(invite_id, token):
invite = Invite.query.filter_by(token=token).first_or_404() invite = Invite.query.get(invite_id)
if not invite or not secrets.compare_digest(invite.token, token):
abort(404)
invite_grant = InviteGrant(invite=invite, user=request.user) invite_grant = InviteGrant(invite=invite, user=request.user)
db.session.add(invite_grant) db.session.add(invite_grant)
success, msg = invite_grant.apply() success, msg = invite_grant.apply()
if not success: if not success:
flash(msg) flash(msg)
return redirect(url_for('selfservice.index')) return redirect(url_for('selfservice.index'))
ldap.session.commit()
db.session.commit() db.session.commit()
flash(_('Roles successfully updated')) flash(_('Roles successfully updated'))
return redirect(url_for('selfservice.index')) return redirect(url_for('selfservice.index'))
@bp.url_defaults @bp.url_defaults
def inject_invite_token(endpoint, values): def inject_invite_token(endpoint, values):
if endpoint in ['invite.signup_submit', 'invite.signup_check'] and 'token' in request.view_args: if endpoint in ['invite.signup_submit', 'invite.signup_check']:
values['token'] = request.view_args['token'] if 'invite_id' in request.view_args:
values['invite_id'] = request.view_args['invite_id']
@bp.route('/<token>/signup') if 'token' in request.view_args:
def signup_start(token): values['token'] = request.view_args['token']
invite = Invite.query.filter_by(token=token).first_or_404()
@bp.route('/<int:invite_id>/<token>/signup')
def signup_start(invite_id, token):
invite = Invite.query.get(invite_id)
if not invite or not secrets.compare_digest(invite.token, token):
abort(404)
if not invite.active: if not invite.active:
flash(_('Invalid invite link')) flash(_('Invalid invite link'))
return redirect('/') return redirect('/')
...@@ -152,12 +150,14 @@ def signup_start(token): ...@@ -152,12 +150,14 @@ def signup_start(token):
return redirect('/') return redirect('/')
return render_template('signup/start.html') return render_template('signup/start.html')
@bp.route('/<token>/signupcheck', methods=['POST']) @bp.route('/<int:invite_id>/<token>/signupcheck', methods=['POST'])
def signup_check(token): def signup_check(invite_id, token):
if host_ratelimit.get_delay(): if host_ratelimit.get_delay():
return jsonify({'status': 'ratelimited'}) return jsonify({'status': 'ratelimited'})
host_ratelimit.log() host_ratelimit.log()
invite = Invite.query.filter_by(token=token).first_or_404() invite = Invite.query.get(invite_id)
if not invite or not secrets.compare_digest(invite.token, token):
abort(404)
if not invite.active or not invite.allow_signup: if not invite.active or not invite.allow_signup:
return jsonify({'status': 'error'}), 403 return jsonify({'status': 'error'}), 403
if not User().set_loginname(request.form['loginname']): if not User().set_loginname(request.form['loginname']):
...@@ -166,18 +166,23 @@ def signup_check(token): ...@@ -166,18 +166,23 @@ def signup_check(token):
return jsonify({'status': 'exists'}) return jsonify({'status': 'exists'})
return jsonify({'status': 'ok'}) return jsonify({'status': 'ok'})
@bp.route('/<token>/signup', methods=['POST']) @bp.route('/<int:invite_id>/<token>/signup', methods=['POST'])
def signup_submit(token): def signup_submit(invite_id, token):
invite = Invite.query.filter_by(token=token).first_or_404() invite = Invite.query.get(invite_id)
if not invite or not secrets.compare_digest(invite.token, token):
abort(404)
if request.form['password1'] != request.form['password2']: if request.form['password1'] != request.form['password2']:
return render_template('signup/start.html', error=_('Passwords do not match')) flash(_('Passwords do not match'), 'error')
return render_template('signup/start.html')
signup_delay = signup_ratelimit.get_delay(request.form['mail']) signup_delay = signup_ratelimit.get_delay(request.form['mail'])
host_delay = host_ratelimit.get_delay() host_delay = host_ratelimit.get_delay()
if signup_delay and signup_delay > host_delay: if signup_delay and signup_delay > host_delay:
return render_template('signup/start.html', error=_('Too many signup requests with this mail address! Please wait %(delay)s.', flash(_('Too many signup requests with this mail address! Please wait %(delay)s.',
delay=format_delay(signup_delay))) delay=format_delay(signup_delay)), 'error')
return render_template('signup/start.html')
if host_delay: if host_delay:
return render_template('signup/start.html', error=_('Too many requests! Please wait %(delay)s.', delay=format_delay(host_delay))) flash(_('Too many requests! Please wait %(delay)s.', delay=format_delay(host_delay)), 'error')
return render_template('signup/start.html')
host_ratelimit.log() host_ratelimit.log()
signup = InviteSignup(invite=invite, loginname=request.form['loginname'], signup = InviteSignup(invite=invite, loginname=request.form['loginname'],
displayname=request.form['displayname'], displayname=request.form['displayname'],
...@@ -185,11 +190,13 @@ def signup_submit(token): ...@@ -185,11 +190,13 @@ def signup_submit(token):
password=request.form['password1']) password=request.form['password1'])
valid, msg = signup.validate() valid, msg = signup.validate()
if not valid: if not valid:
return render_template('signup/start.html', error=msg) flash(msg, 'error')
return render_template('signup/start.html')
db.session.add(signup) db.session.add(signup)
db.session.commit() db.session.commit()
sent = sendmail(signup.mail, 'Confirm your mail address', 'signup/mail.txt', signup=signup) sent = sendmail(signup.mail, 'Confirm your mail address', 'signup/mail.txt', signup=signup)
if not sent: if not sent:
return render_template('signup/start.html', error=_('Cound not send mail')) flash(_('Could not send mail'), 'error')
return render_template('signup/start.html')
signup_ratelimit.log(request.form['mail']) signup_ratelimit.log(request.form['mail'])
return render_template('signup/submitted.html', signup=signup) return render_template('signup/submitted.html', signup=signup)
...@@ -3,55 +3,58 @@ from flask_babel import gettext as _, lazy_gettext ...@@ -3,55 +3,58 @@ from flask_babel import gettext as _, lazy_gettext
from uffd.navbar import register_navbar from uffd.navbar import register_navbar
from uffd.csrf import csrf_protect from uffd.csrf import csrf_protect
from uffd.ldap import ldap from uffd.database import db
from uffd.session import login_required from uffd.models import Mail
from .session import login_required
from uffd.mail.models import Mail
bp = Blueprint("mail", __name__, template_folder='templates', url_prefix='/mail/') bp = Blueprint("mail", __name__, template_folder='templates', url_prefix='/mail/')
@bp.before_request
@login_required()
def mail_acl(): #pylint: disable=inconsistent-return-statements
if not mail_acl_check():
flash('Access denied')
return redirect(url_for('index'))
def mail_acl_check(): def mail_acl_check():
return request.user and request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP']) return request.user and request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP'])
@bp.before_request
@login_required(mail_acl_check)
def mail_acl():
pass
@bp.route("/") @bp.route("/")
@register_navbar(lazy_gettext('Forwardings'), icon='envelope', blueprint=bp, visible=mail_acl_check) @register_navbar(lazy_gettext('Forwardings'), icon='envelope', blueprint=bp, visible=mail_acl_check)
def index(): def index():
return render_template('mail/list.html', mails=Mail.query.all()) return render_template('mail/list.html', mails=Mail.query.all())
@bp.route("/<uid>") @bp.route("/<int:mail_id>")
@bp.route("/new") @bp.route("/new")
def show(uid=None): def show(mail_id=None):
mail = Mail() if mail_id is not None:
if uid is not None: mail = Mail.query.get_or_404(mail_id)
mail = Mail.query.filter_by(uid=uid).first_or_404() else:
mail = Mail()
return render_template('mail/show.html', mail=mail) return render_template('mail/show.html', mail=mail)
@bp.route("/<uid>/update", methods=['POST']) @bp.route("/<int:mail_id>/update", methods=['POST'])
@bp.route("/new", methods=['POST']) @bp.route("/new", methods=['POST'])
@csrf_protect(blueprint=bp) @csrf_protect(blueprint=bp)
def update(uid=None): def update(mail_id=None):
if uid is not None: if mail_id is not None:
mail = Mail.query.filter_by(uid=uid).first_or_404() mail = Mail.query.get_or_404(mail_id)
else: else:
mail = Mail(uid=request.form.get('mail-uid')) mail = Mail(uid=request.form.get('mail-uid'))
mail.receivers = request.form.get('mail-receivers', '').splitlines() mail.receivers = request.form.get('mail-receivers', '').splitlines()
mail.destinations = request.form.get('mail-destinations', '').splitlines() mail.destinations = request.form.get('mail-destinations', '').splitlines()
ldap.session.add(mail) if mail.invalid_receivers:
ldap.session.commit() for addr in mail.invalid_receivers:
flash(_('Invalid receive address: %(mail_address)s', mail_address=addr))
return render_template('mail/show.html', mail=mail)
db.session.add(mail)
db.session.commit()
flash(_('Mail mapping updated.')) flash(_('Mail mapping updated.'))
return redirect(url_for('mail.show', uid=mail.uid)) return redirect(url_for('mail.show', mail_id=mail.id))
@bp.route("/<uid>/del") @bp.route("/<int:mail_id>/del")
@csrf_protect(blueprint=bp) @csrf_protect(blueprint=bp)
def delete(uid): def delete(mail_id):
mail = Mail.query.filter_by(uid=uid).first_or_404() mail = Mail.query.get_or_404(mail_id)
ldap.session.delete(mail) db.session.delete(mail)
ldap.session.commit() db.session.commit()
flash(_('Deleted mail mapping.')) flash(_('Deleted mail mapping.'))
return redirect(url_for('mail.index')) return redirect(url_for('mail.index'))
import urllib.parse
import time
import json
from flask import Blueprint, request, jsonify, render_template, session, redirect, url_for, flash, abort
from flask_babel import gettext as _
from sqlalchemy.exc import IntegrityError
import jwt
from uffd.secure_redirect import secure_local_redirect
from uffd.database import db
from uffd.models import (
DeviceLoginConfirmation, OAuth2Client, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation,
host_ratelimit, format_delay, OAuth2Key,
)
def get_issuer():
return request.host_url.rstrip('/')
OIDC_SCOPES = {
# From https://openid.net/specs/openid-connect-core-1_0.html
'openid': {
# "The sub (subject) Claim MUST always be returned in the UserInfo Response."
'sub': None,
},
'profile': {
'name': None,
'family_name': None,
'given_name': None,
'middle_name': None,
'nickname': None,
'preferred_username': None,
'profile': None,
'picture': None,
'website': None,
'gender': None,
'birthdate': None,
'zoneinfo': None,
'locale': None,
'updated_at': None,
},
'email': {
'email': None,
'email_verified': None,
},
# Custom scopes
'groups': {
'groups': None,
},
}
OIDC_CLAIMS = {
'sub': lambda service_user: str(service_user.user.unix_uid),
'name': lambda service_user: service_user.user.displayname,
'preferred_username': lambda service_user: service_user.user.loginname,
'email': lambda service_user: service_user.email,
'email_verified': lambda service_user: service_user.email_verified,
# RFC 9068 registers the "groups" claim with a syntax taken from SCIM (RFC 7643)
# that is different from what we use here. The plain list of names syntax we use
# is far more common in the context of id_token/userinfo claims.
'groups': lambda service_user: [group.name for group in service_user.user.groups]
}
def render_claims(scopes, claims, service_user):
claims = dict(claims)
for scope in scopes:
claims.update(OIDC_SCOPES.get(scope, {}))
# This would be a good place to enforce permissions on available claims
res = {}
for claim, func in OIDC_CLAIMS.items():
if claim in claims:
res[claim] = func(service_user=service_user)
return res
bp = Blueprint('oauth2', __name__, template_folder='templates')
@bp.route('/.well-known/openid-configuration')
def discover():
return jsonify({
'issuer': get_issuer(),
'authorization_endpoint': url_for('oauth2.authorize', _external=True),
'token_endpoint': url_for('oauth2.token', _external=True),
'userinfo_endpoint': url_for('oauth2.userinfo', _external=True),
'jwks_uri': url_for('oauth2.keys', _external=True),
'scopes_supported': sorted(OIDC_SCOPES.keys()),
'response_types_supported': ['code'],
'grant_types_supported': ['authorization_code'],
'id_token_signing_alg_values_supported': OAuth2Key.get_available_algorithms(),
'subject_types_supported': ['public'],
'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post'],
'claims_supported': sorted(['iat', 'exp', 'aud', 'iss'] + list(OIDC_CLAIMS.keys())),
'claims_parameter_supported': True,
'request_uri_parameter_supported': False, # default is True
})
@bp.route('/oauth2/keys')
def keys():
return jsonify({
'keys': [key.public_key_jwks_dict for key in OAuth2Key.query.filter_by(active=True)],
}), 200, {'Cache-Control': ['max-age=86400, public, must-revalidate, no-transform=true']}
def oauth2_redirect(**extra_args):
urlparts = urllib.parse.urlparse(request.oauth2_redirect_uri)
args = urllib.parse.parse_qs(urlparts.query)
if 'state' in request.args:
args['state'] = request.args['state']
for key, value in extra_args.items():
if value is not None:
args[key] = [value]
return redirect(urlparts._replace(query=urllib.parse.urlencode(args, doseq=True)).geturl())
class OAuth2Error(Exception):
ERROR: str
def __init__(self, error_description=None):
self.error_description = error_description
@property
def params(self):
res = {'error': self.ERROR}
if self.error_description:
res['error_description'] = self.error_description
return res
# RFC 6749: OAuth 2.0
class InvalidRequestError(OAuth2Error):
ERROR = 'invalid_request'
class UnsupportedResponseTypeError(OAuth2Error):
ERROR = 'unsupported_response_type'
class InvalidScopeError(OAuth2Error):
ERROR = 'invalid_scope'
class InvalidClientError(OAuth2Error):
ERROR = 'invalid_client'
class UnsupportedGrantTypeError(OAuth2Error):
ERROR = 'unsupported_grant_type'
class InvalidGrantError(OAuth2Error):
ERROR = 'invalid_grant'
class AccessDeniedError(OAuth2Error):
ERROR = 'access_denied'
def __init__(self, flash_message=None, **kwargs):
self.flash_message = flash_message
super().__init__(**kwargs)
# RFC 6750: OAuth 2.0 Bearer Token Usage
class InvalidTokenError(OAuth2Error):
ERROR = 'invalid_token'
# OpenID Connect Core 1.0
class LoginRequiredError(OAuth2Error):
ERROR = 'login_required'
def __init__(self, response=None, flash_message=None, **kwargs):
self.response = response
self.flash_message = flash_message
super().__init__(**kwargs)
class RequestNotSupportedError(OAuth2Error):
ERROR = 'request_not_supported'
class RequestURINotSupportedError(OAuth2Error):
ERROR = 'request_uri_not_supported'
def authorize_validate_request():
request.oauth2_redirect_uri = None
for param in request.args:
if len(request.args.getlist(param)) > 1:
raise InvalidRequestError(error_description=f'Duplicate parameter {param}')
if 'client_id' not in request.args:
raise InvalidRequestError(error_description='Required parameter client_id missing')
client_id = request.args['client_id']
client = OAuth2Client.query.filter_by(client_id=client_id).one_or_none()
if not client:
raise InvalidRequestError(error_description=f'Unknown client {client_id}')
redirect_uri = request.args.get('redirect_uri')
if redirect_uri and redirect_uri not in client.redirect_uris:
raise InvalidRequestError(error_description='Invalid redirect_uri')
request.oauth2_redirect_uri = redirect_uri or client.default_redirect_uri
if not request.oauth2_redirect_uri:
raise InvalidRequestError(error_description='Parameter redirect_uri required')
if 'response_type' not in request.args:
raise InvalidRequestError(error_description='Required parameter response_type missing')
response_type = request.args['response_type']
if response_type != 'code':
raise UnsupportedResponseTypeError(error_description='Unsupported response type')
scopes = {scope for scope in request.args.get('scope', '').split(' ') if scope} or {'profile'}
if scopes == {'profile'}:
pass # valid plain OAuth2 scopes
elif 'openid' in scopes:
# OIDC core spec: "Scope values used that are not understood by an implementation SHOULD be ignored."
# Since we don't support some of the optional scope values defined by the
# spec (phone, address, offline_access), it's probably best to ignore all
# unknown scopes.
pass # valid OIDC scopes
else:
raise InvalidScopeError(error_description='Unknown scope')
return OAuth2Grant(
client=client,
# redirect_uri is None if not present in request! This affects token request validation.
redirect_uri=redirect_uri,
scopes=scopes,
)
def authorize_validate_request_oidc(grant):
nonce = request.args.get('nonce')
claims = json.loads(request.args['claims']) if 'claims' in request.args else None
if 'request' in request.args:
raise RequestNotSupportedError()
if 'request_uri' in request.args:
raise RequestURINotSupportedError()
prompt_values = {value for value in request.args.get('prompt', '').split(' ') if value}
if 'none' in prompt_values and prompt_values != {'none'}:
raise InvalidRequestError(error_description='Invalid usage of none prompt parameter value')
sub_value = None
if claims and claims.get('id_token', {}).get('sub', {}).get('value') is not None:
sub_value = claims['id_token']['sub']['value']
if 'id_token_hint' in request.args:
try:
id_token = OAuth2Key.decode_jwt(
request.args['id_token_hint'],
issuer=get_issuer(),
options={'verify_exp': False, 'verify_aud': False}
)
except (jwt.exceptions.InvalidTokenError, jwt.exceptions.InvalidKeyError) as err:
raise InvalidRequestError(error_description='Invalid id_token_hint value') from err
if sub_value is not None and id_token['sub'] != sub_value:
raise InvalidRequestError(error_description='Ambiguous sub values in claims and id_token_hint')
sub_value = id_token['sub']
# We "MUST only send a positive response if the End-User identified by that
# sub value has an active session with the Authorization Server or has been
# Authenticated as a result of the request". However, we currently cannot
# display the login page if there is already a valid session. So we can only
# support sub_value in combination with prompt=none for now.
if sub_value is not None and 'none' not in prompt_values:
raise InvalidRequestError(error_description='id_token_hint or sub claim value not supported without prompt=none')
grant.nonce = nonce
grant.claims = claims
return grant, sub_value, prompt_values
def authorize_user(client):
if request.session:
return request.session
if 'devicelogin_started' in session:
del session['devicelogin_started']
host_delay = host_ratelimit.get_delay()
if host_delay:
raise LoginRequiredError(
flash_message=_(
'We received too many requests from your ip address/network! Please wait at least %(delay)s.',
delay=format_delay(host_delay)
),
response=redirect(url_for('session.login', ref=request.full_path, devicelogin=True))
)
host_ratelimit.log()
initiation = OAuth2DeviceLoginInitiation(client=client)
db.session.add(initiation)
try:
db.session.commit()
except IntegrityError as err:
raise LoginRequiredError(
flash_message=_('Device login is currently not available. Try again later!'),
response=redirect(url_for('session.login', ref=request.values['ref'], devicelogin=True))
) from err
session['devicelogin_id'] = initiation.id
session['devicelogin_secret'] = initiation.secret
raise LoginRequiredError(response=redirect(url_for('session.devicelogin', ref=request.full_path)))
if 'devicelogin_id' in session and 'devicelogin_secret' in session and 'devicelogin_confirmation' in session:
initiation = OAuth2DeviceLoginInitiation.query.filter_by(
id=session['devicelogin_id'],
secret=session['devicelogin_secret'],
client=client
).one_or_none()
confirmation = DeviceLoginConfirmation.query.get(session['devicelogin_confirmation'])
del session['devicelogin_id']
del session['devicelogin_secret']
del session['devicelogin_confirmation']
if not initiation or initiation.expired or not confirmation or confirmation.session.user.is_deactivated:
raise LoginRequiredError(
flash_message=_('Device login failed'),
response=redirect(url_for('session.login', ref=request.full_path, devicelogin=True))
)
db.session.delete(initiation)
db.session.commit()
return confirmation.session
raise LoginRequiredError(
flash_message=_('You need to login to access this service'),
response=redirect(url_for('session.login', ref=request.full_path, devicelogin=True))
)
@bp.route('/oauth2/authorize')
def authorize():
is_oidc = 'openid' in request.args.get('scope', '').split(' ')
try:
grant = authorize_validate_request()
sub_value, prompt_values = None, []
if is_oidc:
grant, sub_value, prompt_values = authorize_validate_request_oidc(grant)
except OAuth2Error as err:
# Correct OAuth2/OIDC error handling would be to redirect back to the
# client with an error paramter, unless client_id or redirect_uri is
# invalid. However, uffd never did that before adding OIDC support and
# many applications fail to correctly handle this case. As a compromise
# we report errors correctly in OIDC mode and don't in plain OAuth2 mode.
if is_oidc and request.oauth2_redirect_uri:
return oauth2_redirect(**err.params)
return render_template('oauth2/error.html', **err.params), 400
try:
_session = authorize_user(grant.client)
if sub_value is not None and str(_session.user.unix_uid) != sub_value:
# We only reach this point in OIDC requests with prompt=none, see
# authorize_validate_request_oidc. So this LoginRequiredError is
# always returned as a redirect back to the client.
raise LoginRequiredError()
if not grant.client.access_allowed(_session.user):
raise AccessDeniedError(flash_message=_(
"You don't have the permission to access the service <b>%(service_name)s</b>.",
service_name=grant.client.service.name
))
grant.session = _session
except LoginRequiredError as err:
# We abuse LoginRequiredError to signal a redirect to the login page
if is_oidc and 'none' in prompt_values:
err.error_description = 'Login required but prompt value set to none'
return oauth2_redirect(**err.params)
if err.flash_message:
flash(err.flash_message)
return err.response
except AccessDeniedError as err:
if is_oidc and request.oauth2_redirect_uri:
return oauth2_redirect(**err.params)
abort(403, description=err.flash_message)
db.session.add(grant)
db.session.commit()
return oauth2_redirect(code=grant.code)
def token_authenticate_client():
for param in ('client_id', 'client_secret'):
if len(request.form.getlist(param)) > 1:
raise InvalidRequestError(error_description=f'Duplicate parameter {param}')
if request.authorization:
client_id = urllib.parse.unquote(request.authorization.username)
client_secret = urllib.parse.unquote(request.authorization.password)
if request.form.get('client_id', client_id) != client_id:
raise InvalidRequestError(error_description='Ambiguous parameter client_id')
if 'client_secret' in request.form:
raise InvalidRequestError(error_description='Ambiguous parameter client_secret')
elif 'client_id' in request.form and 'client_secret' in request.form:
client_id = request.form['client_id']
client_secret = request.form['client_secret']
else:
raise InvalidClientError()
client = OAuth2Client.query.filter_by(client_id=client_id).one_or_none()
if client is None or not client.client_secret.verify(client_secret):
raise InvalidClientError()
if client.client_secret.needs_rehash:
client.client_secret = client_secret
db.session.commit()
return client
def token_validate_request(client):
for param in ('grant_type', 'code', 'redirect_uri'):
if len(request.form.getlist(param)) > 1:
raise InvalidRequestError(error_description=f'Duplicate parameter {param}')
if 'grant_type' not in request.form:
raise InvalidRequestError(error_description='Parameter grant_type missing')
grant_type = request.form['grant_type']
if grant_type != 'authorization_code':
raise UnsupportedGrantTypeError()
if 'code' not in request.form:
raise InvalidRequestError(error_description='Parameter code missing')
code = request.form['code']
grant = OAuth2Grant.get_by_authorization_code(code)
if not grant or grant.client != client:
raise InvalidGrantError()
if grant.redirect_uri and grant.redirect_uri != request.form.get('redirect_uri'):
raise InvalidRequestError(error_description='Parameter redirect_uri missing or invalid')
return grant
@bp.route('/oauth2/token', methods=['POST'])
def token():
try:
client = token_authenticate_client()
grant = token_validate_request(client)
except InvalidClientError as err:
return jsonify(err.params), 401, {'WWW-Authenticate': ['Basic realm="oauth2"']}
except OAuth2Error as err:
return jsonify(err.params), 400
tok = grant.make_token()
db.session.add(tok)
db.session.delete(grant)
db.session.commit()
resp = {
'token_type': 'Bearer',
'access_token': tok.access_token,
'expires_in': tok.EXPIRES_IN,
'scope': ' '.join(tok.scopes),
}
if 'openid' in tok.scopes:
key = OAuth2Key.get_preferred_key()
id_token = render_claims(['openid'], (grant.claims or {}).get('id_token', {}), tok.service_user)
id_token['iss'] = get_issuer()
id_token['aud'] = tok.client.client_id
id_token['iat'] = int(time.time())
id_token['at_hash'] = key.oidc_hash(tok.access_token.encode('ascii'))
id_token['exp'] = id_token['iat'] + tok.EXPIRES_IN
if grant.nonce:
id_token['nonce'] = grant.nonce
resp['id_token'] = OAuth2Key.get_preferred_key().encode_jwt(id_token)
else:
# We don't support the refresh_token grant type. Due to limitations of
# oauthlib we always returned (disfunctional) refresh tokens in the past.
# We still do that for non-OIDC clients to not change behavour for
# existing clients.
resp['refresh_token'] = tok.refresh_token
return jsonify(resp), 200, {'Cache-Control': ['no-store']}
def validate_access_token():
if len(request.headers.getlist('Authorization')) == 1 and 'access_token' not in request.values:
auth_type, auth_value = (request.headers['Authorization'].split(' ', 1) + [''])[:2]
if auth_type.lower() != 'bearer':
raise InvalidRequestError()
access_token = auth_value
elif len(request.values.getlist('access_token')) == 1 and 'Authorization' not in request.headers:
access_token = request.values['access_token']
else:
raise InvalidClientError()
tok = OAuth2Token.get_by_access_token(access_token)
if not tok:
raise InvalidTokenError()
return tok
@bp.route('/oauth2/userinfo', methods=['GET', 'POST'])
def userinfo():
try:
tok = validate_access_token()
except OAuth2Error as err:
# RFC 6750:
# If the request lacks any authentication information (e.g., the client
# was unaware that authentication is necessary or attempted using an
# unsupported authentication method), the resource server SHOULD NOT
# include an error code or other error information.
header = 'Bearer'
if request.headers.get('Authorization', '').lower().startswith('bearer') or 'access_token' in request.values:
header += f' error="{err.ERROR}"'
return '', 401, {'WWW-Authenticate': [header]}
service_user = tok.service_user
if 'openid' in tok.scopes:
resp = render_claims(tok.scopes, (tok.claims or {}).get('userinfo', {}), service_user)
else:
resp = {
'id': service_user.user.unix_uid,
'name': service_user.user.displayname,
'nickname': service_user.user.loginname,
'email': service_user.email,
'groups': [group.name for group in service_user.user.groups],
}
return jsonify(resp), 200, {'Cache-Control': ['private']}
@bp.app_url_defaults
def inject_logout_params(endpoint, values):
if endpoint != 'oauth2.logout' or not request.session:
return
client_ids = set(token.client.client_id for token in request.session.oauth2_tokens)
if client_ids:
values['client_ids'] = ','.join(client_ids)
@bp.route('/oauth2/logout')
def logout():
if not request.values.get('client_ids'):
return secure_local_redirect(request.values.get('ref', '/'))
client_ids = request.values['client_ids'].split(',')
clients = [OAuth2Client.query.filter_by(client_id=client_id).one() for client_id in client_ids]
return render_template('oauth2/logout.html', clients=clients)
import sys
from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app
from flask_babel import gettext as _, lazy_gettext from flask_babel import gettext as _, lazy_gettext
import click
from uffd.navbar import register_navbar from uffd.navbar import register_navbar
from uffd.csrf import csrf_protect from uffd.csrf import csrf_protect
from uffd.role.models import Role, RoleGroup
from uffd.user.models import User, Group
from uffd.session import login_required
from uffd.database import db from uffd.database import db
from uffd.ldap import ldap from uffd.models import Role, RoleGroup, Group
from .session import login_required
bp = Blueprint("role", __name__, template_folder='templates', url_prefix='/role/') bp = Blueprint("role", __name__, template_folder='templates', url_prefix='/role/')
@bp.record
def add_cli_commands(state):
@state.app.cli.command('roles-update-all', help='Update group memberships for all users based on their roles')
@click.option('--check-only', is_flag=True)
def roles_update_all(check_only): #pylint: disable=unused-variable
consistent = True
with current_app.test_request_context():
for user in User.query.all():
groups_added, groups_removed = user.update_groups()
if groups_added:
consistent = False
print('Adding groups [%s] to user %s'%(', '.join([group.name for group in groups_added]), user.dn))
if groups_removed:
consistent = False
print('Removing groups [%s] from user %s'%(', '.join([group.name for group in groups_removed]), user.dn))
if not check_only:
ldap.session.commit()
if check_only and not consistent:
print('No changes were made because --check-only is set')
print()
print('Error: LDAP groups are not consistent with roles in database')
sys.exit(1)
@bp.before_request
@login_required()
def role_acl(): #pylint: disable=inconsistent-return-statements
if not role_acl_check():
flash(_('Access denied'))
return redirect(url_for('index'))
def role_acl_check(): def role_acl_check():
return request.user and request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP']) return request.user and request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP'])
@bp.before_request
@login_required(role_acl_check)
def role_acl():
pass
@bp.route("/") @bp.route("/")
@register_navbar(lazy_gettext('Roles'), icon='key', blueprint=bp, visible=role_acl_check) @register_navbar(lazy_gettext('Roles'), icon='key', blueprint=bp, visible=role_acl_check)
def index(): def index():
...@@ -58,9 +28,7 @@ def new(): ...@@ -58,9 +28,7 @@ def new():
@bp.route("/<int:roleid>") @bp.route("/<int:roleid>")
def show(roleid=None): def show(roleid=None):
# prefetch all users so the ldap orm can cache them and doesn't run one ldap query per user role = Role.query.get(roleid)
User.query.all()
role = Role.query.filter_by(id=roleid).one()
return render_template('role/show.html', role=role, groups=Group.query.all(), roles=Role.query.all()) return render_template('role/show.html', role=role, groups=Group.query.all(), roles=Role.query.all())
@bp.route("/<int:roleid>/update", methods=['POST']) @bp.route("/<int:roleid>/update", methods=['POST'])
...@@ -71,12 +39,12 @@ def update(roleid=None): ...@@ -71,12 +39,12 @@ def update(roleid=None):
role = Role() role = Role()
db.session.add(role) db.session.add(role)
else: else:
role = Role.query.filter_by(id=roleid).one() role = Role.query.get(roleid)
role.description = request.values['description'] role.description = request.values['description']
if not role.locked: if not role.locked:
role.name = request.values['name'] role.name = request.values['name']
if not request.values['moderator-group']: if not request.values['moderator-group']:
role.moderator_group_dn = None role.moderator_group = None
else: else:
role.moderator_group = Group.query.get(request.values['moderator-group']) role.moderator_group = Group.query.get(request.values['moderator-group'])
for included_role in Role.query.all(): for included_role in Role.query.all():
...@@ -86,17 +54,16 @@ def update(roleid=None): ...@@ -86,17 +54,16 @@ def update(roleid=None):
role.included_roles.remove(included_role) role.included_roles.remove(included_role)
role.groups.clear() role.groups.clear()
for group in Group.query.all(): for group in Group.query.all():
if request.values.get(f'group-{group.gid}', False): if request.values.get(f'group-{group.id}', False):
role.groups[group] = RoleGroup(requires_mfa=bool(request.values.get(f'group-mfa-{group.gid}', ''))) role.groups[group] = RoleGroup(requires_mfa=bool(request.values.get(f'group-mfa-{group.id}', '')))
role.update_member_groups() role.update_member_groups()
db.session.commit() db.session.commit()
ldap.session.commit()
return redirect(url_for('role.show', roleid=role.id)) return redirect(url_for('role.show', roleid=role.id))
@bp.route("/<int:roleid>/del") @bp.route("/<int:roleid>/del")
@csrf_protect(blueprint=bp) @csrf_protect(blueprint=bp)
def delete(roleid): def delete(roleid):
role = Role.query.filter_by(id=roleid).one() role = Role.query.get(roleid)
if role.locked: if role.locked:
flash(_('Locked roles cannot be deleted')) flash(_('Locked roles cannot be deleted'))
return redirect(url_for('role.show', roleid=role.id)) return redirect(url_for('role.show', roleid=role.id))
...@@ -106,13 +73,12 @@ def delete(roleid): ...@@ -106,13 +73,12 @@ def delete(roleid):
for user in old_members: for user in old_members:
user.update_groups() user.update_groups()
db.session.commit() db.session.commit()
ldap.session.commit()
return redirect(url_for('role.index')) return redirect(url_for('role.index'))
@bp.route("/<int:roleid>/unlock") @bp.route("/<int:roleid>/unlock")
@csrf_protect(blueprint=bp) @csrf_protect(blueprint=bp)
def unlock(roleid): def unlock(roleid):
role = Role.query.filter_by(id=roleid).one() role = Role.query.get(roleid)
role.locked = False role.locked = False
db.session.commit() db.session.commit()
return redirect(url_for('role.show', roleid=role.id)) return redirect(url_for('role.show', roleid=role.id))
...@@ -120,22 +86,21 @@ def unlock(roleid): ...@@ -120,22 +86,21 @@ def unlock(roleid):
@bp.route("/<int:roleid>/setdefault") @bp.route("/<int:roleid>/setdefault")
@csrf_protect(blueprint=bp) @csrf_protect(blueprint=bp)
def set_default(roleid): def set_default(roleid):
role = Role.query.filter_by(id=roleid).one() role = Role.query.get(roleid)
if role.is_default: if role.is_default:
return redirect(url_for('role.show', roleid=role.id)) return redirect(url_for('role.show', roleid=role.id))
role.is_default = True role.is_default = True
for user in set(role.members): for user in set(role.members):
if not user.is_service_user: if not user.is_service_user:
role.members.discard(user) role.members.remove(user)
role.update_member_groups() role.update_member_groups()
db.session.commit() db.session.commit()
ldap.session.commit()
return redirect(url_for('role.show', roleid=role.id)) return redirect(url_for('role.show', roleid=role.id))
@bp.route("/<int:roleid>/unsetdefault") @bp.route("/<int:roleid>/unsetdefault")
@csrf_protect(blueprint=bp) @csrf_protect(blueprint=bp)
def unset_default(roleid): def unset_default(roleid):
role = Role.query.filter_by(id=roleid).one() role = Role.query.get(roleid)
if not role.is_default: if not role.is_default:
return redirect(url_for('role.show', roleid=role.id)) return redirect(url_for('role.show', roleid=role.id))
old_members = set(role.members_effective) old_members = set(role.members_effective)
...@@ -144,5 +109,4 @@ def unset_default(roleid): ...@@ -144,5 +109,4 @@ def unset_default(roleid):
if not user.is_service_user: if not user.is_service_user:
user.update_groups() user.update_groups()
db.session.commit() db.session.commit()
ldap.session.commit()
return redirect(url_for('role.show', roleid=role.id)) return redirect(url_for('role.show', roleid=role.id))
from flask import Blueprint, render_template, request, url_for, redirect, flash from flask import Blueprint, render_template, request, url_for, redirect, flash, abort
from flask_babel import gettext as _, lazy_gettext from flask_babel import gettext as _, lazy_gettext
from uffd.navbar import register_navbar from uffd.navbar import register_navbar
from uffd.csrf import csrf_protect from uffd.csrf import csrf_protect
from uffd.role.models import Role
from uffd.user.models import User
from uffd.session import login_required
from uffd.database import db from uffd.database import db
from uffd.ldap import ldap from uffd.models import Role, User, Group
from .session import login_required
bp = Blueprint('rolemod', __name__, template_folder='templates', url_prefix='/rolemod/') bp = Blueprint('rolemod', __name__, template_folder='templates', url_prefix='/rolemod/')
def user_is_rolemod(): def user_is_rolemod():
return request.user and Role.query.filter(Role.moderator_group_dn.in_(request.user.group_dns)).count() return request.user and Role.query.join(Role.moderator_group).join(Group.members).filter(User.id==request.user.id).count()
@bp.before_request @bp.before_request
@login_required() @login_required()
def acl_check(): #pylint: disable=inconsistent-return-statements def acl_check():
if not user_is_rolemod(): if not user_is_rolemod():
flash('Access denied') abort(403)
return redirect(url_for('index'))
@bp.route("/") @bp.route("/")
@register_navbar(lazy_gettext('Moderation'), icon='user-lock', blueprint=bp, visible=user_is_rolemod) @register_navbar(lazy_gettext('Moderation'), icon='user-lock', blueprint=bp, visible=user_is_rolemod)
def index(): def index():
roles = Role.query.filter(Role.moderator_group_dn.in_(request.user.group_dns)).all() roles = Role.query.join(Role.moderator_group).join(Group.members).filter(User.id==request.user.id).all()
return render_template('rolemod/list.html', roles=roles) return render_template('rolemod/list.html', roles=roles)
@bp.route("/<int:role_id>") @bp.route("/<int:role_id>")
def show(role_id): def show(role_id):
# prefetch all users so the ldap orm can cache them and doesn't run one ldap query per user
User.query.all()
role = Role.query.get_or_404(role_id) role = Role.query.get_or_404(role_id)
if role.moderator_group not in request.user.groups: if role.moderator_group not in request.user.groups:
flash(_('Access denied')) abort(403)
return redirect(url_for('index'))
return render_template('rolemod/show.html', role=role) return render_template('rolemod/show.html', role=role)
@bp.route("/<int:role_id>", methods=['POST']) @bp.route("/<int:role_id>", methods=['POST'])
...@@ -42,8 +36,7 @@ def show(role_id): ...@@ -42,8 +36,7 @@ def show(role_id):
def update(role_id): def update(role_id):
role = Role.query.get_or_404(role_id) role = Role.query.get_or_404(role_id)
if role.moderator_group not in request.user.groups: if role.moderator_group not in request.user.groups:
flash(_('Access denied')) abort(403)
return redirect(url_for('index'))
if request.form['description'] != role.description: if request.form['description'] != role.description:
if len(request.form['description']) > 256: if len(request.form['description']) > 256:
flash(_('Description too long')) flash(_('Description too long'))
...@@ -52,17 +45,16 @@ def update(role_id): ...@@ -52,17 +45,16 @@ def update(role_id):
db.session.commit() db.session.commit()
return redirect(url_for('.show', role_id=role.id)) return redirect(url_for('.show', role_id=role.id))
@bp.route("/<int:role_id>/delete_member/<member_dn>") @bp.route("/<int:role_id>/delete_member/<int:member_id>")
@csrf_protect(blueprint=bp) @csrf_protect(blueprint=bp)
def delete_member(role_id, member_dn): def delete_member(role_id, member_id):
role = Role.query.get_or_404(role_id) role = Role.query.get_or_404(role_id)
if role.moderator_group not in request.user.groups: if role.moderator_group not in request.user.groups:
flash(_('Access denied')) abort(403)
return redirect(url_for('index')) member = User.query.get_or_404(member_id)
member = User.query.get_or_404(member_dn) if member in role.members:
role.members.discard(member) role.members.remove(member)
member.update_groups() member.update_groups()
ldap.session.commit()
db.session.commit() db.session.commit()
flash(_('Member removed')) flash(_('Member removed'))
return redirect(url_for('.show', role_id=role.id)) return redirect(url_for('.show', role_id=role.id))
import secrets
from flask import Blueprint, render_template, session, request, url_for, redirect, flash, current_app, abort
from flask_babel import gettext as _, lazy_gettext
from sqlalchemy.exc import IntegrityError
from uffd.navbar import register_navbar
from uffd.csrf import csrf_protect
from uffd.sendmail import sendmail
from uffd.database import db
from uffd.models import (
User, UserEmail, PasswordToken, Role, host_ratelimit, Ratelimit, format_delay,
Session, MFAMethod, TOTPMethod, WebauthnMethod, RecoveryCodeMethod,
)
from uffd.fido2_compat import * # pylint: disable=wildcard-import,unused-wildcard-import
from .session import login_required
bp = Blueprint("selfservice", __name__, template_folder='templates', url_prefix='/self/')
reset_ratelimit = Ratelimit('passwordreset', 1*60*60, 3)
def selfservice_acl_check():
return request.user and request.user.is_in_group(current_app.config['ACL_SELFSERVICE_GROUP'])
@bp.route("/")
@register_navbar(lazy_gettext('Selfservice'), icon='portrait', blueprint=bp, visible=selfservice_acl_check)
@login_required(selfservice_acl_check)
def index():
return render_template('selfservice/self.html', user=request.user)
@bp.route("/updateprofile", methods=(['POST']))
@csrf_protect(blueprint=bp)
@login_required(selfservice_acl_check)
def update_profile():
if request.values['displayname'] != request.user.displayname:
if request.user.set_displayname(request.values['displayname']):
flash(_('Display name changed.'))
else:
flash(_('Display name is not valid.'))
db.session.commit()
return redirect(url_for('selfservice.index'))
@bp.route("/changepassword", methods=(['POST']))
@csrf_protect(blueprint=bp)
@login_required(selfservice_acl_check)
def change_password():
if not request.values['password1'] == request.values['password2']:
flash(_('Passwords do not match'))
else:
if request.user.set_password(request.values['password1']):
flash(_('Password changed'))
else:
flash(_('Invalid password'))
db.session.commit()
return redirect(url_for('selfservice.index'))
@bp.route("/passwordreset", methods=(['GET', 'POST']))
def forgot_password():
if request.method == 'GET':
return render_template('selfservice/forgot_password.html')
loginname = request.values['loginname'].lower()
mail = request.values['mail']
reset_delay = reset_ratelimit.get_delay(loginname+'/'+mail)
host_delay = host_ratelimit.get_delay()
if reset_delay or host_delay:
if reset_delay > host_delay:
flash(_('We received too many password reset requests for this user! Please wait at least %(delay)s.', delay=format_delay(reset_delay)))
else:
flash(_('We received too many requests from your ip address/network! Please wait at least %(delay)s.', delay=format_delay(host_delay)))
return redirect(url_for('.forgot_password'))
reset_ratelimit.log(loginname+'/'+mail)
host_ratelimit.log()
flash(_("We sent a mail to this user's mail address if you entered the correct mail and login name combination"))
user = User.query.filter_by(loginname=loginname, is_deactivated=False).one_or_none()
if not user:
return redirect(url_for('session.login'))
matches = any(map(lambda email: secrets.compare_digest(email.address, mail), user.verified_emails))
if not matches:
return redirect(url_for('session.login'))
recovery_email = user.recovery_email or user.primary_email
if recovery_email.address == mail and user.is_in_group(current_app.config['ACL_SELFSERVICE_GROUP']):
send_passwordreset(user)
return redirect(url_for('session.login'))
@bp.route("/token/password/<int:token_id>/<token>", methods=(['POST', 'GET']))
def token_password(token_id, token):
dbtoken = PasswordToken.query.get(token_id)
if not dbtoken or not secrets.compare_digest(dbtoken.token, token) or \
dbtoken.expired:
flash(_('Link invalid or expired'))
return redirect(url_for('session.login'))
if request.method == 'GET':
return render_template('selfservice/set_password.html', token=dbtoken)
if not request.values['password1']:
flash(_('You need to set a password, please try again.'))
return render_template('selfservice/set_password.html', token=dbtoken)
if not request.values['password1'] == request.values['password2']:
flash(_('Passwords do not match, please try again.'))
return render_template('selfservice/set_password.html', token=dbtoken)
if not dbtoken.user.is_in_group(current_app.config['ACL_SELFSERVICE_GROUP']):
abort(403)
if not dbtoken.user.set_password(request.values['password1']):
flash(_('Password ist not valid, please try again.'))
return render_template('selfservice/set_password.html', token=dbtoken)
db.session.delete(dbtoken)
db.session.commit()
flash(_('New password set'))
return redirect(url_for('session.login'))
@bp.route("/email/new", methods=['POST'])
@login_required(selfservice_acl_check)
def add_email():
email = UserEmail(user=request.user)
if not email.set_address(request.form['address']):
flash(_('E-Mail address is invalid'))
return redirect(url_for('selfservice.index'))
try:
db.session.flush()
except IntegrityError:
flash(_('E-Mail address already exists'))
return redirect(url_for('selfservice.index'))
secret = email.start_verification()
db.session.add(email)
db.session.commit()
if not sendmail(email.address, 'Mail verification', 'selfservice/mailverification.mail.txt', user=request.user, email=email, secret=secret):
flash(_('E-Mail to "%(mail_address)s" could not be sent!', mail_address=email.address))
else:
flash(_('We sent you an email, please verify your mail address.'))
return redirect(url_for('selfservice.index'))
@bp.route("/email/<int:email_id>/verify/<secret>")
@bp.route("/token/mail_verification/<int:legacy_id>/<secret>")
@login_required(selfservice_acl_check)
def verify_email(secret, email_id=None, legacy_id=None):
if email_id is not None:
email = UserEmail.query.get(email_id)
else:
email = UserEmail.query.filter_by(verification_legacy_id=legacy_id).one()
if not email or email.verification_expired:
flash(_('Link invalid or expired'))
return redirect(url_for('selfservice.index'))
if email.user != request.user:
abort(403, description=_('This link was generated for another user. Login as the correct user to continue.'))
if not email.finish_verification(secret):
flash(_('Link invalid or expired'))
return redirect(url_for('selfservice.index'))
if legacy_id is not None:
request.user.primary_email = email
try:
db.session.commit()
except IntegrityError:
flash(_('E-Mail address is already used by another account'))
return redirect(url_for('selfservice.index'))
flash(_('E-Mail address verified'))
return redirect(url_for('selfservice.index'))
@bp.route("/email/<int:email_id>/retry")
@login_required(selfservice_acl_check)
def retry_email_verification(email_id):
email = UserEmail.query.filter_by(id=email_id, user=request.user, verified=False).first_or_404()
secret = email.start_verification()
db.session.commit()
if not sendmail(email.address, 'E-Mail verification', 'selfservice/mailverification.mail.txt', user=request.user, email=email, secret=secret):
flash(_('E-Mail to "%(mail_address)s" could not be sent!', mail_address=email.address))
else:
flash(_('We sent you an email, please verify your mail address.'))
return redirect(url_for('selfservice.index'))
@bp.route("/email/<int:email_id>/delete", methods=['POST', 'GET'])
@login_required(selfservice_acl_check)
def delete_email(email_id):
email = UserEmail.query.filter_by(id=email_id, user=request.user).first_or_404()
try:
db.session.delete(email)
db.session.commit()
except IntegrityError:
flash(_('Cannot delete primary e-mail address'))
return redirect(url_for('selfservice.index'))
flash(_('E-Mail address deleted'))
return redirect(url_for('selfservice.index'))
@bp.route("/email/preferences", methods=['POST'])
@login_required(selfservice_acl_check)
def update_email_preferences():
verified_emails = UserEmail.query.filter_by(user=request.user, verified=True)
request.user.primary_email = verified_emails.filter_by(id=request.form['primary_email']).one()
if request.form['recovery_email'] == 'primary':
request.user.recovery_email = None
else:
request.user.recovery_email = verified_emails.filter_by(id=request.form['recovery_email']).one()
for service_user in request.user.service_users:
if not service_user.has_email_preferences:
continue
value = request.form.get(f'service_{service_user.service.id}_email', 'primary')
if value == 'primary':
service_user.service_email = None
else:
service_user.service_email = verified_emails.filter_by(id=value).one()
db.session.commit()
flash(_('E-Mail preferences updated'))
return redirect(url_for('selfservice.index'))
@bp.route("/session/<int:session_id>/revoke", methods=(['POST']))
@csrf_protect(blueprint=bp)
@login_required(selfservice_acl_check)
def revoke_session(session_id):
_session = Session.query.filter_by(id=session_id, user=request.user).first_or_404()
db.session.delete(_session)
db.session.commit()
flash(_('Session revoked'))
return redirect(url_for('selfservice.index'))
@bp.route("/leaverole/<int:roleid>", methods=(['POST']))
@csrf_protect(blueprint=bp)
@login_required(selfservice_acl_check)
def leave_role(roleid):
role = Role.query.get_or_404(roleid)
role.members.remove(request.user)
request.user.update_groups()
db.session.commit()
flash(_('You left role %(role_name)s', role_name=role.name))
return redirect(url_for('selfservice.index'))
def send_passwordreset(user, new=False):
PasswordToken.query.filter(PasswordToken.user == user).delete()
token = PasswordToken(user=user)
db.session.add(token)
db.session.commit()
if new:
template = 'selfservice/newuser.mail.txt'
subject = 'Welcome to the %s infrastructure'%current_app.config.get('ORGANISATION_NAME', '')
else:
template = 'selfservice/passwordreset.mail.txt'
subject = 'Password reset'
email = user.recovery_email or user.primary_email
if not sendmail(email.address, subject, template, user=user, token=token):
flash(_('E-Mail to "%(mail_address)s" could not be sent!', mail_address=email.address))
@bp.route('/mfa/', methods=['GET'])
@login_required(selfservice_acl_check)
def setup_mfa():
return render_template('selfservice/setup_mfa.html')
@bp.route('/mfa/setup/disable', methods=['GET'])
@login_required(selfservice_acl_check)
def disable_mfa():
return render_template('selfservice/disable_mfa.html')
@bp.route('/mfa/setup/disable', methods=['POST'])
@login_required(selfservice_acl_check)
@csrf_protect(blueprint=bp)
def disable_mfa_confirm():
MFAMethod.query.filter_by(user=request.user).delete()
db.session.commit()
request.user.update_groups()
db.session.commit()
return redirect(url_for('selfservice.setup_mfa'))
@bp.route('/mfa/setup/recovery', methods=['POST'])
@login_required(selfservice_acl_check)
@csrf_protect(blueprint=bp)
def setup_mfa_recovery():
for method in RecoveryCodeMethod.query.filter_by(user=request.user).all():
db.session.delete(method)
methods = []
for _ in range(10):
method = RecoveryCodeMethod(request.user)
methods.append(method)
db.session.add(method)
db.session.commit()
return render_template('selfservice/setup_mfa_recovery.html', methods=methods)
@bp.route('/mfa/setup/totp', methods=['GET'])
@login_required(selfservice_acl_check)
def setup_mfa_totp():
method = TOTPMethod(request.user)
session['mfa_totp_key'] = method.key
return render_template('selfservice/setup_mfa_totp.html', method=method, name=request.values['name'])
@bp.route('/mfa/setup/totp', methods=['POST'])
@login_required(selfservice_acl_check)
@csrf_protect(blueprint=bp)
def setup_mfa_totp_finish():
if not RecoveryCodeMethod.query.filter_by(user=request.user).all():
flash(_('Generate recovery codes first!'))
return redirect(url_for('selfservice.setup_mfa'))
method = TOTPMethod(request.user, name=request.values['name'], key=session.pop('mfa_totp_key'))
if method.verify(request.form['code']):
db.session.add(method)
request.user.update_groups()
db.session.commit()
return redirect(url_for('selfservice.setup_mfa'))
flash(_('Code is invalid'))
return redirect(url_for('selfservice.setup_mfa_totp', name=request.values['name']))
@bp.route('/mfa/setup/totp/<int:id>/delete')
@login_required(selfservice_acl_check)
@csrf_protect(blueprint=bp)
def delete_mfa_totp(id): #pylint: disable=redefined-builtin
method = TOTPMethod.query.filter_by(user=request.user, id=id).first_or_404()
db.session.delete(method)
request.user.update_groups()
db.session.commit()
return redirect(url_for('selfservice.setup_mfa'))
bp.add_app_template_global(WEBAUTHN_SUPPORTED, name='webauthn_supported')
if WEBAUTHN_SUPPORTED:
@bp.route('/mfa/setup/webauthn/begin', methods=['POST'])
@login_required(selfservice_acl_check)
@csrf_protect(blueprint=bp)
def setup_mfa_webauthn_begin():
if not RecoveryCodeMethod.query.filter_by(user=request.user).all():
abort(403)
methods = WebauthnMethod.query.filter_by(user=request.user).all()
creds = [method.cred for method in methods]
server = get_webauthn_server()
registration_data, state = server.register_begin(
{
"id": str(request.user.id).encode(),
"name": request.user.loginname,
"displayName": request.user.displayname,
},
creds,
user_verification='discouraged',
)
session["webauthn-state"] = state
return cbor.encode(registration_data)
@bp.route('/mfa/setup/webauthn/complete', methods=['POST'])
@login_required(selfservice_acl_check)
@csrf_protect(blueprint=bp)
def setup_mfa_webauthn_complete():
server = get_webauthn_server()
data = cbor.decode(request.get_data())
client_data = ClientData(data["clientDataJSON"])
att_obj = AttestationObject(data["attestationObject"])
auth_data = server.register_complete(session["webauthn-state"], client_data, att_obj)
method = WebauthnMethod(request.user, auth_data.credential_data, name=data['name'])
db.session.add(method)
request.user.update_groups()
db.session.commit()
return cbor.encode({"status": "OK"})
@bp.route('/mfa/setup/webauthn/<int:id>/delete')
@login_required(selfservice_acl_check)
@csrf_protect(blueprint=bp)
def delete_mfa_webauthn(id): #pylint: disable=redefined-builtin
method = WebauthnMethod.query.filter_by(user=request.user, id=id).first_or_404()
db.session.delete(method)
request.user.update_groups()
db.session.commit()
return redirect(url_for('selfservice.setup_mfa'))
import functools
from flask import Blueprint, render_template, request, url_for, redirect, current_app, abort
from flask_babel import lazy_gettext
from uffd.navbar import register_navbar
from uffd.csrf import csrf_protect
from uffd.database import db
from uffd.models import User, Service, ServiceUser, get_services, Group, OAuth2Client, OAuth2LogoutURI, APIClient, RemailerMode
from .session import login_required
bp = Blueprint('service', __name__, template_folder='templates')
bp.add_app_template_global(RemailerMode, 'RemailerMode')
def admin_acl():
return request.user and request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP'])
def overview_login_maybe_required(func):
@functools.wraps(func)
def decorator(*args, **kwargs):
if not current_app.config['SERVICES']:
return login_required(admin_acl)(func)(*args, **kwargs)
if not current_app.config['SERVICES_PUBLIC']:
return login_required()(func)(*args, **kwargs)
return func(*args, **kwargs)
return decorator
def overview_navbar_visible():
return get_services(request.user) or admin_acl()
@bp.route('/services/')
@register_navbar(lazy_gettext('Services'), icon='sitemap', blueprint=bp, visible=overview_navbar_visible)
@overview_login_maybe_required
def overview():
services = get_services(request.user)
banner = ''
if request.user or current_app.config['SERVICES_BANNER_PUBLIC']:
banner = current_app.config['SERVICES_BANNER']
return render_template('service/overview.html', services=services, banner=banner)
@bp.route('/service/admin')
@login_required(admin_acl)
def index():
return render_template('service/index.html', services=Service.query.all())
@bp.route('/service/new')
@bp.route('/service/<int:id>')
@login_required(admin_acl)
def show(id=None):
service = Service() if id is None else Service.query.get_or_404(id)
remailer_overwrites = []
if id is not None:
# pylint: disable=singleton-comparison
remailer_overwrites = ServiceUser.query.filter(
ServiceUser.service_id == id,
ServiceUser.remailer_overwrite_mode != None
).all()
all_groups = Group.query.all()
return render_template('service/show.html', service=service, all_groups=all_groups, remailer_overwrites=remailer_overwrites)
@bp.route('/service/new', methods=['POST'])
@bp.route('/service/<int:id>', methods=['POST'])
@csrf_protect(blueprint=bp)
@login_required(admin_acl)
def edit_submit(id=None):
if id is None:
service = Service()
db.session.add(service)
else:
service = Service.query.get_or_404(id)
service.name = request.form['name']
if not request.form['access-group']:
service.limit_access = True
service.access_group = None
elif request.form['access-group'] == 'all':
service.limit_access = False
service.access_group = None
else:
service.limit_access = True
service.access_group = Group.query.get(request.form['access-group'])
service.hide_deactivated_users = request.form.get('hide_deactivated_users') == '1'
service.enable_email_preferences = request.form.get('enable_email_preferences') == '1'
service.remailer_mode = RemailerMode[request.form['remailer-mode']]
remailer_overwrite_mode = RemailerMode[request.form['remailer-overwrite-mode']]
remailer_overwrite_user_ids = [
User.query.filter_by(loginname=loginname.strip()).one().id
for loginname in request.form['remailer-overwrite-users'].split(',') if loginname.strip()
]
# pylint: disable=singleton-comparison
service_users = ServiceUser.query.filter(
ServiceUser.service == service,
db.or_(
ServiceUser.user_id.in_(remailer_overwrite_user_ids),
ServiceUser.remailer_overwrite_mode != None,
)
)
for service_user in service_users:
if service_user.user_id in remailer_overwrite_user_ids:
service_user.remailer_overwrite_mode = remailer_overwrite_mode
else:
service_user.remailer_overwrite_mode = None
db.session.commit()
return redirect(url_for('service.show', id=service.id))
@bp.route('/service/<int:id>/delete')
@csrf_protect(blueprint=bp)
@login_required(admin_acl)
def delete(id):
service = Service.query.get_or_404(id)
db.session.delete(service)
db.session.commit()
return redirect(url_for('service.index'))
@bp.route('/service/<int:service_id>/oauth2/new')
@bp.route('/service/<int:service_id>/oauth2/<int:db_id>')
@login_required(admin_acl)
def oauth2_show(service_id, db_id=None):
service = Service.query.get_or_404(service_id)
client = OAuth2Client() if db_id is None else OAuth2Client.query.filter_by(service_id=service_id, db_id=db_id).first_or_404()
return render_template('service/oauth2.html', service=service, client=client)
@bp.route('/service/<int:service_id>/oauth2/new', methods=['POST'])
@bp.route('/service/<int:service_id>/oauth2/<int:db_id>', methods=['POST'])
@csrf_protect(blueprint=bp)
@login_required(admin_acl)
def oauth2_submit(service_id, db_id=None):
service = Service.query.get_or_404(service_id)
if db_id is None:
client = OAuth2Client(service=service)
db.session.add(client)
else:
client = OAuth2Client.query.filter_by(service_id=service_id, db_id=db_id).first_or_404()
client.client_id = request.form['client_id']
if request.form['client_secret']:
client.client_secret = request.form['client_secret']
if not client.client_secret:
abort(400)
client.redirect_uris = [x.strip() for x in request.form['redirect_uris'].split('\n') if x.strip()]
client.logout_uris = []
for line in request.form['logout_uris'].split('\n'):
line = line.strip()
if not line:
continue
method, uri = line.split(' ', 2)
client.logout_uris.append(OAuth2LogoutURI(method=method, uri=uri))
db.session.commit()
return redirect(url_for('service.show', id=service.id))
@bp.route('/service/<int:service_id>/oauth2/<int:db_id>/delete')
@csrf_protect(blueprint=bp)
@login_required(admin_acl)
def oauth2_delete(service_id, db_id=None):
service = Service.query.get_or_404(service_id)
client = OAuth2Client.query.filter_by(service_id=service_id, db_id=db_id).first_or_404()
db.session.delete(client)
db.session.commit()
return redirect(url_for('service.show', id=service.id))
@bp.route('/service/<int:service_id>/api/new')
@bp.route('/service/<int:service_id>/api/<int:id>')
@login_required(admin_acl)
def api_show(service_id, id=None):
service = Service.query.get_or_404(service_id)
client = APIClient() if id is None else APIClient.query.filter_by(service_id=service_id, id=id).first_or_404()
return render_template('service/api.html', service=service, client=client)
@bp.route('/service/<int:service_id>/api/new', methods=['POST'])
@bp.route('/service/<int:service_id>/api/<int:id>', methods=['POST'])
@csrf_protect(blueprint=bp)
@login_required(admin_acl)
def api_submit(service_id, id=None):
service = Service.query.get_or_404(service_id)
if id is None:
client = APIClient(service=service)
db.session.add(client)
else:
client = APIClient.query.filter_by(service_id=service_id, id=id).first_or_404()
client.auth_username = request.form['auth_username']
if request.form['auth_password']:
client.auth_password = request.form['auth_password']
if not client.auth_password:
abort(400)
client.perm_users = request.form.get('perm_users') == '1'
client.perm_checkpassword = request.form.get('perm_checkpassword') == '1'
client.perm_mail_aliases = request.form.get('perm_mail_aliases') == '1'
client.perm_remailer = request.form.get('perm_remailer') == '1'
client.perm_metrics = request.form.get('perm_metrics') == '1'
db.session.commit()
return redirect(url_for('service.show', id=service.id))
@bp.route('/service/<int:service_id>/api/<int:id>/delete')
@csrf_protect(blueprint=bp)
@login_required(admin_acl)
def api_delete(service_id, id=None):
service = Service.query.get_or_404(service_id)
client = APIClient.query.filter_by(service_id=service_id, id=id).first_or_404()
db.session.delete(client)
db.session.commit()
return redirect(url_for('service.show', id=service.id))
...@@ -8,77 +8,74 @@ from flask_babel import gettext as _ ...@@ -8,77 +8,74 @@ from flask_babel import gettext as _
from uffd.database import db from uffd.database import db
from uffd.csrf import csrf_protect from uffd.csrf import csrf_protect
from uffd.secure_redirect import secure_local_redirect from uffd.secure_redirect import secure_local_redirect
from uffd.user.models import User from uffd.models import User, DeviceLoginInitiation, DeviceLoginConfirmation, Ratelimit, host_ratelimit, format_delay, Session
from uffd.ldap import ldap, test_user_bind, LDAPInvalidDnError, LDAPBindError, LDAPPasswordIsMandatoryError from uffd.fido2_compat import * # pylint: disable=wildcard-import,unused-wildcard-import
from uffd.ratelimit import Ratelimit, host_ratelimit, format_delay
from uffd.session.models import DeviceLoginInitiation, DeviceLoginConfirmation
bp = Blueprint("session", __name__, template_folder='templates', url_prefix='/') bp = Blueprint("session", __name__, template_folder='templates', url_prefix='/')
login_ratelimit = Ratelimit('login', 1*60, 3) login_ratelimit = Ratelimit('login', 1*60, 3)
mfa_ratelimit = Ratelimit('mfa', 1*60, 3)
@bp.before_app_request @bp.before_app_request
def set_request_user(): def set_request_user():
request.user = None request.user = None
request.user_pre_mfa = None request.user_pre_mfa = None
if 'user_dn' not in session: request.session = None
request.session_pre_mfa = None
if 'id' not in session:
return return
if 'logintime' not in session: if 'secret' not in session:
return return
if datetime.datetime.now().timestamp() > session['logintime'] + current_app.config['SESSION_LIFETIME_SECONDS']: _session = Session.query.get(session['id'])
if _session is None or not _session.secret.verify(session['secret']) or _session.expired:
return return
user = User.query.get(session['user_dn']) if _session.last_used <= datetime.datetime.utcnow() - datetime.timedelta(seconds=60):
request.user_pre_mfa = user _session.last_used = datetime.datetime.utcnow()
if session.get('user_mfa'): _session.ip_address = request.remote_addr
request.user = user _session.user_agent = request.user_agent.string
db.session.commit()
def login_get_user(loginname, password): if _session.user.is_deactivated or not _session.user.is_in_group(current_app.config['ACL_ACCESS_GROUP']):
dn = User(loginname=loginname).dn return
request.session_pre_mfa = _session
# If we use a service connection, test user bind seperately request.user_pre_mfa = _session.user
if not current_app.config['LDAP_SERVICE_USER_BIND'] or current_app.config.get('LDAP_SERVICE_MOCK', False): if _session.mfa_done:
if not test_user_bind(dn, password): request.session = _session
return None request.user = _session.user
# If we use a user connection, just create the connection normally
else:
# ldap.get_connection gets the credentials from the session, so set it here initially
session['user_dn'] = dn
session['user_pw'] = password
try:
ldap.get_connection()
except (LDAPBindError, LDAPPasswordIsMandatoryError):
session.clear()
return None
try:
user = User.query.get(dn)
if user:
return user
except LDAPInvalidDnError:
pass
return None
@bp.route("/logout") @bp.route("/logout")
def logout(): def logout():
# The oauth2 module takes data from `session` and injects it into the url, # The oauth2 module takes data from `session` and injects it into the url,
# so we need to build the url BEFORE we clear the session! # so we need to build the url BEFORE we clear the session!
resp = redirect(url_for('oauth2.logout', ref=request.values.get('ref', url_for('.login')))) resp = redirect(url_for('oauth2.logout', ref=request.values.get('ref', url_for('.login'))))
if request.session_pre_mfa:
db.session.delete(request.session_pre_mfa)
db.session.commit()
session.clear() session.clear()
return resp return resp
def set_session(user, password='', skip_mfa=False): def set_session(user, skip_mfa=False):
session.clear() session.clear()
session['user_dn'] = user.dn session.permanent = True
# only save the password if we use a user connection secret = secrets.token_hex(128)
if password and current_app.config['LDAP_SERVICE_USER_BIND']: _session = Session(
session['user_pw'] = password user=user,
session['logintime'] = datetime.datetime.now().timestamp() secret=secret,
session['_csrf_token'] = secrets.token_hex(128) ip_address=request.remote_addr,
user_agent=request.user_agent.string,
)
if skip_mfa: if skip_mfa:
session['user_mfa'] = True _session.mfa_done = True
db.session.add(_session)
db.session.commit()
session['id'] = _session.id
session['secret'] = secret
session['_csrf_token'] = secrets.token_hex(128)
@bp.route("/login", methods=('GET', 'POST')) @bp.route("/login", methods=('GET', 'POST'))
def login(): def login():
# pylint: disable=too-many-return-statements
if request.user_pre_mfa:
return redirect(url_for('session.mfa_auth', ref=request.values.get('ref', url_for('index'))))
if request.method == 'GET': if request.method == 'GET':
return render_template('session/login.html', ref=request.values.get('ref')) return render_template('session/login.html', ref=request.values.get('ref'))
...@@ -92,17 +89,24 @@ def login(): ...@@ -92,17 +89,24 @@ def login():
else: else:
flash(_('We received too many requests from your ip address/network! Please wait at least %(delay)s.', delay=format_delay(host_delay))) flash(_('We received too many requests from your ip address/network! Please wait at least %(delay)s.', delay=format_delay(host_delay)))
return render_template('session/login.html', ref=request.values.get('ref')) return render_template('session/login.html', ref=request.values.get('ref'))
user = login_get_user(username, password)
if user is None: user = User.query.filter_by(loginname=username).one_or_none()
if user is None or not user.password.verify(password):
login_ratelimit.log(username) login_ratelimit.log(username)
host_ratelimit.log() host_ratelimit.log()
flash(_('Login name or password is wrong')) flash(_('Login name or password is wrong'))
return render_template('session/login.html', ref=request.values.get('ref')) return render_template('session/login.html', ref=request.values.get('ref'))
if not user.is_in_group(current_app.config['ACL_SELFSERVICE_GROUP']): if user.is_deactivated:
flash(_('Your account is deactivated. Contact %(contact_email)s for details.', contact_email=current_app.config['ORGANISATION_CONTACT']))
return render_template('session/login.html', ref=request.values.get('ref'))
if user.password.needs_rehash:
user.password = password
db.session.commit()
if not user.is_in_group(current_app.config['ACL_ACCESS_GROUP']):
flash(_('You do not have access to this service')) flash(_('You do not have access to this service'))
return render_template('session/login.html', ref=request.values.get('ref')) return render_template('session/login.html', ref=request.values.get('ref'))
set_session(user, password=password) set_session(user)
return redirect(url_for('mfa.auth', ref=request.values.get('ref', url_for('index')))) return redirect(url_for('session.mfa_auth', ref=request.values.get('ref', url_for('index'))))
def login_required_pre_mfa(no_redirect=False): def login_required_pre_mfa(no_redirect=False):
def wrapper(func): def wrapper(func):
...@@ -117,7 +121,7 @@ def login_required_pre_mfa(no_redirect=False): ...@@ -117,7 +121,7 @@ def login_required_pre_mfa(no_redirect=False):
return decorator return decorator
return wrapper return wrapper
def login_required(group=None): def login_required(permission_check=lambda: True):
def wrapper(func): def wrapper(func):
@functools.wraps(func) @functools.wraps(func)
def decorator(*args, **kwargs): def decorator(*args, **kwargs):
...@@ -125,14 +129,93 @@ def login_required(group=None): ...@@ -125,14 +129,93 @@ def login_required(group=None):
flash(_('You need to login first')) flash(_('You need to login first'))
return redirect(url_for('session.login', ref=request.full_path)) return redirect(url_for('session.login', ref=request.full_path))
if not request.user: if not request.user:
return redirect(url_for('mfa.auth', ref=request.full_path)) return redirect(url_for('session.mfa_auth', ref=request.full_path))
if not request.user.is_in_group(group): if not permission_check():
flash(_('Access denied')) abort(403)
return redirect(url_for('index'))
return func(*args, **kwargs) return func(*args, **kwargs)
return decorator return decorator
return wrapper return wrapper
@bp.route('/mfa/auth', methods=['GET'])
@login_required_pre_mfa()
def mfa_auth():
if not request.user_pre_mfa.mfa_enabled:
request.session_pre_mfa.mfa_done = True
db.session.commit()
set_request_user()
if request.session_pre_mfa.mfa_done:
return secure_local_redirect(request.values.get('ref', url_for('index')))
return render_template('session/mfa_auth.html', ref=request.values.get('ref'))
@bp.route('/mfa/auth', methods=['POST'])
@login_required_pre_mfa()
def mfa_auth_finish():
delay = mfa_ratelimit.get_delay(request.user_pre_mfa.id)
if delay:
flash(_('We received too many invalid attempts! Please wait at least %s.')%format_delay(delay))
return redirect(url_for('session.mfa_auth', ref=request.values.get('ref')))
for method in request.user_pre_mfa.mfa_totp_methods:
if method.verify(request.form['code']):
request.session_pre_mfa.mfa_done = True
db.session.commit()
set_request_user()
return secure_local_redirect(request.values.get('ref', url_for('index')))
for method in request.user_pre_mfa.mfa_recovery_codes:
if method.verify(request.form['code']):
db.session.delete(method)
request.session_pre_mfa.mfa_done = True
db.session.commit()
set_request_user()
if len(request.user_pre_mfa.mfa_recovery_codes) <= 1:
flash(_('You have exhausted your recovery codes. Please generate new ones now!'))
return redirect(url_for('selfservice.setup_mfa'))
if len(request.user_pre_mfa.mfa_recovery_codes) <= 5:
flash(_('You only have a few recovery codes remaining. Make sure to generate new ones before they run out.'))
return redirect(url_for('selfservice.setup_mfa'))
return secure_local_redirect(request.values.get('ref', url_for('index')))
mfa_ratelimit.log(request.user_pre_mfa.id)
flash(_('Two-factor authentication failed'))
return redirect(url_for('session.mfa_auth', ref=request.values.get('ref')))
if WEBAUTHN_SUPPORTED:
@bp.route("/mfa/auth/webauthn/begin", methods=["POST"])
@login_required_pre_mfa(no_redirect=True)
def mfa_auth_webauthn_begin():
server = get_webauthn_server()
creds = [method.cred for method in request.user_pre_mfa.mfa_webauthn_methods]
if not creds:
abort(404)
auth_data, state = server.authenticate_begin(creds, user_verification='discouraged')
session["webauthn-state"] = state
return cbor.encode(auth_data)
@bp.route("/mfa/auth/webauthn/complete", methods=["POST"])
@login_required_pre_mfa(no_redirect=True)
def mfa_auth_webauthn_complete():
server = get_webauthn_server()
creds = [method.cred for method in request.user_pre_mfa.mfa_webauthn_methods]
if not creds:
abort(404)
data = cbor.decode(request.get_data())
credential_id = data["credentialId"]
client_data = ClientData(data["clientDataJSON"])
auth_data = AuthenticatorData(data["authenticatorData"])
signature = data["signature"]
# authenticate_complete() (as of python-fido2 v0.5.0, the version in Debian Buster)
# does not check signCount, although the spec recommends it
server.authenticate_complete(
session.pop("webauthn-state"),
creds,
credential_id,
client_data,
auth_data,
signature,
)
request.session_pre_mfa.mfa_done = True
db.session.commit()
set_request_user()
return cbor.encode({"status": "OK"})
@bp.route("/login/device/start") @bp.route("/login/device/start")
def devicelogin_start(): def devicelogin_start():
session['devicelogin_started'] = True session['devicelogin_started'] = True
...@@ -178,12 +261,12 @@ def deviceauth(): ...@@ -178,12 +261,12 @@ def deviceauth():
@login_required() @login_required()
@csrf_protect(blueprint=bp) @csrf_protect(blueprint=bp)
def deviceauth_submit(): def deviceauth_submit():
DeviceLoginConfirmation.query.filter_by(user_dn=request.user.dn).delete() DeviceLoginConfirmation.query.filter_by(session=request.session).delete()
initiation = DeviceLoginInitiation.query.filter_by(code=request.form['initiation-code']).one_or_none() initiation = DeviceLoginInitiation.query.filter_by(code=request.form['initiation-code']).one_or_none()
if initiation is None or initiation.expired: if initiation is None or initiation.expired:
flash(_('Invalid initiation code')) flash(_('Invalid initiation code'))
return redirect(url_for('session.deviceauth')) return redirect(url_for('session.deviceauth'))
confirmation = DeviceLoginConfirmation(user=request.user, initiation=initiation) confirmation = DeviceLoginConfirmation(session=request.session, initiation=initiation)
db.session.add(confirmation) db.session.add(confirmation)
db.session.commit() db.session.commit()
return render_template('session/deviceauth.html', initiation=initiation, confirmation=confirmation) return render_template('session/deviceauth.html', initiation=initiation, confirmation=confirmation)
...@@ -191,6 +274,6 @@ def deviceauth_submit(): ...@@ -191,6 +274,6 @@ def deviceauth_submit():
@bp.route("/device/finish", methods=['GET', 'POST']) @bp.route("/device/finish", methods=['GET', 'POST'])
@login_required() @login_required()
def deviceauth_finish(): def deviceauth_finish():
DeviceLoginConfirmation.query.filter_by(user_dn=request.user.dn).delete() DeviceLoginConfirmation.query.filter_by(session=request.session).delete()
db.session.commit() db.session.commit()
return redirect(url_for('index')) return redirect(url_for('index'))