feat: Web后台登录认证
- 新增登录页面 (templates/login.html) - HMAC-SHA256 cookie 认证中间件 - 所有页面和API需登录访问 - /health 保持公开 - 首页右上角退出按钮 - 默认账号 admin/admin123 - Cookie 有效期7天 - 版本升级至 v1.1.0
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"xiaji-go/models"
|
||||
|
||||
@@ -12,14 +16,55 @@ import (
|
||||
)
|
||||
|
||||
type WebServer struct {
|
||||
db *gorm.DB
|
||||
port int
|
||||
username string
|
||||
password string
|
||||
db *gorm.DB
|
||||
port int
|
||||
username string
|
||||
password string
|
||||
secretKey string
|
||||
}
|
||||
|
||||
func NewWebServer(db *gorm.DB, port int, username, password string) *WebServer {
|
||||
return &WebServer{db: db, port: port, username: username, password: password}
|
||||
return &WebServer{
|
||||
db: db,
|
||||
port: port,
|
||||
username: username,
|
||||
password: password,
|
||||
secretKey: "xiaji-go-session-" + password, // 简单派生
|
||||
}
|
||||
}
|
||||
|
||||
// generateToken 生成登录 token
|
||||
func (s *WebServer) generateToken(username string) string {
|
||||
mac := hmac.New(sha256.New, []byte(s.secretKey))
|
||||
mac.Write([]byte(username))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// validateToken 验证 token
|
||||
func (s *WebServer) validateToken(username, token string) bool {
|
||||
expected := s.generateToken(username)
|
||||
return hmac.Equal([]byte(expected), []byte(token))
|
||||
}
|
||||
|
||||
// authRequired 登录认证中间件
|
||||
func (s *WebServer) authRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
username, _ := c.Cookie("xiaji_user")
|
||||
token, _ := c.Cookie("xiaji_token")
|
||||
|
||||
if username == "" || token == "" || !s.validateToken(username, token) {
|
||||
// 判断是 API 请求还是页面请求
|
||||
path := c.Request.URL.Path
|
||||
if len(path) >= 4 && path[:4] == "/api" || c.Request.Method == "POST" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
} else {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
}
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebServer) Start() {
|
||||
@@ -27,17 +72,26 @@ func (s *WebServer) Start() {
|
||||
r := gin.Default()
|
||||
r.LoadHTMLGlob("templates/*")
|
||||
|
||||
// 页面
|
||||
r.GET("/", s.handleIndex)
|
||||
r.GET("/api/records", s.handleRecords)
|
||||
r.POST("/delete/:id", s.handleDelete)
|
||||
r.GET("/export", s.handleExport)
|
||||
// 公开路由(无需登录)
|
||||
r.GET("/login", s.handleLoginPage)
|
||||
r.POST("/login", s.handleLogin)
|
||||
r.GET("/logout", s.handleLogout)
|
||||
|
||||
// 健康检查
|
||||
// 健康检查(公开)
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// 需要登录的路由
|
||||
auth := r.Group("/")
|
||||
auth.Use(s.authRequired())
|
||||
{
|
||||
auth.GET("/", s.handleIndex)
|
||||
auth.GET("/api/records", s.handleRecords)
|
||||
auth.POST("/delete/:id", s.handleDelete)
|
||||
auth.GET("/export", s.handleExport)
|
||||
}
|
||||
|
||||
logAddr := fmt.Sprintf(":%d", s.port)
|
||||
fmt.Printf("🌐 Web后台运行在 http://127.0.0.1%s\n", logAddr)
|
||||
if err := r.Run(logAddr); err != nil {
|
||||
@@ -45,8 +99,43 @@ func (s *WebServer) Start() {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebServer) handleLoginPage(c *gin.Context) {
|
||||
// 已登录则跳转首页
|
||||
username, _ := c.Cookie("xiaji_user")
|
||||
token, _ := c.Cookie("xiaji_token")
|
||||
if username != "" && token != "" && s.validateToken(username, token) {
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
return
|
||||
}
|
||||
c.HTML(http.StatusOK, "login.html", gin.H{"error": ""})
|
||||
}
|
||||
|
||||
func (s *WebServer) handleLogin(c *gin.Context) {
|
||||
username := c.PostForm("username")
|
||||
password := c.PostForm("password")
|
||||
|
||||
if username == s.username && password == s.password {
|
||||
token := s.generateToken(username)
|
||||
maxAge := 7 * 24 * 3600 // 7天
|
||||
|
||||
c.SetCookie("xiaji_user", username, maxAge, "/", "", false, true)
|
||||
c.SetCookie("xiaji_token", token, maxAge, "/", "", false, true)
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "login.html", gin.H{"error": "用户名或密码错误"})
|
||||
}
|
||||
|
||||
func (s *WebServer) handleLogout(c *gin.Context) {
|
||||
c.SetCookie("xiaji_user", "", -1, "/", "", false, true)
|
||||
c.SetCookie("xiaji_token", "", -1, "/", "", false, true)
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
}
|
||||
|
||||
func (s *WebServer) handleIndex(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "index.html", nil)
|
||||
username, _ := c.Cookie("xiaji_user")
|
||||
c.HTML(http.StatusOK, "index.html", gin.H{"username": username})
|
||||
}
|
||||
|
||||
func (s *WebServer) handleRecords(c *gin.Context) {
|
||||
@@ -102,8 +191,11 @@ func (s *WebServer) handleExport(c *gin.Context) {
|
||||
var items []models.Transaction
|
||||
s.db.Where("is_deleted = ?", false).Order("date asc, id asc").Find(&items)
|
||||
|
||||
now := time.Now().Format("20060102")
|
||||
filename := fmt.Sprintf("xiaji_%s.csv", now)
|
||||
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", "attachment; filename=transactions.csv")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
|
||||
// BOM for Excel
|
||||
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||
|
||||
Reference in New Issue
Block a user