feat: Web后台登录认证

- 新增登录页面 (templates/login.html)
- HMAC-SHA256 cookie 认证中间件
- 所有页面和API需登录访问
- /health 保持公开
- 首页右上角退出按钮
- 默认账号 admin/admin123
- Cookie 有效期7天
- 版本升级至 v1.1.0
This commit is contained in:
2026-02-16 16:44:42 +08:00
parent bac7a7b708
commit 52b0d742a7
4 changed files with 236 additions and 14 deletions

View File

@@ -1,5 +1,5 @@
APP_NAME := xiaji-go APP_NAME := xiaji-go
VERSION := 1.0.0 VERSION := 1.1.0
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
LDFLAGS := -X xiaji-go/version.Version=$(VERSION) \ LDFLAGS := -X xiaji-go/version.Version=$(VERSION) \

View File

@@ -1,9 +1,13 @@
package web package web
import ( import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"time"
"xiaji-go/models" "xiaji-go/models"
@@ -12,14 +16,55 @@ import (
) )
type WebServer struct { type WebServer struct {
db *gorm.DB db *gorm.DB
port int port int
username string username string
password string password string
secretKey string
} }
func NewWebServer(db *gorm.DB, port int, username, password string) *WebServer { 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() { func (s *WebServer) Start() {
@@ -27,17 +72,26 @@ func (s *WebServer) Start() {
r := gin.Default() r := gin.Default()
r.LoadHTMLGlob("templates/*") r.LoadHTMLGlob("templates/*")
// 页面 // 公开路由(无需登录)
r.GET("/", s.handleIndex) r.GET("/login", s.handleLoginPage)
r.GET("/api/records", s.handleRecords) r.POST("/login", s.handleLogin)
r.POST("/delete/:id", s.handleDelete) r.GET("/logout", s.handleLogout)
r.GET("/export", s.handleExport)
// 健康检查 // 健康检查(公开)
r.GET("/health", func(c *gin.Context) { r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"}) 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) logAddr := fmt.Sprintf(":%d", s.port)
fmt.Printf("🌐 Web后台运行在 http://127.0.0.1%s\n", logAddr) fmt.Printf("🌐 Web后台运行在 http://127.0.0.1%s\n", logAddr)
if err := r.Run(logAddr); err != nil { 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) { 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) { func (s *WebServer) handleRecords(c *gin.Context) {
@@ -102,8 +191,11 @@ func (s *WebServer) handleExport(c *gin.Context) {
var items []models.Transaction var items []models.Transaction
s.db.Where("is_deleted = ?", false).Order("date asc, id asc").Find(&items) 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-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 // BOM for Excel
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF}) c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})

View File

@@ -11,6 +11,9 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-
.header { background: linear-gradient(135deg, #ff6b6b, #ee5a24); color: #fff; padding: 20px; text-align: center; position: sticky; top: 0; z-index: 100; box-shadow: 0 2px 10px rgba(0,0,0,.15); } .header { background: linear-gradient(135deg, #ff6b6b, #ee5a24); color: #fff; padding: 20px; text-align: center; position: sticky; top: 0; z-index: 100; box-shadow: 0 2px 10px rgba(0,0,0,.15); }
.header h1 { font-size: 24px; margin-bottom: 4px; } .header h1 { font-size: 24px; margin-bottom: 4px; }
.header .subtitle { font-size: 13px; opacity: .85; } .header .subtitle { font-size: 13px; opacity: .85; }
.header { position: relative; }
.logout-btn { position: absolute; right: 16px; top: 50%; transform: translateY(-50%); background: rgba(255,255,255,.2); color: #fff; text-decoration: none; padding: 5px 14px; border-radius: 6px; font-size: 13px; transition: background .2s; }
.logout-btn:hover { background: rgba(255,255,255,.35); }
.stats { display: flex; gap: 10px; padding: 15px; overflow-x: auto; } .stats { display: flex; gap: 10px; padding: 15px; overflow-x: auto; }
.stat-card { flex: 1; min-width: 120px; background: #fff; border-radius: 12px; padding: 15px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.06); } .stat-card { flex: 1; min-width: 120px; background: #fff; border-radius: 12px; padding: 15px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.06); }
@@ -67,6 +70,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-
<div class="header"> <div class="header">
<h1>🦞 虾记记账</h1> <h1>🦞 虾记记账</h1>
<div class="subtitle">Xiaji-Go 记账管理</div> <div class="subtitle">Xiaji-Go 记账管理</div>
<a href="/logout" class="logout-btn" title="退出登录">退出</a>
</div> </div>
<div class="stats"> <div class="stats">

126
templates/login.html Normal file
View File

@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🦞 虾记 - 登录</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, #ff6b6b 0%, #ee5a24 50%, #f39c12 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-card {
background: #fff;
border-radius: 20px;
padding: 40px 32px;
width: 100%;
max-width: 380px;
box-shadow: 0 20px 60px rgba(0,0,0,.2);
animation: slideUp .4s ease;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
.login-logo {
text-align: center;
margin-bottom: 28px;
}
.login-logo .icon { font-size: 52px; }
.login-logo h1 {
font-size: 22px;
color: #333;
margin-top: 8px;
}
.login-logo .subtitle {
font-size: 13px;
color: #999;
margin-top: 4px;
}
.form-group {
margin-bottom: 18px;
}
.form-group label {
display: block;
font-size: 13px;
color: #666;
margin-bottom: 6px;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 12px 16px;
border: 2px solid #eee;
border-radius: 10px;
font-size: 15px;
transition: border-color .2s;
outline: none;
}
.form-group input:focus {
border-color: #ee5a24;
}
.btn-login {
width: 100%;
padding: 13px;
background: linear-gradient(135deg, #ff6b6b, #ee5a24);
color: #fff;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: opacity .2s, transform .1s;
margin-top: 6px;
}
.btn-login:hover { opacity: .9; }
.btn-login:active { transform: scale(.98); }
.error-msg {
background: #fff2f0;
color: #e74c3c;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 16px;
text-align: center;
border: 1px solid #fde2e0;
}
</style>
</head>
<body>
<div class="login-card">
<div class="login-logo">
<div class="icon">🦞</div>
<h1>虾记记账</h1>
<div class="subtitle">Xiaji-Go 管理后台</div>
</div>
{{if .error}}
<div class="error-msg">{{.error}}</div>
{{end}}
<form method="POST" action="/login">
<div class="form-group">
<label>用户名</label>
<input type="text" name="username" placeholder="请输入用户名" autocomplete="username" autofocus required>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" name="password" placeholder="请输入密码" autocomplete="current-password" required>
</div>
<button type="submit" class="btn-login">登 录</button>
</form>
</div>
</body>
</html>