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,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

+ 33
- 0
docs/MANUAL.md View File

@@ -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,38 @@ 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 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,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, 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 1086
 			return
1065 1087
 		}
1066 1088
 	}
@@ -1361,15 +1383,50 @@ 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,
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 1426
 	var account string
1370 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 1430
 		account, _ = tx.Get(certFPKey)
1374 1431
 		if account == "" {
1375 1432
 			return errAccountInvalidCredentials
@@ -1386,19 +1443,8 @@ func (am *AccountManager) AuthenticateByCertFP(client *Client, certfp, authzid s
1386 1443
 	}
1387 1444
 
1388 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 1450
 type settingsMunger func(input AccountSettings) (output AccountSettings, err error)

+ 110
- 0
irc/authscript.go View File

@@ -0,0 +1,110 @@
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,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 View File

@@ -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 View File

@@ -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…
Cancel
Save