2026-08-09 13:36:41 +08:00
|
|
|
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("已退出登录"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:49:28 +08:00
|
|
|
// ChangePassword 修改当前管理员自己的密码;目标身份只取 Session,不读表单用户编号。
|
|
|
|
|
func (h *Handler) ChangePassword(c *gin.Context) {
|
|
|
|
|
returnPath := passwordReturnPath(c.PostForm("next"))
|
|
|
|
|
err := service.ChangeAdminPassword(h.db, currentUser(c), c.PostForm("current_password"),
|
|
|
|
|
c.PostForm("new_password"), c.PostForm("password_confirm"), time.Now())
|
|
|
|
|
if service.IsValidationError(err) {
|
|
|
|
|
query := url.Values{
|
|
|
|
|
"change_password": {"1"},
|
|
|
|
|
"password_error": {err.Error()},
|
|
|
|
|
}
|
|
|
|
|
if field := service.ValidationField(err); field != "" {
|
|
|
|
|
query.Set("password_field", field)
|
|
|
|
|
}
|
|
|
|
|
separator := "?"
|
|
|
|
|
if strings.Contains(returnPath, "?") {
|
|
|
|
|
separator = "&"
|
|
|
|
|
}
|
|
|
|
|
c.Redirect(http.StatusSeeOther, returnPath+separator+query.Encode())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if errors.Is(err, repository.ErrUserPasswordChanged) {
|
|
|
|
|
clearAuthCookie(c)
|
|
|
|
|
c.Redirect(http.StatusSeeOther, "/login?msg="+url.QueryEscape("密码已在其他操作中变更,请重新登录"))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
fail(c, http.StatusInternalServerError, "修改密码失败,原密码和现有登录保持不变。刷新后重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
clearAuthCookie(c)
|
|
|
|
|
c.Redirect(http.StatusSeeOther, "/login?msg="+url.QueryEscape("密码修改成功,请使用新密码重新登录"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 13:36:41 +08:00
|
|
|
// 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,
|
|
|
|
|
})
|
|
|
|
|
}
|