feat: Web后台登录认证
- 新增登录页面 (templates/login.html) - HMAC-SHA256 cookie 认证中间件 - 所有页面和API需登录访问 - /health 保持公开 - 首页右上角退出按钮 - 默认账号 admin/admin123 - Cookie 有效期7天 - 版本升级至 v1.1.0
This commit is contained in:
2
Makefile
2
Makefile
@@ -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) \
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
@@ -16,10 +20,51 @@ type WebServer struct {
|
|||||||
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})
|
||||||
|
|||||||
@@ -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
126
templates/login.html
Normal 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>
|
||||||
Reference in New Issue
Block a user