Files
2025-02-09 12:23:37 +03:00

279 lines
7.5 KiB
Go

// Package tel is an incomplete implementation of SMS or voice credential validator.
package tel
import (
"crypto/rand"
"encoding/json"
"math/big"
"strconv"
"strings"
textt "text/template"
"github.com/nyaruka/phonenumbers"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
t "github.com/tinode/chat/server/store/types"
"github.com/tinode/chat/server/validate"
i18n "golang.org/x/text/language"
)
// Empty placeholder struct.
type validator struct {
// Base URL of the web client to tell clients.
HostUrl string `json:"host_url"`
// List of languages supported by templates.
Languages []string `json:"languages"`
// Path to email validation and password reset templates, either a template itself or a literal string.
UniversalTemplFile string `json:"universal_templ"`
// Sender address (phone number).
Sender string `json:"sender"`
// Debug response to accept during testing.
DebugResponse string `json:"debug_response"`
// Maximum number of validation retires.
MaxRetries int `json:"max_retries"`
// Length of secret numeric code to sent for validation.
CodeLength int `json:"code_length"`
// Twilio-specific config.
Twilio json.RawMessage `json:"twilio_conf"`
// Must use index into language array instead of language tags because language.Matcher is brain damaged:
// https://github.com/golang/go/issues/24211
universalTempl []*textt.Template
langMatcher i18n.Matcher
maxCodeValue *big.Int
}
const (
validatorName = "tel"
defaultMaxRetries = 3
// Default code length when one is not provided in the config
defaultCodeLength = 6
defaultSender = "Tinode"
)
func (v *validator) Init(jsonconf string) error {
var err error
if err = json.Unmarshal([]byte(jsonconf), v); err != nil {
return err
}
if v.HostUrl, err = validate.ValidateHostURL(v.HostUrl); err != nil {
return err
}
var universalPathTempl *textt.Template
universalPathTempl, err = textt.New("universal").Parse(v.UniversalTemplFile)
if err != nil {
return err
}
if len(v.Languages) > 0 {
v.universalTempl = make([]*textt.Template, len(v.Languages))
var langTags []i18n.Tag
// Find actual content templates for each defined language.
for idx, lang := range v.Languages {
tag, err := i18n.Parse(lang)
if err != nil {
return err
}
langTags = append(langTags, tag)
if v.universalTempl[idx], _, err = validate.ReadTemplateFile(universalPathTempl, lang); err != nil {
return err
}
}
v.langMatcher = i18n.NewMatcher(langTags)
} else {
v.universalTempl = make([]*textt.Template, 1)
// No i18n support. Use defaults.
v.universalTempl[0], _, err = validate.ReadTemplateFile(universalPathTempl, "")
if err != nil {
return err
}
}
if v.Twilio != nil {
if err = twilioInit(v.Twilio); err != nil {
return err
}
}
if v.Sender == "" {
v.Sender = defaultSender
}
if v.MaxRetries == 0 {
v.MaxRetries = defaultMaxRetries
}
if v.CodeLength == 0 {
v.CodeLength = defaultCodeLength
}
v.maxCodeValue = big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(v.CodeLength)), nil)
return nil
}
// IsInitialized returns true if the validator is initialized.
func (v *validator) IsInitialized() bool {
return v.CodeLength > 0
}
// PreCheck validates the credential and parameters without sending an SMS or making the call.
// If credential is valid, it's formatted and prefixed with a tag namespace.
func (*validator) PreCheck(cred string, params map[string]any) (string, error) {
// Parse will try to extract the number from any text, make sure it's just the number.
if !phonenumbers.VALID_PHONE_NUMBER_PATTERN.MatchString(cred) {
return "", t.ErrMalformed
}
countryCode, ok := params["countryCode"].(string)
if !ok {
countryCode = "US"
}
number, err := phonenumbers.Parse(cred, countryCode)
if err != nil {
return "", t.ErrMalformed
}
if !phonenumbers.IsValidNumber(number) {
return "", t.ErrMalformed
}
if numType := phonenumbers.GetNumberType(number); numType != phonenumbers.FIXED_LINE_OR_MOBILE &&
numType != phonenumbers.MOBILE {
return "", t.ErrMalformed
}
return validatorName + ":" + phonenumbers.Format(number, phonenumbers.E164), nil
}
// Request sends a request for confirmation to the user: makes a record in DB and nothing else.
func (v *validator) Request(user t.Uid, phone, lang, resp string, tmpToken []byte) (bool, error) {
// Phone validator cannot accept an immediate response.
if resp != "" {
return false, t.ErrFailed
}
// Generate expected response as a random numeric string between 0 and 999999.
code, err := rand.Int(rand.Reader, v.maxCodeValue)
if err != nil {
return false, err
}
resp = strconv.FormatInt(code.Int64(), 10)
resp = strings.Repeat("0", v.CodeLength-len(resp)) + resp
var template *textt.Template
if v.langMatcher != nil {
_, idx := i18n.MatchStrings(v.langMatcher, lang)
template = v.universalTempl[idx]
} else {
template = v.universalTempl[0]
}
content, err := validate.ExecuteTemplate(template, nil, map[string]any{
"Code": resp,
"HostUrl": v.HostUrl})
if err != nil {
return false, err
}
// Create or update validation record in DB.
isNew, err := store.Users.UpsertCred(&t.Credential{
User: user.String(),
Method: validatorName,
Value: phone,
Resp: resp})
if err != nil {
return false, err
}
// Send SMS without blocking. It sending may take long time.
go v.send(phone, content[""])
return isNew, nil
}
// ResetSecret sends a message with instructions for resetting an authentication secret.
func (v *validator) ResetSecret(phone, scheme, lang string, code []byte, params map[string]any) error {
var template *textt.Template
if v.langMatcher != nil {
_, idx := i18n.MatchStrings(v.langMatcher, lang)
template = v.universalTempl[idx]
} else {
template = v.universalTempl[0]
}
content, err := validate.ExecuteTemplate(template, nil, map[string]any{
"Code": string(code),
"HostUrl": v.HostUrl})
if err != nil {
return err
}
// Send SMS without blocking. Sending may take long time.
go v.send(phone, content[""])
return nil
}
// Check checks validity of user's response.
func (v *validator) Check(user t.Uid, resp string) (string, error) {
cred, err := store.Users.GetActiveCred(user, validatorName)
if err != nil {
return "", err
}
if cred == nil {
// Blank credential.
return "", t.ErrNotFound
}
if cred.Retries > v.MaxRetries {
return "", t.ErrPolicy
}
if resp == "" {
return "", t.ErrCredentials
}
// Comparing with dummy response too.
if cred.Resp == resp || v.DebugResponse == resp {
// Valid response, save confirmation.
return cred.Value, store.Users.ConfirmCred(user, validatorName)
}
// Invalid response, increment fail counter, ignore possible error.
store.Users.FailCred(user, validatorName)
return "", t.ErrCredentials
}
// Delete deletes user's records. Returns deleted credentials.
func (*validator) Delete(user t.Uid) error {
return store.Users.DelCred(user, validatorName, "")
}
// Remove or disable the given record.
func (*validator) Remove(user t.Uid, value string) error {
return store.Users.DelCred(user, validatorName, value)
}
// TempAuthScheme returns a temporary authentication method used by this validator.
func (v *validator) TempAuthScheme() (string, error) {
return "code", nil
}
// Implement sending the SMS.
func (v *validator) send(to, body string) error {
if v.Twilio != nil {
if err := twilioSend(v.Sender, to, body); err != nil {
logs.Warn.Println("Twilio SMS error", to, err)
}
} else {
logs.Info.Println("Send SMS, To:", to, "\nText:", body)
}
return nil
}
func init() {
store.RegisterValidator(validatorName, &validator{})
}