Configure Python Flask application for LDAP authentication

Using a crossword-hints Flask application to include authentication for adding, modifying and deleting database content.
We will use ad a users table to the database and hand off the authentication to the directory.

Install the python-ldp pip (3.1.0 as of February 2019)

$ pip install python-ldap --user

Install the Flask-login pip (0.4.1 s of February 2019)

$ pip install flask-login --user

Include the modules in the Flask application

from flask_login import LoginManager, UserMixin, login_required, login_user, logout_user
import ldap

Configure Flask-login in the application

# flask-login
login_manager = LoginManager()
login_manager.init_app(application)
login_manager.login_view = "crossword_login"

The login_view indicated the function to be called to handle the user login.

Flask-login also requires a user_loader function that returns an integer id value for the user,

@login_manager.user_loader
def load_user(id):
    return users.get(users.rowid == int(id))

Add a function to get a connection to the directory (but no authentication at this point)

def get_ldap_connection():
    conn = ldap.initialize('ldap://localhost:389')
    return conn

Add a route to the login form. This accepts GET and POST requests to display the form and then
process the submitted form. Notice that after successful login there is a redirect to a page passed as the next parameter in the query string when an application route requires login: this could be a securiity concern if steps are not taken to prevent the application accepting a spoofed resource as input rather than a permitted resource in the intended application.

@application.route("/login", methods=['GET', 'POST'])
def crossword_login():
    try:
        if current_user.is_authenticated:
        flash('You are already logged in.')
        return redirect(request.path)
    except:
        pass

    if request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')

        try:
            users.try_login(username, password)
        except ldap.INVALID_CREDENTIALS:
            flash(
                'Invalid username or password. Please try again.', 'danger')
            return(render_template('views/login/login.html', u=username, r=request))

        user = users.get(users.username == username)
        login_user(user)
        return redirect(request.args.get("next"))
    else:
        username = 'username'

    return(render_template('views/login/login.html', u=username, r=request))

A template for the login form could be

{%- import "macros.html" as f -%}
Use this form to login to the site to maintain restricted content.
</p>
<form name="crossword_login" action="{{ r.full_path }}" method="POST">
<fieldset id="crossword_login">
<legend>Login</legend>
{{ f.label('username', 'Username') }}
{{ f.input_tag('username', value=(u|escape()|default('')), class="xwordhints") }}

{{ f.label('password', 'Password') }}
{{ f.input_tag('password', type="password", class="xwordhints") }}
</fieldset>

<p>
<input type="submit" name="login" value="Login" />
<input type="reset" value="Reset" />
<br /><br />
<a href="/crossword-hints">Back to index</a>
</p>
</form>

macros.html (there’s a good GitHub gist with some macros but I can’t find a link to it) contains helpers to create the form input elements. The form action
uses the request’s full path which includes the query string containing the next parameter.

Provide a logout route. Note that this requires a valid login. Going direct to /logout will show a prompt to login!

@application.route("/logout")
@login_required
def logout():
    logout_user()
    flash("Logout successful. Please close browser for best security.")
    return(redirect("/"))

A PeeWee user model

class users(BaseModel):
    rowid = AutoField()
    username = CharField(null=False, max_length=32, unique=True)
    created_at = DateTimeField(default=datetime.now())
    updated_at = DateTimeField(default=datetime.now())

    @staticmethod
    def try_login(username, password):
        conn = get_ldap_connection()
        conn.simple_bind_s(
            'uid=%s,ou=People,dc=my-domain,dc=com' % username, password
        )

    def is_authenticated(self):
        return True

    def is_active(self):
        return True

    def is_anonymous(self):
        return False

    def get_id(self):
        conn = get_ldap_connection()
        return(self.rowid)

    def get_name(self):
       return(self.username)

The get_name method is used by HTML templates to display the logged in username. For authentication the try_login method just attempts to bind to the directory as the user.

{% if current_user.get_id() is not none %}Logged in {{ current_user.get_name() }}&nbsp; <a href="/logout">Logout</a>{% endif %}

Routes that need authentication (and authoristion) before access simply need to state ‘login_required):

@application.route("/crossword-setters/<int:id>/edit", methods=["GET", "POST"])
@login_required
def crossword_setters_edit(id):

Accessing this route will redirect to the login form and successful login will redirect back to the edit page.

Unit tests

When requiring authentication to enable certain operations in the application, consideration needs to be given to the impact on running unit tests.

The protecting views section of the Flask-login site indicates that this is simply a matter of setting the following option:

LOGIN_DISABLED=True

which disables the login_required decoration applied to routes. Adding this to a settings file used when running the tests allows everything to complete as expected.

APP_SETTINGS='test-settings.py' python crossword-hints-tests.py

 

Improvements

  • Don’t use the SQL database to store the users
  • Control authorisation according to group membership.
  • Prevent authentication leakage via spoofed ‘next’ resources.
  • Don’t request a login when going direct to the logout route.

References

1 thought on “Configure Python Flask application for LDAP authentication

  1. Pingback: Python Flask avoid login for unit testing | Dough, mud and penguins

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.