3
0
mirror of https://github.com/ergochat/ergo.git synced 2026-04-26 18:48:22 +02:00

enhanced auth integrations (#2383)

* Harvest cookies from the initial websocket handshake to pass to an
  auth-script (#2185)
* Allow running auth-script and ip-check-script over unix domain socket
  (#2280)
This commit is contained in:
Shivaram Lingamneni 2026-04-14 22:48:29 -07:00 committed by GitHub
parent cfee1917f3
commit de005ee69f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 113 additions and 31 deletions

View File

@ -318,6 +318,8 @@ server:
# constant list of args to pass to the command; the actual query
# and result are transmitted over stdin/stdout:
args: []
# alternatively, pass the input to a persistent process over unix domain socket:
#socket: "/tmp/ergo_ip_check_sidecar"
# timeout for process execution, after which we send a SIGTERM:
timeout: 9s
# how long after the SIGTERM before we follow up with a SIGKILL:
@ -617,6 +619,8 @@ accounts:
# constant list of args to pass to the command; the actual authentication
# data is transmitted over stdin/stdout:
args: []
# alternatively, pass the input to a persistent process over unix domain socket:
#socket: "/tmp/ergo_auth_sidecar"
# should we automatically create users if the plugin returns success?
autocreate: true
# timeout for process execution, after which we send a SIGTERM:

View File

@ -2030,15 +2030,20 @@ func unmarshalRegisteredChannels(channelsStr string) (result []string) {
return
}
func (am *AccountManager) AuthenticateByCertificate(client *Client, certfp string, peerCerts []*x509.Certificate, authzid string) (err error) {
if certfp == "" {
return errAccountInvalidCredentials
}
func (am *AccountManager) AuthenticateByCertificateOrCookies(client *Client, certfp string, peerCerts []*x509.Certificate, cookies []RequestCookie, authzid string) (err error) {
clientAccount, err := am.checkCertAuth(client.IP(), certfp, peerCerts, authzid)
clientAccount, err := am.checkCertOrCookieAuth(client.IP(), certfp, peerCerts, cookies, authzid)
if err != nil {
return
}
if authzid != "" {
if cfAuthzid, cErr := CasefoldName(authzid); cErr != nil || cfAuthzid != clientAccount.NameCasefolded {
err = errAuthzidAuthcidMismatch
return
}
}
if client.registered {
if clientAlready := am.server.clients.Get(clientAccount.Name); clientAlready != nil && clientAlready.AlwaysOn() {
err = errNickAccountMismatch
@ -2049,7 +2054,7 @@ func (am *AccountManager) AuthenticateByCertificate(client *Client, certfp strin
return
}
func (am *AccountManager) checkCertAuth(ip net.IP, certfp string, peerCerts []*x509.Certificate, authzid string) (clientAccount ClientAccount, err error) {
func (am *AccountManager) checkCertOrCookieAuth(ip net.IP, certfp string, peerCerts []*x509.Certificate, cookies []RequestCookie, authzid string) (clientAccount ClientAccount, err error) {
defer func() {
if err != nil {
return
@ -2062,6 +2067,11 @@ func (am *AccountManager) checkCertAuth(ip net.IP, certfp string, peerCerts []*x
}
}()
if certfp == "" && len(cookies) == 0 {
err = errAccountInvalidCredentials
return
}
config := am.server.Config()
if config.Accounts.AuthScript.Enabled {
var output AuthScriptOutput
@ -2071,7 +2081,7 @@ func (am *AccountManager) checkCertAuth(ip net.IP, certfp string, peerCerts []*x
}
output, err = CheckAuthScript(
am.server.semaphores.AuthScript, config.Accounts.AuthScript.ScriptConfig,
AuthScriptInput{Certfp: certfp, IP: ipString, peerCerts: peerCerts},
AuthScriptInput{Certfp: certfp, IP: ipString, peerCerts: peerCerts, Cookies: cookies},
)
if err != nil {
am.server.logger.Error("internal", "failed shell auth invocation", err.Error())
@ -2081,6 +2091,11 @@ func (am *AccountManager) checkCertAuth(ip net.IP, certfp string, peerCerts []*x
}
}
if certfp == "" {
err = errAccountInvalidCredentials
return
}
var account string
certFPKey := fmt.Sprintf(keyCertToAccount, certfp)
@ -2096,13 +2111,6 @@ func (am *AccountManager) checkCertAuth(ip net.IP, certfp string, peerCerts []*x
return
}
if authzid != "" {
if cfAuthzid, cErr := CasefoldName(authzid); cErr != nil || cfAuthzid != account {
err = errAuthzidAuthcidMismatch
return
}
}
// ok, we found an account corresponding to their certificate
clientAccount, err = am.LoadAccount(account)
return

View File

@ -169,7 +169,7 @@ func (a *ergoAPI) handleCheckAuth(w http.ResponseWriter, r *http.Request) {
if request.AccountName != "" && request.Passphrase != "" {
account, err = a.server.accounts.checkPassphrase(request.AccountName, request.Passphrase)
} else if request.Certfp != "" {
account, err = a.server.accounts.checkCertAuth(nil, request.Certfp, nil, "")
account, err = a.server.accounts.checkCertOrCookieAuth(nil, request.Certfp, nil, nil, "")
} else {
err = errAccountInvalidCredentials
}

View File

@ -23,6 +23,14 @@ type AuthScriptInput struct {
peerCerts []*x509.Certificate
IP string `json:"ip,omitempty"`
OAuthBearer *oauth2.OAuthBearerOptions `json:"oauth2,omitempty"`
Cookies []RequestCookie `json:"cookies,omitempty"`
}
// RequestCookie represents a cookie sent by the client with the original HTTP
// websocket upgrade request.
type RequestCookie struct {
Name string `json:"name"`
Value string `json:"value"`
}
type AuthScriptOutput struct {
@ -49,7 +57,8 @@ func CheckAuthScript(sem utils.Semaphore, config ScriptConfig, input AuthScriptI
if err != nil {
return
}
outBytes, err := RunScript(config.Command, config.Args, inputBytes, config.Timeout, config.KillTimeout)
inputBytes = append(inputBytes, '\n')
outBytes, err := RunScript(config.Command, config.Socket, config.Args, inputBytes, config.Timeout, config.KillTimeout)
if err != nil {
return
}
@ -96,7 +105,8 @@ func CheckIPBan(sem utils.Semaphore, config IPCheckScriptConfig, addr net.IP) (o
if err != nil {
return
}
outBytes, err := RunScript(config.Command, config.Args, inputBytes, config.Timeout, config.KillTimeout)
inputBytes = append(inputBytes, '\n')
outBytes, err := RunScript(config.Command, config.Socket, config.Args, inputBytes, config.Timeout, config.KillTimeout)
if err != nil {
return
}

View File

@ -180,6 +180,7 @@ type Session struct {
socket *Socket
realIP net.IP
proxiedIP net.IP
cookies []RequestCookie
rawHostname string
hostnameFinalized bool
isTor bool
@ -325,7 +326,7 @@ type ClientDetails struct {
}
// RunClient sets up a new client and runs its goroutine.
func (server *Server) RunClient(conn IRCConn) {
func (server *Server) RunClient(conn IRCConn, cookies []RequestCookie) {
config := server.Config()
wConn := conn.UnderlyingConn()
var isBanned, requireSASL bool
@ -399,6 +400,7 @@ func (server *Server) RunClient(conn IRCConn) {
isTor: wConn.Tor,
hideSTS: wConn.Tor || wConn.HideSTS,
connID: connID,
cookies: cookies,
}
session.sasl.Initialize()
client.sessions = []*Session{session}

View File

@ -348,6 +348,7 @@ type AccountConfig struct {
type ScriptConfig struct {
Enabled bool
Socket string
Command string
Args []string
Timeout time.Duration
@ -1593,6 +1594,19 @@ func LoadConfig(filename string) (config *Config, err error) {
return nil, fmt.Errorf("oauth2 is enabled with auth-script, but no auth-script is enabled")
}
if config.Accounts.AuthScript.Enabled {
config.Accounts.AuthScript.Socket = strings.TrimPrefix(config.Accounts.AuthScript.Socket, "unix:")
if config.Accounts.AuthScript.Command != "" && config.Accounts.AuthScript.Socket != "" {
return nil, errors.New("cannot define both command and socket for auth-script")
}
}
if config.Server.IPCheckScript.Enabled {
config.Server.IPCheckScript.Socket = strings.TrimPrefix(config.Server.IPCheckScript.Socket, "unix:")
if config.Server.IPCheckScript.Command != "" && config.Server.IPCheckScript.Socket != "" {
return nil, errors.New("cannot define both command and socket for ip-check-script")
}
}
if !config.Accounts.Registration.Enabled {
config.Server.supportedCaps.Disable(caps.AccountRegistration)
} else {

View File

@ -360,11 +360,6 @@ func authErrorToMessage(server *Server, err error) (msg string) {
func authExternalHandler(server *Server, client *Client, session *Session, value []byte, rb *ResponseBuffer) bool {
defer session.sasl.Clear()
if rb.session.certfp == "" {
rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, client.t("SASL authentication failed, you are not connecting with a certificate"))
return false
}
// EXTERNAL doesn't carry an authentication ID (this is determined from the
// certificate), but does carry an optional authorization ID.
authzid := string(value)
@ -376,9 +371,13 @@ func authExternalHandler(server *Server, client *Client, session *Session, value
authzid, deviceID = authzid[:strudelIndex], authzid[strudelIndex+1:]
}
if err == nil {
err = server.accounts.AuthenticateByCertificate(client, rb.session.certfp, rb.session.peerCerts, authzid)
if rb.session.certfp != "" || len(rb.session.cookies) != 0 {
err = server.accounts.AuthenticateByCertificateOrCookies(client, rb.session.certfp, rb.session.peerCerts, rb.session.cookies, authzid)
} else {
rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, client.t("SASL authentication failed; no external credentials found (certificate or cookies)"))
return false
}
if err != nil {
sendAuthErrorResponse(client, rb, err)
return false

View File

@ -101,7 +101,7 @@ func (nl *NetListener) serve() {
if ok {
if wConn.ProxyError == nil {
confirmProxyData(wConn, "", "", "", nl.server.Config())
go nl.server.RunClient(NewIRCStreamConn(wConn))
go nl.server.RunClient(NewIRCStreamConn(wConn), nil)
} else {
nl.server.logger.Error("internal", "PROXY protocol error", nl.addr, wConn.ProxyError.Error())
conn.Close()
@ -158,6 +158,7 @@ func (wl *WSListener) handle(w http.ResponseWriter, r *http.Request) {
remoteAddr := r.RemoteAddr
xff := r.Header.Get("X-Forwarded-For")
xfp := r.Header.Get("X-Forwarded-Proto")
cookies := extractCookies(r)
wsUpgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
@ -203,7 +204,7 @@ func (wl *WSListener) handle(w http.ResponseWriter, r *http.Request) {
// avoid a DoS attack from buffering excessively large messages:
conn.SetReadLimit(int64(maxReadQBytes()))
go wl.server.RunClient(NewIRCWSConn(conn))
go wl.server.RunClient(NewIRCWSConn(conn), cookies)
}
// validate conn.ProxiedIP and conn.Secure against config, HTTP headers, etc.
@ -233,3 +234,23 @@ func confirmProxyData(conn *utils.WrappedConn, remoteAddr, xForwardedFor, xForwa
xForwardedProto == "https"
}
}
func extractCookies(r *http.Request) (result []RequestCookie) {
headers := r.Header["Cookie"]
if len(headers) != 0 {
result = make([]RequestCookie, 0, len(headers))
for _, header := range headers {
// ParseCookie only returns Name, Value, and Quoted
// (unlike ParseSetCookie which returns, e.g. Path and Expires as well)
if cookies, err := http.ParseCookie(header); err == nil {
for _, cookie := range cookies {
result = append(result, RequestCookie{
Name: cookie.Name,
Value: cookie.Value,
})
}
}
}
}
return
}

View File

@ -860,7 +860,7 @@ func nsIdentifyHandler(service *ircService, server *Server, client *Client, comm
// try certfp
if !loginSuccessful && rb.session.certfp != "" {
err = server.accounts.AuthenticateByCertificate(client, rb.session.certfp, rb.session.peerCerts, "")
err = server.accounts.AuthenticateByCertificateOrCookies(client, rb.session.certfp, rb.session.peerCerts, nil, "")
loginSuccessful = (err == nil)
}

View File

@ -6,9 +6,12 @@ package irc
import (
"bufio"
"io"
"net"
"os/exec"
"syscall"
"time"
"github.com/ergochat/irc-go/ircreader"
)
// general-purpose scripting API for oragono "plugins"
@ -21,7 +24,10 @@ type scriptResponse struct {
err error
}
func RunScript(command string, args []string, input []byte, timeout, killTimeout time.Duration) (output []byte, err error) {
func RunScript(command, socket string, args []string, input []byte, timeout, killTimeout time.Duration) (output []byte, err error) {
if socket != "" {
return RunScriptOverSocket(socket, input, timeout)
}
cmd := exec.Command(command, args...)
stdin, err := cmd.StdinPipe()
if err != nil {
@ -38,7 +44,6 @@ func RunScript(command string, args []string, input []byte, timeout, killTimeout
return
}
stdin.Write(input)
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
@ -81,3 +86,20 @@ func processScriptOutput(cmd *exec.Cmd, stdout io.Reader, channel chan scriptRes
channel <- response
}
func RunScriptOverSocket(socket string, input []byte, timeout time.Duration) (output []byte, err error) {
sock, err := net.Dial("unix", socket)
if err != nil {
return
}
defer sock.Close()
sock.SetDeadline(time.Now().Add(timeout))
_, err = sock.Write(input)
if err != nil {
return
}
var reader ircreader.Reader
reader.Initialize(sock, 1024, 1024*1024)
output, err = reader.ReadLine()
return
}

View File

@ -419,6 +419,8 @@ func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) {
return false
}
session.cookies = nil // auth is done, allow GC'ing these later
if session.client != c {
// reattached, bail out.
// we'll play the reg burst later, on the new goroutine associated with

View File

@ -188,7 +188,7 @@ func parseProxyLineV2(line []byte) (ip net.IP, err error) {
return ip, nil
}
// / WrappedConn is a net.Conn with some additional data stapled to it;
// WrappedConn is a net.Conn with some additional data stapled to it;
// the proxied IP, if one was read via the PROXY protocol, and the listener
// configuration.
type WrappedConn struct {