mirror of
https://github.com/ergochat/ergo.git
synced 2024-12-23 03:02:48 +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:
parent
0baaf0b711
commit
8b2f6de3e0
@ -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)
|
||||
|
279
irc/accounts.go
279
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
|
||||
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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 != "" {
|
||||
|
198
irc/nickserv.go
198
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 <account>$b
|
||||
|
||||
SENDPASS sends a password reset email to the email address associated with
|
||||
the target account. The reset code in the email can then be used with the
|
||||
$bRESETPASS$b command.`,
|
||||
helpShort: `$bSENDPASS$b initiates an email-based password reset`,
|
||||
enabled: servCmdRequiresEmailReset,
|
||||
minParams: 1,
|
||||
},
|
||||
"resetpass": {
|
||||
handler: nsResetpassHandler,
|
||||
help: `Syntax: $bRESETPASS <account> <code> <password>$b
|
||||
|
||||
RESETPASS resets an account password, using a reset code that was emailed as
|
||||
the result of a previous $bSENDPASS$b command.`,
|
||||
helpShort: `$bRESETPASS$b completes an email-based password reset`,
|
||||
enabled: servCmdRequiresEmailReset,
|
||||
minParams: 3,
|
||||
},
|
||||
"cert": {
|
||||
handler: nsCertHandler,
|
||||
help: `Syntax: $bCERT <LIST | ADD | DEL> [account] [certfp]$b
|
||||
@ -357,6 +389,12 @@ Currently, you can only change the canonical casefolding of an account
|
||||
minParams: 2,
|
||||
capabs: []string{"accreg"},
|
||||
},
|
||||
"verifyemail": {
|
||||
handler: nsVerifyEmailHandler,
|
||||
authRequired: true,
|
||||
minParams: 1,
|
||||
hidden: true,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@ -459,7 +497,12 @@ func displaySetting(service *ircService, settingName string, settings AccountSet
|
||||
effectiveValue := historyEnabled(config.History.Persistent.DirectMessages, settings.DMHistory)
|
||||
service.Notice(rb, fmt.Sprintf(client.t("Your stored direct message history setting is: %s"), historyStatusToString(settings.DMHistory)))
|
||||
service.Notice(rb, fmt.Sprintf(client.t("Given current server settings, your direct message history setting is: %s"), historyStatusToString(effectiveValue)))
|
||||
|
||||
case "email":
|
||||
if settings.Email != "" {
|
||||
service.Notice(rb, fmt.Sprintf(client.t("Your stored e-mail address is: %s"), settings.Email))
|
||||
} else {
|
||||
service.Notice(rb, client.t("You have no stored e-mail address"))
|
||||
}
|
||||
default:
|
||||
service.Notice(rb, client.t("No such setting"))
|
||||
}
|
||||
@ -475,18 +518,27 @@ func userPersistentStatusToString(status PersistentStatus) string {
|
||||
}
|
||||
|
||||
func nsSetHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
|
||||
var privileged bool
|
||||
var account string
|
||||
if command == "saset" {
|
||||
privileged = true
|
||||
account = params[0]
|
||||
params = params[1:]
|
||||
} else {
|
||||
account = client.Account()
|
||||
}
|
||||
|
||||
key := strings.ToLower(params[0])
|
||||
// unprivileged NS SET EMAIL is different because it requires a confirmation
|
||||
if !privileged && key == "email" {
|
||||
nsSetEmailHandler(service, client, params, rb)
|
||||
return
|
||||
}
|
||||
|
||||
var munger settingsMunger
|
||||
var finalSettings AccountSettings
|
||||
var err error
|
||||
switch strings.ToLower(params[0]) {
|
||||
switch key {
|
||||
case "pass", "password":
|
||||
service.Notice(rb, client.t("To change a password, use the PASSWD command. For details, /msg NickServ HELP PASSWD"))
|
||||
return
|
||||
@ -603,6 +655,13 @@ func nsSetHandler(service *ircService, server *Server, client *Client, command s
|
||||
return
|
||||
}
|
||||
}
|
||||
case "email":
|
||||
newValue := params[1]
|
||||
munger = func(in AccountSettings) (out AccountSettings, err error) {
|
||||
out = in
|
||||
out.Email = newValue
|
||||
return
|
||||
}
|
||||
default:
|
||||
err = errInvalidParams
|
||||
}
|
||||
@ -614,7 +673,7 @@ func nsSetHandler(service *ircService, server *Server, client *Client, command s
|
||||
switch err {
|
||||
case nil:
|
||||
service.Notice(rb, client.t("Successfully changed your account settings"))
|
||||
displaySetting(service, params[0], finalSettings, client, rb)
|
||||
displaySetting(service, key, finalSettings, client, rb)
|
||||
case errInvalidParams, errAccountDoesNotExist, errFeatureDisabled, errAccountUnverified, errAccountUpdateFailed:
|
||||
service.Notice(rb, client.t(err.Error()))
|
||||
case errNickAccountMismatch:
|
||||
@ -625,6 +684,55 @@ func nsSetHandler(service *ircService, server *Server, client *Client, command s
|
||||
}
|
||||
}
|
||||
|
||||
// handle unprivileged NS SET EMAIL, which sends a confirmation code
|
||||
func nsSetEmailHandler(service *ircService, client *Client, params []string, rb *ResponseBuffer) {
|
||||
config := client.server.Config()
|
||||
if !config.Accounts.Registration.EmailVerification.Enabled {
|
||||
rb.Notice(client.t("E-mail verification is disabled"))
|
||||
return
|
||||
}
|
||||
if !nsLoginThrottleCheck(service, client, rb) {
|
||||
return
|
||||
}
|
||||
var password string
|
||||
if len(params) > 2 {
|
||||
password = params[2]
|
||||
}
|
||||
account := client.Account()
|
||||
errorMessage := nsConfirmPassword(client.server, account, password)
|
||||
if errorMessage != "" {
|
||||
service.Notice(rb, client.t(errorMessage))
|
||||
return
|
||||
}
|
||||
err := client.server.accounts.NsSetEmail(client, params[1])
|
||||
switch err {
|
||||
case nil:
|
||||
service.Notice(rb, client.t("Check your e-mail for instructions on how to confirm your change of address"))
|
||||
case errLimitExceeded:
|
||||
service.Notice(rb, client.t("Try again later"))
|
||||
default:
|
||||
// if appropriate, show the client the error from the attempted email sending
|
||||
if rErr := registrationCallbackErrorText(config, client, err); rErr != "" {
|
||||
service.Notice(rb, rErr)
|
||||
} else {
|
||||
service.Notice(rb, client.t("An error occurred"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func nsVerifyEmailHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
|
||||
err := server.accounts.NsVerifyEmail(client, params[0])
|
||||
switch err {
|
||||
case nil:
|
||||
service.Notice(rb, client.t("Successfully changed your account settings"))
|
||||
displaySetting(service, "email", client.AccountSettings(), client, rb)
|
||||
case errAccountVerificationInvalidCode:
|
||||
service.Notice(rb, client.t(err.Error()))
|
||||
default:
|
||||
service.Notice(rb, client.t("An error occurred"))
|
||||
}
|
||||
}
|
||||
|
||||
func nsDropHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
|
||||
sadrop := command == "sadrop"
|
||||
var nick string
|
||||
@ -815,8 +923,8 @@ func nsInfoHandler(service *ircService, server *Server, client *Client, command
|
||||
service.Notice(rb, fmt.Sprintf(client.t("Registered at: %s"), registeredAt))
|
||||
|
||||
if account.Name == client.AccountName() || client.HasRoleCapabs("accreg") {
|
||||
if account.Email != "" {
|
||||
service.Notice(rb, fmt.Sprintf(client.t("Email address: %s"), account.Email))
|
||||
if account.Settings.Email != "" {
|
||||
service.Notice(rb, fmt.Sprintf(client.t("Email address: %s"), account.Settings.Email))
|
||||
}
|
||||
}
|
||||
|
||||
@ -1018,6 +1126,19 @@ func nsVerifyHandler(service *ircService, server *Server, client *Client, comman
|
||||
}
|
||||
}
|
||||
|
||||
func nsConfirmPassword(server *Server, account, passphrase string) (errorMessage string) {
|
||||
accountData, err := server.accounts.LoadAccount(account)
|
||||
if err != nil {
|
||||
errorMessage = `You're not logged into an account`
|
||||
} else {
|
||||
hash := accountData.Credentials.PassphraseHash
|
||||
if hash != nil && passwd.CompareHashAndPassword(hash, []byte(passphrase)) != nil {
|
||||
errorMessage = `Password incorrect`
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func nsPasswdHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
|
||||
var target string
|
||||
var newPassword string
|
||||
@ -1041,28 +1162,19 @@ func nsPasswdHandler(service *ircService, server *Server, client *Client, comman
|
||||
}
|
||||
case 3:
|
||||
target = client.Account()
|
||||
newPassword = params[1]
|
||||
if newPassword == "*" {
|
||||
newPassword = ""
|
||||
}
|
||||
if target == "" {
|
||||
errorMessage = `You're not logged into an account`
|
||||
} else if params[1] != params[2] {
|
||||
} else if newPassword != params[2] {
|
||||
errorMessage = `Passwords do not match`
|
||||
} else {
|
||||
if !nsLoginThrottleCheck(service, client, rb) {
|
||||
return
|
||||
}
|
||||
accountData, err := server.accounts.LoadAccount(target)
|
||||
if err != nil {
|
||||
errorMessage = `You're not logged into an account`
|
||||
} else {
|
||||
hash := accountData.Credentials.PassphraseHash
|
||||
if hash != nil && passwd.CompareHashAndPassword(hash, []byte(params[0])) != nil {
|
||||
errorMessage = `Password incorrect`
|
||||
} else {
|
||||
newPassword = params[1]
|
||||
if newPassword == "*" {
|
||||
newPassword = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
errorMessage = nsConfirmPassword(server, target, params[0])
|
||||
}
|
||||
default:
|
||||
errorMessage = `Invalid parameters`
|
||||
@ -1422,6 +1534,52 @@ func suspensionToString(client *Client, suspension AccountSuspension) (result st
|
||||
return fmt.Sprintf(client.t("Account %[1]s suspended at %[2]s. Duration: %[3]s. %[4]s"), suspension.AccountName, ts, duration, reason)
|
||||
}
|
||||
|
||||
func nsSendpassHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
|
||||
if !nsLoginThrottleCheck(service, client, rb) {
|
||||
return
|
||||
}
|
||||
|
||||
account := params[0]
|
||||
var message string
|
||||
err := server.accounts.NsSendpass(client, account)
|
||||
switch err {
|
||||
case nil:
|
||||
server.snomasks.Send(sno.LocalAccounts, fmt.Sprintf("Client %s sent a password reset for account %s", client.Nick(), account))
|
||||
message = `Successfully sent password reset email`
|
||||
case errAccountDoesNotExist, errAccountUnverified, errAccountSuspended:
|
||||
message = err.Error()
|
||||
case errValidEmailRequired:
|
||||
message = `That account is not associated with an email address`
|
||||
case errLimitExceeded:
|
||||
message = `Try again later`
|
||||
default:
|
||||
server.logger.Error("services", "error in NS SENDPASS", err.Error())
|
||||
message = `An error occurred`
|
||||
}
|
||||
rb.Notice(client.t(message))
|
||||
}
|
||||
|
||||
func nsResetpassHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
|
||||
if !nsLoginThrottleCheck(service, client, rb) {
|
||||
return
|
||||
}
|
||||
|
||||
var message string
|
||||
err := server.accounts.NsResetpass(client, params[0], params[1], params[2])
|
||||
switch err {
|
||||
case nil:
|
||||
message = `Successfully reset account password`
|
||||
case errAccountDoesNotExist, errAccountUnverified, errAccountSuspended, errAccountBadPassphrase:
|
||||
message = err.Error()
|
||||
case errAccountInvalidCredentials:
|
||||
message = `Code did not match`
|
||||
default:
|
||||
server.logger.Error("services", "error in NS RESETPASS", err.Error())
|
||||
message = `An error occurred`
|
||||
}
|
||||
rb.Notice(client.t(message))
|
||||
}
|
||||
|
||||
func nsRenameHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
|
||||
oldName, newName := params[0], params[1]
|
||||
err := server.accounts.Rename(oldName, newName)
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user