Implement OIDC through nginx's ngx_http_auth_request_module module.
This commit is contained in:
parent
1ff9a4d352
commit
85a378691e
12 changed files with 355 additions and 42 deletions
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
54
app-config/auth-service/README.md
Normal file
54
app-config/auth-service/README.md
Normal 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
1
app-config/auth-service/app.sh
Executable file
|
|
@ -0,0 +1 @@
|
|||
python -m auth_service
|
||||
150
app-config/auth-service/auth_service/__init__.py
Normal file
150
app-config/auth-service/auth_service/__init__.py
Normal 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'],
|
||||
)
|
||||
9
app-config/auth-service/auth_service/__main__.py
Normal file
9
app-config/auth-service/auth_service/__main__.py
Normal 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()
|
||||
57
app-config/auth-service/setup.py
Normal file
57
app-config/auth-service/setup.py
Normal 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],
|
||||
},
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue