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