diff options
author | T <t@tjp.lol> | 2025-06-26 11:42:17 -0600 |
---|---|---|
committer | T <t@tjp.lol> | 2025-07-01 17:50:49 -0600 |
commit | 639ad6a02cbb4b713434671ec09f309aa5410921 (patch) | |
tree | 7dde9cce8136636d11f2f7c961072984cfc705e7 /password_login_test.go |
Create authentic_kate: user authentication for go HTTP applications
Diffstat (limited to 'password_login_test.go')
-rw-r--r-- | password_login_test.go | 385 |
1 files changed, 385 insertions, 0 deletions
diff --git a/password_login_test.go b/password_login_test.go new file mode 100644 index 0000000..eec1ba7 --- /dev/null +++ b/password_login_test.go @@ -0,0 +1,385 @@ +package kate + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +// Mock implementation of PasswordUserDataStore for testing +type mockPasswordUserDataStore[T any] struct { + users map[string]T + getHash func(T) string +} + +func newMockPasswordUserDataStore[T any](users map[string]T, getHash func(T) string) *mockPasswordUserDataStore[T] { + return &mockPasswordUserDataStore[T]{ + users: users, + getHash: getHash, + } +} + +func (m *mockPasswordUserDataStore[T]) Fetch(username string) (T, bool, error) { + user, exists := m.users[username] + return user, exists, nil +} + +func (m *mockPasswordUserDataStore[T]) GetPassHash(userData T) string { + return m.getHash(userData) +} + +func TestPasswordLoginHandler(t *testing.T) { + auth := New("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", AuthConfig[testUser]{ + SerDes: testUserSerDes{}, + CookieName: "test_session", + }) + + testHash, err := HashPassword("password123", nil) + if err != nil { + t.Fatalf("Failed to hash password: %v", err) + } + + users := map[string]testUser{ + "john": {Username: "john", Hash: testHash, ID: 1}, + "jane": {Username: "jane", Hash: testHash, ID: 2}, + } + + mockStore := newMockPasswordUserDataStore(users, func(user testUser) string { + return user.Hash + }) + + config := PasswordLoginConfig[testUser]{ + UserData: mockStore, + Redirects: Redirects{ + Default: "/dashboard", + AllowedPrefixes: []string{"/app/", "/admin/"}, + FieldName: "redirect", + }, + } + + handler := auth.PasswordLoginHandler(config) + + tests := []struct { + name string + method string + formData url.Values + expectedStatus int + expectedRedirect string + checkCookie bool + }{ + { + name: "successful login", + method: "POST", + formData: url.Values{"username": {"john"}, "password": {"password123"}}, + expectedStatus: http.StatusSeeOther, + expectedRedirect: "/dashboard", + checkCookie: true, + }, + { + name: "successful login with redirect", + method: "POST", + formData: url.Values{"username": {"john"}, "password": {"password123"}, "redirect": {"/app/settings"}}, + expectedStatus: http.StatusSeeOther, + expectedRedirect: "/app/settings", + checkCookie: true, + }, + { + name: "invalid redirect falls back to default", + method: "POST", + formData: url.Values{"username": {"john"}, "password": {"password123"}, "redirect": {"/evil/"}}, + expectedStatus: http.StatusSeeOther, + expectedRedirect: "/dashboard", + checkCookie: true, + }, + { + name: "wrong password", + method: "POST", + formData: url.Values{"username": {"john"}, "password": {"wrongpass"}}, + expectedStatus: http.StatusUnauthorized, + checkCookie: false, + }, + { + name: "nonexistent user", + method: "POST", + formData: url.Values{"username": {"nobody"}, "password": {"password123"}}, + expectedStatus: http.StatusUnauthorized, + checkCookie: false, + }, + { + name: "missing username", + method: "POST", + formData: url.Values{"password": {"password123"}}, + expectedStatus: http.StatusBadRequest, + checkCookie: false, + }, + { + name: "missing password", + method: "POST", + formData: url.Values{"username": {"john"}}, + expectedStatus: http.StatusBadRequest, + checkCookie: false, + }, + { + name: "GET method not allowed", + method: "GET", + formData: url.Values{}, + expectedStatus: http.StatusMethodNotAllowed, + checkCookie: false, + }, + { + name: "PUT method not allowed", + method: "PUT", + formData: url.Values{}, + expectedStatus: http.StatusMethodNotAllowed, + checkCookie: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var req *http.Request + + if tt.method == "POST" { + req = httptest.NewRequest(tt.method, "/login", strings.NewReader(tt.formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } else { + req = httptest.NewRequest(tt.method, "/login", nil) + } + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != tt.expectedStatus { + t.Errorf("expected status %d, got %d", tt.expectedStatus, rr.Code) + } + + if tt.expectedRedirect != "" { + location := rr.Header().Get("Location") + if location != tt.expectedRedirect { + t.Errorf("expected redirect to %s, got %s", tt.expectedRedirect, location) + } + } + + if tt.checkCookie { + cookies := rr.Result().Cookies() + found := false + for _, cookie := range cookies { + if cookie.Name == "test_session" && cookie.Value != "" { + found = true + break + } + } + if !found { + t.Error("expected authentication cookie to be set") + } + } + }) + } +} + +func TestPasswordLoginHandlerCustomFields(t *testing.T) { + auth := New("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", AuthConfig[testUser]{ + SerDes: testUserSerDes{}, + CookieName: "test_session", + }) + + testHash, err := HashPassword("secret", nil) + if err != nil { + t.Fatalf("Failed to hash password: %v", err) + } + + users := map[string]testUser{ + "admin": {Username: "admin", Hash: testHash, ID: 99}, + } + + mockStore := newMockPasswordUserDataStore(users, func(user testUser) string { + return user.Hash + }) + + config := PasswordLoginConfig[testUser]{ + UserData: mockStore, + UsernameField: "email", + PasswordField: "pass", + Redirects: Redirects{ + FieldName: "next", + Default: "/home", + }, + } + + handler := auth.PasswordLoginHandler(config) + + formData := url.Values{ + "email": {"admin"}, + "pass": {"secret"}, + "next": {"/custom"}, + } + + req := httptest.NewRequest("POST", "/login", strings.NewReader(formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusSeeOther { + t.Errorf("expected status %d, got %d", http.StatusSeeOther, rr.Code) + } + + location := rr.Header().Get("Location") + if location != "/custom" { + t.Errorf("expected redirect to /custom, got %s", location) + } +} + +func TestPasswordLoginHandlerParseFormError(t *testing.T) { + auth := New("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", AuthConfig[testUser]{ + SerDes: testUserSerDes{}, + CookieName: "test_session", + }) + + users := map[string]testUser{ + "test": {Username: "test", Hash: "", ID: 1}, + } + + mockStore := newMockPasswordUserDataStore(users, func(user testUser) string { + return user.Hash + }) + + config := PasswordLoginConfig[testUser]{ + UserData: mockStore, + } + + handler := auth.PasswordLoginHandler(config) + + req := httptest.NewRequest("POST", "/login", strings.NewReader("username=%")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, rr.Code) + } +} + +func TestPasswordLoginHandlerGetPassHashError(t *testing.T) { + auth := New("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", AuthConfig[testUser]{ + SerDes: testUserSerDes{}, + CookieName: "test_session", + }) + + users := map[string]testUser{ + "user": {Username: "user", Hash: "", ID: 1}, + } + + mockStore := newMockPasswordUserDataStore(users, func(user testUser) string { + return user.Hash // Empty hash will cause password comparison to fail + }) + + config := PasswordLoginConfig[testUser]{ + UserData: mockStore, + } + + handler := auth.PasswordLoginHandler(config) + + formData := url.Values{"username": {"user"}, "password": {"pass"}} + req := httptest.NewRequest("POST", "/login", strings.NewReader(formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code) + } +} + +func TestPasswordLoginHandlerMalformedHash(t *testing.T) { + auth := New("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", AuthConfig[testUser]{ + SerDes: testUserSerDes{}, + CookieName: "test_session", + }) + + users := map[string]testUser{ + "user": {Username: "user", Hash: "invalid-hash", ID: 1}, + } + + mockStore := newMockPasswordUserDataStore(users, func(user testUser) string { + return user.Hash + }) + + config := PasswordLoginConfig[testUser]{ + UserData: mockStore, + } + + handler := auth.PasswordLoginHandler(config) + + formData := url.Values{"username": {"user"}, "password": {"pass"}} + req := httptest.NewRequest("POST", "/login", strings.NewReader(formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code) + } +} + +func TestRedirectsIsValid(t *testing.T) { + tests := []struct { + name string + target string + allowedPrefixes []string + expected bool + }{ + { + name: "no restrictions allows anything", + target: "/anything", + allowedPrefixes: []string{}, + expected: true, + }, + { + name: "valid prefix match", + target: "/app/dashboard", + allowedPrefixes: []string{"/app/", "/admin/"}, + expected: true, + }, + { + name: "invalid prefix", + target: "/evil/", + allowedPrefixes: []string{"/app/", "/admin/"}, + expected: false, + }, + { + name: "absolute URL rejected", + target: "https://evil.com/", + allowedPrefixes: []string{"/app/"}, + expected: false, + }, + { + name: "protocol relative URL rejected", + target: "//evil.com/", + allowedPrefixes: []string{"/app/"}, + expected: false, + }, + { + name: "invalid URL rejected", + target: ":/invalid", + allowedPrefixes: []string{"/app/"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + redirects := Redirects{AllowedPrefixes: tt.allowedPrefixes} + result := redirects.isValid(tt.target) + if result != tt.expected { + t.Errorf("Redirects{AllowedPrefixes: %v}.isValid(%q) = %v, want %v", tt.allowedPrefixes, tt.target, result, tt.expected) + } + }) + } +} + |