Files
2026-07-27 19:31:43 +08:00

662 lines
22 KiB
Go

package adminauth
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"sync"
"time"
"litepan/internal/domain"
"litepan/pkg/security"
)
const (
cookieName = "admin_session"
defaultAdminUsername = "admin"
defaultAdminPassword = "admin"
defaultSessionTimeout = 7200
tempPasswordTTL = 600
resetCooldownSeconds = 60
KeyAdminUsername = "admin_username"
KeyAdminPassword = "admin_password"
KeySessionTimeout = "session_timeout"
KeyPublicIndexEnabled = "public_index_enabled"
KeyWebDAVEnabled = "webdav_enabled"
KeyIndexAccountSwitchMode = "index_account_switch_mode"
KeyAdminHomeReturnMode = "admin_home_return_mode"
KeyHeaderEffectsEnabled = "header_effects_enabled"
KeyIndexStrmAutoDetectEnabled = "index_strm_auto_detect_enabled"
KeyAdminTempPasswordHash = "admin_temp_password_hash"
KeyAdminTempPasswordExpiresAt = "admin_temp_password_expires_at"
KeyAdminTempPasswordLastReset = "admin_temp_password_last_reset_at"
)
var passwordChangeExemptPaths = map[string]struct{}{
"/api/admin/system-config": {},
"/api/admin/update-credentials": {},
"/api/admin/webdav-config": {},
}
type Session struct {
IsAdmin bool `json:"is_admin"`
Username string `json:"username"`
MustChangePassword bool `json:"must_change_password"`
PasswordChangeReason string `json:"password_change_reason"`
CreatedAt string `json:"created_at"`
Remember bool `json:"remember"`
}
type Status struct {
IsAdmin bool `json:"is_admin"`
Username string `json:"username,omitempty"`
PublicIndexEnabled bool `json:"public_index_enabled"`
MustChangePassword bool `json:"must_change_password"`
PasswordChangeReason string `json:"password_change_reason,omitempty"`
}
type LoginResult struct {
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
MustChangePassword bool `json:"must_change_password"`
PasswordChangeReason string `json:"password_change_reason,omitempty"`
}
type SystemConfig struct {
AdminUsername string `json:"admin_username"`
SessionTimeout float64 `json:"session_timeout"`
PublicIndexEnabled bool `json:"public_index_enabled"`
IndexAccountSwitchMode string `json:"index_account_switch_mode"`
AdminHomeReturnMode string `json:"admin_home_return_mode"`
HeaderEffectsEnabled bool `json:"header_effects_enabled"`
IndexStrmAutoDetectEnabled bool `json:"index_strm_auto_detect_enabled"`
MustChangePassword bool `json:"must_change_password"`
PasswordChangeReason string `json:"password_change_reason"`
OAuthServerURL string `json:"oauth_server_url,omitempty"`
UploadTaskConcurrency int `json:"upload_task_concurrency,omitempty"`
LogRetentionDays int `json:"log_retention_days,omitempty"`
AuthActiveRefreshEnabled bool `json:"auth_active_refresh_enabled,omitempty"`
WebDAVEnabled bool `json:"webdav_enabled"`
}
type WebDAVConfigRequest struct {
WebDAVEnabled *bool `json:"webdav_enabled"`
}
type UpdateCredentialsRequest struct {
AdminUsername string `json:"admin_username"`
AdminPassword string `json:"admin_password"`
SessionTimeout *float64 `json:"session_timeout"`
PublicIndexEnabled *bool `json:"public_index_enabled"`
IndexAccountSwitchMode *string `json:"index_account_switch_mode"`
AdminHomeReturnMode *string `json:"admin_home_return_mode"`
HeaderEffectsEnabled *bool `json:"header_effects_enabled"`
IndexStrmAutoDetectEnabled *bool `json:"index_strm_auto_detect_enabled"`
OAuthServerURL string `json:"oauth_server_url"`
UploadTaskConcurrency *int `json:"upload_task_concurrency"`
LogRetentionDays *int `json:"log_retention_days"`
AuthActiveRefreshEnabled *bool `json:"auth_active_refresh_enabled"`
}
type Service struct {
configs domain.ConfigRepository
secret []byte
log *slog.Logger
resetIPCooldown sync.Map
resetLastAt int64
resetMu sync.Mutex
}
func New(configs domain.ConfigRepository, secret []byte, log *slog.Logger) *Service {
if log == nil {
log = slog.Default()
}
return &Service{configs: configs, secret: secret, log: log}
}
func (s *Service) serializer() *security.TimedSerializer {
return security.NewTimedSerializer(s.secret)
}
func (s *Service) ReadSession(r *http.Request) (*Session, bool) {
c, err := r.Cookie(cookieName)
if err != nil || c == nil || strings.TrimSpace(c.Value) == "" {
return nil, false
}
raw := strings.TrimSpace(c.Value)
payload, err := s.serializer().Loads(raw, 2592000)
if err != nil {
return nil, false
}
var sess Session
if err := json.Unmarshal([]byte(payload), &sess); err != nil {
return nil, false
}
if !sess.IsAdmin {
return nil, false
}
if !sess.Remember {
created, err := time.Parse(time.RFC3339, sess.CreatedAt)
if err != nil {
return nil, false
}
timeout := s.sessionTimeout(r.Context())
if time.Since(created) >= time.Duration(timeout)*time.Second {
return nil, false
}
}
return &sess, true
}
func (s *Service) WriteSession(w http.ResponseWriter, r *http.Request, sess Session, remember bool) error {
if strings.TrimSpace(sess.CreatedAt) == "" {
sess.CreatedAt = time.Now().Format(time.RFC3339)
}
sess.Remember = remember
raw, err := json.Marshal(sess)
if err != nil {
return err
}
token, err := s.serializer().Dumps(string(raw))
if err != nil {
return err
}
cookie := &http.Cookie{
Name: cookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: security.SecureCookie(r),
}
if remember {
cookie.MaxAge = 2592000
}
http.SetCookie(w, cookie)
return nil
}
func (s *Service) ClearSession(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func (s *Service) Status(ctx context.Context, r *http.Request) Status {
sess, ok := s.ReadSession(r)
publicIndex := s.publicIndexEnabled(ctx)
if !ok || sess == nil {
return Status{PublicIndexEnabled: publicIndex}
}
state := s.credentialState(ctx)
mustChange := state.MustChangePassword
reason := state.PasswordChangeReason
if sess.MustChangePassword {
mustChange = true
if reason == "" {
reason = sess.PasswordChangeReason
}
if reason == "" {
reason = "temporary_password"
}
}
return Status{
IsAdmin: true,
Username: sess.Username,
PublicIndexEnabled: publicIndex,
MustChangePassword: mustChange,
PasswordChangeReason: reason,
}
}
func (s *Service) Login(ctx context.Context, r *http.Request, w http.ResponseWriter, username, password string, remember bool) (*LoginResult, error) {
storedUsername, storedPassword := s.adminCredentials(ctx)
if username != storedUsername {
s.log.Warn("管理员登录失败", "username", username, "ip", clientIP(r))
return nil, domain.Errorf(domain.CodeAdminAuthRequired, "用户名或密码错误")
}
state := security.AssessAdminCredentialState(storedUsername, storedPassword)
temp := s.tempPasswordState(ctx)
passwordMatch := security.VerifyAdminPassword(storedPassword, password)
tempMatch := false
if !passwordMatch && temp.Valid && temp.Hash != "" {
tempMatch = security.CheckPasswordHash(temp.Hash, password)
}
if !passwordMatch && !tempMatch {
s.log.Warn("管理员登录失败", "username", username, "ip", clientIP(r))
return nil, domain.Errorf(domain.CodeAdminAuthRequired, "用户名或密码错误")
}
mustChange := state.MustChangePassword || tempMatch
reason := state.PasswordChangeReason
if tempMatch {
reason = "temporary_password"
}
sess := Session{
IsAdmin: true,
Username: username,
MustChangePassword: mustChange,
PasswordChangeReason: reason,
}
if err := s.WriteSession(w, r, sess, remember); err != nil {
return nil, domain.Wrap(domain.CodeInternal, err)
}
s.log.Info("管理员登录成功", "username", username, "ip", clientIP(r))
return &LoginResult{
Username: username,
IsAdmin: true,
MustChangePassword: mustChange,
PasswordChangeReason: reason,
}, nil
}
func (s *Service) ResetPassword(ctx context.Context, r *http.Request) (map[string]any, error) {
now := time.Now().Unix()
ip := clientIP(r)
if ip != "" {
if last, ok := s.resetIPCooldown.Load(ip); ok {
if now-int64(last.(int64)) < resetCooldownSeconds {
return nil, domain.Errorf(domain.CodeRateLimited, "操作过于频繁,请稍后再试。")
}
}
}
temp := s.tempPasswordState(ctx)
if temp.Valid {
remaining := temp.ExpiresAt - now
if remaining < 0 {
remaining = 0
}
return map[string]any{
"reused": true,
"expires_at": temp.ExpiresAt,
"remaining_seconds": remaining,
"ttl_seconds": tempPasswordTTL,
}, nil
}
s.resetMu.Lock()
defer s.resetMu.Unlock()
if now-s.resetLastAt < resetCooldownSeconds {
return nil, domain.Errorf(domain.CodeRateLimited, "操作过于频繁,请稍后再试。")
}
password := randomPassword(12)
hash := security.HashPassword(password)
expiresAt := now + tempPasswordTTL
_ = s.configs.Set(ctx, KeyAdminTempPasswordHash, hash)
_ = s.configs.Set(ctx, KeyAdminTempPasswordExpiresAt, strconv.FormatInt(expiresAt, 10))
_ = s.configs.Set(ctx, KeyAdminTempPasswordLastReset, strconv.FormatInt(now, 10))
s.resetLastAt = now
if ip != "" {
s.resetIPCooldown.Store(ip, now)
}
s.log.Warn("管理员临时密码已生成,请尽快登录并修改密码。")
fmt.Printf("\n[重置密码] 临时管理员密码: %s (有效期 %d 分钟,过期后失效;原密码仍可用;使用临时密码登录后需修改密码)\n\n", password, tempPasswordTTL/60)
return map[string]any{
"expires_at": expiresAt,
"remaining_seconds": tempPasswordTTL,
"ttl_seconds": tempPasswordTTL,
}, nil
}
func (s *Service) EnsureAdminAccess(ctx context.Context, r *http.Request, sess *Session) error {
if sess == nil || !sess.IsAdmin {
return domain.Errorf(domain.CodeAdminAuthRequired, "需要管理员权限")
}
if isWriteMethod(r.Method) && !security.RequestOriginAllowed(r, nil) {
return domain.Errorf(domain.CodePermissionDenied, "请求来源不受信任,请从受信任的后台页面重新登录后再试")
}
path := r.URL.Path
if _, exempt := passwordChangeExemptPaths[path]; exempt {
return nil
}
state := s.credentialState(ctx)
if sess.MustChangePassword {
return domain.Errorf(domain.CodePermissionDenied, "当前会话使用临时密码登录,请先到系统设置修改管理员密码")
}
if state.MustChangePassword {
return domain.Errorf(domain.CodePermissionDenied, "检测到管理员密码处于非安全状态,请先到系统设置修改密码")
}
return nil
}
func (s *Service) EnsurePublicOrAdmin(ctx context.Context, r *http.Request) (*Session, error) {
if sess, ok := s.ReadSession(r); ok {
return sess, nil
}
if s.publicIndexEnabled(ctx) {
return nil, nil
}
return nil, domain.Errorf(domain.CodeAdminAuthRequired, "当前站点未开放匿名文件列表访问,请先登录管理员账号")
}
func (s *Service) SystemConfig(ctx context.Context) SystemConfig {
username, password := s.adminCredentials(ctx)
state := security.AssessAdminCredentialState(username, password)
return SystemConfig{
AdminUsername: username,
SessionTimeout: float64(s.sessionTimeout(ctx)) / 3600,
PublicIndexEnabled: s.publicIndexEnabled(ctx),
IndexAccountSwitchMode: s.indexAccountSwitchMode(ctx),
AdminHomeReturnMode: s.adminHomeReturnMode(ctx),
HeaderEffectsEnabled: s.headerEffectsEnabled(ctx),
IndexStrmAutoDetectEnabled: s.indexStrmAutoDetectEnabled(ctx),
MustChangePassword: state.MustChangePassword,
PasswordChangeReason: state.PasswordChangeReason,
OAuthServerURL: s.configString(ctx, domain.SettingOAuthServerURL, domain.DefaultOAuthServerURL),
UploadTaskConcurrency: s.configInt(ctx, "upload_task_concurrency", 3),
LogRetentionDays: s.configInt(ctx, "log_retention_days", 30),
AuthActiveRefreshEnabled: s.configBool(ctx, "auth_active_refresh_enabled", true),
WebDAVEnabled: s.webdavEnabled(ctx),
}
}
func (s *Service) IndexAccountSwitchMode(ctx context.Context) string {
return s.indexAccountSwitchMode(ctx)
}
func (s *Service) HeaderEffectsEnabled(ctx context.Context) bool {
return s.headerEffectsEnabled(ctx)
}
func (s *Service) IndexStrmAutoDetectEnabled(ctx context.Context) bool {
return s.indexStrmAutoDetectEnabled(ctx)
}
func (s *Service) UpdateWebDAVConfig(ctx context.Context, req WebDAVConfigRequest) error {
if req.WebDAVEnabled != nil {
_ = s.configs.Set(ctx, KeyWebDAVEnabled, boolString(*req.WebDAVEnabled))
}
return nil
}
type configUpdate struct {
key string
value string
}
func (s *Service) UpdateCredentials(ctx context.Context, r *http.Request, w http.ResponseWriter, req UpdateCredentialsRequest, sess *Session) error {
username, updates, err := s.prepareCredentialUpdates(ctx, req)
if err != nil {
return err
}
for _, update := range updates {
if err := s.configs.Set(ctx, update.key, update.value); err != nil {
return domain.Wrap(domain.CodeInternal, err)
}
}
return s.refreshSession(ctx, r, w, username, sess)
}
func (s *Service) prepareCredentialUpdates(ctx context.Context, req UpdateCredentialsRequest) (string, []configUpdate, error) {
username := strings.TrimSpace(req.AdminUsername)
password := strings.TrimSpace(req.AdminPassword)
if err := validateAdminUsername(username); err != nil {
return "", nil, err
}
_, currentPassword := s.adminCredentials(ctx)
currentState := security.AssessAdminCredentialState(username, currentPassword)
if req.SessionTimeout != nil {
if *req.SessionTimeout < 0.5 || *req.SessionTimeout > 24 {
return "", nil, domain.Errorf(domain.CodeValidation, "会话超时时间必须在0.5-24小时之间")
}
}
if password == "" && currentState.MustChangePassword {
return "", nil, domain.Errorf(domain.CodeValidation, "当前管理员密码需要升级,请先设置新密码后再保存其他设置")
}
if password != "" && len(password) < 6 {
return "", nil, domain.Errorf(domain.CodeValidation, "密码长度至少6位")
}
updates := []configUpdate{{key: KeyAdminUsername, value: username}}
if password != "" {
updates = append(updates,
configUpdate{key: KeyAdminPassword, value: security.HashPassword(password)},
configUpdate{key: KeyAdminTempPasswordHash, value: ""},
configUpdate{key: KeyAdminTempPasswordExpiresAt, value: "0"},
)
}
if req.SessionTimeout != nil {
updates = append(updates, configUpdate{key: KeySessionTimeout, value: strconv.Itoa(int(*req.SessionTimeout * 3600))})
}
if req.PublicIndexEnabled != nil {
updates = append(updates, configUpdate{key: KeyPublicIndexEnabled, value: boolString(*req.PublicIndexEnabled)})
}
if req.IndexAccountSwitchMode != nil {
mode := strings.TrimSpace(*req.IndexAccountSwitchMode)
if mode != "dropdown" && mode != "floating" {
return "", nil, domain.Errorf(domain.CodeValidation, "账号切换方式不正确")
}
updates = append(updates, configUpdate{key: KeyIndexAccountSwitchMode, value: mode})
}
if req.AdminHomeReturnMode != nil {
mode := normalizeAdminHomeReturnMode(*req.AdminHomeReturnMode)
if mode == "" {
return "", nil, domain.Errorf(domain.CodeValidation, "首页返回方式不正确")
}
updates = append(updates, configUpdate{key: KeyAdminHomeReturnMode, value: mode})
}
if req.HeaderEffectsEnabled != nil {
updates = append(updates, configUpdate{key: KeyHeaderEffectsEnabled, value: boolString(*req.HeaderEffectsEnabled)})
}
if req.IndexStrmAutoDetectEnabled != nil {
updates = append(updates, configUpdate{key: KeyIndexStrmAutoDetectEnabled, value: boolString(*req.IndexStrmAutoDetectEnabled)})
}
if strings.TrimSpace(req.OAuthServerURL) != "" {
url := strings.TrimSuffix(strings.TrimSpace(req.OAuthServerURL), "/")
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return "", nil, domain.Errorf(domain.CodeValidation, "OAuth服务地址格式不正确,示例:https://litepan.top")
}
updates = append(updates, configUpdate{key: domain.SettingOAuthServerURL, value: url})
}
if req.UploadTaskConcurrency != nil {
if *req.UploadTaskConcurrency < 1 || *req.UploadTaskConcurrency > 5 {
return "", nil, domain.Errorf(domain.CodeValidation, "上传任务并发数必须是 1-5 之间的整数")
}
updates = append(updates, configUpdate{key: "upload_task_concurrency", value: strconv.Itoa(*req.UploadTaskConcurrency)})
}
if req.LogRetentionDays != nil {
if *req.LogRetentionDays < 1 || *req.LogRetentionDays > 365 {
return "", nil, domain.Errorf(domain.CodeValidation, "日志保留天数必须是 1-365 之间的整数")
}
updates = append(updates, configUpdate{key: "log_retention_days", value: strconv.Itoa(*req.LogRetentionDays)})
}
if req.AuthActiveRefreshEnabled != nil {
updates = append(updates, configUpdate{key: "auth_active_refresh_enabled", value: boolString(*req.AuthActiveRefreshEnabled)})
}
return username, updates, nil
}
func validateAdminUsername(username string) error {
if username == "" {
return domain.Errorf(domain.CodeValidation, "用户名不能为空")
}
for _, ch := range username {
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_') {
return domain.Errorf(domain.CodeValidation, "用户名只能包含字母、数字和下划线")
}
}
return nil
}
func (s *Service) refreshSession(ctx context.Context, r *http.Request, w http.ResponseWriter, username string, sess *Session) error {
newSess := Session{IsAdmin: true, Username: username}
remember := false
if sess != nil {
remember = sess.Remember
newSess.CreatedAt = sess.CreatedAt
}
state := s.credentialState(ctx)
newSess.MustChangePassword = state.MustChangePassword
newSess.PasswordChangeReason = state.PasswordChangeReason
return s.WriteSession(w, r, newSess, remember)
}
func (s *Service) adminCredentials(ctx context.Context) (string, string) {
username := s.configString(ctx, KeyAdminUsername, defaultAdminUsername)
password := s.configString(ctx, KeyAdminPassword, "")
if password == "" || (username == defaultAdminUsername && strings.TrimSpace(password) == defaultAdminPassword) {
password = security.HashPassword(defaultAdminPassword)
_ = s.configs.Set(ctx, KeyAdminPassword, password)
}
return username, password
}
func (s *Service) credentialState(ctx context.Context) security.CredentialState {
username, password := s.adminCredentials(ctx)
return security.AssessAdminCredentialState(username, password)
}
func (s *Service) publicIndexEnabled(ctx context.Context) bool {
return s.configBool(ctx, KeyPublicIndexEnabled, true)
}
func (s *Service) webdavEnabled(ctx context.Context) bool {
return s.configBool(ctx, KeyWebDAVEnabled, true)
}
func (s *Service) headerEffectsEnabled(ctx context.Context) bool {
return s.configBool(ctx, KeyHeaderEffectsEnabled, true)
}
func (s *Service) indexStrmAutoDetectEnabled(ctx context.Context) bool {
return s.configBool(ctx, KeyIndexStrmAutoDetectEnabled, true)
}
func (s *Service) indexAccountSwitchMode(ctx context.Context) string {
mode := s.configString(ctx, KeyIndexAccountSwitchMode, "dropdown")
if mode != "dropdown" && mode != "floating" {
return "dropdown"
}
return mode
}
func (s *Service) adminHomeReturnMode(ctx context.Context) string {
mode := normalizeAdminHomeReturnMode(s.configString(ctx, KeyAdminHomeReturnMode, "top_icon"))
if mode == "" {
return "top_icon"
}
return mode
}
func normalizeAdminHomeReturnMode(mode string) string {
switch strings.TrimSpace(mode) {
case "sidebar":
return "sidebar"
case "top_icon", "both":
return "top_icon"
default:
return ""
}
}
func (s *Service) sessionTimeout(ctx context.Context) int {
timeout := s.configInt(ctx, KeySessionTimeout, defaultSessionTimeout)
if timeout <= 0 {
return defaultSessionTimeout
}
return timeout
}
type tempPasswordState struct {
Hash string
ExpiresAt int64
LastReset int64
Valid bool
}
func (s *Service) tempPasswordState(ctx context.Context) tempPasswordState {
hash := s.configString(ctx, KeyAdminTempPasswordHash, "")
expiresAt := int64(s.configInt(ctx, KeyAdminTempPasswordExpiresAt, 0))
lastReset := int64(s.configInt(ctx, KeyAdminTempPasswordLastReset, 0))
now := time.Now().Unix()
return tempPasswordState{
Hash: hash,
ExpiresAt: expiresAt,
LastReset: lastReset,
Valid: hash != "" && expiresAt > now,
}
}
func (s *Service) configString(ctx context.Context, key, fallback string) string {
v, ok, err := s.configs.Get(ctx, key)
if err != nil || !ok || strings.TrimSpace(v) == "" {
return fallback
}
return strings.TrimSpace(v)
}
func (s *Service) configInt(ctx context.Context, key string, fallback int) int {
v, ok, err := s.configs.Get(ctx, key)
if err != nil || !ok || strings.TrimSpace(v) == "" {
return fallback
}
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return fallback
}
return n
}
func (s *Service) configBool(ctx context.Context, key string, fallback bool) bool {
v, ok, err := s.configs.Get(ctx, key)
if err != nil || !ok {
return fallback
}
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return fallback
}
}
func boolString(v bool) string {
if v {
return "true"
}
return "false"
}
func isWriteMethod(method string) bool {
switch strings.ToUpper(method) {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
return true
default:
return false
}
}
func clientIP(r *http.Request) string {
if fwd := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); fwd != "" {
return fwd
}
host := strings.TrimSpace(r.RemoteAddr)
if i := strings.LastIndex(host, ":"); i >= 0 {
return host[:i]
}
return host
}
func randomPassword(length int) string {
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
buf := make([]byte, length)
randBytes := make([]byte, length)
_, _ = rand.Read(randBytes)
for i := range buf {
buf[i] = alphabet[int(randBytes[i])%len(alphabet)]
}
return string(buf)
}