Browse Source

fix #1107

tags/v2.2.0-rc1
Shivaram Lingamneni 4 years ago
parent
commit
61738782c0
7 changed files with 253 additions and 26 deletions
  1. 15
    0
      conventional.yaml
  2. 32
    0
      docs/MANUAL.md
  3. 71
    26
      irc/accounts.go
  4. 109
    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

+ 32
- 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-encoded dictionary with the following keys:
853
+
854
+* `AccountName`: this is a string during passphrase-based authentication, otherwise the empty string
855
+* `Passphrase`: this is a string during passphrase-based authentication, otherwise the empty string
856
+* `Certfp`: this is a string during certfp-based authentication, otherwise the empty string
857
+
858
+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:
859
+
860
+* `Success`, a boolean indicating whether the authentication was successful
861
+* `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)
862
+* `Error`, containing a human-readable description of the authentication error to be logged if applicable
863
+
864
+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):
865
+
866
+```
867
+#!/usr/bin/python3
868
+
869
+import sys, json
870
+
871
+raw_input = sys.stdin.readline()
872
+input = json.loads(b)
873
+account_name = input.get("AccountName")
874
+passphrase = input.get("Passphrase")
875
+success = bool(account_name) and bool(passphrase) and account_name == passphrase
876
+print(json.dumps({"Success": success})
877
+```
878
+
879
+Note that after a failed script invocation, Oragono will proceed to check the credentials against its local database.
880
+
849
 
881
 
850
 --------------------------------------------------------------------------------------------
882
 --------------------------------------------------------------------------------------------
851
 
883
 

+ 71
- 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})
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, AuthScriptInput{Certfp: certfp})
1415
+		if err != nil {
1416
+			am.server.logger.Error("internal", "failed shell auth invocation", err.Error())
1417
+			return err
1418
+		}
1419
+		if output.Success && output.AccountName != "" {
1420
+			clientAccount, err = am.loadWithAutocreation(output.AccountName, config.Accounts.AuthScript.Autocreate)
1421
+			return
1422
+		}
1423
+	}
1424
+
1369
 	var account string
1425
 	var account string
1370
 	certFPKey := fmt.Sprintf(keyCertToAccount, certfp)
1426
 	certFPKey := fmt.Sprintf(keyCertToAccount, certfp)
1371
 
1427
 
1372
-	err := am.server.store.View(func(tx *buntdb.Tx) error {
1428
+	err = am.server.store.View(func(tx *buntdb.Tx) error {
1373
 		account, _ = tx.Get(certFPKey)
1429
 		account, _ = tx.Get(certFPKey)
1374
 		if account == "" {
1430
 		if account == "" {
1375
 			return errAccountInvalidCredentials
1431
 			return errAccountInvalidCredentials
1386
 	}
1442
 	}
1387
 
1443
 
1388
 	// ok, we found an account corresponding to their certificate
1444
 	// 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
1445
+	clientAccount, err = am.LoadAccount(account)
1446
+	return err
1402
 }
1447
 }
1403
 
1448
 
1404
 type settingsMunger func(input AccountSettings) (output AccountSettings, err error)
1449
 type settingsMunger func(input AccountSettings) (output AccountSettings, err error)

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

+ 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