Files
Yunling Zhang b3c7f04c2b 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 不绕过、数据面不受影响
2026-09-29 13:43:57 +08:00

176 lines
6.0 KiB
Go

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)
}
}