216 lines
6.7 KiB
Go
216 lines
6.7 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 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)
|
|
}
|
|
}
|