mirror of
https://github.com/ergochat/ergo.git
synced 2024-12-23 19:22:41 +01:00
8b2f6de3e0
* 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.
2353 lines
67 KiB
Go
2353 lines
67 KiB
Go
// Copyright (c) 2016-2017 Daniel Oaks <daniel@danieloaks.net>
|
|
// released under the MIT license
|
|
|
|
package irc
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/ergochat/irc-go/ircutils"
|
|
"github.com/xdg-go/scram"
|
|
|
|
"github.com/ergochat/ergo/irc/connection_limits"
|
|
"github.com/ergochat/ergo/irc/email"
|
|
"github.com/ergochat/ergo/irc/migrations"
|
|
"github.com/ergochat/ergo/irc/modes"
|
|
"github.com/ergochat/ergo/irc/passwd"
|
|
"github.com/ergochat/ergo/irc/utils"
|
|
"github.com/tidwall/buntdb"
|
|
)
|
|
|
|
const (
|
|
keyAccountExists = "account.exists %s"
|
|
keyAccountVerified = "account.verified %s"
|
|
keyAccountUnregistered = "account.unregistered %s"
|
|
keyAccountVerificationCode = "account.verificationcode %s"
|
|
keyAccountName = "account.name %s" // stores the 'preferred name' of the account, not casemapped
|
|
keyAccountRegTime = "account.registered.time %s"
|
|
keyAccountCredentials = "account.credentials %s"
|
|
keyAccountAdditionalNicks = "account.additionalnicks %s"
|
|
keyAccountSettings = "account.settings %s"
|
|
keyAccountVHost = "account.vhost %s"
|
|
keyCertToAccount = "account.creds.certfp %s"
|
|
keyAccountChannels = "account.channels %s" // channels registered to the account
|
|
keyAccountLastSeen = "account.lastseen %s"
|
|
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"
|
|
|
|
maxCertfpsPerAccount = 5
|
|
)
|
|
|
|
// everything about accounts is persistent; therefore, the database is the authoritative
|
|
// source of truth for all account information. anything on the heap is just a cache
|
|
type AccountManager struct {
|
|
sync.RWMutex // tier 2
|
|
serialCacheUpdateMutex sync.Mutex // tier 3
|
|
|
|
server *Server
|
|
// track clients logged in to accounts
|
|
accountToClients map[string][]*Client
|
|
nickToAccount map[string]string
|
|
skeletonToAccount map[string]string
|
|
accountToMethod map[string]NickEnforcementMethod
|
|
registerThrottle connection_limits.GenericThrottle
|
|
}
|
|
|
|
func (am *AccountManager) Initialize(server *Server) {
|
|
am.accountToClients = make(map[string][]*Client)
|
|
am.nickToAccount = make(map[string]string)
|
|
am.skeletonToAccount = make(map[string]string)
|
|
am.accountToMethod = make(map[string]NickEnforcementMethod)
|
|
am.server = server
|
|
|
|
config := server.Config()
|
|
am.buildNickToAccountIndex(config)
|
|
am.createAlwaysOnClients(config)
|
|
am.resetRegisterThrottle(config)
|
|
}
|
|
|
|
func (am *AccountManager) resetRegisterThrottle(config *Config) {
|
|
am.Lock()
|
|
defer am.Unlock()
|
|
|
|
am.registerThrottle = connection_limits.GenericThrottle{
|
|
Duration: config.Accounts.Registration.Throttling.Duration,
|
|
Limit: config.Accounts.Registration.Throttling.MaxAttempts,
|
|
}
|
|
}
|
|
|
|
func (am *AccountManager) touchRegisterThrottle() (throttled bool) {
|
|
am.Lock()
|
|
defer am.Unlock()
|
|
throttled, _ = am.registerThrottle.Touch()
|
|
return
|
|
}
|
|
|
|
func (am *AccountManager) createAlwaysOnClients(config *Config) {
|
|
if config.Accounts.Multiclient.AlwaysOn == PersistentDisabled {
|
|
return
|
|
}
|
|
|
|
verifiedPrefix := fmt.Sprintf(keyAccountVerified, "")
|
|
|
|
am.serialCacheUpdateMutex.Lock()
|
|
defer am.serialCacheUpdateMutex.Unlock()
|
|
|
|
var accounts []string
|
|
|
|
am.server.store.View(func(tx *buntdb.Tx) error {
|
|
err := tx.AscendGreaterOrEqual("", verifiedPrefix, func(key, value string) bool {
|
|
if !strings.HasPrefix(key, verifiedPrefix) {
|
|
return false
|
|
}
|
|
account := strings.TrimPrefix(key, verifiedPrefix)
|
|
accounts = append(accounts, account)
|
|
return true
|
|
})
|
|
return err
|
|
})
|
|
|
|
for _, accountName := range accounts {
|
|
account, err := am.LoadAccount(accountName)
|
|
if err == nil && (account.Verified && account.Suspended == nil) &&
|
|
persistenceEnabled(config.Accounts.Multiclient.AlwaysOn, account.Settings.AlwaysOn) {
|
|
am.server.AddAlwaysOnClient(
|
|
account,
|
|
am.loadChannels(accountName),
|
|
am.loadLastSeen(accountName),
|
|
am.loadModes(accountName),
|
|
am.loadRealname(accountName),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (am *AccountManager) buildNickToAccountIndex(config *Config) {
|
|
if !config.Accounts.NickReservation.Enabled {
|
|
return
|
|
}
|
|
|
|
nickToAccount := make(map[string]string)
|
|
skeletonToAccount := make(map[string]string)
|
|
accountToMethod := make(map[string]NickEnforcementMethod)
|
|
existsPrefix := fmt.Sprintf(keyAccountExists, "")
|
|
|
|
am.serialCacheUpdateMutex.Lock()
|
|
defer am.serialCacheUpdateMutex.Unlock()
|
|
|
|
err := am.server.store.View(func(tx *buntdb.Tx) error {
|
|
err := tx.AscendGreaterOrEqual("", existsPrefix, func(key, value string) bool {
|
|
if !strings.HasPrefix(key, existsPrefix) {
|
|
return false
|
|
}
|
|
|
|
account := strings.TrimPrefix(key, existsPrefix)
|
|
if _, err := tx.Get(fmt.Sprintf(keyAccountVerified, account)); err == nil {
|
|
nickToAccount[account] = account
|
|
accountName, err := tx.Get(fmt.Sprintf(keyAccountName, account))
|
|
if err != nil {
|
|
am.server.logger.Error("internal", "missing account name for", account)
|
|
} else {
|
|
skeleton, _ := Skeleton(accountName)
|
|
skeletonToAccount[skeleton] = account
|
|
}
|
|
}
|
|
if rawNicks, err := tx.Get(fmt.Sprintf(keyAccountAdditionalNicks, account)); err == nil {
|
|
additionalNicks := unmarshalReservedNicks(rawNicks)
|
|
for _, nick := range additionalNicks {
|
|
cfnick, _ := CasefoldName(nick)
|
|
nickToAccount[cfnick] = account
|
|
skeleton, _ := Skeleton(nick)
|
|
skeletonToAccount[skeleton] = account
|
|
}
|
|
}
|
|
|
|
if rawPrefs, err := tx.Get(fmt.Sprintf(keyAccountSettings, account)); err == nil {
|
|
var prefs AccountSettings
|
|
err := json.Unmarshal([]byte(rawPrefs), &prefs)
|
|
if err == nil && prefs.NickEnforcement != NickEnforcementOptional {
|
|
accountToMethod[account] = prefs.NickEnforcement
|
|
} else if err != nil {
|
|
am.server.logger.Error("internal", "corrupt account creds", account)
|
|
}
|
|
}
|
|
|
|
return true
|
|
})
|
|
return err
|
|
})
|
|
|
|
if config.Accounts.NickReservation.Method == NickEnforcementStrict {
|
|
unregisteredPrefix := fmt.Sprintf(keyAccountUnregistered, "")
|
|
am.server.store.View(func(tx *buntdb.Tx) error {
|
|
tx.AscendGreaterOrEqual("", unregisteredPrefix, func(key, value string) bool {
|
|
if !strings.HasPrefix(key, unregisteredPrefix) {
|
|
return false
|
|
}
|
|
account := strings.TrimPrefix(key, unregisteredPrefix)
|
|
accountName := value
|
|
nickToAccount[account] = account
|
|
skeleton, _ := Skeleton(accountName)
|
|
skeletonToAccount[skeleton] = account
|
|
return true
|
|
})
|
|
return nil
|
|
})
|
|
}
|
|
|
|
if err != nil {
|
|
am.server.logger.Error("internal", "couldn't read reserved nicks", err.Error())
|
|
} else {
|
|
am.Lock()
|
|
am.nickToAccount = nickToAccount
|
|
am.skeletonToAccount = skeletonToAccount
|
|
am.accountToMethod = accountToMethod
|
|
am.Unlock()
|
|
}
|
|
}
|
|
|
|
func (am *AccountManager) NickToAccount(nick string) string {
|
|
cfnick, err := CasefoldName(nick)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
skel, err := Skeleton(nick)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
am.RLock()
|
|
defer am.RUnlock()
|
|
account := am.nickToAccount[cfnick]
|
|
if account != "" {
|
|
return account
|
|
}
|
|
return am.skeletonToAccount[skel]
|
|
}
|
|
|
|
// given an account, combine stored enforcement method with the config settings
|
|
// to compute the actual enforcement method
|
|
func configuredEnforcementMethod(config *Config, storedMethod NickEnforcementMethod) (result NickEnforcementMethod) {
|
|
if !config.Accounts.NickReservation.Enabled {
|
|
return NickEnforcementNone
|
|
}
|
|
result = storedMethod
|
|
// if they don't have a custom setting, or customization is disabled, use the default
|
|
if result == NickEnforcementOptional || !config.Accounts.NickReservation.AllowCustomEnforcement {
|
|
result = config.Accounts.NickReservation.Method
|
|
}
|
|
if result == NickEnforcementOptional {
|
|
// enforcement was explicitly enabled neither in the config or by the user
|
|
result = NickEnforcementNone
|
|
}
|
|
return
|
|
}
|
|
|
|
// Given a nick, looks up the account that owns it and the method (none/timeout/strict)
|
|
// used to enforce ownership.
|
|
func (am *AccountManager) EnforcementStatus(cfnick, skeleton string) (account string, method NickEnforcementMethod) {
|
|
config := am.server.Config()
|
|
if !config.Accounts.NickReservation.Enabled {
|
|
return "", NickEnforcementNone
|
|
}
|
|
|
|
am.RLock()
|
|
defer am.RUnlock()
|
|
|
|
finalEnforcementMethod := func(account_ string) (result NickEnforcementMethod) {
|
|
storedMethod := am.accountToMethod[account_]
|
|
return configuredEnforcementMethod(config, storedMethod)
|
|
}
|
|
|
|
nickAccount := am.nickToAccount[cfnick]
|
|
skelAccount := am.skeletonToAccount[skeleton]
|
|
if nickAccount == "" && skelAccount == "" {
|
|
return "", NickEnforcementNone
|
|
} else if nickAccount != "" && (skelAccount == nickAccount || skelAccount == "") {
|
|
return nickAccount, finalEnforcementMethod(nickAccount)
|
|
} else if skelAccount != "" && nickAccount == "" {
|
|
return skelAccount, finalEnforcementMethod(skelAccount)
|
|
} else {
|
|
// nickAccount != skelAccount and both are nonempty:
|
|
// two people have competing claims on (this casefolding of) this nick!
|
|
nickMethod := finalEnforcementMethod(nickAccount)
|
|
skelMethod := finalEnforcementMethod(skelAccount)
|
|
switch {
|
|
case skelMethod == NickEnforcementNone:
|
|
return nickAccount, nickMethod
|
|
case nickMethod == NickEnforcementNone:
|
|
return skelAccount, skelMethod
|
|
default:
|
|
// nobody can use this nick
|
|
return "!", NickEnforcementStrict
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sets a custom enforcement method for an account and stores it in the database.
|
|
func (am *AccountManager) SetEnforcementStatus(account string, method NickEnforcementMethod) (finalSettings AccountSettings, err error) {
|
|
config := am.server.Config()
|
|
if !(config.Accounts.NickReservation.Enabled && config.Accounts.NickReservation.AllowCustomEnforcement) {
|
|
err = errFeatureDisabled
|
|
return
|
|
}
|
|
|
|
setter := func(in AccountSettings) (out AccountSettings, err error) {
|
|
out = in
|
|
out.NickEnforcement = method
|
|
return out, nil
|
|
}
|
|
|
|
_, err = am.ModifyAccountSettings(account, setter)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// this update of the data plane is racey, but it's probably fine
|
|
am.Lock()
|
|
defer am.Unlock()
|
|
|
|
if method == NickEnforcementOptional {
|
|
delete(am.accountToMethod, account)
|
|
} else {
|
|
am.accountToMethod[account] = method
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func (am *AccountManager) AccountToClients(account string) (result []*Client) {
|
|
cfaccount, err := CasefoldName(account)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
am.RLock()
|
|
defer am.RUnlock()
|
|
return am.accountToClients[cfaccount]
|
|
}
|
|
|
|
func (am *AccountManager) Register(client *Client, account string, callbackNamespace string, callbackValue string, passphrase string, certfp string) error {
|
|
casefoldedAccount, err := CasefoldName(account)
|
|
skeleton, skerr := Skeleton(account)
|
|
if err != nil || skerr != nil || account == "" || account == "*" {
|
|
return errAccountCreation
|
|
}
|
|
|
|
if restrictedCasefoldedNicks.Has(casefoldedAccount) || restrictedSkeletons.Has(skeleton) {
|
|
return errAccountAlreadyRegistered
|
|
}
|
|
|
|
config := am.server.Config()
|
|
|
|
// final "is registration allowed" check:
|
|
if !(config.Accounts.Registration.Enabled || callbackNamespace == "admin") || am.server.Defcon() <= 4 {
|
|
return errFeatureDisabled
|
|
}
|
|
|
|
if client != nil && client.Account() != "" {
|
|
return errAccountAlreadyLoggedIn
|
|
}
|
|
|
|
if client != nil && am.touchRegisterThrottle() {
|
|
am.server.logger.Warning("accounts", "global registration throttle exceeded by client", client.Nick())
|
|
return errLimitExceeded
|
|
}
|
|
|
|
// if nick reservation is enabled, don't let people reserve nicknames
|
|
// that they would not be eligible to take, e.g.,
|
|
// 1. a nickname that someone else is currently holding
|
|
// 2. a nickname confusable with an existing reserved nickname
|
|
// this has a lot of weird edge cases because of force-guest-format
|
|
// and the possibility of registering a nickname on an "unregistered connection"
|
|
// (i.e., pre-handshake).
|
|
if client != nil && config.Accounts.NickReservation.Enabled {
|
|
_, nickAcquireError, _ := am.server.clients.SetNick(client, nil, account, true)
|
|
if !(nickAcquireError == nil || nickAcquireError == errNoop) {
|
|
return errAccountMustHoldNick
|
|
}
|
|
}
|
|
|
|
// can't register a guest nickname
|
|
if config.Accounts.NickReservation.guestRegexpFolded.MatchString(casefoldedAccount) {
|
|
return errAccountAlreadyRegistered
|
|
}
|
|
|
|
accountKey := fmt.Sprintf(keyAccountExists, casefoldedAccount)
|
|
unregisteredKey := fmt.Sprintf(keyAccountUnregistered, casefoldedAccount)
|
|
accountNameKey := fmt.Sprintf(keyAccountName, 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
|
|
creds.Version = 1
|
|
err = creds.SetPassphrase(passphrase, am.server.Config().Accounts.Registration.BcryptCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
creds.AddCertfp(certfp)
|
|
credStr, err := creds.Serialize()
|
|
if err != nil {
|
|
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)
|
|
|
|
var setOptions *buntdb.SetOptions
|
|
ttl := time.Duration(config.Accounts.Registration.VerifyTimeout)
|
|
if ttl != 0 {
|
|
setOptions = &buntdb.SetOptions{Expires: true, TTL: ttl}
|
|
}
|
|
|
|
err = func() error {
|
|
am.serialCacheUpdateMutex.Lock()
|
|
defer am.serialCacheUpdateMutex.Unlock()
|
|
|
|
// can't register an account with the same name as a registered nick
|
|
if am.NickToAccount(account) != "" {
|
|
return errAccountAlreadyRegistered
|
|
}
|
|
|
|
return am.server.store.Update(func(tx *buntdb.Tx) error {
|
|
if _, err := tx.Get(unregisteredKey); err == nil {
|
|
return errAccountAlreadyUnregistered
|
|
}
|
|
|
|
_, err = am.loadRawAccount(tx, casefoldedAccount)
|
|
if err != errAccountDoesNotExist {
|
|
return errAccountAlreadyRegistered
|
|
}
|
|
|
|
if certfp != "" {
|
|
// make sure certfp doesn't already exist because that'd be silly
|
|
_, err := tx.Get(certFPKey)
|
|
if err != buntdb.ErrNotFound {
|
|
return errCertfpAlreadyExists
|
|
}
|
|
}
|
|
|
|
tx.Set(accountKey, "1", setOptions)
|
|
tx.Set(accountNameKey, account, setOptions)
|
|
tx.Set(registeredTimeKey, registeredTimeStr, setOptions)
|
|
tx.Set(credentialsKey, credStr, setOptions)
|
|
tx.Set(settingsKey, settingsStr, setOptions)
|
|
if certfp != "" {
|
|
tx.Set(certFPKey, casefoldedAccount, setOptions)
|
|
}
|
|
return nil
|
|
})
|
|
}()
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
code, err := am.dispatchCallback(client, account, callbackNamespace, callbackValue)
|
|
if err != nil {
|
|
am.Unregister(casefoldedAccount, true)
|
|
return ®istrationCallbackError{underlying: err}
|
|
} else {
|
|
return am.server.store.Update(func(tx *buntdb.Tx) error {
|
|
_, _, err = tx.Set(verificationCodeKey, code, setOptions)
|
|
return err
|
|
})
|
|
}
|
|
}
|
|
|
|
type registrationCallbackError struct {
|
|
underlying error
|
|
}
|
|
|
|
func (r *registrationCallbackError) Error() string {
|
|
return `Account verification could not be sent`
|
|
}
|
|
|
|
func registrationCallbackErrorText(config *Config, client *Client, err error) string {
|
|
if callbackErr, ok := err.(*registrationCallbackError); ok {
|
|
// only expose a user-visible error if we are doing direct sending
|
|
if config.Accounts.Registration.EmailVerification.DirectSendingEnabled() {
|
|
errorText := ircutils.SanitizeText(callbackErr.underlying.Error(), 350)
|
|
return fmt.Sprintf(client.t("Could not dispatch registration e-mail: %s"), errorText)
|
|
} else {
|
|
return client.t("Could not dispatch registration e-mail")
|
|
}
|
|
} else {
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// validatePassphrase checks whether a passphrase is allowed by our rules
|
|
func validatePassphrase(passphrase string) error {
|
|
// sanity check the length
|
|
if len(passphrase) == 0 || len(passphrase) > 300 {
|
|
return errAccountBadPassphrase
|
|
}
|
|
// we use * as a placeholder in some places, if it's gotten this far then fail
|
|
if passphrase == "*" {
|
|
return errAccountBadPassphrase
|
|
}
|
|
// validate that the passphrase contains no spaces, and furthermore is valid as a
|
|
// non-final IRC parameter. we already checked that it is nonempty:
|
|
if passphrase[0] == ':' {
|
|
return errAccountBadPassphrase
|
|
}
|
|
for _, r := range passphrase {
|
|
if unicode.IsSpace(r) {
|
|
return errAccountBadPassphrase
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// changes the password for an account
|
|
func (am *AccountManager) setPassword(accountName string, password string, hasPrivs bool) (err error) {
|
|
cfAccount, err := CasefoldName(accountName)
|
|
if err != nil {
|
|
return errAccountDoesNotExist
|
|
}
|
|
|
|
credKey := fmt.Sprintf(keyAccountCredentials, cfAccount)
|
|
var credStr string
|
|
am.server.store.View(func(tx *buntdb.Tx) error {
|
|
// no need to check verification status here or below;
|
|
// you either need to be auth'ed to the account or be an oper to do this
|
|
credStr, err = tx.Get(credKey)
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return errAccountDoesNotExist
|
|
}
|
|
|
|
var creds AccountCredentials
|
|
err = json.Unmarshal([]byte(credStr), &creds)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !hasPrivs && creds.Empty() {
|
|
return errCredsExternallyManaged
|
|
}
|
|
|
|
err = creds.SetPassphrase(password, am.server.Config().Accounts.Registration.BcryptCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if creds.Empty() && !hasPrivs {
|
|
return errEmptyCredentials
|
|
}
|
|
|
|
newCredStr, err := creds.Serialize()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = am.server.store.Update(func(tx *buntdb.Tx) error {
|
|
curCredStr, err := tx.Get(credKey)
|
|
if credStr != curCredStr {
|
|
return errCASFailed
|
|
}
|
|
_, _, err = tx.Set(credKey, newCredStr, nil)
|
|
return err
|
|
})
|
|
|
|
return err
|
|
}
|
|
|
|
type alwaysOnChannelStatus struct {
|
|
Modes string
|
|
JoinTime int64
|
|
}
|
|
|
|
func (am *AccountManager) saveChannels(account string, channelToModes map[string]alwaysOnChannelStatus) {
|
|
j, err := json.Marshal(channelToModes)
|
|
if err != nil {
|
|
am.server.logger.Error("internal", "couldn't marshal channel-to-modes", account, err.Error())
|
|
return
|
|
}
|
|
jStr := string(j)
|
|
key := fmt.Sprintf(keyAccountChannelToModes, account)
|
|
am.server.store.Update(func(tx *buntdb.Tx) error {
|
|
tx.Set(key, jStr, nil)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (am *AccountManager) loadChannels(account string) (channelToModes map[string]alwaysOnChannelStatus) {
|
|
key := fmt.Sprintf(keyAccountChannelToModes, account)
|
|
var channelsStr string
|
|
am.server.store.View(func(tx *buntdb.Tx) error {
|
|
channelsStr, _ = tx.Get(key)
|
|
return nil
|
|
})
|
|
if channelsStr == "" {
|
|
return nil
|
|
}
|
|
err := json.Unmarshal([]byte(channelsStr), &channelToModes)
|
|
if err != nil {
|
|
am.server.logger.Error("internal", "couldn't marshal channel-to-modes", account, err.Error())
|
|
return nil
|
|
}
|
|
return
|
|
}
|
|
|
|
func (am *AccountManager) saveModes(account string, uModes modes.Modes) {
|
|
modeStr := uModes.String()
|
|
key := fmt.Sprintf(keyAccountModes, account)
|
|
am.server.store.Update(func(tx *buntdb.Tx) error {
|
|
tx.Set(key, modeStr, nil)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (am *AccountManager) loadModes(account string) (uModes modes.Modes) {
|
|
key := fmt.Sprintf(keyAccountModes, account)
|
|
var modeStr string
|
|
am.server.store.View(func(tx *buntdb.Tx) error {
|
|
modeStr, _ = tx.Get(key)
|
|
return nil
|
|
})
|
|
for _, m := range modeStr {
|
|
uModes = append(uModes, modes.Mode(m))
|
|
}
|
|
return
|
|
}
|
|
|
|
func (am *AccountManager) saveLastSeen(account string, lastSeen map[string]time.Time) {
|
|
key := fmt.Sprintf(keyAccountLastSeen, account)
|
|
var val string
|
|
if len(lastSeen) != 0 {
|
|
text, _ := json.Marshal(lastSeen)
|
|
val = string(text)
|
|
}
|
|
err := am.server.store.Update(func(tx *buntdb.Tx) error {
|
|
if val != "" {
|
|
tx.Set(key, val, nil)
|
|
} else {
|
|
tx.Delete(key)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
am.server.logger.Error("internal", "error persisting lastSeen", account, err.Error())
|
|
}
|
|
}
|
|
|
|
func (am *AccountManager) loadLastSeen(account string) (lastSeen map[string]time.Time) {
|
|
key := fmt.Sprintf(keyAccountLastSeen, account)
|
|
var lsText string
|
|
am.server.store.Update(func(tx *buntdb.Tx) error {
|
|
lsText, _ = tx.Get(key)
|
|
return nil
|
|
})
|
|
if lsText == "" {
|
|
return nil
|
|
}
|
|
err := json.Unmarshal([]byte(lsText), &lastSeen)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return
|
|
}
|
|
|
|
func (am *AccountManager) saveRealname(account string, realname string) {
|
|
key := fmt.Sprintf(keyAccountRealname, account)
|
|
am.server.store.Update(func(tx *buntdb.Tx) error {
|
|
if realname != "" {
|
|
tx.Set(key, realname, nil)
|
|
} else {
|
|
tx.Delete(key)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (am *AccountManager) loadRealname(account string) (realname string) {
|
|
key := fmt.Sprintf(keyAccountRealname, account)
|
|
am.server.store.Update(func(tx *buntdb.Tx) error {
|
|
realname, _ = tx.Get(key)
|
|
return nil
|
|
})
|
|
return
|
|
}
|
|
|
|
func (am *AccountManager) addRemoveCertfp(account, certfp string, add bool, hasPrivs bool) (err error) {
|
|
certfp, err = utils.NormalizeCertfp(certfp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cfAccount, err := CasefoldName(account)
|
|
if err != nil {
|
|
return errAccountDoesNotExist
|
|
}
|
|
|
|
credKey := fmt.Sprintf(keyAccountCredentials, cfAccount)
|
|
var credStr string
|
|
am.server.store.View(func(tx *buntdb.Tx) error {
|
|
credStr, err = tx.Get(credKey)
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return errAccountDoesNotExist
|
|
}
|
|
|
|
var creds AccountCredentials
|
|
err = json.Unmarshal([]byte(credStr), &creds)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !hasPrivs && creds.Empty() {
|
|
return errCredsExternallyManaged
|
|
}
|
|
|
|
if add {
|
|
err = creds.AddCertfp(certfp)
|
|
} else {
|
|
err = creds.RemoveCertfp(certfp)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if creds.Empty() && !hasPrivs {
|
|
return errEmptyCredentials
|
|
}
|
|
|
|
newCredStr, err := creds.Serialize()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
certfpKey := fmt.Sprintf(keyCertToAccount, certfp)
|
|
err = am.server.store.Update(func(tx *buntdb.Tx) error {
|
|
curCredStr, err := tx.Get(credKey)
|
|
if credStr != curCredStr {
|
|
return errCASFailed
|
|
}
|
|
if add {
|
|
_, err = tx.Get(certfpKey)
|
|
if err != buntdb.ErrNotFound {
|
|
return errCertfpAlreadyExists
|
|
}
|
|
tx.Set(certfpKey, cfAccount, nil)
|
|
} else {
|
|
tx.Delete(certfpKey)
|
|
}
|
|
_, _, err = tx.Set(credKey, newCredStr, nil)
|
|
return err
|
|
})
|
|
|
|
return err
|
|
}
|
|
|
|
func (am *AccountManager) dispatchCallback(client *Client, account string, callbackNamespace string, callbackValue string) (string, error) {
|
|
if callbackNamespace == "*" || callbackNamespace == "none" || callbackNamespace == "admin" {
|
|
return "", nil
|
|
} else if callbackNamespace == "mailto" {
|
|
return am.dispatchMailtoCallback(client, account, callbackValue)
|
|
} else {
|
|
return "", fmt.Errorf("Callback not implemented: %s", callbackNamespace)
|
|
}
|
|
}
|
|
|
|
func (am *AccountManager) dispatchMailtoCallback(client *Client, account string, callbackValue string) (code string, err error) {
|
|
config := am.server.Config().Accounts.Registration.EmailVerification
|
|
code = utils.GenerateSecretToken()
|
|
|
|
subject := config.VerifyMessageSubject
|
|
if subject == "" {
|
|
subject = fmt.Sprintf(client.t("Verify your account on %s"), am.server.name)
|
|
}
|
|
|
|
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)
|
|
message.WriteString("\r\n")
|
|
message.WriteString("\r\n")
|
|
message.WriteString(client.t("To verify your account, issue the following command:"))
|
|
message.WriteString("\r\n")
|
|
fmt.Fprintf(&message, "/MSG NickServ VERIFY %s %s\r\n", account, code)
|
|
|
|
err = email.SendMail(config, callbackValue, message.Bytes())
|
|
if err != nil {
|
|
am.server.logger.Error("internal", "Failed to dispatch e-mail to", callbackValue, err.Error())
|
|
}
|
|
return
|
|
}
|
|
|
|
func (am *AccountManager) Verify(client *Client, account string, code string) error {
|
|
casefoldedAccount, err := CasefoldName(account)
|
|
var skeleton string
|
|
if err != nil || account == "" || account == "*" {
|
|
return errAccountVerificationFailed
|
|
}
|
|
|
|
if client != nil && client.Account() != "" {
|
|
return errAccountAlreadyLoggedIn
|
|
}
|
|
|
|
verifiedKey := fmt.Sprintf(keyAccountVerified, casefoldedAccount)
|
|
accountKey := fmt.Sprintf(keyAccountExists, casefoldedAccount)
|
|
accountNameKey := fmt.Sprintf(keyAccountName, casefoldedAccount)
|
|
registeredTimeKey := fmt.Sprintf(keyAccountRegTime, casefoldedAccount)
|
|
verificationCodeKey := fmt.Sprintf(keyAccountVerificationCode, casefoldedAccount)
|
|
credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount)
|
|
settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount)
|
|
|
|
var raw rawClientAccount
|
|
|
|
func() {
|
|
am.serialCacheUpdateMutex.Lock()
|
|
defer am.serialCacheUpdateMutex.Unlock()
|
|
|
|
// do a final check for confusability (in case someone already verified
|
|
// a confusable identifier):
|
|
var unfoldedName string
|
|
err = am.server.store.View(func(tx *buntdb.Tx) error {
|
|
unfoldedName, err = tx.Get(accountNameKey)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
err = errAccountDoesNotExist
|
|
return
|
|
}
|
|
skeleton, err = Skeleton(unfoldedName)
|
|
if err != nil {
|
|
err = errAccountDoesNotExist
|
|
return
|
|
}
|
|
err = func() error {
|
|
am.RLock()
|
|
defer am.RUnlock()
|
|
if _, ok := am.skeletonToAccount[skeleton]; ok {
|
|
return errConfusableIdentifier
|
|
}
|
|
return nil
|
|
}()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
err = am.server.store.Update(func(tx *buntdb.Tx) error {
|
|
raw, err = am.loadRawAccount(tx, casefoldedAccount)
|
|
if err == errAccountDoesNotExist {
|
|
return errAccountDoesNotExist
|
|
} else if err != nil {
|
|
return errAccountVerificationFailed
|
|
} else if raw.Verified {
|
|
return errAccountAlreadyVerified
|
|
}
|
|
|
|
// actually verify the code
|
|
// a stored code of "" means a none callback / no code required
|
|
success := false
|
|
storedCode, err := tx.Get(verificationCodeKey)
|
|
if err == nil {
|
|
// this is probably unnecessary
|
|
if storedCode == "" || utils.SecretTokensMatch(storedCode, code) {
|
|
success = true
|
|
}
|
|
}
|
|
if !success {
|
|
return errAccountVerificationInvalidCode
|
|
}
|
|
|
|
// verify the account
|
|
tx.Set(verifiedKey, "1", nil)
|
|
// don't need the code anymore
|
|
tx.Delete(verificationCodeKey)
|
|
// re-set all other keys, removing the TTL
|
|
tx.Set(accountKey, "1", nil)
|
|
tx.Set(accountNameKey, raw.Name, nil)
|
|
tx.Set(registeredTimeKey, raw.RegisteredAt, 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,
|
|
// but this is like 2 usec on my system
|
|
json.Unmarshal([]byte(raw.Credentials), &creds)
|
|
for _, cert := range creds.Certfps {
|
|
certFPKey := fmt.Sprintf(keyCertToAccount, cert)
|
|
tx.Set(certFPKey, casefoldedAccount, nil)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err == nil {
|
|
am.Lock()
|
|
am.nickToAccount[casefoldedAccount] = casefoldedAccount
|
|
am.skeletonToAccount[skeleton] = casefoldedAccount
|
|
am.Unlock()
|
|
}
|
|
}()
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
nick := "[server admin]"
|
|
if client != nil {
|
|
nick = client.Nick()
|
|
}
|
|
am.server.logger.Info("accounts", "client", nick, "registered account", account)
|
|
raw.Verified = true
|
|
clientAccount, err := am.deserializeRawAccount(raw, casefoldedAccount)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if client != nil {
|
|
am.Login(client, clientAccount)
|
|
if client.AlwaysOn() {
|
|
client.markDirty(IncludeRealname)
|
|
}
|
|
}
|
|
// we may need to do nick enforcement here:
|
|
_, method := am.EnforcementStatus(casefoldedAccount, skeleton)
|
|
if method == NickEnforcementStrict {
|
|
currentClient := am.server.clients.Get(casefoldedAccount)
|
|
if currentClient != nil && currentClient != client && currentClient.Account() != casefoldedAccount {
|
|
am.server.RandomlyRename(currentClient)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// register and verify an account, for internal use
|
|
func (am *AccountManager) SARegister(account, passphrase string) (err error) {
|
|
err = am.Register(nil, account, "admin", "", passphrase, "")
|
|
if err == nil {
|
|
err = am.Verify(nil, account, "")
|
|
}
|
|
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, ",")
|
|
}
|
|
|
|
func unmarshalReservedNicks(nicks string) (result []string) {
|
|
if nicks == "" {
|
|
return
|
|
}
|
|
return strings.Split(nicks, ",")
|
|
}
|
|
|
|
func (am *AccountManager) SetNickReserved(client *Client, nick string, saUnreserve bool, reserve bool) error {
|
|
cfnick, err := CasefoldName(nick)
|
|
skeleton, skerr := Skeleton(nick)
|
|
// garbage nick, or garbage options, or disabled
|
|
nrconfig := am.server.Config().Accounts.NickReservation
|
|
if err != nil || skerr != nil || cfnick == "" || (reserve && saUnreserve) || !nrconfig.Enabled {
|
|
return errAccountNickReservationFailed
|
|
}
|
|
|
|
// the cache is in sync with the DB while we hold serialCacheUpdateMutex
|
|
am.serialCacheUpdateMutex.Lock()
|
|
defer am.serialCacheUpdateMutex.Unlock()
|
|
|
|
// find the affected account, which is usually the client's:
|
|
account := client.Account()
|
|
if saUnreserve {
|
|
// unless this is a sadrop:
|
|
account := func() string {
|
|
am.RLock()
|
|
defer am.RUnlock()
|
|
return am.nickToAccount[cfnick]
|
|
}()
|
|
if account == "" {
|
|
// nothing to do
|
|
return nil
|
|
}
|
|
}
|
|
if account == "" {
|
|
return errAccountNotLoggedIn
|
|
}
|
|
|
|
am.Lock()
|
|
accountForNick := am.nickToAccount[cfnick]
|
|
var accountForSkeleton string
|
|
if reserve {
|
|
accountForSkeleton = am.skeletonToAccount[skeleton]
|
|
}
|
|
am.Unlock()
|
|
|
|
if reserve && (accountForNick != "" || accountForSkeleton != "") {
|
|
return errNicknameReserved
|
|
} else if !reserve && !saUnreserve && accountForNick != account {
|
|
return errNicknameReserved
|
|
} else if !reserve && cfnick == account {
|
|
return errAccountCantDropPrimaryNick
|
|
}
|
|
|
|
nicksKey := fmt.Sprintf(keyAccountAdditionalNicks, account)
|
|
unverifiedAccountKey := fmt.Sprintf(keyAccountExists, cfnick)
|
|
err = am.server.store.Update(func(tx *buntdb.Tx) error {
|
|
if reserve {
|
|
// unverified accounts don't show up in NickToAccount yet (which is intentional),
|
|
// however you shouldn't be able to reserve a nick out from under them
|
|
_, err := tx.Get(unverifiedAccountKey)
|
|
if err == nil {
|
|
return errNicknameReserved
|
|
}
|
|
}
|
|
|
|
rawNicks, err := tx.Get(nicksKey)
|
|
if err != nil && err != buntdb.ErrNotFound {
|
|
return err
|
|
}
|
|
|
|
nicks := unmarshalReservedNicks(rawNicks)
|
|
|
|
if reserve {
|
|
if len(nicks) >= nrconfig.AdditionalNickLimit {
|
|
return errAccountTooManyNicks
|
|
}
|
|
nicks = append(nicks, nick)
|
|
} else {
|
|
// compute (original reserved nicks) minus cfnick
|
|
var newNicks []string
|
|
for _, reservedNick := range nicks {
|
|
cfreservednick, _ := CasefoldName(reservedNick)
|
|
if cfreservednick != cfnick {
|
|
newNicks = append(newNicks, reservedNick)
|
|
} else {
|
|
// found the original, unfolded version of the nick we're dropping;
|
|
// recompute the true skeleton from it
|
|
skeleton, _ = Skeleton(reservedNick)
|
|
}
|
|
}
|
|
nicks = newNicks
|
|
}
|
|
|
|
marshaledNicks := marshalReservedNicks(nicks)
|
|
_, _, err = tx.Set(nicksKey, string(marshaledNicks), nil)
|
|
return err
|
|
})
|
|
|
|
if err == errAccountTooManyNicks || err == errNicknameReserved {
|
|
return err
|
|
} else if err != nil {
|
|
return errAccountNickReservationFailed
|
|
}
|
|
|
|
// success
|
|
am.Lock()
|
|
defer am.Unlock()
|
|
if reserve {
|
|
am.nickToAccount[cfnick] = account
|
|
am.skeletonToAccount[skeleton] = account
|
|
} else {
|
|
delete(am.nickToAccount, cfnick)
|
|
delete(am.skeletonToAccount, skeleton)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (am *AccountManager) checkPassphrase(accountName, passphrase string) (account ClientAccount, err error) {
|
|
account, err = am.LoadAccount(accountName)
|
|
// #1476: if grouped nicks are allowed, attempt to interpret accountName as a grouped nick
|
|
if err == errAccountDoesNotExist && !am.server.Config().Accounts.NickReservation.ForceNickEqualsAccount {
|
|
cfnick, cfErr := CasefoldName(accountName)
|
|
if cfErr != nil {
|
|
return
|
|
}
|
|
accountName = func() string {
|
|
am.RLock()
|
|
defer am.RUnlock()
|
|
return am.nickToAccount[cfnick]
|
|
}()
|
|
if accountName != "" {
|
|
account, err = am.LoadAccount(accountName)
|
|
}
|
|
}
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
if !account.Verified {
|
|
err = errAccountUnverified
|
|
return
|
|
} else if account.Suspended != nil {
|
|
err = errAccountSuspended
|
|
return
|
|
}
|
|
|
|
switch account.Credentials.Version {
|
|
case 0:
|
|
err = am.checkLegacyPassphrase(migrations.CheckOragonoPassphraseV0, accountName, account.Credentials.PassphraseHash, passphrase)
|
|
case 1:
|
|
if passwd.CompareHashAndPassword(account.Credentials.PassphraseHash, []byte(passphrase)) != nil {
|
|
err = errAccountInvalidCredentials
|
|
}
|
|
if err == nil && account.Credentials.SCRAMCreds.Iters == 0 {
|
|
// XXX: if the account was created prior to 2.8, it doesn't have SCRAM credentials;
|
|
// since we temporarily have access to a valid plaintext password, create them:
|
|
am.rehashPassword(account.Name, passphrase)
|
|
}
|
|
case -1:
|
|
err = am.checkLegacyPassphrase(migrations.CheckAthemePassphrase, accountName, account.Credentials.PassphraseHash, passphrase)
|
|
case -2:
|
|
err = am.checkLegacyPassphrase(migrations.CheckAnopePassphrase, accountName, account.Credentials.PassphraseHash, passphrase)
|
|
default:
|
|
err = errAccountInvalidCredentials
|
|
}
|
|
return
|
|
}
|
|
|
|
func (am *AccountManager) checkLegacyPassphrase(check migrations.PassphraseCheck, account string, hash []byte, passphrase string) (err error) {
|
|
err = check(hash, []byte(passphrase))
|
|
if err != nil {
|
|
if err == migrations.ErrHashInvalid {
|
|
am.server.logger.Error("internal", "invalid legacy credentials for account", account)
|
|
}
|
|
return errAccountInvalidCredentials
|
|
}
|
|
// re-hash the passphrase with the latest algorithm
|
|
am.rehashPassword(account, passphrase)
|
|
return nil
|
|
}
|
|
|
|
func (am *AccountManager) rehashPassword(accountName, passphrase string) {
|
|
err := am.setPassword(accountName, passphrase, true)
|
|
if err != nil {
|
|
am.server.logger.Error("internal", "could not upgrade user password", accountName, err.Error())
|
|
}
|
|
}
|
|
|
|
func (am *AccountManager) loadWithAutocreation(accountName string, autocreate bool) (account ClientAccount, err error) {
|
|
account, err = am.LoadAccount(accountName)
|
|
if err == errAccountDoesNotExist && autocreate {
|
|
err = am.SARegister(accountName, "")
|
|
if err != nil {
|
|
return
|
|
}
|
|
account, err = am.LoadAccount(accountName)
|
|
}
|
|
return
|
|
}
|
|
|
|
func (am *AccountManager) AuthenticateByPassphrase(client *Client, accountName string, passphrase string) (err error) {
|
|
// XXX check this now, so we don't allow a redundant login for an always-on client
|
|
// even for a brief period. the other potential source of nick-account conflicts
|
|
// is from force-nick-equals-account, but those will be caught later by
|
|
// fixupNickEqualsAccount and if there is a conflict, they will be logged out.
|
|
if client.registered {
|
|
if clientAlready := am.server.clients.Get(accountName); clientAlready != nil && clientAlready.AlwaysOn() {
|
|
return errNickAccountMismatch
|
|
}
|
|
}
|
|
|
|
if throttled, remainingTime := client.checkLoginThrottle(); throttled {
|
|
return &ThrottleError{remainingTime}
|
|
}
|
|
|
|
var account ClientAccount
|
|
|
|
defer func() {
|
|
if err == nil {
|
|
am.Login(client, account)
|
|
}
|
|
}()
|
|
|
|
config := am.server.Config()
|
|
if config.Accounts.AuthScript.Enabled {
|
|
var output AuthScriptOutput
|
|
output, err = CheckAuthScript(am.server.semaphores.AuthScript, config.Accounts.AuthScript.ScriptConfig,
|
|
AuthScriptInput{AccountName: accountName, Passphrase: passphrase, IP: client.IP().String()})
|
|
if err != nil {
|
|
am.server.logger.Error("internal", "failed shell auth invocation", err.Error())
|
|
} else if output.Success {
|
|
if output.AccountName != "" {
|
|
accountName = output.AccountName
|
|
}
|
|
account, err = am.loadWithAutocreation(accountName, config.Accounts.AuthScript.Autocreate)
|
|
return
|
|
}
|
|
}
|
|
|
|
account, err = am.checkPassphrase(accountName, passphrase)
|
|
return err
|
|
}
|
|
|
|
// AllNicks returns the uncasefolded nicknames for all accounts, including additional (grouped) nicks.
|
|
func (am *AccountManager) AllNicks() (result []string) {
|
|
accountNamePrefix := fmt.Sprintf(keyAccountName, "")
|
|
accountAdditionalNicksPrefix := fmt.Sprintf(keyAccountAdditionalNicks, "")
|
|
|
|
am.server.store.View(func(tx *buntdb.Tx) error {
|
|
// Account names
|
|
err := tx.AscendGreaterOrEqual("", accountNamePrefix, func(key, value string) bool {
|
|
if !strings.HasPrefix(key, accountNamePrefix) {
|
|
return false
|
|
}
|
|
result = append(result, value)
|
|
return true
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Additional nicks
|
|
return tx.AscendGreaterOrEqual("", accountAdditionalNicksPrefix, func(key, value string) bool {
|
|
if !strings.HasPrefix(key, accountAdditionalNicksPrefix) {
|
|
return false
|
|
}
|
|
additionalNicks := unmarshalReservedNicks(value)
|
|
for _, additionalNick := range additionalNicks {
|
|
result = append(result, additionalNick)
|
|
}
|
|
return true
|
|
})
|
|
})
|
|
|
|
sort.Strings(result)
|
|
return
|
|
}
|
|
|
|
func (am *AccountManager) LoadAccount(accountName string) (result ClientAccount, err error) {
|
|
casefoldedAccount, err := CasefoldName(accountName)
|
|
if err != nil {
|
|
err = errAccountDoesNotExist
|
|
return
|
|
}
|
|
|
|
var raw rawClientAccount
|
|
am.server.store.View(func(tx *buntdb.Tx) error {
|
|
raw, err = am.loadRawAccount(tx, casefoldedAccount)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
result, err = am.deserializeRawAccount(raw, casefoldedAccount)
|
|
return
|
|
}
|
|
|
|
// look up the unfolded version of an account name, possibly after deletion
|
|
func (am *AccountManager) AccountToAccountName(account string) (result string) {
|
|
casefoldedAccount, err := CasefoldName(account)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
unregisteredKey := fmt.Sprintf(keyAccountUnregistered, casefoldedAccount)
|
|
accountNameKey := fmt.Sprintf(keyAccountName, casefoldedAccount)
|
|
|
|
am.server.store.View(func(tx *buntdb.Tx) error {
|
|
if name, err := tx.Get(accountNameKey); err == nil {
|
|
result = name
|
|
return nil
|
|
}
|
|
if name, err := tx.Get(unregisteredKey); err == nil {
|
|
result = name
|
|
}
|
|
return nil
|
|
})
|
|
|
|
return
|
|
}
|
|
|
|
func (am *AccountManager) deserializeRawAccount(raw rawClientAccount, cfName string) (result ClientAccount, err error) {
|
|
result.Name = raw.Name
|
|
result.NameCasefolded = cfName
|
|
regTimeInt, _ := strconv.ParseInt(raw.RegisteredAt, 10, 64)
|
|
result.RegisteredAt = time.Unix(0, regTimeInt).UTC()
|
|
e := json.Unmarshal([]byte(raw.Credentials), &result.Credentials)
|
|
if e != nil {
|
|
am.server.logger.Error("internal", "could not unmarshal credentials", e.Error())
|
|
err = errAccountDoesNotExist
|
|
return
|
|
}
|
|
result.AdditionalNicks = unmarshalReservedNicks(raw.AdditionalNicks)
|
|
result.Verified = raw.Verified
|
|
if raw.VHost != "" {
|
|
e := json.Unmarshal([]byte(raw.VHost), &result.VHost)
|
|
if e != nil {
|
|
am.server.logger.Warning("internal", "could not unmarshal vhost for account", result.Name, e.Error())
|
|
// pretend they have no vhost and move on
|
|
}
|
|
}
|
|
if raw.Settings != "" {
|
|
e := json.Unmarshal([]byte(raw.Settings), &result.Settings)
|
|
if e != nil {
|
|
am.server.logger.Warning("internal", "could not unmarshal settings for account", result.Name, e.Error())
|
|
}
|
|
}
|
|
if raw.Suspended != "" {
|
|
sus := new(AccountSuspension)
|
|
e := json.Unmarshal([]byte(raw.Suspended), sus)
|
|
if e != nil {
|
|
am.server.logger.Error("internal", "corrupt suspension data", result.Name, e.Error())
|
|
} else {
|
|
result.Suspended = sus
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func (am *AccountManager) loadRawAccount(tx *buntdb.Tx, casefoldedAccount string) (result rawClientAccount, err error) {
|
|
accountKey := fmt.Sprintf(keyAccountExists, casefoldedAccount)
|
|
accountNameKey := fmt.Sprintf(keyAccountName, casefoldedAccount)
|
|
registeredTimeKey := fmt.Sprintf(keyAccountRegTime, casefoldedAccount)
|
|
credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount)
|
|
verifiedKey := fmt.Sprintf(keyAccountVerified, casefoldedAccount)
|
|
nicksKey := fmt.Sprintf(keyAccountAdditionalNicks, casefoldedAccount)
|
|
vhostKey := fmt.Sprintf(keyAccountVHost, casefoldedAccount)
|
|
settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount)
|
|
suspendedKey := fmt.Sprintf(keyAccountSuspended, casefoldedAccount)
|
|
|
|
_, e := tx.Get(accountKey)
|
|
if e == buntdb.ErrNotFound {
|
|
err = errAccountDoesNotExist
|
|
return
|
|
}
|
|
|
|
result.Name, _ = tx.Get(accountNameKey)
|
|
result.RegisteredAt, _ = tx.Get(registeredTimeKey)
|
|
result.Credentials, _ = tx.Get(credentialsKey)
|
|
result.AdditionalNicks, _ = tx.Get(nicksKey)
|
|
result.VHost, _ = tx.Get(vhostKey)
|
|
result.Settings, _ = tx.Get(settingsKey)
|
|
result.Suspended, _ = tx.Get(suspendedKey)
|
|
|
|
if _, e = tx.Get(verifiedKey); e == nil {
|
|
result.Verified = true
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
type AccountSuspension struct {
|
|
AccountName string `json:"AccountName,omitempty"`
|
|
TimeCreated time.Time
|
|
Duration time.Duration
|
|
OperName string
|
|
Reason string
|
|
}
|
|
|
|
func (am *AccountManager) Suspend(accountName string, duration time.Duration, operName, reason string) (err error) {
|
|
account, err := CasefoldName(accountName)
|
|
if err != nil {
|
|
return errAccountDoesNotExist
|
|
}
|
|
|
|
suspension := AccountSuspension{
|
|
TimeCreated: time.Now().UTC(),
|
|
Duration: duration,
|
|
OperName: operName,
|
|
Reason: reason,
|
|
}
|
|
suspensionStr, err := json.Marshal(suspension)
|
|
if err != nil {
|
|
am.server.logger.Error("internal", "suspension json unserializable", err.Error())
|
|
return errAccountDoesNotExist
|
|
}
|
|
|
|
existsKey := fmt.Sprintf(keyAccountExists, account)
|
|
suspensionKey := fmt.Sprintf(keyAccountSuspended, account)
|
|
var setOptions *buntdb.SetOptions
|
|
if duration != time.Duration(0) {
|
|
setOptions = &buntdb.SetOptions{Expires: true, TTL: duration}
|
|
}
|
|
err = am.server.store.Update(func(tx *buntdb.Tx) error {
|
|
_, err := tx.Get(existsKey)
|
|
if err != nil {
|
|
return errAccountDoesNotExist
|
|
}
|
|
_, _, err = tx.Set(suspensionKey, string(suspensionStr), setOptions)
|
|
return err
|
|
})
|
|
|
|
if err == errAccountDoesNotExist {
|
|
return err
|
|
} else if err != nil {
|
|
am.server.logger.Error("internal", "couldn't persist suspension", account, err.Error())
|
|
} // keep going
|
|
|
|
am.Lock()
|
|
clients := am.accountToClients[account]
|
|
delete(am.accountToClients, account)
|
|
am.Unlock()
|
|
|
|
// kill clients, sending them the reason
|
|
suspension.AccountName = accountName
|
|
for _, client := range clients {
|
|
client.Logout()
|
|
client.Quit(suspensionToString(client, suspension), nil)
|
|
client.destroy(nil)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (am *AccountManager) killClients(clients []*Client) {
|
|
for _, client := range clients {
|
|
client.Logout()
|
|
client.Quit(client.t("You are no longer authorized to be on this server"), nil)
|
|
client.destroy(nil)
|
|
}
|
|
}
|
|
|
|
func (am *AccountManager) Unsuspend(accountName string) (err error) {
|
|
cfaccount, err := CasefoldName(accountName)
|
|
if err != nil {
|
|
return errAccountDoesNotExist
|
|
}
|
|
|
|
existsKey := fmt.Sprintf(keyAccountExists, cfaccount)
|
|
suspensionKey := fmt.Sprintf(keyAccountSuspended, cfaccount)
|
|
err = am.server.store.Update(func(tx *buntdb.Tx) error {
|
|
_, err := tx.Get(existsKey)
|
|
if err != nil {
|
|
return errAccountDoesNotExist
|
|
}
|
|
_, err = tx.Delete(suspensionKey)
|
|
if err != nil {
|
|
return errNoop
|
|
}
|
|
return nil
|
|
})
|
|
|
|
return err
|
|
}
|
|
|
|
func (am *AccountManager) ListSuspended() (result []AccountSuspension) {
|
|
var names []string
|
|
var raw []string
|
|
|
|
prefix := fmt.Sprintf(keyAccountSuspended, "")
|
|
am.server.store.View(func(tx *buntdb.Tx) error {
|
|
err := tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool {
|
|
if !strings.HasPrefix(key, prefix) {
|
|
return false
|
|
}
|
|
raw = append(raw, value)
|
|
cfname := strings.TrimPrefix(key, prefix)
|
|
name, _ := tx.Get(fmt.Sprintf(keyAccountName, cfname))
|
|
names = append(names, name)
|
|
return true
|
|
})
|
|
return err
|
|
})
|
|
|
|
result = make([]AccountSuspension, 0, len(raw))
|
|
for i := 0; i < len(raw); i++ {
|
|
var sus AccountSuspension
|
|
err := json.Unmarshal([]byte(raw[i]), &sus)
|
|
if err != nil {
|
|
am.server.logger.Error("internal", "corrupt data for suspension", names[i], err.Error())
|
|
continue
|
|
}
|
|
sus.AccountName = names[i]
|
|
result = append(result, sus)
|
|
}
|
|
return
|
|
}
|
|
|
|
// renames an account (within very restrictive limits); see #1380
|
|
func (am *AccountManager) Rename(oldName, newName string) (err error) {
|
|
accountData, err := am.LoadAccount(oldName)
|
|
if err != nil {
|
|
return
|
|
}
|
|
newCfName, err := CasefoldName(newName)
|
|
if err != nil {
|
|
return errNicknameInvalid
|
|
}
|
|
if newCfName != accountData.NameCasefolded {
|
|
return errInvalidAccountRename
|
|
}
|
|
key := fmt.Sprintf(keyAccountName, accountData.NameCasefolded)
|
|
err = am.server.store.Update(func(tx *buntdb.Tx) error {
|
|
tx.Set(key, newName, nil)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
am.RLock()
|
|
defer am.RUnlock()
|
|
for _, client := range am.accountToClients[accountData.NameCasefolded] {
|
|
client.setAccountName(newName)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (am *AccountManager) Unregister(account string, erase bool) error {
|
|
config := am.server.Config()
|
|
casefoldedAccount, err := CasefoldName(account)
|
|
if err != nil {
|
|
return errAccountDoesNotExist
|
|
}
|
|
|
|
accountKey := fmt.Sprintf(keyAccountExists, casefoldedAccount)
|
|
accountNameKey := fmt.Sprintf(keyAccountName, casefoldedAccount)
|
|
registeredTimeKey := fmt.Sprintf(keyAccountRegTime, casefoldedAccount)
|
|
credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount)
|
|
verificationCodeKey := fmt.Sprintf(keyAccountVerificationCode, casefoldedAccount)
|
|
verifiedKey := fmt.Sprintf(keyAccountVerified, casefoldedAccount)
|
|
nicksKey := fmt.Sprintf(keyAccountAdditionalNicks, casefoldedAccount)
|
|
settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount)
|
|
vhostKey := fmt.Sprintf(keyAccountVHost, casefoldedAccount)
|
|
channelsKey := fmt.Sprintf(keyAccountChannels, casefoldedAccount)
|
|
joinedChannelsKey := fmt.Sprintf(keyAccountChannelToModes, casefoldedAccount)
|
|
lastSeenKey := fmt.Sprintf(keyAccountLastSeen, casefoldedAccount)
|
|
unregisteredKey := fmt.Sprintf(keyAccountUnregistered, casefoldedAccount)
|
|
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() {
|
|
am.killClients(clients)
|
|
}()
|
|
|
|
var registeredChannels []string
|
|
// on our way out, unregister all the account's channels and delete them from the db
|
|
defer func() {
|
|
for _, channelName := range registeredChannels {
|
|
err := am.server.channels.SetUnregistered(channelName, casefoldedAccount)
|
|
if err != nil {
|
|
am.server.logger.Error("internal", "couldn't unregister channel", channelName, err.Error())
|
|
}
|
|
}
|
|
}()
|
|
|
|
var credText string
|
|
var rawNicks string
|
|
|
|
am.serialCacheUpdateMutex.Lock()
|
|
defer am.serialCacheUpdateMutex.Unlock()
|
|
|
|
var accountName string
|
|
var channelsStr string
|
|
keepProtections := false
|
|
am.server.store.Update(func(tx *buntdb.Tx) error {
|
|
// get the unfolded account name; for an active account, this is
|
|
// stored under accountNameKey, for an unregistered account under unregisteredKey
|
|
accountName, _ = tx.Get(accountNameKey)
|
|
if accountName == "" {
|
|
accountName, _ = tx.Get(unregisteredKey)
|
|
}
|
|
if erase {
|
|
tx.Delete(unregisteredKey)
|
|
} else {
|
|
if _, err := tx.Get(verifiedKey); err == nil {
|
|
tx.Set(unregisteredKey, accountName, nil)
|
|
keepProtections = true
|
|
}
|
|
}
|
|
tx.Delete(accountKey)
|
|
tx.Delete(accountNameKey)
|
|
tx.Delete(verifiedKey)
|
|
tx.Delete(registeredTimeKey)
|
|
tx.Delete(verificationCodeKey)
|
|
tx.Delete(settingsKey)
|
|
rawNicks, _ = tx.Get(nicksKey)
|
|
tx.Delete(nicksKey)
|
|
credText, err = tx.Get(credentialsKey)
|
|
tx.Delete(credentialsKey)
|
|
tx.Delete(vhostKey)
|
|
channelsStr, _ = tx.Get(channelsKey)
|
|
tx.Delete(channelsKey)
|
|
tx.Delete(joinedChannelsKey)
|
|
tx.Delete(lastSeenKey)
|
|
tx.Delete(modesKey)
|
|
tx.Delete(realnameKey)
|
|
tx.Delete(suspendedKey)
|
|
tx.Delete(pwResetKey)
|
|
tx.Delete(emailChangeKey)
|
|
|
|
return nil
|
|
})
|
|
|
|
if err == nil {
|
|
var creds AccountCredentials
|
|
if err := json.Unmarshal([]byte(credText), &creds); err == nil {
|
|
for _, cert := range creds.Certfps {
|
|
certFPKey := fmt.Sprintf(keyCertToAccount, cert)
|
|
am.server.store.Update(func(tx *buntdb.Tx) error {
|
|
if account, err := tx.Get(certFPKey); err == nil && account == casefoldedAccount {
|
|
tx.Delete(certFPKey)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
skeleton, _ := Skeleton(accountName)
|
|
additionalNicks := unmarshalReservedNicks(rawNicks)
|
|
registeredChannels = unmarshalRegisteredChannels(channelsStr)
|
|
|
|
am.Lock()
|
|
defer am.Unlock()
|
|
|
|
clients = am.accountToClients[casefoldedAccount]
|
|
delete(am.accountToClients, casefoldedAccount)
|
|
// protect the account name itself where applicable, but not any grouped nicks
|
|
if !(keepProtections && config.Accounts.NickReservation.Method == NickEnforcementStrict) {
|
|
delete(am.nickToAccount, casefoldedAccount)
|
|
delete(am.skeletonToAccount, skeleton)
|
|
}
|
|
for _, nick := range additionalNicks {
|
|
delete(am.nickToAccount, nick)
|
|
additionalSkel, _ := Skeleton(nick)
|
|
delete(am.skeletonToAccount, additionalSkel)
|
|
}
|
|
|
|
if err != nil && !erase {
|
|
return errAccountDoesNotExist
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func unmarshalRegisteredChannels(channelsStr string) (result []string) {
|
|
if channelsStr != "" {
|
|
result = strings.Split(channelsStr, ",")
|
|
}
|
|
return
|
|
}
|
|
|
|
func (am *AccountManager) ChannelsForAccount(account string) (channels []string) {
|
|
cfaccount, err := CasefoldName(account)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
var channelStr string
|
|
key := fmt.Sprintf(keyAccountChannels, cfaccount)
|
|
am.server.store.View(func(tx *buntdb.Tx) error {
|
|
channelStr, _ = tx.Get(key)
|
|
return nil
|
|
})
|
|
return unmarshalRegisteredChannels(channelStr)
|
|
}
|
|
|
|
func (am *AccountManager) AuthenticateByCertificate(client *Client, certfp string, peerCerts []*x509.Certificate, authzid string) (err error) {
|
|
if certfp == "" {
|
|
return errAccountInvalidCredentials
|
|
}
|
|
|
|
var clientAccount ClientAccount
|
|
|
|
defer func() {
|
|
if err != nil {
|
|
return
|
|
} else if !clientAccount.Verified {
|
|
err = errAccountUnverified
|
|
return
|
|
} else if clientAccount.Suspended != nil {
|
|
err = errAccountSuspended
|
|
return
|
|
}
|
|
// TODO(#1109) clean this check up?
|
|
if client.registered {
|
|
if clientAlready := am.server.clients.Get(clientAccount.Name); clientAlready != nil && clientAlready.AlwaysOn() {
|
|
err = errNickAccountMismatch
|
|
return
|
|
}
|
|
}
|
|
am.Login(client, clientAccount)
|
|
return
|
|
}()
|
|
|
|
config := am.server.Config()
|
|
if config.Accounts.AuthScript.Enabled {
|
|
var output AuthScriptOutput
|
|
output, err = CheckAuthScript(am.server.semaphores.AuthScript, config.Accounts.AuthScript.ScriptConfig,
|
|
AuthScriptInput{Certfp: certfp, IP: client.IP().String(), peerCerts: peerCerts})
|
|
if err != nil {
|
|
am.server.logger.Error("internal", "failed shell auth invocation", err.Error())
|
|
} else if output.Success && output.AccountName != "" {
|
|
clientAccount, err = am.loadWithAutocreation(output.AccountName, config.Accounts.AuthScript.Autocreate)
|
|
return
|
|
}
|
|
}
|
|
|
|
var account string
|
|
certFPKey := fmt.Sprintf(keyCertToAccount, certfp)
|
|
|
|
err = am.server.store.View(func(tx *buntdb.Tx) error {
|
|
account, _ = tx.Get(certFPKey)
|
|
if account == "" {
|
|
return errAccountInvalidCredentials
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if authzid != "" && authzid != account {
|
|
return errAuthzidAuthcidMismatch
|
|
}
|
|
|
|
// ok, we found an account corresponding to their certificate
|
|
clientAccount, err = am.LoadAccount(account)
|
|
return err
|
|
}
|
|
|
|
type settingsMunger func(input AccountSettings) (output AccountSettings, err error)
|
|
|
|
func (am *AccountManager) ModifyAccountSettings(account string, munger settingsMunger) (newSettings AccountSettings, err error) {
|
|
casefoldedAccount, err := CasefoldName(account)
|
|
if err != nil {
|
|
return newSettings, errAccountDoesNotExist
|
|
}
|
|
// TODO implement this in general via a compare-and-swap API
|
|
accountData, err := am.LoadAccount(casefoldedAccount)
|
|
if err != nil {
|
|
return
|
|
} else if !accountData.Verified {
|
|
return newSettings, errAccountUnverified
|
|
}
|
|
newSettings, err = munger(accountData.Settings)
|
|
if err != nil {
|
|
return
|
|
}
|
|
text, err := json.Marshal(newSettings)
|
|
if err != nil {
|
|
return
|
|
}
|
|
key := fmt.Sprintf(keyAccountSettings, casefoldedAccount)
|
|
serializedValue := string(text)
|
|
err = am.server.store.Update(func(tx *buntdb.Tx) (err error) {
|
|
_, _, err = tx.Set(key, serializedValue, nil)
|
|
return
|
|
})
|
|
if err != nil {
|
|
err = errAccountUpdateFailed
|
|
return
|
|
}
|
|
// success, push new settings into the client objects
|
|
am.Lock()
|
|
defer am.Unlock()
|
|
for _, client := range am.accountToClients[casefoldedAccount] {
|
|
client.SetAccountSettings(newSettings)
|
|
}
|
|
return
|
|
}
|
|
|
|
// represents someone's status in hostserv
|
|
type VHostInfo struct {
|
|
ApprovedVHost string
|
|
Enabled bool
|
|
}
|
|
|
|
// callback type implementing the actual business logic of vhost operations
|
|
type vhostMunger func(input VHostInfo) (output VHostInfo, err error)
|
|
|
|
func (am *AccountManager) VHostSet(account string, vhost string) (result VHostInfo, err error) {
|
|
munger := func(input VHostInfo) (output VHostInfo, err error) {
|
|
output = input
|
|
output.Enabled = true
|
|
output.ApprovedVHost = vhost
|
|
return
|
|
}
|
|
|
|
return am.performVHostChange(account, munger)
|
|
}
|
|
|
|
func (am *AccountManager) VHostSetEnabled(client *Client, enabled bool) (result VHostInfo, err error) {
|
|
munger := func(input VHostInfo) (output VHostInfo, err error) {
|
|
if input.ApprovedVHost == "" {
|
|
err = errNoVhost
|
|
return
|
|
}
|
|
output = input
|
|
output.Enabled = enabled
|
|
return
|
|
}
|
|
|
|
return am.performVHostChange(client.Account(), munger)
|
|
}
|
|
|
|
func (am *AccountManager) performVHostChange(account string, munger vhostMunger) (result VHostInfo, err error) {
|
|
account, err = CasefoldName(account)
|
|
if err != nil || account == "" {
|
|
err = errAccountDoesNotExist
|
|
return
|
|
}
|
|
|
|
if am.server.Defcon() <= 3 {
|
|
err = errFeatureDisabled
|
|
return
|
|
}
|
|
|
|
clientAccount, err := am.LoadAccount(account)
|
|
if err != nil {
|
|
err = errAccountDoesNotExist
|
|
return
|
|
} else if !clientAccount.Verified {
|
|
err = errAccountUnverified
|
|
return
|
|
}
|
|
|
|
result, err = munger(clientAccount.VHost)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
vhtext, err := json.Marshal(result)
|
|
if err != nil {
|
|
err = errAccountUpdateFailed
|
|
return
|
|
}
|
|
vhstr := string(vhtext)
|
|
|
|
key := fmt.Sprintf(keyAccountVHost, account)
|
|
err = am.server.store.Update(func(tx *buntdb.Tx) error {
|
|
_, _, err := tx.Set(key, vhstr, nil)
|
|
return err
|
|
})
|
|
|
|
if err != nil {
|
|
err = errAccountUpdateFailed
|
|
return
|
|
}
|
|
|
|
am.applyVhostToClients(account, result)
|
|
return result, nil
|
|
}
|
|
|
|
func (am *AccountManager) applyVHostInfo(client *Client, info VHostInfo) {
|
|
// if hostserv is disabled in config, then don't grant vhosts
|
|
// that were previously approved while it was enabled
|
|
if !am.server.Config().Accounts.VHosts.Enabled {
|
|
return
|
|
}
|
|
|
|
vhost := ""
|
|
if info.Enabled {
|
|
vhost = info.ApprovedVHost
|
|
}
|
|
oldNickmask := client.NickMaskString()
|
|
updated := client.SetVHost(vhost)
|
|
if updated && client.Registered() {
|
|
// TODO: doing I/O here is kind of a kludge
|
|
client.sendChghost(oldNickmask, client.Hostname())
|
|
}
|
|
}
|
|
|
|
func (am *AccountManager) applyVhostToClients(account string, result VHostInfo) {
|
|
am.RLock()
|
|
clients := am.accountToClients[account]
|
|
am.RUnlock()
|
|
|
|
for _, client := range clients {
|
|
am.applyVHostInfo(client, result)
|
|
}
|
|
}
|
|
|
|
func (am *AccountManager) Login(client *Client, account ClientAccount) {
|
|
client.Login(account)
|
|
|
|
am.applyVHostInfo(client, account.VHost)
|
|
|
|
casefoldedAccount := client.Account()
|
|
am.Lock()
|
|
defer am.Unlock()
|
|
am.accountToClients[casefoldedAccount] = append(am.accountToClients[casefoldedAccount], client)
|
|
}
|
|
|
|
func (am *AccountManager) Logout(client *Client) {
|
|
am.Lock()
|
|
defer am.Unlock()
|
|
|
|
casefoldedAccount := client.Account()
|
|
if casefoldedAccount == "" {
|
|
return
|
|
}
|
|
|
|
client.Logout()
|
|
|
|
clients := am.accountToClients[casefoldedAccount]
|
|
if len(clients) <= 1 {
|
|
delete(am.accountToClients, casefoldedAccount)
|
|
return
|
|
}
|
|
remainingClients := make([]*Client, len(clients)-1)
|
|
remainingPos := 0
|
|
for currentPos := 0; currentPos < len(clients); currentPos++ {
|
|
if clients[currentPos] != client {
|
|
remainingClients[remainingPos] = clients[currentPos]
|
|
remainingPos++
|
|
}
|
|
}
|
|
am.accountToClients[casefoldedAccount] = remainingClients
|
|
}
|
|
|
|
var (
|
|
// EnabledSaslMechanisms contains the SASL mechanisms that exist and that we support.
|
|
// This can be moved to some other data structure/place if we need to load/unload mechs later.
|
|
EnabledSaslMechanisms = map[string]func(*Server, *Client, *Session, []byte, *ResponseBuffer) bool{
|
|
"PLAIN": authPlainHandler,
|
|
"EXTERNAL": authExternalHandler,
|
|
"SCRAM-SHA-256": authScramHandler,
|
|
}
|
|
)
|
|
|
|
type CredentialsVersion int
|
|
|
|
const (
|
|
CredentialsLegacy CredentialsVersion = 0
|
|
CredentialsSHA3Bcrypt CredentialsVersion = 1
|
|
// negative numbers for migration
|
|
CredentialsAtheme = -1
|
|
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
|
|
}
|
|
|
|
func (ac *AccountCredentials) Empty() bool {
|
|
return len(ac.PassphraseHash) == 0 && len(ac.Certfps) == 0
|
|
}
|
|
|
|
// helper to assemble the serialized JSON for an account's credentials
|
|
func (ac *AccountCredentials) Serialize() (result string, err error) {
|
|
ac.Version = 1
|
|
credText, err := json.Marshal(*ac)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(credText), nil
|
|
}
|
|
|
|
func (ac *AccountCredentials) SetPassphrase(passphrase string, bcryptCost uint) (err error) {
|
|
if passphrase == "" {
|
|
ac.PassphraseHash = nil
|
|
ac.SCRAMCreds = SCRAMCreds{}
|
|
return nil
|
|
}
|
|
|
|
if validatePassphrase(passphrase) != nil {
|
|
return errAccountBadPassphrase
|
|
}
|
|
|
|
ac.PassphraseHash, err = passwd.GenerateFromPassword([]byte(passphrase), int(bcryptCost))
|
|
if err != nil {
|
|
return errAccountBadPassphrase
|
|
}
|
|
|
|
// we can pass an empty account name because it won't actually be incorporated
|
|
// into the credentials; it's just a quirk of the xdg-go/scram API that the way
|
|
// to produce server credentials is to call NewClient* and then GetStoredCredentials
|
|
scramClient, err := scram.SHA256.NewClientUnprepped("", passphrase, "")
|
|
if err != nil {
|
|
return errAccountBadPassphrase
|
|
}
|
|
salt := make([]byte, 16)
|
|
rand.Read(salt)
|
|
// 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 = SCRAMCreds{
|
|
Salt: salt,
|
|
Iters: minIters,
|
|
StoredKey: scramCreds.StoredKey,
|
|
ServerKey: scramCreds.ServerKey,
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (am *AccountManager) NewScramConversation() *scram.ServerConversation {
|
|
server, _ := scram.SHA256.NewServer(am.lookupSCRAMCreds)
|
|
return server.NewConversation()
|
|
}
|
|
|
|
func (am *AccountManager) lookupSCRAMCreds(accountName string) (creds scram.StoredCredentials, err error) {
|
|
// strip client ID if present:
|
|
if strudelIndex := strings.IndexByte(accountName, '@'); strudelIndex != -1 {
|
|
accountName = accountName[:strudelIndex]
|
|
}
|
|
|
|
acct, err := am.LoadAccount(accountName)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if acct.Credentials.SCRAMCreds.Iters == 0 {
|
|
err = errNoSCRAMCredentials
|
|
return
|
|
}
|
|
creds.Salt = string(acct.Credentials.SCRAMCreds.Salt)
|
|
creds.Iters = acct.Credentials.SCRAMCreds.Iters
|
|
creds.StoredKey = acct.Credentials.SCRAMCreds.StoredKey
|
|
creds.ServerKey = acct.Credentials.SCRAMCreds.ServerKey
|
|
return
|
|
}
|
|
|
|
func (ac *AccountCredentials) AddCertfp(certfp string) (err error) {
|
|
// XXX we require that certfp is already normalized (rather than normalize here
|
|
// and pass back the normalized version as an additional return parameter);
|
|
// this is just a final sanity check:
|
|
if len(certfp) != 64 {
|
|
return utils.ErrInvalidCertfp
|
|
}
|
|
|
|
for _, current := range ac.Certfps {
|
|
if certfp == current {
|
|
return errNoop
|
|
}
|
|
}
|
|
|
|
if maxCertfpsPerAccount <= len(ac.Certfps) {
|
|
return errLimitExceeded
|
|
}
|
|
|
|
ac.Certfps = append(ac.Certfps, certfp)
|
|
return nil
|
|
}
|
|
|
|
func (ac *AccountCredentials) RemoveCertfp(certfp string) (err error) {
|
|
found := false
|
|
newList := make([]string, 0, len(ac.Certfps))
|
|
for _, current := range ac.Certfps {
|
|
if current == certfp {
|
|
found = true
|
|
} else {
|
|
newList = append(newList, current)
|
|
}
|
|
}
|
|
if !found {
|
|
// this is important because it prevents you from deleting someone else's
|
|
// fingerprint record
|
|
return errNoop
|
|
}
|
|
ac.Certfps = newList
|
|
return nil
|
|
}
|
|
|
|
type MulticlientAllowedSetting int
|
|
|
|
const (
|
|
MulticlientAllowedServerDefault MulticlientAllowedSetting = iota
|
|
MulticlientDisallowedByUser
|
|
MulticlientAllowedByUser
|
|
)
|
|
|
|
// controls whether/when clients without event-playback support see fake
|
|
// PRIVMSGs for JOINs
|
|
type ReplayJoinsSetting uint
|
|
|
|
const (
|
|
ReplayJoinsCommandsOnly = iota // replay in HISTORY or CHATHISTORY output
|
|
ReplayJoinsAlways // replay in HISTORY, CHATHISTORY, or autoreplay
|
|
ReplayJoinsNever // never replay
|
|
)
|
|
|
|
func replayJoinsSettingFromString(str string) (result ReplayJoinsSetting, err error) {
|
|
switch strings.ToLower(str) {
|
|
case "commands-only":
|
|
result = ReplayJoinsCommandsOnly
|
|
case "always":
|
|
result = ReplayJoinsAlways
|
|
case "never":
|
|
result = ReplayJoinsNever
|
|
default:
|
|
err = errInvalidParams
|
|
}
|
|
return
|
|
}
|
|
|
|
// XXX: AllowBouncer cannot be renamed AllowMulticlient because it is stored in
|
|
// persistent JSON blobs in the database
|
|
type AccountSettings struct {
|
|
AutoreplayLines *int
|
|
NickEnforcement NickEnforcementMethod
|
|
AllowBouncer MulticlientAllowedSetting
|
|
ReplayJoins ReplayJoinsSetting
|
|
AlwaysOn PersistentStatus
|
|
AutoreplayMissed bool
|
|
DMHistory HistoryStatus
|
|
AutoAway PersistentStatus
|
|
Email string
|
|
}
|
|
|
|
// ClientAccount represents a user account.
|
|
type ClientAccount struct {
|
|
// Name of the account.
|
|
Name string
|
|
NameCasefolded string
|
|
RegisteredAt time.Time
|
|
Credentials AccountCredentials
|
|
Verified bool
|
|
Suspended *AccountSuspension
|
|
AdditionalNicks []string
|
|
VHost VHostInfo
|
|
Settings AccountSettings
|
|
}
|
|
|
|
// convenience for passing around raw serialized account data
|
|
type rawClientAccount struct {
|
|
Name string
|
|
RegisteredAt string
|
|
Credentials string
|
|
Verified bool
|
|
AdditionalNicks string
|
|
VHost string
|
|
Settings string
|
|
Suspended string
|
|
}
|