Files
keeporg/internal/store/model.go
T
root 7eed722ff5 Fix single sign-on from the web, and make passwords changeable
Why: two questions about operating this turned up two gaps. "Where do I get
the admin password" had no answer once the line scrolled out of the log,
and "how do I set up OIDC" ran straight into a sign-in link that could not
work.

What:
- /oidc/login with no parameters now signs a browser in and ends in a
  session cookie. It previously required the user code of a waiting client,
  so the link on the web sign-in page always failed. With a code it still
  approves a device, and the two paths no longer overlap: an empty user
  code matches no pending device.
- POST /api/v1/auth/password, `keeporg passwd`, and a form on /web/vaults.
  Changing a password requires the current one, and the one-time code when
  a second factor is enrolled, and revokes that account's other sessions.
  Until now nothing could change a password, which made the advice printed
  at first start impossible to follow.
- keeporgd -reset-admin, for when that first password was not kept: a new
  one printed once, any second factor cleared, sessions revoked. It writes
  the state document directly and says to stop the server first.
- The client read stdin through a new buffered reader per prompt, so the
  first prompt swallowed the rest and anything asking twice could not be
  scripted. One shared reader now. A prompt for a password being set no
  longer reads $KEEPORG_PASSWORD either; that variable holds the password
  you sign in with, and taking it here would have set the new password to
  the old one.
- HANDOVER gains a first-sign-in section and an OIDC setup section covering
  the redirect URI, the config block, both entry points and how an identity
  becomes an account.

Proved: OIDC now has tests, against a stub provider that serves discovery,
authorization, token and userinfo. They cover the web sign-in that was
broken, device approval, linking an existing account by username, the
refusal when auto_create is off, and a forged callback state. Eight more
cover the password change. Checked by hand with the real binaries: the
first-start password logs once and works, -reset-admin replaces it while
the old one stops working, and `keeporg passwd` changes it from a pipe.
Server 0.5.0, desktop clients 0.3.0.
2026-10-30 10:47:51 +01:00

250 lines
7.9 KiB
Go

// Package store holds the server's authoritative metadata: users, nested
// groups, databases and the grants that connect them.
//
// Glossary:
//
// User an account, authenticated by password+TOTP or by OIDC.
// Group a node in a single tree of groups. Grants hang off groups.
// Membership a user's place in one group, as member or admin.
// Database one KDBX file tracked by the server. Owned by one group.
// Grant a (group, database, permission) triple.
// Invite a single- or multi-use registration token.
// Session a bearer token held by a client device.
package store
import "time"
// Perm is a permission on a database, ordered read < write < admin.
type Perm string
const (
PermNone Perm = ""
PermRead Perm = "read"
PermWrite Perm = "write"
PermAdmin Perm = "admin"
)
var permRank = map[Perm]int{PermNone: 0, PermRead: 1, PermWrite: 2, PermAdmin: 3}
// Rank orders permissions so the strongest of several grants can be picked.
func (p Perm) Rank() int { return permRank[p] }
// AtLeast reports whether p is as strong as want.
func (p Perm) AtLeast(want Perm) bool { return p.Rank() >= want.Rank() }
// Valid reports whether p is a permission that may be stored.
func (p Perm) Valid() bool { return p == PermRead || p == PermWrite || p == PermAdmin }
// Role is a user's role inside a group.
type Role string
const (
RoleMember Role = "member"
RoleAdmin Role = "admin"
)
// Valid reports whether r is a role that may be stored.
func (r Role) Valid() bool { return r == RoleMember || r == RoleAdmin }
// Strategy decides what happens when a client pushes a database version that
// is no longer current.
type Strategy string
const (
// StrategyServerWins rejects the stale push; the client takes the server
// copy and keeps its own as a conflict file. This is the default.
StrategyServerWins Strategy = "server-wins"
// StrategyClientWins lets the client overwrite the server copy.
StrategyClientWins Strategy = "client-wins"
// StrategyManual rejects the stale push and leaves both copies in place
// for a human to reconcile.
StrategyManual Strategy = "manual"
// StrategyMerge decrypts both copies in memory and folds them together,
// so neither edit is lost. It is the only strategy that requires the
// server to hold the vault's key.
StrategyMerge Strategy = "merge"
)
// Valid reports whether s is a strategy that may be stored.
func (s Strategy) Valid() bool {
return s == StrategyServerWins || s == StrategyClientWins ||
s == StrategyManual || s == StrategyMerge
}
// NeedsKey reports whether this strategy can only run when the server holds
// the vault's key.
func (s Strategy) NeedsKey() bool { return s == StrategyMerge }
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
PassHash string `json:"pass_hash,omitempty"`
OIDCIssuer string `json:"oidc_issuer,omitempty"`
OIDCSubject string `json:"oidc_subject,omitempty"`
TOTPSecret string `json:"totp_secret,omitempty"`
TOTPConfirmed bool `json:"totp_confirmed"`
ServerAdmin bool `json:"server_admin"`
Disabled bool `json:"disabled"`
CreatedAt time.Time `json:"created_at"`
}
type Group struct {
ID string `json:"id"`
Name string `json:"name"`
ParentID string `json:"parent_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type Membership struct {
UserID string `json:"user_id"`
GroupID string `json:"group_id"`
Role Role `json:"role"`
}
type Database struct {
ID string `json:"id"`
Name string `json:"name"`
OwnerGroupID string `json:"owner_group_id"`
MergeStrategy Strategy `json:"merge_strategy"`
// WrappedKey is the vault's own password, encrypted under the server's
// master key. It is present only for vaults that are merged here, and it
// is never returned by the API.
WrappedKey string `json:"wrapped_key,omitempty"`
Version int64 `json:"version"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
UpdatedAt time.Time `json:"updated_at"`
UpdatedBy string `json:"updated_by,omitempty"`
}
type Grant struct {
GroupID string `json:"group_id"`
DatabaseID string `json:"database_id"`
Perm Perm `json:"perm"`
}
type Invite struct {
Token string `json:"token"`
GroupID string `json:"group_id,omitempty"`
Role Role `json:"role"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
MaxUses int `json:"max_uses"`
Uses int `json:"uses"`
}
// Usable reports whether the invite may still be redeemed at time now.
func (i *Invite) Usable(now time.Time) bool {
return now.Before(i.ExpiresAt) && (i.MaxUses <= 0 || i.Uses < i.MaxUses)
}
type Session struct {
Token string `json:"token"`
UserID string `json:"user_id"`
Label string `json:"label,omitempty"`
// CSRF guards the browser forms of a session held in a cookie.
CSRF string `json:"csrf,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
// DeviceAuth backs the browser-assisted login used for OIDC on desktop and
// Android: the client shows a short user code, the browser approves it, the
// client polls until a session token appears.
type DeviceAuth struct {
DeviceCode string `json:"device_code"`
// UserCode is the short code a person reads out to approve a client.
// A browser signing in to the web interface has none: there is no
// second device to approve.
UserCode string `json:"user_code"`
// Web marks a sign-in that ends in a cookie rather than in a token a
// waiting client polls for.
Web bool `json:"web,omitempty"`
Label string `json:"label,omitempty"`
SessionToken string `json:"session_token,omitempty"`
OIDCState string `json:"oidc_state,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
// State is the whole metadata set, serialized as one JSON document.
type State struct {
Users map[string]*User `json:"users"`
Groups map[string]*Group `json:"groups"`
Databases map[string]*Database `json:"databases"`
Invites map[string]*Invite `json:"invites"`
Sessions map[string]*Session `json:"sessions"`
Devices map[string]*DeviceAuth `json:"devices"`
Memberships []Membership `json:"memberships"`
Grants []Grant `json:"grants"`
}
func newState() *State {
return &State{
Users: map[string]*User{},
Groups: map[string]*Group{},
Databases: map[string]*Database{},
Invites: map[string]*Invite{},
Sessions: map[string]*Session{},
Devices: map[string]*DeviceAuth{},
}
}
func (s *State) init() {
if s.Users == nil {
s.Users = map[string]*User{}
}
if s.Groups == nil {
s.Groups = map[string]*Group{}
}
if s.Databases == nil {
s.Databases = map[string]*Database{}
}
if s.Invites == nil {
s.Invites = map[string]*Invite{}
}
if s.Sessions == nil {
s.Sessions = map[string]*Session{}
}
if s.Devices == nil {
s.Devices = map[string]*DeviceAuth{}
}
}
// UserByName finds a user by exact username.
func (s *State) UserByName(name string) *User {
for _, u := range s.Users {
if u.Username == name {
return u
}
}
return nil
}
// UserByOIDC finds a user previously linked to an OIDC issuer and subject.
func (s *State) UserByOIDC(issuer, subject string) *User {
for _, u := range s.Users {
if u.OIDCIssuer == issuer && u.OIDCSubject == subject {
return u
}
}
return nil
}
// DeviceByUserCode finds a pending device login by the code shown to the
// user. An empty code matches nothing, so a web sign-in, which has no user
// code, is never mistaken for a device waiting to be approved.
func (s *State) DeviceByUserCode(code string) *DeviceAuth {
if code == "" {
return nil
}
for _, d := range s.Devices {
if d.UserCode == code {
return d
}
}
return nil
}