Skip to content
Snippets Groups Projects
Commit e731dca3 authored by Julian's avatar Julian
Browse files

Implemented self-signup

parent 8b3e082f
No related branches found
No related tags found
No related merge requests found
Showing
with 889 additions and 15 deletions
Subproject commit 2358086f5b8184fe89bbb69334a5c80e9b20cfbf
Subproject commit 40ee661e418dd7866b9dc539fa6544cb12f9cd70
......@@ -17,9 +17,6 @@ def get_ldap_password():
return User.query.get('uid=testuser,ou=users,dc=example,dc=com').pwhash
class TestSelfservice(UffdTestCase):
def setUpApp(self):
self.app.config['MAIL_SKIP_SEND'] = True
def login(self):
self.client.post(path=url_for('session.login'),
data={'loginname': 'testuser', 'password': 'userpassword'}, follow_redirects=True)
......
This diff is collapsed.
......@@ -3,6 +3,8 @@ import tempfile
import shutil
import unittest
from flask import request
from uffd import create_app, db
def dump(basename, resp):
......@@ -16,6 +18,11 @@ def dump(basename, resp):
with open(path, 'xb') as f:
f.write(resp.data)
def db_flush():
db.session = db.create_scoped_session()
if hasattr(request, 'ldap_connection'):
del request.ldap_session
class UffdTestCase(unittest.TestCase):
use_openldap = False
......@@ -29,6 +36,7 @@ class UffdTestCase(unittest.TestCase):
'SQLALCHEMY_DATABASE_URI': 'sqlite:///%s/db.sqlite'%self.dir,
'SECRET_KEY': 'DEBUGKEY',
'LDAP_SERVICE_MOCK': True,
'MAIL_SKIP_SEND': True,
}
if self.use_openldap:
if not os.environ.get('UNITTEST_OPENLDAP'):
......
......@@ -13,7 +13,7 @@ from uffd.template_helper import register_template_helper
from uffd.navbar import setup_navbar
# pylint: enable=wrong-import-position
def create_app(test_config=None):
def create_app(test_config=None): # pylint: disable=too-many-locals
# create and configure the app
app = Flask(__name__, instance_relative_config=False)
app.json_encoder = SQLAlchemyJSON
......@@ -43,10 +43,10 @@ def create_app(test_config=None):
db.init_app(app)
# pylint: disable=C0415
from uffd import user, selfservice, role, mail, session, csrf, mfa, oauth2, services
from uffd import user, selfservice, role, mail, session, csrf, mfa, oauth2, services, signup
# pylint: enable=C0415
for i in user.bp + selfservice.bp + role.bp + mail.bp + session.bp + csrf.bp + mfa.bp + oauth2.bp + services.bp:
for i in user.bp + selfservice.bp + role.bp + mail.bp + session.bp + csrf.bp + mfa.bp + oauth2.bp + services.bp + signup.bp:
app.register_blueprint(i)
@app.route("/")
......
......@@ -58,6 +58,9 @@ MAIL_PASSWORD='*****'
MAIL_USE_STARTTLS=True
MAIL_FROM_ADDRESS='foo@bar.com'
# Do not enable this on a public service! There is no spam protection implemented at the moment.
SELF_SIGNUP=False
#MFA_ICON_URL = 'https://example.com/logo.png'
#MFA_RP_ID = 'example.com' # If unset, hostname from current request is used
MFA_RP_NAME = 'Uffd Test Service' # Service name passed to U2F/FIDO2 authenticators
......
import smtplib
from email.message import EmailMessage
import email.utils
from flask import render_template, current_app
def sendmail(addr, subject, template_name, **kwargs):
msg = EmailMessage()
msg.set_content(render_template(template_name, **kwargs))
msg['Subject'] = subject
msg['From'] = current_app.config['MAIL_FROM_ADDRESS']
msg['To'] = addr
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'%(addr)+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:
return False
from .views import bp as bp_ui, get_current_user, login_required, is_valid_session
from .views import bp as bp_ui, get_current_user, login_required, is_valid_session, set_session
bp = [bp_ui]
......@@ -22,7 +22,9 @@
<button type="submit" class="btn btn-primary btn-block" tabindex = "3">Login</button>
</div>
<div class="clearfix col-12">
<a href="#" class="float-left">Register</a>
{% 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>
......
......@@ -50,6 +50,14 @@ def logout():
session.clear()
return resp
def set_session(user, skip_mfa=False):
session.clear()
session['user_dn'] = user.dn
session['logintime'] = datetime.datetime.now().timestamp()
session['_csrf_token'] = secrets.token_hex(128)
if skip_mfa:
session['user_mfa'] = True
@bp.route("/login", methods=('GET', 'POST'))
def login():
if request.method == 'GET':
......@@ -74,10 +82,7 @@ def login():
if not user.is_in_group(current_app.config['ACL_SELFSERVICE_GROUP']):
flash('You do not have access to this service')
return render_template('login.html', ref=request.values.get('ref'))
session.clear()
session['user_dn'] = user.dn
session['logintime'] = datetime.datetime.now().timestamp()
session['_csrf_token'] = secrets.token_hex(128)
set_session(user)
return redirect(url_for('mfa.auth', ref=request.values.get('ref', url_for('index'))))
def get_current_user():
......
from .views import bp as _bp
bp = [_bp]
import secrets
import datetime
from crypt import crypt
from flask import current_app
from sqlalchemy import Column, String, Text, DateTime
from ldapalchemy.dbutils import DBRelationship
from uffd.database import db
from uffd.ldap import ldap
from uffd.user.models import User
from uffd.role.models import Role
class Signup(db.Model):
'''Model that represents a self-signup request
When a person tries to sign up, an instance with user-provided loginname,
displayname, mail and password is created. Signup.validate is called to
validate the request. To ensure that person is in control of the provided
mail address, a mail with Signup.token is sent to that address. To complete
the signup, Signup.finish is called with a user-provided password that must
be equal to the initial password.
Signup.token requires the password again so that a mistyped-but-valid mail
address does not allow a third party to complete the signup procedure and
set a new password with the (also mail-based) password reset functionality.
As long as they are not completed, signup requests have no effect each other
or different parts of the application.'''
__tablename__ = 'signup'
token = Column(String(128), primary_key=True, default=lambda: secrets.token_hex(20))
created = Column(DateTime, default=datetime.datetime.now, nullable=False)
loginname = Column(Text)
displayname = Column(Text)
mail = Column(Text)
pwhash = Column(Text)
user_dn = Column(String(128)) # Set after successful confirmation
user = DBRelationship('user_dn', User)
type = Column(String(50))
__mapper_args__ = {
'polymorphic_identity': 'Signup',
'polymorphic_on': type
}
# Write-only property
def password(self, value):
if not User().set_password(value):
return
self.pwhash = crypt(value)
password = property(fset=password)
def check_password(self, value):
return self.pwhash is not None and crypt(value, self.pwhash) == self.pwhash
@property
def expired(self):
return self.created is not None and datetime.datetime.now() >= self.created + datetime.timedelta(hours=48)
@property
def completed(self):
return self.user_dn is not None
def validate(self): # pylint: disable=too-many-return-statements
'''Return whether the signup request is valid and Signup.finish is likely to succeed
:returns: Tuple (valid, errmsg), if the signup request is invalid, `valid`
is False and `errmsg` contains a string describing why. Otherwise
`valid` is True.'''
if self.completed or self.expired:
return False, 'Invalid signup request'
if not User().set_loginname(self.loginname):
return False, 'Login name is invalid'
if not User().set_displayname(self.displayname):
return False, 'Display name is invalid'
if not User().set_mail(self.mail):
return False, 'Mail address is invalid'
if self.pwhash is None:
return False, 'Invalid password'
if User.query.filter_by(loginname=self.loginname).all():
return False, 'A user with this login name already exists'
return True, 'Valid'
def finish(self, password):
'''Complete the signup procedure and return the new user
Signup.finish should only be called on an object that was (at some point)
successfully validated with Signup.validate!
:param password: User password
:returns: Tuple (user, errmsg), if the operation fails, `user` is None and
`errmsg` contains a string describing why. Otherwise `user` is a
User object.'''
if self.completed or self.expired:
return None, 'Invalid signup request'
if not self.check_password(password):
return None, 'Wrong password'
if User.query.filter_by(loginname=self.loginname).all():
return None, 'A user with this login name already exists'
user = User(loginname=self.loginname, displayname=self.displayname, mail=self.mail, password=password)
ldap.session.add(user)
for name in current_app.config['ROLES_BASEROLES']:
for role in Role.query.filter_by(name=name).all():
user.roles.add(role)
user.update_groups()
self.user = user
self.loginname = None
self.displayname = None
self.mail = None
self.pwhash = None
return user, 'Success'
{% extends 'base.html' %}
{% block body %}
<form action="{{ url_for(".signup_confirm_submit", token=signup.token) }}" 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">Complete Registration</h2>
</div>
{% if error %}
<div class="alert alert-danger" role="alert">{{ error }}</div>
{% endif %}
<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">
</div>
<div class="form-group col-12">
<button type="submit" class="btn btn-primary btn-block">Complete Account Registration</button>
</div>
</div>
</div>
</form>
{% endblock %}
Hi {{ signup.displayname }},
an account was created on the CCCV infrastructure with this mail address.
Please visit the following url to complete the account registration:
{{ url_for('signup.signup_confirm', token=signup.token, _external=True) }}
**The link is valid for 48h**
You can find more information at https://docs.cccv.de/.
If you have not requested an account on the CCCV infrastructure, you can
ignore this mail.
{% extends 'base.html' %}
{% block body %}
<form action="{{ url_for('.signup_submit') }}" 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">Account Registration</h2>
</div>
{% if error %}
<div class="form-group col-12">
<div class="alert alert-danger" role="alert">{{ error }}</div>
</div>
{% endif %}
<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>
<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>
<div id="loginname-feedback" class="invalid-feedback">foobar</div>
</div>
<small class="form-text text-muted">
At least one and at most 32 lower-case characters, digits, dashes ("-") or underscores ("_"). <b>Cannot be changed later!</b>
</small>
</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>
<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>
<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=8 maxlength=256 required>
<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>
</div>
<div class="form-group col-12">
<button type="submit" class="btn btn-primary btn-block">Create Account</button>
</div>
</div>
</div>
</form>
<script>
$(".js-only-input-group").addClass("input-group");
$(".js-only-input-group-append").removeClass("d-none");
$(".js-only-input-group-append").addClass("input-group-append");
let checkreq;
$("#check-loginname").on("click", function () {
if (checkreq)
checkreq.abort();
$("#user-loginname").removeClass("is-valid");
$("#user-loginname").removeClass("is-invalid");
$("#check-loginname").prop("disabled", true);
checkreq = $.ajax({
url: {{ url_for('.signup_check')|tojson }},
method: "POST",
data: {"loginname": $("#user-loginname").val()},
success: function (resp) {
checkreq = null;
$("#check-loginname").prop("disabled", false);
if (resp.status == "ok") {
$("#user-loginname").addClass("is-valid");
$("#loginname-feedback").text("");
} else if (resp.status == 'exists') {
$("#loginname-feedback").text("The name is already taken");
$("#user-loginname").addClass("is-invalid");
} else if (resp.status == 'ratelimited') {
$("#loginname-feedback").text("Too many requests! Please wait a bit before trying again!");
$("#user-loginname").addClass("is-invalid");
} else {
$("#loginname-feedback").text("The name is invalid");
$("#user-loginname").addClass("is-invalid");
}
}
});
});
$("#user-loginname").on("input", function () {
if (checkreq)
checkreq.abort();
checkreq = null;
$("#user-loginname").removeClass("is-valid");
$("#user-loginname").removeClass("is-invalid");
$("#check-loginname").prop("disabled", false);
});
</script>
{% endblock %}
{% extends 'base.html' %}
{% 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="text-center">
<img alt="branding logo" src="{{ config.get("BRANDING_LOGO_URL") }}" class="col-lg-8 col-md-12" >
</div>
<div class="col-12 mb-3">
<h2 class="text-center">Confirm your E-Mail Address</h2>
</div>
<p>We sent a confirmation mail to <b>{{ signup.mail }}</b>. You need to confirm your mail address within 48 hours to complete the account registration.</p>
<p>If you mistyped your mail address or don't receive the confirmation mail for another reason, retry the registration procedure from the beginning.</p>
</div>
</div>
{% endblock %}
import functools
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify
from uffd.database import db
from uffd.ldap import ldap
from uffd.session import set_session
from uffd.user.models import User
from uffd.sendmail import sendmail
from uffd.signup.models import Signup
from uffd.ratelimit import Ratelimit, host_ratelimit, format_delay
bp = Blueprint('signup', __name__, template_folder='templates', url_prefix='/signup/')
signup_ratelimit = Ratelimit('signup', 24*60, 3)
confirm_ratelimit = Ratelimit('signup_confirm', 10*60, 3)
def signup_enabled(func):
@functools.wraps(func)
def decorator(*args, **kwargs):
if not current_app.config['SELF_SIGNUP']:
flash('Singup not enabled')
return redirect(url_for('index'))
return func(*args, **kwargs)
return decorator
@bp.route('/')
@signup_enabled
def signup_start():
return render_template('signup/start.html')
@bp.route('/check', methods=['POST'])
@signup_enabled
def signup_check():
if host_ratelimit.get_delay():
return jsonify({'status': 'ratelimited'})
host_ratelimit.log()
if not User().set_loginname(request.form['loginname']):
return jsonify({'status': 'invalid'})
if User.query.filter_by(loginname=request.form['loginname']).all():
return jsonify({'status': 'exists'})
return jsonify({'status': 'ok'})
@bp.route('/', methods=['POST'])
@signup_enabled
def signup_submit():
if request.form['password1'] != request.form['password2']:
return render_template('signup/start.html', error='Passwords do not match')
signup_delay = signup_ratelimit.get_delay(request.form['mail'])
host_delay = host_ratelimit.get_delay()
if signup_delay and signup_delay > host_delay:
return render_template('signup/start.html', error='Too many signup requests with this mail address! Please wait %s.'%format_delay(signup_delay))
if host_delay:
return render_template('signup/start.html', error='Too many requests! Please wait %s.'%format_delay(host_delay))
host_ratelimit.log()
signup = Signup(loginname=request.form['loginname'],
displayname=request.form['displayname'],
mail=request.form['mail'], password=request.form['password1'])
valid, msg = signup.validate()
if not valid:
return render_template('signup/start.html', error=msg)
db.session.add(signup)
db.session.commit()
sent = sendmail(signup.mail, 'Confirm your mail address', 'signup/mail.txt', signup=signup)
if not sent:
return render_template('signup/start.html', error='Cound not send mail')
signup_ratelimit.log(request.form['mail'])
return render_template('signup/submitted.html', signup=signup)
# signup_confirm* views are always accessible so other modules (e.g. invite) can reuse them
@bp.route('/confirm/<token>')
def signup_confirm(token):
signup = Signup.query.get(token)
if not signup or signup.expired or signup.completed:
flash('Invalid signup link')
return redirect(url_for('index'))
return render_template('signup/confirm.html', signup=signup)
@bp.route('/confirm/<token>', methods=['POST'])
def signup_confirm_submit(token):
signup = Signup.query.get(token)
if not signup or signup.expired or signup.completed:
flash('Invalid signup link')
return redirect(url_for('index'))
confirm_delay = confirm_ratelimit.get_delay(token)
host_delay = host_ratelimit.get_delay()
if confirm_delay and confirm_delay > host_delay:
return render_template('signup/confirm.html', signup=signup, error='Too many failed attempts! Please wait %s.'%format_delay(confirm_delay))
if host_delay:
return render_template('signup/confirm.html', signup=signup, error='Too many requests! Please wait %s.'%format_delay(host_delay))
if not signup.check_password(request.form['password']):
host_ratelimit.log()
confirm_ratelimit.log(token)
return render_template('signup/confirm.html', signup=signup, error='Wrong password')
user, msg = signup.finish(request.form['password'])
if user is None:
return render_template('signup/confirm.html', signup=signup, error=msg)
db.session.commit()
ldap.session.commit()
set_session(user, skip_mfa=True)
flash('Your account was successfully created')
return redirect(url_for('selfservice.index'))
......@@ -42,8 +42,8 @@ class BaseUser(ldap.Model):
mail = ldap.Attribute(lazyconfig_str('LDAP_USER_MAIL_ATTRIBUTE'), aliases=lazyconfig_list('LDAP_USER_MAIL_ALIASES'))
pwhash = ldap.Attribute('userPassword', default=lambda: hashed(HASHED_SALTED_SHA512, secrets.token_hex(128)))
groups = [] # Shuts up pylint, overwritten by back-reference
roles = [] # Shuts up pylint, overwritten by back-reference
groups = set() # Shuts up pylint, overwritten by back-reference
roles = set() # Shuts up pylint, overwritten by back-reference
def add_default_attributes(self):
for name, values in current_app.config['LDAP_USER_DEFAULT_ATTRIBUTES'].items():
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment