zrb 1.0.0a18__py3-none-any.whl → 1.0.0a21__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.
Files changed (67) hide show
  1. zrb/__init__.py +5 -0
  2. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/auth/client/api_client.py +1 -1
  3. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/auth/client/direct_client.py +1 -1
  4. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/auth/route.py +1 -1
  5. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/auth/service/user/user_usecase.py +1 -5
  6. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/auth/service/user/user_usecase_factory.py +6 -0
  7. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/gateway/route.py +3 -9
  8. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/gateway/subroute/auth.py +44 -0
  9. zrb/config.py +17 -0
  10. zrb/input/any_input.py +4 -0
  11. zrb/input/base_input.py +4 -4
  12. zrb/input/bool_input.py +1 -1
  13. zrb/input/float_input.py +2 -2
  14. zrb/input/int_input.py +1 -1
  15. zrb/input/option_input.py +2 -2
  16. zrb/input/password_input.py +2 -2
  17. zrb/input/text_input.py +2 -2
  18. zrb/runner/cli.py +9 -34
  19. zrb/runner/common_util.py +31 -0
  20. zrb/runner/refresh-token.template.js +22 -0
  21. zrb/runner/web_app.py +211 -49
  22. zrb/runner/web_config.py +274 -0
  23. zrb/runner/web_controller/error_page/controller.py +27 -0
  24. zrb/runner/web_controller/error_page/view.html +34 -0
  25. zrb/runner/web_controller/group_info_page/controller.py +40 -0
  26. zrb/runner/web_controller/group_info_page/view.html +37 -0
  27. zrb/runner/web_controller/home_page/controller.py +13 -48
  28. zrb/runner/web_controller/home_page/view.html +30 -20
  29. zrb/runner/web_controller/login_page/controller.py +25 -0
  30. zrb/runner/web_controller/login_page/view.html +51 -0
  31. zrb/runner/web_controller/logout_page/controller.py +26 -0
  32. zrb/runner/web_controller/logout_page/view.html +41 -0
  33. zrb/runner/web_controller/{task_ui → session_page}/controller.py +35 -26
  34. zrb/runner/web_controller/{task_ui → session_page}/partial/input.html +1 -1
  35. zrb/runner/web_controller/session_page/view.html +92 -0
  36. zrb/runner/web_controller/static/common.css +11 -0
  37. zrb/runner/web_controller/static/login/event.js +33 -0
  38. zrb/runner/web_controller/static/logout/event.js +20 -0
  39. zrb/runner/web_controller/static/pico.min.css +1 -1
  40. zrb/runner/web_controller/static/session/common-util.js +63 -0
  41. zrb/runner/web_controller/static/session/current-session.js +164 -0
  42. zrb/runner/web_controller/static/session/event.js +119 -0
  43. zrb/runner/web_controller/static/session/past-session.js +144 -0
  44. zrb/runner/web_util.py +62 -4
  45. {zrb-1.0.0a18.dist-info → zrb-1.0.0a21.dist-info}/METADATA +9 -52
  46. {zrb-1.0.0a18.dist-info → zrb-1.0.0a21.dist-info}/RECORD +51 -47
  47. zrb/runner/web_controller/group_info_ui/controller.py +0 -83
  48. zrb/runner/web_controller/group_info_ui/partial/group_info.html +0 -2
  49. zrb/runner/web_controller/group_info_ui/partial/group_li.html +0 -1
  50. zrb/runner/web_controller/group_info_ui/partial/task_info.html +0 -2
  51. zrb/runner/web_controller/group_info_ui/partial/task_li.html +0 -1
  52. zrb/runner/web_controller/group_info_ui/view.html +0 -31
  53. zrb/runner/web_controller/home_page/partial/group_info.html +0 -2
  54. zrb/runner/web_controller/home_page/partial/group_li.html +0 -1
  55. zrb/runner/web_controller/home_page/partial/task_info.html +0 -2
  56. zrb/runner/web_controller/home_page/partial/task_li.html +0 -1
  57. zrb/runner/web_controller/task_ui/__init__.py +0 -0
  58. zrb/runner/web_controller/task_ui/partial/common-util.js +0 -37
  59. zrb/runner/web_controller/task_ui/partial/main.js +0 -195
  60. zrb/runner/web_controller/task_ui/partial/show-existing-session.js +0 -97
  61. zrb/runner/web_controller/task_ui/partial/visualize-history.js +0 -104
  62. zrb/runner/web_controller/task_ui/view.html +0 -87
  63. /zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/auth/service/user/repository/{factory.py → user_repository_factory.py} +0 -0
  64. /zrb/{builtin/project/add/fastapp/fastapp_template/my_app_name/module/auth/service/user/repository → runner/web_controller/group_info_page}/__init__.py +0 -0
  65. /zrb/runner/web_controller/{group_info_ui → session_page}/__init__.py +0 -0
  66. {zrb-1.0.0a18.dist-info → zrb-1.0.0a21.dist-info}/WHEEL +0 -0
  67. {zrb-1.0.0a18.dist-info → zrb-1.0.0a21.dist-info}/entry_points.txt +0 -0
zrb/runner/web_app.py CHANGED
@@ -1,30 +1,57 @@
1
1
  import asyncio
2
+ import json
2
3
  import os
3
4
  import sys
4
- from datetime import datetime, timedelta
5
- from typing import Any
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import TYPE_CHECKING, Annotated
6
7
 
7
- from zrb.config import BANNER, WEB_HTTP_PORT
8
+ from zrb.config import BANNER, VERSION
8
9
  from zrb.context.shared_context import SharedContext
9
10
  from zrb.group.any_group import AnyGroup
10
- from zrb.runner.web_controller.group_info_ui.controller import handle_group_info_ui
11
- from zrb.runner.web_controller.home_page.controller import handle_home_page
12
- from zrb.runner.web_controller.task_ui.controller import handle_task_ui
13
- from zrb.runner.web_util import NewSessionResponse
11
+ from zrb.runner.common_util import get_run_kwargs
12
+ from zrb.runner.web_config import (
13
+ NewSessionResponse,
14
+ RefreshTokenRequest,
15
+ Token,
16
+ WebConfig,
17
+ )
18
+ from zrb.runner.web_controller.error_page.controller import show_error_page
19
+ from zrb.runner.web_controller.group_info_page.controller import show_group_info_page
20
+ from zrb.runner.web_controller.home_page.controller import show_home_page
21
+ from zrb.runner.web_controller.login_page.controller import show_login_page
22
+ from zrb.runner.web_controller.logout_page.controller import show_logout_page
23
+ from zrb.runner.web_controller.session_page.controller import show_session_page
24
+ from zrb.runner.web_util import get_refresh_token_js
14
25
  from zrb.session.session import Session
15
26
  from zrb.session_state_log.session_state_log import SessionStateLog, SessionStateLogList
16
- from zrb.session_state_logger.default_session_state_logger import (
17
- default_session_state_logger,
18
- )
27
+ from zrb.session_state_logger.any_session_state_logger import AnySessionStateLogger
19
28
  from zrb.task.any_task import AnyTask
20
- from zrb.util.group import extract_node_from_args, get_node_path
29
+ from zrb.util.group import NodeNotFoundError, extract_node_from_args, get_node_path
30
+
31
+ if TYPE_CHECKING:
32
+ # We want fastapi to only be loaded when necessary to decrease footprint
33
+ from fastapi import FastAPI
21
34
 
22
35
 
23
- def create_app(root_group: AnyGroup, port: int = WEB_HTTP_PORT):
36
+ def create_app(
37
+ root_group: AnyGroup,
38
+ web_config: WebConfig,
39
+ session_state_logger: AnySessionStateLogger,
40
+ ) -> "FastAPI":
24
41
  from contextlib import asynccontextmanager
25
42
 
26
- from fastapi import FastAPI, HTTPException, Query, Request
27
- from fastapi.responses import FileResponse, HTMLResponse
43
+ from fastapi import (
44
+ Cookie,
45
+ Depends,
46
+ FastAPI,
47
+ HTTPException,
48
+ Query,
49
+ Request,
50
+ Response,
51
+ )
52
+ from fastapi.openapi.docs import get_swagger_ui_html
53
+ from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse
54
+ from fastapi.security import OAuth2PasswordRequestForm
28
55
  from fastapi.staticfiles import StaticFiles
29
56
 
30
57
  _STATIC_DIR = os.path.join(os.path.dirname(__file__), "web_controller", "static")
@@ -33,7 +60,7 @@ def create_app(root_group: AnyGroup, port: int = WEB_HTTP_PORT):
33
60
  @asynccontextmanager
34
61
  async def lifespan(app: FastAPI):
35
62
  for line in BANNER.split("\n") + [
36
- f"Zrb Server running on http://localhost:{port}"
63
+ f"Zrb Server running on http://localhost:{web_config.port}"
37
64
  ]:
38
65
  print(line, file=sys.stderr)
39
66
  yield
@@ -41,17 +68,16 @@ def create_app(root_group: AnyGroup, port: int = WEB_HTTP_PORT):
41
68
  coro.cancel()
42
69
  asyncio.gather(*_COROS)
43
70
 
44
- app = FastAPI(title="zrb", lifespan=lifespan)
45
-
46
- # Serve static files
71
+ app = FastAPI(
72
+ title="Zrb",
73
+ version=VERSION,
74
+ summary="Your Automation Powerhouse",
75
+ lifespan=lifespan,
76
+ docs_url=None,
77
+ )
47
78
  app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
48
79
 
49
- @app.get("/", response_class=HTMLResponse, include_in_schema=False)
50
- @app.get("/ui", response_class=HTMLResponse, include_in_schema=False)
51
- @app.get("/ui/", response_class=HTMLResponse, include_in_schema=False)
52
- async def home_page():
53
- return handle_home_page(root_group)
54
-
80
+ # Serve static files
55
81
  @app.get("/static/{file_path:path}", include_in_schema=False)
56
82
  async def static_files(file_path: str):
57
83
  full_path = os.path.join(_STATIC_DIR, file_path)
@@ -59,62 +85,198 @@ def create_app(root_group: AnyGroup, port: int = WEB_HTTP_PORT):
59
85
  return FileResponse(full_path)
60
86
  raise HTTPException(status_code=404, detail="File not found")
61
87
 
62
- @app.get("/ui/{path:path}", include_in_schema=False)
63
- async def ui_page(path: str):
88
+ @app.get("/refresh-token.js", include_in_schema=False)
89
+ async def refresh_token_js():
90
+ return PlainTextResponse(
91
+ content=get_refresh_token_js(
92
+ 60 * web_config.refresh_token_expire_minutes / 3
93
+ ),
94
+ media_type="application/javascript",
95
+ )
96
+
97
+ @app.get("/docs", include_in_schema=False)
98
+ async def swagger_ui_html():
99
+ return get_swagger_ui_html(
100
+ openapi_url="/openapi.json",
101
+ title="Zrb",
102
+ swagger_favicon_url="/static/favicon-32x32.png",
103
+ )
104
+
105
+ # Serve homepage
106
+ @app.get("/", response_class=HTMLResponse, include_in_schema=False)
107
+ @app.get("/ui", response_class=HTMLResponse, include_in_schema=False)
108
+ @app.get("/ui/", response_class=HTMLResponse, include_in_schema=False)
109
+ async def home_page_ui(request: Request) -> HTMLResponse:
110
+ user = await web_config.get_user_from_request(request)
111
+ return show_home_page(user, root_group)
112
+
113
+ @app.get("/ui/{path:path}", response_class=HTMLResponse, include_in_schema=False)
114
+ async def ui_page(path: str, request: Request) -> HTMLResponse:
115
+ user = await web_config.get_user_from_request(request)
64
116
  # Avoid capturing '/ui' itself
65
117
  if not path:
66
- raise HTTPException(status_code=404, detail="Not Found")
118
+ return show_error_page(user, root_group, 422, "Undefined path")
67
119
  args = path.strip("/").split("/")
68
- node, node_path, residual_args = extract_node_from_args(root_group, args)
120
+ try:
121
+ node, node_path, residual_args = extract_node_from_args(root_group, args)
122
+ except NodeNotFoundError as e:
123
+ return show_error_page(user, root_group, 404, str(e))
69
124
  url = f"/ui/{'/'.join(node_path)}/"
70
125
  if isinstance(node, AnyTask):
126
+ if not user.can_access_task(node):
127
+ return show_error_page(user, root_group, 403, "Forbidden")
71
128
  shared_ctx = SharedContext(env=dict(os.environ))
72
129
  session = Session(shared_ctx=shared_ctx, root_group=root_group)
73
- return handle_task_ui(root_group, node, session, url, residual_args)
130
+ return show_session_page(
131
+ user, root_group, node, session, url, residual_args
132
+ )
74
133
  elif isinstance(node, AnyGroup):
75
- return handle_group_info_ui(root_group, node, url)
76
- raise HTTPException(status_code=404, detail="Not Found")
134
+ if not user.can_access_group(node):
135
+ return show_error_page(user, root_group, 403, "Forbidden")
136
+ return show_group_info_page(user, root_group, node, url)
137
+ return show_error_page(user, root_group, 404, "Not found")
138
+
139
+ @app.get("/login", response_class=HTMLResponse, include_in_schema=False)
140
+ async def login(request: Request) -> HTMLResponse:
141
+ user = await web_config.get_user_from_request(request)
142
+ return show_login_page(user, root_group)
143
+
144
+ @app.get("/logout", response_class=HTMLResponse, include_in_schema=False)
145
+ async def logout(request: Request) -> HTMLResponse:
146
+ user = await web_config.get_user_from_request(request)
147
+ return show_logout_page(user, root_group)
148
+
149
+ @app.post("/api/v1/login")
150
+ async def login_api(
151
+ response: Response, form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
152
+ ):
153
+ token = web_config.generate_tokens_by_credentials(
154
+ username=form_data.username, password=form_data.password
155
+ )
156
+ if token is None:
157
+ raise HTTPException(
158
+ status_code=400, detail="Incorrect username or password"
159
+ )
160
+ _set_auth_cookie(response, token)
161
+ return token
162
+
163
+ @app.post("/api/v1/refresh-token")
164
+ async def refresh_token_api(
165
+ response: Response,
166
+ body: RefreshTokenRequest = None,
167
+ refresh_token_cookie: str = Cookie(
168
+ None, alias=web_config.refresh_token_cookie_name
169
+ ),
170
+ ):
171
+ # Try to get the refresh token from the request body first
172
+ refresh_token = body.refresh_token if body else None
173
+ # If not in the body, try to get it from the cookie
174
+ if not refresh_token:
175
+ refresh_token = refresh_token_cookie
176
+ # If we still don't have a refresh token, raise an exception
177
+ if not refresh_token:
178
+ raise HTTPException(status_code=400, detail="Refresh token not provided")
179
+ # Get token
180
+ new_token = web_config.regenerate_tokens(refresh_token)
181
+ _set_auth_cookie(response, new_token)
182
+ return new_token
183
+
184
+ def _set_auth_cookie(response: Response, token: Token):
185
+ access_token_max_age = web_config.access_token_expire_minutes * 60
186
+ refresh_token_max_age = web_config.refresh_token_expire_minutes * 60
187
+ now = datetime.now(timezone.utc)
188
+ response.set_cookie(
189
+ key=web_config.access_token_cookie_name,
190
+ value=token.access_token,
191
+ httponly=True,
192
+ max_age=access_token_max_age,
193
+ expires=now + timedelta(seconds=access_token_max_age),
194
+ )
195
+ response.set_cookie(
196
+ key=web_config.refresh_token_cookie_name,
197
+ value=token.refresh_token,
198
+ httponly=True,
199
+ max_age=refresh_token_max_age,
200
+ expires=now + timedelta(seconds=refresh_token_max_age),
201
+ )
202
+
203
+ @app.get("/api/v1/logout")
204
+ @app.post("/api/v1/logout")
205
+ async def logout_api(response: Response):
206
+ response.delete_cookie(web_config.access_token_cookie_name)
207
+ response.delete_cookie(web_config.refresh_token_cookie_name)
208
+ return {"message": "Logout successful"}
77
209
 
78
- @app.post("/api/sessions/{path:path}")
79
- async def create_new_session(
80
- path: str, request: Request = None
210
+ @app.post("/api/v1/task-sessions/{path:path}")
211
+ async def create_new_task_session_api(
212
+ path: str,
213
+ request: Request,
81
214
  ) -> NewSessionResponse:
82
215
  """
83
216
  Creating new session
84
217
  """
218
+ user = await web_config.get_user_from_request(request)
85
219
  args = path.strip("/").split("/")
86
- node, _, residual_args = extract_node_from_args(root_group, args)
87
- if isinstance(node, AnyTask):
220
+ task, _, residual_args = extract_node_from_args(root_group, args)
221
+ if isinstance(task, AnyTask):
222
+ if not user.can_access_task(task):
223
+ raise HTTPException(status_code=403)
88
224
  session_name = residual_args[0] if residual_args else None
89
225
  if not session_name:
90
226
  body = await request.json()
91
227
  shared_ctx = SharedContext(env=dict(os.environ))
92
228
  session = Session(shared_ctx=shared_ctx, root_group=root_group)
93
- coro = asyncio.create_task(node.async_run(session, str_kwargs=body))
229
+ coro = asyncio.create_task(task.async_run(session, str_kwargs=body))
94
230
  _COROS.append(coro)
95
231
  coro.add_done_callback(lambda coro: _COROS.remove(coro))
96
232
  return NewSessionResponse(session_name=session.name)
233
+ raise HTTPException(status_code=404)
234
+
235
+ @app.get("/api/v1/task-inputs/{path:path}", response_model=dict[str, str])
236
+ async def get_default_inputs_api(
237
+ path: str,
238
+ request: Request,
239
+ query: str = Query("{}", description="JSON encoded inputs"),
240
+ ) -> dict[str, str]:
241
+ """
242
+ Getting input completion for path
243
+ """
244
+ user = await web_config.get_user_from_request(request)
245
+ args = path.strip("/").split("/")
246
+ task, _, _ = extract_node_from_args(root_group, args)
247
+ if isinstance(task, AnyTask):
248
+ if not user.can_access_task(task):
249
+ raise HTTPException(status_code=403)
250
+ query_dict = json.loads(query)
251
+ run_kwargs = get_run_kwargs(
252
+ task=task, args=[], kwargs=query_dict, prompt=False
253
+ )
254
+ return run_kwargs
97
255
  raise HTTPException(status_code=404, detail="Not Found")
98
256
 
99
257
  @app.get(
100
- "/api/sessions/{path:path}",
258
+ "/api/v1/task-sessions/{path:path}",
101
259
  response_model=SessionStateLog | SessionStateLogList,
102
260
  )
103
- async def get_session(
261
+ async def get_session_api(
104
262
  path: str,
263
+ request: Request,
105
264
  min_start_query: str = Query(default=None, alias="from"),
106
265
  max_start_query: str = Query(default=None, alias="to"),
107
266
  page: int = Query(default=0, alias="page"),
108
267
  limit: int = Query(default=10, alias="limit"),
109
- ):
268
+ ) -> SessionStateLog | SessionStateLogList:
110
269
  """
111
270
  Getting existing session or sessions
112
271
  """
272
+ user = await web_config.get_user_from_request(request)
113
273
  args = path.strip("/").split("/")
114
- node, _, residual_args = extract_node_from_args(root_group, args)
115
- if isinstance(node, AnyTask) and residual_args:
274
+ task, _, residual_args = extract_node_from_args(root_group, args)
275
+ if isinstance(task, AnyTask) and residual_args:
276
+ if not user.can_access_task(task):
277
+ raise HTTPException(status_code=403)
116
278
  if residual_args[0] == "list":
117
- task_path = get_node_path(root_group, node)
279
+ task_path = get_node_path(root_group, task)
118
280
  max_start_time = (
119
281
  datetime.now()
120
282
  if max_start_query is None
@@ -125,14 +287,14 @@ def create_app(root_group: AnyGroup, port: int = WEB_HTTP_PORT):
125
287
  if min_start_query is None
126
288
  else datetime.strptime(min_start_query, "%Y-%m-%d %H:%M:%S")
127
289
  )
128
- return list_sessions(
290
+ return _get_existing_sessions(
129
291
  task_path, min_start_time, max_start_time, page, limit
130
292
  )
131
293
  else:
132
- return read_session(residual_args[0])
294
+ return _read_session(residual_args[0])
133
295
  raise HTTPException(status_code=404, detail="Not Found")
134
296
 
135
- def list_sessions(
297
+ def _get_existing_sessions(
136
298
  task_path: list[str],
137
299
  min_start_time: datetime,
138
300
  max_start_time: datetime,
@@ -140,7 +302,7 @@ def create_app(root_group: AnyGroup, port: int = WEB_HTTP_PORT):
140
302
  limit: int,
141
303
  ) -> SessionStateLogList:
142
304
  try:
143
- return default_session_state_logger.list(
305
+ return session_state_logger.list(
144
306
  task_path,
145
307
  min_start_time=min_start_time,
146
308
  max_start_time=max_start_time,
@@ -150,9 +312,9 @@ def create_app(root_group: AnyGroup, port: int = WEB_HTTP_PORT):
150
312
  except Exception as e:
151
313
  raise HTTPException(status_code=500, detail=str(e))
152
314
 
153
- def read_session(session_name: str) -> SessionStateLog:
315
+ def _read_session(session_name: str) -> SessionStateLog:
154
316
  try:
155
- return default_session_state_logger.read(session_name)
317
+ return session_state_logger.read(session_name)
156
318
  except Exception as e:
157
319
  raise HTTPException(status_code=500, detail=str(e))
158
320
 
@@ -0,0 +1,274 @@
1
+ from datetime import datetime, timedelta
2
+ from typing import TYPE_CHECKING, Callable
3
+
4
+ from pydantic import BaseModel, ConfigDict
5
+
6
+ from zrb.config import (
7
+ WEB_ACCESS_TOKEN_COOKIE_NAME,
8
+ WEB_AUTH_ACCESS_TOKEN_EXPIRE_MINUTES,
9
+ WEB_AUTH_REFRESH_TOKEN_EXPIRE_MINUTES,
10
+ WEB_ENABLE_AUTH,
11
+ WEB_GUEST_USERNAME,
12
+ WEB_HTTP_PORT,
13
+ WEB_REFRESH_TOKEN_COOKIE_NAME,
14
+ WEB_SECRET_KEY,
15
+ WEB_SUPER_ADMIN_PASSWORD,
16
+ WEB_SUPER_ADMIN_USERNAME,
17
+ )
18
+ from zrb.group.any_group import AnyGroup
19
+ from zrb.task.any_task import AnyTask
20
+ from zrb.util.group import get_all_subtasks
21
+
22
+ if TYPE_CHECKING:
23
+ # Import Request only for type checking to reduce runtime dependencies
24
+ from fastapi import Request
25
+
26
+
27
+ class NewSessionResponse(BaseModel):
28
+ session_name: str
29
+
30
+
31
+ class RefreshTokenRequest(BaseModel):
32
+ refresh_token: str
33
+
34
+
35
+ class User(BaseModel):
36
+ model_config = ConfigDict(arbitrary_types_allowed=True)
37
+ username: str
38
+ password: str = ""
39
+ is_super_admin: bool = False
40
+ is_guest: bool = False
41
+ accessible_tasks: list[AnyTask | str] = []
42
+
43
+ def is_password_match(self, password: str) -> bool:
44
+ return self.password == password
45
+
46
+ def can_access_group(self, group: AnyGroup) -> bool:
47
+ if self.is_super_admin:
48
+ return True
49
+ all_tasks = get_all_subtasks(group, web_only=True)
50
+ if any(self.can_access_task(task) for task in all_tasks):
51
+ return True
52
+ return False
53
+
54
+ def can_access_task(self, task: AnyTask) -> bool:
55
+ if self.is_super_admin:
56
+ return True
57
+ if task.name in self.accessible_tasks or task in self.accessible_tasks:
58
+ return True
59
+ return False
60
+
61
+
62
+ class Token(BaseModel):
63
+ access_token: str
64
+ refresh_token: str
65
+ token_type: str
66
+
67
+
68
+ class WebConfig:
69
+ def __init__(
70
+ self,
71
+ port: int,
72
+ secret_key: str,
73
+ access_token_expire_minutes: int,
74
+ refresh_token_expire_minutes: int,
75
+ access_token_cookie_name: str,
76
+ refresh_token_cookie_name: str,
77
+ enable_auth: bool,
78
+ super_admin_username: str,
79
+ super_admin_password: str,
80
+ guest_username: str,
81
+ guest_accessible_tasks: list[AnyTask | str] = [],
82
+ find_user_by_username: Callable[[str], User | None] | None = None,
83
+ ):
84
+ self.secret_key = secret_key
85
+ self.access_token_expire_minutes = access_token_expire_minutes
86
+ self.refresh_token_expire_minutes = refresh_token_expire_minutes
87
+ self.access_token_cookie_name = access_token_cookie_name
88
+ self.refresh_token_cookie_name = refresh_token_cookie_name
89
+ self.enable_auth = enable_auth
90
+ self.port = port
91
+ self._user_list = []
92
+ self.super_admin_username = super_admin_username
93
+ self.super_admin_password = super_admin_password
94
+ self.guest_username = guest_username
95
+ self.guest_accessible_tasks = guest_accessible_tasks
96
+ self._find_user_by_username = find_user_by_username
97
+
98
+ @property
99
+ def default_user(self) -> User:
100
+ if self.enable_auth:
101
+ return User(
102
+ username=self.guest_username,
103
+ password="",
104
+ is_guest=True,
105
+ accessible_tasks=self.guest_accessible_tasks,
106
+ )
107
+ return User(
108
+ username=self.guest_username,
109
+ password="",
110
+ is_guest=True,
111
+ is_super_admin=True,
112
+ )
113
+
114
+ @property
115
+ def super_admin(self) -> User:
116
+ return User(
117
+ username=self.super_admin_username,
118
+ password=self.super_admin_password,
119
+ is_super_admin=True,
120
+ )
121
+
122
+ @property
123
+ def user_list(self) -> list[User]:
124
+ if not self.enable_auth:
125
+ return [self.default_user]
126
+ return self._user_list + [self.super_admin, self.default_user]
127
+
128
+ def set_guest_accessible_tasks(self, tasks: list[AnyTask | str]):
129
+ self.guest_accessible_tasks = tasks
130
+
131
+ def set_find_user_by_username(
132
+ self, find_user_by_username: Callable[[str], User | None]
133
+ ):
134
+ self._find_user_by_username = find_user_by_username
135
+
136
+ def append_user(self, user: User):
137
+ duplicates = [
138
+ existing_user
139
+ for existing_user in self.user_list
140
+ if existing_user.username == user.username
141
+ ]
142
+ if len(duplicates) > 0:
143
+ raise ValueError(f"User already exists {user.username}")
144
+ self._user_list.append(user)
145
+
146
+ def find_user_by_username(self, username: str) -> User | None:
147
+ user = None
148
+ if self._find_user_by_username is not None:
149
+ user = self._find_user_by_username(username)
150
+ if user is None:
151
+ user = next((u for u in self.user_list if u.username == username), None)
152
+ return user
153
+
154
+ async def get_user_from_request(self, request: "Request") -> User | None:
155
+ from fastapi.security import OAuth2PasswordBearer
156
+
157
+ if not self.enable_auth:
158
+ return self.default_user
159
+ # Normally we use "Depends"
160
+ get_bearer_token = OAuth2PasswordBearer(
161
+ tokenUrl="/api/v1/login", auto_error=False
162
+ )
163
+ bearer_token = await get_bearer_token(request)
164
+ token_user = self._get_user_from_token(bearer_token)
165
+ if token_user is not None:
166
+ return token_user
167
+ cookie_user = self._get_user_from_cookie(request)
168
+ if cookie_user is not None:
169
+ return cookie_user
170
+ return self.default_user
171
+
172
+ def _get_user_from_token(self, token: str) -> User | None:
173
+ try:
174
+ from jose import jwt
175
+
176
+ payload = jwt.decode(
177
+ token,
178
+ self.secret_key,
179
+ options={"require_sub": True, "require_exp": True},
180
+ )
181
+ username: str = payload.get("sub")
182
+ if username is None:
183
+ return None
184
+ user = self.find_user_by_username(username)
185
+ if user is None:
186
+ return None
187
+ return user
188
+ except Exception:
189
+ return None
190
+
191
+ def _get_user_from_cookie(self, request: "Request") -> User | None:
192
+ token = request.cookies.get(self.access_token_cookie_name)
193
+ if token:
194
+ return self._get_user_from_token(token)
195
+ return None
196
+
197
+ def get_user_by_credentials(self, username: str, password: str) -> User | None:
198
+ user = self.find_user_by_username(username)
199
+ if user is None or not user.is_password_match(password):
200
+ return None
201
+ return user
202
+
203
+ def generate_tokens_by_credentials(
204
+ self, username: str, password: str
205
+ ) -> Token | None:
206
+ if not self.enable_auth:
207
+ user = self.default_user
208
+ else:
209
+ user = self.get_user_by_credentials(username, password)
210
+ if user is None:
211
+ return None
212
+ access_token = self._generate_access_token(user.username)
213
+ refresh_token = self._generate_refresh_token(user.username)
214
+ return Token(
215
+ access_token=access_token, refresh_token=refresh_token, token_type="bearer"
216
+ )
217
+
218
+ def _generate_access_token(self, username: str) -> str:
219
+ from jose import jwt
220
+
221
+ expire = datetime.now() + timedelta(minutes=self.access_token_expire_minutes)
222
+ to_encode = {"sub": username, "exp": expire, "type": "access"}
223
+ return jwt.encode(to_encode, self.secret_key)
224
+
225
+ def _generate_refresh_token(self, username: str) -> str:
226
+ from jose import jwt
227
+
228
+ expire = datetime.now() + timedelta(minutes=self.refresh_token_expire_minutes)
229
+ to_encode = {"sub": username, "exp": expire, "type": "refresh"}
230
+ return jwt.encode(to_encode, self.secret_key)
231
+
232
+ def regenerate_tokens(self, refresh_token: str) -> Token:
233
+ from fastapi import HTTPException
234
+ from jose import jwt
235
+
236
+ # Decode and validate token
237
+ try:
238
+ payload = jwt.decode(
239
+ refresh_token,
240
+ self.secret_key,
241
+ options={"require_exp": True, "require_sub": True},
242
+ )
243
+ except Exception:
244
+ raise HTTPException(status_code=401, detail="Invalid JWT token")
245
+ if payload.get("type") != "refresh":
246
+ raise HTTPException(status_code=401, detail="Invalid token type")
247
+ username: str = payload.get("sub")
248
+ if username is None:
249
+ raise HTTPException(status_code=401, detail="Invalid refresh token")
250
+ user = self.find_user_by_username(username)
251
+ if user is None:
252
+ raise HTTPException(status_code=401, detail="User not found")
253
+ # Create new token
254
+ new_access_token = self._generate_access_token(username)
255
+ new_refresh_token = self._generate_refresh_token(username)
256
+ return Token(
257
+ access_token=new_access_token,
258
+ refresh_token=new_refresh_token,
259
+ token_type="bearer",
260
+ )
261
+
262
+
263
+ web_config = WebConfig(
264
+ port=WEB_HTTP_PORT,
265
+ secret_key=WEB_SECRET_KEY,
266
+ access_token_expire_minutes=WEB_AUTH_ACCESS_TOKEN_EXPIRE_MINUTES,
267
+ refresh_token_expire_minutes=WEB_AUTH_REFRESH_TOKEN_EXPIRE_MINUTES,
268
+ access_token_cookie_name=WEB_ACCESS_TOKEN_COOKIE_NAME,
269
+ refresh_token_cookie_name=WEB_REFRESH_TOKEN_COOKIE_NAME,
270
+ enable_auth=WEB_ENABLE_AUTH,
271
+ super_admin_username=WEB_SUPER_ADMIN_USERNAME,
272
+ super_admin_password=WEB_SUPER_ADMIN_PASSWORD,
273
+ guest_username=WEB_GUEST_USERNAME,
274
+ )
@@ -0,0 +1,27 @@
1
+ import os
2
+
3
+ from zrb.group.any_group import AnyGroup
4
+ from zrb.runner.web_config import User
5
+ from zrb.runner.web_util import get_html_auth_link
6
+ from zrb.util.file import read_file
7
+ from zrb.util.string.format import fstring_format
8
+
9
+
10
+ def show_error_page(user: User, root_group: AnyGroup, status_code: int, message: str):
11
+ from fastapi.responses import HTMLResponse
12
+
13
+ _DIR = os.path.dirname(__file__)
14
+ _VIEW_TEMPLATE = read_file(os.path.join(_DIR, "view.html"))
15
+ auth_link = get_html_auth_link(user)
16
+ return HTMLResponse(
17
+ fstring_format(
18
+ _VIEW_TEMPLATE,
19
+ {
20
+ "name": root_group.name,
21
+ "description": root_group.description,
22
+ "auth_link": auth_link,
23
+ "error_status_code": status_code,
24
+ "error_message": message,
25
+ },
26
+ )
27
+ )