mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-18 07:23:00 +00:00
Update service examples and documentation with access scopes and roles
This commit is contained in:
@@ -86,10 +86,18 @@ Hub-Managed Service would include:
|
|||||||
This example would be configured as follows in `jupyterhub_config.py`:
|
This example would be configured as follows in `jupyterhub_config.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
c.JupyterHub.load_roles = [
|
||||||
|
{
|
||||||
|
"name": "idle-culler",
|
||||||
|
"scopes": [
|
||||||
|
"users:servers",
|
||||||
|
# also 'admin:users' if culling idle users as well
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
c.JupyterHub.services = [
|
c.JupyterHub.services = [
|
||||||
{
|
{
|
||||||
'name': 'idle-culler',
|
'name': 'idle-culler',
|
||||||
'admin': True,
|
|
||||||
'command': [sys.executable, '-m', 'jupyterhub_idle_culler', '--timeout=3600']
|
'command': [sys.executable, '-m', 'jupyterhub_idle_culler', '--timeout=3600']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -114,6 +122,7 @@ JUPYTERHUB_BASE_URL: Base URL of the Hub (https://mydomain[:port]/)
|
|||||||
JUPYTERHUB_SERVICE_PREFIX: URL path prefix of this service (/services/:service-name/)
|
JUPYTERHUB_SERVICE_PREFIX: URL path prefix of this service (/services/:service-name/)
|
||||||
JUPYTERHUB_SERVICE_URL: Local URL where the service is expected to be listening.
|
JUPYTERHUB_SERVICE_URL: Local URL where the service is expected to be listening.
|
||||||
Only for proxied web services.
|
Only for proxied web services.
|
||||||
|
JUPYTERHUB_OAUTH_SCOPES: JSON-serialized list of scopes to use for allowing access to the service.
|
||||||
```
|
```
|
||||||
|
|
||||||
For the previous 'cull idle' Service example, these environment variables
|
For the previous 'cull idle' Service example, these environment variables
|
||||||
@@ -231,50 +240,8 @@ service. See the `service-whoami-flask` example in the
|
|||||||
[JupyterHub GitHub repo](https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-whoami-flask)
|
[JupyterHub GitHub repo](https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-whoami-flask)
|
||||||
for more details.
|
for more details.
|
||||||
|
|
||||||
```python
|
```{literalinclude} ../../../examples/service-whoami-flask/whoami-flask.py
|
||||||
from functools import wraps
|
:language: python
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
from flask import Flask, redirect, request, Response
|
|
||||||
|
|
||||||
from jupyterhub.services.auth import HubOAuth
|
|
||||||
|
|
||||||
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
|
|
||||||
|
|
||||||
auth = HubOAuth(
|
|
||||||
api_token=os.environ['JUPYTERHUB_API_TOKEN'],
|
|
||||||
cache_max_age=60,
|
|
||||||
)
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def authenticated(f):
|
|
||||||
"""Decorator for authenticating with the Hub"""
|
|
||||||
@wraps(f)
|
|
||||||
def decorated(*args, **kwargs):
|
|
||||||
token = request.headers.get(auth.auth_header_name)
|
|
||||||
if token:
|
|
||||||
user = auth.user_for_token(token)
|
|
||||||
else:
|
|
||||||
user = None
|
|
||||||
if user:
|
|
||||||
return f(user, *args, **kwargs)
|
|
||||||
else:
|
|
||||||
# redirect to login url on failed auth
|
|
||||||
return redirect(auth.login_url + '?next=%s' % quote(request.path))
|
|
||||||
return decorated
|
|
||||||
|
|
||||||
|
|
||||||
@app.route(prefix)
|
|
||||||
@authenticated
|
|
||||||
def whoami(user):
|
|
||||||
return Response(
|
|
||||||
json.dumps(user, indent=1, sort_keys=True),
|
|
||||||
mimetype='application/json',
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Authenticating tornado services with JupyterHub
|
### Authenticating tornado services with JupyterHub
|
||||||
@@ -315,25 +282,38 @@ undefined, then any user will be allowed.
|
|||||||
If you don't want to use the reference implementation
|
If you don't want to use the reference implementation
|
||||||
(e.g. you find the implementation a poor fit for your Flask app),
|
(e.g. you find the implementation a poor fit for your Flask app),
|
||||||
you can implement authentication via the Hub yourself.
|
you can implement authentication via the Hub yourself.
|
||||||
We recommend looking at the [`HubAuth`][hubauth] class implementation for reference,
|
JupyterHub is a standard OAuth2 provider,
|
||||||
|
so you can any OAuth 2 implementation appropriate for
|
||||||
|
See the [FastAPI example][] for an example of using JupyterHub as an OAuth provider with fastapi,
|
||||||
|
without using any code imported from JupyterHub.
|
||||||
|
|
||||||
|
On completion of OAuth, you will have an access token for JupyterHub,
|
||||||
|
which can be used to identify the user and the permissions (scopes)
|
||||||
|
the user has authorized for your service.
|
||||||
|
|
||||||
|
You will only get to this stage if the user has the required `access:services!service=$service-name` scope.
|
||||||
|
|
||||||
|
To retrieve the user model for the token, make a request to `GET /hub/api/user` with the token in the Authorization header.
|
||||||
|
For example, using flask:
|
||||||
|
|
||||||
|
```{literal-include} ../../../examples/service-whoami-flask/whoami-flask.py
|
||||||
|
:language: python
|
||||||
|
```
|
||||||
|
|
||||||
|
We recommend looking at the [`HubOAuth`][huboauth] class implementation for reference,
|
||||||
and taking note of the following process:
|
and taking note of the following process:
|
||||||
|
|
||||||
1. retrieve the cookie `jupyterhub-services` from the request.
|
1. retrieve the token from the request.
|
||||||
2. Make an API request `GET /hub/api/authorizations/cookie/jupyterhub-services/cookie-value`,
|
2. Make an API request `GET /hub/api/user`,
|
||||||
where cookie-value is the url-encoded value of the `jupyterhub-services` cookie.
|
with the token in the `Authorization` header.
|
||||||
This request must be authenticated with a Hub API token in the `Authorization` header,
|
|
||||||
for example using the `api_token` from your [external service's configuration](#externally-managed-services).
|
|
||||||
|
|
||||||
For example, with [requests][]:
|
For example, with [requests][]:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
r = requests.get(
|
r = requests.get(
|
||||||
'/'.join(["http://127.0.0.1:8081/hub/api",
|
"http://127.0.0.1:8081/hub/api/user",
|
||||||
"authorizations/cookie/jupyterhub-services",
|
|
||||||
quote(encrypted_cookie, safe=''),
|
|
||||||
]),
|
|
||||||
headers = {
|
headers = {
|
||||||
'Authorization' : 'token %s' % api_token,
|
'Authorization' : f'token {api_token}',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
@@ -342,13 +322,27 @@ and taking note of the following process:
|
|||||||
|
|
||||||
3. On success, the reply will be a JSON model describing the user:
|
3. On success, the reply will be a JSON model describing the user:
|
||||||
|
|
||||||
```json
|
```python
|
||||||
{
|
{
|
||||||
"name": "inara",
|
"name": "inara",
|
||||||
"groups": ["serenity", "guild"]
|
# groups may be omitted, depending on permissions
|
||||||
|
"groups": ["serenity", "guild"],
|
||||||
|
# scopes is new in JupyterHub 2.0
|
||||||
|
"scopes": [
|
||||||
|
"access:services",
|
||||||
|
"read:users:name",
|
||||||
|
"read:users!user=inara",
|
||||||
|
"..."
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The `scopes` field can be used to manage access.
|
||||||
|
Note: a user will have access to a service to complete oauth access to the service for the first time.
|
||||||
|
Individual permissions may be revoked at any later point without revoking the token,
|
||||||
|
in which case the `scopes` field in this model should be checked on each access.
|
||||||
|
The default required scopes for access are available from `hub_auth.oauth_scopes` or `$JUPYTERHUB_OAUTH_SCOPES`.
|
||||||
|
|
||||||
An example of using an Externally-Managed Service and authentication is
|
An example of using an Externally-Managed Service and authentication is
|
||||||
in [nbviewer README][nbviewer example] section on securing the notebook viewer,
|
in [nbviewer README][nbviewer example] section on securing the notebook viewer,
|
||||||
and an example of its configuration is found [here](https://github.com/jupyter/nbviewer/blob/ed942b10a52b6259099e2dd687930871dc8aac22/nbviewer/providers/base.py#L95).
|
and an example of its configuration is found [here](https://github.com/jupyter/nbviewer/blob/ed942b10a52b6259099e2dd687930871dc8aac22/nbviewer/providers/base.py#L95).
|
||||||
@@ -357,9 +351,9 @@ section on securing the notebook viewer.
|
|||||||
|
|
||||||
[requests]: http://docs.python-requests.org/en/master/
|
[requests]: http://docs.python-requests.org/en/master/
|
||||||
[services_auth]: ../api/services.auth.html
|
[services_auth]: ../api/services.auth.html
|
||||||
[hubauth]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth
|
[huboauth]: ../api/services.auth.html#jupyterhub.services.auth.HubOAuth
|
||||||
[hubauth.user_for_cookie]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_cookie
|
|
||||||
[hubauth.user_for_token]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token
|
[hubauth.user_for_token]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token
|
||||||
[hubauthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
|
[hubauthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
|
||||||
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
|
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
|
||||||
|
[fastapi example]: https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-fastapi
|
||||||
[jupyterhub_idle_culler]: https://github.com/jupyterhub/jupyterhub-idle-culler
|
[jupyterhub_idle_culler]: https://github.com/jupyterhub/jupyterhub-idle-culler
|
||||||
|
@@ -1,10 +1,10 @@
|
|||||||
# Configuration file for jupyterhub (postgres example).
|
# Configuration file for jupyterhub (postgres example).
|
||||||
|
|
||||||
c = get_config()
|
c = get_config() # noqa
|
||||||
|
|
||||||
# Add some users.
|
# Add some users.
|
||||||
c.JupyterHub.admin_users = {'rhea'}
|
c.JupyterHub.admin_users = {'rhea'}
|
||||||
c.Authenticator.whitelist = {'ganymede', 'io', 'rhea'}
|
c.Authenticator.allowed_users = {'ganymede', 'io', 'rhea'}
|
||||||
|
|
||||||
# These environment variables are automatically supplied by the linked postgres
|
# These environment variables are automatically supplied by the linked postgres
|
||||||
# container.
|
# container.
|
||||||
|
@@ -6,15 +6,17 @@ that appear when JupyterHub renders pages.
|
|||||||
To run the service as a hub-managed service simply include in your JupyterHub
|
To run the service as a hub-managed service simply include in your JupyterHub
|
||||||
configuration file something like:
|
configuration file something like:
|
||||||
|
|
||||||
c.JupyterHub.services = [
|
```python
|
||||||
|
c.JupyterHub.services = [
|
||||||
{
|
{
|
||||||
'name': 'announcement',
|
'name': 'announcement',
|
||||||
'url': 'http://127.0.0.1:8888',
|
'url': 'http://127.0.0.1:8888',
|
||||||
'command': [sys.executable, "-m", "announcement"],
|
'command': [sys.executable, "-m", "announcement", "--port", "8888"],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
```
|
||||||
|
|
||||||
This starts the announcements service up at `/services/announcement` when
|
This starts the announcements service up at `/services/announcement/` when
|
||||||
JupyterHub launches. By default the announcement text is empty.
|
JupyterHub launches. By default the announcement text is empty.
|
||||||
|
|
||||||
The `announcement` module has a configurable port (default 8888) and an API
|
The `announcement` module has a configurable port (default 8888) and an API
|
||||||
@@ -23,15 +25,28 @@ that environment variable is set or `/` if it is not.
|
|||||||
|
|
||||||
## Managing the Announcement
|
## Managing the Announcement
|
||||||
|
|
||||||
Admin users can set the announcement text with an API token:
|
Users with permission can set the announcement text with an API token:
|
||||||
|
|
||||||
$ curl -X POST -H "Authorization: token <token>" \
|
$ curl -X POST -H "Authorization: token <token>" \
|
||||||
-d '{"announcement":"JupyterHub will be upgraded on August 14!"}' \
|
-d '{"announcement":"JupyterHub will be upgraded on August 14!"}' \
|
||||||
https://.../services/announcement
|
https://.../services/announcement/
|
||||||
|
|
||||||
|
To grant permission, add a role (JupyterHub 2.0) with access to the announcement service:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# grant the 'announcer' permission to access the announcement service
|
||||||
|
c.JupyterHub.load_roles = [
|
||||||
|
{
|
||||||
|
"name": "announcers",
|
||||||
|
"users": ["announcer"], # or groups
|
||||||
|
"scopes": ["access:services!service=announcement"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
Anyone can read the announcement:
|
Anyone can read the announcement:
|
||||||
|
|
||||||
$ curl https://.../services/announcement | python -m json.tool
|
$ curl https://.../services/announcement/ | python -m json.tool
|
||||||
{
|
{
|
||||||
announcement: "JupyterHub will be upgraded on August 14!",
|
announcement: "JupyterHub will be upgraded on August 14!",
|
||||||
timestamp: "...",
|
timestamp: "...",
|
||||||
@@ -41,10 +56,11 @@ Anyone can read the announcement:
|
|||||||
The time the announcement was posted is recorded in the `timestamp` field and
|
The time the announcement was posted is recorded in the `timestamp` field and
|
||||||
the user who posted the announcement is recorded in the `user` field.
|
the user who posted the announcement is recorded in the `user` field.
|
||||||
|
|
||||||
To clear the announcement text, just DELETE. Only admin users can do this.
|
To clear the announcement text, send a DELETE request.
|
||||||
|
This has the same permissionOnly admin users can do this.
|
||||||
|
|
||||||
$ curl -X POST -H "Authorization: token <token>" \
|
$ curl -X DELETE -H "Authorization: token <token>" \
|
||||||
https://.../services/announcement
|
https://.../services/announcement/
|
||||||
|
|
||||||
## Seeing the Announcement in JupyterHub
|
## Seeing the Announcement in JupyterHub
|
||||||
|
|
||||||
|
@@ -13,9 +13,6 @@ from jupyterhub.services.auth import HubAuthenticated
|
|||||||
class AnnouncementRequestHandler(HubAuthenticated, web.RequestHandler):
|
class AnnouncementRequestHandler(HubAuthenticated, web.RequestHandler):
|
||||||
"""Dynamically manage page announcements"""
|
"""Dynamically manage page announcements"""
|
||||||
|
|
||||||
hub_users = []
|
|
||||||
allow_admin = True
|
|
||||||
|
|
||||||
def initialize(self, storage):
|
def initialize(self, storage):
|
||||||
"""Create storage for announcement text"""
|
"""Create storage for announcement text"""
|
||||||
self.storage = storage
|
self.storage = storage
|
||||||
|
@@ -2,11 +2,18 @@ import sys
|
|||||||
|
|
||||||
# To run the announcement service managed by the hub, add this.
|
# To run the announcement service managed by the hub, add this.
|
||||||
|
|
||||||
|
port = 9999
|
||||||
c.JupyterHub.services = [
|
c.JupyterHub.services = [
|
||||||
{
|
{
|
||||||
'name': 'announcement',
|
'name': 'announcement',
|
||||||
'url': 'http://127.0.0.1:8888',
|
'url': f'http://127.0.0.1:{port}',
|
||||||
'command': [sys.executable, "-m", "announcement"],
|
'command': [
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"announcement",
|
||||||
|
'--port',
|
||||||
|
str(port),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -14,3 +21,19 @@ c.JupyterHub.services = [
|
|||||||
# for an example of how to do this.
|
# for an example of how to do this.
|
||||||
|
|
||||||
c.JupyterHub.template_paths = ["templates"]
|
c.JupyterHub.template_paths = ["templates"]
|
||||||
|
|
||||||
|
c.Authenticator.allowed_users = {"announcer", "otheruser"}
|
||||||
|
|
||||||
|
# grant the 'announcer' permission to access the announcement service
|
||||||
|
c.JupyterHub.load_roles = [
|
||||||
|
{
|
||||||
|
"name": "announcers",
|
||||||
|
"users": ["announcer"],
|
||||||
|
"scopes": ["access:services!service=announcement"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# dummy spawner and authenticator for testing, don't actually use these!
|
||||||
|
c.JupyterHub.authenticator_class = 'dummy'
|
||||||
|
c.JupyterHub.spawner_class = 'simple'
|
||||||
|
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled
|
||||||
|
@@ -16,6 +16,7 @@ jupyterhub --ip=127.0.0.1
|
|||||||
```
|
```
|
||||||
|
|
||||||
2. Visit http://127.0.0.1:8000/services/fastapi or http://127.0.0.1:8000/services/fastapi/docs
|
2. Visit http://127.0.0.1:8000/services/fastapi or http://127.0.0.1:8000/services/fastapi/docs
|
||||||
|
Login with username 'test-user' and any password.
|
||||||
|
|
||||||
3. Try interacting programmatically. If you create a new token in your control panel or pull out the `JUPYTERHUB_API_TOKEN` in the single user environment, you can skip the third step here.
|
3. Try interacting programmatically. If you create a new token in your control panel or pull out the `JUPYTERHUB_API_TOKEN` in the single user environment, you can skip the third step here.
|
||||||
|
|
||||||
@@ -24,10 +25,10 @@ $ curl -X GET http://127.0.0.1:8000/services/fastapi/
|
|||||||
{"Hello":"World"}
|
{"Hello":"World"}
|
||||||
|
|
||||||
$ curl -X GET http://127.0.0.1:8000/services/fastapi/me
|
$ curl -X GET http://127.0.0.1:8000/services/fastapi/me
|
||||||
{"detail":"Must login with token parameter, cookie, or header"}
|
{"detail":"Must login with token parameter, or Authorization bearer header"}
|
||||||
|
|
||||||
$ curl -X POST http://127.0.0.1:8000/hub/api/authorizations/token \
|
$ curl -X POST http://127.0.0.1:8000/hub/api/users/test-user/tokens \
|
||||||
-d '{"username": "myname", "password": "mypasswd!"}' \
|
-d '{"auth": {"username": "test-user", "password": "mypasswd!"}}' \
|
||||||
| jq '.token'
|
| jq '.token'
|
||||||
"3fee13ce6d2845da9bd5f2c2170d3428"
|
"3fee13ce6d2845da9bd5f2c2170d3428"
|
||||||
|
|
||||||
@@ -35,13 +36,18 @@ $ curl -X GET http://127.0.0.1:8000/services/fastapi/me \
|
|||||||
-H "Authorization: Bearer 3fee13ce6d2845da9bd5f2c2170d3428" \
|
-H "Authorization: Bearer 3fee13ce6d2845da9bd5f2c2170d3428" \
|
||||||
| jq .
|
| jq .
|
||||||
{
|
{
|
||||||
"name": "myname",
|
"name": "test-user",
|
||||||
"admin": false,
|
"admin": false,
|
||||||
"groups": [],
|
"groups": [],
|
||||||
"server": null,
|
"server": null,
|
||||||
"pending": null,
|
"pending": null,
|
||||||
"last_activity": "2021-04-07T18:05:11.587638+00:00",
|
"last_activity": "2021-05-21T09:13:00.514309+00:00",
|
||||||
"servers": null
|
"servers": null,
|
||||||
|
"scopes": [
|
||||||
|
"access:services",
|
||||||
|
"access:users:servers!user=test-user",
|
||||||
|
"...",
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from typing import Dict
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -22,11 +23,12 @@ class Server(BaseModel):
|
|||||||
class User(BaseModel):
|
class User(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
admin: bool
|
admin: bool
|
||||||
groups: List[str]
|
groups: Optional[List[str]]
|
||||||
server: Optional[str]
|
server: Optional[str]
|
||||||
pending: Optional[str]
|
pending: Optional[str]
|
||||||
last_activity: datetime
|
last_activity: datetime
|
||||||
servers: Optional[List[Server]]
|
servers: Optional[Dict[str, Server]]
|
||||||
|
scopes: List[str]
|
||||||
|
|
||||||
|
|
||||||
# https://stackoverflow.com/questions/64501193/fastapi-how-to-use-httpexception-in-responses
|
# https://stackoverflow.com/questions/64501193/fastapi-how-to-use-httpexception-in-responses
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
@@ -27,6 +28,12 @@ auth_by_header = OAuth2AuthorizationCodeBearer(
|
|||||||
### our client_secret (JUPYTERHUB_API_TOKEN) and that code to get an
|
### our client_secret (JUPYTERHUB_API_TOKEN) and that code to get an
|
||||||
### access_token, which it returns to browser, which places in Authorization header.
|
### access_token, which it returns to browser, which places in Authorization header.
|
||||||
|
|
||||||
|
if os.environ.get("JUPYTERHUB_OAUTH_SCOPES"):
|
||||||
|
# typically ["access:services", "access:services!service=$service_name"]
|
||||||
|
access_scopes = json.loads(os.environ["JUPYTERHUB_OAUTH_SCOPES"])
|
||||||
|
else:
|
||||||
|
access_scopes = ["access:services"]
|
||||||
|
|
||||||
### For consideration: optimize performance with a cache instead of
|
### For consideration: optimize performance with a cache instead of
|
||||||
### always hitting the Hub api?
|
### always hitting the Hub api?
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
@@ -58,4 +65,15 @@ async def get_current_user(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
user = User(**resp.json())
|
user = User(**resp.json())
|
||||||
|
if any(scope in user.scopes for scope in access_scopes):
|
||||||
return user
|
return user
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={
|
||||||
|
"msg": f"User not authorized: {user.name}",
|
||||||
|
"request_url": str(resp.request.url),
|
||||||
|
"token": token,
|
||||||
|
"user": resp.json(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
@@ -24,8 +24,21 @@ c.JupyterHub.services = [
|
|||||||
"name": service_name,
|
"name": service_name,
|
||||||
"url": "http://127.0.0.1:10202",
|
"url": "http://127.0.0.1:10202",
|
||||||
"command": ["uvicorn", "app:app", "--port", "10202"],
|
"command": ["uvicorn", "app:app", "--port", "10202"],
|
||||||
"admin": True,
|
|
||||||
"oauth_redirect_uri": oauth_redirect_uri,
|
"oauth_redirect_uri": oauth_redirect_uri,
|
||||||
"environment": {"PUBLIC_HOST": public_host},
|
"environment": {"PUBLIC_HOST": public_host},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
c.JupyterHub.load_roles = [
|
||||||
|
{
|
||||||
|
"name": "user",
|
||||||
|
# grant all users access to services
|
||||||
|
"scopes": ["self", "access:services"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# dummy for testing, create test-user
|
||||||
|
c.Authenticator.allowed_users = {"test-user"}
|
||||||
|
c.JupyterHub.authenticator_class = "dummy"
|
||||||
|
c.JupyterHub.spawner_class = "simple"
|
||||||
|
@@ -1,33 +1,35 @@
|
|||||||
# our user list
|
# our user list
|
||||||
c.Authenticator.allowed_users = ['minrk', 'ellisonbg', 'willingc']
|
c.Authenticator.allowed_users = ['minrk', 'ellisonbg', 'willingc']
|
||||||
|
|
||||||
# ellisonbg and willingc have access to a shared server:
|
service_name = 'shared-notebook'
|
||||||
|
service_port = 9999
|
||||||
|
group_name = 'shared'
|
||||||
|
|
||||||
c.JupyterHub.load_groups = {'shared-notebook-grp': ['ellisonbg', 'willingc']}
|
# ellisonbg and willingc are in a group that will access the shared server:
|
||||||
|
|
||||||
c.JupyterHub.load_roles = [
|
c.JupyterHub.load_groups = {group_name: ['ellisonbg', 'willingc']}
|
||||||
{
|
|
||||||
"name": "shared-notebook",
|
|
||||||
"groups": ["shared-notebook-grp"],
|
|
||||||
"scopes": ["access:services!service=shared-notebook"],
|
|
||||||
},
|
|
||||||
# by default, the user role has access to all services
|
|
||||||
# we want to limit that, so give users only access to 'self'
|
|
||||||
{
|
|
||||||
"name": "user",
|
|
||||||
"scopes": ["self"],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
# start the notebook server as a service
|
# start the notebook server as a service
|
||||||
c.JupyterHub.services = [
|
c.JupyterHub.services = [
|
||||||
{
|
{
|
||||||
'name': 'shared-notebook',
|
'name': service_name,
|
||||||
'url': 'http://127.0.0.1:9999',
|
'url': 'http://127.0.0.1:{}'.format(service_port),
|
||||||
'api_token': 'c3a29e5d386fd7c9aa1e8fe9d41c282ec8b',
|
'api_token': 'c3a29e5d386fd7c9aa1e8fe9d41c282ec8b',
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# This "role assignment" is what grants members of the group
|
||||||
|
# access to the service
|
||||||
|
c.JupyterHub.load_roles = [
|
||||||
|
{
|
||||||
|
"name": "shared-notebook",
|
||||||
|
"groups": [group_name],
|
||||||
|
"scopes": [f"access:services!service={service_name}"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# dummy spawner and authenticator for testing, don't actually use these!
|
# dummy spawner and authenticator for testing, don't actually use these!
|
||||||
c.JupyterHub.authenticator_class = 'dummy'
|
c.JupyterHub.authenticator_class = 'dummy'
|
||||||
c.JupyterHub.spawner_class = 'simple'
|
c.JupyterHub.spawner_class = 'simple'
|
||||||
|
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled
|
||||||
|
@@ -1,19 +1,35 @@
|
|||||||
# our user list
|
# our user list
|
||||||
c.Authenticator.whitelist = ['minrk', 'ellisonbg', 'willingc']
|
c.Authenticator.allowed_users = ['minrk', 'ellisonbg', 'willingc']
|
||||||
|
|
||||||
# ellisonbg and willingc have access to a shared server:
|
|
||||||
|
|
||||||
c.JupyterHub.load_groups = {'shared': ['ellisonbg', 'willingc']}
|
|
||||||
|
|
||||||
service_name = 'shared-notebook'
|
service_name = 'shared-notebook'
|
||||||
service_port = 9999
|
service_port = 9999
|
||||||
group_name = 'shared'
|
group_name = 'shared'
|
||||||
|
|
||||||
|
# ellisonbg and willingc have access to a shared server:
|
||||||
|
|
||||||
|
c.JupyterHub.load_groups = {group_name: ['ellisonbg', 'willingc']}
|
||||||
|
|
||||||
# start the notebook server as a service
|
# start the notebook server as a service
|
||||||
c.JupyterHub.services = [
|
c.JupyterHub.services = [
|
||||||
{
|
{
|
||||||
'name': service_name,
|
'name': service_name,
|
||||||
'url': 'http://127.0.0.1:{}'.format(service_port),
|
'url': 'http://127.0.0.1:{}'.format(service_port),
|
||||||
'command': ['jupyterhub-singleuser', '--group=shared', '--debug'],
|
'command': ['jupyterhub-singleuser', '--debug'],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# This "role assignment" is what grants members of the group
|
||||||
|
# access to the service
|
||||||
|
c.JupyterHub.load_roles = [
|
||||||
|
{
|
||||||
|
"name": "shared-notebook",
|
||||||
|
"groups": [group_name],
|
||||||
|
"scopes": [f"access:services!service={service_name}"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# dummy spawner and authenticator for testing, don't actually use these!
|
||||||
|
c.JupyterHub.authenticator_class = 'dummy'
|
||||||
|
c.JupyterHub.spawner_class = 'simple'
|
||||||
|
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled
|
||||||
|
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
Uses `jupyterhub.services.HubAuthenticated` to authenticate requests with the Hub.
|
Uses `jupyterhub.services.HubAuthenticated` to authenticate requests with the Hub.
|
||||||
|
|
||||||
There is an implementation each of cookie-based `HubAuthenticated` and OAuth-based `HubOAuthenticated`.
|
There is an implementation each of api-token-based `HubAuthenticated` and OAuth-based `HubOAuthenticated`.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
1. Launch JupyterHub and the `whoami service` with
|
1. Launch JupyterHub and the `whoami` services with
|
||||||
|
|
||||||
jupyterhub --ip=127.0.0.1
|
jupyterhub --ip=127.0.0.1
|
||||||
|
|
||||||
2. Visit http://127.0.0.1:8000/services/whoami or http://127.0.0.1:8000/services/whoami-oauth
|
2. Visit http://127.0.0.1:8000/services/whoami-oauth
|
||||||
|
|
||||||
After logging in with your local-system credentials, you should see a JSON dump of your user info:
|
After logging in with your local-system credentials, you should see a JSON dump of your user info:
|
||||||
|
|
||||||
@@ -24,15 +24,65 @@ After logging in with your local-system credentials, you should see a JSON dump
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The `whoami-api` service powered by the base `HubAuthenticated` class only supports token-authenticated API requests,
|
||||||
|
not browser visits, because it does not implement OAuth. Visit it by requesting an api token from the tokens page,
|
||||||
|
and making a direct request:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ curl -H "Authorization: token 8630bbd8ef064c48b22c7f122f0cd8ad" http://127.0.0.1:8000/services/whoami-api/ | jq .
|
||||||
|
{
|
||||||
|
"admin": false,
|
||||||
|
"created": "2021-05-21T09:47:41.299400Z",
|
||||||
|
"groups": [],
|
||||||
|
"kind": "user",
|
||||||
|
"last_activity": "2021-05-21T09:49:08.290745Z",
|
||||||
|
"name": "test",
|
||||||
|
"pending": null,
|
||||||
|
"roles": [
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"scopes": [
|
||||||
|
"access:services",
|
||||||
|
"access:users:servers!user=test",
|
||||||
|
"read:users!user=test",
|
||||||
|
"read:users:activity!user=test",
|
||||||
|
"read:users:groups!user=test",
|
||||||
|
"read:users:name!user=test",
|
||||||
|
"read:users:servers!user=test",
|
||||||
|
"read:users:tokens!user=test",
|
||||||
|
"users!user=test",
|
||||||
|
"users:activity!user=test",
|
||||||
|
"users:groups!user=test",
|
||||||
|
"users:name!user=test",
|
||||||
|
"users:servers!user=test",
|
||||||
|
"users:tokens!user=test"
|
||||||
|
],
|
||||||
|
"server": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
This relies on the Hub starting the whoami services, via config (see [jupyterhub_config.py](./jupyterhub_config.py)).
|
This relies on the Hub starting the whoami services, via config (see [jupyterhub_config.py](./jupyterhub_config.py)).
|
||||||
|
|
||||||
You may set the `hub_users` configuration in the service script
|
To govern access to the services, create **roles** with the scope `access:services!service=$service-name`,
|
||||||
to restrict access to the service to a whitelist of allowed users.
|
and assign users to the scope.
|
||||||
By default, any authenticated user is allowed.
|
|
||||||
|
The jupyterhub_config.py grants access for all users to all services via the default 'user' role, with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.JupyterHub.load_roles = [
|
||||||
|
{
|
||||||
|
"name": "user",
|
||||||
|
# grant all users access to all services
|
||||||
|
"scopes": ["access:services", "self"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
A similar service could be run externally, by setting the JupyterHub service environment variables:
|
A similar service could be run externally, by setting the JupyterHub service environment variables:
|
||||||
|
|
||||||
JUPYTERHUB_API_TOKEN
|
JUPYTERHUB_API_TOKEN
|
||||||
JUPYTERHUB_SERVICE_PREFIX
|
JUPYTERHUB_SERVICE_PREFIX
|
||||||
|
JUPYTERHUB_OAUTH_SCOPES
|
||||||
|
JUPYTERHUB_CLIENT_ID # for whoami-oauth only
|
||||||
|
|
||||||
or instantiating and configuring a HubAuth object yourself, and attaching it as `self.hub_auth` in your HubAuthenticated handlers.
|
or instantiating and configuring a HubAuth object yourself, and attaching it as `self.hub_auth` in your HubAuthenticated handlers.
|
||||||
|
@@ -2,7 +2,7 @@ import sys
|
|||||||
|
|
||||||
c.JupyterHub.services = [
|
c.JupyterHub.services = [
|
||||||
{
|
{
|
||||||
'name': 'whoami',
|
'name': 'whoami-api',
|
||||||
'url': 'http://127.0.0.1:10101',
|
'url': 'http://127.0.0.1:10101',
|
||||||
'command': [sys.executable, './whoami.py'],
|
'command': [sys.executable, './whoami.py'],
|
||||||
},
|
},
|
||||||
@@ -12,3 +12,16 @@ c.JupyterHub.services = [
|
|||||||
'command': [sys.executable, './whoami-oauth.py'],
|
'command': [sys.executable, './whoami-oauth.py'],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
c.JupyterHub.load_roles = [
|
||||||
|
{
|
||||||
|
"name": "user",
|
||||||
|
# grant all users access to all services
|
||||||
|
"scopes": ["access:services", "self"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# dummy spawner and authenticator for testing, don't actually use these!
|
||||||
|
c.JupyterHub.authenticator_class = 'dummy'
|
||||||
|
c.JupyterHub.spawner_class = 'simple'
|
||||||
|
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
"""An example service authenticating with the Hub.
|
"""An example service authenticating with the Hub.
|
||||||
|
|
||||||
This example service serves `/services/whoami/`,
|
This example service serves `/services/whoami-oauth/`,
|
||||||
authenticated with the Hub,
|
authenticated with the Hub,
|
||||||
showing the user their own info.
|
showing the user their own info.
|
||||||
"""
|
"""
|
||||||
@@ -20,13 +20,6 @@ from jupyterhub.utils import url_path_join
|
|||||||
|
|
||||||
|
|
||||||
class WhoAmIHandler(HubOAuthenticated, RequestHandler):
|
class WhoAmIHandler(HubOAuthenticated, RequestHandler):
|
||||||
# hub_users can be a set of users who are allowed to access the service
|
|
||||||
# `getuser()` here would mean only the user who started the service
|
|
||||||
# can access the service:
|
|
||||||
|
|
||||||
# from getpass import getuser
|
|
||||||
# hub_users = {getuser()}
|
|
||||||
|
|
||||||
@authenticated
|
@authenticated
|
||||||
def get(self):
|
def get(self):
|
||||||
user_model = self.get_current_user()
|
user_model = self.get_current_user()
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
"""An example service authenticating with the Hub.
|
"""An example service authenticating with the Hub.
|
||||||
|
|
||||||
This serves `/services/whoami/`, authenticated with the Hub, showing the user their own info.
|
This serves `/services/whoami-api/`, authenticated with the Hub, showing the user their own info.
|
||||||
|
|
||||||
|
HubAuthenticated only supports token-based access.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -16,13 +18,6 @@ from jupyterhub.services.auth import HubAuthenticated
|
|||||||
|
|
||||||
|
|
||||||
class WhoAmIHandler(HubAuthenticated, RequestHandler):
|
class WhoAmIHandler(HubAuthenticated, RequestHandler):
|
||||||
# hub_users can be a set of users who are allowed to access the service
|
|
||||||
# `getuser()` here would mean only the user who started the service
|
|
||||||
# can access the service:
|
|
||||||
|
|
||||||
# from getpass import getuser
|
|
||||||
# hub_users = {getuser()}
|
|
||||||
|
|
||||||
@authenticated
|
@authenticated
|
||||||
def get(self):
|
def get(self):
|
||||||
user_model = self.get_current_user()
|
user_model = self.get_current_user()
|
||||||
|
@@ -824,13 +824,26 @@ class UserNotAllowed(Exception):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class HubAuthenticated(object):
|
class HubAuthenticated:
|
||||||
"""Mixin for tornado handlers that are authenticated with JupyterHub
|
"""Mixin for tornado handlers that are authenticated with JupyterHub
|
||||||
|
|
||||||
A handler that mixes this in must have the following attributes/properties:
|
A handler that mixes this in must have the following attributes/properties:
|
||||||
|
|
||||||
- .hub_auth: A HubAuth instance
|
- .hub_auth: A HubAuth instance
|
||||||
- .hub_scopes: A set of JupyterHub 2.0 OAuth scopes to allow.
|
- .hub_scopes: A set of JupyterHub 2.0 OAuth scopes to allow.
|
||||||
|
Default comes from .hub_auth.oauth_scopes,
|
||||||
|
which in turn is set by $JUPYTERHUB_OAUTH_SCOPES
|
||||||
|
Default values include:
|
||||||
|
- 'access:services', 'access:services!service={service_name}' for services
|
||||||
|
- 'access:users:servers', 'access:users:servers!user={user}',
|
||||||
|
'access:users:servers!server={user}/{server_name}'
|
||||||
|
for single-user servers
|
||||||
|
|
||||||
|
If hub_scopes is not used (e.g. JupyterHub 1.x),
|
||||||
|
these additional properties can be used:
|
||||||
|
|
||||||
|
- .allow_admin: If True, allow any admin user.
|
||||||
|
Default: False.
|
||||||
- .hub_users: A set of usernames to allow.
|
- .hub_users: A set of usernames to allow.
|
||||||
If left unspecified or None, username will not be checked.
|
If left unspecified or None, username will not be checked.
|
||||||
- .hub_groups: A set of group names to allow.
|
- .hub_groups: A set of group names to allow.
|
||||||
@@ -943,6 +956,8 @@ class HubAuthenticated(object):
|
|||||||
# note: this means successful authentication, but insufficient permission
|
# note: this means successful authentication, but insufficient permission
|
||||||
raise UserNotAllowed(model)
|
raise UserNotAllowed(model)
|
||||||
|
|
||||||
|
# proceed with the pre-2.0 way if hub_scopes is not set
|
||||||
|
|
||||||
if self.allow_admin and model.get('admin', False):
|
if self.allow_admin and model.get('admin', False):
|
||||||
app_log.debug("Allowing Hub admin %s", name)
|
app_log.debug("Allowing Hub admin %s", name)
|
||||||
return model
|
return model
|
||||||
|
Reference in New Issue
Block a user