Improve the auth-service to encode the session information into a single secure cookie that cara can read.

This commit is contained in:
Phil Elson 2021-03-01 18:38:36 +01:00
parent 85a378691e
commit 8611cdef4d
7 changed files with 113 additions and 88 deletions

View file

@ -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

View file

@ -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 <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.')
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: <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),

View file

@ -5,6 +5,8 @@ services:
cara-webservice:
image: cara-webservice
environment:
- COOKIE_SECRET
auth-service:
image: auth-service

View file

@ -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;
}
}
}

View file

@ -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', '<undefined>'),
)

View file

@ -16,6 +16,8 @@ class User:
@dataclass
class AuthenticatedUser(User):
username: str
email: str
fullname: str
def is_authenticated(self) -> bool:
return True

View file

@ -69,7 +69,6 @@
<span>
Signed in as:
{{ user.username }}
</a>
</span>
</li>
<!--