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, }) }