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.
250 lines
7.9 KiB
Go
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
|
|
}
|