Shivaram Lingamneni преди 4 години
родител
ревизия
61738782c0
променени са 7 файла, в които са добавени 253 реда и са изтрити 26 реда
  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 Целия файл

@@ -491,6 +491,21 @@ accounts:
491 491
     #     attributes:
492 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 509
 # channel options
495 510
 channels:
496 511
     # modes that are set when new channels are created

+ 32
- 0
docs/MANUAL.md Целия файл

@@ -47,6 +47,7 @@ _Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn
47 47
     - Kiwi IRC
48 48
     - HOPM
49 49
     - Tor
50
+    - External authentication systems
50 51
 - Acknowledgements
51 52
 
52 53
 
@@ -846,6 +847,37 @@ ZNC 1.6.x (still pretty common in distros that package old versions of IRC softw
846 847
 
847 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 Целия файл

@@ -1029,6 +1029,18 @@ func (am *AccountManager) checkPassphrase(accountName, passphrase string) (accou
1029 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 1044
 func (am *AccountManager) AuthenticateByPassphrase(client *Client, accountName string, passphrase string) (err error) {
1033 1045
 	// XXX check this now, so we don't allow a redundant login for an always-on client
1034 1046
 	// 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
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 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 1086
 			return
1065 1087
 		}
1066 1088
 	}
@@ -1361,15 +1383,49 @@ func (am *AccountManager) ChannelsForAccount(account string) (channels []string)
1361 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 1387
 	if certfp == "" {
1366 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 1425
 	var account string
1370 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 1429
 		account, _ = tx.Get(certFPKey)
1374 1430
 		if account == "" {
1375 1431
 			return errAccountInvalidCredentials
@@ -1386,19 +1442,8 @@ func (am *AccountManager) AuthenticateByCertFP(client *Client, certfp, authzid s
1386 1442
 	}
1387 1443
 
1388 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 1449
 type settingsMunger func(input AccountSettings) (output AccountSettings, err error)

+ 109
- 0
irc/authscript.go Целия файл

@@ -0,0 +1,109 @@
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 Целия файл

@@ -278,6 +278,16 @@ type AccountConfig struct {
278 278
 	Multiclient MulticlientConfig
279 279
 	Bouncer     *MulticlientConfig // # handle old name for 'multiclient'
280 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 293
 // AccountRegistrationConfig controls account registration.

+ 1
- 0
irc/errors.go Целия файл

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

+ 15
- 0
oragono.yaml Целия файл

@@ -517,6 +517,21 @@ accounts:
517 517
     #     attributes:
518 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 535
 # channel options
521 536
 channels:
522 537
     # modes that are set when new channels are created

Loading…
Отказ
Запис