Initial commit: DSH Web Manager
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const resetPath = "/api/users/reset-password"
|
||||
|
||||
func TestResetPasswordConcurrentLogin(t *testing.T) {
|
||||
a, req := authFixture(t)
|
||||
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies()
|
||||
start := make(chan struct{})
|
||||
results := make(chan *httptest.ResponseRecorder, 8)
|
||||
for i := 0; i < 8; i++ {
|
||||
go func() { <-start; results <- req("POST", "/api/login", resetLogin("bob", "password-123"), "", nil) }()
|
||||
}
|
||||
close(start)
|
||||
w := req("POST", resetPath, resetBody("bob", resetSecret), cookieValue(admin, "dsh_csrf"), admin)
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
w := <-results
|
||||
if w.Code != 200 && w.Code != 401 && w.Code != 429 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
if w.Code == 200 {
|
||||
if check := req("GET", "/api/me", "", "", w.Result().Cookies()); check.Code != 401 {
|
||||
t.Fatal("old-password login retained session", check.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
var n int
|
||||
if err := a.s.DB.QueryRow("SELECT count(*) FROM sessions WHERE uid='bob'").Scan(&n); err != nil || n != 0 {
|
||||
t.Fatal("old login sessions", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
type resetSignalReader struct {
|
||||
reader *strings.Reader
|
||||
started chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (r *resetSignalReader) Read(p []byte) (int, error) {
|
||||
r.once.Do(func() { close(r.started) })
|
||||
return r.reader.Read(p)
|
||||
}
|
||||
|
||||
func TestResetPasswordRechecksWaitingAdmin(t *testing.T) {
|
||||
for _, revoke := range []string{"session", "admin"} {
|
||||
t.Run(revoke, func(t *testing.T) {
|
||||
a, req := authFixture(t)
|
||||
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies()
|
||||
reader := &resetSignalReader{reader: strings.NewReader(resetBody("bob", resetSecret)), started: make(chan struct{})}
|
||||
r := httptest.NewRequest("POST", resetPath, reader)
|
||||
r.Header.Set("Origin", a.origin)
|
||||
r.Header.Set("X-CSRF-Token", cookieValue(admin, "dsh_csrf"))
|
||||
for _, c := range admin {
|
||||
r.AddCookie(c)
|
||||
}
|
||||
a.authLoginMu.Lock()
|
||||
locked := true
|
||||
defer func() {
|
||||
if locked {
|
||||
a.authLoginMu.Unlock()
|
||||
}
|
||||
}()
|
||||
done := make(chan *httptest.ResponseRecorder, 1)
|
||||
go func() { w := httptest.NewRecorder(); a.Handler().ServeHTTP(w, r); done <- w }()
|
||||
select {
|
||||
case <-reader.started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("request did not reach decoder")
|
||||
}
|
||||
select {
|
||||
case w := <-done:
|
||||
t.Fatal("reset bypassed login lock", w.Code)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
query := "UPDATE users SET admin=0 WHERE id='alice'"
|
||||
want := 403
|
||||
if revoke == "session" {
|
||||
query = "DELETE FROM sessions WHERE uid='alice'"
|
||||
want = 401
|
||||
}
|
||||
if _, err := a.s.DB.Exec(query); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.authLoginMu.Unlock()
|
||||
locked = false
|
||||
select {
|
||||
case w := <-done:
|
||||
if w.Code != want {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("reset did not finish")
|
||||
}
|
||||
hash, err := a.s.Password("bob")
|
||||
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte("password-123")) != nil {
|
||||
t.Fatal("unauthorized reset", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const resetSecret = "new-password-123"
|
||||
|
||||
func resetBody(id, password string) string {
|
||||
b, _ := json.Marshal(map[string]string{"id": id, "password": password})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestResetPasswordValidation(t *testing.T) {
|
||||
a, req := authFixture(t)
|
||||
admin := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies()
|
||||
regular := req("POST", "/api/login", resetLogin("bob", "password-123"), "", nil).Result().Cookies()
|
||||
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
csrf := cookieValue(admin, "dsh_csrf")
|
||||
for _, tc := range []struct {
|
||||
name, method, body, csrf string
|
||||
cookies []*http.Cookie
|
||||
code int
|
||||
}{
|
||||
{"anonymous", "POST", resetBody("bob", resetSecret), "", nil, 401},
|
||||
{"regular", "POST", resetBody("alice", resetSecret), cookieValue(regular, "dsh_csrf"), regular, 403},
|
||||
{"csrf", "POST", resetBody("bob", resetSecret), "bad", admin, 403},
|
||||
{"method", "GET", "", csrf, admin, 405},
|
||||
{"missing id", "POST", resetBody("", resetSecret), csrf, admin, 400},
|
||||
{"short", "POST", resetBody("bob", strings.Repeat("a", 11)), csrf, admin, 400},
|
||||
{"long", "POST", resetBody("bob", strings.Repeat("a", 73)), csrf, admin, 400},
|
||||
{"unicode long", "POST", resetBody("bob", strings.Repeat("密", 25)), csrf, admin, 400},
|
||||
{"malformed", "POST", "{", csrf, admin, 400},
|
||||
{"trailing", "POST", resetBody("bob", resetSecret) + "{}", csrf, admin, 400},
|
||||
{"oversized", "POST", resetBody("bob", strings.Repeat("a", 4096)), csrf, admin, 400},
|
||||
{"missing target", "POST", resetBody("missing", resetSecret), csrf, admin, 404},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := req(tc.method, resetPath, tc.body, tc.csrf, tc.cookies)
|
||||
if w.Code != tc.code {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, origin := range []string{"", "https://evil.test"} {
|
||||
r := httptest.NewRequest("POST", resetPath, strings.NewReader(resetBody("bob", resetSecret)))
|
||||
r.Header.Set("Origin", origin)
|
||||
r.Header.Set("X-CSRF-Token", csrf)
|
||||
for _, c := range admin {
|
||||
r.AddCookie(c)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
a.Handler().ServeHTTP(w, r)
|
||||
if w.Code != 403 {
|
||||
t.Fatal("origin accepted", w.Code)
|
||||
}
|
||||
}
|
||||
for _, password := range []string{strings.Repeat("a", 12), strings.Repeat("密", 24)} {
|
||||
if w := req("POST", resetPath, resetBody("bob", password), csrf, admin); w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
hash, err := a.s.Password("bob")
|
||||
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
|
||||
t.Fatal("boundary hash", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resetLogin(username, password string) string {
|
||||
b, _ := json.Marshal(map[string]string{"username": username, "password": password})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestResetPasswordLifecycle(t *testing.T) {
|
||||
for _, target := range []string{"bob", "alice"} {
|
||||
t.Run(target, func(t *testing.T) {
|
||||
a, req := authFixture(t)
|
||||
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
login := func(name string) []*http.Cookie {
|
||||
w := req("POST", "/api/login", resetLogin(name, "password-123"), "", nil)
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
return w.Result().Cookies()
|
||||
}
|
||||
admin := login("alice")
|
||||
targets := [][]*http.Cookie{login(target), login(target)}
|
||||
other := "alice"
|
||||
if target == "alice" {
|
||||
other = "bob"
|
||||
}
|
||||
unaffected := login(other)
|
||||
if _, err := a.s.DB.Exec("INSERT INTO auth_failures VALUES(?,5,?)", target, 9999999999); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := req("POST", resetPath, resetBody(target, resetSecret), cookieValue(admin, "dsh_csrf"), admin)
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
var result map[string]bool
|
||||
if json.Unmarshal(w.Body.Bytes(), &result) != nil || !result["ok"] || result["reauthenticate"] != (target == "alice") {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
for _, cs := range targets {
|
||||
var remaining int
|
||||
if err := a.s.DB.QueryRow("SELECT count(*) FROM auth_refresh WHERE sid=?", cookieValue(cs, "dsh_session")).Scan(&remaining); err != nil || remaining != 0 {
|
||||
t.Fatal("old refresh row", remaining, err)
|
||||
}
|
||||
if w := req("GET", "/api/me", "", "", cs); w.Code != 401 {
|
||||
t.Fatal("old access", w.Code)
|
||||
}
|
||||
if w := req("POST", "/api/refresh", "", cookieValue(cs, "dsh_csrf"), cs); w.Code != 403 {
|
||||
t.Fatal("old refresh", w.Code)
|
||||
}
|
||||
}
|
||||
for _, query := range []string{"SELECT count(*) FROM sessions WHERE uid=?", "SELECT count(*) FROM auth_failures WHERE username=?", "SELECT count(*) FROM auth_refresh WHERE sid IN (SELECT id FROM sessions WHERE uid=?)"} {
|
||||
var n int
|
||||
if err := a.s.DB.QueryRow(query, target).Scan(&n); err != nil || n != 0 {
|
||||
t.Fatal(query, n, err)
|
||||
}
|
||||
}
|
||||
if w := req("GET", "/api/me", "", "", unaffected); w.Code != 200 {
|
||||
t.Fatal("other access", w.Code)
|
||||
}
|
||||
if w := req("POST", "/api/refresh", "", cookieValue(unaffected, "dsh_csrf"), unaffected); w.Code != 200 {
|
||||
t.Fatal("other refresh", w.Code)
|
||||
}
|
||||
if w := req("POST", "/api/login", resetLogin(target, "password-123"), "", nil); w.Code != 401 {
|
||||
t.Fatal("old password", w.Code)
|
||||
}
|
||||
if w := req("POST", "/api/login", resetLogin(target, resetSecret), "", nil); w.Code != 200 {
|
||||
t.Fatal("new password", w.Code)
|
||||
}
|
||||
var actor, detail string
|
||||
var n int
|
||||
if err := a.s.DB.QueryRow("SELECT uid,detail,count(*) FROM audit WHERE action='user.reset_password' AND target=?", target).Scan(&actor, &detail, &n); err != nil || actor != "alice" || detail != "" || n != 1 {
|
||||
t.Fatal("audit", actor, detail, n, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswordRollback(t *testing.T) {
|
||||
for _, step := range []struct{ table, event string }{{"users", "UPDATE"}, {"auth_refresh", "DELETE"}, {"sessions", "DELETE"}, {"auth_failures", "DELETE"}, {"audit", "INSERT"}} {
|
||||
t.Run(step.table, func(t *testing.T) {
|
||||
a, req := authFixture(t)
|
||||
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies()
|
||||
target := req("POST", "/api/login", resetLogin("bob", "password-123"), "", nil).Result().Cookies()
|
||||
before, err := a.s.Password("bob")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.s.DB.Exec("INSERT INTO auth_failures VALUES('bob',5,9999999999)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.s.DB.Exec(fmt.Sprintf("CREATE TRIGGER reject_reset BEFORE %s ON %s BEGIN SELECT RAISE(ABORT,'forced failure'); END", step.event, step.table)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := req("POST", resetPath, resetBody("bob", resetSecret), cookieValue(admin, "dsh_csrf"), admin)
|
||||
if w.Code != 500 || strings.Contains(w.Body.String(), resetSecret) {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
after, err := a.s.Password("bob")
|
||||
if err != nil || before != after {
|
||||
t.Fatal("hash changed", err)
|
||||
}
|
||||
for query, want := range map[string]int{"SELECT count(*) FROM sessions WHERE uid='bob'": 1, "SELECT count(*) FROM auth_refresh": 2, "SELECT count(*) FROM auth_failures WHERE username='bob' AND failures=5 AND locked_until=9999999999": 1, "SELECT count(*) FROM audit WHERE action='user.reset_password'": 0} {
|
||||
var n int
|
||||
if err := a.s.DB.QueryRow(query).Scan(&n); err != nil || n != want {
|
||||
t.Fatal(query, n, err)
|
||||
}
|
||||
}
|
||||
if w := req("GET", "/api/me", "", "", target); w.Code != 200 {
|
||||
t.Fatal("session changed", w.Code)
|
||||
}
|
||||
if w := req("POST", "/api/refresh", "", cookieValue(target, "dsh_csrf"), target); w.Code != 200 {
|
||||
t.Fatal("refresh changed", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user