diff --git a/README.md b/README.md index bea592b6..21616e52 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/app-config/auth-service/README.md b/app-config/auth-service/README.md new file mode 100644 index 00000000..7ac26e93 --- /dev/null +++ b/app-config/auth-service/README.md @@ -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. diff --git a/app-config/auth-service/app.sh b/app-config/auth-service/app.sh new file mode 100755 index 00000000..2605d1ec --- /dev/null +++ b/app-config/auth-service/app.sh @@ -0,0 +1 @@ +python -m auth_service diff --git a/app-config/auth-service/auth_service/__init__.py b/app-config/auth-service/auth_service/__init__.py new file mode 100644 index 00000000..d2884e81 --- /dev/null +++ b/app-config/auth-service/auth_service/__init__.py @@ -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 try again?') + 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'], + ) diff --git a/app-config/auth-service/auth_service/__main__.py b/app-config/auth-service/auth_service/__main__.py new file mode 100644 index 00000000..f8e10d12 --- /dev/null +++ b/app-config/auth-service/auth_service/__main__.py @@ -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() diff --git a/app-config/auth-service/setup.py b/app-config/auth-service/setup.py new file mode 100644 index 00000000..da5e0253 --- /dev/null +++ b/app-config/auth-service/setup.py @@ -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], + }, +) diff --git a/app-config/docker-compose.yml b/app-config/docker-compose.yml index 2f387b61..b8f6be7c 100644 --- a/app-config/docker-compose.yml +++ b/app-config/docker-compose.yml @@ -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 diff --git a/app-config/nginx/nginx.conf b/app-config/nginx/nginx.conf index c58dd122..24c257db 100644 --- a/app-config/nginx/nginx.conf +++ b/app-config/nginx/nginx.conf @@ -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; } } -} \ No newline at end of file +} diff --git a/cara/apps/calculator/__init__.py b/cara/apps/calculator/__init__.py index 437fffb1..54230b3e 100644 --- a/cara/apps/calculator/__init__.py +++ b/cara/apps/calculator/__init__.py @@ -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() diff --git a/cara/apps/calculator/__main__.py b/cara/apps/calculator/__main__.py index 0db54a65..55c134f7 100644 --- a/cara/apps/calculator/__main__.py +++ b/cara/apps/calculator/__main__.py @@ -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) diff --git a/cara/apps/calculator/user.py b/cara/apps/calculator/user.py index 5169faba..525ef996 100644 --- a/cara/apps/calculator/user.py +++ b/cara/apps/calculator/user.py @@ -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 diff --git a/cara/apps/templates/layout.html.j2 b/cara/apps/templates/layout.html.j2 index 61766c20..0b86c53d 100644 --- a/cara/apps/templates/layout.html.j2 +++ b/cara/apps/templates/layout.html.j2 @@ -68,11 +68,14 @@