- 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 不绕过、数据面不受影响
327 lines
8.5 KiB
Go
327 lines
8.5 KiB
Go
package server
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"github.com/YLing2024/davbox/internal/account"
|
|
"github.com/YLing2024/davbox/internal/auth"
|
|
"github.com/YLing2024/davbox/internal/usage"
|
|
)
|
|
|
|
const (
|
|
cookieAdmin = "davbox_admin"
|
|
cookieClient = "davbox_client"
|
|
sessionTTL = 12 * time.Hour
|
|
)
|
|
|
|
func setSessionCookie(w http.ResponseWriter, name, value string) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: name,
|
|
Value: value,
|
|
Path: "/",
|
|
MaxAge: int(sessionTTL.Seconds()),
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
func clearSessionCookie(w http.ResponseWriter, name string) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: name,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
func (s *Server) adminSession(r *http.Request) (auth.Session, bool) {
|
|
c, err := r.Cookie(cookieAdmin)
|
|
if err != nil {
|
|
return auth.Session{}, false
|
|
}
|
|
sess, ok := s.signer.Verify(c.Value)
|
|
if !ok || sess.Kind != "admin" {
|
|
return auth.Session{}, false
|
|
}
|
|
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"`
|
|
Readonly bool `json:"readonly"`
|
|
Disabled bool `json:"disabled"`
|
|
Note string `json:"note"`
|
|
UsedBytes int64 `json:"usedBytes"`
|
|
UsedFiles int64 `json:"usedFiles"`
|
|
}
|
|
|
|
func (s *Server) viewOf(a account.Account) accountView {
|
|
b, f := s.usage.stat(a.User, a.Root, usage.Dir)
|
|
return accountView{
|
|
User: a.User, Root: a.Root, Readonly: a.Readonly, Disabled: a.Disabled,
|
|
Note: a.Note, UsedBytes: b, UsedFiles: f,
|
|
}
|
|
}
|
|
|
|
func requestBaseURL(r *http.Request) string {
|
|
scheme := "http"
|
|
if r.TLS != nil {
|
|
scheme = "https"
|
|
}
|
|
if xf := r.Header.Get("X-Forwarded-Proto"); xf != "" {
|
|
if i := strings.IndexByte(xf, ','); i >= 0 {
|
|
xf = xf[:i]
|
|
}
|
|
xf = strings.TrimSpace(xf)
|
|
if xf == "https" || xf == "http" {
|
|
scheme = xf
|
|
}
|
|
}
|
|
return scheme + "://" + r.Host
|
|
}
|
|
|
|
func connURL(r *http.Request, user string) string {
|
|
return requestBaseURL(r) + "/" + user
|
|
}
|
|
|
|
// handleAdmin 处理 /api/admin 下的全部请求。
|
|
func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
|
rest := strings.TrimPrefix(r.URL.Path, "/api/admin")
|
|
rest = strings.TrimPrefix(rest, "/")
|
|
var parts []string
|
|
if rest != "" {
|
|
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, "方法不允许")
|
|
return
|
|
}
|
|
s.adminLogin(w, r)
|
|
return
|
|
}
|
|
|
|
if _, ok := s.adminAuth(r); !ok {
|
|
writeError(w, http.StatusUnauthorized, "未登录")
|
|
return
|
|
}
|
|
|
|
switch {
|
|
case len(parts) == 1 && parts[0] == "logout":
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "方法不允许")
|
|
return
|
|
}
|
|
clearSessionCookie(w, cookieAdmin)
|
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
|
|
case len(parts) == 1 && parts[0] == "accounts":
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
s.adminListAccounts(w, r)
|
|
case http.MethodPost:
|
|
s.adminCreateAccount(w, r)
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "方法不允许")
|
|
}
|
|
|
|
case len(parts) == 2 && parts[0] == "accounts":
|
|
user := parts[1]
|
|
switch r.Method {
|
|
case http.MethodPatch:
|
|
s.adminPatchAccount(w, r, user)
|
|
case http.MethodDelete:
|
|
s.adminDeleteAccount(w, r, user)
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "方法不允许")
|
|
}
|
|
|
|
case len(parts) == 3 && parts[0] == "accounts" && parts[2] == "conn":
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "方法不允许")
|
|
return
|
|
}
|
|
s.adminConn(w, r, parts[1])
|
|
|
|
case len(parts) == 3 && parts[0] == "accounts" && parts[2] == "rotate":
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "方法不允许")
|
|
return
|
|
}
|
|
s.adminRotate(w, r, parts[1])
|
|
|
|
default:
|
|
writeError(w, http.StatusNotFound, "未找到")
|
|
}
|
|
}
|
|
|
|
func (s *Server) adminLogin(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Password string `json:"password"`
|
|
}
|
|
if err := decodeJSON(r, &body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "请求格式错误")
|
|
return
|
|
}
|
|
if bcrypt.CompareHashAndPassword(s.adminHash, []byte(body.Password)) != nil {
|
|
writeError(w, http.StatusUnauthorized, "口令错误")
|
|
return
|
|
}
|
|
setSessionCookie(w, cookieAdmin, s.signer.Issue("admin", "", sessionTTL))
|
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
}
|
|
|
|
func (s *Server) adminListAccounts(w http.ResponseWriter, r *http.Request) {
|
|
accounts := s.store.List()
|
|
out := make([]accountView, 0, len(accounts))
|
|
for _, a := range accounts {
|
|
out = append(out, s.viewOf(a))
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (s *Server) adminCreateAccount(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
User string `json:"user"`
|
|
Readonly bool `json:"readonly"`
|
|
Note string `json:"note"`
|
|
}
|
|
if err := decodeJSON(r, &body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "请求格式错误")
|
|
return
|
|
}
|
|
a, pass, err := s.store.Create(strings.TrimSpace(body.User), body.Readonly, body.Note)
|
|
switch {
|
|
case errors.Is(err, account.ErrInvalidName):
|
|
writeError(w, http.StatusBadRequest, "账号名只能用小写字母、数字、下划线或连字符,且不超过 32 位")
|
|
return
|
|
case errors.Is(err, account.ErrDuplicate):
|
|
writeError(w, http.StatusConflict, "账号已存在")
|
|
return
|
|
case err != nil:
|
|
writeError(w, http.StatusInternalServerError, "创建失败")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"user": a.User,
|
|
"pass": pass,
|
|
"url": connURL(r, a.User),
|
|
})
|
|
}
|
|
|
|
func (s *Server) adminConn(w http.ResponseWriter, r *http.Request, user string) {
|
|
a, ok := s.store.Get(user)
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, "账号不存在")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"url": connURL(r, a.User),
|
|
"user": a.User,
|
|
"pass": a.Pass,
|
|
})
|
|
}
|
|
|
|
func (s *Server) adminRotate(w http.ResponseWriter, r *http.Request, user string) {
|
|
pass, err := s.store.Rotate(user)
|
|
if errors.Is(err, account.ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "账号不存在")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "换口令失败")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"user": user,
|
|
"pass": pass,
|
|
"url": connURL(r, user),
|
|
})
|
|
}
|
|
|
|
func (s *Server) adminPatchAccount(w http.ResponseWriter, r *http.Request, user string) {
|
|
var body struct {
|
|
Readonly *bool `json:"readonly"`
|
|
Disabled *bool `json:"disabled"`
|
|
Note *string `json:"note"`
|
|
}
|
|
if err := decodeJSON(r, &body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "请求格式错误")
|
|
return
|
|
}
|
|
a, err := s.store.Update(user, account.Patch{
|
|
Readonly: body.Readonly,
|
|
Disabled: body.Disabled,
|
|
Note: body.Note,
|
|
})
|
|
if errors.Is(err, account.ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "账号不存在")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "更新失败")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, s.viewOf(a))
|
|
}
|
|
|
|
func (s *Server) adminDeleteAccount(w http.ResponseWriter, r *http.Request, user string) {
|
|
err := s.store.Delete(user)
|
|
if errors.Is(err, account.ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "账号不存在")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "删除失败")
|
|
return
|
|
}
|
|
s.forgetLocks(user)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"message": "账号已删除,磁盘目录保留",
|
|
})
|
|
}
|