diff --git a/core/cmd/core-api/main.go b/core/cmd/core-api/main.go index b9a01d2..bfe81d0 100644 --- a/core/cmd/core-api/main.go +++ b/core/cmd/core-api/main.go @@ -18,6 +18,7 @@ func main() { TemplateDir: cfg.TemplateDir, StaticDir: cfg.StaticDir, EnableCORS: cfg.DevEnableCORS, + AuthSecret: cfg.AuthSecret, }) addr := cfg.Addr diff --git a/core/internal/auth/auth.go b/core/internal/auth/auth.go new file mode 100644 index 0000000..24ac260 --- /dev/null +++ b/core/internal/auth/auth.go @@ -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 +} diff --git a/core/internal/auth/auth_test.go b/core/internal/auth/auth_test.go new file mode 100644 index 0000000..affe56e --- /dev/null +++ b/core/internal/auth/auth_test.go @@ -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) +} diff --git a/core/internal/config/config.go b/core/internal/config/config.go index 84a1d47..dd00c02 100644 --- a/core/internal/config/config.go +++ b/core/internal/config/config.go @@ -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 } diff --git a/core/internal/data/users.go b/core/internal/data/users.go new file mode 100644 index 0000000..b82a775 --- /dev/null +++ b/core/internal/data/users.go @@ -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 +} diff --git a/core/internal/server/auth_handlers.go b/core/internal/server/auth_handlers.go new file mode 100644 index 0000000..ee75cd8 --- /dev/null +++ b/core/internal/server/auth_handlers.go @@ -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) +} diff --git a/core/internal/server/router.go b/core/internal/server/router.go index b182f1f..b05cc3c 100644 --- a/core/internal/server/router.go +++ b/core/internal/server/router.go @@ -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)) +} diff --git a/static/login.css b/static/login.css new file mode 100644 index 0000000..b2b4312 --- /dev/null +++ b/static/login.css @@ -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); } + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..10aab48 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,30 @@ + + +
+ + +