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)