Files
cmroubao/backend-api/internal/transport/webui/auth_handler_test.go
T

447 lines
11 KiB
Go

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)
}
if actual := safeNext("/freight/import"); actual != "/freight/import" {
t.Fatalf("safeNext(freight) = %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)