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)