Browse Source

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.
tags/v2.8.0-rc1
Shivaram Lingamneni 2 years ago
parent
commit
8b2f6de3e0
No account linked to committer's email address
8 changed files with 525 additions and 58 deletions
  1. 7
    0
      default.yaml
  2. 244
    35
      irc/accounts.go
  3. 57
    1
      irc/database.go
  4. 21
    0
      irc/email/email.go
  5. 3
    1
      irc/import.go
  6. 178
    20
      irc/nickserv.go
  7. 8
    1
      irc/smtp/smtp.go
  8. 7
    0
      traditional.yaml

+ 7
- 0
default.yaml View File

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

+ 244
- 35
irc/accounts.go View File

@@ -4,7 +4,6 @@
4 4
 package irc
5 5
 
6 6
 import (
7
-	"bytes"
8 7
 	"crypto/rand"
9 8
 	"crypto/x509"
10 9
 	"encoding/json"
@@ -32,7 +31,6 @@ const (
32 31
 	keyAccountExists           = "account.exists %s"
33 32
 	keyAccountVerified         = "account.verified %s"
34 33
 	keyAccountUnregistered     = "account.unregistered %s"
35
-	keyAccountCallback         = "account.callback %s"
36 34
 	keyAccountVerificationCode = "account.verificationcode %s"
37 35
 	keyAccountName             = "account.name %s" // stores the 'preferred name' of the account, not casemapped
38 36
 	keyAccountRegTime          = "account.registered.time %s"
@@ -46,6 +44,8 @@ const (
46 44
 	keyAccountModes            = "account.modes %s"     // user modes for the always-on client as a string
47 45
 	keyAccountRealname         = "account.realname %s"  // client realname stored as string
48 46
 	keyAccountSuspended        = "account.suspended %s" // client realname stored as string
47
+	keyAccountPwReset          = "account.pwreset %s"
48
+	keyAccountEmailChange      = "account.emailchange %s"
49 49
 	// for an always-on client, a map of channel names they're in to their current modes
50 50
 	// (not to be confused with their amodes, which a non-always-on client can have):
51 51
 	keyAccountChannelToModes = "account.channeltomodes %s"
@@ -391,10 +391,10 @@ func (am *AccountManager) Register(client *Client, account string, callbackNames
391 391
 	accountKey := fmt.Sprintf(keyAccountExists, casefoldedAccount)
392 392
 	unregisteredKey := fmt.Sprintf(keyAccountUnregistered, casefoldedAccount)
393 393
 	accountNameKey := fmt.Sprintf(keyAccountName, casefoldedAccount)
394
-	callbackKey := fmt.Sprintf(keyAccountCallback, casefoldedAccount)
395 394
 	registeredTimeKey := fmt.Sprintf(keyAccountRegTime, casefoldedAccount)
396 395
 	credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount)
397 396
 	verificationCodeKey := fmt.Sprintf(keyAccountVerificationCode, casefoldedAccount)
397
+	settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount)
398 398
 	certFPKey := fmt.Sprintf(keyCertToAccount, certfp)
399 399
 
400 400
 	var creds AccountCredentials
@@ -409,8 +409,16 @@ func (am *AccountManager) Register(client *Client, account string, callbackNames
409 409
 		return err
410 410
 	}
411 411
 
412
+	var settingsStr string
413
+	if callbackNamespace == "mailto" {
414
+		settings := AccountSettings{Email: callbackValue}
415
+		j, err := json.Marshal(settings)
416
+		if err == nil {
417
+			settingsStr = string(j)
418
+		}
419
+	}
420
+
412 421
 	registeredTimeStr := strconv.FormatInt(time.Now().UnixNano(), 10)
413
-	callbackSpec := fmt.Sprintf("%s:%s", callbackNamespace, callbackValue)
414 422
 
415 423
 	var setOptions *buntdb.SetOptions
416 424
 	ttl := time.Duration(config.Accounts.Registration.VerifyTimeout)
@@ -449,7 +457,7 @@ func (am *AccountManager) Register(client *Client, account string, callbackNames
449 457
 			tx.Set(accountNameKey, account, setOptions)
450 458
 			tx.Set(registeredTimeKey, registeredTimeStr, setOptions)
451 459
 			tx.Set(credentialsKey, credStr, setOptions)
452
-			tx.Set(callbackKey, callbackSpec, setOptions)
460
+			tx.Set(settingsKey, settingsStr, setOptions)
453 461
 			if certfp != "" {
454 462
 				tx.Set(certFPKey, casefoldedAccount, setOptions)
455 463
 			}
@@ -782,15 +790,7 @@ func (am *AccountManager) dispatchMailtoCallback(client *Client, account string,
782 790
 		subject = fmt.Sprintf(client.t("Verify your account on %s"), am.server.name)
783 791
 	}
784 792
 
785
-	var message bytes.Buffer
786
-	fmt.Fprintf(&message, "From: %s\r\n", config.Sender)
787
-	fmt.Fprintf(&message, "To: %s\r\n", callbackValue)
788
-	if config.DKIM.Domain != "" {
789
-		fmt.Fprintf(&message, "Message-ID: <%s@%s>\r\n", utils.GenerateSecretKey(), config.DKIM.Domain)
790
-	}
791
-	fmt.Fprintf(&message, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
792
-	fmt.Fprintf(&message, "Subject: %s\r\n", subject)
793
-	message.WriteString("\r\n") // blank line: end headers, begin message body
793
+	message := email.ComposeMail(config, callbackValue, subject)
794 794
 	fmt.Fprintf(&message, client.t("Account: %s"), account)
795 795
 	message.WriteString("\r\n")
796 796
 	fmt.Fprintf(&message, client.t("Verification code: %s"), code)
@@ -823,8 +823,8 @@ func (am *AccountManager) Verify(client *Client, account string, code string) er
823 823
 	accountNameKey := fmt.Sprintf(keyAccountName, casefoldedAccount)
824 824
 	registeredTimeKey := fmt.Sprintf(keyAccountRegTime, casefoldedAccount)
825 825
 	verificationCodeKey := fmt.Sprintf(keyAccountVerificationCode, casefoldedAccount)
826
-	callbackKey := fmt.Sprintf(keyAccountCallback, casefoldedAccount)
827 826
 	credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount)
827
+	settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount)
828 828
 
829 829
 	var raw rawClientAccount
830 830
 
@@ -892,8 +892,8 @@ func (am *AccountManager) Verify(client *Client, account string, code string) er
892 892
 			tx.Set(accountKey, "1", nil)
893 893
 			tx.Set(accountNameKey, raw.Name, nil)
894 894
 			tx.Set(registeredTimeKey, raw.RegisteredAt, nil)
895
-			tx.Set(callbackKey, raw.Callback, nil)
896 895
 			tx.Set(credentialsKey, raw.Credentials, nil)
896
+			tx.Set(settingsKey, raw.Settings, nil)
897 897
 
898 898
 			var creds AccountCredentials
899 899
 			// XXX we shouldn't do (de)serialization inside the txn,
@@ -955,6 +955,214 @@ func (am *AccountManager) SARegister(account, passphrase string) (err error) {
955 955
 	return
956 956
 }
957 957
 
958
+type EmailChangeRecord struct {
959
+	TimeCreated time.Time
960
+	Code        string
961
+	Email       string
962
+}
963
+
964
+func (am *AccountManager) NsSetEmail(client *Client, emailAddr string) (err error) {
965
+	casefoldedAccount := client.Account()
966
+	if casefoldedAccount == "" {
967
+		return errAccountNotLoggedIn
968
+	}
969
+
970
+	if am.touchRegisterThrottle() {
971
+		am.server.logger.Warning("accounts", "global registration throttle exceeded by client changing email", client.Nick())
972
+		return errLimitExceeded
973
+	}
974
+
975
+	config := am.server.Config()
976
+	if !config.Accounts.Registration.EmailVerification.Enabled {
977
+		return errFeatureDisabled // redundant check, just in case
978
+	}
979
+	record := EmailChangeRecord{
980
+		TimeCreated: time.Now().UTC(),
981
+		Code:        utils.GenerateSecretToken(),
982
+		Email:       emailAddr,
983
+	}
984
+	recordKey := fmt.Sprintf(keyAccountEmailChange, casefoldedAccount)
985
+	recordBytes, _ := json.Marshal(record)
986
+	recordVal := string(recordBytes)
987
+	am.server.store.Update(func(tx *buntdb.Tx) error {
988
+		tx.Set(recordKey, recordVal, nil)
989
+		return nil
990
+	})
991
+
992
+	if err != nil {
993
+		return err
994
+	}
995
+
996
+	message := email.ComposeMail(config.Accounts.Registration.EmailVerification,
997
+		emailAddr,
998
+		fmt.Sprintf(client.t("Verify your change of e-mail address on %s"), am.server.name))
999
+	message.WriteString(fmt.Sprintf(client.t("To confirm your change of e-mail address on %s, issue the following command:"), am.server.name))
1000
+	message.WriteString("\r\n")
1001
+	fmt.Fprintf(&message, "/MSG NickServ VERIFYEMAIL %s\r\n", record.Code)
1002
+
1003
+	err = email.SendMail(config.Accounts.Registration.EmailVerification, emailAddr, message.Bytes())
1004
+	if err == nil {
1005
+		am.server.logger.Info("services",
1006
+			fmt.Sprintf("email change verification sent for account %s", casefoldedAccount))
1007
+		return
1008
+	} else {
1009
+		am.server.logger.Error("internal", "Failed to dispatch e-mail change verification to", emailAddr, err.Error())
1010
+		return &registrationCallbackError{err}
1011
+	}
1012
+}
1013
+
1014
+func (am *AccountManager) NsVerifyEmail(client *Client, code string) (err error) {
1015
+	casefoldedAccount := client.Account()
1016
+	if casefoldedAccount == "" {
1017
+		return errAccountNotLoggedIn
1018
+	}
1019
+
1020
+	var record EmailChangeRecord
1021
+	success := false
1022
+	key := fmt.Sprintf(keyAccountEmailChange, casefoldedAccount)
1023
+	ttl := time.Duration(am.server.Config().Accounts.Registration.VerifyTimeout)
1024
+	am.server.store.Update(func(tx *buntdb.Tx) error {
1025
+		rawStr, err := tx.Get(key)
1026
+		if err == nil && rawStr != "" {
1027
+			err := json.Unmarshal([]byte(rawStr), &record)
1028
+			if err == nil {
1029
+				if (ttl == 0 || time.Since(record.TimeCreated) < ttl) && utils.SecretTokensMatch(record.Code, code) {
1030
+					success = true
1031
+					tx.Delete(key)
1032
+				}
1033
+			}
1034
+		}
1035
+		return nil
1036
+	})
1037
+
1038
+	if !success {
1039
+		return errAccountVerificationInvalidCode
1040
+	}
1041
+
1042
+	munger := func(in AccountSettings) (out AccountSettings, err error) {
1043
+		out = in
1044
+		out.Email = record.Email
1045
+		return
1046
+	}
1047
+
1048
+	_, err = am.ModifyAccountSettings(casefoldedAccount, munger)
1049
+	return
1050
+}
1051
+
1052
+func (am *AccountManager) NsSendpass(client *Client, accountName string) (err error) {
1053
+	config := am.server.Config()
1054
+	if !(config.Accounts.Registration.EmailVerification.Enabled && config.Accounts.Registration.EmailVerification.PasswordReset.Enabled) {
1055
+		return errFeatureDisabled
1056
+	}
1057
+
1058
+	account, err := am.LoadAccount(accountName)
1059
+	if err != nil {
1060
+		return err
1061
+	}
1062
+	if !account.Verified {
1063
+		return errAccountUnverified
1064
+	}
1065
+	if account.Suspended != nil {
1066
+		return errAccountSuspended
1067
+	}
1068
+	if account.Settings.Email == "" {
1069
+		return errValidEmailRequired
1070
+	}
1071
+
1072
+	record := PasswordResetRecord{
1073
+		TimeCreated: time.Now().UTC(),
1074
+		Code:        utils.GenerateSecretToken(),
1075
+	}
1076
+	recordKey := fmt.Sprintf(keyAccountPwReset, account.NameCasefolded)
1077
+	recordBytes, _ := json.Marshal(record)
1078
+	recordVal := string(recordBytes)
1079
+
1080
+	am.server.store.Update(func(tx *buntdb.Tx) error {
1081
+		recStr, recErr := tx.Get(recordKey)
1082
+		if recErr == nil && recStr != "" {
1083
+			var existing PasswordResetRecord
1084
+			jErr := json.Unmarshal([]byte(recStr), &existing)
1085
+			cooldown := time.Duration(config.Accounts.Registration.EmailVerification.PasswordReset.Cooldown)
1086
+			if jErr == nil && time.Since(existing.TimeCreated) < cooldown {
1087
+				err = errLimitExceeded
1088
+				return nil
1089
+			}
1090
+		}
1091
+		tx.Set(recordKey, recordVal, &buntdb.SetOptions{
1092
+			Expires: true,
1093
+			TTL:     time.Duration(config.Accounts.Registration.EmailVerification.PasswordReset.Timeout),
1094
+		})
1095
+		return nil
1096
+	})
1097
+
1098
+	if err != nil {
1099
+		return
1100
+	}
1101
+
1102
+	subject := fmt.Sprintf(client.t("Reset your password on %s"), am.server.name)
1103
+	message := email.ComposeMail(config.Accounts.Registration.EmailVerification, account.Settings.Email, subject)
1104
+	fmt.Fprintf(&message, client.t("We received a request to reset your password on %s for account: %s"), am.server.name, account.Name)
1105
+	message.WriteString("\r\n")
1106
+	fmt.Fprintf(&message, client.t("If you did not initiate this request, you can safely ignore this message."))
1107
+	message.WriteString("\r\n")
1108
+	message.WriteString("\r\n")
1109
+	message.WriteString(client.t("Otherwise, to reset your password, issue the following command (replace `new_password` with your desired password):"))
1110
+	message.WriteString("\r\n")
1111
+	fmt.Fprintf(&message, "/MSG NickServ RESETPASS %s %s new_password\r\n", account.Name, record.Code)
1112
+
1113
+	err = email.SendMail(config.Accounts.Registration.EmailVerification, account.Settings.Email, message.Bytes())
1114
+	if err == nil {
1115
+		am.server.logger.Info("services",
1116
+			fmt.Sprintf("client %s sent a password reset email for account %s", client.Nick(), account.Name))
1117
+	} else {
1118
+		am.server.logger.Error("internal", "Failed to dispatch e-mail to", account.Settings.Email, err.Error())
1119
+	}
1120
+	return
1121
+
1122
+}
1123
+
1124
+func (am *AccountManager) NsResetpass(client *Client, accountName, code, password string) (err error) {
1125
+	if validatePassphrase(password) != nil {
1126
+		return errAccountBadPassphrase
1127
+	}
1128
+	account, err := am.LoadAccount(accountName)
1129
+	if err != nil {
1130
+		return
1131
+	}
1132
+	if !account.Verified {
1133
+		return errAccountUnverified
1134
+	}
1135
+	if account.Suspended != nil {
1136
+		return errAccountSuspended
1137
+	}
1138
+
1139
+	success := false
1140
+	key := fmt.Sprintf(keyAccountPwReset, account.NameCasefolded)
1141
+	am.server.store.Update(func(tx *buntdb.Tx) error {
1142
+		rawStr, err := tx.Get(key)
1143
+		if err == nil && rawStr != "" {
1144
+			var record PasswordResetRecord
1145
+			err := json.Unmarshal([]byte(rawStr), &record)
1146
+			if err == nil && utils.SecretTokensMatch(record.Code, code) {
1147
+				success = true
1148
+				tx.Delete(key)
1149
+			}
1150
+		}
1151
+		return nil
1152
+	})
1153
+
1154
+	if success {
1155
+		return am.setPassword(accountName, password, true)
1156
+	} else {
1157
+		return errAccountInvalidCredentials
1158
+	}
1159
+}
1160
+
1161
+type PasswordResetRecord struct {
1162
+	TimeCreated time.Time
1163
+	Code        string
1164
+}
1165
+
958 1166
 func marshalReservedNicks(nicks []string) string {
959 1167
 	return strings.Join(nicks, ",")
960 1168
 }
@@ -1294,9 +1502,6 @@ func (am *AccountManager) deserializeRawAccount(raw rawClientAccount, cfName str
1294 1502
 		return
1295 1503
 	}
1296 1504
 	result.AdditionalNicks = unmarshalReservedNicks(raw.AdditionalNicks)
1297
-	if strings.HasPrefix(raw.Callback, "mailto:") {
1298
-		result.Email = strings.TrimPrefix(raw.Callback, "mailto:")
1299
-	}
1300 1505
 	result.Verified = raw.Verified
1301 1506
 	if raw.VHost != "" {
1302 1507
 		e := json.Unmarshal([]byte(raw.VHost), &result.VHost)
@@ -1329,7 +1534,6 @@ func (am *AccountManager) loadRawAccount(tx *buntdb.Tx, casefoldedAccount string
1329 1534
 	registeredTimeKey := fmt.Sprintf(keyAccountRegTime, casefoldedAccount)
1330 1535
 	credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount)
1331 1536
 	verifiedKey := fmt.Sprintf(keyAccountVerified, casefoldedAccount)
1332
-	callbackKey := fmt.Sprintf(keyAccountCallback, casefoldedAccount)
1333 1537
 	nicksKey := fmt.Sprintf(keyAccountAdditionalNicks, casefoldedAccount)
1334 1538
 	vhostKey := fmt.Sprintf(keyAccountVHost, casefoldedAccount)
1335 1539
 	settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount)
@@ -1344,7 +1548,6 @@ func (am *AccountManager) loadRawAccount(tx *buntdb.Tx, casefoldedAccount string
1344 1548
 	result.Name, _ = tx.Get(accountNameKey)
1345 1549
 	result.RegisteredAt, _ = tx.Get(registeredTimeKey)
1346 1550
 	result.Credentials, _ = tx.Get(credentialsKey)
1347
-	result.Callback, _ = tx.Get(callbackKey)
1348 1551
 	result.AdditionalNicks, _ = tx.Get(nicksKey)
1349 1552
 	result.VHost, _ = tx.Get(vhostKey)
1350 1553
 	result.Settings, _ = tx.Get(settingsKey)
@@ -1524,7 +1727,6 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
1524 1727
 	accountNameKey := fmt.Sprintf(keyAccountName, casefoldedAccount)
1525 1728
 	registeredTimeKey := fmt.Sprintf(keyAccountRegTime, casefoldedAccount)
1526 1729
 	credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount)
1527
-	callbackKey := fmt.Sprintf(keyAccountCallback, casefoldedAccount)
1528 1730
 	verificationCodeKey := fmt.Sprintf(keyAccountVerificationCode, casefoldedAccount)
1529 1731
 	verifiedKey := fmt.Sprintf(keyAccountVerified, casefoldedAccount)
1530 1732
 	nicksKey := fmt.Sprintf(keyAccountAdditionalNicks, casefoldedAccount)
@@ -1537,6 +1739,8 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
1537 1739
 	modesKey := fmt.Sprintf(keyAccountModes, casefoldedAccount)
1538 1740
 	realnameKey := fmt.Sprintf(keyAccountRealname, casefoldedAccount)
1539 1741
 	suspendedKey := fmt.Sprintf(keyAccountSuspended, casefoldedAccount)
1742
+	pwResetKey := fmt.Sprintf(keyAccountPwReset, casefoldedAccount)
1743
+	emailChangeKey := fmt.Sprintf(keyAccountEmailChange, casefoldedAccount)
1540 1744
 
1541 1745
 	var clients []*Client
1542 1746
 	defer func() {
@@ -1582,7 +1786,6 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
1582 1786
 		tx.Delete(accountNameKey)
1583 1787
 		tx.Delete(verifiedKey)
1584 1788
 		tx.Delete(registeredTimeKey)
1585
-		tx.Delete(callbackKey)
1586 1789
 		tx.Delete(verificationCodeKey)
1587 1790
 		tx.Delete(settingsKey)
1588 1791
 		rawNicks, _ = tx.Get(nicksKey)
@@ -1597,6 +1800,8 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
1597 1800
 		tx.Delete(modesKey)
1598 1801
 		tx.Delete(realnameKey)
1599 1802
 		tx.Delete(suspendedKey)
1803
+		tx.Delete(pwResetKey)
1804
+		tx.Delete(emailChangeKey)
1600 1805
 
1601 1806
 		return nil
1602 1807
 	})
@@ -1940,17 +2145,19 @@ const (
1940 2145
 	CredentialsAnope  = -2
1941 2146
 )
1942 2147
 
2148
+type SCRAMCreds struct {
2149
+	Salt      []byte
2150
+	Iters     int
2151
+	StoredKey []byte
2152
+	ServerKey []byte
2153
+}
2154
+
1943 2155
 // AccountCredentials stores the various methods for verifying accounts.
1944 2156
 type AccountCredentials struct {
1945 2157
 	Version        CredentialsVersion
1946 2158
 	PassphraseHash []byte
1947 2159
 	Certfps        []string
1948
-	SCRAMCreds     struct {
1949
-		Salt      []byte
1950
-		Iters     int
1951
-		StoredKey []byte
1952
-		ServerKey []byte
1953
-	}
2160
+	SCRAMCreds
1954 2161
 }
1955 2162
 
1956 2163
 func (ac *AccountCredentials) Empty() bool {
@@ -1970,6 +2177,7 @@ func (ac *AccountCredentials) Serialize() (result string, err error) {
1970 2177
 func (ac *AccountCredentials) SetPassphrase(passphrase string, bcryptCost uint) (err error) {
1971 2178
 	if passphrase == "" {
1972 2179
 		ac.PassphraseHash = nil
2180
+		ac.SCRAMCreds = SCRAMCreds{}
1973 2181
 		return nil
1974 2182
 	}
1975 2183
 
@@ -1994,10 +2202,12 @@ func (ac *AccountCredentials) SetPassphrase(passphrase string, bcryptCost uint)
1994 2202
 	// xdg-go/scram says: "Clients have a default minimum PBKDF2 iteration count of 4096."
1995 2203
 	minIters := 4096
1996 2204
 	scramCreds := scramClient.GetStoredCredentials(scram.KeyFactors{Salt: string(salt), Iters: minIters})
1997
-	ac.SCRAMCreds.Salt = salt
1998
-	ac.SCRAMCreds.Iters = minIters
1999
-	ac.SCRAMCreds.StoredKey = scramCreds.StoredKey
2000
-	ac.SCRAMCreds.ServerKey = scramCreds.ServerKey
2205
+	ac.SCRAMCreds = SCRAMCreds{
2206
+		Salt:      salt,
2207
+		Iters:     minIters,
2208
+		StoredKey: scramCreds.StoredKey,
2209
+		ServerKey: scramCreds.ServerKey,
2210
+	}
2001 2211
 
2002 2212
 	return nil
2003 2213
 }
@@ -2112,6 +2322,7 @@ type AccountSettings struct {
2112 2322
 	AutoreplayMissed bool
2113 2323
 	DMHistory        HistoryStatus
2114 2324
 	AutoAway         PersistentStatus
2325
+	Email            string
2115 2326
 }
2116 2327
 
2117 2328
 // ClientAccount represents a user account.
@@ -2120,7 +2331,6 @@ type ClientAccount struct {
2120 2331
 	Name            string
2121 2332
 	NameCasefolded  string
2122 2333
 	RegisteredAt    time.Time
2123
-	Email           string
2124 2334
 	Credentials     AccountCredentials
2125 2335
 	Verified        bool
2126 2336
 	Suspended       *AccountSuspension
@@ -2134,7 +2344,6 @@ type rawClientAccount struct {
2134 2344
 	Name            string
2135 2345
 	RegisteredAt    string
2136 2346
 	Credentials     string
2137
-	Callback        string
2138 2347
 	Verified        bool
2139 2348
 	AdditionalNicks string
2140 2349
 	VHost           string

+ 57
- 1
irc/database.go View File

@@ -24,7 +24,7 @@ const (
24 24
 	// 'version' of the database schema
25 25
 	keySchemaVersion = "db.version"
26 26
 	// latest schema of the db
27
-	latestDbSchema = 20
27
+	latestDbSchema = 21
28 28
 
29 29
 	keyCloakSecret = "crypto.cloak_secret"
30 30
 )
@@ -1008,6 +1008,57 @@ func schemaChangeV19To20(config *Config, tx *buntdb.Tx) error {
1008 1008
 	return nil
1009 1009
 }
1010 1010
 
1011
+// #734: move the email address into the settings object,
1012
+// giving people a way to change it
1013
+func schemaChangeV20To21(config *Config, tx *buntdb.Tx) error {
1014
+	type accountSettingsv21 struct {
1015
+		AutoreplayLines  *int
1016
+		NickEnforcement  NickEnforcementMethod
1017
+		AllowBouncer     MulticlientAllowedSetting
1018
+		ReplayJoins      ReplayJoinsSetting
1019
+		AlwaysOn         PersistentStatus
1020
+		AutoreplayMissed bool
1021
+		DMHistory        HistoryStatus
1022
+		AutoAway         PersistentStatus
1023
+		Email            string
1024
+	}
1025
+	var accounts []string
1026
+	var emails []string
1027
+	callbackPrefix := "account.callback "
1028
+	tx.AscendGreaterOrEqual("", callbackPrefix, func(key, value string) bool {
1029
+		if !strings.HasPrefix(key, callbackPrefix) {
1030
+			return false
1031
+		}
1032
+		account := strings.TrimPrefix(key, callbackPrefix)
1033
+		if _, err := tx.Get("account.verified " + account); err != nil {
1034
+			return true
1035
+		}
1036
+		if strings.HasPrefix(value, "mailto:") {
1037
+			accounts = append(accounts, account)
1038
+			emails = append(emails, strings.TrimPrefix(value, "mailto:"))
1039
+		}
1040
+		return true
1041
+	})
1042
+	for i, account := range accounts {
1043
+		var settings accountSettingsv21
1044
+		email := emails[i]
1045
+		settingsKey := "account.settings " + account
1046
+		settingsStr, err := tx.Get(settingsKey)
1047
+		if err == nil && settingsStr != "" {
1048
+			json.Unmarshal([]byte(settingsStr), &settings)
1049
+		}
1050
+		settings.Email = email
1051
+		settingsBytes, err := json.Marshal(settings)
1052
+		if err != nil {
1053
+			log.Printf("couldn't marshal settings for %s: %v\n", account, err)
1054
+		} else {
1055
+			tx.Set(settingsKey, string(settingsBytes), nil)
1056
+		}
1057
+		tx.Delete(callbackPrefix + account)
1058
+	}
1059
+	return nil
1060
+}
1061
+
1011 1062
 func getSchemaChange(initialVersion int) (result SchemaChange, ok bool) {
1012 1063
 	for _, change := range allChanges {
1013 1064
 		if initialVersion == change.InitialVersion {
@@ -1113,4 +1164,9 @@ var allChanges = []SchemaChange{
1113 1164
 		TargetVersion:  20,
1114 1165
 		Changer:        schemaChangeV19To20,
1115 1166
 	},
1167
+	{
1168
+		InitialVersion: 20,
1169
+		TargetVersion:  21,
1170
+		Changer:        schemaChangeV20To21,
1171
+	},
1116 1172
 }

+ 21
- 0
irc/email/email.go View File

@@ -4,6 +4,7 @@
4 4
 package email
5 5
 
6 6
 import (
7
+	"bytes"
7 8
 	"errors"
8 9
 	"fmt"
9 10
 	"net"
@@ -11,7 +12,9 @@ import (
11 12
 	"strings"
12 13
 	"time"
13 14
 
15
+	"github.com/ergochat/ergo/irc/custime"
14 16
 	"github.com/ergochat/ergo/irc/smtp"
17
+	"github.com/ergochat/ergo/irc/utils"
15 18
 )
16 19
 
17 20
 var (
@@ -42,6 +45,11 @@ type MailtoConfig struct {
42 45
 	BlacklistRegexes     []string  `yaml:"blacklist-regexes"`
43 46
 	blacklistRegexes     []*regexp.Regexp
44 47
 	Timeout              time.Duration
48
+	PasswordReset        struct {
49
+		Enabled  bool
50
+		Cooldown custime.Duration
51
+		Timeout  custime.Duration
52
+	} `yaml:"password-reset"`
45 53
 }
46 54
 
47 55
 func (config *MailtoConfig) Postprocess(heloDomain string) (err error) {
@@ -95,6 +103,19 @@ func lookupMX(domain string) (server string) {
95 103
 	return
96 104
 }
97 105
 
106
+func ComposeMail(config MailtoConfig, recipient, subject string) (message bytes.Buffer) {
107
+	fmt.Fprintf(&message, "From: %s\r\n", config.Sender)
108
+	fmt.Fprintf(&message, "To: %s\r\n", recipient)
109
+	dkimDomain := config.DKIM.Domain
110
+	if dkimDomain != "" {
111
+		fmt.Fprintf(&message, "Message-ID: <%s@%s>\r\n", utils.GenerateSecretKey(), dkimDomain)
112
+	}
113
+	fmt.Fprintf(&message, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
114
+	fmt.Fprintf(&message, "Subject: %s\r\n", subject)
115
+	message.WriteString("\r\n") // blank line: end headers, begin message body
116
+	return message
117
+}
118
+
98 119
 func SendMail(config MailtoConfig, recipient string, msg []byte) (err error) {
99 120
 	for _, reg := range config.blacklistRegexes {
100 121
 		if reg.MatchString(recipient) {

+ 3
- 1
irc/import.go View File

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

+ 178
- 20
irc/nickserv.go View File

@@ -36,6 +36,11 @@ func servCmdRequiresBouncerEnabled(config *Config) bool {
36 36
 	return config.Accounts.Multiclient.Enabled
37 37
 }
38 38
 
39
+func servCmdRequiresEmailReset(config *Config) bool {
40
+	return config.Accounts.Registration.EmailVerification.Enabled &&
41
+		config.Accounts.Registration.EmailVerification.PasswordReset.Enabled
42
+}
43
+
39 44
 const nickservHelp = `NickServ lets you register, log in to, and manage an account.`
40 45
 
41 46
 var (
@@ -302,6 +307,12 @@ how the history of your direct messages is stored. Your options are:
302 307
 'auto-away' is only effective for always-on clients. If enabled, you will
303 308
 automatically be marked away when all your sessions are disconnected, and
304 309
 automatically return from away when you connect again.`,
310
+				`$bEMAIL$b
311
+'email' controls the e-mail address associated with your account (if the
312
+server operator allows it, this address can be used for password resets).
313
+As an additional security measure, if you have a password set, you must
314
+provide it as an additional argument to $bSET$b, for example,
315
+SET EMAIL test@example.com hunter2`,
305 316
 			},
306 317
 			authRequired: true,
307 318
 			enabled:      servCmdRequiresAuthEnabled,
@@ -318,6 +329,27 @@ information on the settings and their possible values, see HELP SET.`,
318 329
 			minParams: 3,
319 330
 			capabs:    []string{"accreg"},
320 331
 		},
332
+		"sendpass": {
333
+			handler: nsSendpassHandler,
334
+			help: `Syntax: $bSENDPASS <account>$b
335
+
336
+SENDPASS sends a password reset email to the email address associated with
337
+the target account. The reset code in the email can then be used with the
338
+$bRESETPASS$b command.`,
339
+			helpShort: `$bSENDPASS$b initiates an email-based password reset`,
340
+			enabled:   servCmdRequiresEmailReset,
341
+			minParams: 1,
342
+		},
343
+		"resetpass": {
344
+			handler: nsResetpassHandler,
345
+			help: `Syntax: $bRESETPASS <account> <code> <password>$b
346
+
347
+RESETPASS resets an account password, using a reset code that was emailed as
348
+the result of a previous $bSENDPASS$b command.`,
349
+			helpShort: `$bRESETPASS$b completes an email-based password reset`,
350
+			enabled:   servCmdRequiresEmailReset,
351
+			minParams: 3,
352
+		},
321 353
 		"cert": {
322 354
 			handler: nsCertHandler,
323 355
 			help: `Syntax: $bCERT <LIST | ADD | DEL> [account] [certfp]$b
@@ -357,6 +389,12 @@ Currently, you can only change the canonical casefolding of an account
357 389
 			minParams: 2,
358 390
 			capabs:    []string{"accreg"},
359 391
 		},
392
+		"verifyemail": {
393
+			handler:      nsVerifyEmailHandler,
394
+			authRequired: true,
395
+			minParams:    1,
396
+			hidden:       true,
397
+		},
360 398
 	}
361 399
 )
362 400
 
@@ -459,7 +497,12 @@ func displaySetting(service *ircService, settingName string, settings AccountSet
459 497
 		effectiveValue := historyEnabled(config.History.Persistent.DirectMessages, settings.DMHistory)
460 498
 		service.Notice(rb, fmt.Sprintf(client.t("Your stored direct message history setting is: %s"), historyStatusToString(settings.DMHistory)))
461 499
 		service.Notice(rb, fmt.Sprintf(client.t("Given current server settings, your direct message history setting is: %s"), historyStatusToString(effectiveValue)))
462
-
500
+	case "email":
501
+		if settings.Email != "" {
502
+			service.Notice(rb, fmt.Sprintf(client.t("Your stored e-mail address is: %s"), settings.Email))
503
+		} else {
504
+			service.Notice(rb, client.t("You have no stored e-mail address"))
505
+		}
463 506
 	default:
464 507
 		service.Notice(rb, client.t("No such setting"))
465 508
 	}
@@ -475,18 +518,27 @@ func userPersistentStatusToString(status PersistentStatus) string {
475 518
 }
476 519
 
477 520
 func nsSetHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
521
+	var privileged bool
478 522
 	var account string
479 523
 	if command == "saset" {
524
+		privileged = true
480 525
 		account = params[0]
481 526
 		params = params[1:]
482 527
 	} else {
483 528
 		account = client.Account()
484 529
 	}
485 530
 
531
+	key := strings.ToLower(params[0])
532
+	// unprivileged NS SET EMAIL is different because it requires a confirmation
533
+	if !privileged && key == "email" {
534
+		nsSetEmailHandler(service, client, params, rb)
535
+		return
536
+	}
537
+
486 538
 	var munger settingsMunger
487 539
 	var finalSettings AccountSettings
488 540
 	var err error
489
-	switch strings.ToLower(params[0]) {
541
+	switch key {
490 542
 	case "pass", "password":
491 543
 		service.Notice(rb, client.t("To change a password, use the PASSWD command. For details, /msg NickServ HELP PASSWD"))
492 544
 		return
@@ -603,6 +655,13 @@ func nsSetHandler(service *ircService, server *Server, client *Client, command s
603 655
 				return
604 656
 			}
605 657
 		}
658
+	case "email":
659
+		newValue := params[1]
660
+		munger = func(in AccountSettings) (out AccountSettings, err error) {
661
+			out = in
662
+			out.Email = newValue
663
+			return
664
+		}
606 665
 	default:
607 666
 		err = errInvalidParams
608 667
 	}
@@ -614,7 +673,7 @@ func nsSetHandler(service *ircService, server *Server, client *Client, command s
614 673
 	switch err {
615 674
 	case nil:
616 675
 		service.Notice(rb, client.t("Successfully changed your account settings"))
617
-		displaySetting(service, params[0], finalSettings, client, rb)
676
+		displaySetting(service, key, finalSettings, client, rb)
618 677
 	case errInvalidParams, errAccountDoesNotExist, errFeatureDisabled, errAccountUnverified, errAccountUpdateFailed:
619 678
 		service.Notice(rb, client.t(err.Error()))
620 679
 	case errNickAccountMismatch:
@@ -625,6 +684,55 @@ func nsSetHandler(service *ircService, server *Server, client *Client, command s
625 684
 	}
626 685
 }
627 686
 
687
+// handle unprivileged NS SET EMAIL, which sends a confirmation code
688
+func nsSetEmailHandler(service *ircService, client *Client, params []string, rb *ResponseBuffer) {
689
+	config := client.server.Config()
690
+	if !config.Accounts.Registration.EmailVerification.Enabled {
691
+		rb.Notice(client.t("E-mail verification is disabled"))
692
+		return
693
+	}
694
+	if !nsLoginThrottleCheck(service, client, rb) {
695
+		return
696
+	}
697
+	var password string
698
+	if len(params) > 2 {
699
+		password = params[2]
700
+	}
701
+	account := client.Account()
702
+	errorMessage := nsConfirmPassword(client.server, account, password)
703
+	if errorMessage != "" {
704
+		service.Notice(rb, client.t(errorMessage))
705
+		return
706
+	}
707
+	err := client.server.accounts.NsSetEmail(client, params[1])
708
+	switch err {
709
+	case nil:
710
+		service.Notice(rb, client.t("Check your e-mail for instructions on how to confirm your change of address"))
711
+	case errLimitExceeded:
712
+		service.Notice(rb, client.t("Try again later"))
713
+	default:
714
+		// if appropriate, show the client the error from the attempted email sending
715
+		if rErr := registrationCallbackErrorText(config, client, err); rErr != "" {
716
+			service.Notice(rb, rErr)
717
+		} else {
718
+			service.Notice(rb, client.t("An error occurred"))
719
+		}
720
+	}
721
+}
722
+
723
+func nsVerifyEmailHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
724
+	err := server.accounts.NsVerifyEmail(client, params[0])
725
+	switch err {
726
+	case nil:
727
+		service.Notice(rb, client.t("Successfully changed your account settings"))
728
+		displaySetting(service, "email", client.AccountSettings(), client, rb)
729
+	case errAccountVerificationInvalidCode:
730
+		service.Notice(rb, client.t(err.Error()))
731
+	default:
732
+		service.Notice(rb, client.t("An error occurred"))
733
+	}
734
+}
735
+
628 736
 func nsDropHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
629 737
 	sadrop := command == "sadrop"
630 738
 	var nick string
@@ -815,8 +923,8 @@ func nsInfoHandler(service *ircService, server *Server, client *Client, command
815 923
 	service.Notice(rb, fmt.Sprintf(client.t("Registered at: %s"), registeredAt))
816 924
 
817 925
 	if account.Name == client.AccountName() || client.HasRoleCapabs("accreg") {
818
-		if account.Email != "" {
819
-			service.Notice(rb, fmt.Sprintf(client.t("Email address: %s"), account.Email))
926
+		if account.Settings.Email != "" {
927
+			service.Notice(rb, fmt.Sprintf(client.t("Email address: %s"), account.Settings.Email))
820 928
 		}
821 929
 	}
822 930
 
@@ -1018,6 +1126,19 @@ func nsVerifyHandler(service *ircService, server *Server, client *Client, comman
1018 1126
 	}
1019 1127
 }
1020 1128
 
1129
+func nsConfirmPassword(server *Server, account, passphrase string) (errorMessage string) {
1130
+	accountData, err := server.accounts.LoadAccount(account)
1131
+	if err != nil {
1132
+		errorMessage = `You're not logged into an account`
1133
+	} else {
1134
+		hash := accountData.Credentials.PassphraseHash
1135
+		if hash != nil && passwd.CompareHashAndPassword(hash, []byte(passphrase)) != nil {
1136
+			errorMessage = `Password incorrect`
1137
+		}
1138
+	}
1139
+	return
1140
+}
1141
+
1021 1142
 func nsPasswdHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
1022 1143
 	var target string
1023 1144
 	var newPassword string
@@ -1041,28 +1162,19 @@ func nsPasswdHandler(service *ircService, server *Server, client *Client, comman
1041 1162
 		}
1042 1163
 	case 3:
1043 1164
 		target = client.Account()
1165
+		newPassword = params[1]
1166
+		if newPassword == "*" {
1167
+			newPassword = ""
1168
+		}
1044 1169
 		if target == "" {
1045 1170
 			errorMessage = `You're not logged into an account`
1046
-		} else if params[1] != params[2] {
1171
+		} else if newPassword != params[2] {
1047 1172
 			errorMessage = `Passwords do not match`
1048 1173
 		} else {
1049 1174
 			if !nsLoginThrottleCheck(service, client, rb) {
1050 1175
 				return
1051 1176
 			}
1052
-			accountData, err := server.accounts.LoadAccount(target)
1053
-			if err != nil {
1054
-				errorMessage = `You're not logged into an account`
1055
-			} else {
1056
-				hash := accountData.Credentials.PassphraseHash
1057
-				if hash != nil && passwd.CompareHashAndPassword(hash, []byte(params[0])) != nil {
1058
-					errorMessage = `Password incorrect`
1059
-				} else {
1060
-					newPassword = params[1]
1061
-					if newPassword == "*" {
1062
-						newPassword = ""
1063
-					}
1064
-				}
1065
-			}
1177
+			errorMessage = nsConfirmPassword(server, target, params[0])
1066 1178
 		}
1067 1179
 	default:
1068 1180
 		errorMessage = `Invalid parameters`
@@ -1422,6 +1534,52 @@ func suspensionToString(client *Client, suspension AccountSuspension) (result st
1422 1534
 	return fmt.Sprintf(client.t("Account %[1]s suspended at %[2]s. Duration: %[3]s. %[4]s"), suspension.AccountName, ts, duration, reason)
1423 1535
 }
1424 1536
 
1537
+func nsSendpassHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
1538
+	if !nsLoginThrottleCheck(service, client, rb) {
1539
+		return
1540
+	}
1541
+
1542
+	account := params[0]
1543
+	var message string
1544
+	err := server.accounts.NsSendpass(client, account)
1545
+	switch err {
1546
+	case nil:
1547
+		server.snomasks.Send(sno.LocalAccounts, fmt.Sprintf("Client %s sent a password reset for account %s", client.Nick(), account))
1548
+		message = `Successfully sent password reset email`
1549
+	case errAccountDoesNotExist, errAccountUnverified, errAccountSuspended:
1550
+		message = err.Error()
1551
+	case errValidEmailRequired:
1552
+		message = `That account is not associated with an email address`
1553
+	case errLimitExceeded:
1554
+		message = `Try again later`
1555
+	default:
1556
+		server.logger.Error("services", "error in NS SENDPASS", err.Error())
1557
+		message = `An error occurred`
1558
+	}
1559
+	rb.Notice(client.t(message))
1560
+}
1561
+
1562
+func nsResetpassHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
1563
+	if !nsLoginThrottleCheck(service, client, rb) {
1564
+		return
1565
+	}
1566
+
1567
+	var message string
1568
+	err := server.accounts.NsResetpass(client, params[0], params[1], params[2])
1569
+	switch err {
1570
+	case nil:
1571
+		message = `Successfully reset account password`
1572
+	case errAccountDoesNotExist, errAccountUnverified, errAccountSuspended, errAccountBadPassphrase:
1573
+		message = err.Error()
1574
+	case errAccountInvalidCredentials:
1575
+		message = `Code did not match`
1576
+	default:
1577
+		server.logger.Error("services", "error in NS RESETPASS", err.Error())
1578
+		message = `An error occurred`
1579
+	}
1580
+	rb.Notice(client.t(message))
1581
+}
1582
+
1425 1583
 func nsRenameHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
1426 1584
 	oldName, newName := params[0], params[1]
1427 1585
 	err := server.accounts.Rename(oldName, newName)

+ 8
- 1
irc/smtp/smtp.go View File

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

+ 7
- 0
traditional.yaml View File

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

Loading…
Cancel
Save