d827ceedff
First half of #7. Until now the only way to get an account was for somebody who already had one to create it, and the login page told people to "ask an admin" -- workable for one operator, impossible for a team. Two modes, chosen by an administrator in the settings table: invite_only, which is the default, and open. A third domain-restricted mode was considered and dropped, because with no email in this server there is nothing to verify an address against and it would only check the domain of a string somebody typed. The default is the closed door. An install that gets a public hostname before anybody has thought about sign-up should not be collecting accounts from the internet, and the failure mode of a typo in the setting is invite_only rather than open. An invite is a link, not an email. Adding SMTP to send one message would be a subsystem to run, secure and monitor; the person inviting sends the link however they already talk to the person they are inviting. A link carries the team and the role, because an account in no team sees an empty queue and can be paged by nobody -- that is not a state to invite somebody into. Links are single-use by default, expire after seven days, and can be revoked before that: a link that works forever is a credential nobody remembers issuing, sitting in a chat log. The uses counter is incremented inside the sign-up transaction and guarded by `uses < max_uses`, so two people redeeming the last use at once cannot both get in. GET /api/signup reports the mode and whether a link is usable, so the form can say "this link has expired" before somebody picks a password rather than after. It gives one answer for expired, revoked, used up and never existed: telling a stranger which it was tells them something about links they do not hold. Sign-up signs you in. The alternative is a form that says "now go and log in", which is the same credential typed twice. login and signup now share startSession rather than each minting a cookie. Rate-limited per address on its own limiter, not login's: a burst of sign-ups must not lock somebody out of logging in. The settings table grew a second shape for this. It held only durations; signup_mode is a word from a fixed list, so the admin endpoint now validates everything before writing anything -- a request that sets two settings and gets one wrong changes neither. Still to come in #7: the sign-up and invite-redemption pages, the first-run checklist, and the in-app integration instructions. The schema carries onboarding_dismissed_at for the checklist already. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
370 lines
11 KiB
Go
370 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
|
|
}
|
|
|
|
// 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 {
|
|
raw, tokenHash, err := randomToken()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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 {
|
|
return err
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookie,
|
|
Value: raw,
|
|
Path: "/",
|
|
MaxAge: int(sessionTTL.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"`
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|