Files
dsh-web/internal/api/auth_tokens_test.go
T

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)
}
}