Merge branch 'feature/oidc-nginx' into 'master'

Implement authentication through nginx's ngx_http_auth_request_module module

See merge request cara/cara!139
This commit is contained in:
Nicola Tarocco 2021-03-02 17:54:13 +00:00
commit 18af16f749
13 changed files with 390 additions and 49 deletions

View file

@ -27,6 +27,7 @@ trigger_build_on_openshift:
- curl -X POST -k https://openshift.cern.ch:443/apis/build.openshift.io/v1/namespaces/cara/buildconfigs/cara-app/webhooks/${OPENSHIFT_BUILD_WEBHOOK_SECRET}/generic
- curl -X POST -k https://openshift.cern.ch:443/apis/build.openshift.io/v1/namespaces/cara/buildconfigs/cara-router/webhooks/${OPENSHIFT_BUILD_WEBHOOK_SECRET}/generic
- curl -X POST -k https://openshift.cern.ch:443/apis/build.openshift.io/v1/namespaces/cara/buildconfigs/cara-webservice/webhooks/${OPENSHIFT_BUILD_WEBHOOK_SECRET}/generic
- curl -X POST -k https://openshift.cern.ch:443/apis/build.openshift.io/v1/namespaces/cara/buildconfigs/auth-service/webhooks/${OPENSHIFT_BUILD_WEBHOOK_SECRET}/generic
deploy_to_test:
@ -37,3 +38,4 @@ deploy_to_test:
- curl -X POST -k https://openshift-dev.cern.ch:443/apis/build.openshift.io/v1/namespaces/test-cara/buildconfigs/cara-app/webhooks/${OPENSHIFT_TEST_BUILD_WEBHOOK_SECRET}/generic
- curl -X POST -k https://openshift-dev.cern.ch:443/apis/build.openshift.io/v1/namespaces/test-cara/buildconfigs/cara-router/webhooks/${OPENSHIFT_TEST_BUILD_WEBHOOK_SECRET}/generic
- curl -X POST -k https://openshift-dev.cern.ch:443/apis/build.openshift.io/v1/namespaces/test-cara/buildconfigs/cara-webservice/webhooks/${OPENSHIFT_TEST_BUILD_WEBHOOK_SECRET}/generic
- curl -X POST -k https://openshift-dev.cern.ch:443/apis/build.openshift.io/v1/namespaces/test-cara/buildconfigs/auth-service/webhooks/${OPENSHIFT_TEST_BUILD_WEBHOOK_SECRET}/generic

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,55 @@
# 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 be told that you are now logged in.
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 to
http://localhost:8080 with no login setup. Confirm this by again visiting
http://localhost:8080/auth/probe and getting a 401 again.
At this point, you may be wondering why would you want so many 401 errors.
The idea of this service is to be able to use
[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,180 @@
try:
from contextlib import asynccontextmanager
except ImportError:
# Python 3.6
from asyncio_extras import async_contextmanager as asynccontextmanager
import json
import logging
import os
import typing
import aiohttp
from keycloak.aio.realm import KeycloakRealm
import tornado.ioloop
import tornado.web
LOG = logging.getLogger(__name__)
class BaseHandler(tornado.web.RequestHandler):
def set_session_cookie(self, session_data: dict, expiry_in_seconds: int) -> None:
seconds_per_day = 60 * 60 * 24
self.set_secure_cookie(
'session', json.dumps(session_data),
expires_days=expiry_in_seconds / seconds_per_day,
)
def get_session_cookie(self) -> typing.Optional[dict]:
session_data = json.loads(self.get_secure_cookie('session') or 'null')
return session_data
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(BaseHandler):
async def get(self):
# Initiate the OICD flow.
return self.redirect('/auth/authenticate')
class Authentication(BaseHandler, 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(BaseHandler, 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>')
LOG.info(f'Fetching user info')
user_info = await oidc_cli.userinfo(result['access_token'] or '')
session_data = {
'refresh_token': result['refresh_token'],
'username': user_info.get('preferred_username', user_info['email']),
'fullname': user_info['name'],
'email': user_info['email'],
}
self.set_session_cookie(
session_data,
expiry_in_seconds=result['refresh_expires_in'],
)
LOG.info(f'User {session_data["username"]} successfully logged in. Redirecting to complete.')
return self.redirect(f'/auth/complete')
class LoginComplete(BaseHandler):
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(BaseHandler):
"""A handler to return 200 if the user is logged in, and 401 if not"""
def check_etag_header(self):
# We should never cache the result.
return False
def get(self):
session = self.get_secure_cookie('session')
if session is None:
self.set_status(401)
else:
self.set_status(200)
self.set_header('Cache-Control', 'no-cache')
self.finish()
class Logout(BaseHandler, OIDCClientMixin):
async def get(self):
session = self.get_session_cookie()
if session:
LOG.info(f"Logging user {session['username']} out")
self.clear_cookie('session')
refresh_token = session['refresh_token']
async with self.get_oidc_client() as oicd_cli:
try:
await oicd_cli.logout(refresh_token)
except aiohttp.client_exceptions.ClientConnectionError:
LOG.warn(
'There was a problem logging out (refresh_token expired?).'
)
self.redirect('/')
class MainHandler(BaseHandler):
async def get(self):
session = self.get_session_cookie()
if session is None:
return self.finish("""
You are currently not logged in: <a href="/auth/login">Login</a>
""")
else:
return self.finish(f"""
You are currently logged in as "{session['username']}":
<a href="/auth/logout">Logout</a>
""")
def make_app():
return tornado.web.Application(
[
(r"/", MainHandler),
(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,29 @@ version: "3.8"
services:
cara-app:
image: cara-voila-app
links:
- cara-router
cara-webservice:
image: cara-webservice
environment:
- COOKIE_SECRET
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,39 @@ http {
location = /50x.html {
}
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
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;
location /auth {
proxy_pass_request_body off;
proxy_set_header Host $http_host;
proxy_set_header Content-Length "";
proxy_set_header If-None-Match "";
proxy_pass http://auth-service:8080;
}
location @error401 {
# Store the request_uri (complete with args) to be redirected to
# when we hit /auth/complete.
add_header Set-Cookie "POST_AUTH_REDIRECT=$request_uri;";
return 302 /auth/login;
}
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/;
# Anything under voila-server or expert-app is authenticated.
auth_request /auth/probe;
error_page 401 = @error401;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
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 +84,18 @@ 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;
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

@ -1,5 +1,6 @@
import html
import json
import os
from pathlib import Path
import jinja2
@ -12,22 +13,18 @@ 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)
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"]
# Read the secure cookie which exists if we are in an authenticated
# context (though not if the cara webservice is running standalone).
session = json.loads(self.get_secure_cookie('session') or 'null')
if session:
self.current_user = AuthenticatedUser(
username=username,
email=email,
firstname=firstname,
lastname=lastname,
fullname=fullname
username=session['username'],
email=session['email'],
fullname=session['fullname'],
)
else:
self.current_user = AnonymousUser()
@ -124,4 +121,7 @@ def make_app(debug=False, prefix='/calculator'):
debug=debug,
template_environment=template_environment,
xsrf_cookies=True,
# COOKIE_SECRET being undefined will result in no login information being
# presented to the user.
cookie_secret=os.environ.get('COOKIE_SECRET', '<undefined>'),
)

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,11 +15,8 @@ class User:
@dataclass
class AuthenticatedUser(User):
username: str
email: str
firstname: str
lastname: str
fullname: str
def is_authenticated(self) -> bool:

View file

@ -68,11 +68,15 @@
<li class="signin">
<span>
Signed in as:
<a href="https://cern.ch/account" class="account cern-multiple-mobile-signin">
{{ user.fullname }}
<a href="" class="account cern-multiple-mobile-signin">
{{ 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>