Skip to content

feat(verification): back email/reset codes with Redis when available#4537

Open
jr-ni wants to merge 2 commits intoQuantumNous:mainfrom
itopick-com:feat/verification-redis-backend
Open

feat(verification): back email/reset codes with Redis when available#4537
jr-ni wants to merge 2 commits intoQuantumNous:mainfrom
itopick-com:feat/verification-redis-backend

Conversation

@jr-ni
Copy link
Copy Markdown

@jr-ni jr-ni commented Apr 29, 2026

Summary

`common/verification.go` stores email-verification and password-reset codes in an in-process `map` (`verificationMap`) protected by a `sync.Mutex`, with no Redis branch. This breaks horizontal scaling: when new-api runs with >1 replica behind a load balancer, the first request (e.g. `POST /api/email_verification`) registers the code in pod A's memory, the next request (`POST /api/user/register`) is routed to pod B and finds nothing — registration always fails.

Since `common/redis.go` is already integrated and most other in-memory fallbacks (caches, rate limiters, notify limit) already check `RedisEnabled`, it was natural to extend the same pattern here.

Changes

  • `RegisterVerificationCodeWithKey` uses `RedisSet` with `VerificationValidMinutes` TTL when `RedisEnabled`.
  • `VerifyCodeWithKey` uses `RedisGet` + equality compare; on Redis error it fails closed (returns false).
  • `DeleteKey` uses `RDB.Del`.
  • The in-memory path is preserved unchanged for single-pod deployments.
  • Redis key shape: `verify_code::`.
  • Adds a small unit test covering the in-memory contract (registration, wrong code, purpose isolation, deletion).

Test plan

  • `go test ./common/ -run TestVerifyCodeWithKey_MemoryFallback` passes
  • Live verification in a multi-pod deployment (Redis-on path) — codes registered on pod A successfully verified on pod B.

Backwards compatibility

  • No API surface changes: `RegisterVerificationCodeWithKey`, `VerifyCodeWithKey`, `DeleteKey` keep their signatures.
  • No new env vars, no migration, no schema changes.
  • Single-pod / Redis-off deployments behave identically (the in-memory `map` path is unchanged).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Optional Redis-backed verification code storage with TTL-based expiry for more reliable, scalable, and secure verification handling (includes protections against timing attacks).
  • Tests

    • Added unit tests covering verification lifecycle: registration, validation, cross-purpose isolation, incorrect-code handling, and deletion.

The verification helpers stored codes in an in-process map keyed by
purpose+key, which breaks horizontal scaling: a code registered on one
pod is invisible to another, so signup/password-reset round-trips that
hit different replicas always fail.

When RedisEnabled, store codes at `verify_code:<purpose>:<key>` with the
existing `VerificationValidMinutes` TTL so Redis ages them out
automatically. Verification fails closed on Redis errors. The in-memory
path is preserved for single-pod deployments and is unchanged.

Adds a unit test covering the in-memory contract (registration, wrong
code, purpose isolation, deletion). The Redis branch was validated live
in a multi-pod deployment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 29, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 33edd751-81e2-4b4a-b1ba-333c1f8df3a6

📥 Commits

Reviewing files that changed from the base of the PR and between b80aaf2 and 1b28bd9.

📒 Files selected for processing (1)
  • common/verification.go
✅ Files skipped from review due to trivial changes (1)
  • common/verification.go

Walkthrough

Adds an optional Redis-backed storage path for verification codes controlled by RedisEnabled. When enabled, codes are stored/read/deleted in Redis with a TTL; when disabled, the existing in-memory map path is used. Tests exercise the in-process fallback.

Changes

Cohort / File(s) Summary
Verification implementation
common/verification.go
Adds Redis-backed storage (verificationRedisPrefix, verificationRedisKey) and conditional logic in RegisterVerificationCodeWithKey, VerifyCodeWithKey, and DeleteKey to use Redis when RedisEnabled is true; retains in-memory fallback. Adds imports for Redis error handling and constant-time comparison.
Verification tests
common/verification_test.go
Adds unit test validating the in-process (Redis disabled) verification code lifecycle: register, verify, wrong code, cross-purpose isolation, and deletion.

Sequence Diagram(s)

sequenceDiagram
    participant App as Application
    participant Verif as Verification Logic
    participant Redis as Redis Cache
    participant Memory as In-Memory Store

    rect rgba(76, 175, 80, 0.5)
    Note over App,Redis: Redis Enabled Path
    App->>Verif: RegisterVerificationCodeWithKey(key, code, purpose)
    Verif->>Redis: SET verification:purpose:key = code (EX TTL)
    Redis-->>Verif: OK
    App->>Verif: VerifyCodeWithKey(key, inputCode, purpose)
    Verif->>Redis: GET verification:purpose:key
    Redis-->>Verif: stored code / nil
    Verif-->>App: constant-time compare result
    end

    rect rgba(33, 150, 243, 0.5)
    Note over App,Memory: Redis Disabled Path
    App->>Verif: RegisterVerificationCodeWithKey(key, code, purpose)
    Verif->>Memory: store map[purpose:key] = code with timestamp
    Memory-->>Verif: stored
    App->>Verif: VerifyCodeWithKey(key, inputCode, purpose)
    Verif->>Memory: lookup map[purpose:key]
    Memory-->>Verif: stored code / missing
    Verif-->>App: constant-time compare result
    end

    rect rgba(244, 67, 54, 0.5)
    Note over App,Verif: Deletion
    App->>Verif: DeleteKey(key, purpose)
    alt Redis Enabled
        Verif->>Redis: DEL verification:purpose:key
        Redis-->>Verif: deleted
    else Redis Disabled
        Verif->>Memory: delete map[purpose:key]
        Memory-->>Verif: removed
    end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰
I stored a code beneath a log,
Redis hummed while I wrote the prog,
TTLs tick like a clock,
Fallback maps guard the lock,
Hopping changes — quick as a jog! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding Redis-backed storage for verification codes when available, which directly addresses the multi-replica deployment problem described in the PR objectives.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
common/verification.go (1)

71-71: Consider constant-time comparison for verification codes.

The direct string comparison stored == code is theoretically vulnerable to timing attacks. While the risk is low due to code entropy and TTL, using subtle.ConstantTimeCompare would follow security best practices.

🔒 Optional: use constant-time comparison
+import "crypto/subtle"
+
 func VerifyCodeWithKey(key string, code string, purpose string) bool {
 	if RedisEnabled {
 		stored, err := RedisGet(verificationRedisKey(key, purpose))
 		if err != nil {
 			if err != redis.Nil {
 				SysError("VerifyCodeWithKey: redis get failed: " + err.Error())
 			}
 			return false
 		}
-		return stored == code
+		return subtle.ConstantTimeCompare([]byte(stored), []byte(code)) == 1
 	}
 	// ... also update line 80 for consistency ...
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@common/verification.go` at line 71, Replace the direct equality check (the
line returning `stored == code`) with a constant-time comparison using
`subtle.ConstantTimeCompare`: first ensure the lengths match (return false if
len differs) then compare the byte slices and return whether the result equals
1; also add the `crypto/subtle` import. Update the function that contains
`return stored == code` accordingly (e.g., VerifyCode/Compare function) so it
uses the length check plus `subtle.ConstantTimeCompare([]byte(stored),
[]byte(code)) == 1`.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@common/verification.go`:
- Around line 44-50: The Redis branch in RegisterVerificationCodeWithKey
currently logs but swallows RedisSet errors; change
RegisterVerificationCodeWithKey to return an error (update its signature), and
when RedisSet(verificationRedisKey(key, purpose), code, ttl) fails return that
error instead of just SysError; update all callers (e.g., the handlers in
controller/misc.go) to handle the returned error (respond to the user or retry)
and preserve the existing behavior when RedisEnabled is false by returning nil
on success.
- Around line 83-89: DeleteKey currently calls RDB.Del directly; replace that
direct Redis call with the existing wrapper RedisDelKey for consistency with
RedisSet/RedisGet. In the DeleteKey function, remove the
RDB.Del(context.Background(), verificationRedisKey(key, purpose)).Err() usage
and call RedisDelKey(verificationRedisKey(key, purpose)) instead, keeping the
RedisEnabled check and SysError handling consistent with other callers like
RegisterVerificationCodeWithKey and VerifyCodeWithKey.

---

Nitpick comments:
In `@common/verification.go`:
- Line 71: Replace the direct equality check (the line returning `stored ==
code`) with a constant-time comparison using `subtle.ConstantTimeCompare`: first
ensure the lengths match (return false if len differs) then compare the byte
slices and return whether the result equals 1; also add the `crypto/subtle`
import. Update the function that contains `return stored == code` accordingly
(e.g., VerifyCode/Compare function) so it uses the length check plus
`subtle.ConstantTimeCompare([]byte(stored), []byte(code)) == 1`.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0125f7f5-e129-4cfe-809d-e0b9645c023d

📥 Commits

Reviewing files that changed from the base of the PR and between 3b59289 and b80aaf2.

📒 Files selected for processing (2)
  • common/verification.go
  • common/verification_test.go

Comment thread common/verification.go
Comment thread common/verification.go
- Use crypto/subtle.ConstantTimeCompare for code equality check
  to avoid timing-attack surface, even though codes are random UUIDs.
- Use the existing common.RedisDelKey wrapper in DeleteKey instead of
  calling RDB.Del directly, matching the rest of the file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jr-ni
Copy link
Copy Markdown
Author

jr-ni commented Apr 29, 2026

Thanks for the review @coderabbitai. Pushed b1b28bd addressing two of the three points:

  • DeleteKey: switched to the existing common.RedisDelKey wrapper for consistency with the rest of the file.
  • Constant-time compare: now using crypto/subtle.ConstantTimeCompare for the code equality check. The codes are random UUIDs so the practical risk is essentially zero, but adopting it costs nothing.
  • ⏸️ Returning errors from Register… / Delete…: deferring this. It would change three exported function signatures and require touching every caller (controller/misc.go, controller/user.go). Happy to do it as a follow-up if maintainers prefer that direction; otherwise, the existing SysError log + fail-closed read keeps the PR scope tight.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 29, 2026

Tip

For best results, initiate chat on the files or code changes.

@jr-ni Thanks for the update! The two addressed points look great:

  • RedisDelKey wrapper — good call for consistency; it avoids duplicating the key-formatting logic and keeps the Redis surface area in one place.
  • ConstantTimeCompare — agreed, zero practical risk with random UUIDs, but it's the right habit and costs nothing.

The deferral on error propagation is entirely reasonable. The fail-closed path (SysError log + VerifyCodeWithKey returning false) is a safe interim stance, and bumping three exported signatures across controller/misc.go and controller/user.go is genuinely out of scope for this PR.

Would you like me to open a follow-up GitHub issue to track the error-propagation improvement for RegisterVerificationCodeWithKey / DeleteKey? That way it won't get lost without requiring any additional changes here.


🧠 Learnings used
Learnt from: 0-don
Repo: QuantumNous/new-api PR: 3390
File: common/str.go:205-218
Timestamp: 2026-03-22T15:12:54.076Z
Learning: In `common/str.go`, the `RedeemOAuthExchangeCode` function intentionally uses separate `RedisGet` + `RedisDel` calls rather than an atomic `GETDEL`/pipeline. The maintainer (0-don) considers this acceptable because: (1) the one-time code is a 32-char random alphanumeric string (62^32 space), making collisions practically impossible; (2) the 30-second TTL (`OAuthExchangeCodeTTL`) limits the exposure window; and (3) the code is delivered only to the legitimate frontend, so there is no realistic concurrent-redemption attack vector.

Comment thread common/verification.go
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant