Add support for post-connection-registration upstream SASL auth

Once the downstream connection has logged in with their bouncer
credentials, allow them to issue more SASL auths which will be
redirected to the upstream network. This allows downstream clients
to provide UIs to login to transparently login to upstream networks.
This commit is contained in:
Simon Ser 2021-11-21 16:10:54 +01:00
parent 4e84b41592
commit 313c6e7f97
3 changed files with 203 additions and 122 deletions

View File

@ -244,6 +244,11 @@ var passthroughIsupport = map[string]bool{
"WHOX": true, "WHOX": true,
} }
type downstreamSASL struct {
server sasl.Server
plainUsername, plainPassword string
}
type downstreamConn struct { type downstreamConn struct {
conn conn
@ -267,12 +272,11 @@ type downstreamConn struct {
capVersion int capVersion int
supportedCaps map[string]string supportedCaps map[string]string
caps map[string]bool caps map[string]bool
sasl *downstreamSASL
lastBatchRef uint64 lastBatchRef uint64
monitored casemapMap monitored casemapMap
saslServer sasl.Server
} }
func newDownstreamConn(srv *Server, ic ircConn, id uint64) *downstreamConn { func newDownstreamConn(srv *Server, ic ircConn, id uint64) *downstreamConn {
@ -686,102 +690,28 @@ func (dc *downstreamConn) handleMessageUnregistered(ctx context.Context, msg *ir
return err return err
} }
case "AUTHENTICATE": case "AUTHENTICATE":
if !dc.caps["sasl"] { credentials, err := dc.handleAuthenticateCommand(msg)
return ircError{&irc.Message{ if err != nil {
Prefix: dc.srv.prefix(), return err
Command: irc.ERR_SASLFAIL, } else if credentials == nil {
Params: []string{"*", "AUTHENTICATE requires the \"sasl\" capability to be enabled"}, break
}}
}
if len(msg.Params) == 0 {
return ircError{&irc.Message{
Prefix: dc.srv.prefix(),
Command: irc.ERR_SASLFAIL,
Params: []string{"*", "Missing AUTHENTICATE argument"},
}}
} }
var resp []byte if err := dc.authenticate(ctx, credentials.plainUsername, credentials.plainPassword); err != nil {
if msg.Params[0] == "*" { dc.logger.Printf("SASL authentication error: %v", err)
dc.saslServer = nil dc.endSASL(&irc.Message{
return ircError{&irc.Message{
Prefix: dc.srv.prefix(),
Command: irc.ERR_SASLABORTED,
Params: []string{"*", "SASL authentication aborted"},
}}
} else if dc.saslServer == nil {
mech := strings.ToUpper(msg.Params[0])
switch mech {
case "PLAIN":
dc.saslServer = sasl.NewPlainServer(sasl.PlainAuthenticator(func(identity, username, password string) error {
// TODO: we can't use the command context here, because it
// gets cancelled once the command handler returns. SASL
// might take multiple AUTHENTICATE commands to complete.
return dc.authenticate(context.TODO(), username, password)
}))
default:
return ircError{&irc.Message{
Prefix: dc.srv.prefix(), Prefix: dc.srv.prefix(),
Command: irc.ERR_SASLFAIL, Command: irc.ERR_SASLFAIL,
Params: []string{"*", fmt.Sprintf("Unsupported SASL mechanism %q", mech)}, Params: []string{"Authentication failed"},
}}
}
} else if msg.Params[0] == "+" {
resp = nil
} else {
// TODO: multi-line messages
var err error
resp, err = base64.StdEncoding.DecodeString(msg.Params[0])
if err != nil {
dc.saslServer = nil
return ircError{&irc.Message{
Prefix: dc.srv.prefix(),
Command: irc.ERR_SASLFAIL,
Params: []string{"*", "Invalid base64-encoded response"},
}}
}
}
challenge, done, err := dc.saslServer.Next(resp)
if err != nil {
dc.saslServer = nil
if ircErr, ok := err.(ircError); ok && ircErr.Message.Command == irc.ERR_PASSWDMISMATCH {
return ircError{&irc.Message{
Prefix: dc.srv.prefix(),
Command: irc.ERR_SASLFAIL,
Params: []string{"*", ircErr.Message.Params[1]},
}}
}
dc.SendMessage(&irc.Message{
Prefix: dc.srv.prefix(),
Command: irc.ERR_SASLFAIL,
Params: []string{"*", "SASL error"},
}) })
return fmt.Errorf("SASL authentication failed: %v", err) break
} else if done { }
dc.saslServer = nil
// Technically we should send RPL_LOGGEDIN here. However we use // Technically we should send RPL_LOGGEDIN here. However we use
// RPL_LOGGEDIN to mirror the upstream connection status. Let's see // RPL_LOGGEDIN to mirror the upstream connection status. Let's
// how many clients that breaks. See: // see how many clients that breaks. See:
// https://github.com/ircv3/ircv3-specifications/pull/476 // https://github.com/ircv3/ircv3-specifications/pull/476
dc.SendMessage(&irc.Message{ dc.endSASL(nil)
Prefix: dc.srv.prefix(),
Command: irc.RPL_SASLSUCCESS,
Params: []string{dc.nick, "SASL authentication successful"},
})
} else {
challengeStr := "+"
if len(challenge) > 0 {
challengeStr = base64.StdEncoding.EncodeToString(challenge)
}
// TODO: multi-line messages
dc.SendMessage(&irc.Message{
Prefix: dc.srv.prefix(),
Command: "AUTHENTICATE",
Params: []string{challengeStr},
})
}
case "BOUNCER": case "BOUNCER":
var subcommand string var subcommand string
if err := parseMessageParams(msg, &subcommand); err != nil { if err := parseMessageParams(msg, &subcommand); err != nil {
@ -951,6 +881,107 @@ func (dc *downstreamConn) handleCapCommand(cmd string, args []string) error {
return nil return nil
} }
func (dc *downstreamConn) handleAuthenticateCommand(msg *irc.Message) (result *downstreamSASL, err error) {
defer func() {
if err != nil {
dc.sasl = nil
}
}()
if !dc.caps["sasl"] {
return nil, ircError{&irc.Message{
Prefix: dc.srv.prefix(),
Command: irc.ERR_SASLFAIL,
Params: []string{"*", "AUTHENTICATE requires the \"sasl\" capability to be enabled"},
}}
}
if len(msg.Params) == 0 {
return nil, ircError{&irc.Message{
Prefix: dc.srv.prefix(),
Command: irc.ERR_SASLFAIL,
Params: []string{"*", "Missing AUTHENTICATE argument"},
}}
}
if msg.Params[0] == "*" {
return nil, ircError{&irc.Message{
Prefix: dc.srv.prefix(),
Command: irc.ERR_SASLABORTED,
Params: []string{"*", "SASL authentication aborted"},
}}
}
var resp []byte
if dc.sasl == nil {
mech := strings.ToUpper(msg.Params[0])
var server sasl.Server
switch mech {
case "PLAIN":
server = sasl.NewPlainServer(sasl.PlainAuthenticator(func(identity, username, password string) error {
dc.sasl.plainUsername = username
dc.sasl.plainPassword = password
return nil
}))
default:
return nil, ircError{&irc.Message{
Prefix: dc.srv.prefix(),
Command: irc.ERR_SASLFAIL,
Params: []string{"*", fmt.Sprintf("Unsupported SASL mechanism %q", mech)},
}}
}
dc.sasl = &downstreamSASL{server: server}
} else {
// TODO: multi-line messages
if msg.Params[0] == "+" {
resp = nil
} else if resp, err = base64.StdEncoding.DecodeString(msg.Params[0]); err != nil {
return nil, ircError{&irc.Message{
Prefix: dc.srv.prefix(),
Command: irc.ERR_SASLFAIL,
Params: []string{"*", "Invalid base64-encoded response"},
}}
}
}
challenge, done, err := dc.sasl.server.Next(resp)
if err != nil {
return nil, err
} else if done {
return dc.sasl, nil
} else {
challengeStr := "+"
if len(challenge) > 0 {
challengeStr = base64.StdEncoding.EncodeToString(challenge)
}
// TODO: multi-line messages
dc.SendMessage(&irc.Message{
Prefix: dc.srv.prefix(),
Command: "AUTHENTICATE",
Params: []string{challengeStr},
})
return nil, nil
}
}
func (dc *downstreamConn) endSASL(msg *irc.Message) {
if dc.sasl == nil {
return
}
dc.sasl = nil
if msg != nil {
dc.SendMessage(msg)
} else {
dc.SendMessage(&irc.Message{
Prefix: dc.srv.prefix(),
Command: irc.RPL_SASLSUCCESS,
Params: []string{dc.nick, "SASL authentication successful"},
})
}
}
func (dc *downstreamConn) setSupportedCap(name, value string) { func (dc *downstreamConn) setSupportedCap(name, value string) {
prevValue, hasPrev := dc.supportedCaps[name] prevValue, hasPrev := dc.supportedCaps[name]
changed := !hasPrev || prevValue != value changed := !hasPrev || prevValue != value
@ -1141,9 +1172,8 @@ func (dc *downstreamConn) register(ctx context.Context) error {
return fmt.Errorf("tried to register twice") return fmt.Errorf("tried to register twice")
} }
if dc.saslServer != nil { if dc.sasl != nil {
dc.saslServer = nil dc.endSASL(&irc.Message{
dc.SendMessage(&irc.Message{
Prefix: dc.srv.prefix(), Prefix: dc.srv.prefix(),
Command: irc.ERR_SASLABORTED, Command: irc.ERR_SASLABORTED,
Params: []string{"*", "SASL authentication aborted"}, Params: []string{"*", "SASL authentication aborted"},
@ -2330,6 +2360,40 @@ func (dc *downstreamConn) handleMessageRegistered(ctx context.Context, msg *irc.
Command: "INVITE", Command: "INVITE",
Params: []string{upstreamUser, upstreamChannel}, Params: []string{upstreamUser, upstreamChannel},
}) })
case "AUTHENTICATE":
// Post-connection-registration AUTHENTICATE is unsupported in
// multi-upstream mode, or if the upstream doesn't support SASL
uc := dc.upstream()
if uc == nil || !uc.caps["sasl"] {
return ircError{&irc.Message{
Prefix: dc.srv.prefix(),
Command: irc.ERR_SASLFAIL,
Params: []string{dc.nick, "Upstream network authentication not supported"},
}}
}
credentials, err := dc.handleAuthenticateCommand(msg)
if err != nil {
return err
}
if credentials != nil {
if uc.saslClient != nil {
dc.endSASL(&irc.Message{
Prefix: dc.srv.prefix(),
Command: irc.ERR_SASLFAIL,
Params: []string{dc.nick, "Another authentication attempt is already in progress"},
})
return nil
}
uc.logger.Printf("starting post-registration SASL PLAIN authentication with username %q", credentials.plainUsername)
uc.saslClient = sasl.NewPlainClient("", credentials.plainUsername, credentials.plainPassword)
uc.enqueueCommand(dc, &irc.Message{
Command: "AUTHENTICATE",
Params: []string{"PLAIN"},
})
}
case "MONITOR": case "MONITOR":
// MONITOR is unsupported in multi-upstream mode // MONITOR is unsupported in multi-upstream mode
uc := dc.upstream() uc := dc.upstream()
@ -2700,23 +2764,8 @@ func (dc *downstreamConn) handleMessageRegistered(ctx context.Context, msg *irc.
func (dc *downstreamConn) handleNickServPRIVMSG(ctx context.Context, uc *upstreamConn, text string) { func (dc *downstreamConn) handleNickServPRIVMSG(ctx context.Context, uc *upstreamConn, text string) {
username, password, ok := parseNickServCredentials(text, uc.nick) username, password, ok := parseNickServCredentials(text, uc.nick)
if !ok { if ok {
return uc.network.autoSaveSASLPlain(ctx, username, password)
}
// User may have e.g. EXTERNAL mechanism configured. We do not want to
// automatically erase the key pair or any other credentials.
if uc.network.SASL.Mechanism != "" && uc.network.SASL.Mechanism != "PLAIN" {
return
}
dc.logger.Printf("auto-saving NickServ credentials with username %q", username)
n := uc.network
n.SASL.Mechanism = "PLAIN"
n.SASL.Plain.Username = username
n.SASL.Plain.Password = password
if err := dc.srv.db.StoreNetwork(ctx, dc.user.ID, &n.Network); err != nil {
dc.logger.Printf("failed to save NickServ credentials: %v", err)
} }
} }

View File

@ -31,6 +31,7 @@ var permanentUpstreamCaps = map[string]bool{
"labeled-response": true, "labeled-response": true,
"message-tags": true, "message-tags": true,
"multi-prefix": true, "multi-prefix": true,
"sasl": true,
"server-time": true, "server-time": true,
"setname": true, "setname": true,
@ -293,6 +294,12 @@ func (uc *upstreamConn) endPendingCommands() {
Command: irc.RPL_ENDOFWHO, Command: irc.RPL_ENDOFWHO,
Params: []string{dc.nick, mask, "End of /WHO"}, Params: []string{dc.nick, mask, "End of /WHO"},
}) })
case "AUTHENTICATE":
dc.endSASL(&irc.Message{
Prefix: dc.srv.prefix(),
Command: irc.ERR_SASLABORTED,
Params: []string{dc.nick, "SASL authentication aborted"},
})
default: default:
panic(fmt.Errorf("Unsupported pending command %q", pendingCmd.msg.Command)) panic(fmt.Errorf("Unsupported pending command %q", pendingCmd.msg.Command))
} }
@ -311,7 +318,7 @@ func (uc *upstreamConn) sendNextPendingCommand(cmd string) {
func (uc *upstreamConn) enqueueCommand(dc *downstreamConn, msg *irc.Message) { func (uc *upstreamConn) enqueueCommand(dc *downstreamConn, msg *irc.Message) {
switch msg.Command { switch msg.Command {
case "LIST", "WHO": case "LIST", "WHO", "AUTHENTICATE":
// Supported // Supported
default: default:
panic(fmt.Errorf("Unsupported pending command %q", msg.Command)) panic(fmt.Errorf("Unsupported pending command %q", msg.Command))
@ -612,10 +619,20 @@ func (uc *upstreamConn) handleMessage(msg *irc.Message) error {
uc.saslClient = nil uc.saslClient = nil
uc.saslStarted = false uc.saslStarted = false
if dc, _ := uc.dequeueCommand("AUTHENTICATE"); dc != nil && dc.sasl != nil {
if msg.Command == irc.RPL_SASLSUCCESS {
uc.network.autoSaveSASLPlain(context.TODO(), dc.sasl.plainUsername, dc.sasl.plainPassword)
}
dc.endSASL(msg)
}
if !uc.registered {
uc.SendMessage(&irc.Message{ uc.SendMessage(&irc.Message{
Command: "CAP", Command: "CAP",
Params: []string{"END"}, Params: []string{"END"},
}) })
}
case irc.RPL_WELCOME: case irc.RPL_WELCOME:
uc.registered = true uc.registered = true
uc.logger.Printf("connection registered") uc.logger.Printf("connection registered")
@ -1704,10 +1721,6 @@ func (uc *upstreamConn) requestCaps() {
} }
} }
if uc.requestSASL() && !uc.caps["sasl"] {
requestCaps = append(requestCaps, "sasl")
}
if len(requestCaps) == 0 { if len(requestCaps) == 0 {
return return
} }
@ -1749,6 +1762,9 @@ func (uc *upstreamConn) handleCapAck(name string, ok bool) error {
switch name { switch name {
case "sasl": case "sasl":
if !uc.requestSASL() {
return nil
}
if !ok { if !ok {
uc.logger.Printf("server refused to acknowledge the SASL capability") uc.logger.Printf("server refused to acknowledge the SASL capability")
return nil return nil

16
user.go
View File

@ -404,6 +404,22 @@ func (net *network) detachedMessageNeedsRelay(ch *Channel, msg *irc.Message) boo
return ch.RelayDetached == FilterMessage || ((ch.RelayDetached == FilterHighlight || ch.RelayDetached == FilterDefault) && highlight) return ch.RelayDetached == FilterMessage || ((ch.RelayDetached == FilterHighlight || ch.RelayDetached == FilterDefault) && highlight)
} }
func (net *network) autoSaveSASLPlain(ctx context.Context, username, password string) {
// User may have e.g. EXTERNAL mechanism configured. We do not want to
// automatically erase the key pair or any other credentials.
if net.SASL.Mechanism != "" && net.SASL.Mechanism != "PLAIN" {
return
}
net.logger.Printf("auto-saving SASL PLAIN credentials with username %q", username)
net.SASL.Mechanism = "PLAIN"
net.SASL.Plain.Username = username
net.SASL.Plain.Password = password
if err := net.user.srv.db.StoreNetwork(ctx, net.user.ID, &net.Network); err != nil {
net.logger.Printf("failed to save SASL PLAIN credentials: %v", err)
}
}
type user struct { type user struct {
User User
srv *Server srv *Server