From 8b2f6de3e0b99fbe8437a329379923cc858ad443 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Wed, 25 Aug 2021 22:32:55 -0400 Subject: [PATCH] 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. --- default.yaml | 7 ++ irc/accounts.go | 279 +++++++++++++++++++++++++++++++++++++++------ irc/database.go | 58 +++++++++- irc/email/email.go | 21 ++++ irc/import.go | 4 +- irc/nickserv.go | 198 ++++++++++++++++++++++++++++---- irc/smtp/smtp.go | 9 +- traditional.yaml | 7 ++ 8 files changed, 525 insertions(+), 58 deletions(-) diff --git a/default.yaml b/default.yaml index d9055828..3fd72ea1 100644 --- a/default.yaml +++ b/default.yaml @@ -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) diff --git a/irc/accounts.go b/irc/accounts.go index 969ef57b..b833f22a 100644 --- a/irc/accounts.go +++ b/irc/accounts.go @@ -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 ®istrationCallbackError{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 ) +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 struct { - Salt []byte - Iters int - StoredKey []byte - ServerKey []byte - } + 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 diff --git a/irc/database.go b/irc/database.go index 0851e3fe..b4b417f4 100644 --- a/irc/database.go +++ b/irc/database.go @@ -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, + }, } diff --git a/irc/email/email.go b/irc/email/email.go index d06f25e0..b86b6b6f 100644 --- a/irc/email/email.go +++ b/irc/email/email.go @@ -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) { diff --git a/irc/import.go b/irc/import.go index c7286cfc..cc59e6a5 100644 --- a/irc/import.go +++ b/irc/import.go @@ -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 != "" { diff --git a/irc/nickserv.go b/irc/nickserv.go index 9178434b..3658c8fb 100644 --- a/irc/nickserv.go +++ b/irc/nickserv.go @@ -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 $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 $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 [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) diff --git a/irc/smtp/smtp.go b/irc/smtp/smtp.go index d17b09ae..2cdc64db 100644 --- a/irc/smtp/smtp.go +++ b/irc/smtp/smtp.go @@ -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) } diff --git a/traditional.yaml b/traditional.yaml index bee90c4d..f86a79de 100644 --- a/traditional.yaml +++ b/traditional.yaml @@ -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)