Rewrites the internal/api comment blocks to rule 32: task, milestone and rule ids gone, the stories behind them restated as the constraint they left, stale references corrected (the stream secret is wired from config, the timezone handler already refreshes the scheduler, the Android app is the native client, the design docs cited are not in the repo). Also rewraps comment lines over 80 columns left by the earlier batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
158 lines
5.3 KiB
Go
158 lines
5.3 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// sessionCookieMaxAge is the cookie lifetime. Sessions don't auto-expire
|
|
// server-side yet (future work); the cookie still caps browser-side lifetime
|
|
// so an abandoned laptop doesn't stay logged in forever.
|
|
const sessionCookieMaxAge = 30 * 24 * time.Hour
|
|
|
|
// Limits on the unauthenticated auth routes other than login (which uses
|
|
// auth.LoginGuard). Per address, per window as wired in Mount.
|
|
const (
|
|
// registerPerAddressMax: 10 registrations an hour. Every attempt counts,
|
|
// typos included, which is still far above a household's need.
|
|
registerPerAddressMax = 10
|
|
// forgotPerAddressMax / forgotPerEmailMax: reset emails an hour. The
|
|
// per-email cap is what keeps one inbox from being mailbombed.
|
|
forgotPerAddressMax = 5
|
|
forgotPerEmailMax = 3
|
|
// resetFailuresPerAddressMax: wrong or expired reset tokens per 15 min.
|
|
resetFailuresPerAddressMax = 20
|
|
)
|
|
|
|
func (h *handlers) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
// The session token can be on the cookie OR bearer header — RequireUser
|
|
// accepted either. Re-resolve it here so we can delete the row.
|
|
token := sessionTokenFromHTTP(r)
|
|
if token != "" {
|
|
if err := dbq.New(h.pool).DeleteSessionByTokenHash(r.Context(), auth.HashSessionToken(token)); err != nil {
|
|
h.logger.Warn("api: delete session failed", "err", err)
|
|
// Continue — logout is best-effort; the client still gets the
|
|
// cookie cleared.
|
|
}
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: auth.SessionCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
MaxAge: -1,
|
|
})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// sessionTokenFromHTTP duplicates the internal helper in internal/auth
|
|
// because that one is unexported. Cheap to repeat here; keeping the auth
|
|
// package's internal helper package-private is worth more than DRY. Must
|
|
// match that helper's trimming behavior exactly — otherwise a bearer with
|
|
// trailing whitespace authenticates via RequireUser (which trims) but logout
|
|
// hashes the padded value and silently no-ops, leaving the session alive.
|
|
func sessionTokenFromHTTP(r *http.Request) string {
|
|
if c, err := r.Cookie(auth.SessionCookieName); err == nil && c.Value != "" {
|
|
return c.Value
|
|
}
|
|
h := r.Header.Get("Authorization")
|
|
const prefix = "bearer "
|
|
if len(h) > len(prefix) && (h[:7] == "Bearer " || h[:7] == "bearer ") {
|
|
return strings.TrimSpace(h[len(prefix):])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
var req LoginRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, apierror.BadRequest("bad_request", "invalid JSON body"))
|
|
return
|
|
}
|
|
if req.Username == "" || req.Password == "" {
|
|
writeErr(w, apierror.BadRequest("bad_request", "username and password required"))
|
|
return
|
|
}
|
|
|
|
// Checked before any lookup or bcrypt, so a throttled guess costs the
|
|
// server nothing and learns nothing.
|
|
addr := auth.ClientIP(r, h.netSettings.Hops())
|
|
if blocked, wait := h.loginGuard.Blocked(req.Username, addr); blocked {
|
|
writeRateLimited(w, wait)
|
|
return
|
|
}
|
|
|
|
q := dbq.New(h.pool)
|
|
user, err := q.GetUserByUsername(r.Context(), req.Username)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
// Same bcrypt time as a wrong password, so timing doesn't say
|
|
// which usernames exist.
|
|
auth.DummyVerify(req.Password)
|
|
h.loginGuard.Fail(req.Username, addr)
|
|
writeErr(w, apierror.Unauthorized("invalid_credentials", "invalid username or password"))
|
|
return
|
|
}
|
|
h.logger.Error("api: user lookup failed", "err", err)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
return
|
|
}
|
|
if !auth.VerifyPassword(user.PasswordHash, req.Password) {
|
|
h.loginGuard.Fail(req.Username, addr)
|
|
writeErr(w, apierror.Unauthorized("invalid_credentials", "invalid username or password"))
|
|
return
|
|
}
|
|
h.loginGuard.Succeed(req.Username)
|
|
|
|
token, err := auth.MintSessionToken()
|
|
if err != nil {
|
|
h.logger.Error("api: mint session token failed", "err", err)
|
|
writeErr(w, apierror.InternalMsg("mint failed", err))
|
|
return
|
|
}
|
|
if _, err := q.InsertSession(r.Context(), dbq.InsertSessionParams{
|
|
UserID: user.ID,
|
|
TokenHash: auth.HashSessionToken(token),
|
|
UserAgent: r.UserAgent(),
|
|
// Origin address, frozen at issue time. Compared against last_ip in
|
|
// the active-sessions surface: a session that was born somewhere the
|
|
// user recognises but is being used from somewhere they don't is the
|
|
// case this whole surface exists to surface.
|
|
Ip: addr,
|
|
}); err != nil {
|
|
h.logger.Error("api: insert session failed", "err", err)
|
|
writeErr(w, apierror.InternalMsg("insert failed", err))
|
|
return
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: auth.SessionCookieName,
|
|
Value: token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: auth.IsHTTPS(r, h.netSettings.Hops()), // plain HTTP and LAN stay functional
|
|
SameSite: http.SameSiteStrictMode,
|
|
MaxAge: int(sessionCookieMaxAge.Seconds()),
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(LoginResponse{
|
|
Token: token,
|
|
User: UserView{
|
|
ID: user.ID,
|
|
Username: user.Username,
|
|
IsAdmin: user.IsAdmin,
|
|
},
|
|
})
|
|
}
|