1377d9005b
Until now every authenticated caller could create and delete users, set anybody's password and mint anybody's API keys -- auth.go said so in a comment. Defensible with one operator and a hand-made account; not once people sign themselves up (#7), and not in a multi-tenant install (#4), where the user list is no longer everybody who works here. users.is_admin is the flag. AdminOnly gates creating and deleting users and granting the flag itself. The endpoints that are self-service for your own account and administration for somebody else's -- password, ntfy topic, API keys -- go through requireSelfOrAdmin instead, because which rule applies depends on the {id} in the path rather than on the route. Minting your own API key stays self-service. A key carries exactly the rights of the user it belongs to, so issuing one is no more than signing in again; requiring an admin for it would mean a responder cannot set up the TUI without somebody else in the room. /api/users stays readable by everybody. The queue's assignment control and the on-call schedule both have to name people, and hiding the roster from the people on it buys nothing. THE MIGRATION MAKES EVERY EXISTING USER AN ADMINISTRATOR. They already hold these powers, so nobody's access changes on upgrade: it names what is already true and leaves demotion as a deliberate act. Promoting only user 1 would silently strip the others, and could leave an install whose only administrator is an account nobody has a password for. Two guards keep an install administrable: the last administrator can be neither deleted nor demoted, and nobody can delete or demote themselves -- the likelier accident, where the only admin clears their own flag while tidying up and locks the door behind them. No UI changes: there are no account-management screens yet. models.User carries is_admin (not omitempty, so a client can tell false from an old server), which is what #5's admin page will render from.
361 lines
11 KiB
Go
361 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 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)
|
|
}
|
|
}
|