mirror of
https://github.com/ergochat/ergo.git
synced 2024-11-22 03:49:27 +01:00
parent
9de9fcf069
commit
f920d3b79f
@ -15,12 +15,6 @@ from collections import namedtuple
|
||||
CapDef = namedtuple("CapDef", ['identifier', 'name', 'url', 'standard'])
|
||||
|
||||
CAPDEFS = [
|
||||
CapDef(
|
||||
identifier="Acc",
|
||||
name="draft/acc",
|
||||
url="https://github.com/ircv3/ircv3-specifications/pull/276",
|
||||
standard="proposed IRCv3",
|
||||
),
|
||||
CapDef(
|
||||
identifier="AccountNotify",
|
||||
name="account-notify",
|
||||
@ -163,7 +157,7 @@ CAPDEFS = [
|
||||
identifier="EventPlayback",
|
||||
name="draft/event-playback",
|
||||
url="https://github.com/ircv3/ircv3-specifications/pull/362",
|
||||
standard="Proposed IRCv3",
|
||||
standard="proposed IRCv3",
|
||||
),
|
||||
CapDef(
|
||||
identifier="ZNCPlayback",
|
||||
@ -181,7 +175,7 @@ CAPDEFS = [
|
||||
identifier="Multiline",
|
||||
name="draft/multiline",
|
||||
url="https://github.com/ircv3/ircv3-specifications/pull/398",
|
||||
standard="Proposed IRCv3",
|
||||
standard="proposed IRCv3",
|
||||
),
|
||||
]
|
||||
|
||||
|
245
irc/accounts.go
245
irc/accounts.go
@ -36,6 +36,8 @@ const (
|
||||
|
||||
keyVHostQueueAcctToId = "vhostQueue %s"
|
||||
vhostRequestIdx = "vhostQueue"
|
||||
|
||||
maxCertfpsPerAccount = 5
|
||||
)
|
||||
|
||||
// everything about accounts is persistent; therefore, the database is the authoritative
|
||||
@ -327,7 +329,14 @@ func (am *AccountManager) Register(client *Client, account string, callbackNames
|
||||
verificationCodeKey := fmt.Sprintf(keyAccountVerificationCode, casefoldedAccount)
|
||||
certFPKey := fmt.Sprintf(keyCertToAccount, certfp)
|
||||
|
||||
credStr, err := am.serializeCredentials(passphrase, 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
|
||||
}
|
||||
@ -411,58 +420,124 @@ func validatePassphrase(passphrase string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// helper to assemble the serialized JSON for an account's credentials
|
||||
func (am *AccountManager) serializeCredentials(passphrase string, certfp string) (result string, err error) {
|
||||
var creds AccountCredentials
|
||||
creds.Version = 1
|
||||
// we need at least one of passphrase and certfp:
|
||||
if passphrase == "" && certfp == "" {
|
||||
return "", errAccountBadPassphrase
|
||||
}
|
||||
// but if we have one, it's fine if the other is missing, it just means no
|
||||
// credential of that type will be accepted.
|
||||
creds.Certificate = certfp
|
||||
if passphrase != "" {
|
||||
if validatePassphrase(passphrase) != nil {
|
||||
return "", errAccountBadPassphrase
|
||||
}
|
||||
bcryptCost := int(am.server.Config().Accounts.Registration.BcryptCost)
|
||||
creds.PassphraseHash, err = passwd.GenerateFromPassword([]byte(passphrase), bcryptCost)
|
||||
if err != nil {
|
||||
am.server.logger.Error("internal", "could not hash password", err.Error())
|
||||
return "", errAccountCreation
|
||||
}
|
||||
}
|
||||
|
||||
credText, err := json.Marshal(creds)
|
||||
if err != nil {
|
||||
am.server.logger.Error("internal", "could not marshal credentials", err.Error())
|
||||
return "", errAccountCreation
|
||||
}
|
||||
return string(credText), nil
|
||||
}
|
||||
|
||||
// changes the password for an account
|
||||
func (am *AccountManager) setPassword(account string, password string) (err error) {
|
||||
casefoldedAccount, err := CasefoldName(account)
|
||||
func (am *AccountManager) setPassword(account string, password string, hasPrivs bool) (err error) {
|
||||
cfAccount, err := CasefoldName(account)
|
||||
if err != nil {
|
||||
return err
|
||||
return errAccountDoesNotExist
|
||||
}
|
||||
act, err := am.LoadAccount(casefoldedAccount)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
credStr, err := am.serializeCredentials(password, act.Credentials.Certificate)
|
||||
err = creds.SetPassphrase(password, am.server.Config().Accounts.Registration.BcryptCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount)
|
||||
return am.server.store.Update(func(tx *buntdb.Tx) error {
|
||||
_, _, err := tx.Set(credentialsKey, credStr, nil)
|
||||
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
|
||||
}
|
||||
|
||||
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 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, casefoldedAccount string, callbackNamespace string, callbackValue string) (string, error) {
|
||||
@ -574,8 +649,8 @@ func (am *AccountManager) Verify(client *Client, account string, code string) er
|
||||
// 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)
|
||||
if creds.Certificate != "" {
|
||||
certFPKey := fmt.Sprintf(keyCertToAccount, creds.Certificate)
|
||||
for _, cert := range creds.Certfps {
|
||||
certFPKey := fmt.Sprintf(keyCertToAccount, cert)
|
||||
tx.Set(certFPKey, casefoldedAccount, nil)
|
||||
}
|
||||
|
||||
@ -906,14 +981,16 @@ func (am *AccountManager) Unregister(account string) error {
|
||||
|
||||
if err == nil {
|
||||
var creds AccountCredentials
|
||||
if err = json.Unmarshal([]byte(credText), &creds); err == nil && creds.Certificate != "" {
|
||||
certFPKey := fmt.Sprintf(keyCertToAccount, creds.Certificate)
|
||||
am.server.store.Update(func(tx *buntdb.Tx) error {
|
||||
if account, err := tx.Get(certFPKey); err == nil && account == casefoldedAccount {
|
||||
tx.Delete(certFPKey)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1326,7 +1403,73 @@ type AccountCredentials struct {
|
||||
Version uint
|
||||
PassphraseSalt []byte // legacy field, not used by v1 and later
|
||||
PassphraseHash []byte
|
||||
Certificate string // fingerprint
|
||||
Certfps []string
|
||||
}
|
||||
|
||||
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
|
||||
return nil
|
||||
}
|
||||
|
||||
if validatePassphrase(passphrase) != nil {
|
||||
return errAccountBadPassphrase
|
||||
}
|
||||
|
||||
ac.PassphraseHash, err = passwd.GenerateFromPassword([]byte(passphrase), int(bcryptCost))
|
||||
if err != nil {
|
||||
return errAccountBadPassphrase
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ac *AccountCredentials) AddCertfp(certfp string) (err error) {
|
||||
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 BouncerAllowedSetting int
|
||||
|
@ -7,7 +7,7 @@ package caps
|
||||
|
||||
const (
|
||||
// number of recognized capabilities:
|
||||
numCapabs = 28
|
||||
numCapabs = 27
|
||||
// length of the uint64 array that represents the bitset:
|
||||
bitsetLen = 1
|
||||
)
|
||||
@ -37,11 +37,7 @@ const (
|
||||
// https://ircv3.net/specs/extensions/chghost-3.2.html
|
||||
ChgHost Capability = iota
|
||||
|
||||
// Acc is the proposed IRCv3 capability named "draft/acc":
|
||||
// https://github.com/ircv3/ircv3-specifications/pull/276
|
||||
Acc Capability = iota
|
||||
|
||||
// EventPlayback is the Proposed IRCv3 capability named "draft/event-playback":
|
||||
// EventPlayback is the proposed IRCv3 capability named "draft/event-playback":
|
||||
// https://github.com/ircv3/ircv3-specifications/pull/362
|
||||
EventPlayback Capability = iota
|
||||
|
||||
@ -53,7 +49,7 @@ const (
|
||||
// https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6
|
||||
Languages Capability = iota
|
||||
|
||||
// Multiline is the Proposed IRCv3 capability named "draft/multiline":
|
||||
// Multiline is the proposed IRCv3 capability named "draft/multiline":
|
||||
// https://github.com/ircv3/ircv3-specifications/pull/398
|
||||
Multiline Capability = iota
|
||||
|
||||
@ -135,7 +131,6 @@ var (
|
||||
"batch",
|
||||
"cap-notify",
|
||||
"chghost",
|
||||
"draft/acc",
|
||||
"draft/event-playback",
|
||||
"draft/labeled-response-0.2",
|
||||
"draft/languages",
|
||||
|
@ -134,6 +134,7 @@ set using PURGE.`,
|
||||
|
||||
INFO displays info about a registered channel.`,
|
||||
helpShort: `$bINFO$b displays info about a registered channel.`,
|
||||
enabled: chanregEnabled,
|
||||
minParams: 1,
|
||||
},
|
||||
}
|
||||
|
@ -1419,10 +1419,11 @@ func (client *Client) attemptAutoOper(session *Session) {
|
||||
return
|
||||
}
|
||||
for _, oper := range client.server.Config().operators {
|
||||
if oper.Auto && oper.Pass == nil && utils.CertfpsMatch(oper.Fingerprint, client.certfp) {
|
||||
if oper.Auto && oper.Pass == nil && oper.Fingerprint != "" && oper.Fingerprint == client.certfp {
|
||||
rb := NewResponseBuffer(session)
|
||||
applyOper(client, oper, rb)
|
||||
rb.Send(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -80,11 +80,6 @@ var Commands map[string]Command
|
||||
|
||||
func init() {
|
||||
Commands = map[string]Command{
|
||||
"ACC": {
|
||||
handler: accHandler,
|
||||
usablePreReg: true,
|
||||
minParams: 1,
|
||||
},
|
||||
"AMBIANCE": {
|
||||
handler: sceneHandler,
|
||||
minParams: 2,
|
||||
|
@ -511,7 +511,12 @@ func (conf *Config) Operators(oc map[string]*OperClass) (map[string]*Oper, error
|
||||
return nil, fmt.Errorf("Oper %s has an invalid password hash: %s", oper.Name, err.Error())
|
||||
}
|
||||
}
|
||||
oper.Fingerprint = opConf.Fingerprint
|
||||
if opConf.Fingerprint != "" {
|
||||
oper.Fingerprint, err = utils.NormalizeCertfp(opConf.Fingerprint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Oper %s has an invalid fingerprint: %s", oper.Name, err.Error())
|
||||
}
|
||||
}
|
||||
oper.Auto = opConf.Auto
|
||||
|
||||
if oper.Pass == nil && oper.Fingerprint == "" {
|
||||
|
@ -22,7 +22,7 @@ const (
|
||||
// 'version' of the database schema
|
||||
keySchemaVersion = "db.version"
|
||||
// latest schema of the db
|
||||
latestDbSchema = "8"
|
||||
latestDbSchema = "9"
|
||||
)
|
||||
|
||||
type SchemaChanger func(*Config, *buntdb.Tx) error
|
||||
@ -553,6 +553,57 @@ func schemaChangeV7ToV8(config *Config, tx *buntdb.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type accountCredsLegacyV8 struct {
|
||||
Version uint
|
||||
PassphraseSalt []byte // legacy field, not used by v1 and later
|
||||
PassphraseHash []byte
|
||||
Certificate string
|
||||
}
|
||||
|
||||
type accountCredsLegacyV9 struct {
|
||||
Version uint
|
||||
PassphraseSalt []byte // legacy field, not used by v1 and later
|
||||
PassphraseHash []byte
|
||||
Certfps []string
|
||||
}
|
||||
|
||||
// #530: support multiple client certificate fingerprints
|
||||
func schemaChangeV8ToV9(config *Config, tx *buntdb.Tx) error {
|
||||
prefix := "account.credentials "
|
||||
var accounts, blobs []string
|
||||
tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool {
|
||||
var legacy accountCredsLegacyV8
|
||||
var current accountCredsLegacyV9
|
||||
if !strings.HasPrefix(key, prefix) {
|
||||
return false
|
||||
}
|
||||
account := strings.TrimPrefix(key, prefix)
|
||||
err := json.Unmarshal([]byte(value), &legacy)
|
||||
if err != nil {
|
||||
log.Printf("corrupt record for %s: %v\n", account, err)
|
||||
return true
|
||||
}
|
||||
current.Version = legacy.Version
|
||||
current.PassphraseSalt = legacy.PassphraseSalt // ugh can't get rid of this
|
||||
current.PassphraseHash = legacy.PassphraseHash
|
||||
if legacy.Certificate != "" {
|
||||
current.Certfps = []string{legacy.Certificate}
|
||||
}
|
||||
blob, err := json.Marshal(current)
|
||||
if err != nil {
|
||||
log.Printf("could not marshal record for %s: %v\n", account, err)
|
||||
return true
|
||||
}
|
||||
accounts = append(accounts, account)
|
||||
blobs = append(blobs, string(blob))
|
||||
return true
|
||||
})
|
||||
for i, account := range accounts {
|
||||
tx.Set(prefix+account, blobs[i], nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
allChanges := []SchemaChange{
|
||||
{
|
||||
@ -590,6 +641,11 @@ func init() {
|
||||
TargetVersion: "8",
|
||||
Changer: schemaChangeV7ToV8,
|
||||
},
|
||||
{
|
||||
InitialVersion: "8",
|
||||
TargetVersion: "9",
|
||||
Changer: schemaChangeV8ToV9,
|
||||
},
|
||||
}
|
||||
|
||||
// build the index
|
||||
|
@ -50,6 +50,10 @@ var (
|
||||
errBanned = errors.New("IP or nickmask banned")
|
||||
errInvalidParams = utils.ErrInvalidParams
|
||||
errNoVhost = errors.New(`You do not have an approved vhost`)
|
||||
errLimitExceeded = errors.New("Limit exceeded")
|
||||
errNoop = errors.New("Action was a no-op")
|
||||
errCASFailed = errors.New("Compare-and-swap update of database value failed")
|
||||
errEmptyCredentials = errors.New("No more credentials are approved")
|
||||
)
|
||||
|
||||
// Socket Errors
|
||||
|
@ -39,13 +39,17 @@ type webircConfig struct {
|
||||
// Populate fills out our password or fingerprint.
|
||||
func (wc *webircConfig) Populate() (err error) {
|
||||
if wc.Fingerprint == "" && wc.PasswordString == "" {
|
||||
return ErrNoFingerprintOrPassword
|
||||
err = ErrNoFingerprintOrPassword
|
||||
}
|
||||
|
||||
if wc.PasswordString != "" {
|
||||
if err == nil && wc.PasswordString != "" {
|
||||
wc.Password, err = decodeLegacyPasswordHash(wc.PasswordString)
|
||||
}
|
||||
|
||||
if err == nil && wc.Fingerprint != "" {
|
||||
wc.Fingerprint, err = utils.NormalizeCertfp(wc.Fingerprint)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
wc.allowedNets, err = utils.ParseNetList(wc.Hosts)
|
||||
}
|
||||
|
192
irc/handlers.go
192
irc/handlers.go
@ -31,52 +31,6 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// ACC [LS|REGISTER|VERIFY] ...
|
||||
func accHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool {
|
||||
subcommand := strings.ToLower(msg.Params[0])
|
||||
|
||||
if subcommand == "ls" {
|
||||
config := server.Config().Accounts
|
||||
|
||||
rb.Add(nil, server.name, "ACC", "LS", "SUBCOMMANDS", "LS REGISTER VERIFY")
|
||||
|
||||
// this list is sorted by the config loader, yay
|
||||
rb.Add(nil, server.name, "ACC", "LS", "CALLBACKS", strings.Join(config.Registration.EnabledCallbacks, " "))
|
||||
|
||||
rb.Add(nil, server.name, "ACC", "LS", "CREDTYPES", "passphrase certfp")
|
||||
|
||||
flags := []string{"nospaces"}
|
||||
if config.NickReservation.Enabled {
|
||||
flags = append(flags, "regnick")
|
||||
}
|
||||
sort.Strings(flags)
|
||||
rb.Add(nil, server.name, "ACC", "LS", "FLAGS", strings.Join(flags, " "))
|
||||
return false
|
||||
}
|
||||
|
||||
// disallow account stuff before connection registration has completed, for now
|
||||
if !client.Registered() {
|
||||
client.Send(nil, server.name, ERR_NOTREGISTERED, "*", client.t("You need to register before you can use that command"))
|
||||
return false
|
||||
}
|
||||
|
||||
// make sure reg is enabled
|
||||
if !server.AccountConfig().Registration.Enabled {
|
||||
rb.Add(nil, server.name, "FAIL", "ACC", "REG_UNAVAILABLE", client.t("Account registration is disabled"))
|
||||
return false
|
||||
}
|
||||
|
||||
if subcommand == "register" {
|
||||
return accRegisterHandler(server, client, msg, rb)
|
||||
} else if subcommand == "verify" {
|
||||
return accVerifyHandler(server, client, msg, rb)
|
||||
} else {
|
||||
rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.nick, "ACC", msg.Params[0], client.t("Unknown subcommand"))
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// helper function to parse ACC callbacks, e.g., mailto:person@example.com, tel:16505551234
|
||||
func parseCallback(spec string, config *AccountConfig) (callbackNamespace string, callbackValue string) {
|
||||
callback := strings.ToLower(spec)
|
||||
@ -103,113 +57,6 @@ func parseCallback(spec string, config *AccountConfig) (callbackNamespace string
|
||||
return
|
||||
}
|
||||
|
||||
// ACC REGISTER <accountname> [callback_namespace:]<callback> [cred_type] :<credential>
|
||||
func accRegisterHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool {
|
||||
nick := client.Nick()
|
||||
|
||||
if len(msg.Params) < 4 {
|
||||
rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, nick, msg.Command, client.t("Not enough parameters"))
|
||||
return false
|
||||
}
|
||||
|
||||
account := msg.Params[1]
|
||||
|
||||
// check for account name of *
|
||||
if account == "*" {
|
||||
account = nick
|
||||
} else {
|
||||
if server.Config().Accounts.NickReservation.Enabled {
|
||||
rb.Add(nil, server.name, "FAIL", "ACC", "REG_MUST_USE_REGNICK", account, client.t("Must register with current nickname instead of separate account name"))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// clients can't reg new accounts if they're already logged in
|
||||
if client.LoggedIntoAccount() {
|
||||
rb.Add(nil, server.name, "FAIL", "ACC", "REG_UNSPECIFIED_ERROR", account, client.t("You're already logged into an account"))
|
||||
return false
|
||||
}
|
||||
|
||||
// sanitise account name
|
||||
casefoldedAccount, err := CasefoldName(account)
|
||||
if err != nil {
|
||||
rb.Add(nil, server.name, "FAIL", "ACC", "REG_INVALID_ACCOUNT_NAME", account, client.t("Account name is not valid"))
|
||||
return false
|
||||
}
|
||||
|
||||
callbackSpec := msg.Params[2]
|
||||
callbackNamespace, callbackValue := parseCallback(callbackSpec, server.AccountConfig())
|
||||
|
||||
if callbackNamespace == "" {
|
||||
rb.Add(nil, server.name, "FAIL", "ACC", "REG_INVALID_CALLBACK", account, callbackSpec, client.t("Cannot send verification code there"))
|
||||
return false
|
||||
}
|
||||
|
||||
// get credential type/value
|
||||
var credentialType, credentialValue string
|
||||
|
||||
if len(msg.Params) > 4 {
|
||||
credentialType = strings.ToLower(msg.Params[3])
|
||||
credentialValue = msg.Params[4]
|
||||
} else {
|
||||
// exactly 4 params
|
||||
credentialType = "passphrase" // default from the spec
|
||||
credentialValue = msg.Params[3]
|
||||
}
|
||||
|
||||
// ensure the credential type is valid
|
||||
var credentialValid bool
|
||||
for _, name := range server.AccountConfig().Registration.EnabledCredentialTypes {
|
||||
if credentialType == name {
|
||||
credentialValid = true
|
||||
}
|
||||
}
|
||||
if credentialType == "certfp" && client.certfp == "" {
|
||||
rb.Add(nil, server.name, "FAIL", "ACC", "REG_INVALID_CREDENTIAL", account, client.t("You must connect with a TLS client certificate to use certfp"))
|
||||
return false
|
||||
}
|
||||
|
||||
if !credentialValid {
|
||||
rb.Add(nil, server.name, "FAIL", "ACC", "REG_INVALID_CRED_TYPE", account, credentialType, client.t("Credential type is not supported"))
|
||||
return false
|
||||
}
|
||||
|
||||
var passphrase, certfp string
|
||||
if credentialType == "certfp" {
|
||||
certfp = client.certfp
|
||||
} else if credentialType == "passphrase" {
|
||||
passphrase = credentialValue
|
||||
}
|
||||
|
||||
throttled, remainingTime := client.loginThrottle.Touch()
|
||||
if throttled {
|
||||
rb.Add(nil, server.name, "FAIL", "ACC", "REG_UNSPECIFIED_ERROR", account, fmt.Sprintf(client.t("Please wait at least %v and try again"), remainingTime))
|
||||
return false
|
||||
}
|
||||
|
||||
err = server.accounts.Register(client, account, callbackNamespace, callbackValue, passphrase, certfp)
|
||||
if err != nil {
|
||||
msg, code := registrationErrorToMessageAndCode(err)
|
||||
rb.Add(nil, server.name, "FAIL", "ACC", code, account, client.t(msg))
|
||||
return false
|
||||
}
|
||||
|
||||
// automatically complete registration
|
||||
if callbackNamespace == "*" {
|
||||
err := server.accounts.Verify(client, casefoldedAccount, "")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
sendSuccessfulRegResponse(client, rb, false)
|
||||
} else {
|
||||
messageTemplate := client.t("Account created, pending verification; verification code has been sent to %s")
|
||||
message := fmt.Sprintf(messageTemplate, fmt.Sprintf("%s:%s", callbackNamespace, callbackValue))
|
||||
rb.Add(nil, server.name, RPL_REG_VERIFICATION_REQUIRED, nick, casefoldedAccount, message)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func registrationErrorToMessageAndCode(err error) (message, code string) {
|
||||
// default responses: let's be risk-averse about displaying internal errors
|
||||
// to the clients, especially for something as sensitive as accounts
|
||||
@ -263,41 +110,6 @@ func sendSuccessfulAccountAuth(client *Client, rb *ResponseBuffer, forNS, forSAS
|
||||
client.server.logger.Info("accounts", "client", details.nick, "logged into account", details.accountName)
|
||||
}
|
||||
|
||||
// ACC VERIFY <accountname> <auth_code>
|
||||
func accVerifyHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool {
|
||||
account := strings.TrimSpace(msg.Params[1])
|
||||
|
||||
if len(msg.Params) < 3 {
|
||||
rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.Nick(), msg.Command, client.t("Not enough parameters"))
|
||||
return false
|
||||
}
|
||||
|
||||
err := server.accounts.Verify(client, account, msg.Params[2])
|
||||
|
||||
var code string
|
||||
var message string
|
||||
|
||||
if err == errAccountVerificationInvalidCode {
|
||||
code = "ACCOUNT_INVALID_VERIFY_CODE"
|
||||
message = err.Error()
|
||||
} else if err == errAccountAlreadyVerified {
|
||||
code = "ACCOUNT_ALREADY_VERIFIED"
|
||||
message = err.Error()
|
||||
} else if err != nil {
|
||||
code = "VERIFY_UNSPECIFIED_ERROR"
|
||||
message = errAccountVerificationFailed.Error()
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
rb.Add(nil, server.name, RPL_VERIFY_SUCCESS, client.Nick(), account, client.t("Account verification successful"))
|
||||
sendSuccessfulAccountAuth(client, rb, false, false)
|
||||
} else {
|
||||
rb.Add(nil, server.name, "FAIL", "ACC", code, account, client.t(message))
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// AUTHENTICATE [<mechanism>|<data>|*]
|
||||
func authenticateHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool {
|
||||
config := server.Config()
|
||||
@ -2300,7 +2112,7 @@ func operHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp
|
||||
oper := server.GetOperator(msg.Params[0])
|
||||
if oper != nil {
|
||||
if oper.Fingerprint != "" {
|
||||
if utils.CertfpsMatch(oper.Fingerprint, client.certfp) {
|
||||
if oper.Fingerprint == client.certfp {
|
||||
checkPassed = true
|
||||
} else {
|
||||
checkFailed = true
|
||||
@ -2772,7 +2584,7 @@ func webircHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re
|
||||
if 0 < len(info.Password) && bcrypt.CompareHashAndPassword(info.Password, givenPassword) != nil {
|
||||
continue
|
||||
}
|
||||
if 0 < len(info.Fingerprint) && !utils.CertfpsMatch(info.Fingerprint, client.certfp) {
|
||||
if info.Fingerprint != "" && info.Fingerprint != client.certfp {
|
||||
continue
|
||||
}
|
||||
|
||||
|
@ -63,7 +63,7 @@ func handleLegacyPasswordV0(server *Server, account string, credentials AccountC
|
||||
}
|
||||
|
||||
// upgrade credentials
|
||||
err = server.accounts.setPassword(account, passphrase)
|
||||
err = server.accounts.setPassword(account, passphrase, true)
|
||||
if err != nil {
|
||||
server.logger.Error("internal", fmt.Sprintf("could not upgrade user password: %v", err))
|
||||
}
|
||||
|
159
irc/nickserv.go
159
irc/nickserv.go
@ -12,6 +12,7 @@ import (
|
||||
"github.com/goshuirc/irc-go/ircfmt"
|
||||
|
||||
"github.com/oragono/oragono/irc/modes"
|
||||
"github.com/oragono/oragono/irc/passwd"
|
||||
"github.com/oragono/oragono/irc/sno"
|
||||
"github.com/oragono/oragono/irc/utils"
|
||||
)
|
||||
@ -72,6 +73,7 @@ entry for $bSET$b for more information.`,
|
||||
GHOST disconnects the given user from the network if they're logged in with the
|
||||
same user account, letting you reclaim your nickname.`,
|
||||
helpShort: `$bGHOST$b reclaims your nickname.`,
|
||||
enabled: servCmdRequiresAuthEnabled,
|
||||
authRequired: true,
|
||||
minParams: 1,
|
||||
},
|
||||
@ -92,6 +94,7 @@ will not be able to use it.`,
|
||||
IDENTIFY lets you login to the given username using either password auth, or
|
||||
certfp (your client certificate) if a password is not given.`,
|
||||
helpShort: `$bIDENTIFY$b lets you login to your account.`,
|
||||
enabled: servCmdRequiresAuthEnabled,
|
||||
minParams: 1,
|
||||
},
|
||||
"info": {
|
||||
@ -179,7 +182,8 @@ Or: $bPASSWD <username> <new>$b
|
||||
PASSWD lets you change your account password. You must supply your current
|
||||
password and confirm the new one by typing it twice. If you're an IRC operator
|
||||
with the correct permissions, you can use PASSWD to reset someone else's
|
||||
password by supplying their username and then the desired password.`,
|
||||
password by supplying their username and then the desired password. To
|
||||
indicate an empty password, use * instead.`,
|
||||
helpShort: `$bPASSWD$b lets you change your password.`,
|
||||
enabled: servCmdRequiresAuthEnabled,
|
||||
minParams: 2,
|
||||
@ -259,6 +263,20 @@ information on the settings and their possible values, see HELP SET.`,
|
||||
minParams: 3,
|
||||
capabs: []string{"accreg"},
|
||||
},
|
||||
"cert": {
|
||||
handler: nsCertHandler,
|
||||
help: `Syntax: $bCERT <LIST | ADD | DEL> [account] [certfp]$b
|
||||
|
||||
CERT examines or modifies the TLS certificate fingerprints that can be used to
|
||||
log into an account. Specifically, $bCERT LIST$b lists the authorized
|
||||
fingerprints, $bCERT ADD <fingerprint>$b adds a new fingerprint, and
|
||||
$bCERT DEL <fingerprint>$b removes a fingerprint. If you're an IRC operator
|
||||
with the correct permissions, you can act on another user's account, for
|
||||
example with $bCERT ADD <account> <fingerprint>$b.`,
|
||||
helpShort: `$bCERT$b controls a user account's certificate fingerprints`,
|
||||
enabled: servCmdRequiresAuthEnabled,
|
||||
minParams: 1,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@ -548,6 +566,11 @@ func nsIdentifyHandler(server *Server, client *Client, command string, params []
|
||||
}
|
||||
|
||||
func nsInfoHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
|
||||
if !server.Config().Accounts.AuthenticationEnabled && !client.HasRoleCapabs("accreg") {
|
||||
nsNotice(rb, client.t("This command has been disabled by the server administrators"))
|
||||
return
|
||||
}
|
||||
|
||||
var accountName string
|
||||
if len(params) > 0 {
|
||||
nick := params[0]
|
||||
@ -659,6 +682,9 @@ func nsRegisterHandler(server *Server, client *Client, command string, params []
|
||||
|
||||
func nsSaregisterHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
|
||||
account, passphrase := params[0], params[1]
|
||||
if passphrase == "*" {
|
||||
passphrase = ""
|
||||
}
|
||||
err := server.accounts.Register(nil, account, "admin", "", passphrase, "")
|
||||
if err == nil {
|
||||
err = server.accounts.Verify(nil, account, "")
|
||||
@ -753,30 +779,40 @@ func nsPasswdHandler(server *Server, client *Client, command string, params []st
|
||||
var errorMessage string
|
||||
|
||||
hasPrivs := client.HasRoleCapabs("accreg")
|
||||
if !hasPrivs && !nsLoginThrottleCheck(client, rb) {
|
||||
return
|
||||
}
|
||||
|
||||
switch len(params) {
|
||||
case 2:
|
||||
if !hasPrivs {
|
||||
errorMessage = "Insufficient privileges"
|
||||
errorMessage = `Insufficient privileges`
|
||||
} else {
|
||||
target, newPassword = params[0], params[1]
|
||||
if newPassword == "*" {
|
||||
newPassword = ""
|
||||
}
|
||||
}
|
||||
case 3:
|
||||
target = client.Account()
|
||||
if target == "" {
|
||||
errorMessage = "You're not logged into an account"
|
||||
errorMessage = `You're not logged into an account`
|
||||
} else if params[1] != params[2] {
|
||||
errorMessage = "Passwords do not match"
|
||||
errorMessage = `Passwords do not match`
|
||||
} else {
|
||||
// check that they correctly supplied the preexisting password
|
||||
_, err := server.accounts.checkPassphrase(target, params[0])
|
||||
if !nsLoginThrottleCheck(client, rb) {
|
||||
return
|
||||
}
|
||||
accountData, err := server.accounts.LoadAccount(target)
|
||||
if err != nil {
|
||||
errorMessage = "Password incorrect"
|
||||
errorMessage = `You're not logged into an account`
|
||||
} else {
|
||||
newPassword = params[1]
|
||||
hash := accountData.Credentials.PassphraseHash
|
||||
if hash != nil && passwd.CompareHashAndPassword(hash, []byte(params[0])) != nil {
|
||||
errorMessage = `Password incorrect`
|
||||
} else {
|
||||
newPassword = params[1]
|
||||
if newPassword == "*" {
|
||||
newPassword = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
@ -788,10 +824,15 @@ func nsPasswdHandler(server *Server, client *Client, command string, params []st
|
||||
return
|
||||
}
|
||||
|
||||
err := server.accounts.setPassword(target, newPassword)
|
||||
if err == nil {
|
||||
err := server.accounts.setPassword(target, newPassword, hasPrivs)
|
||||
switch err {
|
||||
case nil:
|
||||
nsNotice(rb, client.t("Password changed"))
|
||||
} else {
|
||||
case errEmptyCredentials:
|
||||
nsNotice(rb, client.t("You can't delete your password unless you add a certificate fingerprint"))
|
||||
case errCASFailed:
|
||||
nsNotice(rb, client.t("Try again later"))
|
||||
default:
|
||||
server.logger.Error("internal", "could not upgrade user password:", err.Error())
|
||||
nsNotice(rb, client.t("Password could not be changed due to server error"))
|
||||
}
|
||||
@ -837,3 +878,93 @@ func nsSessionsHandler(server *Server, client *Client, command string, params []
|
||||
nsNotice(rb, fmt.Sprintf(client.t("Last active: %s"), session.atime.Format(time.RFC1123)))
|
||||
}
|
||||
}
|
||||
|
||||
func nsCertHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
|
||||
verb := strings.ToLower(params[0])
|
||||
params = params[1:]
|
||||
var target, certfp string
|
||||
|
||||
switch verb {
|
||||
case "list":
|
||||
if 1 <= len(params) {
|
||||
target = params[0]
|
||||
}
|
||||
case "add", "del":
|
||||
if 2 <= len(params) {
|
||||
target, certfp = params[0], params[1]
|
||||
} else if len(params) == 1 {
|
||||
certfp = params[0]
|
||||
} else {
|
||||
nsNotice(rb, client.t("Invalid parameters"))
|
||||
return
|
||||
}
|
||||
default:
|
||||
nsNotice(rb, client.t("Invalid parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
hasPrivs := client.HasRoleCapabs("accreg")
|
||||
if target != "" && !hasPrivs {
|
||||
nsNotice(rb, client.t("Insufficient privileges"))
|
||||
return
|
||||
} else if target == "" {
|
||||
target = client.Account()
|
||||
if target == "" {
|
||||
nsNotice(rb, client.t("You're not logged into an account"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
switch verb {
|
||||
case "list":
|
||||
accountData, err := server.accounts.LoadAccount(target)
|
||||
if err == errAccountDoesNotExist {
|
||||
nsNotice(rb, client.t("Account does not exist"))
|
||||
return
|
||||
} else if err != nil {
|
||||
nsNotice(rb, client.t("An error occurred"))
|
||||
return
|
||||
}
|
||||
certfps := accountData.Credentials.Certfps
|
||||
nsNotice(rb, fmt.Sprintf(client.t("There are %d certificate fingerprint(s) authorized for account %s."), len(certfps), accountData.Name))
|
||||
for i, certfp := range certfps {
|
||||
nsNotice(rb, fmt.Sprintf("%d: %s", i+1, certfp))
|
||||
}
|
||||
return
|
||||
case "add":
|
||||
err = server.accounts.addRemoveCertfp(target, certfp, true, hasPrivs)
|
||||
case "del":
|
||||
err = server.accounts.addRemoveCertfp(target, certfp, false, hasPrivs)
|
||||
}
|
||||
|
||||
switch err {
|
||||
case nil:
|
||||
if verb == "add" {
|
||||
nsNotice(rb, client.t("Certificate fingerprint successfully added"))
|
||||
} else {
|
||||
nsNotice(rb, client.t("Certificate fingerprint successfully removed"))
|
||||
}
|
||||
case errNoop:
|
||||
if verb == "add" {
|
||||
nsNotice(rb, client.t("That certificate fingerprint was already authorized"))
|
||||
} else {
|
||||
nsNotice(rb, client.t("Certificate fingerprint not found"))
|
||||
}
|
||||
case errAccountDoesNotExist:
|
||||
nsNotice(rb, client.t("Account does not exist"))
|
||||
case errLimitExceeded:
|
||||
nsNotice(rb, client.t("You already have too many certificate fingerprints"))
|
||||
case utils.ErrInvalidCertfp:
|
||||
nsNotice(rb, client.t("Invalid certificate fingerprint"))
|
||||
case errCertfpAlreadyExists:
|
||||
nsNotice(rb, client.t("That certificate fingerprint is already associated with another account"))
|
||||
case errEmptyCredentials:
|
||||
nsNotice(rb, client.t("You can't remove all your certificate fingerprints unless you add a password"))
|
||||
case errCASFailed:
|
||||
nsNotice(rb, client.t("Try again later"))
|
||||
default:
|
||||
server.logger.Error("internal", "could not modify certificates:", err.Error())
|
||||
nsNotice(rb, client.t("An error occurred"))
|
||||
}
|
||||
}
|
||||
|
@ -8,12 +8,16 @@ import (
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
// slingamn's own private b32 alphabet, removing 1, l, o, and 0
|
||||
B32Encoder = base32.NewEncoding("abcdefghijkmnpqrstuvwxyz23456789").WithPadding(base32.NoPadding)
|
||||
|
||||
ErrInvalidCertfp = errors.New("Invalid certfp")
|
||||
)
|
||||
|
||||
const (
|
||||
@ -70,14 +74,12 @@ func GenerateSecretKey() string {
|
||||
return base64.RawURLEncoding.EncodeToString(buf[:])
|
||||
}
|
||||
|
||||
func normalizeCertfp(certfp string) string {
|
||||
return strings.ToLower(strings.Replace(certfp, ":", "", -1))
|
||||
}
|
||||
|
||||
// Convenience to compare certfps as returned by different tools, e.g., openssl vs. oragono
|
||||
func CertfpsMatch(storedCertfp, suppliedCertfp string) bool {
|
||||
if storedCertfp == "" {
|
||||
return false
|
||||
// Normalize openssl-formatted certfp's to oragono's format
|
||||
func NormalizeCertfp(certfp string) (result string, err error) {
|
||||
result = strings.ToLower(strings.Replace(certfp, ":", "", -1))
|
||||
decoded, err := hex.DecodeString(result)
|
||||
if err != nil || len(decoded) != 32 {
|
||||
return "", ErrInvalidCertfp
|
||||
}
|
||||
return normalizeCertfp(storedCertfp) == normalizeCertfp(suppliedCertfp)
|
||||
return
|
||||
}
|
||||
|
@ -85,17 +85,23 @@ func BenchmarkMungeSecretToken(b *testing.B) {
|
||||
func TestCertfpComparisons(t *testing.T) {
|
||||
opensslFP := "3D:6B:11:BF:B4:05:C3:F8:4B:38:CD:30:38:FB:EC:01:71:D5:03:54:79:04:07:88:4C:A5:5D:23:41:85:66:C9"
|
||||
oragonoFP := "3d6b11bfb405c3f84b38cd3038fbec0171d50354790407884ca55d23418566c9"
|
||||
badFP := "3d6b11bfb405c3f84b38cd3038fbec0171d50354790407884ca55d23418566c8"
|
||||
if !CertfpsMatch(opensslFP, oragonoFP) {
|
||||
t.Error("these certs should match")
|
||||
badFP := "3d6b11bfb405c3f84b38cd3038fbec0171d50354790407884ca55d23418566c"
|
||||
badFP2 := "*"
|
||||
|
||||
normalizedOpenssl, err := NormalizeCertfp(opensslFP)
|
||||
assertEqual(err, nil, t)
|
||||
assertEqual(normalizedOpenssl, oragonoFP, t)
|
||||
|
||||
normalizedOragono, err := NormalizeCertfp(oragonoFP)
|
||||
assertEqual(err, nil, t)
|
||||
assertEqual(normalizedOragono, oragonoFP, t)
|
||||
|
||||
_, err = NormalizeCertfp(badFP)
|
||||
if err == nil {
|
||||
t.Errorf("corrupt fp should fail normalization")
|
||||
}
|
||||
if !CertfpsMatch(oragonoFP, opensslFP) {
|
||||
t.Error("these certs should match")
|
||||
}
|
||||
if CertfpsMatch("", "") {
|
||||
t.Error("empty stored certfp should not match empty provided certfp")
|
||||
}
|
||||
if CertfpsMatch(opensslFP, badFP) {
|
||||
t.Error("these certs should not match")
|
||||
_, err = NormalizeCertfp(badFP2)
|
||||
if err == nil {
|
||||
t.Errorf("corrupt fp should fail normalization")
|
||||
}
|
||||
}
|
||||
|
@ -244,6 +244,9 @@ server:
|
||||
|
||||
# account options
|
||||
accounts:
|
||||
# is account authentication enabled, i.e., can users log into existing accounts?
|
||||
authentication-enabled: true
|
||||
|
||||
# account registration
|
||||
registration:
|
||||
# can users register new accounts for themselves? if this is false, operators with
|
||||
@ -271,9 +274,6 @@ accounts:
|
||||
# password: ""
|
||||
# sender: "admin@my.network"
|
||||
|
||||
# is account authentication enabled?
|
||||
authentication-enabled: true
|
||||
|
||||
# throttle account login attempts (to prevent either password guessing, or DoS
|
||||
# attacks on the server aimed at forcing repeated expensive bcrypt computations)
|
||||
login-throttling:
|
||||
|
Loading…
Reference in New Issue
Block a user