Skip to content

kentslaney/login

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Flask Modular Login

The goal of this project is to allow multiple servers to share a single OAuth login interface, removing the need for multiple access tokens across separate user databases. It also serves as an authentication microservice, enabling load balancing for a distributed system without acting as a bottleneck. Finally, the project also handles role based access control, including hierarchical groups and limited-privledge sharing with user friendly QR code support.

It is written in Flask has the simplest interface with client applications also in Flask, but also has an interface for any other Python web framework. The middleware for aiohttp, for example, will be covered below. The project doesn't have bindings for projects in languages besides Python yet, but is well equipped to handle them using the same interfaces as the ones supporting non-Flask based Python applications.

Starting the Login Service

Start the login service on port 8000 with

$ sh server.sh debug

If you go to http://localhost:8000/login/view/sessions, you should see a 'test' link, which will let you specify a username, then show you the current session.

Clearly, this isn't much of a secure login interface yet, but it does allow for testing the process without any OAuth API keys. The 'test' option will go away once the server is run without the debug subcommand.

You may notice that the user interfaces are very bare-bones. This is because they are generally expected to be used through their corresponding AJAX endpoints from a page styled by the corresponding project. Alternatively, the templates are in src/flask_modular_login/templates.

Authentication Groups

Most applications need more state information about a user than just whether they're logged in. For example, to do a closed beta, there needs to be a distinction betweeen a user that logged in versus one of the testers. Another common example is a remote file system where users can access or share private documents.

The most general justification for a unified authentication/access control system is that any time a user accesses private data, they need to be allowed to. This by itself is a bad argument though, since, if users just need to access their own data, then their username is an easy substitute for their access group. Unfortunately, including user groups is a natural extension of the requirements in many cases.

Access groups are organized as a disjoint set of trees, with permissions flowing downwards. This is backwards from the unix groups system, where access to each of the ancestor nodes is required to interact with a node. That being said, most file system sharing software (Google Drive, Dropbox, etc) uses a top down setup for permissions: access to a parent folder gives access to the files. In cases where a disconnect in permissions is needed, the client can symlink two group trees.

The snippet below both requires a user to be logged in, as well as creates an access group the user needs to either own or join to see their profile.

import flask
from flask_modular_login import AccessNamespace
from flask_modular_login import login_required

app = flask.Flask(__name__)
login_required.prefix = "//localhost:8000"

group = AccessNamespace(
    "test_group", ownership_method="test", owner_id="127.0.0.1")

@app.route("/access_profile")
@login_required(kw="user", group=group)
def access_profile(user):
    return str(user)

if __name__ == "__main__":
    app.run(port=8080)

By logging into the 'test' option named 127.0.0.1 (which should be the default), it is now possible to:

Group hierarchies are created using the group method, and can be composed using the binary operators | and & to indicate or and and respectively. For information on more than one group, use +, and to reference a subgroup by name, a pathlib-like / can be used. Groups can also be constructed based on arguments passed to the endpoint, including the user dictionary.

As a brief, incomplete example to showcase the usage, consider a messaging app where images can be unsent or users can be blocked altogether

chat = AccessNamespace("chat", ownership_method="test", owner_id="127.0.0.1")

@app.route("/chats/<DMing>/images/<img>")
@login_required(kw="user", group=lambda DMing, user: \
    !(chat / DMing / "blocked") & \
    chat / DMing / user["id"] / img / "read_access")
def access_profile(DMing, img, user):
    ...

Adding OAuth Providers

This project currently relies on flask-dance for its OAuth interface, meaning that any of the providers supported there will also work here. For now, the easiest platforms to set up are Google, Facebook, and Github, for reasons that will be explained momentarily.

Add a credentials.json to the root directory of this project, in the form

{
    "google|facebook|github": {"id": "username", "secret": "API key"},
}

Once the user logs in, the service guarantees a username (id), display name (name), and picture (picture).

The available platforms are defined in src/flask_modular_login/platforms.py, which maps the OAuth response to the platform-agnostic keys given to client projects. Remove unused providers from the methods dictionary, or add them in both userlookup and methods.

At this point, some providers may require a public facing URL to redirect to. For notes on deployment to public facing URLs, see the Deployment section.

Add Logging in to Projects

In the project with a login requirement ('client'), install the local copy of this repo using

$ pip install -e path/to/repo

The client can either be on the same server as the login service ('local') or on a separate one ('remote'), and the server can be either Flask ('builder') or not ('interface'). For naming purposes, the project is assumed to be on the same server and written in Flask by default.

(Local)LoginBuilder

When all the URLs are accessed via localhost loopback, the client and server projects are served on different ports. This is in contrast to the default setup for deployment, where only the paths differ. To run a test setup, then, the URL prefix for the login server has to be specified as http://localhost:8000. This will be included in the example code below, but needs to be removed or updated when deploying to public facing URLs.

For in the server code for the client project, login requirements can now be specified using login_required and login_optional. This login system was demonstrated briefly above, but a more complete walkthrough of the features is below

import flask
from flask_modular_login import login_required, login_optional

app = flask.Flask(__name__)
login_required.prefix = "//localhost:8000"

@app.route("/user_info/<kind>")
@login_required(kw="user")
def protected_or_user_info(kind, user):
    # only logged in users can access this route, others redirected by flask
    # user argument now contains keys id, name, picture
    return user["id"]

@app.route("/profile_api")
@login_optional(kw="user")
def profile(user=None):
    # login optional can be used when logged out users shouldn't see a redirect
    return str(user)

@app.route("/hidden")
@login_required
def hidden():
    # same as before, but the user info is now stored in flask.g.user
    return profile(user={"id": flask.g.user["id"], "name": "me", "picture": ""})
    # methods with optional login can also be called with a custom user argument
    # but only as a keyword, since *args wrappers can make positional matching
    # unreliable

bp = flask.Blueprint("private", __name__, url_prefix="/private")
login_required(bp) # returns bp, could be integrated into line above

@bp.route("/page")
def page():
    # user info in flask.g.user, access limited to logged in users
    return flask.g.user["name"]

app.register_blueprint(bp) # login_required call could also be here

if __name__ == "__main__":
    app.run(port=8080)

Because the login_required and login_optional objects refer to the same LoginBuilder, updating one will update both. Another way to achieve this would be

from flask_modular_login import LoginBuilder

login_config = LoginBuilder(prefix="http://localhost:8000")
login_required, login_optional = login_config.decorators

Websocket Interfaces

The other methods for connecting to the login server rely on sockets to forward request information. In general, the websocket servers should be started using subcommands of

$ sh server.sh ws

LocalLoginInterface

Python projects using web servers other than Flask are also supported, but have to specify a way to

  1. get the web request's IP address
  2. get the HTTP cookie login (barring config changes)
  3. set the HTTP cookie login (again, subject to config)

For example, a web server written in aiohttp might set up the login interface as

from aiohttp import web
from flask_modular_login import LocalLoginInterface

class LoginBuilder(LocalLoginInterface):
    def remote_addr(self, request):
        return request.remote

    def get_cookie(self, request):
        return request.cookies.get('login')

    def set_cookie(self, value, request):
        request["login"] = value

login = LoginBuilder()

@web.middleware
async def process_request(request, handler):
    request['user'] = await login(request)
    response = await handler(request)
    if 'login' in request:
        response.set_cookie('login', request['login'])
    return response

app = web.Application(middlewares=[process_request])

async def handler(request):
    return web.Response(text='Hello, ' + str(request["user"]))

app.router.add_get('/', handler)

if __name__ == '__main__':
    web.run_app(app)

RemoteLoginBuilder

Another important option for load balancing is being able to have the login system as a separate service, only contacted when an access token lease needs to be refreshed or revoked (eg when the user logs out). In order to connect the login service from the client server, the client needs to be able to access

  • an open port on the login server
    • by default, the server websocket is hosted on port 8001
    • the port can be made accessable using SSH reverse tunneling to port forward
  • a copy of the secret_key used by Flask.
    • by default, this is stored in run/login_secret_session_key
  • the base URL that the login service is hosted on
    • this can be either the public URL or port forwarded
    • used to coordinate shared secrets before opening the websocket connection

RemoteLoginInterface

Using frameworks other than Flask for a remote client requires the same information as above.

Deployment

Deployment requires memcached to be installed in the environment hosting the login service. Installation is OS dependant. The python virtual environment will be set up and started by the server script, so if it's already running in a container, you may prefer to uncomment the early exit from the setup function in server.sh.

Serving via Unix Socket

With the default deployment, uwsgi.ini will serve requests from run/uwsgi.sock, relative to this project's root directory. The other important thing for the web server middleware is that only requests prefixed with /login should be passed to this process.

Here is an example pulled from a working Nginx setup where socket in uwsgi.ini has been modified to be /tmp/flask_modular_login.sock:

location = /login { rewrite ^ /login/; }
location /login { try_files $uri @login; }
location @login {
    include uwsgi_params;
    uwsgi_pass unix:/tmp/flask_modular_login.sock;
}

Useful commands

echo "$(grep TODO -r src && grep '^#\+ TODO' README.md \
-A `wc -l README.md | sed 's%[^0-9]%%g'` | tail -n +2)" | nl
find src -type f -name "*.py" | xargs wc | sort

Project Structure

Rough, slightly outdated builder/interface boxes and "client login app" doesn't set the available platforms yet. The goal is to have the login server launched via LoginBlueprint and use the repo as a package so it doesn't have to be modified.

+-------------+
| access root |
+-------------+
       |      \\_______
       |       \  \_   \_______
       |        \   \_         \_______
       |         \    \_               \_______
       |          \     \__                    \_______
       |           \       \                           \
+-------------+     \       +-------------+             +-------------+
|  oauth BP   |      \      |access group |____________ |local client |
|  interface  |       \     +-------------+             |  flask app  |
+-------------+        \           |                    +-------------+
       |      \         \          |                 __/
       |       \         \         |              __/
       |        \         \        |           __/
       |         \         \       |        __/
       |          \         \      |       /
+-------------+    \        +-------------+
|  platforms  |     \       |login builder|
+-------------+      \      +-------------+
       |              \            |       \__
       |               \           |          \_
       |                \          |            \_
       |                 \         |              \_
       |                  \        |                \__
       |                   \       |                   \
+-------------+             +-------------+             +-------------+
|   client    |_____________|  login app  |             |   remote    |
|  login app  |             +-------------+             |login builder|
+-------------+             /                           +-------------+
                           /                           /       |
                          /                           /        |
                         /                           /         |
                        /                           /          |
                       /                           /           |
                      /     +-------------+       /     +-------------+
                     /      |local client |      /      |remote client|
                    /       |   compat    |     /       |     app     |
                   /        +-------------+    /        +-------------+
                  /                |          /                |
                 /                 |         /                 |
                /                  |        /                  |
               /                   |       /                   |
              /                    |      /                    |
+-------------+             +-------------+             +-------------+
|  server BP  |             |login builder|             |  client BP  |
+-------------+             |  interface  |             +-------------+
               \__          +-------------+                    |
                  \_               |                           |
                    \_             |                           |
                      \_           |                           |
                        \__        |                           |
                           \       |                           |
                            +-------------+             +-------------+
                            |  server WS  |-------------|  client WS  |
                            +-------------+             +-------------+

TODOs

  • set the platforms via launch conditions
  • other language compat
  • option to auto-redirect to QR code link on first login if not at /qr
  • linked accounts
  • check path interface consitency (pub/sub, memcached, secret_key)
  • consistent indentation between if statements and others
  • purge access tokens from remote clients after they're stale
  • invite option to limit sharing by total use time?
  • include X-API-Version header and/or version field in WS messages
  • alternate language bindings link
  • login.gov integration might be a polite civil service
  • switch to python-social-auth
  • consider sql alchemy, prep database schema migration system
  • horizontal scaling (maybe?)
  • check SQL indicies
  • caching in various places
  • API docs (doxygen?)
  • ...unit tests (hopefully higher up please)
  • get a security audit from someone else