feat: 实现 Admin 首次初始化与网页登录 (#50)

This commit is contained in:
chengma
2026-08-09 13:36:41 +08:00
parent 1064ffed59
commit 0a7db404a1
17 changed files with 1140 additions and 12 deletions
+183
View File
@@ -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,
})
}
+66
View File
@@ -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)
}
})
}
}
+14 -5
View File
@@ -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