3
0
mirror of https://github.com/ergochat/ergo.git synced 2024-11-25 13:29:27 +01:00

Add email-based password reset (#1779)

* Add email-based password reset

Fixes #734

* rename SETPASS to RESETPASS

* review fixes

* abuse mitigations

* SENDPASS and RESETPASS should both touch the client login throttle
* Produce a logline and a sno on SENDPASS (since it actually sends an email)

* don't re-retrieve the settings value

* add email confirmation for NS SET EMAIL

* smtp: if require-tls is disabled, don't validate server cert

* review fixes

* remove cooldown for NS SET EMAIL

If you accidentally set the wrong address, the cooldown would prevent you
from fixing your mistake. Since we touch the registration throttle anyway,
this shouldn't present more of an abuse concern than registration itself.
This commit is contained in:
Shivaram Lingamneni 2021-08-25 22:32:55 -04:00 committed by GitHub
parent 0baaf0b711
commit 8b2f6de3e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 525 additions and 58 deletions

View File

@ -414,6 +414,13 @@ accounts:
blacklist-regexes:
# - ".*@mailinator.com"
timeout: 60s
# email-based password reset:
password-reset:
enabled: false
# time before we allow resending the email
cooldown: 1h
# time for which a password reset code is valid
timeout: 1d
# throttle account login attempts (to prevent either password guessing, or DoS
# attacks on the server aimed at forcing repeated expensive bcrypt computations)

View File

@ -4,7 +4,6 @@
package irc
import (
"bytes"
"crypto/rand"
"crypto/x509"
"encoding/json"
@ -32,7 +31,6 @@ const (
keyAccountExists = "account.exists %s"
keyAccountVerified = "account.verified %s"
keyAccountUnregistered = "account.unregistered %s"
keyAccountCallback = "account.callback %s"
keyAccountVerificationCode = "account.verificationcode %s"
keyAccountName = "account.name %s" // stores the 'preferred name' of the account, not casemapped
keyAccountRegTime = "account.registered.time %s"
@ -46,6 +44,8 @@ const (
keyAccountModes = "account.modes %s" // user modes for the always-on client as a string
keyAccountRealname = "account.realname %s" // client realname stored as string
keyAccountSuspended = "account.suspended %s" // client realname stored as string
keyAccountPwReset = "account.pwreset %s"
keyAccountEmailChange = "account.emailchange %s"
// for an always-on client, a map of channel names they're in to their current modes
// (not to be confused with their amodes, which a non-always-on client can have):
keyAccountChannelToModes = "account.channeltomodes %s"
@ -391,10 +391,10 @@ func (am *AccountManager) Register(client *Client, account string, callbackNames
accountKey := fmt.Sprintf(keyAccountExists, casefoldedAccount)
unregisteredKey := fmt.Sprintf(keyAccountUnregistered, casefoldedAccount)
accountNameKey := fmt.Sprintf(keyAccountName, casefoldedAccount)
callbackKey := fmt.Sprintf(keyAccountCallback, casefoldedAccount)
registeredTimeKey := fmt.Sprintf(keyAccountRegTime, casefoldedAccount)
credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount)
verificationCodeKey := fmt.Sprintf(keyAccountVerificationCode, casefoldedAccount)
settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount)
certFPKey := fmt.Sprintf(keyCertToAccount, certfp)
var creds AccountCredentials
@ -409,8 +409,16 @@ func (am *AccountManager) Register(client *Client, account string, callbackNames
return err
}
var settingsStr string
if callbackNamespace == "mailto" {
settings := AccountSettings{Email: callbackValue}
j, err := json.Marshal(settings)
if err == nil {
settingsStr = string(j)
}
}
registeredTimeStr := strconv.FormatInt(time.Now().UnixNano(), 10)
callbackSpec := fmt.Sprintf("%s:%s", callbackNamespace, callbackValue)
var setOptions *buntdb.SetOptions
ttl := time.Duration(config.Accounts.Registration.VerifyTimeout)
@ -449,7 +457,7 @@ func (am *AccountManager) Register(client *Client, account string, callbackNames
tx.Set(accountNameKey, account, setOptions)
tx.Set(registeredTimeKey, registeredTimeStr, setOptions)
tx.Set(credentialsKey, credStr, setOptions)
tx.Set(callbackKey, callbackSpec, setOptions)
tx.Set(settingsKey, settingsStr, setOptions)
if certfp != "" {
tx.Set(certFPKey, casefoldedAccount, setOptions)
}
@ -782,15 +790,7 @@ func (am *AccountManager) dispatchMailtoCallback(client *Client, account string,
subject = fmt.Sprintf(client.t("Verify your account on %s"), am.server.name)
}
var message bytes.Buffer
fmt.Fprintf(&message, "From: %s\r\n", config.Sender)
fmt.Fprintf(&message, "To: %s\r\n", callbackValue)
if config.DKIM.Domain != "" {
fmt.Fprintf(&message, "Message-ID: <%s@%s>\r\n", utils.GenerateSecretKey(), config.DKIM.Domain)
}
fmt.Fprintf(&message, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
fmt.Fprintf(&message, "Subject: %s\r\n", subject)
message.WriteString("\r\n") // blank line: end headers, begin message body
message := email.ComposeMail(config, callbackValue, subject)
fmt.Fprintf(&message, client.t("Account: %s"), account)
message.WriteString("\r\n")
fmt.Fprintf(&message, client.t("Verification code: %s"), code)
@ -823,8 +823,8 @@ func (am *AccountManager) Verify(client *Client, account string, code string) er
accountNameKey := fmt.Sprintf(keyAccountName, casefoldedAccount)
registeredTimeKey := fmt.Sprintf(keyAccountRegTime, casefoldedAccount)
verificationCodeKey := fmt.Sprintf(keyAccountVerificationCode, casefoldedAccount)
callbackKey := fmt.Sprintf(keyAccountCallback, casefoldedAccount)
credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount)
settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount)
var raw rawClientAccount
@ -892,8 +892,8 @@ func (am *AccountManager) Verify(client *Client, account string, code string) er
tx.Set(accountKey, "1", nil)
tx.Set(accountNameKey, raw.Name, nil)
tx.Set(registeredTimeKey, raw.RegisteredAt, nil)
tx.Set(callbackKey, raw.Callback, nil)
tx.Set(credentialsKey, raw.Credentials, nil)
tx.Set(settingsKey, raw.Settings, nil)
var creds AccountCredentials
// XXX we shouldn't do (de)serialization inside the txn,
@ -955,6 +955,214 @@ func (am *AccountManager) SARegister(account, passphrase string) (err error) {
return
}
type EmailChangeRecord struct {
TimeCreated time.Time
Code string
Email string
}
func (am *AccountManager) NsSetEmail(client *Client, emailAddr string) (err error) {
casefoldedAccount := client.Account()
if casefoldedAccount == "" {
return errAccountNotLoggedIn
}
if am.touchRegisterThrottle() {
am.server.logger.Warning("accounts", "global registration throttle exceeded by client changing email", client.Nick())
return errLimitExceeded
}
config := am.server.Config()
if !config.Accounts.Registration.EmailVerification.Enabled {
return errFeatureDisabled // redundant check, just in case
}
record := EmailChangeRecord{
TimeCreated: time.Now().UTC(),
Code: utils.GenerateSecretToken(),
Email: emailAddr,
}
recordKey := fmt.Sprintf(keyAccountEmailChange, casefoldedAccount)
recordBytes, _ := json.Marshal(record)
recordVal := string(recordBytes)
am.server.store.Update(func(tx *buntdb.Tx) error {
tx.Set(recordKey, recordVal, nil)
return nil
})
if err != nil {
return err
}
message := email.ComposeMail(config.Accounts.Registration.EmailVerification,
emailAddr,
fmt.Sprintf(client.t("Verify your change of e-mail address on %s"), am.server.name))
message.WriteString(fmt.Sprintf(client.t("To confirm your change of e-mail address on %s, issue the following command:"), am.server.name))
message.WriteString("\r\n")
fmt.Fprintf(&message, "/MSG NickServ VERIFYEMAIL %s\r\n", record.Code)
err = email.SendMail(config.Accounts.Registration.EmailVerification, emailAddr, message.Bytes())
if err == nil {
am.server.logger.Info("services",
fmt.Sprintf("email change verification sent for account %s", casefoldedAccount))
return
} else {
am.server.logger.Error("internal", "Failed to dispatch e-mail change verification to", emailAddr, err.Error())
return &registrationCallbackError{err}
}
}
func (am *AccountManager) NsVerifyEmail(client *Client, code string) (err error) {
casefoldedAccount := client.Account()
if casefoldedAccount == "" {
return errAccountNotLoggedIn
}
var record EmailChangeRecord
success := false
key := fmt.Sprintf(keyAccountEmailChange, casefoldedAccount)
ttl := time.Duration(am.server.Config().Accounts.Registration.VerifyTimeout)
am.server.store.Update(func(tx *buntdb.Tx) error {
rawStr, err := tx.Get(key)
if err == nil && rawStr != "" {
err := json.Unmarshal([]byte(rawStr), &record)
if err == nil {
if (ttl == 0 || time.Since(record.TimeCreated) < ttl) && utils.SecretTokensMatch(record.Code, code) {
success = true
tx.Delete(key)
}
}
}
return nil
})
if !success {
return errAccountVerificationInvalidCode
}
munger := func(in AccountSettings) (out AccountSettings, err error) {
out = in
out.Email = record.Email
return
}
_, err = am.ModifyAccountSettings(casefoldedAccount, munger)
return
}
func (am *AccountManager) NsSendpass(client *Client, accountName string) (err error) {
config := am.server.Config()
if !(config.Accounts.Registration.EmailVerification.Enabled && config.Accounts.Registration.EmailVerification.PasswordReset.Enabled) {
return errFeatureDisabled
}
account, err := am.LoadAccount(accountName)
if err != nil {
return err
}
if !account.Verified {
return errAccountUnverified
}
if account.Suspended != nil {
return errAccountSuspended
}
if account.Settings.Email == "" {
return errValidEmailRequired
}
record := PasswordResetRecord{
TimeCreated: time.Now().UTC(),
Code: utils.GenerateSecretToken(),
}
recordKey := fmt.Sprintf(keyAccountPwReset, account.NameCasefolded)
recordBytes, _ := json.Marshal(record)
recordVal := string(recordBytes)
am.server.store.Update(func(tx *buntdb.Tx) error {
recStr, recErr := tx.Get(recordKey)
if recErr == nil && recStr != "" {
var existing PasswordResetRecord
jErr := json.Unmarshal([]byte(recStr), &existing)
cooldown := time.Duration(config.Accounts.Registration.EmailVerification.PasswordReset.Cooldown)
if jErr == nil && time.Since(existing.TimeCreated) < cooldown {
err = errLimitExceeded
return nil
}
}
tx.Set(recordKey, recordVal, &buntdb.SetOptions{
Expires: true,
TTL: time.Duration(config.Accounts.Registration.EmailVerification.PasswordReset.Timeout),
})
return nil
})
if err != nil {
return
}
subject := fmt.Sprintf(client.t("Reset your password on %s"), am.server.name)
message := email.ComposeMail(config.Accounts.Registration.EmailVerification, account.Settings.Email, subject)
fmt.Fprintf(&message, client.t("We received a request to reset your password on %s for account: %s"), am.server.name, account.Name)
message.WriteString("\r\n")
fmt.Fprintf(&message, client.t("If you did not initiate this request, you can safely ignore this message."))
message.WriteString("\r\n")
message.WriteString("\r\n")
message.WriteString(client.t("Otherwise, to reset your password, issue the following command (replace `new_password` with your desired password):"))
message.WriteString("\r\n")
fmt.Fprintf(&message, "/MSG NickServ RESETPASS %s %s new_password\r\n", account.Name, record.Code)
err = email.SendMail(config.Accounts.Registration.EmailVerification, account.Settings.Email, message.Bytes())
if err == nil {
am.server.logger.Info("services",
fmt.Sprintf("client %s sent a password reset email for account %s", client.Nick(), account.Name))
} else {
am.server.logger.Error("internal", "Failed to dispatch e-mail to", account.Settings.Email, err.Error())
}
return
}
func (am *AccountManager) NsResetpass(client *Client, accountName, code, password string) (err error) {
if validatePassphrase(password) != nil {
return errAccountBadPassphrase
}
account, err := am.LoadAccount(accountName)
if err != nil {
return
}
if !account.Verified {
return errAccountUnverified
}
if account.Suspended != nil {
return errAccountSuspended
}
success := false
key := fmt.Sprintf(keyAccountPwReset, account.NameCasefolded)
am.server.store.Update(func(tx *buntdb.Tx) error {
rawStr, err := tx.Get(key)
if err == nil && rawStr != "" {
var record PasswordResetRecord
err := json.Unmarshal([]byte(rawStr), &record)
if err == nil && utils.SecretTokensMatch(record.Code, code) {
success = true
tx.Delete(key)
}
}
return nil
})
if success {
return am.setPassword(accountName, password, true)
} else {
return errAccountInvalidCredentials
}
}
type PasswordResetRecord struct {
TimeCreated time.Time
Code string
}
func marshalReservedNicks(nicks []string) string {
return strings.Join(nicks, ",")
}
@ -1294,9 +1502,6 @@ func (am *AccountManager) deserializeRawAccount(raw rawClientAccount, cfName str
return
}
result.AdditionalNicks = unmarshalReservedNicks(raw.AdditionalNicks)
if strings.HasPrefix(raw.Callback, "mailto:") {
result.Email = strings.TrimPrefix(raw.Callback, "mailto:")
}
result.Verified = raw.Verified
if raw.VHost != "" {
e := json.Unmarshal([]byte(raw.VHost), &result.VHost)
@ -1329,7 +1534,6 @@ func (am *AccountManager) loadRawAccount(tx *buntdb.Tx, casefoldedAccount string
registeredTimeKey := fmt.Sprintf(keyAccountRegTime, casefoldedAccount)
credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount)
verifiedKey := fmt.Sprintf(keyAccountVerified, casefoldedAccount)
callbackKey := fmt.Sprintf(keyAccountCallback, casefoldedAccount)
nicksKey := fmt.Sprintf(keyAccountAdditionalNicks, casefoldedAccount)
vhostKey := fmt.Sprintf(keyAccountVHost, casefoldedAccount)
settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount)
@ -1344,7 +1548,6 @@ func (am *AccountManager) loadRawAccount(tx *buntdb.Tx, casefoldedAccount string
result.Name, _ = tx.Get(accountNameKey)
result.RegisteredAt, _ = tx.Get(registeredTimeKey)
result.Credentials, _ = tx.Get(credentialsKey)
result.Callback, _ = tx.Get(callbackKey)
result.AdditionalNicks, _ = tx.Get(nicksKey)
result.VHost, _ = tx.Get(vhostKey)
result.Settings, _ = tx.Get(settingsKey)
@ -1524,7 +1727,6 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
accountNameKey := fmt.Sprintf(keyAccountName, casefoldedAccount)
registeredTimeKey := fmt.Sprintf(keyAccountRegTime, casefoldedAccount)
credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount)
callbackKey := fmt.Sprintf(keyAccountCallback, casefoldedAccount)
verificationCodeKey := fmt.Sprintf(keyAccountVerificationCode, casefoldedAccount)
verifiedKey := fmt.Sprintf(keyAccountVerified, casefoldedAccount)
nicksKey := fmt.Sprintf(keyAccountAdditionalNicks, casefoldedAccount)
@ -1537,6 +1739,8 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
modesKey := fmt.Sprintf(keyAccountModes, casefoldedAccount)
realnameKey := fmt.Sprintf(keyAccountRealname, casefoldedAccount)
suspendedKey := fmt.Sprintf(keyAccountSuspended, casefoldedAccount)
pwResetKey := fmt.Sprintf(keyAccountPwReset, casefoldedAccount)
emailChangeKey := fmt.Sprintf(keyAccountEmailChange, casefoldedAccount)
var clients []*Client
defer func() {
@ -1582,7 +1786,6 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
tx.Delete(accountNameKey)
tx.Delete(verifiedKey)
tx.Delete(registeredTimeKey)
tx.Delete(callbackKey)
tx.Delete(verificationCodeKey)
tx.Delete(settingsKey)
rawNicks, _ = tx.Get(nicksKey)
@ -1597,6 +1800,8 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
tx.Delete(modesKey)
tx.Delete(realnameKey)
tx.Delete(suspendedKey)
tx.Delete(pwResetKey)
tx.Delete(emailChangeKey)
return nil
})
@ -1940,17 +2145,19 @@ const (
CredentialsAnope = -2
)
// AccountCredentials stores the various methods for verifying accounts.
type AccountCredentials struct {
Version CredentialsVersion
PassphraseHash []byte
Certfps []string
SCRAMCreds struct {
type SCRAMCreds struct {
Salt []byte
Iters int
StoredKey []byte
ServerKey []byte
}
// AccountCredentials stores the various methods for verifying accounts.
type AccountCredentials struct {
Version CredentialsVersion
PassphraseHash []byte
Certfps []string
SCRAMCreds
}
func (ac *AccountCredentials) Empty() bool {
@ -1970,6 +2177,7 @@ func (ac *AccountCredentials) Serialize() (result string, err error) {
func (ac *AccountCredentials) SetPassphrase(passphrase string, bcryptCost uint) (err error) {
if passphrase == "" {
ac.PassphraseHash = nil
ac.SCRAMCreds = SCRAMCreds{}
return nil
}
@ -1994,10 +2202,12 @@ func (ac *AccountCredentials) SetPassphrase(passphrase string, bcryptCost uint)
// xdg-go/scram says: "Clients have a default minimum PBKDF2 iteration count of 4096."
minIters := 4096
scramCreds := scramClient.GetStoredCredentials(scram.KeyFactors{Salt: string(salt), Iters: minIters})
ac.SCRAMCreds.Salt = salt
ac.SCRAMCreds.Iters = minIters
ac.SCRAMCreds.StoredKey = scramCreds.StoredKey
ac.SCRAMCreds.ServerKey = scramCreds.ServerKey
ac.SCRAMCreds = SCRAMCreds{
Salt: salt,
Iters: minIters,
StoredKey: scramCreds.StoredKey,
ServerKey: scramCreds.ServerKey,
}
return nil
}
@ -2112,6 +2322,7 @@ type AccountSettings struct {
AutoreplayMissed bool
DMHistory HistoryStatus
AutoAway PersistentStatus
Email string
}
// ClientAccount represents a user account.
@ -2120,7 +2331,6 @@ type ClientAccount struct {
Name string
NameCasefolded string
RegisteredAt time.Time
Email string
Credentials AccountCredentials
Verified bool
Suspended *AccountSuspension
@ -2134,7 +2344,6 @@ type rawClientAccount struct {
Name string
RegisteredAt string
Credentials string
Callback string
Verified bool
AdditionalNicks string
VHost string

View File

@ -24,7 +24,7 @@ const (
// 'version' of the database schema
keySchemaVersion = "db.version"
// latest schema of the db
latestDbSchema = 20
latestDbSchema = 21
keyCloakSecret = "crypto.cloak_secret"
)
@ -1008,6 +1008,57 @@ func schemaChangeV19To20(config *Config, tx *buntdb.Tx) error {
return nil
}
// #734: move the email address into the settings object,
// giving people a way to change it
func schemaChangeV20To21(config *Config, tx *buntdb.Tx) error {
type accountSettingsv21 struct {
AutoreplayLines *int
NickEnforcement NickEnforcementMethod
AllowBouncer MulticlientAllowedSetting
ReplayJoins ReplayJoinsSetting
AlwaysOn PersistentStatus
AutoreplayMissed bool
DMHistory HistoryStatus
AutoAway PersistentStatus
Email string
}
var accounts []string
var emails []string
callbackPrefix := "account.callback "
tx.AscendGreaterOrEqual("", callbackPrefix, func(key, value string) bool {
if !strings.HasPrefix(key, callbackPrefix) {
return false
}
account := strings.TrimPrefix(key, callbackPrefix)
if _, err := tx.Get("account.verified " + account); err != nil {
return true
}
if strings.HasPrefix(value, "mailto:") {
accounts = append(accounts, account)
emails = append(emails, strings.TrimPrefix(value, "mailto:"))
}
return true
})
for i, account := range accounts {
var settings accountSettingsv21
email := emails[i]
settingsKey := "account.settings " + account
settingsStr, err := tx.Get(settingsKey)
if err == nil && settingsStr != "" {
json.Unmarshal([]byte(settingsStr), &settings)
}
settings.Email = email
settingsBytes, err := json.Marshal(settings)
if err != nil {
log.Printf("couldn't marshal settings for %s: %v\n", account, err)
} else {
tx.Set(settingsKey, string(settingsBytes), nil)
}
tx.Delete(callbackPrefix + account)
}
return nil
}
func getSchemaChange(initialVersion int) (result SchemaChange, ok bool) {
for _, change := range allChanges {
if initialVersion == change.InitialVersion {
@ -1113,4 +1164,9 @@ var allChanges = []SchemaChange{
TargetVersion: 20,
Changer: schemaChangeV19To20,
},
{
InitialVersion: 20,
TargetVersion: 21,
Changer: schemaChangeV20To21,
},
}

View File

@ -4,6 +4,7 @@
package email
import (
"bytes"
"errors"
"fmt"
"net"
@ -11,7 +12,9 @@ import (
"strings"
"time"
"github.com/ergochat/ergo/irc/custime"
"github.com/ergochat/ergo/irc/smtp"
"github.com/ergochat/ergo/irc/utils"
)
var (
@ -42,6 +45,11 @@ type MailtoConfig struct {
BlacklistRegexes []string `yaml:"blacklist-regexes"`
blacklistRegexes []*regexp.Regexp
Timeout time.Duration
PasswordReset struct {
Enabled bool
Cooldown custime.Duration
Timeout custime.Duration
} `yaml:"password-reset"`
}
func (config *MailtoConfig) Postprocess(heloDomain string) (err error) {
@ -95,6 +103,19 @@ func lookupMX(domain string) (server string) {
return
}
func ComposeMail(config MailtoConfig, recipient, subject string) (message bytes.Buffer) {
fmt.Fprintf(&message, "From: %s\r\n", config.Sender)
fmt.Fprintf(&message, "To: %s\r\n", recipient)
dkimDomain := config.DKIM.Domain
if dkimDomain != "" {
fmt.Fprintf(&message, "Message-ID: <%s@%s>\r\n", utils.GenerateSecretKey(), dkimDomain)
}
fmt.Fprintf(&message, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
fmt.Fprintf(&message, "Subject: %s\r\n", subject)
message.WriteString("\r\n") // blank line: end headers, begin message body
return message
}
func SendMail(config MailtoConfig, recipient string, msg []byte) (err error) {
for _, reg := range config.blacklistRegexes {
if reg.MatchString(recipient) {

View File

@ -121,7 +121,9 @@ func doImportDBGeneric(config *Config, dbImport databaseImport, credsType Creden
tx.Set(fmt.Sprintf(keyAccountExists, cfUsername), "1", nil)
tx.Set(fmt.Sprintf(keyAccountVerified, cfUsername), "1", nil)
tx.Set(fmt.Sprintf(keyAccountName, cfUsername), userInfo.Name, nil)
tx.Set(fmt.Sprintf(keyAccountCallback, cfUsername), "mailto:"+userInfo.Email, nil)
settings := AccountSettings{Email: userInfo.Email}
settingsBytes, _ := json.Marshal(settings)
tx.Set(fmt.Sprintf(keyAccountSettings, cfUsername), string(settingsBytes), nil)
tx.Set(fmt.Sprintf(keyAccountCredentials, cfUsername), string(marshaledCredentials), nil)
tx.Set(fmt.Sprintf(keyAccountRegTime, cfUsername), strconv.FormatInt(userInfo.RegisteredAt, 10), nil)
if userInfo.Vhost != "" {

View File

@ -36,6 +36,11 @@ func servCmdRequiresBouncerEnabled(config *Config) bool {
return config.Accounts.Multiclient.Enabled
}
func servCmdRequiresEmailReset(config *Config) bool {
return config.Accounts.Registration.EmailVerification.Enabled &&
config.Accounts.Registration.EmailVerification.PasswordReset.Enabled
}
const nickservHelp = `NickServ lets you register, log in to, and manage an account.`
var (
@ -302,6 +307,12 @@ how the history of your direct messages is stored. Your options are:
'auto-away' is only effective for always-on clients. If enabled, you will
automatically be marked away when all your sessions are disconnected, and
automatically return from away when you connect again.`,
`$bEMAIL$b
'email' controls the e-mail address associated with your account (if the
server operator allows it, this address can be used for password resets).
As an additional security measure, if you have a password set, you must
provide it as an additional argument to $bSET$b, for example,
SET EMAIL test@example.com hunter2`,
},
authRequired: true,
enabled: servCmdRequiresAuthEnabled,
@ -318,6 +329,27 @@ information on the settings and their possible values, see HELP SET.`,
minParams: 3,
capabs: []string{"accreg"},
},
"sendpass": {
handler: nsSendpassHandler,
help: `Syntax: $bSENDPASS <account>$b
SENDPASS sends a password reset email to the email address associated with
the target account. The reset code in the email can then be used with the
$bRESETPASS$b command.`,
helpShort: `$bSENDPASS$b initiates an email-based password reset`,
enabled: servCmdRequiresEmailReset,
minParams: 1,
},
"resetpass": {
handler: nsResetpassHandler,
help: `Syntax: $bRESETPASS <account> <code> <password>$b
RESETPASS resets an account password, using a reset code that was emailed as
the result of a previous $bSENDPASS$b command.`,
helpShort: `$bRESETPASS$b completes an email-based password reset`,
enabled: servCmdRequiresEmailReset,
minParams: 3,
},
"cert": {
handler: nsCertHandler,
help: `Syntax: $bCERT <LIST | ADD | DEL> [account] [certfp]$b
@ -357,6 +389,12 @@ Currently, you can only change the canonical casefolding of an account
minParams: 2,
capabs: []string{"accreg"},
},
"verifyemail": {
handler: nsVerifyEmailHandler,
authRequired: true,
minParams: 1,
hidden: true,
},
}
)
@ -459,7 +497,12 @@ func displaySetting(service *ircService, settingName string, settings AccountSet
effectiveValue := historyEnabled(config.History.Persistent.DirectMessages, settings.DMHistory)
service.Notice(rb, fmt.Sprintf(client.t("Your stored direct message history setting is: %s"), historyStatusToString(settings.DMHistory)))
service.Notice(rb, fmt.Sprintf(client.t("Given current server settings, your direct message history setting is: %s"), historyStatusToString(effectiveValue)))
case "email":
if settings.Email != "" {
service.Notice(rb, fmt.Sprintf(client.t("Your stored e-mail address is: %s"), settings.Email))
} else {
service.Notice(rb, client.t("You have no stored e-mail address"))
}
default:
service.Notice(rb, client.t("No such setting"))
}
@ -475,18 +518,27 @@ func userPersistentStatusToString(status PersistentStatus) string {
}
func nsSetHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
var privileged bool
var account string
if command == "saset" {
privileged = true
account = params[0]
params = params[1:]
} else {
account = client.Account()
}
key := strings.ToLower(params[0])
// unprivileged NS SET EMAIL is different because it requires a confirmation
if !privileged && key == "email" {
nsSetEmailHandler(service, client, params, rb)
return
}
var munger settingsMunger
var finalSettings AccountSettings
var err error
switch strings.ToLower(params[0]) {
switch key {
case "pass", "password":
service.Notice(rb, client.t("To change a password, use the PASSWD command. For details, /msg NickServ HELP PASSWD"))
return
@ -603,6 +655,13 @@ func nsSetHandler(service *ircService, server *Server, client *Client, command s
return
}
}
case "email":
newValue := params[1]
munger = func(in AccountSettings) (out AccountSettings, err error) {
out = in
out.Email = newValue
return
}
default:
err = errInvalidParams
}
@ -614,7 +673,7 @@ func nsSetHandler(service *ircService, server *Server, client *Client, command s
switch err {
case nil:
service.Notice(rb, client.t("Successfully changed your account settings"))
displaySetting(service, params[0], finalSettings, client, rb)
displaySetting(service, key, finalSettings, client, rb)
case errInvalidParams, errAccountDoesNotExist, errFeatureDisabled, errAccountUnverified, errAccountUpdateFailed:
service.Notice(rb, client.t(err.Error()))
case errNickAccountMismatch:
@ -625,6 +684,55 @@ func nsSetHandler(service *ircService, server *Server, client *Client, command s
}
}
// handle unprivileged NS SET EMAIL, which sends a confirmation code
func nsSetEmailHandler(service *ircService, client *Client, params []string, rb *ResponseBuffer) {
config := client.server.Config()
if !config.Accounts.Registration.EmailVerification.Enabled {
rb.Notice(client.t("E-mail verification is disabled"))
return
}
if !nsLoginThrottleCheck(service, client, rb) {
return
}
var password string
if len(params) > 2 {
password = params[2]
}
account := client.Account()
errorMessage := nsConfirmPassword(client.server, account, password)
if errorMessage != "" {
service.Notice(rb, client.t(errorMessage))
return
}
err := client.server.accounts.NsSetEmail(client, params[1])
switch err {
case nil:
service.Notice(rb, client.t("Check your e-mail for instructions on how to confirm your change of address"))
case errLimitExceeded:
service.Notice(rb, client.t("Try again later"))
default:
// if appropriate, show the client the error from the attempted email sending
if rErr := registrationCallbackErrorText(config, client, err); rErr != "" {
service.Notice(rb, rErr)
} else {
service.Notice(rb, client.t("An error occurred"))
}
}
}
func nsVerifyEmailHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
err := server.accounts.NsVerifyEmail(client, params[0])
switch err {
case nil:
service.Notice(rb, client.t("Successfully changed your account settings"))
displaySetting(service, "email", client.AccountSettings(), client, rb)
case errAccountVerificationInvalidCode:
service.Notice(rb, client.t(err.Error()))
default:
service.Notice(rb, client.t("An error occurred"))
}
}
func nsDropHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
sadrop := command == "sadrop"
var nick string
@ -815,8 +923,8 @@ func nsInfoHandler(service *ircService, server *Server, client *Client, command
service.Notice(rb, fmt.Sprintf(client.t("Registered at: %s"), registeredAt))
if account.Name == client.AccountName() || client.HasRoleCapabs("accreg") {
if account.Email != "" {
service.Notice(rb, fmt.Sprintf(client.t("Email address: %s"), account.Email))
if account.Settings.Email != "" {
service.Notice(rb, fmt.Sprintf(client.t("Email address: %s"), account.Settings.Email))
}
}
@ -1018,6 +1126,19 @@ func nsVerifyHandler(service *ircService, server *Server, client *Client, comman
}
}
func nsConfirmPassword(server *Server, account, passphrase string) (errorMessage string) {
accountData, err := server.accounts.LoadAccount(account)
if err != nil {
errorMessage = `You're not logged into an account`
} else {
hash := accountData.Credentials.PassphraseHash
if hash != nil && passwd.CompareHashAndPassword(hash, []byte(passphrase)) != nil {
errorMessage = `Password incorrect`
}
}
return
}
func nsPasswdHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
var target string
var newPassword string
@ -1041,28 +1162,19 @@ func nsPasswdHandler(service *ircService, server *Server, client *Client, comman
}
case 3:
target = client.Account()
newPassword = params[1]
if newPassword == "*" {
newPassword = ""
}
if target == "" {
errorMessage = `You're not logged into an account`
} else if params[1] != params[2] {
} else if newPassword != params[2] {
errorMessage = `Passwords do not match`
} else {
if !nsLoginThrottleCheck(service, client, rb) {
return
}
accountData, err := server.accounts.LoadAccount(target)
if err != nil {
errorMessage = `You're not logged into an account`
} else {
hash := accountData.Credentials.PassphraseHash
if hash != nil && passwd.CompareHashAndPassword(hash, []byte(params[0])) != nil {
errorMessage = `Password incorrect`
} else {
newPassword = params[1]
if newPassword == "*" {
newPassword = ""
}
}
}
errorMessage = nsConfirmPassword(server, target, params[0])
}
default:
errorMessage = `Invalid parameters`
@ -1422,6 +1534,52 @@ func suspensionToString(client *Client, suspension AccountSuspension) (result st
return fmt.Sprintf(client.t("Account %[1]s suspended at %[2]s. Duration: %[3]s. %[4]s"), suspension.AccountName, ts, duration, reason)
}
func nsSendpassHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
if !nsLoginThrottleCheck(service, client, rb) {
return
}
account := params[0]
var message string
err := server.accounts.NsSendpass(client, account)
switch err {
case nil:
server.snomasks.Send(sno.LocalAccounts, fmt.Sprintf("Client %s sent a password reset for account %s", client.Nick(), account))
message = `Successfully sent password reset email`
case errAccountDoesNotExist, errAccountUnverified, errAccountSuspended:
message = err.Error()
case errValidEmailRequired:
message = `That account is not associated with an email address`
case errLimitExceeded:
message = `Try again later`
default:
server.logger.Error("services", "error in NS SENDPASS", err.Error())
message = `An error occurred`
}
rb.Notice(client.t(message))
}
func nsResetpassHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
if !nsLoginThrottleCheck(service, client, rb) {
return
}
var message string
err := server.accounts.NsResetpass(client, params[0], params[1], params[2])
switch err {
case nil:
message = `Successfully reset account password`
case errAccountDoesNotExist, errAccountUnverified, errAccountSuspended, errAccountBadPassphrase:
message = err.Error()
case errAccountInvalidCredentials:
message = `Code did not match`
default:
server.logger.Error("services", "error in NS RESETPASS", err.Error())
message = `An error occurred`
}
rb.Notice(client.t(message))
}
func nsRenameHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
oldName, newName := params[0], params[1]
err := server.accounts.Rename(oldName, newName)

View File

@ -354,7 +354,14 @@ func SendMail(addr string, a Auth, heloDomain string, from string, to []string,
return err
}
if ok, _ := c.Extension("STARTTLS"); ok {
config := &tls.Config{ServerName: c.serverName}
var config *tls.Config
if requireTLS {
config = &tls.Config{ServerName: c.serverName}
} else {
// if TLS isn't a hard requirement, don't verify the certificate either,
// since a MITM attacker could just remove the STARTTLS advertisement
config = &tls.Config{InsecureSkipVerify: true}
}
if testHookStartTLS != nil {
testHookStartTLS(config)
}

View File

@ -387,6 +387,13 @@ accounts:
blacklist-regexes:
# - ".*@mailinator.com"
timeout: 60s
# email-based password reset:
password-reset:
enabled: false
# time before we allow resending the email
cooldown: 1h
# time for which a password reset code is valid
timeout: 1d
# throttle account login attempts (to prevent either password guessing, or DoS
# attacks on the server aimed at forcing repeated expensive bcrypt computations)