Files
terdut-server/internal/api/auth.go
T
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

397 lines
12 KiB
Go

package api
import (
"context"
"database/sql"
"errors"
"log"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"golang.org/x/crypto/bcrypt"
)
const (
// sessionCookie carries a web UI session. It is HttpOnly, so page script
// never sees the token; the page learns who it is from GET /api/me.
sessionCookie = "terdut_session"
// sessionTTL is how long a session lives without being used. It slides, so
// a phone that opens the UI now and then stays signed in indefinitely.
sessionTTL = 30 * 24 * time.Hour
// sessionTouchEvery bounds how often a request may slide the expiry.
sessionTouchEvery = time.Hour
minPasswordLen = 10
// maxPasswordLen is bcrypt's limit; it rejects longer input outright.
maxPasswordLen = 72
loginWindow = 15 * time.Minute
loginMaxPerUser = 10
loginMaxPerAddr = 30
passwordHashCost = bcrypt.DefaultCost
)
// dummyHash is compared against when the username is unknown or has no
// password, so a failed login takes as long whichever way it failed.
var dummyHash = sync.OnceValue(func() []byte {
h, _ := bcrypt.GenerateFromPassword([]byte("terdut-dummy-password"), passwordHashCost)
return h
})
// loginLimiter counts failed logins in a fixed window, per username and per
// client address. The username limit is what stops guessing one account; the
// address limit is looser because every user behind the same gateway or NAT
// shares it.
type loginLimiter struct {
mu sync.Mutex
failures map[string]*loginWindowCount
}
type loginWindowCount struct {
start time.Time
n int
}
func newLoginLimiter() *loginLimiter {
return &loginLimiter{failures: map[string]*loginWindowCount{}}
}
func (l *loginLimiter) blocked(key string, max int) bool {
l.mu.Lock()
defer l.mu.Unlock()
c, ok := l.failures[key]
if !ok || time.Since(c.start) > loginWindow {
return false
}
return c.n >= max
}
func (l *loginLimiter) fail(keys ...string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
for k, c := range l.failures {
if now.Sub(c.start) > loginWindow {
delete(l.failures, k)
}
}
for _, key := range keys {
c, ok := l.failures[key]
if !ok {
c = &loginWindowCount{start: now}
l.failures[key] = c
}
c.n++
}
}
func (l *loginLimiter) clear(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.failures, key)
}
// clientAddr is the address a login is counted against. Behind the gateway
// RemoteAddr is the gateway itself, so the first X-Forwarded-For hop is used
// when present. It can be forged, but only to dodge the address limit; the
// per-username limit does not depend on it.
func clientAddr(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
first, _, _ := strings.Cut(xff, ",")
return strings.TrimSpace(first)
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// cookieSecure decides the cookie's Secure flag. TLS terminates at the gateway,
// so the server usually sees plain HTTP; the public URL is what says whether
// browsers reach it over HTTPS.
func cookieSecure(publicURL string, r *http.Request) bool {
return strings.HasPrefix(publicURL, "https://") ||
r.TLS != nil ||
r.Header.Get("X-Forwarded-Proto") == "https"
}
// validatePassword returns a message for the client, or "" when acceptable.
func validatePassword(pw string) string {
switch {
case len(pw) < minPasswordLen:
return "password must be at least " + strconv.Itoa(minPasswordLen) + " characters"
case len(pw) > maxPasswordLen:
return "password must be at most " + strconv.Itoa(maxPasswordLen) + " bytes"
}
return ""
}
func hashPassword(pw string) (string, error) {
h, err := bcrypt.GenerateFromPassword([]byte(pw), passwordHashCost)
return string(h), err
}
// startSession mints a session and sets the cookie. Shared by login and
// sign-up: somebody who has just chosen a password is signed in, rather than
// being sent to a form to type the same credential again.
func startSession(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string) error {
return startSessionCapped(w, r, db, userID, publicURL, 0)
}
// startSessionCapped is startSession with a hard ceiling on the session's life,
// which sliding never extends. maxAge zero means no ceiling. A single sign-on
// login uses it: the login is the only moment the provider's groups are read, so
// a session that could outlive it indefinitely would keep access the provider
// has since taken away.
func startSessionCapped(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string, maxAge time.Duration) error {
raw, tokenHash, err := randomToken()
if err != nil {
return err
}
now := time.Now()
life := sessionTTL
var ceiling *int64
if maxAge > 0 {
c := now.Add(maxAge).Unix()
ceiling = &c
life = min(life, maxAge)
}
if _, err := db.ExecContext(r.Context(), `
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, max_expires_at, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
tokenHash, userID, now.Unix(), now.Unix(), now.Add(life).Unix(), ceiling, r.UserAgent()); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: raw,
Path: "/",
MaxAge: int(life.Seconds()),
HttpOnly: true,
Secure: cookieSecure(publicURL, r),
SameSite: http.SameSiteLaxMode,
})
return nil
}
// handleLogin exchanges a username and password for a session cookie.
func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
username := strings.TrimSpace(req.Username)
userKey := "user:" + strings.ToLower(username)
addrKey := "addr:" + clientAddr(r)
if limiter.blocked(userKey, loginMaxPerUser) || limiter.blocked(addrKey, loginMaxPerAddr) {
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
respond(w, http.StatusTooManyRequests, errResp("too many failed attempts, try again later"))
return
}
var userID int64
var hash sql.NullString
err := db.QueryRowContext(r.Context(),
"SELECT id, password_hash FROM users WHERE username = $1", username,
).Scan(&userID, &hash)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
stored := dummyHash()
if hash.Valid {
stored = []byte(hash.String)
}
match := bcrypt.CompareHashAndPassword(stored, []byte(req.Password)) == nil
if !match || !hash.Valid {
limiter.fail(userKey, addrKey)
respond(w, http.StatusUnauthorized, errResp("invalid username or password"))
return
}
limiter.clear(userKey)
if err := startSession(w, r, db, userID, publicURL); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
user, err := fetchUser(r.Context(), db, userID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, meResponse{User: user, HasPassword: true})
}
}
// handleLogout ends the browser's session. It sits outside AuthMiddleware so
// that a browser holding an already-expired cookie can still clear it.
func handleLogout(db *sql.DB, publicURL string) http.HandlerFunc {
crossOrigin := http.NewCrossOriginProtection()
return func(w http.ResponseWriter, r *http.Request) {
if err := crossOrigin.Check(r); err != nil {
respond(w, http.StatusForbidden, errResp("cross-origin request rejected"))
return
}
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
db.ExecContext(r.Context(), "DELETE FROM sessions WHERE token_hash = $1", hashToken(c.Value))
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: cookieSecure(publicURL, r),
SameSite: http.SameSiteLaxMode,
})
w.WriteHeader(http.StatusNoContent)
}
}
type meResponse struct {
User any `json:"user"`
HasPassword bool `json:"has_password"`
// OnboardingDismissed is whether this person has put the first-run
// checklist away. Per user rather than per browser: somebody who finishes
// setting up on a laptop should not be nagged again on their phone.
OnboardingDismissed bool `json:"onboarding_dismissed"`
}
// handleMe says who the caller is. The web UI calls it on load to decide
// between the login form and the app, since it cannot read its own cookie.
func handleMe(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
caller, _ := userFromContext(r.Context())
user, err := fetchUser(r.Context(), db, caller.ID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
var hash sql.NullString
var dismissed *int64
db.QueryRowContext(r.Context(),
"SELECT password_hash, onboarding_dismissed_at FROM users WHERE id = $1",
caller.ID).Scan(&hash, &dismissed)
respond(w, http.StatusOK, meResponse{
User: user,
HasPassword: hash.Valid,
OnboardingDismissed: dismissed != nil,
})
}
}
// handleSetPassword sets a user's web UI password.
//
// Changing your own password takes the current one, when there is one, so an
// unattended signed-in browser cannot be used to take the account over. Setting
// somebody else's is how an admin gives a user their first password, and is
// restricted to administrators: it hands over an account outright, without
// knowing the password it replaces.
//
// Every other session of the target is ended: a password change is what you
// do when you think someone else is signed in.
func handleSetPassword(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid user id"))
return
}
if !requireSelfOrAdmin(w, r, id) {
return
}
var req struct {
Password string `json:"password"`
CurrentPassword string `json:"current_password"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
if msg := validatePassword(req.Password); msg != "" {
respond(w, http.StatusBadRequest, errResp(msg))
return
}
var existing sql.NullString
err = db.QueryRowContext(r.Context(),
"SELECT password_hash FROM users WHERE id = $1", id).Scan(&existing)
if errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusNotFound, errResp("user not found"))
return
}
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
caller, _ := userFromContext(r.Context())
if caller.ID == id && existing.Valid &&
bcrypt.CompareHashAndPassword([]byte(existing.String), []byte(req.CurrentPassword)) != nil {
respond(w, http.StatusForbidden, errResp("current password is incorrect"))
return
}
hash, err := hashPassword(req.Password)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
tx, err := db.BeginTx(r.Context(), nil)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(),
"UPDATE users SET password_hash = $1 WHERE id = $2", hash, id); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
keep, _ := sessionFromContext(r.Context()) // zero when changed with an API key
if _, err := tx.ExecContext(r.Context(),
"DELETE FROM sessions WHERE user_id = $1 AND id != $2", id, keep); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if err := tx.Commit(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// purgeSessions deletes sessions that have expired, from the sweeper.
func purgeSessions(ctx context.Context, db *sql.DB) {
res, err := db.ExecContext(ctx,
"DELETE FROM sessions WHERE expires_at < $1", time.Now().Unix())
if err != nil {
log.Printf("sweeper: purge sessions: %v", err)
return
}
if n, _ := res.RowsAffected(); n > 0 {
log.Printf("sweeper: purged %d expired session(s)", n)
}
}