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
  • Dockerfile
  • feature_invite_validuntil_minmax
  • incremental-sync
  • jwt_encode_inconsistencies
  • master
  • redis-rate-limits
  • roles-recursive-cte
  • typehints
  • v1.0.x
  • v1.1.x
  • v1.2.x
  • v1.x.x
  • v0.1.2
  • v0.1.4
  • v0.1.5
  • v0.2.0
  • v0.3.0
  • v1.0.0
  • v1.0.1
  • v1.0.2
  • v1.1.0
  • v1.1.1
  • v1.1.2
  • v1.2.0
  • v2.0.0
  • v2.0.1
  • v2.1.0
  • v2.2.0
  • v2.3.0
  • v2.3.1
30 results

Target

Select target project
  • uffd/uffd
  • rixx/uffd
  • thies/uffd
  • leona/uffd
  • enbewe/uffd
  • strifel/uffd
  • thies/uffd-2
7 results
Select Git revision
  • Dockerfile
  • claims-in-idtoke
  • feature_invite_validuntil_minmax
  • incremental-sync
  • jwt_encode_inconsistencies
  • master
  • recovery-code-pwhash
  • redis-rate-limits
  • roles-recursive-cte
  • typehints
  • v1.0.x
  • v1.1.x
  • v1.2.x
  • v1.x.x
  • v0.1.2
  • v0.1.4
  • v0.1.5
  • v0.2.0
  • v0.3.0
  • v1.0.0
  • v1.0.1
  • v1.0.2
  • v1.1.0
  • v1.1.1
  • v1.1.2
  • v1.2.0
  • v2.0.0
  • v2.0.1
  • v2.1.0
  • v2.2.0
  • v2.3.0
  • v2.3.1
32 results
Show changes
Showing
with 751 additions and 595 deletions
......@@ -28,11 +28,11 @@ mfa_enabled: The user has setup at least one two-factor method. Two-factor authe
{% if mfa_setup or mfa_enabled %}
<div class="clearfix">
{% if mfa_enabled %}
<form class="form float-right" action="{{ url_for('mfa.disable') }}">
<form class="form float-right" action="{{ url_for('selfservice.disable_mfa') }}">
<button type="submit" class="btn btn-danger mb-2">{{_("Disable two-factor authentication")}}</button>
</form>
{% else %}
<form class="form float-right" action="{{ url_for('mfa.disable_confirm') }}" method="POST">
<form class="form float-right" action="{{ url_for('selfservice.disable_mfa_confirm') }}" method="POST">
<button type="submit" class="btn btn-light mb-2">{{_("Reset two-factor configuration")}}</button>
</form>
{% endif %}
......@@ -56,7 +56,7 @@ mfa_enabled: The user has setup at least one two-factor method. Two-factor authe
</div>
<div class="col-12 col-md-7">
<form class="form" action="{{ url_for('mfa.setup_recovery') }}" method="POST">
<form class="form" action="{{ url_for('selfservice.setup_mfa_recovery') }}" method="POST">
{% if mfa_init %}
<button type="submit" class="btn btn-primary mb-2 col">
{{_("Generate recovery codes to enable two-factor authentication")}}
......@@ -93,7 +93,7 @@ mfa_enabled: The user has setup at least one two-factor method. Two-factor authe
</div>
<div class="col-12 col-md-7">
<form class="form mb-2" action="{{ url_for('mfa.setup_totp') }}">
<form class="form mb-2" action="{{ url_for('selfservice.setup_mfa_totp') }}" autocomplete="off">
<div class="row m-0">
<label class="sr-only" for="totp-name">{{_("Name")}}</label>
<input type="text" name="name" class="form-control mb-2 col-12 col-lg-auto mr-2" style="width: 15em;" id="totp-name" placeholder="{{_("Name")}}" required {{ 'disabled' if mfa_init }}>
......@@ -114,7 +114,7 @@ mfa_enabled: The user has setup at least one two-factor method. Two-factor authe
<tr>
<td>{{ method.name }}</td>
<td>{{ method.created|dateformat }}</td>
<td><a class="btn btn-sm btn-danger float-right" href="{{ url_for('mfa.delete_totp', id=method.id) }}">{{_("Delete")}}</a></td>
<td><a class="btn btn-sm btn-danger float-right" href="{{ url_for('selfservice.delete_mfa_totp', id=method.id) }}">{{_("Delete")}}</a></td>
</tr>
{% endfor %}
{% if not request.user.mfa_totp_methods %}
......@@ -152,7 +152,7 @@ mfa_enabled: The user has setup at least one two-factor method. Two-factor authe
</div>
</noscript>
<div id="webauthn-alert" class="alert alert-warning d-none" role="alert"></div>
<form id="webauthn-form" class="form mb-2">
<form id="webauthn-form" autocomplete="off" class="form mb-2">
<div class="row m-0">
<label class="sr-only" for="webauthn-name">{{_("Name")}}</label>
<input type="text" class="form-control mb-2 col-12 col-lg-auto mr-2" style="width: 15em;" id="webauthn-name" placeholder="{{_("Name")}}" required disabled>
......@@ -176,7 +176,7 @@ mfa_enabled: The user has setup at least one two-factor method. Two-factor authe
<tr>
<td>{{ method.name }}</td>
<td>{{ method.created|dateformat }}</td>
<td><a class="btn btn-sm btn-danger float-right" href="{{ url_for('mfa.delete_webauthn', id=method.id) }}">{{_("Delete")}}</a></td>
<td><a class="btn btn-sm btn-danger float-right" href="{{ url_for('selfservice.delete_mfa_webauthn', id=method.id) }}">{{_("Delete")}}</a></td>
</tr>
{% endfor %}
{% if not request.user.mfa_webauthn_methods %}
......@@ -198,7 +198,7 @@ $('#webauthn-form').on('submit', function(e) {
$('#webauthn-spinner').removeClass('d-none');
$('#webauthn-btn-text').text({{ _('Contacting server')|tojson }});
$('#webauthn-btn').prop('disabled', true);
fetch({{ url_for('mfa.setup_webauthn_begin')|tojson }}, {
fetch({{ url_for('selfservice.setup_mfa_webauthn_begin')|tojson }}, {
method: 'POST',
}).then(function(response) {
if (response.ok)
......@@ -210,7 +210,7 @@ $('#webauthn-form').on('submit', function(e) {
$('#webauthn-btn-text').text({{ _('Waiting for device')|tojson }});
return navigator.credentials.create(options);
}).then(function(attestation) {
return fetch({{ url_for('mfa.setup_webauthn_complete')|tojson }}, {
return fetch({{ url_for('selfservice.setup_mfa_webauthn_complete')|tojson }}, {
method: 'POST',
headers: {'Content-Type': 'application/cbor'},
body: CBOR.encode({
......@@ -223,7 +223,7 @@ $('#webauthn-form').on('submit', function(e) {
if (response.ok) {
$('#webauthn-spinner').addClass('d-none');
$('#webauthn-btn-text').text({{ _('Success')|tojson }});
window.location = {{ url_for('mfa.setup')|tojson }};
window.location = {{ url_for('selfservice.setup_mfa')|tojson }};
} else {
throw new Error({{ _('Invalid response from device')|tojson }});
}
......
......@@ -12,7 +12,7 @@
<div class="text-monospace">
<ul>
{% for method in methods %}
<li>{{ method.code }}</li>
<li>{{ method.code_value }}</li>
{% endfor %}
</ul>
</div>
......@@ -23,8 +23,8 @@
</p>
<div class="btn-toolbar">
<a class="ml-auto mb-2 btn btn-primary d-print-none" href="{{ url_for('mfa.setup') }}">{{_("Continue")}}</a>
<a class="ml-2 mb-2 btn btn-light d-print-none" href="{{ methods|map(attribute='code')|join('\n')|datauri }}" download="uffd-recovery-codes">
<a class="ml-auto mb-2 btn btn-primary d-print-none" href="{{ url_for('selfservice.setup_mfa') }}">{{_("Continue")}}</a>
<a class="ml-2 mb-2 btn btn-light d-print-none" href="{{ methods|map(attribute='code_value')|join('\n')|datauri }}" download="uffd-recovery-codes">
{{_("Download codes")}}
</a>
<button class="ml-2 mb-2 btn btn-light d-print-none" type="button" onClick="window.print()">{{_("Print codes")}}</button>
......
......@@ -32,7 +32,7 @@
</div>
</div>
<form action="{{ url_for('mfa.setup_totp_finish', name=name) }}" method="POST" class="form">
<form action="{{ url_for('selfservice.setup_mfa_totp_finish', name=name) }}" method="POST" autocomplete="off" class="form">
<div class="row m-0">
<input type="text" name="code" class="form-control mb-2 mr-2 col-auto col-md" id="code" placeholder="{{_('Code')}}" required autofocus>
<button type="submit" class="btn btn-primary mb-2 col col-md-auto">{{_("Verify and complete setup")}}</button>
......
......@@ -2,7 +2,7 @@
{% block body %}
<div class="row">
<form action="{{ url_for('service.api_submit', service_id=service.id, id=client.id) }}" method="POST" class="form col-12 px-0">
<form action="{{ url_for('service.api_submit', service_id=service.id, id=client.id) }}" method="POST" autocomplete="off" class="form col-12 px-0">
<div class="form-group col">
<p class="text-right">
......@@ -24,9 +24,9 @@
<div class="form-group col">
<label for="client-auth-password">{{ _('Authentication Password') }}</label>
{% if client.id %}
<input type="password" class="form-control" id="client-auth-password" name="auth_password" placeholder="●●●●●●●●">
<input type="password" autocomplete="new-password" class="form-control" id="client-auth-password" name="auth_password" placeholder="●●●●●●●●">
{% else %}
<input type="password" class="form-control" id="client-auth-password" name="auth_password" required>
<input type="password" autocomplete="new-password" class="form-control" id="client-auth-password" name="auth_password" required>
{% endif %}
</div>
......
......@@ -2,7 +2,7 @@
{% block body %}
<div class="row">
<form action="{{ url_for('service.oauth2_submit', service_id=service.id, db_id=client.db_id) }}" method="POST" class="form col-12 px-0">
<form action="{{ url_for('service.oauth2_submit', service_id=service.id, db_id=client.db_id) }}" method="POST" autocomplete="off" class="form col-12 px-0">
<div class="form-group col">
<p class="text-right">
......@@ -24,9 +24,9 @@
<div class="form-group col">
<label for="client-client-secret">{{ _('Client Secret') }}</label>
{% if client.db_id %}
<input type="password" class="form-control" id="client-client-secret" name="client_secret" placeholder="●●●●●●●●">
<input type="password" autocomplete="new-password" class="form-control" id="client-client-secret" name="client_secret" placeholder="●●●●●●●●">
{% else %}
<input type="password" class="form-control" id="client-client-secret" name="client_secret" required>
<input type="password" autocomplete="new-password" class="form-control" id="client-client-secret" name="client_secret" required>
{% endif %}
</div>
......
......@@ -4,7 +4,7 @@
<div class="row">
<form action="{{ url_for('service.edit_submit', id=service.id) }}" method="POST" class="form col-12 px-0">
<form action="{{ url_for('service.edit_submit', id=service.id) }}" method="POST" autocomplete="off" class="form col-12 px-0">
<div class="form-group col">
<p class="text-right">
<a href="{{ url_for('service.index') }}" class="btn btn-secondary">{{ _('Cancel') }}</a>
......@@ -31,6 +31,13 @@
</select>
</div>
<div class="form-group col">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="hide-deactivated-users" name="hide_deactivated_users" value="1" aria-label="enabled" {{ 'checked' if service.hide_deactivated_users }}>
<label class="form-check-label" for="hide-deactivated-users">{{ _('Hide deactivated users from service') }}</label>
</div>
</div>
<div class="form-group col">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="service-enable-email-preferences" name="enable_email_preferences" value="1" aria-label="enabled" {{ 'checked' if service.enable_email_preferences }}>
......
......@@ -2,11 +2,11 @@
{% block body %}
{% if not initiation %}
<form action="{{ url_for("session.deviceauth") }}">
<form action="{{ url_for("session.deviceauth") }}" autocomplete="off">
{% elif not confirmation %}
<form action="{{ url_for("session.deviceauth_submit") }}" method="POST">
<form action="{{ url_for("session.deviceauth_submit") }}" method="POST" autocomplete="off">
{% else %}
<form action="{{ url_for("session.deviceauth_finish") }}" method="POST">
<form action="{{ url_for("session.deviceauth_finish") }}" method="POST" autocomplete="off">
{% endif %}
<div class="col-12">
<h2 class="text-center">{{_('Authorize Device Login')}}</h2>
......
{% extends 'base_narrow.html' %}
{% block body %}
<form action="{{ url_for("session.devicelogin_submit", ref=ref) }}" method="POST">
<form action="{{ url_for("session.devicelogin_submit", ref=ref) }}" method="POST" autocomplete="off">
<div class="col-12">
<h2 class="text-center">{{_('Device Login')}}</h2>
</div>
......
......@@ -10,11 +10,11 @@
{% endif %}
<div class="form-group col-12">
<label for="user-loginname">{{_("Login Name")}}</label>
<input type="text" class="form-control" id="user-loginname" name="loginname" required="required" tabindex = "1" autofocus>
<input type="text" autocomplete="username" class="form-control" id="user-loginname" name="loginname" required="required" tabindex="1" autofocus>
</div>
<div class="form-group col-12">
<label for="user-password1">{{_("Password")}}</label>
<input type="password" class="form-control" id="user-password1" name="password" required="required" tabindex = "2">
<input type="password" autocomplete="current-password" class="form-control" id="user-password1" name="password" required="required" tabindex="2">
</div>
<div class="form-group col-12">
<button type="submit" class="btn btn-primary btn-block" tabindex="3">{{_("Login")}}</button>
......
{% extends 'base_narrow.html' %}
{% block body %}
<form action="{{ url_for("mfa.auth_finish", ref=ref) }}" method="POST">
<form action="{{ url_for("session.mfa_auth_finish", ref=ref) }}" method="POST" autocomplete="off">
<div class="col-12 mb-3">
<h2 class="text-center">{{_("Two-Factor Authentication")}}</h2>
</div>
......@@ -24,7 +24,7 @@
<div class="text-center text-muted d-none webauthn-group mb-3">- {{_("or")}} -</div>
{% endif %}
<div class="form-group col-12 mb-2">
<input type="text" class="form-control" id="mfa-code" name="code" required="required" placeholder="{{_("Code from your authenticator app or recovery code")}}" autocomplete="off" autofocus>
<input type="text" class="form-control" id="mfa-code" name="code" required="required" placeholder="{{_("Code from your authenticator app or recovery code")}}" autofocus>
</div>
<div class="form-group col-12">
<button type="submit" class="btn btn-primary btn-block">{{_("Verify")}}</button>
......@@ -42,7 +42,7 @@ function begin_webauthn() {
$('#webauthn-spinner').removeClass('d-none');
$('#webauthn-btn-text').text({{ _('Contacting server')|tojson }});
$('#webauthn-btn').prop('disabled', true);
fetch({{ url_for('mfa.auth_webauthn_begin')|tojson }}, {
fetch({{ url_for('session.mfa_auth_webauthn_begin')|tojson }}, {
method: 'POST',
}).then(function(response) {
if (response.ok) {
......@@ -60,7 +60,7 @@ function begin_webauthn() {
return navigator.credentials.get(options);
}).then(function(assertion) {
$('#webauthn-btn-text').text({{ _('Verifing response')|tojson }});
return fetch({{ url_for('mfa.auth_webauthn_complete')|tojson }}, {
return fetch({{ url_for('session.mfa_auth_webauthn_complete')|tojson }}, {
method: 'POST',
headers: {'Content-Type': 'application/cbor'},
body: CBOR.encode({
......
......@@ -7,7 +7,7 @@
</div>
<div class="form-group col-12">
<label for="user-password1">{{_('Please enter your password to complete the account registration')}}</label>
<input type="password" class="form-control" id="user-password1" name="password" required="required">
<input type="password" autocomplete="current-password" class="form-control" id="user-password1" name="password" required="required">
</div>
<div class="form-group col-12">
<button type="submit" class="btn btn-primary btn-block">{{_('Complete Account Registration')}}</button>
......
......@@ -8,7 +8,7 @@
<div class="form-group col-12">
<label for="user-loginname">{{_('Login Name')}}</label>
<div class="js-only-input-group">
<input type="text" class="form-control" id="user-loginname" name="loginname" aria-describedby="loginname-feedback" value="{{ request.form.loginname }}" minlength=1 maxlength=32 pattern="[a-z0-9_-]*" required>
<input type="text" autocomplete="username" class="form-control" id="user-loginname" name="loginname" aria-describedby="loginname-feedback" value="{{ request.form.loginname }}" minlength=1 maxlength=32 pattern="[a-z0-9_-]*" required>
<div class="js-only-input-group-append d-none">
<button class="btn btn-outline-secondary rounded-right" type="button" id="check-loginname">{{_('Check')}}</button>
</div>
......@@ -20,28 +20,28 @@
</div>
<div class="form-group col-12">
<label for="user-displayname">{{_('Display Name')}}</label>
<input type="text" class="form-control" id="user-displayname" name="displayname" value="{{ request.form.displayname }}" minlength=1 maxlength=128 required>
<input type="text" autocomplete="nickname" class="form-control" id="user-displayname" name="displayname" value="{{ request.form.displayname }}" minlength=1 maxlength=128 required>
<small class="form-text text-muted">
{{_('At least one and at most 128 characters, no other special requirements.')}}
</small>
</div>
<div class="form-group col-12">
<label for="user-mail">{{_('E-Mail Address')}}</label>
<input type="email" class="form-control" id="user-mail" name="mail" value="{{ request.form.mail }}" required>
<input type="email" autocomplete="email" class="form-control" id="user-mail" name="mail" value="{{ request.form.mail }}" required>
<small class="form-text text-muted">
{{_('We will send a confirmation mail to this address that you need to complete the registration.')}}
</small>
</div>
<div class="form-group col-12">
<label for="user-password1">{{_('Password')}}</label>
<input type="password" class="form-control" id="user-password1" name="password1" minlength={{ User.PASSWORD_MINLEN }} maxlength={{ User.PASSWORD_MAXLEN }} pattern="{{ User.PASSWORD_REGEX }}" required>
<input type="password" autocomplete="new-password" class="form-control" id="user-password1" name="password1" minlength={{ User.PASSWORD_MINLEN }} maxlength={{ User.PASSWORD_MAXLEN }} pattern="{{ User.PASSWORD_REGEX }}" required>
<small class="form-text text-muted">
{{ User.PASSWORD_DESCRIPTION|safe }}
</small>
</div>
<div class="form-group col-12">
<label for="user-password2">{{_('Repeat Password')}}</label>
<input type="password" class="form-control" id="user-password2" name="password2" required>
<input type="password" autocomplete="new-password" class="form-control" id="user-password2" name="password2" required>
</div>
<div class="form-group col-12">
<button type="submit" class="btn btn-primary btn-block">{{_('Create Account')}}</button>
......
......@@ -33,6 +33,9 @@
{% if user.is_service_user %}
<span class="badge badge-secondary">{{_('service')}}</span>
{% endif %}
{% if user.is_deactivated %}
<span class="badge badge-danger">{{ _('deactivated') }}</span>
{% endif %}
</td>
<td>
{{ user.displayname }}
......
......@@ -17,20 +17,32 @@
{% block body %}
{% if user.id %}
<form action="{{ url_for("user.update", id=user.id) }}" method="POST">
<form action="{{ url_for("user.update", id=user.id) }}" method="POST" autocomplete="off">
{% else %}
<form action="{{ url_for("user.create") }}" method="POST">
<form action="{{ url_for("user.create") }}" method="POST" autocomplete="off">
{% endif %}
<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>
<a href="{{ url_for("user.index") }}" class="btn btn-secondary">{{_("Cancel")}}</a>
{% if user.id %}
{% if user.id and not user.is_deactivated and user != request.user %}
<a href="{{ url_for("user.deactivate", id=user.id) }}" class="btn btn-secondary">{{ _("Deactivate") }}</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 %}
<a href="#" class="btn btn-danger disabled"><i class="fa fa-trash" aria-hidden="true"></i> {{_("Delete")}}</a>
{% endif %}
</div>
</div></div>
<ul class="nav nav-tabs pt-2 border-0" id="tablist" role="tablist">
<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>
......@@ -162,9 +174,9 @@
<div class="form-group col">
<label for="user-loginname">{{_("Password")}}</label>
{% if user.id %}
<input type="password" class="form-control" id="user-password" name="password" placeholder="●●●●●●●●" minlength={{ User.PASSWORD_MINLEN }} maxlength={{ User.PASSWORD_MAXLEN }} pattern="{{ User.PASSWORD_REGEX }}">
<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 %}
<input type="password" class="form-control" id="user-password" name="password" placeholder="{{_("E-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 %}
<small class="form-text text-muted">
{{ User.PASSWORD_DESCRIPTION|safe }}
......@@ -178,8 +190,15 @@
{{ _("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("mfa.admin_disable", id=user.id) }}" onClick='return confirm({{_("Are you sure?")|tojson}});' class="btn btn-secondary">{{_("Reset 2FA")}}</a>
<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>
......
No preview for this file type
......@@ -7,30 +7,30 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2022-11-08 20:12+0100\n"
"POT-Creation-Date: 2024-03-24 18:37+0100\n"
"PO-Revision-Date: 2021-05-25 21:18+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de\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"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.8.0\n"
"Generated-By: Babel 2.10.3\n"
#: uffd/models/invite.py:82 uffd/models/invite.py:105 uffd/models/invite.py:110
#: 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:84
#: uffd/models/invite.py:86
msgid "Invite link does not grant any roles"
msgstr "Einladungslink weist keine Rollen zu"
#: uffd/models/invite.py:86
#: 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:91 uffd/models/signup.py:122
#: uffd/models/invite.py:93 uffd/models/signup.py:122
#: uffd/templates/mfa/setup.html:225
msgid "Success"
msgstr "Erfolgreich"
......@@ -61,6 +61,11 @@ msgstr "eine Stunde"
msgid "%(hours)d hours"
msgstr "%(hours)d Stunden"
#: uffd/models/session.py:62 uffd/models/session.py:74
#: uffd/models/session.py:80 uffd/models/session.py:90
msgid "Unknown"
msgstr "Unbekannt"
#: uffd/models/signup.py:78 uffd/models/signup.py:103
msgid "Invalid signup request"
msgstr "Ungültiger Account-Registrierungs-Link"
......@@ -117,35 +122,35 @@ msgstr "Zugriff verweigert"
msgid "You don't have the permission to access this page."
msgstr "Du bist nicht berechtigt, auf diese Seite zuzugreifen."
#: uffd/templates/base.html:84
#: uffd/templates/base.html:85
msgid "Change"
msgstr "Ändern"
#: uffd/templates/base.html:92 uffd/templates/session/deviceauth.html:12
#: uffd/templates/base.html:93 uffd/templates/session/deviceauth.html:12
msgid "Authorize Device Login"
msgstr "Gerätelogin erlauben"
#: uffd/templates/base.html:93 uffd/templates/session/devicelogin.html:6
#: uffd/templates/base.html:94 uffd/templates/session/devicelogin.html:6
msgid "Device Login"
msgstr "Gerätelogin"
#: uffd/templates/base.html:99 uffd/templates/oauth2/logout.html:5
#: uffd/templates/base.html:100 uffd/templates/oauth2/logout.html:5
msgid "Logout"
msgstr "Abmelden"
#: uffd/templates/base.html:106 uffd/templates/service/overview.html:15
#: uffd/templates/base.html:107 uffd/templates/service/overview.html:15
#: uffd/templates/session/login.html:6 uffd/templates/session/login.html:20
msgid "Login"
msgstr "Anmelden"
#: uffd/templates/base.html:142
#: 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:99
#: uffd/templates/service/show.html:127 uffd/templates/user/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"
msgstr "Neu"
......@@ -160,25 +165,25 @@ msgstr "GID"
#: 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:190
#: uffd/templates/selfservice/self.html:239
#: uffd/templates/service/index.html:14 uffd/templates/service/show.html:20
#: uffd/templates/service/show.html:133 uffd/templates/user/show.html:193
#: uffd/templates/user/show.html:225
#: 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:191
#: uffd/templates/user/show.html:194 uffd/templates/user/show.html:226
#: 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:26
#: uffd/templates/service/show.html:16 uffd/templates/user/show.html:31
msgid "Save"
msgstr "Speichern"
......@@ -189,15 +194,17 @@ msgstr "Speichern"
#: 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:27
#: 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:205 uffd/templates/service/api.html:11
#: 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:29 uffd/templates/user/show.html:181
#: 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?"
......@@ -207,8 +214,8 @@ msgstr "Wirklich fortfahren?"
#: 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:29 uffd/templates/user/show.html:31
#: uffd/templates/user/show.html:104
#: uffd/templates/user/show.html:41 uffd/templates/user/show.html:43
#: uffd/templates/user/show.html:116
msgid "Delete"
msgstr "Löschen"
......@@ -238,7 +245,7 @@ msgid "Created by"
msgstr "Erstellt durch"
#: uffd/templates/invite/list.html:14 uffd/templates/service/api.html:34
#: uffd/templates/service/show.html:134
#: uffd/templates/service/show.html:141
msgid "Permissions"
msgstr "Berechtigungen"
......@@ -266,7 +273,7 @@ msgstr "Account-Registrierung"
msgid "user signups"
msgstr "Account-Registrierungen"
#: uffd/templates/invite/list.html:49 uffd/templates/user/show.html:178
#: uffd/templates/invite/list.html:49 uffd/templates/user/show.html:190
msgid "Disabled"
msgstr "Deaktiviert"
......@@ -460,7 +467,7 @@ msgid "One address per line"
msgstr "Eine Adresse pro Zeile"
#: uffd/templates/mfa/auth.html:6 uffd/templates/selfservice/self.html:159
#: uffd/templates/user/show.html:176
#: uffd/templates/user/show.html:188
msgid "Two-Factor Authentication"
msgstr "Zwei-Faktor-Authentifizierung"
......@@ -599,7 +606,7 @@ msgid "Reset two-factor configuration"
msgstr "Zwei-Faktor-Authentifizierung zurücksetzen"
#: uffd/templates/mfa/setup.html:46 uffd/templates/mfa/setup_recovery.html:5
#: uffd/templates/user/show.html:179
#: uffd/templates/user/show.html:191
msgid "Recovery Codes"
msgstr "Wiederherstellungscodes"
......@@ -637,7 +644,7 @@ msgstr "Generiere neue Wiederherstellungscodes"
msgid "You have no remaining recovery codes."
msgstr "Du hast keine Wiederherstellungscodes übrig."
#: uffd/templates/mfa/setup.html:85 uffd/templates/user/show.html:179
#: uffd/templates/mfa/setup.html:85 uffd/templates/user/show.html:191
msgid "Authenticator Apps (TOTP)"
msgstr "Authentifikator-Apps (TOTP)"
......@@ -667,7 +674,7 @@ msgstr "Registriert am"
msgid "No authenticator apps registered yet"
msgstr "Bisher keine Authentifikator-Apps registriert"
#: uffd/templates/mfa/setup.html:134 uffd/templates/user/show.html:179
#: uffd/templates/mfa/setup.html:134 uffd/templates/user/show.html:191
msgid "U2F and FIDO2 Devices"
msgstr "U2F und FIDO2 Geräte"
......@@ -972,7 +979,7 @@ msgstr "Passwort vergessen"
#: uffd/templates/selfservice/forgot_password.html:9
#: uffd/templates/selfservice/self.html:21 uffd/templates/session/login.html:12
#: uffd/templates/signup/start.html:9 uffd/templates/user/list.html:18
#: uffd/templates/user/show.html:66
#: uffd/templates/user/show.html:78
msgid "Login Name"
msgstr "Anmeldename"
......@@ -993,7 +1000,7 @@ msgstr ""
"Authentifizierung.\n"
"\tDiese Berechtigungen werden erst aktiv, wenn du dies getan hast."
#: uffd/templates/selfservice/self.html:14 uffd/templates/user/show.html:36
#: uffd/templates/selfservice/self.html:14 uffd/templates/user/show.html:48
msgid "Profile"
msgstr "Profil"
......@@ -1010,7 +1017,7 @@ msgid "Changes may take several minutes to be visible in all services."
msgstr "Änderungen sind erst nach einigen Minuten in allen Diensten sichtbar."
#: uffd/templates/selfservice/self.html:25 uffd/templates/signup/start.html:22
#: uffd/templates/user/list.html:19 uffd/templates/user/show.html:81
#: uffd/templates/user/list.html:19 uffd/templates/user/show.html:93
msgid "Display Name"
msgstr "Anzeigename"
......@@ -1018,7 +1025,7 @@ msgstr "Anzeigename"
msgid "Update Profile"
msgstr "Änderungen speichern"
#: uffd/templates/selfservice/self.html:37 uffd/templates/user/show.html:98
#: uffd/templates/selfservice/self.html:37 uffd/templates/user/show.html:110
msgid "E-Mail Addresses"
msgstr "E-Mail-Adressen"
......@@ -1043,7 +1050,7 @@ msgstr "Neue E-Mail-Adresse"
msgid "Add address"
msgstr "Adresse hinzufügen"
#: uffd/templates/selfservice/self.html:55 uffd/templates/user/show.html:114
#: uffd/templates/selfservice/self.html:55 uffd/templates/user/show.html:126
msgid "primary"
msgstr "primär"
......@@ -1090,8 +1097,8 @@ msgid "Address for Password Reset E-Mails"
msgstr "Adresse für Passwort-Zurücksetzen-E-Mails"
#: uffd/templates/selfservice/self.html:103
#: uffd/templates/selfservice/self.html:116 uffd/templates/user/show.html:143
#: uffd/templates/user/show.html:153
#: uffd/templates/selfservice/self.html:116 uffd/templates/user/show.html:155
#: uffd/templates/user/show.html:165
msgid "Use primary address"
msgstr "Primäre Adresse verwenden"
......@@ -1110,7 +1117,7 @@ msgstr "E-Mail-Einstellungen speichern"
#: uffd/templates/selfservice/self.html:136
#: uffd/templates/session/login.html:16 uffd/templates/signup/start.html:36
#: uffd/templates/user/show.html:163
#: uffd/templates/user/show.html:175
msgid "Password"
msgstr "Passwort"
......@@ -1154,13 +1161,48 @@ msgstr ""
msgid "Manage two-factor authentication"
msgstr "Zwei-Faktor-Authentifizierung (2FA) verwalten"
#: uffd/templates/selfservice/self.html:178 uffd/templates/user/list.html:20
#: uffd/templates/user/show.html:39 uffd/templates/user/show.html:188
#: 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:179
#: uffd/templates/selfservice/self.html:228
msgid ""
"Aside from a set of base permissions, your roles determine the "
"permissions of your account."
......@@ -1168,7 +1210,7 @@ msgstr ""
"Deine Berechtigungen werden, von einigen Basis-Berechtigungen abgesehen, "
"von deinen Rollen bestimmt"
#: uffd/templates/selfservice/self.html:181
#: uffd/templates/selfservice/self.html:230
#, python-format
msgid ""
"See <a href=\"%(services_url)s\">Services</a> for an overview of your "
......@@ -1177,13 +1219,13 @@ msgstr ""
"Auf <a href=\"%(services_url)s\">Dienste</a> erhälst du einen Überblick "
"über deine aktuellen Berechtigungen."
#: uffd/templates/selfservice/self.html:185
#: uffd/templates/selfservice/self.html:234
msgid "Administrators and role moderators can invite you to new roles."
msgstr ""
"Accounts mit Adminrechten oder Rollen-Moderationsrechten können dich zu "
"Rollen einladen."
#: uffd/templates/selfservice/self.html:200
#: uffd/templates/selfservice/self.html:249
msgid ""
"Some permissions in this role require you to setup two-factor "
"authentication"
......@@ -1191,11 +1233,11 @@ msgstr ""
"Einige Berechtigungen dieser Rolle erfordern das Einrichten von Zwei-"
"Faktor-Authentifikation"
#: uffd/templates/selfservice/self.html:206
#: uffd/templates/selfservice/self.html:255
msgid "Leave"
msgstr "Verlassen"
#: uffd/templates/selfservice/self.html:213
#: uffd/templates/selfservice/self.html:262
msgid "You currently don't have any roles"
msgstr "Du hast derzeit keine Rollen"
......@@ -1231,7 +1273,7 @@ msgstr "Zugriff auf Mail-Weiterleitungen"
msgid "Resolve remailer addresses"
msgstr "Auflösen von Remailer-Adressen"
#: uffd/templates/service/api.html:51 uffd/templates/service/show.html:48
#: 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"
......@@ -1239,7 +1281,7 @@ msgstr "Diese Option hat keine Auswirkung: Remailer ist nicht konfiguriert"
msgid "Access uffd metrics"
msgstr "Zugriff auf uffd-Metriken"
#: uffd/templates/service/oauth2.html:20 uffd/templates/service/show.html:105
#: uffd/templates/service/oauth2.html:20 uffd/templates/service/show.html:112
msgid "Client ID"
msgstr "Client-ID"
......@@ -1286,8 +1328,8 @@ msgstr "Kein Zugriff"
msgid "Manage OAuth2 and API clients"
msgstr "OAuth2- und API-Clients verwalten"
#: uffd/templates/service/overview.html:95 uffd/templates/user/list.html:58
#: uffd/templates/user/list.html:79
#: uffd/templates/service/overview.html:95 uffd/templates/user/list.html:61
#: uffd/templates/user/list.html:82
msgid "Close"
msgstr "Schließen"
......@@ -1309,6 +1351,10 @@ 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 ""
"Allow users with access to select a different e-mail address for this "
"service"
......@@ -1316,29 +1362,29 @@ msgstr ""
"Ermögliche Nutzern mit Zugriff auf diesen Dienst eine andere E-Mail-"
"Adresse auszuwählen"
#: uffd/templates/service/show.html:39
#: uffd/templates/service/show.html:46
msgid "If disabled, the service always uses the primary e-mail address."
msgstr "Wenn deaktiviert, wird immer die primäre E-Mail-Adresse verwendet."
#: uffd/templates/service/show.html:46
#: uffd/templates/service/show.html:53
msgid "Hide e-mail addresses with remailer"
msgstr "E-Mail-Adressen mit Remailer verstecken"
#: uffd/templates/service/show.html:53 uffd/templates/service/show.html:76
#: uffd/templates/service/show.html:60 uffd/templates/service/show.html:83
msgid "Remailer disabled"
msgstr "Remailer deaktiviert"
#: uffd/templates/service/show.html:56 uffd/templates/service/show.html:79
#: uffd/templates/service/show.html:63 uffd/templates/service/show.html:86
msgid "Remailer enabled"
msgstr "Remailer aktiviert"
#: uffd/templates/service/show.html:59 uffd/templates/service/show.html:82
#: uffd/templates/service/show.html:66 uffd/templates/service/show.html:89
msgid "Remailer enabled (deprecated, case-sensitive format)"
msgstr ""
"Remailer aktiviert (veraltetes, Groß-/Kleinschreibung-unterscheidendes "
"Format)"
#: uffd/templates/service/show.html:63
#: uffd/templates/service/show.html:70
msgid ""
"Some services notify users about changes to their e-mail address. "
"Modifying this setting immediatly affects the e-mail addresses of all "
......@@ -1349,15 +1395,15 @@ msgstr ""
"-Mail-Adressen aller Nutzer aus und kann zu massenhaftem Versand von "
"Benachrichtigungs-E-Mails führen."
#: uffd/templates/service/show.html:69
#: uffd/templates/service/show.html:76
msgid "Overwrite remailer setting for specific users"
msgstr "Überschreibe Remailer-Einstellung für ausgewählte Nutzer"
#: uffd/templates/service/show.html:72
#: uffd/templates/service/show.html:79
msgid "Login names"
msgstr "Anmeldenamen"
#: uffd/templates/service/show.html:87
#: 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."
......@@ -1467,7 +1513,7 @@ msgstr "Überprüfen"
msgid "At least one and at most 128 characters, no other special requirements."
msgstr "Mindestens 1 und maximal 128 Zeichen, keine weiteren Einschränkungen."
#: uffd/templates/signup/start.html:29 uffd/templates/user/show.html:90
#: uffd/templates/signup/start.html:29 uffd/templates/user/show.html:102
msgid "E-Mail Address"
msgstr "E-Mail-Adresse"
......@@ -1527,15 +1573,19 @@ msgstr "CSV-Import"
msgid "UID"
msgstr "UID"
#: uffd/templates/user/list.html:34 uffd/templates/user/show.html:48
#: uffd/templates/user/list.html:34 uffd/templates/user/show.html:60
msgid "service"
msgstr "service"
#: uffd/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"
msgstr "Importiere eine als CSV formatierte Liste von Accounts"
#: uffd/templates/user/list.html:64
#: uffd/templates/user/list.html:67
msgid ""
"The format should be \"loginname,mailaddres,roleid1;roleid2\". Neither "
"setting the display name nor setting passwords is supported (yet). "
......@@ -1545,11 +1595,11 @@ msgstr ""
"Anzeigename oder das Password können (derzeit) nicht gesetzt werden. "
"Beispiel:"
#: uffd/templates/user/list.html:75 uffd/templates/user/show.html:76
#: uffd/templates/user/list.html:78 uffd/templates/user/show.html:88
msgid "Ignore login name blocklist"
msgstr "Liste der nicht erlaubten Anmeldenamen ignorieren"
#: uffd/templates/user/list.html:80
#: uffd/templates/user/list.html:83
msgid "Import"
msgstr "Importieren"
......@@ -1557,19 +1607,38 @@ msgstr "Importieren"
msgid "New address"
msgstr "Neue Adresse"
#: uffd/templates/user/show.html:46
#: 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/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:54
#: uffd/templates/user/show.html:66
msgid "will be choosen"
msgstr "wird automatisch bestimmt"
#: uffd/templates/user/show.html:61
#: uffd/templates/user/show.html:73
msgid "Service User"
msgstr "Service-Account"
#: uffd/templates/user/show.html:69
#: uffd/templates/user/show.html:81
msgid ""
"Only letters, numbers, dashes (\"-\") and underscores (\"_\") are "
"allowed. At most 32, at least 2 characters. There is a word blocklist. "
......@@ -1579,7 +1648,7 @@ msgstr ""
"erlaubt. Maximal 32, mindestens 2 Zeichen. Es gibt eine Liste nicht "
"erlaubter Namen. Muss einmalig sein."
#: uffd/templates/user/show.html:84
#: uffd/templates/user/show.html:96
msgid ""
"If you leave this empty it will be set to the login name. At most 128, at"
" least 2 characters. No character restrictions."
......@@ -1587,7 +1656,7 @@ msgstr ""
"Wenn das Feld leer bleibt, wird der Anmeldename verwendet. Maximal 128, "
"mindestens 2 Zeichen. Keine Zeichenbeschränkung."
#: uffd/templates/user/show.html:93
#: uffd/templates/user/show.html:105
msgid ""
"Make sure the address is correct! Services might use e-mail addresses as "
"account identifiers and rely on them being unique and verified."
......@@ -1596,15 +1665,15 @@ msgstr ""
" E-Mail-Adresse um Accounts zu identifizieren und verlassen sich darauf, "
"dass diese verifiziert und einzigartig sind."
#: uffd/templates/user/show.html:102
#: uffd/templates/user/show.html:114
msgid "Address"
msgstr "Adresse"
#: uffd/templates/user/show.html:103
#: uffd/templates/user/show.html:115
msgid "Verified"
msgstr "Verifiziert"
#: uffd/templates/user/show.html:129
#: uffd/templates/user/show.html:141
msgid ""
"Make sure that addresses you add are correct! Services might use e-mail "
"addresses as account identifiers and rely on them being unique and "
......@@ -1614,36 +1683,49 @@ msgstr ""
"Dienste verwenden die E-Mail-Adresse um Accounts zu identifizieren und "
"verlassen sich darauf, dass diese verifiziert und einzigartig sind."
#: uffd/templates/user/show.html:133
#: uffd/templates/user/show.html:145
msgid "Primary E-Mail Address"
msgstr "Primäre E-Mail-Adresse"
#: uffd/templates/user/show.html:141
#: uffd/templates/user/show.html:153
msgid "Recovery E-Mail Address"
msgstr "Wiederherstellungs-E-Mail-Adresse"
#: uffd/templates/user/show.html:151
#: uffd/templates/user/show.html:163
#, python-format
msgid "Address for %(name)s"
msgstr "Adresse für %(name)s"
#: uffd/templates/user/show.html:167
#: uffd/templates/user/show.html:179
msgid "E-Mail to set it will be sent"
msgstr "Mail zum Setzen wird versendet"
#: uffd/templates/user/show.html:178
#: uffd/templates/user/show.html:190
msgid "Status:"
msgstr "Status:"
#: uffd/templates/user/show.html:178
#: uffd/templates/user/show.html:190
msgid "Enabled"
msgstr "Aktiv"
#: uffd/templates/user/show.html:181
#: uffd/templates/user/show.html:193
msgid "Reset 2FA"
msgstr "2FA zurücksetzen"
#: uffd/templates/user/show.html:221
#: 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)"
msgstr "Resultierende Gruppen (wird nur aktualisiert beim Speichern)"
......@@ -1764,16 +1846,16 @@ msgstr ""
"2FA WebAuthn Unterstützung deaktiviert, da das fido2 Modul nicht geladen "
"werden konnte (%s)"
#: uffd/views/mfa.py:214
#: 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:228
#: 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:231
#: uffd/views/mfa.py:234
msgid ""
"You only have a few recovery codes remaining. Make sure to generate new "
"ones before they run out."
......@@ -1781,12 +1863,12 @@ msgstr ""
"Du hast nur noch wenige Wiederherstellungscodes übrig. Bitte generiere "
"diese erneut bevor keine mehr übrig sind."
#: uffd/views/mfa.py:235
#: uffd/views/mfa.py:238
msgid "Two-factor authentication failed"
msgstr "Zwei-Faktor-Authentifizierung fehlgeschlagen"
#: uffd/views/oauth2.py:167 uffd/views/selfservice.py:66
#: uffd/views/session.py:72
#: 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 "
......@@ -1795,19 +1877,19 @@ msgstr ""
"Wir haben zu viele Anfragen von deiner IP-Adresses bzw. aus deinem "
"Netzwerk empfangen! Bitte warte mindestens %(delay)s."
#: uffd/views/oauth2.py:175
#: 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:188
#: uffd/views/oauth2.py:296
msgid "Device login failed"
msgstr "Gerätelogin fehlgeschlagen"
#: uffd/views/oauth2.py:194
#: 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:201
#: uffd/views/oauth2.py:335
#, python-format
msgid ""
"You don't have the permission to access the service "
......@@ -1891,7 +1973,7 @@ 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:227
#: 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!"
......@@ -1924,7 +2006,11 @@ msgstr "E-Mail-Adresse gelöscht"
msgid "E-Mail preferences updated"
msgstr "E-Mail-Einstellungen geändert"
#: uffd/views/selfservice.py:209
#: 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"
......@@ -1933,7 +2019,7 @@ msgstr "Rolle %(role_name)s verlassen"
msgid "Services"
msgstr "Dienste"
#: uffd/views/session.py:70
#: uffd/views/session.py:84
#, python-format
msgid ""
"We received too many invalid login attempts for this user! Please wait at"
......@@ -1942,27 +2028,34 @@ msgstr ""
"Wir haben zu viele fehlgeschlagene Anmeldeversuche für diesen Account "
"erhalten! Bitte warte mindestens %(delay)s."
#: uffd/views/session.py:78
#: uffd/views/session.py:93
msgid "Login name or password is wrong"
msgstr "Der Anmeldename oder das Passwort ist falsch"
#: uffd/views/session.py:84
#: 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:96 uffd/views/session.py:107
#: 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:128 uffd/views/session.py:138
#: 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:142
#: uffd/views/session.py:160
msgid "Invalid confirmation code"
msgstr "Ungültiger Bestätigungscode"
#: uffd/views/session.py:154 uffd/views/session.py:165
#: uffd/views/session.py:172 uffd/views/session.py:183
msgid "Invalid initiation code"
msgstr "Ungültiger Startcode"
......@@ -2019,7 +2112,19 @@ msgstr "Passwort ist ungültig"
msgid "User updated"
msgstr "Account aktualisiert"
#: uffd/views/user.py:156
#: 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"
......@@ -3,7 +3,7 @@ from werkzeug.exceptions import Forbidden
from uffd.secure_redirect import secure_local_redirect
from . import session, selfservice, signup, mfa, oauth2, user, group, service, role, invite, api, mail, rolemod
from . import session, selfservice, signup, oauth2, user, group, service, role, invite, api, mail, rolemod
def init_app(app):
@app.errorhandler(403)
......@@ -26,7 +26,6 @@ def init_app(app):
app.register_blueprint(session.bp)
app.register_blueprint(selfservice.bp)
app.register_blueprint(signup.bp)
app.register_blueprint(mfa.bp)
app.register_blueprint(oauth2.bp)
app.register_blueprint(user.bp)
app.register_blueprint(group.bp)
......
......@@ -38,7 +38,11 @@ def generate_group_dict(group):
return {
'id': group.unix_gid,
'name': group.name,
'members': [user.loginname for user in group.members]
'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'])
......@@ -57,6 +61,8 @@ def getgroups():
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
......@@ -81,6 +87,8 @@ def getusers():
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:
......@@ -117,6 +125,8 @@ def checkpassword():
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()
......@@ -170,7 +180,7 @@ def prometheus_metrics():
from prometheus_client import PLATFORM_COLLECTOR, generate_latest, CONTENT_TYPE_LATEST #pylint: disable=import-outside-toplevel
class UffdCollector():
def collect(self): #pylint: disable=no-self-use
def collect(self):
try:
uffd_version = str(pkg_resources.get_distribution('uffd').version)
except pkg_resources.DistributionNotFound:
......
from warnings import warn
import urllib.parse
from flask import Blueprint, render_template, session, request, redirect, url_for, flash, current_app, abort
from flask_babel import gettext as _
from uffd.csrf import csrf_protect
from uffd.secure_redirect import secure_local_redirect
from uffd.database import db
from uffd.models import MFAMethod, TOTPMethod, WebauthnMethod, RecoveryCodeMethod, User, Ratelimit, format_delay
from .session import login_required, login_required_pre_mfa, set_request_user
bp = Blueprint('mfa', __name__, template_folder='templates', url_prefix='/mfa/')
mfa_ratelimit = Ratelimit('mfa', 1*60, 3)
@bp.route('/', methods=['GET'])
@login_required()
def setup():
return render_template('mfa/setup.html')
@bp.route('/setup/disable', methods=['GET'])
@login_required()
def disable():
return render_template('mfa/disable.html')
@bp.route('/setup/disable', methods=['POST'])
@login_required()
@csrf_protect(blueprint=bp)
def disable_confirm():
MFAMethod.query.filter_by(user=request.user).delete()
db.session.commit()
request.user.update_groups()
db.session.commit()
return redirect(url_for('mfa.setup'))
@bp.route('/admin/<int:id>/disable')
@login_required()
@csrf_protect(blueprint=bp)
def admin_disable(id):
# Group cannot be checked with login_required kwarg, because the config
# variable is not available when the decorator is processed
if not request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP']):
abort(403)
user = User.query.get(id)
MFAMethod.query.filter_by(user=user).delete()
user.update_groups()
db.session.commit()
flash(_('Two-factor authentication was reset'))
return redirect(url_for('user.show', id=id))
@bp.route('/setup/recovery', methods=['POST'])
@login_required()
@csrf_protect(blueprint=bp)
def setup_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('mfa/setup_recovery.html', methods=methods)
@bp.route('/setup/totp', methods=['GET'])
@login_required()
def setup_totp():
method = TOTPMethod(request.user)
session['mfa_totp_key'] = method.key
return render_template('mfa/setup_totp.html', method=method, name=request.values['name'])
@bp.route('/setup/totp', methods=['POST'])
@login_required()
@csrf_protect(blueprint=bp)
def setup_totp_finish():
if not RecoveryCodeMethod.query.filter_by(user=request.user).all():
flash(_('Generate recovery codes first!'))
return redirect(url_for('mfa.setup'))
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('mfa.setup'))
flash(_('Code is invalid'))
return redirect(url_for('mfa.setup_totp', name=request.values['name']))
@bp.route('/setup/totp/<int:id>/delete')
@login_required()
@csrf_protect(blueprint=bp)
def delete_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('mfa.setup'))
# WebAuthn support is optional because fido2 has a pretty unstable
# interface and might be difficult to install with the correct version
try:
from uffd.fido2_compat import * # pylint: disable=wildcard-import,unused-wildcard-import
WEBAUTHN_SUPPORTED = True
except ImportError as err:
warn(_('2FA WebAuthn support disabled because import of the fido2 module failed (%s)')%err)
WEBAUTHN_SUPPORTED = False
bp.add_app_template_global(WEBAUTHN_SUPPORTED, name='webauthn_supported')
if WEBAUTHN_SUPPORTED:
def get_webauthn_server():
hostname = urllib.parse.urlsplit(request.url).hostname
return Fido2Server(PublicKeyCredentialRpEntity(id=current_app.config.get('MFA_RP_ID', hostname),
name=current_app.config['MFA_RP_NAME']))
@bp.route('/setup/webauthn/begin', methods=['POST'])
@login_required()
@csrf_protect(blueprint=bp)
def setup_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('/setup/webauthn/complete', methods=['POST'])
@login_required()
@csrf_protect(blueprint=bp)
def setup_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("/auth/webauthn/begin", methods=["POST"])
@login_required_pre_mfa(no_redirect=True)
def 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("/auth/webauthn/complete", methods=["POST"])
@login_required_pre_mfa(no_redirect=True)
def 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,
)
session['user_mfa'] = True
set_request_user()
return cbor.encode({"status": "OK"})
@bp.route('/setup/webauthn/<int:id>/delete')
@login_required()
@csrf_protect(blueprint=bp)
def delete_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('mfa.setup'))
@bp.route('/auth', methods=['GET'])
@login_required_pre_mfa()
def auth():
if not request.user_pre_mfa.mfa_enabled:
session['user_mfa'] = True
set_request_user()
if session.get('user_mfa'):
return secure_local_redirect(request.values.get('ref', url_for('index')))
return render_template('mfa/auth.html', ref=request.values.get('ref'))
@bp.route('/auth', methods=['POST'])
@login_required_pre_mfa()
def 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('mfa.auth', ref=request.values.get('ref')))
for method in request.user_pre_mfa.mfa_totp_methods:
if method.verify(request.form['code']):
session['user_mfa'] = True
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)
db.session.commit()
session['user_mfa'] = True
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('mfa.setup'))
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('mfa.setup'))
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('mfa.auth', ref=request.values.get('ref')))
import functools
import secrets
import urllib.parse
import time
import json
from flask import Blueprint, request, jsonify, render_template, session, redirect, url_for, flash, abort
import oauthlib.oauth2
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, ServiceUser
class UffdRequestValidator(oauthlib.oauth2.RequestValidator):
# Argument "oauthreq" is named "request" in superclass but this clashes with flask's "request" object
# Arguments "token_value" and "token_data" are named "token" in superclass but this clashs with "token" endpoint
# pylint: disable=arguments-differ,arguments-renamed,unused-argument,too-many-public-methods,abstract-method
# In all cases (aside from validate_bearer_token), either validate_client_id or authenticate_client is called
# before anything else. authenticate_client_id would be called instead of authenticate_client for non-confidential
# clients. However, we don't support those.
def validate_client_id(self, client_id, oauthreq, *args, **kwargs):
oauthreq.client = OAuth2Client.query.filter_by(client_id=client_id).one_or_none()
return oauthreq.client is not None
def authenticate_client(self, oauthreq, *args, **kwargs):
authorization = oauthreq.extra_credentials.get('authorization')
if authorization:
# From RFC6749 2.3.1:
# Clients in possession of a client password MAY use the HTTP Basic authentication
# scheme as defined in [RFC2617] to authenticate with the authorization server.
# The client identifier is encoded using the "application/x-www-form-urlencoded"
# encoding algorithm per Appendix B, and the encoded value is used as the username
# the client password is encoded using the same algorithm and used as the password.
oauthreq.client_id = urllib.parse.unquote(authorization.username)
oauthreq.client_secret = urllib.parse.unquote(authorization.password)
if oauthreq.client_secret is None:
return False
oauthreq.client = OAuth2Client.query.filter_by(client_id=oauthreq.client_id).one_or_none()
if oauthreq.client is None:
return False
if not oauthreq.client.client_secret.verify(oauthreq.client_secret):
return False
if oauthreq.client.client_secret.needs_rehash:
oauthreq.client.client_secret = oauthreq.client_secret
db.session.commit()
return True
from uffd.models import (
DeviceLoginConfirmation, OAuth2Client, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation,
host_ratelimit, format_delay, OAuth2Key,
)
def get_default_redirect_uri(self, client_id, oauthreq, *args, **kwargs):
return oauthreq.client.default_redirect_uri
def get_issuer():
return request.host_url.rstrip('/')
def validate_redirect_uri(self, client_id, redirect_uri, oauthreq, *args, **kwargs):
return redirect_uri in oauthreq.client.redirect_uris
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,
},
}
def validate_response_type(self, client_id, response_type, client, oauthreq, *args, **kwargs):
return response_type == 'code'
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 get_default_scopes(self, client_id, oauthreq, *args, **kwargs):
return oauthreq.client.default_scopes
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
def validate_scopes(self, client_id, scopes, client, oauthreq, *args, **kwargs):
if scopes == ['']:
oauthreq.scopes = scopes = self.get_default_scopes(client_id, oauthreq)
return set(scopes).issubset({'profile'})
bp = Blueprint('oauth2', __name__, template_folder='templates')
def save_authorization_code(self, client_id, code, oauthreq, *args, **kwargs):
grant = OAuth2Grant(user=oauthreq.user, client=oauthreq.client, code=code['code'],
redirect_uri=oauthreq.redirect_uri, scopes=oauthreq.scopes)
db.session.add(grant)
db.session.commit()
# Oauthlib does not really provide a way to customize grant code generation.
# Actually `code` is created just before `save_authorization_code` is called
# and the same dict is later used to generate the OAuth2 response. So by
# modifing the `code` dict we can actually influence the grant code.
code['code'] = f"{grant.id}-{code['code']}"
def validate_code(self, client_id, code, client, oauthreq, *args, **kwargs):
if '-' not in code:
return False
grant_id, grant_code = code.split('-', 2)
oauthreq.grant = OAuth2Grant.query.get(grant_id)
if not oauthreq.grant or oauthreq.grant.client != client:
return False
if not secrets.compare_digest(oauthreq.grant.code, grant_code):
return False
if oauthreq.grant.expired:
return False
oauthreq.user = oauthreq.grant.user
oauthreq.scopes = oauthreq.grant.scopes
return True
def invalidate_authorization_code(self, client_id, code, oauthreq, *args, **kwargs):
OAuth2Grant.query.filter_by(client=oauthreq.client, code=code).delete()
db.session.commit()
@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'
def save_bearer_token(self, token_data, oauthreq, *args, **kwargs):
tok = OAuth2Token(
user=oauthreq.user,
client=oauthreq.client,
token_type=token_data['token_type'],
access_token=token_data['access_token'],
refresh_token=token_data['refresh_token'],
expires_in_seconds=token_data['expires_in'],
scopes=oauthreq.scopes
# 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,
)
db.session.add(tok)
db.session.commit()
# Oauthlib does not really provide a way to customize access/refresh token
# generation. Actually `token_data` is created just before
# `save_bearer_token` is called and the same dict is later used to generate
# the OAuth2 response. So by modifing the `token_data` dict we can actually
# influence the tokens.
token_data['access_token'] = f"{tok.id}-{token_data['access_token']}"
token_data['refresh_token'] = f"{tok.id}-{token_data['refresh_token']}"
return oauthreq.client.default_redirect_uri
def validate_grant_type(self, client_id, grant_type, client, oauthreq, *args, **kwargs):
return grant_type == 'authorization_code'
def confirm_redirect_uri(self, client_id, code, redirect_uri, client, oauthreq, *args, **kwargs):
return redirect_uri == oauthreq.grant.redirect_uri
def validate_bearer_token(self, token_value, scopes, oauthreq):
if '-' not in token_value:
return False
tok_id, tok_secret = token_value.split('-', 2)
tok = OAuth2Token.query.get(tok_id)
if not tok or not secrets.compare_digest(tok.access_token, tok_secret):
return False
if tok.expired:
oauthreq.error_message = 'Token expired'
return False
if not set(scopes).issubset(tok.scopes):
oauthreq.error_message = 'Scopes invalid'
return False
oauthreq.access_token = tok
oauthreq.user = tok.user
oauthreq.scopes = scopes
oauthreq.client = tok.client
oauthreq.client_id = oauthreq.client.client_id
return True
# get_original_scopes/validate_refresh_token are only used for refreshing tokens. We don't implement the refresh endpoint.
# revoke_token is only used for revoking access tokens. We don't implement the revoke endpoint.
# get_id_token/validate_silent_authorization/validate_silent_login are OpenID Connect specfic.
# validate_user/validate_user_match are not required for Authorization Code Grant flow.
validator = UffdRequestValidator()
server = oauthlib.oauth2.WebApplicationServer(validator)
bp = Blueprint('oauth2', __name__, url_prefix='/oauth2/', template_folder='templates')
@bp.errorhandler(oauthlib.oauth2.rfc6749.errors.OAuth2Error)
def handle_oauth2error(error):
return render_template('oauth2/error.html', error=type(error).__name__, error_description=error.description), 400
@bp.route('/authorize', methods=['GET', 'POST'])
def authorize():
scopes, credentials = server.validate_authorization_request(request.url, request.method, request.form, request.headers)
client = OAuth2Client.query.filter_by(client_id=credentials['client_id']).one()
if request.user:
credentials['user'] = request.user
elif 'devicelogin_started' in session:
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:
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('session.login', ref=request.full_path, devicelogin=True))
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:
flash(_('Device login is currently not available. Try again later!'))
return redirect(url_for('session.login', ref=request.values['ref'], devicelogin=True))
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
return redirect(url_for('session.devicelogin', ref=request.full_path))
elif '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()
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:
flash(_('Device login failed'))
return redirect(url_for('session.login', ref=request.full_path, devicelogin=True))
credentials['user'] = confirmation.user
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:
flash(_('You need to login to access this service'))
return redirect(url_for('session.login', ref=request.full_path, devicelogin=True))
# Here we would normally ask the user, if he wants to give the requesting
# service access to his data. Since we only have trusted services (the
# clients defined in the server config), we don't ask for consent.
if not client.access_allowed(credentials['user']):
abort(403, description=_("You don't have the permission to access the service <b>%(service_name)s</b>.", service_name=client.service.name))
session['oauth2-clients'] = session.get('oauth2-clients', [])
if client.client_id not in session['oauth2-clients']:
session['oauth2-clients'].append(client.client_id)
headers, body, status = server.create_authorization_response(request.url, request.method, request.form, request.headers, scopes, credentials)
return body or '', status, headers
@bp.route('/token', methods=['GET', 'POST'])
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():
headers, body, status = server.create_token_response(request.url, request.method, request.form,
request.headers, {'authorization': request.authorization})
return body, status, headers
def oauth_required(*scopes):
def wrapper(func):
@functools.wraps(func)
def decorator(*args, **kwargs):
valid, oauthreq = server.verify_request(request.url, request.method, request.form, request.headers, scopes)
if not valid:
abort(401)
request.oauth = oauthreq
return func(*args, **kwargs)
return decorator
return wrapper
@bp.route('/userinfo')
@oauth_required('profile')
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():
service_user = ServiceUser.query.get((request.oauth.client.service_id, request.oauth.user.id))
return jsonify(
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]
)
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 session.get('oauth2-clients'):
if endpoint != 'oauth2.logout' or not request.session:
return
values['client_ids'] = ','.join(session['oauth2-clients'])
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('/logout')
@bp.route('/oauth2/logout')
def logout():
if not request.values.get('client_ids'):
return secure_local_redirect(request.values.get('ref', '/'))
......