feat: 新增登录功能
This commit is contained in:
parent
f3dabb887e
commit
e2878477de
@ -18,6 +18,7 @@ func main() {
|
||||
TemplateDir: cfg.TemplateDir,
|
||||
StaticDir: cfg.StaticDir,
|
||||
EnableCORS: cfg.DevEnableCORS,
|
||||
AuthSecret: cfg.AuthSecret,
|
||||
})
|
||||
|
||||
addr := cfg.Addr
|
||||
|
||||
74
core/internal/auth/auth.go
Normal file
74
core/internal/auth/auth.go
Normal 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
|
||||
}
|
||||
26
core/internal/auth/auth_test.go
Normal file
26
core/internal/auth/auth_test.go
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
32
core/internal/data/users.go
Normal file
32
core/internal/data/users.go
Normal 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
|
||||
}
|
||||
51
core/internal/server/auth_handlers.go
Normal file
51
core/internal/server/auth_handlers.go
Normal 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)
|
||||
}
|
||||
@ -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
15
static/login.css
Normal 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
30
templates/login.html
Normal 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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user