3
0
mirror of https://github.com/ergochat/ergo.git synced 2024-11-11 06:29:29 +01:00

Merge pull request #1111 from slingamn/shellauth.1

fix #1107
This commit is contained in:
Shivaram Lingamneni 2020-06-04 07:46:23 -07:00 committed by GitHub
commit cfec0721fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 256 additions and 26 deletions

View File

@ -491,6 +491,21 @@ accounts:
# attributes:
# 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
channels:
# 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
- HOPM
- Tor
- External authentication systems
- Acknowledgements
@ -846,6 +847,38 @@ 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.
## 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 dictionary with the following keys:
* `accountName`: during passphrase-based authentication, this is a string, otherwise omitted
* `passphrase`: during passphrase-based authentication, this is a string, otherwise omitted
* `certfp`: during certfp-based authentication, this is a string, otherwise omitted
* `ip`: a string representation of the client's IP address
The script must print a single line (`\n`-terminated) to its output and exit. This line must be a JSON 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
}
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
@ -1048,19 +1060,29 @@ func (am *AccountManager) AuthenticateByPassphrase(client *Client, accountName s
}
}()
ldapConf := am.server.Config().Accounts.LDAP
if ldapConf.Enabled {
config := am.server.Config()
if config.Accounts.LDAP.Enabled {
ldapConf := am.server.Config().Accounts.LDAP
err = ldap.CheckLDAPPassphrase(ldapConf, accountName, passphrase, am.server.logger)
if err == nil {
account, err = am.LoadAccount(accountName)
// autocreate if necessary:
if err == errAccountDoesNotExist && ldapConf.Autocreate {
err = am.SARegister(accountName, "")
if err != nil {
return
}
account, err = am.LoadAccount(accountName)
if err != nil {
account, err = am.loadWithAutocreation(accountName, ldapConf.Autocreate)
return
}
}
if config.Accounts.AuthScript.Enabled {
var output AuthScriptOutput
output, err = CheckAuthScript(config.Accounts.AuthScript,
AuthScriptInput{AccountName: accountName, Passphrase: passphrase, IP: client.IP().String()})
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
}
}
@ -1361,15 +1383,50 @@ func (am *AccountManager) ChannelsForAccount(account string) (channels []string)
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 == "" {
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, IP: client.IP().String()})
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
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)
if account == "" {
return errAccountInvalidCredentials
@ -1386,19 +1443,8 @@ func (am *AccountManager) AuthenticateByCertFP(client *Client, certfp, authzid s
}
// ok, we found an account corresponding to their certificate
clientAccount, err := am.LoadAccount(account)
if err != nil {
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
clientAccount, err = am.LoadAccount(account)
return err
}
type settingsMunger func(input AccountSettings) (output AccountSettings, err error)

110
irc/authscript.go Normal file
View File

@ -0,0 +1,110 @@
// 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 `json:"accountName,omitempty"`
Passphrase string `json:"passphrase,omitempty"`
Certfp string `json:"certfp,omitempty"`
IP string `json:"ip,omitempty"`
}
type AuthScriptOutput struct {
AccountName string `json:"accountName"`
Success bool `json:"success"`
Error string `json:"error"`
}
// 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
Bouncer *MulticlientConfig // # handle old name for 'multiclient'
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.

View File

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

View File

@ -517,6 +517,21 @@ accounts:
# attributes:
# 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
channels:
# modes that are set when new channels are created