Keep server data in an encrypted database, and harden every part
Storage. The server keeps its data in SQLite (a file in the data directory, the default) or PostgreSQL (database, KEEPORG_DATABASE_URL or -database). Every row, the KDBX versions included, is sealed with AES-256-GCM under a key derived from the master key and named by an HMAC. The master key is required to start and a different one is refused. A data directory from 0.10 or earlier is imported on first start. Several servers may share one PostgreSQL database. Go goes to 1.26 for the pure-Go drivers (modernc.org/sqlite, jackc/pgx). Hardening, after an audit of the server, both clients and the deployment: - Single sign-on approves a device only after an explicit Approve in the browser that started it, bound by a cookie and PKCE; identities are never linked by username, by verified email only when allowed, never to admins. - Sign-in attempts are limited per address and per account, passwords are checked outside the store lock with uniform timing, one-time codes work once, and starting two-factor needs the password. - Merge vaults bound KDF parameters, payload size and XML shape, check pushes before storing them, and run at most two merges at once. A version is written in the same transaction as its metadata. - Tokens in URLs only on the event stream, which ends on revocation; CSP, frame-ancestors, no-store and HSTS; sign-in CSRF; 404 for databases without access; new trusted_proxies. - Clients open only the server's approval page, keep case-variant and retired file names apart, never overwrite a save made during a pull, require https except locally, send the stream token in a header, bound their reads and escape notifications and terminal output. - The .deb creates the master key, systemd is sandboxed further, nginx sets modern TLS and HSTS, and CI deploys from main only. Server 0.12.0, desktop 0.6.0, Android 0.4.0 (versionCode 4). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f8f634fbf7
commit
4ffc434b1b
+25
-41
@@ -27,8 +27,8 @@ var (
|
||||
// is no password to change.
|
||||
errNoPassword = errors.New("this account signs in through single sign-on and has no password here")
|
||||
// errReauth asks for the password and, once enrolled, the one-time code,
|
||||
// before a change that would weaken how the account signs in. A live
|
||||
// session is what an unlocked laptop leaves lying around.
|
||||
// before a change to how the account signs in. A live session is what
|
||||
// an unlocked laptop leaves lying around.
|
||||
errReauth = errors.New("confirm with your password and one-time code")
|
||||
)
|
||||
|
||||
@@ -104,24 +104,12 @@ func revokeOtherSessions(st *store.State, userID, keep string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// reauth checks the caller's password, and their one-time code when one is
|
||||
// enrolled, for a change that weakens or replaces how the account signs in.
|
||||
func reauth(st *store.State, u *store.User, password, code string) error {
|
||||
if u.PassHash == "" {
|
||||
return errNoPassword
|
||||
}
|
||||
if password == "" {
|
||||
return errReauth
|
||||
}
|
||||
_, err := checkPassword(st, u.Username, password, code)
|
||||
return err
|
||||
}
|
||||
|
||||
// beginTOTP starts enrolling a second factor and returns its secret. Until
|
||||
// confirmTOTP sees a code from it, the account signs in as before. Replacing
|
||||
// a factor that is already active needs the password and the current code,
|
||||
// because a new secret disarms the old one.
|
||||
func beginTOTP(st *store.State, userID, password, code string) (string, error) {
|
||||
// confirmTOTP sees a code from it, the account signs in as before. The
|
||||
// caller has re-proved the password (and any current code): turning a
|
||||
// factor on from a stolen session would lock the owner out, and replacing
|
||||
// one disarms the old.
|
||||
func beginTOTP(st *store.State, userID string) (string, error) {
|
||||
u := st.Users[userID]
|
||||
if u == nil {
|
||||
return "", errNoSuchSession
|
||||
@@ -129,16 +117,12 @@ func beginTOTP(st *store.State, userID, password, code string) (string, error) {
|
||||
if u.PassHash == "" {
|
||||
return "", errNoPassword
|
||||
}
|
||||
if u.TOTPConfirmed {
|
||||
if err := reauth(st, u, password, code); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
u.TOTPSecret, u.TOTPConfirmed = totp.NewSecret(), false
|
||||
u.TOTPSecret, u.TOTPConfirmed, u.TOTPLastStep = totp.NewSecret(), false, 0
|
||||
return u.TOTPSecret, nil
|
||||
}
|
||||
|
||||
// confirmTOTP turns the second factor on, once a code from it checks out.
|
||||
// That code is spent: it cannot also sign in.
|
||||
func confirmTOTP(st *store.State, userID, code string) error {
|
||||
u := st.Users[userID]
|
||||
if u == nil || u.TOTPSecret == "" {
|
||||
@@ -147,10 +131,11 @@ func confirmTOTP(st *store.State, userID, code string) error {
|
||||
if u.TOTPConfirmed {
|
||||
return errors.New("the second factor is already on")
|
||||
}
|
||||
if !totp.Check(u.TOTPSecret, code, time.Now()) {
|
||||
step, ok := totp.Match(u.TOTPSecret, code, time.Now())
|
||||
if !ok {
|
||||
return errTOTPInvalid
|
||||
}
|
||||
u.TOTPConfirmed = true
|
||||
u.TOTPConfirmed, u.TOTPLastStep = true, step
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -161,9 +146,9 @@ func cancelTOTP(st *store.State, userID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// disableTOTP turns the second factor off, behind the password and a
|
||||
// current code.
|
||||
func disableTOTP(st *store.State, userID, password, code string) error {
|
||||
// disableTOTP turns the second factor off. The caller has re-proved the
|
||||
// password and a current code.
|
||||
func disableTOTP(st *store.State, userID string) error {
|
||||
u := st.Users[userID]
|
||||
if u == nil {
|
||||
return errNoSuchSession
|
||||
@@ -171,10 +156,7 @@ func disableTOTP(st *store.State, userID, password, code string) error {
|
||||
if !u.TOTPConfirmed {
|
||||
return errors.New("no second factor is on")
|
||||
}
|
||||
if err := reauth(st, u, password, code); err != nil {
|
||||
return err
|
||||
}
|
||||
u.TOTPSecret, u.TOTPConfirmed = "", false
|
||||
u.TOTPSecret, u.TOTPConfirmed, u.TOTPLastStep = "", false, 0
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -182,6 +164,9 @@ func disableTOTP(st *store.State, userID, password, code string) error {
|
||||
// failures are 403, not 401: a client that drops its token on 401 must not
|
||||
// sign itself out over a mistyped password.
|
||||
func failAccount(w http.ResponseWriter, err error) {
|
||||
if writeThrottled(w, err) {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, errNoSuchSession):
|
||||
fail(w, http.StatusNotFound, "not_found", err.Error())
|
||||
@@ -190,7 +175,7 @@ func failAccount(w http.ResponseWriter, err error) {
|
||||
case errors.Is(err, errTOTPInvalid):
|
||||
fail(w, http.StatusForbidden, "totp_invalid", "the one-time code was rejected")
|
||||
case errors.Is(err, errBadCredentials):
|
||||
fail(w, http.StatusForbidden, "bad_credentials", "the password is wrong")
|
||||
fail(w, http.StatusForbidden, "bad_credentials", "the password or one-time code is wrong")
|
||||
case errors.Is(err, errNoPassword):
|
||||
fail(w, http.StatusConflict, "no_password", err.Error())
|
||||
default:
|
||||
@@ -228,8 +213,7 @@ func (s *Server) handleRevokeOtherSessions(w http.ResponseWriter, r *http.Reques
|
||||
writeJSON(w, http.StatusOK, map[string]int{"revoked": n})
|
||||
}
|
||||
|
||||
// reauthRequest is the body of a change that may need re-authentication.
|
||||
// It is optional where nothing is enrolled yet.
|
||||
// reauthRequest is the body of a change that needs re-authentication.
|
||||
type reauthRequest struct {
|
||||
Password string `json:"password"`
|
||||
TOTP string `json:"totp"`
|
||||
@@ -241,9 +225,9 @@ func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request, u *sto
|
||||
return
|
||||
}
|
||||
var sec string
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
if err := s.reconfirm(r, u, req.Password, req.TOTP, func(st *store.State, u *store.User) error {
|
||||
var err error
|
||||
sec, err = beginTOTP(st, u.ID, req.Password, req.TOTP)
|
||||
sec, err = beginTOTP(st, u.ID)
|
||||
return err
|
||||
}); err != nil {
|
||||
failAccount(w, err)
|
||||
@@ -280,8 +264,8 @@ func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request, u *st
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
return disableTOTP(st, u.ID, req.Password, req.TOTP)
|
||||
if err := s.reconfirm(r, u, req.Password, req.TOTP, func(st *store.State, u *store.User) error {
|
||||
return disableTOTP(st, u.ID)
|
||||
}); err != nil {
|
||||
failAccount(w, err)
|
||||
return
|
||||
|
||||
@@ -13,6 +13,34 @@ import (
|
||||
"cc.tiredsysadmin.keeporg/internal/totp"
|
||||
)
|
||||
|
||||
// codeAt is the one-time code d steps from now. Each code is good once, so
|
||||
// a test that needs several uses inside one 30-second step takes them at
|
||||
// -1, 0 and +1, all within the accepted skew.
|
||||
func codeAt(t *testing.T, sec string, d int) string {
|
||||
t.Helper()
|
||||
c, err := totp.Code(sec, time.Now().Add(time.Duration(d)*30*time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// enrollTOTP turns the second factor on for the token's account, spending
|
||||
// the code one step back, and returns the secret.
|
||||
func (h *harness) enrollTOTP(t *testing.T, tok, password string) string {
|
||||
t.Helper()
|
||||
status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, map[string]string{"password": password})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("enroll: %d %v", status, body)
|
||||
}
|
||||
sec := body["secret"].(string)
|
||||
if status, body := h.call("POST", "/api/v1/auth/totp/confirm", tok,
|
||||
map[string]string{"code": codeAt(t, sec, -1)}); status != http.StatusOK {
|
||||
t.Fatalf("confirm: %d %v", status, body)
|
||||
}
|
||||
return sec
|
||||
}
|
||||
|
||||
func sessionList(t *testing.T, h *harness, token string) []map[string]any {
|
||||
t.Helper()
|
||||
status, body := h.call("GET", "/api/v1/auth/sessions", token, nil)
|
||||
@@ -75,16 +103,13 @@ func TestReplacingOrRemovingTheSecondFactorNeedsThePassword(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
tok, _, _ := h.login("admin", adminPassword, "")
|
||||
|
||||
// Turning it on needs nothing beyond the session: it only adds a check.
|
||||
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)
|
||||
code, _ := totp.Code(sec, time.Now())
|
||||
if status, _ := h.call("POST", "/api/v1/auth/totp/confirm", tok, map[string]string{"code": code}); status != http.StatusOK {
|
||||
t.Fatalf("confirm: %d", status)
|
||||
// Even turning it on needs the password: on a stolen session it would
|
||||
// lock the owner out.
|
||||
if status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil); status != http.StatusForbidden {
|
||||
t.Fatalf("enroll without the password: %d %v", status, body)
|
||||
}
|
||||
sec := h.enrollTOTP(t, tok, adminPassword)
|
||||
code := codeAt(t, sec, 0)
|
||||
|
||||
// A session alone can no longer swap the secret or turn it off.
|
||||
if status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil); status != http.StatusForbidden || body["code"] != "reauth_required" {
|
||||
@@ -98,6 +123,11 @@ func TestReplacingOrRemovingTheSecondFactorNeedsThePassword(t *testing.T) {
|
||||
map[string]string{"password": "wrong-password", "totp": code}); status != http.StatusForbidden || body["code"] != "bad_credentials" {
|
||||
t.Fatalf("disable with a wrong password: %d %v", status, body)
|
||||
}
|
||||
// A wrong code reads the same as a wrong password.
|
||||
if status, body := h.call("DELETE", "/api/v1/auth/totp", tok,
|
||||
map[string]string{"password": adminPassword, "totp": "000000"}); status != http.StatusForbidden || body["code"] != "bad_credentials" {
|
||||
t.Fatalf("disable with a wrong code: %d %v", status, body)
|
||||
}
|
||||
var on bool
|
||||
h.srv.Store().Read(func(st *store.State) { on = st.Users["admin"].TOTPConfirmed })
|
||||
if !on {
|
||||
@@ -171,6 +201,11 @@ func TestAccountPageSetsUpTheSecondFactor(t *testing.T) {
|
||||
csrf := b.signIn()
|
||||
|
||||
_, page := b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-begin"}})
|
||||
if !strings.Contains(page, "Confirm with your password") {
|
||||
t.Fatalf("set up without the password:\n%s", page)
|
||||
}
|
||||
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-begin"},
|
||||
"password": {adminPassword}})
|
||||
if !strings.Contains(page, `href="otpauth://totp/`) {
|
||||
t.Fatalf("no authenticator link:\n%s", page)
|
||||
}
|
||||
@@ -184,8 +219,7 @@ func TestAccountPageSetsUpTheSecondFactor(t *testing.T) {
|
||||
if !strings.Contains(page, "rejected") {
|
||||
t.Fatalf("a wrong code was not refused:\n%s", page)
|
||||
}
|
||||
code, _ := totp.Code(sec, time.Now())
|
||||
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-confirm"}, "code": {code}})
|
||||
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-confirm"}, "code": {codeAt(t, sec, -1)}})
|
||||
if !strings.Contains(page, "Two-factor authentication is on") {
|
||||
t.Fatalf("not turned on:\n%s", page)
|
||||
}
|
||||
@@ -199,7 +233,7 @@ func TestAccountPageSetsUpTheSecondFactor(t *testing.T) {
|
||||
t.Fatalf("turned off without a code:\n%s", page)
|
||||
}
|
||||
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-disable"},
|
||||
"password": {adminPassword}, "totp": {code}})
|
||||
"password": {adminPassword}, "totp": {codeAt(t, sec, 0)}})
|
||||
if !strings.Contains(page, "Two-factor authentication is off") {
|
||||
t.Fatalf("not turned off:\n%s", page)
|
||||
}
|
||||
@@ -257,6 +291,17 @@ func TestBrowserLabel(t *testing.T) {
|
||||
|
||||
// makeUser adds a password account and returns a session token for it.
|
||||
func (h *harness) makeUser(name, password string) string {
|
||||
h.t.Helper()
|
||||
h.makeUserNoLogin(name, password)
|
||||
tok, status, body := h.login(name, password, "")
|
||||
if status != http.StatusOK {
|
||||
h.t.Fatalf("login %s: %d %v", name, status, body)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
// makeUserNoLogin adds a password account.
|
||||
func (h *harness) makeUserNoLogin(name, password string) {
|
||||
h.t.Helper()
|
||||
hash, err := secret.Hash(password)
|
||||
if err != nil {
|
||||
@@ -268,9 +313,4 @@ func (h *harness) makeUser(name, password string) string {
|
||||
}); err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
tok, status, body := h.login(name, password, "")
|
||||
if status != http.StatusOK {
|
||||
h.t.Fatalf("login %s: %d %v", name, status, body)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
+82
-58
@@ -23,14 +23,19 @@ var errForbidden = errors.New("forbidden")
|
||||
// cases stay distinct so a client knows to prompt for the factor it is
|
||||
// missing rather than reporting a flat refusal.
|
||||
func denyAuth(w http.ResponseWriter, err error) {
|
||||
if writeThrottled(w, err) {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, errNoDatabase):
|
||||
fail(w, http.StatusNotFound, "not_found", err.Error())
|
||||
case errors.Is(err, errTOTPRequired):
|
||||
fail(w, http.StatusUnauthorized, "totp_required", "second factor required")
|
||||
case errors.Is(err, errTOTPInvalid):
|
||||
fail(w, http.StatusUnauthorized, "totp_invalid", "second factor rejected")
|
||||
fail(w, http.StatusUnauthorized, "totp_required", "your one-time code is required for this")
|
||||
case errors.Is(err, errReauth):
|
||||
fail(w, http.StatusUnauthorized, "bad_credentials", "your own account password is required for this")
|
||||
case errors.Is(err, errBadCredentials):
|
||||
fail(w, http.StatusUnauthorized, "bad_credentials",
|
||||
"your own account password is required for this")
|
||||
"your own account password or one-time code is wrong")
|
||||
default:
|
||||
deny(w, err)
|
||||
}
|
||||
@@ -41,6 +46,8 @@ func deny(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case err == nil:
|
||||
return
|
||||
case errors.Is(err, errNoDatabase):
|
||||
fail(w, http.StatusNotFound, "not_found", err.Error())
|
||||
case errors.Is(err, errForbidden):
|
||||
fail(w, http.StatusForbidden, "forbidden", "you do not administer that object")
|
||||
default:
|
||||
@@ -215,16 +222,27 @@ func removeMember(st *store.State, u *store.User, groupID string, refs ...string
|
||||
|
||||
// ---- grants ----
|
||||
|
||||
// adminsDatabase checks that u administers a database. One without any
|
||||
// access cannot tell a database that is not theirs from one that does not
|
||||
// exist; one who can see it is told it is not theirs to change.
|
||||
func adminsDatabase(st *store.State, u *store.User, dbID string) error {
|
||||
perm := st.DatabasePerm(u.ID, dbID)
|
||||
switch {
|
||||
case st.Databases[dbID] == nil || perm == store.PermNone:
|
||||
return errNoDatabase
|
||||
case perm != store.PermAdmin:
|
||||
return errForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setGrant(st *store.State, u *store.User, groupID, dbID string, perm store.Perm) error {
|
||||
if st.Databases[dbID] == nil {
|
||||
return errors.New("no such database")
|
||||
if err := adminsDatabase(st, u, dbID); err != nil {
|
||||
return err
|
||||
}
|
||||
if st.Groups[groupID] == nil {
|
||||
return errors.New("no such group")
|
||||
}
|
||||
if st.DatabasePerm(u.ID, dbID) != store.PermAdmin {
|
||||
return errForbidden
|
||||
}
|
||||
if !perm.Valid() {
|
||||
return errors.New("perm must be read, write or admin")
|
||||
}
|
||||
@@ -239,11 +257,8 @@ func setGrant(st *store.State, u *store.User, groupID, dbID string, perm store.P
|
||||
}
|
||||
|
||||
func removeGrant(st *store.State, u *store.User, groupID, dbID string) error {
|
||||
if st.Databases[dbID] == nil {
|
||||
return errors.New("no such database")
|
||||
}
|
||||
if st.DatabasePerm(u.ID, dbID) != store.PermAdmin {
|
||||
return errForbidden
|
||||
if err := adminsDatabase(st, u, dbID); err != nil {
|
||||
return err
|
||||
}
|
||||
st.Grants = filterGrants(st.Grants, func(g store.Grant) bool {
|
||||
return g.GroupID != groupID || g.DatabaseID != dbID
|
||||
@@ -445,13 +460,10 @@ func (s *Server) handlePatchDatabase(w http.ResponseWriter, r *http.Request, u *
|
||||
}
|
||||
var out dbView
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
if err := adminsDatabase(st, u, id); err != nil {
|
||||
return err
|
||||
}
|
||||
db := st.Databases[id]
|
||||
if db == nil {
|
||||
return errors.New("no such database")
|
||||
}
|
||||
if st.DatabasePerm(u.ID, id) != store.PermAdmin {
|
||||
return errForbidden
|
||||
}
|
||||
if req.Name != nil {
|
||||
if strings.TrimSpace(*req.Name) == "" {
|
||||
return errors.New("name may not be empty")
|
||||
@@ -483,49 +495,48 @@ func (s *Server) handlePatchDatabase(w http.ResponseWriter, r *http.Request, u *
|
||||
// did not type the name of the thing it is about to destroy.
|
||||
var errConfirmName = errors.New("type the name exactly to confirm")
|
||||
|
||||
// confirmDeletion re-proves who the caller is before something irreversible.
|
||||
// Holding a session is enough to read and write a vault; it is not enough to
|
||||
// destroy every version of one, because a session is what an unlocked laptop
|
||||
// leaves lying around.
|
||||
//
|
||||
// An account that signs in only through an identity provider has no password
|
||||
// to type. Refusing those accounts would mean a deployment where nobody can
|
||||
// delete anything, so they confirm by typing the exact name instead. That is
|
||||
// a weaker gate and it is deliberately the fallback, not the rule.
|
||||
func confirmDeletion(st *store.State, u *store.User, name, password, totpCode, typed string) error {
|
||||
target := st.Users[u.ID]
|
||||
if target == nil {
|
||||
return errBadCredentials
|
||||
}
|
||||
if target.PassHash == "" {
|
||||
if strings.TrimSpace(typed) != name {
|
||||
return errConfirmName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
_, err := checkPassword(st, target.Username, password, totpCode)
|
||||
return err
|
||||
}
|
||||
// errNoDatabase is a database that does not exist or that the caller does
|
||||
// not administer; the two are not told apart, so neither answer says that a
|
||||
// database exists.
|
||||
var errNoDatabase = errors.New("no such database")
|
||||
|
||||
// deleteDatabase removes a database, its grants and every stored version of
|
||||
// the file. Clients keep whatever they already have on disk: the server can
|
||||
// stop tracking a vault, but it has no business reaching into somebody's
|
||||
// home directory.
|
||||
func (s *Server) deleteDatabase(u *store.User, id, password, totpCode, typed string) (string, error) {
|
||||
//
|
||||
// It re-proves who the caller is first. Holding a session is enough to read
|
||||
// and write a vault; it is not enough to destroy every version of one,
|
||||
// because a session is what an unlocked laptop leaves lying around. The
|
||||
// password is checked like a sign-in — outside the store's lock and against
|
||||
// the attempt limits — and only for a caller who administers the database,
|
||||
// so it is not a password oracle for anyone else.
|
||||
//
|
||||
// An account that signs in only through an identity provider has no password
|
||||
// to type. Refusing those accounts would mean a deployment where nobody can
|
||||
// delete anything, so they confirm by typing the exact name instead. That is
|
||||
// a weaker gate and it is deliberately the fallback, not the rule.
|
||||
func (s *Server) deleteDatabase(r *http.Request, u *store.User, id, password, totpCode, typed string) (string, error) {
|
||||
var (
|
||||
name string
|
||||
watchers []string
|
||||
name string
|
||||
hasPassword bool
|
||||
)
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
s.st.Read(func(st *store.State) {
|
||||
if db := st.Databases[id]; db != nil && st.DatabasePerm(u.ID, id) == store.PermAdmin {
|
||||
name = db.Name
|
||||
}
|
||||
if cur := st.Users[u.ID]; cur != nil {
|
||||
hasPassword = cur.PassHash != ""
|
||||
}
|
||||
})
|
||||
if name == "" {
|
||||
return "", errNoDatabase
|
||||
}
|
||||
var watchers []string
|
||||
remove := func(st *store.State, _ *store.User) error {
|
||||
db := st.Databases[id]
|
||||
if db == nil {
|
||||
return errors.New("no such database")
|
||||
}
|
||||
if st.DatabasePerm(u.ID, id) != store.PermAdmin {
|
||||
return errForbidden
|
||||
}
|
||||
if cerr := confirmDeletion(st, u, db.Name, password, totpCode, typed); cerr != nil {
|
||||
return cerr
|
||||
if db == nil || st.DatabasePerm(u.ID, id) != store.PermAdmin {
|
||||
return errNoDatabase
|
||||
}
|
||||
name = db.Name
|
||||
// Taken before the delete: the people to tell are the ones who are
|
||||
@@ -534,7 +545,16 @@ func (s *Server) deleteDatabase(u *store.User, id, password, totpCode, typed str
|
||||
delete(st.Databases, id)
|
||||
st.Grants = filterGrants(st.Grants, func(g store.Grant) bool { return g.DatabaseID != id })
|
||||
return nil
|
||||
})
|
||||
}
|
||||
var err error
|
||||
if !hasPassword {
|
||||
if strings.TrimSpace(typed) != name {
|
||||
return "", errConfirmName
|
||||
}
|
||||
err = s.st.Update(func(st *store.State) error { return remove(st, u) })
|
||||
} else {
|
||||
err = s.reconfirm(r, u, password, totpCode, remove)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -555,7 +575,7 @@ func (s *Server) handleDeleteDatabase(w http.ResponseWriter, r *http.Request, u
|
||||
if r.ContentLength > 0 && !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if _, err := s.deleteDatabase(u, r.PathValue("id"), req.Password, req.TOTP, req.Confirm); err != nil {
|
||||
if _, err := s.deleteDatabase(r, u, r.PathValue("id"), req.Password, req.TOTP, req.Confirm); err != nil {
|
||||
denyAuth(w, err)
|
||||
return
|
||||
}
|
||||
@@ -777,7 +797,11 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request, u *stor
|
||||
out := []userView{}
|
||||
s.st.Read(func(st *store.State) {
|
||||
for _, target := range visibleUsers(st, u) {
|
||||
out = append(out, view(target))
|
||||
v := view(target)
|
||||
if !u.ServerAdmin && target.ID != u.ID {
|
||||
v.Email, v.TOTPEnabled, v.ServerAdmin = "", false, false
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": out})
|
||||
|
||||
+69
-62
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
"cc.tiredsysadmin.keeporg/internal/totp"
|
||||
)
|
||||
|
||||
// MinPasswordLen is the shortest password the server will store.
|
||||
@@ -19,33 +18,31 @@ const MinPasswordLen = 10
|
||||
// deviceTTL bounds how long a pending device login stays open.
|
||||
const deviceTTL = 10 * time.Minute
|
||||
|
||||
// maxPendingDevices bounds the device logins anyone can open without an
|
||||
// account. Each is a row in the state, which every write copies.
|
||||
const maxPendingDevices = 500
|
||||
|
||||
// errBusy means too many device logins are pending.
|
||||
var errBusy = errors.New("too many sign-ins are pending; try again in a few minutes")
|
||||
|
||||
// pruneDevices drops expired device logins and reports whether there is
|
||||
// room for another.
|
||||
func pruneDevices(st *store.State) bool {
|
||||
now := time.Now()
|
||||
for k, d := range st.Devices {
|
||||
if now.After(d.ExpiresAt) {
|
||||
delete(st.Devices, k)
|
||||
}
|
||||
}
|
||||
return len(st.Devices) < maxPendingDevices
|
||||
}
|
||||
|
||||
var (
|
||||
errBadCredentials = errors.New("bad credentials")
|
||||
errTOTPRequired = errors.New("totp required")
|
||||
errTOTPInvalid = errors.New("totp invalid")
|
||||
)
|
||||
|
||||
// checkPassword validates a username, password and second factor against the
|
||||
// state. It is the one place password login is decided.
|
||||
func checkPassword(st *store.State, username, password, code string) (*store.User, error) {
|
||||
u := st.UserByName(username)
|
||||
if u == nil || u.Disabled || u.PassHash == "" {
|
||||
return nil, errBadCredentials
|
||||
}
|
||||
if !secret.Verify(u.PassHash, password) {
|
||||
return nil, errBadCredentials
|
||||
}
|
||||
if u.TOTPConfirmed {
|
||||
if strings.TrimSpace(code) == "" {
|
||||
return nil, errTOTPRequired
|
||||
}
|
||||
if !totp.Check(u.TOTPSecret, code, time.Now()) {
|
||||
return nil, errTOTPInvalid
|
||||
}
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
@@ -78,15 +75,14 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var resp loginResponse
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
u, err := checkPassword(st, req.Username, req.Password, req.TOTP)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sess := s.newSession(st, u.ID, req.Label)
|
||||
err := s.signIn(r, req.Username, req.Password, req.TOTP, func(st *store.State, u *store.User) error {
|
||||
sess := s.newSession(st, u.ID, clampLabel(req.Label))
|
||||
resp = loginResponse{Token: sess.Token, ExpiresAt: sess.ExpiresAt, User: view(u)}
|
||||
return nil
|
||||
})
|
||||
if writeThrottled(w, err) {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, errTOTPRequired):
|
||||
fail(w, http.StatusUnauthorized, "totp_required", "second factor required")
|
||||
@@ -151,19 +147,31 @@ func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) {
|
||||
"return_url must use the "+returnScheme+": scheme")
|
||||
return
|
||||
}
|
||||
if wait := s.limits.take("start:" + s.clientIP(r)); wait > 0 {
|
||||
writeThrottled(w, &errThrottled{RetryAfter: wait})
|
||||
return
|
||||
}
|
||||
d := &store.DeviceAuth{
|
||||
DeviceCode: secret.Token(),
|
||||
UserCode: secret.UserCode(),
|
||||
Label: req.Label,
|
||||
Label: clampLabel(req.Label),
|
||||
ReturnURL: req.ReturnURL,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(deviceTTL),
|
||||
}
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
if !pruneDevices(st) {
|
||||
return errBusy
|
||||
}
|
||||
st.Devices[d.DeviceCode] = d
|
||||
return nil
|
||||
}); err != nil {
|
||||
fail(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
if errors.Is(err, errBusy) {
|
||||
fail(w, http.StatusServiceUnavailable, "busy", err.Error())
|
||||
return
|
||||
}
|
||||
s.log.Printf("device start: %v", err)
|
||||
fail(w, http.StatusInternalServerError, "internal", "could not start the sign-in")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
@@ -237,20 +245,20 @@ func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
code := strings.ToUpper(strings.TrimSpace(r.FormValue("code")))
|
||||
var returnURL string
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
d := st.DeviceByUserCode(code)
|
||||
if d == nil || time.Now().After(d.ExpiresAt) {
|
||||
return errors.New("unknown or expired code")
|
||||
}
|
||||
returnURL = d.ReturnURL
|
||||
u, err := checkPassword(st, r.FormValue("username"), r.FormValue("password"), r.FormValue("totp"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.SessionToken = s.newSession(st, u.ID, d.Label).Token
|
||||
return nil
|
||||
})
|
||||
err := s.signIn(r, r.FormValue("username"), r.FormValue("password"), r.FormValue("totp"),
|
||||
func(st *store.State, u *store.User) error {
|
||||
d := st.DeviceByUserCode(code)
|
||||
if d == nil || time.Now().After(d.ExpiresAt) || d.SessionToken != "" {
|
||||
return errors.New("unknown or expired code")
|
||||
}
|
||||
returnURL = d.ReturnURL
|
||||
d.SessionToken = s.newSession(st, u.ID, d.Label).Token
|
||||
return nil
|
||||
})
|
||||
var throttled *errThrottled
|
||||
switch {
|
||||
case errors.As(err, &throttled):
|
||||
s.renderDevice(w, code, throttled.Error())
|
||||
case errors.Is(err, errTOTPRequired):
|
||||
s.renderDevice(w, code, "This account requires a one-time code.")
|
||||
case errors.Is(err, errTOTPInvalid):
|
||||
@@ -280,28 +288,27 @@ func validReturnURL(raw string) bool {
|
||||
// session, the second factor is required when one is enrolled, and every
|
||||
// other session of that user is revoked, because a password change is what
|
||||
// someone does when they think a session is no longer theirs.
|
||||
func changePassword(st *store.State, username, current, totpCode, next, keepToken string) error {
|
||||
u, err := checkPassword(st, username, current, totpCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
func (s *Server) changePassword(r *http.Request, u *store.User, current, code, next, keepToken string) error {
|
||||
if len(next) < MinPasswordLen {
|
||||
return fmt.Errorf("the new password must be at least %d characters", MinPasswordLen)
|
||||
}
|
||||
if next == current {
|
||||
return errors.New("the new password is the same as the old one")
|
||||
}
|
||||
// Hashed before the write, for the same reason verify checks outside it.
|
||||
hash, err := secret.Hash(next)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.PassHash = hash
|
||||
for token, sess := range st.Sessions {
|
||||
if sess.UserID == u.ID && token != keepToken {
|
||||
delete(st.Sessions, token)
|
||||
return s.reconfirm(r, u, current, code, func(st *store.State, u *store.User) error {
|
||||
u.PassHash = hash
|
||||
for token, sess := range st.Sessions {
|
||||
if sess.UserID == u.ID && token != keepToken {
|
||||
delete(st.Sessions, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
@@ -313,17 +320,17 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request, u
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
return changePassword(st, u.Username, req.CurrentPassword, req.TOTP,
|
||||
req.NewPassword, bearer(r))
|
||||
})
|
||||
err := s.changePassword(r, u, req.CurrentPassword, req.TOTP, req.NewPassword, bearer(r))
|
||||
if writeThrottled(w, err) {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, errTOTPRequired):
|
||||
fail(w, http.StatusUnauthorized, "totp_required", "second factor required")
|
||||
case errors.Is(err, errTOTPInvalid):
|
||||
fail(w, http.StatusUnauthorized, "totp_invalid", "second factor rejected")
|
||||
case errors.Is(err, errBadCredentials):
|
||||
fail(w, http.StatusUnauthorized, "bad_credentials", "the current password is wrong")
|
||||
case errors.Is(err, errBadCredentials), errors.Is(err, errReauth):
|
||||
fail(w, http.StatusUnauthorized, "bad_credentials", "the current password or one-time code is wrong")
|
||||
case errors.Is(err, errNoPassword):
|
||||
fail(w, http.StatusUnauthorized, "bad_credentials", err.Error())
|
||||
case err != nil:
|
||||
fail(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
"cc.tiredsysadmin.keeporg/internal/totp"
|
||||
)
|
||||
|
||||
// Proving who someone is: every password and one-time code the server
|
||||
// checks goes through verify, wherever it was typed — the API, the sign-in
|
||||
// form, the device page, a password change, a second-factor change or a
|
||||
// vault deletion. Two things follow from that being one function.
|
||||
//
|
||||
// The password hash is checked outside the store's lock. It is deliberately
|
||||
// slow, and under the write lock it stalled every other request, so a few
|
||||
// guesses a second were a denial of service. verify reads what it needs,
|
||||
// checks it, and commit re-checks inside the write that nothing changed in
|
||||
// between.
|
||||
//
|
||||
// Failures are counted per client address and per account, and a client or
|
||||
// account over its budget is refused before any hash is computed. The
|
||||
// counts live in memory: a restart forgets them, which costs an attacker a
|
||||
// restart they cannot cause.
|
||||
|
||||
// errThrottled means too many failures; RetryAfter says how long to wait.
|
||||
type errThrottled struct{ RetryAfter time.Duration }
|
||||
|
||||
func (e *errThrottled) Error() string {
|
||||
return fmt.Sprintf("too many failed attempts; try again in %s",
|
||||
e.RetryAfter.Round(time.Minute))
|
||||
}
|
||||
|
||||
// credentials is a password, and code, that checked out against an account
|
||||
// as it stood when verify read it.
|
||||
type credentials struct {
|
||||
userID string
|
||||
hash string
|
||||
step int64 // the one-time code's time step, when one was needed
|
||||
totp bool
|
||||
}
|
||||
|
||||
// dummyHash is verified against when there is no real one, so that an
|
||||
// unknown, disabled or password-less account takes as long to refuse as a
|
||||
// wrong password: the timing would otherwise list the usernames.
|
||||
var dummyHash = func() string {
|
||||
h, err := secret.Hash("keeporg timing equaliser, never a real password")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return h
|
||||
}()
|
||||
|
||||
// verify checks a username, password and one-time code.
|
||||
func (s *Server) verify(r *http.Request, username, password, code string) (*credentials, error) {
|
||||
ip := "ip:" + s.clientIP(r)
|
||||
acct := "user:" + strings.ToLower(strings.TrimSpace(username))
|
||||
if wait := s.limits.blocked(ip, acct); wait > 0 {
|
||||
return nil, &errThrottled{RetryAfter: wait}
|
||||
}
|
||||
var (
|
||||
u store.User
|
||||
found bool
|
||||
)
|
||||
s.st.Read(func(st *store.State) {
|
||||
if p := st.UserByName(username); p != nil {
|
||||
u, found = *p, true
|
||||
}
|
||||
})
|
||||
if !found || u.Disabled || u.PassHash == "" {
|
||||
secret.Verify(dummyHash, password)
|
||||
s.limits.fail(ip, acct)
|
||||
return nil, errBadCredentials
|
||||
}
|
||||
if !secret.Verify(u.PassHash, password) {
|
||||
s.limits.fail(ip, acct)
|
||||
return nil, errBadCredentials
|
||||
}
|
||||
c := &credentials{userID: u.ID, hash: u.PassHash}
|
||||
if u.TOTPConfirmed {
|
||||
if strings.TrimSpace(code) == "" {
|
||||
return nil, errTOTPRequired
|
||||
}
|
||||
step, ok := totp.Match(u.TOTPSecret, code, time.Now())
|
||||
if !ok || step <= u.TOTPLastStep {
|
||||
s.limits.fail(ip, acct)
|
||||
return nil, errTOTPInvalid
|
||||
}
|
||||
c.step, c.totp = step, true
|
||||
}
|
||||
s.limits.reset(acct)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// commit re-checks verified credentials inside a write: the account must
|
||||
// still exist, be enabled and have the same password, and the one-time code
|
||||
// is spent. It returns the account to act on.
|
||||
func (c *credentials) commit(st *store.State) (*store.User, error) {
|
||||
u := st.Users[c.userID]
|
||||
if u == nil || u.Disabled || u.PassHash != c.hash {
|
||||
return nil, errBadCredentials
|
||||
}
|
||||
if c.totp && u.TOTPConfirmed {
|
||||
if c.step <= u.TOTPLastStep {
|
||||
return nil, errTOTPInvalid
|
||||
}
|
||||
u.TOTPLastStep = c.step
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// signIn verifies credentials and, inside one write, re-checks them and runs
|
||||
// then on the account. It is the shape of every sign-in.
|
||||
func (s *Server) signIn(r *http.Request, username, password, code string,
|
||||
then func(st *store.State, u *store.User) error) error {
|
||||
c, err := s.verify(r, username, password, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.st.Update(func(st *store.State) error {
|
||||
u, err := c.commit(st)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return then(st, u)
|
||||
})
|
||||
}
|
||||
|
||||
// reconfirm proves, for a change that weakens or replaces how an account
|
||||
// signs in, that the person holding the session also holds the password and
|
||||
// the current code. Unlike a sign-in it does not say which of the two was
|
||||
// wrong: a stolen session must not become a password oracle.
|
||||
func (s *Server) reconfirm(r *http.Request, u *store.User, password, code string,
|
||||
then func(st *store.State, u *store.User) error) error {
|
||||
// The account as stored now, not as the session saw it at the start of
|
||||
// the request.
|
||||
s.st.Read(func(st *store.State) {
|
||||
if cur := st.Users[u.ID]; cur != nil {
|
||||
cp := *cur
|
||||
u = &cp
|
||||
}
|
||||
})
|
||||
if u.PassHash == "" {
|
||||
return errNoPassword
|
||||
}
|
||||
if password == "" {
|
||||
return errReauth
|
||||
}
|
||||
if u.TOTPConfirmed && strings.TrimSpace(code) == "" {
|
||||
// Asked before the password is checked, so it says nothing about
|
||||
// whether the password was right.
|
||||
return errTOTPRequired
|
||||
}
|
||||
err := s.signIn(r, u.Username, password, code, then)
|
||||
if errors.Is(err, errTOTPInvalid) || errors.Is(err, errTOTPRequired) {
|
||||
return errBadCredentials
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// writeThrottled answers a refused attempt with 429 and Retry-After.
|
||||
func writeThrottled(w http.ResponseWriter, err error) bool {
|
||||
var t *errThrottled
|
||||
if !errors.As(err, &t) {
|
||||
return false
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(t.RetryAfter.Seconds())+1))
|
||||
fail(w, http.StatusTooManyRequests, "throttled", t.Error())
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- limits ----
|
||||
|
||||
// limitWindow is how long failures are remembered.
|
||||
const limitWindow = 15 * time.Minute
|
||||
|
||||
// Budgets per window. An address may fail more often than an account, since
|
||||
// several people may share one address; an account fails rarely when its
|
||||
// owner is the one typing.
|
||||
var limitBudget = map[string]int{
|
||||
"ip": 30,
|
||||
"user": 10,
|
||||
"start": 30, // unauthenticated device and single sign-on starts, per address
|
||||
}
|
||||
|
||||
type attempts struct {
|
||||
n int
|
||||
since time.Time
|
||||
}
|
||||
|
||||
// limiter counts failures per key ("ip:…", "user:…", "start:…").
|
||||
type limiter struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*attempts
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func newLimiter() *limiter { return &limiter{m: map[string]*attempts{}, now: time.Now} }
|
||||
|
||||
func budget(key string) int {
|
||||
kind, _, _ := strings.Cut(key, ":")
|
||||
return limitBudget[kind]
|
||||
}
|
||||
|
||||
// blocked says how long until every key is under budget again, or 0.
|
||||
func (l *limiter) blocked(keys ...string) time.Duration {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
var wait time.Duration
|
||||
for _, k := range keys {
|
||||
a := l.m[k]
|
||||
if a == nil || now.Sub(a.since) >= limitWindow || a.n < budget(k) {
|
||||
continue
|
||||
}
|
||||
if left := limitWindow - now.Sub(a.since); left > wait {
|
||||
wait = left
|
||||
}
|
||||
}
|
||||
return wait
|
||||
}
|
||||
|
||||
// fail counts one failure against each key.
|
||||
func (l *limiter) fail(keys ...string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
if len(l.m) > 50_000 {
|
||||
for k, a := range l.m {
|
||||
if now.Sub(a.since) >= limitWindow {
|
||||
delete(l.m, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range keys {
|
||||
a := l.m[k]
|
||||
if a == nil || now.Sub(a.since) >= limitWindow {
|
||||
a = &attempts{since: now}
|
||||
l.m[k] = a
|
||||
}
|
||||
a.n++
|
||||
}
|
||||
}
|
||||
|
||||
// take counts one use of key and reports whether it was within budget, for
|
||||
// endpoints limited by use rather than by failure.
|
||||
func (l *limiter) take(key string) time.Duration {
|
||||
if wait := l.blocked(key); wait > 0 {
|
||||
return wait
|
||||
}
|
||||
l.fail(key)
|
||||
return 0
|
||||
}
|
||||
|
||||
func (l *limiter) reset(key string) {
|
||||
l.mu.Lock()
|
||||
delete(l.m, key)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// ---- client addresses ----
|
||||
|
||||
// clientIP is the address a request came from. Behind a reverse proxy every
|
||||
// request comes from the proxy, so for a peer listed in trusted_proxies the
|
||||
// address is taken from X-Forwarded-For, right to left, skipping trusted
|
||||
// hops; nobody else gets to choose their own address with a header.
|
||||
func (s *Server) clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
if !s.trusted(host) {
|
||||
return host
|
||||
}
|
||||
hops := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
|
||||
for i := len(hops) - 1; i >= 0; i-- {
|
||||
h := strings.TrimSpace(hops[i])
|
||||
if h == "" {
|
||||
continue
|
||||
}
|
||||
if !s.trusted(h) {
|
||||
return h
|
||||
}
|
||||
host = h
|
||||
}
|
||||
if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" && net.ParseIP(real) != nil {
|
||||
return real
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func (s *Server) trusted(addr string) bool {
|
||||
ip := net.ParseIP(addr)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, n := range s.proxies {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseProxies reads trusted_proxies: addresses or CIDR ranges.
|
||||
func parseProxies(list []string) ([]*net.IPNet, error) {
|
||||
var out []*net.IPNet
|
||||
for _, p := range list {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(p, "/") {
|
||||
if ip := net.ParseIP(p); ip != nil && ip.To4() != nil {
|
||||
p += "/32"
|
||||
} else {
|
||||
p += "/128"
|
||||
}
|
||||
}
|
||||
_, n, err := net.ParseCIDR(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trusted_proxies: %q: %w", p, err)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// maxLabel bounds a session or device label a client chooses: it is shown
|
||||
// in lists and stored with every session, and nothing needs more.
|
||||
const maxLabel = 64
|
||||
|
||||
// clampLabel trims a client-chosen label to something displayable.
|
||||
func clampLabel(label string) string {
|
||||
label = strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, strings.TrimSpace(label))
|
||||
if r := []rune(label); len(r) > maxLabel {
|
||||
label = string(r[:maxLabel])
|
||||
}
|
||||
return label
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFailedSignInsAreThrottledPerAccount(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
for i := 0; i < limitBudget["user"]; i++ {
|
||||
if _, status, _ := h.login("admin", "wrong-password-x", ""); status != http.StatusUnauthorized {
|
||||
t.Fatalf("attempt %d: %d", i, status)
|
||||
}
|
||||
}
|
||||
// Over budget, even the right password is refused, before any hashing.
|
||||
_, status, body := h.login("admin", adminPassword, "")
|
||||
if status != http.StatusTooManyRequests || body["code"] != "throttled" {
|
||||
t.Fatalf("after the budget: %d %v", status, body)
|
||||
}
|
||||
// Another account is not affected.
|
||||
h.makeUserNoLogin("bob", "bob-password-1")
|
||||
if _, status, _ := h.login("bob", "bob-password-1", ""); status != http.StatusOK {
|
||||
t.Fatalf("another account was throttled too: %d", status)
|
||||
}
|
||||
// The window passes.
|
||||
h.srv.limits.now = func() time.Time { return time.Now().Add(limitWindow) }
|
||||
if _, status, _ := h.login("admin", adminPassword, ""); status != http.StatusOK {
|
||||
t.Fatalf("still throttled after the window: %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownUsersCountAgainstTheAddress(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
for i := 0; i < limitBudget["ip"]; i++ {
|
||||
h.login("nobody-"+strings.Repeat("x", i%5+1), "whatever-password", "")
|
||||
}
|
||||
if _, status, _ := h.login("admin", adminPassword, ""); status != http.StatusTooManyRequests {
|
||||
t.Fatalf("an address over its budget could still sign in: %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPTrustsOnlyConfiguredProxies(t *testing.T) {
|
||||
proxies, err := parseProxies([]string{"10.0.0.0/8", "192.168.1.5"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Server{proxies: proxies}
|
||||
req := func(remote, xff string) *http.Request {
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.RemoteAddr = remote
|
||||
if xff != "" {
|
||||
r.Header.Set("X-Forwarded-For", xff)
|
||||
}
|
||||
return r
|
||||
}
|
||||
for _, c := range []struct{ remote, xff, want string }{
|
||||
{"203.0.113.9:1234", "1.2.3.4", "203.0.113.9"}, // untrusted peer: header ignored
|
||||
{"10.1.2.3:1234", "198.51.100.7", "198.51.100.7"}, // proxy: its client
|
||||
{"10.1.2.3:1234", "6.6.6.6, 198.51.100.7", "198.51.100.7"}, // a spoofed left entry is skipped
|
||||
{"10.1.2.3:1234", "198.51.100.7, 192.168.1.5", "198.51.100.7"},
|
||||
{"10.1.2.3:1234", "", "10.1.2.3"},
|
||||
} {
|
||||
if got := s.clientIP(req(c.remote, c.xff)); got != c.want {
|
||||
t.Errorf("clientIP(%s, %q) = %s, want %s", c.remote, c.xff, got, c.want)
|
||||
}
|
||||
}
|
||||
if _, err := parseProxies([]string{"not-an-address"}); err == nil {
|
||||
t.Error("a malformed proxy was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampLabel(t *testing.T) {
|
||||
if got := clampLabel(" phone\x1b[31m\n"); got != "phone[31m" {
|
||||
t.Errorf("control characters kept: %q", got)
|
||||
}
|
||||
if got := clampLabel(strings.Repeat("ä", 200)); len([]rune(got)) != maxLabel {
|
||||
t.Errorf("label not clamped: %d runes", len([]rune(got)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/kdbx"
|
||||
"cc.tiredsysadmin.keeporg/internal/sqldb/sqldbtest"
|
||||
)
|
||||
|
||||
// A merge vault must never store a file the server could not afford to open
|
||||
// later: its header is checked before anything is written.
|
||||
func TestMergeVaultRefusesAnOverLimitKDF(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
tok, _, _ := h.login("admin", adminPassword, "")
|
||||
id := h.makeVault(tok, "Merged")
|
||||
|
||||
saved := kdbx.MaxArgon2Iterations
|
||||
kdbx.MaxArgon2Iterations = 10_000
|
||||
heavy, err := kdbx.New("Merged", kdbx.KDFParams{MemoryKiB: 8, Iterations: 5_000, Lanes: 1}).Marshal(vaultPassword)
|
||||
kdbx.MaxArgon2Iterations = saved
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, body, _ := h.push(tok, id, `"1"`, heavy)
|
||||
if status != http.StatusBadRequest || body["code"] != "invalid_kdbx" {
|
||||
t.Fatalf("over-limit push: %d %v", status, body)
|
||||
}
|
||||
if v := h.srv.blobs.Versions(id); len(v) != 1 {
|
||||
t.Fatalf("versions after a refused push: %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// Two server processes on one database: a push accepted by a process whose
|
||||
// view is stale must not overwrite the version another process committed.
|
||||
func TestStalePushOnAnotherNodeDoesNotOverwrite(t *testing.T) {
|
||||
url := sqldbtest.URL(t)
|
||||
key := filepath.Join(t.TempDir(), "master.key")
|
||||
h := newHarnessWith(t, func(c *Config) { c.Database, c.MasterKeyFile = url, key })
|
||||
tok, _, _ := h.login("admin", adminPassword, "")
|
||||
_, created := h.call("POST", "/api/v1/databases", tok, map[string]string{
|
||||
"name": "Shared", "owner_group_id": "everyone", "merge_strategy": "server-wins"})
|
||||
id := created["id"].(string)
|
||||
if status, body, _ := h.push(tok, id, `"0"`, []byte("version one")); status != http.StatusOK {
|
||||
t.Fatalf("v1: %d %v", status, body)
|
||||
}
|
||||
|
||||
other, err := New(Config{DataDir: t.TempDir(), Database: url, MasterKeyFile: key,
|
||||
ExternalURL: "http://other"}, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer other.Close()
|
||||
ts := httptest.NewServer(other.Handler())
|
||||
defer ts.Close()
|
||||
put := func(base, data string) int {
|
||||
req, _ := http.NewRequest("PUT", ts.URL+"/api/v1/databases/"+id+"/blob", bytes.NewReader([]byte(data)))
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
req.Header.Set("If-Match", base)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
if status := put(`"1"`, "version two, by the other node"); status != http.StatusOK {
|
||||
t.Fatalf("v2 on the other node: %d", status)
|
||||
}
|
||||
// The first node has not reloaded yet and still thinks v1 is current.
|
||||
status, body, _ := h.push(tok, id, `"1"`, []byte("a stale edit"))
|
||||
if status != http.StatusConflict {
|
||||
t.Fatalf("stale push: %d %v", status, body)
|
||||
}
|
||||
got, err := h.srv.blobs.Get(id, 2)
|
||||
if err != nil || string(got) != "version two, by the other node" {
|
||||
t.Fatalf("version 2 is now %q (%v)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryTokenOnlyOpensTheEventStream(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
tok, _, _ := h.login("admin", adminPassword, "")
|
||||
resp, err := http.Get(h.http.URL + "/api/v1/databases?access_token=" + tok)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("a token in the URL opened /databases: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminEndpointsDoNotRevealDatabases(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
admin, _, _ := h.login("admin", adminPassword, "")
|
||||
id := h.makeVault(admin, "Private")
|
||||
bob := h.makeUser("bob", "bob-password-1")
|
||||
for _, target := range []string{id, "does-not-exist"} {
|
||||
status, _ := h.call("PATCH", "/api/v1/databases/"+target, bob, map[string]string{"name": "x"})
|
||||
if status != http.StatusNotFound {
|
||||
t.Errorf("PATCH %s as an outsider: %d", target, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,24 +2,21 @@ package server
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// blobsExist reports whether the server still keeps any version of a
|
||||
// database on disk.
|
||||
// database.
|
||||
func (h *harness) blobsExist(dbID string) bool {
|
||||
h.t.Helper()
|
||||
_, err := os.Stat(filepath.Join(h.dataDir, "blobs", dbID))
|
||||
return err == nil
|
||||
return len(h.srv.blobs.Versions(dbID)) > 0
|
||||
}
|
||||
|
||||
func TestWebDeletesAVaultOnlyWithTheAdminsPassword(t *testing.T) {
|
||||
@@ -48,7 +45,7 @@ func TestWebDeletesAVaultOnlyWithTheAdminsPassword(t *testing.T) {
|
||||
|
||||
_, page = b.post("/web/vaults/"+id+"/delete", url.Values{
|
||||
"csrf": {csrf}, "password": {"not-the-password"}})
|
||||
if !strings.Contains(page, "not your account password") {
|
||||
if !strings.Contains(page, "password or one-time code is wrong") {
|
||||
t.Fatalf("a wrong password was not reported:\n%s", page)
|
||||
}
|
||||
h.srv.Store().Read(func(st *store.State) {
|
||||
@@ -155,28 +152,15 @@ func TestDeleteAsksForTheSecondFactorToo(t *testing.T) {
|
||||
admin, _, _ := h.login("admin", adminPassword, "")
|
||||
id := h.makeVault(admin, "Guarded")
|
||||
|
||||
status, body := h.call("POST", "/api/v1/auth/totp/enroll", admin, nil)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("enroll: %d %v", status, body)
|
||||
}
|
||||
sec := body["secret"].(string)
|
||||
code, err := totp.Code(sec, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status, body = h.call("POST", "/api/v1/auth/totp/confirm", admin,
|
||||
map[string]string{"code": code}); status != http.StatusOK {
|
||||
t.Fatalf("confirm: %d %v", status, body)
|
||||
}
|
||||
sec := h.enrollTOTP(t, admin, adminPassword)
|
||||
|
||||
if status, body = h.call("DELETE", "/api/v1/databases/"+id, admin,
|
||||
if status, body := h.call("DELETE", "/api/v1/databases/"+id, admin,
|
||||
map[string]string{"password": adminPassword}); status != http.StatusUnauthorized ||
|
||||
body["code"] != "totp_required" {
|
||||
t.Fatalf("delete without a code: %d %v", status, body)
|
||||
}
|
||||
code, _ = totp.Code(sec, time.Now())
|
||||
if status, body = h.call("DELETE", "/api/v1/databases/"+id, admin,
|
||||
map[string]string{"password": adminPassword, "totp": code}); status != http.StatusOK {
|
||||
if status, body := h.call("DELETE", "/api/v1/databases/"+id, admin,
|
||||
map[string]string{"password": adminPassword, "totp": codeAt(t, sec, 0)}); status != http.StatusOK {
|
||||
t.Fatalf("delete with the code: %d %v", status, body)
|
||||
}
|
||||
}
|
||||
@@ -197,13 +181,13 @@ func TestSingleSignOnAccountConfirmsByName(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := h.srv.deleteDatabase(sso, id, "", "", ""); err != errConfirmName {
|
||||
if _, err := h.srv.deleteDatabase(httptest.NewRequest("POST", "/", nil), sso, id, "", "", ""); err != errConfirmName {
|
||||
t.Fatalf("delete with nothing typed = %v", err)
|
||||
}
|
||||
if _, err := h.srv.deleteDatabase(sso, id, "", "", "federated"); err != errConfirmName {
|
||||
if _, err := h.srv.deleteDatabase(httptest.NewRequest("POST", "/", nil), sso, id, "", "", "federated"); err != errConfirmName {
|
||||
t.Fatalf("delete with the wrong case = %v", err)
|
||||
}
|
||||
if _, err := h.srv.deleteDatabase(sso, id, "", "", "Federated"); err != nil {
|
||||
if _, err := h.srv.deleteDatabase(httptest.NewRequest("POST", "/", nil), sso, id, "", "", "Federated"); err != nil {
|
||||
t.Fatalf("delete with the name typed = %v", err)
|
||||
}
|
||||
h.srv.Store().Read(func(st *store.State) {
|
||||
@@ -222,7 +206,7 @@ func TestDeleteNotifiesTheWatchers(t *testing.T) {
|
||||
|
||||
sub, events := h.srv.hub.Subscribe("admin")
|
||||
defer h.srv.hub.Unsubscribe(sub)
|
||||
if _, err := h.srv.deleteDatabase(&store.User{ID: "admin", Username: "admin"},
|
||||
if _, err := h.srv.deleteDatabase(httptest.NewRequest("POST", "/", nil), &store.User{ID: "admin", Username: "admin"},
|
||||
id, adminPassword, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -9,15 +9,14 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/sqldb/sqldbtest"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
"cc.tiredsysadmin.keeporg/internal/totp"
|
||||
)
|
||||
|
||||
type harness struct {
|
||||
@@ -43,6 +42,7 @@ func newHarnessWith(t *testing.T, adjust func(*Config)) *harness {
|
||||
ts := httptest.NewUnstartedServer(nil)
|
||||
cfg := Config{
|
||||
DataDir: dir,
|
||||
Database: sqldbtest.URL(t),
|
||||
ExternalURL: "http://" + ts.Listener.Addr().String(),
|
||||
MasterKeyFile: filepath.Join(t.TempDir(), "master.key"),
|
||||
VaultKDF: testKDF,
|
||||
@@ -54,6 +54,7 @@ func newHarnessWith(t *testing.T, adjust func(*Config)) *harness {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(srv.Close)
|
||||
hash, err := secret.Hash(adminPassword)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -73,14 +74,31 @@ func newHarnessWith(t *testing.T, adjust func(*Config)) *harness {
|
||||
return &harness{t: t, srv: srv, http: ts, dataDir: dir}
|
||||
}
|
||||
|
||||
// stateBytes is the server's whole metadata document as stored on disk.
|
||||
// stateBytes is everything the server has stored, as the database holds it:
|
||||
// every row of every table, concatenated. A test that something is not kept
|
||||
// in the clear searches this.
|
||||
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)
|
||||
var out []byte
|
||||
for _, q := range []string{
|
||||
`SELECT name, value FROM keeporg_meta`,
|
||||
`SELECT id, body FROM keeporg_records`,
|
||||
`SELECT ref, body FROM keeporg_blobs`,
|
||||
} {
|
||||
rows, err := h.srv.db.Query(q)
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var a, b []byte
|
||||
if err := rows.Scan(&a, &b); err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
out = append(append(out, a...), b...)
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
return b
|
||||
return out
|
||||
}
|
||||
|
||||
// call sends a JSON request and decodes the JSON response.
|
||||
@@ -357,30 +375,28 @@ func TestEventStreamDeliversPush(t *testing.T) {
|
||||
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 {
|
||||
status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, map[string]string{"password": adminPassword})
|
||||
if status != http.StatusOK || !strings.HasPrefix(body["uri"].(string), "otpauth://totp/") {
|
||||
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 {
|
||||
map[string]string{"code": codeAt(t, sec, -1)}); 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())
|
||||
code := codeAt(t, sec, 0)
|
||||
if _, status, body = h.login("admin", adminPassword, code); status != http.StatusOK {
|
||||
t.Fatalf("login with code: %d %v", status, body)
|
||||
}
|
||||
// The same code a second time is refused: each code is good once.
|
||||
if _, status, body = h.login("admin", adminPassword, code); status != http.StatusUnauthorized ||
|
||||
body["code"] != "totp_invalid" {
|
||||
t.Fatalf("replayed code: %d %v", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceLoginApprovedInBrowser(t *testing.T) {
|
||||
@@ -441,7 +457,8 @@ func TestNonAdminCannotAdminister(t *testing.T) {
|
||||
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 {
|
||||
map[string]string{"merge_strategy": "client-wins"}); status != http.StatusNotFound {
|
||||
// 404, not 403: without access the database does not exist for them.
|
||||
t.Fatalf("outsider changed the strategy: %d", status)
|
||||
}
|
||||
// Without any grant the database is invisible, not merely unwritable.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -18,7 +20,21 @@ import (
|
||||
//
|
||||
// Any failure answers with a conflict instead, so the client falls back to
|
||||
// keeping its copy aside rather than losing an edit.
|
||||
func (s *Server) mergePush(w http.ResponseWriter, u *store.User, id string, basedOn int64, incoming []byte) {
|
||||
func (s *Server) mergePush(w http.ResponseWriter, r *http.Request, u *store.User, id string, basedOn int64, incoming []byte) {
|
||||
// A merge runs the vault's key derivation three times and holds two
|
||||
// decrypted copies; only a few run at once, and a push that cannot get
|
||||
// a turn soon is told to retry rather than piling up.
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
select {
|
||||
case s.merges <- struct{}{}:
|
||||
defer func() { <-s.merges }()
|
||||
case <-ctx.Done():
|
||||
w.Header().Set("Retry-After", "5")
|
||||
fail(w, http.StatusServiceUnavailable, "busy", "the server is busy merging; try again shortly")
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
wrapped string
|
||||
current int64
|
||||
@@ -36,7 +52,7 @@ func (s *Server) mergePush(w http.ResponseWriter, u *store.User, id string, base
|
||||
w.Header().Set("ETag", etag(current))
|
||||
w.Header().Set("X-KeepOrg-Strategy", string(store.StrategyMerge))
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"code": "merge_failed", "error": err.Error(),
|
||||
"code": "merge_failed", "error": "the server could not merge this save; the server log says why",
|
||||
"current_version": current, "merge_strategy": store.StrategyMerge,
|
||||
})
|
||||
return
|
||||
@@ -48,7 +64,8 @@ func (s *Server) mergePush(w http.ResponseWriter, u *store.User, id string, base
|
||||
watchers []string
|
||||
stale bool
|
||||
)
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
if err := s.st.UpdateWith(func(st *store.State) error {
|
||||
stale = false
|
||||
d := st.Databases[id]
|
||||
if d == nil {
|
||||
return errors.New("no such database")
|
||||
@@ -59,15 +76,18 @@ func (s *Server) mergePush(w http.ResponseWriter, u *store.User, id string, base
|
||||
return nil
|
||||
}
|
||||
next = d.Version + 1
|
||||
if err := s.blobs.Put(id, next, merged); err != nil {
|
||||
return err
|
||||
}
|
||||
d.Version, d.SHA256, d.Size = next, sum, int64(len(merged))
|
||||
d.UpdatedAt, d.UpdatedBy = time.Now(), u.Username
|
||||
watchers = st.Watchers(id)
|
||||
return nil
|
||||
}, func(ctx context.Context, tx *sql.Tx) error {
|
||||
if stale {
|
||||
return nil
|
||||
}
|
||||
return s.blobs.PutTx(ctx, tx, id, next, merged)
|
||||
}); err != nil {
|
||||
fail(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
s.log.Printf("merge %s: %v", id, err)
|
||||
fail(w, http.StatusInternalServerError, "internal", "the merge could not be stored; try again")
|
||||
return
|
||||
}
|
||||
if stale {
|
||||
|
||||
+213
-48
@@ -1,6 +1,8 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -28,6 +30,12 @@ type OIDCConfig struct {
|
||||
AutoCreate bool `json:"auto_create"`
|
||||
// DefaultGroupID is the group auto-created users join.
|
||||
DefaultGroupID string `json:"default_group_id"`
|
||||
// LinkVerifiedEmail lets an identity claim an existing, unlinked local
|
||||
// account whose email it shares, when the provider says the email is
|
||||
// verified. Off by default: whoever controls an identity provider's
|
||||
// email field then controls those accounts. Accounts can always be
|
||||
// linked explicitly from their Account page.
|
||||
LinkVerifiedEmail bool `json:"link_verified_email"`
|
||||
}
|
||||
|
||||
func (c OIDCConfig) Enabled() bool {
|
||||
@@ -85,14 +93,24 @@ func (p *oidcProvider) metadata() (*oidcMetadata, error) {
|
||||
}
|
||||
|
||||
type oidcIdentity struct {
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"preferred_username"`
|
||||
Name string `json:"name"`
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified flexBool `json:"email_verified"`
|
||||
Username string `json:"preferred_username"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// flexBool reads a JSON boolean that some providers send as a string.
|
||||
type flexBool bool
|
||||
|
||||
func (b *flexBool) UnmarshalJSON(data []byte) error {
|
||||
v := strings.Trim(string(data), `"`)
|
||||
*b = flexBool(v == "true")
|
||||
return nil
|
||||
}
|
||||
|
||||
// exchange turns an authorization code into the caller's identity.
|
||||
func (p *oidcProvider) exchange(code string) (*oidcIdentity, error) {
|
||||
func (p *oidcProvider) exchange(code, verifier string) (*oidcIdentity, error) {
|
||||
meta, err := p.metadata()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -101,6 +119,8 @@ func (p *oidcProvider) exchange(code string) (*oidcIdentity, error) {
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"redirect_uri": {p.redirectURI},
|
||||
// PKCE: only the party that started the flow can spend the code.
|
||||
"code_verifier": {verifier},
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, meta.TokenEndpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
@@ -153,59 +173,114 @@ func (p *oidcProvider) userinfo(endpoint, accessToken string) (*oidcIdentity, er
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
// handleOIDCLogin starts the authorization code flow for a pending device.
|
||||
// oidcCookie binds a single sign-on round trip to the browser that started
|
||||
// it. The state in the URL alone is not enough: a callback link forwarded to
|
||||
// someone else, or a flow started by an attacker's page in a victim's
|
||||
// browser, would otherwise finish in the wrong hands. Lax, because the
|
||||
// provider's redirect back is a cross-site navigation.
|
||||
const oidcCookie = "keeporg_oidc"
|
||||
|
||||
func (s *Server) setOIDCCookie(w http.ResponseWriter, value string, ttl time.Duration) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: oidcCookie, Value: value, Path: "/oidc", HttpOnly: true,
|
||||
Secure: strings.HasPrefix(s.cfg.ExternalURL, "https://"),
|
||||
SameSite: http.SameSiteLaxMode, MaxAge: int(ttl.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func oidcCookieMatches(r *http.Request, want string) bool {
|
||||
c, err := r.Cookie(oidcCookie)
|
||||
return err == nil && want != "" && secret.Equal(c.Value, want)
|
||||
}
|
||||
|
||||
// pkceChallenge is the S256 challenge for a verifier.
|
||||
func pkceChallenge(verifier string) string {
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// handleOIDCLogin starts the authorization code flow. It has three uses:
|
||||
// with ?code= it identifies the person approving a waiting client; with
|
||||
// ?link=1 it links an identity to the account signed in here; without
|
||||
// either it signs a browser in to the web interface.
|
||||
func (s *Server) handleOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if s.oidc == nil {
|
||||
s.renderDone(w, "Single sign-on unavailable", "This server has no OIDC provider configured.")
|
||||
return
|
||||
}
|
||||
if wait := s.limits.take("start:" + s.clientIP(r)); wait > 0 {
|
||||
s.renderDone(w, "Too many sign-in attempts", (&errThrottled{RetryAfter: wait}).Error())
|
||||
return
|
||||
}
|
||||
meta, err := s.oidc.metadata()
|
||||
if err != nil {
|
||||
s.log.Printf("oidc discovery: %v", err)
|
||||
s.renderDone(w, "Single sign-on unavailable", "The identity provider could not be reached.")
|
||||
return
|
||||
}
|
||||
// With a user code this approves a waiting client; without one it signs
|
||||
// a browser in to the web interface.
|
||||
userCode := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("code")))
|
||||
state := secret.Token()
|
||||
q := r.URL.Query()
|
||||
userCode := strings.ToUpper(strings.TrimSpace(q.Get("code")))
|
||||
var linkUser string
|
||||
if q.Get("link") == "1" {
|
||||
// Linking changes how an account signs in, so it needs that
|
||||
// account's session and its CSRF token: a page elsewhere must not
|
||||
// be able to link the attacker's identity to the victim's account.
|
||||
sess := s.webUser(r)
|
||||
if sess == nil || !secret.Equal(sess.csrf, q.Get("csrf")) {
|
||||
s.renderDone(w, "Single sign-on not linked", "Start again from your Account page.")
|
||||
return
|
||||
}
|
||||
linkUser = sess.user.ID
|
||||
}
|
||||
state, verifier := secret.Token(), secret.Token()
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
if userCode == "" {
|
||||
if !pruneDevices(st) {
|
||||
return errBusy
|
||||
}
|
||||
st.Devices[state] = &store.DeviceAuth{
|
||||
DeviceCode: state, Web: true, Label: "web", OIDCState: state,
|
||||
DeviceCode: state, Web: linkUser == "", LinkUserID: linkUser, Label: "web",
|
||||
OIDCState: state, PKCEVerifier: verifier,
|
||||
CreatedAt: time.Now(), ExpiresAt: time.Now().Add(deviceTTL),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
d := st.DeviceByUserCode(userCode)
|
||||
if d == nil || time.Now().After(d.ExpiresAt) {
|
||||
if d == nil || time.Now().After(d.ExpiresAt) || d.SessionToken != "" {
|
||||
return errors.New("unknown or expired device code")
|
||||
}
|
||||
d.OIDCState = state
|
||||
d.OIDCState, d.PKCEVerifier = state, verifier
|
||||
return nil
|
||||
}); err != nil {
|
||||
s.renderDevice(w, userCode, err.Error())
|
||||
return
|
||||
}
|
||||
q := url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {s.oidc.cfg.ClientID},
|
||||
"redirect_uri": {s.oidc.redirectURI},
|
||||
"scope": {s.oidc.cfg.Scopes},
|
||||
"state": {state},
|
||||
s.setOIDCCookie(w, state, deviceTTL)
|
||||
v := url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {s.oidc.cfg.ClientID},
|
||||
"redirect_uri": {s.oidc.redirectURI},
|
||||
"scope": {s.oidc.cfg.Scopes},
|
||||
"state": {state},
|
||||
"code_challenge": {pkceChallenge(verifier)},
|
||||
"code_challenge_method": {"S256"},
|
||||
}
|
||||
http.Redirect(w, r, meta.AuthorizationEndpoint+"?"+q.Encode(), http.StatusFound)
|
||||
http.Redirect(w, r, meta.AuthorizationEndpoint+"?"+v.Encode(), http.StatusFound)
|
||||
}
|
||||
|
||||
// handleOIDCCallback finishes the flow and approves the waiting device.
|
||||
// handleOIDCCallback finishes the flow. A browser sign-in ends signed in; a
|
||||
// link ends linked; a waiting client is not approved here but shown to the
|
||||
// person for an explicit decision, because arriving at this URL proves only
|
||||
// that a browser with a provider session was sent here, not that its owner
|
||||
// meant to let a device in.
|
||||
func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if s.oidc == nil {
|
||||
s.renderDone(w, "Single sign-on unavailable", "This server has no OIDC provider configured.")
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if e := q.Get("error"); e != "" {
|
||||
s.renderDone(w, "Sign-in failed", e)
|
||||
if q.Get("error") != "" {
|
||||
s.renderDone(w, "Sign-in failed", "The identity provider refused the sign-in.")
|
||||
return
|
||||
}
|
||||
state, code := q.Get("state"), q.Get("code")
|
||||
@@ -213,26 +288,34 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderDone(w, "Sign-in failed", "The provider did not return a code.")
|
||||
return
|
||||
}
|
||||
// Bind the state to a pending device before spending the code.
|
||||
var found bool
|
||||
if !oidcCookieMatches(r, state) {
|
||||
s.renderDone(w, "Sign-in failed",
|
||||
"This sign-in was not started in this browser. Start again from the same browser.")
|
||||
return
|
||||
}
|
||||
s.setOIDCCookie(w, "", -time.Second)
|
||||
var verifier string
|
||||
s.st.Read(func(st *store.State) {
|
||||
for _, d := range st.Devices {
|
||||
if d.OIDCState != "" && secret.Equal(d.OIDCState, state) && time.Now().Before(d.ExpiresAt) {
|
||||
found = true
|
||||
verifier = d.PKCEVerifier
|
||||
}
|
||||
}
|
||||
})
|
||||
if !found {
|
||||
if verifier == "" {
|
||||
s.renderDone(w, "Sign-in failed", "This sign-in request has expired. Start again from the client.")
|
||||
return
|
||||
}
|
||||
id, err := s.oidc.exchange(code)
|
||||
id, err := s.oidc.exchange(code, verifier)
|
||||
if err != nil {
|
||||
s.log.Printf("oidc exchange: %v", err)
|
||||
s.renderDone(w, "Sign-in failed", "The identity provider rejected the sign-in.")
|
||||
return
|
||||
}
|
||||
var webToken, returnURL string
|
||||
var (
|
||||
webToken, linked string
|
||||
confirm *webDeviceConfirm
|
||||
)
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
var dev *store.DeviceAuth
|
||||
for _, d := range st.Devices {
|
||||
@@ -243,32 +326,110 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if dev == nil || time.Now().After(dev.ExpiresAt) {
|
||||
return errors.New("this sign-in request has expired")
|
||||
}
|
||||
dev.OIDCState, dev.PKCEVerifier = "", ""
|
||||
if dev.LinkUserID != "" {
|
||||
delete(st.Devices, dev.DeviceCode)
|
||||
return s.linkIdentity(st, dev.LinkUserID, id, &linked)
|
||||
}
|
||||
u, err := s.linkOIDCUser(st, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dev.OIDCState = ""
|
||||
if dev.Web {
|
||||
token := s.newBrowserSession(st, u.ID, r).Token
|
||||
// Nothing is waiting to poll for this one; the browser in front
|
||||
// of us is the client.
|
||||
webToken = token
|
||||
webToken = s.newBrowserSession(st, u.ID, r).Token
|
||||
delete(st.Devices, dev.DeviceCode)
|
||||
return nil
|
||||
}
|
||||
dev.SessionToken = s.newSession(st, u.ID, dev.Label).Token
|
||||
returnURL = dev.ReturnURL
|
||||
dev.PendingUserID, dev.ApprovalNonce = u.ID, secret.Token()
|
||||
confirm = &webDeviceConfirm{Nonce: dev.ApprovalNonce, User: u.Username, Label: dev.Label,
|
||||
Code: dev.UserCode, Started: ago(dev.CreatedAt)}
|
||||
return nil
|
||||
}); err != nil {
|
||||
s.renderDone(w, "Sign-in failed", err.Error())
|
||||
s.renderDone(w, "Sign-in failed", humanError(err))
|
||||
return
|
||||
}
|
||||
if webToken != "" {
|
||||
switch {
|
||||
case webToken != "":
|
||||
s.setWebCookie(w, webToken, s.cfg.SessionTTL)
|
||||
http.Redirect(w, r, "/web/vaults", http.StatusFound)
|
||||
case linked != "":
|
||||
http.Redirect(w, r, "/web/account", http.StatusFound)
|
||||
default:
|
||||
s.setOIDCCookie(w, confirm.Nonce, deviceTTL)
|
||||
render(w, deviceConfirmTmpl, map[string]any{"Title": "Approve a device", "D": confirm})
|
||||
}
|
||||
}
|
||||
|
||||
// webDeviceConfirm is the question put to someone who signed in through
|
||||
// their provider to approve a device.
|
||||
type webDeviceConfirm struct {
|
||||
Nonce, User, Label, Code, Started string
|
||||
}
|
||||
|
||||
// handleOIDCApprove is the person's answer. It needs the nonce from the
|
||||
// confirmation page and the same nonce in this browser's cookie, so it can
|
||||
// only come from the page the callback showed in this browser.
|
||||
func (s *Server) handleOIDCApprove(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
fail(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
s.renderApproved(w, returnURL)
|
||||
nonce := r.FormValue("nonce")
|
||||
if !oidcCookieMatches(r, nonce) {
|
||||
s.renderDone(w, "Not approved", "This approval was not started in this browser.")
|
||||
return
|
||||
}
|
||||
s.setOIDCCookie(w, "", -time.Second)
|
||||
approve := r.FormValue("decision") == "approve"
|
||||
var returnURL string
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
for code, d := range st.Devices {
|
||||
if d.ApprovalNonce == "" || !secret.Equal(d.ApprovalNonce, nonce) {
|
||||
continue
|
||||
}
|
||||
if time.Now().After(d.ExpiresAt) || d.SessionToken != "" {
|
||||
return errors.New("this sign-in request has expired")
|
||||
}
|
||||
if !approve {
|
||||
delete(st.Devices, code)
|
||||
return nil
|
||||
}
|
||||
u := st.Users[d.PendingUserID]
|
||||
if u == nil || u.Disabled {
|
||||
return errors.New("this account cannot sign in")
|
||||
}
|
||||
d.ApprovalNonce = ""
|
||||
d.SessionToken = s.newSession(st, u.ID, d.Label).Token
|
||||
returnURL = d.ReturnURL
|
||||
return nil
|
||||
}
|
||||
return errors.New("this sign-in request has expired")
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
s.renderDone(w, "Not approved", humanError(err))
|
||||
case !approve:
|
||||
s.renderDone(w, "Device refused", "The device was not signed in. If you did not start this, someone tried to.")
|
||||
default:
|
||||
s.renderApproved(w, returnURL)
|
||||
}
|
||||
}
|
||||
|
||||
// linkIdentity links an identity to an account its owner is signed in to.
|
||||
func (s *Server) linkIdentity(st *store.State, userID string, id *oidcIdentity, linked *string) error {
|
||||
u := st.Users[userID]
|
||||
if u == nil || u.Disabled {
|
||||
return errors.New("this account cannot be linked")
|
||||
}
|
||||
issuer := strings.TrimRight(s.oidc.cfg.Issuer, "/")
|
||||
if other := st.UserByOIDC(issuer, id.Subject); other != nil && other.ID != u.ID {
|
||||
return errors.New("this identity is already linked to another account")
|
||||
}
|
||||
u.OIDCIssuer, u.OIDCSubject = issuer, id.Subject
|
||||
*linked = u.Username
|
||||
return nil
|
||||
}
|
||||
|
||||
// linkOIDCUser finds the account behind an identity, provisioning one when
|
||||
@@ -281,20 +442,24 @@ func (s *Server) linkOIDCUser(st *store.State, id *oidcIdentity) (*store.User, e
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
// A local account may claim the identity by matching username or email,
|
||||
// which is how an invited user upgrades to single sign-on.
|
||||
for _, u := range st.Users {
|
||||
if u.OIDCSubject != "" {
|
||||
continue
|
||||
}
|
||||
if (id.Username != "" && u.Username == id.Username) ||
|
||||
(id.Email != "" && strings.EqualFold(u.Email, id.Email)) {
|
||||
u.OIDCIssuer, u.OIDCSubject = issuer, id.Subject
|
||||
return u, nil
|
||||
// Never by username: anyone who can choose their preferred_username at
|
||||
// the provider could otherwise claim "admin". By email only when the
|
||||
// deployment allows it and the provider vouches for the address, and
|
||||
// never for a server administrator, whose account is linked from its
|
||||
// own Account page or not at all.
|
||||
if s.oidc.cfg.LinkVerifiedEmail && bool(id.EmailVerified) && id.Email != "" {
|
||||
for _, u := range st.Users {
|
||||
if u.OIDCSubject != "" || u.ServerAdmin || u.Disabled {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(u.Email, id.Email) {
|
||||
u.OIDCIssuer, u.OIDCSubject = issuer, id.Subject
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if !s.oidc.cfg.AutoCreate {
|
||||
return nil, errors.New("no account is linked to this identity; redeem an invite link first")
|
||||
return nil, errors.New("no account is linked to this identity; sign in with your password and link single sign-on from your Account page")
|
||||
}
|
||||
name := id.Username
|
||||
if name == "" {
|
||||
|
||||
+149
-25
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -17,9 +18,12 @@ import (
|
||||
type fakeIdP struct {
|
||||
*httptest.Server
|
||||
subject, email, username string
|
||||
emailVerified bool
|
||||
// tokenAuth records the client credentials the server presented.
|
||||
tokenAuth [2]string
|
||||
authCalls int
|
||||
// challenge is the PKCE challenge of the last authorization request.
|
||||
challenge string
|
||||
}
|
||||
|
||||
func newFakeIdP(t *testing.T, subject, email, username string) *fakeIdP {
|
||||
@@ -39,6 +43,11 @@ func newFakeIdP(t *testing.T, subject, email, username string) *fakeIdP {
|
||||
mux.HandleFunc("GET /authorize", func(w http.ResponseWriter, r *http.Request) {
|
||||
idp.authCalls++
|
||||
q := r.URL.Query()
|
||||
if q.Get("code_challenge_method") != "S256" || q.Get("code_challenge") == "" {
|
||||
http.Error(w, "PKCE required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
idp.challenge = q.Get("code_challenge")
|
||||
back, err := url.Parse(q.Get("redirect_uri"))
|
||||
if err != nil {
|
||||
http.Error(w, "bad redirect_uri", http.StatusBadRequest)
|
||||
@@ -58,6 +67,10 @@ func newFakeIdP(t *testing.T, subject, email, username string) *fakeIdP {
|
||||
http.Error(w, "bad code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if pkceChallenge(r.FormValue("code_verifier")) != idp.challenge {
|
||||
http.Error(w, "PKCE verifier does not match", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "an-access-token", "token_type": "Bearer", "expires_in": 300,
|
||||
})
|
||||
@@ -67,8 +80,9 @@ func newFakeIdP(t *testing.T, subject, email, username string) *fakeIdP {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"sub": idp.subject, "email": idp.email, "preferred_username": idp.username,
|
||||
"email_verified": idp.emailVerified,
|
||||
})
|
||||
})
|
||||
return idp
|
||||
@@ -136,54 +150,164 @@ func TestOIDCSignsInToTheWebInterface(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCApprovesAWaitingClient(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-2", "dev@example.com", "devuser")
|
||||
h := newHarnessWith(t, idp.withOIDC(true, "everyone"))
|
||||
var noncePattern = regexp.MustCompile(`name="nonce" value="([^"]+)"`)
|
||||
|
||||
// startDeviceApproval starts a device login and takes a browser through
|
||||
// single sign-on for it, up to the confirmation page.
|
||||
func startDeviceApproval(t *testing.T, h *harness, b *browser) (start map[string]any, nonce string) {
|
||||
t.Helper()
|
||||
status, start := h.call("POST", "/api/v1/auth/device/start", "",
|
||||
map[string]string{"label": "phone", "return_url": "keeporg://device-approved"})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("device start: %d %v", status, start)
|
||||
}
|
||||
userCode := start["user_code"].(string)
|
||||
|
||||
b := newBrowser(t, h)
|
||||
status, page := b.get("/oidc/login?code=" + url.QueryEscape(userCode))
|
||||
if status != http.StatusOK || !strings.Contains(page, "Device approved") {
|
||||
t.Fatalf("device approval through single sign-on: %d\n%s", status, page)
|
||||
status, page := b.get("/oidc/login?code=" + url.QueryEscape(start["user_code"].(string)))
|
||||
if status != http.StatusOK || !strings.Contains(page, "Let a device in?") ||
|
||||
!strings.Contains(page, start["user_code"].(string)) {
|
||||
t.Fatalf("no confirmation page after single sign-on: %d\n%s", status, page)
|
||||
}
|
||||
// A phone is handed back to its app from the provider's redirect too.
|
||||
if !strings.Contains(page, `href="keeporg://device-approved"`) {
|
||||
t.Fatalf("no link back to the app after single sign-on:\n%s", page)
|
||||
m := noncePattern.FindStringSubmatch(page)
|
||||
if m == nil {
|
||||
t.Fatalf("no nonce on the confirmation page:\n%s", page)
|
||||
}
|
||||
return start, m[1]
|
||||
}
|
||||
|
||||
func TestOIDCApprovesAWaitingClient(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-2", "dev@example.com", "devuser")
|
||||
h := newHarnessWith(t, idp.withOIDC(true, "everyone"))
|
||||
b := newBrowser(t, h)
|
||||
start, nonce := startDeviceApproval(t, h, b)
|
||||
poll := func() (int, map[string]any) {
|
||||
return h.call("POST", "/api/v1/auth/device/poll", "",
|
||||
map[string]string{"device_code": start["device_code"].(string)})
|
||||
}
|
||||
|
||||
// Signing in at the provider is not yet an approval: one click on a
|
||||
// link must never let somebody else's device in.
|
||||
if status, _ := poll(); status != http.StatusAccepted {
|
||||
t.Fatalf("the device was approved before the person said yes: %d", status)
|
||||
}
|
||||
_, page := b.post("/oidc/approve", url.Values{"nonce": {nonce}, "decision": {"approve"}})
|
||||
if !strings.Contains(page, "Device approved") || !strings.Contains(page, `href="keeporg://device-approved"`) {
|
||||
t.Fatalf("approval page:\n%s", page)
|
||||
}
|
||||
// A device approval must not also hand the browser a session cookie.
|
||||
if b.cookie() != "" {
|
||||
t.Fatal("approving a device set a web session cookie")
|
||||
}
|
||||
|
||||
status, body := h.call("POST", "/api/v1/auth/device/poll", "",
|
||||
map[string]string{"device_code": start["device_code"].(string)})
|
||||
status, body := poll()
|
||||
if status != http.StatusOK || body["token"] == "" {
|
||||
t.Fatalf("poll after single sign-on: %d %v", status, body)
|
||||
t.Fatalf("poll after approval: %d %v", status, body)
|
||||
}
|
||||
if u := body["user"].(map[string]any); u["username"] != "devuser" {
|
||||
t.Fatalf("the session belongs to %v", u["username"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCLinksAnExistingAccountByUsername(t *testing.T) {
|
||||
func TestOIDCDeviceApprovalCanBeRefused(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-6", "", "devuser")
|
||||
h := newHarnessWith(t, idp.withOIDC(true, ""))
|
||||
b := newBrowser(t, h)
|
||||
start, nonce := startDeviceApproval(t, h, b)
|
||||
_, page := b.post("/oidc/approve", url.Values{"nonce": {nonce}, "decision": {"deny"}})
|
||||
if !strings.Contains(page, "not signed in") {
|
||||
t.Fatalf("refusal page:\n%s", page)
|
||||
}
|
||||
if status, _ := h.call("POST", "/api/v1/auth/device/poll", "",
|
||||
map[string]string{"device_code": start["device_code"].(string)}); status != http.StatusGone {
|
||||
t.Fatalf("a refused device can still poll: %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
// The attack: someone starts a device login and gets the victim's browser to
|
||||
// the provider with that device's code. The victim's browser ends on the
|
||||
// confirmation page; the approval itself cannot be posted from anywhere
|
||||
// else, because it needs this browser's cookie.
|
||||
func TestOIDCApprovalCannotBePostedFromAnotherBrowser(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-7", "", "devuser")
|
||||
h := newHarnessWith(t, idp.withOIDC(true, ""))
|
||||
victim := newBrowser(t, h)
|
||||
start, nonce := startDeviceApproval(t, h, victim)
|
||||
|
||||
attacker := newBrowser(t, h)
|
||||
_, page := attacker.post("/oidc/approve", url.Values{"nonce": {nonce}, "decision": {"approve"}})
|
||||
if !strings.Contains(page, "not started in this browser") {
|
||||
t.Fatalf("an approval was accepted from another browser:\n%s", page)
|
||||
}
|
||||
if status, _ := h.call("POST", "/api/v1/auth/device/poll", "",
|
||||
map[string]string{"device_code": start["device_code"].(string)}); status != http.StatusAccepted {
|
||||
t.Fatalf("the device got in: %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCNeverLinksByUsername(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-3", "", "admin")
|
||||
h := newHarnessWith(t, idp.withOIDC(true, ""))
|
||||
b := newBrowser(t, h)
|
||||
b.get("/oidc/login")
|
||||
h.srv.Store().Read(func(st *store.State) {
|
||||
if st.Users["admin"].OIDCSubject != "" {
|
||||
t.Fatal("an identity calling itself admin claimed the admin account")
|
||||
}
|
||||
if u := st.UserByOIDC(idp.URL, "subject-3"); u == nil || u.Username == "admin" || u.ServerAdmin {
|
||||
t.Fatalf("the identity got %+v", u)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOIDCLinksAVerifiedEmailOnlyWhenAllowed(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
allow, verified bool
|
||||
wantLinkedToBob bool
|
||||
}{
|
||||
{"off", false, true, false},
|
||||
{"unverified", true, false, false},
|
||||
{"verified", true, true, true},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-8", "bob@example.com", "someone-else")
|
||||
idp.emailVerified = c.verified
|
||||
h := newHarnessWith(t, func(cfg *Config) {
|
||||
idp.withOIDC(false, "")(cfg)
|
||||
cfg.OIDC.LinkVerifiedEmail = c.allow
|
||||
})
|
||||
_ = h.srv.Store().Update(func(st *store.State) error {
|
||||
st.Users["bob"] = &store.User{ID: "bob", Username: "bob", Email: "bob@example.com", PassHash: "x"}
|
||||
return nil
|
||||
})
|
||||
newBrowser(t, h).get("/oidc/login")
|
||||
var linked bool
|
||||
h.srv.Store().Read(func(st *store.State) { linked = st.Users["bob"].OIDCSubject == "subject-8" })
|
||||
if linked != c.wantLinkedToBob {
|
||||
t.Fatalf("linked = %v, want %v", linked, c.wantLinkedToBob)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCIsLinkedFromTheAccountPage(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-9", "", "anything")
|
||||
h := newHarnessWith(t, idp.withOIDC(false, ""))
|
||||
b := newBrowser(t, h)
|
||||
csrf := b.signIn()
|
||||
|
||||
if _, page := b.get("/oidc/login"); !strings.Contains(page, "Your vaults") {
|
||||
t.Fatalf("an existing account was not matched:\n%s", page)
|
||||
// Without the session's CSRF token, a link elsewhere cannot do it.
|
||||
b.get("/oidc/login?link=1")
|
||||
_, page := b.get("/web/account")
|
||||
if !strings.Contains(page, "Link single sign-on") {
|
||||
t.Fatalf("the account page does not offer linking:\n%s", page)
|
||||
}
|
||||
var u *store.User
|
||||
h.srv.Store().Read(func(st *store.State) { u = st.Users["admin"] })
|
||||
if u.OIDCSubject != "subject-3" {
|
||||
t.Fatalf("the existing account was not linked: %+v", u)
|
||||
_, page = b.get("/oidc/login?link=1&csrf=" + url.QueryEscape(csrf))
|
||||
if !strings.Contains(page, "single sign-on") || strings.Contains(page, "Link single sign-on") {
|
||||
t.Fatalf("not linked:\n%s", page)
|
||||
}
|
||||
h.srv.Store().Read(func(st *store.State) {
|
||||
if st.Users["admin"].OIDCSubject != "subject-9" {
|
||||
t.Fatalf("admin = %+v", st.Users["admin"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOIDCRefusesAnUnknownIdentityWhenProvisioningIsOff(t *testing.T) {
|
||||
@@ -206,7 +330,7 @@ func TestOIDCCallbackRejectsAnUnknownState(t *testing.T) {
|
||||
b := newBrowser(t, h)
|
||||
|
||||
_, page := b.get("/oidc/callback?code=authorization-code&state=not-a-real-state")
|
||||
if !strings.Contains(page, "expired") {
|
||||
if !strings.Contains(page, "not started in this browser") {
|
||||
t.Fatalf("a forged callback was accepted:\n%s", page)
|
||||
}
|
||||
if b.cookie() != "" {
|
||||
|
||||
@@ -121,6 +121,7 @@ var signInTmpl = template.Must(template.Must(pages.Clone()).Parse(`
|
||||
<p class="sub">Sign in to create and manage your vaults.</p>
|
||||
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/web/signin">
|
||||
<input type="hidden" name="pre" value="{{.Pre}}">
|
||||
<label for="username">Username</label><input id="username" name="username" autocomplete="username" required autofocus>
|
||||
<label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
<label for="totp">One-time code <small>(if enabled)</small></label><input id="totp" name="totp" inputmode="numeric" autocomplete="one-time-code">
|
||||
@@ -236,6 +237,23 @@ to ask for.</p>
|
||||
{{end}}
|
||||
{{end}}`))
|
||||
|
||||
var deviceConfirmTmpl = template.Must(template.Must(pages.Clone()).Parse(`
|
||||
{{define "body"}}{{with .D}}<h1>Let a device in?</h1>
|
||||
<p class="sub">A KeepOrg client asked to sign in as <b>{{.User}}</b>.</p>
|
||||
<table><tbody>
|
||||
<tr><th>Device</th><td class="name">{{if .Label}}{{.Label}}{{else}}unnamed{{end}}</td></tr>
|
||||
<tr><th>Its code</th><td><code>{{.Code}}</code></td></tr>
|
||||
<tr><th>Started</th><td>{{.Started}}</td></tr>
|
||||
</tbody></table>
|
||||
<p class="note">Approve only if you started this sign-in yourself, just now,
|
||||
and your device shows the code above. Anyone who sent you a link to this page
|
||||
gets your access if you approve.</p>
|
||||
<form method="post" action="/oidc/approve">
|
||||
<input type="hidden" name="nonce" value="{{.Nonce}}">
|
||||
<button type="submit" name="decision" value="approve">Approve {{if .Label}}{{.Label}}{{else}}this device{{end}}</button>
|
||||
<button class="secondary" type="submit" name="decision" value="deny">This was not me</button>
|
||||
</form>{{end}}{{end}}`))
|
||||
|
||||
var doneTmpl = template.Must(template.Must(pages.Clone()).Parse(`
|
||||
{{define "body"}}<h1>{{.Title}}</h1><p class="sub">{{.Message}}</p>
|
||||
{{if .Return}}<p><a class="button" href="{{.Return}}">Return to KeepOrg</a></p>{{end}}{{end}}`))
|
||||
@@ -250,9 +268,10 @@ func (s *Server) renderInvite(w http.ResponseWriter, token, errMsg string) {
|
||||
render(w, inviteTmpl, map[string]any{"Title": "Create account", "Token": token, "Error": errMsg})
|
||||
}
|
||||
|
||||
func (s *Server) renderSignIn(w http.ResponseWriter, errMsg string) {
|
||||
func (s *Server) renderSignIn(w http.ResponseWriter, r *http.Request, errMsg string) {
|
||||
render(w, signInTmpl, map[string]any{
|
||||
"Title": "Sign in", "Error": errMsg, "OIDC": s.oidc != nil,
|
||||
"Pre": s.preSessionToken(w, r),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
"cc.tiredsysadmin.keeporg/internal/totp"
|
||||
)
|
||||
|
||||
const newAdminPassword = "a-much-better-password"
|
||||
@@ -69,20 +68,14 @@ func TestChangePasswordSwapsThePasswordAndRevokesOtherSessions(t *testing.T) {
|
||||
func TestChangePasswordRequiresTheSecondFactorWhenEnrolled(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
tok, _, _ := h.login("admin", adminPassword, "")
|
||||
_, enroll := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil)
|
||||
sec := enroll["secret"].(string)
|
||||
code, _ := totp.Code(sec, time.Now())
|
||||
if s, _ := h.call("POST", "/api/v1/auth/totp/confirm", tok,
|
||||
map[string]string{"code": code}); s != http.StatusOK {
|
||||
t.Fatal("could not enrol the second factor")
|
||||
}
|
||||
sec := h.enrollTOTP(t, tok, adminPassword)
|
||||
|
||||
status, body := h.call("POST", "/api/v1/auth/password", tok, map[string]string{
|
||||
"current_password": adminPassword, "new_password": newAdminPassword})
|
||||
if status != http.StatusUnauthorized || body["code"] != "totp_required" {
|
||||
t.Fatalf("change without a code: %d %v", status, body)
|
||||
}
|
||||
code, _ = totp.Code(sec, time.Now())
|
||||
code := codeAt(t, sec, 0)
|
||||
if s, b := h.call("POST", "/api/v1/auth/password", tok, map[string]string{
|
||||
"current_password": adminPassword, "new_password": newAdminPassword,
|
||||
"totp": code}); s != http.StatusOK {
|
||||
|
||||
+92
-19
@@ -8,7 +8,9 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -17,6 +19,7 @@ import (
|
||||
"cc.tiredsysadmin.keeporg/internal/blob"
|
||||
"cc.tiredsysadmin.keeporg/internal/kdbx"
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/sqldb"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
)
|
||||
|
||||
@@ -31,9 +34,19 @@ type Config struct {
|
||||
TLSKey string `json:"tls_key,omitempty"`
|
||||
OIDC OIDCConfig `json:"oidc"`
|
||||
|
||||
// MasterKeyFile holds the key that wraps the vault keys the server keeps
|
||||
// in order to merge. It belongs outside DataDir: a copy of the data
|
||||
// directory alone must not be enough to open a vault.
|
||||
// TrustedProxies are the reverse proxies whose X-Forwarded-For is
|
||||
// believed, as addresses or CIDR ranges. Rate limits count per client
|
||||
// address, and behind a proxy every request otherwise comes from it.
|
||||
TrustedProxies []string `json:"trusted_proxies,omitempty"`
|
||||
|
||||
// Database is where the server keeps its data: postgres://… for
|
||||
// PostgreSQL, or sqlite:<path>. The default is a SQLite file in DataDir.
|
||||
Database string `json:"database"`
|
||||
|
||||
// MasterKeyFile holds the key that encrypts everything in the database
|
||||
// and wraps the vault keys the server keeps in order to merge. It belongs
|
||||
// outside DataDir and outside the database's backups: either alone must
|
||||
// not be enough to read anything.
|
||||
MasterKeyFile string `json:"master_key_file"`
|
||||
// VaultKDF is the work factor of vaults this server creates. The server
|
||||
// pays it three times per merge, and so does every client on every open.
|
||||
@@ -49,7 +62,12 @@ func (c *Config) Defaults() {
|
||||
c.DataDir = "/var/lib/keeporg"
|
||||
}
|
||||
if c.ExternalURL == "" {
|
||||
c.ExternalURL = "http://localhost" + c.Addr
|
||||
// ":8765" means every interface; "127.0.0.1:8765" names its host.
|
||||
host := c.Addr
|
||||
if strings.HasPrefix(host, ":") {
|
||||
host = "localhost" + host
|
||||
}
|
||||
c.ExternalURL = "http://" + host
|
||||
}
|
||||
c.ExternalURL = strings.TrimRight(c.ExternalURL, "/")
|
||||
if c.SessionTTL == 0 {
|
||||
@@ -58,6 +76,9 @@ func (c *Config) Defaults() {
|
||||
if c.MaxDBSize == 0 {
|
||||
c.MaxDBSize = 32 << 20
|
||||
}
|
||||
if c.Database == "" {
|
||||
c.Database = "sqlite:" + filepath.Join(c.DataDir, "keeporg.db")
|
||||
}
|
||||
if c.MasterKeyFile == "" {
|
||||
c.MasterKeyFile = "/etc/keeporg/master.key"
|
||||
}
|
||||
@@ -68,33 +89,42 @@ func (c *Config) Defaults() {
|
||||
|
||||
type Server struct {
|
||||
cfg Config
|
||||
db *sqldb.DB
|
||||
st *store.Store
|
||||
blobs *blob.Store
|
||||
hub *Hub
|
||||
oidc *oidcProvider
|
||||
keyring *secret.Keyring
|
||||
limits *limiter
|
||||
proxies []*net.IPNet
|
||||
merges chan struct{} // a slot per merge allowed to run at once
|
||||
mux *http.ServeMux
|
||||
log *log.Logger
|
||||
}
|
||||
|
||||
// New opens the server's data directory and wires up its routes.
|
||||
// New opens the server's database and wires up its routes.
|
||||
func New(cfg Config, lg *log.Logger) (*Server, error) {
|
||||
cfg.Defaults()
|
||||
st, err := store.Open(filepath.Join(cfg.DataDir, "state.json"))
|
||||
proxies, err := parseProxies(cfg.TrustedProxies)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blobs, err := blob.Open(filepath.Join(cfg.DataDir, "blobs"))
|
||||
// Everything in the database is sealed under the master key, so there
|
||||
// is no serving without it.
|
||||
kr, err := secret.LoadKeyring(cfg.MasterKeyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("master key: %w", err)
|
||||
}
|
||||
st, db, err := OpenStore(cfg, kr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Server{cfg: cfg, st: st, blobs: blobs, hub: NewHub(), mux: http.NewServeMux(), log: lg}
|
||||
// Without a master key the server still serves every strategy that does
|
||||
// not need to read a vault; only merging and vault creation are refused.
|
||||
if kr, kerr := secret.LoadKeyring(cfg.MasterKeyFile); kerr != nil {
|
||||
lg.Printf("no master key, so this server cannot create or merge vaults: %v", kerr)
|
||||
} else {
|
||||
s.keyring = kr
|
||||
s := &Server{cfg: cfg, db: db, st: st, blobs: blob.Open(db, kr.Derive("blobs")),
|
||||
keyring: kr, limits: newLimiter(), proxies: proxies, merges: make(chan struct{}, 2),
|
||||
hub: NewHub(), mux: http.NewServeMux(), log: lg}
|
||||
if err := s.importLegacy(); err != nil {
|
||||
s.Close()
|
||||
return nil, fmt.Errorf("import %s: %w", cfg.DataDir, err)
|
||||
}
|
||||
if cfg.OIDC.Enabled() {
|
||||
s.oidc = newOIDCProvider(cfg.OIDC, cfg.ExternalURL+"/oidc/callback")
|
||||
@@ -146,7 +176,30 @@ func (s *Server) Store() *store.Store { return s.st }
|
||||
func (s *Server) Config() Config { return s.cfg }
|
||||
|
||||
// Handler is the server's HTTP entry point.
|
||||
func (s *Server) Handler() http.Handler { return s.mux }
|
||||
// Handler is the server's HTTP surface, with the security headers every
|
||||
// response carries.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
hsts := strings.HasPrefix(s.cfg.ExternalURL, "https://")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
// The pages have no scripts, no images and no frames; their only
|
||||
// style is inline. Nothing may frame them, which is what stops a
|
||||
// sign-out or delete button being clicked through a disguise.
|
||||
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; "+
|
||||
"form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
// Pages carry CSRF tokens, invite links and, once, a TOTP secret;
|
||||
// API answers carry bearer tokens. None of it belongs in a cache.
|
||||
h.Set("Cache-Control", "no-store")
|
||||
if hsts {
|
||||
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
|
||||
}
|
||||
s.mux.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// StartHousekeeping runs periodic expiry until done is closed.
|
||||
func (s *Server) StartHousekeeping(done <-chan struct{}) {
|
||||
@@ -215,6 +268,7 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("POST /invite/{token}", s.handleInviteRedeem)
|
||||
m.HandleFunc("GET /oidc/login", s.handleOIDCLogin)
|
||||
m.HandleFunc("GET /oidc/callback", s.handleOIDCCallback)
|
||||
m.HandleFunc("POST /oidc/approve", s.handleOIDCApprove)
|
||||
|
||||
// Synchronization.
|
||||
m.HandleFunc("GET /api/v1/databases", s.authed(s.handleListDatabases))
|
||||
@@ -338,15 +392,34 @@ func (s *Server) markSeen(token string, last time.Time) {
|
||||
})
|
||||
}
|
||||
|
||||
// bearer extracts a token from the Authorization header, or from the
|
||||
// access_token query parameter, which EventSource clients need because they
|
||||
// cannot set headers.
|
||||
// bearer extracts a token from the Authorization header. The event stream
|
||||
// alone also takes ?access_token=, which clients before 0.12 sent; a token
|
||||
// in a URL ends up in proxy logs, so nothing else accepts it, and current
|
||||
// clients send the header there too.
|
||||
func bearer(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if after, ok := strings.CutPrefix(h, "Bearer "); ok {
|
||||
return strings.TrimSpace(after)
|
||||
}
|
||||
return r.URL.Query().Get("access_token")
|
||||
if r.URL.Path == "/api/v1/events" {
|
||||
return r.URL.Query().Get("access_token")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// sessionAlive reports whether a token still belongs to a live session of
|
||||
// an enabled account, for long requests that outlast their authentication.
|
||||
func (s *Server) sessionAlive(token string) bool {
|
||||
alive := false
|
||||
s.st.Read(func(st *store.State) {
|
||||
sess := st.Sessions[token]
|
||||
if sess == nil || time.Now().After(sess.ExpiresAt) {
|
||||
return
|
||||
}
|
||||
u := st.Users[sess.UserID]
|
||||
alive = u != nil && !u.Disabled
|
||||
})
|
||||
return alive
|
||||
}
|
||||
|
||||
func (s *Server) newSession(st *store.State, userID, label string) *store.Session {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/sqldb"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
)
|
||||
|
||||
// OpenStore opens the configured database, proves the master key is the one
|
||||
// it was written with, and loads the metadata. keeporgd -reset-admin uses it
|
||||
// too, so a reset goes through the same checks as the server.
|
||||
func OpenStore(cfg Config, kr *secret.Keyring) (*store.Store, *sqldb.DB, error) {
|
||||
cfg.Defaults()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
db, err := sqldb.Open(ctx, cfg.Database)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := db.CheckKey(ctx, kr.Derive("check")); err != nil {
|
||||
db.Close()
|
||||
return nil, nil, fmt.Errorf("database %s: %w", db.Where, err)
|
||||
}
|
||||
st, err := store.Open(ctx, db, kr.Derive("records"))
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return nil, nil, fmt.Errorf("database %s: %w", db.Where, err)
|
||||
}
|
||||
return st, db, nil
|
||||
}
|
||||
|
||||
// Close stops the store's watcher and closes the database.
|
||||
func (s *Server) Close() {
|
||||
s.st.Close()
|
||||
s.db.Close()
|
||||
}
|
||||
|
||||
// legacyState and legacyBlobs are where servers before 0.11 kept everything,
|
||||
// in the data directory.
|
||||
const (
|
||||
legacyState = "state.json"
|
||||
legacyBlobs = "blobs"
|
||||
)
|
||||
|
||||
// importLegacy moves the data of a server from before 0.11 into the
|
||||
// database, once: when the database is empty and the old files are there.
|
||||
// The payloads go first and the metadata last, in one transaction, so a
|
||||
// crash part-way leaves an empty store and the import simply runs again.
|
||||
//
|
||||
// The old files are renamed rather than deleted, so nothing is lost if the
|
||||
// import turns out wrong. They are not encrypted the way the database is —
|
||||
// state.json holds session tokens in the clear — so the log asks for them to
|
||||
// be deleted once the server is known to work.
|
||||
func (s *Server) importLegacy() error {
|
||||
statePath := filepath.Join(s.cfg.DataDir, legacyState)
|
||||
if _, err := os.Stat(statePath); errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if !s.st.Empty() {
|
||||
s.log.Printf("WARNING: %s is still there, but the database already holds data, "+
|
||||
"so it was not imported again. Delete it, and %s, once you no longer need them.",
|
||||
statePath, filepath.Join(s.cfg.DataDir, legacyBlobs))
|
||||
return nil
|
||||
}
|
||||
old, err := store.ReadLegacy(statePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := s.blobs.ImportDir(filepath.Join(s.cfg.DataDir, legacyBlobs))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.st.Replace(old); err != nil {
|
||||
return err
|
||||
}
|
||||
stamp := time.Now().UTC().Format("20060102-150405")
|
||||
var renamed []string
|
||||
for _, name := range []string{legacyState, legacyBlobs} {
|
||||
from := filepath.Join(s.cfg.DataDir, name)
|
||||
to := from + ".imported-" + stamp
|
||||
if err := os.Rename(from, to); err == nil {
|
||||
renamed = append(renamed, to)
|
||||
}
|
||||
}
|
||||
s.log.Printf("imported %d accounts, %d databases and %d stored versions from %s into %s",
|
||||
len(old.Users), len(old.Databases), n, s.cfg.DataDir, s.db.Where)
|
||||
s.log.Printf("the old files were kept as %v. They are not encrypted like the database "+
|
||||
"(state.json holds session tokens): delete them once this server works", renamed)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DatabaseWhere is the database's location without any password, for logs.
|
||||
func (s *Server) DatabaseWhere() string { return s.db.Where }
|
||||
@@ -0,0 +1,99 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/sqldb"
|
||||
"cc.tiredsysadmin.keeporg/internal/sqldb/sqldbtest"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
)
|
||||
|
||||
// A data directory written by a server before 0.11 is imported on the first
|
||||
// start: accounts, sessions and every stored version, after which the old
|
||||
// files are set aside.
|
||||
func TestOldDataDirectoryIsImported(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
old := map[string]any{
|
||||
"users": map[string]any{"u1": map[string]any{"id": "u1", "username": "alice"}},
|
||||
"groups": map[string]any{"g1": map[string]any{"id": "g1", "name": "everyone"}},
|
||||
"databases": map[string]any{"d1": map[string]any{"id": "d1", "name": "Team",
|
||||
"owner_group_id": "g1", "merge_strategy": "server-wins", "version": 2}},
|
||||
"sessions": map[string]any{"old-token": map[string]any{"token": "old-token",
|
||||
"user_id": "u1", "expires_at": time.Now().Add(time.Hour)}},
|
||||
"memberships": []any{map[string]any{"user_id": "u1", "group_id": "g1", "role": "admin"}},
|
||||
}
|
||||
b, _ := json.Marshal(old)
|
||||
os.WriteFile(filepath.Join(dir, "state.json"), b, 0o600)
|
||||
os.MkdirAll(filepath.Join(dir, "blobs", "d1"), 0o700)
|
||||
for v, body := range map[string]string{"1": "first", "2": "second"} {
|
||||
os.WriteFile(filepath.Join(dir, "blobs", "d1", "0000000000000000000"+v+".kdbx"), []byte(body), 0o600)
|
||||
}
|
||||
|
||||
var logs bytes.Buffer
|
||||
cfg := Config{DataDir: dir, Database: sqldbtest.URL(t), MasterKeyFile: filepath.Join(t.TempDir(), "mk")}
|
||||
srv, err := New(cfg, log.New(&logs, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
|
||||
srv.Store().Read(func(st *store.State) {
|
||||
if st.Users["u1"] == nil || st.Sessions["old-token"] == nil || len(st.Memberships) != 1 {
|
||||
t.Fatalf("imported state = %+v", st)
|
||||
}
|
||||
})
|
||||
if got, err := srv.blobs.Get("d1", 2); err != nil || string(got) != "second" {
|
||||
t.Fatalf("imported version 2 = %q, %v", got, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "state.json")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("state.json was left in place, to be imported again")
|
||||
}
|
||||
if !strings.Contains(logs.String(), "delete them") {
|
||||
t.Fatalf("the log does not ask for the old files to be deleted:\n%s", logs.String())
|
||||
}
|
||||
|
||||
// A second start finds the database populated and imports nothing.
|
||||
srv.Close()
|
||||
again, err := New(cfg, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer again.Close()
|
||||
again.Store().Read(func(st *store.State) {
|
||||
if len(st.Users) != 1 {
|
||||
t.Fatalf("users after restart = %d", len(st.Users))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestServerRefusesAnotherMasterKey(t *testing.T) {
|
||||
url := sqldbtest.URL(t)
|
||||
cfg := Config{DataDir: t.TempDir(), Database: url, MasterKeyFile: filepath.Join(t.TempDir(), "mk")}
|
||||
srv, err := New(cfg, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.Close()
|
||||
|
||||
cfg.MasterKeyFile = filepath.Join(t.TempDir(), "another")
|
||||
if _, err := New(cfg, log.New(io.Discard, "", 0)); !errors.Is(err, sqldb.ErrWrongKey) {
|
||||
t.Fatalf("started with a different master key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerRefusesToStartWithoutItsMasterKey(t *testing.T) {
|
||||
t.Setenv("KEEPORG_MASTER_KEY_FILE", filepath.Join(t.TempDir(), "not-mounted"))
|
||||
cfg := Config{DataDir: t.TempDir(), Database: sqldbtest.URL(t)}
|
||||
if _, err := New(cfg, log.New(io.Discard, "", 0)); err == nil || !strings.Contains(err.Error(), "master key") {
|
||||
t.Fatalf("started without its master key: %v", err)
|
||||
}
|
||||
}
|
||||
+33
-7
@@ -1,6 +1,8 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -10,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/blob"
|
||||
"cc.tiredsysadmin.keeporg/internal/kdbx"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
)
|
||||
|
||||
@@ -145,8 +148,20 @@ func (s *Server) handlePutBlob(w http.ResponseWriter, r *http.Request, u *store.
|
||||
return
|
||||
}
|
||||
|
||||
// The server opens a merge vault, so a file it will one day have to
|
||||
// open must stay within what it can afford to: a header demanding a
|
||||
// billion KDF rounds is refused here, before it is stored and before
|
||||
// any merge tries it.
|
||||
if strategy == store.StrategyMerge {
|
||||
if err := kdbx.Inspect(data); err != nil {
|
||||
fail(w, http.StatusBadRequest, "invalid_kdbx",
|
||||
"this server merges this vault, and cannot accept this file: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if merging {
|
||||
s.mergePush(w, u, id, current, data)
|
||||
s.mergePush(w, r, u, id, current, data)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -156,7 +171,9 @@ func (s *Server) handlePutBlob(w http.ResponseWriter, r *http.Request, u *store.
|
||||
watchers []string
|
||||
name string
|
||||
)
|
||||
err = s.st.Update(func(st *store.State) error {
|
||||
write := false
|
||||
err = s.st.UpdateWith(func(st *store.State) error {
|
||||
write = false
|
||||
d := st.Databases[id]
|
||||
if d == nil {
|
||||
return errors.New("no such database")
|
||||
@@ -169,15 +186,17 @@ func (s *Server) handlePutBlob(w http.ResponseWriter, r *http.Request, u *store.
|
||||
next, unchanged, name = d.Version, true, d.Name
|
||||
return nil
|
||||
}
|
||||
next = d.Version + 1
|
||||
if err := s.blobs.Put(id, next, data); err != nil {
|
||||
return err
|
||||
}
|
||||
next, write = d.Version+1, true
|
||||
d.Version, d.SHA256, d.Size = next, sum, int64(len(data))
|
||||
d.UpdatedAt, d.UpdatedBy = time.Now(), u.Username
|
||||
name = d.Name
|
||||
watchers = st.Watchers(id)
|
||||
return nil
|
||||
}, func(ctx context.Context, tx *sql.Tx) error {
|
||||
if !write {
|
||||
return nil
|
||||
}
|
||||
return s.blobs.PutTx(ctx, tx, id, next, data)
|
||||
})
|
||||
var ce conflictError
|
||||
switch {
|
||||
@@ -190,7 +209,8 @@ func (s *Server) handlePutBlob(w http.ResponseWriter, r *http.Request, u *store.
|
||||
})
|
||||
return
|
||||
case err != nil:
|
||||
fail(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
s.log.Printf("push %s: %v", id, err)
|
||||
fail(w, http.StatusInternalServerError, "internal", "the push could not be stored; try again")
|
||||
return
|
||||
}
|
||||
if !unchanged {
|
||||
@@ -212,6 +232,7 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request, u *store.U
|
||||
fail(w, http.StatusInternalServerError, "internal", "streaming unsupported")
|
||||
return
|
||||
}
|
||||
token := bearer(r)
|
||||
id, ch := s.hub.Subscribe(u.ID)
|
||||
defer s.hub.Unsubscribe(id)
|
||||
|
||||
@@ -237,6 +258,11 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request, u *store.U
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, ev.encode())
|
||||
flusher.Flush()
|
||||
case <-keepalive.C:
|
||||
// A stream outlives the request that authenticated it: end it
|
||||
// once the session is revoked or the account disabled.
|
||||
if !s.sessionAlive(token) {
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, ": keepalive\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
@@ -40,6 +40,13 @@ func (s *Server) createVault(u *store.User, name, groupID string, strategy store
|
||||
case strategy.NeedsKey() && s.keyring == nil:
|
||||
return dbView{}, errNoKeyring
|
||||
}
|
||||
// Permission first: the key derivation below is deliberately costly,
|
||||
// and nobody who could not create the vault should get to spend it.
|
||||
var allowed bool
|
||||
s.st.Read(func(st *store.State) { allowed = st.Groups[groupID] != nil && st.IsGroupAdmin(u.ID, groupID) })
|
||||
if !allowed {
|
||||
return dbView{}, errForbidden
|
||||
}
|
||||
|
||||
// Build the empty database before touching any state, so a failure here
|
||||
// leaves nothing behind.
|
||||
@@ -125,16 +132,16 @@ func (s *Server) handleSetVaultKey(w http.ResponseWriter, r *http.Request, u *st
|
||||
}
|
||||
var (
|
||||
version int64
|
||||
perm store.Perm
|
||||
access error
|
||||
)
|
||||
s.st.Read(func(st *store.State) {
|
||||
perm = st.DatabasePerm(u.ID, id)
|
||||
access = adminsDatabase(st, u, id)
|
||||
if d := st.Databases[id]; d != nil {
|
||||
version = d.Version
|
||||
}
|
||||
})
|
||||
if perm != store.PermAdmin {
|
||||
deny(w, errForbidden)
|
||||
if access != nil {
|
||||
deny(w, access)
|
||||
return
|
||||
}
|
||||
if !req.Forget {
|
||||
|
||||
+47
-20
@@ -62,7 +62,7 @@ func (s *Server) webPage(fn webHandler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := s.webUser(r)
|
||||
if sess == nil {
|
||||
s.renderSignIn(w, "")
|
||||
s.renderSignIn(w, r, "")
|
||||
return
|
||||
}
|
||||
fn(w, r, sess)
|
||||
@@ -75,7 +75,7 @@ func (s *Server) webForm(fn webHandler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := s.webUser(r)
|
||||
if sess == nil {
|
||||
s.renderSignIn(w, "")
|
||||
s.renderSignIn(w, r, "")
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil || !s.checkCSRF(r, sess) {
|
||||
@@ -108,7 +108,28 @@ func (s *Server) handleWebHome(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/web/vaults", http.StatusFound)
|
||||
return
|
||||
}
|
||||
s.renderSignIn(w, "")
|
||||
s.renderSignIn(w, r, "")
|
||||
}
|
||||
|
||||
// preCookie carries a token that the sign-in form must echo. Without it a
|
||||
// page elsewhere could post the form with its own credentials and sign a
|
||||
// visitor in to the attacker's account, where whatever they then create
|
||||
// lands. Strict, so a cross-site post does not carry it.
|
||||
const preCookie = "keeporg_pre"
|
||||
|
||||
// preSessionToken returns this browser's sign-in form token, issuing one if
|
||||
// it has none.
|
||||
func (s *Server) preSessionToken(w http.ResponseWriter, r *http.Request) string {
|
||||
if c, err := r.Cookie(preCookie); err == nil && len(c.Value) == 64 {
|
||||
return c.Value
|
||||
}
|
||||
tok := secret.Token()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: preCookie, Value: tok, Path: "/web", HttpOnly: true,
|
||||
Secure: strings.HasPrefix(s.cfg.ExternalURL, "https://"),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
return tok
|
||||
}
|
||||
|
||||
func (s *Server) handleWebSignIn(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -116,25 +137,28 @@ func (s *Server) handleWebSignIn(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
if c, err := r.Cookie(preCookie); err != nil || !secret.Equal(c.Value, r.FormValue("pre")) {
|
||||
s.renderSignIn(w, r, "The sign-in form had expired. Sign in again.")
|
||||
return
|
||||
}
|
||||
var token string
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
u, err := checkPassword(st, r.FormValue("username"), r.FormValue("password"),
|
||||
r.FormValue("totp"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token = s.newBrowserSession(st, u.ID, r).Token
|
||||
return nil
|
||||
})
|
||||
err := s.signIn(r, r.FormValue("username"), r.FormValue("password"), r.FormValue("totp"),
|
||||
func(st *store.State, u *store.User) error {
|
||||
token = s.newBrowserSession(st, u.ID, r).Token
|
||||
return nil
|
||||
})
|
||||
var throttled *errThrottled
|
||||
switch {
|
||||
case errors.As(err, &throttled):
|
||||
s.renderSignIn(w, r, throttled.Error())
|
||||
case errors.Is(err, errTOTPRequired):
|
||||
s.renderSignIn(w, "This account needs its one-time code.")
|
||||
s.renderSignIn(w, r, "This account needs its one-time code.")
|
||||
case errors.Is(err, errTOTPInvalid):
|
||||
s.renderSignIn(w, "That one-time code was rejected.")
|
||||
s.renderSignIn(w, r, "That one-time code was rejected.")
|
||||
case errors.Is(err, errBadCredentials):
|
||||
s.renderSignIn(w, "Unknown user or wrong password.")
|
||||
s.renderSignIn(w, r, "Unknown user or wrong password.")
|
||||
case err != nil:
|
||||
s.renderSignIn(w, err.Error())
|
||||
s.renderSignIn(w, r, err.Error())
|
||||
default:
|
||||
s.setWebCookie(w, token, s.cfg.SessionTTL)
|
||||
http.Redirect(w, r, "/web/vaults", http.StatusFound)
|
||||
@@ -246,15 +270,18 @@ func (s *Server) handleWebDeleteVaultPage(w http.ResponseWriter, r *http.Request
|
||||
|
||||
func (s *Server) handleWebDeleteVault(w http.ResponseWriter, r *http.Request, sess *webSession) {
|
||||
id := r.PathValue("id")
|
||||
name, err := s.deleteDatabase(sess.user, id, r.FormValue("password"),
|
||||
name, err := s.deleteDatabase(r, sess.user, id, r.FormValue("password"),
|
||||
r.FormValue("totp"), r.FormValue("confirm_name"))
|
||||
var throttled *errThrottled
|
||||
switch {
|
||||
case errors.As(err, &throttled):
|
||||
s.renderDeleteVault(w, sess, id, throttled.Error())
|
||||
case errors.Is(err, errReauth):
|
||||
s.renderDeleteVault(w, sess, id, "Enter your account password.")
|
||||
case errors.Is(err, errTOTPRequired):
|
||||
s.renderDeleteVault(w, sess, id, "Enter your one-time code as well.")
|
||||
case errors.Is(err, errTOTPInvalid):
|
||||
s.renderDeleteVault(w, sess, id, "That one-time code was rejected.")
|
||||
case errors.Is(err, errBadCredentials):
|
||||
s.renderDeleteVault(w, sess, id, "That is not your account password.")
|
||||
s.renderDeleteVault(w, sess, id, "Your account password or one-time code is wrong.")
|
||||
case errors.Is(err, errConfirmName):
|
||||
s.renderDeleteVault(w, sess, id, "Type the vault's name exactly as it is shown.")
|
||||
case err != nil:
|
||||
|
||||
@@ -41,8 +41,18 @@ func (b *browser) get(path string) (int, string) {
|
||||
return resp.StatusCode, string(body)
|
||||
}
|
||||
|
||||
var prePattern = regexp.MustCompile(`name="pre" value="([^"]+)"`)
|
||||
|
||||
func (b *browser) post(path string, form url.Values) (int, string) {
|
||||
b.t.Helper()
|
||||
// The sign-in form carries a token from the page that showed it; fill
|
||||
// it in the way a browser would by having loaded the page first.
|
||||
if path == "/web/signin" && form.Get("pre") == "" {
|
||||
_, page := b.get("/web")
|
||||
if m := prePattern.FindStringSubmatch(page); m != nil {
|
||||
form.Set("pre", m[1])
|
||||
}
|
||||
}
|
||||
resp, err := b.http.PostForm(b.h.http.URL+path, form)
|
||||
if err != nil {
|
||||
b.t.Fatal(err)
|
||||
@@ -107,11 +117,49 @@ func TestWebSignInRejectsWrongPassword(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A sign-in form posted from elsewhere carries no pre-session cookie and is
|
||||
// refused, so a page cannot sign a visitor in to an account of its choosing.
|
||||
func TestWebSignInRefusesACrossSitePost(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
resp, err := h.http.Client().PostForm(h.http.URL+"/web/signin", url.Values{
|
||||
"username": {"admin"}, "password": {adminPassword}, "pre": {strings.Repeat("a", 64)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == webCookie && c.Value != "" {
|
||||
t.Fatal("a cross-site sign-in was accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryResponseCarriesSecurityHeaders(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
for _, path := range []string{"/web", "/device", "/api/v1/databases", "/healthz"} {
|
||||
resp, err := h.http.Client().Get(h.http.URL + path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
for _, k := range []string{"Content-Security-Policy", "X-Frame-Options", "X-Content-Type-Options", "Cache-Control"} {
|
||||
if resp.Header.Get(k) == "" {
|
||||
t.Errorf("%s: no %s", path, k)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(resp.Header.Get("Content-Security-Policy"), "frame-ancestors 'none'") {
|
||||
t.Errorf("%s: frames allowed", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSessionCookieIsHardened(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
b := newBrowser(t, h)
|
||||
_, page := b.get("/web")
|
||||
resp, err := b.http.PostForm(h.http.URL+"/web/signin", url.Values{
|
||||
"username": {"admin"}, "password": {adminPassword}})
|
||||
"username": {"admin"}, "password": {adminPassword},
|
||||
"pre": {prePattern.FindStringSubmatch(page)[1]}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -138,17 +186,27 @@ func TestWebSessionCookieIsHardened(t *testing.T) {
|
||||
func TestWebCookieIsSecureBehindHTTPS(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.srv.cfg.ExternalURL = "https://keeporg.example.com"
|
||||
resp, err := h.http.Client().PostForm(h.http.URL+"/web/signin", url.Values{
|
||||
"username": {"admin"}, "password": {adminPassword}})
|
||||
form := url.Values{"username": {"admin"}, "password": {adminPassword}, "pre": {strings.Repeat("a", 64)}}
|
||||
req, _ := http.NewRequest("POST", h.http.URL+"/web/signin", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: preCookie, Value: strings.Repeat("a", 64)})
|
||||
resp, err := h.http.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var found bool
|
||||
for _, c := range resp.Request.Response.Cookies() {
|
||||
if c.Name == webCookie {
|
||||
found = true
|
||||
}
|
||||
if c.Name == webCookie && !c.Secure {
|
||||
t.Fatal("the cookie is not marked Secure although the server is https")
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("no session cookie was issued")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebCreatesAVaultThatOpensWithItsPassword(t *testing.T) {
|
||||
|
||||
@@ -32,6 +32,9 @@ type webAccount struct {
|
||||
TOTPKey string
|
||||
TOTPLink template.URL
|
||||
Sessions []webSessionRow
|
||||
// SSOLink starts linking single sign-on to this account, when the
|
||||
// server has a provider and the account is not linked yet.
|
||||
SSOLink string
|
||||
}
|
||||
|
||||
type webMembership struct {
|
||||
@@ -61,6 +64,9 @@ func (s *Server) accountView(sess *webSession) webAccount {
|
||||
Since: u.CreatedAt.UTC().Format("2006-01-02"),
|
||||
HasPassword: u.PassHash != "", TOTPOn: u.TOTPConfirmed,
|
||||
}
|
||||
if s.oidc != nil && u.OIDCSubject == "" && !u.Disabled {
|
||||
v.SSOLink = "/oidc/login?link=1&csrf=" + url.QueryEscape(sess.csrf)
|
||||
}
|
||||
if u.OIDCIssuer != "" {
|
||||
v.SSO = u.OIDCIssuer
|
||||
if iss, err := url.Parse(u.OIDCIssuer); err == nil && iss.Host != "" {
|
||||
@@ -134,6 +140,14 @@ func (s *Server) handleWebAccount(w http.ResponseWriter, r *http.Request, sess *
|
||||
// handleWebAccountAction applies one control of the settings page.
|
||||
func (s *Server) handleWebAccountAction(w http.ResponseWriter, r *http.Request, sess *webSession) {
|
||||
var notice string
|
||||
// Changes that need the password and code are proven outside the
|
||||
// store's lock, then applied inside it.
|
||||
switch r.FormValue("action") {
|
||||
case "password", "totp-begin", "totp-disable":
|
||||
err := s.accountReconfirmed(r, sess, ¬ice)
|
||||
s.finishAccountAction(w, r, sess, err, notice)
|
||||
return
|
||||
}
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
switch r.FormValue("action") {
|
||||
case "revoke":
|
||||
@@ -144,21 +158,6 @@ func (s *Server) handleWebAccountAction(w http.ResponseWriter, r *http.Request,
|
||||
case "revoke-others":
|
||||
n := revokeOtherSessions(st, sess.user.ID, sess.token)
|
||||
notice = fmt.Sprintf("Signed out %d other %s.", n, plural(n, "session", "sessions"))
|
||||
case "password":
|
||||
next := r.FormValue("new_password")
|
||||
if next != r.FormValue("new_password2") {
|
||||
return errors.New("the two new passwords do not match")
|
||||
}
|
||||
if err := changePassword(st, sess.user.Username, r.FormValue("current_password"),
|
||||
r.FormValue("totp"), next, sess.token); err != nil {
|
||||
return err
|
||||
}
|
||||
notice = "Password changed. Your other sessions were signed out."
|
||||
case "totp-begin":
|
||||
if _, err := beginTOTP(st, sess.user.ID, r.FormValue("password"), r.FormValue("totp")); err != nil {
|
||||
return err
|
||||
}
|
||||
notice = "Add the key below to your authenticator app, then enter the code it shows."
|
||||
case "totp-confirm":
|
||||
if err := confirmTOTP(st, sess.user.ID, r.FormValue("code")); err != nil {
|
||||
return err
|
||||
@@ -167,16 +166,45 @@ func (s *Server) handleWebAccountAction(w http.ResponseWriter, r *http.Request,
|
||||
case "totp-cancel":
|
||||
cancelTOTP(st, sess.user.ID)
|
||||
notice = "Setup cancelled. Nothing changed."
|
||||
case "totp-disable":
|
||||
if err := disableTOTP(st, sess.user.ID, r.FormValue("password"), r.FormValue("totp")); err != nil {
|
||||
return err
|
||||
}
|
||||
notice = "Two-factor authentication is off."
|
||||
default:
|
||||
return errBadAction
|
||||
}
|
||||
return nil
|
||||
})
|
||||
s.finishAccountAction(w, r, sess, err, notice)
|
||||
}
|
||||
|
||||
// accountReconfirmed applies the settings changes that need the password,
|
||||
// and the one-time code when one is on.
|
||||
func (s *Server) accountReconfirmed(r *http.Request, sess *webSession, notice *string) error {
|
||||
switch r.FormValue("action") {
|
||||
case "password":
|
||||
next := r.FormValue("new_password")
|
||||
if next != r.FormValue("new_password2") {
|
||||
return errors.New("the two new passwords do not match")
|
||||
}
|
||||
if err := s.changePassword(r, sess.user, r.FormValue("current_password"),
|
||||
r.FormValue("totp"), next, sess.token); err != nil {
|
||||
return err
|
||||
}
|
||||
*notice = "Password changed. Your other sessions were signed out."
|
||||
case "totp-begin":
|
||||
if err := s.reconfirm(r, sess.user, r.FormValue("password"), r.FormValue("totp"),
|
||||
func(st *store.State, u *store.User) error { _, err := beginTOTP(st, u.ID); return err }); err != nil {
|
||||
return err
|
||||
}
|
||||
*notice = "Add the key below to your authenticator app, then enter the code it shows."
|
||||
case "totp-disable":
|
||||
if err := s.reconfirm(r, sess.user, r.FormValue("password"), r.FormValue("totp"),
|
||||
func(st *store.State, u *store.User) error { return disableTOTP(st, u.ID) }); err != nil {
|
||||
return err
|
||||
}
|
||||
*notice = "Two-factor authentication is off."
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) finishAccountAction(w http.ResponseWriter, r *http.Request, sess *webSession, err error, notice string) {
|
||||
if err != nil {
|
||||
s.renderAccount(w, sess, accountError(err), "")
|
||||
return
|
||||
@@ -191,13 +219,16 @@ func (s *Server) handleWebAccountAction(w http.ResponseWriter, r *http.Request,
|
||||
}
|
||||
|
||||
func accountError(err error) string {
|
||||
var throttled *errThrottled
|
||||
switch {
|
||||
case errors.As(err, &throttled):
|
||||
return throttled.Error()
|
||||
case errors.Is(err, errReauth), errors.Is(err, errTOTPRequired):
|
||||
return "Confirm with your password and your current one-time code."
|
||||
case errors.Is(err, errTOTPInvalid):
|
||||
return "That one-time code was rejected. Check the clock on the device that shows it."
|
||||
case errors.Is(err, errBadCredentials):
|
||||
return "The password is wrong."
|
||||
return "The password or the one-time code is wrong."
|
||||
case errors.Is(err, errNoSuchSession):
|
||||
return "That session is already signed out."
|
||||
}
|
||||
@@ -232,6 +263,7 @@ var accountTmpl = template.Must(template.Must(pages.Clone()).Parse(`
|
||||
{{if .HasPassword}}<span class="tag">password</span>{{end}}
|
||||
{{if .TOTPOn}}<span class="tag">one-time code</span>{{end}}
|
||||
{{if .SSO}}<span class="tag">single sign-on</span> <small>via {{.SSO}}</small>{{end}}
|
||||
{{if .SSOLink}}<br><a href="{{.SSOLink}}">Link single sign-on to this account</a>{{end}}
|
||||
</td></tr>
|
||||
<tr><th>Role</th><td>{{if .ServerAdmin}}server administrator{{else}}member{{end}}</td></tr>
|
||||
<tr><th>Groups</th><td>{{range $i, $g := .Groups}}{{if $i}}, {{end}}{{$g.Name}} <small>({{$g.Role}})</small>{{else}}none{{end}}</td></tr>
|
||||
@@ -310,6 +342,8 @@ each sign-in also needs a code from an app on your phone.</p>
|
||||
<form method="post" action="/web/account">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="action" value="totp-begin">
|
||||
<label for="tb_password">Your password</label>
|
||||
<input id="tb_password" name="password" type="password" autocomplete="current-password" required>
|
||||
<button type="submit">Set up two-factor authentication</button>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
@@ -362,10 +362,18 @@ func userRows(st *store.State, u *store.User) []webUserRow {
|
||||
case target.OIDCSubject != "":
|
||||
signIn = "single sign-on"
|
||||
}
|
||||
out = append(out, webUserRow{ID: target.ID, Username: target.Username,
|
||||
row := webUserRow{ID: target.ID, Username: target.Username,
|
||||
Email: target.Email, SignIn: signIn, Groups: strings.Join(mine, ", "),
|
||||
TOTP: target.TOTPConfirmed, ServerAdmin: target.ServerAdmin,
|
||||
Disabled: target.Disabled, Self: target.ID == u.ID})
|
||||
Disabled: target.Disabled, Self: target.ID == u.ID}
|
||||
// A group administrator manages access, not accounts: how somebody
|
||||
// signs in, their address and whether they run the server are no
|
||||
// business of theirs, and a map of weak accounts to whoever can add
|
||||
// people to a group.
|
||||
if !u.ServerAdmin && !row.Self {
|
||||
row.Email, row.SignIn, row.TOTP, row.ServerAdmin = "", "", false, false
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user