2021-02-26 20:36:20 +00:00
|
|
|
try:
|
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
except ImportError:
|
|
|
|
|
# Python 3.6
|
|
|
|
|
from asyncio_extras import async_contextmanager as asynccontextmanager
|
2021-03-01 17:38:36 +00:00
|
|
|
import json
|
2021-02-26 20:36:20 +00:00
|
|
|
import logging
|
|
|
|
|
import os
|
2021-03-01 17:38:36 +00:00
|
|
|
import typing
|
2021-02-26 20:36:20 +00:00
|
|
|
|
|
|
|
|
import aiohttp
|
|
|
|
|
from keycloak.aio.realm import KeycloakRealm
|
|
|
|
|
import tornado.ioloop
|
|
|
|
|
import tornado.web
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
2021-03-01 17:38:36 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2021-02-26 20:36:20 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2021-03-01 17:38:36 +00:00
|
|
|
class Login(BaseHandler):
|
2021-02-26 20:36:20 +00:00
|
|
|
async def get(self):
|
|
|
|
|
# Initiate the OICD flow.
|
|
|
|
|
return self.redirect('/auth/authenticate')
|
|
|
|
|
|
|
|
|
|
|
2021-03-01 17:38:36 +00:00
|
|
|
class Authentication(BaseHandler, OIDCClientMixin):
|
2021-02-26 20:36:20 +00:00
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
|
2021-03-01 17:38:36 +00:00
|
|
|
class Authorization(BaseHandler, OIDCClientMixin):
|
2021-02-26 20:36:20 +00:00
|
|
|
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>')
|
|
|
|
|
|
2021-03-01 17:38:36 +00:00
|
|
|
|
2021-02-26 20:36:20 +00:00
|
|
|
LOG.info(f'Fetching user info')
|
|
|
|
|
user_info = await oidc_cli.userinfo(result['access_token'] or '')
|
|
|
|
|
|
2021-03-01 17:38:36 +00:00
|
|
|
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.')
|
2021-02-26 20:36:20 +00:00
|
|
|
return self.redirect(f'/auth/complete')
|
|
|
|
|
|
|
|
|
|
|
2021-03-01 17:38:36 +00:00
|
|
|
class LoginComplete(BaseHandler):
|
2021-02-26 20:36:20 +00:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2021-03-01 17:38:36 +00:00
|
|
|
class ProbeAuthentication(BaseHandler):
|
2021-02-26 20:36:20 +00:00
|
|
|
"""A handler to return 200 if the user is logged in, and 401 if not"""
|
2021-03-01 17:38:36 +00:00
|
|
|
def check_etag_header(self):
|
|
|
|
|
# We should never cache the result.
|
|
|
|
|
return False
|
2021-02-26 20:36:20 +00:00
|
|
|
|
2021-03-01 17:38:36 +00:00
|
|
|
def get(self):
|
|
|
|
|
session = self.get_secure_cookie('session')
|
|
|
|
|
if session is None:
|
2021-02-26 20:36:20 +00:00
|
|
|
self.set_status(401)
|
|
|
|
|
else:
|
|
|
|
|
self.set_status(200)
|
2021-03-01 17:38:36 +00:00
|
|
|
self.set_header('Cache-Control', 'no-cache')
|
|
|
|
|
self.finish()
|
2021-02-26 20:36:20 +00:00
|
|
|
|
|
|
|
|
|
2021-03-01 17:38:36 +00:00
|
|
|
class Logout(BaseHandler, OIDCClientMixin):
|
2021-02-26 20:36:20 +00:00
|
|
|
async def get(self):
|
2021-03-01 17:38:36 +00:00
|
|
|
session = self.get_session_cookie()
|
|
|
|
|
if session:
|
|
|
|
|
LOG.info(f"Logging user {session['username']} out")
|
|
|
|
|
self.clear_cookie('session')
|
|
|
|
|
refresh_token = session['refresh_token']
|
2021-02-26 20:36:20 +00:00
|
|
|
async with self.get_oidc_client() as oicd_cli:
|
2021-03-01 17:38:36 +00:00
|
|
|
try:
|
|
|
|
|
await oicd_cli.logout(refresh_token)
|
|
|
|
|
except aiohttp.client_exceptions.ClientConnectionError:
|
|
|
|
|
LOG.warn(
|
|
|
|
|
'There was a problem logging out (refresh_token expired?).'
|
|
|
|
|
)
|
2021-02-26 20:36:20 +00:00
|
|
|
self.redirect('/')
|
|
|
|
|
|
|
|
|
|
|
2021-03-01 17:38:36 +00:00
|
|
|
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>
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
2021-02-26 20:36:20 +00:00
|
|
|
def make_app():
|
|
|
|
|
return tornado.web.Application(
|
|
|
|
|
[
|
2021-03-01 17:38:36 +00:00
|
|
|
(r"/", MainHandler),
|
2021-02-26 20:36:20 +00:00
|
|
|
(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'],
|
|
|
|
|
)
|