Why: two questions about operating this turned up two gaps. "Where do I get the admin password" had no answer once the line scrolled out of the log, and "how do I set up OIDC" ran straight into a sign-in link that could not work. What: - /oidc/login with no parameters now signs a browser in and ends in a session cookie. It previously required the user code of a waiting client, so the link on the web sign-in page always failed. With a code it still approves a device, and the two paths no longer overlap: an empty user code matches no pending device. - POST /api/v1/auth/password, `keeporg passwd`, and a form on /web/vaults. Changing a password requires the current one, and the one-time code when a second factor is enrolled, and revokes that account's other sessions. Until now nothing could change a password, which made the advice printed at first start impossible to follow. - keeporgd -reset-admin, for when that first password was not kept: a new one printed once, any second factor cleared, sessions revoked. It writes the state document directly and says to stop the server first. - The client read stdin through a new buffered reader per prompt, so the first prompt swallowed the rest and anything asking twice could not be scripted. One shared reader now. A prompt for a password being set no longer reads $KEEPORG_PASSWORD either; that variable holds the password you sign in with, and taking it here would have set the new password to the old one. - HANDOVER gains a first-sign-in section and an OIDC setup section covering the redirect URI, the config block, both entry points and how an identity becomes an account. Proved: OIDC now has tests, against a stub provider that serves discovery, authorization, token and userinfo. They cover the web sign-in that was broken, device approval, linking an existing account by username, the refusal when auto_create is off, and a forged callback state. Eight more cover the password change. Checked by hand with the real binaries: the first-start password logs once and works, -reset-admin replaces it while the old one stops working, and `keeporg passwd` changes it from a pipe. Server 0.5.0, desktop clients 0.3.0.
455 lines
14 KiB
Go
455 lines
14 KiB
Go
package server
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"cc.tiredsysadmin.keeporg/internal/secret"
|
|
"cc.tiredsysadmin.keeporg/internal/store"
|
|
"cc.tiredsysadmin.keeporg/internal/totp"
|
|
)
|
|
|
|
type harness struct {
|
|
t *testing.T
|
|
srv *Server
|
|
http *httptest.Server
|
|
dataDir string
|
|
}
|
|
|
|
const adminPassword = "admin-password-1"
|
|
|
|
func newHarness(t *testing.T) *harness {
|
|
return newHarnessWith(t, nil)
|
|
}
|
|
|
|
// newHarnessWith builds a server whose configuration the caller may adjust,
|
|
// for the cases that need OIDC or a different work factor.
|
|
func newHarnessWith(t *testing.T, adjust func(*Config)) *harness {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
// The listener is created first so the server's external URL is the one
|
|
// it actually answers on, which is what an OIDC redirect needs.
|
|
ts := httptest.NewUnstartedServer(nil)
|
|
cfg := Config{
|
|
DataDir: dir,
|
|
ExternalURL: "http://" + ts.Listener.Addr().String(),
|
|
MasterKeyFile: filepath.Join(t.TempDir(), "master.key"),
|
|
VaultKDF: testKDF,
|
|
}
|
|
if adjust != nil {
|
|
adjust(&cfg)
|
|
}
|
|
srv, err := New(cfg, log.New(io.Discard, "", 0))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hash, err := secret.Hash(adminPassword)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := srv.Store().Update(func(st *store.State) error {
|
|
st.Users["admin"] = &store.User{ID: "admin", Username: "admin", PassHash: hash, ServerAdmin: true}
|
|
st.Groups["everyone"] = &store.Group{ID: "everyone", Name: "everyone"}
|
|
st.Memberships = append(st.Memberships,
|
|
store.Membership{UserID: "admin", GroupID: "everyone", Role: store.RoleAdmin})
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ts.Config = &http.Server{Handler: srv.Handler()}
|
|
ts.Start()
|
|
t.Cleanup(ts.Close)
|
|
return &harness{t: t, srv: srv, http: ts, dataDir: dir}
|
|
}
|
|
|
|
// stateBytes is the server's whole metadata document as stored on disk.
|
|
func (h *harness) stateBytes() []byte {
|
|
h.t.Helper()
|
|
b, err := os.ReadFile(filepath.Join(h.dataDir, "state.json"))
|
|
if err != nil {
|
|
h.t.Fatal(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// call sends a JSON request and decodes the JSON response.
|
|
func (h *harness) call(method, path, token string, body any) (int, map[string]any) {
|
|
h.t.Helper()
|
|
var rdr io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
h.t.Fatal(err)
|
|
}
|
|
rdr = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequest(method, h.http.URL+path, rdr)
|
|
if err != nil {
|
|
h.t.Fatal(err)
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
resp, err := h.http.Client().Do(req)
|
|
if err != nil {
|
|
h.t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
out := map[string]any{}
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
if len(raw) > 0 {
|
|
_ = json.Unmarshal(raw, &out)
|
|
}
|
|
return resp.StatusCode, out
|
|
}
|
|
|
|
func (h *harness) login(user, password, code string) (string, int, map[string]any) {
|
|
h.t.Helper()
|
|
status, body := h.call("POST", "/api/v1/auth/login", "",
|
|
map[string]string{"username": user, "password": password, "totp": code, "label": "test"})
|
|
tok, _ := body["token"].(string)
|
|
return tok, status, body
|
|
}
|
|
|
|
// push uploads bytes with an explicit If-Match and returns the response.
|
|
func (h *harness) push(token, dbID, ifMatch string, data []byte) (int, map[string]any, http.Header) {
|
|
h.t.Helper()
|
|
req, err := http.NewRequest("PUT", h.http.URL+"/api/v1/databases/"+dbID+"/blob", bytes.NewReader(data))
|
|
if err != nil {
|
|
h.t.Fatal(err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("If-Match", ifMatch)
|
|
resp, err := h.http.Client().Do(req)
|
|
if err != nil {
|
|
h.t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
out := map[string]any{}
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
_ = json.Unmarshal(raw, &out)
|
|
return resp.StatusCode, out, resp.Header
|
|
}
|
|
|
|
// makeDB creates a database owned by the "everyone" group.
|
|
func (h *harness) makeDB(token, name string) string {
|
|
h.t.Helper()
|
|
status, body := h.call("POST", "/api/v1/databases", token,
|
|
map[string]string{"name": name, "owner_group_id": "everyone"})
|
|
if status != http.StatusCreated {
|
|
h.t.Fatalf("create database: %d %v", status, body)
|
|
}
|
|
return body["id"].(string)
|
|
}
|
|
|
|
func TestLoginRejectsWrongPassword(t *testing.T) {
|
|
h := newHarness(t)
|
|
if _, status, _ := h.login("admin", "nope", ""); status != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d", status)
|
|
}
|
|
tok, status, _ := h.login("admin", adminPassword, "")
|
|
if status != http.StatusOK || tok == "" {
|
|
t.Fatalf("status = %d token = %q", status, tok)
|
|
}
|
|
}
|
|
|
|
func TestPushPullAndServerWinsConflict(t *testing.T) {
|
|
h := newHarness(t)
|
|
tok, _, _ := h.login("admin", adminPassword, "")
|
|
db := h.makeDB(tok, "Team")
|
|
|
|
status, body, hdr := h.push(tok, db, `"0"`, []byte("kdbx-v1"))
|
|
if status != http.StatusOK || body["version"].(float64) != 1 {
|
|
t.Fatalf("first push: %d %v", status, body)
|
|
}
|
|
if hdr.Get("ETag") != `"1"` {
|
|
t.Fatalf("etag = %q", hdr.Get("ETag"))
|
|
}
|
|
|
|
// A second client that still believes it is on version 0 is refused, and
|
|
// learns both the current version and the policy to apply.
|
|
status, body, hdr = h.push(tok, db, `"0"`, []byte("kdbx-other"))
|
|
if status != http.StatusConflict || body["code"] != "conflict" {
|
|
t.Fatalf("stale push: %d %v", status, body)
|
|
}
|
|
if body["current_version"].(float64) != 1 || body["merge_strategy"] != "server-wins" {
|
|
t.Fatalf("conflict body = %v", body)
|
|
}
|
|
if hdr.Get("X-KeepOrg-Strategy") != "server-wins" {
|
|
t.Fatalf("strategy header = %q", hdr.Get("X-KeepOrg-Strategy"))
|
|
}
|
|
|
|
// Forcing is refused while the strategy is server-wins.
|
|
if status, _, _ = h.push(tok, db, "*", []byte("forced")); status != http.StatusConflict {
|
|
t.Fatalf("force under server-wins: %d", status)
|
|
}
|
|
|
|
// Re-pushing identical bytes is a no-op, so a save that changed nothing
|
|
// does not burn a version.
|
|
status, body, _ = h.push(tok, db, `"1"`, []byte("kdbx-v1"))
|
|
if status != http.StatusOK || body["unchanged"] != true || body["version"].(float64) != 1 {
|
|
t.Fatalf("idempotent push: %d %v", status, body)
|
|
}
|
|
|
|
// Download returns exactly what was stored.
|
|
req, _ := http.NewRequest("GET", h.http.URL+"/api/v1/databases/"+db+"/blob", nil)
|
|
req.Header.Set("Authorization", "Bearer "+tok)
|
|
resp, err := h.http.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
got, _ := io.ReadAll(resp.Body)
|
|
if string(got) != "kdbx-v1" || resp.Header.Get("ETag") != `"1"` {
|
|
t.Fatalf("download = %q etag %q", got, resp.Header.Get("ETag"))
|
|
}
|
|
}
|
|
|
|
func TestClientWinsAllowsForce(t *testing.T) {
|
|
h := newHarness(t)
|
|
tok, _, _ := h.login("admin", adminPassword, "")
|
|
db := h.makeDB(tok, "Team")
|
|
h.push(tok, db, `"0"`, []byte("v1"))
|
|
|
|
if status, body := h.call("PATCH", "/api/v1/databases/"+db, tok,
|
|
map[string]string{"merge_strategy": "client-wins"}); status != http.StatusOK {
|
|
t.Fatalf("patch strategy: %d %v", status, body)
|
|
}
|
|
status, body, _ := h.push(tok, db, "*", []byte("v2-forced"))
|
|
if status != http.StatusOK || body["version"].(float64) != 2 {
|
|
t.Fatalf("forced push: %d %v", status, body)
|
|
}
|
|
}
|
|
|
|
func TestReadOnlyGrantCannotPush(t *testing.T) {
|
|
h := newHarness(t)
|
|
admin, _, _ := h.login("admin", adminPassword, "")
|
|
db := h.makeDB(admin, "Team")
|
|
|
|
// A child group with a read-only grant, and a member invited into it.
|
|
status, g := h.call("POST", "/api/v1/groups", admin,
|
|
map[string]string{"name": "readers", "parent_id": "everyone"})
|
|
if status != http.StatusCreated {
|
|
t.Fatalf("create group: %d %v", status, g)
|
|
}
|
|
readers := g["id"].(string)
|
|
if status, body := h.call("POST", "/api/v1/grants", admin, map[string]string{
|
|
"group_id": readers, "database_id": db, "perm": "read"}); status != http.StatusOK {
|
|
t.Fatalf("grant: %d %v", status, body)
|
|
}
|
|
status, inv := h.call("POST", "/api/v1/invites", admin,
|
|
map[string]any{"group_id": readers, "role": "member", "ttl_hours": 1, "max_uses": 1})
|
|
if status != http.StatusCreated {
|
|
t.Fatalf("invite: %d %v", status, inv)
|
|
}
|
|
token := inv["token"].(string)
|
|
|
|
form := url.Values{"username": {"reader"}, "password": {"reader-password"},
|
|
"password2": {"reader-password"}}
|
|
resp, err := h.http.Client().PostForm(h.http.URL+"/invite/"+token, form)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
page, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if !strings.Contains(string(page), "Account created") {
|
|
t.Fatalf("invite redemption page: %s", page)
|
|
}
|
|
|
|
rtok, status, _ := h.login("reader", "reader-password", "")
|
|
if status != http.StatusOK {
|
|
t.Fatalf("reader login: %d", status)
|
|
}
|
|
// The reader sees the database through the grant on its group...
|
|
status, list := h.call("GET", "/api/v1/databases", rtok, nil)
|
|
dbs := list["databases"].([]any)
|
|
if status != http.StatusOK || len(dbs) != 1 {
|
|
t.Fatalf("reader database list: %d %v", status, list)
|
|
}
|
|
if dbs[0].(map[string]any)["perm"] != "read" {
|
|
t.Fatalf("reader perm = %v", dbs[0])
|
|
}
|
|
// ...but may not write it.
|
|
if status, body, _ := h.push(rtok, db, `"0"`, []byte("nope")); status != http.StatusForbidden {
|
|
t.Fatalf("reader push: %d %v", status, body)
|
|
}
|
|
// A second redemption of a single-use invite fails.
|
|
resp2, err := h.http.Client().PostForm(h.http.URL+"/invite/"+token, form)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
page2, _ := io.ReadAll(resp2.Body)
|
|
resp2.Body.Close()
|
|
if !strings.Contains(string(page2), "already been used") {
|
|
t.Fatalf("reused invite page: %s", page2)
|
|
}
|
|
}
|
|
|
|
func TestEventStreamDeliversPush(t *testing.T) {
|
|
h := newHarness(t)
|
|
tok, _, _ := h.login("admin", adminPassword, "")
|
|
db := h.makeDB(tok, "Team")
|
|
|
|
req, _ := http.NewRequest("GET", h.http.URL+"/api/v1/events?access_token="+tok, nil)
|
|
resp, err := h.http.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
|
|
t.Fatalf("content type = %q", ct)
|
|
}
|
|
|
|
lines := make(chan string, 8)
|
|
go func() {
|
|
sc := bufio.NewScanner(resp.Body)
|
|
for sc.Scan() {
|
|
lines <- sc.Text()
|
|
}
|
|
close(lines)
|
|
}()
|
|
// Wait for the stream to register before pushing.
|
|
deadline := time.After(3 * time.Second)
|
|
for h.srv.hub.Count() == 0 {
|
|
select {
|
|
case <-deadline:
|
|
t.Fatal("stream never registered")
|
|
default:
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
}
|
|
if status, body, _ := h.push(tok, db, `"0"`, []byte("v1")); status != http.StatusOK {
|
|
t.Fatalf("push: %d %v", status, body)
|
|
}
|
|
for {
|
|
select {
|
|
case l, open := <-lines:
|
|
if !open {
|
|
t.Fatal("stream closed before the event arrived")
|
|
}
|
|
if strings.HasPrefix(l, "data: ") {
|
|
var ev Event
|
|
if err := json.Unmarshal([]byte(strings.TrimPrefix(l, "data: ")), &ev); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ev.Type != EventUpdated || ev.DatabaseID != db || ev.Version != 1 {
|
|
t.Fatalf("event = %+v", ev)
|
|
}
|
|
return
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for the push event")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTOTPEnrollmentThenRequiredAtLogin(t *testing.T) {
|
|
h := newHarness(t)
|
|
tok, _, _ := h.login("admin", adminPassword, "")
|
|
status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("enroll: %d %v", status, body)
|
|
}
|
|
sec := body["secret"].(string)
|
|
if !strings.HasPrefix(body["uri"].(string), "otpauth://totp/") {
|
|
t.Fatalf("uri = %v", body["uri"])
|
|
}
|
|
code, err := totp.Code(sec, time.Now())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if status, body = h.call("POST", "/api/v1/auth/totp/confirm", tok,
|
|
map[string]string{"code": code}); status != http.StatusOK {
|
|
t.Fatalf("confirm: %d %v", status, body)
|
|
}
|
|
if _, status, body = h.login("admin", adminPassword, ""); status != http.StatusUnauthorized ||
|
|
body["code"] != "totp_required" {
|
|
t.Fatalf("login without code: %d %v", status, body)
|
|
}
|
|
code, _ = totp.Code(sec, time.Now())
|
|
if _, status, body = h.login("admin", adminPassword, code); status != http.StatusOK {
|
|
t.Fatalf("login with code: %d %v", status, body)
|
|
}
|
|
}
|
|
|
|
func TestDeviceLoginApprovedInBrowser(t *testing.T) {
|
|
h := newHarness(t)
|
|
status, start := h.call("POST", "/api/v1/auth/device/start", "", map[string]string{"label": "laptop"})
|
|
if status != http.StatusOK {
|
|
t.Fatalf("device start: %d %v", status, start)
|
|
}
|
|
deviceCode := start["device_code"].(string)
|
|
userCode := start["user_code"].(string)
|
|
if !strings.Contains(start["verification_url"].(string), userCode) {
|
|
t.Fatalf("verification url = %v", start["verification_url"])
|
|
}
|
|
if status, body := h.call("POST", "/api/v1/auth/device/poll", "",
|
|
map[string]string{"device_code": deviceCode}); status != http.StatusAccepted {
|
|
t.Fatalf("poll before approval: %d %v", status, body)
|
|
}
|
|
resp, err := h.http.Client().PostForm(h.http.URL+"/device", url.Values{
|
|
"code": {userCode}, "username": {"admin"}, "password": {adminPassword}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
page, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if !strings.Contains(string(page), "Device approved") {
|
|
t.Fatalf("approval page: %s", page)
|
|
}
|
|
status, body := h.call("POST", "/api/v1/auth/device/poll", "",
|
|
map[string]string{"device_code": deviceCode})
|
|
if status != http.StatusOK || body["token"] == "" {
|
|
t.Fatalf("poll after approval: %d %v", status, body)
|
|
}
|
|
// The issued token works, and the device code is single use.
|
|
if status, _ := h.call("GET", "/api/v1/auth/whoami", body["token"].(string), nil); status != http.StatusOK {
|
|
t.Fatalf("whoami with device token: %d", status)
|
|
}
|
|
if status, _ := h.call("POST", "/api/v1/auth/device/poll", "",
|
|
map[string]string{"device_code": deviceCode}); status != http.StatusGone {
|
|
t.Fatalf("second poll: %d", status)
|
|
}
|
|
}
|
|
|
|
func TestNonAdminCannotAdminister(t *testing.T) {
|
|
h := newHarness(t)
|
|
admin, _, _ := h.login("admin", adminPassword, "")
|
|
hash, _ := secret.Hash("outsider-password")
|
|
if err := h.srv.Store().Update(func(st *store.State) error {
|
|
st.Users["out"] = &store.User{ID: "out", Username: "outsider", PassHash: hash}
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db := h.makeDB(admin, "Team")
|
|
otok, _, _ := h.login("outsider", "outsider-password", "")
|
|
|
|
if status, _ := h.call("POST", "/api/v1/databases", otok,
|
|
map[string]string{"name": "x", "owner_group_id": "everyone"}); status != http.StatusForbidden {
|
|
t.Fatalf("outsider created a database: %d", status)
|
|
}
|
|
if status, _ := h.call("PATCH", "/api/v1/databases/"+db, otok,
|
|
map[string]string{"merge_strategy": "client-wins"}); status != http.StatusForbidden {
|
|
t.Fatalf("outsider changed the strategy: %d", status)
|
|
}
|
|
// Without any grant the database is invisible, not merely unwritable.
|
|
if status, _ := h.call("GET", "/api/v1/databases/"+db+"/blob", otok, nil); status != http.StatusNotFound {
|
|
t.Fatalf("outsider read the blob: %d", status)
|
|
}
|
|
if status, _ := h.call("GET", "/api/v1/databases", "", nil); status != http.StatusUnauthorized {
|
|
t.Fatalf("anonymous list: %d", status)
|
|
}
|
|
}
|