5b4683febf
Team membership from single sign-on used to come from one env var,
TERDUT_OIDC_GROUP_MAPPINGS, matched against a team by name and creating
the team if none existed. That put the decision in the server's
environment rather than the team's own hands, needed a restart to
change, and let a typo in a team name silently create a stray team.
Each team now carries its own oidc_member_group and oidc_owner_group,
set by its owner (or an administrator) from the Members tab, or PUT
/api/teams/{teamID}/oidc-groups. The "highest role wins" rule
TERDUT_OIDC_GROUP_MAPPINGS used to apply across mappings now applies
across one team's own two fields: being in both makes somebody an
owner. The sync no longer creates a team by name; a group only ever
grants into a team that already exists.
This is a breaking change for anyone already using
TERDUT_OIDC_GROUP_MAPPINGS, deliberately not auto-migrated: an
OIDC-sourced membership is dropped at a user's next sign-in until its
team's owner re-sets the group. The README's OIDC section spells out
the migration and the risk of a visible access gap during it.
TERDUT_OIDC_ADMIN_GROUP and TERDUT_OIDC_ALLOWED_GROUPS are untouched --
only team membership moved. terdut-tui needs no change: it only reads
GET /api/teams and GET /api/teams/{id}/members, and neither response
shape moved.
485 lines
17 KiB
Go
485 lines
17 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
|
"git.ryuvia.com/niklas/terdut-server/internal/oidc"
|
|
)
|
|
|
|
const (
|
|
// oidcStateCookie ties an in-flight login to the browser that started it.
|
|
// Without it anybody could start a login, and send the callback URL that
|
|
// results to somebody else, who would be signed in as the attacker.
|
|
oidcStateCookie = "terdut_oidc_state"
|
|
|
|
// oidcLoginTTL is how long a login may take between the redirect to the
|
|
// provider and the callback, which includes the person typing a password
|
|
// and a second factor.
|
|
oidcLoginTTL = 10 * time.Minute
|
|
|
|
// oidcStartMaxPerAddr bounds unauthenticated logins started per address.
|
|
// Each writes a row, so an unbounded endpoint is a way to grow the table.
|
|
oidcStartMaxPerAddr = 30
|
|
)
|
|
|
|
// ssoError is a sign-in refusal the person can be told about. Its value is the
|
|
// code the web UI is sent back with, as ?sso_error=<code>; the detail stays in
|
|
// the server log, since it can name accounts.
|
|
type ssoError string
|
|
|
|
func (e ssoError) Error() string { return "sso: " + string(e) }
|
|
|
|
const (
|
|
ssoDenied ssoError = "denied" // the provider reported an error, or the person declined
|
|
ssoExpired ssoError = "expired" // unknown, used or expired state; start again
|
|
ssoFailed ssoError = "failed" // the token exchange or its verification failed
|
|
ssoUnavailable ssoError = "unavailable" // the provider could not be reached
|
|
ssoNotAllowed ssoError = "not_allowed" // authenticated, but in none of the allowed groups
|
|
ssoNoEmail ssoError = "no_email" // the provider sent no email address
|
|
ssoEmailConflict ssoError = "email_conflict" // a local account has this email and cannot be linked
|
|
ssoDisabled ssoError = "disabled" // the linked account is disabled
|
|
)
|
|
|
|
// handleAuthConfig says how this server can be signed in to, so the login form
|
|
// and the TUI can offer the right choices before anybody types anything. It is
|
|
// unauthenticated by necessity, and reveals nothing beyond what the login page
|
|
// shows anyway.
|
|
func handleAuthConfig(cfg config.Config) http.HandlerFunc {
|
|
type oidcInfo struct {
|
|
Enabled bool `json:"enabled"`
|
|
Name string `json:"name,omitempty"`
|
|
}
|
|
type response struct {
|
|
PasswordLogin bool `json:"password_login"`
|
|
OIDC oidcInfo `json:"oidc"`
|
|
|
|
// DeviceLogin is whether a client that cannot open a browser (the TUI)
|
|
// can sign in by showing a code, through /api/oidc/device.
|
|
DeviceLogin bool `json:"device_login"`
|
|
}
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
resp := response{PasswordLogin: !cfg.DisablePasswordLogin}
|
|
if cfg.OIDC.Enabled() {
|
|
resp.OIDC = oidcInfo{Enabled: true, Name: cfg.OIDC.Name}
|
|
resp.DeviceLogin = true
|
|
}
|
|
respond(w, http.StatusOK, resp)
|
|
}
|
|
}
|
|
|
|
// passwordLoginOnly refuses a route when password login is switched off.
|
|
func passwordLoginOnly(enabled bool) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
if enabled {
|
|
return next
|
|
}
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
respond(w, http.StatusForbidden, errResp("password login is disabled on this server"))
|
|
})
|
|
}
|
|
}
|
|
|
|
// ssoRedirect sends the browser back to the web UI with the reason a sign-in
|
|
// failed. It is a redirect and not a JSON error because the browser arrived
|
|
// here by navigating from the provider: there is no page script to read one.
|
|
func ssoRedirect(w http.ResponseWriter, r *http.Request, code ssoError) {
|
|
http.Redirect(w, r, "/?sso_error="+url.QueryEscape(string(code)), http.StatusFound)
|
|
}
|
|
|
|
// handleOIDCLogin starts a sign-in: it records the state, nonce and PKCE
|
|
// verifier the callback will need and sends the browser to the provider.
|
|
func handleOIDCLogin(db *sql.DB, prov *oidc.Provider, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
addrKey := "oidc:" + clientAddr(r)
|
|
if limiter.blocked(addrKey, oidcStartMaxPerAddr) {
|
|
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
|
|
respond(w, http.StatusTooManyRequests, errResp("too many sign-in attempts, try again later"))
|
|
return
|
|
}
|
|
limiter.fail(addrKey)
|
|
|
|
state, stateHash, err := randomToken()
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
nonce, _, err := randomToken()
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
verifier := oidc.NewVerifier()
|
|
next := safeNext(r.URL.Query().Get("next"))
|
|
|
|
// Abandoned logins are swept here rather than by the sweeper: this is
|
|
// the only place they are made, so the table cannot outgrow its writers.
|
|
now := time.Now()
|
|
db.ExecContext(r.Context(), "DELETE FROM oidc_logins WHERE expires_at < $1", now.Unix())
|
|
if _, err := db.ExecContext(r.Context(), `
|
|
INSERT INTO oidc_logins (state_hash, nonce, pkce_verifier, next, expires_at)
|
|
VALUES ($1, $2, $3, $4, $5)`,
|
|
stateHash, nonce, verifier, next, now.Add(oidcLoginTTL).Unix()); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
|
|
authURL, err := prov.AuthURL(r.Context(), state, nonce, verifier)
|
|
if err != nil {
|
|
log.Printf("oidc: start login: %v", err)
|
|
ssoRedirect(w, r, ssoUnavailable)
|
|
return
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: oidcStateCookie,
|
|
Value: state,
|
|
Path: "/api/oidc",
|
|
MaxAge: int(oidcLoginTTL.Seconds()),
|
|
HttpOnly: true,
|
|
Secure: cookieSecure(publicURL, r),
|
|
// Lax, not Strict: the callback is a top-level navigation from the
|
|
// provider's site, which Strict would not send the cookie on.
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
http.Redirect(w, r, authURL, http.StatusFound)
|
|
}
|
|
}
|
|
|
|
// handleOIDCCallback finishes a sign-in: it verifies the provider's answer,
|
|
// finds or creates the user, applies their groups and starts a session.
|
|
func handleOIDCCallback(db *sql.DB, prov *oidc.Provider, publicURL string) http.HandlerFunc {
|
|
cfg := prov.Config()
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// The state cookie has done its job once the callback arrives, whatever
|
|
// the outcome.
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: oidcStateCookie, Value: "", Path: "/api/oidc", MaxAge: -1,
|
|
HttpOnly: true, Secure: cookieSecure(publicURL, r), SameSite: http.SameSiteLaxMode,
|
|
})
|
|
|
|
q := r.URL.Query()
|
|
if e := q.Get("error"); e != "" {
|
|
log.Printf("oidc: provider returned error %q: %s", e, q.Get("error_description"))
|
|
ssoRedirect(w, r, ssoDenied)
|
|
return
|
|
}
|
|
state := q.Get("state")
|
|
cookie, err := r.Cookie(oidcStateCookie)
|
|
if state == "" || q.Get("code") == "" || err != nil || cookie.Value != state {
|
|
ssoRedirect(w, r, ssoExpired)
|
|
return
|
|
}
|
|
|
|
// DELETE ... RETURNING makes the state single-use: a replayed callback
|
|
// finds nothing.
|
|
var nonce, verifier, next string
|
|
err = db.QueryRowContext(r.Context(), `
|
|
DELETE FROM oidc_logins WHERE state_hash = $1 AND expires_at > $2
|
|
RETURNING nonce, pkce_verifier, next`,
|
|
hashToken(state), time.Now().Unix()).Scan(&nonce, &verifier, &next)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
ssoRedirect(w, r, ssoExpired)
|
|
return
|
|
}
|
|
if err != nil {
|
|
log.Printf("oidc: load login state: %v", err)
|
|
ssoRedirect(w, r, ssoFailed)
|
|
return
|
|
}
|
|
|
|
identity, err := prov.Exchange(r.Context(), q.Get("code"), verifier, nonce)
|
|
if err != nil {
|
|
log.Printf("oidc: %v", err)
|
|
ssoRedirect(w, r, ssoFailed)
|
|
return
|
|
}
|
|
|
|
grants := oidc.ComputeGrants(cfg, identity.Groups)
|
|
if !grants.Admitted {
|
|
log.Printf("oidc: %q (%s) is in none of the allowed groups", identity.Username, identity.Subject)
|
|
ssoRedirect(w, r, ssoNotAllowed)
|
|
return
|
|
}
|
|
|
|
teamGroups, err := loadTeamGroups(r.Context(), db)
|
|
if err != nil {
|
|
log.Printf("oidc: load team groups: %v", err)
|
|
ssoRedirect(w, r, ssoFailed)
|
|
return
|
|
}
|
|
teamGrants := oidc.ComputeTeamGrants(teamGroups, identity.Groups)
|
|
|
|
userID, err := signInSSO(r.Context(), db, cfg, identity, grants, teamGrants)
|
|
if err != nil {
|
|
var se ssoError
|
|
if errors.As(err, &se) {
|
|
log.Printf("oidc: refused %q (%s): %v", identity.Username, identity.Subject, se)
|
|
ssoRedirect(w, r, se)
|
|
return
|
|
}
|
|
log.Printf("oidc: sign in %q: %v", identity.Username, err)
|
|
ssoRedirect(w, r, ssoFailed)
|
|
return
|
|
}
|
|
|
|
if err := startSessionCapped(w, r, db, userID, publicURL, cfg.SessionMaxAge); err != nil {
|
|
log.Printf("oidc: start session: %v", err)
|
|
ssoRedirect(w, r, ssoFailed)
|
|
return
|
|
}
|
|
http.Redirect(w, r, safeNext(next), http.StatusFound)
|
|
}
|
|
}
|
|
|
|
// safeNext returns where to send the browser after a sign-in: the path asked
|
|
// for, if it is one on this server, and the front page otherwise. It is the
|
|
// only thing standing between a login link and an open redirect, so it accepts
|
|
// a single leading slash and nothing that a browser could read as another host
|
|
// ("//evil.example", "/\evil.example"), and never an API path, which would
|
|
// land somebody on raw JSON.
|
|
func safeNext(next string) string {
|
|
switch {
|
|
case next == "", len(next) > 512,
|
|
!strings.HasPrefix(next, "/"),
|
|
strings.HasPrefix(next, "//"),
|
|
strings.HasPrefix(next, "/api/"),
|
|
strings.ContainsAny(next, "\\\r\n"):
|
|
return "/"
|
|
}
|
|
return next
|
|
}
|
|
|
|
// signInSSO resolves the identity to a user and applies its grants, in one
|
|
// transaction: a login that fails half way must not leave memberships changed.
|
|
func signInSSO(ctx context.Context, db *sql.DB, cfg config.OIDC, id *oidc.Identity, g oidc.Grants, teamRoles map[int64]string) (int64, error) {
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
userID, err := resolveSSOUser(ctx, tx, cfg, id)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var disabled bool
|
|
if err := tx.QueryRowContext(ctx,
|
|
"SELECT disabled_at IS NOT NULL FROM users WHERE id = $1", userID).Scan(&disabled); err != nil {
|
|
return 0, err
|
|
}
|
|
if disabled {
|
|
return 0, ssoDisabled
|
|
}
|
|
if err := syncGrants(ctx, tx, userID, g, teamRoles); err != nil {
|
|
return 0, err
|
|
}
|
|
return userID, tx.Commit()
|
|
}
|
|
|
|
// loadTeamGroups reads every team's own OIDC group binding, for the sync to
|
|
// evaluate against one user's groups at a time. Teams are few, so this reads
|
|
// the whole table rather than filtering it.
|
|
func loadTeamGroups(ctx context.Context, db *sql.DB) ([]oidc.TeamGroup, error) {
|
|
rows, err := db.QueryContext(ctx,
|
|
"SELECT id, COALESCE(oidc_member_group, ''), COALESCE(oidc_owner_group, '') FROM teams")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []oidc.TeamGroup
|
|
for rows.Next() {
|
|
var tg oidc.TeamGroup
|
|
if err := rows.Scan(&tg.TeamID, &tg.MemberGroup, &tg.OwnerGroup); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, tg)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// resolveSSOUser finds the user an identity belongs to, linking or creating one
|
|
// when this is its first sign-in.
|
|
//
|
|
// The order matters. The (issuer, subject) pair is the identity; email is only
|
|
// a way to recognise an existing local account the first time. Once linked, a
|
|
// changed email at the provider must not move the account to somebody else.
|
|
func resolveSSOUser(ctx context.Context, tx *sql.Tx, cfg config.OIDC, id *oidc.Identity) (int64, error) {
|
|
now := time.Now().Unix()
|
|
|
|
var userID int64
|
|
err := tx.QueryRowContext(ctx,
|
|
"SELECT user_id FROM user_identities WHERE issuer = $1 AND subject = $2",
|
|
id.Issuer, id.Subject).Scan(&userID)
|
|
if err == nil {
|
|
if _, err := tx.ExecContext(ctx,
|
|
"UPDATE user_identities SET last_login_at = $1 WHERE issuer = $2 AND subject = $3",
|
|
now, id.Issuer, id.Subject); err != nil {
|
|
return 0, err
|
|
}
|
|
return userID, refreshProfile(ctx, tx, userID, id)
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return 0, err
|
|
}
|
|
|
|
// First sign-in with this identity.
|
|
if id.Email == "" {
|
|
return 0, ssoNoEmail
|
|
}
|
|
err = tx.QueryRowContext(ctx,
|
|
"SELECT id FROM users WHERE lower(email) = lower($1)", id.Email).Scan(&userID)
|
|
switch {
|
|
case err == nil:
|
|
if !id.EmailVerified && !cfg.TrustEmail {
|
|
return 0, ssoEmailConflict
|
|
}
|
|
// A local account that already has an identity from this issuer is a
|
|
// different person at the provider using a recycled address. Linking
|
|
// them would hand one person's account to another.
|
|
var linked bool
|
|
if err := tx.QueryRowContext(ctx,
|
|
"SELECT EXISTS (SELECT 1 FROM user_identities WHERE user_id = $1 AND issuer = $2)",
|
|
userID, id.Issuer).Scan(&linked); err != nil {
|
|
return 0, err
|
|
}
|
|
if linked {
|
|
return 0, ssoEmailConflict
|
|
}
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
userID, err = createSSOUser(ctx, tx, id)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
default:
|
|
return 0, err
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
"INSERT INTO user_identities (user_id, issuer, subject) VALUES ($1, $2, $3)",
|
|
userID, id.Issuer, id.Subject); err != nil {
|
|
return 0, err
|
|
}
|
|
return userID, nil
|
|
}
|
|
|
|
// createSSOUser inserts a user with no password. The username is the provider's,
|
|
// made unique with a numeric suffix when somebody local already has it.
|
|
func createSSOUser(ctx context.Context, tx *sql.Tx, id *oidc.Identity) (int64, error) {
|
|
base := strings.TrimSpace(id.Username)
|
|
if base == "" {
|
|
base, _, _ = strings.Cut(id.Email, "@")
|
|
}
|
|
if base == "" {
|
|
base = "user"
|
|
}
|
|
for n := 1; n <= 100; n++ {
|
|
name := base
|
|
if n > 1 {
|
|
name = base + "-" + strconv.Itoa(n)
|
|
}
|
|
var userID int64
|
|
err := tx.QueryRowContext(ctx, `
|
|
INSERT INTO users (username, email) VALUES ($1, $2)
|
|
ON CONFLICT (username) DO NOTHING RETURNING id`,
|
|
name, id.Email).Scan(&userID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
continue // taken; try the next suffix
|
|
}
|
|
return userID, err
|
|
}
|
|
return 0, errors.New("no free username for " + base)
|
|
}
|
|
|
|
// refreshProfile brings a linked user's username and email in line with the
|
|
// provider. Each update is skipped, not failed, when another user already holds
|
|
// the value: both columns are unique, and a sign-in must not break over a name.
|
|
func refreshProfile(ctx context.Context, tx *sql.Tx, userID int64, id *oidc.Identity) error {
|
|
if id.Username != "" {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE users SET username = $1
|
|
WHERE id = $2 AND username <> $1
|
|
AND NOT EXISTS (SELECT 1 FROM users WHERE username = $1)`,
|
|
id.Username, userID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if id.Email != "" {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE users SET email = $1
|
|
WHERE id = $2 AND email <> $1
|
|
AND NOT EXISTS (SELECT 1 FROM users WHERE lower(email) = lower($1))`,
|
|
id.Email, userID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// syncGrants makes the user's OIDC-sourced access match what their groups grant
|
|
// now, and touches nothing else.
|
|
//
|
|
// Rows the sync owns are marked source 'oidc'. It adds them, changes their role
|
|
// and removes them. The last-owner and last-administrator guards do not apply:
|
|
// they exist to stop a person's mistake, and the provider is the source of truth
|
|
// for the access it grants, so a team or an install can be left without an
|
|
// SSO-granted owner. Administrators can always repair a team, and the bootstrap
|
|
// administrator is a manual one. Rows added by hand are 'manual', and the sync
|
|
// only ever raises them (turning them into 'oidc' rows), never lowers or removes
|
|
// them.
|
|
//
|
|
// teamRoles is keyed by team ID, not name: a team must already exist, with its
|
|
// own oidc_member_group/oidc_owner_group set by its owner, before a group can
|
|
// grant access to it. The sync never creates a team.
|
|
func syncGrants(ctx context.Context, tx *sql.Tx, userID int64, g oidc.Grants, teamRoles map[int64]string) error {
|
|
// Administrator. A manual administrator stays one whatever the groups say.
|
|
if g.Admin {
|
|
if _, err := tx.ExecContext(ctx,
|
|
"UPDATE users SET is_admin = true, admin_source = 'oidc' WHERE id = $1 AND NOT is_admin",
|
|
userID); err != nil {
|
|
return err
|
|
}
|
|
} else if _, err := tx.ExecContext(ctx,
|
|
"UPDATE users SET is_admin = false, admin_source = 'manual' WHERE id = $1 AND admin_source = 'oidc'",
|
|
userID); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Teams. The result of the loop is the set of teams the groups grant.
|
|
granted := make([]int64, 0, len(teamRoles))
|
|
for teamID, role := range teamRoles {
|
|
granted = append(granted, teamID)
|
|
|
|
// A row the sync owns follows the groups in both directions. One added by
|
|
// hand is only raised: a member the owner made an owner by hand is not
|
|
// demoted because the group says member.
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO team_members (team_id, user_id, role, source)
|
|
VALUES ($1, $2, $3, 'oidc')
|
|
ON CONFLICT (team_id, user_id) DO UPDATE
|
|
SET role = excluded.role, source = 'oidc'
|
|
WHERE team_members.source = 'oidc'
|
|
OR (excluded.role = $4 AND team_members.role = $5)`,
|
|
teamID, userID, role, models.RoleOwner, models.RoleMember); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Access the groups no longer grant. granted is never nil, or the ALL
|
|
// comparison would be against NULL and delete nothing.
|
|
_, err := tx.ExecContext(ctx,
|
|
"DELETE FROM team_members WHERE user_id = $1 AND source = 'oidc' AND team_id <> ALL($2)",
|
|
userID, granted)
|
|
return err
|
|
}
|