2026-07-26 15:18:48 +08:00
|
|
|
package webui
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
|
|
|
|
"path"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"cmroubao/backend-api/internal/transport/authcommon"
|
|
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
AdminSessionCookieName = authcommon.AdminSessionCookieName
|
|
|
|
|
maxLoginFormBytes = 16 << 10
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
ErrInvalidCredentials = errors.New("invalid credentials")
|
|
|
|
|
ErrAccountDisabled = errors.New("account disabled")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type AdminSessionService interface {
|
|
|
|
|
LoginAdmin(context.Context, AdminLoginInput) (AdminLoginResult, error)
|
|
|
|
|
LogoutAdmin(context.Context, string) error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type AdminLoginInput struct {
|
|
|
|
|
Username string
|
|
|
|
|
Password string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type AdminLoginResult struct {
|
|
|
|
|
Token string
|
|
|
|
|
ExpiresAt time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type AuthHandler struct {
|
|
|
|
|
sessions AdminSessionService
|
|
|
|
|
renderer *Renderer
|
|
|
|
|
limiter authcommon.AttemptLimiter
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewAuthHandler(
|
|
|
|
|
sessions AdminSessionService,
|
|
|
|
|
renderer *Renderer,
|
|
|
|
|
limiter authcommon.AttemptLimiter,
|
|
|
|
|
) (*AuthHandler, error) {
|
|
|
|
|
if sessions == nil {
|
|
|
|
|
return nil, errors.New("admin session service is required")
|
|
|
|
|
}
|
|
|
|
|
if renderer == nil {
|
|
|
|
|
return nil, errors.New("admin auth renderer is required")
|
|
|
|
|
}
|
|
|
|
|
if limiter == nil {
|
|
|
|
|
return nil, errors.New("admin login limiter is required")
|
|
|
|
|
}
|
|
|
|
|
return &AuthHandler{
|
|
|
|
|
sessions: sessions,
|
|
|
|
|
renderer: renderer,
|
|
|
|
|
limiter: limiter,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *AuthHandler) RegisterPublic(routes gin.IRoutes) {
|
|
|
|
|
routes.GET("/login", SecurityHeaders(), h.LoginPage)
|
|
|
|
|
routes.POST("/login", SecurityHeaders(), h.Login)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *AuthHandler) RegisterProtected(routes gin.IRoutes) {
|
|
|
|
|
routes.POST("/logout", SecurityHeaders(), h.Logout)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *AuthHandler) LoginPage(ctx *gin.Context) {
|
|
|
|
|
token, err := csrfToken(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.renderError(ctx, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.render(ctx, http.StatusOK, loginPage{
|
|
|
|
|
Page: pageView{Title: "管理端登录"},
|
|
|
|
|
CSRFToken: token,
|
|
|
|
|
Next: safeNext(ctx.Query("next")),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *AuthHandler) Login(ctx *gin.Context) {
|
|
|
|
|
previousSessionToken := ""
|
|
|
|
|
if cookie, err := ctx.Request.Cookie(
|
|
|
|
|
authcommon.AdminSessionCookieName,
|
|
|
|
|
); err == nil {
|
|
|
|
|
previousSessionToken = cookie.Value
|
|
|
|
|
}
|
|
|
|
|
ctx.Request.Body = http.MaxBytesReader(
|
|
|
|
|
ctx.Writer,
|
|
|
|
|
ctx.Request.Body,
|
|
|
|
|
maxLoginFormBytes,
|
|
|
|
|
)
|
|
|
|
|
if err := ctx.Request.ParseForm(); err != nil {
|
|
|
|
|
h.renderError(ctx, http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
next := safeNext(ctx.PostForm("next"))
|
|
|
|
|
page := loginPage{
|
|
|
|
|
Page: pageView{Title: "管理端登录"},
|
|
|
|
|
CSRFToken: strings.TrimSpace(
|
|
|
|
|
ctx.PostForm(authcommon.CSRFFormField),
|
|
|
|
|
),
|
|
|
|
|
Next: next,
|
|
|
|
|
Username: strings.TrimSpace(ctx.PostForm("username")),
|
|
|
|
|
}
|
|
|
|
|
if !validCSRF(ctx) {
|
|
|
|
|
page.Message = "登录页面已失效,请刷新后重试。"
|
|
|
|
|
h.render(ctx, http.StatusForbidden, page)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
password := ctx.PostForm("password")
|
|
|
|
|
if page.Username == "" {
|
|
|
|
|
page.UsernameError = "请输入账号。"
|
|
|
|
|
}
|
|
|
|
|
if password == "" {
|
|
|
|
|
page.PasswordError = "请输入密码。"
|
|
|
|
|
}
|
|
|
|
|
if page.UsernameError != "" || page.PasswordError != "" {
|
|
|
|
|
page.Message = "请检查并补全必填项。"
|
|
|
|
|
h.render(ctx, http.StatusUnprocessableEntity, page)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
attemptKey := authcommon.LoginAttemptKey(
|
|
|
|
|
"admin",
|
|
|
|
|
ctx.Request.RemoteAddr,
|
|
|
|
|
)
|
|
|
|
|
if allowed, wait := h.limiter.Allow(attemptKey); !allowed {
|
|
|
|
|
ctx.Header(
|
|
|
|
|
"Retry-After",
|
|
|
|
|
strconv.Itoa(authcommon.RetryAfterSeconds(wait)),
|
|
|
|
|
)
|
|
|
|
|
page.Message = "登录尝试次数过多,请稍后再试。"
|
|
|
|
|
h.render(ctx, http.StatusTooManyRequests, page)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
result, err := h.sessions.LoginAdmin(
|
|
|
|
|
ctx.Request.Context(),
|
|
|
|
|
AdminLoginInput{
|
|
|
|
|
Username: page.Username,
|
|
|
|
|
Password: password,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
switch {
|
|
|
|
|
case errors.Is(err, ErrInvalidCredentials),
|
|
|
|
|
errors.Is(err, ErrAccountDisabled):
|
|
|
|
|
page.Message = "账号或密码不正确。"
|
|
|
|
|
h.render(ctx, http.StatusUnauthorized, page)
|
|
|
|
|
case errors.Is(err, context.DeadlineExceeded),
|
|
|
|
|
errors.Is(err, ErrUnavailable):
|
|
|
|
|
page.Message = "登录服务暂时不可用,请稍后重试。"
|
|
|
|
|
h.render(ctx, http.StatusServiceUnavailable, page)
|
|
|
|
|
default:
|
|
|
|
|
h.renderError(ctx, http.StatusInternalServerError)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.limiter.Reset(attemptKey)
|
|
|
|
|
|
|
|
|
|
if previousSessionToken != "" &&
|
|
|
|
|
previousSessionToken != result.Token {
|
|
|
|
|
if err := h.sessions.LogoutAdmin(
|
|
|
|
|
ctx.Request.Context(),
|
|
|
|
|
previousSessionToken,
|
|
|
|
|
); err != nil {
|
|
|
|
|
_ = h.sessions.LogoutAdmin(
|
|
|
|
|
ctx.Request.Context(),
|
|
|
|
|
result.Token,
|
|
|
|
|
)
|
|
|
|
|
clearAdminSessionCookie(ctx)
|
|
|
|
|
h.renderError(ctx, http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
setAdminSessionCookie(ctx, result)
|
|
|
|
|
if _, err := rotateCSRFToken(ctx); err != nil {
|
|
|
|
|
_ = h.sessions.LogoutAdmin(ctx.Request.Context(), result.Token)
|
|
|
|
|
clearAdminSessionCookie(ctx)
|
|
|
|
|
h.renderError(ctx, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx.Redirect(http.StatusSeeOther, next)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *AuthHandler) Logout(ctx *gin.Context) {
|
|
|
|
|
if !validCSRF(ctx) {
|
|
|
|
|
h.renderError(ctx, http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
cookie, err := ctx.Request.Cookie(authcommon.AdminSessionCookieName)
|
|
|
|
|
if err == nil && cookie.Value != "" {
|
|
|
|
|
if err := h.sessions.LogoutAdmin(
|
|
|
|
|
ctx.Request.Context(),
|
|
|
|
|
cookie.Value,
|
|
|
|
|
); err != nil &&
|
|
|
|
|
!errors.Is(err, ErrNotFound) {
|
|
|
|
|
h.renderError(ctx, http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
clearAdminSessionCookie(ctx)
|
|
|
|
|
_, _ = rotateCSRFToken(ctx)
|
|
|
|
|
ctx.Redirect(http.StatusSeeOther, "/login")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func setAdminSessionCookie(
|
|
|
|
|
ctx *gin.Context,
|
|
|
|
|
result AdminLoginResult,
|
|
|
|
|
) {
|
|
|
|
|
maxAge := int(time.Until(result.ExpiresAt).Seconds())
|
|
|
|
|
if maxAge < 1 {
|
|
|
|
|
maxAge = 1
|
|
|
|
|
}
|
|
|
|
|
http.SetCookie(ctx.Writer, &http.Cookie{
|
|
|
|
|
Name: authcommon.AdminSessionCookieName,
|
|
|
|
|
Value: result.Token,
|
|
|
|
|
Path: "/",
|
|
|
|
|
Expires: result.ExpiresAt.UTC(),
|
|
|
|
|
MaxAge: maxAge,
|
|
|
|
|
HttpOnly: true,
|
|
|
|
|
Secure: ctx.Request.TLS != nil,
|
|
|
|
|
SameSite: http.SameSiteLaxMode,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func clearAdminSessionCookie(ctx *gin.Context) {
|
|
|
|
|
http.SetCookie(ctx.Writer, &http.Cookie{
|
|
|
|
|
Name: authcommon.AdminSessionCookieName,
|
|
|
|
|
Value: "",
|
|
|
|
|
Path: "/",
|
|
|
|
|
Expires: time.Unix(1, 0).UTC(),
|
|
|
|
|
MaxAge: -1,
|
|
|
|
|
HttpOnly: true,
|
|
|
|
|
Secure: ctx.Request.TLS != nil,
|
|
|
|
|
SameSite: http.SameSiteLaxMode,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func safeNext(value string) string {
|
|
|
|
|
value = strings.TrimSpace(value)
|
|
|
|
|
if value == "" {
|
|
|
|
|
return "/tasks"
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(value, `\`) ||
|
|
|
|
|
strings.HasPrefix(value, "//") {
|
|
|
|
|
return "/tasks"
|
|
|
|
|
}
|
|
|
|
|
parsed, err := url.Parse(value)
|
|
|
|
|
if err != nil ||
|
|
|
|
|
parsed.IsAbs() ||
|
|
|
|
|
parsed.Host != "" ||
|
|
|
|
|
parsed.Fragment != "" ||
|
|
|
|
|
path.Clean(parsed.Path) != parsed.Path {
|
|
|
|
|
return "/tasks"
|
|
|
|
|
}
|
|
|
|
|
if parsed.Path != "/tasks" &&
|
2026-07-28 23:26:34 +08:00
|
|
|
!strings.HasPrefix(parsed.Path, "/tasks/") &&
|
|
|
|
|
parsed.Path != "/freight" &&
|
2026-07-29 10:48:06 +08:00
|
|
|
!strings.HasPrefix(parsed.Path, "/freight/") {
|
2026-07-26 15:18:48 +08:00
|
|
|
return "/tasks"
|
|
|
|
|
}
|
|
|
|
|
return parsed.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *AuthHandler) render(
|
|
|
|
|
ctx *gin.Context,
|
|
|
|
|
status int,
|
|
|
|
|
page loginPage,
|
|
|
|
|
) {
|
|
|
|
|
var output bytes.Buffer
|
|
|
|
|
if err := h.renderer.Execute(&output, "login", page); err != nil {
|
|
|
|
|
ctx.Data(
|
|
|
|
|
http.StatusInternalServerError,
|
|
|
|
|
formContentType,
|
|
|
|
|
[]byte("页面暂时无法显示,请稍后重试。"),
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx.Data(status, formContentType, output.Bytes())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *AuthHandler) renderError(ctx *gin.Context, status int) {
|
|
|
|
|
token, _ := csrfToken(ctx)
|
|
|
|
|
h.render(ctx, status, loginPage{
|
|
|
|
|
Page: pageView{Title: "管理端登录"},
|
|
|
|
|
CSRFToken: token,
|
|
|
|
|
Next: "/tasks",
|
|
|
|
|
Message: "操作失败,请刷新页面后重试。",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type loginPage struct {
|
|
|
|
|
Page pageView
|
|
|
|
|
CSRFToken string
|
|
|
|
|
Next string
|
|
|
|
|
Username string
|
|
|
|
|
Message string
|
|
|
|
|
UsernameError string
|
|
|
|
|
PasswordError string
|
|
|
|
|
}
|