155 lines
7.7 KiB
Go
155 lines
7.7 KiB
Go
package domain
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
)
|
|
|
|
// loginCodeTemplateCodePlaceholder marks where the actual OTP code is
|
|
// substituted into a (possibly admin-edited) login-code message template.
|
|
// Unlike the old hardcoded "Login code: %s" format, the template body is no
|
|
// longer fixed, so the substituted code's bold MessageEntity offset/length
|
|
// must be computed dynamically from wherever the placeholder actually lands
|
|
// -- see OfficialLoginCodeMessage. It must appear exactly once in any
|
|
// template that reaches OfficialLoginCodeMessage (see
|
|
// ValidateLoginCodeMessageTemplate): zero occurrences would silently drop
|
|
// the code from the message entirely, and two-or-more is ambiguous about
|
|
// which occurrence is "the" code.
|
|
const loginCodeTemplateCodePlaceholder = "{{code}}"
|
|
|
|
// DefaultLoginCodeMessageTemplate is the built-in, final-fallback copy for
|
|
// the 777000 login-code delivery message. It is sent for every login code
|
|
// regardless of delivery channel (phone SMS or email) -- see the
|
|
// LoginCodeDeliveryStore implementations in internal/store/{postgres,memory},
|
|
// which all pass the same code through unconditionally, and
|
|
// internal/app/auth's recordLoginMessage (the new-account bootstrap path).
|
|
// Unlike DefaultWelcomeMessage{Phone,Email}Template there is only one
|
|
// template: the message never varies by channel. Supports {{server_name}}
|
|
// (see RenderWelcomeMessageTemplate) and requires the {{code}} placeholder
|
|
// exactly once (see ValidateLoginCodeMessageTemplate).
|
|
const DefaultLoginCodeMessageTemplate = `Login code: {{code}}. Do not give this code to anyone, even if they say they are from {{server_name}}!
|
|
|
|
This code can be used to log in to your {{server_name}} account. We never ask it for anything else.
|
|
|
|
If you didn't request this code by trying to log in on another device, simply ignore this message.`
|
|
|
|
// ErrLoginCodeMessageTemplateMissingCode is returned when a candidate
|
|
// login-code message template does not contain the {{code}} placeholder
|
|
// exactly once -- see ValidateLoginCodeMessageTemplate. The admin-API layer
|
|
// (cmd/telesrv-admin) must reject a save with this error outright rather
|
|
// than silently accepting it: a template with zero {{code}} occurrences
|
|
// would never deliver the actual OTP to the user at all.
|
|
var ErrLoginCodeMessageTemplateMissingCode = errors.New("login code message template must contain the {{code}} placeholder exactly once")
|
|
|
|
// ValidateLoginCodeMessageTemplate requires the {{code}} placeholder to
|
|
// appear exactly once. Zero occurrences is a functional break (the OTP
|
|
// itself would never reach the user), and two-or-more is ambiguous (which
|
|
// occurrence gets the bold entity and the substitution?) -- both are
|
|
// rejected outright, never silently patched around by e.g. appending the
|
|
// code somewhere the admin didn't put it.
|
|
func ValidateLoginCodeMessageTemplate(template string) error {
|
|
if strings.Count(template, loginCodeTemplateCodePlaceholder) != 1 {
|
|
return ErrLoginCodeMessageTemplateMissingCode
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ResolveLoginCodeMessageTemplate picks the final template body, in order:
|
|
// an explicit admin-panel override (panelOverride, as stored raw in
|
|
// identity.Info -- empty means "not configured"), then an explicit env-var
|
|
// default (envDefault, empty means "not configured"), then the compiled-in
|
|
// DefaultLoginCodeMessageTemplate. It is a pure function so the precedence
|
|
// logic can be unit-tested without touching the identity store or config --
|
|
// those live in internal/app/auth, which resolves this fresh on every
|
|
// login-code delivery (never cached) so an admin-panel edit takes effect
|
|
// immediately, mirroring ResolveWelcomeMessageTemplate. Unlike that
|
|
// resolver there is no per-method branching: every login code, regardless
|
|
// of delivery channel, uses the same template.
|
|
//
|
|
// This does not itself validate the {{code}} placeholder -- callers that
|
|
// persist an override (the admin API) must call
|
|
// ValidateLoginCodeMessageTemplate before saving. OfficialLoginCodeMessage
|
|
// re-validates whatever it resolves to anyway, as defense in depth against
|
|
// an invalid value that reached here some other way (a hand-edited
|
|
// identity.json, an out-of-band env var change).
|
|
func ResolveLoginCodeMessageTemplate(panelOverride, envDefault string) string {
|
|
if t := strings.TrimSpace(panelOverride); t != "" {
|
|
return panelOverride
|
|
}
|
|
if t := strings.TrimSpace(envDefault); t != "" {
|
|
return envDefault
|
|
}
|
|
return DefaultLoginCodeMessageTemplate
|
|
}
|
|
|
|
// LoginCodeDeliveryRequest describes one durable 777000 login-code delivery.
|
|
// PhoneCodeHash is an opaque idempotency token and must never be persisted in
|
|
// plaintext; store implementations persist only its SHA-256 digest.
|
|
type LoginCodeDeliveryRequest struct {
|
|
UserID int64
|
|
PhoneCodeHash string
|
|
Code string
|
|
// Template is the already-resolved login-code message template (see
|
|
// ResolveLoginCodeMessageTemplate) -- resolving it requires the identity
|
|
// store and config, both of which live above internal/store, so callers
|
|
// (internal/app/auth) do that and pass the final template text in here,
|
|
// the same division of responsibility OfficialWelcomeMessage's body
|
|
// parameter uses. Empty falls back to DefaultLoginCodeMessageTemplate
|
|
// (see OfficialLoginCodeMessage).
|
|
Template string
|
|
Date int
|
|
// ExpiresAt is the unix second after which the compact idempotency receipt
|
|
// may be reclaimed. It must cover the corresponding code's usable lifetime.
|
|
ExpiresAt int64
|
|
}
|
|
|
|
// LoginCodeDeliveryResult returns the immutable first delivery. Created is
|
|
// false when the same phone_code_hash was already committed and replayed.
|
|
type LoginCodeDeliveryResult struct {
|
|
Message Message
|
|
Created bool
|
|
}
|
|
|
|
// OfficialLoginCodeMessage builds the account-visible incoming message from
|
|
// Telegram's official notification account. Persistence assigns ID, UID and
|
|
// Pts atomically.
|
|
//
|
|
// template is rendered ({{server_name}} substituted, then {{code}} replaced
|
|
// with the actual code) and the resulting bold MessageEntity is positioned
|
|
// dynamically from wherever {{code}} actually landed after substitution --
|
|
// never assumed from a fixed prefix, since template is admin-editable (see
|
|
// ValidateLoginCodeMessageTemplate). A template that is empty or fails
|
|
// validation falls back to DefaultLoginCodeMessageTemplate instead of ever
|
|
// shipping a message with no code in it.
|
|
func OfficialLoginCodeMessage(userID int64, template, code string, date int) (Message, error) {
|
|
if userID <= 0 || IsSystemUserID(userID) || strings.TrimSpace(code) == "" || len(code) > 64 || date < 0 || date > math.MaxInt32 {
|
|
return Message{}, fmt.Errorf("%w: user=%d code_length=%d date=%d", ErrLoginCodeDeliveryInvalid, userID, len(code), date)
|
|
}
|
|
if strings.TrimSpace(template) == "" || ValidateLoginCodeMessageTemplate(template) != nil {
|
|
template = DefaultLoginCodeMessageTemplate
|
|
}
|
|
rendered := RenderWelcomeMessageTemplate(template)
|
|
idx := strings.Index(rendered, loginCodeTemplateCodePlaceholder)
|
|
if idx < 0 {
|
|
// Unreachable in practice: template was just validated (or is the
|
|
// compiled-in default) to contain the placeholder exactly once, and
|
|
// {{server_name}} substitution cannot remove or relocate an
|
|
// unrelated placeholder. Guarded anyway rather than ever ship a
|
|
// message silently missing its code.
|
|
rendered = DefaultLoginCodeMessageTemplate
|
|
idx = strings.Index(rendered, loginCodeTemplateCodePlaceholder)
|
|
}
|
|
body := rendered[:idx] + code + rendered[idx+len(loginCodeTemplateCodePlaceholder):]
|
|
return Message{
|
|
OwnerUserID: userID,
|
|
Peer: Peer{Type: PeerTypeUser, ID: OfficialSystemUserID},
|
|
From: Peer{Type: PeerTypeUser, ID: OfficialSystemUserID},
|
|
Date: date,
|
|
Body: body,
|
|
Entities: []MessageEntity{
|
|
{Type: MessageEntityBold, Offset: automaticEntityUTF16Length(rendered[:idx]), Length: automaticEntityUTF16Length(code)},
|
|
},
|
|
}, nil
|
|
}
|