From 1f6afa31d611729573936d0e00e9c340402f0326 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Tue, 27 Oct 2020 11:24:17 -0400 Subject: [PATCH] fix #1274 Enhancements to NS SUSPEND, including stored metadata and the ability to list suspensions --- irc/accounts.go | 109 ++++++++++++++++++++++++++++++++++++++++++------ irc/database.go | 2 +- irc/errors.go | 1 + irc/handlers.go | 2 +- irc/nickserv.go | 109 +++++++++++++++++++++++++++++++++++++++++------- 5 files changed, 194 insertions(+), 29 deletions(-) diff --git a/irc/accounts.go b/irc/accounts.go index f5a06d15..4e782130 100644 --- a/irc/accounts.go +++ b/irc/accounts.go @@ -40,8 +40,9 @@ const ( keyAccountChannels = "account.channels %s" // channels registered to the account keyAccountJoinedChannels = "account.joinedto %s" // channels a persistent client has joined 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 + 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 maxCertfpsPerAccount = 5 ) @@ -117,7 +118,7 @@ func (am *AccountManager) createAlwaysOnClients(config *Config) { for _, accountName := range accounts { account, err := am.LoadAccount(accountName) - if err == nil && account.Verified && + if err == nil && (account.Verified && account.Suspended == nil) && persistenceEnabled(config.Accounts.Multiclient.AlwaysOn, account.Settings.AlwaysOn) { am.server.AddAlwaysOnClient( account, @@ -1035,6 +1036,9 @@ func (am *AccountManager) checkPassphrase(accountName, passphrase string) (accou if !account.Verified { err = errAccountUnverified return + } else if account.Suspended != nil { + err = errAccountSuspended + return } switch account.Credentials.Version { @@ -1230,6 +1234,15 @@ func (am *AccountManager) deserializeRawAccount(raw rawClientAccount, cfName str 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 } @@ -1243,6 +1256,7 @@ func (am *AccountManager) loadRawAccount(tx *buntdb.Tx, casefoldedAccount string 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 { @@ -1257,6 +1271,7 @@ func (am *AccountManager) loadRawAccount(tx *buntdb.Tx, casefoldedAccount string 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 @@ -1265,20 +1280,44 @@ func (am *AccountManager) loadRawAccount(tx *buntdb.Tx, casefoldedAccount string return } -func (am *AccountManager) Suspend(accountName string) (err error) { +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) - verifiedKey := fmt.Sprintf(keyAccountVerified, 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.Delete(verifiedKey) + _, _, err = tx.Set(suspensionKey, string(suspensionStr), setOptions) return err }) @@ -1293,7 +1332,13 @@ func (am *AccountManager) Suspend(accountName string) (err error) { delete(am.accountToClients, account) am.Unlock() - am.killClients(clients) + // 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 } @@ -1312,20 +1357,53 @@ func (am *AccountManager) Unsuspend(account string) (err error) { } existsKey := fmt.Sprintf(keyAccountExists, cfaccount) - verifiedKey := fmt.Sprintf(keyAccountVerified, cfaccount) + suspensionKey := fmt.Sprintf(keyAccountSuspended, account) err = am.server.store.Update(func(tx *buntdb.Tx) error { _, err := tx.Get(existsKey) if err != nil { return errAccountDoesNotExist } - tx.Set(verifiedKey, "1", nil) + _, err = tx.Delete(suspensionKey) + if err != nil { + return errNoop + } return nil }) - if err != nil { - return errAccountDoesNotExist + 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 nil + return } func (am *AccountManager) Unregister(account string, erase bool) error { @@ -1351,6 +1429,7 @@ func (am *AccountManager) Unregister(account string, erase bool) error { unregisteredKey := fmt.Sprintf(keyAccountUnregistered, casefoldedAccount) modesKey := fmt.Sprintf(keyAccountModes, casefoldedAccount) realnameKey := fmt.Sprintf(keyAccountRealname, casefoldedAccount) + suspendedKey := fmt.Sprintf(keyAccountSuspended, casefoldedAccount) var clients []*Client defer func() { @@ -1410,6 +1489,7 @@ func (am *AccountManager) Unregister(account string, erase bool) error { tx.Delete(lastSeenKey) tx.Delete(modesKey) tx.Delete(realnameKey) + tx.Delete(suspendedKey) return nil }) @@ -1491,6 +1571,9 @@ func (am *AccountManager) AuthenticateByCertificate(client *Client, certfp strin } else if !clientAccount.Verified { err = errAccountUnverified return + } else if clientAccount.Suspended != nil { + err = errAccountSuspended + return } // TODO(#1109) clean this check up? if client.registered { @@ -1882,6 +1965,7 @@ type ClientAccount struct { RegisteredAt time.Time Credentials AccountCredentials Verified bool + Suspended *AccountSuspension AdditionalNicks []string VHost VHostInfo Settings AccountSettings @@ -1897,4 +1981,5 @@ type rawClientAccount struct { AdditionalNicks string VHost string Settings string + Suspended string } diff --git a/irc/database.go b/irc/database.go index a01f73ca..e9de3f0e 100644 --- a/irc/database.go +++ b/irc/database.go @@ -857,7 +857,7 @@ func schemaChangeV16ToV17(config *Config, tx *buntdb.Tx) error { func getSchemaChange(initialVersion int) (result SchemaChange, ok bool) { for _, change := range allChanges { if initialVersion == change.InitialVersion { - return result, true + return change, true } } return diff --git a/irc/errors.go b/irc/errors.go index aa64e760..e0d0361f 100644 --- a/irc/errors.go +++ b/irc/errors.go @@ -28,6 +28,7 @@ var ( errAccountAlreadyLoggedIn = errors.New("You're already logged into an account") errAccountTooManyNicks = errors.New("Account has too many reserved nicks") errAccountUnverified = errors.New(`Account is not yet verified`) + errAccountSuspended = errors.New(`Account has been suspended`) errAccountVerificationFailed = errors.New("Account verification failed") errAccountVerificationInvalidCode = errors.New("Invalid account verification code") errAccountUpdateFailed = errors.New(`Error while updating your account information`) diff --git a/irc/handlers.go b/irc/handlers.go index 4c13f47a..1a79df9f 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -267,7 +267,7 @@ func authErrorToMessage(server *Server, err error) (msg string) { } switch err { - case errAccountDoesNotExist, errAccountUnverified, errAccountInvalidCredentials, errAuthzidAuthcidMismatch, errNickAccountMismatch: + case errAccountDoesNotExist, errAccountUnverified, errAccountInvalidCredentials, errAuthzidAuthcidMismatch, errNickAccountMismatch, errAccountSuspended: return err.Error() default: // don't expose arbitrary error messages to the user diff --git a/irc/nickserv.go b/irc/nickserv.go index 605a7a5a..9bef8fde 100644 --- a/irc/nickserv.go +++ b/irc/nickserv.go @@ -6,12 +6,14 @@ package irc import ( "fmt" "regexp" + "sort" "strconv" "strings" "time" "github.com/goshuirc/irc-go/ircfmt" + "github.com/oragono/oragono/irc/custime" "github.com/oragono/oragono/irc/passwd" "github.com/oragono/oragono/irc/sno" "github.com/oragono/oragono/irc/utils" @@ -333,19 +335,15 @@ example with $bCERT ADD $b.`, }, "suspend": { handler: nsSuspendHandler, - help: `Syntax: $bSUSPEND $b + help: `Syntax: $bSUSPEND ADD [DURATION duration] [reason]$b + $bSUSPEND DEL $b + $bSUSPEND LIST$b -SUSPEND disables an account and disconnects the associated clients.`, - helpShort: `$bSUSPEND$b disables an account and disconnects the clients`, - minParams: 1, - capabs: []string{"accreg"}, - }, - "unsuspend": { - handler: nsUnsuspendHandler, - help: `Syntax: $bUNSUSPEND $b - -UNSUSPEND reverses a previous SUSPEND, restoring access to the account.`, - helpShort: `$bUNSUSPEND$b restores access to a suspended account`, +Suspending an account disables it (preventing new logins) and disconnects +all associated clients. You can specify a time limit or a reason for +the suspension. The $bDEL$b subcommand reverses a suspension, and the $bLIST$b +command lists all current suspensions.`, + helpShort: `$bSUSPEND$b adds or removes an account suspension`, minParams: 1, capabs: []string{"accreg"}, }, @@ -810,6 +808,9 @@ func nsInfoHandler(server *Server, client *Client, command string, params []stri for _, channel := range server.accounts.ChannelsForAccount(accountName) { nsNotice(rb, fmt.Sprintf(client.t("Registered channel: %s"), channel)) } + if account.Suspended != nil { + nsNotice(rb, suspensionToString(client, *account.Suspended)) + } } func nsRegisterHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { @@ -1276,10 +1277,52 @@ func nsCertHandler(server *Server, client *Client, command string, params []stri } func nsSuspendHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { - err := server.accounts.Suspend(params[0]) + subCmd := strings.ToLower(params[0]) + params = params[1:] + switch subCmd { + case "add": + nsSuspendAddHandler(server, client, command, params, rb) + case "del", "delete", "remove": + nsSuspendRemoveHandler(server, client, command, params, rb) + case "list": + nsSuspendListHandler(server, client, command, params, rb) + default: + nsNotice(rb, client.t("Invalid parameters")) + } +} + +func nsSuspendAddHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { + if len(params) == 0 { + nsNotice(rb, client.t("Invalid parameters")) + return + } + + account := params[0] + params = params[1:] + + var duration time.Duration + if 2 <= len(params) && strings.ToLower(params[0]) == "duration" { + var err error + cDuration, err := custime.ParseDuration(params[1]) + if err != nil { + nsNotice(rb, client.t("Invalid time duration for NS SUSPEND")) + return + } + duration = time.Duration(cDuration) + params = params[2:] + } + + var reason string + if len(params) != 0 { + reason = strings.Join(params, " ") + } + + name := client.Oper().Name + + err := server.accounts.Suspend(account, duration, name, reason) switch err { case nil: - nsNotice(rb, fmt.Sprintf(client.t("Successfully suspended account %s"), params[0])) + nsNotice(rb, fmt.Sprintf(client.t("Successfully suspended account %s"), account)) case errAccountDoesNotExist: nsNotice(rb, client.t("No such account")) default: @@ -1287,14 +1330,50 @@ func nsSuspendHandler(server *Server, client *Client, command string, params []s } } -func nsUnsuspendHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { +func nsSuspendRemoveHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { + if len(params) == 0 { + nsNotice(rb, client.t("Invalid parameters")) + return + } + err := server.accounts.Unsuspend(params[0]) switch err { case nil: nsNotice(rb, fmt.Sprintf(client.t("Successfully un-suspended account %s"), params[0])) case errAccountDoesNotExist: nsNotice(rb, client.t("No such account")) + case errNoop: + nsNotice(rb, client.t("Account was not suspended")) default: nsNotice(rb, client.t("An error occurred")) } } + +// sort in reverse order of creation time +type ByCreationTime []AccountSuspension + +func (a ByCreationTime) Len() int { return len(a) } +func (a ByCreationTime) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a ByCreationTime) Less(i, j int) bool { return a[i].TimeCreated.After(a[j].TimeCreated) } + +func nsSuspendListHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { + suspensions := server.accounts.ListSuspended() + sort.Sort(ByCreationTime(suspensions)) + nsNotice(rb, fmt.Sprintf(client.t("There are %d active suspensions."), len(suspensions))) + for _, suspension := range suspensions { + nsNotice(rb, suspensionToString(client, suspension)) + } +} + +func suspensionToString(client *Client, suspension AccountSuspension) (result string) { + duration := client.t("indefinite") + if suspension.Duration != time.Duration(0) { + duration = suspension.Duration.String() + } + ts := suspension.TimeCreated.Format(time.RFC1123) + reason := client.t("No reason given.") + if suspension.Reason != "" { + reason = fmt.Sprintf(client.t("Reason: %s"), suspension.Reason) + } + return fmt.Sprintf(client.t("Account %s suspended at %s. Duration: %s. %s"), suspension.AccountName, ts, duration, reason) +}