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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user