package api import ( "bytes" "git.misaka.ren/m1saka/dsh-web/internal/store" "golang.org/x/crypto/bcrypt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" ) func authFixture(t *testing.T) (*API, func(string, string, string, string, []*http.Cookie) *httptest.ResponseRecorder) { t.Helper() s, e := store.Open(filepath.Join(t.TempDir(), "db")) if e != nil { t.Fatal(e) } t.Cleanup(s.Close) h, _ := bcrypt.GenerateFromPassword([]byte("password-123"), bcrypt.MinCost) for _, id := range []string{"alice", "bob"} { if e = s.CreateUser(store.User{ID: id, Username: id}, string(h)); e != nil { t.Fatal(e) } } a := New(s, t.TempDir()) a.origin = "https://panel.test" return a, func(method, path, body, csrf string, cookies []*http.Cookie) *httptest.ResponseRecorder { r := httptest.NewRequest(method, path, strings.NewReader(body)) r.Header.Set("Origin", a.origin) r.Header.Set("X-CSRF-Token", csrf) for _, c := range cookies { r.AddCookie(c) } w := httptest.NewRecorder() a.Handler().ServeHTTP(w, r) return w } } func cookieValue(cs []*http.Cookie, name string) string { for _, c := range cs { if c.Name == name { return c.Value } } return "" } func TestAccessRefreshLifecycle(t *testing.T) { a, req := authFixture(t) w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil) if w.Code != 200 { t.Fatal(w.Code, w.Body.String()) } cs := w.Result().Cookies() sid := cookieValue(cs, "dsh_session") csrf := cookieValue(cs, "dsh_csrf") for _, name := range []string{"dsh_access", "dsh_refresh"} { v := cookieValue(cs, name) if v == "" || strings.Contains(w.Body.String(), v) { t.Fatal("missing/leaked", name) } } for _, c := range cs { if !c.Secure || (c.Name != "dsh_csrf" && !c.HttpOnly) { t.Fatal("unsafe cookie", c.Name) } } if w = req("GET", "/api/me", "", "", []*http.Cookie{{Name: "dsh_session", Value: sid}}); w.Code != 401 { t.Fatal("missing access accepted", w.Code) } expired := append([]*http.Cookie{}, cs...) for i, c := range expired { if c.Name == "dsh_access" { expired[i] = &http.Cookie{Name: c.Name, Value: a.accessJWT(sid, "alice", time.Now().Add(-time.Minute))} } } if w = req("GET", "/api/me", "", "", expired); w.Code != 401 { t.Fatal("expired access accepted") } if w = req("POST", "/api/refresh", "", "", expired); w.Code != 403 { t.Fatal("refresh bypassed csrf", w.Code) } w = req("POST", "/api/refresh", "", csrf, expired) if w.Code != 200 { t.Fatal(w.Code, w.Body.String()) } fresh := w.Result().Cookies() if cookieValue(fresh, "dsh_refresh") == cookieValue(cs, "dsh_refresh") { t.Fatal("not rotated") } if w = req("POST", "/api/refresh", "", csrf, cs); w.Code != 401 { t.Fatal("replay accepted", w.Code) } if w = req("GET", "/api/me", "", "", fresh); w.Code != 200 { t.Fatal(w.Code) } if w = req("POST", "/api/logout", "", csrf, fresh); w.Code != 200 { t.Fatal(w.Code) } if w = req("GET", "/api/me", "", "", fresh); w.Code != 401 { t.Fatal("logout access valid") } if w = req("POST", "/api/refresh", "", csrf, fresh); w.Code == 200 { t.Fatal("logout refresh valid") } } func TestAccountLockAndPersistentKey(t *testing.T) { a, req := authFixture(t) for i := 0; i < 5; i++ { if w := req("POST", "/api/login", `{"username":"alice","password":"wrong"}`, "", nil); w.Code != 401 { t.Fatal(w.Code) } } if w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil); w.Code != 429 { t.Fatal("not locked", w.Code) } if w := req("POST", "/api/login", `{"username":"bob","password":"password-123"}`, "", nil); w.Code != 200 { t.Fatal("other account locked", w.Code) } b := New(a.s, a.root) if !bytes.Equal(a.authKey, b.authKey) || b.authErr != nil { t.Fatal("key not persistent", b.authErr) } fi, e := os.Stat(filepath.Join(a.root, "auth-signing.key")) if e != nil || fi.Mode().Perm() != 0600 { t.Fatal("unsafe key") } if locked, e := b.accountLocked("alice"); e != nil || !locked { t.Fatal("lock not persistent", e) } if _, e = a.s.DB.Exec("UPDATE auth_failures SET locked_until=? WHERE username='alice'", time.Now().Add(-time.Second).Unix()); e != nil { t.Fatal(e) } if w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil); w.Code != 200 { t.Fatal("lock did not expire", w.Code) } } func TestRefreshOriginConcurrentRotationAndJWTBinding(t *testing.T) { a, req := authFixture(t) w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil) cs := w.Result().Cookies() sid := cookieValue(cs, "dsh_session") csrf := cookieValue(cs, "dsh_csrf") var stored string if err := a.s.DB.QueryRow("SELECT token_hash FROM auth_refresh WHERE sid=?", sid).Scan(&stored); err != nil || stored != refreshHash(cookieValue(cs, "dsh_refresh")) { t.Fatal("refresh not hashed", err) } for _, token := range []string{cookieValue(cs, "dsh_access") + "x", a.accessJWT("other", "alice", time.Now().Add(accessLifetime)), a.accessJWT(sid, "bob", time.Now().Add(accessLifetime)), "eyJhbGciOiJub25lIn0.e30."} { if w = req("GET", "/api/me", "", "", []*http.Cookie{{Name: "dsh_session", Value: sid}, {Name: "dsh_access", Value: token}}); w.Code != 401 { t.Fatal("invalid JWT accepted") } } for _, origin := range []string{"", "https://evil.test"} { r := httptest.NewRequest("POST", "/api/refresh", nil) r.Header.Set("Origin", origin) r.Header.Set("X-CSRF-Token", csrf) for _, c := range cs { r.AddCookie(c) } w = httptest.NewRecorder() a.Handler().ServeHTTP(w, r) if w.Code != 403 { t.Fatal("origin accepted", w.Code) } } var wg sync.WaitGroup codes := make(chan int, 2) for i := 0; i < 2; i++ { wg.Add(1) go func() { defer wg.Done(); codes <- req("POST", "/api/refresh", "", csrf, cs).Code }() } wg.Wait() close(codes) counts := map[int]int{} for code := range codes { counts[code]++ } if counts[200] != 1 || counts[401] != 1 { t.Fatal(counts) } b := New(a.s, a.root) r := httptest.NewRequest("GET", "/api/me", nil) for _, c := range cs { r.AddCookie(c) } w = httptest.NewRecorder() b.Handler().ServeHTTP(w, r) if w.Code != 200 { t.Fatal("restart rejected access", w.Code) } a.s.DeleteSession(sid) if w = req("GET", "/api/me", "", "", cs); w.Code != 401 { t.Fatal("deleted binding accepted") } } func TestUnsafeAuthKeyFailsClosed(t *testing.T) { a, req := authFixture(t) if err := os.Chmod(filepath.Join(a.root, "auth-signing.key"), 0644); err != nil { t.Fatal(err) } b := New(a.s, a.root) if b.authErr == nil { t.Fatal("unsafe key accepted") } a.authErr = b.authErr if w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil); w.Code != 503 { t.Fatal(w.Code) } }