Compare commits

..
5 Commits
Author SHA1 Message Date
渡口浪人 c3bed2edce release: v0.1.26 account security and settings polish
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m47s
2026-10-20 16:33:28 +08:00
渡口浪人 f8fe8e8b14 fix: retain logo beside stacked sidebar wordmark
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m31s
2026-10-20 16:26:57 +08:00
渡口浪人 50903564ad fix: use stacked sidebar brand wordmark
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 9m57s
2026-10-20 16:23:16 +08:00
渡口浪人 69ef87e8e9 fix: confirm new password and compact settings layout
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m45s
2026-10-20 16:19:50 +08:00
渡口浪人 09c6773d76 feat: harden single-user account authentication
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m47s
2026-10-20 16:06:24 +08:00
14 changed files with 249 additions and 90 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ WORKDIR /app
ARG BUILD_COMMIT=development ARG BUILD_COMMIT=development
ARG BUILD_TIME=unknown ARG BUILD_TIME=unknown
ENV BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$BUILD_TIME ENV BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$BUILD_TIME
LABEL org.opencontainers.image.title="PIGWay Frame Flow" org.opencontainers.image.version="0.1.25" org.opencontainers.image.revision=$BUILD_COMMIT LABEL org.opencontainers.image.title="PIGWay Frame Flow" org.opencontainers.image.version="0.1.26" org.opencontainers.image.revision=$BUILD_COMMIT
USER 10333:10333 USER 10333:10333
EXPOSE 18787 EXPOSE 18787
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
+2
View File
@@ -28,6 +28,8 @@ docker compose up -d
打开 `http://127.0.0.1:6005`,使用刚创建的账号登录。网页使用 HttpOnly 会话 Cookie,无需输入 API Token;勾选记住登录后会话有效期为 30 天,可在设置中修改密码或退出。`FRAME_FLOW_TOKEN` 用于外部 API 客户端鉴权,与 KoKonna API Key 分开。 打开 `http://127.0.0.1:6005`,使用刚创建的账号登录。网页使用 HttpOnly 会话 Cookie,无需输入 API Token;勾选记住登录后会话有效期为 30 天,可在设置中修改密码或退出。`FRAME_FLOW_TOKEN` 用于外部 API 客户端鉴权,与 KoKonna API Key 分开。
网页仅支持一个用户,不提供公开注册。密码为 8–256 位,支持密码管理器;使用带随机盐的 scrypt(N=16384、r=8、p=5)保存,旧哈希登录成功后自动升级。连续 5 次失败后按账号和来源 IP 限制登录 5 分钟。修改密码需验证当前密码,成功后撤销全部会话;写入请求校验 CSRF Token,登录拒绝跨来源请求。公网访问应通过 HTTPS,HTTPS 下会话 Cookie 自动启用 Secure。
默认端口只绑定本机回环地址。需要局域网访问时,显式将 `WEB_BIND` 设置为 NAS 的局域网地址,并通过 `WEB_PORT` 选择空闲端口。参见[群晖部署](docs/NAS.md)、[构建镜像源与缓存](docs/BUILDING.md)。不会自动修改防火墙、路由器或反向代理。 默认端口只绑定本机回环地址。需要局域网访问时,显式将 `WEB_BIND` 设置为 NAS 的局域网地址,并通过 `WEB_PORT` 选择空闲端口。参见[群晖部署](docs/NAS.md)、[构建镜像源与缓存](docs/BUILDING.md)。不会自动修改防火墙、路由器或反向代理。
使用 Python 3.14 运行本地 Mock 演示: 使用 Python 3.14 运行本地 Mock 演示:
+8 -3
View File
@@ -1,5 +1,10 @@
# pigway-frame-flow v0.1.25 # pigway-frame-flow v0.1.26
正式 PIGWAY Brand Production v1.1 图形标、项目头像与来源说明随项目交付;中英文项目介绍页使用统一品牌图。网页增加正式 Logo、浏览器 SVG/ICO 图标和 Apple Touch Icon,适配桌面、平板、手机及深浅主题。 完善单用户账号安全与设置页面体验。
软件业务逻辑和设备控制边界沿用上一版本。Logo 使用已发布的 v1.1 原文件,不重新生成或修改颜色、比例。源码包保留 LICENSE 及原有第三方许可说明;插件 ZIP 保留对应许可证。 - 浏览器账号仅支持单用户;密码至少 8 位,强化加盐 scrypt 哈希,兼容并自动升级原有哈希。
- 登录增加账号与来源 IP 限流、跨来源请求拦截;修改密码后撤销全部会话。
- 修改密码增加“确认新密码”和一致性校验;桌面端三个紧凑输入框同排,鉴权信息区收窄,手机端纵向排列。
- 左上角保留正式 Logo,文字采用 PIGWAY / FRAME FLOW 两行排版,适配深浅主题。
现有 API Token、设备集成与控制边界保持一致。源码包包含 LICENSE、NOTICE 及第三方许可材料。公网访问仍应使用 HTTPS。
+13 -13
View File
@@ -5,12 +5,12 @@ b45d35d737f119a70bf40cb1f9626ff80772ed7913cb35f6e9b870d6ada01a15 .github/workfl
c8c545718e9b9880555c950a078cac160c82e0cd6e593afcf20bc13727539ec5 .github/workflows/verify.yml c8c545718e9b9880555c950a078cac160c82e0cd6e593afcf20bc13727539ec5 .github/workflows/verify.yml
3931cc314019760a582e0914e2d9c0f3b862a004d0a30d374900edcad5f599e5 .gitignore 3931cc314019760a582e0914e2d9c0f3b862a004d0a30d374900edcad5f599e5 .gitignore
4b4ed4926a350cbeb71436541b4566e4ba7d2494ac95236b88827a9f5dd171c2 CONTRIBUTING.md 4b4ed4926a350cbeb71436541b4566e4ba7d2494ac95236b88827a9f5dd171c2 CONTRIBUTING.md
5a58bdb93b17bf980fb02d5d78efca0dba3bb3d703bc012dcc383f3d4d894666 Dockerfile bd03eb87817b811ab7701b6c63470905a0c554a8ee0de6a83c67b22732b2727e Dockerfile
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE 6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md 250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md
b7a41a2c00df1a0dbe3869d059d65c3bcaee8c50e51217c358527c4f1d3d6c98 README.md b7a41a2c00df1a0dbe3869d059d65c3bcaee8c50e51217c358527c4f1d3d6c98 README.md
a4dc99f04b3f6527559cc6adbe8fe789e2795e28bfa976d4243fc0c23900b5d3 README.zh-CN.md 791881a9956148e0a3aa00e1d5650983d4e397eb5f3e67b5ccacb1fb965725ec README.zh-CN.md
bfd227da78c40cacdd584cf47399822ef73e2a0c00ed8f93131605c711a689a6 RELEASE_NOTES.md 1083bf4b9bfa263efa4243703d3345e546db86bd1f9eb22bda581fc509aa6cfe RELEASE_NOTES.md
3dd364e47fdbd2ce9ce2df83342c523adc21583f093a5439d501b3424878ce2f SECURITY.md 3dd364e47fdbd2ce9ce2df83342c523adc21583f093a5439d501b3424878ce2f SECURITY.md
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 config/.gitkeep e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 config/.gitkeep
@@ -44,9 +44,9 @@ ec53d587b855be84283c50f8fb45be62a4c1caba78b28d436a8260fd171943a2 docs/research/
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json 2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/api/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/api/__init__.py
dcc6fb94c5d3df730c164f1c2b96d5a7602655624c1ba61e161dc91e1668a663 pigway_frame_flow/api/app.py 46c6e2331bda76eeac108d07ef282dbce74f482cc679ad9cf64d9cd2e29aaef3 pigway_frame_flow/api/app.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py
fbadf6beaf25b9670dcbf125ec9d192188cf9e076ee4d4a0b5decdaf3834417c pigway_frame_flow/core/accounts.py 0b8402603c2f397f4f42491873b27bf363959194b05a0c67c7b1d49596288b95 pigway_frame_flow/core/accounts.py
818e8fb29d22c13159a6cb22bcbc6b9e211a8a1301588126eb192ac4ed71441b pigway_frame_flow/core/auth.py 818e8fb29d22c13159a6cb22bcbc6b9e211a8a1301588126eb192ac4ed71441b pigway_frame_flow/core/auth.py
8f2220fa90b0dc2e45df2023dc5ac695efe55361644ef266829553c99151949b pigway_frame_flow/core/registry.py 8f2220fa90b0dc2e45df2023dc5ac695efe55361644ef266829553c99151949b pigway_frame_flow/core/registry.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/display/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/display/__init__.py
@@ -86,7 +86,7 @@ a518965aa4db9abeb60865c315229c3053c8e9edd26d80f24dc0af0528bad9e6 pigway_frame_f
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 pigway_frame_flow/integrations/kokonna_official/widget_catalog.json 2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 pigway_frame_flow/integrations/kokonna_official/widget_catalog.json
aea594fb4e4c9a6e2094d4d7973eb627ac02fb632811b2d1a558a0ee1cd12e77 pigway_frame_flow/integrations/kokonna_official/widgets.py aea594fb4e4c9a6e2094d4d7973eb627ac02fb632811b2d1a558a0ee1cd12e77 pigway_frame_flow/integrations/kokonna_official/widgets.py
1fe53f4bfb407b8db774fe730131ecf1213e16d752a3506c4c56b46365ea14e8 pigway_frame_flow/main.py 1fe53f4bfb407b8db774fe730131ecf1213e16d752a3506c4c56b46365ea14e8 pigway_frame_flow/main.py
ed32fbca4e7d5a6ae86b57fd6c6898ce23860c5a9cc0b32ac214944b38e8d504 pigway_frame_flow/models.py 0661802c0078133225c87a835c7c19313487b4b5808e5701c1a676994836f75d pigway_frame_flow/models.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/providers/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/providers/__init__.py
b7a6cb8eb40d8dedfbce18777f8fbd433a174f14bcdadf498bd834132aa568fd pigway_frame_flow/providers/cloud_drives.py b7a6cb8eb40d8dedfbce18777f8fbd433a174f14bcdadf498bd834132aa568fd pigway_frame_flow/providers/cloud_drives.py
58b070f0c814d639a4a912d079479d751d459816d59f2199d0be03e4d573ec3b pigway_frame_flow/providers/external.py 58b070f0c814d639a4a912d079479d751d459816d59f2199d0be03e4d573ec3b pigway_frame_flow/providers/external.py
@@ -120,27 +120,27 @@ f9e777980ea5bb3ea8fb2357f23530802811ea2674d25100af9b728cbc9f83a9 pigway_frame_f
8f37296f52a88c11a000a5a0767310354550da34e1d4c0c7260393ec00cd0d9b pigway_frame_flow/templates/quote.json 8f37296f52a88c11a000a5a0767310354550da34e1d4c0c7260393ec00cd0d9b pigway_frame_flow/templates/quote.json
82c17f4e7eaac95483592add181f549186dea9d7c0375fa9cad3554e3ccd1794 pigway_frame_flow/templates/system-alert.json 82c17f4e7eaac95483592add181f549186dea9d7c0375fa9cad3554e3ccd1794 pigway_frame_flow/templates/system-alert.json
d6f705b9d183b57d50759e283db2161f8493c4e3969459e74da635252014c925 pigway_frame_flow/templates/weather.json d6f705b9d183b57d50759e283db2161f8493c4e3969459e74da635252014c925 pigway_frame_flow/templates/weather.json
3acad7c209e3483481c3b966b27a0609f2d12c2383955274dfd226a1fe5f41bf pigway_frame_flow/web/app.js 0d47629b1075a171f87911001760dd0d1fca79c7afc609bf18fbaa57f008dc36 pigway_frame_flow/web/app.js
f332fac63f611572597812a06ae0cc266a59e5677efda0d6e9b6925acf9f9309 pigway_frame_flow/web/apple-touch-icon.png f332fac63f611572597812a06ae0cc266a59e5677efda0d6e9b6925acf9f9309 pigway_frame_flow/web/apple-touch-icon.png
d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js
94558dbf524a1068e83283eb814c1b1501512d4b7e75e092b52e9aabfea720e7 pigway_frame_flow/web/favicon.ico 94558dbf524a1068e83283eb814c1b1501512d4b7e75e092b52e9aabfea720e7 pigway_frame_flow/web/favicon.ico
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/favicon.svg 0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/favicon.svg
cd3dc54289418eea667ad71e236cce61e9dc6add4925bdb46c8a9b7c19860c48 pigway_frame_flow/web/features.js cd3dc54289418eea667ad71e236cce61e9dc6add4925bdb46c8a9b7c19860c48 pigway_frame_flow/web/features.js
a43bde8abb2ac9a9e0f41f83219c43c20804d76f0984407308847238554b1f3d pigway_frame_flow/web/i18n.js 914baa94037bbe2bffa1a48a934274644eae7b8d523af0b789360e8ca7b0de27 pigway_frame_flow/web/i18n.js
d14294a8752994d029d1112d29d9facae97618af3935f2600e2b1bc38f2357b0 pigway_frame_flow/web/index.html f36ec88d90471fe1702fb6d50bf99f9b189af1170cbac47113135f2761985933 pigway_frame_flow/web/index.html
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/logo-symbol.svg 0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/logo-symbol.svg
246256009927ec8a92d6c9a96eb0b1cecc741c48544c1d7080d12b6ee3255b49 pigway_frame_flow/web/style.css a97528940dc2edbb35d725770eeec5c10f0a996f235302a1aed048ca755afd96 pigway_frame_flow/web/style.css
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 pigway_frame_flow/web/swagger-LICENSE cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 pigway_frame_flow/web/swagger-LICENSE
050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js 050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js
1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css 1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css
955b6bd8c5589600df50bb7bb5744044beae487e0263886d60c3451d67ca741d pyproject.toml bb03214a95e1f7c5889129ffecb6236b6fe9053ed8e2ff8a1090d379602bd23d pyproject.toml
d34ac82e4c3feacd38546aa2f0a4491ab17ddbf3ad8f489375783166d78923f0 requirements.lock d34ac82e4c3feacd38546aa2f0a4491ab17ddbf3ad8f489375783166d78923f0 requirements.lock
008af5f4adf616c86e805febc016faad60be0008abf5dddad6e955f1d03606cf scripts/build-verify.sh 008af5f4adf616c86e805febc016faad60be0008abf5dddad6e955f1d03606cf scripts/build-verify.sh
43e8345ac5f29c2a5d1fa751c0e7c963b00b5391817393541e72c52919f0f2b4 scripts/check-compose-remote.py 43e8345ac5f29c2a5d1fa751c0e7c963b00b5391817393541e72c52919f0f2b4 scripts/check-compose-remote.py
8186d4d9c2542d42d786404b096fbbdee22e4c74edae78ddebab5bd3f632764c scripts/ci-audit.py 8186d4d9c2542d42d786404b096fbbdee22e4c74edae78ddebab5bd3f632764c scripts/ci-audit.py
b0ec44fba0adfceca3655628021683030469be77c9e303c3f5cf830e8fd0cee3 scripts/ci-compose-verify.py b0ec44fba0adfceca3655628021683030469be77c9e303c3f5cf830e8fd0cee3 scripts/ci-compose-verify.py
6ebe82488d7eb2f10f69cf8bf4ed4e1ee272a216413fc5be1329f05889ed49a7 scripts/ci-install.py 6ebe82488d7eb2f10f69cf8bf4ed4e1ee272a216413fc5be1329f05889ed49a7 scripts/ci-install.py
a11e660a0343f2631c1a3dd0aa28471db827fb1f43d1af4217f51209aa3afe32 scripts/create-account.py eb94843315f0671ca14fac66fc0c6e7380866f28dc822cce14312fa548bb5c82 scripts/create-account.py
cdabf30144ea089ae8e64228f6762951f53d4fe8c191673fb5676331d249473d scripts/migrate-photos-api.py cdabf30144ea089ae8e64228f6762951f53d4fe8c191673fb5676331d249473d scripts/migrate-photos-api.py
80813607835c19d321085fac4cabb17d865d98ed62a09b7f45195ac48bec94a8 scripts/package-nas.py 80813607835c19d321085fac4cabb17d865d98ed62a09b7f45195ac48bec94a8 scripts/package-nas.py
b0b877cfbb2bdf830e2e41097c2057538bb9d0b61a3c39952fe505d910cd5c29 scripts/persistence-check.py b0b877cfbb2bdf830e2e41097c2057538bb9d0b61a3c39952fe505d910cd5c29 scripts/persistence-check.py
@@ -179,7 +179,7 @@ b7816947e33a77997a4dd7d595ce385081fc578b2821aedb859277c3b490bf07 scripts/verify
d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py
41f128b9bf09fd160b8e99f3c811b73443b765b019c71306ada3dc18b83a31ca tests/contract.json 41f128b9bf09fd160b8e99f3c811b73443b765b019c71306ada3dc18b83a31ca tests/contract.json
c2c398d7323e47255646e580c498a211a8f1a4fee32861954eaa29628bec8301 tests/schema-contract.json c2c398d7323e47255646e580c498a211a8f1a4fee32861954eaa29628bec8301 tests/schema-contract.json
14a7c56c40ea4b520585c20341b3ec70fa3ba52518144a10ba3494ceceb30758 tests/test_accounts.py 23ffc7413f8388648c9d52d3c8f56e2966b73ca1bdc5e209e6c15469c790edba tests/test_accounts.py
e87ce325c8260f96e586b20697ab0bac71058918ba73f7412eb482364f83f73e tests/test_ai_images.py e87ce325c8260f96e586b20697ab0bac71058918ba73f7412eb482364f83f73e tests/test_ai_images.py
a6edb781f4d081b2db45ffe2b02eed52a10f9b87a70e1e201b5d2f7c6813b93b tests/test_api.py a6edb781f4d081b2db45ffe2b02eed52a10f9b87a70e1e201b5d2f7c6813b93b tests/test_api.py
24fca799b0f5e33c800c6310764b18f6b39fc210be769684171388cf14b95746 tests/test_audit_regressions.py 24fca799b0f5e33c800c6310764b18f6b39fc210be769684171388cf14b95746 tests/test_audit_regressions.py
+2 -2
View File
@@ -126,7 +126,7 @@ def create_app(settings=None, start_worker=True):
app = FastAPI( app = FastAPI(
title="PIGWay Frame Flow API", title="PIGWay Frame Flow API",
docs_url=None, docs_url=None,
version="0.1.25", version="0.1.26",
lifespan=lifespan, lifespan=lifespan,
responses=error_responses, responses=error_responses,
) )
@@ -1040,7 +1040,7 @@ def create_app(settings=None, start_worker=True):
supports_upload=True, supports_upload=True,
), ),
], ],
version="0.1.25", version="0.1.26",
mock=settings.mock, mock=settings.mock,
write_enabled=settings.write_enabled, write_enabled=settings.write_enabled,
api_key_configured=bool(settings.api_key) api_key_configured=bool(settings.api_key)
+105 -61
View File
@@ -21,16 +21,22 @@ cookie = APIKeyCookie(name=COOKIE, auto_error=False, scheme_name="AccountSession
def password_hash(password: str) -> str: def password_hash(password: str) -> str:
salt = secrets.token_hex(16) salt = secrets.token_hex(16)
digest = hashlib.scrypt( digest = hashlib.scrypt(
password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1 password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=5
).hex() ).hex()
return f"scrypt${salt}${digest}" return f"scrypt-v2${salt}${digest}"
def verify_password(password: str, encoded: str) -> bool: def verify_password(password: str, encoded: str) -> bool:
try: try:
_, salt, expected = encoded.split("$") algorithm, salt, expected = encoded.split("$")
if algorithm not in {"scrypt", "scrypt-v2"}:
return False
actual = hashlib.scrypt( actual = hashlib.scrypt(
password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1 password.encode(),
salt=bytes.fromhex(salt),
n=16384,
r=8,
p=5 if algorithm == "scrypt-v2" else 1,
).hex() ).hex()
return hmac.compare_digest(actual, expected) return hmac.compare_digest(actual, expected)
except (ValueError, TypeError): except (ValueError, TypeError):
@@ -57,6 +63,7 @@ def seed_accounts(db, config_dir: Path):
db.execute( db.execute(
"CREATE TABLE IF NOT EXISTS users (username TEXT PRIMARY KEY, password_hash TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1)" "CREATE TABLE IF NOT EXISTS users (username TEXT PRIMARY KEY, password_hash TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1)"
) )
db.execute("CREATE UNIQUE INDEX IF NOT EXISTS single_browser_account ON users((1))")
db.execute( db.execute(
"CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, username TEXT NOT NULL, csrf TEXT NOT NULL, expires_at REAL NOT NULL)" "CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, username TEXT NOT NULL, csrf TEXT NOT NULL, expires_at REAL NOT NULL)"
) )
@@ -65,7 +72,13 @@ def seed_accounts(db, config_dir: Path):
) )
path = config_dir / "accounts.json" path = config_dir / "accounts.json"
if path.exists(): if path.exists():
for user in json.loads(path.read_text()).get("users", []): users = json.loads(path.read_text()).get("users", [])
if len(users) > 1:
raise ValueError("Only one browser account is supported")
for user in users:
existing = db.one("SELECT username FROM users LIMIT 1")
if existing and existing["username"] != user["username"]:
raise ValueError("A browser account already exists")
db.execute( db.execute(
"INSERT OR IGNORE INTO users VALUES(?,?,1)", "INSERT OR IGNORE INTO users VALUES(?,?,1)",
(user["username"], user["password_hash"]), (user["username"], user["password_hash"]),
@@ -83,7 +96,7 @@ def session_user(request: Request):
) )
if not row or not row["enabled"] or row["expires_at"] <= time.time(): if not row or not row["enabled"] or row["expires_at"] <= time.time():
return None return None
if request.method in {"POST", "PUT", "DELETE"}: if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
if not hmac.compare_digest( if not hmac.compare_digest(
request.headers.get("X-CSRF-Token", ""), row["csrf"] request.headers.get("X-CSRF-Token", ""), row["csrf"]
): ):
@@ -94,60 +107,88 @@ def session_user(request: Request):
def install_accounts(app, db): def install_accounts(app, db):
@app.post("/api/v1/auth/login", response_model=AccountView, tags=["Accounts"]) @app.post("/api/v1/auth/login", response_model=AccountView, tags=["Accounts"])
def login(payload: Login, request: Request, response: Response): def login(payload: Login, request: Request, response: Response):
identity = hashlib.sha256( with db.lock:
(request.client.host if request.client else "unknown").encode() # Reject login CSRF before checking credentials. Never trust forwarded hosts.
).hexdigest() origin = request.headers.get("origin")
attempt = db.one("SELECT * FROM login_attempts WHERE identity=?", (identity,)) if request.headers.get("sec-fetch-site") == "cross-site" or (
if attempt and attempt["blocked_until"] > time.time(): origin and origin != str(request.base_url).rstrip("/")
raise FlowError( ):
E.RATE_LIMITED, "Too many login attempts; try again later", 429 raise FlowError(E.FORBIDDEN, "Cross-origin login is not allowed", 403)
identity = hashlib.sha256(
(request.client.host if request.client else "unknown").encode()
).hexdigest()
# Account throttling also applies when an attacker changes source IPs.
identities = (
identity,
"account:" + hashlib.sha256(payload.username.encode()).hexdigest(),
) )
row = db.one( attempts = [
"SELECT * FROM users WHERE username=? AND enabled=1", (payload.username,) db.one("SELECT * FROM login_attempts WHERE identity=?", (key,))
) for key in identities
# Perform a password calculation even for unknown usernames. ]
fallback = "scrypt$" + "00" * 16 + "$" + "00" * 64 attempt = next(
valid = verify_password( (a for a in attempts if a and a["blocked_until"] > time.time()), None
payload.password, row["password_hash"] if row else fallback )
) if attempt and attempt["blocked_until"] > time.time():
if not row or not valid: raise FlowError(
failures = ( E.RATE_LIMITED, "Too many login attempts; try again later", 429
attempt["failures"] if attempt and attempt["blocked_until"] == 0 else 0 )
) + 1 row = db.one(
"SELECT * FROM users WHERE username=? AND enabled=1",
(payload.username,),
)
# Perform a password calculation even for unknown usernames.
fallback = "scrypt-v2$" + "00" * 16 + "$" + "00" * 64
valid = verify_password(
payload.password, row["password_hash"] if row else fallback
)
if not row or not valid:
for key, previous in zip(identities, attempts, strict=True):
failures = (
previous["failures"]
if previous and previous["blocked_until"] == 0
else 0
) + 1
db.execute(
"INSERT OR REPLACE INTO login_attempts VALUES(?,?,?)",
(key, failures, time.time() + 300 if failures >= 5 else 0),
)
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401)
for key in identities:
db.execute("DELETE FROM login_attempts WHERE identity=?", (key,))
if row["password_hash"].startswith("scrypt$"):
db.execute(
"UPDATE users SET password_hash=? WHERE username=?",
(password_hash(payload.password), row["username"]),
)
raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
lifetime = 30 * 86400 if payload.remember else 12 * 3600
db.execute("DELETE FROM sessions WHERE expires_at<=?", (time.time(),))
old = request.cookies.get(COOKIE)
if old:
db.execute(
"DELETE FROM sessions WHERE token_hash=?",
(hashlib.sha256(old.encode()).hexdigest(),),
)
db.execute( db.execute(
"INSERT OR REPLACE INTO login_attempts VALUES(?,?,?)", "INSERT INTO sessions VALUES(?,?,?,?)",
(identity, failures, time.time() + 300 if failures >= 5 else 0), (
hashlib.sha256(raw.encode()).hexdigest(),
row["username"],
csrf,
time.time() + lifetime,
),
) )
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401) response.set_cookie(
db.execute("DELETE FROM login_attempts WHERE identity=?", (identity,)) COOKIE,
raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32) raw,
lifetime = 30 * 86400 if payload.remember else 12 * 3600 max_age=lifetime if payload.remember else None,
db.execute("DELETE FROM sessions WHERE expires_at<=?", (time.time(),)) httponly=True,
old = request.cookies.get(COOKIE) samesite="strict",
if old: secure=request.url.scheme == "https",
db.execute( path="/",
"DELETE FROM sessions WHERE token_hash=?",
(hashlib.sha256(old.encode()).hexdigest(),),
) )
db.execute( return AccountView(username=row["username"], csrf_token=csrf)
"INSERT INTO sessions VALUES(?,?,?,?)",
(
hashlib.sha256(raw.encode()).hexdigest(),
row["username"],
csrf,
time.time() + lifetime,
),
)
response.set_cookie(
COOKIE,
raw,
max_age=lifetime if payload.remember else None,
httponly=True,
samesite="strict",
secure=request.url.scheme == "https",
path="/",
)
return AccountView(username=row["username"], csrf_token=csrf)
def current(request): def current(request):
row = session_user(request) row = session_user(request)
@@ -187,9 +228,12 @@ def install_accounts(app, db):
user = db.one("SELECT * FROM users WHERE username=?", (session["username"],)) user = db.one("SELECT * FROM users WHERE username=?", (session["username"],))
if not verify_password(payload.current_password, user["password_hash"]): if not verify_password(payload.current_password, user["password_hash"]):
raise FlowError(E.UNAUTHORIZED, "Current password is incorrect", 401) raise FlowError(E.UNAUTHORIZED, "Current password is incorrect", 401)
db.execute( with db.transaction() as connection:
"UPDATE users SET password_hash=? WHERE username=?", connection.execute(
(password_hash(payload.new_password), session["username"]), "UPDATE users SET password_hash=? WHERE username=?",
) (password_hash(payload.new_password), session["username"]),
db.execute("DELETE FROM sessions WHERE username=?", (session["username"],)) )
connection.execute(
"DELETE FROM sessions WHERE username=?", (session["username"],)
)
response.delete_cookie(COOKIE, path="/") response.delete_cookie(COOKIE, path="/")
+2 -2
View File
@@ -368,7 +368,7 @@ class SettingsView(BaseModel):
class Health(BaseModel): class Health(BaseModel):
status: str status: str
version: str = "0.1.25" version: str = "0.1.26"
class Readiness(BaseModel): class Readiness(BaseModel):
@@ -399,7 +399,7 @@ class RestoreRequest(StrictModel):
class Version(BaseModel): class Version(BaseModel):
product: str = "PIGWay Frame Flow" product: str = "PIGWay Frame Flow"
version: str = "0.1.25" version: str = "0.1.26"
api_version: str = "v1" api_version: str = "v1"
commit: str = "development" commit: str = "development"
build_time: str = "unknown" build_time: str = "unknown"
+1 -1
View File
@@ -126,7 +126,7 @@ async function templateEditor(selected){
const reset=document.createElement('button');reset.id='reset-template';reset.textContent=t('resetTemplate');$('#save-template').after(reset);bind('reset-template',()=>confirmDialog(t('resetConfirm'),async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'DELETE'});await navigate();})); const reset=document.createElement('button');reset.id='reset-template';reset.textContent=t('resetTemplate');$('#save-template').after(reset);bind('reset-template',()=>confirmDialog(t('resetConfirm'),async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'DELETE'});await navigate();}));
const save=$('#save-template');const oldSave=save.onclick;save.onclick=async e=>{if([...panel.querySelectorAll('textarea'),raw].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}await oldSave(e);}; const save=$('#save-template');const oldSave=save.onclick;save.onclick=async e=>{if([...panel.querySelectorAll('textarea'),raw].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}await oldSave(e);};
} }
function accountSettings(){const section=$('#logout').parentElement;const form=document.createElement('form');form.id='password-form';form.innerHTML=`<h3>${t('changePassword')}</h3><div class="form-grid password-fields"><div class="field"><label for="current-password">${t('currentPassword')}</label><input id="current-password" type="password" required autocomplete="current-password"></div><div class="field"><label for="new-password">${t('newPassword')}</label><input id="new-password" type="password" minlength="8" required autocomplete="new-password"></div></div><button type="submit">${t('changePassword')}</button>`;section.append(form);form.onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/auth/password',{method:'PUT',body:{current_password:$('#current-password').value,new_password:$('#new-password').value}});forgetLogin();shell();login();toast(t('passwordChanged'));});} function accountSettings(){const section=$('#logout').parentElement;const form=document.createElement('form');form.id='password-form';form.innerHTML=`<h3>${t('changePassword')}</h3><div class="form-grid password-fields"><div class="field"><label for="current-password">${t('currentPassword')}</label><input id="current-password" type="password" required autocomplete="current-password"></div><div class="field"><label for="new-password">${t('newPassword')}</label><input id="new-password" type="password" minlength="8" required autocomplete="new-password"></div><div class="field"><label for="confirm-password">${t('confirmPassword')}</label><input id="confirm-password" type="password" minlength="8" required autocomplete="new-password"></div></div><button type="submit">${t('changePassword')}</button>`;section.append(form);const confirm=$('#confirm-password');const validate=()=>confirm.setCustomValidity(confirm.value!==$('#new-password').value?t('passwordMismatch'):'');confirm.oninput=validate;$('#new-password').oninput=validate;form.onsubmit=busy(async e=>{e.preventDefault();validate();if(!form.reportValidity())return;await api('/api/v1/auth/password',{method:'PUT',body:{current_password:$('#current-password').value,new_password:$('#new-password').value}});forgetLogin();shell();login();toast(t('passwordChanged'));});}
const views={ const views={
studio:()=>layoutStudioPage(), studio:()=>layoutStudioPage(),
connections:()=>connectionsPage(), connections:()=>connectionsPage(),
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,3 +1,3 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="icon" type="image/svg+xml" href="/ui/favicon.svg"><link rel="icon" href="/ui/favicon.ico" sizes="16x16 32x32 48x48"><link rel="apple-touch-icon" sizes="180x180" href="/ui/apple-touch-icon.png"><link rel="stylesheet" href="/ui/style.css"></head> <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="icon" type="image/svg+xml" href="/ui/favicon.svg"><link rel="icon" href="/ui/favicon.ico" sizes="16x16 32x32 48x48"><link rel="apple-touch-icon" sizes="180x180" href="/ui/apple-touch-icon.png"><link rel="stylesheet" href="/ui/style.css"></head>
<body><aside class="sidebar"><a class="brand" href="/"><img class="pigway-logo" src="/ui/logo-symbol.svg" alt="PIGWay" width="56" height="56"><span>PIGWay Frame Flow<small>Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.25</small></div></aside><div class="workspace"><header><div class="workspace-heading"><img class="mobile-brand-logo pigway-logo" src="/ui/logo-symbol.svg" alt="PIGWay" width="56" height="56"><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=0.1.25"></script><script src="/ui/features.js?v=0.1.25"></script><script src="/ui/extensions.js?v=0.1.25"></script><script src="/ui/app.js?v=0.1.25"></script></body></html> <body><aside class="sidebar"><a class="brand" href="/" aria-label="PIGWay Frame Flow"><img class="pigway-logo" src="/ui/logo-symbol.svg" alt="" width="56" height="56"><span>PIGWAY<small>FRAME FLOW</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.26</small></div></aside><div class="workspace"><header><div class="workspace-heading"><img class="mobile-brand-logo pigway-logo" src="/ui/logo-symbol.svg" alt="PIGWay" width="56" height="56"><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=0.1.26"></script><script src="/ui/features.js?v=0.1.26"></script><script src="/ui/extensions.js?v=0.1.26"></script><script src="/ui/app.js?v=0.1.26"></script></body></html>
+8 -1
View File
@@ -187,7 +187,10 @@ body.login-mode header,body.login-mode footer{flex-shrink:0}
.history-status{color:var(--accent)} .history-status{color:var(--accent)}
.history-status.bad{color:var(--bad)} .history-status.bad{color:var(--bad)}
.settings-columns{align-items:stretch} .settings-columns{align-items:stretch;grid-template-columns:minmax(0,2fr) minmax(240px,1fr)}
.password-fields{grid-template-columns:repeat(3,minmax(0,1fr));max-width:780px;align-items:end}
@media(max-width:1050px){.settings-columns{grid-template-columns:minmax(0,1fr)}}
@media(max-width:720px){.password-fields{grid-template-columns:minmax(0,1fr)}}
.settings-preferences .field,.password-fields .field,#weather-form .field{min-width:0;margin-bottom:0} .settings-preferences .field,.password-fields .field,#weather-form .field{min-width:0;margin-bottom:0}
#password-form>button{align-self:flex-start} #password-form>button{align-self:flex-start}
form#weather-form{display:grid;grid-template-columns:minmax(160px,1fr) minmax(220px,2fr) repeat(2,minmax(100px,1fr)) auto;gap:18px;align-items:end} form#weather-form{display:grid;grid-template-columns:minmax(160px,1fr) minmax(220px,2fr) repeat(2,minmax(100px,1fr)) auto;gap:18px;align-items:end}
@@ -231,3 +234,7 @@ form#mqtt-config{grid-template-columns:minmax(160px,1fr) 110px minmax(180px,1.5f
.pigway-logo{display:block;width:56px;height:56px;padding:6px;flex:0 0 56px;object-fit:contain} .pigway-logo{display:block;width:56px;height:56px;padding:6px;flex:0 0 56px;object-fit:contain}
.brand>span{min-width:0}.workspace-heading{display:flex;align-items:center;gap:12px}.workspace-heading>div{min-width:0}.mobile-brand-logo{display:none} .brand>span{min-width:0}.workspace-heading{display:flex;align-items:center;gap:12px}.workspace-heading>div{min-width:0}.mobile-brand-logo{display:none}
@media(max-width:720px){.mobile-brand-logo{display:block}} @media(max-width:720px){.mobile-brand-logo{display:block}}
/* Stacked wordmark keeps the product name readable within the sidebar. */
.sidebar .brand{font-size:26px;font-weight:800;line-height:1.15;letter-spacing:1px}
.sidebar .brand small{margin-top:6px;font-size:12px;font-weight:700;letter-spacing:4px;line-height:1.4;white-space:nowrap}
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "pigway-frame-flow" name = "pigway-frame-flow"
version = "0.1.25" version = "0.1.26"
readme = "README.md" readme = "README.md"
license = {file = "LICENSE"} license = {file = "LICENSE"}
requires-python = ">=3.14,<3.15" requires-python = ">=3.14,<3.15"
+2 -2
View File
@@ -19,8 +19,8 @@ if len(password) < 8 or password != getpass.getpass("Confirm password: "):
args.config_dir.mkdir(parents=True, exist_ok=True) args.config_dir.mkdir(parents=True, exist_ok=True)
path = args.config_dir / "accounts.json" path = args.config_dir / "accounts.json"
users = json.loads(path.read_text()).get("users", []) if path.exists() else [] users = json.loads(path.read_text()).get("users", []) if path.exists() else []
if any(u["username"] == args.username for u in users): if users:
sys.exit("Bootstrap user already exists. Change the password in Settings.") sys.exit("Single-user account already exists. Change the password in Settings.")
users.append({"username": args.username, "password_hash": password_hash(password)}) users.append({"username": args.username, "password_hash": password_hash(password)})
path.write_text(json.dumps({"users": users}) + "\n") path.write_text(json.dumps({"users": users}) + "\n")
path.chmod(0o600) path.chmod(0o600)
+101
View File
@@ -141,3 +141,104 @@ def test_browser_admin_can_read_other_clients_operation(client, app, service):
assert operation_id in [ assert operation_id in [
op["operation_id"] for op in client.get("/api/v1/operations").json() op["operation_id"] for op in client.get("/api/v1/operations").json()
] ]
def test_password_minimum_and_https_cookie(client, app):
response = login(client, app)
client.headers["X-CSRF-Token"] = response.json()["csrf_token"]
assert (
client.put(
"/api/v1/auth/password",
json={"current_password": "fixture-password", "new_password": "1234567"},
).status_code
== 422
)
assert (
client.put(
"/api/v1/auth/password",
json={"current_password": "fixture-password", "new_password": "12345678"},
).status_code
== 204
)
response = client.post(
"https://testserver/api/v1/auth/login",
json={"username": "test-user", "password": "12345678"},
)
assert response.status_code == 200
assert "Secure" in response.headers["set-cookie"]
assert response.headers["cache-control"] == "no-store"
def test_login_rejects_cross_origin(client, app):
account(app)
assert (
client.post(
"/api/v1/auth/login",
headers={"Origin": "https://evil.example"},
json={"username": "test-user", "password": "fixture-password"},
).status_code
== 403
)
def test_account_throttle_across_source_addresses(client, app):
account(app)
for _ in range(5):
assert (
client.post(
"/api/v1/auth/login",
json={"username": "test-user", "password": "wrong"},
).status_code
== 401
)
app.state.service.db.execute(
"DELETE FROM login_attempts WHERE identity NOT LIKE 'account:%'"
)
assert (
client.post(
"/api/v1/auth/login",
json={"username": "test-user", "password": "fixture-password"},
).status_code
== 429
)
def test_legacy_hash_upgrade(client, app):
import hmac
from pigway_frame_flow.core.accounts import verify_password
salt = "12" * 16
digest = hashlib.scrypt(
b"fixture-password", salt=bytes.fromhex(salt), n=16384, r=8, p=1
).hex()
legacy = f"scrypt${salt}${digest}"
app.state.service.db.execute(
"INSERT INTO users VALUES(?,?,1)", ("test-user", legacy)
)
assert verify_password("fixture-password", legacy)
assert (
client.post(
"/api/v1/auth/login",
json={"username": "test-user", "password": "fixture-password"},
).status_code
== 200
)
stored = app.state.service.db.one("SELECT password_hash FROM users")[
"password_hash"
]
assert stored.startswith("scrypt-v2$") and not hmac.compare_digest(stored, legacy)
assert verify_password("fixture-password", stored)
def test_only_one_browser_account(app):
import sqlite3
import pytest
account(app)
with pytest.raises(sqlite3.IntegrityError):
app.state.service.db.execute(
"INSERT INTO users VALUES(?,?,1)",
("other", password_hash("another-password")),
)