Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/ui-checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Management UI and identity
on:
pull_request:
paths:
- 'extore/auth.py'
- 'extore/static/**'
- 'tests/**'
- 'scripts/check_management_ui.py'
- '.github/workflows/ui-checks.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
with:
persist-credentials: false
submodules: recursive
- uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9
with:
version: "0.12.20"
- run: uv sync --frozen --python 3.12
- run: uv run ruff check extore scripts tests examples
- run: uv run ruff format --check extore scripts tests examples
- run: node --test tests/*.test.cjs
- run: uv run pytest -q tests/test_passkeys.py tests/test_passkey_reauth.py tests/test_shop_account_auth.py tests/test_browser_shop_scope.py
- run: uv run --with playwright==1.57.0 playwright install --with-deps chromium
- run: sudo apt-get install -y --no-install-recommends fonts-noto-cjk
- run: uv run --with playwright==1.57.0 python scripts/check_management_ui.py
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
if: always()
with:
name: management-ui-review
path: output/ui-review
retention-days: 7
113 changes: 100 additions & 13 deletions extore/auth.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import base64
import json
import time

Expand All @@ -22,7 +23,15 @@
from .config import COOKIE_SECURE, DATA, ORIGIN, RP_ID
from .db import audit, db, set_setting, setting
from .link_access import revoke_session
from .security import create_session, digest, fail, rate_limit, session, token
from .security import (
authorize_management,
create_session,
digest,
fail,
rate_limit,
session,
token,
)

router = APIRouter(prefix="/api/auth")
ph = PasswordHasher()
Expand Down Expand Up @@ -148,17 +157,23 @@ def logout(request: Request, response: Response):

def challenge(c, request, response, kind, s=None):
key = token()
descriptors = None
if s:
rows = c.execute(
"SELECT id FROM credentials WHERE shop_id IS ?", (s.get("shop_id"),)
).fetchall()
descriptors = [
PublicKeyCredentialDescriptor(
id=base64.urlsafe_b64decode(r["id"] + "=" * (-len(r["id"]) % 4))
)
for r in rows
]
if s and kind == "register":
if (
s["channel"] == "browser"
and time.time() - (s.get("auth_at") or s["created"]) > 600
):
fail("添加 Passkey 前请重新登录", 401)
rows = c.execute(
"SELECT id FROM credentials WHERE shop_id IS ?", (s.get("shop_id"),)
).fetchall()
import base64

shop_id = s.get("shop_id")
if shop_id:
from .shops import shop_row
Expand All @@ -175,20 +190,19 @@ def challenge(c, request, response, kind, s=None):
rp_name="Extore · 兑所",
user_id=user_id,
user_name=user_name,
exclude_credentials=[
PublicKeyCredentialDescriptor(
id=base64.urlsafe_b64decode(r["id"] + "=" * (-len(r["id"]) % 4))
)
for r in rows
],
exclude_credentials=descriptors,
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.REQUIRED,
user_verification=UserVerificationRequirement.REQUIRED,
),
)
else:
if s and not descriptors:
fail("当前账号没有 Passkey,请使用其他验证方式", 409)
opts = generate_authentication_options(
rp_id=RP_ID, user_verification=UserVerificationRequirement.REQUIRED
rp_id=RP_ID,
allow_credentials=descriptors,
user_verification=UserVerificationRequirement.REQUIRED,
)
c.execute("DELETE FROM challenges WHERE expires<?", (time.time(),))
c.execute(
Expand Down Expand Up @@ -346,6 +360,79 @@ def login_verify(body: CredentialInput, request: Request, response: Response):
}


@router.post("/reauth/passkey/options")
def reauth_options(request: Request, response: Response):
s = session(request)
if s["channel"] != "browser":
fail("请在当前浏览器中确认身份", 403)
rate_limit(request, "passkey-reauth", 20, 60)
with db() as c:
authorize_management(c, s)
return challenge(c, request, response, "reauth", s)


@router.post("/reauth/passkey/verify")
def reauth_verify(body: CredentialInput, request: Request, response: Response):
s = session(request)
if s["channel"] != "browser":
fail("请在当前浏览器中确认身份", 403)
rate_limit(request, "passkey-reauth-verify", 20, 60)
# Commit consumption before validation: a failed assertion is single-use too.
with db() as c:
authorize_management(c, s)
ch = consume_challenge(c, request, "reauth", s)
with db() as c:
authorize_management(c, s)
row = c.execute(
"SELECT * FROM credentials WHERE id=? AND shop_id IS ?",
(body.credential.get("id", ""), s.get("shop_id")),
).fetchone()
if not row:
fail("所选 Passkey 不属于当前账号,请重新选择", 401)
try:
handle = body.credential.get("response", {}).get("userHandle")
if handle is not None:
expected = (
("extore-shop:" + s["shop_id"]).encode()
if s.get("shop_id")
else b"extore-owner"
)
if (
base64.b64decode(
handle + "=" * (-len(handle) % 4), altchars=b"-_", validate=True
)
!= expected
):
raise ValueError("Passkey user handle does not match the session")
verified = verify_authentication_response(
credential=body.credential,
expected_challenge=ch,
expected_rp_id=RP_ID,
expected_origin=ORIGIN,
credential_public_key=row["public_key"],
credential_current_sign_count=row["sign_count"],
require_user_verification=True,
)
except Exception:
fail("Passkey 校验失败,请使用当前账号的 Passkey", 401)
c.execute(
"UPDATE credentials SET sign_count=? WHERE id=?",
(verified.new_sign_count, row["id"]),
)
c.execute(
"UPDATE sessions SET auth_at=?,auth_method='passkey' WHERE digest=?",
(time.time(), s["digest"]),
)
audit(
c,
f"shop:{s['shop_id']}" if s.get("shop_id") else "owner",
"account.reauthenticate",
s["id"],
)
response.delete_cookie("extore_challenge", path="/api/auth")
return {"ok": True}


@router.get("/passkeys")
def keys(request: Request):
s = session(request)
Expand Down
12 changes: 9 additions & 3 deletions extore/static/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
clearTotpSetup();
throw new Error("当前登录账户或店铺已改变,请重新打开后台后再操作");
}
if (allowFreshSession) { auth = current; onAuth(current); }
if (allowFreshSession || verified) { auth = current; onAuth(current); }
return true;
}
function showTotpSetup(result) {
Expand Down Expand Up @@ -181,12 +181,18 @@
on("#account-fresh-passkey", "click", async () => {
if (!current()) return;
if (typeof passkey !== "function") throw new Error("Passkey 模块未加载,请刷新页面");
const verified = await passkey(false, null, { signal: controller.signal, isCurrent: current });
if (!current() || !await checkScope(true, verified) || !current()) return;
const verified = await passkey(false, null, {
signal: controller.signal, isCurrent: current, reauthenticate: true,
expectedScope: auth.shop_id || (rootScope(auth) ? "platform" : undefined),
expectedSessionId: auth.session_id,
});
if (!current() || !await checkScope(false, verified) || !current()) return;
target.innerHTML = "";
await handler();
});
on("#account-fresh-cancel", "click", () => { if (!current()) return; options.onCancel?.(); target.innerHTML = ""; });
target.scrollIntoView?.({ block: "nearest" });
$(shopScope(auth) ? "#fresh-password" : "#account-fresh-passkey")?.focus();
}
const completed = (message) => { page(tr("操作已完成", "Done"), `<section class="panel"><p>${esc(message)}</p><a href="/account/login">${tr("返回登录", "Return to sign in")}</a></section>`); };

Expand Down
46 changes: 39 additions & 7 deletions extore/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ function managementAuthority(auth = authStatus) {
return JSON.stringify([auth.role, auth.shop_id, auth.superadmin === true, auth.link_id || auth.staff_id || null]);
}
function managementHeaders(path, options = {}, headers = {}) {
const bound = /^\/(?:admin|manage|platform|shop)(?:\/|\?|$)/.test(path) || /^\/auth\/(?:passkeys|register\/(?:options|verify)|password\/change|totp|reauth\/password|logout)(?:\/|\?|$)/.test(path);
const bound = /^\/(?:admin|manage|platform|shop)(?:\/|\?|$)/.test(path) || /^\/auth\/(?:passkeys|register\/(?:options|verify)|password\/change|totp|reauth\/(?:password|passkey\/(?:options|verify))|logout)(?:\/|\?|$)/.test(path);
if (bound) {
const { expectedScope: scope, expectedSessionId: sessionId } = managementOptions(options);
if (typeof scope === "string") headers["X-Extore-Shop-Scope"] = scope;
Expand Down Expand Up @@ -573,6 +573,15 @@ $("#brand").addEventListener("click", () => {
});
function syncPreferenceControls() {
$("#theme").value = preferences.settings.theme;
$("#accent").value = preferences.settings.accent || "green";
$("#appearance-label").textContent = tr("外观", "Appearance");
$("#theme-label").textContent = tr("颜色主题", "Color theme");
$("#accent-label").textContent = tr("强调色", "Accent color");
$("#language-label").textContent = tr("语言", "Language");
$("#appearance-note").textContent = tr("自动保存到此浏览器。", "Saved in this browser.");
$("#accent").setAttribute("aria-label", tr("强调色", "Accent color"));
for (const [value, cn, en] of [["green", "松绿", "Pine"], ["blue", "海蓝", "Ocean"], ["violet", "紫罗兰", "Violet"], ["rose", "玫瑰", "Rose"], ["amber", "琥珀", "Amber"], ["graphite", "石墨", "Graphite"]])
$(`#accent option[value=${value}]`).textContent = tr(cn, en);
$("#language").value = preferences.settings.language;
$("#theme").setAttribute("aria-label", tr("主题", "Theme"));
$("#language").setAttribute("aria-label", tr("语言", "Language"));
Expand All @@ -598,6 +607,14 @@ $("#language").addEventListener("change", () =>
$("#theme").addEventListener("change", () =>
preferences.setTheme($("#theme").value),
);
$("#accent").addEventListener("change", () => preferences.setAccent($("#accent").value));
$("#appearance").addEventListener("keydown", (event) => {
if (event.key === "Escape") { $("#appearance").open = false; $("#appearance summary").focus(); }
});
document.addEventListener?.("click", (event) => {
const appearance = $("#appearance");
if (appearance.open && !appearance.contains(event.target)) appearance.open = false;
});
window.addEventListener("popstate", start);
window.addEventListener("hashchange", () => {
if (location.pathname === "/cli/owner") start();
Expand Down Expand Up @@ -1384,8 +1401,9 @@ async function passkey(register = false, name = null, options = {}) {
options = managementOptions(options);
const generation = routeLoadId, pathname = location.pathname;
const active = () => (options.isCurrent ? options.isCurrent() : generation === routeLoadId && pathname === location.pathname);
const ceremony = register ? "register" : options.reauthenticate ? "reauth/passkey" : "login";
const publicOptions = await api(
"/auth/" + (register ? "register" : "login") + "/options",
"/auth/" + ceremony + "/options",
{}, "POST", options,
);
if (!active()) throw new DOMException("Account page changed", "AbortError");
Expand All @@ -1409,7 +1427,7 @@ async function passkey(register = false, name = null, options = {}) {
? encode(c.response.userHandle)
: null;
}
await api("/auth/" + (register ? "register" : "login") + "/verify", {
await api("/auth/" + ceremony + "/verify", {
credential: {
id: c.id,
rawId: encode(c.rawId),
Expand All @@ -1420,7 +1438,7 @@ async function passkey(register = false, name = null, options = {}) {
name: name || $("#key-name")?.value || "我的 Passkey",
}, "POST", options);
if (!active()) throw new DOMException("Account page changed", "AbortError");
return api("/auth/status");
return api("/auth/status", undefined, "GET", options);
}
async function admin() {
stopPoll();
Expand Down Expand Up @@ -1521,24 +1539,38 @@ function shell() {
{ name: tr("店铺设置", "Shop settings"), keys: ["profiles", "commerce", "proxy", "security"] },
{ name: tr("平台管理", "Platform settings"), keys: ["shops", "mail"] },
].map((group) => ({ ...group, items: group.keys.map((key) => items.find(([value]) => value === key)).filter(Boolean) })).filter((group) => group.items.length);
app.innerHTML = `<div class="management-shell"><div class="admin-top"><div><h1>${role === "staff" ? tr("商品管理", "Product management") : tr("商家后台", "Merchant dashboard")}</h1><p id="management-identity" class="muted">${esc(managementIdentity())}</p></div><button id="logout" class="secondary">${tr("退出登录", "Sign out")}</button></div><div class="management-layout"><nav class="management-navigation" aria-label="${tr("管理导航", "Management navigation")}"><div class="management-mobile-navigation"><label for="management-section">${tr("当前工作区", "Workspace")}</label><select id="management-section">${groups.map((group) => `<optgroup label="${esc(group.name)}">${group.items.map(([value, label]) => `<option value="${value}" ${tab === value ? "selected" : ""}>${esc(label)}</option>`).join("")}</optgroup>`).join("")}</select></div><div class="management-desktop-navigation">${groups.map((group) => `<section class="management-navigation-group"><h2>${esc(group.name)}</h2>${group.items.map(([value, label]) => `<button type="button" data-tab="${value}" ${tab === value ? 'class="active" aria-current="page"' : ""}>${esc(label)}</button>`).join("")}</section>`).join("")}</div></nav><div id="workspace" class="management-workspace"></div></div></div>`;
app.innerHTML = `<div class="management-shell"><div class="admin-top"><div><h1>${role === "staff" ? tr("商品管理", "Product management") : tr("商家后台", "Merchant dashboard")}</h1><p id="management-identity" class="muted">${esc(managementIdentity())}</p></div><button id="logout" class="secondary">${tr("退出登录", "Sign out")}</button></div><div class="management-layout"><nav class="management-navigation" aria-label="${tr("管理导航", "Management navigation")}"><div class="management-mobile-navigation"><div><label for="management-section">${tr("当前工作区", "Workspace")}</label><select id="management-section">${groups.map((group) => `<optgroup label="${esc(group.name)}">${group.items.map(([value, label]) => `<option value="${value}" ${tab === value ? "selected" : ""}>${esc(label)}</option>`).join("")}</optgroup>`).join("")}</select></div><button id="management-menu-toggle" class="secondary" type="button" aria-expanded="false" aria-controls="management-navigation-items">${tr("设置目录", "All pages")}</button></div><div id="management-navigation-items" class="management-desktop-navigation">${groups.map((group) => `<section class="management-navigation-group"><h2>${esc(group.name)}</h2>${group.items.map(([value, label]) => `<button type="button" data-tab="${value}" ${tab === value ? 'class="active" aria-current="page"' : ""}>${esc(label)}</button>`).join("")}</section>`).join("")}</div></nav><div id="workspace" class="management-workspace"></div></div></div>`;
on("#logout", async () => {
progressBoardController?.dispose(); progressBoardController = null;
await api("/auth/logout", {});
navigate("/admin");
});
const choose = (value, mobile = false) => {
if (!items.some(([key]) => key === value) || tab === value) return;
if (!items.some(([key]) => key === value)) return;
if (tab === value) {
if (mobile) { closeMenu(); $("#management-section").focus(); }
return;
}
tab = value;
shell();
const target = mobile ? $("#management-section") : $(`[data-tab="${value}"]`);
target?.focus?.();
perform(renderTab);
};
const menu = $("#management-menu-toggle"), navigation = $("#management-navigation-items");
const closeMenu = () => { menu.setAttribute("aria-expanded", "false"); navigation.removeAttribute("data-open"); };
on("#management-menu-toggle", () => {
const open = menu.getAttribute("aria-expanded") !== "true";
menu.setAttribute("aria-expanded", String(open));
navigation.toggleAttribute("data-open", open);
});
navigation.addEventListener("keydown", (event) => {
if (event.key === "Escape") { closeMenu(); menu.focus(); }
});
on("#management-section", () => choose($("#management-section").value, true), "change");
document.querySelectorAll("[data-tab]").forEach((b) =>
b.addEventListener("click", () => {
choose(b.dataset.tab);
choose(b.dataset.tab, menu.getAttribute("aria-expanded") === "true");
}),
);
}
Expand Down
Loading