a27ff49171
terdut can now sign people in through any OIDC provider (written against Authentik), and let groups at the provider decide who may sign in, which teams they belong to and whether they administer the install. Password login keeps working alongside it; TERDUT_PASSWORD_LOGIN=false turns it off, and is refused at startup unless SSO is configured. With no TERDUT_OIDC_* setting nothing changes, so every existing install behaves as before. Identity is (issuer, subject), never email or username: those are mutable at the provider and a recycled address must not inherit an account. An existing user is linked by email only when the provider marks it verified, or TERDUT_OIDC_TRUST_EMAIL is set, which Authentik needs. Group grants are marked source='oidc' on team_members and users, and the sync changes only those rows. Hand-made memberships and administrators are left alone, and the sync bypasses the last-owner and last-admin guards because the provider is the source of truth for what it grants. Editing managed access by hand is refused with 409, since the next sign-in would undo it. The web UI badges it as SSO and disables the controls. Groups are read only at sign-in, so an SSO session carries a hard ceiling (sessions.max_expires_at, 12h by default) that sliding never extends. There is no refresh token, which means API keys of somebody removed at the provider stay valid until an administrator disables the user. That is accepted and documented, not fixed. A client with no browser, the TUI over SSH, signs in with a device code run by terdut itself (POST /api/oidc/device and /device/token), so the terminal never talks to the provider and ends up with the ordinary terdut_session cookie. Only a browser session can approve a code; an API key cannot. /device?code= sends a signed-out visitor through sign-in and back, which is what oidc_logins.next is for. oauth2 is pinned to v0.36.0: v0.37 needs Go 1.26 and the Dockerfile builds on 1.25. Migrations 011 and 012 add tables and defaulted columns only.
459 lines
16 KiB
Go
459 lines
16 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
|
|
}
|
|
|
|
userID, err := signInSSO(r.Context(), db, cfg, identity, grants)
|
|
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) (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); err != nil {
|
|
return 0, err
|
|
}
|
|
return userID, tx.Commit()
|
|
}
|
|
|
|
// 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.
|
|
func syncGrants(ctx context.Context, tx *sql.Tx, userID int64, g oidc.Grants) 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(g.Teams))
|
|
for name, role := range g.Teams {
|
|
if _, err := tx.ExecContext(ctx,
|
|
"INSERT INTO teams (name) VALUES ($1) ON CONFLICT (name) DO NOTHING", name); err != nil {
|
|
return err
|
|
}
|
|
var teamID int64
|
|
if err := tx.QueryRowContext(ctx, "SELECT id FROM teams WHERE name = $1", name).Scan(&teamID); err != nil {
|
|
return err
|
|
}
|
|
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 mapping 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
|
|
}
|