feat: add admin login page with session auth
Build and Push to GHCR / build-and-push (push) Has been cancelled
Build and Push to GHCR / build-and-push (push) Has been cancelled
Replace HTTP Basic Auth with a dedicated login page using cookie-based session authentication. - Add web/static/login.html with form-based login UI - Add in-memory session store with configurable timeout - Add /api/auth/login, /api/auth/logout, /api/auth/me endpoints - Replace BasicAuth middleware with SessionAuth - Add session_timeout config field (default 24h) - Remove listen_addr from settings UI via [HAPI](https://hapi.run) Co-Authored-By: HAPI <[email protected]>
This commit is contained in:
+197
-27
@@ -2,10 +2,12 @@ package admin
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"mirror-proxy/internal/auth"
|
||||
@@ -13,26 +15,181 @@ import (
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
cfg *config.Config
|
||||
onRestart func()
|
||||
cfg *config.Config
|
||||
onRestart func()
|
||||
sessions *SessionStore
|
||||
}
|
||||
|
||||
func NewHandler(cfg *config.Config) *Handler {
|
||||
return &Handler{cfg: cfg}
|
||||
return &Handler{
|
||||
cfg: cfg,
|
||||
sessions: newSessionStore(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) SetOnRestart(fn func()) {
|
||||
h.onRestart = fn
|
||||
}
|
||||
|
||||
// GetLinks 获取所有链接
|
||||
// ---------- Session Management ----------
|
||||
|
||||
type Session struct {
|
||||
Token string
|
||||
Username string
|
||||
Expires time.Time
|
||||
}
|
||||
|
||||
type SessionStore struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*Session
|
||||
}
|
||||
|
||||
func newSessionStore() *SessionStore {
|
||||
s := &SessionStore{sessions: make(map[string]*Session)}
|
||||
go s.cleanupLoop()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *SessionStore) cleanupLoop() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
for range ticker.C {
|
||||
s.mu.Lock()
|
||||
for token, sess := range s.sessions {
|
||||
if time.Now().After(sess.Expires) {
|
||||
delete(s.sessions, token)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SessionStore) Create(username string, timeoutHours int) *Session {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
token := hex.EncodeToString(b)
|
||||
|
||||
if timeoutHours <= 0 {
|
||||
timeoutHours = 24
|
||||
}
|
||||
|
||||
sess := &Session{
|
||||
Token: token,
|
||||
Username: username,
|
||||
Expires: time.Now().Add(time.Duration(timeoutHours) * time.Hour),
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.sessions[token] = sess
|
||||
s.mu.Unlock()
|
||||
|
||||
return sess
|
||||
}
|
||||
|
||||
func (s *SessionStore) Get(token string) (*Session, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
sess, ok := s.sessions[token]
|
||||
if !ok || time.Now().After(sess.Expires) {
|
||||
return nil, false
|
||||
}
|
||||
return sess, true
|
||||
}
|
||||
|
||||
func (s *SessionStore) Delete(token string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.sessions, token)
|
||||
}
|
||||
|
||||
func (h *Handler) SessionStore() *SessionStore {
|
||||
return h.sessions
|
||||
}
|
||||
|
||||
// ---------- Auth Handlers ----------
|
||||
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Username != h.cfg.AdminUser || req.Password != h.cfg.AdminPass {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
sess := h.sessions.Create(req.Username, h.cfg.SessionTimeout)
|
||||
|
||||
maxAge := h.cfg.SessionTimeout * 3600
|
||||
if maxAge <= 0 {
|
||||
maxAge = 86400
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: sess.Token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: maxAge,
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"username": sess.Username})
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("session")
|
||||
if err == nil {
|
||||
h.sessions.Delete(cookie.Value)
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("session")
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
sess, ok := h.sessions.Get(cookie.Value)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "session expired"})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"username": sess.Username})
|
||||
}
|
||||
|
||||
// ---------- Link Handlers ----------
|
||||
|
||||
func (h *Handler) GetLinks(w http.ResponseWriter, r *http.Request) {
|
||||
links := h.cfg.ListLinks()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(links)
|
||||
}
|
||||
|
||||
// CreateLink 创建新链接
|
||||
func (h *Handler) CreateLink(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
@@ -77,7 +234,6 @@ func (h *Handler) CreateLink(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(link)
|
||||
}
|
||||
|
||||
// UpdateLink 更新链接
|
||||
func (h *Handler) UpdateLink(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Path[len("/api/links/"):]
|
||||
|
||||
@@ -126,7 +282,6 @@ func (h *Handler) UpdateLink(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(link)
|
||||
}
|
||||
|
||||
// DeleteLink 删除链接
|
||||
func (h *Handler) DeleteLink(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Path[len("/api/links/"):]
|
||||
|
||||
@@ -145,7 +300,6 @@ func (h *Handler) DeleteLink(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// RegenerateToken 重新生成token
|
||||
func (h *Handler) RegenerateToken(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Path[len("/api/links/"):len(r.URL.Path)-len("/token")]
|
||||
|
||||
@@ -166,7 +320,6 @@ func (h *Handler) RegenerateToken(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{"token": link.Token})
|
||||
}
|
||||
|
||||
// GetStats 获取统计信息
|
||||
func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
|
||||
links := h.cfg.ListLinks()
|
||||
stats := make(map[string]interface{})
|
||||
@@ -177,26 +330,24 @@ func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(stats)
|
||||
}
|
||||
|
||||
// GetConfig 获取配置
|
||||
func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"listen_addr": h.cfg.ListenAddr,
|
||||
"admin_path": h.cfg.AdminPath,
|
||||
"admin_user": h.cfg.AdminUser,
|
||||
"docker_enabled": true,
|
||||
"ghcr_enabled": true,
|
||||
"github_enabled": true,
|
||||
"admin_path": h.cfg.AdminPath,
|
||||
"admin_user": h.cfg.AdminUser,
|
||||
"session_timeout": h.cfg.SessionTimeout,
|
||||
"docker_enabled": true,
|
||||
"ghcr_enabled": true,
|
||||
"github_enabled": true,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateConfig 更新系统配置
|
||||
func (h *Handler) UpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ListenAddr string `json:"listen_addr"`
|
||||
AdminPath string `json:"admin_path"`
|
||||
AdminUser string `json:"admin_user"`
|
||||
AdminPass string `json:"admin_pass"`
|
||||
AdminPath string `json:"admin_path"`
|
||||
AdminUser string `json:"admin_user"`
|
||||
AdminPass string `json:"admin_pass"`
|
||||
SessionTimeout int `json:"session_timeout"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -206,10 +357,6 @@ func (h *Handler) UpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
restartRequired := false
|
||||
|
||||
if req.ListenAddr != "" {
|
||||
h.cfg.ListenAddr = req.ListenAddr
|
||||
restartRequired = true
|
||||
}
|
||||
if req.AdminPath != "" {
|
||||
if !strings.HasPrefix(req.AdminPath, "/") {
|
||||
req.AdminPath = "/" + req.AdminPath
|
||||
@@ -225,6 +372,9 @@ func (h *Handler) UpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if req.AdminPass != "" {
|
||||
h.cfg.AdminPass = req.AdminPass
|
||||
}
|
||||
if req.SessionTimeout > 0 {
|
||||
h.cfg.SessionTimeout = req.SessionTimeout
|
||||
}
|
||||
|
||||
if err := h.cfg.Save(config.GetConfigFilePath()); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -237,9 +387,9 @@ func (h *Handler) UpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"listen_addr": h.cfg.ListenAddr,
|
||||
"admin_path": h.cfg.AdminPath,
|
||||
"admin_user": h.cfg.AdminUser,
|
||||
"session_timeout": h.cfg.SessionTimeout,
|
||||
"restart_required": restartRequired,
|
||||
})
|
||||
}
|
||||
@@ -271,8 +421,28 @@ func (h *Handler) LinksHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
// AuthHandler 认证路由分发
|
||||
func (h *Handler) AuthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
if r.URL.Path == "/api/auth/login" {
|
||||
h.Login(w, r)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/auth/logout" {
|
||||
h.Logout(w, r)
|
||||
return
|
||||
}
|
||||
case http.MethodGet:
|
||||
if r.URL.Path == "/api/auth/me" {
|
||||
h.GetMe(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func generateID() string {
|
||||
// 生成8位随机ID
|
||||
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, 8)
|
||||
for i := range b {
|
||||
|
||||
@@ -18,15 +18,16 @@ func GetConfigFilePath() string {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ListenAddr string `json:"listen_addr"`
|
||||
AdminPath string `json:"admin_path"`
|
||||
AdminUser string `json:"admin_user"`
|
||||
AdminPass string `json:"admin_pass"`
|
||||
DockerHubHost string `json:"docker_hub_host"`
|
||||
GHCRCacheEnabled bool `json:"ghcr_cache_enabled"`
|
||||
CacheDir string `json:"cache_dir"`
|
||||
MaxCacheSize int64 `json:"max_cache_size"`
|
||||
Links map[string]*Link `json:"links"`
|
||||
ListenAddr string `json:"listen_addr"`
|
||||
AdminPath string `json:"admin_path"`
|
||||
AdminUser string `json:"admin_user"`
|
||||
AdminPass string `json:"admin_pass"`
|
||||
SessionTimeout int `json:"session_timeout"` // hours
|
||||
DockerHubHost string `json:"docker_hub_host"`
|
||||
GHCRCacheEnabled bool `json:"ghcr_cache_enabled"`
|
||||
CacheDir string `json:"cache_dir"`
|
||||
MaxCacheSize int64 `json:"max_cache_size"`
|
||||
Links map[string]*Link `json:"links"`
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
@@ -74,6 +75,7 @@ func defaultConfig() *Config {
|
||||
AdminPath: "/admin",
|
||||
AdminUser: "admin",
|
||||
AdminPass: "admin123",
|
||||
SessionTimeout: 24,
|
||||
DockerHubHost: "registry-1.docker.io",
|
||||
GHCRCacheEnabled: true,
|
||||
CacheDir: "./cache",
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mirror-proxy/internal/admin"
|
||||
)
|
||||
|
||||
// CORS 跨域中间件
|
||||
@@ -45,21 +47,42 @@ func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// BasicAuth 基础认证中间件
|
||||
func BasicAuth(username, password string) func(http.Handler) http.Handler {
|
||||
// SessionAuth 基于 Cookie Session 的认证中间件
|
||||
func SessionAuth(sessions *admin.SessionStore, loginPath string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != username || pass != password {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Admin Panel"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
cookie, err := r.Cookie("session")
|
||||
if err != nil {
|
||||
unauthorized(w, r, loginPath)
|
||||
return
|
||||
}
|
||||
|
||||
_, ok := sessions.Get(cookie.Value)
|
||||
if !ok {
|
||||
unauthorized(w, r, loginPath)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func unauthorized(w http.ResponseWriter, r *http.Request, loginPath string) {
|
||||
accept := r.Header.Get("Accept")
|
||||
isAPI := strings.Contains(r.URL.Path, "/api/") ||
|
||||
strings.Contains(accept, "application/json")
|
||||
|
||||
if isAPI {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"unauthorized"}`))
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, loginPath, http.StatusFound)
|
||||
}
|
||||
|
||||
// StripPrefix 安全地移除路径前缀
|
||||
func StripPrefix(prefix string, h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user