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:
+14
-3
@@ -107,11 +107,22 @@ func buildHandler(cfg *config.Config) (*admin.Handler, http.Handler) {
|
|||||||
adminPath = "/admin"
|
adminPath = "/admin"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 管理后台
|
adminAuth := middleware.SessionAuth(adminHandler.SessionStore(), adminPath+"/login")
|
||||||
adminAuth := middleware.BasicAuth(cfg.AdminUser, cfg.AdminPass)
|
|
||||||
|
// 登录页面(免认证)
|
||||||
|
mux.HandleFunc(adminPath+"/login", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.ServeFile(w, r, "web/static/login.html")
|
||||||
|
})
|
||||||
|
|
||||||
|
// 管理后台(需要 session 认证)
|
||||||
mux.Handle(adminPath+"/", adminAuth(http.StripPrefix(adminPath, http.FileServer(http.Dir("web/static")))))
|
mux.Handle(adminPath+"/", adminAuth(http.StripPrefix(adminPath, http.FileServer(http.Dir("web/static")))))
|
||||||
|
|
||||||
// 管理API
|
// 认证 API(login 免认证,其他需要认证)
|
||||||
|
mux.HandleFunc("/api/auth/login", adminHandler.Login)
|
||||||
|
mux.HandleFunc("/api/auth/logout", adminAuth(http.HandlerFunc(adminHandler.Logout)).ServeHTTP)
|
||||||
|
mux.HandleFunc("/api/auth/me", adminAuth(http.HandlerFunc(adminHandler.GetMe)).ServeHTTP)
|
||||||
|
|
||||||
|
// 管理 API(需要认证)
|
||||||
mux.HandleFunc("/api/links", adminAuth(http.HandlerFunc(adminHandler.LinksHandler)).ServeHTTP)
|
mux.HandleFunc("/api/links", adminAuth(http.HandlerFunc(adminHandler.LinksHandler)).ServeHTTP)
|
||||||
mux.HandleFunc("/api/links/", adminAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/links/", adminAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if strings.HasSuffix(r.URL.Path, "/token") {
|
if strings.HasSuffix(r.URL.Path, "/token") {
|
||||||
|
|||||||
+197
-27
@@ -2,10 +2,12 @@ package admin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"mirror-proxy/internal/auth"
|
"mirror-proxy/internal/auth"
|
||||||
@@ -13,26 +15,181 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
onRestart func()
|
onRestart func()
|
||||||
|
sessions *SessionStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(cfg *config.Config) *Handler {
|
func NewHandler(cfg *config.Config) *Handler {
|
||||||
return &Handler{cfg: cfg}
|
return &Handler{
|
||||||
|
cfg: cfg,
|
||||||
|
sessions: newSessionStore(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) SetOnRestart(fn func()) {
|
func (h *Handler) SetOnRestart(fn func()) {
|
||||||
h.onRestart = fn
|
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) {
|
func (h *Handler) GetLinks(w http.ResponseWriter, r *http.Request) {
|
||||||
links := h.cfg.ListLinks()
|
links := h.cfg.ListLinks()
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(links)
|
json.NewEncoder(w).Encode(links)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateLink 创建新链接
|
|
||||||
func (h *Handler) CreateLink(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) CreateLink(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -77,7 +234,6 @@ func (h *Handler) CreateLink(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(link)
|
json.NewEncoder(w).Encode(link)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateLink 更新链接
|
|
||||||
func (h *Handler) UpdateLink(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) UpdateLink(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.URL.Path[len("/api/links/"):]
|
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)
|
json.NewEncoder(w).Encode(link)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteLink 删除链接
|
|
||||||
func (h *Handler) DeleteLink(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) DeleteLink(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.URL.Path[len("/api/links/"):]
|
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)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegenerateToken 重新生成token
|
|
||||||
func (h *Handler) RegenerateToken(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) RegenerateToken(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.URL.Path[len("/api/links/"):len(r.URL.Path)-len("/token")]
|
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})
|
json.NewEncoder(w).Encode(map[string]string{"token": link.Token})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStats 获取统计信息
|
|
||||||
func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
|
||||||
links := h.cfg.ListLinks()
|
links := h.cfg.ListLinks()
|
||||||
stats := make(map[string]interface{})
|
stats := make(map[string]interface{})
|
||||||
@@ -177,26 +330,24 @@ func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(stats)
|
json.NewEncoder(w).Encode(stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetConfig 获取配置
|
|
||||||
func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"listen_addr": h.cfg.ListenAddr,
|
"admin_path": h.cfg.AdminPath,
|
||||||
"admin_path": h.cfg.AdminPath,
|
"admin_user": h.cfg.AdminUser,
|
||||||
"admin_user": h.cfg.AdminUser,
|
"session_timeout": h.cfg.SessionTimeout,
|
||||||
"docker_enabled": true,
|
"docker_enabled": true,
|
||||||
"ghcr_enabled": true,
|
"ghcr_enabled": true,
|
||||||
"github_enabled": true,
|
"github_enabled": true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateConfig 更新系统配置
|
|
||||||
func (h *Handler) UpdateConfig(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) UpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
ListenAddr string `json:"listen_addr"`
|
AdminPath string `json:"admin_path"`
|
||||||
AdminPath string `json:"admin_path"`
|
AdminUser string `json:"admin_user"`
|
||||||
AdminUser string `json:"admin_user"`
|
AdminPass string `json:"admin_pass"`
|
||||||
AdminPass string `json:"admin_pass"`
|
SessionTimeout int `json:"session_timeout"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
restartRequired := false
|
||||||
|
|
||||||
if req.ListenAddr != "" {
|
|
||||||
h.cfg.ListenAddr = req.ListenAddr
|
|
||||||
restartRequired = true
|
|
||||||
}
|
|
||||||
if req.AdminPath != "" {
|
if req.AdminPath != "" {
|
||||||
if !strings.HasPrefix(req.AdminPath, "/") {
|
if !strings.HasPrefix(req.AdminPath, "/") {
|
||||||
req.AdminPath = "/" + req.AdminPath
|
req.AdminPath = "/" + req.AdminPath
|
||||||
@@ -225,6 +372,9 @@ func (h *Handler) UpdateConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.AdminPass != "" {
|
if req.AdminPass != "" {
|
||||||
h.cfg.AdminPass = 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 {
|
if err := h.cfg.Save(config.GetConfigFilePath()); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"listen_addr": h.cfg.ListenAddr,
|
|
||||||
"admin_path": h.cfg.AdminPath,
|
"admin_path": h.cfg.AdminPath,
|
||||||
"admin_user": h.cfg.AdminUser,
|
"admin_user": h.cfg.AdminUser,
|
||||||
|
"session_timeout": h.cfg.SessionTimeout,
|
||||||
"restart_required": restartRequired,
|
"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)
|
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 {
|
func generateID() string {
|
||||||
// 生成8位随机ID
|
|
||||||
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
b := make([]byte, 8)
|
b := make([]byte, 8)
|
||||||
for i := range b {
|
for i := range b {
|
||||||
|
|||||||
@@ -18,15 +18,16 @@ func GetConfigFilePath() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ListenAddr string `json:"listen_addr"`
|
ListenAddr string `json:"listen_addr"`
|
||||||
AdminPath string `json:"admin_path"`
|
AdminPath string `json:"admin_path"`
|
||||||
AdminUser string `json:"admin_user"`
|
AdminUser string `json:"admin_user"`
|
||||||
AdminPass string `json:"admin_pass"`
|
AdminPass string `json:"admin_pass"`
|
||||||
DockerHubHost string `json:"docker_hub_host"`
|
SessionTimeout int `json:"session_timeout"` // hours
|
||||||
GHCRCacheEnabled bool `json:"ghcr_cache_enabled"`
|
DockerHubHost string `json:"docker_hub_host"`
|
||||||
CacheDir string `json:"cache_dir"`
|
GHCRCacheEnabled bool `json:"ghcr_cache_enabled"`
|
||||||
MaxCacheSize int64 `json:"max_cache_size"`
|
CacheDir string `json:"cache_dir"`
|
||||||
Links map[string]*Link `json:"links"`
|
MaxCacheSize int64 `json:"max_cache_size"`
|
||||||
|
Links map[string]*Link `json:"links"`
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ func defaultConfig() *Config {
|
|||||||
AdminPath: "/admin",
|
AdminPath: "/admin",
|
||||||
AdminUser: "admin",
|
AdminUser: "admin",
|
||||||
AdminPass: "admin123",
|
AdminPass: "admin123",
|
||||||
|
SessionTimeout: 24,
|
||||||
DockerHubHost: "registry-1.docker.io",
|
DockerHubHost: "registry-1.docker.io",
|
||||||
GHCRCacheEnabled: true,
|
GHCRCacheEnabled: true,
|
||||||
CacheDir: "./cache",
|
CacheDir: "./cache",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mirror-proxy/internal/admin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CORS 跨域中间件
|
// CORS 跨域中间件
|
||||||
@@ -45,21 +47,42 @@ func (rw *responseWriter) WriteHeader(code int) {
|
|||||||
rw.ResponseWriter.WriteHeader(code)
|
rw.ResponseWriter.WriteHeader(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BasicAuth 基础认证中间件
|
// SessionAuth 基于 Cookie Session 的认证中间件
|
||||||
func BasicAuth(username, password string) func(http.Handler) http.Handler {
|
func SessionAuth(sessions *admin.SessionStore, loginPath string) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, pass, ok := r.BasicAuth()
|
cookie, err := r.Cookie("session")
|
||||||
if !ok || user != username || pass != password {
|
if err != nil {
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="Admin Panel"`)
|
unauthorized(w, r, loginPath)
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, ok := sessions.Get(cookie.Value)
|
||||||
|
if !ok {
|
||||||
|
unauthorized(w, r, loginPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
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 安全地移除路径前缀
|
// StripPrefix 安全地移除路径前缀
|
||||||
func StripPrefix(prefix string, h http.Handler) http.Handler {
|
func StripPrefix(prefix string, h http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
+65
-18
@@ -19,6 +19,29 @@
|
|||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
}
|
}
|
||||||
header h1 { font-size: 24px; }
|
header h1 { font-size: 24px; }
|
||||||
|
.user-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.user-bar .username {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
.user-bar .btn-logout {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255,255,255,0.3);
|
||||||
|
color: white;
|
||||||
|
padding: 5px 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.user-bar .btn-logout:hover {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border-color: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
.stats {
|
.stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
@@ -222,7 +245,13 @@
|
|||||||
<header>
|
<header>
|
||||||
<div class="container" style="display:flex;justify-content:space-between;align-items:center;">
|
<div class="container" style="display:flex;justify-content:space-between;align-items:center;">
|
||||||
<h1>Mirror Proxy - 镜像代理管理后台</h1>
|
<h1>Mirror Proxy - 镜像代理管理后台</h1>
|
||||||
<button class="btn btn-primary" onclick="openSettingsModal()">⚙️ 系统设置</button>
|
<div style="display:flex;align-items:center;gap:15px;">
|
||||||
|
<div class="user-bar" id="userBar" style="display:none;">
|
||||||
|
<span class="username" id="currentUser">admin</span>
|
||||||
|
<button class="btn-logout" onclick="doLogout()">退出登录</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="openSettingsModal()">系统设置</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -332,10 +361,6 @@
|
|||||||
<div class="modal" id="settingsModal">
|
<div class="modal" id="settingsModal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h2 style="margin-bottom: 20px;">系统设置</h2>
|
<h2 style="margin-bottom: 20px;">系统设置</h2>
|
||||||
<div class="form-group">
|
|
||||||
<label>监听地址</label>
|
|
||||||
<input type="text" id="cfgListenAddr" placeholder=":8080">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>管理后台路径</label>
|
<label>管理后台路径</label>
|
||||||
<input type="text" id="cfgAdminPath" placeholder="/admin">
|
<input type="text" id="cfgAdminPath" placeholder="/admin">
|
||||||
@@ -348,6 +373,10 @@
|
|||||||
<label>管理员密码(留空表示不修改)</label>
|
<label>管理员密码(留空表示不修改)</label>
|
||||||
<input type="password" id="cfgAdminPass" placeholder="不修改请留空">
|
<input type="password" id="cfgAdminPass" placeholder="不修改请留空">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Session 有效期(小时)</label>
|
||||||
|
<input type="number" id="cfgSessionTimeout" value="24" min="1" max="720">
|
||||||
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn" onclick="closeSettingsModal()" style="background: #6c757d; color: white;">取消</button>
|
<button class="btn" onclick="closeSettingsModal()" style="background: #6c757d; color: white;">取消</button>
|
||||||
<button class="btn btn-primary" onclick="saveConfig()">保存</button>
|
<button class="btn btn-primary" onclick="saveConfig()">保存</button>
|
||||||
@@ -359,6 +388,28 @@
|
|||||||
let links = [];
|
let links = [];
|
||||||
let currentConfig = {};
|
let currentConfig = {};
|
||||||
|
|
||||||
|
async function checkAuth() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/me');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById('currentUser').textContent = data.username;
|
||||||
|
document.getElementById('userBar').style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
window.location.href = 'login';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
window.location.href = 'login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogout() {
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
|
} catch (e) {}
|
||||||
|
window.location.href = 'login';
|
||||||
|
}
|
||||||
|
|
||||||
async function loadLinks() {
|
async function loadLinks() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/links');
|
const res = await fetch('/api/links');
|
||||||
@@ -615,10 +666,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openSettingsModal() {
|
function openSettingsModal() {
|
||||||
document.getElementById('cfgListenAddr').value = currentConfig.listen_addr || ':8080';
|
|
||||||
document.getElementById('cfgAdminPath').value = currentConfig.admin_path || '/admin';
|
document.getElementById('cfgAdminPath').value = currentConfig.admin_path || '/admin';
|
||||||
document.getElementById('cfgAdminUser').value = currentConfig.admin_user || 'admin';
|
document.getElementById('cfgAdminUser').value = currentConfig.admin_user || 'admin';
|
||||||
document.getElementById('cfgAdminPass').value = '';
|
document.getElementById('cfgAdminPass').value = '';
|
||||||
|
document.getElementById('cfgSessionTimeout').value = currentConfig.session_timeout || 24;
|
||||||
document.getElementById('settingsModal').classList.add('active');
|
document.getElementById('settingsModal').classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,18 +678,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveConfig() {
|
async function saveConfig() {
|
||||||
const listenAddr = document.getElementById('cfgListenAddr').value.trim();
|
|
||||||
const adminPath = document.getElementById('cfgAdminPath').value.trim();
|
const adminPath = document.getElementById('cfgAdminPath').value.trim();
|
||||||
const adminUser = document.getElementById('cfgAdminUser').value.trim();
|
const adminUser = document.getElementById('cfgAdminUser').value.trim();
|
||||||
const adminPass = document.getElementById('cfgAdminPass').value;
|
const adminPass = document.getElementById('cfgAdminPass').value;
|
||||||
|
const sessionTimeout = parseInt(document.getElementById('cfgSessionTimeout').value) || 24;
|
||||||
|
|
||||||
if (!listenAddr || !adminPath || !adminUser) {
|
if (!adminPath || !adminUser) {
|
||||||
showToast('请填写完整信息', 'error');
|
showToast('请填写完整信息', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = { listen_addr: listenAddr, admin_path: adminPath, admin_user: adminUser };
|
const body = { admin_path: adminPath, admin_user: adminUser, session_timeout: sessionTimeout };
|
||||||
if (adminPass) body.admin_pass = adminPass;
|
if (adminPass) body.admin_pass = adminPass;
|
||||||
|
|
||||||
const res = await fetch('/api/config', {
|
const res = await fetch('/api/config', {
|
||||||
@@ -654,13 +705,7 @@
|
|||||||
showToast('配置已保存,服务正在应用新配置');
|
showToast('配置已保存,服务正在应用新配置');
|
||||||
closeSettingsModal();
|
closeSettingsModal();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const newPort = data.listen_addr.includes(':') ? data.listen_addr.split(':').pop() : '';
|
window.location.href = data.admin_path + '/';
|
||||||
const currentPort = window.location.port || (window.location.protocol === 'https:' ? '443' : '80');
|
|
||||||
if (newPort && newPort !== currentPort) {
|
|
||||||
showToast('服务已在新端口启动,请使用新地址访问', 'warning');
|
|
||||||
} else {
|
|
||||||
window.location.href = data.admin_path + '/';
|
|
||||||
}
|
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} else {
|
} else {
|
||||||
showToast('保存成功');
|
showToast('保存成功');
|
||||||
@@ -684,8 +729,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 加载数据
|
// 加载数据
|
||||||
loadConfig();
|
checkAuth().then(() => {
|
||||||
loadLinks();
|
loadConfig();
|
||||||
|
loadLinks();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>登录 - Mirror Proxy</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.login-card h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.login-card .subtitle {
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 15px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #0056b3; }
|
||||||
|
.btn-primary:active { transform: scale(0.98); }
|
||||||
|
.btn:disabled {
|
||||||
|
background: #6c757d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.error.active { display: block; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-card">
|
||||||
|
<h1>Mirror Proxy</h1>
|
||||||
|
<p class="subtitle">管理后台登录</p>
|
||||||
|
<div class="error" id="errorMsg"></div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input type="text" id="username" placeholder="请输入用户名" autocomplete="username" autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>密码</label>
|
||||||
|
<input type="password" id="password" placeholder="请输入密码" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" id="loginBtn" onclick="doLogin()">登录</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function checkAuth() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/me', { credentials: 'same-origin' });
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.href = '../';
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
checkAuth();
|
||||||
|
|
||||||
|
async function doLogin() {
|
||||||
|
const username = document.getElementById('username').value.trim();
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const errorEl = document.getElementById('errorMsg');
|
||||||
|
const btn = document.getElementById('loginBtn');
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
errorEl.textContent = '请输入用户名和密码';
|
||||||
|
errorEl.classList.add('active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.textContent = '登录中...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.href = '../';
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
errorEl.textContent = data.error || '用户名或密码错误';
|
||||||
|
errorEl.classList.add('active');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorEl.textContent = '登录失败: ' + e.message;
|
||||||
|
errorEl.classList.add('active');
|
||||||
|
} finally {
|
||||||
|
btn.textContent = '登录';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('password').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') doLogin();
|
||||||
|
});
|
||||||
|
document.getElementById('username').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') document.getElementById('password').focus();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user