-
Notifications
You must be signed in to change notification settings - Fork 518
exp/services/webauth: add SEP-10 v1.2.0 implementation #2074
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
38b769c
b738015
41ea106
4f60806
d19b9c4
a5ce7fa
fa8c22c
223e13b
fa66f33
5e8bf2e
dbe1522
78e6d1d
d172565
d014da9
69b05e2
f5575e5
1f0457d
271b5aa
a45dfe6
b5a7734
5beab66
03dbca2
97d33e5
6aba5cc
a1a8106
6b7e851
8a11519
79c0fee
6dc1db5
abad794
100b32f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
# webauth | ||
|
||
This is a [SEP-10] Web Authentication implementation based on SEP-10 v1.2.0 | ||
that requires the master key have a high threshold for authentication to | ||
succeed. | ||
|
||
SEP-10 defines an endpoint for authenticating a user in possession of a Stellar | ||
account using their Stellar account as credentials. This implementation is a | ||
standalone microservice that implements the minimum requirements as defined by | ||
the SEP-10 protocol and will be adapted as the protocol evolves. | ||
|
||
This implementation is not polished and is still experimental. | ||
Running this implementation in production is not recommended. | ||
|
||
## Usage | ||
|
||
```` | ||
$ webauth --help | ||
SEP-10 Web Authentication Server | ||
|
||
Usage: | ||
webauth [command] [flags] | ||
webauth [command] | ||
|
||
Available Commands: | ||
genjwtkey Generate a JWT ECDSA key | ||
serve Run the SEP-10 Web Authentication server | ||
|
||
Flags: | ||
-h, --help help for webauth | ||
|
||
Use "webauth [command] --help" for more information about a command. | ||
``` | ||
|
||
## Usage: Serve | ||
|
||
``` | ||
$ webauth serve --help | ||
Run the SEP-10 Web Authentication server | ||
|
||
Usage: | ||
webauth serve [flags] | ||
|
||
Flags: | ||
--challenge-expires-in int The time period in seconds after which the challenge transaction expires (default 300) | ||
--horizon-url string Horizon URL used for looking up account details (default "https://horizon-testnet.stellar.org/") | ||
--jwt-expires-in int The time period in seconds after which the JWT expires (default 300) | ||
--jwt-key string Base64 encoded ECDSA private key used for signing JWTs | ||
--network-passphrase string Network passphrase of the Stellar network transactions should be signed for (default "Test SDF Network ; September 2015") | ||
--port int Port to listen and serve on (default 8000) | ||
--signing-key string Stellar signing key used for signing transactions | ||
``` | ||
|
||
[SEP-10]: https://github.com/stellar/stellar-protocol/blob/2be91ce8d8032ca9b2f368800d06b9fba346a147/ecosystem/sep-0010.md |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
package commands | ||
|
||
import ( | ||
"github.com/spf13/cobra" | ||
"github.com/stellar/go/exp/support/jwtkey" | ||
supportlog "github.com/stellar/go/support/log" | ||
) | ||
|
||
type GenJWTKeyCommand struct { | ||
Logger *supportlog.Entry | ||
} | ||
|
||
func (c *GenJWTKeyCommand) Command() *cobra.Command { | ||
cmd := &cobra.Command{ | ||
Use: "genjwtkey", | ||
Short: "Generate a JWT ECDSA key", | ||
Run: func(_ *cobra.Command, _ []string) { | ||
c.Run() | ||
}, | ||
} | ||
return cmd | ||
} | ||
|
||
func (c *GenJWTKeyCommand) Run() { | ||
k, err := jwtkey.GenerateKey() | ||
if err != nil { | ||
c.Logger.Fatal(err) | ||
} | ||
|
||
if public, err := jwtkey.PublicKeyToString(&k.PublicKey); err == nil { | ||
c.Logger.Print("Public:", public) | ||
} else { | ||
c.Logger.Print("Public:", err) | ||
} | ||
|
||
if private, err := jwtkey.PrivateKeyToString(k); err == nil { | ||
c.Logger.Print("Private:", private) | ||
} else { | ||
c.Logger.Print("Private:", err) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
package commands | ||
|
||
import ( | ||
"go/types" | ||
|
||
"github.com/spf13/cobra" | ||
"github.com/stellar/go/clients/horizonclient" | ||
"github.com/stellar/go/exp/services/webauth/internal/serve" | ||
"github.com/stellar/go/network" | ||
"github.com/stellar/go/support/config" | ||
supportlog "github.com/stellar/go/support/log" | ||
) | ||
|
||
type ServeCommand struct { | ||
Logger *supportlog.Entry | ||
} | ||
|
||
func (c *ServeCommand) Command() *cobra.Command { | ||
opts := serve.Options{ | ||
Logger: c.Logger, | ||
} | ||
configOpts := config.ConfigOptions{ | ||
{ | ||
Name: "port", | ||
Usage: "Port to listen and serve on", | ||
OptType: types.Int, | ||
ConfigKey: &opts.Port, | ||
FlagDefault: 8000, | ||
Required: true, | ||
}, | ||
{ | ||
Name: "horizon-url", | ||
Usage: "Horizon URL used for looking up account details", | ||
OptType: types.String, | ||
ConfigKey: &opts.HorizonURL, | ||
FlagDefault: horizonclient.DefaultTestNetClient.HorizonURL, | ||
Required: true, | ||
}, | ||
{ | ||
Name: "network-passphrase", | ||
Usage: "Network passphrase of the Stellar network transactions should be signed for", | ||
OptType: types.String, | ||
ConfigKey: &opts.NetworkPassphrase, | ||
FlagDefault: network.TestNetworkPassphrase, | ||
Required: true, | ||
}, | ||
{ | ||
Name: "signing-key", | ||
Usage: "Stellar signing key used for signing transactions", | ||
OptType: types.String, | ||
ConfigKey: &opts.SigningKey, | ||
Required: true, | ||
}, | ||
{ | ||
Name: "challenge-expires-in", | ||
Usage: "The time period in seconds after which the challenge transaction expires", | ||
OptType: types.Int, | ||
CustomSetValue: config.SetDuration, | ||
ConfigKey: &opts.ChallengeExpiresIn, | ||
FlagDefault: 300, | ||
Required: true, | ||
}, | ||
{ | ||
Name: "jwt-key", | ||
Usage: "Base64 encoded ECDSA private key used for signing JWTs", | ||
OptType: types.String, | ||
ConfigKey: &opts.JWTPrivateKey, | ||
Required: true, | ||
}, | ||
{ | ||
Name: "jwt-expires-in", | ||
Usage: "The time period in seconds after which the JWT expires", | ||
OptType: types.Int, | ||
CustomSetValue: config.SetDuration, | ||
ConfigKey: &opts.JWTExpiresIn, | ||
FlagDefault: 300, | ||
leighmcculloch marked this conversation as resolved.
Show resolved
Hide resolved
|
||
Required: true, | ||
}, | ||
} | ||
cmd := &cobra.Command{ | ||
Use: "serve", | ||
Short: "Run the SEP-10 Web Authentication server", | ||
Run: func(_ *cobra.Command, _ []string) { | ||
configOpts.Require() | ||
configOpts.SetValues() | ||
c.Run(opts) | ||
}, | ||
} | ||
configOpts.Init(cmd) | ||
return cmd | ||
} | ||
|
||
func (c *ServeCommand) Run(opts serve.Options) { | ||
serve.Serve(opts) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
package serve | ||
|
||
import ( | ||
"net/http" | ||
"time" | ||
|
||
"github.com/stellar/go/keypair" | ||
"github.com/stellar/go/strkey" | ||
supportlog "github.com/stellar/go/support/log" | ||
"github.com/stellar/go/support/render/httpjson" | ||
"github.com/stellar/go/txnbuild" | ||
) | ||
|
||
// ChallengeHandler implements the SEP-10 challenge endpoint and handles | ||
// requests for a new challenge transaction. | ||
type challengeHandler struct { | ||
Logger *supportlog.Entry | ||
ServerName string | ||
NetworkPassphrase string | ||
SigningKey *keypair.Full | ||
ChallengeExpiresIn time.Duration | ||
} | ||
|
||
type challengeResponse struct { | ||
Transaction string `json:"transaction"` | ||
NetworkPassphrase string `json:"network_passphrase"` | ||
} | ||
|
||
func (h challengeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
ctx := r.Context() | ||
|
||
account := r.URL.Query().Get("account") | ||
if !strkey.IsValidEd25519PublicKey(account) { | ||
badRequest.Render(w) | ||
return | ||
} | ||
|
||
tx, err := txnbuild.BuildChallengeTx( | ||
h.SigningKey.Seed(), | ||
account, | ||
h.ServerName, | ||
h.NetworkPassphrase, | ||
h.ChallengeExpiresIn, | ||
) | ||
if err != nil { | ||
h.Logger.Ctx(ctx).WithStack(err).Error(err) | ||
serverError.Render(w) | ||
return | ||
} | ||
|
||
res := challengeResponse{ | ||
Transaction: tx, | ||
NetworkPassphrase: h.NetworkPassphrase, | ||
} | ||
httpjson.Render(w, res, httpjson.JSON) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
package serve | ||
|
||
import ( | ||
"encoding/json" | ||
"io/ioutil" | ||
"net/http" | ||
"net/http/httptest" | ||
"testing" | ||
"time" | ||
|
||
"github.com/stellar/go/keypair" | ||
"github.com/stellar/go/network" | ||
supportlog "github.com/stellar/go/support/log" | ||
"github.com/stellar/go/xdr" | ||
"github.com/stretchr/testify/assert" | ||
"github.com/stretchr/testify/require" | ||
) | ||
|
||
func TestChallenge(t *testing.T) { | ||
serverKey := keypair.MustRandom() | ||
account := keypair.MustRandom() | ||
|
||
h := challengeHandler{ | ||
Logger: supportlog.DefaultLogger, | ||
ServerName: "testserver", | ||
NetworkPassphrase: network.TestNetworkPassphrase, | ||
SigningKey: serverKey, | ||
ChallengeExpiresIn: time.Minute, | ||
} | ||
|
||
r := httptest.NewRequest("GET", "/?account="+account.Address(), nil) | ||
w := httptest.NewRecorder() | ||
h.ServeHTTP(w, r) | ||
resp := w.Result() | ||
|
||
require.Equal(t, http.StatusOK, resp.StatusCode) | ||
assert.Equal(t, "application/json; charset=utf-8", resp.Header.Get("Content-Type")) | ||
|
||
res := struct { | ||
Transaction string `json:"transaction"` | ||
NetworkPassphrase string `json:"network_passphrase"` | ||
}{} | ||
Comment on lines
+39
to
+42
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's the reason that we are not doing There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm writing API tests, it's ideal if they don't use code they're testing, they should test all the code behind the endpoint and it's inexpensive to use a type here. |
||
err := json.NewDecoder(resp.Body).Decode(&res) | ||
require.NoError(t, err) | ||
|
||
var tx xdr.TransactionEnvelope | ||
err = xdr.SafeUnmarshalBase64(res.Transaction, &tx) | ||
require.NoError(t, err) | ||
|
||
assert.Len(t, tx.Signatures, 1) | ||
assert.Equal(t, serverKey.Address(), tx.Tx.SourceAccount.Address()) | ||
assert.Equal(t, tx.Tx.SeqNum, xdr.SequenceNumber(0)) | ||
assert.Equal(t, time.Unix(int64(tx.Tx.TimeBounds.MaxTime), 0).Sub(time.Unix(int64(tx.Tx.TimeBounds.MinTime), 0)), time.Minute) | ||
assert.Len(t, tx.Tx.Operations, 1) | ||
assert.Equal(t, account.Address(), tx.Tx.Operations[0].SourceAccount.Address()) | ||
assert.Equal(t, xdr.OperationTypeManageData, tx.Tx.Operations[0].Body.Type) | ||
assert.Regexp(t, "^testserver auth", tx.Tx.Operations[0].Body.ManageDataOp.DataName) | ||
|
||
hash, err := network.HashTransaction(&tx.Tx, res.NetworkPassphrase) | ||
require.NoError(t, err) | ||
assert.NoError(t, serverKey.FromAddress().Verify(hash[:], tx.Signatures[0].Signature)) | ||
|
||
assert.Equal(t, network.TestNetworkPassphrase, res.NetworkPassphrase) | ||
} | ||
|
||
func TestChallengeNoAccount(t *testing.T) { | ||
h := challengeHandler{} | ||
|
||
r := httptest.NewRequest("GET", "/", nil) | ||
w := httptest.NewRecorder() | ||
h.ServeHTTP(w, r) | ||
resp := w.Result() | ||
|
||
require.Equal(t, http.StatusBadRequest, resp.StatusCode) | ||
assert.Equal(t, "application/json; charset=utf-8", resp.Header.Get("Content-Type")) | ||
|
||
body, err := ioutil.ReadAll(resp.Body) | ||
require.NoError(t, err) | ||
assert.JSONEq(t, `{"error":"The request was invalid in some way."}`, string(body)) | ||
} | ||
|
||
func TestChallengeInvalidAccount(t *testing.T) { | ||
h := challengeHandler{} | ||
|
||
r := httptest.NewRequest("GET", "/?account=GREATACCOUNT", nil) | ||
w := httptest.NewRecorder() | ||
h.ServeHTTP(w, r) | ||
resp := w.Result() | ||
|
||
require.Equal(t, http.StatusBadRequest, resp.StatusCode) | ||
assert.Equal(t, "application/json; charset=utf-8", resp.Header.Get("Content-Type")) | ||
|
||
body, err := ioutil.ReadAll(resp.Body) | ||
require.NoError(t, err) | ||
assert.JSONEq(t, `{"error":"The request was invalid in some way."}`, string(body)) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
package serve | ||
|
||
import ( | ||
"net/http" | ||
|
||
"github.com/stellar/go/support/render/httpjson" | ||
) | ||
|
||
var serverError = errorResponse{ | ||
Status: http.StatusInternalServerError, | ||
Error: "An error occurred while processing this request.", | ||
} | ||
var notFound = errorResponse{ | ||
Status: http.StatusNotFound, | ||
Error: "The resource at the url requested was not found.", | ||
} | ||
var methodNotAllowed = errorResponse{ | ||
Status: http.StatusMethodNotAllowed, | ||
Error: "The method is not allowed for resource at the url requested.", | ||
} | ||
var badRequest = errorResponse{ | ||
Status: http.StatusBadRequest, | ||
Error: "The request was invalid in some way.", | ||
} | ||
var unauthorized = errorResponse{ | ||
Status: http.StatusUnauthorized, | ||
Error: "The request could not be authenticated.", | ||
} | ||
|
||
type errorResponse struct { | ||
Status int `json:"-"` | ||
Error string `json:"error"` | ||
} | ||
|
||
func (e errorResponse) Render(w http.ResponseWriter) { | ||
httpjson.RenderStatus(w, e.Status, e, httpjson.JSON) | ||
} | ||
|
||
type errorHandler struct { | ||
Error errorResponse | ||
} | ||
|
||
func (h errorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
h.Error.Render(w) | ||
} |
Uh oh!
There was an error while loading. Please reload this page.