Add KeepOrg sync server: accounts, nested groups, versioned KDBX sync
Why: collaborative KeePass use needs one arbiter of "who wrote last" that never sees a master password, plus access control that follows an org chart. What: stdlib-only Go server. Metadata is a single JSON document with one writer and atomic commit; KDBX payloads are versioned blobs on disk. Access comes from a group tree where a grant on a group reaches every member below it. Login is password+TOTP, invite links, or OIDC through a browser-approved device flow. Pushes carry If-Match and are arbitrated by a per-database merge strategy (server-wins by default). Updates are pushed to clients over SSE. Proved: go test ./internal/... covers RFC 6238 vectors, ACL inheritance and isolation, store rollback, blob pruning, every push verdict, and an end-to-end HTTP pass over login, conflict, force, read-only grants, invite redemption, TOTP enrolment, device login and the event stream.
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
)
|
||||
|
||||
// OIDCConfig configures single sign-on. The server acts as a confidential
|
||||
// client: it exchanges the authorization code over TLS directly with the
|
||||
// provider's token endpoint and then reads the identity from the userinfo
|
||||
// endpoint, so no local JWT verification is needed.
|
||||
type OIDCConfig struct {
|
||||
Issuer string `json:"issuer"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
Scopes string `json:"scopes"`
|
||||
// AutoCreate provisions an account on first successful sign-in.
|
||||
AutoCreate bool `json:"auto_create"`
|
||||
// DefaultGroupID is the group auto-created users join.
|
||||
DefaultGroupID string `json:"default_group_id"`
|
||||
}
|
||||
|
||||
func (c OIDCConfig) Enabled() bool {
|
||||
return c.Issuer != "" && c.ClientID != "" && c.ClientSecret != ""
|
||||
}
|
||||
|
||||
type oidcMetadata struct {
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserinfoEndpoint string `json:"userinfo_endpoint"`
|
||||
}
|
||||
|
||||
type oidcProvider struct {
|
||||
cfg OIDCConfig
|
||||
redirectURI string
|
||||
http *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
meta *oidcMetadata
|
||||
}
|
||||
|
||||
func newOIDCProvider(cfg OIDCConfig, redirectURI string) *oidcProvider {
|
||||
if cfg.Scopes == "" {
|
||||
cfg.Scopes = "openid profile email"
|
||||
}
|
||||
return &oidcProvider{cfg: cfg, redirectURI: redirectURI,
|
||||
http: &http.Client{Timeout: 15 * time.Second}}
|
||||
}
|
||||
|
||||
// metadata fetches and caches the provider's discovery document.
|
||||
func (p *oidcProvider) metadata() (*oidcMetadata, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.meta != nil {
|
||||
return p.meta, nil
|
||||
}
|
||||
u := strings.TrimRight(p.cfg.Issuer, "/") + "/.well-known/openid-configuration"
|
||||
resp, err := p.http.Get(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("oidc: discovery %s: %s", u, resp.Status)
|
||||
}
|
||||
var m oidcMetadata
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.AuthorizationEndpoint == "" || m.TokenEndpoint == "" || m.UserinfoEndpoint == "" {
|
||||
return nil, errors.New("oidc: discovery document is missing endpoints")
|
||||
}
|
||||
p.meta = &m
|
||||
return p.meta, nil
|
||||
}
|
||||
|
||||
type oidcIdentity struct {
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"preferred_username"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// exchange turns an authorization code into the caller's identity.
|
||||
func (p *oidcProvider) exchange(code string) (*oidcIdentity, error) {
|
||||
meta, err := p.metadata()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
form := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"redirect_uri": {p.redirectURI},
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, meta.TokenEndpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(url.QueryEscape(p.cfg.ClientID), url.QueryEscape(p.cfg.ClientSecret))
|
||||
resp, err := p.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<12))
|
||||
return nil, fmt.Errorf("oidc: token endpoint: %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var tok struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&tok); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tok.AccessToken == "" {
|
||||
return nil, errors.New("oidc: token response carried no access token")
|
||||
}
|
||||
return p.userinfo(meta.UserinfoEndpoint, tok.AccessToken)
|
||||
}
|
||||
|
||||
func (p *oidcProvider) userinfo(endpoint, accessToken string) (*oidcIdentity, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
resp, err := p.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("oidc: userinfo: %s", resp.Status)
|
||||
}
|
||||
var id oidcIdentity
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id.Subject == "" {
|
||||
return nil, errors.New("oidc: userinfo carried no subject")
|
||||
}
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
// handleOIDCLogin starts the authorization code flow for a pending device.
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
userCode := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("code")))
|
||||
state := secret.Token()
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
d := st.DeviceByUserCode(userCode)
|
||||
if d == nil || time.Now().After(d.ExpiresAt) {
|
||||
return errors.New("unknown or expired device code")
|
||||
}
|
||||
d.OIDCState = state
|
||||
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},
|
||||
}
|
||||
http.Redirect(w, r, meta.AuthorizationEndpoint+"?"+q.Encode(), http.StatusFound)
|
||||
}
|
||||
|
||||
// handleOIDCCallback finishes the flow and approves the waiting device.
|
||||
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)
|
||||
return
|
||||
}
|
||||
state, code := q.Get("state"), q.Get("code")
|
||||
if state == "" || code == "" {
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
if !found {
|
||||
s.renderDone(w, "Sign-in failed", "This sign-in request has expired. Start again from the client.")
|
||||
return
|
||||
}
|
||||
id, err := s.oidc.exchange(code)
|
||||
if err != nil {
|
||||
s.log.Printf("oidc exchange: %v", err)
|
||||
s.renderDone(w, "Sign-in failed", "The identity provider rejected the sign-in.")
|
||||
return
|
||||
}
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
var dev *store.DeviceAuth
|
||||
for _, d := range st.Devices {
|
||||
if d.OIDCState != "" && secret.Equal(d.OIDCState, state) {
|
||||
dev = d
|
||||
}
|
||||
}
|
||||
if dev == nil || time.Now().After(dev.ExpiresAt) {
|
||||
return errors.New("this sign-in request has expired")
|
||||
}
|
||||
u, err := s.linkOIDCUser(st, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dev.OIDCState = ""
|
||||
dev.SessionToken = s.newSession(st, u.ID, dev.Label).Token
|
||||
return nil
|
||||
}); err != nil {
|
||||
s.renderDone(w, "Sign-in failed", err.Error())
|
||||
return
|
||||
}
|
||||
s.renderDone(w, "Device approved", "You can close this page and return to the application.")
|
||||
}
|
||||
|
||||
// linkOIDCUser finds the account behind an identity, provisioning one when
|
||||
// the deployment allows it.
|
||||
func (s *Server) linkOIDCUser(st *store.State, id *oidcIdentity) (*store.User, error) {
|
||||
issuer := strings.TrimRight(s.oidc.cfg.Issuer, "/")
|
||||
if u := st.UserByOIDC(issuer, id.Subject); u != nil {
|
||||
if u.Disabled {
|
||||
return nil, errors.New("this account is disabled")
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
if !s.oidc.cfg.AutoCreate {
|
||||
return nil, errors.New("no account is linked to this identity; redeem an invite link first")
|
||||
}
|
||||
name := id.Username
|
||||
if name == "" {
|
||||
name = id.Email
|
||||
}
|
||||
if name == "" {
|
||||
name = "sso-" + secret.ID()
|
||||
}
|
||||
if validUsername(name) != nil || st.UserByName(name) != nil {
|
||||
name = "sso-" + secret.ID()
|
||||
}
|
||||
u := &store.User{ID: secret.ID(), Username: name, Email: id.Email,
|
||||
OIDCIssuer: issuer, OIDCSubject: id.Subject, CreatedAt: time.Now()}
|
||||
st.Users[u.ID] = u
|
||||
if g := s.oidc.cfg.DefaultGroupID; g != "" && st.Groups[g] != nil {
|
||||
st.Memberships = append(st.Memberships, store.Membership{UserID: u.ID, GroupID: g, Role: store.RoleMember})
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
Reference in New Issue
Block a user