Files
keeporg/internal/client/admin.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

194 lines
6.7 KiB
Go

package client
import (
"context"
"net/http"
"net/url"
"time"
)
// Group is a node of the server's group tree as the client sees it.
type Group struct {
ID string `json:"id"`
Name string `json:"name"`
ParentID string `json:"parent_id"`
Admin bool `json:"admin"`
Members int `json:"members"`
}
// Invite is a registration link.
type Invite struct {
Token string `json:"token"`
URL string `json:"url"`
GroupID string `json:"group_id"`
Role string `json:"role"`
Uses int `json:"uses"`
MaxUses int `json:"max_uses"`
ExpiresAt time.Time `json:"expires_at"`
}
// User is an account as an administrator sees it.
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
ServerAdmin bool `json:"server_admin"`
TOTPEnabled bool `json:"totp_enabled"`
}
// Identity is the answer to "who am I and where do I belong".
type Identity struct {
User
Groups []struct {
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
} `json:"groups"`
}
func (a *API) Whoami(ctx context.Context) (*Identity, error) {
var out Identity
return &out, a.do(ctx, http.MethodGet, "/api/v1/auth/whoami", nil, &out)
}
func (a *API) Groups(ctx context.Context) ([]Group, error) {
var out struct {
Groups []Group `json:"groups"`
}
err := a.do(ctx, http.MethodGet, "/api/v1/groups", nil, &out)
return out.Groups, err
}
func (a *API) CreateGroup(ctx context.Context, name, parentID string) (*Group, error) {
var out Group
return &out, a.do(ctx, http.MethodPost, "/api/v1/groups",
map[string]string{"name": name, "parent_id": parentID}, &out)
}
func (a *API) DeleteGroup(ctx context.Context, id string) error {
return a.do(ctx, http.MethodDelete, "/api/v1/groups/"+url.PathEscape(id), nil, nil)
}
func (a *API) SetMember(ctx context.Context, groupID, username, role string) error {
return a.do(ctx, http.MethodPost,
"/api/v1/groups/"+url.PathEscape(groupID)+"/members",
map[string]string{"username": username, "role": role}, nil)
}
func (a *API) RemoveMember(ctx context.Context, groupID, user string) error {
return a.do(ctx, http.MethodDelete,
"/api/v1/groups/"+url.PathEscape(groupID)+"/members/"+url.PathEscape(user), nil, nil)
}
func (a *API) CreateDatabase(ctx context.Context, name, groupID, strategy string) (*Database, error) {
body := map[string]string{"name": name, "owner_group_id": groupID}
if strategy != "" {
body["merge_strategy"] = strategy
}
var out Database
return &out, a.do(ctx, http.MethodPost, "/api/v1/databases", body, &out)
}
// SetStrategy changes the merge strategy of a database. Only an
// administrator of the owning group may do this.
func (a *API) SetStrategy(ctx context.Context, dbID, strategy string) (*Database, error) {
var out Database
return &out, a.do(ctx, http.MethodPatch, "/api/v1/databases/"+url.PathEscape(dbID),
map[string]string{"merge_strategy": strategy}, &out)
}
func (a *API) RenameDatabase(ctx context.Context, dbID, name string) (*Database, error) {
var out Database
return &out, a.do(ctx, http.MethodPatch, "/api/v1/databases/"+url.PathEscape(dbID),
map[string]string{"name": name}, &out)
}
func (a *API) DeleteDatabase(ctx context.Context, dbID string) error {
return a.do(ctx, http.MethodDelete, "/api/v1/databases/"+url.PathEscape(dbID), nil, nil)
}
func (a *API) SetGrant(ctx context.Context, groupID, dbID, perm string) error {
return a.do(ctx, http.MethodPost, "/api/v1/grants",
map[string]string{"group_id": groupID, "database_id": dbID, "perm": perm}, nil)
}
func (a *API) DeleteGrant(ctx context.Context, groupID, dbID string) error {
q := url.Values{"group_id": {groupID}, "database_id": {dbID}}
return a.do(ctx, http.MethodDelete, "/api/v1/grants?"+q.Encode(), nil, nil)
}
func (a *API) CreateInvite(ctx context.Context, groupID, role string, ttlHours, maxUses int) (*Invite, error) {
var out Invite
return &out, a.do(ctx, http.MethodPost, "/api/v1/invites", map[string]any{
"group_id": groupID, "role": role, "ttl_hours": ttlHours, "max_uses": maxUses}, &out)
}
func (a *API) Invites(ctx context.Context) ([]Invite, error) {
var out struct {
Invites []Invite `json:"invites"`
}
err := a.do(ctx, http.MethodGet, "/api/v1/invites", nil, &out)
return out.Invites, err
}
func (a *API) Users(ctx context.Context) ([]User, error) {
var out struct {
Users []User `json:"users"`
}
err := a.do(ctx, http.MethodGet, "/api/v1/users", nil, &out)
return out.Users, err
}
// ChangePassword replaces the account password. The server requires the
// current one, and the second factor when one is enrolled, then signs out
// this account's other sessions.
func (a *API) ChangePassword(ctx context.Context, current, next, totp string) error {
return a.do(ctx, http.MethodPost, "/api/v1/auth/password", map[string]string{
"current_password": current, "new_password": next, "totp": totp}, nil)
}
// EnrollTOTP starts second-factor enrolment and returns the shared secret
// and its otpauth URI.
func (a *API) EnrollTOTP(ctx context.Context) (secret, uri string, err error) {
var out struct {
Secret string `json:"secret"`
URI string `json:"uri"`
}
err = a.do(ctx, http.MethodPost, "/api/v1/auth/totp/enroll", nil, &out)
return out.Secret, out.URI, err
}
// ConfirmTOTP completes enrolment by proving the secret was stored.
func (a *API) ConfirmTOTP(ctx context.Context, code string) error {
return a.do(ctx, http.MethodPost, "/api/v1/auth/totp/confirm",
map[string]string{"code": code}, nil)
}
// CreateVault asks the server to create a new, empty KDBX vault encrypted
// with this password. For the merge strategy the server keeps the password,
// wrapped under its master key, because merging means opening the file.
func (a *API) CreateVault(ctx context.Context, name, groupID, strategy, password string) (*Database, error) {
var out Database
return &out, a.do(ctx, http.MethodPost, "/api/v1/vaults", map[string]string{
"name": name, "owner_group_id": groupID,
"merge_strategy": strategy, "password": password,
}, &out)
}
// StoreVaultKey gives the server the password of an existing database, so it
// can merge it. The server verifies the password against the stored file
// before accepting it.
func (a *API) StoreVaultKey(ctx context.Context, dbID, password string) error {
return a.do(ctx, http.MethodPost,
"/api/v1/databases/"+url.PathEscape(dbID)+"/key",
map[string]any{"password": password}, nil)
}
// ForgetVaultKey removes the stored password, which also ends any ability to
// merge that database here.
func (a *API) ForgetVaultKey(ctx context.Context, dbID string) error {
return a.do(ctx, http.MethodPost,
"/api/v1/databases/"+url.PathEscape(dbID)+"/key",
map[string]any{"forget": true}, nil)
}