Implement OIDC through nginx's ngx_http_auth_request_module module.

This commit is contained in:
Phil Elson 2021-02-26 21:36:20 +01:00
parent 1ff9a4d352
commit 85a378691e
12 changed files with 355 additions and 42 deletions

View file

@ -51,6 +51,7 @@ pytest ./cara
s2i build file://$(pwd) --copy --keep-symlinks --context-dir ./app-config/nginx/ centos/nginx-112-centos7 cara-nginx-app
s2i build file://$(pwd) --copy --keep-symlinks --env APP_NAME=cara-voila --context-dir ./ centos/python-36-centos7 cara-voila-app
s2i build file://$(pwd) --copy --keep-symlinks --env APP_NAME=cara-webservice --context-dir ./ centos/python-36-centos7 cara-webservice
s2i build file://$(pwd) --copy --keep-symlinks --context-dir ./app-config/auth-service centos/python-36-centos7 auth-service
cd app-config
docker-compose up
```

View file

@ -0,0 +1,54 @@
# auth-service
A simple auth service using OIDC which can be interrogated by NGINX.
To use, set the following environment variables:
* COOKIE_SECRET
* OIDC_SERVER
* OIDC_REALM
* CLIENT_ID
* CLIENT_SECRET
Then ``python -m auth_service``.
Once running, visit http://localhost:8080/auth/probe to find out if you are
already authenticated. Since you just started the app, you won't be authenticated,
so a 401 will be returned. Now go and visit http://localhost:8080/auth/login, which
will take you through the OIDC code authorization flow. Once complete you will eventually
be redirected to http://localhost:8080 and get a 404 error. Now go back to
http://localhost:8080/auth/probe to observe that your now authenticated.
To logout, hit http://localhost:8080/auth/logout (you'll be redirected again to
a 404) then re-visit http://localhost:8080/auth/probe to confirm you get a 401 again.
At this point, you may be wondering why would you want so many 401 & 404 errors.
The idea of this service is to be able to use it using
[nginx's ``ngx_http_auth_request_module``](
http://nginx.org/en/docs/http/ngx_http_auth_request_module.html).
A nice tutorial of using it inspired the creation of this package and may be
interesting to the curious reader
(https://redbyte.eu/en/blog/using-the-nginx-auth-request-module/).
## Integrating into NGINX
As mentioned, typically nginx has the [``ngx_http_auth_request_module``](
http://nginx.org/en/docs/http/ngx_http_auth_request_module.html) built-in, and so
we want to be able to profit from its ability to only allow authorized access to
certain specified locations.
In our nginx config we declare ``auth_request /auth/probe;`` for all
locations that should be authenticated. This endpoint (as you've already seen)
must either return a 200 or a 401 (and no other status!) depending on whether the
user is auth-ed or not. If we are authorized then nginx will redirect to the location,
otherwise we trigger a specialised 401 error with ``error_page 401 = @error401;`` which
essentially has a definition of:
```
location @error401 {
return 302 /auth/login;
}
```
In English: if the page is not authorized, redirect the browser to the login page.

1
app-config/auth-service/app.sh Executable file
View file

@ -0,0 +1 @@
python -m auth_service

View file

@ -0,0 +1,150 @@
try:
from contextlib import asynccontextmanager
except ImportError:
# Python 3.6
from asyncio_extras import async_contextmanager as asynccontextmanager
import datetime
import logging
import os
import aiohttp
from keycloak.aio.realm import KeycloakRealm
import tornado.ioloop
import tornado.web
LOG = logging.getLogger(__name__)
class OIDCClientMixin:
@asynccontextmanager
async def get_oidc_client(self):
"""Async context manager to get hold of a OIDC client."""
realm_params = {
'server_url': self.settings['oicd_server'],
'realm_name': self.settings['oicd_realm'],
}
oicd_params = {
'client_id': self.settings['client_id'],
'client_secret': self.settings['client_secret'],
}
async with KeycloakRealm(**realm_params) as realm:
oidc_client = await realm.open_id_connect(**oicd_params)
yield oidc_client
class Login(tornado.web.RequestHandler):
async def get(self):
# Initiate the OICD flow.
return self.redirect('/auth/authenticate')
class Authentication(tornado.web.RequestHandler, OIDCClientMixin):
async def get(self):
async with self.get_oidc_client() as oidc_cli:
redirect_uri = f'{self.request.protocol}://{self.request.host}/auth/authorize'
LOG.info(f'Redirecting to the authorization url. Will return to {redirect_uri}')
return self.redirect(oidc_cli.authorization_url(redirect_uri=redirect_uri))
class Authorization(tornado.web.RequestHandler, OIDCClientMixin):
async def get(self):
code = self.get_argument('code', None)
if code is None:
# Somebody is hitting this endpoint without going through the proper
# flow. Let's start again.
return self.redirect('/auth/authenticate')
async with self.get_oidc_client() as oidc_cli:
redirect_uri = f'{self.request.protocol}://{self.request.host}/auth/authorize'
try:
LOG.info(f'Validating the authorization code')
result = await oidc_cli.authorization_code(code, redirect_uri=redirect_uri)
except aiohttp.client_exceptions.ClientConnectionError:
LOG.error(f'There was a problem validating the authorization code')
self.set_status(401)
# Happens when the code is no longer valid (e.g. if you re-visit a
# url that was tracked in the browser devtools).
self.finish('Error logging in. Would you like to <a href="/auth/login">try again?</a>')
seconds_per_day = 60 * 60 * 24
self.set_secure_cookie(
'refresh_token', result['refresh_token'],
expires_days=result['refresh_expires_in'] / seconds_per_day,
)
LOG.info(f'Fetching user info')
user_info = await oidc_cli.userinfo(result['access_token'] or '')
self.set_cookie('username', user_info['preferred_username'], expires_days=0.1)
LOG.info(f'User {user_info["preferred_username"]} successfully logged in. Redirecting to complete.')
return self.redirect(f'/auth/complete')
class LoginComplete(tornado.web.RequestHandler):
def get(self):
redirect = self.get_cookie('POST_AUTH_REDIRECT')
self.clear_cookie('POST_AUTH_REDIRECT')
if redirect is None:
LOG.info("Login complete. No redirect specified, redirecting to /")
self.redirect('/')
else:
LOG.info(f"Login complete. Redirecting to {redirect} as was initially requested")
self.redirect(redirect)
class ProbeAuthentication(tornado.web.RequestHandler):
"""A handler to return 200 if the user is logged in, and 401 if not"""
def get(self):
# Our "session" cookie is effectively the refresh_token.
refresh_token = self.get_secure_cookie('refresh_token')
self.set_header('Cache-Control', 'no-cache')
if refresh_token is None:
self.set_status(401)
else:
self.set_status(200)
username = self.get_cookie('username')
if username:
self.set_header('x_forwarded_user', username)
# Return some unique content to prevent tornado returning a 304
# (there is probably a better way).
self.finish(f'{datetime.datetime.now()}')
class Logout(tornado.web.RequestHandler, OIDCClientMixin):
async def get(self):
username = self.get_cookie('username')
if username:
LOG.info(f"Logging user {username} out")
self.clear_cookie('username')
refresh_token = self.get_secure_cookie('refresh_token')
if refresh_token:
self.clear_cookie('refresh_token')
refresh_token = refresh_token.decode()
async with self.get_oidc_client() as oicd_cli:
await oicd_cli.logout(refresh_token)
self.redirect('/')
def make_app():
return tornado.web.Application(
[
(r"/auth/probe", ProbeAuthentication),
(r'/auth/login', Login),
(r'/auth/authenticate', Authentication),
(r'/auth/authorize', Authorization),
(r'/auth/complete', LoginComplete),
(r'/auth/logout', Logout),
],
cookie_secret=os.environ['COOKIE_SECRET'],
debug=True,
oicd_server=os.environ['OIDC_SERVER'],
oicd_realm=os.environ['OIDC_REALM'],
client_id=os.environ['CLIENT_ID'],
client_secret=os.environ['CLIENT_SECRET'],
)

View file

@ -0,0 +1,9 @@
import tornado.ioloop
from . import make_app
if __name__ == "__main__":
app = make_app()
app.listen(8080)
tornado.ioloop.IOLoop.current().start()

View file

@ -0,0 +1,57 @@
"""
setup.py for auth-service.
For reference see
https://packaging.python.org/guides/distributing-packages-using-setuptools/
"""
from pathlib import Path
from setuptools import setup, find_packages
HERE = Path(__file__).parent.absolute()
with (HERE / 'README.md').open('rt') as fh:
LONG_DESCRIPTION = fh.read().strip()
REQUIREMENTS: dict = {
'core': [
'aiohttp',
'asyncio_extras; python_version<"3.7"',
'python-keycloak-client',
'tornado',
],
'test': [
],
'dev': [
],
}
setup(
name='auth-service',
version="0.0.1",
author='Phil Elson',
author_email='philip.elson@cern.ch',
description='A simple auth service that can be interrogated by NGINX',
packages=find_packages(),
python_requires='~=3.6',
classifiers=[
"Programming Language :: Python :: 3",
"Operating System :: OS Independent",
],
install_requires=REQUIREMENTS['core'],
extras_require={
**REQUIREMENTS,
# The 'dev' extra is the union of 'test' and 'doc', with an option
# to have explicit development dependencies listed.
'dev': [req
for extra in ['dev', 'test', 'doc']
for req in REQUIREMENTS.get(extra, [])],
# The 'all' extra is the union of all requirements.
'all': [req for reqs in REQUIREMENTS.values() for req in reqs],
},
)

View file

@ -2,11 +2,27 @@ version: "3.8"
services:
cara-app:
image: cara-voila-app
links:
- cara-router
cara-webservice:
image: cara-webservice
auth-service:
image: auth-service
environment:
- COOKIE_SECRET
- OIDC_SERVER
- OIDC_REALM
- CLIENT_ID
- CLIENT_SECRET
cara-router:
image: cara-nginx-app
ports:
- "8080:8080"
cara-webservice:
image: cara-webservice
- "8080:8080"
depends_on:
cara-webservice:
condition: service_started
cara-app:
condition: service_started
auth-service:
condition: service_started

View file

@ -13,7 +13,6 @@ http {
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
sendfile on;
tcp_nopush on;
tcp_nodelay on;
@ -34,7 +33,6 @@ http {
large_client_header_buffers 4 16k;
error_page 404 /404.html;
location = /40x.html {
}
@ -43,19 +41,43 @@ http {
location = /50x.html {
}
location /voila-server/ {
# cara-app is the name of the voila server in each of docker-compose,
# test-cara.web.cern.ch and cara.web.cern.ch.
proxy_pass http://cara-app:8080/voila-server/;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
location /auth {
proxy_pass_request_body off;
proxy_set_header Host $http_host;
proxy_set_header Content-Length "";
proxy_set_header If-Modified-Since "";
proxy_pass http://auth-service:8080;
}
location @error401 {
add_header Set-Cookie "POST_AUTH_REDIRECT=$request_uri;";
return 302 /auth/login;
}
location /voila-server/ {
# Anything under voila-server or expert-app is authenticated.
auth_request /auth/probe;
error_page 401 = @error401;
# Promote some auth_request response headers to proxy headers.
# In the future we could store session data on the auth server, and
# use that to store user data such as email, etc.
auth_request_set $user $upstream_http_x_forwarded_user;
proxy_set_header X-Forwarded-User $user;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
# cara-app is the name of the voila server in each of docker-compose,
# test-cara.web.cern.ch and cara.web.cern.ch.
proxy_pass http://cara-app:8080/voila-server/;
}
rewrite ^/expert-app$ /voila-server/voila/render/cara.ipynb last;
rewrite ^/(files/static)/(.*)$ /voila-server/voila/$1/$2 last;
@ -66,18 +88,29 @@ http {
rewrite ^/voila/(.*)$ /voila-server/voila/$1 redirect;
location / {
# cara-webservice is the name of the tornado server (for the calculator)
# in each of docker-compose, test-cara.web.cern.ch and cara.web.cern.ch.
proxy_pass http://cara-webservice:8080/;
# By default we have no authentication.
proxy_pass http://cara-webservice:8080;
}
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
location /calculator {
# Anything under calculator is authenticated.
auth_request /auth/probe;
error_page 401 = @error401;
# Promote some auth_request response headers to proxy headers.
# In the future we could store session data on the auth server, and
# use that to store user data such as email, etc.
auth_request_set $user $upstream_http_x_forwarded_user;
proxy_set_header X-Forwarded-User $user;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
# cara-webservice is the name of the tornado server (for the calculator)
# in each of docker-compose, test-cara.web.cern.ch and cara.web.cern.ch.
proxy_pass http://cara-webservice:8080/calculator;
}
}
}
}

View file

@ -12,22 +12,17 @@ from .user import AuthenticatedUser, AnonymousUser
class BaseRequestHandler(RequestHandler):
async def prepare(self):
"""Called at the beginning of a request before `get`/`post`/etc."""
username = self.request.headers.get("X-ADFS-LOGIN", None)
# For unauthenticated endpoints we have the username cookie if the
# user is logged in.
# For authenticated endpoints, we can expect X-Forwarded-User to be set.
username = self.get_cookie('username')
if username:
# the following headers must be set when logged in
email = self.request.headers["X-ADFS-EMAIL"]
firstname = self.request.headers["X-ADFS-FIRSTNAME"]
lastname = self.request.headers["X-ADFS-LASTNAME"]
fullname = self.request.headers["X-ADFS-FULLNAME"]
self.current_user = AuthenticatedUser(
username=username,
email=email,
firstname=firstname,
lastname=lastname,
fullname=fullname
username=html.escape(username),
)
else:
self.current_user = AnonymousUser()

View file

@ -1,7 +1,6 @@
import argparse
from tornado.ioloop import IOLoop
from tornado.options import define, options
from . import make_app
@ -16,7 +15,7 @@ def configure_parser(parser):
def main():
parser = argparse.ArgumentParser()
args = configure_parser(parser)
configure_parser(parser)
args = parser.parse_args()
app = make_app(debug=args.no_debug)
app.listen(8080)

View file

@ -15,12 +15,7 @@ class User:
@dataclass
class AuthenticatedUser(User):
username: str
email: str
firstname: str
lastname: str
fullname: str
def is_authenticated(self) -> bool:
return True

View file

@ -68,11 +68,14 @@
<li class="signin">
<span>
Signed in as:
<a href="https://cern.ch/account" class="account cern-multiple-mobile-signin">
{{ user.fullname }}
{{ user.username }}
</a>
</span>
</li>
<!--
// Disabled until we tidy up the CSS... ;)
<li><a href="/auth/logout">Sign out</a></li>
-->
{% endif %}
<li><a href="https://cern.ch/directory" class="cern-directory ext"title="Search CERN resources and browse the directory" data-extlink="">Directory</a></li>
</ul>