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%;
}
.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'] {
appearance: textfield;
}
@ -806,7 +817,6 @@ input.message-input-nick.invalid {
}
.settings h2 {
font-weight: 700;
color: #222;
}

View File

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

View File

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

View File

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

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

View File

@ -152,11 +152,13 @@ func TestRegister(t *testing.T) {
c.Username = "user"
c.Realname = "rn"
c.register()
assert.Equal(t, "CAP LS 302\r\n", <-out)
assert.Equal(t, "NICK nick\r\n", <-out)
assert.Equal(t, "USER user 0 * :rn\r\n", <-out)
c.Password = "pass"
c.register()
assert.Equal(t, "CAP LS 302\r\n", <-out)
assert.Equal(t, "PASS pass\r\n", <-out)
assert.Equal(t, "NICK nick\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)
}
case CAP:
c.handleCAP(msg)
case RPL_WELCOME:
c.setNick(msg.Params[0])
c.setRegistered(true)
@ -251,6 +254,8 @@ func (c *Client) recv() {
return
}
c.handleSASL(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 {
*config.Defaults
Password bool
ServerPassword bool
}
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 {
data.Settings = storage.DefaultClientSettings()

View File

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

View File

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

View File

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

View File

@ -286,7 +286,7 @@ func (d *Server) Size() (s uint64) {
s += l
}
{
l := uint64(len(d.Password))
l := uint64(len(d.ServerPassword))
{
@ -345,6 +345,36 @@ func (d *Server) Size() (s uint64) {
}
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
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++
}
copy(buf[i+1:], d.Password)
copy(buf[i+1:], d.ServerPassword)
i += l
}
{
@ -499,6 +529,44 @@ func (d *Server) Marshal(buf []byte) ([]byte, error) {
copy(buf[i+1:], d.Realname)
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
}
@ -585,7 +653,7 @@ func (d *Server) Unmarshal(buf []byte) (uint64, error) {
l = t
}
d.Password = string(buf[i+1 : i+1+l])
d.ServerPassword = string(buf[i+1 : i+1+l])
i += l
}
{
@ -648,6 +716,46 @@ func (d *Server) Unmarshal(buf []byte) (uint64, error) {
d.Realname = 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.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
}

View File

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