mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(ws): 1 defect(s) (OC-0001) * fix(db): 1 defect(s) (OC-0002) sanitizeFTSQuery filtered only characters, so FTS5's bareword boolean keywords (AND, OR, NOT) reached MATCH as operators; a query in an invalid operator position raised "fts5: syntax error" instead of returning results. Drop those bareword tokens after sanitizing. * fix(dm): 1 defect(s) (OC-0004) * fix(ws): 1 defect(s) (OC-0005) * fix(voice): 1 defect(s) (OC-0006) Count the shared voice_max_video budget in streams rather than rows: a single user publishing both camera and screenshare consumed one slot while producing two live streams, letting a channel over-admit up to 2N streams against an N-stream cap. * fix(client): 2 defect(s) (OC-0007, OC-0009) OC-0007: mark the active channel loading before invalidating its message window on a full-ready resync, so MessageList shows the spinner instead of the empty-channel state for the duration of the refetch. OC-0009: fan USER_UPDATE renames out to voiceStore.voiceUsers, which keeps its own frozen username copy, so the voice roster no longer shows a stale name for the rest of the call. * fix(admin): 1 defect(s) (OC-0010) * fix(identity): 1 defect(s) (OC-0011) * fix(ws): 1 defect(s) (OC-0003) The public half of an invisible user's presence (PresenceOthersEvent, and BroadcastPresence's own mapped payload) went out via broadcastExcludeLow on the low-priority queue - the ephemeral, unsequenced, drop-on-overflow transport built for typing indicators - while every other source of the same user's presence shares the normal-priority queue. That split one user's presence across two per-client FIFOs with different durability and different drain order (writePump drains normal strictly before low), so a frame could land out of order against a later connect/disconnect presence frame, or be silently dropped with no replay recovery. Adds Hub.BroadcastToAllExcept, which routes through the same h.broadcast channel and seqMu-serialized deliverBroadcast as BroadcastToAll, carrying an excludeUserID that deliverBroadcast applies via pubsub.Publish(TopicGlobal, msg, excludeUserID). * fix(ws): 1 defect(s) (OC-0008) --------- Co-authored-by: Claude <noreply@anthropic.com>
464 lines
16 KiB
Go
464 lines
16 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/owncord/server/auth"
|
|
"github.com/owncord/server/db"
|
|
)
|
|
|
|
// ─── TOTP request/response types ─────────────────────────────────────────────
|
|
|
|
type verifyTotpRequest struct {
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
type passwordConfirmationRequest struct {
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type totpConfirmationRequest struct {
|
|
Password string `json:"password"`
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
type totpEnableResponse struct {
|
|
QRURI string `json:"qr_uri"`
|
|
BackupCodes []string `json:"backup_codes"`
|
|
}
|
|
|
|
// ─── Handlers ────────────────────────────────────────────────────────────────
|
|
|
|
func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limiter *auth.RateLimiter, usedTOTPCodes *auth.UsedTOTPCodeStore, totpKey []byte) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
partialToken, ok := auth.ExtractBearerToken(r)
|
|
if !ok {
|
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
|
Error: "UNAUTHORIZED",
|
|
Message: "missing or invalid authorization header",
|
|
})
|
|
return
|
|
}
|
|
|
|
challenge, ok := partialStore.Lookup(partialToken)
|
|
if !ok {
|
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
|
Error: "UNAUTHORIZED",
|
|
Message: "invalid or expired two-factor challenge",
|
|
})
|
|
return
|
|
}
|
|
|
|
var req verifyTotpRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "INVALID_INPUT",
|
|
Message: "malformed request body",
|
|
})
|
|
return
|
|
}
|
|
|
|
totpRateLimitKey := auth.Key("totp_fail", challenge.UserID)
|
|
// Atomically record this attempt and reject once the per-user failure cap
|
|
// is reached. Recording up-front — rather than a read-only Check now and
|
|
// Allow only on failure — closes a TOCTOU where many concurrent requests
|
|
// reusing one valid partial token all pass the read-only check before any
|
|
// failure is recorded, defeating the per-user brute-force cap (the only
|
|
// cross-IP defence). A successful verification resets the counter below,
|
|
// so legitimate retries are not penalised.
|
|
// Deliberately NOT scaledAuthLimit: this cap is keyed per USER, and it
|
|
// is the only cross-IP brute-force defence on TOTP codes. The
|
|
// multiplier exists for shared-NAT per-IP limits; scaling a per-user
|
|
// threshold with it would hand a distributed attacker more guesses.
|
|
// Mirrors loginUserFailureThreshold staying unscaled in auth_handler.
|
|
if !limiter.Allow(totpRateLimitKey, totpFailureRateLimit, totpFailureWindow) {
|
|
writeJSON(w, http.StatusTooManyRequests, errorResponse{
|
|
Error: "RATE_LIMITED",
|
|
Message: "too many failed attempts, try again later",
|
|
})
|
|
return
|
|
}
|
|
|
|
user, err := database.GetUserByID(r.Context(), challenge.UserID)
|
|
if err != nil || user == nil || user.TOTPSecret == nil {
|
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
|
Error: "UNAUTHORIZED",
|
|
Message: "invalid or expired two-factor challenge",
|
|
})
|
|
return
|
|
}
|
|
|
|
// A ban can land inside the partial-token window; the login path
|
|
// refuses banned users right after the password compare, so the
|
|
// second factor must refuse them too.
|
|
if auth.IsEffectivelyBanned(user) {
|
|
writeJSON(w, http.StatusForbidden, errorResponse{
|
|
Error: "FORBIDDEN",
|
|
Message: "your account has been suspended",
|
|
})
|
|
return
|
|
}
|
|
|
|
secret, decErr := auth.DecryptTOTPSecret(totpKey, *user.TOTPSecret)
|
|
if decErr != nil {
|
|
slog.Error("failed to decrypt TOTP secret", "user_id", user.ID, "error", decErr)
|
|
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
|
Error: "INTERNAL_ERROR",
|
|
Message: "failed to verify two-factor code",
|
|
})
|
|
return
|
|
}
|
|
|
|
if !auth.VerifyTOTPCodeOnce(secret, strings.TrimSpace(req.Code), time.Now().UTC(), user.ID, usedTOTPCodes) {
|
|
// The attempt was already recorded atomically up-front via
|
|
// limiter.Allow; only the per-partial-token counter is advanced here.
|
|
partialStore.RegisterFailure(partialToken, partialAuthMaxFailures)
|
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
|
Error: "UNAUTHORIZED",
|
|
Message: "invalid two-factor code",
|
|
})
|
|
return
|
|
}
|
|
|
|
limiter.Reset(r.Context(), totpRateLimitKey)
|
|
|
|
if _, ok := partialStore.Consume(partialToken); !ok {
|
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
|
Error: "UNAUTHORIZED",
|
|
Message: "invalid or expired two-factor challenge",
|
|
})
|
|
return
|
|
}
|
|
|
|
token, err := issueSession(r.Context(), database, user.ID, challenge.Device, challenge.IP)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
|
Error: "INTERNAL_ERROR",
|
|
Message: "failed to create session",
|
|
})
|
|
return
|
|
}
|
|
|
|
slog.Info("totp verified", "user_id", user.ID, "ip", challenge.IP)
|
|
db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "totp_verified", "user", user.ID,
|
|
"two-factor verification completed from "+challenge.IP)
|
|
|
|
writeJSON(w, http.StatusOK, authSuccessResponse{
|
|
Token: token,
|
|
Requires2FA: false,
|
|
User: toUserResponse(user),
|
|
})
|
|
}
|
|
}
|
|
|
|
func handleEnableTOTP(pendingStore *auth.PendingTOTPStore, limiter *auth.RateLimiter) 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
|
|
}
|
|
|
|
// BUG-111: Per-user lockout for password confirmation.
|
|
lockKey := auth.Key("pw_confirm_lock", user.ID)
|
|
if limiter.IsLockedOut(lockKey) {
|
|
writeJSON(w, http.StatusTooManyRequests, errorResponse{
|
|
Error: "RATE_LIMITED",
|
|
Message: "too many failed attempts, try again later",
|
|
})
|
|
return
|
|
}
|
|
|
|
if user.TOTPSecret != nil && *user.TOTPSecret != "" {
|
|
writeJSON(w, http.StatusConflict, errorResponse{
|
|
Error: "TOTP_ALREADY_ENABLED",
|
|
Message: "disable 2FA before re-enabling",
|
|
})
|
|
return
|
|
}
|
|
|
|
var req passwordConfirmationRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "INVALID_INPUT",
|
|
Message: "malformed request body",
|
|
})
|
|
return
|
|
}
|
|
failKey := auth.Key("pw_confirm_fail", user.ID)
|
|
if err := requirePasswordConfirmation(user, req.Password); err != nil {
|
|
if !limiter.Allow(failKey, pwConfirmFailureThreshold, pwConfirmFailureWindow) {
|
|
limiter.Lockout(r.Context(), lockKey, pwConfirmLockoutDuration)
|
|
}
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "INVALID_INPUT",
|
|
Message: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
limiter.Reset(r.Context(), failKey)
|
|
|
|
secret, err := auth.GenerateTOTPSecret()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
|
Error: "INTERNAL_ERROR",
|
|
Message: "failed to generate two-factor secret",
|
|
})
|
|
return
|
|
}
|
|
|
|
pendingStore.Put(user.ID, secret)
|
|
writeJSON(w, http.StatusOK, totpEnableResponse{
|
|
QRURI: auth.BuildTOTPURI(user.Username, secret, "OwnCord"),
|
|
BackupCodes: []string{},
|
|
})
|
|
}
|
|
}
|
|
|
|
// revokeOtherSessionsAfterAuthChange revokes every session for userID except
|
|
// keepSessionID as the security tail of a committed 2FA state change. It
|
|
// mirrors UserService.ChangePassword (service/user.go:262-274): a failure is
|
|
// logged and retried once (bounded compensating retry for transient write
|
|
// contention); if the retry also fails, revoked reports what did succeed and
|
|
// failed is true so the caller can report a partial success instead of
|
|
// silently claiming the other sessions were revoked when they were not.
|
|
func revokeOtherSessionsAfterAuthChange(ctx context.Context, database *db.DB, userID, keepSessionID int64, action string) (revoked int64, failed bool) {
|
|
revoked, err := database.DeleteOtherSessions(ctx, userID, keepSessionID)
|
|
if err != nil {
|
|
slog.Error("DeleteOtherSessions after "+action, "err", err, "user_id", userID)
|
|
revokedRetry, retryErr := database.DeleteOtherSessions(ctx, userID, keepSessionID)
|
|
if retryErr != nil {
|
|
slog.Error("DeleteOtherSessions retry after "+action, "err", retryErr, "user_id", userID)
|
|
return revoked, true
|
|
}
|
|
revoked += revokedRetry
|
|
}
|
|
if revoked > 0 {
|
|
slog.Info("revoked other sessions after "+action, "user_id", userID, "revoked", revoked)
|
|
}
|
|
return revoked, false
|
|
}
|
|
|
|
func handleConfirmTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, usedTOTPCodes *auth.UsedTOTPCodeStore, limiter *auth.RateLimiter, totpKey []byte) 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
|
|
}
|
|
|
|
// BUG-111: Per-user lockout for password confirmation.
|
|
lockKey := auth.Key("pw_confirm_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 totpConfirmationRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "INVALID_INPUT",
|
|
Message: "malformed request body",
|
|
})
|
|
return
|
|
}
|
|
failKey := auth.Key("pw_confirm_fail", user.ID)
|
|
if err := requirePasswordConfirmation(user, req.Password); err != nil {
|
|
if !limiter.Allow(failKey, pwConfirmFailureThreshold, pwConfirmFailureWindow) {
|
|
limiter.Lockout(r.Context(), lockKey, pwConfirmLockoutDuration)
|
|
}
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "INVALID_INPUT",
|
|
Message: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
limiter.Reset(r.Context(), failKey)
|
|
|
|
secret, ok := pendingStore.Lookup(user.ID)
|
|
if !ok {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "BAD_REQUEST",
|
|
Message: "no pending two-factor enrollment found",
|
|
})
|
|
return
|
|
}
|
|
|
|
if !auth.VerifyTOTPCodeOnce(secret, strings.TrimSpace(req.Code), time.Now().UTC(), user.ID, usedTOTPCodes) {
|
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
|
Error: "UNAUTHORIZED",
|
|
Message: "invalid two-factor code",
|
|
})
|
|
return
|
|
}
|
|
|
|
encryptedSecret, encErr := auth.EncryptTOTPSecret(totpKey, secret)
|
|
if encErr != nil {
|
|
slog.Error("failed to encrypt TOTP secret", "user_id", user.ID, "error", encErr)
|
|
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
|
Error: "INTERNAL_ERROR",
|
|
Message: "failed to enable two-factor authentication",
|
|
})
|
|
return
|
|
}
|
|
|
|
if err := database.UpdateUserTOTPSecret(r.Context(), user.ID, &encryptedSecret); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
|
Error: "INTERNAL_ERROR",
|
|
Message: "failed to enable two-factor authentication",
|
|
})
|
|
return
|
|
}
|
|
pendingStore.Delete(user.ID)
|
|
|
|
// BUG-108: Revoke all other sessions after 2FA state change. An
|
|
// API-token principal has a nil session; keep=0 matches no row, so
|
|
// every login session is revoked — same semantics as change-password.
|
|
sess, _ := r.Context().Value(SessionKey).(*db.Session)
|
|
keepSessionID := int64(0)
|
|
if sess != nil {
|
|
keepSessionID = sess.ID
|
|
}
|
|
// Security tail of the 2FA change: once the secret update committed,
|
|
// revoking the other sessions must not be aborted by a dead request.
|
|
tailCtx := context.WithoutCancel(r.Context())
|
|
revoked, revokeFailed := revokeOtherSessionsAfterAuthChange(tailCtx, database, user.ID, keepSessionID, "totp enable")
|
|
|
|
slog.Info("totp enabled", "user_id", user.ID)
|
|
db.WriteAudit(tailCtx, database, user.ID, "totp_enabled", "user", user.ID,
|
|
"two-factor authentication enrolled")
|
|
|
|
if revokeFailed {
|
|
// Partial success: 2FA IS enabled; only revoking the other
|
|
// sessions failed. A 5xx here would be a lie — the state change
|
|
// already committed — so mirror the ChangePassword contract
|
|
// (api/profile_handler.go) and report 200 with an explicit warning
|
|
// instead of a silent, unqualified 204.
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"warning": "two-factor authentication enabled, but other sessions could not be revoked; revoke them from the sessions list",
|
|
"sessions_revoked": revoked,
|
|
})
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
func handleDisableTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, limiter *auth.RateLimiter) 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
|
|
}
|
|
|
|
// BUG-111: Per-user lockout for password confirmation.
|
|
lockKey := auth.Key("pw_confirm_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 passwordConfirmationRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "INVALID_INPUT",
|
|
Message: "malformed request body",
|
|
})
|
|
return
|
|
}
|
|
failKey := auth.Key("pw_confirm_fail", user.ID)
|
|
if err := requirePasswordConfirmation(user, req.Password); err != nil {
|
|
if !limiter.Allow(failKey, pwConfirmFailureThreshold, pwConfirmFailureWindow) {
|
|
limiter.Lockout(r.Context(), lockKey, pwConfirmLockoutDuration)
|
|
}
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "INVALID_INPUT",
|
|
Message: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
limiter.Reset(r.Context(), failKey)
|
|
|
|
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 require2FA {
|
|
writeJSON(w, http.StatusForbidden, errorResponse{
|
|
Error: "FORBIDDEN",
|
|
Message: "two-factor authentication is required for this server",
|
|
})
|
|
return
|
|
}
|
|
|
|
pendingStore.Delete(user.ID)
|
|
if err := database.UpdateUserTOTPSecret(r.Context(), user.ID, nil); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
|
Error: "INTERNAL_ERROR",
|
|
Message: "failed to disable two-factor authentication",
|
|
})
|
|
return
|
|
}
|
|
|
|
// BUG-108: Revoke all other sessions after 2FA state change. An
|
|
// API-token principal has a nil session; keep=0 matches no row, so
|
|
// every login session is revoked — same semantics as change-password.
|
|
sess, _ := r.Context().Value(SessionKey).(*db.Session)
|
|
keepSessionID := int64(0)
|
|
if sess != nil {
|
|
keepSessionID = sess.ID
|
|
}
|
|
// Security tail of the 2FA change: once the secret update committed,
|
|
// revoking the other sessions must not be aborted by a dead request.
|
|
tailCtx := context.WithoutCancel(r.Context())
|
|
revoked, revokeFailed := revokeOtherSessionsAfterAuthChange(tailCtx, database, user.ID, keepSessionID, "totp disable")
|
|
|
|
slog.Info("totp disabled", "user_id", user.ID)
|
|
db.WriteAudit(tailCtx, database, user.ID, "totp_disabled", "user", user.ID,
|
|
"two-factor authentication disabled")
|
|
|
|
if revokeFailed {
|
|
// Partial success: 2FA IS disabled; only revoking the other
|
|
// sessions failed. A 5xx here would be a lie — the state change
|
|
// already committed — so mirror the ChangePassword contract
|
|
// (api/profile_handler.go) and report 200 with an explicit warning
|
|
// instead of a silent, unqualified 204.
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"warning": "two-factor authentication disabled, but other sessions could not be revoked; revoke them from the sessions list",
|
|
"sessions_revoked": revoked,
|
|
})
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|