a27ff49171
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.
281 lines
9.2 KiB
Go
281 lines
9.2 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
|
)
|
|
|
|
type contextKey string
|
|
|
|
const (
|
|
ctxUser contextKey = "user"
|
|
ctxSession contextKey = "session"
|
|
ctxTeams contextKey = "teams"
|
|
)
|
|
|
|
// AuthMiddleware accepts either of the two credentials the server issues: an
|
|
// API key in an Authorization header (the TUI, scripts) or a session cookie
|
|
// (the web UI). A request carrying a Bearer header is judged on that alone and
|
|
// never falls back to the cookie.
|
|
//
|
|
// Only the cookie needs a CSRF guard. A browser attaches it to requests other
|
|
// sites make, whereas an Authorization header is only ever set by the client
|
|
// that holds the key.
|
|
func AuthMiddleware(db *sql.DB) func(http.Handler) http.Handler {
|
|
crossOrigin := http.NewCrossOriginProtection()
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if header := r.Header.Get("Authorization"); header != "" {
|
|
token, ok := strings.CutPrefix(header, "Bearer ")
|
|
if !ok || token == "" {
|
|
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
|
return
|
|
}
|
|
userID, ok := apiKeyUser(r.Context(), db, token)
|
|
if !ok {
|
|
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
|
return
|
|
}
|
|
serveAs(w, r, next, db, userID, 0)
|
|
return
|
|
}
|
|
|
|
c, err := r.Cookie(sessionCookie)
|
|
if err != nil || c.Value == "" {
|
|
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
|
return
|
|
}
|
|
sessionID, userID, ok := sessionUser(r.Context(), db, c.Value)
|
|
if !ok {
|
|
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
|
return
|
|
}
|
|
if err := crossOrigin.Check(r); err != nil {
|
|
respond(w, http.StatusForbidden, errResp("cross-origin request rejected"))
|
|
return
|
|
}
|
|
serveAs(w, r, next, db, userID, sessionID)
|
|
})
|
|
}
|
|
}
|
|
|
|
// AdminOnly rejects a caller who is not a system administrator. It runs inside
|
|
// AuthMiddleware's group, so by the time it sees a request the caller is known.
|
|
//
|
|
// 403 and not 404: the route exists and the caller is authenticated, they are
|
|
// simply not allowed. Hiding the endpoint would buy nothing — every one of them
|
|
// is in the README.
|
|
func AdminOnly(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
caller, ok := userFromContext(r.Context())
|
|
if !ok || !caller.IsAdmin {
|
|
respond(w, http.StatusForbidden, errResp("administrator access required"))
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// requireSelfOrAdmin guards the endpoints that are self-service for your own
|
|
// account and administration for anybody else's: your password, your ntfy
|
|
// topic, your API keys. Reports whether the request may proceed, and answers it
|
|
// if not.
|
|
//
|
|
// An API key is not an escalation: it carries exactly the rights of the user it
|
|
// belongs to, so minting your own is no more than signing in again.
|
|
func requireSelfOrAdmin(w http.ResponseWriter, r *http.Request, targetID int64) bool {
|
|
caller, ok := userFromContext(r.Context())
|
|
if !ok || (caller.ID != targetID && !caller.IsAdmin) {
|
|
respond(w, http.StatusForbidden, errResp("administrator access required"))
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// apiKeyUser resolves an API key to its user and stamps its last use.
|
|
func apiKeyUser(ctx context.Context, db *sql.DB, token string) (int64, bool) {
|
|
var keyID, userID int64
|
|
err := db.QueryRowContext(ctx,
|
|
"SELECT id, user_id FROM api_keys WHERE key_hash = $1", hashToken(token),
|
|
).Scan(&keyID, &userID)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
|
|
// best-effort; don't fail the request if this update fails
|
|
db.ExecContext(ctx,
|
|
"UPDATE api_keys SET last_used_at = $1 WHERE id = $2",
|
|
time.Now().Unix(), keyID)
|
|
return userID, true
|
|
}
|
|
|
|
// sessionUser resolves a session token to its session and user. The expiry
|
|
// slides forward with use, but at most once per sessionTouchEvery, so a page
|
|
// that polls does not write to the database on every request.
|
|
func sessionUser(ctx context.Context, db *sql.DB, token string) (sessionID, userID int64, ok bool) {
|
|
now := time.Now()
|
|
var lastSeen int64
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT id, user_id, last_seen_at FROM sessions
|
|
WHERE token_hash = $1 AND expires_at > $2`,
|
|
hashToken(token), now.Unix()).Scan(&sessionID, &userID, &lastSeen)
|
|
if err != nil {
|
|
return 0, 0, false
|
|
}
|
|
|
|
if now.Sub(time.Unix(lastSeen, 0)) > sessionTouchEvery {
|
|
// LEAST keeps a capped session (a single sign-on login) from sliding
|
|
// past its ceiling; with no ceiling COALESCE makes it the plain slide.
|
|
db.ExecContext(ctx, `
|
|
UPDATE sessions
|
|
SET last_seen_at = $1,
|
|
expires_at = LEAST($2::bigint, COALESCE(max_expires_at, $2::bigint))
|
|
WHERE id = $3`,
|
|
now.Unix(), now.Add(sessionTTL).Unix(), sessionID)
|
|
}
|
|
return sessionID, userID, true
|
|
}
|
|
|
|
// serveAs loads the user and hands the request on with it in the context.
|
|
// sessionID is zero for API-key requests.
|
|
func serveAs(w http.ResponseWriter, r *http.Request, next http.Handler, db *sql.DB, userID, sessionID int64) {
|
|
var u models.User
|
|
var createdUnix int64
|
|
// disabled_at IS NULL is part of the lookup rather than a check afterwards:
|
|
// a disabled account is one that cannot authenticate, by either credential,
|
|
// and the way to be sure of that is for there to be no path where the row
|
|
// is loaded and the flag is then forgotten.
|
|
if err := db.QueryRowContext(r.Context(),
|
|
"SELECT id, username, email, created_at, is_admin FROM users WHERE id = $1 AND disabled_at IS NULL", userID,
|
|
).Scan(&u.ID, &u.Username, &u.Email, &createdUnix, &u.IsAdmin); err != nil {
|
|
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
|
return
|
|
}
|
|
u.CreatedAt = time.Unix(createdUnix, 0).UTC()
|
|
|
|
// Every scoped query needs the caller's teams, so they are loaded once here
|
|
// rather than per handler. One extra round trip per request, against a
|
|
// table with one row per membership.
|
|
teams, err := callerMemberships(r.Context(), db, userID)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
|
|
ctx := context.WithValue(r.Context(), ctxTeams, teams)
|
|
ctx = context.WithValue(ctx, ctxUser, u)
|
|
if sessionID != 0 {
|
|
ctx = context.WithValue(ctx, ctxSession, sessionID)
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
}
|
|
|
|
func hashToken(token string) string {
|
|
h := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
func userFromContext(ctx context.Context) (models.User, bool) {
|
|
u, ok := ctx.Value(ctxUser).(models.User)
|
|
return u, ok
|
|
}
|
|
|
|
// membership is the caller's role in one team.
|
|
type membership struct {
|
|
teamID int64
|
|
role string
|
|
}
|
|
|
|
func callerMemberships(ctx context.Context, db *sql.DB, userID int64) ([]membership, error) {
|
|
rows, err := db.QueryContext(ctx,
|
|
"SELECT team_id, role FROM team_members WHERE user_id = $1 ORDER BY team_id", userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []membership
|
|
for rows.Next() {
|
|
var m membership
|
|
if err := rows.Scan(&m.teamID, &m.role); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// callerTeamIDs lists the teams the caller belongs to, for the `team_id = ANY`
|
|
// filter every list query carries. An admin is NOT implicitly in every team:
|
|
// administration is about accounts, not about reading other people's incidents,
|
|
// and an admin who needs to see a team's queue can add themselves to it.
|
|
func callerTeamIDs(ctx context.Context) []int64 {
|
|
ms, _ := ctx.Value(ctxTeams).([]membership)
|
|
ids := make([]int64, 0, len(ms))
|
|
for _, m := range ms {
|
|
ids = append(ids, m.teamID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// callerRole reports the caller's role in one team, and whether they are in it
|
|
// at all.
|
|
func callerRole(ctx context.Context, teamID int64) (string, bool) {
|
|
ms, _ := ctx.Value(ctxTeams).([]membership)
|
|
for _, m := range ms {
|
|
if m.teamID == teamID {
|
|
return m.role, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// requireTeamMember answers the request and reports false unless the caller
|
|
// belongs to teamID.
|
|
//
|
|
// 404, not 403: whether a team exists is itself something only its members
|
|
// should learn, and the same reasoning applies to every incident and alert
|
|
// under it.
|
|
func requireTeamMember(w http.ResponseWriter, r *http.Request, teamID int64) bool {
|
|
if _, ok := callerRole(r.Context(), teamID); !ok {
|
|
respond(w, http.StatusNotFound, errResp("not found"))
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// requireTeamOwner is requireTeamMember for the things only an owner may change:
|
|
// the schedule, the integrations and who is in the team. A system administrator
|
|
// passes without being a member, because somebody has to be able to repair a
|
|
// team whose owner has left.
|
|
func requireTeamOwner(w http.ResponseWriter, r *http.Request, teamID int64) bool {
|
|
role, ok := callerRole(r.Context(), teamID)
|
|
if ok && role == models.RoleOwner {
|
|
return true
|
|
}
|
|
if caller, _ := userFromContext(r.Context()); caller.IsAdmin {
|
|
return true
|
|
}
|
|
if !ok {
|
|
respond(w, http.StatusNotFound, errResp("not found"))
|
|
return false
|
|
}
|
|
respond(w, http.StatusForbidden, errResp("team owner access required"))
|
|
return false
|
|
}
|
|
|
|
// sessionFromContext returns the id of the session a request was authenticated
|
|
// with, or false for an API-key request.
|
|
func sessionFromContext(ctx context.Context) (int64, bool) {
|
|
id, ok := ctx.Value(ctxSession).(int64)
|
|
return id, ok
|
|
}
|