2026-07-26 15:18:48 +08:00
|
|
|
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)
|
|
|
|
|
}
|
2026-07-28 23:26:34 +08:00
|
|
|
if actual := safeNext("/freight/import"); actual != "/freight/import" {
|
|
|
|
|
t.Fatalf("safeNext(freight) = %q", actual)
|
|
|
|
|
}
|
2026-07-26 15:18:48 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|