feat(auth): implement user and device authentication
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user