117 lines
4.3 KiB
Go
117 lines
4.3 KiB
Go
// Package sms delivers one-time codes to a phone number.
|
|
//
|
|
// There is no SMS provider wired into this backend yet — the miler app
|
|
// authenticates on a PIN and the console on a password, so nothing has ever
|
|
// needed to send a text. The customer app's only credential is a code sent to a
|
|
// phone, which makes this the one piece of the auth flow that cannot be
|
|
// finished from inside this repository.
|
|
//
|
|
// So this package is the seam, not the integration: a small interface, a
|
|
// logging sink that lets the whole flow be exercised end to end without a
|
|
// provider, and a fixed-code mode for staging. Plugging in a real gateway
|
|
// (MSG91, Gupshup, Twilio) means adding one Sender and selecting it here —
|
|
// nothing above this package changes.
|
|
package sms
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"doormile/utils"
|
|
)
|
|
|
|
// Sender delivers a message to an E.164 phone number.
|
|
type Sender interface {
|
|
Send(phone, message string) error
|
|
// Name identifies the transport in logs and in the readiness probe, so
|
|
// "OTP not arriving" can be answered without reading code.
|
|
Name() string
|
|
}
|
|
|
|
// logSender writes the code to the application log instead of sending it.
|
|
//
|
|
// This is what runs until a gateway is configured. It is deliberately loud and
|
|
// deliberately marked: an OTP in a log file is a credential in a log file, and
|
|
// nobody should be able to reach production with this active and not know.
|
|
type logSender struct{}
|
|
|
|
func (logSender) Name() string { return "log" }
|
|
|
|
func (logSender) Send(phone, message string) error {
|
|
// In production this is a failure, not a fallback. A code that only
|
|
// reaches the application log has not been delivered, and returning nil
|
|
// reports a send that did not happen: the customer waits for a text that
|
|
// is never coming, and the endpoint cheerfully answers sent:true. An
|
|
// error at least surfaces as a clear failure on the sign-in screen.
|
|
if strings.EqualFold(strings.TrimSpace(os.Getenv("ENV")), "production") {
|
|
utils.Error("SMS NOT CONFIGURED in production — refusing to write a live "+
|
|
"verification code to the log. Set SMS_GATEWAY_URL.", "phone", maskPhone(phone))
|
|
return fmt.Errorf("sms: no gateway configured")
|
|
}
|
|
|
|
utils.Warn("SMS NOT CONFIGURED — code written to the log instead of being sent",
|
|
"phone", maskPhone(phone), "message", message)
|
|
return nil
|
|
}
|
|
|
|
var active Sender = logSender{}
|
|
|
|
// Register installs the real gateway. Call it from main() once a provider is
|
|
// configured; until then the log sink stays in place.
|
|
func Register(s Sender) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
active = s
|
|
utils.Info("SMS sender registered", "transport", s.Name())
|
|
}
|
|
|
|
// Transport reports which sender is active, for the readiness probe.
|
|
func Transport() string { return active.Name() }
|
|
|
|
// Configured reports whether a real gateway is in place. False means codes are
|
|
// only reaching the log.
|
|
func Configured() bool { return active.Name() != "log" }
|
|
|
|
// SendOTP delivers a login code.
|
|
func SendOTP(phone, code string) error {
|
|
if strings.TrimSpace(phone) == "" {
|
|
return fmt.Errorf("sms: empty phone number")
|
|
}
|
|
msg := fmt.Sprintf("%s is your Doormile verification code. It expires in 5 minutes. Do not share it with anyone.", code)
|
|
return active.Send(phone, msg)
|
|
}
|
|
|
|
// maskPhone keeps the country code and the last two digits so a log line can be
|
|
// matched to a support call without recording the number itself.
|
|
func maskPhone(phone string) string {
|
|
if len(phone) < 5 {
|
|
return "***"
|
|
}
|
|
return phone[:3] + strings.Repeat("*", len(phone)-5) + phone[len(phone)-2:]
|
|
}
|
|
|
|
// StagingCode returns the fixed verification code for non-production
|
|
// environments, or "" when none is set.
|
|
//
|
|
// Automated tests and design QA cannot receive a real text, and the previous
|
|
// end-to-end attempt on this system stalled for exactly that reason: customer
|
|
// login needed an OTP on a real handset and could not be scripted. CX_STAGING_OTP
|
|
// closes that.
|
|
//
|
|
// It is refused outright when ENV is production, because a fixed code is a
|
|
// permanent skeleton key for every account on the platform.
|
|
func StagingCode() string {
|
|
code := strings.TrimSpace(os.Getenv("CX_STAGING_OTP"))
|
|
if code == "" {
|
|
return ""
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(os.Getenv("ENV")), "production") {
|
|
utils.Error("CX_STAGING_OTP is set in a production environment and has been ignored — " +
|
|
"a fixed verification code would accept a login for every account on the platform")
|
|
return ""
|
|
}
|
|
return code
|
|
}
|