diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..59ee0a502268715e007c5347f06a9e1690b0c4c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +**/__pycache__/** diff --git a/Procfile b/Procfile index 244c130127e4d890b3bc6646b9ff8f41554031c5..bff65d4f21c3858677b3a44592d74f19c6ac014a 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: gunicorn app:app --log-file=- +web: gunicorn app:app --log-file=- \ No newline at end of file diff --git a/README.md b/README.md index a7a88e42c0f01ff5236c0f7663c700c4d531901d..4647bea5c21e8926205ee1d8d95d23bb04d625e1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,47 @@ -## Imperial PaaS Template: Python Flask +# Python3 Flask Template -Get started with this Python Flask template. +A started template for Flask web apps using **Python 3.8**. This started template includes: + +- Dynamic frontend templates (not React) +- Database support +- Imperial LDAP user authentication +- Multiple environments (development and production) + + +This template is useful if you would like to create a backend REST API, optionally coupled with a simple dynamic frontend. + +## Getting started + +Once you have created your new app, take a few minutes to look through the files to familiarise yourself with the project structure. + +- `app.py` : entry point to the Flask app +- `templates/` : contains the frontend dynamic HTML files +- `static/` : contains the static frontend assets (images, stylesheets and scripts) +- `blueprints/` : contains all the application routes +- `models/` : contains all the database models +- `database/` : contains the database creation +- `config/` : contains the app settings for the different environments + +**The first change you should make** is to set the `APP_NAME` variable in `config/config.py` to whatever you app's name is. + +To start the application locally, you can just run `python3 app.py` and this will launch the app on port 5000 (by default). +You will notice a message in the console saying: + +`WARNING: Could not connect to the given database URL!` + +To fix this, you should set the environment variable DATABASE_URL accordingly. If you have PostgreSQL running locally, you can use that. Alternatively, you could use SQLite which is much simpler and does not require installation. + +If you do not want to use a database yet, you can ignore this warning and delete any routes that interact with the database. + +If you navigate to `http://localhost:5000`, you will see the response created by the route defined in `blueprints/home.py`. + +You will also notice the lines `Environment: production` and `Debug mode: off` when the Flask application starts in the console. To enable debug mode, you must set the environment variable `ENV` to `dev`, ie: `export ENV=dev` (see `config/config.py` for more details on different environments). + +## Tutorial 1: Adding a new route + + + +## Tutorial 2: Adding database interaction + + +## Tutorial 3: Configuring a test environment \ No newline at end of file diff --git a/app.py b/app.py index 566010be09c4dff4e16c1f566d90d6065601b4f5..709de365de095394de717712a1059590ab50d7de 100644 --- a/app.py +++ b/app.py @@ -1,15 +1,69 @@ -from flask import Flask, render_template +from flask import Flask, send_from_directory, url_for +from database.db import db +from config.config import APP_NAME, ENV, get_app_config, get_static_url -app = Flask(__name__) +# Create and configure our Flask app +app = Flask(__name__, static_url_path=get_static_url()) +app.url_map.strict_slashes = False +app.config.from_object(get_app_config()) +db.init_app(app) -@app.route('/') -def index(): - return render_template('index.html') +# uri = os.getenv("DATABASE_URL") # or other relevant config var +# print(uri) +# # if uri and uri.startswith("postgres://"): +# # uri = uri.replace("postgres://", "postgresql://", 1) +# # app.config["SQLALCHEMY_DATABASE_URI"] = uri +# # db = SQLAlchemy(app) +# # db.create_all() +# # if db.session.query(User).filter_by(username="testuser").first() is not None: +# # db.session.add(User(username='testuser', email='admin@example.com')) +# # db.session.commit() -if __name__ == '__main__': app.run(debug=True) \ No newline at end of file +# # if db.session.query(Entity).filter_by(username="my entity").first() is not None: +# # db.session.add(Entity(username='my entity', email='entity@example.com')) +# # db.session.commit() + +# # else: +# # print("No database created/linked with this application") + + +# Serve all static assets for the frontend +@app.route('/static/<path:path>') +def serve_static_files(path): + return send_from_directory('static', path) + + +# Register all routes from the blueprints module +from blueprints.home import home_blueprint +from blueprints.auth import auth_blueprint +app.register_blueprint(home_blueprint) +app.register_blueprint(auth_blueprint) + +# @app.route('/test-db') +# def test_db(): +# try: +# rows = "" + +# for user in db.session.query(User).all(): +# rows += str(user) + " " + +# for e in db.session.query(Entity).all(): +# rows += str(e) + " " + +# print("All rows:", rows) +# return rows +# except: +# return "App database error" + +# Hook any custom Jinja templating functions +from config import CUSTOM_TEMPLATE_FUNCTIONS +app.jinja_env.globals.update(CUSTOM_TEMPLATE_FUNCTIONS) + +if __name__ == '__main__': + app.run() \ No newline at end of file diff --git a/auth/__init__.py b/auth/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/auth/constants.py b/auth/constants.py new file mode 100644 index 0000000000000000000000000000000000000000..bd473637a832a2fbc2341e5216534f82fc43e054 --- /dev/null +++ b/auth/constants.py @@ -0,0 +1,13 @@ +# LDAP server config +LDAP_URL = "ldaps://ldaps-vip.cc.ic.ac.uk:636" +LDAP_DN = "OU=Users,OU=Imperial College (London),DC=ic,DC=ac,DC=uk" + +# Relevant IC LDAP attributes +TITLE = "extensionAttribute6" +NAME = "givenName" +SURNAME = "sn" +DN = "distinguishedName" +MEMBERSHIPS = "memberOf" + +# List of attributes to be parsed into dictionaries +ATTRIBUTES_TO_SERIALISE = [DN, MEMBERSHIPS] \ No newline at end of file diff --git a/auth/ldap_auth.py b/auth/ldap_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..f7a1cd71677c48e0f70f58e9c9a03846ba4e1406 --- /dev/null +++ b/auth/ldap_auth.py @@ -0,0 +1,43 @@ +from .constants import * +from .ldap_handler import ldap_service + +WHITE_LIST = ["ictsec"] + + +def ldap_login(username, password): + """ + Perform (a) LDAP authentication and (b) additional (app specific) verifications + before granting access and returning the user LDAP attributes 'name, surname, title and memberships'. + """ + ldap_attributes = ldap_service.ldap_login( + username, password, query_attrs=(TITLE, NAME, SURNAME, DN, MEMBERSHIPS) + ) + return custom_authentication_checks(username, ldap_attributes) + + +def custom_authentication_checks(username, ldap_attributes): + # ADD HERE CUSTOM HIGHER-LEVEL CHECKS + # e.g.: + # + # if 'doc' not in dict_attrs[DN]['OU']: # is 'doc' in the organisation sub-attribute? + # if 'doc-all-students' not in dict_attrs[MEMBERSHIPS]['CN']: # is 'doc-all-students' among the memberships? + # raise ldap.INVALID_CREDENTIALS # raise INVALID_CREDENTIALS exception + return ldap_attributes + + +# To enforce a distinction between "student" and "staff", the `ldap_constant_TITLE` ldap attribute is +# requested (see above) and associated to the user model. The following decorator is then an example +# on how to leverage the title to implement title-based access (where DEFAULT_REDIRECTION is assigned +# a convenient application route). +# For inspiration on how to implement title-based access, refer to emarking's source code: +# https://gitlab.doc.ic.ac.uk/edtech/emarking +# +# def role_required(access_role, redirection_url=None): +# def decorator(f): +# @wraps(f) +# def decorated_function(*args, **kwargs): +# if current_user.title == access_role: +# return f(*args, **kwargs) +# return redirect(url_for(redirection_url or DEFAULT_REDIRECTION)) +# return decorated_function +# return decorator diff --git a/auth/ldap_handler.py b/auth/ldap_handler.py new file mode 100644 index 0000000000000000000000000000000000000000..855af320dc5ae778676a782a65e5a062d39931e1 --- /dev/null +++ b/auth/ldap_handler.py @@ -0,0 +1,85 @@ +import itertools +import re +from collections import defaultdict + +from .constants import * +import ldap + +# Used to parse key-value LDAP attributes +KEY_VAL_ATT_REGEX = "([A-Za-z0-9]+)=([A-Za-z0-9-@]+)" +USERNAME_FILTER_TEMPLATE = "(&(objectClass=user)(sAMAccountName=%s))" +BINDING_TEMPLATE = "%s@IC.AC.UK" + + +class LdapConnectionHandler: + """ + Adapter for the python-LDAP library. + The class simplifies the interaction with python-LDAP + to initialise an LDAPObject and handle the retrieval of + relevant LDAP user attributes. + + EXAMPLE USAGE FOR LOGIN PURPOSES: + 1. An LDAP object is initialised with LDAP server URL and base distinct name + 2. A new connection is established with connect() + 3. The LDAP binding for a given username and password is performed with ldap_login() + 4. Relevant attributes are queried with query_attributes(). + """ + + def __init__(self): + self.base_dn = LDAP_DN + self.server_url = LDAP_URL + + def ldap_login(self, username, password, query_attrs): + """ + Performs basic LDAP authentication by binding on a fresh connection with `username` and `password`. + Throws INVALID_CREDENTIALS exception if authentication fails. On successful authentication, + retrieves the values stored on the LDAP server associated to `username` for the given `attributes`. + :param username: username credential + :param password: password credential + :param attributes: names of the attributes to filter for + :return: attr_name -> attr_value dict for given username + """ + connection = ldap.initialize(self.server_url) + connection.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_ALLOW) + connection.set_option(ldap.OPT_X_TLS_NEWCTX, 0) + connection.simple_bind_s(BINDING_TEMPLATE % username, password) + attributes = parse_ldap_attributes( + self.raw_attributes(username, query_attrs, connection) + ) + connection.unbind_s() + return attributes + + def raw_attributes(self, username, attributes, connection): + ldap_filter = USERNAME_FILTER_TEMPLATE % username + raw_res = connection.search( + self.base_dn, ldap.SCOPE_SUBTREE, ldap_filter, attributes + ) + res_type, res_data = connection.result(raw_res) + _, filtered_attributes = res_data[0] + return filtered_attributes.items() + + +################################################################### +# U T I L I T I E S # +################################################################### +def parse_ldap_attributes(attributes): + return { + k: ldap_attributes_to_dictionary(vs) + if k in ATTRIBUTES_TO_SERIALISE + else vs[0].decode("utf-8") + for k, vs in attributes + } + + +def ldap_attributes_to_dictionary(attr_values): + items = ( + re.findall(KEY_VAL_ATT_REGEX, item.decode("utf-8").replace(",", " ")) + for item in attr_values + ) + d = defaultdict(set) + for k, v in itertools.chain.from_iterable(items): + d[k].add(v) + return d + + +ldap_service = LdapConnectionHandler() diff --git a/blueprints/__init__.py b/blueprints/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/blueprints/auth.py b/blueprints/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..59362cd6d79a387c1f9d8ce8b89e63d3ecc5efd2 --- /dev/null +++ b/blueprints/auth.py @@ -0,0 +1,23 @@ +from flask import Blueprint, render_template, request +from auth.ldap_auth import ldap_login +import ldap + +auth_blueprint = Blueprint('auth', __name__, url_prefix='/auth') + + +@auth_blueprint.route('/login', methods=['GET', 'POST']) +def login(): + if request.method == 'GET': + return render_template('login.html') + + # Handle post request + username = request.form.get('username') + password = request.form.get('password') + print(f'Got username={username} and password={password}') + if username and password: + try: + r = ldap_login(username, password) + return f"Logged in! LDAP response: {r}" + except ldap.INVALID_CREDENTIALS: + return "Invalid credentials." + return "please provide a username and password" \ No newline at end of file diff --git a/blueprints/home.py b/blueprints/home.py new file mode 100644 index 0000000000000000000000000000000000000000..2b9af5b843d2cdb21711f5222e79909502b7f71b --- /dev/null +++ b/blueprints/home.py @@ -0,0 +1,35 @@ +from flask import Blueprint, render_template, request, redirect, url_for +from database.db import db +from models.user import Entity + +home_blueprint = Blueprint('home', __name__, url_prefix='/') + +@home_blueprint.route('') +def home(): + return render_template('index.html') + +@home_blueprint.route('/hello/<name>') +def hello(name: str): + return "Hello, " + name + + +# Example CRUD route + +@home_blueprint.route('/entities', methods=['GET', 'POST']) +def entities(): + if request.method == "GET": + try: + rows = "" + for e in db.session.query(Entity).all(): + rows += str(e) + "<br>" + + return rows + except: + return "App database error (have you setup a database for this app?)" + else: + username = request.form.get('username') + email = request.form.get('email') + + db.session.add(Entity(username=username, email=email)) + db.session.commit() + return redirect('/entities') diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..65531fe0f925707a2dfda491db5347a357cb1270 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,12 @@ +# Define any custom Jinja2 functions here +from flask import url_for +from .config import URL_PREFIX + +# Get around the routing prefix issue inside the templates +def url_for2(endpoint: str, **kwargs): + return URL_PREFIX + str(url_for(endpoint, **kwargs)) + + +CUSTOM_TEMPLATE_FUNCTIONS = { + "url": url_for2, +} diff --git a/config/config.py b/config/config.py new file mode 100644 index 0000000000000000000000000000000000000000..c0738ad1d71139fa28b33689437b769e7da2365a --- /dev/null +++ b/config/config.py @@ -0,0 +1,50 @@ +import os + +# This should match exactly the name of the app you specified +APP_NAME = "dbtestapp2" +ENV = os.environ.get('ENV', 'prod').lower() +URL_PREFIX = f"/{APP_NAME}" if ENV == 'prod' else "" + +# Get the static URL of the app (to get around the production path issue) +def get_static_url(): + if ENV == 'prod': + return f'/{APP_NAME}/static' + else: + return '/static' + +# Get the app configuration based on the ENV environment variable (default is prod) +def get_app_config(): + if ENV == 'prod': + return ProductionConfig() + else: + return DevelopmentConfig() + + +# If you have created a database for this app, the connection string will be automatically +# accessible through the DATABASE_URL environment variable. +def get_db_url(): + url = os.environ.get('DATABASE_URL') + if url is None: + print("WARNING: Could not connect to the given database URL!") + + # For PostgreSQL databases, the conn string needs to start with "postgresql" + if url and url.startswith("postgres://"): + url = url.replace("postgres://", "postgresql://", 1) + + return url + + +# Flask App settings for production environment +class ProductionConfig: + DEBUG = False + APPLICATION_ROOT = f"/{APP_NAME}" + SQLALCHEMY_DATABASE_URI = get_db_url() + SQLALCHEMY_TRACK_MODIFICATIONS = False + + +# Flask App settings for local development enviroment +class DevelopmentConfig: + DEBUG = True + SQLALCHEMY_DATABASE_URI = get_db_url() + SQLALCHEMY_TRACK_MODIFICATIONS = False + diff --git a/database/__init__.py b/database/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/database/db.py b/database/db.py new file mode 100644 index 0000000000000000000000000000000000000000..2e1eeb63ff7528d9ae956c7700724bf9989c1fe1 --- /dev/null +++ b/database/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() \ No newline at end of file diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/models/user.py b/models/user.py new file mode 100644 index 0000000000000000000000000000000000000000..dc408282b42e4f945b6cec4a97493b922ba71f5c --- /dev/null +++ b/models/user.py @@ -0,0 +1,18 @@ +from database.db import db + +# class User(db.Model): +# id = db.Column(db.Integer, primary_key=True) +# username = db.Column(db.String(80), unique=True, nullable=False) +# email = db.Column(db.String(120), unique=True, nullable=False) + +# def __repr__(self): +# return f'User({self.id}, {self.username}, {self.email})' + + +class Entity(db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + email = db.Column(db.String(120), unique=True, nullable=False) + + def __repr__(self): + return f'Entity({self.id}, {self.username}, {self.email})' \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 961decff13a3e8a347cea2ba1ded8d4ca92bd674..ee819a9eb4075093b1baf9ab1e83420597c61145 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,8 @@ gunicorn itsdangerous Jinja2 peewee -Werkzeug \ No newline at end of file +Werkzeug +Flask-SQLAlchemy +SQLAlchemy +psycopg2-binary +python_ldap \ No newline at end of file diff --git a/static/img/python-logo.png b/static/img/python-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..7a4bfafc006f4dfa6edee0d25b5efaa5a8edc6fc Binary files /dev/null and b/static/img/python-logo.png differ diff --git a/templates/index.html b/templates/index.html index 8840789240f75e8f3ba3effa2f4bd23190e6c2e3..e83b98f04230f9ab4301c5d8fd983feacd7179bc 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,25 +1,38 @@ <!DOCTYPE html> <html lang="en"> <head> - <title>Heroku Flask Template</title> + <title>Flask Template</title> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> </head> <body> - <h1 id="tittle">Heroku Flask Template</h1> + <h1 id="title">Imperial PaaS - Python Flask Template</h1> <nav> <ul> - <li><a href="index.html">HOME</a></li> - <li><a href="https://github.com/vetronus/heroku-flask-template">SOURCE CODE(GitHub)</a></li> - <li><a href="http://parthsarthee.com/">PARTH SARTHEE(Author)</a></li> - <li><a href="http://aroxbit.com">AROXBIT(Awesome Indie Startup)</a></li> + <li><a href="{{ url('home.home') }}">HOME</a></li> + <li><a href="{{ url('auth.login') }}">LOGIN</a></li> </ul> </nav> - <h2>What is Heroku Flask Template</h2> + + <img src="{{ url('static', filename='img/python-logo.png') }}" width="100px" alt="python logo"/> + + <h2>What is Flask Template</h2> <p> - Heroku Flask Template is a simple web app programmed in Python-3 using flask micro-framework. It is created for begginers to understand the basics of creating a flask web app and deploying it on the Heroku. It can also be used as a template to create your new flask web apps which can then easily be deployable on Heroku. + Flask Template is a simple web app programmed in Python-3 using flask micro-framework. It is created for begginers to understand the basics of creating a flask web app and deploying it on the Heroku. It can also be used as a template to create your new flask web apps. </p><br> - <h3>You can download this plugin, or modify its source code from <a href="https://github.com/vetronus/heroku-flask-template">GitHub</a></h3> + + Example CRUD form: + <form method="post" action="{{ url('home.entities') }}"> + Username:<br> + <input type="text" name="username"> + <br> + Email:<br> + <input type="email" name="email"> + <br> + <button type="submit">Create Entry</button> + </form> + <br> + <a href="{{ url('home.entities') }}">View entities</a> </body> </html> \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000000000000000000000000000000000000..6771198785f869b9c65a024f6f17212fd15f0cdd --- /dev/null +++ b/templates/login.html @@ -0,0 +1,28 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <title>Flask Template</title> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + + </head> + <body> + <h1 id="title">Imperial PaaS - Python Flask Template</h1> + <nav> + <ul> + <li><a href="{{ url('home.home') }}">HOME</a></li> + <li><a href="{{ url('auth.login') }}">LOGIN</a></li> + </ul> + </nav> + + <form method="post"> + LDAP Username:<br> + <input type="text" name="username"> + <br> + Password:<br> + <input type="password" name="password"> + <br> + <button type="submit">Login</button> + </form> + </body> +</html> \ No newline at end of file