Files
Niklas Ye a27ff49171 Sign in through an OpenID Connect provider, and from a terminal
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.
2026-09-26 21:37:40 +02:00

283 lines
9.3 KiB
Go

package api
import (
"crypto/rand"
"database/sql"
"errors"
"log"
"math/big"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// The device login flow lets a client that cannot open a browser sign in: it
// shows a code, the person approves it in a browser they are signed in to, and
// the client is handed an ordinary session. See migration 012.
const (
// deviceTTL is how long a person has to get from the terminal's prompt to an
// approval.
deviceTTL = 10 * time.Minute
// deviceInterval is how often the client is told to poll. The server holds it
// to that, with a second of slack for clocks and scheduling.
deviceInterval = 5 * time.Second
// deviceStartMaxPerAddr bounds unauthenticated device logins started per
// address, since each writes a row.
deviceStartMaxPerAddr = 30
// userCodeAlphabet has no vowels, so a code cannot spell a word, and none of
// the characters that read alike (0/O, 1/I/L).
userCodeAlphabet = "BCDFGHJKMNPQRSTVWXZ23456789"
userCodeLen = 8
)
// newUserCode returns a code for a person to read, as XXXX-XXXX.
func newUserCode() (string, error) {
max := big.NewInt(int64(len(userCodeAlphabet)))
b := make([]byte, userCodeLen)
for i := range b {
n, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
b[i] = userCodeAlphabet[n.Int64()]
}
return string(b[:4]) + "-" + string(b[4:]), nil
}
// normalizeUserCode reduces whatever a person typed or pasted to the stored
// form, so "bcdf ghjk" and "BCDF-GHJK" name the same login. It returns "" for
// anything that cannot be a code.
func normalizeUserCode(s string) string {
var b strings.Builder
for _, r := range strings.ToUpper(s) {
if strings.ContainsRune(userCodeAlphabet, r) {
b.WriteRune(r)
}
}
code := b.String()
if len(code) != userCodeLen {
return ""
}
return code[:4] + "-" + code[4:]
}
// handleDeviceStart begins a device login: it returns the device code the
// client polls with, and the user code and URL the person is shown.
func handleDeviceStart(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
addrKey := "device:" + clientAddr(r)
if limiter.blocked(addrKey, deviceStartMaxPerAddr) {
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)
deviceCode, deviceHash, err := randomToken()
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
now := time.Now()
db.ExecContext(r.Context(), "DELETE FROM device_logins WHERE expires_at < $1", now.Unix())
// A collision on the user code is one in 27^8; retrying a few times makes
// it a non-event rather than a 500.
var userCode string
for range 5 {
userCode, err = newUserCode()
if err != nil {
break
}
_, err = db.ExecContext(r.Context(), `
INSERT INTO device_logins (device_hash, user_code, expires_at) VALUES ($1, $2, $3)`,
deviceHash, userCode, now.Add(deviceTTL).Unix())
if err == nil || !isUniqueViolation(err) {
break
}
}
if err != nil {
log.Printf("device login: start: %v", err)
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, map[string]any{
"device_code": deviceCode,
"user_code": userCode,
// The code is in the URL so nobody has to type it; it is shown anyway,
// for the person to check against the terminal before approving.
"verification_url": strings.TrimRight(publicURL, "/") + "/device?code=" + url.QueryEscape(userCode),
"interval": int(deviceInterval.Seconds()),
"expires_in": int(deviceTTL.Seconds()),
})
}
}
// handleDeviceDecision approves or denies a pending device login on behalf of
// the signed-in caller.
//
// It takes a session, not an API key. Approving hands a terminal the caller's
// identity, and the approval must come from a browser the person is looking at:
// the page shows the code and asks. A script with a key has no business
// approving one, and the check keeps it from being a way to mint sessions out of
// keys.
func handleDeviceDecision(db *sql.DB, approve bool) http.HandlerFunc {
status := "denied"
if approve {
status = "approved"
}
return func(w http.ResponseWriter, r *http.Request) {
if _, viaSession := sessionFromContext(r.Context()); !viaSession {
respond(w, http.StatusForbidden, errResp("sign in with the web UI to approve a device"))
return
}
var req struct {
UserCode string `json:"user_code"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
code := normalizeUserCode(req.UserCode)
if code == "" {
respond(w, http.StatusBadRequest, errResp("that is not a sign-in code"))
return
}
caller, _ := userFromContext(r.Context())
// Only a pending login can be decided, and only once: an approval cannot
// be overwritten, so a second browser cannot take a login over.
res, err := db.ExecContext(r.Context(), `
UPDATE device_logins SET status = $1, user_id = $2
WHERE user_code = $3 AND status = 'pending' AND expires_at > $4`,
status, caller.ID, code, time.Now().Unix())
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("that sign-in code is unknown, expired or already used"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// handleDeviceToken is what the client polls. Pending answers 202; an approval
// answers 200 with the session cookie, once; anything else is 410.
func handleDeviceToken(db *sql.DB, ssoMaxAge time.Duration, publicURL string) http.HandlerFunc {
gone := func(w http.ResponseWriter, why string) {
respond(w, http.StatusGone, map[string]string{"error": why})
}
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
DeviceCode string `json:"device_code"`
}
if err := decodeJSON(r, &req); err != nil || req.DeviceCode == "" {
respond(w, http.StatusBadRequest, errResp("device_code is required"))
return
}
hash := hashToken(req.DeviceCode)
now := time.Now()
tx, err := db.BeginTx(r.Context(), nil)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer tx.Rollback() //nolint:errcheck
var status string
var userID sql.NullInt64
var expires, lastPolled int64
err = tx.QueryRowContext(r.Context(), `
SELECT status, user_id, expires_at, last_polled_at FROM device_logins
WHERE device_hash = $1 FOR UPDATE`, hash).Scan(&status, &userID, &expires, &lastPolled)
if errors.Is(err, sql.ErrNoRows) || (err == nil && expires <= now.Unix()) {
gone(w, "expired")
return
}
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
switch status {
case "denied":
tx.ExecContext(r.Context(), "DELETE FROM device_logins WHERE device_hash = $1", hash)
tx.Commit() //nolint:errcheck
gone(w, "denied")
return
case "pending":
// Held to the interval it was given, less a second of slack.
if now.Unix()-lastPolled < int64(deviceInterval.Seconds())-1 {
w.Header().Set("Retry-After", strconv.Itoa(int(deviceInterval.Seconds())))
respond(w, http.StatusTooManyRequests, map[string]string{"error": "slow_down"})
return
}
if _, err := tx.ExecContext(r.Context(),
"UPDATE device_logins SET last_polled_at = $1 WHERE device_hash = $2", now.Unix(), hash); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if err := tx.Commit(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusAccepted, map[string]string{"status": "pending"})
return
}
// Approved. Single use: the row goes before the session is made, so two
// racing polls cannot both be given one.
if _, err := tx.ExecContext(r.Context(), "DELETE FROM device_logins WHERE device_hash = $1", hash); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
var disabled, sso bool
if err := tx.QueryRowContext(r.Context(), `
SELECT disabled_at IS NOT NULL,
EXISTS (SELECT 1 FROM user_identities WHERE user_id = $1)
FROM users WHERE id = $1`, userID.Int64).Scan(&disabled, &sso); err != nil {
gone(w, "denied")
return
}
if err := tx.Commit(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if disabled {
gone(w, "denied")
return
}
// A session for somebody who signs in through the provider carries the
// same ceiling as their browser's would, so the terminal is not a way
// round it. Password users have none.
var maxAge time.Duration
if sso {
maxAge = ssoMaxAge
}
if err := startSessionCapped(w, r, db, userID.Int64, publicURL, maxAge); err != nil {
log.Printf("device login: start session: %v", err)
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
user, err := fetchUser(r.Context(), db, userID.Int64)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, meResponse{User: user, HasPassword: false})
}
}