Add SASL auth and CAP negotiation

This commit is contained in:
Ken-Håvard Lieng 2020-05-23 08:05:37 +02:00
parent be8b785813
commit 2f8dad2529
18 changed files with 563 additions and 127 deletions

File diff suppressed because one or more lines are too long

@ -408,6 +408,17 @@ i[class*=' icon-']:before {
width: 100%; width: 100%;
} }
.connect-section {
border-bottom: 1px solid #ddd;
padding-bottom: 15px;
margin: 15px 0;
margin-bottom: 10px;
}
.connect-section h2 {
margin-bottom: 10px;
}
input[type='number'] { input[type='number'] {
appearance: textfield; appearance: textfield;
} }
@ -806,7 +817,6 @@ input.message-input-nick.invalid {
} }
.settings h2 { .settings h2 {
font-weight: 700;
color: #222; color: #222;
} }

@ -1,13 +1,13 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { Form, withFormik } from 'formik'; import { Form, withFormik } from 'formik';
import { FiMoreHorizontal } from 'react-icons/fi';
import Navicon from 'components/ui/Navicon'; import Navicon from 'components/ui/Navicon';
import Button from 'components/ui/Button'; import Button from 'components/ui/Button';
import Checkbox from 'components/ui/formik/Checkbox'; import Checkbox from 'components/ui/formik/Checkbox';
import TextInput from 'components/ui/TextInput'; import TextInput from 'components/ui/TextInput';
import Error from 'components/ui/formik/Error'; import Error from 'components/ui/formik/Error';
import { isValidNick, isValidChannel, isValidUsername, isInt } from 'utils'; import { isValidNick, isValidChannel, isValidUsername, isInt } from 'utils';
import { FiMoreHorizontal } from 'react-icons/fi';
const getSortedDefaultChannels = createSelector( const getSortedDefaultChannels = createSelector(
defaults => defaults.channels, defaults => defaults.channels,
@ -56,11 +56,21 @@ class Connect extends Component {
const { hexIP } = this.props; const { hexIP } = this.props;
return ( return (
<div> <>
<div className="connect-section">
<h2>SASL</h2>
<TextInput name="account" />
<TextInput name="password" type="password" />
</div>
{!hexIP && <TextInput name="username" />} {!hexIP && <TextInput name="username" />}
<TextInput name="password" type="password" noTrim /> <TextInput
name="serverPassword"
label="Server Password"
type="password"
noTrim
/>
<TextInput name="realname" noTrim /> <TextInput name="realname" noTrim />
</div> </>
); );
}; };
@ -170,8 +180,10 @@ export default withFormik({
port, port,
nick: query.nick || localStorage.lastNick || '', nick: query.nick || localStorage.lastNick || '',
channels: channels || defaults.channels.join(','), channels: channels || defaults.channels.join(','),
account: '',
password: '',
username: query.username || '', username: query.username || '',
password: defaults.password ? ' ' : '', serverPassword: defaults.serverPassword ? ' ' : '',
realname: query.realname || localStorage.lastRealname || '', realname: query.realname || localStorage.lastRealname || '',
tls: ssl tls: ssl
}; };
@ -219,7 +231,7 @@ export default withFormik({
const channels = values.channels ? values.channels.split(',') : []; const channels = values.channels ? values.channels.split(',') : [];
delete values.channels; delete values.channels;
values.password = values.password.trim(); values.serverPassword = values.serverPassword.trim();
values.port = `${values.port}`; values.port = `${values.port}`;
connect(values); connect(values);

@ -13,7 +13,7 @@ port = 6697
channels = [ channels = [
"#dispatch" "#dispatch"
] ]
password = "" server_password = ""
ssl = true ssl = true
# Only allow a nick to be filled in # Only allow a nick to be filled in
readonly = false readonly = false

@ -23,14 +23,14 @@ type Config struct {
} }
type Defaults struct { type Defaults struct {
Name string Name string
Host string Host string
Port string Port string
Channels []string Channels []string
Password string ServerPassword string `mapstructure:"server_password"`
SSL bool SSL bool
ReadOnly bool ReadOnly bool
ShowDetails bool `mapstructure:"show_details"` ShowDetails bool `mapstructure:"show_details"`
} }
type HTTPS struct { type HTTPS struct {

129
pkg/irc/cap.go Normal file

@ -0,0 +1,129 @@
package irc
import (
"strings"
)
func (c *Client) HasCapability(name string, values ...string) bool {
if capValues, ok := c.enabledCapabilities[name]; ok {
if len(values) == 0 || capValues == nil {
return true
}
for _, v := range values {
for _, vCap := range capValues {
if v == vCap {
return true
}
}
}
}
return false
}
var clientWantedCaps = []string{}
func (c *Client) writeCAP() {
c.write("CAP LS 302")
}
func (c *Client) handleCAP(msg *Message) {
if len(msg.Params) < 3 {
c.write("CAP END")
return
}
caps := parseCaps(msg.LastParam())
c.lock.Lock()
defer c.lock.Unlock()
switch msg.Params[1] {
case "LS":
for cap, values := range caps {
for _, wanted := range c.wantedCapabilities {
if cap == wanted {
c.requestedCapabilities[cap] = values
}
}
}
if len(msg.Params) == 3 {
if len(c.requestedCapabilities) == 0 {
c.write("CAP END")
return
}
reqCaps := []string{}
for cap := range c.requestedCapabilities {
reqCaps = append(reqCaps, cap)
}
c.write("CAP REQ :" + strings.Join(reqCaps, " "))
}
case "ACK":
for cap := range caps {
if v, ok := c.requestedCapabilities[cap]; ok {
c.enabledCapabilities[cap] = v
delete(c.requestedCapabilities, cap)
}
}
if len(c.requestedCapabilities) == 0 {
if c.SASL != nil && c.HasCapability("sasl", c.SASL.Name()) {
c.write("AUTHENTICATE " + c.SASL.Name())
} else {
c.write("CAP END")
}
}
case "NAK":
for cap := range caps {
delete(c.requestedCapabilities, cap)
}
if len(c.requestedCapabilities) == 0 {
c.write("CAP END")
}
case "NEW":
reqCaps := []string{}
for cap, values := range caps {
for _, wanted := range c.wantedCapabilities {
if cap == wanted && !c.HasCapability(cap) {
c.requestedCapabilities[cap] = values
reqCaps = append(reqCaps, cap)
}
}
}
if len(reqCaps) > 0 {
c.write("CAP REQ :" + strings.Join(reqCaps, " "))
}
case "DEL":
for cap := range caps {
delete(c.enabledCapabilities, cap)
}
}
}
func parseCaps(caps string) map[string][]string {
result := map[string][]string{}
parts := strings.Split(caps, " ")
for _, part := range parts {
capParts := strings.Split(part, "=")
name := capParts[0]
if len(capParts) > 1 {
result[name] = strings.Split(capParts[1], ",")
} else {
result[name] = nil
}
}
return result
}

58
pkg/irc/cap_test.go Normal file

@ -0,0 +1,58 @@
package irc
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseCaps(t *testing.T) {
cases := []struct {
input string
expected map[string][]string
}{
{
"sasl",
map[string][]string{
"sasl": nil,
},
}, {
"sasl=PLAIN",
map[string][]string{
"sasl": {"PLAIN"},
},
}, {
"cake sasl=PLAIN",
map[string][]string{
"cake": nil,
"sasl": {"PLAIN"},
},
}, {
"cake sasl=PLAIN pie",
map[string][]string{
"cake": nil,
"sasl": {"PLAIN"},
"pie": nil,
},
}, {
"cake sasl=PLAIN pie=BLUEBERRY,RASPBERRY",
map[string][]string{
"cake": nil,
"sasl": {"PLAIN"},
"pie": {"BLUEBERRY", "RASPBERRY"},
},
}, {
"cake sasl=PLAIN pie=BLUEBERRY,RASPBERRY cheesecake",
map[string][]string{
"cake": nil,
"sasl": {"PLAIN"},
"pie": {"BLUEBERRY", "RASPBERRY"},
"cheesecake": nil,
},
},
}
for _, tc := range cases {
assert.Equal(t, tc.expected, parseCaps(tc.input))
}
}

@ -19,6 +19,7 @@ type Client struct {
Password string Password string
Username string Username string
Realname string Realname string
SASL SASL
HandleNickInUse func(string) string HandleNickInUse func(string) string
// Version is the reply to VERSION and FINGER CTCP messages // Version is the reply to VERSION and FINGER CTCP messages
@ -32,6 +33,10 @@ type Client struct {
nick string nick string
channels []string channels []string
wantedCapabilities []string
requestedCapabilities map[string][]string
enabledCapabilities map[string][]string
conn net.Conn conn net.Conn
connected bool connected bool
registered bool registered bool
@ -49,17 +54,20 @@ type Client struct {
func NewClient(nick, username string) *Client { func NewClient(nick, username string) *Client {
return &Client{ return &Client{
nick: nick, nick: nick,
Features: NewFeatures(), Features: NewFeatures(),
Username: username, Username: username,
Realname: nick, Realname: nick,
Messages: make(chan *Message, 32), Messages: make(chan *Message, 32),
ConnectionChanged: make(chan ConnectionState, 4), ConnectionChanged: make(chan ConnectionState, 4),
out: make(chan string, 32), out: make(chan string, 32),
quit: make(chan struct{}), quit: make(chan struct{}),
reconnect: make(chan struct{}), reconnect: make(chan struct{}),
dialer: &net.Dialer{Timeout: 10 * time.Second}, wantedCapabilities: clientWantedCaps,
recvBuf: make([]byte, 0, 4096), enabledCapabilities: map[string][]string{},
requestedCapabilities: map[string][]string{},
dialer: &net.Dialer{Timeout: 10 * time.Second},
recvBuf: make([]byte, 0, 4096),
backoff: &backoff.Backoff{ backoff: &backoff.Backoff{
Min: 500 * time.Millisecond, Min: 500 * time.Millisecond,
Max: 30 * time.Second, Max: 30 * time.Second,
@ -191,6 +199,11 @@ func (c *Client) writeUser(username, realname string) {
} }
func (c *Client) register() { func (c *Client) register() {
if c.SASL != nil {
c.wantedCapabilities = append(c.wantedCapabilities, "sasl")
}
c.writeCAP()
if c.Password != "" { if c.Password != "" {
c.writePass(c.Password) c.writePass(c.Password)
} }

@ -152,11 +152,13 @@ func TestRegister(t *testing.T) {
c.Username = "user" c.Username = "user"
c.Realname = "rn" c.Realname = "rn"
c.register() c.register()
assert.Equal(t, "CAP LS 302\r\n", <-out)
assert.Equal(t, "NICK nick\r\n", <-out) assert.Equal(t, "NICK nick\r\n", <-out)
assert.Equal(t, "USER user 0 * :rn\r\n", <-out) assert.Equal(t, "USER user 0 * :rn\r\n", <-out)
c.Password = "pass" c.Password = "pass"
c.register() c.register()
assert.Equal(t, "CAP LS 302\r\n", <-out)
assert.Equal(t, "PASS pass\r\n", <-out) assert.Equal(t, "PASS pass\r\n", <-out)
assert.Equal(t, "NICK nick\r\n", <-out) assert.Equal(t, "NICK nick\r\n", <-out)
assert.Equal(t, "USER user 0 * :rn\r\n", <-out) assert.Equal(t, "USER user 0 * :rn\r\n", <-out)

@ -226,6 +226,9 @@ func (c *Client) recv() {
c.handleCTCP(ctcp, msg) c.handleCTCP(ctcp, msg)
} }
case CAP:
c.handleCAP(msg)
case RPL_WELCOME: case RPL_WELCOME:
c.setNick(msg.Params[0]) c.setNick(msg.Params[0])
c.setRegistered(true) c.setRegistered(true)
@ -251,6 +254,8 @@ func (c *Client) recv() {
return return
} }
c.handleSASL(msg)
c.Messages <- msg c.Messages <- msg
} }
} }

64
pkg/irc/sasl.go Normal file

@ -0,0 +1,64 @@
package irc
import (
"bytes"
"encoding/base64"
)
type SASL interface {
Name() string
Encode() string
}
type SASLPlain struct {
Username string
Password string
}
func (s *SASLPlain) Name() string {
return "PLAIN"
}
func (s *SASLPlain) Encode() string {
buf := bytes.Buffer{}
buf.WriteString(s.Username)
buf.WriteByte(0x0)
buf.WriteString(s.Username)
buf.WriteByte(0x0)
buf.WriteString(s.Password)
return base64.StdEncoding.EncodeToString(buf.Bytes())
}
type SASLExternal struct{}
func (s *SASLExternal) Name() string {
return "EXTERNAL"
}
func (s *SASLExternal) Encode() string {
return "+"
}
func (c *Client) handleSASL(msg *Message) {
switch msg.Command {
case AUTHENTICATE:
auth := c.SASL.Encode()
for len(auth) >= 400 {
c.write("AUTHENTICATE " + auth)
auth = auth[400:]
}
if len(auth) > 0 {
c.write("AUTHENTICATE " + auth)
} else {
c.write("AUTHENTICATE +")
}
case RPL_SASLSUCCESS:
c.write("CAP END")
case ERR_NICKLOCKED, ERR_SASLFAIL, ERR_SASLTOOLONG, ERR_SASLABORTED, RPL_SASLMECHS:
c.write("CAP END")
}
}

@ -12,7 +12,7 @@ import (
type connectDefaults struct { type connectDefaults struct {
*config.Defaults *config.Defaults
Password bool ServerPassword bool
} }
type dispatchVersion struct { type dispatchVersion struct {
@ -51,7 +51,7 @@ func (d *Dispatch) getIndexData(r *http.Request, state *State) *indexData {
}, },
} }
data.Defaults.Password = cfg.Defaults.Password != "" data.Defaults.ServerPassword = cfg.Defaults.ServerPassword != ""
if state == nil { if state == nil {
data.Settings = storage.DefaultClientSettings() data.Settings = storage.DefaultClientSettings()

@ -553,8 +553,8 @@ func easyjson7e607aefDecodeGithubComKhliengDispatchServer2(in *jlexer.Lexer, out
continue continue
} }
switch key { switch key {
case "password": case "serverPassword":
out.Password = bool(in.Bool()) out.ServerPassword = bool(in.Bool())
case "name": case "name":
out.Name = string(in.String()) out.Name = string(in.String())
case "host": case "host":
@ -604,11 +604,11 @@ func easyjson7e607aefEncodeGithubComKhliengDispatchServer2(out *jwriter.Writer,
out.RawByte('{') out.RawByte('{')
first := true first := true
_ = first _ = first
if in.Password { if in.ServerPassword {
const prefix string = ",\"password\":" const prefix string = ",\"serverPassword\":"
first = false first = false
out.RawString(prefix[1:]) out.RawString(prefix[1:])
out.Bool(bool(in.Password)) out.Bool(bool(in.ServerPassword))
} }
if in.Name != "" { if in.Name != "" {
const prefix string = ",\"name\":" const prefix string = ",\"name\":"

@ -55,12 +55,19 @@ func connectIRC(server *storage.Server, state *State, srcIP []byte) *irc.Client
i.Realname = server.Nick i.Realname = server.Nick
} }
if server.Password == "" && if server.ServerPassword == "" &&
cfg.Defaults.Password != "" && cfg.Defaults.ServerPassword != "" &&
address == cfg.Defaults.Host { address == cfg.Defaults.Host {
i.Password = cfg.Defaults.Password i.Password = cfg.Defaults.ServerPassword
} else { } else {
i.Password = server.Password i.Password = server.ServerPassword
}
if server.Account != "" && server.Password != "" {
i.SASL = &irc.SASLPlain{
Username: server.Account,
Password: server.Password,
}
} }
if i.TLS { if i.TLS {

@ -866,14 +866,18 @@ func easyjson42239ddeDecodeGithubComKhliengDispatchServer8(in *jlexer.Lexer, out
out.Port = string(in.String()) out.Port = string(in.String())
case "tls": case "tls":
out.TLS = bool(in.Bool()) out.TLS = bool(in.Bool())
case "password": case "serverPassword":
out.Password = string(in.String()) out.ServerPassword = string(in.String())
case "nick": case "nick":
out.Nick = string(in.String()) out.Nick = string(in.String())
case "username": case "username":
out.Username = string(in.String()) out.Username = string(in.String())
case "realname": case "realname":
out.Realname = string(in.String()) out.Realname = string(in.String())
case "account":
out.Account = string(in.String())
case "password":
out.Password = string(in.String())
default: default:
in.SkipRecursive() in.SkipRecursive()
} }
@ -964,15 +968,15 @@ func easyjson42239ddeEncodeGithubComKhliengDispatchServer8(out *jwriter.Writer,
} }
out.Bool(bool(in.TLS)) out.Bool(bool(in.TLS))
} }
if in.Password != "" { if in.ServerPassword != "" {
const prefix string = ",\"password\":" const prefix string = ",\"serverPassword\":"
if first { if first {
first = false first = false
out.RawString(prefix[1:]) out.RawString(prefix[1:])
} else { } else {
out.RawString(prefix) out.RawString(prefix)
} }
out.String(string(in.Password)) out.String(string(in.ServerPassword))
} }
if in.Nick != "" { if in.Nick != "" {
const prefix string = ",\"nick\":" const prefix string = ",\"nick\":"
@ -1004,6 +1008,26 @@ func easyjson42239ddeEncodeGithubComKhliengDispatchServer8(out *jwriter.Writer,
} }
out.String(string(in.Realname)) out.String(string(in.Realname))
} }
if in.Account != "" {
const prefix string = ",\"account\":"
if first {
first = false
out.RawString(prefix[1:])
} else {
out.RawString(prefix)
}
out.String(string(in.Account))
}
if in.Password != "" {
const prefix string = ",\"password\":"
if first {
first = false
out.RawString(prefix[1:])
} else {
out.RawString(prefix)
}
out.String(string(in.Password))
}
out.RawByte('}') out.RawByte('}')
} }

@ -14,10 +14,12 @@ struct Server {
Host string Host string
Port string Port string
TLS bool TLS bool
Password string ServerPassword string
Nick string Nick string
Username string Username string
Realname string Realname string
Account string
Password string
} }
struct Channel { struct Channel {

@ -286,7 +286,7 @@ func (d *Server) Size() (s uint64) {
s += l s += l
} }
{ {
l := uint64(len(d.Password)) l := uint64(len(d.ServerPassword))
{ {
@ -345,6 +345,36 @@ func (d *Server) Size() (s uint64) {
} }
s += l s += l
} }
{
l := uint64(len(d.Account))
{
t := l
for t >= 0x80 {
t >>= 7
s++
}
s++
}
s += l
}
{
l := uint64(len(d.Password))
{
t := l
for t >= 0x80 {
t >>= 7
s++
}
s++
}
s += l
}
s += 1 s += 1
return return
} }
@ -424,7 +454,7 @@ func (d *Server) Marshal(buf []byte) ([]byte, error) {
} }
} }
{ {
l := uint64(len(d.Password)) l := uint64(len(d.ServerPassword))
{ {
@ -439,7 +469,7 @@ func (d *Server) Marshal(buf []byte) ([]byte, error) {
i++ i++
} }
copy(buf[i+1:], d.Password) copy(buf[i+1:], d.ServerPassword)
i += l i += l
} }
{ {
@ -499,6 +529,44 @@ func (d *Server) Marshal(buf []byte) ([]byte, error) {
copy(buf[i+1:], d.Realname) copy(buf[i+1:], d.Realname)
i += l i += l
} }
{
l := uint64(len(d.Account))
{
t := uint64(l)
for t >= 0x80 {
buf[i+1] = byte(t) | 0x80
t >>= 7
i++
}
buf[i+1] = byte(t)
i++
}
copy(buf[i+1:], d.Account)
i += l
}
{
l := uint64(len(d.Password))
{
t := uint64(l)
for t >= 0x80 {
buf[i+1] = byte(t) | 0x80
t >>= 7
i++
}
buf[i+1] = byte(t)
i++
}
copy(buf[i+1:], d.Password)
i += l
}
return buf[:i+1], nil return buf[:i+1], nil
} }
@ -585,7 +653,7 @@ func (d *Server) Unmarshal(buf []byte) (uint64, error) {
l = t l = t
} }
d.Password = string(buf[i+1 : i+1+l]) d.ServerPassword = string(buf[i+1 : i+1+l])
i += l i += l
} }
{ {
@ -648,6 +716,46 @@ func (d *Server) Unmarshal(buf []byte) (uint64, error) {
d.Realname = string(buf[i+1 : i+1+l]) d.Realname = string(buf[i+1 : i+1+l])
i += l i += l
} }
{
l := uint64(0)
{
bs := uint8(7)
t := uint64(buf[i+1] & 0x7F)
for buf[i+1]&0x80 == 0x80 {
i++
t |= uint64(buf[i+1]&0x7F) << bs
bs += 7
}
i++
l = t
}
d.Account = string(buf[i+1 : i+1+l])
i += l
}
{
l := uint64(0)
{
bs := uint8(7)
t := uint64(buf[i+1] & 0x7F)
for buf[i+1]&0x80 == 0x80 {
i++
t |= uint64(buf[i+1]&0x7F) << bs
bs += 7
}
i++
l = t
}
d.Password = string(buf[i+1 : i+1+l])
i += l
}
return i + 1, nil return i + 1, nil
} }

@ -132,14 +132,16 @@ func (u *User) UnmarshalClientSettingsJSON(b []byte) error {
} }
type Server struct { type Server struct {
Name string Name string
Host string Host string
Port string Port string
TLS bool TLS bool
Password string ServerPassword string
Nick string Nick string
Username string Username string
Realname string Realname string
Account string
Password string
} }
func (u *User) GetServer(address string) (*Server, error) { func (u *User) GetServer(address string) (*Server, error) {