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=; 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 }