349 lines
11 KiB
Go
349 lines
11 KiB
Go
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 TestRefreshSessionLookupFailureIsServerError(t *testing.T) {
|
|
a, req := authFixture(t)
|
|
w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil)
|
|
cookies := w.Result().Cookies()
|
|
a.s.Close()
|
|
r := httptest.NewRequest("POST", "/api/refresh", nil)
|
|
for _, c := range cookies {
|
|
r.AddCookie(c)
|
|
}
|
|
w = httptest.NewRecorder()
|
|
a.refresh(w, r)
|
|
if w.Code != 500 || len(w.Result().Cookies()) != 0 {
|
|
t.Fatal(w.Code, w.Header(), w.Body.String())
|
|
}
|
|
}
|
|
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 != 200 {
|
|
t.Fatal("retry rejected", w.Code)
|
|
}
|
|
if cookieValue(w.Result().Cookies(), "dsh_refresh") != cookieValue(fresh, "dsh_refresh") {
|
|
t.Fatal("retry must return the same successor")
|
|
}
|
|
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 != 403 {
|
|
t.Fatal("logout refresh valid")
|
|
}
|
|
}
|
|
func TestPersistentCookiesRecoverAfterBrowserRestart(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)
|
|
}
|
|
cookies := w.Result().Cookies()
|
|
sid := cookieValue(cookies, "dsh_session")
|
|
var expires int64
|
|
if err := a.s.DB.QueryRow("SELECT expires FROM sessions WHERE id=?", sid).Scan(&expires); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var restored []*http.Cookie
|
|
for _, c := range cookies {
|
|
if c.MaxAge <= 0 || c.Expires.IsZero() {
|
|
t.Fatalf("nonpersistent cookie: %s", c.Name)
|
|
}
|
|
if c.Name == "dsh_access" {
|
|
continue
|
|
} // Access has expired while backgrounded.
|
|
if c.Expires.Unix() != expires {
|
|
t.Fatalf("wrong expiry: %s", c.Name)
|
|
}
|
|
restored = append(restored, c)
|
|
}
|
|
if w = req("GET", "/api/me", "", "", restored); w.Code != 401 {
|
|
t.Fatal(w.Code)
|
|
}
|
|
// Simulate an older session without sleeping; rotation must not reset its TTL.
|
|
expires = time.Now().Add(time.Hour).Unix()
|
|
if _, err := a.s.DB.Exec("UPDATE sessions SET expires=? WHERE id=?", expires, sid); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w = req("POST", "/api/refresh", "", cookieValue(restored, "dsh_csrf"), restored)
|
|
if w.Code != 200 {
|
|
t.Fatal(w.Code, w.Body.String())
|
|
}
|
|
fresh := w.Result().Cookies()
|
|
for _, c := range fresh {
|
|
if c.Name == "dsh_access" {
|
|
if c.MaxAge != int(accessLifetime.Seconds()) {
|
|
t.Fatal("access lifetime changed")
|
|
}
|
|
continue
|
|
}
|
|
if c.Expires.Unix() != expires || c.MaxAge <= 0 || c.MaxAge > 3600 {
|
|
t.Fatalf("session extended: %s", c.Name)
|
|
}
|
|
}
|
|
if w = req("GET", "/api/me", "", "", fresh); w.Code != 200 {
|
|
t.Fatal(w.Code)
|
|
}
|
|
w = req("POST", "/api/logout", "", cookieValue(fresh, "dsh_csrf"), fresh)
|
|
if w.Code != 200 {
|
|
t.Fatal(w.Code)
|
|
}
|
|
for _, c := range w.Result().Cookies() {
|
|
if c.MaxAge >= 0 {
|
|
t.Fatalf("cookie not cleared: %s", c.Name)
|
|
}
|
|
}
|
|
if w = req("GET", "/api/me", "", "", fresh); w.Code != 401 {
|
|
t.Fatal("revoked session accepted")
|
|
}
|
|
}
|
|
|
|
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] != 2 {
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestSessionCleanupRemovesExpiredRows(t *testing.T) {
|
|
a, _ := authFixture(t)
|
|
expired, _, e := a.s.Session("alice")
|
|
if e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
current, _, e := a.s.Session("alice")
|
|
if e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
past := time.Now().Add(-time.Minute).Unix()
|
|
if _, e = a.s.DB.Exec("UPDATE sessions SET expires=? WHERE id=?", past, expired); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
token, e := refreshToken()
|
|
if e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if _, e = a.s.DB.Exec("INSERT INTO auth_refresh(sid,token_hash) VALUES(?,?)", expired, refreshHash(token)); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
future := time.Now().Add(time.Hour).Unix()
|
|
if _, e = a.s.DB.Exec("INSERT INTO auth_failures(username,failures,locked_until) VALUES('expired-lock',5,?)", past); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if _, e = a.s.DB.Exec("INSERT INTO auth_failures(username,failures,locked_until) VALUES('active-lock',5,?)", future); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
a.cleanupSessions(time.Now().Unix())
|
|
var n int
|
|
for _, q := range []string{
|
|
"SELECT count(*) FROM sessions WHERE id='" + expired + "'",
|
|
"SELECT count(*) FROM auth_refresh WHERE sid='" + expired + "'",
|
|
"SELECT count(*) FROM auth_failures WHERE username='expired-lock'",
|
|
} {
|
|
if err := a.s.DB.QueryRow(q).Scan(&n); err != nil || n != 0 {
|
|
t.Fatalf("expired row retained: %s (%d, %v)", q, n, err)
|
|
}
|
|
}
|
|
for _, q := range []string{
|
|
"SELECT count(*) FROM sessions WHERE id='" + current + "'",
|
|
"SELECT count(*) FROM auth_failures WHERE username='active-lock'",
|
|
} {
|
|
if err := a.s.DB.QueryRow(q).Scan(&n); err != nil || n != 1 {
|
|
t.Fatalf("live row removed: %s (%d, %v)", q, n, err)
|
|
}
|
|
}
|
|
}
|