Improve the auth-service to encode the session information into a single secure cookie that cara can read.
This commit is contained in:
parent
85a378691e
commit
8611cdef4d
7 changed files with 113 additions and 88 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ services:
|
|||
|
||||
cara-webservice:
|
||||
image: cara-webservice
|
||||
environment:
|
||||
- COOKIE_SECRET
|
||||
|
||||
auth-service:
|
||||
image: auth-service
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>'),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ class User:
|
|||
@dataclass
|
||||
class AuthenticatedUser(User):
|
||||
username: str
|
||||
email: str
|
||||
fullname: str
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@
|
|||
<span>
|
||||
Signed in as:
|
||||
{{ user.username }}
|
||||
</a>
|
||||
</span>
|
||||
</li>
|
||||
<!--
|
||||
|
|
|
|||
Loading…
Reference in a new issue