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
@@ -0,0 +1,75 @@
package webui
import (
"context"
"errors"
"cmroubao/backend-api/internal/usecase"
)
type AuthUsecaseAdapter struct {
service *usecase.AuthService
}
func NewAuthUsecaseAdapter(
service *usecase.AuthService,
) (*AuthUsecaseAdapter, error) {
if service == nil {
return nil, errors.New("admin auth use case is required")
}
return &AuthUsecaseAdapter{service: service}, nil
}
func (adapter *AuthUsecaseAdapter) LoginAdmin(
ctx context.Context,
input AdminLoginInput,
) (AdminLoginResult, error) {
result, err := adapter.service.LoginAdmin(
ctx,
usecase.LoginAdminCommand{
Username: input.Username,
Password: input.Password,
},
)
if err != nil {
return AdminLoginResult{}, mapAuthUsecaseError(err)
}
return AdminLoginResult{
Token: result.Token,
ExpiresAt: result.ExpiresAt,
}, nil
}
func (adapter *AuthUsecaseAdapter) LogoutAdmin(
ctx context.Context,
token string,
) error {
if err := adapter.service.LogoutAdmin(ctx, token); err != nil {
return mapAuthUsecaseError(err)
}
return nil
}
func mapAuthUsecaseError(err error) error {
var typed *usecase.Error
if !errors.As(err, &typed) {
return err
}
var public error
switch typed.Kind {
case usecase.ErrorKindUnauthorized,
usecase.ErrorKindForbidden,
usecase.ErrorKindInvalid:
public = ErrInvalidCredentials
case usecase.ErrorKindUnavailable:
public = ErrUnavailable
default:
return err
}
return &adapterError{
public: public,
cause: err,
}
}
var _ AdminSessionService = (*AuthUsecaseAdapter)(nil)
@@ -0,0 +1,311 @@
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" &&
!strings.HasPrefix(parsed.Path, "/tasks/") {
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
}
@@ -0,0 +1,443 @@
package webui
import (
"context"
"crypto/tls"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"cmroubao/backend-api/internal/transport/authcommon"
"github.com/gin-gonic/gin"
)
func TestLoginPageIssuesCSRFAndRendersSafeNext(t *testing.T) {
router, _ := newAuthTestRouter(t, &fakeAdminSessions{})
response := performRequest(
t,
router,
http.MethodGet,
"/login?next=%2Ftasks%2Fnew",
nil,
"",
)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body)
}
cookie := csrfCookie(t, response)
body := response.Body.String()
for _, expected := range []string{
`name="csrf_token" value="` + cookie.Value + `"`,
`name="next" value="/tasks/new"`,
`autocomplete="username"`,
`autocomplete="current-password"`,
`data-password-toggle`,
} {
if !strings.Contains(body, expected) {
t.Fatalf("login body missing %q", expected)
}
}
assertSecurityHeaders(t, response)
}
func TestLoginRejectsInvalidCredentialsWithoutLeakingPassword(t *testing.T) {
sessions := &fakeAdminSessions{loginErr: ErrInvalidCredentials}
router, _ := newAuthTestRouter(t, sessions)
csrf := loginCSRF(t, router)
form := url.Values{
"csrf_token": {csrf.Value},
"username": {"admin"},
"password": {"private-password-value"},
"next": {"/tasks"},
}
request := httptest.NewRequest(
http.MethodPost,
"/login",
strings.NewReader(form.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(csrf)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized ||
!strings.Contains(response.Body.String(), "账号或密码不正确") {
t.Fatalf("status/body = %d / %s", response.Code, response.Body)
}
if strings.Contains(response.Body.String(), "private-password-value") {
t.Fatal("login response contains submitted password")
}
if sessions.loginInput.Username != "admin" ||
sessions.loginInput.Password != "private-password-value" {
t.Fatalf("login input = %+v", sessions.loginInput)
}
}
func TestLoginRateLimitBlocksBeforePasswordVerification(t *testing.T) {
sessions := &fakeAdminSessions{loginErr: ErrInvalidCredentials}
limiter, err := authcommon.NewAttemptLimiter(1, time.Minute, 10)
if err != nil {
t.Fatalf("NewAttemptLimiter() error = %v", err)
}
router, _ := newAuthTestRouterWithLimiter(t, sessions, limiter)
csrf := loginCSRF(t, router)
form := url.Values{
"csrf_token": {csrf.Value},
"username": {"admin"},
"password": {"private-password-value"},
"next": {"/tasks"},
}
requestLogin := func() *httptest.ResponseRecorder {
request := httptest.NewRequest(
http.MethodPost,
"/login",
strings.NewReader(form.Encode()),
)
request.Header.Set(
"Content-Type",
"application/x-www-form-urlencoded",
)
request.AddCookie(csrf)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
if response := requestLogin(); response.Code != http.StatusUnauthorized {
t.Fatalf("first status = %d", response.Code)
}
response := requestLogin()
if response.Code != http.StatusTooManyRequests ||
response.Header().Get("Retry-After") == "" ||
!strings.Contains(response.Body.String(), "尝试次数过多") ||
sessions.loginCalls != 1 {
t.Fatalf(
"second status/retry/calls = %d / %q / %d",
response.Code,
response.Header().Get("Retry-After"),
sessions.loginCalls,
)
}
}
func TestLoginRotatesSessionAndCSRFThenUsesSafeRedirect(t *testing.T) {
expiresAt := time.Now().UTC().Add(8 * time.Hour)
sessions := &fakeAdminSessions{
loginResult: AdminLoginResult{
Token: mustToken(t),
ExpiresAt: expiresAt,
},
}
router, _ := newAuthTestRouter(t, sessions)
csrf := loginCSRF(t, router)
form := url.Values{
"csrf_token": {csrf.Value},
"username": {"admin"},
"password": {"valid-password"},
"next": {"https://attacker.invalid/private"},
}
request := httptest.NewRequest(
http.MethodPost,
"/login",
strings.NewReader(form.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(csrf)
request.AddCookie(&http.Cookie{
Name: AdminSessionCookieName,
Value: mustToken(t),
})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther ||
response.Header().Get("Location") != "/tasks" {
t.Fatalf(
"status/location = %d / %q",
response.Code,
response.Header().Get("Location"),
)
}
var sessionCookie, rotatedCSRF *http.Cookie
for _, cookie := range response.Result().Cookies() {
switch cookie.Name {
case AdminSessionCookieName:
sessionCookie = cookie
case csrfCookieName:
rotatedCSRF = cookie
}
}
if sessionCookie == nil ||
sessionCookie.Value != sessions.loginResult.Token ||
!sessionCookie.HttpOnly ||
sessionCookie.SameSite != http.SameSiteLaxMode ||
sessionCookie.Path != "/" ||
sessionCookie.Secure {
t.Fatalf("session cookie = %+v", sessionCookie)
}
if rotatedCSRF == nil || rotatedCSRF.Value == csrf.Value {
t.Fatalf("CSRF was not rotated: %+v", rotatedCSRF)
}
if sessions.logoutToken == "" ||
sessions.logoutToken == sessions.loginResult.Token {
t.Fatalf(
"previous session was not revoked: %q",
sessions.logoutToken,
)
}
}
func TestAdminSessionCookieIsSecureForTLSRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/secure-cookie", func(ctx *gin.Context) {
setAdminSessionCookie(ctx, AdminLoginResult{
Token: mustToken(t),
ExpiresAt: time.Now().UTC().Add(time.Hour),
})
ctx.Status(http.StatusNoContent)
})
request := httptest.NewRequest(http.MethodGet, "/secure-cookie", nil)
request.TLS = &tls.ConnectionState{}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
var sessionCookie *http.Cookie
for _, cookie := range response.Result().Cookies() {
if cookie.Name == AdminSessionCookieName {
sessionCookie = cookie
break
}
}
if sessionCookie == nil || !sessionCookie.Secure {
t.Fatalf("TLS session cookie = %+v", sessionCookie)
}
}
func TestLoginFailsClosedWhenPreviousSessionCannotBeRevoked(
t *testing.T,
) {
newToken := mustToken(t)
oldToken := mustToken(t)
for oldToken == newToken {
oldToken = mustToken(t)
}
sessions := &fakeAdminSessions{
loginResult: AdminLoginResult{
Token: newToken,
ExpiresAt: time.Now().UTC().Add(8 * time.Hour),
},
logoutErr: ErrUnavailable,
}
router, _ := newAuthTestRouter(t, sessions)
csrf := loginCSRF(t, router)
form := url.Values{
"csrf_token": {csrf.Value},
"username": {"admin"},
"password": {"valid-password"},
"next": {"/tasks"},
}
request := httptest.NewRequest(
http.MethodPost,
"/login",
strings.NewReader(form.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(csrf)
request.AddCookie(&http.Cookie{
Name: AdminSessionCookieName,
Value: oldToken,
})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable ||
sessions.logoutToken != newToken {
t.Fatalf(
"status/last revoked token = %d / %q",
response.Code,
sessions.logoutToken,
)
}
for _, cookie := range response.Result().Cookies() {
if cookie.Name == AdminSessionCookieName &&
cookie.Value == newToken {
t.Fatal("new session cookie was returned after revoke failure")
}
}
}
func TestSafeNextRejectsExternalAndAmbiguousPaths(t *testing.T) {
tests := []string{
"https://attacker.invalid/tasks",
"//attacker.invalid/tasks",
`/tasks\redirect`,
"/tasks/../admin",
"/healthz",
"tasks",
}
for _, candidate := range tests {
if actual := safeNext(candidate); actual != "/tasks" {
t.Fatalf("safeNext(%q) = %q", candidate, actual)
}
}
if actual := safeNext("/tasks/item?id=1"); actual != "/tasks/item?id=1" {
t.Fatalf("safeNext(valid) = %q", actual)
}
}
func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
sessions := &fakeAdminSessions{}
router, _ := newAuthTestRouter(t, sessions)
csrf := loginCSRF(t, router)
sessionValue := mustToken(t)
form := url.Values{"csrf_token": {csrf.Value}}
request := httptest.NewRequest(
http.MethodPost,
"/logout",
strings.NewReader(form.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(csrf)
request.AddCookie(&http.Cookie{
Name: AdminSessionCookieName,
Value: sessionValue,
})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther ||
response.Header().Get("Location") != "/login" ||
sessions.logoutToken != sessionValue {
t.Fatalf(
"status/location/token = %d / %q / %q",
response.Code,
response.Header().Get("Location"),
sessions.logoutToken,
)
}
foundCleared := false
for _, cookie := range response.Result().Cookies() {
if cookie.Name == AdminSessionCookieName && cookie.MaxAge < 0 {
foundCleared = true
}
}
if !foundCleared {
t.Fatal("logout did not clear the admin session cookie")
}
}
func TestLogoutRejectsWrongCSRFWithoutRevoking(t *testing.T) {
sessions := &fakeAdminSessions{}
router, _ := newAuthTestRouter(t, sessions)
request := httptest.NewRequest(
http.MethodPost,
"/logout",
strings.NewReader("csrf_token=wrong"),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{
Name: csrfCookieName,
Value: mustToken(t),
})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusForbidden || sessions.logoutToken != "" {
t.Fatalf(
"status/logout token = %d / %q",
response.Code,
sessions.logoutToken,
)
}
}
type fakeAdminSessions struct {
loginInput AdminLoginInput
loginResult AdminLoginResult
loginErr error
loginCalls int
logoutToken string
logoutErr error
}
func (service *fakeAdminSessions) LoginAdmin(
_ context.Context,
input AdminLoginInput,
) (AdminLoginResult, error) {
service.loginCalls++
service.loginInput = input
return service.loginResult, service.loginErr
}
func (service *fakeAdminSessions) LogoutAdmin(
_ context.Context,
token string,
) error {
service.logoutToken = token
return service.logoutErr
}
func newAuthTestRouter(
t *testing.T,
sessions AdminSessionService,
) (http.Handler, *AuthHandler) {
t.Helper()
limiter, err := authcommon.NewAttemptLimiter(
100,
time.Minute,
100,
)
if err != nil {
t.Fatalf("NewAttemptLimiter() error = %v", err)
}
return newAuthTestRouterWithLimiter(t, sessions, limiter)
}
func newAuthTestRouterWithLimiter(
t *testing.T,
sessions AdminSessionService,
limiter authcommon.AttemptLimiter,
) (http.Handler, *AuthHandler) {
t.Helper()
gin.SetMode(gin.TestMode)
renderer, err := NewRenderer()
if err != nil {
t.Fatalf("NewRenderer() error = %v", err)
}
handler, err := NewAuthHandler(sessions, renderer, limiter)
if err != nil {
t.Fatalf("NewAuthHandler() error = %v", err)
}
router := gin.New()
handler.RegisterPublic(router)
handler.RegisterProtected(router)
return router, handler
}
func loginCSRF(t *testing.T, router http.Handler) *http.Cookie {
t.Helper()
response := performRequest(
t,
router,
http.MethodGet,
"/login",
nil,
"",
)
return csrfCookie(t, response)
}
var _ AdminSessionService = (*fakeAdminSessions)(nil)
+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 {
@@ -112,7 +112,7 @@ func TestNewTaskIssuesReusableStrictCSRFCookie(t *testing.T) {
cookie := csrfCookie(t, response)
if !cookie.HttpOnly ||
cookie.SameSite != http.SameSiteStrictMode ||
cookie.Path != "/tasks" {
cookie.Path != "/" {
t.Fatalf("CSRF cookie = %+v", cookie)
}
body := response.Body.String()
@@ -128,6 +128,7 @@ func TestNewTaskIssuesReusableStrictCSRFCookie(t *testing.T) {
`name="quantity"`,
`name="max_budget"`,
`name="image"`,
`action="/logout"`,
"最高总预算",
} {
if !strings.Contains(body, required) {
@@ -141,6 +142,39 @@ func TestNewTaskIssuesReusableStrictCSRFCookie(t *testing.T) {
assertSecurityHeaders(t, response)
}
func TestNewTaskPrefersRootCSRFCookieDuringLegacyPathMigration(
t *testing.T,
) {
router := newTestRouter(t, &fakeService{})
legacy := mustToken(t)
root := mustToken(t)
for root == legacy {
root = mustToken(t)
}
request := httptest.NewRequest(http.MethodGet, "/tasks/new", nil)
request.AddCookie(&http.Cookie{
Name: csrfCookieName,
Value: legacy,
Path: "/tasks",
})
request.AddCookie(&http.Cookie{
Name: csrfCookieName,
Value: root,
Path: "/",
})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK ||
!strings.Contains(
response.Body.String(),
`name="csrf_token" value="`+root+`"`,
) {
t.Fatalf("status/body = %d / %s", response.Code, response.Body)
}
}
func TestCreateTaskRejectsCSRFBeforeCallingService(t *testing.T) {
service := &fakeService{}
router := newTestRouter(t, service)
@@ -622,7 +656,9 @@ func csrfCookie(
) *http.Cookie {
t.Helper()
for _, cookie := range response.Result().Cookies() {
if cookie.Name == csrfCookieName {
if cookie.Name == csrfCookieName &&
cookie.Path == "/" &&
cookie.Value != "" {
return cookie
}
}
@@ -144,6 +144,27 @@ a {
font-weight: 700;
}
.logout-form {
margin: 0;
}
.logout-button {
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--surface);
color: var(--text-muted);
font: inherit;
cursor: pointer;
}
.logout-button:hover,
.logout-button:focus-visible {
border-color: var(--text-muted);
color: var(--text);
}
.page {
width: min(calc(100% - 32px), 1180px);
margin: 0 auto;
@@ -705,6 +726,65 @@ tbody tr:last-child td {
cursor: wait;
}
.login-body {
min-height: 100vh;
display: grid;
background: #eef1f0;
}
.login-page {
width: min(calc(100% - 28px), 430px);
margin: auto;
padding: 32px 0;
}
.login-brand {
min-height: 44px;
display: inline-flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
color: var(--ink);
font-weight: 800;
text-decoration: none;
}
.login-panel {
padding: 28px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
}
.login-panel h1 {
margin: 0;
}
.login-panel form {
display: grid;
gap: 16px;
margin-top: 22px;
}
.password-field {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
}
.password-field input {
border-radius: 5px 0 0 5px;
}
.password-toggle {
min-width: 64px;
border-left: 0;
border-radius: 0 5px 5px 0;
}
.login-submit {
width: 100%;
}
@media (max-width: 760px) {
.page {
width: min(calc(100% - 20px), 1180px);
@@ -797,6 +877,11 @@ tbody tr:last-child td {
font-size: 13px;
}
.logout-button {
padding-inline: 8px;
font-size: 13px;
}
.title-row,
.detail-title {
align-items: stretch;
@@ -9,6 +9,19 @@
if (summary) summary.focus();
}
const passwordToggle = document.querySelector("[data-password-toggle]");
if (passwordToggle) {
const password = document.getElementById(
passwordToggle.getAttribute("aria-controls"),
);
passwordToggle.addEventListener("click", () => {
const showing = password.type === "text";
password.type = showing ? "password" : "text";
passwordToggle.textContent = showing ? "显示" : "隐藏";
passwordToggle.setAttribute("aria-pressed", String(!showing));
});
}
document.querySelectorAll("[data-loading-form]").forEach((form) => {
form.addEventListener("submit", () => {
form.setAttribute("aria-busy", "true");
@@ -0,0 +1,55 @@
{{define "login"}}
<!doctype html>
<html lang="zh-CN">
<head>
<title>{{.Page.Title}} - 采购任务管理</title>
{{template "document-head" .}}
</head>
<body class="login-body">
<main id="main-content" class="login-page">
<a class="login-brand" href="/login" aria-label="采购任务管理登录">
<span class="brand-mark" aria-hidden="true">采</span>
<span>采购任务管理</span>
</a>
<section class="login-panel" aria-labelledby="login-title">
<h1 id="login-title">管理端登录</h1>
<p class="subtitle">使用采购管理员账号继续</p>
{{if .Message}}
<div class="notice notice-error" role="alert" tabindex="-1" data-error-summary>
{{.Message}}
</div>
{{end}}
<form method="post" action="/login" novalidate data-loading-form>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="next" value="{{.Next}}">
<div class="field">
<label for="username">账号</label>
<input id="username" name="username" value="{{.Username}}"
autocomplete="username" autofocus required
aria-describedby="username-error"
{{if .UsernameError}}aria-invalid="true" data-error-field{{end}}>
<p id="username-error" class="field-error">{{.UsernameError}}</p>
</div>
<div class="field">
<label for="login-password">密码</label>
<div class="password-field">
<input id="login-password" name="password" type="password"
autocomplete="current-password" required
aria-describedby="password-error"
{{if .PasswordError}}aria-invalid="true" data-error-field{{end}}>
<button class="button password-toggle" type="button"
aria-controls="login-password" aria-pressed="false"
data-password-toggle>显示</button>
</div>
<p id="password-error" class="field-error">{{.PasswordError}}</p>
</div>
<button class="button primary login-submit" type="submit"
data-loading-label="正在登录…">登录</button>
</form>
</section>
</main>
</body>
</html>
{{end}}
@@ -18,5 +18,11 @@
<a href="/tasks" {{if .Page.TasksCurrent}}aria-current="page"{{end}}>任务列表</a>
<a href="/tasks/new" {{if .Page.NewCurrent}}aria-current="page"{{end}}>新建任务</a>
</nav>
{{if .Page.CSRFToken}}
<form class="logout-form" method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
<button class="logout-button" type="submit">退出</button>
</form>
{{end}}
</header>
{{end}}
@@ -5,6 +5,7 @@ import (
"errors"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/transport/authcommon"
"cmroubao/backend-api/internal/usecase"
)
@@ -104,6 +105,7 @@ func (adapter *UsecaseAdapter) CreateTask(
}
result, err := adapter.tasks.Create(ctx, usecase.CreateTaskCommand{
CreatorSubject: localAdminSubject,
ActorUserID: actorUserID(ctx),
IdempotencyKey: input.IdempotencyKey,
Title: input.Title,
Description: input.Description,
@@ -118,12 +120,21 @@ func (adapter *UsecaseAdapter) CreateTask(
return taskFromPurchase(result.Task), nil
}
func actorUserID(ctx context.Context) string {
principal, ok := authcommon.Principal(ctx)
if !ok || principal.Role != domain.UserRoleAdmin {
return ""
}
return principal.UserID
}
func (adapter *UsecaseAdapter) CancelPending(
ctx context.Context,
input CancelPendingInput,
) (Task, error) {
task, err := adapter.tasks.Cancel(ctx, usecase.CancelTaskCommand{
CreatorSubject: localAdminSubject,
ActorUserID: actorUserID(ctx),
TaskID: input.TaskID,
Reason: "管理员取消",
})