Files
2025-06-18 16:02:45 +03:00

554 lines
16 KiB
Go

// Package email is a credential validator which uses an external SMTP server.
package email
import (
"bytes"
crand "crypto/rand"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
"math/rand"
"mime"
qp "mime/quotedprintable"
"net/mail"
"net/smtp"
"net/url"
"strconv"
"strings"
textt "text/template"
"slices"
"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"
)
// Validator configuration.
type validator struct {
// Base URL of the web client.
HostUrl string `json:"host_url"`
// List of languages supported by templates.
Languages []string `json:"languages"`
// Path to email validation templates, either a template itself or a literal string.
ValidationTemplFile string `json:"validation_templ"`
// Path to templates for resetting the authentication secret.
ResetTemplFile string `json:"reset_secret_templ"`
// Sender RFC 5322 email address.
SendFrom string `json:"sender"`
// Login to use for SMTP authentication.
Login string `json:"login"`
// Password to use for SMTP authentication.
SenderPassword string `json:"sender_password"`
// Authentication mechanism to use, optional. One of "login", "md5", "plain" (default).
AuthMechanism string `json:"auth_mechanism"`
// Optional response which bypasses the validation.
DebugResponse string `json:"debug_response"`
// Number of validation attempts before email is locked.
MaxRetries int `json:"max_retries"`
// Address of the SMTP server.
SMTPAddr string `json:"smtp_server"`
// Port of the SMTP server.
SMTPPort string `json:"smtp_port"`
// ServerName used in SMTP HELO/EHLO command.
SMTPHeloHost string `json:"smtp_helo_host"`
// Skip verification of the server's certificate chain and host name.
// In this mode, TLS is susceptible to machine-in-the-middle attacks.
TLSInsecureSkipVerify bool `json:"insecure_skip_verify"`
// Optional whitelist of email domains accepted for registration.
Domains []string `json:"domains"`
// Length of secret numeric code to sent for validation.
CodeLength int `json:"code_length"`
// Must use index into language array instead of language tags because language.Matcher is brain damaged:
// https://github.com/golang/go/issues/24211
validationTempl []*textt.Template
resetTempl []*textt.Template
auth smtp.Auth
senderEmail string
langMatcher i18n.Matcher
maxCodeValue *big.Int
}
const (
validatorName = "email"
defaultMaxRetries = 3
defaultPort = "25"
// Technically email could be up to 255 bytes long but practically 128 is enough.
maxEmailLength = 128
// Default code length when one is not provided in the config
defaultCodeLength = 6
)
// Email template parts
var templateParts = []string{"subject", "body_plain", "body_html"}
// Init: initialize validator.
func (v *validator) Init(jsonconf string) error {
if err := json.Unmarshal([]byte(jsonconf), v); err != nil {
return err
}
sender, err := mail.ParseAddress(v.SendFrom)
if err != nil {
return err
}
v.senderEmail = sender.Address
// Enable auth if login is provided.
if v.Login != "" {
mechanism := strings.ToLower(v.AuthMechanism)
switch mechanism {
case "cram-md5":
v.auth = smtp.CRAMMD5Auth(v.Login, v.SenderPassword)
case "login":
v.auth = &loginAuth{[]byte(v.Login), []byte(v.SenderPassword)}
case "", "plain":
v.auth = smtp.PlainAuth("", v.Login, v.SenderPassword, v.SMTPAddr)
default:
return errors.New("unknown auth_mechanism")
}
}
// Optionally resolve paths.
v.ValidationTemplFile, err = validate.ResolveTemplatePath(v.ValidationTemplFile)
if err != nil {
return err
}
v.ResetTemplFile, err = validate.ResolveTemplatePath(v.ResetTemplFile)
if err != nil {
return err
}
// Paths to templates could be templates themselves: they may be language-dependent.
var validationPathTempl, resetPathTempl *textt.Template
validationPathTempl, err = textt.New("validation").Parse(v.ValidationTemplFile)
if err != nil {
return err
}
resetPathTempl, err = textt.New("reset").Parse(v.ResetTemplFile)
if err != nil {
return err
}
var path string
if len(v.Languages) > 0 {
v.validationTempl = make([]*textt.Template, len(v.Languages))
v.resetTempl = 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.validationTempl[idx], path, err = validate.ReadTemplateFile(validationPathTempl, lang); err != nil {
return err
}
if err = isTemplateValid(v.validationTempl[idx]); err != nil {
return fmt.Errorf("parsing %s: %w", path, err)
}
if v.resetTempl[idx], path, err = validate.ReadTemplateFile(resetPathTempl, lang); err != nil {
return err
}
if err = isTemplateValid(v.resetTempl[idx]); err != nil {
return fmt.Errorf("parsing %s: %w", path, err)
}
}
v.langMatcher = i18n.NewMatcher(langTags)
} else {
v.validationTempl = make([]*textt.Template, 1)
v.resetTempl = make([]*textt.Template, 1)
// No i18n support. Use defaults.
v.validationTempl[0], path, err = validate.ReadTemplateFile(validationPathTempl, "")
if err != nil {
return err
}
if err = isTemplateValid(v.validationTempl[0]); err != nil {
return fmt.Errorf("parsing %s: %w", path, err)
}
v.resetTempl[0], path, err = validate.ReadTemplateFile(resetPathTempl, "")
if err != nil {
return err
}
if err = isTemplateValid(v.resetTempl[0]); err != nil {
return fmt.Errorf("parsing %s: %w", path, err)
}
}
if v.HostUrl, err = validate.ValidateHostURL(v.HostUrl); err != nil {
return err
}
if v.SMTPHeloHost == "" {
hostUrl, _ := url.Parse(v.HostUrl)
v.SMTPHeloHost = hostUrl.Hostname()
}
if v.SMTPHeloHost == "" {
return errors.New("missing SMTP host")
}
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)
if v.SMTPPort == "" {
v.SMTPPort = defaultPort
}
return nil
}
// IsInitialized returns true if the validator is initialized.
func (v *validator) IsInitialized() bool {
return v.SMTPHeloHost != ""
}
// PreCheck validates the credential and parameters without sending an email.
// If the credential is valid, it's returned with an appropriate prefix.
func (v *validator) PreCheck(cred string, _ map[string]any) (string, error) {
if len(cred) > maxEmailLength {
return "", t.ErrMalformed
}
// The email must be plain user@domain.
addr, err := mail.ParseAddress(cred)
if err != nil || addr.Address != cred {
return "", t.ErrMalformed
}
// Normalize email to make sure Unicode case collisions don't lead to security problems.
addr.Address = strings.ToLower(addr.Address)
// If a whitelist of domains is provided, make sure the email belongs to the list.
if len(v.Domains) > 0 {
// Parse email into user and domain parts.
parts := strings.Split(addr.Address, "@")
if len(parts) != 2 {
return "", t.ErrMalformed
}
if !slices.Contains(v.Domains, parts[1]) {
return "", t.ErrPolicy
}
}
return validatorName + ":" + addr.Address, nil
}
// Send a request for confirmation to the user: makes a record in DB and nothing else.
func (v *validator) Request(user t.Uid, email, lang, resp string, tmpToken []byte) (bool, error) {
// Email validator cannot accept an immediate response.
if resp != "" {
return false, t.ErrFailed
}
// Normalize email to make sure Unicode case collisions don't lead to security problems.
email = strings.ToLower(email)
token := make([]byte, base64.StdEncoding.EncodedLen(len(tmpToken)))
base64.StdEncoding.Encode(token, tmpToken)
// Generate expected response as a random numeric string between 0 and 999999.
code, err := crand.Int(crand.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 {
// Find the template for the requested language.
// Make sure the language tag is standardized. Matcher is a bit dumber than Parse().
normalized, _ := i18n.Parse(lang)
// The matched tag is usually not in the list of available languages (e.g. es_ES -> es-u-rg-eszzzz).
// Use index to find the template instead of tag.
_, idx := i18n.MatchStrings(v.langMatcher, normalized.String())
template = v.validationTempl[idx]
} else {
template = v.validationTempl[0]
}
content, err := validate.ExecuteTemplate(template, templateParts, map[string]any{
"Token": url.QueryEscape(string(token)),
"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: email,
Resp: resp})
if err != nil {
return false, err
}
// Send email without blocking. Email sending may take long time.
go v.send(email, content)
return isNew, nil
}
// ResetSecret sends a message with instructions for resetting an authentication secret.
func (v *validator) ResetSecret(email, scheme, lang string, code []byte, params map[string]any) error {
// Normalize email to make sure Unicode case collisions don't lead to security problems.
email = strings.ToLower(email)
var template *textt.Template
if v.langMatcher != nil {
_, idx := i18n.MatchStrings(v.langMatcher, lang)
template = v.resetTempl[idx]
} else {
template = v.resetTempl[0]
}
var login string
if params != nil {
// Invariant: params["login"] is a string. Will panic if the invariant doesn't hold.
login = params["login"].(string)
}
content, err := validate.ExecuteTemplate(template, templateParts, map[string]any{
"Login": login,
"Code": string(code),
"Cred": email,
"Scheme": scheme,
"HostUrl": v.HostUrl})
if err != nil {
return err
}
// Send email without blocking. Email sending may take long time.
go v.send(email, content)
return nil
}
// Check checks if the provided validation response matches the expected response.
// Returns the value of validated credential on success.
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 {
// Request to validate non-existent 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.
func (v *validator) Delete(user t.Uid) error {
return store.Users.DelCred(user, validatorName, "")
}
// Remove deactivates or removes user's credential.
func (v *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
}
// SendMail replacement
func (v *validator) sendMail(rcpt []string, msg []byte) error {
client, err := smtp.Dial(v.SMTPAddr + ":" + v.SMTPPort)
if err != nil {
return err
}
defer client.Close()
if err = client.Hello(v.SMTPHeloHost); err != nil {
return err
}
if istls, _ := client.Extension("STARTTLS"); istls {
tlsConfig := &tls.Config{
InsecureSkipVerify: v.TLSInsecureSkipVerify,
ServerName: v.SMTPAddr,
}
if err = client.StartTLS(tlsConfig); err != nil {
return err
}
}
if v.auth != nil {
if isauth, _ := client.Extension("AUTH"); isauth {
err = client.Auth(v.auth)
if err != nil {
return err
}
}
}
if err = client.Mail(strings.ReplaceAll(strings.ReplaceAll(v.senderEmail, "\r", " "), "\n", " ")); err != nil {
return err
}
for _, to := range rcpt {
if err = client.Rcpt(strings.ReplaceAll(strings.ReplaceAll(to, "\r", " "), "\n", " ")); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
if _, err = w.Write(msg); err != nil {
return err
}
if err = w.Close(); err != nil {
return err
}
return client.Quit()
}
// This is a basic SMTP sender which connects to a server using login/password.
// -
// See here how to send email from Amazon SES:
// https://docs.aws.amazon.com/sdk-for-go/api/service/ses/#example_SES_SendEmail_shared00
// -
// Mailjet and SendGrid have some free email limits.
func (v *validator) send(to string, content map[string]string) error {
message := &bytes.Buffer{}
// Common headers.
fmt.Fprintf(message, "From: %s\r\n", v.SendFrom)
fmt.Fprintf(message, "To: %s\r\n", to)
message.WriteString("Subject: ")
// Old email clients may barf on UTF-8 strings.
// Encode as quoted printable with 75-char strings separated by spaces, split by spaces, reassemble.
message.WriteString(strings.Join(strings.Split(mime.QEncoding.Encode("utf-8", content["subject"]), " "), "\r\n "))
message.WriteString("\r\n")
message.WriteString("MIME-version: 1.0;\r\n")
if content["body_html"] == "" {
// Plain text message
message.WriteString("Content-Type: text/plain; charset=\"UTF-8\"; format=flowed; delsp=yes\r\n")
message.WriteString("Content-Transfer-Encoding: base64\r\n\r\n")
b64w := base64.NewEncoder(base64.StdEncoding, message)
b64w.Write([]byte(content["body_plain"]))
b64w.Close()
} else if content["body_plain"] == "" {
// HTML-formatted message
message.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n")
message.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qpw := qp.NewWriter(message)
qpw.Write([]byte(content["body_html"]))
qpw.Close()
} else {
// Multipart-alternative message includes both HTML and plain text components.
boundary := randomBoundary()
message.WriteString("Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n\r\n")
message.WriteString("--" + boundary + "\r\n")
message.WriteString("Content-Type: text/plain; charset=\"UTF-8\"; format=flowed; delsp=yes\r\n")
message.WriteString("Content-Transfer-Encoding: base64\r\n\r\n")
b64w := base64.NewEncoder(base64.StdEncoding, message)
b64w.Write([]byte(content["body_plain"]))
b64w.Close()
message.WriteString("\r\n")
message.WriteString("--" + boundary + "\r\n")
message.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n")
message.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qpw := qp.NewWriter(message)
qpw.Write([]byte(content["body_html"]))
qpw.Close()
message.WriteString("\r\n--" + boundary + "--")
}
message.WriteString("\r\n")
err := v.sendMail([]string{to}, message.Bytes())
if err != nil {
logs.Warn.Println("SMTP error", to, err)
}
return err
}
// Check if the template contains all required parts.
func isTemplateValid(templ *textt.Template) error {
if templ.Lookup("subject") == nil {
return fmt.Errorf("template invalid: '%s' not found", "subject")
}
if templ.Lookup("body_plain") == nil && templ.Lookup("body_html") == nil {
return fmt.Errorf("template invalid: neither of '%s', '%s' is found", "body_plain", "body_html")
}
return nil
}
type loginAuth struct {
username, password []byte
}
// Start begins an authentication with a server. Exported only to satisfy the interface definition.
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
return "LOGIN", []byte(a.username), nil
}
// Next continues the authentication. Exported only to satisfy the interface definition.
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
switch strings.ToLower(string(fromServer)) {
case "username:":
return a.username, nil
case "password:":
return a.password, nil
default:
return nil, fmt.Errorf("LOGIN AUTH unknown server response '%s'", string(fromServer))
}
}
return nil, nil
}
func randomBoundary() string {
var buf [24]byte
rand.Read(buf[:])
return fmt.Sprintf("tinode--%x", buf[:])
}
func init() {
store.RegisterValidator(validatorName, &validator{})
}