feat: 实现 Admin 首次初始化与网页登录 (#50)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user