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.
194 lines
6.7 KiB
Go
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)
|
|
}
|