feat: 实现 Admin 首次初始化与网页登录 (#50)
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
func TestAdmin首次初始化登录退出完整流程(t *testing.T) {
|
||||
db, err := repository.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := repository.Migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router, err := newRouter(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
setupPage := httptest.NewRecorder()
|
||||
router.ServeHTTP(setupPage, httptest.NewRequest(http.MethodGet, "/setup", nil))
|
||||
if setupPage.Code != http.StatusOK || !strings.Contains(setupPage.Body.String(), "初始化管理员") {
|
||||
t.Fatalf("GET /setup = %d,响应:%s", setupPage.Code, setupPage.Body.String())
|
||||
}
|
||||
csrfCookie := findResponseCookie(t, setupPage, "cmautobuy_csrf")
|
||||
if csrfCookie.HttpOnly || csrfCookie.Value == "" {
|
||||
t.Fatalf("CSRF Cookie 应可供双提交表单使用: %#v", csrfCookie)
|
||||
}
|
||||
|
||||
// 初始化和登录都是公开入口,但 POST 同样必须通过 CSRF。
|
||||
missingCSRF := postFormRequest("/setup", url.Values{
|
||||
"username": {"admin"}, "password": {"safe-password"},
|
||||
"password_confirm": {"safe-password"},
|
||||
}, nil)
|
||||
missingCSRFResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(missingCSRFResponse, missingCSRF)
|
||||
if missingCSRFResponse.Code != http.StatusForbidden {
|
||||
t.Fatalf("无 CSRF 初始化 = %d,期望 403", missingCSRFResponse.Code)
|
||||
}
|
||||
|
||||
setup := postFormRequest("/setup", url.Values{
|
||||
"csrf_token": {csrfCookie.Value}, "username": {"admin"},
|
||||
"password": {"safe-password"}, "password_confirm": {"safe-password"},
|
||||
}, csrfCookie)
|
||||
setupResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(setupResponse, setup)
|
||||
if setupResponse.Code != http.StatusSeeOther || !strings.HasPrefix(setupResponse.Header().Get("Location"), "/login?") {
|
||||
t.Fatalf("POST /setup = %d %s", setupResponse.Code, setupResponse.Header().Get("Location"))
|
||||
}
|
||||
|
||||
// 初始化完成后入口永久关闭。
|
||||
setupAgain := httptest.NewRecorder()
|
||||
router.ServeHTTP(setupAgain, httptest.NewRequest(http.MethodGet, "/setup", nil))
|
||||
if setupAgain.Code != http.StatusSeeOther || !strings.HasPrefix(setupAgain.Header().Get("Location"), "/login?") {
|
||||
t.Fatalf("再次 GET /setup = %d %s", setupAgain.Code, setupAgain.Header().Get("Location"))
|
||||
}
|
||||
|
||||
wrongLogin := postFormRequest("/login", url.Values{
|
||||
"csrf_token": {csrfCookie.Value}, "username": {"admin"},
|
||||
"password": {"wrong-password"}, "next": {"/pdd"},
|
||||
}, csrfCookie)
|
||||
wrongResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(wrongResponse, wrongLogin)
|
||||
if wrongResponse.Code != http.StatusUnauthorized || !strings.Contains(wrongResponse.Body.String(), "用户名或密码错误") {
|
||||
t.Fatalf("错误密码响应 = %d %s", wrongResponse.Code, wrongResponse.Body.String())
|
||||
}
|
||||
|
||||
login := postFormRequest("/login", url.Values{
|
||||
"csrf_token": {csrfCookie.Value}, "username": {"admin"},
|
||||
"password": {"safe-password"}, "next": {"/pdd"},
|
||||
}, csrfCookie)
|
||||
loginResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(loginResponse, login)
|
||||
if loginResponse.Code != http.StatusSeeOther || loginResponse.Header().Get("Location") != "/pdd" {
|
||||
t.Fatalf("POST /login = %d %s", loginResponse.Code, loginResponse.Header().Get("Location"))
|
||||
}
|
||||
authCookie := findResponseCookie(t, loginResponse, "cmautobuy_session")
|
||||
if !authCookie.HttpOnly || authCookie.Secure || authCookie.SameSite != http.SameSiteLaxMode ||
|
||||
authCookie.Path != "/" || authCookie.MaxAge != 12*60*60 {
|
||||
t.Fatalf("认证 Cookie 属性不正确: %#v", authCookie)
|
||||
}
|
||||
|
||||
protected := httptest.NewRequest(http.MethodGet, "/shopee", nil)
|
||||
protected.AddCookie(authCookie)
|
||||
protectedResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(protectedResponse, protected)
|
||||
if protectedResponse.Code != http.StatusOK || !strings.Contains(protectedResponse.Body.String(), "admin") ||
|
||||
!strings.Contains(protectedResponse.Body.String(), "退出登录") {
|
||||
t.Fatalf("登录后业务页 = %d,响应:%s", protectedResponse.Code, protectedResponse.Body.String())
|
||||
}
|
||||
|
||||
logout := postFormRequest("/logout", url.Values{"csrf_token": {csrfCookie.Value}}, csrfCookie)
|
||||
logout.AddCookie(authCookie)
|
||||
logoutResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(logoutResponse, logout)
|
||||
if logoutResponse.Code != http.StatusSeeOther || !strings.HasPrefix(logoutResponse.Header().Get("Location"), "/login?") {
|
||||
t.Fatalf("POST /logout = %d %s", logoutResponse.Code, logoutResponse.Header().Get("Location"))
|
||||
}
|
||||
|
||||
oldSession := httptest.NewRequest(http.MethodGet, "/shopee", nil)
|
||||
oldSession.AddCookie(authCookie)
|
||||
oldSessionResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(oldSessionResponse, oldSession)
|
||||
if oldSessionResponse.Code != http.StatusSeeOther || !strings.HasPrefix(oldSessionResponse.Header().Get("Location"), "/login?next=") {
|
||||
t.Fatalf("退出后旧 Session 仍可访问: %d %s", oldSessionResponse.Code, oldSessionResponse.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAPI不经过网页登录和CSRF(t *testing.T) {
|
||||
db, err := repository.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := repository.Migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router, err := newRouter(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodPut, "/api/v1/client/registration", strings.NewReader(`{}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "MISSING_CLIENT_ID") {
|
||||
t.Fatalf("Client API 不应被网页登录或 CSRF 拦截: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func postFormRequest(path string, form url.Values, cookies ...*http.Cookie) *http.Request {
|
||||
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, cookie := range cookies {
|
||||
if cookie != nil {
|
||||
request.AddCookie(cookie)
|
||||
}
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func findResponseCookie(t *testing.T, response *httptest.ResponseRecorder, name string) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, cookie := range response.Result().Cookies() {
|
||||
if cookie.Name == name {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
t.Fatalf("响应缺少 Cookie %s", name)
|
||||
return nil
|
||||
}
|
||||
+1
-1
@@ -15,6 +15,7 @@ require (
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/goccy/go-yaml v1.18.0
|
||||
github.com/xuri/excelize/v2 v2.9.1
|
||||
golang.org/x/crypto v0.40.0
|
||||
modernc.org/sqlite v1.38.0
|
||||
)
|
||||
|
||||
@@ -50,7 +51,6 @@ require (
|
||||
github.com/xuri/nfp v0.0.1 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
"cmautobuy/admin/service"
|
||||
)
|
||||
|
||||
const (
|
||||
authCookieName = "cmautobuy_session"
|
||||
currentUserKey = "cmautobuy_current_user"
|
||||
)
|
||||
|
||||
// SetupPage 只在数据库完全没有用户时显示首次管理员表单。
|
||||
func (h *Handler) SetupPage(c *gin.Context) {
|
||||
hasUsers, err := service.HasUsers(h.db)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "检查管理员初始化状态失败,数据没有被改动。刷新后重试。")
|
||||
return
|
||||
}
|
||||
if hasUsers {
|
||||
c.Redirect(http.StatusSeeOther, "/login?msg="+url.QueryEscape("管理员已经初始化,请登录"))
|
||||
return
|
||||
}
|
||||
h.renderSetup(c, http.StatusOK, "", "admin")
|
||||
}
|
||||
|
||||
// SetupSubmit 创建首位管理员。密码无论成功失败都不回显。
|
||||
func (h *Handler) SetupSubmit(c *gin.Context) {
|
||||
username := strings.TrimSpace(c.PostForm("username"))
|
||||
err := service.SetupInitialAdmin(
|
||||
h.db, username, c.PostForm("password"), c.PostForm("password_confirm"), time.Now())
|
||||
if errors.Is(err, repository.ErrUsersAlreadyExist) {
|
||||
c.Redirect(http.StatusSeeOther, "/login?msg="+url.QueryEscape("管理员已经初始化,请登录"))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
h.renderSetup(c, http.StatusBadRequest, err.Error(), username)
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, "/login?msg="+url.QueryEscape("管理员创建成功,请登录"))
|
||||
}
|
||||
|
||||
func (h *Handler) renderSetup(c *gin.Context, status int, message, username string) {
|
||||
c.HTML(status, "auth/setup", gin.H{
|
||||
"Title": "初始化管理员", "CSRFToken": csrfToken(c),
|
||||
"Message": message, "Username": username,
|
||||
})
|
||||
}
|
||||
|
||||
// LoginPage 显示登录页。没有用户时先去初始化;已有有效 Session 时直接返回目标页。
|
||||
func (h *Handler) LoginPage(c *gin.Context) {
|
||||
hasUsers, err := service.HasUsers(h.db)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "检查登录状态失败。刷新后重试。")
|
||||
return
|
||||
}
|
||||
if !hasUsers {
|
||||
c.Redirect(http.StatusSeeOther, "/setup")
|
||||
return
|
||||
}
|
||||
next := safeNext(c.Query("next"))
|
||||
if token, err := c.Cookie(authCookieName); err == nil {
|
||||
if _, authErr := service.Authenticate(h.db, token, time.Now()); authErr == nil {
|
||||
c.Redirect(http.StatusSeeOther, next)
|
||||
return
|
||||
}
|
||||
}
|
||||
h.renderLogin(c, http.StatusOK, c.Query("msg"), "", next)
|
||||
}
|
||||
|
||||
// LoginSubmit 校验凭据。用户名不存在、密码错误和账号禁用使用同一句提示。
|
||||
func (h *Handler) LoginSubmit(c *gin.Context) {
|
||||
username := strings.TrimSpace(c.PostForm("username"))
|
||||
next := safeNext(c.PostForm("next"))
|
||||
token, _, expiresAt, err := service.Login(h.db, username, c.PostForm("password"), time.Now())
|
||||
if errors.Is(err, service.ErrInvalidCredentials) {
|
||||
h.renderLogin(c, http.StatusUnauthorized, service.ErrInvalidCredentials.Error(), username, next)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "登录失败,但账号数据没有被修改。请稍后重试。")
|
||||
return
|
||||
}
|
||||
setAuthCookie(c, token, expiresAt)
|
||||
c.Redirect(http.StatusSeeOther, next)
|
||||
}
|
||||
|
||||
func (h *Handler) renderLogin(c *gin.Context, status int, message, username, next string) {
|
||||
c.HTML(status, "auth/login", gin.H{
|
||||
"Title": "登录 Admin", "CSRFToken": csrfToken(c),
|
||||
"Message": message, "Username": username, "Next": safeNext(next),
|
||||
})
|
||||
}
|
||||
|
||||
// Logout 撤销服务端 Session 并清除浏览器 Cookie。退出必须走 POST + CSRF。
|
||||
func (h *Handler) Logout(c *gin.Context) {
|
||||
token, _ := c.Cookie(authCookieName)
|
||||
if err := service.Logout(h.db, token); err != nil {
|
||||
fail(c, http.StatusInternalServerError, "退出登录失败,请刷新页面后重试。")
|
||||
return
|
||||
}
|
||||
clearAuthCookie(c)
|
||||
c.Redirect(http.StatusSeeOther, "/login?msg="+url.QueryEscape("已退出登录"))
|
||||
}
|
||||
|
||||
// AuthRequired 只挂在 Web 业务路由组。Client API 注册在另一个组,不能经过这里。
|
||||
func AuthRequired(db *sql.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token, _ := c.Cookie(authCookieName)
|
||||
user, err := service.Authenticate(db, token, time.Now())
|
||||
if err == nil {
|
||||
c.Set(currentUserKey, user)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, service.ErrUnauthenticated) {
|
||||
fail(c, http.StatusInternalServerError, "检查登录状态失败。刷新页面后重试。")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
hasUsers, countErr := service.HasUsers(db)
|
||||
if countErr != nil {
|
||||
fail(c, http.StatusInternalServerError, "检查管理员初始化状态失败。刷新页面后重试。")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !hasUsers {
|
||||
c.Redirect(http.StatusSeeOther, "/setup")
|
||||
} else {
|
||||
next := safeNext(c.Request.URL.RequestURI())
|
||||
c.Redirect(http.StatusSeeOther, "/login?next="+url.QueryEscape(next))
|
||||
}
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
func currentUser(c *gin.Context) *model.User {
|
||||
value, ok := c.Get(currentUserKey)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
user, _ := value.(*model.User)
|
||||
return user
|
||||
}
|
||||
|
||||
// safeNext 只接受本站绝对路径,阻止登录成功后跳转到外部网站。
|
||||
func safeNext(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
|
||||
return "/shopee"
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.IsAbs() || parsed.Host != "" {
|
||||
return "/shopee"
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func setAuthCookie(c *gin.Context, token string, expiresAt time.Time) {
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: authCookieName, Value: token, Path: "/", HttpOnly: true,
|
||||
Secure: c.Request.TLS != nil, SameSite: http.SameSiteLaxMode,
|
||||
Expires: expiresAt, MaxAge: int(service.WebSessionDuration.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func clearAuthCookie(c *gin.Context) {
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: authCookieName, Value: "", Path: "/", HttpOnly: true,
|
||||
Secure: c.Request.TLS != nil, SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Unix(1, 0), MaxAge: -1,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestSafeNext只允许本站绝对路径(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{"/pdd?status=pending", "/pdd?status=pending"},
|
||||
{"", "/shopee"},
|
||||
{"https://example.com", "/shopee"},
|
||||
{"//example.com/path", "/shopee"},
|
||||
{"pdd", "/shopee"},
|
||||
} {
|
||||
if got := safeNext(test.raw); got != test.want {
|
||||
t.Errorf("safeNext(%q) = %q,期望 %q", test.raw, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCookie安全属性(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
tls bool
|
||||
secure bool
|
||||
}{
|
||||
{"HTTP", false, false},
|
||||
{"HTTPS", true, true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
if test.tls {
|
||||
request.TLS = &tls.ConnectionState{}
|
||||
}
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
setAuthCookie(context, "raw-token", time.Now().Add(12*time.Hour))
|
||||
|
||||
cookies := response.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("Set-Cookie 数量 = %d,期望 1", len(cookies))
|
||||
}
|
||||
cookie := cookies[0]
|
||||
if cookie.Name != authCookieName || cookie.Value != "raw-token" || cookie.Path != "/" {
|
||||
t.Errorf("Cookie 名称、值或 Path 不正确: %#v", cookie)
|
||||
}
|
||||
if !cookie.HttpOnly || cookie.SameSite != http.SameSiteLaxMode || cookie.Secure != test.secure {
|
||||
t.Errorf("Cookie 安全属性不正确: %#v", cookie)
|
||||
}
|
||||
if cookie.MaxAge != 12*60*60 {
|
||||
t.Errorf("Cookie MaxAge = %d,期望 43200", cookie.MaxAge)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,17 @@ type Handler struct {
|
||||
func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
|
||||
h := &Handler{db: db, onlineThreshold: onlineThreshold}
|
||||
|
||||
// CSRF 只挂在页面路由上。
|
||||
// CSRF 只挂在页面路由上。初始化和登录是公开页面,但 POST 仍要 CSRF。
|
||||
// 给 Client 的 /api/v1/client/* 绝不能加——它不是浏览器、没有 Cookie。
|
||||
pages := r.Group("/", CSRFMiddleware())
|
||||
public := r.Group("/", CSRFMiddleware())
|
||||
public.GET("/setup", h.SetupPage)
|
||||
public.POST("/setup", h.SetupSubmit)
|
||||
public.GET("/login", h.LoginPage)
|
||||
public.POST("/login", h.LoginSubmit)
|
||||
|
||||
// 登录中间件只挂业务网页组,绝不能挂在整个 Engine。
|
||||
pages := r.Group("/", CSRFMiddleware(), AuthRequired(db))
|
||||
pages.POST("/logout", h.Logout)
|
||||
|
||||
// 打开根路径直接进第一个模块
|
||||
pages.GET("/", func(c *gin.Context) {
|
||||
@@ -80,9 +88,10 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
|
||||
// page 组装每个页面都要的公共数据(导航高亮、标题、CSRF token)。
|
||||
func page(c *gin.Context, active, title string, extra gin.H) gin.H {
|
||||
data := gin.H{
|
||||
"Active": active,
|
||||
"Title": title,
|
||||
"CSRFToken": csrfToken(c),
|
||||
"Active": active,
|
||||
"Title": title,
|
||||
"CSRFToken": csrfToken(c),
|
||||
"CurrentUser": currentUser(c),
|
||||
}
|
||||
for k, v := range extra {
|
||||
data[k] = v
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmautobuy/admin/repository"
|
||||
"cmautobuy/admin/service"
|
||||
)
|
||||
|
||||
// 五个主页面都走一次真实路由和模板渲染。
|
||||
@@ -26,9 +28,31 @@ func TestMainPagesReturnOK(t *testing.T) {
|
||||
t.Fatalf("组装路由失败: %v", err)
|
||||
}
|
||||
|
||||
// 全新库的业务页必须先去初始化,不能匿名打开。
|
||||
unauthenticated := httptest.NewRequest(http.MethodGet, "/shopee", nil)
|
||||
unauthenticatedResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(unauthenticatedResponse, unauthenticated)
|
||||
if unauthenticatedResponse.Code != http.StatusSeeOther ||
|
||||
unauthenticatedResponse.Header().Get("Location") != "/setup" {
|
||||
t.Fatalf("全新库访问业务页应跳转 /setup,实际 %d %s",
|
||||
unauthenticatedResponse.Code, unauthenticatedResponse.Header().Get("Location"))
|
||||
}
|
||||
|
||||
if err := service.SetupInitialAdmin(db, "admin", "test-password", "test-password", time.Now()); err != nil {
|
||||
t.Fatalf("准备测试管理员失败: %v", err)
|
||||
}
|
||||
token, _, _, err := service.Login(db, "admin", "test-password", time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("准备测试登录 Session 失败: %v", err)
|
||||
}
|
||||
addAuth := func(request *http.Request) {
|
||||
request.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: token})
|
||||
}
|
||||
|
||||
for _, path := range []string{"/shopee", "/pdd", "/syb", "/tasks", "/clients"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
addAuth(request)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
@@ -39,6 +63,7 @@ func TestMainPagesReturnOK(t *testing.T) {
|
||||
}
|
||||
|
||||
sybRequest := httptest.NewRequest(http.MethodGet, "/syb", nil)
|
||||
addAuth(sybRequest)
|
||||
sybResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(sybResponse, sybRequest)
|
||||
for _, want := range []string{
|
||||
@@ -72,6 +97,7 @@ func TestMainPagesReturnOK(t *testing.T) {
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/pdd/detail?id=1", nil)
|
||||
addAuth(request)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
|
||||
@@ -46,6 +46,48 @@ func ParseISO(s string) (time.Time, bool) {
|
||||
return ts, true
|
||||
}
|
||||
|
||||
// ---------- Admin 用户与网页登录 ----------
|
||||
|
||||
// UserRole 是 Admin 网页账号的固定角色。本阶段不做可配置 RBAC。
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
RoleAdmin UserRole = "admin"
|
||||
RolePurchaser UserRole = "purchaser"
|
||||
)
|
||||
|
||||
// UserStatus 表示账号是否允许登录。
|
||||
type UserStatus string
|
||||
|
||||
const (
|
||||
UserActive UserStatus = "active"
|
||||
UserDisabled UserStatus = "disabled"
|
||||
)
|
||||
|
||||
// User 是一个 Admin 网页账号。PasswordHash 只在认证服务内部使用,
|
||||
// 模板和日志都不得输出它。
|
||||
type User struct {
|
||||
UserID string
|
||||
Username string
|
||||
PasswordHash string
|
||||
Role UserRole
|
||||
Status UserStatus
|
||||
LastLoginAt string
|
||||
PasswordChangedAt string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// WebSession 是服务端保存的网页登录状态。SessionHash 是浏览器随机 Token
|
||||
// 的 SHA-256,不是 Token 原文。
|
||||
type WebSession struct {
|
||||
SessionHash string
|
||||
UserID string
|
||||
ExpiresAt string
|
||||
CreatedAt string
|
||||
LastSeenAt string
|
||||
}
|
||||
|
||||
// ---------- 蝦皮 ----------
|
||||
|
||||
// CollectStatus 是一个 **PDD 商品**的采集进度。
|
||||
|
||||
+36
-1
@@ -264,7 +264,7 @@ var migrations = [][]string{
|
||||
// 背景见 #20:v1 曾经被原地改写而不是新增版本,导致已经建过库的机器
|
||||
// (user_version 已经越过 v1)永远不会重跑改写后的语句,程序拿着一个
|
||||
// 和代码对不上的库静默启动。
|
||||
const schemaVersion = 5
|
||||
const schemaVersion = 6
|
||||
|
||||
// migrationV4 给 PDD 商品增加店铺名。
|
||||
//
|
||||
@@ -300,6 +300,32 @@ var migrationV5 = []string{
|
||||
`ALTER TABLE syb_orders ADD COLUMN product_spec TEXT;`,
|
||||
}
|
||||
|
||||
// migrationV6 增加 Admin 网页账号和 Session。只追加新表和索引,
|
||||
// v1-v5 的任何语句都不修改,见工单 #50。
|
||||
var migrationV6 = []string{
|
||||
`CREATE TABLE users (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('admin', 'purchaser')),
|
||||
status TEXT NOT NULL CHECK (status IN ('active', 'disabled')),
|
||||
last_login_at TEXT,
|
||||
password_changed_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);`,
|
||||
`CREATE TABLE web_sessions (
|
||||
session_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id)
|
||||
);`,
|
||||
`CREATE INDEX idx_web_sessions_user ON web_sessions(user_id);`,
|
||||
`CREATE INDEX idx_web_sessions_expiry ON web_sessions(expires_at);`,
|
||||
}
|
||||
|
||||
// Migrate 把数据库升到最新版本。
|
||||
// 已经是最新的就什么都不做,可以重复调用。
|
||||
func Migrate(db *sql.DB) error {
|
||||
@@ -362,6 +388,14 @@ func Migrate(db *sql.DB) error {
|
||||
if err := runSQLMigration(db, 5, migrationV5); err != nil {
|
||||
return err
|
||||
}
|
||||
reached = 5
|
||||
}
|
||||
|
||||
// v6 是纯追加的用户和 Web Session 表。
|
||||
if reached < 6 {
|
||||
if err := runSQLMigration(db, 6, migrationV6); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -823,6 +857,7 @@ var requiredTables = []string{
|
||||
"syb_orders", "sku_mappings", "tasks", "clients",
|
||||
"idempotency_keys", "task_claims",
|
||||
"syb_session", "syb_sync_state",
|
||||
"users", "web_sessions",
|
||||
}
|
||||
|
||||
// requiredColumns 只列出不能靠“表存在”发现的关键追加列。
|
||||
|
||||
@@ -593,6 +593,36 @@ func TestMigrate_v5新增会话表同步状态表和product_spec列(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_v6新增用户和WebSession表(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
db *sql.DB
|
||||
}{
|
||||
{"全新库", newFreshDB(t)},
|
||||
{"v2 老结构库", newV2DB(t)},
|
||||
{"v2 新结构库", newV2NewStructureDB(t)},
|
||||
} {
|
||||
if c.name != "全新库" {
|
||||
if err := Migrate(c.db); err != nil {
|
||||
t.Fatalf("%s 迁移失败: %v", c.name, err)
|
||||
}
|
||||
}
|
||||
tables := existingTableSet(t, c.db)
|
||||
for _, table := range []string{"users", "web_sessions"} {
|
||||
if !tables[table] {
|
||||
t.Errorf("%s:迁移后应该有表 %s", c.name, table)
|
||||
}
|
||||
}
|
||||
var version int
|
||||
if err := c.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
|
||||
t.Fatalf("%s 读取 user_version 失败: %v", c.name, err)
|
||||
}
|
||||
if version != 6 {
|
||||
t.Errorf("%s user_version = %d,期望 6", c.name, version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// existingTableSet 返回库里当前存在的表名集合。
|
||||
func existingTableSet(t *testing.T, db *sql.DB) map[string]bool {
|
||||
t.Helper()
|
||||
@@ -1027,11 +1057,14 @@ func TestCheckSchema_缺少v4关键列时拒绝(t *testing.T) {
|
||||
if err := migrateV3(db); err != nil {
|
||||
t.Fatalf("准备 v3 数据库失败: %v", err)
|
||||
}
|
||||
// 故意跳过 v4(不加 shop_name),但把 v5 补上——否则 CheckSchema 会先
|
||||
// 因为缺 v5 的表报错,测不到本测试真正要覆盖的"缺 shop_name"这条路径。
|
||||
// 故意跳过 v4(不加 shop_name),但把 v5/v6 补上——否则 CheckSchema 会先
|
||||
// 因为缺后续表报错,测不到本测试真正要覆盖的"缺 shop_name"这条路径。
|
||||
if err := runSQLMigration(db, 5, migrationV5); err != nil {
|
||||
t.Fatalf("准备 v5 数据库失败: %v", err)
|
||||
}
|
||||
if err := runSQLMigration(db, 6, migrationV6); err != nil {
|
||||
t.Fatalf("准备 v6 数据库失败: %v", err)
|
||||
}
|
||||
|
||||
err := CheckSchema(db)
|
||||
if err == nil || !strings.Contains(err.Error(), "shop_name") {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// Admin 网页用户与 Session 的数据库读写。
|
||||
package repository
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrUsersAlreadyExist 表示首次初始化入口已经永久关闭。
|
||||
ErrUsersAlreadyExist = errors.New("管理员已经初始化")
|
||||
ErrUserNotFound = errors.New("用户不存在")
|
||||
ErrSessionNotFound = errors.New("网页登录会话不存在")
|
||||
)
|
||||
|
||||
// CountUsers 返回所有用户数量,disabled 也算。即使所有账号被禁用,
|
||||
// 首次初始化入口也不能重新开放。
|
||||
func CountUsers(q Execer) (int, error) {
|
||||
var count int
|
||||
if err := q.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("统计 Admin 用户失败: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// CreateInitialAdmin 在写事务里再次确认 users 为空后创建首位管理员。
|
||||
// Open 的 _txlock=immediate 让并发事务从一开始就串行,最多一个能成功。
|
||||
func CreateInitialAdmin(db *sql.DB, user model.User) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始初始化管理员事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
count, err := CountUsers(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 0 {
|
||||
return ErrUsersAlreadyExist
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO users
|
||||
(user_id, username, password_hash, role, status, last_login_at,
|
||||
password_changed_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)`,
|
||||
user.UserID, user.Username, user.PasswordHash, user.Role, user.Status,
|
||||
user.PasswordChangedAt, user.CreatedAt, user.UpdatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建首位管理员失败: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("提交初始化管理员事务失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindUserByUsername 使用 users.username 的 NOCASE 规则查账号。
|
||||
func FindUserByUsername(q Execer, username string) (*model.User, error) {
|
||||
var user model.User
|
||||
var lastLogin sql.NullString
|
||||
err := q.QueryRow(`
|
||||
SELECT user_id, username, password_hash, role, status, last_login_at,
|
||||
password_changed_at, created_at, updated_at
|
||||
FROM users WHERE username = ?`, username,
|
||||
).Scan(&user.UserID, &user.Username, &user.PasswordHash, &user.Role, &user.Status,
|
||||
&lastLogin, &user.PasswordChangedAt, &user.CreatedAt, &user.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询 Admin 用户失败: %w", err)
|
||||
}
|
||||
user.LastLoginAt = lastLogin.String
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// CreateLoginSession 原子写入新 Session 并记录最后登录时间。
|
||||
func CreateLoginSession(db *sql.DB, session model.WebSession, loginAt string) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始登录事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO web_sessions
|
||||
(session_hash, user_id, expires_at, created_at, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
session.SessionHash, session.UserID, session.ExpiresAt,
|
||||
session.CreatedAt, session.LastSeenAt); err != nil {
|
||||
return fmt.Errorf("保存 Web Session 失败: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
UPDATE users SET last_login_at = ?, updated_at = ? WHERE user_id = ?`,
|
||||
loginAt, loginAt, session.UserID); err != nil {
|
||||
return fmt.Errorf("更新用户最后登录时间失败: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("提交登录事务失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindUserBySessionHash 返回 Session 及其用户。账号状态由 service 判断,
|
||||
// 这样“禁用账号立即失效”不依赖额外清理是否及时。
|
||||
func FindUserBySessionHash(q Execer, sessionHash string) (*model.WebSession, *model.User, error) {
|
||||
var session model.WebSession
|
||||
var user model.User
|
||||
var lastLogin sql.NullString
|
||||
err := q.QueryRow(`
|
||||
SELECT s.session_hash, s.user_id, s.expires_at, s.created_at, s.last_seen_at,
|
||||
u.user_id, u.username, u.password_hash, u.role, u.status,
|
||||
u.last_login_at, u.password_changed_at, u.created_at, u.updated_at
|
||||
FROM web_sessions s
|
||||
JOIN users u ON u.user_id = s.user_id
|
||||
WHERE s.session_hash = ?`, sessionHash,
|
||||
).Scan(&session.SessionHash, &session.UserID, &session.ExpiresAt,
|
||||
&session.CreatedAt, &session.LastSeenAt,
|
||||
&user.UserID, &user.Username, &user.PasswordHash, &user.Role, &user.Status,
|
||||
&lastLogin, &user.PasswordChangedAt, &user.CreatedAt, &user.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil, ErrSessionNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("查询 Web Session 失败: %w", err)
|
||||
}
|
||||
user.LastLoginAt = lastLogin.String
|
||||
return &session, &user, nil
|
||||
}
|
||||
|
||||
// DeleteSession 删除当前浏览器对应的 Session。不存在也视为成功,退出幂等。
|
||||
func DeleteSession(q Execer, sessionHash string) error {
|
||||
if _, err := q.Exec(`DELETE FROM web_sessions WHERE session_hash = ?`, sessionHash); err != nil {
|
||||
return fmt.Errorf("删除 Web Session 失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteExpiredSessions 清理固定过期的 Session,不需要后台常驻线程。
|
||||
func DeleteExpiredSessions(q Execer, nowISO string) error {
|
||||
if _, err := q.Exec(`DELETE FROM web_sessions WHERE expires_at <= ?`, nowISO); err != nil {
|
||||
return fmt.Errorf("清理过期 Web Session 失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Admin 网页认证:首次管理员、密码校验、随机 Session 和过期判断。
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
WebSessionDuration = 12 * time.Hour
|
||||
minimumPasswordLen = 8
|
||||
maxUsernameLen = 64
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("用户名或密码错误")
|
||||
ErrUnauthenticated = errors.New("未登录或登录已过期")
|
||||
|
||||
// 用户不存在时也跑一次 bcrypt,避免响应时间直接泄露“这个用户名存在”。
|
||||
dummyPasswordHash, _ = bcrypt.GenerateFromPassword([]byte("not-a-real-password"), bcrypt.DefaultCost)
|
||||
)
|
||||
|
||||
// HasUsers 判断首次初始化入口是否已经永久关闭。
|
||||
func HasUsers(db *sql.DB) (bool, error) {
|
||||
count, err := repository.CountUsers(db)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// SetupInitialAdmin 校验表单、哈希密码并在事务中创建首位管理员。
|
||||
func SetupInitialAdmin(db *sql.DB, username, password, confirmation string, now time.Time) error {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return fmt.Errorf("用户名不能为空")
|
||||
}
|
||||
if len([]rune(username)) > maxUsernameLen {
|
||||
return fmt.Errorf("用户名不能超过 %d 个字符", maxUsernameLen)
|
||||
}
|
||||
if len([]rune(password)) < minimumPasswordLen {
|
||||
return fmt.Errorf("密码至少需要 %d 个字符", minimumPasswordLen)
|
||||
}
|
||||
// bcrypt 最多接受 72 字节。中文等字符可能占多个字节,因此不能只靠
|
||||
// HTML 的 maxlength;服务端需要在哈希前给出可理解的校验错误。
|
||||
if len([]byte(password)) > 72 {
|
||||
return fmt.Errorf("密码不能超过 72 个字节")
|
||||
}
|
||||
if password != confirmation {
|
||||
return fmt.Errorf("两次输入的密码不一致")
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("生成密码哈希失败: %w", err)
|
||||
}
|
||||
userID, err := randomID("USR-", 16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
at := now.UTC().Format(model.TimeLayout)
|
||||
return repository.CreateInitialAdmin(db, model.User{
|
||||
UserID: userID, Username: username, PasswordHash: string(hash),
|
||||
Role: model.RoleAdmin, Status: model.UserActive,
|
||||
PasswordChangedAt: at, CreatedAt: at, UpdatedAt: at,
|
||||
})
|
||||
}
|
||||
|
||||
// Login 校验统一凭据并创建一个固定 12 小时有效的 Session。
|
||||
// 返回的 token 原文只交给 Cookie,数据库仅保存 SHA-256。
|
||||
func Login(db *sql.DB, username, password string, now time.Time) (token string, user *model.User, expiresAt time.Time, err error) {
|
||||
username = strings.TrimSpace(username)
|
||||
user, findErr := repository.FindUserByUsername(db, username)
|
||||
hash := dummyPasswordHash
|
||||
if findErr == nil {
|
||||
hash = []byte(user.PasswordHash)
|
||||
} else if !errors.Is(findErr, repository.ErrUserNotFound) {
|
||||
return "", nil, time.Time{}, findErr
|
||||
}
|
||||
passwordOK := bcrypt.CompareHashAndPassword(hash, []byte(password)) == nil
|
||||
if findErr != nil || !passwordOK || user.Status != model.UserActive {
|
||||
return "", nil, time.Time{}, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return "", nil, time.Time{}, fmt.Errorf("生成 Web Session 失败: %w", err)
|
||||
}
|
||||
token = base64.RawURLEncoding.EncodeToString(tokenBytes)
|
||||
expiresAt = now.Add(WebSessionDuration).UTC()
|
||||
at := now.UTC().Format(model.TimeLayout)
|
||||
if err := repository.DeleteExpiredSessions(db, at); err != nil {
|
||||
return "", nil, time.Time{}, err
|
||||
}
|
||||
if err := repository.CreateLoginSession(db, model.WebSession{
|
||||
SessionHash: SessionTokenHash(token), UserID: user.UserID,
|
||||
ExpiresAt: expiresAt.Format(model.TimeLayout), CreatedAt: at, LastSeenAt: at,
|
||||
}, at); err != nil {
|
||||
return "", nil, time.Time{}, err
|
||||
}
|
||||
return token, user, expiresAt, nil
|
||||
}
|
||||
|
||||
// Authenticate 验证 Cookie Token、固定过期时间和账号状态。
|
||||
func Authenticate(db *sql.DB, token string, now time.Time) (*model.User, error) {
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
session, user, err := repository.FindUserBySessionHash(db, SessionTokenHash(token))
|
||||
if errors.Is(err, repository.ErrSessionNotFound) {
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expiresAt, ok := model.ParseISO(session.ExpiresAt)
|
||||
if !ok || !now.UTC().Before(expiresAt) || user.Status != model.UserActive {
|
||||
_ = repository.DeleteSession(db, session.SessionHash)
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Logout 撤销当前 Token 对应的服务端 Session。空 Token 也视为成功。
|
||||
func Logout(db *sql.DB, token string) error {
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
return repository.DeleteSession(db, SessionTokenHash(token))
|
||||
}
|
||||
|
||||
// SessionTokenHash 返回数据库可保存的 Token SHA-256 十六进制文本。
|
||||
func SessionTokenHash(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func randomID(prefix string, byteCount int) (string, error) {
|
||||
buf := make([]byte, byteCount)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("生成用户编号失败: %w", err)
|
||||
}
|
||||
return prefix + hex.EncodeToString(buf), nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
func TestSetupInitialAdmin_保存哈希且入口永久关闭(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
now := time.Date(2026, 8, 9, 6, 0, 0, 0, time.UTC)
|
||||
if err := SetupInitialAdmin(db, "admin", "safe-password", "safe-password", now); err != nil {
|
||||
t.Fatalf("初始化管理员失败: %v", err)
|
||||
}
|
||||
user, err := repository.FindUserByUsername(db, "ADMIN") // username 是 NOCASE
|
||||
if err != nil {
|
||||
t.Fatalf("读取管理员失败: %v", err)
|
||||
}
|
||||
if user.PasswordHash == "safe-password" || strings.Contains(user.PasswordHash, "safe-password") {
|
||||
t.Fatal("数据库不能保存或包含明文密码")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte("safe-password")); err != nil {
|
||||
t.Fatalf("密码哈希不能验证原密码: %v", err)
|
||||
}
|
||||
if err := SetupInitialAdmin(db, "second", "other-password", "other-password", now); !errors.Is(err, repository.ErrUsersAlreadyExist) {
|
||||
t.Fatalf("已有用户后初始化入口应永久关闭,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupInitialAdmin_校验密码字符数和Bcrypt字节上限(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
password string
|
||||
want string
|
||||
}{
|
||||
{"少于八个字符", "1234567", "至少需要 8 个字符"},
|
||||
{"超过bcrypt字节上限", strings.Repeat("密", 25), "不能超过 72 个字节"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
err := SetupInitialAdmin(db, "admin", test.password, test.password, time.Now())
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("校验错误 = %v,期望包含 %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupInitialAdmin_并发最多一个成功(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
start := make(chan struct{})
|
||||
errorsOut := make(chan error, 2)
|
||||
var wg sync.WaitGroup
|
||||
for _, username := range []string{"admin-a", "admin-b"} {
|
||||
wg.Add(1)
|
||||
go func(name string) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
errorsOut <- SetupInitialAdmin(db, name, "safe-password", "safe-password", time.Now())
|
||||
}(username)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errorsOut)
|
||||
|
||||
successes := 0
|
||||
for err := range errorsOut {
|
||||
if err == nil {
|
||||
successes++
|
||||
continue
|
||||
}
|
||||
if !errors.Is(err, repository.ErrUsersAlreadyExist) {
|
||||
t.Errorf("失败请求应明确提示已初始化,实际 %v", err)
|
||||
}
|
||||
}
|
||||
if successes != 1 {
|
||||
t.Fatalf("并发初始化应恰好一个成功,实际 %d", successes)
|
||||
}
|
||||
count, _ := repository.CountUsers(db)
|
||||
if count != 1 {
|
||||
t.Fatalf("并发初始化后数据库应只有一个用户,实际 %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAuthenticateLogout_数据库只存Token哈希(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
now := time.Date(2026, 8, 9, 6, 0, 0, 0, time.UTC)
|
||||
if err := SetupInitialAdmin(db, "admin", "safe-password", "safe-password", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, user, expiresAt, err := Login(db, "admin", "safe-password", now)
|
||||
if err != nil {
|
||||
t.Fatalf("登录失败: %v", err)
|
||||
}
|
||||
if len(token) < 40 || expiresAt.Sub(now) != WebSessionDuration {
|
||||
t.Fatalf("Session Token 或 12 小时过期时间不正确")
|
||||
}
|
||||
var stored string
|
||||
if err := db.QueryRow(`SELECT session_hash FROM web_sessions`).Scan(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored == token || stored != SessionTokenHash(token) || len(stored) != 64 {
|
||||
t.Fatalf("数据库应只保存 64 位 SHA-256,stored=%q token=%q", stored, token)
|
||||
}
|
||||
got, err := Authenticate(db, token, now.Add(time.Hour))
|
||||
if err != nil || got.UserID != user.UserID {
|
||||
t.Fatalf("有效 Session 认证失败: user=%+v err=%v", got, err)
|
||||
}
|
||||
if err := Logout(db, token); err != nil {
|
||||
t.Fatalf("退出失败: %v", err)
|
||||
}
|
||||
if _, err := Authenticate(db, token, now.Add(time.Hour)); !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("退出后旧 Token 不应继续有效,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogin_错误凭据和禁用账号统一提示(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
if err := SetupInitialAdmin(db, "admin", "safe-password", "safe-password", time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, password := range map[string]string{
|
||||
"missing": "safe-password",
|
||||
"admin": "wrong-password",
|
||||
} {
|
||||
if _, _, _, err := Login(db, name, password, time.Now()); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Errorf("%s 登录错误应统一,实际 %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE users SET status = 'disabled' WHERE username = 'admin'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := Login(db, "admin", "safe-password", time.Now()); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Fatalf("禁用账号也应使用统一提示,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticate_过期Session被删除(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
now := time.Date(2026, 8, 9, 6, 0, 0, 0, time.UTC)
|
||||
if err := SetupInitialAdmin(db, "admin", "safe-password", "safe-password", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _, _, err := Login(db, "admin", "safe-password", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Authenticate(db, token, now.Add(WebSessionDuration)); !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("到期时刻 Session 必须失效,实际 %v", err)
|
||||
}
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM web_sessions`).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("过期 Session 应被清理,实际剩 %d", count)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,40 @@ body {
|
||||
}
|
||||
.nav a:hover { background: #3a4552; color: #fff; }
|
||||
.nav a.active { background: #1f6feb; color: #fff; }
|
||||
.nav-user {
|
||||
margin-left: auto;
|
||||
color: #fff;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.nav-logout { margin: 0; }
|
||||
.nav-logout button {
|
||||
color: #c8cdd4;
|
||||
border-color: #56616d;
|
||||
background: transparent;
|
||||
}
|
||||
.nav-logout button:hover { color: #fff; background: #3a4552; }
|
||||
|
||||
/* ── 首次初始化 / 登录 ────────────────── */
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
border: 1px solid #e1e4e8;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, .08);
|
||||
}
|
||||
.auth-card h1 { margin: 0 0 8px; font-size: 22px; }
|
||||
.auth-card form { display: grid; gap: 16px; margin-top: 20px; }
|
||||
.auth-card input { width: 100%; }
|
||||
.auth-card small { color: #777; }
|
||||
.auth-submit { width: 100%; min-height: 38px; }
|
||||
|
||||
/* ── 页面主体 ─────────────────────────── */
|
||||
.page { padding: 16px; }
|
||||
@@ -259,7 +293,7 @@ button.primary:hover { background: #1a5fd0; }
|
||||
/* 创建弹窗里的单个字段,标签在输入框上面 */
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field label { color: #555; }
|
||||
input[type="text"] {
|
||||
input[type="text"], input[type="password"] {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #ccd1d6;
|
||||
border-radius: 3px;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{{define "auth/login"}}<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} · 采集采购管理端</title>
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body class="auth-page">
|
||||
<main class="auth-card">
|
||||
<h1>登录 Admin</h1>
|
||||
<p class="hint">登录后进入采集采购管理端。</p>
|
||||
{{if .Message}}<p class="{{if eq .Message "用户名或密码错误"}}missing{{else}}hint{{end}}" role="status">{{.Message}}</p>{{end}}
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="next" value="{{.Next}}">
|
||||
<div class="field">
|
||||
<label for="login-username">用户名</label>
|
||||
<input id="login-username" type="text" name="username" value="{{.Username}}"
|
||||
maxlength="64" required autocomplete="username" autofocus>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="login-password">密码</label>
|
||||
<input id="login-password" type="password" name="password" maxlength="72"
|
||||
required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="primary auth-submit">登录</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
@@ -0,0 +1,36 @@
|
||||
{{define "auth/setup"}}<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} · 采集采购管理端</title>
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body class="auth-page">
|
||||
<main class="auth-card">
|
||||
<h1>初始化管理员</h1>
|
||||
<p class="hint">首次使用需要创建管理员。系统没有固定默认密码,此入口完成后永久关闭。</p>
|
||||
{{if .Message}}<p class="missing" role="alert">{{.Message}}</p>{{end}}
|
||||
<form method="post" action="/setup">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<div class="field">
|
||||
<label for="setup-username">用户名</label>
|
||||
<input id="setup-username" type="text" name="username" value="{{.Username}}"
|
||||
maxlength="64" required autocomplete="username" autofocus>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="setup-password">密码</label>
|
||||
<input id="setup-password" type="password" name="password" minlength="8"
|
||||
maxlength="72" required autocomplete="new-password">
|
||||
<small>至少 8 个字符;请使用只有你知道的密码。</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="setup-password-confirm">确认密码</label>
|
||||
<input id="setup-password-confirm" type="password" name="password_confirm"
|
||||
minlength="8" maxlength="72" required autocomplete="new-password">
|
||||
</div>
|
||||
<button type="submit" class="primary auth-submit">创建管理员</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
@@ -15,6 +15,13 @@
|
||||
<a href="/syb" class="{{if eq .Active "syb"}}active{{end}}">顺运宝数据</a>
|
||||
<a href="/tasks" class="{{if eq .Active "tasks"}}active{{end}}">采集采购</a>
|
||||
<a href="/clients" class="{{if eq .Active "clients"}}active{{end}}">客户端列表</a>
|
||||
{{if .CurrentUser}}
|
||||
<span class="nav-user">{{.CurrentUser.Username}}</span>
|
||||
<form class="nav-logout" method="post" action="/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit">退出登录</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</nav>
|
||||
|
||||
<main class="page">
|
||||
|
||||
@@ -95,7 +95,7 @@ SQLite 同一时刻只允许一个写事务,连接放太开会互相抢锁、
|
||||
| v3 | 把 PDD 采集数据从 `shopee_products` 拆到独立的 `pdd_products`(本文档 §4 描述的最终结构);重建 `shopee_products`,去掉已经搬走的四个字段;重建 `sku_mappings`,主键改成 `(shopee_sku_id, pdd_goods_id)`(§6.1 的理由)。 |
|
||||
| v4 | `pdd_products` 增加可空的 `shop_name`;老数据保持 `NULL`。 |
|
||||
| v5 | 顺运宝货运单同步(工单 #46):新增 `syb_session`(会话缓存)、`syb_sync_state`(同步进度)两张表;`syb_orders` 增加可空的 `product_spec`(规格原文)。三条都是新增,v1–v4 一个字节没改。 |
|
||||
| v6(规划) | Admin 网页登录:新增 `users` 和 `web_sessions`。正式实现时只能在迁移末尾追加,不得提前改写 v1–v5。 |
|
||||
| v6(#50) | Admin 网页登录:新增 `users` 和 `web_sessions`,只在迁移末尾追加,未改写 v1–v5。 |
|
||||
|
||||
**v3 为什么丢弃旧 `sku_mappings` 数据(见 #20):** 新主键需要 `pdd_option_key`,
|
||||
这是 Go 的 `service.OptionKey()` 用 `json.Marshal` 算出来的规范化键,SQL 语句
|
||||
@@ -709,7 +709,7 @@ Admin 和 Client **各有一个 SQLite,互不相通**,只通过接口交换
|
||||
`[必须]` **两边的状态是两套,不要试图同步。**
|
||||
Admin 只知道"发出去了 / 收到结果了",中间过程看不到,这是有意的设计。
|
||||
|
||||
## 12. Admin 用户和 Web Session(v6 规划)
|
||||
## 12. Admin 用户和 Web Session(v6)
|
||||
|
||||
`users` 保存网页登录账号。没有公开注册,第一条管理员记录只能由首次初始化流程创建。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user