dc39e3a5d3
First step of #1, and it goes first for one reason: #4 adds a team_id to nearly every table, and doing that twice -- once for SQLite, once for Postgres -- is work nobody gets paid for. The teams migrations now only have to be written against one database. The ten SQLite migrations are replaced by a single Postgres baseline rather than ported one by one. They were incremental in a way that has no value on a fresh install: 004 adds columns 008 drops again, and 008's backfill rewrites data a Postgres database never had. The history stays in git; the schema they add up to is now 001_baseline.sql. Timestamps stay BIGINT unix seconds and are NOT converted to timestamptz. Everything in Go already speaks epochs, so converting would have been a second, larger change riding along inside this one. It is worth doing on its own. The JSON columns did move to jsonb, because #4 will want to filter and index on labels. Most of the port is mechanical -- 170 placeholders from ? to $1 -- but four things needed more than a search and replace: * Dynamically built WHERE clauses cannot keep their numbering straight by hand, so they hand out placeholders through sqlArgs instead. A filter can now be added or reordered without renumbering anything. * SUM(resolved_at IS NULL) was SQLite counting a boolean as 0 or 1. Postgres has no sum(boolean), and this was breaking every dead man's switch -- silently, since the sweeper only logs. Now COUNT(*) FILTER. * unixepoch() became FLOOR(EXTRACT(EPOCH FROM now()))::bigint. The FLOOR is load-bearing: a bare cast rounds half up, so a row written at .6 of a second claimed a timestamp a second in the future and disagreed with the time.Now().Unix() the Go side stamps. * The unique-violation check matched SQLite's error text. It matches SQLSTATE 23505 now, so a renamed constraint cannot turn a 409 back into a 500. Tests need a real Postgres, because there is no in-memory Postgres the way there was an in-memory SQLite. Each test gets its own schema on a shared server -- cheaper than a database each, and still isolated. TERDUT_TEST_DSN says where it is; `make test-db` starts one locally and ci.yaml runs one as a service container. An unset DSN fails the suite rather than skipping it: a run that quietly tests nothing is worse than one that does not run. TestMigration_BackfillCarriesAckAndComments is deleted along with the migrations it replayed. What it protected -- an upgrade not losing acknowledgements and comments -- now belongs to scripts/sqlite-to-postgres.go, which is build-tagged so the SQLite driver stays out of the server binary. Both are meant to be deleted once this install has migrated. The chart loses the PVC, the data volume and the python backup sidecar, and requires database.dsnSecret.name: it provisions no database and cannot guess where the credentials live, so a render without it is meant to fail. Backups move to where Postgres actually runs. The other half of that -- the postgresql CR, the k8up pg_dump annotation and the network policy -- is a change to the wrapper chart in Ryuvia/charts and is not in here. Verified rather than assumed: the gate is green with -race against Postgres 17, govulncheck and gitleaks are clean, and the migration script was run end to end against a SQLite database built at the old schema and seeded in every table. Ids survive, so incidents keep their numbers and every foreign key still points where it did; the identity sequences are moved past the copied ids, and a webhook after the migration opened incident 12 rather than colliding at 1.
357 lines
11 KiB
Go
357 lines
11 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
|
|
}
|
|
|
|
// 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)
|
|
|
|
raw, tokenHash, err := randomToken()
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
now := time.Now()
|
|
if _, err := db.ExecContext(r.Context(), `
|
|
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, user_agent)
|
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
tokenHash, userID, now.Unix(), now.Unix(), now.Add(sessionTTL).Unix(), r.UserAgent()); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookie,
|
|
Value: raw,
|
|
Path: "/",
|
|
MaxAge: int(sessionTTL.Seconds()),
|
|
HttpOnly: true,
|
|
Secure: cookieSecure(publicURL, r),
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
|
|
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"`
|
|
}
|
|
|
|
// 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
|
|
db.QueryRowContext(r.Context(),
|
|
"SELECT password_hash FROM users WHERE id = $1", caller.ID).Scan(&hash)
|
|
respond(w, http.StatusOK, meResponse{User: user, HasPassword: hash.Valid})
|
|
}
|
|
}
|
|
|
|
// 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 like
|
|
// the other user endpoints it is open to any authenticated caller.
|
|
//
|
|
// 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
|
|
}
|
|
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)
|
|
}
|
|
}
|