3
0
mirror of https://github.com/ergochat/ergo.git synced 2024-11-22 20:09:41 +01:00
This commit is contained in:
Shivaram Lingamneni 2020-06-04 01:18:24 -04:00
parent a45e15b520
commit 61738782c0
7 changed files with 253 additions and 26 deletions

View File

@ -491,6 +491,21 @@ accounts:
# attributes: # attributes:
# member-of: "memberOf" # member-of: "memberOf"
# pluggable authentication mechanism, via subprocess invocation
# see the manual for details on how to write an authentication plugin script
auth-script:
enabled: false
command: "/usr/local/bin/authenticate-irc-user"
# constant list of args to pass to the command; the actual authentication
# data is transmitted over stdin/stdout:
args: []
# should we automatically create users if the plugin returns success?
autocreate: true
# timeout for process execution, after which we send a SIGTERM:
timeout: 9s
# how long after the SIGTERM before we follow up with a SIGKILL:
kill-timeout: 1s
# channel options # channel options
channels: channels:
# modes that are set when new channels are created # modes that are set when new channels are created

View File

@ -47,6 +47,7 @@ _Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn
- Kiwi IRC - Kiwi IRC
- HOPM - HOPM
- Tor - Tor
- External authentication systems
- Acknowledgements - Acknowledgements
@ -846,6 +847,37 @@ ZNC 1.6.x (still pretty common in distros that package old versions of IRC softw
Oragono can emulate certain capabilities of the ZNC bouncer for the benefit of clients, in particular the third-party [playback](https://wiki.znc.in/Playback) module. This enables clients with specific support for ZNC to receive selective history playback automatically. To configure this in [Textual](https://www.codeux.com/textual/), go to "Server properties", select "Vendor specific", uncheck "Do not automatically join channels on connect", and check "Only play back messages you missed". Other clients with support are listed on ZNC's wiki page. Oragono can emulate certain capabilities of the ZNC bouncer for the benefit of clients, in particular the third-party [playback](https://wiki.znc.in/Playback) module. This enables clients with specific support for ZNC to receive selective history playback automatically. To configure this in [Textual](https://www.codeux.com/textual/), go to "Server properties", select "Vendor specific", uncheck "Do not automatically join channels on connect", and check "Only play back messages you missed". Other clients with support are listed on ZNC's wiki page.
## External authentication systems
Oragono can be configured to call arbitrary scripts to authenticate users; see the `auth-script` section of the config. The API for these scripts is as follows: Oragono will invoke the script with a configurable set of arguments, then send it the authentication data as JSON on the first line (`\n`-terminated) of stdin. The input is a JSON-encoded dictionary with the following keys:
* `AccountName`: this is a string during passphrase-based authentication, otherwise the empty string
* `Passphrase`: this is a string during passphrase-based authentication, otherwise the empty string
* `Certfp`: this is a string during certfp-based authentication, otherwise the empty string
The script must print a single line (`\n`-terminated) to its output and exit. This line must be a JSON-encoded dictionary with the following keys:
* `Success`, a boolean indicating whether the authentication was successful
* `AccountName`, a string containing the normalized account name (in the case of passphrase-based authentication, it is permissible to return the empty string or omit the value)
* `Error`, containing a human-readable description of the authentication error to be logged if applicable
Here is a toy example of an authentication script in Python that checks that the account name and the password are equal (and rejects any attempts to authenticate via certfp):
```
#!/usr/bin/python3
import sys, json
raw_input = sys.stdin.readline()
input = json.loads(b)
account_name = input.get("AccountName")
passphrase = input.get("Passphrase")
success = bool(account_name) and bool(passphrase) and account_name == passphrase
print(json.dumps({"Success": success})
```
Note that after a failed script invocation, Oragono will proceed to check the credentials against its local database.
-------------------------------------------------------------------------------------------- --------------------------------------------------------------------------------------------

View File

@ -1029,6 +1029,18 @@ func (am *AccountManager) checkPassphrase(accountName, passphrase string) (accou
return return
} }
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) { 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 // 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 // even for a brief period. the other potential source of nick-account conflicts
@ -1048,19 +1060,29 @@ func (am *AccountManager) AuthenticateByPassphrase(client *Client, accountName s
} }
}() }()
ldapConf := am.server.Config().Accounts.LDAP config := am.server.Config()
if ldapConf.Enabled { if config.Accounts.LDAP.Enabled {
ldapConf := am.server.Config().Accounts.LDAP
err = ldap.CheckLDAPPassphrase(ldapConf, accountName, passphrase, am.server.logger) err = ldap.CheckLDAPPassphrase(ldapConf, accountName, passphrase, am.server.logger)
if err == nil { if err != nil {
account, err = am.LoadAccount(accountName) account, err = am.loadWithAutocreation(accountName, ldapConf.Autocreate)
// autocreate if necessary: return
if err == errAccountDoesNotExist && ldapConf.Autocreate { }
err = am.SARegister(accountName, "") }
if err != nil {
return if config.Accounts.AuthScript.Enabled {
} var output AuthScriptOutput
account, err = am.LoadAccount(accountName) output, err = CheckAuthScript(config.Accounts.AuthScript,
AuthScriptInput{AccountName: accountName, Passphrase: passphrase})
if err != nil {
am.server.logger.Error("internal", "failed shell auth invocation", err.Error())
return err
}
if output.Success {
if output.AccountName != "" {
accountName = output.AccountName
} }
account, err = am.loadWithAutocreation(accountName, config.Accounts.AuthScript.Autocreate)
return return
} }
} }
@ -1361,15 +1383,49 @@ func (am *AccountManager) ChannelsForAccount(account string) (channels []string)
return unmarshalRegisteredChannels(channelStr) return unmarshalRegisteredChannels(channelStr)
} }
func (am *AccountManager) AuthenticateByCertFP(client *Client, certfp, authzid string) error { func (am *AccountManager) AuthenticateByCertFP(client *Client, certfp, authzid string) (err error) {
if certfp == "" { if certfp == "" {
return errAccountInvalidCredentials return errAccountInvalidCredentials
} }
var clientAccount ClientAccount
defer func() {
if err != nil {
return
} else if !clientAccount.Verified {
err = errAccountUnverified
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(config.Accounts.AuthScript, AuthScriptInput{Certfp: certfp})
if err != nil {
am.server.logger.Error("internal", "failed shell auth invocation", err.Error())
return err
}
if output.Success && output.AccountName != "" {
clientAccount, err = am.loadWithAutocreation(output.AccountName, config.Accounts.AuthScript.Autocreate)
return
}
}
var account string var account string
certFPKey := fmt.Sprintf(keyCertToAccount, certfp) certFPKey := fmt.Sprintf(keyCertToAccount, certfp)
err := am.server.store.View(func(tx *buntdb.Tx) error { err = am.server.store.View(func(tx *buntdb.Tx) error {
account, _ = tx.Get(certFPKey) account, _ = tx.Get(certFPKey)
if account == "" { if account == "" {
return errAccountInvalidCredentials return errAccountInvalidCredentials
@ -1386,19 +1442,8 @@ func (am *AccountManager) AuthenticateByCertFP(client *Client, certfp, authzid s
} }
// ok, we found an account corresponding to their certificate // ok, we found an account corresponding to their certificate
clientAccount, err := am.LoadAccount(account) clientAccount, err = am.LoadAccount(account)
if err != nil { return err
return err
} else if !clientAccount.Verified {
return errAccountUnverified
}
if client.registered {
if clientAlready := am.server.clients.Get(clientAccount.Name); clientAlready != nil && clientAlready.AlwaysOn() {
return errNickAccountMismatch
}
}
am.Login(client, clientAccount)
return nil
} }
type settingsMunger func(input AccountSettings) (output AccountSettings, err error) type settingsMunger func(input AccountSettings) (output AccountSettings, err error)

109
irc/authscript.go Normal file
View File

@ -0,0 +1,109 @@
// Copyright (c) 2020 Shivaram Lingamneni
// released under the MIT license
package irc
import (
"bufio"
"encoding/json"
"fmt"
"io"
"os/exec"
"syscall"
"time"
)
// JSON-serializable input and output types for the script
type AuthScriptInput struct {
AccountName string
Passphrase string
Certfp string
}
type AuthScriptOutput struct {
AccountName string
Success bool
Error string
}
// internal tupling of output and error for passing over a channel
type authScriptResponse struct {
output AuthScriptOutput
err error
}
func CheckAuthScript(config AuthScriptConfig, input AuthScriptInput) (output AuthScriptOutput, err error) {
inputBytes, err := json.Marshal(input)
if err != nil {
return
}
cmd := exec.Command(config.Command, config.Args...)
stdin, err := cmd.StdinPipe()
if err != nil {
return
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return
}
channel := make(chan authScriptResponse, 1)
err = cmd.Start()
if err != nil {
return
}
stdin.Write(inputBytes)
stdin.Write([]byte{'\n'})
// lots of potential race conditions here. we want to ensure that Wait()
// will be called, and will return, on the other goroutine, no matter
// where it is blocked. If it's blocked on ReadBytes(), we will kill it
// (first with SIGTERM, then with SIGKILL) and ReadBytes will return
// with EOF. If it's blocked on Wait(), then one of the kill signals
// will succeed and unblock it.
go processAuthScriptOutput(cmd, stdout, channel)
outputTimer := time.NewTimer(config.Timeout)
select {
case response := <-channel:
return response.output, response.err
case <-outputTimer.C:
}
err = errTimedOut
cmd.Process.Signal(syscall.SIGTERM)
termTimer := time.NewTimer(config.Timeout)
select {
case <-channel:
return
case <-termTimer.C:
}
cmd.Process.Kill()
return
}
func processAuthScriptOutput(cmd *exec.Cmd, stdout io.Reader, channel chan authScriptResponse) {
var response authScriptResponse
var out AuthScriptOutput
reader := bufio.NewReader(stdout)
outBytes, err := reader.ReadBytes('\n')
if err == nil {
err = json.Unmarshal(outBytes, &out)
if err == nil {
response.output = out
if out.Error != "" {
err = fmt.Errorf("Authentication process reported error: %s", out.Error)
}
}
}
response.err = err
// always call Wait() to ensure resource cleanup
err = cmd.Wait()
if err != nil {
response.err = err
}
channel <- response
}

View File

@ -278,6 +278,16 @@ type AccountConfig struct {
Multiclient MulticlientConfig Multiclient MulticlientConfig
Bouncer *MulticlientConfig // # handle old name for 'multiclient' Bouncer *MulticlientConfig // # handle old name for 'multiclient'
VHosts VHostConfig VHosts VHostConfig
AuthScript AuthScriptConfig `yaml:"auth-script"`
}
type AuthScriptConfig struct {
Enabled bool
Command string
Args []string
Autocreate bool
Timeout time.Duration
KillTimeout time.Duration `yaml:"kill-timeout"`
} }
// AccountRegistrationConfig controls account registration. // AccountRegistrationConfig controls account registration.

View File

@ -64,6 +64,7 @@ var (
errEmptyCredentials = errors.New("No more credentials are approved") errEmptyCredentials = errors.New("No more credentials are approved")
errCredsExternallyManaged = errors.New("Credentials are externally managed and cannot be changed here") errCredsExternallyManaged = errors.New("Credentials are externally managed and cannot be changed here")
errInvalidMultilineBatch = errors.New("Invalid multiline batch") errInvalidMultilineBatch = errors.New("Invalid multiline batch")
errTimedOut = errors.New("Operation timed out")
) )
// Socket Errors // Socket Errors

View File

@ -517,6 +517,21 @@ accounts:
# attributes: # attributes:
# member-of: "memberOf" # member-of: "memberOf"
# pluggable authentication mechanism, via subprocess invocation
# see the manual for details on how to write an authentication plugin script
auth-script:
enabled: false
command: "/usr/local/bin/authenticate-irc-user"
# constant list of args to pass to the command; the actual authentication
# data is transmitted over stdin/stdout:
args: []
# should we automatically create users if the plugin returns success?
autocreate: true
# timeout for process execution, after which we send a SIGTERM:
timeout: 9s
# how long after the SIGTERM before we follow up with a SIGKILL:
kill-timeout: 1s
# channel options # channel options
channels: channels:
# modes that are set when new channels are created # modes that are set when new channels are created