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.
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
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})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user