/web/account, offered to everyone, lists every browser and client signed in as you, with when each was last used, and signs any of them out, or all but the current one. It sets up, replaces and removes the second factor, and holds the password form, moved from the vault page. Administrators can sign a user out everywhere from the People page. The API gains GET and DELETE /api/v1/auth/sessions and DELETE /api/v1/auth/totp. Sessions are named by a digest of their token, never the token, and record when they were last used. Replacing or removing an active second factor now needs the password and a current code. Server version 0.10.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
317 lines
9.4 KiB
Go
317 lines
9.4 KiB
Go
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
|
|
}
|
|
// With a user code this approves a waiting client; without one it signs
|
|
// a browser in to the web interface.
|
|
userCode := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("code")))
|
|
state := secret.Token()
|
|
if err := s.st.Update(func(st *store.State) error {
|
|
if userCode == "" {
|
|
st.Devices[state] = &store.DeviceAuth{
|
|
DeviceCode: state, Web: true, Label: "web", OIDCState: state,
|
|
CreatedAt: time.Now(), ExpiresAt: time.Now().Add(deviceTTL),
|
|
}
|
|
return nil
|
|
}
|
|
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
|
|
}
|
|
var webToken, returnURL string
|
|
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 = ""
|
|
if dev.Web {
|
|
token := s.newBrowserSession(st, u.ID, r).Token
|
|
// Nothing is waiting to poll for this one; the browser in front
|
|
// of us is the client.
|
|
webToken = token
|
|
delete(st.Devices, dev.DeviceCode)
|
|
return nil
|
|
}
|
|
dev.SessionToken = s.newSession(st, u.ID, dev.Label).Token
|
|
returnURL = dev.ReturnURL
|
|
return nil
|
|
}); err != nil {
|
|
s.renderDone(w, "Sign-in failed", err.Error())
|
|
return
|
|
}
|
|
if webToken != "" {
|
|
s.setWebCookie(w, webToken, s.cfg.SessionTTL)
|
|
http.Redirect(w, r, "/web/vaults", http.StatusFound)
|
|
return
|
|
}
|
|
s.renderApproved(w, returnURL)
|
|
}
|
|
|
|
// 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
|
|
}
|