mirror of
https://github.com/ergochat/ergo.git
synced 2024-12-22 10:42:52 +01:00
commit
cfec0721fe
@ -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
|
||||
|
@ -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.
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------------------
|
||||
|
||||
|
@ -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
110
irc/authscript.go
Normal 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
|
||||
}
|
@ -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.
|
||||
|
@ -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
|
||||
|
15
oragono.yaml
15
oragono.yaml
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user