add support for rpc auth
example: monerod --rpc-login foo:foo (...) monero daemon -u foo -p foo get-version new flags -p, --password string password to supply for rpc auth -u, --username string name of the user to use during rpc auth Signed-off-by: Ciro S. Costa <utxobr@protonmail.com>
This commit is contained in:
parent
096ad758c2
commit
e78b200d02
|
@ -122,7 +122,13 @@ Available Commands:
|
|||
Flags:
|
||||
-a, --address string full address of the monero node to reach out to (default "http://localhost:18081")
|
||||
-h, --help help for daemon
|
||||
--request-timeout duration how long to wait until considering the request a failure (default 1m0s)
|
||||
-p, --password string password to supply for rpc auth
|
||||
--request-timeout duration max wait time until considering the request a failure (default 1m0s)
|
||||
--tls-ca-cert string certificate authority to load
|
||||
--tls-client-cert string tls client certificate to use when connecting
|
||||
--tls-client-key string tls client key to use when connecting
|
||||
-k, --tls-skip-verify skip verification of certificate chain and host name
|
||||
-u, --username string name of the user to use during rpc auth
|
||||
-v, --verbose dump http requests and responses to stderr
|
||||
|
||||
Use "monero daemon [command] --help" for more information about a command.
|
||||
|
|
|
@ -83,6 +83,16 @@ func Bind(cmd *cobra.Command) {
|
|||
"http://localhost:18081",
|
||||
"full address of the monero node to reach out to")
|
||||
|
||||
cmd.PersistentFlags().StringVarP(&RootOptions.Username,
|
||||
"username", "u",
|
||||
"",
|
||||
"name of the user to use during rpc auth")
|
||||
|
||||
cmd.PersistentFlags().StringVarP(&RootOptions.Password,
|
||||
"password", "p",
|
||||
"",
|
||||
"password to supply for rpc auth")
|
||||
|
||||
cmd.PersistentFlags().BoolVarP(&RootOptions.TLSSkipVerify,
|
||||
"tls-skip-verify", "k",
|
||||
false,
|
||||
|
|
|
@ -44,6 +44,23 @@ type ClientConfig struct {
|
|||
// client.
|
||||
//
|
||||
RequestTimeout time.Duration
|
||||
|
||||
// Username is the name of the user to send in the header of every HTTP
|
||||
// call - must match the first portion of
|
||||
// `--rpc-login=<username>:[password]` provided to `monerod`.
|
||||
//
|
||||
Username string
|
||||
|
||||
// Password is the user's password to send in the header of every HTTP
|
||||
// call - must match the second portion of
|
||||
// `--rpc-login=<username>:[password]` provided to `monerod` or the
|
||||
// password interactively supplied during the daemon's startup.
|
||||
//
|
||||
// Note that because the `monerod` performs digest auth, the password
|
||||
// won't be sent solely in plain base64 encoding, but the rest of the
|
||||
// body of every request and response will still be cleartext.
|
||||
//
|
||||
Password string
|
||||
}
|
||||
|
||||
func (c ClientConfig) Validate() error {
|
||||
|
@ -57,6 +74,14 @@ func (c ClientConfig) Validate() error {
|
|||
"tls client key")
|
||||
}
|
||||
|
||||
if c.Username != "" && c.Password == "" {
|
||||
return fmt.Errorf("username specified but password not")
|
||||
}
|
||||
|
||||
if c.Password != "" && c.Username == "" {
|
||||
return fmt.Errorf("password specified but username not")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -100,8 +125,21 @@ func NewClient(cfg ClientConfig) (*http.Client, error) {
|
|||
}
|
||||
|
||||
if cfg.Verbose {
|
||||
client.Transport = NewDumpTransport(client.Transport)
|
||||
WithTransport(NewDumpTransport(client.Transport))(client)
|
||||
}
|
||||
|
||||
if cfg.Username != "" {
|
||||
WithTransport(NewDigestAuthTransport(
|
||||
cfg.Username, cfg.Password,
|
||||
client.Transport,
|
||||
))(client)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func WithTransport(rt http.RoundTripper) func(*http.Client) {
|
||||
return func(c *http.Client) {
|
||||
c.Transport = rt
|
||||
}
|
||||
}
|
||||
|
|
270
pkg/http/digest_auth.go
Normal file
270
pkg/http/digest_auth.go
Normal file
|
@ -0,0 +1,270 @@
|
|||
//
|
||||
// This is a derived work based on `code.google.com/p/mlab-ns2/gae/ns/digest`
|
||||
// (original work of Bipasa Chattopadhyay bipasa@cs.unc.edu Eric Gavaletz
|
||||
// gavaletz@gmail.com Seon-Wook Park seon.wook@swook.net, from the fork
|
||||
// maintained by Bob Ziuchkovski @bobziuchkovski
|
||||
// (https://github.com/rkl-/digest).
|
||||
//
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNilTransport = errors.New("Transport is nil")
|
||||
ErrBadChallenge = errors.New("Challenge is bad")
|
||||
ErrAlgNotImplemented = errors.New("algorithm not implemented")
|
||||
)
|
||||
|
||||
// DigestAuthTransport is an implementation of http.RoundTripper that takes
|
||||
// care of http digest authentication.
|
||||
//
|
||||
type DigestAuthTransport struct {
|
||||
Username string
|
||||
Password string
|
||||
transport http.RoundTripper
|
||||
}
|
||||
|
||||
// NewDigestAuthTransport creates a new digest transport using the
|
||||
// http.DefaultTransport.
|
||||
//
|
||||
func NewDigestAuthTransport(
|
||||
username, password string, rt http.RoundTripper,
|
||||
) *DigestAuthTransport {
|
||||
return &DigestAuthTransport{
|
||||
Username: username,
|
||||
Password: password,
|
||||
transport: rt,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *DigestAuthTransport) newCredentials(
|
||||
req *http.Request, c *challenge,
|
||||
) *credentials {
|
||||
return &credentials{
|
||||
Algorithm: c.Algorithm,
|
||||
DigestURI: req.URL.RequestURI(),
|
||||
MessageQop: c.Qop, // "auth" must be a single value
|
||||
Nonce: c.Nonce,
|
||||
NonceCount: 0,
|
||||
Opaque: c.Opaque,
|
||||
Realm: c.Realm,
|
||||
Username: t.Username,
|
||||
|
||||
method: req.Method,
|
||||
password: t.Password,
|
||||
}
|
||||
}
|
||||
|
||||
// RoundTrip makes a request expecting a 401 response that will require digest
|
||||
// authentication. It creates the credentials it needs and makes a follow-up
|
||||
// request.
|
||||
//
|
||||
func (t *DigestAuthTransport) RoundTrip(
|
||||
req *http.Request,
|
||||
) (*http.Response, error) {
|
||||
// copy the request so we don't modify the input.
|
||||
req2 := new(http.Request)
|
||||
*req2 = *req
|
||||
req2.Header = make(http.Header)
|
||||
for k, s := range req.Header {
|
||||
req2.Header[k] = s
|
||||
}
|
||||
|
||||
// we need two readers for the body.
|
||||
if req.Body != nil {
|
||||
tmp, err := ioutil.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read all body: %w", err)
|
||||
}
|
||||
|
||||
reqBody01 := ioutil.NopCloser(bytes.NewBuffer(tmp))
|
||||
reqBody02 := ioutil.NopCloser(bytes.NewBuffer(tmp))
|
||||
|
||||
req.Body = reqBody01
|
||||
req2.Body = reqBody02
|
||||
}
|
||||
|
||||
// make a request to get the 401 that contains the challenge.
|
||||
resp, err := t.transport.RoundTrip(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("round trip err: %w", err)
|
||||
}
|
||||
if resp.StatusCode != 401 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
chal := resp.Header.Get("WWW-Authenticate")
|
||||
c, err := parseChallenge(chal)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse challange: %w", err)
|
||||
}
|
||||
|
||||
// form credentials based on the challenge.
|
||||
cr := t.newCredentials(req2, c)
|
||||
auth, err := cr.authorize()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authorize: %w", err)
|
||||
}
|
||||
|
||||
// we'll no longer use the initial response, so close it
|
||||
resp.Body.Close()
|
||||
// Make authenticated request.
|
||||
req2.Header.Set("Authorization", auth)
|
||||
return t.transport.RoundTrip(req2)
|
||||
}
|
||||
|
||||
type challenge struct {
|
||||
Realm string
|
||||
Domain string
|
||||
Nonce string
|
||||
Opaque string
|
||||
Stale string
|
||||
Algorithm string
|
||||
Qop string
|
||||
}
|
||||
|
||||
func parseChallenge(input string) (*challenge, error) {
|
||||
const challengePrefix = "Digest "
|
||||
const whitespaceDelimiters = " \n\r\t"
|
||||
const quotation = `"`
|
||||
|
||||
str := strings.Trim(input, whitespaceDelimiters)
|
||||
if !strings.HasPrefix(str, challengePrefix) {
|
||||
return nil, fmt.Errorf("bad challange: "+
|
||||
"input doesn't start with '%s'", challengePrefix)
|
||||
}
|
||||
|
||||
str = strings.Trim(str[len(challengePrefix):], whitespaceDelimiters)
|
||||
fields := strings.Split(str, ",")
|
||||
if len(fields) != 5 {
|
||||
return nil, fmt.Errorf("split: expected 5 fields, got %d",
|
||||
len(fields))
|
||||
}
|
||||
|
||||
c := &challenge{}
|
||||
|
||||
for _, field := range fields {
|
||||
kv := strings.SplitN(field, "=", 2)
|
||||
if len(kv) != 2 {
|
||||
return nil, fmt.Errorf("split: expected to 2 parts "+
|
||||
"in entry, got %d. field: '%s'",
|
||||
len(kv), field)
|
||||
}
|
||||
|
||||
key, value := kv[0], kv[1]
|
||||
value = strings.Trim(value, quotation)
|
||||
switch key {
|
||||
case "qop":
|
||||
c.Qop = value
|
||||
case "algorithm":
|
||||
c.Algorithm = value
|
||||
case "realm":
|
||||
c.Realm = value
|
||||
case "nonce":
|
||||
c.Nonce = value
|
||||
case "stale":
|
||||
c.Stale = value
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown field '%s'", key)
|
||||
}
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
type credentials struct {
|
||||
Algorithm string
|
||||
Cnonce string
|
||||
DigestURI string
|
||||
MessageQop string
|
||||
Nonce string
|
||||
NonceCount int
|
||||
Opaque string
|
||||
Realm string
|
||||
Username string
|
||||
|
||||
method string
|
||||
password string
|
||||
}
|
||||
|
||||
func (c *credentials) ha1() string {
|
||||
return h(fmt.Sprintf("%s:%s:%s", c.Username, c.Realm, c.password))
|
||||
}
|
||||
|
||||
func (c *credentials) ha2() string {
|
||||
return h(fmt.Sprintf("%s:%s", c.method, c.DigestURI))
|
||||
}
|
||||
|
||||
func (c *credentials) resp(cnonce string) (string, error) {
|
||||
c.NonceCount++
|
||||
|
||||
if c.MessageQop == "auth" {
|
||||
if cnonce != "" {
|
||||
c.Cnonce = cnonce
|
||||
} else {
|
||||
b := make([]byte, 8)
|
||||
io.ReadFull(rand.Reader, b)
|
||||
c.Cnonce = fmt.Sprintf("%x", b)[:16]
|
||||
}
|
||||
return kd(c.ha1(), fmt.Sprintf("%s:%08x:%s:%s:%s",
|
||||
c.Nonce, c.NonceCount, c.Cnonce, c.MessageQop, c.ha2())), nil
|
||||
} else if c.MessageQop == "" {
|
||||
return kd(c.ha1(), fmt.Sprintf("%s:%s", c.Nonce, c.ha2())), nil
|
||||
}
|
||||
|
||||
return "", ErrAlgNotImplemented
|
||||
}
|
||||
|
||||
func (c *credentials) authorize() (string, error) {
|
||||
// Note that this is only implemented for MD5 and NOT MD5-sess.
|
||||
// MD5-sess is rarely supported and those that do are a big mess.
|
||||
if c.Algorithm != "MD5" {
|
||||
return "", ErrAlgNotImplemented
|
||||
}
|
||||
// Note that this is NOT implemented for "qop=auth-int". Similarly the
|
||||
// auth-int server side implementations that do exist are a mess.
|
||||
if c.MessageQop != "auth" && c.MessageQop != "" {
|
||||
return "", ErrAlgNotImplemented
|
||||
}
|
||||
resp, err := c.resp("")
|
||||
if err != nil {
|
||||
return "", ErrAlgNotImplemented
|
||||
}
|
||||
sl := []string{fmt.Sprintf(`username="%s"`, c.Username)}
|
||||
sl = append(sl, fmt.Sprintf(`realm="%s"`, c.Realm))
|
||||
sl = append(sl, fmt.Sprintf(`nonce="%s"`, c.Nonce))
|
||||
sl = append(sl, fmt.Sprintf(`uri="%s"`, c.DigestURI))
|
||||
sl = append(sl, fmt.Sprintf(`response="%s"`, resp))
|
||||
if c.Algorithm != "" {
|
||||
sl = append(sl, fmt.Sprintf(`algorithm="%s"`, c.Algorithm))
|
||||
}
|
||||
if c.Opaque != "" {
|
||||
sl = append(sl, fmt.Sprintf(`opaque="%s"`, c.Opaque))
|
||||
}
|
||||
if c.MessageQop != "" {
|
||||
sl = append(sl, fmt.Sprintf("qop=%s", c.MessageQop))
|
||||
sl = append(sl, fmt.Sprintf("nc=%08x", c.NonceCount))
|
||||
sl = append(sl, fmt.Sprintf(`cnonce="%s"`, c.Cnonce))
|
||||
}
|
||||
return fmt.Sprintf("Digest %s", strings.Join(sl, ", ")), nil
|
||||
}
|
||||
|
||||
func h(data string) string {
|
||||
hf := md5.New()
|
||||
io.WriteString(hf, data)
|
||||
return fmt.Sprintf("%x", hf.Sum(nil))
|
||||
}
|
||||
|
||||
func kd(secret, data string) string {
|
||||
return h(fmt.Sprintf("%s:%s", secret, data))
|
||||
}
|
22
pkg/http/digest_auth_test.go
Normal file
22
pkg/http/digest_auth_test.go
Normal file
|
@ -0,0 +1,22 @@
|
|||
package http_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
mhttp "github.com/cirocosta/go-monero/pkg/http"
|
||||
)
|
||||
|
||||
func TestParseChallenge(t *testing.T) {
|
||||
input := `Digest qop="auth",algorithm=MD5,realm="monero-rpc",nonce="IdDHjxbfpLYP/KzjaxaOqA==",stale=false`
|
||||
|
||||
challenge, err := mhttp.ParseChallenge(input)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "auth", challenge.Qop)
|
||||
assert.Equal(t, "MD5", challenge.Algorithm)
|
||||
assert.Equal(t, "monero-rpc", challenge.Realm)
|
||||
assert.Equal(t, `IdDHjxbfpLYP/KzjaxaOqA==`, challenge.Nonce)
|
||||
assert.Equal(t, "false", challenge.Stale)
|
||||
}
|
3
pkg/http/export_test.go
Normal file
3
pkg/http/export_test.go
Normal file
|
@ -0,0 +1,3 @@
|
|||
package http
|
||||
|
||||
var ParseChallenge = parseChallenge
|
Reference in a new issue