mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
`Server/go.mod` declared `github.com/owncord/server` while the public repository is `github.com/J3vb/OwnCord`. Nothing resolves that path — there is no `owncord` GitHub org and no vanity-import host serving go-import metadata for it — so every import line in the tree named a location that does not exist. It compiles because a main module's own path is never fetched, which is exactly why it went unnoticed. The obvious fix — an AST-aware import rewriter (`gomvpkg`, `go mod edit`) — is wrong here, and provably so. Six of the 722 occurrences are not imports at all: `api/main_test.go:20` (a goleak `IgnoreTopFunction` pattern), `telemetry/metrics.go:17-19` (three OTel instrumentation-scope names), `invariants/syncutil_locks.go:73` (a diagnostic message), and `invariants/syncutil_locks_test.go:56` (an import line inside a raw-string Go fixture). An import rewriter touches none of them, and the compiler cannot see any of them either. Done as one scripted substitution over `git ls-files`, anchored on the full `github.com/owncord/server` string. The anchor matters: `owncord-server` is a different identifier — the OTel `service.name` (`config/config.go`, `telemetry/telemetry_otel.go`) and the GHCR image name (`.github/workflows/release.yml`, `docker-compose.yml`) — and a looser pattern would have moved it. It is untouched: 10 occurrences across 9 files, before and after. 350 files, 728 insertions, 728 deletions. 722 occurrences in 344 Go files, plus `go.mod:1`, the `sed` at `Makefile:67`, `Server/CLAUDE.md:3`, `docs/architecture/server.md:5`, and the ledger pair (`findings-ledger.json:3758` plus a `render-ledger.mjs` re-render of `FINDINGS.md`). Zero in any workflow, zero in the Dockerfile, zero in `Server/.golangci.yml` (no `local-prefixes`, `gci`, `importas` or `depguard` rule keys on the module path, so import grouping is not configured anywhere). The plan's blast-radius estimate missed one thing, and it is the one that would have gone red: **gofmt**. `J` (0x4A) sorts before every lowercase letter, so in the 36 files where a module-local import shares a contiguous group with a third-party one, the module's imports must move above `github.com/go-chi/...`. `gofmt -l` was clean before the substitution and listed exactly 36 files after it; `gofmt -w` on those 36 restores it to clean. `gofmt` is an enforced gate — the `formatters` block in `Server/.golangci.yml`, which is S-05 — so a substitution-only commit fails Lint. Verified: both directions, and the line accounting is exact. Every added line in this diff contains the new module path (728) and every removed line contains the old one (728); the count of changed lines containing neither is **zero**, so the gofmt re-sort moved module-path lines only and touched no third-party import. The residual check (`git ls-files -z | xargs -0 grep -n 'github\.com/owncord/server'`) returns exactly two hits, both deliberately out of scope: the RL-13 row in `docs/audit-2026-08-23-repository-layout.md` and the measurement row in this phase's own plan. The compiler-invisible half was proven by reverting *only* `api/main_test.go:20` to the old path on the otherwise-renamed tree: `go build ./...` and `go vet ./api/` both still pass — they see nothing wrong — while `go test ./api/` FAILS, because the runtime function name now carries the new path and goleak stops ignoring `ws.(*Hub).Run.func1`. Restoring the line makes it pass. `go.sum` is byte-identical (no `go mod tidy` was run and none was needed). All four build-tag variants compile; `go vet ./...`, `go vet -tags otel,wazero ./...` and `go vet -tags deadlock ./...` pass; `go test -race ./...` is 16/16 packages green; `go test -tags deadlock ./...` passes; the tag-gated `./plugin/...` (wazero) and `./telemetry/...` (otel) runs pass. `golangci-lint` v2.11.3 — the pinned CI version, rebuilt locally against Go 1.26 because the packaged binary cannot load a 1.26 config — reports **0 issues**. `go run ./cmd/genprotocol` leaves `git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts` clean, so the rename does not reach the generated protocol constants. `npx prettier --check .` and `node .superpowers/render-ledger.mjs --check` pass. Not included: `docs/audit-2026-08-23-repository-layout.md` and `docs/plans/b1-repository-foundation-2026-08-25.md` keep the old path — they are the audit row and the measurement that motivated this change, and rewriting them would erase the record of what was measured. They are why the residual check needs a two-path allowance rather than being empty; that allowance is stated above rather than hidden in a pathspec. `telemetry/metrics.go:19` declares `scopeVoice` for a `Server/voice` package that does not exist; the substitution carried the dead path forward verbatim as `github.com/J3vb/OwnCord/Server/voice` rather than fixing it, because correcting a real observability bug inside a mechanical rename would hide it in a 350-file diff. It needs its own item. No `go.work`, no second module, and no vanity-import host was set up — the new path resolves against the real repository, but nothing imports this module as a library, so `go get` reachability was not exercised either way. Refs RL-13, L-12
787 lines
28 KiB
Go
787 lines
28 KiB
Go
package api
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"github.com/J3vb/OwnCord/Server/auth"
|
||
"github.com/J3vb/OwnCord/Server/db"
|
||
"github.com/J3vb/OwnCord/Server/permissions"
|
||
"github.com/J3vb/OwnCord/Server/service"
|
||
"github.com/go-chi/chi/v5"
|
||
)
|
||
|
||
// maxLoginUsernameLen bounds the username accepted by handleLogin, mirroring
|
||
// auth.ValidateUsername's 32-rune cap on registered usernames. Enforced
|
||
// before the value is ever used to build a RateLimiter map key — see the
|
||
// check in handleLogin for why.
|
||
const maxLoginUsernameLen = 32
|
||
|
||
// genericAuthError is returned for all login/register failures to avoid
|
||
// revealing whether a username exists.
|
||
var genericAuthError = errorResponse{
|
||
Error: "INVALID_CREDENTIALS",
|
||
Message: "invalid invite or credentials",
|
||
}
|
||
|
||
// registerRequest is the JSON body for POST /api/v1/auth/register.
|
||
type registerRequest struct {
|
||
Username string `json:"username"`
|
||
Password string `json:"password"`
|
||
InviteCode string `json:"invite_code"`
|
||
}
|
||
|
||
// loginRequest is the JSON body for POST /api/v1/auth/login.
|
||
type loginRequest struct {
|
||
Username string `json:"username"`
|
||
Password string `json:"password"`
|
||
}
|
||
|
||
// userResponse is the user shape included in auth responses.
|
||
type userResponse struct {
|
||
ID int64 `json:"id"`
|
||
Username string `json:"username"`
|
||
Avatar string `json:"avatar,omitempty"`
|
||
// DisplayName and About are always present (null = unset) so the settings
|
||
// form can tell "cleared" from "the server does not know this field".
|
||
DisplayName *string `json:"display_name"`
|
||
About *string `json:"about"`
|
||
// CustomStatus is the user's own free-text status line.
|
||
CustomStatus *string `json:"custom_status"`
|
||
// Status is the user's own true status, invisible included. This response
|
||
// only ever describes the caller, so there is nothing to hide from them.
|
||
Status string `json:"status"`
|
||
RoleID int64 `json:"role_id"`
|
||
TOTPEnabled bool `json:"totp_enabled"`
|
||
CreatedAt string `json:"created_at"`
|
||
}
|
||
|
||
// authSuccessResponse is returned on successful login/register.
|
||
type authSuccessResponse struct {
|
||
Token string `json:"token,omitempty"`
|
||
PartialToken string `json:"partial_token,omitempty"`
|
||
Requires2FA bool `json:"requires_2fa"`
|
||
User *userResponse `json:"user,omitempty"`
|
||
}
|
||
|
||
// AuthBroadcaster is the interface handleDeleteAccount uses to notify
|
||
// connected WebSocket clients that an account is gone. Satisfied by *ws.Hub
|
||
// (which already implements BroadcastMemberBan for the admin ban path this
|
||
// mirrors).
|
||
type AuthBroadcaster interface {
|
||
BroadcastMemberBan(userID int64)
|
||
}
|
||
|
||
// MountAuthRoutes registers all auth endpoints on the given router.
|
||
// Rate limiters are applied per-endpoint as specified. trustedProxies is the
|
||
// list of CIDRs whose X-Forwarded-For / X-Real-IP headers are honoured for
|
||
// rate-limiting IP resolution. totpKey is the AES-256 key used to encrypt
|
||
// TOTP secrets at rest (M1 security hardening).
|
||
//
|
||
// broadcaster is variadic and optional: MountAuthRoutes is called before the
|
||
// hub exists (router.go mounts auth routes first, and the hub needs the
|
||
// router to register its own webhook route), so a caller that cannot supply
|
||
// one yet may omit it entirely and self-deletion simply sends no event,
|
||
// exactly like today. A caller mounted after hub creation should pass it so
|
||
// DELETE /api/v1/auth/account can broadcast the same member_ban event the
|
||
// admin ban path already sends for the identical anonymise-and-ban DB state.
|
||
func MountAuthRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, trustedProxies []string, totpKey []byte, broadcaster ...AuthBroadcaster) {
|
||
var ab AuthBroadcaster
|
||
if len(broadcaster) > 0 {
|
||
ab = broadcaster[0]
|
||
}
|
||
registerLimiter := limiter
|
||
loginLimiter := limiter
|
||
partialStore := auth.NewPartialAuthStore(partialAuthStoreTTL)
|
||
pendingTOTPStore := auth.NewPendingTOTPStore(pendingTOTPStoreTTL)
|
||
usedTOTPCodes := auth.NewUsedTOTPCodeStore()
|
||
|
||
r.Route("/api/v1/auth", func(r chi.Router) {
|
||
r.With(RateLimitMiddleware(registerLimiter, "register:", scaledAuthLimit(registerRateLimitPerMinute), time.Minute, trustedProxies)).
|
||
Post("/register", handleRegister(database, trustedProxies))
|
||
|
||
r.With(RateLimitMiddleware(loginLimiter, "login:", scaledAuthLimit(loginRateLimitPerMinute), time.Minute, trustedProxies)).
|
||
Post("/login", handleLogin(database, limiter, partialStore, trustedProxies))
|
||
|
||
r.With(RateLimitMiddleware(limiter, "totp_verify:", scaledAuthLimit(verifyTOTPRateLimitPerMinute), time.Minute, trustedProxies)).
|
||
Post("/verify-totp", handleVerifyTOTP(database, partialStore, limiter, usedTOTPCodes, totpKey))
|
||
|
||
r.With(AuthMiddleware(database)).
|
||
Post("/logout", handleLogout(database))
|
||
|
||
r.With(AuthMiddleware(database)).
|
||
Get("/me", handleMe())
|
||
|
||
r.With(AuthMiddleware(database),
|
||
RateLimitMiddleware(limiter, "del_account:", scaledAuthLimit(sensitiveEndpointRateLimitPerMinute), time.Minute, trustedProxies)).
|
||
Delete("/account", handleDeleteAccount(database, limiter, ab))
|
||
})
|
||
|
||
r.With(AuthMiddleware(database),
|
||
RateLimitMiddleware(limiter, "totp:", scaledAuthLimit(sensitiveEndpointRateLimitPerMinute), time.Minute, trustedProxies)).
|
||
Post("/api/v1/users/me/totp/enable", handleEnableTOTP(pendingTOTPStore, limiter))
|
||
|
||
r.With(AuthMiddleware(database),
|
||
RateLimitMiddleware(limiter, "totp:", scaledAuthLimit(sensitiveEndpointRateLimitPerMinute), time.Minute, trustedProxies)).
|
||
Post("/api/v1/users/me/totp/confirm", handleConfirmTOTP(database, pendingTOTPStore, usedTOTPCodes, limiter, totpKey))
|
||
|
||
r.With(AuthMiddleware(database),
|
||
RateLimitMiddleware(limiter, "totp:", scaledAuthLimit(sensitiveEndpointRateLimitPerMinute), time.Minute, trustedProxies)).
|
||
Delete("/api/v1/users/me/totp", handleDisableTOTP(database, pendingTOTPStore, limiter))
|
||
}
|
||
|
||
// handleRegister processes POST /api/v1/auth/register.
|
||
func handleRegister(database *db.DB, trustedProxies []string) http.HandlerFunc {
|
||
proxyNets := parseCIDRList(trustedProxies) // W3-3a: parse once at construction
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
if !registerPolicyGate(w, r, database) {
|
||
return
|
||
}
|
||
|
||
req, ok := registerReadRequest(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
// Hash password before consuming the invite so that a hashing failure
|
||
// does not burn a valid invite code.
|
||
hash, err := auth.HashPassword(req.Password)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||
Error: "INTERNAL_ERROR",
|
||
Message: "failed to process registration",
|
||
})
|
||
return
|
||
}
|
||
|
||
// Atomically consume the invite and create the user so failed
|
||
// registrations do not burn a valid invite code.
|
||
uid, err := database.CreateUserWithInvite(r.Context(), req.Username, hash, int(permissions.MemberRoleID), req.InviteCode)
|
||
if err != nil {
|
||
// UNIQUE constraint violation → duplicate username → 400.
|
||
// Any other DB error → 500.
|
||
switch {
|
||
case db.IsUniqueConstraintError(err):
|
||
writeJSON(w, http.StatusBadRequest, genericAuthError)
|
||
case errors.Is(err, db.ErrNotFound):
|
||
writeJSON(w, http.StatusBadRequest, genericAuthError)
|
||
default:
|
||
slog.Error("CreateUserWithInvite failed", "err", err, "username", req.Username)
|
||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||
Error: "INTERNAL_ERROR",
|
||
Message: "registration failed — please try again",
|
||
})
|
||
}
|
||
return
|
||
}
|
||
|
||
ip := clientIPWithProxies(r, proxyNets)
|
||
slog.Info("user registered", "username", req.Username, "user_id", uid, "ip", ip)
|
||
db.WriteAudit(context.WithoutCancel(r.Context()), database, uid, "user_register", "user", uid,
|
||
"new account created via invite")
|
||
|
||
// Issue session.
|
||
token, err := auth.GenerateToken()
|
||
if err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||
Error: "INTERNAL_ERROR",
|
||
Message: "failed to create session",
|
||
})
|
||
return
|
||
}
|
||
|
||
device := truncateDevice(r.Header.Get("User-Agent"))
|
||
if _, err := database.CreateSession(r.Context(), uid, auth.HashToken(token), device, ip); err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||
Error: "INTERNAL_ERROR",
|
||
Message: "failed to create session",
|
||
})
|
||
return
|
||
}
|
||
|
||
user, err := database.GetUserByID(r.Context(), uid)
|
||
if err != nil || user == nil {
|
||
slog.Error("failed to fetch user after registration", "user_id", uid, "error", err)
|
||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||
Error: "INTERNAL_ERROR",
|
||
Message: "registration succeeded but user fetch failed",
|
||
})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, authSuccessResponse{
|
||
Token: token,
|
||
Requires2FA: false,
|
||
User: toUserResponse(user),
|
||
})
|
||
}
|
||
}
|
||
|
||
// registerPolicyGate reports whether registration is currently permitted,
|
||
// writing the refusal response itself when it is not.
|
||
func registerPolicyGate(w http.ResponseWriter, r *http.Request, database *db.DB) bool {
|
||
registrationOpen, err := isRegistrationOpen(r.Context(), database)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||
Error: "INTERNAL_ERROR",
|
||
Message: "failed to load registration policy",
|
||
})
|
||
return false
|
||
}
|
||
if !registrationOpen {
|
||
writeJSON(w, http.StatusForbidden, errorResponse{
|
||
Error: "FORBIDDEN",
|
||
Message: "registration is currently closed",
|
||
})
|
||
return false
|
||
}
|
||
|
||
require2FA, err := isRequire2FAEnabled(r.Context(), database)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||
Error: "INTERNAL_ERROR",
|
||
Message: "failed to load registration policy",
|
||
})
|
||
return false
|
||
}
|
||
if require2FA {
|
||
writeJSON(w, http.StatusForbidden, errorResponse{
|
||
Error: "FORBIDDEN",
|
||
Message: "registration is unavailable while two-factor authentication is required",
|
||
})
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// registerReadRequest decodes and validates the registration body, writing the
|
||
// rejection response itself when the input cannot be used.
|
||
func registerReadRequest(w http.ResponseWriter, r *http.Request) (registerRequest, bool) {
|
||
var req registerRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||
Error: "INVALID_INPUT",
|
||
Message: "malformed request body",
|
||
})
|
||
return req, false
|
||
}
|
||
|
||
// OC-0151: bound the raw field before it ever reaches the fixpoint
|
||
// sanitizer below. sanitizeToFixpoint's cost is quadratic in input
|
||
// length (nested HTML entities force roughly one extra pass per two
|
||
// nesting levels), so an unauthenticated caller could otherwise pin a
|
||
// core for minutes with one oversized username, all before
|
||
// auth.ValidateUsername's 32-rune cap ever runs. This is a cheap
|
||
// byte-length pre-check — *4 still admits any legitimate 32-rune UTF-8
|
||
// username — mirroring sanitizeContent's raw-length bound in
|
||
// service/message.go and loginReadRequest's username bound below.
|
||
if len(req.Username) > maxLoginUsernameLen*4 {
|
||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||
Error: "INVALID_INPUT",
|
||
Message: "username is too long",
|
||
})
|
||
return req, false
|
||
}
|
||
|
||
// F: use the fixpoint sanitizer (service.SanitizeText), not a bare
|
||
// bluemonday.StrictPolicy().Sanitize call — Sanitize's output is always HTML-escaped
|
||
// (' -> ', & -> &, " -> "), so a plain call here would store
|
||
// a different string than what handleLogin looks up (which only
|
||
// trims), permanently locking out any username containing one of
|
||
// those characters. See service.SanitizeText's doc comment.
|
||
req.Username = strings.TrimSpace(service.SanitizeText(req.Username))
|
||
req.InviteCode = strings.TrimSpace(req.InviteCode)
|
||
|
||
if req.Username == "" || req.Password == "" || req.InviteCode == "" {
|
||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||
Error: "INVALID_INPUT",
|
||
Message: "username, password, and invite_code are required",
|
||
})
|
||
return req, false
|
||
}
|
||
|
||
// Validate username format (length, no control/invisible chars).
|
||
if err := auth.ValidateUsername(req.Username); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||
Error: "INVALID_INPUT",
|
||
Message: err.Error(),
|
||
})
|
||
return req, false
|
||
}
|
||
|
||
// Validate password strength before anything else.
|
||
if err := auth.ValidatePasswordStrength(req.Password); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||
Error: "INVALID_INPUT",
|
||
Message: err.Error(),
|
||
})
|
||
return req, false
|
||
}
|
||
return req, true
|
||
}
|
||
|
||
// handleLogin processes POST /api/v1/auth/login.
|
||
func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth.PartialAuthStore, trustedProxies []string) http.HandlerFunc {
|
||
proxyNets := parseCIDRList(trustedProxies) // W3-3a: parse once at construction
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
req, ok := loginReadRequest(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
ip := clientIPWithProxies(r, proxyNets)
|
||
|
||
user, ok := loginAuthenticate(w, r, database, limiter, req, ip)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
if auth.IsEffectivelyBanned(user) {
|
||
slog.Warn("banned user login attempt", "username", user.Username, "user_id", user.ID, "ip", ip)
|
||
db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "login_blocked_banned", "user", user.ID,
|
||
"banned user attempted login from "+ip)
|
||
writeJSON(w, http.StatusForbidden, errorResponse{
|
||
Error: "FORBIDDEN",
|
||
Message: "your account has been suspended",
|
||
})
|
||
return
|
||
}
|
||
|
||
require2FA, err := isRequire2FAEnabled(r.Context(), database)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||
Error: "INTERNAL_ERROR",
|
||
Message: "failed to load authentication policy",
|
||
})
|
||
return
|
||
}
|
||
if user.TOTPSecret != nil {
|
||
partialToken, err := partialStore.Issue(user.ID, truncateDevice(r.Header.Get("User-Agent")), ip)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||
Error: "INTERNAL_ERROR",
|
||
Message: "failed to start two-factor challenge",
|
||
})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, authSuccessResponse{
|
||
PartialToken: partialToken,
|
||
Requires2FA: true,
|
||
})
|
||
return
|
||
}
|
||
if require2FA {
|
||
writeJSON(w, http.StatusForbidden, errorResponse{
|
||
Error: "FORBIDDEN",
|
||
Message: "two-factor authentication must be enabled on this account before login",
|
||
})
|
||
return
|
||
}
|
||
|
||
// Issue session.
|
||
token, err := issueSession(r.Context(), database, user.ID, truncateDevice(r.Header.Get("User-Agent")), ip)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||
Error: "INTERNAL_ERROR",
|
||
Message: "failed to create session",
|
||
})
|
||
return
|
||
}
|
||
|
||
// Don't set status to "online" here — the WebSocket connection in
|
||
// serve.go does that when the user actually connects. Setting it here
|
||
// would leave the user permanently "online" if they never open a WS
|
||
// connection or if the client crashes before connecting.
|
||
slog.Info("user logged in", "username", user.Username, "user_id", user.ID, "ip", ip)
|
||
db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "user_login", "user", user.ID,
|
||
"logged in from "+ip)
|
||
writeJSON(w, http.StatusOK, authSuccessResponse{
|
||
Token: token,
|
||
Requires2FA: false,
|
||
User: toUserResponse(user),
|
||
})
|
||
}
|
||
}
|
||
|
||
// loginReadRequest decodes and validates the login body, writing the rejection
|
||
// response itself when the input cannot be used.
|
||
func loginReadRequest(w http.ResponseWriter, r *http.Request) (loginRequest, bool) {
|
||
var req loginRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||
Error: "INVALID_INPUT",
|
||
Message: "malformed request body",
|
||
})
|
||
return req, false
|
||
}
|
||
|
||
req.Username = strings.TrimSpace(req.Username)
|
||
// Do NOT trim req.Password — passwords may intentionally contain
|
||
// leading/trailing whitespace. Bcrypt handles arbitrary bytes.
|
||
|
||
if req.Username == "" || req.Password == "" {
|
||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||
Error: "INVALID_INPUT",
|
||
Message: "username and password are required",
|
||
})
|
||
return req, false
|
||
}
|
||
|
||
// F: reject an over-long username before it is ever used to build a
|
||
// RateLimiter map key below (unameKey, failKey, userFailKey, lockout
|
||
// keys). Unlike registration, login has no account to validate
|
||
// against yet, so nothing else bounds this value — an unauthenticated
|
||
// caller could otherwise pin an arbitrarily large, body-sized string
|
||
// as a retained key (Cleanup only evicts it after hours). Mirrors the
|
||
// same 32-rune cap auth.ValidateUsername enforces at registration.
|
||
if utf8.RuneCountInString(req.Username) > maxLoginUsernameLen {
|
||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||
Error: "INVALID_INPUT",
|
||
Message: "username is too long",
|
||
})
|
||
return req, false
|
||
}
|
||
return req, true
|
||
}
|
||
|
||
// loginAuthenticate runs the lockout gates, the constant-time password compare
|
||
// and the failure accounting for one login attempt. It returns the
|
||
// authenticated user, or false after writing the rejection response itself.
|
||
func loginAuthenticate(w http.ResponseWriter, r *http.Request, database *db.DB, limiter *auth.RateLimiter, req loginRequest, ip string) (*db.User, bool) {
|
||
// Check per-IP lockout first.
|
||
lockKey := "login_lock:" + ip
|
||
if limiter.IsLockedOut(lockKey) {
|
||
writeJSON(w, http.StatusTooManyRequests, errorResponse{
|
||
Error: "RATE_LIMITED",
|
||
Message: "account temporarily locked due to too many failed attempts",
|
||
})
|
||
return nil, false
|
||
}
|
||
|
||
// BUG-110: Also check per-username lockout to prevent distributed brute force.
|
||
// F1: canonicalize the username the same way GetUserByUsername does (COLLATE
|
||
// NOCASE) before keying the lockout, so case variants of one account
|
||
// (admin/Admin/ADMIN) share a single bucket instead of each getting its own.
|
||
unameKey := strings.ToLower(req.Username)
|
||
userLockKey := "login_user_lock:" + unameKey
|
||
if limiter.IsLockedOut(userLockKey) {
|
||
writeJSON(w, http.StatusTooManyRequests, errorResponse{
|
||
Error: "RATE_LIMITED",
|
||
Message: "account temporarily locked due to too many failed attempts",
|
||
})
|
||
return nil, false
|
||
}
|
||
|
||
// Constant-time lookup: always attempt bcrypt compare even when user
|
||
// does not exist to prevent timing-based username enumeration.
|
||
user, err := database.GetUserByUsername(r.Context(), req.Username)
|
||
|
||
// Distinguish DB errors from authentication failures. DB errors
|
||
// should NOT increment the rate limiter — otherwise a transient
|
||
// DB outage would lock out legitimate users.
|
||
if err != nil && user == nil {
|
||
// Could be a real DB error or simply "user not found".
|
||
// GetUserByUsername returns (nil, nil) for not-found, so a
|
||
// non-nil error here is a genuine DB failure.
|
||
slog.Error("login: GetUserByUsername failed", "err", err, "ip", ip)
|
||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||
Error: "INTERNAL_ERROR",
|
||
Message: "login temporarily unavailable",
|
||
})
|
||
return nil, false
|
||
}
|
||
|
||
failKey := "login_fail:" + ip
|
||
userFailKey := "login_user_fail:" + unameKey
|
||
// F3: atomically reserve this attempt BEFORE the bcrypt compare. The
|
||
// read-only IsLockedOut gates above are check-then-act: N concurrent
|
||
// requests all pass them before any failure is recorded below, so the
|
||
// per-username cap — the only cross-IP brute-force defence — bound
|
||
// only sequential attackers. Allow records the attempt under the
|
||
// limiter's lock, capping a concurrent burst at the same budget a
|
||
// sequential attacker gets. Sized at threshold+1 so the sequential
|
||
// accepted-input set is unchanged: failures 1–10 still land, the 10th
|
||
// still trips the lockout (via the Check below), and a correct
|
||
// password on attempt 10 still succeeds — successful logins reset
|
||
// both counters. The reservation sits after the DB-error return above
|
||
// so a transient DB outage still does not consume attempts.
|
||
if !limiter.Allow(failKey, scaledAuthLimit(loginFailureThreshold)+1, loginFailureWindow) ||
|
||
!limiter.Allow(userFailKey, loginUserFailureThreshold+1, loginUserFailureWindow) {
|
||
writeJSON(w, http.StatusTooManyRequests, errorResponse{
|
||
Error: "RATE_LIMITED",
|
||
Message: "account temporarily locked due to too many failed attempts",
|
||
})
|
||
return nil, false
|
||
}
|
||
// Always run the password check — with an empty hash when the user does
|
||
// not exist. auth.CheckPassword performs a dummy bcrypt comparison for an
|
||
// empty hash, so bcrypt executes on every path and response time stays
|
||
// constant, preventing timing-based username enumeration. (A `user == nil
|
||
// || CheckPassword(...)` short-circuit would skip bcrypt entirely for
|
||
// unknown usernames, reintroducing the timing side-channel.)
|
||
storedHash := ""
|
||
if user != nil {
|
||
storedHash = user.PasswordHash
|
||
}
|
||
if !auth.CheckPassword(storedHash, req.Password) {
|
||
// The attempt was already recorded atomically up-front (F3); here
|
||
// only decide the lockouts, at the same boundary as before: the
|
||
// 10th in-window failure locks the key. Check is read-only, so
|
||
// the reservation is not double-counted.
|
||
if !limiter.Check(failKey, scaledAuthLimit(loginFailureThreshold)+1, loginFailureWindow) {
|
||
limiter.Lockout(r.Context(), lockKey, loginLockoutDuration)
|
||
}
|
||
// BUG-110: per-username lockout on threshold.
|
||
if !limiter.Check(userFailKey, loginUserFailureThreshold+1, loginUserFailureWindow) {
|
||
limiter.Lockout(r.Context(), userLockKey, loginUserLockoutDuration)
|
||
}
|
||
slog.Info("login failed", "ip", ip, "username_len", len(req.Username))
|
||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||
Error: "UNAUTHORIZED",
|
||
Message: "invalid credentials",
|
||
})
|
||
return nil, false
|
||
}
|
||
|
||
// Reset failure counters on success.
|
||
limiter.Reset(r.Context(), failKey)
|
||
limiter.Reset(r.Context(), userFailKey)
|
||
return user, true
|
||
}
|
||
|
||
// handleLogout processes POST /api/v1/auth/logout.
|
||
func handleLogout(database *db.DB) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
sess, ok := r.Context().Value(SessionKey).(*db.Session)
|
||
if !ok || sess == nil {
|
||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||
Error: "UNAUTHORIZED",
|
||
Message: "not authenticated",
|
||
})
|
||
return
|
||
}
|
||
|
||
// The client clears its token optimistically — once logout reaches the
|
||
// server, the revocation must not die with a dropped connection.
|
||
if err := database.DeleteSession(context.WithoutCancel(r.Context()), sess.TokenHash); err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||
Error: "INTERNAL_ERROR",
|
||
Message: "failed to logout",
|
||
})
|
||
return
|
||
}
|
||
|
||
// A custom status is a "what I am doing right now" note. Leaving it
|
||
// standing after the user signed out states something about them that
|
||
// is no longer true, so logout clears it — unlike the chosen presence
|
||
// status, which is a preference and deliberately survives.
|
||
if err := database.UpdateUserCustomStatus(context.WithoutCancel(r.Context()), sess.UserID, nil); err != nil {
|
||
slog.Warn("failed to clear custom status on logout", "user_id", sess.UserID, "err", err)
|
||
}
|
||
|
||
slog.Info("user logged out", "user_id", sess.UserID)
|
||
db.WriteAudit(context.WithoutCancel(r.Context()), database, sess.UserID, "user_logout", "user", sess.UserID, "")
|
||
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
}
|
||
|
||
// handleMe processes GET /api/v1/auth/me.
|
||
func handleMe() http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := r.Context().Value(UserKey).(*db.User)
|
||
if !ok || user == nil {
|
||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||
Error: "UNAUTHORIZED",
|
||
Message: "not authenticated",
|
||
})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, toUserResponse(user))
|
||
}
|
||
}
|
||
|
||
// deleteAccountRequest is the JSON body for DELETE /api/v1/auth/account.
|
||
type deleteAccountRequest struct {
|
||
Password string `json:"password"`
|
||
}
|
||
|
||
// handleDeleteAccount processes DELETE /api/v1/auth/account.
|
||
// The caller must supply their current password for confirmation.
|
||
// Progressive lockout mirrors the login handler: 3 failures → 15-min lock.
|
||
// broadcaster may be nil, in which case no event is sent and other connected
|
||
// clients converge on their next reconnect instead (same fallback every
|
||
// other broadcaster-optional handler in this package uses).
|
||
func handleDeleteAccount(database *db.DB, limiter *auth.RateLimiter, broadcaster AuthBroadcaster) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := r.Context().Value(UserKey).(*db.User)
|
||
if !ok || user == nil {
|
||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||
Error: "UNAUTHORIZED",
|
||
Message: "not authenticated",
|
||
})
|
||
return
|
||
}
|
||
|
||
// Per-user lockout to prevent password brute-force on this destructive endpoint.
|
||
lockKey := auth.Key("delete_lock", user.ID)
|
||
if limiter.IsLockedOut(lockKey) {
|
||
writeJSON(w, http.StatusTooManyRequests, errorResponse{
|
||
Error: "RATE_LIMITED",
|
||
Message: "too many failed attempts, try again later",
|
||
})
|
||
return
|
||
}
|
||
|
||
var req deleteAccountRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||
Error: "INVALID_INPUT",
|
||
Message: "malformed request body",
|
||
})
|
||
return
|
||
}
|
||
|
||
if req.Password == "" {
|
||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||
Error: "INVALID_INPUT",
|
||
Message: "password is required",
|
||
})
|
||
return
|
||
}
|
||
|
||
// Verify the supplied password matches the stored hash.
|
||
failKey := auth.Key("delete_fail", user.ID)
|
||
if !auth.CheckPassword(user.PasswordHash, req.Password) {
|
||
if !limiter.Allow(failKey, deleteAccountFailureThreshold, deleteAccountFailureWindow) {
|
||
limiter.Lockout(r.Context(), lockKey, deleteAccountLockoutDuration)
|
||
}
|
||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||
Error: "INVALID_INPUT",
|
||
Message: "incorrect password",
|
||
})
|
||
return
|
||
}
|
||
limiter.Reset(r.Context(), failKey)
|
||
|
||
if err := database.DeleteAccount(r.Context(), user.ID); err != nil {
|
||
if errors.Is(err, db.ErrLastAdmin) {
|
||
writeJSON(w, http.StatusForbidden, errorResponse{
|
||
Error: "FORBIDDEN",
|
||
Message: "cannot delete the last admin account",
|
||
})
|
||
return
|
||
}
|
||
slog.Error("DeleteAccount failed", "err", err, "user_id", user.ID)
|
||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||
Error: "INTERNAL_ERROR",
|
||
Message: "failed to delete account",
|
||
})
|
||
return
|
||
}
|
||
|
||
ip := clientIP(r)
|
||
slog.Info("account deleted", "username", user.Username, "user_id", user.ID, "ip", ip)
|
||
db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "account_deleted", "user", user.ID,
|
||
"account self-deleted from "+ip)
|
||
|
||
// DeleteAccount left the row in exactly the state an admin ban does
|
||
// (anonymised, banned, sessions revoked) — broadcast the same event so
|
||
// every other connected client drops the deleted user immediately
|
||
// instead of keeping their pre-deletion username until it reconnects.
|
||
if broadcaster != nil {
|
||
broadcaster.BroadcastMemberBan(user.ID)
|
||
}
|
||
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
}
|
||
|
||
// toUserResponse converts a db.User to the API response shape.
|
||
func toUserResponse(u *db.User) *userResponse {
|
||
avatar := ""
|
||
if u.Avatar != nil {
|
||
avatar = *u.Avatar
|
||
}
|
||
resp := &userResponse{
|
||
ID: u.ID,
|
||
Username: u.Username,
|
||
Avatar: avatar,
|
||
DisplayName: u.DisplayName,
|
||
About: u.About,
|
||
CustomStatus: u.CustomStatus,
|
||
Status: u.Status,
|
||
RoleID: u.RoleID,
|
||
TOTPEnabled: u.TOTPSecret != nil,
|
||
CreatedAt: u.CreatedAt,
|
||
}
|
||
return resp
|
||
}
|
||
|
||
// truncateDevice truncates the User-Agent to prevent oversized session records.
|
||
const maxDeviceLen = 512
|
||
|
||
func truncateDevice(ua string) string {
|
||
if len(ua) > maxDeviceLen {
|
||
return ua[:maxDeviceLen]
|
||
}
|
||
return ua
|
||
}
|
||
|
||
func issueSession(ctx context.Context, database *db.DB, userID int64, device, ip string) (string, error) {
|
||
token, err := auth.GenerateToken()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if _, err := database.CreateSession(ctx, userID, auth.HashToken(token), device, ip); err != nil {
|
||
return "", err
|
||
}
|
||
return token, nil
|
||
}
|
||
|
||
func isRequire2FAEnabled(ctx context.Context, database *db.DB) (bool, error) {
|
||
return getBooleanSetting(ctx, database, "require_2fa", false)
|
||
}
|
||
|
||
func isRegistrationOpen(ctx context.Context, database *db.DB) (bool, error) {
|
||
return getBooleanSetting(ctx, database, "registration_open", true)
|
||
}
|
||
|
||
func getBooleanSetting(ctx context.Context, database *db.DB, key string, defaultValue bool) (bool, error) {
|
||
value, err := database.GetSetting(ctx, key)
|
||
if err != nil {
|
||
if errors.Is(err, db.ErrNotFound) {
|
||
return defaultValue, nil
|
||
}
|
||
return false, err
|
||
}
|
||
return parseBooleanSettingValue(value)
|
||
}
|
||
|
||
func parseBooleanSettingValue(value string) (bool, error) {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "1", "true":
|
||
return true, nil
|
||
case "0", "false":
|
||
return false, nil
|
||
default:
|
||
return false, fmt.Errorf("invalid boolean setting value %q", value)
|
||
}
|
||
}
|
||
|
||
func requirePasswordConfirmation(user *db.User, password string) error {
|
||
if password == "" {
|
||
return fmt.Errorf("password is required")
|
||
}
|
||
if !auth.CheckPassword(user.PasswordHash, password) {
|
||
return fmt.Errorf("password confirmation failed")
|
||
}
|
||
return nil
|
||
}
|