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:
1 parent
a730929bf6
commit
b3c7f04c2b
6 files changed
+318
-4
No files matched your search
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user