mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* refactor(ws): split handleVoiceJoin into cohesive join-stage helpers handleVoiceJoin was 130 statements / cyclomatic 59 / nestif 11, breaking all three complexity budgets at once. Split along the stage boundaries the doc comment already described: precheck, leave-current, persist, restore moderator flags, grant token, complete. The publish-permission derivation becomes its own helper because it is the one branch-heavy block inside the token grant. Pure move: every statement is preserved verbatim. The only edits are bare `return`s becoming the typed returns of their new helper, `c.userID` becoming the `userID` parameter inside voiceJoinPublishPerms, and voiceJoinComplete re-reading `ch.VoiceMaxUsers` instead of receiving it — `ch` is never mutated, so the value is identical. Verified by normalising both revisions of the region to sorted, comment- and whitespace-stripped statements and diffing: the only deltas are the ones listed above. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: collapse the three duplicated sibling pairs dupl flagged three pairs of adjacent near-identical functions. Each pair is now one parameterised implementation plus two thin, still-greppable wrappers. - ws/voice_controls.go: handleVoiceMuteV2 / handleVoiceDeafenV2 share voiceSelfToggleV2; handleVoiceCameraV2 / handleVoiceScreenshareV2 share voiceStreamToggleV2. Camera and screenshare drawing from one voice_max_video budget (OC-0023) was a bug caused by exactly this duplication drifting, so one body is the point, not a side effect. - db/mention_queries.go: ListMentionTargetsByRoles / ListMentionTargetsByUserIDs share listMentionTargets. The matched column is a closed named type (mentionTargetColumn) rather than a bare string, so the value interpolated into the SELECT cannot become caller-supplied. Behaviour is unchanged: every rate-limit key, error code, error string, slog message and slog key is preserved verbatim, including the two "failed to update <kind> state" messages, which are now assembled the same way enableVideoSlot already assembled them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(api): extract readEmojiUpload from handleCreateEmoji handleCreateEmoji was 101 lines against a 100-line budget. The upload-bytes stage — pull the file out of the parsed form, cap its size, sniff its MIME type and sniff its dimensions — is the one self-contained block in it, and it already wrote its own refusals, so it moves out whole as readEmojiUpload. The permission-before-parse ordering the doc comment calls out is unchanged; so is every error string. file.Close() now runs when the helper returns rather than when the handler does, which is strictly earlier and unobservable: the bytes are already copied into raw and nothing else touches the handle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: extract one cohesive block from three single-budget offenders Each of these was over exactly one budget, so each gets exactly one extraction rather than a restructure: - api/totp_handler.go handleVerifyTOTP (102 lines / 100): the block that resolves the user behind the partial-auth challenge and decrypts their TOTP secret becomes totpChallengeSecret. The ban-inside-the-partial-window check moves with it. - service/message_reactions.go handleReaction (cyclop 21 / 20): the whole authorisation chain — channel lookup, archived gate, DM participant and block checks, non-DM permission check — becomes reactionAudience, which also returns the DM fan-out audience it already resolved. Check order is unchanged and load-bearing. - db/admin_queries.go BackupToSafe (cyclop 21 / 20): the character allowlist loop and the SQL-comment rejection become validateBackupPathChars. That loop alone was most of the branch count. No error string, no check and no ordering changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(plugin): split InstallFromZip into staged install helpers 104 statements / cyclomatic 44 / nestif 12. Split along the stages the code already had: installZipExtract (the per-entry write loop, with installZipEntryDest holding the mode/symlink/zip-slip guard chain and installZipWriteEntry the size-capped copy), installZipStagedManifest, installZipPromote, and installZipReactivate for the :399 nested block. Every zip-slip, symlink, entry-mode and uncompressed-size check is preserved in the same order relative to the writes it guards. The 19 inline `cleanup(); return` sites collapse to 4 in the orchestrator, one per stage, because each helper now returns an error instead of unwinding itself — the staging directory is still removed on exactly the same set of failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(api): split newWAFMiddleware into engine build and per-phase helpers 184 lines / cyclomatic 38, and the request-body block at :382 was the worst nested site in the tree at nestif 17. Engine construction moves out of the closure (wafInlineEngine, wafCRSEngine — the Coraza directive string is lifted verbatim), and each request phase becomes its own helper: wafInlineRequestHeaders, wafCRSRequestHeaders (including the Host/Transfer-Encoding re-add for CRS 920280), wafFeedCRSBody and wafInspectRequestBody, which is the old :382 block. The three `handleWAFInterruption(w, it); return` sites inside the body block become one: the helper now returns the interruption and the orchestrator handles it. No statement runs between the two points on either side, so the verdict is honoured identically — in particular a CRS body interruption still returns without replacing r.Body. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(service): split SendMessage and lift EditMessage's access check SendMessage was 79 statements / cyclomatic 35 with an 11-deep nested attachment block at :101; EditMessage was one point over cyclop. SendMessage becomes sendMessagePrecheck (permission and DM-block gates, content sanitisation), sendMessageLinkAttachments (the :101 block: attachment ownership, claim and link) and sendMessageDMSideEffects. EditMessage gets editMessageCheckAccess and nothing else — one budget over earns one extraction. The sanitizeContent fixpoint and the attachment ownership check are unchanged, as is the order of every gate. The DM side effects run behind `isDM && !s.sendMessageDMSideEffects(...)`, so a non-DM never enters them; inside, only the GetDMParticipantIDs failure returns false, matching the one error the original early-returned on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(admin): split handlePatchUser into per-field apply helpers 106 lines / cyclomatic 29, with the ban block at :154 nested 9 deep. Each optional field of the partial edit becomes its own helper — patchUserPrecheck, patchUserAuthorizeRole, patchUserApplyBan (the :154 block, including the session disconnect and the broadcast) and patchUserApplyRole. Each returns a bool meaning "keep going"; none of them writes a success response, so the single response site in the orchestrator is unchanged. Field application order, the permission-cache invalidation on a role change and the disconnect-and-broadcast on a ban are all preserved, as are the three fail-closed `mod == nil` guards, which now sit at the top of their own helper and still fire on exactly the same conditions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(admin): split handleSetup into first-run setup stages 143 lines / cyclomatic 30, with the optional-wizard block at :219 sitting exactly on the nestif threshold. Split into the stages the endpoint already had: request gating (rate limit and origin check, which run before any auth exists on a fresh server), owner account creation, and the wizard application that was the :219 block. Every gate in front of the handler is a security control on an unauthenticated endpoint; none moved relative to the work it protects. setup_wizard.go is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: split run() into named bootstrap and shutdown steps 131 statements / cyclomatic 57, with the executable-path fallback at :126 nested 9 deep. The five anonymous `defer func(){...}()` blocks become named functions — telemetryStop, runClosePlugins, runStopEventPersistence, runStopAuditWriter, maintenanceStop — and the bootstrap stages move out likewise. Every defer is still registered in run() itself, at the same point in the sequence, so the LIFO teardown order is unchanged; that order is documented in the surrounding comments and is load-bearing (the audit-writer stop must follow database.Close's registration, the event-persistence stop must precede it). runStopEventPersistence is now registered unconditionally with a nil persister meaning "disabled", where the old code registered its defer inside the enabled branch — a no-op occupying that slot cannot change the relative order of the others. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(ws): split handleReconnect into resume stages 77 statements / cyclomatic 41, plus the replay block at :199 and, in handleFreshConnect, the voice-state restore at :622. handleReconnect becomes reconnectPrecheck, reconnectSelectReplay (with reconnectVetColdTail for the cold-tier gap check), reconnectRegister and reconnectWriteReplay. handleFreshConnect's stale-voice cleanup moves to its own helper, where the `if h.livekit != nil` wrapper becomes a guard clause — that block was the tail of its scope, so returning early and falling off the end are the same. The parts that carry the invariants are moved verbatim: reconnectRegister still takes h.seqMu, still calls registerNow inside that same critical section (BUG-123 / OC-0206), still unlocks on every exit, and still emits the "full" tier counter and telemetry on each of its three re-check failures. handleReconnect's two-boolean contract is unchanged — the collapsed `return false, false` sites are all fall-through-to-full-ready, and the single `return true, false` is still the handshake-write-failure path whose teardown already ran (OC-0051). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(server): fold in the adversarial review of the complexity refactors Eleven skeptic passes over the refactor commits on this branch found no blocker and no major — behaviour is preserved throughout. They did find comment and accuracy defects worth correcting: - db/mention_queries.go: the mentionTargetColumn rationale claimed the named type made the interpolated column "only ever one of the two constants". A Go named type is not closed, so that is a convention the type makes visible, not one it enforces. Reworded, gosec justification included. - ws/voice_controls.go: the dupl collapse generalised away three specifics — that a server deafen is the moderator's to lift (now on the serverDeafen field), the concrete voice_states.camera / voice_states.screenshare column names, and the half of the OC-0023 rationale about neither stream kind hiding from the other's count. All three restored. - ws/voice_join.go: `maxUsers := ch.VoiceMaxUsers` had been hoisted to the top of voiceJoinComplete, moving a read across the tail supersession guard. The read is inert, but it was the one statement in that commit whose position relative to a security guard changed; it now sits at its use, as before. - ws/*_test.go: three test comments cited voice_join.go line numbers that the split invalidated. They now cite the helper by name instead. - service/message_reactions.go: reactionAudience's doc claimed to enforce "every gate on reacting"; it enforces the channel-scoped ones, and the doc now says which gates stay with the caller. - api/emoji_handler.go: the readEmojiUpload call reused the outer `ok` from the auth check by assignment; it gets its own readOK. - admin/setup_handler.go: a moved comment kept a "the response above" deictic that no longer had a response above it. No behaviour change. Build, vet, full tests and -race on five packages green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(ws): clear the remaining complexity budgets across the hub Eight files, thirteen findings. Each function is split at the stages it already had; no branch is reordered, merged or inverted. - handlers.go handleMessage (cyclop 28, 88 stmts): session re-check, frame decode and result application become handleMessageSessionRecheck, handleMessageDecode and handleMessageApply. The V2 constructor lookup -> DispatchV2 -> Result resolution order is untouched. - serve_ready.go buildReady (cyclop 26, 61 stmts): the per-section fetches split out, readyChannelPayloads among them. Every visibility predicate is preserved verbatim — this is the payload that decides what a client may see. - serve_pumps.go writePump (cyclop 31): writePumpWrite, writePumpDeliver, writePumpDrainChannel and writePumpDrainAndClose. Every channel receive stays in the same select statement, so scheduling is unchanged. - hub_sweep.go sweepStaleVoiceStates (cyclop 22, 56 stmts): the staleness predicate, the hub-lock ordering and the position of the race hook are all as they were — handleVoiceJoin's BUG-088 ordering depends on them. - hub_broadcast.go channelReadAudienceImpl and RefreshChannelVisibility (cyclop 22 each, 57 stmts): channelReadAudienceDM and refreshChannelVisibilityCanSend. The audience predicate is the OC-0090 group-DM leak surface, so it is extracted, never simplified. - livekit_webhook.go (nestif 13 and 14): webhookJoinedEnforceVoiceState, webhookLeftCleanupClient and webhookLeftFinishLeave. DB delete still precedes broadcast on every path. - livekit_download.go EnsureLiveKitBinary (52 stmts): one extraction, ensureLiveKitStageBinary, keeping every archive path check intact. - voice_moderation.go (nestif 8): voiceModDeafenRollback. The persisted server_muted flag remains the authority. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(api): clear the remaining complexity budgets across the HTTP layer - router.go NewRouter (cyclop 28, 84 stmts): split by wiring concern into routerTOTPKey, routerHealthDeps, routerMiddleware, routerUploadRoutes, routerPluginWiring, routerVoiceRoutes and routerMetricsRoutes. Middleware ORDER is a security property (auth before handler, WAF before body parse, rate limit before work) and is unchanged; the returned cleanup func still closes over and releases everything it did before. - auth_handler.go handleRegister (133 lines) and handleLogin (cyclop 21, 152 lines): registerPolicyGate, registerReadRequest, loginReadRequest and loginAuthenticate. The always-compare posture, every rate-limit key, every counter reset and the ban-check-versus-password-compare order are all preserved — including loginUserFailureThreshold staying unscaled by scaledAuthLimit, which is deliberate and commented. - upload_handler.go handleServeFile (cyclop 31, 128 lines): serveFileResolve and serveFileAuthorize. Every header this sets — Content-Disposition included, which is what stops a stored file being served as active content — is still set with the same value in the same circumstances. - profile_handler.go handleUploadAvatar (120 lines): avatarUploadReadImage, mirroring readEmojiUpload in shape but with the avatar caps and MIME set. The two deliberately do not share a helper. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: clear the last complexity budgets in db and admin - db/account.go DeleteAccount (cyclop 28, 55 stmts): grouped by subsystem into deleteAccountAdminGuard, deleteAccountDMChannels and deleteAccountCloseDMChannels, each taking the same transaction. The transaction boundary, the delete ORDER (which foreign keys depend on) and the rollback path are unchanged. - admin/logstream.go handleLogStream (cyclop 24): logStreamAuthorize. Flush cadence, heartbeat and disconnect detection untouched. - admin/setup_wizard.go validateWizard (cyclop 23): grouped by section into wizardValidateIdentity, wizardValidateNetwork and wizardValidateMedia. Every message and bound is unchanged — this is the first input-validation boundary on a fresh server, before any auth exists. With this the tree is at zero: golangci-lint run reports 0 issues against the budgets set in #1384 (funlen 100/50, cyclop 20, nestif 8, dupl 150), with no //nolint and no exclusion added anywhere. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
774 lines
28 KiB
Go
774 lines
28 KiB
Go
package api
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
"github.com/microcosm-cc/bluemonday"
|
||
"github.com/owncord/server/auth"
|
||
"github.com/owncord/server/db"
|
||
"github.com/owncord/server/permissions"
|
||
"github.com/owncord/server/service"
|
||
)
|
||
|
||
// sanitizer strips all HTML from user-supplied strings before storage.
|
||
var sanitizer = bluemonday.StrictPolicy()
|
||
|
||
// 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
|
||
}
|
||
|
||
// F: use the fixpoint sanitizer (service.SanitizeText), not the bare
|
||
// sanitizer.Sanitize below — 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
|
||
}
|