From b3c7f04c2ba044727b8a794576d8d905aa46afc2 Mon Sep 17 00:00:00 2001 From: Yunling Zhang Date: Tue, 29 Sep 2026 13:43:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E7=AE=A1=E7=90=86=E7=AB=AF?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20AUTH=5FMODE=3Dbuiltin|sso=EF=BC=8C?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=20builtin=20=E8=A1=8C=E4=B8=BA=E4=B8=8D?= =?UTF-8?q?=E5=8F=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/auth/mode.go:Mode 解析/归一化,X-Auth-User 头常量 - server:adminAuth 按模式鉴权,sso 只认网关注入的 X-Auth-User(缺失 401 JSON) - 新增公开 /api/admin/mode 供前端识别模式;sso 下 login/logout 返回 404 - main:读取 AUTH_MODE,sso 时跳过自带口令并打印启动提示 - 测试覆盖两种模式、伪造 header 不绕过、数据面不受影响 --- cmd/davbox/main.go | 19 +++- internal/auth/mode.go | 43 ++++++++ internal/auth/mode_test.go | 45 ++++++++ internal/server/admin.go | 36 ++++++- internal/server/authmode_test.go | 175 +++++++++++++++++++++++++++++++ internal/server/server.go | 4 + 6 files changed, 318 insertions(+), 4 deletions(-) create mode 100644 internal/auth/mode.go create mode 100644 internal/auth/mode_test.go create mode 100644 internal/server/authmode_test.go diff --git a/cmd/davbox/main.go b/cmd/davbox/main.go index e730993..dd527d7 100644 --- a/cmd/davbox/main.go +++ b/cmd/davbox/main.go @@ -18,6 +18,11 @@ func main() { dataDir := flag.String("data", "./data", "数据目录") flag.Parse() + authMode, err := auth.ParseMode(os.Getenv("AUTH_MODE")) + if err != nil { + log.Fatalf("%v", err) + } + if err := os.MkdirAll(*dataDir, 0o755); err != nil { log.Fatalf("创建数据目录失败: %v", err) } @@ -30,9 +35,16 @@ func main() { if err != nil { log.Fatalf("加载会话密钥失败: %v", err) } - adminHash, err := auth.LoadOrCreateAdmin(*dataDir) - if err != nil { - log.Fatalf("加载管理员口令失败: %v", err) + + // builtin 模式加载(必要时生成)自带管理员口令;sso 模式不碰自带账号体系。 + var adminHash []byte + if authMode.IsSSO() { + log.Printf("管理端认证: SSO(信任 %s,请确保仅在网关后暴露)", auth.HeaderAuthUser) + } else { + adminHash, err = auth.LoadOrCreateAdmin(*dataDir) + if err != nil { + log.Fatalf("加载管理员口令失败: %v", err) + } } srv := server.New(server.Config{ @@ -40,6 +52,7 @@ func main() { Store: store, AdminHash: adminHash, Signer: auth.NewSigner(secret), + AuthMode: authMode, }) httpServer := &http.Server{ diff --git a/internal/auth/mode.go b/internal/auth/mode.go new file mode 100644 index 0000000..cbc1957 --- /dev/null +++ b/internal/auth/mode.go @@ -0,0 +1,43 @@ +package auth + +import ( + "fmt" + "strings" +) + +// HeaderAuthUser 是 SSO 模式下网关注入的已认证用户标识头。 +// 只在服务仅监听回环、且由网关经反向代理暴露时才能信任。 +const HeaderAuthUser = "X-Auth-User" + +// Mode 是管理端的认证模式。 +type Mode string + +const ( + // ModeBuiltin 用 davbox 自带的管理员口令(默认,开箱即用)。 + ModeBuiltin Mode = "builtin" + // ModeSSO 关闭自带口令,只信任网关注入的 X-Auth-User。 + ModeSSO Mode = "sso" +) + +// ParseMode 解析配置值:空串按默认 builtin 处理,其余非法值返回错误。 +func ParseMode(v string) (Mode, error) { + switch strings.ToLower(strings.TrimSpace(v)) { + case "", string(ModeBuiltin): + return ModeBuiltin, nil + case string(ModeSSO): + return ModeSSO, nil + default: + return "", fmt.Errorf("AUTH_MODE 取值非法: %q(只支持 builtin 或 sso)", v) + } +} + +// Normalize 把零值或非法值收敛为默认的 builtin。 +func (m Mode) Normalize() Mode { + if m == ModeBuiltin || m == ModeSSO { + return m + } + return ModeBuiltin +} + +// IsSSO 报告当前是否为 SSO 模式。 +func (m Mode) IsSSO() bool { return m == ModeSSO } diff --git a/internal/auth/mode_test.go b/internal/auth/mode_test.go new file mode 100644 index 0000000..bfe87ac --- /dev/null +++ b/internal/auth/mode_test.go @@ -0,0 +1,45 @@ +package auth + +import "testing" + +func TestParseMode(t *testing.T) { + cases := []struct { + in string + want Mode + bad bool + }{ + {"", ModeBuiltin, false}, + {"builtin", ModeBuiltin, false}, + {" Builtin ", ModeBuiltin, false}, + {"sso", ModeSSO, false}, + {"SSO", ModeSSO, false}, + {"oidc", "", true}, + } + for _, c := range cases { + got, err := ParseMode(c.in) + if c.bad { + if err == nil { + t.Fatalf("ParseMode(%q) 应报错", c.in) + } + continue + } + if err != nil { + t.Fatalf("ParseMode(%q) 意外错误: %v", c.in, err) + } + if got != c.want { + t.Fatalf("ParseMode(%q) = %q,期望 %q", c.in, got, c.want) + } + } +} + +func TestModeNormalize(t *testing.T) { + if got := Mode("").Normalize(); got != ModeBuiltin { + t.Fatalf("零值应归一为 builtin,实际 %q", got) + } + if got := Mode("whatever").Normalize(); got != ModeBuiltin { + t.Fatalf("非法值应归一为 builtin,实际 %q", got) + } + if got := ModeSSO.Normalize(); got != ModeSSO { + t.Fatalf("sso 应保持不变,实际 %q", got) + } +} diff --git a/internal/server/admin.go b/internal/server/admin.go index e7e82f0..968e8d6 100644 --- a/internal/server/admin.go +++ b/internal/server/admin.go @@ -53,6 +53,24 @@ func (s *Server) adminSession(r *http.Request) (auth.Session, bool) { return sess, true } +// adminAuth 按当前认证模式判断管理端身份。 +// builtin:校验会话 cookie,用户标识为空串。 +// sso:只认网关注入的 X-Auth-User,缺失或空白即未认证(绝不回退到 cookie)。 +func (s *Server) adminAuth(r *http.Request) (string, bool) { + if s.authMode.IsSSO() { + user := strings.TrimSpace(r.Header.Get(auth.HeaderAuthUser)) + if user == "" { + return "", false + } + return user, true + } + sess, ok := s.adminSession(r) + if !ok { + return "", false + } + return sess.User, true +} + type accountView struct { User string `json:"user"` Root string `json:"root"` @@ -101,6 +119,22 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) { parts = strings.Split(rest, "/") } + // 认证模式查询:前端在未登录时也要知道当前模式,故不鉴权。 + if len(parts) == 1 && parts[0] == "mode" { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "方法不允许") + return + } + writeJSON(w, http.StatusOK, map[string]string{"mode": string(s.authMode)}) + return + } + + // SSO 模式不提供自带口令的登录/退出,交由网关的 /_auth/* 处理。 + if s.authMode.IsSSO() && len(parts) == 1 && (parts[0] == "login" || parts[0] == "logout") { + writeError(w, http.StatusNotFound, "未找到") + return + } + if len(parts) == 1 && parts[0] == "login" { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "方法不允许") @@ -110,7 +144,7 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) { return } - if _, ok := s.adminSession(r); !ok { + if _, ok := s.adminAuth(r); !ok { writeError(w, http.StatusUnauthorized, "未登录") return } diff --git a/internal/server/authmode_test.go b/internal/server/authmode_test.go new file mode 100644 index 0000000..9df9ea4 --- /dev/null +++ b/internal/server/authmode_test.go @@ -0,0 +1,175 @@ +package server + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/YLing2024/davbox/internal/account" + "github.com/YLing2024/davbox/internal/auth" +) + +// newSSOTestServer 构造一个 SSO 模式的测试服务器。 +func newSSOTestServer(t *testing.T) (*Server, *account.Store, string) { + t.Helper() + dir := t.TempDir() + store, err := account.Open(dir) + if err != nil { + t.Fatal(err) + } + signer := auth.NewSigner([]byte("0123456789abcdef0123456789abcdef")) + return New(Config{ + DataDir: dir, + Store: store, + Signer: signer, + AuthMode: auth.ModeSSO, + }), store, dir +} + +func TestAdminModeEndpoint(t *testing.T) { + // builtin 默认 + s, _, _ := newTestServer(t) + rec := request(s, http.MethodGet, "/api/admin/mode", "", "", nil, nil) + if rec.Code != http.StatusOK { + t.Fatalf("mode 应为 200,实际 %d", rec.Code) + } + var got struct { + Mode string `json:"mode"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Mode != "builtin" { + t.Fatalf("默认模式应为 builtin,实际 %q", got.Mode) + } + + // sso + s2, _, _ := newSSOTestServer(t) + rec = request(s2, http.MethodGet, "/api/admin/mode", "", "", nil, nil) + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Mode != "sso" { + t.Fatalf("sso 模式应为 sso,实际 %q", got.Mode) + } +} + +func TestAdminSSOHeaderAccepted(t *testing.T) { + s, _, _ := newSSOTestServer(t) + rec := request(s, http.MethodGet, "/api/admin/accounts", "", "", nil, map[string]string{"X-Auth-User": "linden"}) + if rec.Code != http.StatusOK { + t.Fatalf("带 X-Auth-User 应为 200,实际 %d", rec.Code) + } +} + +func TestAdminSSOMissingHeaderIs401JSON(t *testing.T) { + s, _, _ := newSSOTestServer(t) + rec := request(s, http.MethodGet, "/api/admin/accounts", "", "", nil, nil) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("缺 header 应为 401,实际 %d", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Fatalf("401 应为 JSON,实际 Content-Type=%q", ct) + } + if loc := rec.Header().Get("Location"); loc != "" { + t.Fatalf("401 不应重定向,实际 Location=%q", loc) + } + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("响应体不是 JSON: %v (%q)", err, rec.Body.String()) + } + if body["error"] == "" { + t.Fatalf("JSON 应含 error 字段,实际 %q", rec.Body.String()) + } +} + +func TestAdminSSOEmptyHeaderIs401(t *testing.T) { + s, _, _ := newSSOTestServer(t) + rec := request(s, http.MethodGet, "/api/admin/accounts", "", "", nil, map[string]string{"X-Auth-User": " "}) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("空白 X-Auth-User 应为 401,实际 %d", rec.Code) + } +} + +func TestAdminBuiltinIgnoresAuthUserHeader(t *testing.T) { + // builtin 模式下伪造 X-Auth-User 不能绕过口令认证。 + s, _, _ := newTestServer(t) + rec := request(s, http.MethodGet, "/api/admin/accounts", "", "", nil, map[string]string{"X-Auth-User": "linden"}) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("builtin 模式下伪造 header 应为 401,实际 %d", rec.Code) + } +} + +func TestAdminSSOLoginLogoutNotFound(t *testing.T) { + s, _, _ := newSSOTestServer(t) + rec := request(s, http.MethodPost, "/api/admin/login", "", "", strings.NewReader(`{"password":"x"}`), nil) + if rec.Code != http.StatusNotFound { + t.Fatalf("sso 模式 login 应为 404,实际 %d", rec.Code) + } + rec = request(s, http.MethodPost, "/api/admin/logout", "", "", nil, map[string]string{"X-Auth-User": "linden"}) + if rec.Code != http.StatusNotFound { + t.Fatalf("sso 模式 logout 应为 404,实际 %d", rec.Code) + } +} + +func TestAdminSSOAccountCRUD(t *testing.T) { + s, _, _ := newSSOTestServer(t) + h := map[string]string{"X-Auth-User": "linden"} + + rec := request(s, http.MethodPost, "/api/admin/accounts", "", "", strings.NewReader(`{"user":"ssoapp","readonly":false}`), h) + if rec.Code != http.StatusCreated { + t.Fatalf("sso 建账号应为 201,实际 %d", rec.Code) + } + rec = request(s, http.MethodGet, "/api/admin/accounts", "", "", nil, h) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "ssoapp") { + t.Fatalf("sso 列表应含新账号,实际 %d %q", rec.Code, rec.Body.String()) + } + rec = request(s, http.MethodDelete, "/api/admin/accounts/ssoapp", "", "", nil, h) + if rec.Code != http.StatusOK { + t.Fatalf("sso 删除应为 200,实际 %d", rec.Code) + } +} + +// SSO 模式不得影响 WebDAV 数据面与 client 接口。 +func TestSSOModeDoesNotAffectDataPlane(t *testing.T) { + s, store, _ := newSSOTestServer(t) + _, pass, _ := store.Create("app1", false, "") + + rec := request(s, "PROPFIND", "/app1/", "app1", pass, nil, map[string]string{"Depth": "0"}) + if rec.Code != 207 { + t.Fatalf("SSO 下 WebDAV 认证后应为 207,实际 %d", rec.Code) + } + rec = request(s, "PROPFIND", "/app1/", "", "", nil, map[string]string{"Depth": "0"}) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("SSO 下 WebDAV 匿名应为 401,实际 %d", rec.Code) + } + + rec = request(s, http.MethodPost, "/api/client/login", "", "", strings.NewReader(`{"user":"app1","pass":"`+pass+`"}`), nil) + if rec.Code != http.StatusOK { + t.Fatalf("SSO 下 client 登录应为 200,实际 %d", rec.Code) + } +} + +// 静态资源在 SSO 模式下仍可访问,不受管理端鉴权影响。 +func TestSSOModeStaticUnaffected(t *testing.T) { + s, _, _ := newSSOTestServer(t) + rec := request(s, http.MethodGet, "/admin", "", "", nil, nil) + if rec.Code != http.StatusOK { + t.Fatalf("/admin 页面应为 200,实际 %d", rec.Code) + } +} + +// 保证 builtin 回归:现有显式流程仍可用(含 cookie 登录)。 +func TestBuiltinModeStillWorks(t *testing.T) { + s, _, _ := newTestServer(t) + rec := request(s, http.MethodPost, "/api/admin/login", "", "", strings.NewReader(`{"password":"`+testAdminPass+`"}`), nil) + if rec.Code != http.StatusOK { + t.Fatalf("builtin 登录应为 200,实际 %d", rec.Code) + } + cookies := rec.Result().Cookies() + rec = doWithCookie(s, http.MethodGet, "/api/admin/accounts", cookies, nil) + if rec.Code != http.StatusOK { + t.Fatalf("builtin 带 cookie 列表应为 200,实际 %d", rec.Code) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 67b8102..54e1221 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -21,6 +21,8 @@ type Config struct { Store *account.Store AdminHash []byte Signer *auth.Signer + // AuthMode 是管理端认证模式;零值按 builtin 处理。 + AuthMode auth.Mode } // Server 持有全部运行期状态。 @@ -29,6 +31,7 @@ type Server struct { store *account.Store adminHash []byte signer *auth.Signer + authMode auth.Mode davMu sync.Mutex locks map[string]webdav.LockSystem @@ -43,6 +46,7 @@ func New(cfg Config) *Server { store: cfg.Store, adminHash: cfg.AdminHash, signer: cfg.Signer, + authMode: cfg.AuthMode.Normalize(), locks: map[string]webdav.LockSystem{}, usage: newUsageCache(30 * time.Second), }