186 lines
6.4 KiB
Go
186 lines
6.4 KiB
Go
package api
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"felis.lolicon.best/internal/metrics"
|
|
)
|
|
|
|
// Audit rows (audit_logs, spec §6).
|
|
//
|
|
// Every row an HTTP request writes carries the acting account's id
|
|
// (actor_user_id), the caller's address (the one the sign-in limit keys on) and
|
|
// user agent; actor is display text. A failed write never fails the operation
|
|
// it records, which already happened, but it is logged and counted
|
|
// (felis_audit_write_failures_total, FelisAuditWriteFailing): a silent drop is
|
|
// how a database blip erases the trail.
|
|
|
|
const (
|
|
// auditWriteTimeout bounds one audit insert. The write outlives the caller's
|
|
// request context, so a client that hangs up right after the action cannot
|
|
// cancel its own audit row.
|
|
auditWriteTimeout = 5 * time.Second
|
|
// auditUserAgentMax bounds the stored user agent; the header is the caller's
|
|
// to write.
|
|
auditUserAgentMax = 256
|
|
// anonymousActor names a caller no account was resolved for.
|
|
anonymousActor = "anonymous"
|
|
)
|
|
|
|
// auditActor is the display name for a principal: an email only when something
|
|
// vouches for it (a session whose address was verified, or an ExternalAuth other
|
|
// than SessionAuth that resolved the principal itself), else the username. A player can set their address to anyone's before verifying it,
|
|
// so an unverified email would let them sign rows as that person.
|
|
func auditActor(p *Principal) string {
|
|
switch {
|
|
case p == nil:
|
|
return anonymousActor
|
|
case p.Email != "" && (p.EmailVerified || !p.ViaSession):
|
|
return p.Email
|
|
case p.Username != "":
|
|
return p.Username
|
|
case p.UserID != "":
|
|
return p.UserID
|
|
}
|
|
return anonymousActor
|
|
}
|
|
|
|
// audit records an action by the signed-in caller. target is the object acted
|
|
// on (a server name, a user or credential id) and lands in server_name.
|
|
func (a *API) audit(r *http.Request, action, target string) {
|
|
p := principalFromContext(r.Context())
|
|
e := AuditEntry{Actor: auditActor(p), Action: action, ServerName: target}
|
|
if p != nil {
|
|
e.ActorUserID = p.UserID
|
|
}
|
|
a.auditEntry(r, e)
|
|
}
|
|
|
|
// auditImageChange records a confirmed image change as server.patch with the
|
|
// image it replaced and the one it set, so the audit log alone can say which
|
|
// build a world ran before it was moved.
|
|
func (a *API) auditImageChange(r *http.Request, server, from, to string) {
|
|
p := principalFromContext(r.Context())
|
|
e := AuditEntry{Actor: auditActor(p), Action: "server.patch", ServerName: server}
|
|
if p != nil {
|
|
e.ActorUserID = p.UserID
|
|
}
|
|
e.Payload = auditPayload(map[string]any{"image_from": from, "image_to": to})
|
|
a.auditEntry(r, e)
|
|
}
|
|
|
|
// auditAccount records an action a pre-session door took for the account it
|
|
// resolved (u nil: none was). The username is the actor: the door has not yet
|
|
// proven anything about the address.
|
|
func (a *API) auditAccount(r *http.Request, u *StaffUser, action, target string) {
|
|
e := AuditEntry{Actor: anonymousActor, Action: action, ServerName: target}
|
|
if u != nil {
|
|
e.Actor, e.ActorUserID = u.Username, u.ID
|
|
}
|
|
a.auditEntry(r, e)
|
|
}
|
|
|
|
// auditEntry fills the request detail into e and writes it. Source defaults to
|
|
// external; internal callers set it and the component actor themselves.
|
|
func (a *API) auditEntry(r *http.Request, e AuditEntry) {
|
|
if e.Source == "" {
|
|
e.Source = "external"
|
|
}
|
|
e.RequestID = requestIDFromContext(r.Context())
|
|
if e.Source == "external" {
|
|
if ip := a.clientIP(r); ip.IsValid() {
|
|
e.ClientIP = ip.String()
|
|
}
|
|
e.UserAgent = truncateUTF8(r.UserAgent(), auditUserAgentMax)
|
|
}
|
|
a.writeAudit(r.Context(), e)
|
|
}
|
|
|
|
// writeAudit inserts e, logging and counting a failure.
|
|
func (a *API) writeAudit(ctx context.Context, e AuditEntry) {
|
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), auditWriteTimeout)
|
|
defer cancel()
|
|
if err := a.Repo.Audit(ctx, e); err != nil {
|
|
metrics.AuditWriteFailuresTotal.Inc()
|
|
log.Printf("audit: lost %s by %s (user %q, request_id=%s): %v",
|
|
e.Action, e.Actor, e.ActorUserID, e.RequestID, err)
|
|
}
|
|
}
|
|
|
|
// authFailure records one refused sign-in attempt: felis_auth_failures_total
|
|
// by door and reason, and an auth.<door>.failed row naming the account when
|
|
// the door resolved one (u nil: the signed-in caller if any, else anonymous).
|
|
// The doors keep their answers uniform so a prober learns nothing; the reason
|
|
// is for the operator.
|
|
func (a *API) authFailure(r *http.Request, door, reason string, u *StaffUser) {
|
|
metrics.AuthFailuresTotal.WithLabelValues(door, reason).Inc()
|
|
e := AuditEntry{Action: "auth." + door + ".failed", Payload: auditPayload(map[string]any{"reason": reason})}
|
|
switch p := principalFromContext(r.Context()); {
|
|
case u != nil:
|
|
e.Actor, e.ActorUserID = cmp.Or(u.Username, u.ID), u.ID
|
|
case p != nil:
|
|
e.Actor, e.ActorUserID = auditActor(p), p.UserID
|
|
default:
|
|
e.Actor = anonymousActor
|
|
}
|
|
a.auditEntry(r, e)
|
|
}
|
|
|
|
// passkeyCloneRejected records an assertion refused for a regressed signature
|
|
// counter. It keeps its own action so a cloned authenticator stands out from
|
|
// ordinary failures, and counts as a failure of its door. u nil: a signed-in
|
|
// step-up, attributed to the caller.
|
|
func (a *API) passkeyCloneRejected(r *http.Request, door string, u *StaffUser, credentialID string) {
|
|
metrics.AuthFailuresTotal.WithLabelValues(door, "clone_rejected").Inc()
|
|
if u == nil {
|
|
a.audit(r, "auth.passkey_clone_rejected", credentialID)
|
|
return
|
|
}
|
|
a.auditAccount(r, u, "auth.passkey_clone_rejected", credentialID)
|
|
}
|
|
|
|
// isOTPRefusal reports whether err is a refused code (wrong, spent, or the
|
|
// account's budget locked), as opposed to a fault.
|
|
func isOTPRefusal(err error) bool {
|
|
return errors.Is(err, ErrOTPInvalid) || errors.Is(err, ErrOTPLocked) || errors.Is(err, ErrOTPAccountLocked)
|
|
}
|
|
|
|
// otpFailureReason names a refused code for authFailure.
|
|
func otpFailureReason(err error) string {
|
|
switch {
|
|
case errors.Is(err, ErrOTPAccountLocked):
|
|
return "account_locked"
|
|
case errors.Is(err, ErrOTPLocked):
|
|
return "code_locked"
|
|
}
|
|
return "bad_code"
|
|
}
|
|
|
|
// auditPayload marshals a small detail map for AuditEntry.Payload.
|
|
func auditPayload(v map[string]any) []byte {
|
|
b, _ := json.Marshal(v)
|
|
return b
|
|
}
|
|
|
|
// truncateUTF8 makes s valid UTF-8 (a header may carry any byte, a text
|
|
// column refuses invalid sequences) and cuts it to at most n bytes on a rune
|
|
// boundary.
|
|
func truncateUTF8(s string, n int) string {
|
|
s = strings.ToValidUTF8(s, "\uFFFD")
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
for n > 0 && !utf8.RuneStart(s[n]) {
|
|
n--
|
|
}
|
|
return s[:n]
|
|
}
|