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}) } }