solara-enterprise 1.45.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- solara_enterprise/__init__.py +3 -0
- solara_enterprise/auth/__init__.py +13 -0
- solara_enterprise/auth/components.py +118 -0
- solara_enterprise/auth/flask.py +119 -0
- solara_enterprise/auth/middleware.py +127 -0
- solara_enterprise/auth/starlette.py +118 -0
- solara_enterprise/auth/utils.py +36 -0
- solara_enterprise/cache/__init__.py +3 -0
- solara_enterprise/cache/base.py +64 -0
- solara_enterprise/cache/disk.py +44 -0
- solara_enterprise/cache/memory_size.py +29 -0
- solara_enterprise/cache/multi_level.py +25 -0
- solara_enterprise/cache/redis.py +25 -0
- solara_enterprise/license.py +17 -0
- solara_enterprise/search/__init__.py +0 -0
- solara_enterprise/search/index.py +89 -0
- solara_enterprise/search/search.py +25 -0
- solara_enterprise/search/search.vue +170 -0
- solara_enterprise/ssg.py +249 -0
- solara_enterprise-1.45.0.dist-info/METADATA +27 -0
- solara_enterprise-1.45.0.dist-info/RECORD +23 -0
- solara_enterprise-1.45.0.dist-info/WHEEL +4 -0
- solara_enterprise-1.45.0.dist-info/licenses/LICENSE +1 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from typing import Dict, Optional, cast
|
|
2
|
+
|
|
3
|
+
from solara.lab import Reactive
|
|
4
|
+
|
|
5
|
+
from .components import Avatar, AvatarMenu
|
|
6
|
+
from .utils import get_login_url, get_logout_url
|
|
7
|
+
|
|
8
|
+
__all__ = ["user", "get_login_url", "get_logout_url", "Avatar", "AvatarMenu"]
|
|
9
|
+
|
|
10
|
+
# the current way of generating a key is based in the default value
|
|
11
|
+
# which may collide after a hot reload, since solara itself is not reloaded
|
|
12
|
+
# if we give a fixed key, we can avoid this
|
|
13
|
+
user = Reactive(cast(Optional[Dict], None), key="solara-enterprise.auth.user")
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from typing import Optional, Union
|
|
2
|
+
|
|
3
|
+
import reacton.ipyvuetify as v
|
|
4
|
+
import solara
|
|
5
|
+
|
|
6
|
+
from .. import auth, license
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@solara.component
|
|
10
|
+
def AvatarMenu(image_url: Optional[str] = None, size: Union[int, str] = 40, color: str = "primary", children=[]):
|
|
11
|
+
"""Show a menu with the user's avatar and a list of items.
|
|
12
|
+
|
|
13
|
+
By default the menu shows a logout button.
|
|
14
|
+
|
|
15
|
+
## Example
|
|
16
|
+
|
|
17
|
+
```solara
|
|
18
|
+
import solara
|
|
19
|
+
from solara_enterprise import auth
|
|
20
|
+
|
|
21
|
+
@solara.component
|
|
22
|
+
def Page():
|
|
23
|
+
if not auth.user.value:
|
|
24
|
+
solara.Info("Login to see your avatar")
|
|
25
|
+
solara.Button("Login", icon_name="mdi-login", href=auth.get_login_url())
|
|
26
|
+
else:
|
|
27
|
+
auth.AvatarMenu() # this shows the user's picture
|
|
28
|
+
solara.Button("Logout", icon_name="mdi-logout", href=auth.get_logout_url())
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Note that a common use case is to put the avatar in the [AppBar](/documentation/components/layout/app_bar).
|
|
32
|
+
```solara
|
|
33
|
+
import solara
|
|
34
|
+
from solara_enterprise import auth
|
|
35
|
+
|
|
36
|
+
@solara.component
|
|
37
|
+
def Page():
|
|
38
|
+
solara.Title("Avatar in the app bar demo")
|
|
39
|
+
if not auth.user.value:
|
|
40
|
+
solara.Info("Login to see your avatar (see the button in the app bar)")
|
|
41
|
+
with solara.AppBar():
|
|
42
|
+
solara.Button(icon_name="mdi-login", href=auth.get_login_url(), icon=True)
|
|
43
|
+
else:
|
|
44
|
+
with solara.AppBar(): # this shows the user's picture in the app bar
|
|
45
|
+
with auth.AvatarMenu():
|
|
46
|
+
solara.Button("Logout", icon_name="mdi-logout", href=auth.get_logout_url(), text=True)
|
|
47
|
+
solara.Button("Fake user settings", icon_name="mdi-account-cog", text=True)
|
|
48
|
+
solara.Info("Logout via the appbar")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Arguments
|
|
52
|
+
|
|
53
|
+
* image_url: if not given, the picture from the user's profile will be used (OAuth only)
|
|
54
|
+
* size: size of the avatar (in pixels)
|
|
55
|
+
* color: color of the avatar (if no picture is available)
|
|
56
|
+
* children: list of elements to show in the menu
|
|
57
|
+
|
|
58
|
+
"""
|
|
59
|
+
license.check("auth")
|
|
60
|
+
|
|
61
|
+
with v.Html(tag="div", v_on="x.on") as activator:
|
|
62
|
+
Avatar(image_url=image_url, size=size, color=color)
|
|
63
|
+
v.Icon(children=["mdi-menu-down"])
|
|
64
|
+
|
|
65
|
+
if not children:
|
|
66
|
+
children = [solara.Button("logout", icon_name="mdi-logout", href=auth.get_logout_url(), text=True)]
|
|
67
|
+
|
|
68
|
+
with v.Menu(v_slots=[{"name": "activator", "children": activator, "variable": "x"}], offset_y=True) as menu:
|
|
69
|
+
with v.List():
|
|
70
|
+
for child in children:
|
|
71
|
+
with v.ListItem(children=[child]):
|
|
72
|
+
pass
|
|
73
|
+
return menu
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@solara.component
|
|
77
|
+
def Avatar(image_url: Optional[str] = None, size: Union[int, str] = 40, color: str = "primary"):
|
|
78
|
+
"""Display an avatar with the user's picture or a default icon.
|
|
79
|
+
|
|
80
|
+
## Example
|
|
81
|
+
|
|
82
|
+
```solara
|
|
83
|
+
import solara
|
|
84
|
+
from solara_enterprise import auth
|
|
85
|
+
|
|
86
|
+
@solara.component
|
|
87
|
+
def Page():
|
|
88
|
+
if not auth.user.value:
|
|
89
|
+
solara.Info("Login to see your avatar")
|
|
90
|
+
solara.Button("Login", icon_name="mdi-login", href=auth.get_login_url())
|
|
91
|
+
else:
|
|
92
|
+
auth.Avatar() # this shows the user's picture
|
|
93
|
+
solara.Button("Logout", icon_name="mdi-logout", href=auth.get_logout_url())
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Arguments
|
|
97
|
+
|
|
98
|
+
* image_url: if not given, the picture from the user's profile will be used (OAuth only)
|
|
99
|
+
* size: size of the avatar (in pixels)
|
|
100
|
+
* color: color of the avatar (if no picture is available)
|
|
101
|
+
"""
|
|
102
|
+
license.check("auth")
|
|
103
|
+
user = auth.user.value
|
|
104
|
+
if user:
|
|
105
|
+
user_info = user.get("userinfo", {})
|
|
106
|
+
src = image_url
|
|
107
|
+
if src is None:
|
|
108
|
+
src = user_info.get("picture")
|
|
109
|
+
if src:
|
|
110
|
+
with v.Avatar(size=size, class_="ma-2"):
|
|
111
|
+
v.Img(src=src)
|
|
112
|
+
else:
|
|
113
|
+
with v.Avatar(size=size, color=color):
|
|
114
|
+
v.Icon(children=["mdi-account"])
|
|
115
|
+
else:
|
|
116
|
+
with v.Avatar(size=size, color=color):
|
|
117
|
+
with solara.Tooltip("No user"):
|
|
118
|
+
v.Icon(children=["mdi-error"])
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
|
|
5
|
+
from authlib.integrations.flask_client import OAuth
|
|
6
|
+
from flask import redirect, request, session
|
|
7
|
+
from solara.server import settings
|
|
8
|
+
|
|
9
|
+
from .. import license
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("solara.enterprise.auth.starlette")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
oauth: Optional[OAuth] = None
|
|
15
|
+
_app = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def init_flask(app):
|
|
19
|
+
global _app
|
|
20
|
+
_app = app
|
|
21
|
+
init()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def init():
|
|
25
|
+
global oauth
|
|
26
|
+
assert _app is not None
|
|
27
|
+
_app.secret_key = settings.oauth.client_secret
|
|
28
|
+
if settings.oauth.client_id:
|
|
29
|
+
api_base_url = settings.oauth.api_base_url
|
|
30
|
+
if not api_base_url.startswith("https://") and not api_base_url.startswith("http://"):
|
|
31
|
+
api_base_url = f"https://{api_base_url}"
|
|
32
|
+
|
|
33
|
+
oauth = OAuth(_app)
|
|
34
|
+
oauth.register(
|
|
35
|
+
name="oauth1",
|
|
36
|
+
client_id=settings.oauth.client_id,
|
|
37
|
+
client_secret=settings.oauth.client_secret,
|
|
38
|
+
api_base_url=api_base_url,
|
|
39
|
+
server_metadata_url=f"{api_base_url}/.well-known/openid-configuration",
|
|
40
|
+
client_kwargs={"scope": settings.oauth.scope},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def check_oauth():
|
|
45
|
+
assert oauth is not None
|
|
46
|
+
assert oauth.oauth1 is not None
|
|
47
|
+
if oauth.oauth1.client_id != settings.oauth.client_id:
|
|
48
|
+
init()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def authorize():
|
|
52
|
+
check_oauth()
|
|
53
|
+
assert oauth is not None
|
|
54
|
+
assert oauth.oauth1 is not None
|
|
55
|
+
|
|
56
|
+
org_url = session.pop("redirect_uri", settings.main.base_url + "/")
|
|
57
|
+
|
|
58
|
+
token = oauth.oauth1.authorize_access_token()
|
|
59
|
+
# workaround: if token is set in the session in one piece, it is not saved, so we
|
|
60
|
+
# split it up
|
|
61
|
+
token.pop("id_token", None)
|
|
62
|
+
user = token.pop("userinfo", None)
|
|
63
|
+
session["token"] = json.dumps(token)
|
|
64
|
+
session["user"] = json.dumps(user)
|
|
65
|
+
|
|
66
|
+
return redirect(org_url)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def logout():
|
|
70
|
+
redirect_uri = request.args.get("redirect_uri", "/")
|
|
71
|
+
# ideally, we only remove these:
|
|
72
|
+
session.pop("token", None)
|
|
73
|
+
session.pop("user", None)
|
|
74
|
+
session.pop("client_id", None)
|
|
75
|
+
# but authlib sometimes leaves some stuff in the session on failed logins
|
|
76
|
+
# so we clear it all
|
|
77
|
+
return redirect(redirect_uri)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def login(redirect_uri: Optional[str] = None):
|
|
81
|
+
license.check("auth")
|
|
82
|
+
check_oauth()
|
|
83
|
+
assert oauth is not None
|
|
84
|
+
assert oauth.oauth1 is not None
|
|
85
|
+
if "redirect_uri" in request.args:
|
|
86
|
+
# we arrived here via the auth.get_login_url() call, which means the
|
|
87
|
+
# redirect_uri is in the query params
|
|
88
|
+
session["redirect_uri"] = request.args["redirect_uri"]
|
|
89
|
+
else:
|
|
90
|
+
# otherwise we assume we got here via the solara.server.starlette method
|
|
91
|
+
# where it detect we the OAuth.private=True setting, leading to a redirect
|
|
92
|
+
session["redirect_uri"] = str(request.url)
|
|
93
|
+
session["client_id"] = settings.oauth.client_id
|
|
94
|
+
callback_url = str(settings.main.base_url) + "_solara/auth/authorize"
|
|
95
|
+
result = oauth.oauth1.authorize_redirect(callback_url)
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def allowed():
|
|
100
|
+
if settings.oauth.private:
|
|
101
|
+
user = session.get("user")
|
|
102
|
+
if not user:
|
|
103
|
+
return False
|
|
104
|
+
else:
|
|
105
|
+
client_id = session.get("client_id")
|
|
106
|
+
if client_id != settings.oauth.client_id:
|
|
107
|
+
return False
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_user() -> Optional[Dict]:
|
|
112
|
+
user = session.get("user")
|
|
113
|
+
if user:
|
|
114
|
+
user = json.loads(session["token"])
|
|
115
|
+
user["userinfo"] = json.loads(session["user"])
|
|
116
|
+
client_id = session.get("client_id")
|
|
117
|
+
if client_id != settings.oauth.client_id:
|
|
118
|
+
user = None
|
|
119
|
+
return user
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
import typing
|
|
4
|
+
from base64 import b64decode, b64encode
|
|
5
|
+
|
|
6
|
+
import itsdangerous
|
|
7
|
+
from itsdangerous.exc import BadSignature
|
|
8
|
+
from starlette.datastructures import MutableHeaders, Secret
|
|
9
|
+
from starlette.middleware.sessions import SessionMiddleware
|
|
10
|
+
from starlette.requests import HTTPConnection
|
|
11
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
12
|
+
|
|
13
|
+
if sys.version_info >= (3, 8): # pragma: no cover
|
|
14
|
+
from typing import Literal
|
|
15
|
+
else: # pragma: no cover
|
|
16
|
+
from typing_extensions import Literal
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# mutable mapping that keeps track of whether it has been modified
|
|
20
|
+
class ModifiedDict(dict):
|
|
21
|
+
def __init__(self, *args, **kwargs):
|
|
22
|
+
super().__init__(*args, **kwargs)
|
|
23
|
+
self.modified = False
|
|
24
|
+
|
|
25
|
+
def __setitem__(self, key, value):
|
|
26
|
+
super().__setitem__(key, value)
|
|
27
|
+
self.modified = True
|
|
28
|
+
|
|
29
|
+
def __delitem__(self, key):
|
|
30
|
+
super().__delitem__(key)
|
|
31
|
+
self.modified = True
|
|
32
|
+
|
|
33
|
+
def clear(self):
|
|
34
|
+
super().clear()
|
|
35
|
+
self.modified = True
|
|
36
|
+
|
|
37
|
+
def pop(self, key, default=None):
|
|
38
|
+
value = super().pop(key, default)
|
|
39
|
+
self.modified = True
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
def popitem(self):
|
|
43
|
+
value = super().popitem()
|
|
44
|
+
self.modified = True
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
def setdefault(self, key, default=None):
|
|
48
|
+
value = super().setdefault(key, default)
|
|
49
|
+
self.modified = True
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
def update(self, *args, **kwargs):
|
|
53
|
+
super().update(*args, **kwargs)
|
|
54
|
+
self.modified = True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class MutateDetectSessionMiddleware(SessionMiddleware):
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
app: ASGIApp,
|
|
61
|
+
secret_key: typing.Union[str, Secret],
|
|
62
|
+
session_cookie: str = "session",
|
|
63
|
+
max_age: typing.Optional[int] = 14 * 24 * 60 * 60, # 14 days, in seconds
|
|
64
|
+
path: str = "/",
|
|
65
|
+
same_site: Literal["lax", "strict", "none"] = "lax",
|
|
66
|
+
https_only: bool = False,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.app = app
|
|
69
|
+
self.signer = itsdangerous.TimestampSigner(str(secret_key))
|
|
70
|
+
self.session_cookie = session_cookie
|
|
71
|
+
self.max_age = max_age
|
|
72
|
+
self.path = path
|
|
73
|
+
self.security_flags = "httponly; samesite=" + same_site
|
|
74
|
+
if https_only: # Secure flag can be used with HTTPS only
|
|
75
|
+
self.security_flags += "; secure"
|
|
76
|
+
|
|
77
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
78
|
+
if scope["type"] not in ("http", "websocket"): # pragma: no cover
|
|
79
|
+
await self.app(scope, receive, send)
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
connection = HTTPConnection(scope)
|
|
83
|
+
initial_session_was_empty = True
|
|
84
|
+
|
|
85
|
+
if self.session_cookie in connection.cookies:
|
|
86
|
+
data = connection.cookies[self.session_cookie].encode("utf-8")
|
|
87
|
+
try:
|
|
88
|
+
data = self.signer.unsign(data, max_age=self.max_age)
|
|
89
|
+
scope["session"] = ModifiedDict(json.loads(b64decode(data)))
|
|
90
|
+
initial_session_was_empty = False
|
|
91
|
+
except BadSignature:
|
|
92
|
+
scope["session"] = ModifiedDict()
|
|
93
|
+
else:
|
|
94
|
+
scope["session"] = ModifiedDict()
|
|
95
|
+
|
|
96
|
+
async def send_wrapper(message: Message) -> None:
|
|
97
|
+
if message["type"] == "http.response.start":
|
|
98
|
+
if scope["session"]:
|
|
99
|
+
# this deviates for starlette, we only update the cookie if the session has been modified
|
|
100
|
+
# this avoids race conditions where the session is modified by another request
|
|
101
|
+
if scope["session"].modified:
|
|
102
|
+
# We have session data to persist.
|
|
103
|
+
data = b64encode(json.dumps(scope["session"]).encode("utf-8"))
|
|
104
|
+
data = self.signer.sign(data)
|
|
105
|
+
headers = MutableHeaders(scope=message)
|
|
106
|
+
header_value = "{session_cookie}={data}; path={path}; {max_age}{security_flags}".format( # noqa E501
|
|
107
|
+
session_cookie=self.session_cookie,
|
|
108
|
+
data=data.decode("utf-8"),
|
|
109
|
+
path=self.path,
|
|
110
|
+
max_age=f"Max-Age={self.max_age}; " if self.max_age else "",
|
|
111
|
+
security_flags=self.security_flags,
|
|
112
|
+
)
|
|
113
|
+
headers.append("Set-Cookie", header_value)
|
|
114
|
+
elif not initial_session_was_empty:
|
|
115
|
+
# The session has been cleared.
|
|
116
|
+
headers = MutableHeaders(scope=message)
|
|
117
|
+
header_value = "{session_cookie}={data}; path={path}; {expires}{security_flags}".format( # noqa E501
|
|
118
|
+
session_cookie=self.session_cookie,
|
|
119
|
+
data="null",
|
|
120
|
+
path=self.path,
|
|
121
|
+
expires="expires=Thu, 01 Jan 1970 00:00:00 GMT; ",
|
|
122
|
+
security_flags=self.security_flags,
|
|
123
|
+
)
|
|
124
|
+
headers.append("Set-Cookie", header_value)
|
|
125
|
+
await send(message)
|
|
126
|
+
|
|
127
|
+
await self.app(scope, receive, send_wrapper)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
|
|
5
|
+
from authlib.integrations.starlette_client import OAuth
|
|
6
|
+
from solara.server import settings
|
|
7
|
+
from starlette.authentication import (
|
|
8
|
+
AuthCredentials,
|
|
9
|
+
AuthenticationBackend,
|
|
10
|
+
SimpleUser,
|
|
11
|
+
UnauthenticatedUser,
|
|
12
|
+
)
|
|
13
|
+
from starlette.requests import HTTPConnection, Request
|
|
14
|
+
from starlette.responses import RedirectResponse
|
|
15
|
+
|
|
16
|
+
from .. import license
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("solara.enterprise.auth.starlette")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
oauth: Optional[OAuth] = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def init():
|
|
25
|
+
global oauth
|
|
26
|
+
if settings.oauth.client_id:
|
|
27
|
+
api_base_url = settings.oauth.api_base_url
|
|
28
|
+
if not api_base_url.startswith("https://") and not api_base_url.startswith("http://"):
|
|
29
|
+
api_base_url = f"https://{api_base_url}"
|
|
30
|
+
oauth = OAuth()
|
|
31
|
+
oauth.register(
|
|
32
|
+
name="oauth1",
|
|
33
|
+
client_id=settings.oauth.client_id,
|
|
34
|
+
client_secret=settings.oauth.client_secret,
|
|
35
|
+
api_base_url=api_base_url,
|
|
36
|
+
server_metadata_url=f"{api_base_url}/.well-known/openid-configuration",
|
|
37
|
+
client_kwargs={"scope": settings.oauth.scope},
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def check_oauth():
|
|
42
|
+
assert oauth is not None
|
|
43
|
+
assert oauth.oauth1 is not None
|
|
44
|
+
if oauth.oauth1.client_id != settings.oauth.client_id:
|
|
45
|
+
init()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
init()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def authorize(request: Request):
|
|
52
|
+
check_oauth()
|
|
53
|
+
assert oauth is not None
|
|
54
|
+
assert oauth.oauth1 is not None
|
|
55
|
+
|
|
56
|
+
org_url = request.session.pop("redirect_uri", settings.main.base_url + "/")
|
|
57
|
+
|
|
58
|
+
token = await oauth.oauth1.authorize_access_token(request)
|
|
59
|
+
# workaround: if token is set in the session in one piece, it is not saved, so we
|
|
60
|
+
# split it up
|
|
61
|
+
token.pop("id_token", None)
|
|
62
|
+
user = token.pop("userinfo", None)
|
|
63
|
+
request.session["token"] = json.dumps(token)
|
|
64
|
+
request.session["user"] = json.dumps(user)
|
|
65
|
+
|
|
66
|
+
return RedirectResponse(org_url)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def logout(request: Request):
|
|
70
|
+
redirect_uri = request.query_params.get("redirect_uri", "/")
|
|
71
|
+
# ideally, we only remove these:
|
|
72
|
+
request.session.pop("token", None)
|
|
73
|
+
request.session.pop("user", None)
|
|
74
|
+
request.session.pop("client_id", None)
|
|
75
|
+
# but authlib sometimes leaves some stuff in the session on failed logins
|
|
76
|
+
# so we clear it all
|
|
77
|
+
request.session.clear()
|
|
78
|
+
return RedirectResponse(redirect_uri)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def login(request: Request, redirect_uri: Optional[str] = None):
|
|
82
|
+
license.check("auth")
|
|
83
|
+
check_oauth()
|
|
84
|
+
assert oauth is not None
|
|
85
|
+
assert oauth.oauth1 is not None
|
|
86
|
+
if "redirect_uri" in request.query_params:
|
|
87
|
+
# we arrived here via the auth.get_login_url() call, which means the
|
|
88
|
+
# redirect_uri is in the query params
|
|
89
|
+
request.session["redirect_uri"] = request.query_params["redirect_uri"]
|
|
90
|
+
else:
|
|
91
|
+
# otherwise we assume we got here via the solara.server.starlette method
|
|
92
|
+
# where it detect we the OAuth.required=True setting, leading to a redirect
|
|
93
|
+
request.session["redirect_uri"] = str(request.url.path)
|
|
94
|
+
request.session["client_id"] = settings.oauth.client_id
|
|
95
|
+
result = await oauth.oauth1.authorize_redirect(request, str(settings.main.base_url) + "_solara/auth/authorize")
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_user(request: HTTPConnection) -> Optional[Dict]:
|
|
100
|
+
user = request.session.get("user")
|
|
101
|
+
if user:
|
|
102
|
+
user = json.loads(request.session["token"])
|
|
103
|
+
user["userinfo"] = json.loads(request.session["user"])
|
|
104
|
+
client_id = request.session.get("client_id")
|
|
105
|
+
if client_id != settings.oauth.client_id:
|
|
106
|
+
user = None
|
|
107
|
+
return user
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# only provides request.user.is_authenticated
|
|
111
|
+
class AuthBackend(AuthenticationBackend):
|
|
112
|
+
async def authenticate(self, conn: HTTPConnection):
|
|
113
|
+
user = get_user(conn)
|
|
114
|
+
if user is None:
|
|
115
|
+
return AuthCredentials(), UnauthenticatedUser()
|
|
116
|
+
else:
|
|
117
|
+
username = "noname"
|
|
118
|
+
return AuthCredentials(["authenticated"]), SimpleUser(username)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import urllib.parse
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from solara.routing import router_context
|
|
6
|
+
from solara.server import settings
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("solara-enterprise.auth")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_logout_url(return_to_path: Optional[str] = None):
|
|
12
|
+
if return_to_path is None:
|
|
13
|
+
router = router_context.get()
|
|
14
|
+
return_to_path = router.path
|
|
15
|
+
if return_to_path.startswith("/"):
|
|
16
|
+
return_to_path = return_to_path[1:]
|
|
17
|
+
assert settings.main.base_url is not None
|
|
18
|
+
return_to_app = urllib.parse.quote(settings.main.base_url + return_to_path)
|
|
19
|
+
return_to = urllib.parse.quote(settings.main.base_url + f"_solara/auth/logout?redirect_uri={return_to_app}")
|
|
20
|
+
client_id = settings.oauth.client_id
|
|
21
|
+
url = f"https://{settings.oauth.api_base_url}/{settings.oauth.logout_path}"
|
|
22
|
+
if settings.oauth.logout_path.startswith("http"):
|
|
23
|
+
url = settings.oauth.logout_path
|
|
24
|
+
return f"{url}?returnTo={return_to}&redirect_uri={return_to}&post_logout_redirect_uri={return_to}&client_id={client_id}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_login_url(return_to_path: Optional[str] = None):
|
|
28
|
+
if return_to_path is None:
|
|
29
|
+
router = router_context.get()
|
|
30
|
+
return_to_path = router.path
|
|
31
|
+
if return_to_path.startswith("/"):
|
|
32
|
+
return_to_path = return_to_path[1:]
|
|
33
|
+
assert settings.main.base_url is not None
|
|
34
|
+
redirect_uri = urllib.parse.quote(settings.main.base_url + return_to_path)
|
|
35
|
+
root = settings.main.root_path or ""
|
|
36
|
+
return f"{root}/_solara/auth/login?redirect_uri={redirect_uri}"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import pickle
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any, Callable, MutableMapping
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def make_key(object):
|
|
8
|
+
"""Generates a key by pickling the object, and generating an md5 hash of the pickled object.
|
|
9
|
+
|
|
10
|
+
Primitive objects such as short (<100 length) strings and ints are not pickled, and are used as is.
|
|
11
|
+
"""
|
|
12
|
+
if isinstance(object, str) and len(object) < 100:
|
|
13
|
+
return object.encode("utf-8")
|
|
14
|
+
elif isinstance(object, int):
|
|
15
|
+
return str(object).encode("utf-8")
|
|
16
|
+
else:
|
|
17
|
+
bytes = pickle.dumps(object)
|
|
18
|
+
if sys.version_info[:2] < (3, 9):
|
|
19
|
+
return hashlib.md5(bytes).digest()
|
|
20
|
+
else:
|
|
21
|
+
return hashlib.md5(bytes, usedforsecurity=False).digest() # type: ignore
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Base(MutableMapping):
|
|
25
|
+
def __init__(self, wrapper_dict, clear=False, prefix=b"", make_key: Callable[[Any], bytes] = make_key):
|
|
26
|
+
assert wrapper_dict is not None
|
|
27
|
+
self._wrapper_dict = wrapper_dict
|
|
28
|
+
self._make_key = make_key
|
|
29
|
+
self.prefix = prefix
|
|
30
|
+
if clear:
|
|
31
|
+
self.clear()
|
|
32
|
+
|
|
33
|
+
def generate_key(self, key) -> bytes:
|
|
34
|
+
return self.prefix + bytes(self._make_key(key))
|
|
35
|
+
|
|
36
|
+
def __getitem__(self, key):
|
|
37
|
+
if isinstance(key, bytes) and key.startswith(self.prefix):
|
|
38
|
+
wrapper_key = key
|
|
39
|
+
else:
|
|
40
|
+
wrapper_key = self.generate_key(key)
|
|
41
|
+
return pickle.loads(self._wrapper_dict[wrapper_key]) # type: ignore
|
|
42
|
+
|
|
43
|
+
def __setitem__(self, key, value):
|
|
44
|
+
if isinstance(key, bytes) and key.startswith(self.prefix):
|
|
45
|
+
wrapper_key = key
|
|
46
|
+
else:
|
|
47
|
+
wrapper_key = self.generate_key(key)
|
|
48
|
+
self._wrapper_dict[wrapper_key] = pickle.dumps(value)
|
|
49
|
+
|
|
50
|
+
def __delitem__(self, key):
|
|
51
|
+
if isinstance(key, bytes) and key.startswith(self.prefix):
|
|
52
|
+
wrapper_key = key
|
|
53
|
+
else:
|
|
54
|
+
wrapper_key = self.generate_key(key)
|
|
55
|
+
del self._wrapper_dict[wrapper_key]
|
|
56
|
+
|
|
57
|
+
def __iter__(self):
|
|
58
|
+
return iter(self.keys())
|
|
59
|
+
|
|
60
|
+
def __len__(self):
|
|
61
|
+
return len(self.keys())
|
|
62
|
+
|
|
63
|
+
def keys(self):
|
|
64
|
+
return self._wrapper_dict.keys()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import shutil
|
|
3
|
+
from typing import MutableMapping
|
|
4
|
+
|
|
5
|
+
import diskcache
|
|
6
|
+
import solara.settings
|
|
7
|
+
import solara.util
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("solara-enterprise.cache.disk")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Disk(MutableMapping):
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
clear=False,
|
|
16
|
+
max_size=solara.settings.cache.disk_max_size,
|
|
17
|
+
path=solara.settings.cache.path,
|
|
18
|
+
):
|
|
19
|
+
# same as solara.cache.Memory
|
|
20
|
+
eviction_policy = "least-recently-used"
|
|
21
|
+
max_size = solara.util.parse_size(max_size)
|
|
22
|
+
if clear:
|
|
23
|
+
try:
|
|
24
|
+
logger.debug("Clearing disk cache: %s", path)
|
|
25
|
+
shutil.rmtree(path)
|
|
26
|
+
except OSError: # Windows wonkiness
|
|
27
|
+
logger.exception(f"Error clearing disk cache: {path}")
|
|
28
|
+
|
|
29
|
+
self.diskcache = diskcache.Cache(path, size_limit=max_size, eviction_policy=eviction_policy)
|
|
30
|
+
|
|
31
|
+
def __getitem__(self, key):
|
|
32
|
+
return self.diskcache[key]
|
|
33
|
+
|
|
34
|
+
def __setitem__(self, key, value):
|
|
35
|
+
self.diskcache[key] = value
|
|
36
|
+
|
|
37
|
+
def __delitem__(self, key):
|
|
38
|
+
del self.diskcache[key]
|
|
39
|
+
|
|
40
|
+
def __iter__(self):
|
|
41
|
+
return iter(self.diskcache)
|
|
42
|
+
|
|
43
|
+
def __len__(self):
|
|
44
|
+
return len(self.diskcache)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import pickle
|
|
3
|
+
from typing import Any, Callable, MutableMapping
|
|
4
|
+
|
|
5
|
+
import solara.settings
|
|
6
|
+
import solara.util
|
|
7
|
+
from cachetools import LRUCache
|
|
8
|
+
|
|
9
|
+
from solara_enterprise.cache.base import Base, make_key
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("solara-enterprise.cache.memory")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def sizeof(obj):
|
|
15
|
+
size = len(pickle.dumps(obj))
|
|
16
|
+
logger.debug("size of %s: %s", obj, size)
|
|
17
|
+
return size
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MemorySize(Base):
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
max_size=solara.settings.cache.memory_max_size,
|
|
24
|
+
make_key: Callable[[Any], bytes] = make_key,
|
|
25
|
+
sizeof: Callable[[Any], int] = sizeof,
|
|
26
|
+
):
|
|
27
|
+
maxsize = solara.util.parse_size(max_size)
|
|
28
|
+
_wrapper_dict: MutableMapping[bytes, bytes] = LRUCache(maxsize=maxsize, getsizeof=sizeof)
|
|
29
|
+
super().__init__(_wrapper_dict, make_key=make_key)
|