feat(admin): add administrator sessions
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# 采购服务
|
||||
|
||||
启动前必须显式设置下列环境变量;服务不提供默认管理员账号、密码或会话密钥。
|
||||
|
||||
| 变量 | 要求 |
|
||||
| --- | --- |
|
||||
| `CMBUYER_ADMIN_USERNAME` | 非空管理员账号。 |
|
||||
| `CMBUYER_ADMIN_PASSWORD_BCRYPT` | 非空 bcrypt 密码哈希,不接受明文密码。 |
|
||||
| `CMBUYER_SESSION_SECRET` | 至少 32 字节的会话签名密钥。 |
|
||||
| `CMBUYER_COOKIE_SECURE` | 可选;存在时只能精确为 `true` 或 `false`。HTTPS 部署应设为 `true`。 |
|
||||
|
||||
示例仅展示变量名,不提供可运行凭据:
|
||||
|
||||
```powershell
|
||||
$env:CMBUYER_ADMIN_USERNAME = '<管理员账号>'
|
||||
$env:CMBUYER_ADMIN_PASSWORD_BCRYPT = '<bcrypt 密码哈希>'
|
||||
$env:CMBUYER_SESSION_SECRET = '<至少 32 字节的随机密钥>'
|
||||
$env:CMBUYER_COOKIE_SECURE = 'true'
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
采购服务会话仅保存在当前进程内;进程重启后既有登录会话会安全失效。
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"cmbuyer/admin/internal/auth"
|
||||
"cmbuyer/admin/internal/config"
|
||||
"cmbuyer/admin/internal/server"
|
||||
)
|
||||
|
||||
@@ -17,7 +19,21 @@ func main() {
|
||||
}
|
||||
|
||||
func run() error {
|
||||
err := http.ListenAndServe(listenAddress, server.NewRouter())
|
||||
configuration, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(server.Options{
|
||||
AdminUsername: configuration.AdminUsername,
|
||||
AdminPasswordBcrypt: configuration.AdminPasswordBcrypt,
|
||||
Sessions: auth.NewManager(configuration.SessionSecret, configuration.CookieSecure),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = http.ListenAndServe(listenAddress, router)
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,6 +6,7 @@ require (
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/mattn/go-sqlite3 v1.14.49
|
||||
github.com/pressly/goose/v3 v3.24.0
|
||||
golang.org/x/crypto v0.40.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -35,7 +36,6 @@ require (
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// Package auth 提供内存会话与 CSRF 防护。会话不落库,服务重启会安全地使所有登录失效。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
CookieName = "cmbuyer_session"
|
||||
SessionLifetime = 8 * time.Hour
|
||||
csrfTokenByteSize = 32
|
||||
)
|
||||
|
||||
type session struct {
|
||||
csrfToken string
|
||||
authenticated bool
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// Manager 签发、验证并撤销进程内会话。cookie 仅承载经过 HMAC 签名的随机 session ID。
|
||||
type Manager struct {
|
||||
secret []byte
|
||||
cookieSecure bool
|
||||
now func() time.Time
|
||||
random io.Reader
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]session
|
||||
}
|
||||
|
||||
// NewManager 创建会话管理器。secret 在启动时已由 config 验证为足够长度。
|
||||
func NewManager(secret []byte, cookieSecure bool) *Manager {
|
||||
return &Manager{
|
||||
secret: append([]byte(nil), secret...),
|
||||
cookieSecure: cookieSecure,
|
||||
now: time.Now,
|
||||
random: rand.Reader,
|
||||
sessions: make(map[string]session),
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure 返回当前有效会话;不存在或过期时签发匿名会话,以保护登录表单本身的 POST。
|
||||
func (manager *Manager) Ensure(writer http.ResponseWriter, request *http.Request) (csrfToken string, authenticated bool) {
|
||||
if id, current, ok := manager.current(request); ok {
|
||||
return current.csrfToken, current.authenticated
|
||||
} else if id != "" {
|
||||
manager.delete(id)
|
||||
}
|
||||
|
||||
id, current := manager.create(false)
|
||||
manager.writeCookie(writer, id, current.expiresAt)
|
||||
return current.csrfToken, false
|
||||
}
|
||||
|
||||
// VerifyCSRF 只接受当前未过期会话中以恒定时间比较匹配的 token。
|
||||
func (manager *Manager) VerifyCSRF(request *http.Request, token string) (authenticated bool, ok bool) {
|
||||
_, current, found := manager.current(request)
|
||||
if !found || token == "" {
|
||||
return false, false
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(current.csrfToken), []byte(token)) != 1 {
|
||||
return false, false
|
||||
}
|
||||
|
||||
return current.authenticated, true
|
||||
}
|
||||
|
||||
// RotateAuthenticated 在登录成功后撤销旧会话并签发全新认证会话,避免 session fixation 与 CSRF 复用。
|
||||
func (manager *Manager) RotateAuthenticated(writer http.ResponseWriter, request *http.Request) string {
|
||||
if id, _, ok := manager.current(request); ok {
|
||||
manager.delete(id)
|
||||
}
|
||||
|
||||
id, current := manager.create(true)
|
||||
manager.writeCookie(writer, id, current.expiresAt)
|
||||
return current.csrfToken
|
||||
}
|
||||
|
||||
// Logout 撤销当前会话并立即清除浏览器 cookie。
|
||||
func (manager *Manager) Logout(writer http.ResponseWriter, request *http.Request) {
|
||||
if id, _, ok := manager.current(request); ok {
|
||||
manager.delete(id)
|
||||
}
|
||||
http.SetCookie(writer, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: manager.cookieSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *Manager) current(request *http.Request) (string, session, bool) {
|
||||
cookie, err := request.Cookie(CookieName)
|
||||
if err != nil {
|
||||
return "", session{}, false
|
||||
}
|
||||
|
||||
id, expiresAt, ok := manager.verifyCookie(cookie.Value)
|
||||
if !ok || !manager.now().Before(expiresAt) {
|
||||
return id, session{}, false
|
||||
}
|
||||
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
current, found := manager.sessions[id]
|
||||
if !found || !manager.now().Before(current.expiresAt) {
|
||||
return id, session{}, false
|
||||
}
|
||||
|
||||
return id, current, true
|
||||
}
|
||||
|
||||
func (manager *Manager) create(authenticated bool) (string, session) {
|
||||
id := manager.randomToken()
|
||||
current := session{
|
||||
csrfToken: manager.randomToken(),
|
||||
authenticated: authenticated,
|
||||
expiresAt: manager.now().Add(SessionLifetime),
|
||||
}
|
||||
|
||||
manager.mu.Lock()
|
||||
manager.sessions[id] = current
|
||||
manager.mu.Unlock()
|
||||
return id, current
|
||||
}
|
||||
|
||||
func (manager *Manager) delete(id string) {
|
||||
manager.mu.Lock()
|
||||
delete(manager.sessions, id)
|
||||
manager.mu.Unlock()
|
||||
}
|
||||
|
||||
func (manager *Manager) randomToken() string {
|
||||
bytes := make([]byte, csrfTokenByteSize)
|
||||
if _, err := io.ReadFull(manager.random, bytes); err != nil {
|
||||
panic("crypto/rand failed while creating a session token")
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
func (manager *Manager) writeCookie(writer http.ResponseWriter, id string, expiresAt time.Time) {
|
||||
http.SetCookie(writer, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: manager.signCookie(id, expiresAt),
|
||||
Path: "/",
|
||||
MaxAge: int(expiresAt.Sub(manager.now()).Seconds()),
|
||||
Expires: expiresAt,
|
||||
HttpOnly: true,
|
||||
Secure: manager.cookieSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *Manager) signCookie(id string, expiresAt time.Time) string {
|
||||
payload := id + "." + strconv.FormatInt(expiresAt.Unix(), 10)
|
||||
mac := hmac.New(sha256.New, manager.secret)
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
return payload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (manager *Manager) verifyCookie(value string) (string, time.Time, bool) {
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) != 3 || parts[0] == "" {
|
||||
return "", time.Time{}, false
|
||||
}
|
||||
|
||||
expiresUnix, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil {
|
||||
return "", time.Time{}, false
|
||||
}
|
||||
provided, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return "", time.Time{}, false
|
||||
}
|
||||
|
||||
payload := parts[0] + "." + parts[1]
|
||||
mac := hmac.New(sha256.New, manager.secret)
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
if !hmac.Equal(provided, mac.Sum(nil)) {
|
||||
return "", time.Time{}, false
|
||||
}
|
||||
|
||||
return parts[0], time.Unix(expiresUnix, 0), true
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestManagerRejectsTamperedAndExpiredCookies(t *testing.T) {
|
||||
manager := NewManager([]byte(strings.Repeat("s", 32)), true)
|
||||
request := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
response := httptest.NewRecorder()
|
||||
csrf, authenticated := manager.Ensure(response, request)
|
||||
if csrf == "" || authenticated {
|
||||
t.Fatalf("Ensure = (%q, %t), want anonymous CSRF session", csrf, authenticated)
|
||||
}
|
||||
cookie := response.Result().Cookies()[0]
|
||||
if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteLaxMode || cookie.Path != "/" {
|
||||
t.Fatalf("session cookie is missing security attributes: %#v", cookie)
|
||||
}
|
||||
|
||||
tampered := *cookie
|
||||
tampered.Value = flipCookieValue(t, cookie.Value)
|
||||
tamperedRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||
tamperedRequest.AddCookie(&tampered)
|
||||
if _, ok := manager.VerifyCSRF(tamperedRequest, csrf); ok {
|
||||
t.Fatal("tampered signed cookie passed CSRF verification")
|
||||
}
|
||||
|
||||
manager.now = func() time.Time { return time.Now().Add(9 * time.Hour) }
|
||||
expiredRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||
expiredRequest.AddCookie(cookie)
|
||||
if _, ok := manager.VerifyCSRF(expiredRequest, csrf); ok {
|
||||
t.Fatal("expired cookie passed CSRF verification")
|
||||
}
|
||||
}
|
||||
|
||||
func flipCookieValue(t *testing.T, value string) string {
|
||||
t.Helper()
|
||||
if value == "" {
|
||||
t.Fatal("cannot tamper with an empty cookie")
|
||||
}
|
||||
if value[0] == 'A' {
|
||||
return "B" + value[1:]
|
||||
}
|
||||
return "A" + value[1:]
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Package config 读取采购服务的启动配置。凭据只允许来自显式环境变量,避免把秘密写入代码或仓库。
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
adminUsernameEnv = "CMBUYER_ADMIN_USERNAME"
|
||||
adminPasswordBcryptEnv = "CMBUYER_ADMIN_PASSWORD_BCRYPT"
|
||||
sessionSecretEnv = "CMBUYER_SESSION_SECRET"
|
||||
cookieSecureEnv = "CMBUYER_COOKIE_SECURE"
|
||||
minimumSecretLength = 32
|
||||
)
|
||||
|
||||
// Config 是启动采购服务所需的最小安全配置。
|
||||
type Config struct {
|
||||
AdminUsername string
|
||||
AdminPasswordBcrypt string
|
||||
SessionSecret []byte
|
||||
CookieSecure bool
|
||||
}
|
||||
|
||||
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
|
||||
func LoadFromEnv() (Config, error) {
|
||||
return Load(os.LookupEnv)
|
||||
}
|
||||
|
||||
// Load 使用 lookup 读取配置,以便在不污染进程环境的情况下测试启动边界。
|
||||
func Load(lookup func(string) (string, bool)) (Config, error) {
|
||||
username, err := required(lookup, adminUsernameEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
passwordHash, err := required(lookup, adminPasswordBcryptEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if _, err := bcrypt.Cost([]byte(passwordHash)); err != nil {
|
||||
return Config{}, fmt.Errorf("%s is not a valid bcrypt hash", adminPasswordBcryptEnv)
|
||||
}
|
||||
|
||||
secret, err := required(lookup, sessionSecretEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if len([]byte(secret)) < minimumSecretLength {
|
||||
return Config{}, fmt.Errorf("%s must be at least %d bytes", sessionSecretEnv, minimumSecretLength)
|
||||
}
|
||||
|
||||
cookieSecure := false
|
||||
if value, present := lookup(cookieSecureEnv); present {
|
||||
switch value {
|
||||
case "true":
|
||||
cookieSecure = true
|
||||
case "false":
|
||||
cookieSecure = false
|
||||
default:
|
||||
return Config{}, fmt.Errorf("%s must be exactly true or false", cookieSecureEnv)
|
||||
}
|
||||
}
|
||||
|
||||
return Config{
|
||||
AdminUsername: username,
|
||||
AdminPasswordBcrypt: passwordHash,
|
||||
SessionSecret: []byte(secret),
|
||||
CookieSecure: cookieSecure,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func required(lookup func(string) (string, bool), name string) (string, error) {
|
||||
value, present := lookup(name)
|
||||
if !present || strings.TrimSpace(value) == "" {
|
||||
return "", errors.New(name + " must be set")
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/config"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("generate bcrypt hash: %v", err)
|
||||
}
|
||||
|
||||
values := map[string]string{
|
||||
"CMBUYER_ADMIN_USERNAME": "admin",
|
||||
"CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash),
|
||||
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
|
||||
"CMBUYER_COOKIE_SECURE": "true",
|
||||
}
|
||||
|
||||
got, err := config.Load(lookup(values))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if got.AdminUsername != "admin" || !got.CookieSecure {
|
||||
t.Fatalf("Load returned unexpected public configuration: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("generate bcrypt hash: %v", err)
|
||||
}
|
||||
|
||||
base := map[string]string{
|
||||
"CMBUYER_ADMIN_USERNAME": "admin",
|
||||
"CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash),
|
||||
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(map[string]string)
|
||||
want string
|
||||
}{
|
||||
{"missing username", func(values map[string]string) { delete(values, "CMBUYER_ADMIN_USERNAME") }, "CMBUYER_ADMIN_USERNAME"},
|
||||
{"invalid bcrypt", func(values map[string]string) { values["CMBUYER_ADMIN_PASSWORD_BCRYPT"] = "not-a-bcrypt-hash" }, "CMBUYER_ADMIN_PASSWORD_BCRYPT"},
|
||||
{"short secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = "short" }, "CMBUYER_SESSION_SECRET"},
|
||||
{"invalid secure flag", func(values map[string]string) { values["CMBUYER_COOKIE_SECURE"] = "1" }, "CMBUYER_COOKIE_SECURE"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
values := copyValues(base)
|
||||
test.mutate(values)
|
||||
_, err := config.Load(lookup(values))
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Load error = %v, want mention of %s", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func lookup(values map[string]string) func(string) (string, bool) {
|
||||
return func(key string) (string, bool) {
|
||||
value, ok := values[key]
|
||||
return value, ok
|
||||
}
|
||||
}
|
||||
|
||||
func copyValues(values map[string]string) map[string]string {
|
||||
copy := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
copy[key] = value
|
||||
}
|
||||
return copy
|
||||
}
|
||||
@@ -2,18 +2,164 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"cmbuyer/admin/internal/auth"
|
||||
"cmbuyer/admin/internal/transport/webui"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
|
||||
func NewRouter() *gin.Engine {
|
||||
router := gin.New()
|
||||
const maxFormBytes = 8 << 10
|
||||
|
||||
router.GET("/healthz", func(context *gin.Context) {
|
||||
context.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
// Options 是路由层需要的安全依赖。凭据由启动配置注入,不能在路由中设置默认值。
|
||||
type Options struct {
|
||||
AdminUsername string
|
||||
AdminPasswordBcrypt string
|
||||
Sessions *auth.Manager
|
||||
}
|
||||
|
||||
return router
|
||||
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
|
||||
func NewRouter(options Options) (*gin.Engine, error) {
|
||||
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil {
|
||||
return nil, errors.New("server authentication options are incomplete")
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
router.Use(securityHeaders())
|
||||
router.GET("/healthz", healthz)
|
||||
router.GET("/login", loginPage(options))
|
||||
router.POST("/login", login(options))
|
||||
router.POST("/logout", logout(options))
|
||||
router.GET("/tasks", tasksPage(options))
|
||||
|
||||
return router, nil
|
||||
}
|
||||
|
||||
func healthz(context *gin.Context) {
|
||||
context.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func securityHeaders() gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
context.Header("Cache-Control", "no-store")
|
||||
context.Header("X-Content-Type-Options", "nosniff")
|
||||
context.Header("Referrer-Policy", "no-referrer")
|
||||
context.Header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
|
||||
context.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func loginPage(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
csrfToken, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
|
||||
if authenticated {
|
||||
context.Redirect(http.StatusSeeOther, "/tasks")
|
||||
return
|
||||
}
|
||||
|
||||
renderLogin(context, http.StatusOK, csrfToken, returnTo(context.Query("return_to")), "", "")
|
||||
}
|
||||
}
|
||||
|
||||
func login(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
limitFormBody(context)
|
||||
csrfToken := context.PostForm("csrf_token")
|
||||
returnPath := returnTo(context.PostForm("return_to"))
|
||||
username := context.PostForm("username")
|
||||
password := context.PostForm("password")
|
||||
|
||||
if _, ok := options.Sessions.VerifyCSRF(context.Request, csrfToken); !ok {
|
||||
newCSRF, _ := options.Sessions.Ensure(context.Writer, context.Request)
|
||||
renderLogin(context, http.StatusForbidden, newCSRF, returnPath, "", "请求已过期,请重新登录。")
|
||||
return
|
||||
}
|
||||
|
||||
usernameMatches := subtle.ConstantTimeCompare([]byte(options.AdminUsername), []byte(username)) == 1
|
||||
passwordMatches := bcrypt.CompareHashAndPassword([]byte(options.AdminPasswordBcrypt), []byte(password)) == nil
|
||||
if !usernameMatches || !passwordMatches {
|
||||
csrf, _ := options.Sessions.Ensure(context.Writer, context.Request)
|
||||
renderLogin(context, http.StatusUnauthorized, csrf, returnPath, "", "账号或密码不正确,请检查后重试。")
|
||||
return
|
||||
}
|
||||
|
||||
options.Sessions.RotateAuthenticated(context.Writer, context.Request)
|
||||
context.Redirect(http.StatusSeeOther, returnPath)
|
||||
}
|
||||
}
|
||||
|
||||
func logout(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
limitFormBody(context)
|
||||
authenticated, ok := options.Sessions.VerifyCSRF(context.Request, context.PostForm("csrf_token"))
|
||||
if !ok || !authenticated {
|
||||
context.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
options.Sessions.Logout(context.Writer, context.Request)
|
||||
context.Redirect(http.StatusSeeOther, "/login")
|
||||
}
|
||||
}
|
||||
|
||||
func tasksPage(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
csrfToken, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
|
||||
if !authenticated {
|
||||
context.Redirect(http.StatusSeeOther, "/login?return_to="+url.QueryEscape(context.Request.URL.RequestURI()))
|
||||
return
|
||||
}
|
||||
|
||||
context.Header("Content-Type", "text/html; charset=utf-8")
|
||||
if err := webui.RenderTasks(context.Writer, webui.TasksData{CSRFToken: csrfToken}); err != nil {
|
||||
_ = context.Error(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renderLogin(context *gin.Context, status int, csrfToken, returnPath, username, message string) {
|
||||
context.Header("Content-Type", "text/html; charset=utf-8")
|
||||
context.Status(status)
|
||||
if err := webui.RenderLogin(context.Writer, webui.LoginData{
|
||||
CSRFToken: csrfToken,
|
||||
ReturnTo: returnPath,
|
||||
Username: username,
|
||||
Error: message,
|
||||
}); err != nil {
|
||||
_ = context.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func limitFormBody(context *gin.Context) {
|
||||
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxFormBytes)
|
||||
}
|
||||
|
||||
func returnTo(value string) string {
|
||||
if value == "/tasks" || strings.HasPrefix(value, "/tasks/") || strings.HasPrefix(value, "/tasks?") {
|
||||
if strings.Contains(value, "\\") || strings.Contains(value, "%") || strings.HasPrefix(value, "//") {
|
||||
return "/tasks"
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(value)
|
||||
if err == nil && parsed.IsAbs() == false && parsed.Host == "" && hasSafeTaskPath(parsed.Path) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return "/tasks"
|
||||
}
|
||||
|
||||
func hasSafeTaskPath(path string) bool {
|
||||
for _, segment := range strings.Split(path, "/") {
|
||||
if segment == "." || segment == ".." {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -3,26 +3,291 @@ package server_test
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/auth"
|
||||
"cmbuyer/admin/internal/server"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
var csrfPattern = regexp.MustCompile(`name="csrf_token" value="([^"]+)"`)
|
||||
|
||||
func TestHealthzIsPublic(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
server.NewRouter().ServeHTTP(response, request)
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("healthz status = %d, want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
if contentType := response.Header().Get("Content-Type"); contentType != "application/json; charset=utf-8" {
|
||||
t.Fatalf("healthz content type = %q, want application/json; charset=utf-8", contentType)
|
||||
}
|
||||
|
||||
if body := response.Body.String(); body != "{\"status\":\"ok\"}" {
|
||||
t.Fatalf("healthz body = %q, want {\"status\":\"ok\"}", body)
|
||||
}
|
||||
assertSecurityHeaders(t, response)
|
||||
}
|
||||
|
||||
func TestTasksRequiresLoginAndBlocksOpenRedirects(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
|
||||
tasks := serve(router, http.MethodGet, "/tasks", nil, nil)
|
||||
if tasks.Code != http.StatusSeeOther {
|
||||
t.Fatalf("GET /tasks status = %d, want %d", tasks.Code, http.StatusSeeOther)
|
||||
}
|
||||
if location := tasks.Header().Get("Location"); location != "/login?return_to=%2Ftasks" {
|
||||
t.Fatalf("GET /tasks location = %q, want login return path", location)
|
||||
}
|
||||
|
||||
for _, target := range []string{"https://example.invalid", "//example.invalid", `\\example.invalid`, "/other", "/tasks/..", "/tasks/../other", "/tasks/%2e%2e", "%2F%2Fevil.invalid", "%252F%252Fevil.invalid"} {
|
||||
response := serve(router, http.MethodGet, "/login?return_to="+url.QueryEscape(target), nil, nil)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("GET /login return_to=%q status = %d, want 200", target, response.Code)
|
||||
}
|
||||
if strings.Contains(response.Body.String(), target) || !strings.Contains(response.Body.String(), `name="return_to" value="/tasks"`) {
|
||||
t.Fatalf("GET /login accepted unsafe return_to %q", target)
|
||||
}
|
||||
}
|
||||
|
||||
encodedPath := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%252F..", nil, nil)
|
||||
if !strings.Contains(encodedPath.Body.String(), `name="return_to" value="/tasks"`) {
|
||||
t.Fatal("encoded parent path was accepted as return_to")
|
||||
}
|
||||
encodedQuery := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%3Fnext%3D%252Ftasks%252F..", nil, nil)
|
||||
if !strings.Contains(encodedQuery.Body.String(), `name="return_to" value="/tasks"`) {
|
||||
t.Fatal("encoded query bypass was accepted as return_to")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRotatesSessionAndCSRF(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
initial := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%3Fview%3Dmine", nil, nil)
|
||||
oldCookie := sessionCookie(t, initial)
|
||||
oldCSRF := csrfToken(t, initial.Body.String())
|
||||
|
||||
login := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"csrf_token": {oldCSRF},
|
||||
"return_to": {"/tasks?view=mine"},
|
||||
"username": {"admin"},
|
||||
"password": {"test-password"},
|
||||
}, oldCookie)
|
||||
if login.Code != http.StatusSeeOther || login.Header().Get("Location") != "/tasks?view=mine" {
|
||||
t.Fatalf("successful login = (%d, %q), want 303 /tasks?view=mine", login.Code, login.Header().Get("Location"))
|
||||
}
|
||||
newCookie := sessionCookie(t, login)
|
||||
if newCookie.Value == oldCookie.Value {
|
||||
t.Fatal("successful login reused the anonymous session cookie")
|
||||
}
|
||||
|
||||
tasks := serve(router, http.MethodGet, "/tasks", nil, newCookie)
|
||||
if tasks.Code != http.StatusOK {
|
||||
t.Fatalf("GET /tasks after login status = %d, want 200", tasks.Code)
|
||||
}
|
||||
if newCSRF := csrfToken(t, tasks.Body.String()); newCSRF == oldCSRF {
|
||||
t.Fatal("successful login reused the anonymous CSRF token")
|
||||
}
|
||||
for _, forbidden := range []string{"建单", "试选", "拼多多", "规格", "单价", "证据"} {
|
||||
if strings.Contains(tasks.Body.String(), forbidden) {
|
||||
t.Fatalf("task shell must not expose deferred feature content %q", forbidden)
|
||||
}
|
||||
}
|
||||
assertSecurityHeaders(t, initial)
|
||||
assertSecurityHeaders(t, tasks)
|
||||
}
|
||||
|
||||
func TestLoginPageIncludesAccessibleFormBasics(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
page := serve(router, http.MethodGet, "/login", nil, nil)
|
||||
body := page.Body.String()
|
||||
for _, want := range []string{
|
||||
`<label for="username">`,
|
||||
`<label for="password">`,
|
||||
`autocomplete="username"`,
|
||||
`autocomplete="current-password"`,
|
||||
`min-height:44px`,
|
||||
`:focus-visible`,
|
||||
`prefers-reduced-motion`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("login page is missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "http://") || strings.Contains(body, "https://") || strings.Contains(body, "<script") {
|
||||
t.Fatal("login page must not load external resources or require client-side JavaScript")
|
||||
}
|
||||
|
||||
failure := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"csrf_token": {csrfToken(t, body)},
|
||||
"username": {"admin"},
|
||||
"password": {"wrong"},
|
||||
}, sessionCookie(t, page))
|
||||
if !strings.Contains(failure.Body.String(), `role="alert"`) {
|
||||
t.Fatal("login failure must announce its error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCSRFAndCredentialFailuresAreSafe(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
page := serve(router, http.MethodGet, "/login", nil, nil)
|
||||
cookie := sessionCookie(t, page)
|
||||
|
||||
withoutCSRF := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"username": {"admin"},
|
||||
"password": {"test-password"},
|
||||
}, cookie)
|
||||
if withoutCSRF.Code != http.StatusForbidden || !strings.Contains(withoutCSRF.Body.String(), "请求已过期") {
|
||||
t.Fatalf("login without CSRF = (%d, %q), want rejected form", withoutCSRF.Code, withoutCSRF.Body.String())
|
||||
}
|
||||
|
||||
page = serve(router, http.MethodGet, "/login", nil, cookie)
|
||||
badCredentials := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"csrf_token": {csrfToken(t, page.Body.String())},
|
||||
"username": {"unknown"},
|
||||
"password": {"wrong"},
|
||||
}, cookie)
|
||||
if badCredentials.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("login with invalid credentials status = %d, want 401", badCredentials.Code)
|
||||
}
|
||||
if body := badCredentials.Body.String(); !strings.Contains(body, "账号或密码不正确") || strings.Contains(body, "unknown") {
|
||||
t.Fatalf("invalid login leaked account detail: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTamperedCookieCannotAccessTasks(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
page := serve(router, http.MethodGet, "/login", nil, nil)
|
||||
cookie := sessionCookie(t, page)
|
||||
|
||||
tampered := *cookie
|
||||
tampered.Value = flipCookieValue(t, cookie.Value)
|
||||
response := serve(router, http.MethodGet, "/tasks", nil, &tampered)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("tampered cookie status = %d, want 303", response.Code)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func assertSecurityHeaders(t *testing.T, response *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
want := map[string]string{
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"Content-Security-Policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
||||
}
|
||||
for name, expected := range want {
|
||||
if got := response.Header().Get(name); got != expected {
|
||||
t.Fatalf("%s = %q, want %q", name, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func flipCookieValue(t *testing.T, value string) string {
|
||||
t.Helper()
|
||||
if value == "" {
|
||||
t.Fatal("cannot tamper with an empty cookie")
|
||||
}
|
||||
if value[0] == 'A' {
|
||||
return "B" + value[1:]
|
||||
}
|
||||
return "A" + value[1:]
|
||||
}
|
||||
|
||||
func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
loginPage := serve(router, http.MethodGet, "/login", nil, nil)
|
||||
loginCookie := sessionCookie(t, loginPage)
|
||||
login := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"csrf_token": {csrfToken(t, loginPage.Body.String())},
|
||||
"username": {"admin"},
|
||||
"password": {"test-password"},
|
||||
}, loginCookie)
|
||||
authenticatedCookie := sessionCookie(t, login)
|
||||
|
||||
missingCSRF := serve(router, http.MethodPost, "/logout", url.Values{}, authenticatedCookie)
|
||||
if missingCSRF.Code != http.StatusForbidden {
|
||||
t.Fatalf("logout without CSRF status = %d, want 403", missingCSRF.Code)
|
||||
}
|
||||
|
||||
tasks := serve(router, http.MethodGet, "/tasks", nil, authenticatedCookie)
|
||||
logout := serve(router, http.MethodPost, "/logout", url.Values{
|
||||
"csrf_token": {csrfToken(t, tasks.Body.String())},
|
||||
}, authenticatedCookie)
|
||||
if logout.Code != http.StatusSeeOther || logout.Header().Get("Location") != "/login" {
|
||||
t.Fatalf("logout = (%d, %q), want 303 /login", logout.Code, logout.Header().Get("Location"))
|
||||
}
|
||||
if cookie := sessionCookie(t, logout); cookie.MaxAge >= 0 {
|
||||
t.Fatalf("logout cookie MaxAge = %d, want a deletion cookie", cookie.MaxAge)
|
||||
}
|
||||
|
||||
reused := serve(router, http.MethodGet, "/tasks", nil, authenticatedCookie)
|
||||
if reused.Code != http.StatusSeeOther {
|
||||
t.Fatalf("revoked session status = %d, want 303", reused.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("generate bcrypt hash: %v", err)
|
||||
}
|
||||
manager := auth.NewManager([]byte(strings.Repeat("s", 32)), false)
|
||||
router, err := server.NewRouter(server.Options{
|
||||
AdminUsername: "admin",
|
||||
AdminPasswordBcrypt: string(hash),
|
||||
Sessions: manager,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter: %v", err)
|
||||
}
|
||||
return router, manager
|
||||
}
|
||||
|
||||
func serve(router http.Handler, method, target string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
var body *strings.Reader
|
||||
if form == nil {
|
||||
body = strings.NewReader("")
|
||||
} else {
|
||||
body = strings.NewReader(form.Encode())
|
||||
}
|
||||
request := httptest.NewRequest(method, target, body)
|
||||
if form != nil {
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
if cookie != nil {
|
||||
request.AddCookie(cookie)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func sessionCookie(t *testing.T, response *httptest.ResponseRecorder) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, cookie := range response.Result().Cookies() {
|
||||
if cookie.Name == auth.CookieName {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
t.Fatalf("response did not set %s cookie", auth.CookieName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func csrfToken(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
matches := csrfPattern.FindStringSubmatch(body)
|
||||
if len(matches) != 2 || matches[1] == "" {
|
||||
t.Fatalf("no CSRF token in response body: %q", body)
|
||||
}
|
||||
return matches[1]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
{{define "login.html"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>登录 · 采购服务</title>
|
||||
<style>
|
||||
:root { color-scheme: light; --bg:#f4f7fb; --surface:#fff; --text:#172033; --muted:#526079; --border:#cfd8e6; --primary:#155eef; --primary-hover:#0b4ed1; --primary-soft:#eaf1ff; --danger:#b42318; --danger-soft:#fef3f2; --focus:#ffbf47; --shadow:0 12px 30px rgba(23,32,51,.1); font-family:"Segoe UI","Microsoft YaHei UI",system-ui,sans-serif; }
|
||||
* { box-sizing:border-box; }
|
||||
html { min-width:320px; background:var(--bg); }
|
||||
body { min-height:100dvh; margin:0; color:var(--text); background:var(--bg); font-size:16px; line-height:1.55; }
|
||||
button,input { font:inherit; }
|
||||
:focus-visible { outline:3px solid var(--focus); outline-offset:3px; }
|
||||
.skip-link { position:fixed; z-index:10; top:8px; left:8px; padding:10px 14px; color:#fff; background:var(--text); transform:translateY(-160%); }
|
||||
.skip-link:focus { transform:translateY(0); }
|
||||
main { display:grid; min-height:100dvh; place-items:center; padding:24px 16px; }
|
||||
.card { width:min(100%,440px); padding:32px; border:1px solid var(--border); border-radius:14px; background:var(--surface); box-shadow:var(--shadow); }
|
||||
.brand { display:flex; align-items:center; gap:10px; margin:0 0 24px; font-size:1rem; font-weight:700; }
|
||||
.brand-mark { display:grid; width:32px; height:32px; place-items:center; border-radius:8px; color:#fff; background:var(--primary); font-size:.82rem; }
|
||||
h1 { margin:0; font-size:clamp(1.6rem,5vw,2rem); line-height:1.25; }
|
||||
.intro { margin:8px 0 24px; color:var(--muted); }
|
||||
.field { margin-top:16px; }
|
||||
label { display:block; margin-bottom:6px; font-weight:700; }
|
||||
input { width:100%; min-height:44px; padding:10px 12px; border:1px solid #9ba9bc; border-radius:8px; color:var(--text); background:#fff; }
|
||||
input[aria-invalid="true"] { border-color:var(--danger); box-shadow:0 0 0 1px var(--danger); }
|
||||
.hint { margin:5px 0 0; color:var(--muted); font-size:.875rem; }
|
||||
.error { margin:0 0 18px; padding:12px 14px; border-left:4px solid var(--danger); border-radius:6px; color:var(--danger); background:var(--danger-soft); font-weight:650; }
|
||||
.submit { width:100%; min-height:44px; margin-top:24px; padding:10px 16px; border:1px solid transparent; border-radius:8px; color:#fff; background:var(--primary); font-weight:700; cursor:pointer; transition:background-color 180ms ease-out; }
|
||||
.submit:hover { background:var(--primary-hover); }
|
||||
.notice { margin:20px 0 0; padding:12px 14px; border:1px solid #b9cffc; border-radius:8px; color:#29466f; background:var(--primary-soft); font-size:.9rem; }
|
||||
@media (max-width:420px) { main { padding-inline:12px; } .card { padding:24px 16px; } }
|
||||
@media (prefers-reduced-motion:reduce) { *,*::before,*::after { transition-duration:.01ms !important; animation-duration:.01ms !important; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">跳到主要内容</a>
|
||||
<main id="main">
|
||||
<section class="card" aria-labelledby="login-title">
|
||||
<p class="brand"><span class="brand-mark" aria-hidden="true">采</span><span>采购服务</span></p>
|
||||
<h1 id="login-title">管理端登录</h1>
|
||||
<p class="intro">登录后进入采购任务工作台。设备身份不能使用此入口。</p>
|
||||
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="return_to" value="{{.ReturnTo}}">
|
||||
<div class="field">
|
||||
<label for="username">账号</label>
|
||||
<input id="username" name="username" type="text" value="{{.Username}}" autocomplete="username" required aria-invalid="{{if .Error}}true{{else}}false{{end}}" aria-describedby="username-hint">
|
||||
<p class="hint" id="username-hint">使用采购管理员账号登录。</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">密码</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required aria-invalid="{{if .Error}}true{{else}}false{{end}}">
|
||||
</div>
|
||||
<button class="submit" type="submit">登录并继续</button>
|
||||
</form>
|
||||
<p class="notice">系统只创建待付款订单,付款始终由人完成。</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,27 @@
|
||||
{{define "tasks.html"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>采购任务 · 采购服务</title>
|
||||
<style>
|
||||
:root { color-scheme:light; --bg:#f4f7fb; --surface:#fff; --text:#172033; --muted:#526079; --border:#cfd8e6; --primary:#155eef; --focus:#ffbf47; font-family:"Segoe UI","Microsoft YaHei UI",system-ui,sans-serif; }
|
||||
* { box-sizing:border-box; } body { min-height:100dvh; margin:0; color:var(--text); background:var(--bg); font-size:16px; line-height:1.55; } button { font:inherit; } :focus-visible { outline:3px solid var(--focus); outline-offset:3px; }
|
||||
.skip-link { position:fixed; z-index:10; top:8px; left:8px; padding:10px 14px; color:#fff; background:var(--text); transform:translateY(-160%); } .skip-link:focus { transform:translateY(0); }
|
||||
header { display:flex; min-height:64px; align-items:center; justify-content:space-between; gap:16px; padding:10px clamp(16px,4vw,40px); border-bottom:1px solid var(--border); background:var(--surface); }
|
||||
.brand { display:flex; align-items:center; gap:10px; font-weight:700; } .brand-mark { display:grid; width:32px; height:32px; place-items:center; border-radius:8px; color:#fff; background:var(--primary); font-size:.82rem; }
|
||||
.logout { min-height:44px; padding:8px 14px; border:1px solid var(--border); border-radius:8px; color:var(--text); background:var(--surface); font-weight:700; cursor:pointer; }
|
||||
main { width:min(100% - 32px,760px); margin:48px auto; padding:32px; border:1px solid var(--border); border-radius:14px; background:var(--surface); }
|
||||
h1 { margin:0; font-size:clamp(1.5rem,5vw,2rem); } p { color:var(--muted); } .notice { margin-top:24px; padding:14px; border-left:4px solid var(--primary); border-radius:6px; background:#eaf1ff; color:#29466f; }
|
||||
@media (max-width:420px) { main { width:calc(100% - 24px); margin:24px auto; padding:24px 16px; } }
|
||||
@media (prefers-reduced-motion:reduce) { *,*::before,*::after { transition-duration:.01ms !important; animation-duration:.01ms !important; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">跳到主要内容</a>
|
||||
<header><div class="brand"><span class="brand-mark" aria-hidden="true">采</span><span>采购服务</span></div><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button class="logout" type="submit">退出登录</button></form></header>
|
||||
<main id="main"><h1>采购任务</h1><p>任务功能正在准备中。</p><p class="notice">当前页面仅用于验证管理员会话。</p></main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Package webui 渲染采购服务当前可用的服务端页面。
|
||||
package webui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"io"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
var templateFiles embed.FS
|
||||
|
||||
var templates = template.Must(template.New("webui").ParseFS(templateFiles, "templates/*.html"))
|
||||
|
||||
// LoginData 是登录页面所需的非敏感展示数据。
|
||||
type LoginData struct {
|
||||
CSRFToken string
|
||||
ReturnTo string
|
||||
Username string
|
||||
Error string
|
||||
}
|
||||
|
||||
// TasksData 是当前受保护任务空壳所需的数据。任务字段将在后续任务实现。
|
||||
type TasksData struct {
|
||||
CSRFToken string
|
||||
}
|
||||
|
||||
// RenderLogin 写入登录页。
|
||||
func RenderLogin(writer io.Writer, data LoginData) error {
|
||||
return templates.ExecuteTemplate(writer, "login.html", data)
|
||||
}
|
||||
|
||||
// RenderTasks 写入登录后的受保护空壳。
|
||||
func RenderTasks(writer io.Writer, data TasksData) error {
|
||||
return templates.ExecuteTemplate(writer, "tasks.html", data)
|
||||
}
|
||||
Reference in New Issue
Block a user