Files
doormile_backend/internal/sms/sms.go

106 lines
3.7 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 {
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
}