feat: 新增登录功能

This commit is contained in:
yarnom 2025-11-19 17:34:42 +08:00
parent f3dabb887e
commit e2878477de
9 changed files with 282 additions and 1 deletions

View File

@ -18,6 +18,7 @@ func main() {
TemplateDir: cfg.TemplateDir,
StaticDir: cfg.StaticDir,
EnableCORS: cfg.DevEnableCORS,
AuthSecret: cfg.AuthSecret,
})
addr := cfg.Addr

View File

@ -0,0 +1,74 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// HashPassword returns a bcrypt hash for the given plain text password.
func HashPassword(password string) (string, error) {
if password == "" {
return "", errors.New("empty password")
}
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(b), nil
}
// CheckPassword verifies a bcrypt hash against the given plain text password.
func CheckPassword(hash, password string) bool {
if hash == "" || password == "" {
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// MakeSessionToken builds a simple HMAC-signed token: username|expUnix|sigHex
func MakeSessionToken(username string, ttl time.Duration, secret []byte) (string, time.Time) {
if ttl <= 0 {
ttl = 24 * time.Hour
}
exp := time.Now().Add(ttl).UTC()
payload := username + "|" + strconv.FormatInt(exp.Unix(), 10)
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(payload))
sig := hex.EncodeToString(mac.Sum(nil))
return payload + "|" + sig, exp
}
// ParseSessionToken validates token and returns username if valid.
func ParseSessionToken(token string, secret []byte) (string, bool) {
parts := strings.Split(token, "|")
if len(parts) != 3 {
return "", false
}
username := parts[0]
expStr := parts[1]
sigHex := parts[2]
// recompute
payload := fmt.Sprintf("%s|%s", username, expStr)
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(payload))
if hex.EncodeToString(mac.Sum(nil)) != sigHex {
return "", false
}
// expiry check
expUnix, err := strconv.ParseInt(expStr, 10, 64)
if err != nil {
return "", false
}
if time.Now().UTC().After(time.Unix(expUnix, 0)) {
return "", false
}
return username, true
}

View File

@ -0,0 +1,26 @@
package auth
import (
"os"
"testing"
)
// TestGenerateHash helps generate and verify password hashes.
// Usage:
//
// PW="your-password" go test ./core/internal/auth -run TestGenerateHash -v
func TestGenerateHash(t *testing.T) {
pw := os.Getenv("PW")
if pw == "" {
t.Skip("set PW env to generate a hash")
return
}
hash, err := HashPassword(pw)
if err != nil {
t.Fatalf("HashPassword error: %v", err)
}
if !CheckPassword(hash, pw) {
t.Fatalf("CheckPassword failed for generated hash")
}
t.Logf("password hash: %s", hash)
}

View File

@ -19,6 +19,7 @@ type Config struct {
DevEnableCORS bool
Legacy *legacy.Config
SMS SmsConfig
AuthSecret string
}
// SmsConfig holds Aliyun SMS settings loaded from config/env.
@ -47,6 +48,7 @@ func Load() Config {
DevEnableCORS: true,
Legacy: lg,
SMS: SmsConfig{},
AuthSecret: "change-me-dev", // default dev secret; override with CORE_AUTH_SECRET
}
// Try load SMS from YAML (same search order as legacy)
@ -121,6 +123,11 @@ func Load() Config {
}
}
// Auth secret; do not log value
if v := os.Getenv("CORE_AUTH_SECRET"); v != "" {
cfg.AuthSecret = v
}
// SMS settings (do not log secrets)
if v := os.Getenv("CORE_SMS_AK"); v != "" {
cfg.SMS.AccessKeyID = v
@ -138,6 +145,6 @@ func Load() Config {
cfg.SMS.Endpoint = v
}
log.Printf("config: addr=%s ui=%s bigscreen=%s tpl=%s static=%s cors=%v sms.sign=%v sms.tpl=%v", cfg.Addr, cfg.UIServeDir, cfg.BigscreenDir, cfg.TemplateDir, cfg.StaticDir, cfg.DevEnableCORS, cfg.SMS.SignName != "", cfg.SMS.TemplateCode != "")
log.Printf("config: addr=%s ui=%s bigscreen=%s tpl=%s static=%s cors=%v sms.sign=%v sms.tpl=%v auth.secret=%v", cfg.Addr, cfg.UIServeDir, cfg.BigscreenDir, cfg.TemplateDir, cfg.StaticDir, cfg.DevEnableCORS, cfg.SMS.SignName != "", cfg.SMS.TemplateCode != "", cfg.AuthSecret != "")
return cfg
}

View File

@ -0,0 +1,32 @@
package data
import (
"database/sql"
"errors"
"time"
)
type User struct {
Username string
Password string // bcrypt hash
CreatedAt time.Time
}
func GetUser(username string) (*User, error) {
const q = `SELECT username, password, created_at FROM users WHERE username = $1`
var u User
err := DB().QueryRow(q, username).Scan(&u.Username, &u.Password, &u.CreatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &u, nil
}
func CreateUser(username, passwordHash string) error {
const q = `INSERT INTO users (username, password, created_at) VALUES ($1, $2, NOW()) ON CONFLICT (username) DO NOTHING`
_, err := DB().Exec(q, username, passwordHash)
return err
}

View File

@ -0,0 +1,51 @@
package server
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"weatherstation/core/internal/auth"
"weatherstation/core/internal/data"
)
func handleLogin(opts Options) gin.HandlerFunc {
secret := []byte(opts.AuthSecret)
return func(c *gin.Context) {
username := c.PostForm("username")
password := c.PostForm("password")
if username == "" || password == "" {
c.String(http.StatusBadRequest, "missing username or password")
return
}
u, err := data.GetUser(username)
if err != nil {
c.String(http.StatusInternalServerError, "login error")
return
}
if u == nil || !auth.CheckPassword(u.Password, password) {
// simple failure
c.String(http.StatusUnauthorized, "invalid credentials")
return
}
token, exp := auth.MakeSessionToken(username, 24*time.Hour, secret)
// set HttpOnly cookie
// maxAge in seconds
maxAge := int(time.Until(exp).Seconds())
c.SetCookie("core_session", token, maxAge, "/", "", false, true)
c.Redirect(http.StatusFound, "/bigscreen")
}
}
func handleLogout(opts Options) gin.HandlerFunc {
return func(c *gin.Context) {
// expire cookie immediately
c.SetCookie("core_session", "", -1, "/", "", false, true)
c.Redirect(http.StatusFound, "/admin/login")
}
}
// parseToken wraps auth.ParseSessionToken for local use.
func parseToken(token string, secret []byte) (string, bool) {
return auth.ParseSessionToken(token, secret)
}

View File

@ -15,6 +15,7 @@ type Options struct {
TemplateDir string
StaticDir string
EnableCORS bool
AuthSecret string
}
func NewRouter(opts Options) *gin.Engine {
@ -69,11 +70,38 @@ func NewRouter(opts Options) *gin.Engine {
if hasBigscreen {
bigscreenDir := filepath.Clean(opts.BigscreenDir)
bigscreenIndex = filepath.Join(bigscreenDir, "index.html")
// auth guard
requireAuth := func(c *gin.Context) bool {
if strings.TrimSpace(opts.AuthSecret) == "" {
// no secret configured -> deny by default
c.Redirect(http.StatusFound, "/admin/login")
c.Abort()
return false
}
cookie, err := c.Cookie("core_session")
if err != nil || cookie == "" {
c.Redirect(http.StatusFound, "/admin/login")
c.Abort()
return false
}
if _, ok := validateSession(cookie, opts.AuthSecret); !ok {
c.Redirect(http.StatusFound, "/admin/login")
c.Abort()
return false
}
return true
}
serveBigscreenIndex := func(c *gin.Context) {
if !requireAuth(c) {
return
}
c.File(bigscreenIndex)
}
r.GET("/bigscreen", serveBigscreenIndex)
r.GET("/bigscreen/*filepath", func(c *gin.Context) {
if !requireAuth(c) {
return
}
rel := strings.TrimPrefix(c.Param("filepath"), "/")
if rel == "" {
serveBigscreenIndex(c)
@ -92,6 +120,17 @@ func NewRouter(opts Options) *gin.Engine {
})
}
// Admin login routes
r.GET("/admin/login", func(c *gin.Context) {
if strings.TrimSpace(opts.TemplateDir) != "" {
c.File(filepath.Join(opts.TemplateDir, "login.html"))
return
}
c.String(http.StatusOK, "login page not configured")
})
r.POST("/admin/login", handleLogin(opts))
r.GET("/admin/logout", handleLogout(opts))
// Optional SPA fallback: serve index.html for non-API, non-static routes
r.NoRoute(func(c *gin.Context) {
p := c.Request.URL.Path
@ -112,3 +151,9 @@ func NewRouter(opts Options) *gin.Engine {
return r
}
// validateSession bridges to auth package without importing at top to avoid circular style concerns.
func validateSession(token, secret string) (string, bool) {
// local shim to avoid exposing auth from here
return parseToken(token, []byte(secret))
}

15
static/login.css Normal file
View File

@ -0,0 +1,15 @@
html, body { height: 100%; margin: 0; font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif; background: #f5f6f8; }
.page { min-height: 100%; display: flex; flex-direction: column; }
.login-card { width: 360px; max-width: 92%; margin: auto; background: #fff; border-radius: 12px; box-shadow: 0 6px 24px rgba(0,0,0,.08); padding: 24px 22px 20px; }
.title { margin: 0 0 12px; font-size: 20px; text-align: center; color: #222; }
.form { display: flex; flex-direction: column; gap: 12px; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
label { font-size: 14px; color: #444; }
input[type="text"], input[type="password"] { padding: 10px 12px; border: 1px solid #dcdfe6; border-radius: 6px; font-size: 14px; outline: none; }
input[type="text"]:focus, input[type="password"]:focus { border-color: #409eff; box-shadow: 0 0 0 3px rgba(64,158,255,.12); }
.btn { cursor: pointer; border: none; border-radius: 6px; padding: 10px 14px; font-size: 14px; }
.btn-primary { background: #409eff; color: #fff; }
.btn-primary:hover { background: #3a8be0; }
.btn-block { width: 100%; }
.footer { margin-top: auto; background: #fff; height: 72px; box-shadow: 0 -1px 0 rgba(0,0,0,.05); }

30
templates/login.html Normal file
View File

@ -0,0 +1,30 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登录 - 管理后台</title>
<link rel="stylesheet" href="/static/login.css">
</head>
<body>
<div class="page">
<div class="card login-card">
<h2 class="title">管理员登录</h2>
<form method="post" action="/admin/login" class="form">
<input type="hidden" name="_csrf" value="placeholder">
<div class="form-group">
<label for="username">用户名</label>
<input id="username" type="text" name="username" placeholder="输入用户名" autocomplete="username" required>
</div>
<div class="form-group">
<label for="password">密码</label>
<input id="password" type="password" name="password" placeholder="输入密码" autocomplete="current-password" required>
</div>
<button class="btn btn-primary btn-block" type="submit">登录</button>
</form>
</div>
<div class="footer"></div>
</div>
</body>
</html>