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

View File

@ -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;
} }

View File

@ -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> <>
{!hexIP && <TextInput name="username" />} <div className="connect-section">
<TextInput name="password" type="password" noTrim /> <h2>SASL</h2>
<TextInput name="realname" noTrim /> <TextInput name="account" />
<TextInput name="password" type="password" />
</div> </div>
{!hexIP && <TextInput name="username" />}
<TextInput
name="serverPassword"
label="Server Password"
type="password"
noTrim
/>
<TextInput name="realname" noTrim />
</>
); );
}; };
@ -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);

View File

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

View File

@ -27,7 +27,7 @@ type Defaults struct {
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"`

129
pkg/irc/cap.go Normal file
View 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
View 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))
}
}

View File

@ -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
@ -58,6 +63,9 @@ func NewClient(nick, username string) *Client {
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{}),
wantedCapabilities: clientWantedCaps,
enabledCapabilities: map[string][]string{},
requestedCapabilities: map[string][]string{},
dialer: &net.Dialer{Timeout: 10 * time.Second}, dialer: &net.Dialer{Timeout: 10 * time.Second},
recvBuf: make([]byte, 0, 4096), recvBuf: make([]byte, 0, 4096),
backoff: &backoff.Backoff{ backoff: &backoff.Backoff{
@ -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)
} }

View File

@ -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)

View File

@ -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
View 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")
}
}

View File

@ -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()

View File

@ -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\":"

View File

@ -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 {

View File

@ -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('}')
} }

View File

@ -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 {

View File

@ -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
} }

View File

@ -136,10 +136,12 @@ type Server struct {
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) {