From 8611cdef4d0185ac35c24ce8efa9a4ad4f8de7a1 Mon Sep 17 00:00:00 2001 From: Phil Elson Date: Mon, 1 Mar 2021 18:38:36 +0100 Subject: [PATCH] Improve the auth-service to encode the session information into a single secure cookie that cara can read. --- app-config/auth-service/README.md | 13 ++- .../auth-service/auth_service/__init__.py | 107 +++++++++++------- app-config/docker-compose.yml | 2 + app-config/nginx/nginx.conf | 59 ++++------ cara/apps/calculator/__init__.py | 17 ++- cara/apps/calculator/user.py | 2 + cara/apps/templates/layout.html.j2 | 1 - 7 files changed, 113 insertions(+), 88 deletions(-) diff --git a/app-config/auth-service/README.md b/app-config/auth-service/README.md index 7ac26e93..8f13cb3e 100644 --- a/app-config/auth-service/README.md +++ b/app-config/auth-service/README.md @@ -17,14 +17,15 @@ 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. +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 again to -a 404) then re-visit http://localhost:8080/auth/probe to confirm you get a 401 again. +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 & 404 errors. -The idea of this service is to be able to use it using +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 diff --git a/app-config/auth-service/auth_service/__init__.py b/app-config/auth-service/auth_service/__init__.py index d2884e81..501c3926 100644 --- a/app-config/auth-service/auth_service/__init__.py +++ b/app-config/auth-service/auth_service/__init__.py @@ -3,9 +3,10 @@ try: except ImportError: # Python 3.6 from asyncio_extras import async_contextmanager as asynccontextmanager -import datetime +import json import logging import os +import typing import aiohttp from keycloak.aio.realm import KeycloakRealm @@ -16,6 +17,19 @@ 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): @@ -33,13 +47,13 @@ class OIDCClientMixin: yield oidc_client -class Login(tornado.web.RequestHandler): +class Login(BaseHandler): async def get(self): # Initiate the OICD flow. return self.redirect('/auth/authenticate') -class Authentication(tornado.web.RequestHandler, OIDCClientMixin): +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' @@ -47,7 +61,7 @@ class Authentication(tornado.web.RequestHandler, OIDCClientMixin): return self.redirect(oidc_cli.authorization_url(redirect_uri=redirect_uri)) -class Authorization(tornado.web.RequestHandler, OIDCClientMixin): +class Authorization(BaseHandler, OIDCClientMixin): async def get(self): code = self.get_argument('code', None) if code is None: @@ -67,21 +81,28 @@ class Authorization(tornado.web.RequestHandler, OIDCClientMixin): # 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.') + session_data = { + 'refresh_token': result['refresh_token'], + 'username': user_info['preferred_username'], + '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(tornado.web.RequestHandler): +class LoginComplete(BaseHandler): def get(self): redirect = self.get_cookie('POST_AUTH_REDIRECT') self.clear_cookie('POST_AUTH_REDIRECT') @@ -93,47 +114,57 @@ class LoginComplete(tornado.web.RequestHandler): self.redirect(redirect) -class ProbeAuthentication(tornado.web.RequestHandler): +class ProbeAuthentication(BaseHandler): """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') + def check_etag_header(self): + # We should never cache the result. + return False - if refresh_token is None: + def get(self): + session = self.get_secure_cookie('session') + if session 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()}') + self.set_header('Cache-Control', 'no-cache') + self.finish() -class Logout(tornado.web.RequestHandler, OIDCClientMixin): +class Logout(BaseHandler, 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() + 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: - await oicd_cli.logout(refresh_token) - + 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: Login + """) + else: + return self.finish(f""" + You are currently logged in as "{session['username']}": + Logout + """) + + def make_app(): return tornado.web.Application( [ + (r"/", MainHandler), (r"/auth/probe", ProbeAuthentication), (r'/auth/login', Login), (r'/auth/authenticate', Authentication), diff --git a/app-config/docker-compose.yml b/app-config/docker-compose.yml index b8f6be7c..e3d76f0c 100644 --- a/app-config/docker-compose.yml +++ b/app-config/docker-compose.yml @@ -5,6 +5,8 @@ services: cara-webservice: image: cara-webservice + environment: + - COOKIE_SECRET auth-service: image: auth-service diff --git a/app-config/nginx/nginx.conf b/app-config/nginx/nginx.conf index 24c257db..0895e583 100644 --- a/app-config/nginx/nginx.conf +++ b/app-config/nginx/nginx.conf @@ -41,6 +41,11 @@ 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; @@ -50,34 +55,25 @@ http { proxy_set_header Host $http_host; proxy_set_header Content-Length ""; - proxy_set_header If-Modified-Since ""; + 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/ { - # 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; + # Anything under voila-server or expert-app 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-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/; + # 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; @@ -88,29 +84,18 @@ http { rewrite ^/voila/(.*)$ /voila-server/voila/$1 redirect; location / { - # By default we have no authentication. - proxy_pass http://cara-webservice:8080; + # By default we have no authentication. + proxy_pass http://cara-webservice:8080; } 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; + # 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; + # 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; } } } diff --git a/cara/apps/calculator/__init__.py b/cara/apps/calculator/__init__.py index 54230b3e..dd225de8 100644 --- a/cara/apps/calculator/__init__.py +++ b/cara/apps/calculator/__init__.py @@ -1,5 +1,6 @@ import html import json +import os from pathlib import Path import jinja2 @@ -15,14 +16,15 @@ class BaseRequestHandler(RequestHandler): async def prepare(self): """Called at the beginning of a request before `get`/`post`/etc.""" - # 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') + # 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 username: + if session: self.current_user = AuthenticatedUser( - username=html.escape(username), + username=session['username'], + email=session['email'], + fullname=session['fullname'], ) else: self.current_user = AnonymousUser() @@ -117,4 +119,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', ''), ) diff --git a/cara/apps/calculator/user.py b/cara/apps/calculator/user.py index 525ef996..3f35094a 100644 --- a/cara/apps/calculator/user.py +++ b/cara/apps/calculator/user.py @@ -16,6 +16,8 @@ class User: @dataclass class AuthenticatedUser(User): username: str + email: 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 0b86c53d..334b8f6d 100644 --- a/cara/apps/templates/layout.html.j2 +++ b/cara/apps/templates/layout.html.j2 @@ -69,7 +69,6 @@ Signed in as: {{ user.username }} -