updates on the otp updates on the customer app
This commit is contained in:
@@ -201,14 +201,38 @@ func issueCxOtp(cfg *config.Config, identifier, kind string) (resendAfter int, e
|
||||
db.Rdb.Del(ctx, cxOtpTriesKey(identifier))
|
||||
db.Rdb.Set(ctx, cxOtpSentKey(identifier), "1", cxResendWait)
|
||||
|
||||
// A code that was never delivered must leave nothing behind.
|
||||
//
|
||||
// The stored code, the resend cooldown and the rate-limit slot are all
|
||||
// written BEFORE delivery is attempted, because they have to be — the code
|
||||
// has to exist before it can be sent. But when sending fails, keeping them
|
||||
// punishes the customer for the gateway's failure: they are told something
|
||||
// went wrong, cannot resend until the cooldown expires, and have spent one
|
||||
// of their five hourly codes — while a valid code they never received sits
|
||||
// live in Redis for its full TTL.
|
||||
//
|
||||
// So unwind all three on failure. The customer can retry immediately, and
|
||||
// nothing usable is left in Redis.
|
||||
rollback := func() {
|
||||
rctx, rcancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer rcancel()
|
||||
db.Rdb.Del(rctx, cxOtpKey(identifier))
|
||||
db.Rdb.Del(rctx, cxOtpSentKey(identifier))
|
||||
// Give the slot back rather than deleting the window: DECR keeps the
|
||||
// hourly window honest for codes that DID go out.
|
||||
db.Rdb.Decr(rctx, cxOtpRateKey(identifier))
|
||||
}
|
||||
|
||||
if kind == "email" {
|
||||
if merr := mail.SendOTPEmail(cfg, identifier, code); merr != nil {
|
||||
utils.Warn("cx auth: failed to send OTP email", "error", merr)
|
||||
utils.Warn("cx auth: failed to send OTP email — rolling back the stored code", "error", merr)
|
||||
rollback()
|
||||
return 0, merr
|
||||
}
|
||||
} else {
|
||||
if serr := sms.SendOTP(identifier, code); serr != nil {
|
||||
utils.Warn("cx auth: failed to send OTP sms", "error", serr)
|
||||
utils.Warn("cx auth: failed to send OTP sms — rolling back the stored code", "error", serr)
|
||||
rollback()
|
||||
return 0, serr
|
||||
}
|
||||
}
|
||||
@@ -368,18 +392,37 @@ func CxVerifyOtp(cfg *config.Config) fiber.Handler {
|
||||
var req struct {
|
||||
Identifier string `json:"identifier"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
// Otp is a DEPRECATED alias for Code, and the only reason sign-in
|
||||
// works for anyone on an already-installed build.
|
||||
//
|
||||
// The customer app was written against a spec that named this
|
||||
// field "otp". The server only ever read "code", so req.Code was
|
||||
// always empty, the empty-code branch below always fired, and
|
||||
// EVERY sign-in failed with a 400 — a correct code failed exactly
|
||||
// like a wrong one. Fixing the app alone would have left every
|
||||
// customer locked out until they updated; accepting both keys
|
||||
// fixes them all without a release.
|
||||
//
|
||||
// "code" stays the documented field. Remove this once the install
|
||||
// base has moved on.
|
||||
Otp string `json:"otp"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.CxBadRequest(c, "We could not read that request")
|
||||
}
|
||||
|
||||
code := strings.TrimSpace(req.Code)
|
||||
if code == "" {
|
||||
code = strings.TrimSpace(req.Otp)
|
||||
}
|
||||
|
||||
identifier, kind, ok := normalizeIdentifier(req.Identifier)
|
||||
if !ok || strings.TrimSpace(req.Code) == "" {
|
||||
if !ok || code == "" {
|
||||
return utils.CxBadRequest(c, "Enter the code we sent you")
|
||||
}
|
||||
|
||||
if !consumeCxOtp(identifier, strings.TrimSpace(req.Code)) {
|
||||
if !consumeCxOtp(identifier, code) {
|
||||
return utils.CxFail(c, fiber.StatusUnauthorized, utils.CxErrInvalidOtp, "That code did not match")
|
||||
}
|
||||
|
||||
|
||||
88
controllers/cxOtpFieldAlias_test.go
Normal file
88
controllers/cxOtpFieldAlias_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// DM-01: the customer app posts the verification code as "otp"; the server was
|
||||
// written to read "code". req.Code was therefore always empty and EVERY
|
||||
// sign-in failed with a 400 — a correct code failed exactly like a wrong one.
|
||||
//
|
||||
// These pin the alias. The struct is re-declared here to match the handler's
|
||||
// anonymous one; what is under test is that both wire names reach the same
|
||||
// value and that the precedence is stable.
|
||||
type cxVerifyBody struct {
|
||||
Identifier string `json:"identifier"`
|
||||
Code string `json:"code"`
|
||||
Otp string `json:"otp"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// codeFrom mirrors the handler's selection: Code wins, Otp is the fallback.
|
||||
func codeFrom(b cxVerifyBody) string {
|
||||
code := strings.TrimSpace(b.Code)
|
||||
if code == "" {
|
||||
code = strings.TrimSpace(b.Otp)
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func parseVerify(t *testing.T, raw string) cxVerifyBody {
|
||||
t.Helper()
|
||||
var b cxVerifyBody
|
||||
if err := json.Unmarshal([]byte(raw), &b); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", raw, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// The shape the app actually sends. This is the regression that locked every
|
||||
// customer out of production.
|
||||
func TestVerifyAcceptsTheAppsOtpField(t *testing.T) {
|
||||
body := parseVerify(t, `{"identifier":"+919000000001","otp":"123456"}`)
|
||||
if got := codeFrom(body); got != "123456" {
|
||||
t.Errorf(`{"otp":"123456"} yielded %q — the app's field is being dropped again`, got)
|
||||
}
|
||||
}
|
||||
|
||||
// The documented field keeps working unchanged.
|
||||
func TestVerifyStillAcceptsCode(t *testing.T) {
|
||||
body := parseVerify(t, `{"identifier":"+919000000001","code":"123456"}`)
|
||||
if got := codeFrom(body); got != "123456" {
|
||||
t.Errorf(`{"code":"123456"} yielded %q, want "123456"`, got)
|
||||
}
|
||||
}
|
||||
|
||||
// When a client sends both, the documented field wins — so "code" stays the
|
||||
// contract and "otp" can be removed later without changing behaviour for
|
||||
// anyone who migrated.
|
||||
func TestCodeWinsOverOtpWhenBothArePresent(t *testing.T) {
|
||||
body := parseVerify(t, `{"identifier":"+919000000001","code":"111111","otp":"222222"}`)
|
||||
if got := codeFrom(body); got != "111111" {
|
||||
t.Errorf("got %q, want the documented `code` value 111111", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Neither field, or whitespace only, is still the empty-code rejection. The
|
||||
// alias must not turn a missing code into an accepted one.
|
||||
func TestMissingOrBlankCodeIsStillRejected(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
`{"identifier":"+919000000001"}`,
|
||||
`{"identifier":"+919000000001","code":"","otp":""}`,
|
||||
`{"identifier":"+919000000001","code":" "}`,
|
||||
`{"identifier":"+919000000001","otp":" "}`,
|
||||
} {
|
||||
if got := codeFrom(parseVerify(t, raw)); got != "" {
|
||||
t.Errorf("%s yielded %q, want empty so the handler rejects it", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A code arriving with padding must still match the stored one.
|
||||
func TestPaddedOtpIsTrimmed(t *testing.T) {
|
||||
if got := codeFrom(parseVerify(t, `{"identifier":"x","otp":" 123456 "}`)); got != "123456" {
|
||||
t.Errorf("got %q, want the trimmed 123456", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user