feat(auth): implement user and device authentication

This commit is contained in:
QiuSW
2026-07-26 15:18:48 +08:00
parent c5d3b215ff
commit 49db5b8305
66 changed files with 6216 additions and 271 deletions
+69 -28
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"io"
@@ -14,6 +13,8 @@ import (
"time"
"unicode/utf8"
"cmroubao/backend-api/internal/transport/authcommon"
"github.com/gin-gonic/gin"
)
@@ -24,8 +25,8 @@ const (
maxTitleBytes = 2048
maxSKUBytes = 512
maxDescriptionBytes = 8192
csrfCookieName = "cmroubao_admin_csrf"
csrfFormField = "csrf_token"
csrfCookieName = authcommon.CSRFCookieName
csrfFormField = authcommon.CSRFFormField
formContentType = "text/html; charset=utf-8"
cssContentType = "text/css; charset=utf-8"
javascriptContentType = "text/javascript; charset=utf-8"
@@ -50,8 +51,16 @@ func NewHandler(service Service, renderer *Renderer) (*Handler, error) {
}
func (h *Handler) Register(routes gin.IRoutes) {
h.RegisterStatic(routes)
h.RegisterProtected(routes)
}
func (h *Handler) RegisterStatic(routes gin.IRoutes) {
routes.GET("/static/admin.css", SecurityHeaders(), h.Stylesheet)
routes.GET("/static/admin.js", SecurityHeaders(), h.Script)
}
func (h *Handler) RegisterProtected(routes gin.IRoutes) {
routes.GET("/tasks", SecurityHeaders(), h.ListTasks)
routes.GET("/tasks/new", SecurityHeaders(), h.NewTask)
routes.POST("/tasks", SecurityHeaders(), h.CreateTask)
@@ -97,6 +106,11 @@ func (h *Handler) serveStatic(
}
func (h *Handler) ListTasks(ctx *gin.Context) {
token, err := csrfToken(ctx)
if err != nil {
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
return
}
input := ListTasksInput{
Query: strings.TrimSpace(ctx.Query("q")),
Status: strings.TrimSpace(ctx.Query("status")),
@@ -127,6 +141,7 @@ func (h *Handler) ListTasks(ctx *gin.Context) {
Page: pageView{
Title: "采购任务",
TasksCurrent: true,
CSRFToken: token,
},
Query: input.Query,
Status: input.Status,
@@ -138,7 +153,7 @@ func (h *Handler) ListTasks(ctx *gin.Context) {
}
func (h *Handler) NewTask(ctx *gin.Context) {
token, err := h.csrfToken(ctx)
token, err := csrfToken(ctx)
if err != nil {
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
return
@@ -167,7 +182,7 @@ func (h *Handler) CreateTask(ctx *gin.Context) {
if ctx.Request.MultipartForm != nil {
defer ctx.Request.MultipartForm.RemoveAll()
}
if !h.validCSRF(ctx) {
if !validCSRF(ctx) {
h.renderError(
ctx,
http.StatusForbidden,
@@ -239,7 +254,7 @@ func (h *Handler) TaskDetail(ctx *gin.Context) {
h.renderServiceError(ctx, err, "无法加载任务详情,请稍后重试。")
return
}
token, tokenErr := h.csrfToken(ctx)
token, tokenErr := csrfToken(ctx)
if tokenErr != nil {
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
return
@@ -253,6 +268,7 @@ func (h *Handler) TaskDetail(ctx *gin.Context) {
Page: pageView{
Title: "任务详情",
TasksCurrent: true,
CSRFToken: token,
},
Task: taskDetailViewFrom(task),
CSRFToken: token,
@@ -263,7 +279,7 @@ func (h *Handler) TaskDetail(ctx *gin.Context) {
}
func (h *Handler) CancelTask(ctx *gin.Context) {
if !h.validCSRF(ctx) {
if !validCSRF(ctx) {
h.renderError(
ctx,
http.StatusForbidden,
@@ -328,7 +344,7 @@ func (h *Handler) uploadReference(
}
func (h *Handler) createPageFromRequest(ctx *gin.Context) newTaskPageView {
token, err := h.csrfToken(ctx)
token, err := csrfToken(ctx)
if err != nil {
token = ""
}
@@ -338,6 +354,7 @@ func (h *Handler) createPageFromRequest(ctx *gin.Context) newTaskPageView {
Page: pageView{
Title: "新建采购任务",
NewCurrent: true,
CSRFToken: token,
},
CSRFToken: token,
UploadKey: strings.TrimSpace(ctx.PostForm("upload_key")),
@@ -438,19 +455,35 @@ func validBudget(value string) bool {
return units > 0 || fraction > 0
}
func (h *Handler) csrfToken(ctx *gin.Context) (string, error) {
if cookie, err := ctx.Request.Cookie(csrfCookieName); err == nil &&
validToken(cookie.Value) {
return cookie.Value, nil
func csrfToken(ctx *gin.Context) (string, error) {
cookies := csrfCookies(ctx.Request)
for index := len(cookies) - 1; index >= 0; index-- {
if validToken(cookies[index].Value) {
return cookies[index].Value, nil
}
}
return rotateCSRFToken(ctx)
}
func rotateCSRFToken(ctx *gin.Context) (string, error) {
token, err := newToken()
if err != nil {
return "", err
}
http.SetCookie(ctx.Writer, &http.Cookie{
Name: csrfCookieName,
Value: token,
Name: authcommon.CSRFCookieName,
Value: "",
Path: "/tasks",
Expires: time.Unix(1, 0).UTC(),
MaxAge: -1,
HttpOnly: true,
Secure: ctx.Request.TLS != nil,
SameSite: http.SameSiteStrictMode,
})
http.SetCookie(ctx.Writer, &http.Cookie{
Name: authcommon.CSRFCookieName,
Value: token,
Path: "/",
MaxAge: 3600,
HttpOnly: true,
Secure: ctx.Request.TLS != nil,
@@ -459,24 +492,28 @@ func (h *Handler) csrfToken(ctx *gin.Context) (string, error) {
return token, nil
}
func (h *Handler) validCSRF(ctx *gin.Context) bool {
cookie, err := ctx.Request.Cookie(csrfCookieName)
if err != nil || !validToken(cookie.Value) {
return false
func validCSRF(ctx *gin.Context) bool {
presented := ctx.PostForm(authcommon.CSRFFormField)
for _, cookie := range csrfCookies(ctx.Request) {
if authcommon.ValidCSRFPair(cookie.Value, presented) {
return true
}
}
formToken := strings.TrimSpace(ctx.PostForm(csrfFormField))
if len(cookie.Value) != len(formToken) {
return false
return false
}
func csrfCookies(request *http.Request) []*http.Cookie {
result := make([]*http.Cookie, 0, 2)
for _, cookie := range request.Cookies() {
if cookie.Name == authcommon.CSRFCookieName {
result = append(result, cookie)
}
}
return subtle.ConstantTimeCompare(
[]byte(cookie.Value),
[]byte(formToken),
) == 1
return result
}
func validToken(value string) bool {
decoded, err := base64.RawURLEncoding.DecodeString(value)
return err == nil && len(decoded) == 32
return authcommon.ValidOpaqueValue(value)
}
func newToken() (string, error) {
@@ -500,6 +537,7 @@ func newTaskPage(token string) (newTaskPageView, error) {
Page: pageView{
Title: "新建采购任务",
NewCurrent: true,
CSRFToken: token,
},
CSRFToken: token,
UploadKey: uploadKey,
@@ -561,9 +599,11 @@ func (h *Handler) renderError(
title string,
message string,
) {
token, _ := csrfToken(ctx)
h.render(ctx, status, "error", errorPage{
Page: pageView{
Title: title,
Title: title,
CSRFToken: token,
},
Heading: title,
Message: message,
@@ -622,6 +662,7 @@ type pageView struct {
Title string
TasksCurrent bool
NewCurrent bool
CSRFToken string
}
type statusOption struct {