feat(auth): 管理端新增 AUTH_MODE=builtin|sso,默认 builtin 行为不变

- 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 不绕过、数据面不受影响
This commit is contained in:
Yunling Zhang committed 2026-09-29 13:43:57 +08:00
1 parent a730929bf6
commit b3c7f04c2b
6 files changed
+318 -4

No files matched your search

+43
View File
@@ -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 }
+45
View File
@@ -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)
}
}
+35 -1
View File
@@ -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
}
+175
View File
@@ -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)
}
}
+4
View File
@@ -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),
}