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 2613 additions and 44 deletions
......@@ -2,13 +2,94 @@ import datetime
import secrets
import enum
from sqlalchemy import Column, String, Integer, DateTime, ForeignKey, Enum
from flask import current_app
from sqlalchemy import Column, String, Integer, DateTime, ForeignKey, Enum, Text, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.ext.hybrid import hybrid_property
from flask_babel import gettext as _
try:
from ua_parser import user_agent_parser
USER_AGENT_PARSER_SUPPORTED = True
except ImportError:
USER_AGENT_PARSER_SUPPORTED = False
from uffd.database import db
from uffd.utils import token_typeable
from uffd.tasks import cleanup_task
from uffd.password_hash import PasswordHashAttribute, HighEntropyPasswordHash
@cleanup_task.delete_by_attribute('expired')
class Session(db.Model):
__tablename__ = 'session'
id = Column(Integer(), primary_key=True, autoincrement=True)
_secret = Column('secret', Text)
secret = PasswordHashAttribute('_secret', HighEntropyPasswordHash)
user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False)
user = relationship('User', back_populates='sessions')
oauth2_grants = relationship('OAuth2Grant', back_populates='session', cascade='all, delete-orphan')
oauth2_tokens = relationship('OAuth2Token', back_populates='session', cascade='all, delete-orphan')
created = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
last_used = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
user_agent = Column(Text, nullable=False, default='')
ip_address = Column(Text)
mfa_done = Column(Boolean(create_constraint=True), default=False, nullable=False)
@hybrid_property
def expired(self):
if self.created is None or self.last_used is None:
return False
if self.created < datetime.datetime.utcnow() - datetime.timedelta(seconds=current_app.config['SESSION_LIFETIME_SECONDS']):
return True
if self.last_used < datetime.datetime.utcnow() - current_app.permanent_session_lifetime:
return True
return False
@expired.expression
def expired(cls): # pylint: disable=no-self-argument
return db.or_(
cls.created < datetime.datetime.utcnow() - datetime.timedelta(seconds=current_app.config['SESSION_LIFETIME_SECONDS']),
cls.last_used < datetime.datetime.utcnow() - current_app.permanent_session_lifetime,
)
@property
def user_agent_browser(self):
# pylint: disable=too-many-return-statements
if USER_AGENT_PARSER_SUPPORTED and not getattr(self, 'DISABLE_USER_AGENT_PARSER', False):
family = user_agent_parser.ParseUserAgent(self.user_agent)['family']
return family if family != 'Other' else _('Unknown')
if ' OPR/' in self.user_agent:
return 'Opera'
if ' Edg/' in self.user_agent:
return 'Microsoft Edge'
if ' Safari/' in self.user_agent and ' Chrome/' not in self.user_agent:
return 'Safari'
if ' Chrome/' in self.user_agent:
return 'Chrome'
if ' Firefox/' in self.user_agent:
return 'Firefox'
return _('Unknown')
@property
def user_agent_platform(self):
if USER_AGENT_PARSER_SUPPORTED and not getattr(self, 'DISABLE_USER_AGENT_PARSER', False):
family = user_agent_parser.ParseOS(self.user_agent)['family']
return family if family != 'Other' else _('Unknown')
sysinfo = ([''] + self.user_agent.split('(', 1))[-1].split(')', 0)[0]
platforms = [
'Android', 'Linux', 'OpenBSD', 'FreeBSD', 'NetBSD', 'Windows', 'iPhone',
'iPad', 'Macintosh',
]
for platform in platforms:
if platform in sysinfo:
return platform
return _('Unknown')
# Device login provides a convenient and secure way to log into SSO-enabled
# services on a secondary device without entering the user password or
......@@ -70,12 +151,11 @@ class DeviceLoginInitiation(db.Model):
existing and possibly attacker-controlled code).
An initiation code is securly bound to the session that it was created
with by storing both id and secret in the encrypted and authenticated
session cookie.'''
with by storing both id and secret in the authenticated session cookie.'''
__tablename__ = 'device_login_initiation'
id = Column(Integer(), primary_key=True, autoincrement=True)
type = Column(Enum(DeviceLoginType), nullable=False)
type = Column(Enum(DeviceLoginType, create_constraint=True), nullable=False)
code0 = Column(String(32), unique=True, nullable=False, default=lambda: token_typeable(3))
code1 = Column(String(32), unique=True, nullable=False, default=lambda: token_typeable(3))
secret = Column(String(128), nullable=False, default=lambda: secrets.token_hex(64))
......@@ -107,7 +187,7 @@ class DeviceLoginConfirmation(db.Model):
A confirmation code is generated and displayed when an authenticated user
enters an initiation code and confirms the device login attempt. Every
instance is bound to both an initiation code and a user.
instance is bound to both an initiation code and a login session.
The code attribute is formed out of two indepentently unique parts
to ensure that at any time all existing codes differ in at least two
......@@ -120,8 +200,8 @@ class DeviceLoginConfirmation(db.Model):
name='fk_device_login_confirmation_initiation_id_',
onupdate='CASCADE', ondelete='CASCADE'), nullable=False)
initiation = relationship('DeviceLoginInitiation', back_populates='confirmations')
user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False, unique=True)
user = relationship('User')
session_id = Column(Integer(), ForeignKey('session.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False, unique=True)
session = relationship('Session')
code0 = Column(String(32), nullable=False, default=lambda: token_typeable(1))
code1 = Column(String(32), nullable=False, default=lambda: token_typeable(1))
......
......@@ -167,12 +167,15 @@ class User(db.Model):
_password = Column('pwhash', Text(), nullable=True)
password = PasswordHashAttribute('_password', LowEntropyPasswordHash)
is_service_user = Column(Boolean(), default=False, nullable=False)
is_service_user = Column(Boolean(create_constraint=True), default=False, nullable=False)
is_deactivated = Column(Boolean(create_constraint=True), default=False, nullable=False)
groups = relationship('Group', secondary='user_groups', back_populates='members')
roles = relationship('Role', secondary='role_members', back_populates='members')
service_users = relationship('ServiceUser', viewonly=True)
sessions = relationship('Session', back_populates='user', cascade='all, delete-orphan')
def __init__(self, primary_email_address=None, **kwargs):
super().__init__(**kwargs)
if primary_email_address is not None:
......@@ -278,7 +281,7 @@ class UserEmail(db.Model):
return value
# True or None/NULL (not False, see constraints below)
_verified = Column('verified', Boolean(), nullable=True)
_verified = Column('verified', Boolean(create_constraint=True), nullable=True)
@hybrid_property
def verified(self):
......@@ -301,7 +304,7 @@ class UserEmail(db.Model):
# on a per-row basis.
# True or None/NULL if disabled (not False, see constraints below)
enable_strict_constraints = Column(
Boolean(),
Boolean(create_constraint=True),
nullable=True,
default=db.select([db.case([(FeatureFlag.unique_email_addresses.expr, True)], else_=None)])
)
......
import secrets
import hashlib
import base64
from crypt import crypt
from crypt import crypt # pylint: disable=deprecated-module
import argon2
def build_value(method_name, data):
......@@ -206,7 +206,7 @@ class InvalidPasswordHash:
def __init__(self, value=None):
self.value = value
# pylint: disable=no-self-use,unused-argument
# pylint: disable=unused-argument
def verify(self, password):
return False
......
......@@ -14,8 +14,6 @@ class Remailer:
Version 2 of the remailer address format is tolerant to case conversions at
the cost of being slightly longer.'''
# pylint: disable=no-self-use
@property
def configured(self):
return bool(current_app.config['REMAILER_DOMAIN'])
......
This diff is collapsed.
.footer {
position: fixed;
bottom: 0;
width: 100%;
height: 2em;
line-height: 2em;
background-color: #f5f5f5;
}
.narrow-card {
background: #f7f7f7;
box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3);
padding: 30px;
}
.qrcode {
background: #ffffff;
}
@media (prefers-color-scheme: dark) {
.footer {
background-color: #23282d;
}
.narrow-card {
background: #292e33;
}
a {
color: #3395ff;
background-color: transparent
}
a:hover {
color: #0d82ff
}
.btn-link {
color: #3395ff;
}
.btn-link:hover {
color: #0d82ff;
}
.btn-link.disabled,
.btn-link:disabled {
color: #3395ff;
}
/* Dark theme breaks spinners (appears as full ring without animation) */
.spinner-border {
display: inline-block;
width: 2rem;
height: 2rem;
vertical-align: text-bottom;
border: 0.25em solid currentColor;
border-right-color: transparent;
border-radius: 50%;
-webkit-animation: spinner-border .75s linear infinite;
animation: spinner-border .75s linear infinite;
}
.spinner-border-sm {
width: 1rem;
height: 1rem;
border-width: 0.2em;
}
}
.footer {
position: fixed;
bottom: 0;
width: 100%;
height: 2em;
line-height: 2em;
background-color: #f5f5f5;
}
......@@ -15,13 +15,18 @@ def register_template_helper(app):
@app.template_filter()
def qrcode_svg(content, **attrs): #pylint: disable=unused-variable
img = qrcode.make(content, image_factory=qrcode.image.svg.SvgPathImage, border=0)
img = qrcode.make(content, image_factory=qrcode.image.svg.SvgPathImage, border=4)
svg = img.get_image()
for key, value, in attrs.items():
svg.set(key, value)
buf = io.BytesIO()
img.save(buf)
return Markup(buf.getvalue().decode().replace('<?xml version=\'1.0\' encoding=\'UTF-8\'?>\n', '').replace(' id="qr-path" ', ' '))
return Markup(
buf.getvalue().decode()
.replace('<?xml version=\'1.0\' encoding=\'UTF-8\'?>\n', '')
.replace(' id="qr-path" ', ' ')
.replace('<svg ', '<svg class="qrcode" ')
)
@app.template_filter()
def datauri(data, mimetype='text/plain'): #pylint: disable=unused-variable
......
......@@ -2,7 +2,7 @@
{% block body %}
<div class="row mt-2 justify-content-center">
<div class="col-lg-6 col-md-10" style="background: #f7f7f7; box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3); padding: 30px;">
<div class="col-lg-6 col-md-10 narrow-card">
<div class="text-center">
<img alt="branding logo" src="{{ config.get("BRANDING_LOGO_URL") }}" class="col-lg-8 col-md-12" >
</div>
......
......@@ -7,8 +7,9 @@
<title>{% block title %}{{ config['SITE_TITLE'] }}{% endblock %}</title>
<link href="{{ url_for('static', filename="bootstrap/bootstrap.min.css") }}" rel="stylesheet">
<link href="{{ url_for('static', filename="bootstrap/bootstrap-prefers-dark-color-only.min.css") }}" rel="stylesheet">
<link href="{{ url_for('static', filename="fa/css/all.css") }}" rel="stylesheet">
<link href="{{ url_for('static', filename="style.css") }}" rel="stylesheet">
<link href="{{ url_for('static', filename="style-1.css") }}" rel="stylesheet">
<script src="{{ url_for('static', filename="jquery/jquery-3.4.1.min.js") }}"></script>
<script src="{{ url_for('static', filename="popper/popper-1.16.0.min.js") }}"></script>
<script src="{{ url_for('static', filename="bootstrap/bootstrap.min.js") }}"></script>
......
......@@ -14,7 +14,7 @@
</div>
</div>
<div class="row justify-content-center">
<div class="col-lg-6 col-md-10" style="background: #f7f7f7; box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3); padding: 30px;">
<div class="col-lg-6 col-md-10 narrow-card">
<div class="text-center">
<img alt="branding logo" src="{{ config.get("BRANDING_LOGO_URL") }}" class="col-lg-8 col-md-12" >
</div>
......
{% extends 'base.html' %}
{% block body %}
<form action="{{ url_for("group.update", id=group.id) }}" method="POST">
<form action="{{ url_for("group.update", id=group.id) }}" method="POST" autocomplete="off">
<div class="align-self-center">
<div class="clearfix pb-2 col">
<div class="float-sm-right">
......
{% extends 'base.html' %}
{% block body %}
<form action="{{ url_for("invite.new_submit") }}" method="POST" class="form">
<form action="{{ url_for("invite.new_submit") }}" method="POST" autocomplete="off" class="form">
<div class="form-group">
<label for="single-use">{{_('Link Type')}}</label>
<select class="form-control" id="single-use" name="single-use">
......
{% extends 'base.html' %}
{% block body %}
<form action="{{ url_for("mail.update", mail_id=mail.id) }}" method="POST">
<form action="{{ url_for("mail.update", mail_id=mail.id) }}" method="POST" autocomplete="off">
<div class="align-self-center">
<div class="form-group col">
<label for="mail-name">{{_('Name')}}</label>
......
......@@ -7,9 +7,9 @@
</div>
{% endif %}
<form action="{{ url_for("role.update", roleid=role.id) }}" method="POST">
<form action="{{ url_for("role.update", roleid=role.id) }}" method="POST" autocomplete="off">
<div class="align-self-center">
<div class="float-sm-right pb-2">
<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("role.index") }}" class="btn btn-secondary">{{_("Cancel")}}</a>
{% if role.id %}
......@@ -23,7 +23,7 @@
<a href="#" class="btn btn-secondary disabled">{{_("Set as default")}}</a>
<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="settings-tab" data-toggle="tab" href="#settings" role="tab" aria-controls="settings" aria-selected="true">{{_("Settings")}}</a>
......
......@@ -7,7 +7,7 @@
You can later generate new recovery codes and setup your applications and devices again.")}}
</p>
<form class="form" action="{{ url_for('mfa.disable_confirm') }}" method="POST">
<form class="form" action="{{ url_for('selfservice.disable_mfa_confirm') }}" method="POST">
<button type="submit" class="btn btn-danger btn-block">{{_("Disable two-factor authentication")}}</button>
</form>
......
......@@ -7,14 +7,14 @@
</div>
<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">
<input type="text" autocomplete="username" class="form-control" id="user-loginname" name="loginname" required="required" tabindex="1">
</div>
<div class="form-group col-12">
<label for="user-mail">{{_("Mail Address")}}</label>
<input type="text" class="form-control" id="user-mail" name="mail" required="required" tabindex = "2">
<input type="email" autocomplete="email" class="form-control" id="user-mail" name="mail" required="required" tabindex="2">
</div>
<div class="form-group col-12">
<button type="submit" class="btn btn-primary btn-block" tabindex = "3">{{_("Send password reset mail")}}</button>
<button type="submit" class="btn btn-primary btn-block" tabindex="3">{{_("Send password reset mail")}}</button>
</div>
</form>
{% endblock %}
......@@ -41,7 +41,7 @@
<form method="POST" action="{{ url_for('selfservice.add_email') }}" class="form mb-2">
<div class="row m-0">
<label class="sr-only" for="new-email-address">{{_("Email")}}</label>
<input type="text" class="form-control mb-2 col-12 col-lg-auto mr-2" style="width: 20em;" id="new-email-address" name="address" placeholder="{{_("New E-Mail Address")}}" required>
<input type="email" autocomplete="email" class="form-control mb-2 col-12 col-lg-auto mr-2" style="width: 20em;" id="new-email-address" name="address" placeholder="{{_("New E-Mail Address")}}" required>
<button type="submit" class="btn btn-primary mb-2 col">{{_("Add address")}}</button>
</div>
</form>
......@@ -139,13 +139,13 @@
<div class="col-12 col-md-7">
<form class="form" action="{{ url_for("selfservice.change_password") }}" method="POST">
<div class="form-group">
<input type="password" class="form-control" id="user-password1" name="password1" placeholder="{{_("New Password")}}" 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" placeholder="{{_("New Password")}}" 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">
<input type="password" class="form-control" id="user-password2" name="password2" placeholder="{{_("Repeat Password")}}" required>
<input type="password" autocomplete="new-password" class="form-control" id="user-password2" name="password2" placeholder="{{_("Repeat Password")}}" required>
</div>
<button type="submit" class="btn btn-primary btn-block">{{_("Change Password")}}</button>
</form>
......@@ -167,7 +167,56 @@
{{ _("Two-factor authentication is currently <strong>disabled</strong>.")|safe }}
{% endif %}
</p>
<a class="btn btn-primary btn-block" href="{{ url_for('mfa.setup') }}">{{_("Manage two-factor authentication")}}</a>
<a class="btn btn-primary btn-block" href="{{ url_for('selfservice.setup_mfa') }}">{{_("Manage two-factor authentication")}}</a>
</div>
</div>
<hr>
<div class="row mt-3">
<div class="col-12 col-md-5">
<h5>{{_("Active Sessions")}}</h5>
<p>{{_("Your active login sessions on this device and other devices.")}}</p>
<p>{{_("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>")}}</p>
</div>
<div class="col-12 col-md-7">
<table class="table">
<thead>
<tr>
<th scope="col">{{_("Last used")}}</th>
<th scope="col">{{_("Device")}}</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr>
<td>{{_("Just now")}}</td>
<td>{{ request.session.user_agent_browser }} on {{ request.session.user_agent_platform }} ({{ request.session.ip_address }})</td>
<td></td>
</tr>
{% for session in user.sessions|sort(attribute='last_used', reverse=True) if not session.expired and session != request.session %}
<tr>
<td>
{% set last_used_rel = session.last_used - datetime.utcnow() %}
{% if -last_used_rel.total_seconds() <= 60 %}
{{_("Just now")}}
{% else %}
{{ last_used_rel|timedeltaformat(add_direction=True, granularity='minute') }}
{% endif %}
</td>
<td>{{ session.user_agent_browser }} on {{ session.user_agent_platform }} ({{ session.ip_address }})</td>
<td>
{% if session != request.session %}
<form action="{{ url_for("selfservice.revoke_session", session_id=session.id) }}" method="POST" onsubmit='return confirm({{_("Are you sure?")|tojson|e}});'>
<button type="submit" class="btn btn-sm btn-danger float-right">{{_("Revoke")}}</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
......
......@@ -7,17 +7,17 @@
</div>
<div class="form-group col-12">
<label for="user-password1">{{_("New Password")}}</label>
<input type="password" class="form-control" id="user-password1" name="password1" tabindex="2" 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" tabindex="2" 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" tabindex="3" required>
<input type="password" autocomplete="new-password" class="form-control" id="user-password2" name="password2" tabindex="3" required>
</div>
<div class="form-group col-12">
<button type="submit" class="btn btn-primary btn-block" tabindex = "3">{{_("Set password")}}</button>
<button type="submit" class="btn btn-primary btn-block" tabindex="3">{{_("Set password")}}</button>
</div>
</form>
{% endblock %}