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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,3 +386,19 @@ A: 本项目是家庭服务,内网只暴露了 http,外网通过一个虚拟
可选缓存覆盖书目、库位、已发布版本元数据及应用 Token 快照;权限和撤销信息保持实时检查。托管页面支持浏览器口径的每日/累计 PV、近似 UV,保存最近 90 日每日汇总与长期总量,编辑页可查看 7/30/90 日趋势。

新开关默认关闭。统计开启前需要手动执行增量统计表迁移,并配置独立私有访客密钥;应用不会修改 Redis 配置或自动建表。统计只使用普通 Redis 命令和 pipeline,不需要 Lua/EVAL 权限,也不要求特定淘汰策略。完整口径、启用配置、故障边界和回滚步骤见 [Redis 缓存与访问统计](docs/redis-cache-analytics.md)。

### 密码长度配置与网页快速解锁

管理员在“管理中心 → 通用配置 → 账号策略”中调整密码长度,校验并发布后可查看实际生效版本。

| 配置项 | 可配置范围 | 默认值 | 适用范围 |
| --- | --- | --- | --- |
| 账号密码最少字符数 | 4~64 个字符 | 15 | 注册、改密、管理员创建及重置密码 |
| 网页、说明书与文件分享密码最少字节数 | 4~72 个 UTF-8 字节 | 8 | 网页阅读密码、说明书阅读密码、要求登录的文件分享密码 |
| 公开文件分享码长度 | 4~12 位 | 6 | 新建公开文件分享的随机或自定义 ASCII 字母数字分享码 |

ASCII 字符各占 1 字节,因此三类口令均可配置为 4 位。账号与分享密码最多 72 个 UTF-8 字节;账号自动生成的临时密码仍至少 20 位。配置只约束新建或修改操作,已有密码与分享码仍按原值验证。升级不会覆盖现有账号策略,也不自动降低默认长度。

网页阅读链接支持 `/p/report/?code=1234`。独立解锁页会立即从地址栏删除全部 `code` 参数,再通过原 POST 解锁接口验证密码;成功后打开原页面,保留其他查询参数和 `#` 定位。错误密码显示“密码不正确”,可直接手动重试。原始页面和增强页面均适用,已有解锁 Cookie 或所有者访问也会清除参数。链接不会绕过仅自己、指定用户等访问范围;文件分享和说明书的 URL 不使用此参数。

`code` 仍会出现在首次请求中,地址栏清除仅避免打开后复制时再次带出密码。服务端日志会对该参数脱敏;部署应同步更新网页代理日志配置,避免代理层记录原始请求。分享完整带码链接等于同时分享密码。
2 changes: 1 addition & 1 deletion api/accounts/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ func publicBootstrap(c *gin.Context) {
return
}
runtime := config.Runtime()
respond(c, map[string]interface{}{"site": map[string]string{"title": runtime.SiteTitle, "notice": runtime.SiteNotice}, "registration": value, "modules": map[string]bool{"manuals": runtime.Manuals.Enabled, "file_sharing": runtime.FileSharing.Enabled}}, nil)
respond(c, map[string]interface{}{"site": map[string]string{"title": runtime.SiteTitle, "notice": runtime.SiteNotice}, "registration": value, "share_password_policy": map[string]int{"min_length": runtime.AccountPolicy.MinSharePasswordLength, "code_length": runtime.AccountPolicy.ShareCodeLength}, "modules": map[string]bool{"manuals": runtime.Manuals.Enabled, "file_sharing": runtime.FileSharing.Enabled}}, nil)
}

// validateInvitation 处理 POST /api/auth/invitations/validate:在来源限流后校验邀请码,仅返回有效状态与到期时间,不消费邀请码。
Expand Down
40 changes: 40 additions & 0 deletions api/accounts/resource_password_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,43 @@ func passwordGrantCookie(t *testing.T, cookies []*http.Cookie) *http.Cookie {
t.Fatal("unlock did not issue a resource grant cookie")
return nil
}

func TestHTTPFourBytePolicyAndQueryPasswordGate(t *testing.T) {
f := newHTTPFixture(t)
owner := f.login(f.owner, integrationPassword)
current := requireSuccess(t, f.request("GET", "/api/admin/config/account_policy", owner, nil, nil))
values := object(current["values"])
values["min_password_length"], values["min_share_password_length"], values["share_code_length"] = 4, 4, 4
requireSuccess(t, f.publish(owner, "account_policy", values, "four-byte-password-policy"))
bootstrap := requireSuccess(t, f.request("GET", "/api/site/bootstrap", nil, nil, nil))
if number(object(bootstrap["share_password_policy"])["min_length"]) != 4 || number(object(bootstrap["registration"])["min_password_length"]) != 4 {
t.Fatal("bootstrap omitted active password lengths")
}
project, _ := f.privatePage(owner)
id := project["id"].(string)
base := "/api/web-share/" + id
queryPath := "/p/http-private-fixture/index.html?code=1234&view=wide"
if response := f.request("GET", queryPath, nil, nil, map[string]string{"Accept": "text/html"}); response.Status != http.StatusNotFound || !strings.Contains(string(response.Raw), "history.replaceState") || response.Header.Get("Referrer-Policy") != "no-referrer" {
t.Fatal("private page must retain 404 while clearing the query password")
}
requireSuccess(t, f.request("PATCH", base, owner, map[string]interface{}{"access_mode": "public", "container_mode": "raw"}, map[string]string{"If-Match": strconv.FormatInt(number(project["revision"]), 10)}))
if response := f.request("PUT", base+"/password", owner, map[string]interface{}{"password": "123", "version": 0}, nil); response.Status != http.StatusBadRequest {
t.Fatal("three-byte password accepted")
}
requireSuccess(t, f.request("PUT", base+"/password", owner, map[string]interface{}{"password": "1234", "version": 0}, nil))
unlocked := f.request("POST", base+"/unlock", nil, map[string]interface{}{"password": "1234"}, nil)
requireSuccess(t, unlocked)
grant := passwordGrantCookie(t, unlocked.Cookies)
for _, headers := range []map[string]string{nil, {"Cookie": grant.Name + "=" + grant.Value}, {"Cookie": owner.Cookie.Name + "=" + owner.Cookie.Value}} {
page := f.request("GET", queryPath, nil, nil, headers)
if page.Status != http.StatusForbidden || !strings.Contains(string(page.Raw), "history.replaceState") || strings.Contains(string(page.Raw), "code=1234") {
t.Fatalf("query did not use isolated gate: %d", page.Status)
}
}
values["min_share_password_length"] = 8
requireSuccess(t, f.publish(owner, "account_policy", values, "restore-share-password-policy"))
requireSuccess(t, f.request("POST", base+"/unlock", nil, map[string]interface{}{"password": "1234"}, nil))
if bad := f.request("POST", base+"/unlock", nil, map[string]interface{}{"password": "4321"}, nil); bad.Status != http.StatusForbidden {
t.Fatal("incorrect password accepted")
}
}
3 changes: 2 additions & 1 deletion api/webprojects/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ func contentNotFound(c *gin.Context) {
return
}
if strings.Contains(c.GetHeader("Accept"), "text/html") {
c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>404</title><body><h1>404</h1><p>页面不存在或不可见。</p><a href="/">系统主页</a></body></html>`))
c.Header("Referrer-Policy", "no-referrer")
c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>404</title><script>const url=new URL(location.href);if(url.searchParams.has('code')){url.searchParams.delete('code');history.replaceState(history.state,'',url.pathname+url.search+url.hash)}</script><body><h1>404</h1><p>页面不存在或不可见。</p><a href="/">系统主页</a></body></html>`))
} else {
c.Status(http.StatusNotFound)
}
Expand Down
8 changes: 7 additions & 1 deletion api/webprojects/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ func serveProjectContent(c *gin.Context) {
if requested == "" {
requested = release.EntryFile
}
// Consume query passwords in an isolated page even for owners or existing grants,
// before any hosted script or resource can observe the original URL.
if _, hasCode := c.Request.URL.Query()["code"]; hasCode && c.Request.Method == http.MethodGet && passwordHTMLRequest(c, requested) {
servePasswordUnlockPage(c, project.ID)
return
}
_, passwordErr := resourcepasswords.Default.Authorize(ginfmt.RPCContext(c), resourcepasswords.ResourceWebProject, project.ID, principal != nil && principal.UserID == project.OwnerUserID, resourcepasswords.GrantToken(c.Request, resourcepasswords.ResourceWebProject, project.ID))
if passwordErr != nil {
c.Header("X-Resource-Password", "required")
Expand Down Expand Up @@ -162,7 +168,7 @@ func passwordHTMLRequest(c *gin.Context, requested string) bool {

func servePasswordUnlockPage(c *gin.Context, projectID int64) {
endpoint := "/api/web-share/" + strconv.FormatInt(projectID, 10) + "/unlock"
html := `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>密码阅读</title><style>body{margin:0;background:#f3f7f5;color:#173f35;font:16px system-ui,sans-serif}.card{box-sizing:border-box;width:min(92vw,430px);margin:12vh auto;padding:28px;background:#fff;border:1px solid #d8e4df;border-radius:18px;box-shadow:0 14px 40px #173f3514}h1{margin:0 0 8px;font-size:24px}p{color:#55736b}input,button{box-sizing:border-box;width:100%;height:46px;border-radius:10px;font:inherit}input{padding:0 12px;border:1px solid #a9beb7}button{margin-top:12px;border:0;background:#176c55;color:#fff;font-weight:650;cursor:pointer}button:disabled{opacity:.65}.error{min-height:22px;color:#b42318;font-size:14px}</style></head><body><main class="card"><h1>此网页受密码保护</h1><p>输入分享者提供的阅读密码。</p><form id="unlock"><input id="password" type="password" autocomplete="current-password" required autofocus><button id="submit" type="submit">解锁网页</button><p class="error" id="error" role="alert"></p></form></main><script>document.getElementById('unlock').addEventListener('submit',async function(e){e.preventDefault();const button=document.getElementById('submit'),error=document.getElementById('error');button.disabled=true;error.textContent='';try{const response=await fetch('` + endpoint + `',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:document.getElementById('password').value})});if(!response.ok)throw new Error();location.reload()}catch(_){error.textContent='密码错误或尝试过于频繁';button.disabled=false}})</script></body></html>`
html := `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>密码阅读</title><script>const shareURL=new URL(location.href);let shareCode=shareURL.searchParams.get('code');if(shareURL.searchParams.has('code')){shareURL.searchParams.delete('code');history.replaceState(history.state,'',shareURL.pathname+shareURL.search+shareURL.hash)}</script><style>body{margin:0;background:#f3f7f5;color:#173f35;font:16px system-ui,sans-serif}.card{box-sizing:border-box;width:min(92vw,430px);margin:12vh auto;padding:28px;background:#fff;border:1px solid #d8e4df;border-radius:18px;box-shadow:0 14px 40px #173f3514}h1{margin:0 0 8px;font-size:24px}p{color:#55736b}input,button{box-sizing:border-box;width:100%;height:46px;border-radius:10px;font:inherit}input{padding:0 12px;border:1px solid #a9beb7}button{margin-top:12px;border:0;background:#176c55;color:#fff;font-weight:650;cursor:pointer}button:disabled{opacity:.65}.error{min-height:22px;color:#b42318;font-size:14px}</style></head><body><main class="card"><h1>此网页受密码保护</h1><p>输入分享者提供的阅读密码。</p><form id="unlock"><input id="password" type="password" autocomplete="current-password" required autofocus><button id="submit" type="submit">解锁网页</button><p class="error" id="error" role="alert"></p></form></main><script>async function unlock(password){const button=document.getElementById('submit'),error=document.getElementById('error');button.disabled=true;error.textContent='';try{const response=await fetch('` + endpoint + `',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify({password})});if(!response.ok)throw new Error(response.status===403?'密码不正确':response.status===429?'尝试过于频繁,请稍后重试':'暂时无法解锁,请稍后重试');location.reload()}catch(failure){error.textContent=failure.message||'暂时无法解锁,请稍后重试';button.disabled=false}}document.getElementById('unlock').addEventListener('submit',function(e){e.preventDefault();unlock(document.getElementById('password').value)});if(shareCode!==null){const password=shareCode;shareCode=null;unlock(password)}</script></body></html>`
c.Header("Cache-Control", "no-store")
c.Header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'")
c.Header("Referrer-Policy", "no-referrer")
Expand Down
8 changes: 5 additions & 3 deletions app/siteconfig/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,12 @@ func (s *Service) checkedValues(row model.SiteConfigCurrent) (map[string]interfa
if err != nil {
return nil, appErrors.ErrDependency
}
// Published v1 account policies omit this additive key; preserve their original JSON and checksum.
// Published v1 policies may omit additive keys; preserve their original JSON and checksum.
if row.Namespace == "account_policy" && values != nil {
if _, exists := values["max_active_sessions"]; !exists {
values["max_active_sessions"] = 5
for key, fallback := range map[string]int{"max_active_sessions": 5, "min_share_password_length": 8, "share_code_length": 6} {
if _, exists := values[key]; !exists {
values[key] = fallback
}
}
}
normalized, err := config.ValidateValues(s.bootstrap, row.Namespace, values)
Expand Down
27 changes: 27 additions & 0 deletions app/siteconfig/session_policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,30 @@ func TestStoredSessionPolicyIsReadOnlyAndLegacyRollbackAddsDefault(t *testing.T)
t.Fatal("legacy rollback rewrote historical JSON or checksum")
}
}

func TestStoredLegacyPasswordPolicyKeepsOriginalDocument(t *testing.T) {
service := New(nil, config.Config{})
values := config.DefaultRuntimeValues(config.Config{})["account_policy"]
delete(values, "min_share_password_length")
delete(values, "share_code_length")
raw, _ := json.Marshal(values)
hash := sha256.Sum256(raw)
row := model.SiteConfigCurrent{Namespace: "account_policy", Revision: 1, SchemaVersion: 1, ValuesJSON: string(raw), ValuesSHA256: hash[:]}
actual, err := service.checkedValues(row)
if err != nil || actual["min_share_password_length"] != int64(8) || actual["share_code_length"] != int64(6) {
t.Fatalf("legacy defaults: %v %v", actual, err)
}
if row.ValuesJSON != string(raw) || !bytes.Equal(row.ValuesSHA256, hash[:]) {
t.Fatal("legacy stored policy changed")
}
for _, key := range []string{"min_share_password_length", "share_code_length"} {
values[key] = 3
raw, _ = json.Marshal(values)
hash = sha256.Sum256(raw)
row.ValuesJSON, row.ValuesSHA256 = string(raw), hash[:]
if _, err := service.checkedValues(row); err == nil {
t.Fatalf("invalid stored %s accepted", key)
}
delete(values, key)
}
}
4 changes: 4 additions & 0 deletions config/nginx/web_projects_locations.conf
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ location ^~ /api/web-projects/ {
}

location ^~ /p/ {
# Query passwords must not appear in access or upstream error logs.
access_log off;
error_log /dev/null;
add_header Referrer-Policy "no-referrer" always;
proxy_pass http://127.0.0.1:18080;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
Expand Down
19 changes: 19 additions & 0 deletions config/password_policy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package config

import "testing"

func TestConfigurablePasswordBoundaries(t *testing.T) {
for _, key := range []string{"min_password_length", "min_share_password_length", "share_code_length"} {
values := DefaultRuntimeValues(Config{})
values["account_policy"][key] = 4
if _, err := BuildRuntimeSnapshot(Config{}, 0, nil, values); err != nil {
t.Fatalf("%s=4: %v", key, err)
}
for _, invalid := range []interface{}{3, 0, nil, 4.5, 73} {
values["account_policy"][key] = invalid
if _, err := ValidateValues(Config{}, "account_policy", values["account_policy"]); err == nil {
t.Fatalf("accepted %s=%v", key, invalid)
}
}
}
}
4 changes: 3 additions & 1 deletion config/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ func Registry(conf Config) []NamespaceSchema {
{"account_policy", "session_ttl_seconds", "用户会话有效期", "秒", 300, 2592000, 604800},
{"account_policy", "max_active_sessions", "每个账号同时有效的网站登录上限", "个会话", 1, 100, 5},
{"account_policy", "temporary_password_ttl_days", "临时密码有效期", "天", 1, 30, 7},
{"account_policy", "min_password_length", "密码最少字符数", "字符", 15, 64, 15},
{"account_policy", "min_password_length", "账号密码最少字符数", "字符", 4, 64, 15},
{"account_policy", "min_share_password_length", "网页、说明书与文件分享密码最少字节数", "UTF-8 字节", 4, 72, 8},
{"account_policy", "share_code_length", "公开文件分享码长度", "位", 4, 12, 6},
{"account_policy", "bcrypt_cost", "密码哈希成本", "", 10, 14, 12},
} {
min, max := limit.min, limit.max
Expand Down
2 changes: 2 additions & 0 deletions config/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
)

type AccountPolicyConfig struct {
MinSharePasswordLength int `json:"min_share_password_length"`
ShareCodeLength int `json:"share_code_length"`
SessionTTLSeconds int `json:"session_ttl_seconds"`
MaxActiveSessions int `json:"max_active_sessions"`
TemporaryPasswordTTLDays int `json:"temporary_password_ttl_days"`
Expand Down
8 changes: 8 additions & 0 deletions deploy/pi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,11 @@ sudo systemctl enable home_server.target home_server.service home_server_fronten
下载计数表示成功授权并开始传输的次数,客户端断线不会恢复额度。程序回滚时保留四张新增表及私有文件,先核对下一段的密码兼容性条件;不得把数据库快照覆盖到已经产生新业务数据的库。

回滚密码阅读后端前必须检查 `resource_passwords`:旧版本不识别新密码。如果已有非空密码,优先仅回滚前端;回滚后端前先停止相关内容对外访问,不能让密码资源退化为仅原ACL保护。自动回滚只有在确认尚无密码记录时才允许切回旧后端;无法核实则停止服务并保留新release与备份,等待恢复。

## 可配置密码长度升级

本版本没有新增表或 DDL。旧 `account_policy` 缺少 `min_share_password_length` 与 `share_code_length` 时,仅在读取时补充 8、6,保留原 JSON、校验和及历史版本;新发布配置须包含完整字段。账号密码下限允许设为 4,已有值保持不变。发布配置后,旧程序不识别新增字段,不能直接切回旧后端;应部署兼容新字段的修复版本;管理页面回滚历史配置也会补齐新增字段,不能据此恢复旧程序。旧版本严格校验配置字段,单纯恢复前端不影响后端配置。

前后端须使用同一源码提交构建。更新 `/etc/home_server/locations/web_projects_locations.conf`,沿用现有可信代理变量;同步将两个 TLS 网关模板的 `/p/` location 配置应用到实际站点,关闭该路径访问日志与错误日志,增加 `Referrer-Policy: no-referrer`。分别运行 Nginx 配置检查再 reload。保留旧 release 和配置副本用于未发布新策略前的程序回滚。

验收应覆盖:网页配置下限为 4、3 位口令拒绝、4 位口令成功、提高下限后旧密码仍有效;带码链接正确及错误两种情况、地址栏删除所有 code 参数、保留其他参数与片段、原有 Cookie/所有者场景以及仅自己可见的权限约束。
13 changes: 13 additions & 0 deletions deploy/pi/gateway-api.conf
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,19 @@ server {
proxy_intercept_errors off;
}

# Query passwords are consumed by the backend password gate.
location ^~ /p/ {
access_log off;
error_log /dev/null;
proxy_pass http://192.0.2.10:18080;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_cache off;
proxy_intercept_errors off;
add_header Referrer-Policy "no-referrer" always;
}

location / {
proxy_pass http://192.0.2.10:18080;
proxy_http_version 1.1;
Expand Down
Loading
Loading