3
0
mirror of https://github.com/ergochat/ergo.git synced 2024-11-15 00:19:29 +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: blacklist-regexes:
# - ".*@mailinator.com" # - ".*@mailinator.com"
timeout: 60s 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 # throttle account login attempts (to prevent either password guessing, or DoS
# attacks on the server aimed at forcing repeated expensive bcrypt computations) # attacks on the server aimed at forcing repeated expensive bcrypt computations)

View File

@ -4,7 +4,6 @@
package irc package irc
import ( import (
"bytes"
"crypto/rand" "crypto/rand"
"crypto/x509" "crypto/x509"
"encoding/json" "encoding/json"
@ -32,7 +31,6 @@ const (
keyAccountExists = "account.exists %s" keyAccountExists = "account.exists %s"
keyAccountVerified = "account.verified %s" keyAccountVerified = "account.verified %s"
keyAccountUnregistered = "account.unregistered %s" keyAccountUnregistered = "account.unregistered %s"
keyAccountCallback = "account.callback %s"
keyAccountVerificationCode = "account.verificationcode %s" keyAccountVerificationCode = "account.verificationcode %s"
keyAccountName = "account.name %s" // stores the 'preferred name' of the account, not casemapped keyAccountName = "account.name %s" // stores the 'preferred name' of the account, not casemapped
keyAccountRegTime = "account.registered.time %s" keyAccountRegTime = "account.registered.time %s"
@ -46,6 +44,8 @@ const (
keyAccountModes = "account.modes %s" // user modes for the always-on client as a string keyAccountModes = "account.modes %s" // user modes for the always-on client as a string
keyAccountRealname = "account.realname %s" // client realname stored as string keyAccountRealname = "account.realname %s" // client realname stored as string
keyAccountSuspended = "account.suspended %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 // 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): // (not to be confused with their amodes, which a non-always-on client can have):
keyAccountChannelToModes = "account.channeltomodes %s" keyAccountChannelToModes = "account.channeltomodes %s"
@ -391,10 +391,10 @@ func (am *AccountManager) Register(client *Client, account string, callbackNames
accountKey := fmt.Sprintf(keyAccountExists, casefoldedAccount) accountKey := fmt.Sprintf(keyAccountExists, casefoldedAccount)
unregisteredKey := fmt.Sprintf(keyAccountUnregistered, casefoldedAccount) unregisteredKey := fmt.Sprintf(keyAccountUnregistered, casefoldedAccount)
accountNameKey := fmt.Sprintf(keyAccountName, casefoldedAccount) accountNameKey := fmt.Sprintf(keyAccountName, casefoldedAccount)
callbackKey := fmt.Sprintf(keyAccountCallback, casefoldedAccount)
registeredTimeKey := fmt.Sprintf(keyAccountRegTime, casefoldedAccount) registeredTimeKey := fmt.Sprintf(keyAccountRegTime, casefoldedAccount)
credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount) credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount)
verificationCodeKey := fmt.Sprintf(keyAccountVerificationCode, casefoldedAccount) verificationCodeKey := fmt.Sprintf(keyAccountVerificationCode, casefoldedAccount)
settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount)
certFPKey := fmt.Sprintf(keyCertToAccount, certfp) certFPKey := fmt.Sprintf(keyCertToAccount, certfp)
var creds AccountCredentials var creds AccountCredentials
@ -409,8 +409,16 @@ func (am *AccountManager) Register(client *Client, account string, callbackNames
return err 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) registeredTimeStr := strconv.FormatInt(time.Now().UnixNano(), 10)
callbackSpec := fmt.Sprintf("%s:%s", callbackNamespace, callbackValue)
var setOptions *buntdb.SetOptions var setOptions *buntdb.SetOptions
ttl := time.Duration(config.Accounts.Registration.VerifyTimeout) 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(accountNameKey, account, setOptions)
tx.Set(registeredTimeKey, registeredTimeStr, setOptions) tx.Set(registeredTimeKey, registeredTimeStr, setOptions)
tx.Set(credentialsKey, credStr, setOptions) tx.Set(credentialsKey, credStr, setOptions)
tx.Set(callbackKey, callbackSpec, setOptions) tx.Set(settingsKey, settingsStr, setOptions)
if certfp != "" { if certfp != "" {
tx.Set(certFPKey, casefoldedAccount, setOptions) 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) subject = fmt.Sprintf(client.t("Verify your account on %s"), am.server.name)
} }
var message bytes.Buffer message := email.ComposeMail(config, callbackValue, subject)
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
fmt.Fprintf(&message, client.t("Account: %s"), account) fmt.Fprintf(&message, client.t("Account: %s"), account)
message.WriteString("\r\n") message.WriteString("\r\n")
fmt.Fprintf(&message, client.t("Verification code: %s"), code) 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) accountNameKey := fmt.Sprintf(keyAccountName, casefoldedAccount)
registeredTimeKey := fmt.Sprintf(keyAccountRegTime, casefoldedAccount) registeredTimeKey := fmt.Sprintf(keyAccountRegTime, casefoldedAccount)
verificationCodeKey := fmt.Sprintf(keyAccountVerificationCode, casefoldedAccount) verificationCodeKey := fmt.Sprintf(keyAccountVerificationCode, casefoldedAccount)
callbackKey := fmt.Sprintf(keyAccountCallback, casefoldedAccount)
credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount) credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount)
settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount)
var raw rawClientAccount 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(accountKey, "1", nil)
tx.Set(accountNameKey, raw.Name, nil) tx.Set(accountNameKey, raw.Name, nil)
tx.Set(registeredTimeKey, raw.RegisteredAt, nil) tx.Set(registeredTimeKey, raw.RegisteredAt, nil)
tx.Set(callbackKey, raw.Callback, nil)
tx.Set(credentialsKey, raw.Credentials, nil) tx.Set(credentialsKey, raw.Credentials, nil)
tx.Set(settingsKey, raw.Settings, nil)
var creds AccountCredentials var creds AccountCredentials
// XXX we shouldn't do (de)serialization inside the txn, // XXX we shouldn't do (de)serialization inside the txn,
@ -955,6 +955,214 @@ func (am *AccountManager) SARegister(account, passphrase string) (err error) {
return 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 { func marshalReservedNicks(nicks []string) string {
return strings.Join(nicks, ",") return strings.Join(nicks, ",")
} }
@ -1294,9 +1502,6 @@ func (am *AccountManager) deserializeRawAccount(raw rawClientAccount, cfName str
return return
} }
result.AdditionalNicks = unmarshalReservedNicks(raw.AdditionalNicks) result.AdditionalNicks = unmarshalReservedNicks(raw.AdditionalNicks)
if strings.HasPrefix(raw.Callback, "mailto:") {
result.Email = strings.TrimPrefix(raw.Callback, "mailto:")
}
result.Verified = raw.Verified result.Verified = raw.Verified
if raw.VHost != "" { if raw.VHost != "" {
e := json.Unmarshal([]byte(raw.VHost), &result.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) registeredTimeKey := fmt.Sprintf(keyAccountRegTime, casefoldedAccount)
credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount) credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount)
verifiedKey := fmt.Sprintf(keyAccountVerified, casefoldedAccount) verifiedKey := fmt.Sprintf(keyAccountVerified, casefoldedAccount)
callbackKey := fmt.Sprintf(keyAccountCallback, casefoldedAccount)
nicksKey := fmt.Sprintf(keyAccountAdditionalNicks, casefoldedAccount) nicksKey := fmt.Sprintf(keyAccountAdditionalNicks, casefoldedAccount)
vhostKey := fmt.Sprintf(keyAccountVHost, casefoldedAccount) vhostKey := fmt.Sprintf(keyAccountVHost, casefoldedAccount)
settingsKey := fmt.Sprintf(keyAccountSettings, 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.Name, _ = tx.Get(accountNameKey)
result.RegisteredAt, _ = tx.Get(registeredTimeKey) result.RegisteredAt, _ = tx.Get(registeredTimeKey)
result.Credentials, _ = tx.Get(credentialsKey) result.Credentials, _ = tx.Get(credentialsKey)
result.Callback, _ = tx.Get(callbackKey)
result.AdditionalNicks, _ = tx.Get(nicksKey) result.AdditionalNicks, _ = tx.Get(nicksKey)
result.VHost, _ = tx.Get(vhostKey) result.VHost, _ = tx.Get(vhostKey)
result.Settings, _ = tx.Get(settingsKey) result.Settings, _ = tx.Get(settingsKey)
@ -1524,7 +1727,6 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
accountNameKey := fmt.Sprintf(keyAccountName, casefoldedAccount) accountNameKey := fmt.Sprintf(keyAccountName, casefoldedAccount)
registeredTimeKey := fmt.Sprintf(keyAccountRegTime, casefoldedAccount) registeredTimeKey := fmt.Sprintf(keyAccountRegTime, casefoldedAccount)
credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount) credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount)
callbackKey := fmt.Sprintf(keyAccountCallback, casefoldedAccount)
verificationCodeKey := fmt.Sprintf(keyAccountVerificationCode, casefoldedAccount) verificationCodeKey := fmt.Sprintf(keyAccountVerificationCode, casefoldedAccount)
verifiedKey := fmt.Sprintf(keyAccountVerified, casefoldedAccount) verifiedKey := fmt.Sprintf(keyAccountVerified, casefoldedAccount)
nicksKey := fmt.Sprintf(keyAccountAdditionalNicks, casefoldedAccount) nicksKey := fmt.Sprintf(keyAccountAdditionalNicks, casefoldedAccount)
@ -1537,6 +1739,8 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
modesKey := fmt.Sprintf(keyAccountModes, casefoldedAccount) modesKey := fmt.Sprintf(keyAccountModes, casefoldedAccount)
realnameKey := fmt.Sprintf(keyAccountRealname, casefoldedAccount) realnameKey := fmt.Sprintf(keyAccountRealname, casefoldedAccount)
suspendedKey := fmt.Sprintf(keyAccountSuspended, casefoldedAccount) suspendedKey := fmt.Sprintf(keyAccountSuspended, casefoldedAccount)
pwResetKey := fmt.Sprintf(keyAccountPwReset, casefoldedAccount)
emailChangeKey := fmt.Sprintf(keyAccountEmailChange, casefoldedAccount)
var clients []*Client var clients []*Client
defer func() { defer func() {
@ -1582,7 +1786,6 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
tx.Delete(accountNameKey) tx.Delete(accountNameKey)
tx.Delete(verifiedKey) tx.Delete(verifiedKey)
tx.Delete(registeredTimeKey) tx.Delete(registeredTimeKey)
tx.Delete(callbackKey)
tx.Delete(verificationCodeKey) tx.Delete(verificationCodeKey)
tx.Delete(settingsKey) tx.Delete(settingsKey)
rawNicks, _ = tx.Get(nicksKey) rawNicks, _ = tx.Get(nicksKey)
@ -1597,6 +1800,8 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
tx.Delete(modesKey) tx.Delete(modesKey)
tx.Delete(realnameKey) tx.Delete(realnameKey)
tx.Delete(suspendedKey) tx.Delete(suspendedKey)
tx.Delete(pwResetKey)
tx.Delete(emailChangeKey)
return nil return nil
}) })
@ -1940,17 +2145,19 @@ const (
CredentialsAnope = -2 CredentialsAnope = -2
) )
type SCRAMCreds struct {
Salt []byte
Iters int
StoredKey []byte
ServerKey []byte
}
// AccountCredentials stores the various methods for verifying accounts. // AccountCredentials stores the various methods for verifying accounts.
type AccountCredentials struct { type AccountCredentials struct {
Version CredentialsVersion Version CredentialsVersion
PassphraseHash []byte PassphraseHash []byte
Certfps []string Certfps []string
SCRAMCreds struct { SCRAMCreds
Salt []byte
Iters int
StoredKey []byte
ServerKey []byte
}
} }
func (ac *AccountCredentials) Empty() bool { 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) { func (ac *AccountCredentials) SetPassphrase(passphrase string, bcryptCost uint) (err error) {
if passphrase == "" { if passphrase == "" {
ac.PassphraseHash = nil ac.PassphraseHash = nil
ac.SCRAMCreds = SCRAMCreds{}
return nil 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." // xdg-go/scram says: "Clients have a default minimum PBKDF2 iteration count of 4096."
minIters := 4096 minIters := 4096
scramCreds := scramClient.GetStoredCredentials(scram.KeyFactors{Salt: string(salt), Iters: minIters}) scramCreds := scramClient.GetStoredCredentials(scram.KeyFactors{Salt: string(salt), Iters: minIters})
ac.SCRAMCreds.Salt = salt ac.SCRAMCreds = SCRAMCreds{
ac.SCRAMCreds.Iters = minIters Salt: salt,
ac.SCRAMCreds.StoredKey = scramCreds.StoredKey Iters: minIters,
ac.SCRAMCreds.ServerKey = scramCreds.ServerKey StoredKey: scramCreds.StoredKey,
ServerKey: scramCreds.ServerKey,
}
return nil return nil
} }
@ -2112,6 +2322,7 @@ type AccountSettings struct {
AutoreplayMissed bool AutoreplayMissed bool
DMHistory HistoryStatus DMHistory HistoryStatus
AutoAway PersistentStatus AutoAway PersistentStatus
Email string
} }
// ClientAccount represents a user account. // ClientAccount represents a user account.
@ -2120,7 +2331,6 @@ type ClientAccount struct {
Name string Name string
NameCasefolded string NameCasefolded string
RegisteredAt time.Time RegisteredAt time.Time
Email string
Credentials AccountCredentials Credentials AccountCredentials
Verified bool Verified bool
Suspended *AccountSuspension Suspended *AccountSuspension
@ -2134,7 +2344,6 @@ type rawClientAccount struct {
Name string Name string
RegisteredAt string RegisteredAt string
Credentials string Credentials string
Callback string
Verified bool Verified bool
AdditionalNicks string AdditionalNicks string
VHost string VHost string

View File

@ -24,7 +24,7 @@ const (
// 'version' of the database schema // 'version' of the database schema
keySchemaVersion = "db.version" keySchemaVersion = "db.version"
// latest schema of the db // latest schema of the db
latestDbSchema = 20 latestDbSchema = 21
keyCloakSecret = "crypto.cloak_secret" keyCloakSecret = "crypto.cloak_secret"
) )
@ -1008,6 +1008,57 @@ func schemaChangeV19To20(config *Config, tx *buntdb.Tx) error {
return nil 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) { func getSchemaChange(initialVersion int) (result SchemaChange, ok bool) {
for _, change := range allChanges { for _, change := range allChanges {
if initialVersion == change.InitialVersion { if initialVersion == change.InitialVersion {
@ -1113,4 +1164,9 @@ var allChanges = []SchemaChange{
TargetVersion: 20, TargetVersion: 20,
Changer: schemaChangeV19To20, Changer: schemaChangeV19To20,
}, },
{
InitialVersion: 20,
TargetVersion: 21,
Changer: schemaChangeV20To21,
},
} }

View File

@ -4,6 +4,7 @@
package email package email
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"net" "net"
@ -11,7 +12,9 @@ import (
"strings" "strings"
"time" "time"
"github.com/ergochat/ergo/irc/custime"
"github.com/ergochat/ergo/irc/smtp" "github.com/ergochat/ergo/irc/smtp"
"github.com/ergochat/ergo/irc/utils"
) )
var ( var (
@ -42,6 +45,11 @@ type MailtoConfig struct {
BlacklistRegexes []string `yaml:"blacklist-regexes"` BlacklistRegexes []string `yaml:"blacklist-regexes"`
blacklistRegexes []*regexp.Regexp blacklistRegexes []*regexp.Regexp
Timeout time.Duration Timeout time.Duration
PasswordReset struct {
Enabled bool
Cooldown custime.Duration
Timeout custime.Duration
} `yaml:"password-reset"`
} }
func (config *MailtoConfig) Postprocess(heloDomain string) (err error) { func (config *MailtoConfig) Postprocess(heloDomain string) (err error) {
@ -95,6 +103,19 @@ func lookupMX(domain string) (server string) {
return 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) { func SendMail(config MailtoConfig, recipient string, msg []byte) (err error) {
for _, reg := range config.blacklistRegexes { for _, reg := range config.blacklistRegexes {
if reg.MatchString(recipient) { 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(keyAccountExists, cfUsername), "1", nil)
tx.Set(fmt.Sprintf(keyAccountVerified, cfUsername), "1", nil) tx.Set(fmt.Sprintf(keyAccountVerified, cfUsername), "1", nil)
tx.Set(fmt.Sprintf(keyAccountName, cfUsername), userInfo.Name, 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(keyAccountCredentials, cfUsername), string(marshaledCredentials), nil)
tx.Set(fmt.Sprintf(keyAccountRegTime, cfUsername), strconv.FormatInt(userInfo.RegisteredAt, 10), nil) tx.Set(fmt.Sprintf(keyAccountRegTime, cfUsername), strconv.FormatInt(userInfo.RegisteredAt, 10), nil)
if userInfo.Vhost != "" { if userInfo.Vhost != "" {

View File

@ -36,6 +36,11 @@ func servCmdRequiresBouncerEnabled(config *Config) bool {
return config.Accounts.Multiclient.Enabled 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.` const nickservHelp = `NickServ lets you register, log in to, and manage an account.`
var ( 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 '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 be marked away when all your sessions are disconnected, and
automatically return from away when you connect again.`, 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, authRequired: true,
enabled: servCmdRequiresAuthEnabled, enabled: servCmdRequiresAuthEnabled,
@ -318,6 +329,27 @@ information on the settings and their possible values, see HELP SET.`,
minParams: 3, minParams: 3,
capabs: []string{"accreg"}, 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": { "cert": {
handler: nsCertHandler, handler: nsCertHandler,
help: `Syntax: $bCERT <LIST | ADD | DEL> [account] [certfp]$b 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, minParams: 2,
capabs: []string{"accreg"}, 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) 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("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))) 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: default:
service.Notice(rb, client.t("No such setting")) 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) { func nsSetHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
var privileged bool
var account string var account string
if command == "saset" { if command == "saset" {
privileged = true
account = params[0] account = params[0]
params = params[1:] params = params[1:]
} else { } else {
account = client.Account() 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 munger settingsMunger
var finalSettings AccountSettings var finalSettings AccountSettings
var err error var err error
switch strings.ToLower(params[0]) { switch key {
case "pass", "password": case "pass", "password":
service.Notice(rb, client.t("To change a password, use the PASSWD command. For details, /msg NickServ HELP PASSWD")) service.Notice(rb, client.t("To change a password, use the PASSWD command. For details, /msg NickServ HELP PASSWD"))
return return
@ -603,6 +655,13 @@ func nsSetHandler(service *ircService, server *Server, client *Client, command s
return return
} }
} }
case "email":
newValue := params[1]
munger = func(in AccountSettings) (out AccountSettings, err error) {
out = in
out.Email = newValue
return
}
default: default:
err = errInvalidParams err = errInvalidParams
} }
@ -614,7 +673,7 @@ func nsSetHandler(service *ircService, server *Server, client *Client, command s
switch err { switch err {
case nil: case nil:
service.Notice(rb, client.t("Successfully changed your account settings")) 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: case errInvalidParams, errAccountDoesNotExist, errFeatureDisabled, errAccountUnverified, errAccountUpdateFailed:
service.Notice(rb, client.t(err.Error())) service.Notice(rb, client.t(err.Error()))
case errNickAccountMismatch: 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) { func nsDropHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
sadrop := command == "sadrop" sadrop := command == "sadrop"
var nick string 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)) service.Notice(rb, fmt.Sprintf(client.t("Registered at: %s"), registeredAt))
if account.Name == client.AccountName() || client.HasRoleCapabs("accreg") { if account.Name == client.AccountName() || client.HasRoleCapabs("accreg") {
if account.Email != "" { if account.Settings.Email != "" {
service.Notice(rb, fmt.Sprintf(client.t("Email address: %s"), account.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) { func nsPasswdHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
var target string var target string
var newPassword string var newPassword string
@ -1041,28 +1162,19 @@ func nsPasswdHandler(service *ircService, server *Server, client *Client, comman
} }
case 3: case 3:
target = client.Account() target = client.Account()
newPassword = params[1]
if newPassword == "*" {
newPassword = ""
}
if target == "" { if target == "" {
errorMessage = `You're not logged into an account` errorMessage = `You're not logged into an account`
} else if params[1] != params[2] { } else if newPassword != params[2] {
errorMessage = `Passwords do not match` errorMessage = `Passwords do not match`
} else { } else {
if !nsLoginThrottleCheck(service, client, rb) { if !nsLoginThrottleCheck(service, client, rb) {
return return
} }
accountData, err := server.accounts.LoadAccount(target) errorMessage = nsConfirmPassword(server, target, params[0])
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 = ""
}
}
}
} }
default: default:
errorMessage = `Invalid parameters` 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) 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) { func nsRenameHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
oldName, newName := params[0], params[1] oldName, newName := params[0], params[1]
err := server.accounts.Rename(oldName, newName) 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 return err
} }
if ok, _ := c.Extension("STARTTLS"); ok { 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 { if testHookStartTLS != nil {
testHookStartTLS(config) testHookStartTLS(config)
} }

View File

@ -387,6 +387,13 @@ accounts:
blacklist-regexes: blacklist-regexes:
# - ".*@mailinator.com" # - ".*@mailinator.com"
timeout: 60s 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 # throttle account login attempts (to prevent either password guessing, or DoS
# attacks on the server aimed at forcing repeated expensive bcrypt computations) # attacks on the server aimed at forcing repeated expensive bcrypt computations)