Files
cmautobuy/admin/handler/web/auth.go
T

217 lines
7.0 KiB
Go
Raw Normal View History

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("已退出登录"))
}
// 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("密码修改成功,请使用新密码重新登录"))
}
// 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,
})
}