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

Target

Select target project
  • uffd/uffd
  • rixx/uffd
  • thies/uffd
  • leona/uffd
  • enbewe/uffd
  • strifel/uffd
  • thies/uffd-2
7 results
Show changes
Showing
with 380 additions and 817 deletions
import datetime
import functools
import urllib.parse
from flask import Blueprint, request, jsonify, render_template, session, redirect
from flask_oauthlib.provider import OAuth2Provider
from uffd.database import db
from uffd.session.views import get_current_user, login_required
from .models import OAuth2Client, OAuth2Grant, OAuth2Token
oauth = OAuth2Provider()
@oauth.clientgetter
def load_client(client_id):
return OAuth2Client.from_id(client_id)
@oauth.grantgetter
def load_grant(client_id, code):
return OAuth2Grant.query.filter_by(client_id=client_id, code=code).first()
@oauth.grantsetter
def save_grant(client_id, code, oauthreq, *args, **kwargs): # pylint: disable=unused-argument
expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=100)
grant = OAuth2Grant(user_dn=get_current_user().dn, client_id=client_id,
code=code['code'], redirect_uri=oauthreq.redirect_uri, expires=expires, _scopes=' '.join(oauthreq.scopes))
db.session.add(grant)
db.session.commit()
return grant
@oauth.tokengetter
def load_token(access_token=None, refresh_token=None):
if access_token:
return OAuth2Token.query.filter_by(access_token=access_token).first()
if refresh_token:
return OAuth2Token.query.filter_by(refresh_token=refresh_token).first()
return None
@oauth.tokensetter
def save_token(token_data, oauthreq, *args, **kwargs): # pylint: disable=unused-argument
OAuth2Token.query.filter_by(client_id=oauthreq.client.client_id, user_dn=oauthreq.user.dn).delete()
expires_in = token_data.get('expires_in')
expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=expires_in)
tok = OAuth2Token(
user_dn=oauthreq.user.dn,
client_id=oauthreq.client.client_id,
token_type=token_data['token_type'],
access_token=token_data['access_token'],
refresh_token=token_data['refresh_token'],
expires=expires,
_scopes=' '.join(oauthreq.scopes)
)
db.session.add(tok)
db.session.commit()
return tok
bp = Blueprint('oauth2', __name__, url_prefix='/oauth2/', template_folder='templates')
@bp.record
def init(state):
state.app.config.setdefault('OAUTH2_PROVIDER_ERROR_ENDPOINT', 'oauth2.error')
oauth.init_app(state.app)
# flask-oauthlib has the bug to require the scope parameter for authorize
# requests, which is actually optional according to the OAuth2.0 spec.
# We don't really use scopes and this requirement just complicates the
# configuration of clients.
# See also: https://github.com/lepture/flask-oauthlib/pull/320
def inject_scope(func):
@functools.wraps(func)
def decorator(*args, **kwargs):
args = request.args.to_dict()
if not args.get('scope'):
args['scope'] = 'profile'
return redirect(request.base_url+'?'+urllib.parse.urlencode(args))
return func(*args, **kwargs)
return decorator
@bp.route('/authorize', methods=['GET', 'POST'])
@login_required()
@inject_scope
@oauth.authorize_handler
def authorize(*args, **kwargs): # pylint: disable=unused-argument
# 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.
client = kwargs['request'].client
session['oauth2-clients'] = session.get('oauth2-clients', [])
if client.client_id not in session['oauth2-clients']:
session['oauth2-clients'].append(client.client_id)
return client.access_allowed(get_current_user())
@bp.route('/token', methods=['GET', 'POST'])
@oauth.token_handler
def token():
return None
@bp.route('/userinfo')
@oauth.require_oauth('profile')
def userinfo():
user = request.oauth.user
# We once exposed the entryUUID here as "ldap_uuid" until realising that it
# can (and does!) change randomly and is therefore entirely useless as an
# indentifier.
return jsonify(
id=user.uid,
name=user.displayname,
nickname=user.loginname,
email=user.mail,
ldap_dn=user.dn,
groups=[group.name for group in user.groups]
)
@bp.route('/error')
def error():
args = dict(request.values)
err = args.pop('error', 'unknown')
error_description = args.pop('error_description', '')
return render_template('error.html', error=err, error_description=error_description, args=args)
@bp.app_url_defaults
def inject_logout_params(endpoint, values):
if endpoint != 'oauth2.logout' or not session.get('oauth2-clients'):
return
values['client_ids'] = ','.join(session['oauth2-clients'])
@bp.route('/logout')
def logout():
if not request.values.get('client_ids'):
return redirect(request.values.get('ref', '/'))
client_ids = request.values['client_ids'].split(',')
clients = [OAuth2Client.from_id(client_id) for client_id in client_ids]
return render_template('logout.html', clients=clients)
import secrets
import hashlib
import base64
from crypt import crypt # pylint: disable=deprecated-module
import argon2
def build_value(method_name, data):
return '{' + method_name + '}' + data
def parse_value(value):
if value is not None and value.startswith('{') and '}' in value:
method_name, data = value[1:].split('}', 1)
return method_name.lower(), data
raise ValueError('Invalid password hash')
class PasswordHashRegistry:
'''Factory for creating objects of the correct PasswordHash subclass for a
given password hash value'''
def __init__(self):
self.methods = {}
def register(self, cls):
assert cls.METHOD_NAME not in self.methods
self.methods[cls.METHOD_NAME] = cls
return cls
def parse(self, value, **kwargs):
method_name, _ = parse_value(value)
method_cls = self.methods.get(method_name)
if method_cls is None:
raise ValueError(f'Unknown password hash method {method_name}')
return method_cls(value, **kwargs)
registry = PasswordHashRegistry()
class PasswordHash:
'''OpenLDAP-/NIS-style password hash
Instances wrap password hash strings in the form "{METHOD_NAME}DATA".
Allows gradual migration of password hashing methods by checking
PasswordHash.needs_rehash every time the password is processed and rehashing
it with PasswordHash.from_password if needed. For PasswordHash.needs_rehash
to work, the PasswordHash subclass for the current password hashing method
is instantiated with target_cls set to the PasswordHash subclass of the
intended hashing method.
Instances should be created with PasswordHashRegistry.parse to get the
appropriate subclass based on the method name in a value.'''
METHOD_NAME = None
def __init__(self, value, target_cls=None):
method_name, data = parse_value(value)
if method_name != self.METHOD_NAME:
raise ValueError('Invalid password hash')
self.value = value
self.data = data
self.target_cls = target_cls or type(self)
@classmethod
def from_password(cls, password):
raise NotImplementedError()
def verify(self, password):
raise NotImplementedError()
@property
def needs_rehash(self):
return not isinstance(self, self.target_cls)
@registry.register
class PlaintextPasswordHash(PasswordHash):
'''Pseudo password hash for passwords stored without hashing
Should only be used for migration of existing plaintext passwords. Add the
prefix "{plain}" for this.'''
METHOD_NAME = 'plain'
@classmethod
def from_password(cls, password):
return cls(build_value(cls.METHOD_NAME, password))
def verify(self, password):
return secrets.compare_digest(self.data, password)
class HashlibPasswordHash(PasswordHash):
HASHLIB_ALGORITHM = None
@classmethod
def from_password(cls, password):
ctx = hashlib.new(cls.HASHLIB_ALGORITHM, password.encode())
return cls(build_value(cls.METHOD_NAME, base64.b64encode(ctx.digest()).decode()))
def verify(self, password):
digest = base64.b64decode(self.data.encode())
ctx = hashlib.new(self.HASHLIB_ALGORITHM, password.encode())
return secrets.compare_digest(digest, ctx.digest())
class SaltedHashlibPasswordHash(PasswordHash):
HASHLIB_ALGORITHM = None
@classmethod
def from_password(cls, password):
salt = secrets.token_bytes(8)
ctx = hashlib.new(cls.HASHLIB_ALGORITHM)
ctx.update(password.encode())
ctx.update(salt)
return cls(build_value(cls.METHOD_NAME, base64.b64encode(ctx.digest()+salt).decode()))
def verify(self, password):
data = base64.b64decode(self.data.encode())
ctx = hashlib.new(self.HASHLIB_ALGORITHM)
digest = data[:ctx.digest_size]
salt = data[ctx.digest_size:]
ctx.update(password.encode())
ctx.update(salt)
return secrets.compare_digest(digest, ctx.digest())
@registry.register
class MD5PasswordHash(HashlibPasswordHash):
METHOD_NAME = 'md5'
HASHLIB_ALGORITHM = 'md5'
@registry.register
class SaltedMD5PasswordHash(SaltedHashlibPasswordHash):
METHOD_NAME = 'smd5'
HASHLIB_ALGORITHM = 'md5'
@registry.register
class SHA1PasswordHash(HashlibPasswordHash):
METHOD_NAME = 'sha'
HASHLIB_ALGORITHM = 'sha1'
@registry.register
class SaltedSHA1PasswordHash(SaltedHashlibPasswordHash):
METHOD_NAME = 'ssha'
HASHLIB_ALGORITHM = 'sha1'
@registry.register
class SHA256PasswordHash(HashlibPasswordHash):
METHOD_NAME = 'sha256'
HASHLIB_ALGORITHM = 'sha256'
@registry.register
class SaltedSHA256PasswordHash(SaltedHashlibPasswordHash):
METHOD_NAME = 'ssha256'
HASHLIB_ALGORITHM = 'sha256'
@registry.register
class SHA384PasswordHash(HashlibPasswordHash):
METHOD_NAME = 'sha384'
HASHLIB_ALGORITHM = 'sha384'
@registry.register
class SaltedSHA384PasswordHash(SaltedHashlibPasswordHash):
METHOD_NAME = 'ssha384'
HASHLIB_ALGORITHM = 'sha384'
@registry.register
class SHA512PasswordHash(HashlibPasswordHash):
METHOD_NAME = 'sha512'
HASHLIB_ALGORITHM = 'sha512'
@registry.register
class SaltedSHA512PasswordHash(SaltedHashlibPasswordHash):
METHOD_NAME = 'ssha512'
HASHLIB_ALGORITHM = 'sha512'
@registry.register
class CryptPasswordHash(PasswordHash):
METHOD_NAME = 'crypt'
@classmethod
def from_password(cls, password):
return cls(build_value(cls.METHOD_NAME, crypt(password)))
def verify(self, password):
return secrets.compare_digest(crypt(password, self.data), self.data)
@registry.register
class Argon2PasswordHash(PasswordHash):
METHOD_NAME = 'argon2'
hasher = argon2.PasswordHasher()
@classmethod
def from_password(cls, password):
return cls(build_value(cls.METHOD_NAME, cls.hasher.hash(password)))
def verify(self, password):
try:
return self.hasher.verify(self.data, password)
except argon2.exceptions.Argon2Error:
return False
except argon2.exceptions.InvalidHash:
return False
@property
def needs_rehash(self):
return super().needs_rehash or self.hasher.check_needs_rehash(self.data)
class InvalidPasswordHash:
def __init__(self, value=None):
self.value = value
# pylint: disable=unused-argument
def verify(self, password):
return False
@property
def needs_rehash(self):
return True
def __bool__(self):
return False
# An alternative approach for the behaviour of PasswordHashAttribute would be
# to use sqlalchemy.TypeDecorator. A type decorator allows custom encoding and
# decoding of values coming from the database (when query results are loaded)
# and going into the database (when statements are executed).
#
# This has one downside: After setting e.g. user.password to a string value it
# remains a string value until the change is flushed. It is not possible to
# coerce values to PasswordHash objects as soon as they are set.
#
# This is too inconsistent. Code should be able to rely on user.password to
# always behave like a PasswordHash object.
class PasswordHashAttribute:
'''Descriptor for wrapping an attribute storing a password hash string
Usage example:
>>> class User:
... # Could e.g. be an SQLAlchemy.Column or just a simple attribute
... _passord_hash = None
... password = PasswordHashAttribute('_passord_hash', SHA512PasswordHash)
...
>>> user = User()
>>> type(user.password)
<class 'uffd.password_hash.InvalidPasswordHash'>
>>>
>>> user._password_hash = '{plain}my_password'
>>> type(user.password)
<class 'uffd.password_hash.InvalidPasswordHash'>
>>> user.password.needs_rehash
True
>>>
>>> user.password = 'my_password'
>>> user._passord_hash
'{sha512}3ajDRohg3LJOIoq47kQgjUPrL1/So6U4uvvTnbT/EUyYKaZL0aRxDgwCH4pBNLai+LF+zMh//nnYRZ4t8pT7AQ=='
>>> type(user.password)
<class 'uffd.password_hash.SHA512PasswordHash'>
>>>
>>> user.password = None
>>> user._passord_hash is None
True
>>> type(user.password)
<class 'uffd.password_hash.InvalidPasswordHash'>
When set to a (plaintext) password the underlying attribute is set to a hash
value for the password. When set to None the underlying attribute is also set
to None.'''
def __init__(self, attribute_name, method_cls):
self.attribute_name = attribute_name
self.method_cls = method_cls
def __get__(self, obj, objtype=None):
if obj is None:
return self
value = getattr(obj, self.attribute_name)
try:
return registry.parse(value, target_cls=self.method_cls)
except ValueError:
return InvalidPasswordHash(value)
def __set__(self, obj, value):
if value is None:
value = InvalidPasswordHash()
elif isinstance(value, str):
value = self.method_cls.from_password(value)
setattr(obj, self.attribute_name, value.value)
# Hashing method for (potentially) low entropy secrets like user passwords. Is
# usually slow and uses salting to make dictionary attacks difficult.
LowEntropyPasswordHash = Argon2PasswordHash
# Hashing method for high entropy secrets like API keys. The secrets are
# generated instead of user-selected to ensure a high level of entropy. Is
# fast and does not need salting, since dictionary attacks are not feasable
# due to high entropy.
HighEntropyPasswordHash = SHA512PasswordHash
from flask import current_app
import itsdangerous
from uffd.utils import nopad_b32decode, nopad_b32encode, nopad_urlsafe_b64decode, nopad_urlsafe_b64encode
class Remailer:
'''The remailer feature improves user privacy by hiding real mail addresses
from services and instead providing them with autogenerated pseudonymous
remailer addresses. If a service sends a mail to a remailer address, the mail
service uses an uffd API endpoint to get the real mail address and rewrites
the remailer address with it. In case of a leak of user data from a service,
the remailer addresses are useless for third-parties.
Version 2 of the remailer address format is tolerant to case conversions at
the cost of being slightly longer.'''
@property
def configured(self):
return bool(current_app.config['REMAILER_DOMAIN'])
def get_serializer(self):
secret = current_app.config['REMAILER_SECRET_KEY'] or current_app.secret_key
return itsdangerous.URLSafeSerializer(secret, salt='remailer_address_v1')
def build_v1_address(self, service_id, user_id):
payload = self.get_serializer().dumps([service_id, user_id])
return 'v1-' + payload + '@' + current_app.config['REMAILER_DOMAIN']
def build_v2_address(self, service_id, user_id):
data, sign = self.get_serializer().dumps([service_id, user_id]).split('.', 1)
data = nopad_b32encode(nopad_urlsafe_b64decode(data)).decode().lower()
sign = nopad_b32encode(nopad_urlsafe_b64decode(sign)).decode().lower()
payload = data + '-' + sign
return 'v2-' + payload + '@' + current_app.config['REMAILER_DOMAIN']
def is_remailer_domain(self, domain):
domains = {domain.lower().strip() for domain in current_app.config['REMAILER_OLD_DOMAINS']}
if current_app.config['REMAILER_DOMAIN']:
domains.add(current_app.config['REMAILER_DOMAIN'].lower().strip())
return domain.lower().strip() in domains
def parse_v1_payload(self, payload):
try:
service_id, user_id = self.get_serializer().loads(payload)
except itsdangerous.BadData:
return None
return (service_id, user_id)
def parse_v2_payload(self, payload):
data, sign = (payload.split('-', 1) + [''])[:2]
try:
data = nopad_urlsafe_b64encode(nopad_b32decode(data.upper())).decode()
sign = nopad_urlsafe_b64encode(nopad_b32decode(sign.upper())).decode()
except ValueError:
return None
payload = data + '.' + sign
try:
service_id, user_id = self.get_serializer().loads(payload)
except itsdangerous.BadData:
return None
return (service_id, user_id)
def parse_address(self, address):
if '@' not in address:
return None
local_part, domain = address.rsplit('@', 1)
if not self.is_remailer_domain(domain):
return None
prefix, payload = (local_part.strip().split('-', 1) + [''])[:2]
if prefix == 'v1':
return self.parse_v1_payload(payload)
if prefix.lower() == 'v2':
return self.parse_v2_payload(payload)
return None
remailer = Remailer()
from .views import bp as bp_ui
bp = [bp_ui]
from sqlalchemy import Column, String, Integer, Text, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declared_attr
from ldapalchemy.dbutils import DBRelationship
from uffd.database import db
from uffd.user.models import User, Group
class LdapMapping:
id = Column(Integer(), primary_key=True, autoincrement=True)
dn = Column(String(128))
__table_args__ = (
db.UniqueConstraint('dn', 'role_id'),
)
@declared_attr
def role_id(self):
return Column(ForeignKey('role.id'))
class RoleGroup(LdapMapping, db.Model):
__tablename__ = 'role-group'
class RoleUser(LdapMapping, db.Model):
__tablename__ = 'role-user'
def update_user_groups(user):
current_groups = set(user.groups)
groups = set()
for role in user.roles:
groups.update(role.groups)
if groups == current_groups:
return set(), set()
groups_added = groups - current_groups
groups_removed = current_groups - groups
for group in groups_removed:
user.groups.discard(group)
user.groups.update(groups_added)
return groups_added, groups_removed
User.update_groups = update_user_groups
class Role(db.Model):
__tablename__ = 'role'
id = Column(Integer(), primary_key=True, autoincrement=True)
name = Column(String(32), unique=True)
description = Column(Text(), default='')
db_members = relationship("RoleUser", backref="role", cascade="all, delete-orphan")
members = DBRelationship('db_members', User, RoleUser, backattr='role', backref='roles')
db_groups = relationship("RoleGroup", backref="role", cascade="all, delete-orphan")
groups = DBRelationship('db_groups', Group, RoleGroup, backattr='role', backref='roles')
def update_member_groups(self):
for user in self.members:
user.update_groups()
{% extends 'base.html' %}
{% block body %}
<form action="{{ url_for("role.update", roleid=role.id) }}" method="POST">
<div class="align-self-center">
<div class="form-group col">
<label for="role-name">Role Name</label>
<input type="text" class="form-control" id="role-name" name="name" value="{{ role.name }}">
<small class="form-text text-muted">
</small>
</div>
<div class="form-group col">
<label for="role-description">Description</label>
<textarea class="form-control" id="role-description" name="description" rows="5">{{ role.description }}</textarea>
<small class="form-text text-muted">
</small>
</div>
<div class="form-group col">
<span>Included groups</span>
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col"></th>
<th scope="col">name</th>
<th scope="col">description</th>
</tr>
</thead>
<tbody>
{% for group in groups|sort(attribute="name") %}
<tr id="group-{{ group.gid }}">
<td>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="group-{{ group.gid }}-checkbox" name="group-{{ group.gid }}" value="1" aria-label="enabled" {% if group in role.groups %}checked{% endif %}>
</div>
</td>
<td>
<a href="{{ url_for("group.show", gid=group.gid) }}">
{{ group.name }}
</a>
</td>
<td>
{{ group.description }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="form-group col">
<p>
Members
</p>
<ul>
{% for dbmember in role.db_members %}
<li>{{ dbmember.dn }}</li>
{% endfor %}
</ul>
</div>
<div class="form-group col">
<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 %}
<a href="{{ url_for("role.delete", roleid=role.id) }}" 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>
</form>
{% endblock %}
import sys
from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app
import click
from uffd.navbar import register_navbar
from uffd.csrf import csrf_protect
from uffd.role.models import Role
from uffd.user.models import User, Group
from uffd.session import get_current_user, login_required, is_valid_session
from uffd.database import db
from uffd.ldap import ldap
bp = Blueprint("role", __name__, template_folder='templates', url_prefix='/role/')
@bp.record
def add_cli_commands(state):
@state.app.cli.command('roles-update-all', help='Update group memberships for all users based on their roles')
@click.option('--check-only', is_flag=True)
def roles_update_all(check_only): #pylint: disable=unused-variable
consistent = True
with current_app.test_request_context():
for user in User.query.all():
groups_added, groups_removed = user.update_groups()
if groups_added:
consistent = False
print('Adding groups [%s] to user %s'%(', '.join([group.name for group in groups_added]), user.dn))
if groups_removed:
consistent = False
print('Removing groups [%s] from user %s'%(', '.join([group.name for group in groups_removed]), user.dn))
if not check_only:
ldap.session.commit()
if check_only and not consistent:
print('No changes were made because --check-only is set')
print()
print('Error: LDAP groups are not consistent with roles in database')
sys.exit(1)
@bp.before_request
@login_required()
def role_acl(): #pylint: disable=inconsistent-return-statements
if not role_acl_check():
flash('Access denied')
return redirect(url_for('index'))
def role_acl_check():
return is_valid_session() and get_current_user().is_in_group(current_app.config['ACL_ADMIN_GROUP'])
@bp.route("/")
@register_navbar('Roles', icon='key', blueprint=bp, visible=role_acl_check)
def index():
return render_template('role_list.html', roles=Role.query.all())
@bp.route("/<int:roleid>")
@bp.route("/new")
def show(roleid=False):
if not roleid:
role = Role()
else:
role = Role.query.filter_by(id=roleid).one()
return render_template('role.html', role=role, groups=Group.query.all())
@bp.route("/<int:roleid>/update", methods=['POST'])
@bp.route("/new", methods=['POST'])
@csrf_protect(blueprint=bp)
def update(roleid=False):
is_newrole = bool(not roleid)
if is_newrole:
role = Role()
db.session.add(role)
else:
role = Role.query.filter_by(id=roleid).one()
role.name = request.values['name']
role.description = request.values['description']
for group in Group.query.all():
if request.values.get('group-{}'.format(group.gid), False):
role.groups.add(group)
else:
role.groups.discard(group)
role.update_member_groups()
db.session.commit()
ldap.session.commit()
return redirect(url_for('role.index'))
@bp.route("/<int:roleid>/del")
@csrf_protect(blueprint=bp)
def delete(roleid):
role = Role.query.filter_by(id=roleid).one()
oldmembers = list(role.members)
role.members.clear()
db.session.delete(role)
for user in oldmembers:
user.update_groups()
db.session.commit()
ldap.session.commit()
return redirect(url_for('role.index'))
from flask import redirect
def secure_local_redirect(target):
# Reject URLs that include a scheme or host part
if not target.startswith('/') or target.startswith('//'):
target = '/'
return redirect(target)
from .views import bp as bp_ui, send_passwordreset
bp = [bp_ui]
import datetime
import secrets
from sqlalchemy import Column, String, DateTime
from uffd.database import db
def random_token():
return secrets.token_hex(128)
class Token():
token = Column(String(128), primary_key=True, default=random_token)
created = Column(DateTime, default=datetime.datetime.now)
class PasswordToken(Token, db.Model):
__tablename__ = 'passwordToken'
loginname = Column(String(32))
class MailToken(Token, db.Model):
__tablename__ = 'mailToken'
loginname = Column(String(32))
newmail = Column(String(255))
{% extends 'base.html' %}
{% block body %}
<form action="{{ url_for("selfservice.forgot_password") }}" method="POST">
<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="text-center">
<img alt="branding logo" src="{{ config.get("BRANDING_LOGO_URL") }}" class="col-lg-8 col-md-12" >
</div>
<div class="col-12">
<h2 class="text-center">Forgot password</h2>
</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">
</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">
</div>
<div class="form-group col-12">
<button type="submit" class="btn btn-primary btn-block" tabindex = "3">Send password reset mail</button>
</div>
</div>
</div>
</form>
{% endblock %}
Hi {{ user.displayname }},
welcome to the CCCV infrastructure. An account was created for you, please visit the following url to set your
password: {{ url_for('selfservice.token_password', token=token, _external=True) }}
**The link is valid for 48h**
You can find more information at https://docs.cccv.de/.
If you have no idea why someone would create an account for you to be used for the next CCC event organization, please
contact the infra team at it@cccv.de.
{% extends 'base.html' %}
{% block body %}
<div class="btn-toolbar">
<a class="ml-auto mb-3 btn btn-primary" href="{{ url_for('mfa.setup') }}">Manage two-factor authentication</a>
</div>
<form action="{{ url_for("selfservice.update") }}" method="POST" onInput="
password2.setCustomValidity(password1.value != password2.value ? 'Passwords do not match.' : '');
password1.setCustomValidity((password1.value.length < 8 && password1.value.length > 0) ? 'Password is too short' : '') ">
<div class="align-self-center row">
<div class="form-group col-md-6">
<label for="user-uid">Uid</label>
<input type="number" class="form-control" id="user-uid" name="uid" value="{{ user.uid }}" readonly>
</div>
<div class="form-group col-md-6">
<label for="user-loginname">Login Name</label>
<input type="text" class="form-control" id="user-loginname" name="loginname" value="{{ user.loginname }}" readonly>
</div>
<div class="form-group col-md-6">
<label for="user-displayname">Display Name</label>
<input type="text" class="form-control" id="user-displayname" name="displayname" value="{{ user.displayname }}">
</div>
<div class="form-group col-md-6">
<label for="user-mail">Mail</label>
<input type="email" class="form-control" id="user-mail" name="mail" value="{{ user.mail }}">
<small class="form-text text-muted">
We will send you a confirmation mail to set a new mail address.
</small>
</div>
<div class="form-group col-md-6">
<label for="user-password1">Password</label>
<input type="password" class="form-control" id="user-password1" name="password1" placeholder="●●●●●●●●">
<small class="form-text text-muted">
At least 8 and at most 256 characters, no other special requirements. But please don't be stupid, do use a password manager.
</small>
</div>
<div class="form-group col-md-6">
<label for="user-password2">Password Repeat</label>
<input type="password" class="form-control" id="user-password2" name="password2" placeholder="●●●●●●●●">
</div>
<div class="form-group col-md-12">
<button type="submit" class="btn btn-primary float-right"><i class="fa fa-save" aria-hidden="true"></i> Save</button>
</div>
</div>
</form>
{% endblock %}
{% extends 'base.html' %}
{% block body %}
<form action="{{ url_for("selfservice.token_password", token=token) }}" method="POST" onInput="password2.setCustomValidity(password1.value != password2.value ? 'Passwords do not match.' : '') ">
<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="text-center">
<img alt="branding logo" src="{{ config.get("BRANDING_LOGO_URL") }}" class="col-lg-8 col-md-12" >
</div>
<div class="col-12">
<h2 class="text-center">Reset password</h2>
</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" required="required" tabindex = "2">
<small class="form-text text-muted">
At least 8 and at most 256 characters, no other special requirements. But please don't be stupid, do use a password manager.
</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="required" tabindex = "2">
</div>
<div class="form-group col-12">
<button type="submit" class="btn btn-primary btn-block" tabindex = "3">Set password</button>
</div>
</div>
</div>
</form>
{% endblock %}
import datetime
import smtplib
from email.message import EmailMessage
import email.utils
from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app
from uffd.navbar import register_navbar
from uffd.csrf import csrf_protect
from uffd.user.models import User
from uffd.session import get_current_user, login_required, is_valid_session
from uffd.selfservice.models import PasswordToken, MailToken
from uffd.database import db
from uffd.ldap import ldap
from uffd.ratelimit import host_ratelimit, Ratelimit, format_delay
bp = Blueprint("selfservice", __name__, template_folder='templates', url_prefix='/self/')
reset_ratelimit = Ratelimit('passwordreset', 1*60*60, 3)
@bp.route("/")
@register_navbar('Selfservice', icon='portrait', blueprint=bp, visible=is_valid_session)
@login_required()
def index():
return render_template('self.html', user=get_current_user())
@bp.route("/update", methods=(['POST']))
@csrf_protect(blueprint=bp)
@login_required()
def update():
user = get_current_user()
if request.values['displayname'] != user.displayname:
if user.set_displayname(request.values['displayname']):
flash('Display name changed.')
else:
flash('Display name is not valid.')
if request.values['password1']:
if not request.values['password1'] == request.values['password2']:
flash('Passwords do not match')
else:
if user.set_password(request.values['password1']):
flash('Password changed.')
else:
flash('Password could not be set.')
if request.values['mail'] != user.mail:
send_mail_verification(user.loginname, request.values['mail'])
flash('We sent you an email, please verify your mail address.')
ldap.session.commit()
return redirect(url_for('selfservice.index'))
@bp.route("/passwordreset", methods=(['GET', 'POST']))
def forgot_password():
if request.method == 'GET':
return render_template('forgot_password.html')
loginname = request.values['loginname']
mail = request.values['mail']
reset_delay = reset_ratelimit.get_delay(loginname+'/'+mail)
host_delay = host_ratelimit.get_delay()
if reset_delay or host_delay:
if reset_delay > host_delay:
flash('We received too many password reset requests for this user! Please wait at least %s.'%format_delay(reset_delay))
else:
flash('We received too many requests from your ip address/network! Please wait at least %s.'%format_delay(host_delay))
return redirect(url_for('.forgot_password'))
reset_ratelimit.log(loginname+'/'+mail)
host_ratelimit.log()
flash("We sent a mail to this users mail address if you entered the correct mail and login name combination")
user = User.query.filter_by(loginname=loginname).one_or_none()
if user and user.mail == mail:
send_passwordreset(user)
return redirect(url_for('session.login'))
@bp.route("/token/password/<token>", methods=(['POST', 'GET']))
def token_password(token):
dbtoken = PasswordToken.query.get(token)
if not dbtoken or dbtoken.created < (datetime.datetime.now() - datetime.timedelta(days=2)):
flash('Token expired, please try again.')
if dbtoken:
db.session.delete(dbtoken)
db.session.commit()
return redirect(url_for('session.login'))
if request.method == 'GET':
return render_template('set_password.html', token=token)
if not request.values['password1']:
flash('You need to set a password, please try again.')
return render_template('set_password.html', token=token)
if not request.values['password1'] == request.values['password2']:
flash('Passwords do not match, please try again.')
return render_template('set_password.html', token=token)
user = User.query.filter_by(loginname=dbtoken.loginname).one()
if not user.set_password(request.values['password1']):
flash('Password ist not valid, please try again.')
return render_template('set_password.html', token=token)
db.session.delete(dbtoken)
flash('New password set')
ldap.session.commit()
db.session.commit()
return redirect(url_for('session.login'))
@bp.route("/token/mail_verification/<token>")
@login_required()
def token_mail(token):
dbtoken = MailToken.query.get(token)
if not dbtoken or dbtoken.created < (datetime.datetime.now() - datetime.timedelta(days=2)):
flash('Token expired, please try again.')
if dbtoken:
db.session.delete(dbtoken)
db.session.commit()
return redirect(url_for('selfservice.index'))
user = User.query.filter_by(loginname=dbtoken.loginname).one()
user.set_mail(dbtoken.newmail)
flash('New mail set')
db.session.delete(dbtoken)
ldap.session.commit()
db.session.commit()
return redirect(url_for('selfservice.index'))
def send_mail_verification(loginname, newmail):
expired_tokens = MailToken.query.filter(MailToken.created < (datetime.datetime.now() - datetime.timedelta(days=2))).all()
duplicate_tokens = MailToken.query.filter(MailToken.loginname == loginname).all()
for i in expired_tokens + duplicate_tokens:
db.session.delete(i)
token = MailToken()
token.loginname = loginname
token.newmail = newmail
db.session.add(token)
db.session.commit()
user = User.query.filter_by(loginname=loginname).one()
msg = EmailMessage()
msg.set_content(render_template('mailverification.mail.txt', user=user, token=token.token))
msg['Subject'] = 'Mail verification'
send_mail(newmail, msg)
def send_passwordreset(user, new=False):
expired_tokens = PasswordToken.query.filter(PasswordToken.created < (datetime.datetime.now() - datetime.timedelta(days=2))).all()
duplicate_tokens = PasswordToken.query.filter(PasswordToken.loginname == user.loginname).all()
for i in expired_tokens + duplicate_tokens:
db.session.delete(i)
token = PasswordToken()
token.loginname = user.loginname
db.session.add(token)
db.session.commit()
msg = EmailMessage()
if new:
msg.set_content(render_template('newuser.mail.txt', user=user, token=token.token))
msg['Subject'] = 'Welcome to the CCCV infrastructure'
else:
msg.set_content(render_template('passwordreset.mail.txt', user=user, token=token.token))
msg['Subject'] = 'Password reset'
send_mail(user.mail, msg)
def send_mail(to_address, msg):
msg['From'] = current_app.config['MAIL_FROM_ADDRESS']
msg['To'] = to_address
msg['Date'] = email.utils.formatdate(localtime=1)
msg['Message-ID'] = email.utils.make_msgid()
try:
if current_app.debug:
current_app.last_mail = None
current_app.logger.debug('Trying to send email to %s:\n'%(to_address)+str(msg))
if current_app.debug and current_app.config.get('MAIL_SKIP_SEND', False):
if current_app.config['MAIL_SKIP_SEND'] == 'fail':
raise smtplib.SMTPException()
current_app.last_mail = msg
return True
server = smtplib.SMTP(host=current_app.config['MAIL_SERVER'], port=current_app.config['MAIL_PORT'])
if current_app.config['MAIL_USE_STARTTLS']:
server.starttls()
server.login(current_app.config['MAIL_USERNAME'], current_app.config['MAIL_PASSWORD'])
server.send_message(msg)
server.quit()
if current_app.debug:
current_app.last_mail = msg
return True
except smtplib.SMTPException:
flash('Mail to "{}" could not be sent!'.format(to_address))
return False
...@@ -24,7 +24,8 @@ def sendmail(addr, subject, template_name, **kwargs): ...@@ -24,7 +24,8 @@ def sendmail(addr, subject, template_name, **kwargs):
server = smtplib.SMTP(host=current_app.config['MAIL_SERVER'], port=current_app.config['MAIL_PORT']) server = smtplib.SMTP(host=current_app.config['MAIL_SERVER'], port=current_app.config['MAIL_PORT'])
if current_app.config['MAIL_USE_STARTTLS']: if current_app.config['MAIL_USE_STARTTLS']:
server.starttls() server.starttls()
server.login(current_app.config['MAIL_USERNAME'], current_app.config['MAIL_PASSWORD']) if current_app.config['MAIL_USERNAME']:
server.login(current_app.config['MAIL_USERNAME'], current_app.config['MAIL_PASSWORD'])
server.send_message(msg) server.send_message(msg)
server.quit() server.quit()
if current_app.debug: if current_app.debug:
......
from .views import bp as _bp
bp = [_bp]
from flask import Blueprint, render_template, current_app, abort
from uffd.navbar import register_navbar
from uffd.session import is_valid_session, get_current_user
bp = Blueprint("services", __name__, template_folder='templates', url_prefix='/services')
# pylint: disable=too-many-branches
def get_services(user=None):
if not user and not current_app.config['SERVICES_PUBLIC']:
return []
services = []
for service_data in current_app.config['SERVICES']:
if not service_data.get('title'):
continue
service = {
'title': service_data['title'],
'subtitle': service_data.get('subtitle', ''),
'description': service_data.get('description', ''),
'url': service_data.get('url', ''),
'logo_url': service_data.get('logo_url', ''),
'has_access': True,
'permission': '',
'groups': [],
'infos': [],
'links': [],
}
if service_data.get('required_group'):
if not user or not user.has_permission(service_data['required_group']):
service['has_access'] = False
for permission_data in service_data.get('permission_levels', []):
if permission_data.get('required_group'):
if not user or not user.has_permission(permission_data['required_group']):
continue
if not permission_data.get('name'):
continue
service['has_access'] = True
service['permission'] = permission_data['name']
if service_data.get('confidential', False) and not service['has_access']:
continue
for group_data in service_data.get('groups', []):
if group_data.get('required_group'):
if not user or not user.has_permission(group_data['required_group']):
continue
if not group_data.get('name'):
continue
service['groups'].append(group_data)
for info_data in service_data.get('infos', []):
if info_data.get('required_group'):
if not user or not user.has_permission(info_data['required_group']):
continue
if not info_data.get('title') or not info_data.get('html'):
continue
info = {
'title': info_data['title'],
'button_text': info_data.get('button_text', info_data['title']),
'html': info_data['html'],
'id': '%d-%d'%(len(services), len(service['infos'])),
}
service['infos'].append(info)
for link_data in service_data.get('links', []):
if link_data.get('required_group'):
if not user or not user.has_permission(link_data['required_group']):
continue
if not link_data.get('url') or not link_data.get('title'):
continue
service['links'].append(link_data)
services.append(service)
return services
def services_visible():
user = None
if is_valid_session():
user = get_current_user()
return len(get_services(user)) > 0
@bp.route("/")
@register_navbar('Services', icon='sitemap', blueprint=bp, visible=services_visible)
def index():
user = None
if is_valid_session():
user = get_current_user()
services = get_services(user)
if not current_app.config['SERVICES']:
abort(404)
banner = current_app.config.get('SERVICES_BANNER')
# Set the banner to None if it is not public and no user is logged in
if not (current_app.config["SERVICES_BANNER_PUBLIC"] or user):
banner = None
return render_template('overview.html', user=user, services=services, banner=banner)
from .views import bp as bp_ui, get_current_user, login_required, is_valid_session, set_session
bp = [bp_ui]
{% extends 'base.html' %}
{% block body %}
<form action="{{ url_for("session.login", ref=ref) }}" method="POST">
<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="text-center">
<img alt="branding logo" src="{{ config.get("BRANDING_LOGO_URL") }}" class="col-lg-8 col-md-12" >
</div>
<div class="col-12">
<h2 class="text-center">Login</h2>
</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" 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">
</div>
<div class="form-group col-12">
<button type="submit" class="btn btn-primary btn-block" tabindex = "3">Login</button>
</div>
<div class="clearfix col-12">
{% if config['SELF_SIGNUP'] %}
<a href="{{ url_for("signup.signup_start") }}" class="float-left">Register</a>
{% endif %}
<a href="{{ url_for("selfservice.forgot_password") }}" class="float-right">Forgot Password?</a>
</div>
</div>
</div>
</form>
{% endblock %}