Browse Source

Merge pull request #1111 from slingamn/shellauth.1

fix #1107
tags/v2.2.0-rc1
Shivaram Lingamneni 4 years ago
parent
commit
cfec0721fe
No account linked to committer's email address
7 changed files with 256 additions and 26 deletions
  1. 15
    0
      conventional.yaml
  2. 33
    0
      docs/MANUAL.md
  3. 72
    26
      irc/accounts.go
  4. 110
    0
      irc/authscript.go
  5. 10
    0
      irc/config.go
  6. 1
    0
      irc/errors.go
  7. 15
    0
      oragono.yaml

+ 15
- 0
conventional.yaml View File

491
     #     attributes:
491
     #     attributes:
492
     #         member-of: "memberOf"
492
     #         member-of: "memberOf"
493
 
493
 
494
+    # pluggable authentication mechanism, via subprocess invocation
495
+    # see the manual for details on how to write an authentication plugin script
496
+    auth-script:
497
+        enabled: false
498
+        command: "/usr/local/bin/authenticate-irc-user"
499
+        # constant list of args to pass to the command; the actual authentication
500
+        # data is transmitted over stdin/stdout:
501
+        args: []
502
+        # should we automatically create users if the plugin returns success?
503
+        autocreate: true
504
+        # timeout for process execution, after which we send a SIGTERM:
505
+        timeout: 9s
506
+        # how long after the SIGTERM before we follow up with a SIGKILL:
507
+        kill-timeout: 1s
508
+
494
 # channel options
509
 # channel options
495
 channels:
510
 channels:
496
     # modes that are set when new channels are created
511
     # modes that are set when new channels are created

+ 33
- 0
docs/MANUAL.md View File

47
     - Kiwi IRC
47
     - Kiwi IRC
48
     - HOPM
48
     - HOPM
49
     - Tor
49
     - Tor
50
+    - External authentication systems
50
 - Acknowledgements
51
 - Acknowledgements
51
 
52
 
52
 
53
 
846
 
847
 
847
 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.
848
 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.
848
 
849
 
850
+## External authentication systems
851
+
852
+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:
853
+
854
+* `accountName`: during passphrase-based authentication, this is a string, otherwise omitted
855
+* `passphrase`: during passphrase-based authentication, this is a string, otherwise omitted
856
+* `certfp`: during certfp-based authentication, this is a string, otherwise omitted
857
+* `ip`: a string representation of the client's IP address
858
+
859
+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:
860
+
861
+* `success`, a boolean indicating whether the authentication was successful
862
+* `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)
863
+* `error`, containing a human-readable description of the authentication error to be logged if applicable
864
+
865
+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):
866
+
867
+```
868
+#!/usr/bin/python3
869
+
870
+import sys, json
871
+
872
+raw_input = sys.stdin.readline()
873
+input = json.loads(b)
874
+account_name = input.get("accountName")
875
+passphrase = input.get("passphrase")
876
+success = bool(account_name) and bool(passphrase) and account_name == passphrase
877
+print(json.dumps({"success": success})
878
+```
879
+
880
+Note that after a failed script invocation, Oragono will proceed to check the credentials against its local database.
881
+
849
 
882
 
850
 --------------------------------------------------------------------------------------------
883
 --------------------------------------------------------------------------------------------
851
 
884
 

+ 72
- 26
irc/accounts.go View File

1029
 	return
1029
 	return
1030
 }
1030
 }
1031
 
1031
 
1032
+func (am *AccountManager) loadWithAutocreation(accountName string, autocreate bool) (account ClientAccount, err error) {
1033
+	account, err = am.LoadAccount(accountName)
1034
+	if err == errAccountDoesNotExist && autocreate {
1035
+		err = am.SARegister(accountName, "")
1036
+		if err != nil {
1037
+			return
1038
+		}
1039
+		account, err = am.LoadAccount(accountName)
1040
+	}
1041
+	return
1042
+}
1043
+
1032
 func (am *AccountManager) AuthenticateByPassphrase(client *Client, accountName string, passphrase string) (err error) {
1044
 func (am *AccountManager) AuthenticateByPassphrase(client *Client, accountName string, passphrase string) (err error) {
1033
 	// XXX check this now, so we don't allow a redundant login for an always-on client
1045
 	// XXX check this now, so we don't allow a redundant login for an always-on client
1034
 	// even for a brief period. the other potential source of nick-account conflicts
1046
 	// even for a brief period. the other potential source of nick-account conflicts
1048
 		}
1060
 		}
1049
 	}()
1061
 	}()
1050
 
1062
 
1051
-	ldapConf := am.server.Config().Accounts.LDAP
1052
-	if ldapConf.Enabled {
1063
+	config := am.server.Config()
1064
+	if config.Accounts.LDAP.Enabled {
1065
+		ldapConf := am.server.Config().Accounts.LDAP
1053
 		err = ldap.CheckLDAPPassphrase(ldapConf, accountName, passphrase, am.server.logger)
1066
 		err = ldap.CheckLDAPPassphrase(ldapConf, accountName, passphrase, am.server.logger)
1054
-		if err == nil {
1055
-			account, err = am.LoadAccount(accountName)
1056
-			// autocreate if necessary:
1057
-			if err == errAccountDoesNotExist && ldapConf.Autocreate {
1058
-				err = am.SARegister(accountName, "")
1059
-				if err != nil {
1060
-					return
1061
-				}
1062
-				account, err = am.LoadAccount(accountName)
1067
+		if err != nil {
1068
+			account, err = am.loadWithAutocreation(accountName, ldapConf.Autocreate)
1069
+			return
1070
+		}
1071
+	}
1072
+
1073
+	if config.Accounts.AuthScript.Enabled {
1074
+		var output AuthScriptOutput
1075
+		output, err = CheckAuthScript(config.Accounts.AuthScript,
1076
+			AuthScriptInput{AccountName: accountName, Passphrase: passphrase, IP: client.IP().String()})
1077
+		if err != nil {
1078
+			am.server.logger.Error("internal", "failed shell auth invocation", err.Error())
1079
+			return err
1080
+		}
1081
+		if output.Success {
1082
+			if output.AccountName != "" {
1083
+				accountName = output.AccountName
1063
 			}
1084
 			}
1085
+			account, err = am.loadWithAutocreation(accountName, config.Accounts.AuthScript.Autocreate)
1064
 			return
1086
 			return
1065
 		}
1087
 		}
1066
 	}
1088
 	}
1361
 	return unmarshalRegisteredChannels(channelStr)
1383
 	return unmarshalRegisteredChannels(channelStr)
1362
 }
1384
 }
1363
 
1385
 
1364
-func (am *AccountManager) AuthenticateByCertFP(client *Client, certfp, authzid string) error {
1386
+func (am *AccountManager) AuthenticateByCertFP(client *Client, certfp, authzid string) (err error) {
1365
 	if certfp == "" {
1387
 	if certfp == "" {
1366
 		return errAccountInvalidCredentials
1388
 		return errAccountInvalidCredentials
1367
 	}
1389
 	}
1368
 
1390
 
1391
+	var clientAccount ClientAccount
1392
+
1393
+	defer func() {
1394
+		if err != nil {
1395
+			return
1396
+		} else if !clientAccount.Verified {
1397
+			err = errAccountUnverified
1398
+			return
1399
+		}
1400
+		// TODO(#1109) clean this check up?
1401
+		if client.registered {
1402
+			if clientAlready := am.server.clients.Get(clientAccount.Name); clientAlready != nil && clientAlready.AlwaysOn() {
1403
+				err = errNickAccountMismatch
1404
+				return
1405
+			}
1406
+		}
1407
+		am.Login(client, clientAccount)
1408
+		return
1409
+	}()
1410
+
1411
+	config := am.server.Config()
1412
+	if config.Accounts.AuthScript.Enabled {
1413
+		var output AuthScriptOutput
1414
+		output, err = CheckAuthScript(config.Accounts.AuthScript,
1415
+			AuthScriptInput{Certfp: certfp, IP: client.IP().String()})
1416
+		if err != nil {
1417
+			am.server.logger.Error("internal", "failed shell auth invocation", err.Error())
1418
+			return err
1419
+		}
1420
+		if output.Success && output.AccountName != "" {
1421
+			clientAccount, err = am.loadWithAutocreation(output.AccountName, config.Accounts.AuthScript.Autocreate)
1422
+			return
1423
+		}
1424
+	}
1425
+
1369
 	var account string
1426
 	var account string
1370
 	certFPKey := fmt.Sprintf(keyCertToAccount, certfp)
1427
 	certFPKey := fmt.Sprintf(keyCertToAccount, certfp)
1371
 
1428
 
1372
-	err := am.server.store.View(func(tx *buntdb.Tx) error {
1429
+	err = am.server.store.View(func(tx *buntdb.Tx) error {
1373
 		account, _ = tx.Get(certFPKey)
1430
 		account, _ = tx.Get(certFPKey)
1374
 		if account == "" {
1431
 		if account == "" {
1375
 			return errAccountInvalidCredentials
1432
 			return errAccountInvalidCredentials
1386
 	}
1443
 	}
1387
 
1444
 
1388
 	// ok, we found an account corresponding to their certificate
1445
 	// ok, we found an account corresponding to their certificate
1389
-	clientAccount, err := am.LoadAccount(account)
1390
-	if err != nil {
1391
-		return err
1392
-	} else if !clientAccount.Verified {
1393
-		return errAccountUnverified
1394
-	}
1395
-	if client.registered {
1396
-		if clientAlready := am.server.clients.Get(clientAccount.Name); clientAlready != nil && clientAlready.AlwaysOn() {
1397
-			return errNickAccountMismatch
1398
-		}
1399
-	}
1400
-	am.Login(client, clientAccount)
1401
-	return nil
1446
+	clientAccount, err = am.LoadAccount(account)
1447
+	return err
1402
 }
1448
 }
1403
 
1449
 
1404
 type settingsMunger func(input AccountSettings) (output AccountSettings, err error)
1450
 type settingsMunger func(input AccountSettings) (output AccountSettings, err error)

+ 110
- 0
irc/authscript.go View File

1
+// Copyright (c) 2020 Shivaram Lingamneni
2
+// released under the MIT license
3
+
4
+package irc
5
+
6
+import (
7
+	"bufio"
8
+	"encoding/json"
9
+	"fmt"
10
+	"io"
11
+	"os/exec"
12
+	"syscall"
13
+	"time"
14
+)
15
+
16
+// JSON-serializable input and output types for the script
17
+type AuthScriptInput struct {
18
+	AccountName string `json:"accountName,omitempty"`
19
+	Passphrase  string `json:"passphrase,omitempty"`
20
+	Certfp      string `json:"certfp,omitempty"`
21
+	IP          string `json:"ip,omitempty"`
22
+}
23
+
24
+type AuthScriptOutput struct {
25
+	AccountName string `json:"accountName"`
26
+	Success     bool   `json:"success"`
27
+	Error       string `json:"error"`
28
+}
29
+
30
+// internal tupling of output and error for passing over a channel
31
+type authScriptResponse struct {
32
+	output AuthScriptOutput
33
+	err    error
34
+}
35
+
36
+func CheckAuthScript(config AuthScriptConfig, input AuthScriptInput) (output AuthScriptOutput, err error) {
37
+	inputBytes, err := json.Marshal(input)
38
+	if err != nil {
39
+		return
40
+	}
41
+	cmd := exec.Command(config.Command, config.Args...)
42
+	stdin, err := cmd.StdinPipe()
43
+	if err != nil {
44
+		return
45
+	}
46
+	stdout, err := cmd.StdoutPipe()
47
+	if err != nil {
48
+		return
49
+	}
50
+
51
+	channel := make(chan authScriptResponse, 1)
52
+	err = cmd.Start()
53
+	if err != nil {
54
+		return
55
+	}
56
+	stdin.Write(inputBytes)
57
+	stdin.Write([]byte{'\n'})
58
+
59
+	// lots of potential race conditions here. we want to ensure that Wait()
60
+	// will be called, and will return, on the other goroutine, no matter
61
+	// where it is blocked. If it's blocked on ReadBytes(), we will kill it
62
+	// (first with SIGTERM, then with SIGKILL) and ReadBytes will return
63
+	// with EOF. If it's blocked on Wait(), then one of the kill signals
64
+	// will succeed and unblock it.
65
+	go processAuthScriptOutput(cmd, stdout, channel)
66
+	outputTimer := time.NewTimer(config.Timeout)
67
+	select {
68
+	case response := <-channel:
69
+		return response.output, response.err
70
+	case <-outputTimer.C:
71
+	}
72
+
73
+	err = errTimedOut
74
+	cmd.Process.Signal(syscall.SIGTERM)
75
+	termTimer := time.NewTimer(config.Timeout)
76
+	select {
77
+	case <-channel:
78
+		return
79
+	case <-termTimer.C:
80
+	}
81
+
82
+	cmd.Process.Kill()
83
+	return
84
+}
85
+
86
+func processAuthScriptOutput(cmd *exec.Cmd, stdout io.Reader, channel chan authScriptResponse) {
87
+	var response authScriptResponse
88
+	var out AuthScriptOutput
89
+
90
+	reader := bufio.NewReader(stdout)
91
+	outBytes, err := reader.ReadBytes('\n')
92
+	if err == nil {
93
+		err = json.Unmarshal(outBytes, &out)
94
+		if err == nil {
95
+			response.output = out
96
+			if out.Error != "" {
97
+				err = fmt.Errorf("Authentication process reported error: %s", out.Error)
98
+			}
99
+		}
100
+	}
101
+	response.err = err
102
+
103
+	// always call Wait() to ensure resource cleanup
104
+	err = cmd.Wait()
105
+	if err != nil {
106
+		response.err = err
107
+	}
108
+
109
+	channel <- response
110
+}

+ 10
- 0
irc/config.go View File

278
 	Multiclient MulticlientConfig
278
 	Multiclient MulticlientConfig
279
 	Bouncer     *MulticlientConfig // # handle old name for 'multiclient'
279
 	Bouncer     *MulticlientConfig // # handle old name for 'multiclient'
280
 	VHosts      VHostConfig
280
 	VHosts      VHostConfig
281
+	AuthScript  AuthScriptConfig `yaml:"auth-script"`
282
+}
283
+
284
+type AuthScriptConfig struct {
285
+	Enabled     bool
286
+	Command     string
287
+	Args        []string
288
+	Autocreate  bool
289
+	Timeout     time.Duration
290
+	KillTimeout time.Duration `yaml:"kill-timeout"`
281
 }
291
 }
282
 
292
 
283
 // AccountRegistrationConfig controls account registration.
293
 // AccountRegistrationConfig controls account registration.

+ 1
- 0
irc/errors.go View File

64
 	errEmptyCredentials               = errors.New("No more credentials are approved")
64
 	errEmptyCredentials               = errors.New("No more credentials are approved")
65
 	errCredsExternallyManaged         = errors.New("Credentials are externally managed and cannot be changed here")
65
 	errCredsExternallyManaged         = errors.New("Credentials are externally managed and cannot be changed here")
66
 	errInvalidMultilineBatch          = errors.New("Invalid multiline batch")
66
 	errInvalidMultilineBatch          = errors.New("Invalid multiline batch")
67
+	errTimedOut                       = errors.New("Operation timed out")
67
 )
68
 )
68
 
69
 
69
 // Socket Errors
70
 // Socket Errors

+ 15
- 0
oragono.yaml View File

517
     #     attributes:
517
     #     attributes:
518
     #         member-of: "memberOf"
518
     #         member-of: "memberOf"
519
 
519
 
520
+    # pluggable authentication mechanism, via subprocess invocation
521
+    # see the manual for details on how to write an authentication plugin script
522
+    auth-script:
523
+        enabled: false
524
+        command: "/usr/local/bin/authenticate-irc-user"
525
+        # constant list of args to pass to the command; the actual authentication
526
+        # data is transmitted over stdin/stdout:
527
+        args: []
528
+        # should we automatically create users if the plugin returns success?
529
+        autocreate: true
530
+        # timeout for process execution, after which we send a SIGTERM:
531
+        timeout: 9s
532
+        # how long after the SIGTERM before we follow up with a SIGKILL:
533
+        kill-timeout: 1s
534
+
520
 # channel options
535
 # channel options
521
 channels:
536
 channels:
522
     # modes that are set when new channels are created
537
     # modes that are set when new channels are created

Loading…
Cancel
Save