bazaar-contrib-server-access 0.3.0rc1__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.
@@ -0,0 +1,71 @@
1
+ """Sessions and the login page the server's pages and APIs are behind."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import asynccontextmanager
6
+ lazy import asyncio
7
+ lazy import logging
8
+ lazy import os
9
+ lazy from collections.abc import AsyncIterator
10
+ lazy from importlib.resources import files
11
+ lazy from pathlib import Path
12
+
13
+ from bazaar_contrib_server import SERVER
14
+ from bazaar_contrib_server_database import SERVER_DB, SERVER_SCHEMA
15
+ from bazaar_contrib_server_migrations import SERVER_MIGRATIONS
16
+ from bazaar_core import Context, Contrib
17
+ from pydantic import BaseModel, ConfigDict, Field
18
+
19
+ lazy from .accounts import ADMIN, Accounts
20
+ lazy from .login import Login
21
+ lazy from .schema import MODULE
22
+ lazy from .sessions import Sessions, key
23
+
24
+ __all__ = ["AccessConfig", "access"]
25
+
26
+
27
+ class AccessConfig(BaseModel):
28
+ model_config = ConfigDict(extra="forbid", frozen=True)
29
+
30
+ # how long a login lasts before the person is asked again
31
+ session_minutes: int = Field(default=10, ge=1, strict=True)
32
+
33
+
34
+ @asynccontextmanager
35
+ async def _access(ctx: Context, config: AccessConfig) -> AsyncIterator[None]:
36
+ server = ctx.accept(SERVER)
37
+ async with ctx.accept(SERVER_SCHEMA).register(MODULE):
38
+ home = Path(os.environ.get("BCN_HOME") or await asyncio.to_thread(Path.home))
39
+ sessions = Sessions(await asyncio.to_thread(key, home / ".bcn" / "session.key"))
40
+ accounts = await Accounts.open(ctx.accept(SERVER_DB))
41
+ password = await accounts.ensure_admin()
42
+ if password is not None:
43
+ # the only time the password exists in the clear; it goes where
44
+ # whoever started the server is looking
45
+ logging.getLogger(__name__).warning(
46
+ "created account %s with password %s; log in and change it",
47
+ ADMIN,
48
+ password,
49
+ )
50
+ resources = Path(str(files(__name__))) / "resources"
51
+ async with (
52
+ server.module(
53
+ "access", static=resources / "static", locales=resources / "locales"
54
+ ),
55
+ server.login(
56
+ Login(
57
+ await server.pages("access", __name__),
58
+ sessions,
59
+ accounts,
60
+ config.session_minutes,
61
+ )
62
+ ),
63
+ ):
64
+ yield
65
+
66
+
67
+ access = Contrib(
68
+ config=AccessConfig,
69
+ accepts=[SERVER, SERVER_DB, SERVER_SCHEMA, SERVER_MIGRATIONS],
70
+ lifespan=_access,
71
+ )
@@ -0,0 +1,120 @@
1
+ """The accounts that may log in, read from the server's database."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ lazy from time import time_ns
7
+ lazy from typing import cast
8
+ lazy from uuid import uuid7
9
+
10
+ lazy from bazaar_contrib_storage import (
11
+ Column,
12
+ Compare,
13
+ Database,
14
+ Insert,
15
+ Record,
16
+ Select,
17
+ Value,
18
+ )
19
+
20
+ from .secrets import derive, hash_secret, new_secret, verify_secret
21
+
22
+ # the account made at first start; on its own, the only one there is
23
+ ADMIN = "admin"
24
+ # base64url of this many bytes: long enough that printing it once is safe
25
+ _PASSWORD_BYTES = 24
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class Stored:
30
+ id: str
31
+ name: str
32
+ password_hash: str
33
+ language: str | None
34
+ theme: str | None
35
+ auth_type: str
36
+ session_version: int
37
+
38
+ @property
39
+ def admin(self) -> bool:
40
+ return self.name == ADMIN and self.auth_type == "local"
41
+
42
+
43
+ def _stored(row: Record) -> Stored:
44
+ return Stored(
45
+ id=cast(str, row["id"]),
46
+ name=cast(str, row["name"]),
47
+ password_hash=cast(str, row["password_hash"]),
48
+ language=cast(str | None, row["language"]),
49
+ theme=cast(str | None, row["theme"]),
50
+ auth_type=cast(str, row["auth_type"]),
51
+ session_version=cast(int, row["session_version"]),
52
+ )
53
+
54
+
55
+ class Accounts:
56
+ def __init__(self, database: Database, nobody: str) -> None:
57
+ self._database = database
58
+ # a hash nobody's password matches, verified for names nobody has
59
+ self._nobody = nobody
60
+
61
+ @classmethod
62
+ async def open(cls, database: Database) -> Accounts:
63
+ return cls(database, await derive(hash_secret, new_secret()))
64
+
65
+ async def get(self, account_id: str) -> Stored | None:
66
+ return await self._one("id", account_id)
67
+
68
+ async def named(self, name: str) -> Stored | None:
69
+ return await self._one("name", name)
70
+
71
+ async def ensure_admin(self) -> str | None:
72
+ """The password the admin was just given, when it is new; it is shown
73
+ once and never kept in the clear."""
74
+ if await self.named(ADMIN) is not None:
75
+ return None
76
+ password = new_secret(_PASSWORD_BYTES)
77
+ password_hash = await derive(hash_secret, password)
78
+ await self._database.write(
79
+ lambda session: session.insert(
80
+ Insert(
81
+ table="accounts",
82
+ rows=(
83
+ {
84
+ "id": str(uuid7()),
85
+ "name": ADMIN,
86
+ "password_hash": password_hash,
87
+ "created_at_ms": time_ns() // 1_000_000,
88
+ },
89
+ ),
90
+ )
91
+ )
92
+ )
93
+ return password
94
+
95
+ async def verify(self, name: str, password: str) -> Stored | None:
96
+ account = await self.named(name)
97
+ # an unknown name costs the same as a wrong password, so neither can
98
+ # be told apart by timing
99
+ stored = (
100
+ self._nobody
101
+ if account is None or account.auth_type != "local"
102
+ else account.password_hash
103
+ )
104
+ if not await derive(verify_secret, password, stored):
105
+ return None
106
+ return account
107
+
108
+ async def _one(self, column: str, value: str) -> Stored | None:
109
+ rows = await self._database.read(
110
+ lambda session: session.select(
111
+ Select(
112
+ table="accounts",
113
+ where=Compare(
114
+ left=Column(name=column), operator="=", right=Value(value=value)
115
+ ),
116
+ limit=1,
117
+ )
118
+ )
119
+ )
120
+ return _stored(rows[0]) if rows else None
@@ -0,0 +1,118 @@
1
+ """Logging in and out, and who each request behind the login is from."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ lazy from bazaar_contrib_server import Pages
8
+ lazy from pydantic import BaseModel, ValidationError
9
+ lazy from starlette.requests import HTTPConnection, Request
10
+ lazy from starlette.responses import RedirectResponse, Response
11
+ lazy from starlette.routing import Route
12
+ lazy from starlette.types import ASGIApp
13
+
14
+ lazy from .accounts import Accounts
15
+ lazy from .sessions import COOKIE, Sessions
16
+
17
+ LOGIN = "/login/"
18
+ HOME = "/"
19
+ # the hint OIDC leaves for signing in again without being asked
20
+ REAUTH = "bcs_reauth"
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class Viewer:
25
+ """An account as the server's pages see it."""
26
+
27
+ id: str
28
+ name: str
29
+ theme: str | None
30
+ language: str | None
31
+ admin: bool
32
+
33
+ def allows(self, point: str) -> bool:
34
+ del point
35
+ return self.admin
36
+
37
+
38
+ class _Credentials(BaseModel):
39
+ name: str
40
+ password: str
41
+
42
+
43
+ class Login:
44
+ """The login module the server's pages and APIs are behind."""
45
+
46
+ def __init__(
47
+ self, pages: Pages, sessions: Sessions, accounts: Accounts, minutes: int
48
+ ) -> None:
49
+ self._pages = pages
50
+ self._sessions = sessions
51
+ self._accounts = accounts
52
+ self._minutes = minutes
53
+ self.app: ASGIApp = pages.application(
54
+ [
55
+ Route("/", self._form),
56
+ Route("/", self._login, methods=["POST"]),
57
+ Route("/logout", self._logout, methods=["POST"]),
58
+ ]
59
+ )
60
+
61
+ async def account(self, connection: HTTPConnection) -> Viewer | Response:
62
+ claim = self._sessions.read(connection.cookies.get(COOKIE))
63
+ stored = None if claim is None else await self._accounts.get(claim.account_id)
64
+ if (
65
+ claim is None
66
+ or stored is None
67
+ or claim.session_version != stored.session_version
68
+ ):
69
+ # htmx cannot follow a redirect into another page; it is told to go
70
+ if connection.headers.get("HX-Request") == "true":
71
+ return Response(status_code=401, headers={"HX-Redirect": LOGIN})
72
+ return RedirectResponse(LOGIN, status_code=303)
73
+ return Viewer(
74
+ id=stored.id,
75
+ name=stored.name,
76
+ theme=stored.theme,
77
+ language=stored.language,
78
+ admin=stored.admin,
79
+ )
80
+
81
+ async def _form(self, request: Request) -> Response:
82
+ return await self._pages.render(request, "access/login.html", failed=False)
83
+
84
+ async def _login(self, request: Request) -> Response:
85
+ try:
86
+ given = _Credentials.model_validate(dict(await request.form()))
87
+ except ValidationError:
88
+ given = None
89
+ stored = (
90
+ None
91
+ if given is None
92
+ else await self._accounts.verify(given.name.strip(), given.password)
93
+ )
94
+ if stored is None:
95
+ # a failed login is a fragment too: htmx swaps it into the form
96
+ return await self._pages.render(
97
+ request, "access/login_form.html", status_code=401, failed=True
98
+ )
99
+ response = Response(status_code=204, headers={"HX-Redirect": HOME})
100
+ response.delete_cookie(REAUTH, path="/")
101
+ response.set_cookie(
102
+ COOKIE,
103
+ self._sessions.issue(stored.id, stored.session_version, self._minutes),
104
+ max_age=self._minutes * 60,
105
+ path="/",
106
+ httponly=True,
107
+ samesite="lax",
108
+ # a cookie issued over https never travels over plain http
109
+ secure=request.url.scheme == "https",
110
+ )
111
+ return response
112
+
113
+ async def _logout(self, request: Request) -> Response:
114
+ del request
115
+ response = Response(status_code=204, headers={"HX-Redirect": LOGIN})
116
+ response.delete_cookie(COOKIE, path="/")
117
+ response.delete_cookie(REAUTH, path="/")
118
+ return response
File without changes
@@ -0,0 +1,8 @@
1
+ "access.login.title" = "Log in"
2
+ "access.login.welcome" = "Welcome back. Choose a way into the bazaar."
3
+ "access.login.name" = "Account"
4
+ "access.login.password" = "Password"
5
+ "access.login.submit" = "Log in"
6
+ "access.login.failed" = "Incorrect account or password."
7
+ "access.login.slogan" = "Don't build alone —<br>bring your <span>agents</span> to the bazaar."
8
+ "access.login.tagline" = "Bring your own agents to the bazaar, and build with your teams in the open."
@@ -0,0 +1,8 @@
1
+ "access.login.title" = "登录"
2
+ "access.login.welcome" = "欢迎回来。选择一种方式进入集市。"
3
+ "access.login.name" = "账号"
4
+ "access.login.password" = "密码"
5
+ "access.login.submit" = "登录"
6
+ "access.login.failed" = "账号或密码不正确。"
7
+ "access.login.slogan" = "不必独自建造——<br>带上你的<span>智能体</span>,来集市。"
8
+ "access.login.tagline" = "Bring your own agents to the bazaar, and build with your teams in the open."
@@ -0,0 +1,98 @@
1
+ /* The login page: the market street beside the form on a wide screen, below
2
+ it on a narrow one. Every colour is the theme's; the street's come from its
3
+ scene tokens, mixed for shade and light, never graded. */
4
+
5
+ html, body { height: 100%; }
6
+ body { background: var(--bg); }
7
+ .login { display: grid; grid-template-columns: 1fr 600px; min-height: 100vh; min-height: 100dvh; }
8
+
9
+ .art { position: relative; overflow: hidden; background: var(--scene-sky); }
10
+ .art svg.street-art { position: absolute; inset: 0; width: 100%; height: 100%; }
11
+ .copy { position: absolute; z-index: 1; }
12
+ .art .copy { left: 72px; top: 300px; right: 72px; }
13
+ .copy .eyebrow { font: 700 12px var(--font-ui); letter-spacing: .32em; color: var(--saffron); }
14
+ .copy h1, .copy h2 { margin: 18px 0 0; font-family: var(--font-display); font-weight: 900; font-size: 52px; line-height: 1.22; color: var(--text); letter-spacing: .02em; }
15
+ .copy h1 span, .copy h2 span { color: var(--saffron); }
16
+ .copy p { margin: 20px 0 0; font: italic 400 19px/1.5 var(--font-display); color: var(--text-2); font-variation-settings: "opsz" 24, "SOFT" 80; max-width: 520px; }
17
+
18
+ .panel { background: var(--surface); border-left: 1px solid var(--line); display: flex; flex-direction: column; padding: 40px 72px 28px; }
19
+ .brand { display: flex; align-items: center; gap: 10px; }
20
+ .brand .mark { width: 30px; height: 30px; }
21
+ .brand .word { font-family: var(--font-display); font-weight: 650; font-size: 21px; letter-spacing: -.01em; font-variation-settings: "opsz" 72, "SOFT" 60; }
22
+ .panel h2 { margin: 54px 0 6px; font-family: var(--font-display); font-size: 32px; font-weight: 650; letter-spacing: -.01em; }
23
+ .panel .sub { color: var(--muted); font-size: 14px; }
24
+
25
+ /* the form: labels above, quiet fields, one flat amber button */
26
+ .signin { display: grid; gap: 18px; margin-top: 30px; }
27
+ .fld { display: grid; gap: 8px; }
28
+ .fld label { font-size: 13px; font-weight: 500; color: var(--text-2); }
29
+ .fld input { height: 46px; padding: 0 14px; border-radius: 10px; border: 1px solid var(--line-soft); background: var(--sunken); color: var(--text); font: 15px var(--font-ui); outline: 0; }
30
+ .fld input:focus { border-color: var(--saffron-line); box-shadow: 0 0 0 3px var(--saffron-soft); }
31
+ .fld input[type="password"] { letter-spacing: .14em; }
32
+ .err { display: flex; align-items: center; gap: 7px; margin: 0; color: var(--crimson); font-size: 13px; }
33
+ .err .i svg { width: 14px; height: 14px; }
34
+ .go { margin-top: 6px; height: 46px; border: 0; border-radius: 10px; font: 600 15px var(--font-ui); background: var(--saffron); color: var(--saffron-ink); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .22), inset 0 -1px 0 rgba(0, 0, 0, .14); }
35
+
36
+ .band { display: none; }
37
+
38
+ /* narrow screens: the form first, the street under a downward arc */
39
+ @media (max-width: 959px) {
40
+ .login { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
41
+ .art { display: none; }
42
+ .panel { border-left: 0; padding: 26px 22px 10px; }
43
+ .panel h2 { margin: 18px 0 4px; font-size: 26px; }
44
+ .panel .sub { font-size: 13.5px; }
45
+ .signin { gap: 14px; margin-top: 20px; }
46
+ .fld input { font-size: 16px; }
47
+ .band { display: block; position: relative; min-height: 270px; overflow: hidden; background: var(--scene-sky); }
48
+ .band svg.street-top { position: absolute; left: 0; top: 0; width: 100%; height: auto; }
49
+ .band svg.street-bottom { position: absolute; left: 0; bottom: 0; width: 100%; height: auto; }
50
+ .band .copy { left: 22px; right: 22px; bottom: 178px; }
51
+ .band .eyebrow { font-size: 11px; letter-spacing: .3em; }
52
+ .band h2 { margin-top: 10px; font-size: 20px; line-height: 1.3; }
53
+ }
54
+
55
+ /* the street */
56
+ .s-sky { fill: var(--scene-sky); }
57
+ .s-edge { fill: var(--surface); }
58
+ .s-star { fill: var(--scene-star); }
59
+ .s-moon { fill: var(--scene-moon); }
60
+ .s-bite { fill: var(--scene-bite); }
61
+ .s-far { fill: var(--scene-far); }
62
+ .s-far2 { fill: var(--scene-far-roof); }
63
+ .s-window { fill: var(--scene-window); }
64
+ .s-cord { stroke: var(--scene-cord); stroke-width: 1.4; fill: none; }
65
+ .s-cap { fill: var(--scene-cap); }
66
+ .s-rib { stroke: rgba(0, 0, 0, .2); stroke-width: 1; fill: none; }
67
+ .s-wood { fill: var(--scene-wood); }
68
+ .s-wood-light { fill: var(--scene-wood-light); }
69
+ .s-wood-shade { fill: var(--scene-wood-shade); }
70
+ .s-inside { fill: var(--scene-inside); }
71
+ .s-inside-shade { fill: var(--scene-inside-shade); }
72
+ .s-alt { fill: var(--scene-alt); }
73
+ .s-alt-shade { fill: color-mix(in srgb, var(--scene-alt) 78%, #000); }
74
+ .s-bulb { fill: var(--scene-bulb); }
75
+ .s-ground { fill: var(--scene-ground); }
76
+ .s-ground-light { fill: var(--scene-ground-light); }
77
+ .s-ground-shade { fill: var(--scene-ground-shade); }
78
+ .s-pot { fill: var(--scene-pot); }
79
+ .s-pot-shade { fill: color-mix(in srgb, var(--scene-pot) 70%, #000); }
80
+ .s-steam { stroke: var(--scene-steam); stroke-width: 4; stroke-linecap: round; }
81
+ .f-paper, .f-cap, .f-cord, .f-rib { opacity: var(--scene-far-fade); }
82
+ .s-saffron { fill: var(--saffron); } .s-jade { fill: var(--jade); } .s-crimson { fill: var(--crimson); }
83
+ .s-indigo { fill: var(--indigo); } .s-plum { fill: var(--plum); }
84
+ .s-saffron-shade { fill: color-mix(in srgb, var(--saffron) 70%, #000); }
85
+ .s-jade-shade { fill: color-mix(in srgb, var(--jade) 70%, #000); }
86
+ .s-crimson-shade { fill: color-mix(in srgb, var(--crimson) 70%, #000); }
87
+ .s-indigo-shade { fill: color-mix(in srgb, var(--indigo) 70%, #000); }
88
+ .s-plum-shade { fill: color-mix(in srgb, var(--plum) 70%, #000); }
89
+ .s-saffron-glow { fill: color-mix(in srgb, var(--saffron) 45%, var(--scene-glow)); }
90
+ .s-jade-glow { fill: color-mix(in srgb, var(--jade) 45%, var(--scene-glow)); }
91
+ .s-crimson-glow { fill: color-mix(in srgb, var(--crimson) 45%, var(--scene-glow)); }
92
+ .s-indigo-glow { fill: color-mix(in srgb, var(--indigo) 45%, var(--scene-glow)); }
93
+ .s-plum-glow { fill: color-mix(in srgb, var(--plum) 45%, var(--scene-glow)); }
94
+ .s-saffron-dim { fill: color-mix(in srgb, var(--saffron) 60%, var(--scene-inside)); }
95
+ .s-jade-dim { fill: color-mix(in srgb, var(--jade) 60%, var(--scene-inside)); }
96
+ .s-crimson-dim { fill: color-mix(in srgb, var(--crimson) 60%, var(--scene-inside)); }
97
+ .s-indigo-dim { fill: color-mix(in srgb, var(--indigo) 60%, var(--scene-inside)); }
98
+ .s-plum-dim { fill: color-mix(in srgb, var(--plum) 60%, var(--scene-inside)); }
@@ -0,0 +1,17 @@
1
+ {% extends "server/base.html" %}
2
+ {# the login page fills the window, whatever frame it was asked for in #}
3
+ {% block root %} data-top{% endblock %}
4
+ {% block title %}{{ t.text("access.login.title") }}{% endblock %}
5
+ {% block head %}<link rel="stylesheet" href="{{ asset('access.css') }}">{% endblock %}
6
+ {% block body %}
7
+ <div class="login">
8
+ {% include "access/street.html" %}
9
+ <div class="panel">
10
+ <div class="brand">{% include "server/mark.svg" %}<span class="word">Bazaar</span></div>
11
+ <h2>{{ t.text("access.login.title") }}</h2>
12
+ <div class="sub">{{ t.text("access.login.welcome") }}</div>
13
+ {% include "access/login_form.html" %}
14
+ </div>
15
+ {% include "access/street_phone.html" %}
16
+ </div>
17
+ {% endblock %}
@@ -0,0 +1,6 @@
1
+ <form class="signin" hx-post="/login/" hx-target="this" hx-swap="outerHTML">
2
+ <div class="fld"><label for="name">{{ t.text("access.login.name") }}</label><input id="name" name="name" required autocomplete="username" spellcheck="false" autocapitalize="off"></div>
3
+ <div class="fld"><label for="password">{{ t.text("access.login.password") }}</label><input id="password" name="password" type="password" required autocomplete="current-password"></div>
4
+ {% if failed %}<p class="err" role="alert"><span class="i">{% include "access/triangle-alert.svg" %}</span>{{ t.text("access.login.failed") }}</p>{% endif %}
5
+ <button class="go" type="submit">{{ t.text("access.login.submit") }}</button>
6
+ </form>