From 1c66f0f7dde9bcf9b02bfad0c0770ffdd121a048 Mon Sep 17 00:00:00 2001 From: Daniel Muckerman Date: Sat, 12 Dec 2020 00:52:01 -0500 Subject: [PATCH] Big login update, combine flask-login with ldap to enable persistent domain-wide cookies --- .gitignore | 4 +- accounts/__init__.py | 46 +++++ accounts/auth/__init__.py | 0 accounts/auth/models.py | 74 ++++++++ accounts/auth/views.py | 167 +++++++++++++++++++ {static => accounts/static}/style.css | 0 {templates => accounts/templates}/login.j2 | 15 +- {templates => accounts/templates}/profile.j2 | 27 ++- app.py | 160 +----------------- requirements.txt | 2 +- 10 files changed, 327 insertions(+), 168 deletions(-) create mode 100644 accounts/__init__.py create mode 100644 accounts/auth/__init__.py create mode 100644 accounts/auth/models.py create mode 100644 accounts/auth/views.py rename {static => accounts/static}/style.css (100%) rename {templates => accounts/templates}/login.j2 (63%) rename {templates => accounts/templates}/profile.j2 (89%) diff --git a/.gitignore b/.gitignore index 9b235d9..48e5a42 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,6 @@ venv.bak/ .envrc .DS_Store -.vscode/ \ No newline at end of file +.vscode/ + +*.db \ No newline at end of file diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..9016eee --- /dev/null +++ b/accounts/__init__.py @@ -0,0 +1,46 @@ +import ldap as l +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager, login_manager +from flask_bootstrap import Bootstrap +from flask_simpleldap import LDAP + +import os + + +app = Flask(__name__) +Bootstrap(app) +app.secret_key = 'asdf' +app.debug = True + +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///../users.db' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['WTF_CSRF_SECRET_KEY'] = 'asdf' + +# Base +app.config['LDAP_REALM_NAME'] = 'OpenLDAP Authentication' +app.config['LDAP_HOST'] = os.environ.get('LDAP_HOST') +app.config['LDAP_BASE_DN'] = os.environ.get('LDAP_BASE_DN') +app.config['LDAP_USERNAME'] = os.environ.get('LDAP_USERNAME') +app.config['LDAP_PASSWORD'] = os.environ.get('LDAP_PASSWORD') + +# OpenLDAP +app.config['LDAP_OBJECTS_DN'] = 'dn' +app.config['LDAP_OPENLDAP'] = True +app.config['LDAP_USER_OBJECT_FILTER'] = '(&(objectclass=posixAccount)(uid=%s))' + +# Login cookies +app.config['REMEMBER_COOKIE_DOMAIN'] = os.environ.get('COOKIE_DOMAIN') + +db = SQLAlchemy(app) + +ldap = LDAP(app) + +login_manager = LoginManager(app) +login_manager.init_app(app) +login_manager.login_view = 'auth.login' + +from accounts.auth.views import auth +app.register_blueprint(auth) + +db.create_all() \ No newline at end of file diff --git a/accounts/auth/__init__.py b/accounts/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/auth/models.py b/accounts/auth/models.py new file mode 100644 index 0000000..d1562f1 --- /dev/null +++ b/accounts/auth/models.py @@ -0,0 +1,74 @@ +import ldap +from ldap3 import Server, Connection +from flask_wtf import FlaskForm +from flask_login import UserMixin +from ldap3.core.exceptions import LDAPBindError +from wtforms import StringField, PasswordField, BooleanField, SubmitField +from wtforms.validators import DataRequired +from accounts import app, db + + +def get_ldap_connection(): + server = Server(app.config['LDAP_HOST']) + conn = Connection(server, app.config['LDAP_USERNAME'], app.config['LDAP_PASSWORD'], auto_bind=True) + return conn + + +class User(db.Model): + + __tablename__ = 'user' + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(100)) + password = db.Column(db.String(128)) + authenticated = db.Column(db.Boolean, default=False) + + def __init__(self, username, password): + self.username = username + self.password = password + + @staticmethod + def try_login(username, password): + conn = get_ldap_connection() + conn.search(app.config['LDAP_BASE_DN'], app.config['LDAP_USER_OBJECT_FILTER'] % username, attributes=['*']) + if len(conn.entries) > 0: + Connection(app.config['LDAP_HOST'], conn.entries[0].entry_dn, password, auto_bind=True) + return + raise LDAPBindError + + def is_authenticated(self): + return self.authenticated + + def is_active(self): + return True + + def is_anonymous(self): + return False + + def get_id(self): + return self.id + + def get_user_dict(self): + user = {'dn': '', + 'firstName': '', + 'lastName': '', + 'email': '', + 'userName': self.username, + } + + conn = get_ldap_connection() + conn.search(app.config['LDAP_BASE_DN'], app.config['LDAP_USER_OBJECT_FILTER'] % self.username, attributes=['*']) + + user['dn'] = conn.entries[0].entry_dn + user['firstName'] = conn.entries[0].givenName.value + user['lastName'] = conn.entries[0].sn.value + user['email'] = conn.entries[0].mail.value + + return user + + +class LoginForm(FlaskForm): + username = StringField('Username', validators=[DataRequired()]) + password = PasswordField('Password', validators=[DataRequired()]) + remember_me = BooleanField('Remember Me') + submit = SubmitField('Sign In') \ No newline at end of file diff --git a/accounts/auth/views.py b/accounts/auth/views.py new file mode 100644 index 0000000..16ce24d --- /dev/null +++ b/accounts/auth/views.py @@ -0,0 +1,167 @@ +from flask import request, render_template, flash, redirect, \ + url_for, Blueprint, g +from flask_login import current_user, login_user, \ + logout_user, login_required +from ldap3 import MODIFY_REPLACE +from ldap3.core.exceptions import LDAPBindError +from accounts import login_manager, db, ldap +from accounts.auth.models import User, LoginForm, get_ldap_connection +from email_validator import validate_email, EmailNotValidError + + +auth = Blueprint('auth', __name__) + + +@login_manager.user_loader +def load_user(id): + return User.query.get(int(id)) + + +@auth.before_request +def get_current_user(): + g.user = current_user + + +@auth.route('/') +@login_required +def home(): + return render_template('profile.j2', user = current_user.get_user_dict()) + + +@auth.route('/update/email', methods=['POST']) +@login_required +def update_email(): + if request.method == 'POST': + email = request.form['email'] + dn = request.form['dn'] + + if email != None and len(email) > 0: + try: + # Validate. + valid = validate_email(email) + + # Update with the normalized form. + conn = get_ldap_connection() + conn.modify(dn, {'mail': [(MODIFY_REPLACE, [valid.email])]}) + return 'Success' + except EmailNotValidError as e: + # email is not valid, exception message is human-readable + print(str(e)) + return 'Invalid email address' + return 'Email cannot be empty' + + +@auth.route('/update/name', methods=['POST']) +@login_required +def update_name(): + if request.method == 'POST': + firstName = request.form['firstName'] + lastName = request.form['lastName'] + dn = request.form['dn'] + + if (firstName != None and len(firstName) > 0) and (lastName != None and len(lastName) > 0): + conn = get_ldap_connection() + conn.modify(dn, {'givenName': [(MODIFY_REPLACE, [firstName])], + 'sn': [(MODIFY_REPLACE, [lastName])]}) + return 'Success' + return 'Name cannot be empty' + + +@auth.route('/update/username', methods=['POST']) +@login_required +def update_username(): + if request.method == 'POST': + userName = request.form['userName'] + dn = request.form['dn'] + + if userName != None and len(userName) > 0: + conn = get_ldap_connection() + conn.modify(dn, {'uid': [(MODIFY_REPLACE, [userName])]}) + return 'Success' + return 'Username cannot be empty' + + +@auth.route('/update/password', methods=['POST']) +@login_required +def update_password(): + if request.method == 'POST': + currentPassword = request.form['currentPassword'] + newPassword = request.form['newPassword'] + confirmPassword = request.form['confirmPassword'] + dn = request.form['dn'] + + if currentPassword == '': + return 'Please enter your current password' + + if newPassword == '': + return 'Please enter a new password' + + if confirmPassword == '': + return 'Please confirm your new password' + + if newPassword != confirmPassword: + return 'Could not confirm new password, please make sure you typed it correctly' + + try: + User.try_login(current_user.username, currentPassword) + except LDAPBindError: + return 'Current password is incorrect' + + conn = get_ldap_connection() + conn.extend.standard.modify_password(user=dn, new_password=newPassword) + return 'Success' + return 'Error' + + +@auth.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + flash('You are already logged in.') + return redirect(url_for('auth.home')) + + form = LoginForm(request.form) + print(form) + print(request.method) + + if request.method == 'POST' and form.validate(): + username = request.form.get('username') + password = request.form.get('password') + print(username) + print(password) + + try: + User.try_login(username, password) + except LDAPBindError: + flash( + 'Invalid username or password. Please try again.', + 'danger') + return render_template('login.j2', form=form) + + user = User.query.filter(User.username == username).first() + + print(user) + if user is None: + user = User(username, password) + db.session.add(user) + user.authenticated = True + db.session.commit() + login_user(user, remember=form.remember_me.data) + + print('You have successfully logged in.') + return redirect(url_for('auth.home')) + + if form.errors: + flash(form.errors, 'danger') + + return render_template('login.j2', form=form) + + +@auth.route('/logout') +@login_required +def logout(): + user = current_user + user.authenticated = False + db.session.add(user) + db.session.commit() + logout_user() + return redirect(url_for('auth.home')) \ No newline at end of file diff --git a/static/style.css b/accounts/static/style.css similarity index 100% rename from static/style.css rename to accounts/static/style.css diff --git a/templates/login.j2 b/accounts/templates/login.j2 similarity index 63% rename from templates/login.j2 rename to accounts/templates/login.j2 index d4935b3..6ae0078 100644 --- a/templates/login.j2 +++ b/accounts/templates/login.j2 @@ -1,9 +1,10 @@ +{% import "bootstrap/wtf.html" as wtf %} {% extends "bootstrap/base.html" %} {% block title %}Manage your Technical Incompetence account{% endblock %} {% block styles %} {{super()}} - + {% endblock %} {% block navbar %} @@ -26,12 +27,12 @@ {% endif %} -
- -
- -
+ {# + {{ form.csrf_token }} +
{{ form.username.label }}: {{ form.username() }}

+
{{ form.password.label }}: {{ form.password() }}

-
+ #} + {{wtf.quick_form(form, novalidate=True)}} {% endblock %} \ No newline at end of file diff --git a/templates/profile.j2 b/accounts/templates/profile.j2 similarity index 89% rename from templates/profile.j2 rename to accounts/templates/profile.j2 index 888912c..86426ee 100644 --- a/templates/profile.j2 +++ b/accounts/templates/profile.j2 @@ -3,7 +3,7 @@ {% block styles %} {{super()}} - + {% endblock %} {% block navbar %} @@ -65,6 +65,11 @@
+

+
+ +
+

+ + {% endblock %} {% block scripts %} diff --git a/app.py b/app.py index 7ddba92..5f3da8b 100644 --- a/app.py +++ b/app.py @@ -1,160 +1,4 @@ -import ldap as l -from ldap3 import Server, Connection, ALL, MODIFY_REPLACE -from flask import Flask, g, request, session, redirect, url_for, render_template -from flask_simpleldap import LDAP -from flask_bootstrap import Bootstrap -from email_validator import validate_email, EmailNotValidError -import os +from accounts import app -app = Flask(__name__) -Bootstrap(app) -app.secret_key = 'asdf' -app.debug = True - -# Base -app.config['LDAP_REALM_NAME'] = 'OpenLDAP Authentication' -app.config['LDAP_HOST'] = os.environ.get('LDAP_HOST') -app.config['LDAP_BASE_DN'] = os.environ.get('LDAP_BASE_DN') -app.config['LDAP_USERNAME'] = os.environ.get('LDAP_USERNAME') -app.config['LDAP_PASSWORD'] = os.environ.get('LDAP_PASSWORD') - -# OpenLDAP -app.config['LDAP_OBJECTS_DN'] = 'dn' -app.config['LDAP_OPENLDAP'] = True -app.config['LDAP_USER_OBJECT_FILTER'] = '(&(objectclass=posixAccount)(uid=%s))' - -ldap = LDAP(app) - -server = Server(app.config['LDAP_HOST']) -conn = Connection(server, app.config['LDAP_USERNAME'], app.config['LDAP_PASSWORD'], auto_bind=True) - -@app.before_request -def before_request(): - g.user = None - if 'user_id' in session: - # This is where you'd query your database to get the user info. - g.user = {} - - -@app.route('/') -@ldap.login_required -def index(): - user_dict = ldap.get_object_details(session['user_id']) - - if 'user_id' in session: - user = {'dn': 'cn={},cn=usergroup,ou=users,dc=technicalincompetence,dc=club'.format(user_dict['cn'][0].decode('ascii')), - 'firstName': user_dict['givenName'][0].decode('ascii'), - 'lastName': user_dict['sn'][0].decode('ascii'), - 'email': user_dict['mail'][0].decode('ascii'), - 'userName': user_dict['uid'][0].decode('ascii'), - } - - - return render_template('profile.j2', user = user) - - -@app.route('/login', methods=['GET', 'POST']) -def login(): - if g.user: - return redirect(url_for('index')) - if request.method == 'POST': - user = request.form['user'] - passwd = request.form['passwd'] - test = ldap.bind_user(user, passwd) - if test is None or passwd == '': - return render_template('login.j2', error='Invalid credentials') - else: - session['user_id'] = request.form['user'] - session['passwd'] = request.form['passwd'] - return redirect('/') - return render_template('login.j2') - - -@ldap.login_required -@app.route('/update/email', methods=['POST']) -def update_email(): - if request.method == 'POST': - email = request.form['email'] - dn = request.form['dn'] - - if email != None and len(email) > 0: - try: - # Validate. - valid = validate_email(email) - - # Update with the normalized form. - conn.modify(dn, {'mail': [(MODIFY_REPLACE, [valid.email])]}) - return 'Success' - except EmailNotValidError as e: - # email is not valid, exception message is human-readable - print(str(e)) - return 'Invalid email address' - return 'Email cannot be empty' - - -@ldap.login_required -@app.route('/update/name', methods=['POST']) -def update_name(): - if request.method == 'POST': - firstName = request.form['firstName'] - lastName = request.form['lastName'] - dn = request.form['dn'] - - if (firstName != None and len(firstName) > 0) and (lastName != None and len(lastName) > 0): - conn.modify(dn, {'givenName': [(MODIFY_REPLACE, [firstName])], - 'sn': [(MODIFY_REPLACE, [lastName])]}) - return 'Success' - return 'Name cannot be empty' - - -@ldap.login_required -@app.route('/update/username', methods=['POST']) -def update_username(): - if request.method == 'POST': - userName = request.form['userName'] - dn = request.form['dn'] - - if userName != None and len(userName) > 0: - conn.modify(dn, {'uid': [(MODIFY_REPLACE, [userName])]}) - return 'Success' - return 'Username cannot be empty' - - -@ldap.login_required -@app.route('/update/password', methods=['POST']) -def update_password(): - if request.method == 'POST': - currentPassword = request.form['currentPassword'] - newPassword = request.form['newPassword'] - confirmPassword = request.form['confirmPassword'] - dn = request.form['dn'] - - if currentPassword == '': - return 'Please enter your current password' - - if newPassword == '': - return 'Please enter a new password' - - if confirmPassword == '': - return 'Please confirm your new password' - - if newPassword != confirmPassword: - return 'Could not confirm new password, please make sure you typed it correctly' - - test = ldap.bind_user(session['user_id'], currentPassword) - if test is None: - return 'Current password is incorrect' - else: - conn.extend.standard.modify_password(user=dn, new_password=newPassword) - return 'Success' - return 'Error' - - -@app.route('/logout') -def logout(): - session.pop('user_id', None) - return redirect(url_for('index')) - - -if __name__ == '__main__': +if __name__ == "main": app.run() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 60b2455..a3b4004 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,5 @@ Flask-Bootstrap4==4.0.2 Flask-Login==0.5.0 Flask-SimpleLDAP==1.4.0 python-ldap==3.2.0 -ldap3==2.7 +ldap3==2.8.1 email-validator==1.1.1 \ No newline at end of file