Storage. The server keeps its data in SQLite (a file in the data directory, the default) or PostgreSQL (database, KEEPORG_DATABASE_URL or -database). Every row, the KDBX versions included, is sealed with AES-256-GCM under a key derived from the master key and named by an HMAC. The master key is required to start and a different one is refused. A data directory from 0.10 or earlier is imported on first start. Several servers may share one PostgreSQL database. Go goes to 1.26 for the pure-Go drivers (modernc.org/sqlite, jackc/pgx). Hardening, after an audit of the server, both clients and the deployment: - Single sign-on approves a device only after an explicit Approve in the browser that started it, bound by a cookie and PKCE; identities are never linked by username, by verified email only when allowed, never to admins. - Sign-in attempts are limited per address and per account, passwords are checked outside the store lock with uniform timing, one-time codes work once, and starting two-factor needs the password. - Merge vaults bound KDF parameters, payload size and XML shape, check pushes before storing them, and run at most two merges at once. A version is written in the same transaction as its metadata. - Tokens in URLs only on the event stream, which ends on revocation; CSP, frame-ancestors, no-store and HSTS; sign-in CSRF; 404 for databases without access; new trusted_proxies. - Clients open only the server's approval page, keep case-variant and retired file names apart, never overwrite a save made during a pull, require https except locally, send the stream token in a header, bound their reads and escape notifications and terminal output. - The .deb creates the master key, systemd is sandboxed further, nginx sets modern TLS and HSTS, and CI deploys from main only. Server 0.12.0, desktop 0.6.0, Android 0.4.0 (versionCode 4). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
482 lines
16 KiB
Go
482 lines
16 KiB
Go
package server
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"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"`
|
|
// LinkVerifiedEmail lets an identity claim an existing, unlinked local
|
|
// account whose email it shares, when the provider says the email is
|
|
// verified. Off by default: whoever controls an identity provider's
|
|
// email field then controls those accounts. Accounts can always be
|
|
// linked explicitly from their Account page.
|
|
LinkVerifiedEmail bool `json:"link_verified_email"`
|
|
}
|
|
|
|
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"`
|
|
EmailVerified flexBool `json:"email_verified"`
|
|
Username string `json:"preferred_username"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// flexBool reads a JSON boolean that some providers send as a string.
|
|
type flexBool bool
|
|
|
|
func (b *flexBool) UnmarshalJSON(data []byte) error {
|
|
v := strings.Trim(string(data), `"`)
|
|
*b = flexBool(v == "true")
|
|
return nil
|
|
}
|
|
|
|
// exchange turns an authorization code into the caller's identity.
|
|
func (p *oidcProvider) exchange(code, verifier 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},
|
|
// PKCE: only the party that started the flow can spend the code.
|
|
"code_verifier": {verifier},
|
|
}
|
|
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
|
|
}
|
|
|
|
// oidcCookie binds a single sign-on round trip to the browser that started
|
|
// it. The state in the URL alone is not enough: a callback link forwarded to
|
|
// someone else, or a flow started by an attacker's page in a victim's
|
|
// browser, would otherwise finish in the wrong hands. Lax, because the
|
|
// provider's redirect back is a cross-site navigation.
|
|
const oidcCookie = "keeporg_oidc"
|
|
|
|
func (s *Server) setOIDCCookie(w http.ResponseWriter, value string, ttl time.Duration) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: oidcCookie, Value: value, Path: "/oidc", HttpOnly: true,
|
|
Secure: strings.HasPrefix(s.cfg.ExternalURL, "https://"),
|
|
SameSite: http.SameSiteLaxMode, MaxAge: int(ttl.Seconds()),
|
|
})
|
|
}
|
|
|
|
func oidcCookieMatches(r *http.Request, want string) bool {
|
|
c, err := r.Cookie(oidcCookie)
|
|
return err == nil && want != "" && secret.Equal(c.Value, want)
|
|
}
|
|
|
|
// pkceChallenge is the S256 challenge for a verifier.
|
|
func pkceChallenge(verifier string) string {
|
|
sum := sha256.Sum256([]byte(verifier))
|
|
return base64.RawURLEncoding.EncodeToString(sum[:])
|
|
}
|
|
|
|
// handleOIDCLogin starts the authorization code flow. It has three uses:
|
|
// with ?code= it identifies the person approving a waiting client; with
|
|
// ?link=1 it links an identity to the account signed in here; without
|
|
// either it signs a browser in to the web interface.
|
|
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
|
|
}
|
|
if wait := s.limits.take("start:" + s.clientIP(r)); wait > 0 {
|
|
s.renderDone(w, "Too many sign-in attempts", (&errThrottled{RetryAfter: wait}).Error())
|
|
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
|
|
}
|
|
q := r.URL.Query()
|
|
userCode := strings.ToUpper(strings.TrimSpace(q.Get("code")))
|
|
var linkUser string
|
|
if q.Get("link") == "1" {
|
|
// Linking changes how an account signs in, so it needs that
|
|
// account's session and its CSRF token: a page elsewhere must not
|
|
// be able to link the attacker's identity to the victim's account.
|
|
sess := s.webUser(r)
|
|
if sess == nil || !secret.Equal(sess.csrf, q.Get("csrf")) {
|
|
s.renderDone(w, "Single sign-on not linked", "Start again from your Account page.")
|
|
return
|
|
}
|
|
linkUser = sess.user.ID
|
|
}
|
|
state, verifier := secret.Token(), secret.Token()
|
|
if err := s.st.Update(func(st *store.State) error {
|
|
if userCode == "" {
|
|
if !pruneDevices(st) {
|
|
return errBusy
|
|
}
|
|
st.Devices[state] = &store.DeviceAuth{
|
|
DeviceCode: state, Web: linkUser == "", LinkUserID: linkUser, Label: "web",
|
|
OIDCState: state, PKCEVerifier: verifier,
|
|
CreatedAt: time.Now(), ExpiresAt: time.Now().Add(deviceTTL),
|
|
}
|
|
return nil
|
|
}
|
|
d := st.DeviceByUserCode(userCode)
|
|
if d == nil || time.Now().After(d.ExpiresAt) || d.SessionToken != "" {
|
|
return errors.New("unknown or expired device code")
|
|
}
|
|
d.OIDCState, d.PKCEVerifier = state, verifier
|
|
return nil
|
|
}); err != nil {
|
|
s.renderDevice(w, userCode, err.Error())
|
|
return
|
|
}
|
|
s.setOIDCCookie(w, state, deviceTTL)
|
|
v := url.Values{
|
|
"response_type": {"code"},
|
|
"client_id": {s.oidc.cfg.ClientID},
|
|
"redirect_uri": {s.oidc.redirectURI},
|
|
"scope": {s.oidc.cfg.Scopes},
|
|
"state": {state},
|
|
"code_challenge": {pkceChallenge(verifier)},
|
|
"code_challenge_method": {"S256"},
|
|
}
|
|
http.Redirect(w, r, meta.AuthorizationEndpoint+"?"+v.Encode(), http.StatusFound)
|
|
}
|
|
|
|
// handleOIDCCallback finishes the flow. A browser sign-in ends signed in; a
|
|
// link ends linked; a waiting client is not approved here but shown to the
|
|
// person for an explicit decision, because arriving at this URL proves only
|
|
// that a browser with a provider session was sent here, not that its owner
|
|
// meant to let a device in.
|
|
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 q.Get("error") != "" {
|
|
s.renderDone(w, "Sign-in failed", "The identity provider refused the sign-in.")
|
|
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
|
|
}
|
|
if !oidcCookieMatches(r, state) {
|
|
s.renderDone(w, "Sign-in failed",
|
|
"This sign-in was not started in this browser. Start again from the same browser.")
|
|
return
|
|
}
|
|
s.setOIDCCookie(w, "", -time.Second)
|
|
var verifier string
|
|
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) {
|
|
verifier = d.PKCEVerifier
|
|
}
|
|
}
|
|
})
|
|
if verifier == "" {
|
|
s.renderDone(w, "Sign-in failed", "This sign-in request has expired. Start again from the client.")
|
|
return
|
|
}
|
|
id, err := s.oidc.exchange(code, verifier)
|
|
if err != nil {
|
|
s.log.Printf("oidc exchange: %v", err)
|
|
s.renderDone(w, "Sign-in failed", "The identity provider rejected the sign-in.")
|
|
return
|
|
}
|
|
var (
|
|
webToken, linked string
|
|
confirm *webDeviceConfirm
|
|
)
|
|
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")
|
|
}
|
|
dev.OIDCState, dev.PKCEVerifier = "", ""
|
|
if dev.LinkUserID != "" {
|
|
delete(st.Devices, dev.DeviceCode)
|
|
return s.linkIdentity(st, dev.LinkUserID, id, &linked)
|
|
}
|
|
u, err := s.linkOIDCUser(st, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if dev.Web {
|
|
// Nothing is waiting to poll for this one; the browser in front
|
|
// of us is the client.
|
|
webToken = s.newBrowserSession(st, u.ID, r).Token
|
|
delete(st.Devices, dev.DeviceCode)
|
|
return nil
|
|
}
|
|
dev.PendingUserID, dev.ApprovalNonce = u.ID, secret.Token()
|
|
confirm = &webDeviceConfirm{Nonce: dev.ApprovalNonce, User: u.Username, Label: dev.Label,
|
|
Code: dev.UserCode, Started: ago(dev.CreatedAt)}
|
|
return nil
|
|
}); err != nil {
|
|
s.renderDone(w, "Sign-in failed", humanError(err))
|
|
return
|
|
}
|
|
switch {
|
|
case webToken != "":
|
|
s.setWebCookie(w, webToken, s.cfg.SessionTTL)
|
|
http.Redirect(w, r, "/web/vaults", http.StatusFound)
|
|
case linked != "":
|
|
http.Redirect(w, r, "/web/account", http.StatusFound)
|
|
default:
|
|
s.setOIDCCookie(w, confirm.Nonce, deviceTTL)
|
|
render(w, deviceConfirmTmpl, map[string]any{"Title": "Approve a device", "D": confirm})
|
|
}
|
|
}
|
|
|
|
// webDeviceConfirm is the question put to someone who signed in through
|
|
// their provider to approve a device.
|
|
type webDeviceConfirm struct {
|
|
Nonce, User, Label, Code, Started string
|
|
}
|
|
|
|
// handleOIDCApprove is the person's answer. It needs the nonce from the
|
|
// confirmation page and the same nonce in this browser's cookie, so it can
|
|
// only come from the page the callback showed in this browser.
|
|
func (s *Server) handleOIDCApprove(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
fail(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
nonce := r.FormValue("nonce")
|
|
if !oidcCookieMatches(r, nonce) {
|
|
s.renderDone(w, "Not approved", "This approval was not started in this browser.")
|
|
return
|
|
}
|
|
s.setOIDCCookie(w, "", -time.Second)
|
|
approve := r.FormValue("decision") == "approve"
|
|
var returnURL string
|
|
err := s.st.Update(func(st *store.State) error {
|
|
for code, d := range st.Devices {
|
|
if d.ApprovalNonce == "" || !secret.Equal(d.ApprovalNonce, nonce) {
|
|
continue
|
|
}
|
|
if time.Now().After(d.ExpiresAt) || d.SessionToken != "" {
|
|
return errors.New("this sign-in request has expired")
|
|
}
|
|
if !approve {
|
|
delete(st.Devices, code)
|
|
return nil
|
|
}
|
|
u := st.Users[d.PendingUserID]
|
|
if u == nil || u.Disabled {
|
|
return errors.New("this account cannot sign in")
|
|
}
|
|
d.ApprovalNonce = ""
|
|
d.SessionToken = s.newSession(st, u.ID, d.Label).Token
|
|
returnURL = d.ReturnURL
|
|
return nil
|
|
}
|
|
return errors.New("this sign-in request has expired")
|
|
})
|
|
switch {
|
|
case err != nil:
|
|
s.renderDone(w, "Not approved", humanError(err))
|
|
case !approve:
|
|
s.renderDone(w, "Device refused", "The device was not signed in. If you did not start this, someone tried to.")
|
|
default:
|
|
s.renderApproved(w, returnURL)
|
|
}
|
|
}
|
|
|
|
// linkIdentity links an identity to an account its owner is signed in to.
|
|
func (s *Server) linkIdentity(st *store.State, userID string, id *oidcIdentity, linked *string) error {
|
|
u := st.Users[userID]
|
|
if u == nil || u.Disabled {
|
|
return errors.New("this account cannot be linked")
|
|
}
|
|
issuer := strings.TrimRight(s.oidc.cfg.Issuer, "/")
|
|
if other := st.UserByOIDC(issuer, id.Subject); other != nil && other.ID != u.ID {
|
|
return errors.New("this identity is already linked to another account")
|
|
}
|
|
u.OIDCIssuer, u.OIDCSubject = issuer, id.Subject
|
|
*linked = u.Username
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
// Never by username: anyone who can choose their preferred_username at
|
|
// the provider could otherwise claim "admin". By email only when the
|
|
// deployment allows it and the provider vouches for the address, and
|
|
// never for a server administrator, whose account is linked from its
|
|
// own Account page or not at all.
|
|
if s.oidc.cfg.LinkVerifiedEmail && bool(id.EmailVerified) && id.Email != "" {
|
|
for _, u := range st.Users {
|
|
if u.OIDCSubject != "" || u.ServerAdmin || u.Disabled {
|
|
continue
|
|
}
|
|
if 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; sign in with your password and link single sign-on from your Account page")
|
|
}
|
|
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
|
|
}
|