diff --git a/examples/gno.land/p/demo/auth/auth.gno b/examples/gno.land/p/demo/auth/auth.gno new file mode 100644 index 00000000000..f9a9cdcf820 --- /dev/null +++ b/examples/gno.land/p/demo/auth/auth.gno @@ -0,0 +1,39 @@ +// Package auth provides object-based authentication interfaces and helpers. +package auth + +import ( + "errors" + "path" + "std" + "strings" + + "gno.land/p/demo/ufmt" +) + +// Token represents an authentication token +type Token interface { + // Source is used to identify the provenance of the token. + // It is intented to be used to find the corresponding [AuthenticateFn], for example in registries. + // It can be spoofed and should not be trusted. + Source() std.Realm +} + +// AuthenticateFn validates a token and returns the ID of the authenticated entity. +// It panics with [ErrInvalidToken] if the token is invalid. +// +// XXX: Should we add some kind of `Token.EntityID` method instead of returning the ID here? +type AuthenticateFn = func(autok Token) string + +var ( + ErrInvalidToken = errors.New("invalid token") +) + +// NamespacedEntityID generates safe entity IDs from a namespace and a sub-path. +// subPath can be user-provided and will be sanitized to prevent overriding the namespace. +func NamespacedEntityID(namespace, subPath string) string { + subPath = path.Clean(subPath) + if strings.HasPrefix(subPath, "..") { + panic(ufmt.Errorf("invalid sub-path %q", subPath)) + } + return path.Join("/", namespace, subPath) +} diff --git a/examples/gno.land/p/demo/auth/auth_test.gno b/examples/gno.land/p/demo/auth/auth_test.gno new file mode 100644 index 00000000000..fa919f4d070 --- /dev/null +++ b/examples/gno.land/p/demo/auth/auth_test.gno @@ -0,0 +1,119 @@ +package auth_test + +import ( + "std" + "testing" + + "gno.land/p/demo/auth" + "gno.land/p/demo/urequire" +) + +func TestToken(t *testing.T) { + urequire.NotPanics(t, func() { + authenticate(getToken()) + }) + + urequire.PanicsWithMessage(t, auth.ErrInvalidToken.Error(), func() { + authenticate((*token)(nil)) + }) + + urequire.PanicsWithMessage(t, auth.ErrInvalidToken.Error(), func() { + authenticate(getFakeToken()) + }) + + urequire.PanicsWithMessage(t, auth.ErrInvalidToken.Error(), func() { + authenticate(nil) + }) + + urequire.PanicsWithMessage(t, auth.ErrInvalidToken.Error(), func() { + authenticate((*fakeToken)(nil)) + }) +} + +var testRealm = std.NewCodeRealm("gno.land/r/demo/absacc") + +type token struct { +} + +func (t *token) Source() std.Realm { + return testRealm +} + +var _ auth.Token = (*token)(nil) + +func getToken() auth.Token { + return &token{} +} + +func authenticate(autok auth.Token) string { + // the next line is the core of the auth pattern, this ensures we created this token + if val, ok := autok.(*token); !ok || val == nil { + panic(auth.ErrInvalidToken) + } + + return "alice" +} + +var _ auth.AuthenticateFn = authenticate + +type fakeToken struct { +} + +func (t *fakeToken) Source() std.Realm { + return testRealm +} + +var _ auth.Token = (*fakeToken)(nil) + +func getFakeToken() auth.Token { + return &fakeToken{} +} + +func TestEntityID(t *testing.T) { + cases := []struct { + name string + namespace string + subPath string + res string + panicMessage string + }{ + { + name: "good", + namespace: "alice", + subPath: "savings", + res: "/alice/savings", + }, + { + name: "mal_backtrack", + namespace: "eve", + subPath: "../alice/savings", + panicMessage: `invalid sub-path "../alice/savings"`, + }, + { + name: "mal_backtrack_dumb", + namespace: "eve", + subPath: "/../alice/savings", + res: "/eve/alice/savings", + }, + { + name: "mal_backtrack_hidden", + namespace: "eve", + subPath: "todobien/very/deep/../../../../alice/savings", + panicMessage: `invalid sub-path "../alice/savings"`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + run := func() { + res := auth.NamespacedEntityID(tc.namespace, tc.subPath) + urequire.Equal(t, tc.res, res) + } + if tc.panicMessage != "" { + urequire.PanicsWithMessage(t, tc.panicMessage, run) + } else { + urequire.NotPanics(t, run) + } + }) + } +} diff --git a/examples/gno.land/p/demo/auth/gno.mod b/examples/gno.land/p/demo/auth/gno.mod new file mode 100644 index 00000000000..056ab54eab3 --- /dev/null +++ b/examples/gno.land/p/demo/auth/gno.mod @@ -0,0 +1 @@ +module gno.land/p/demo/auth diff --git a/examples/gno.land/r/demo/absacc/absacc.gno b/examples/gno.land/r/demo/absacc/absacc.gno new file mode 100644 index 00000000000..d3b443c7c69 --- /dev/null +++ b/examples/gno.land/r/demo/absacc/absacc.gno @@ -0,0 +1,145 @@ +package absacc + +import ( + "errors" + "std" + + "gno.land/p/demo/auth" + "gno.land/p/demo/seqid" + "gno.land/p/demo/ufmt" + "gno.land/r/demo/authreg" + "gno.land/r/demo/sessions" +) + +var ( + source std.Realm + id seqid.ID + accounts = make(map[seqid.ID]*account) +) + +func init() { + source = std.CurrentRealm() + authreg.Register(Authenticate) +} + +func CreateCallerAccount() seqid.ID { + authorities := []Authority{ + { + Provider: std.DerivePkgAddr("gno.land/r/demo/sessions"), + EntityID: sessions.EntityID(std.PrevRealm().Addr()), + }, + } + return CreateAccount(authorities...) +} + +func CreateAccount(authorities ...Authority) seqid.ID { + if len(authorities) == 0 { + panic(errors.New("must provide at least one authority, otherwise the account is locked")) + } + + accountID := id.Next() + acc := account{ + authorities: make(map[std.Address]map[string]struct{}), + } + acc.addAuthorities(authorities...) + + accounts[accountID] = &acc + return accountID +} + +func AddAuthorities(autok auth.Token, authorities ...Authority) { + account := authenticateAccount(autok) + account.addAuthorities(authorities...) +} + +func RemoveAuthorities(autok auth.Token, authorities ...Authority) { + account := authenticateAccount(autok) + account.removeAuthorities(authorities...) +} + +func AuthToken(accountID seqid.ID, subToken auth.Token) auth.Token { + if _, ok := accounts[accountID]; !ok { + panic(errors.New("unknown account")) + } + return &token{ + accountID: accountID, + subToken: subToken, + } +} + +func Authenticate(autok auth.Token) string { + return ufmt.Sprintf("%d", uint64(authenticateID(autok))) +} + +func EntityID(accountID seqid.ID) string { + return ufmt.Sprintf("/%s/%d", source.Addr().String(), uint64(accountID)) +} + +func authenticateID(autok auth.Token) seqid.ID { + val, ok := autok.(*token) + if !ok || val == nil { + panic(auth.ErrInvalidToken) + } + + entityID := authreg.Authenticate(val.subToken) + provider := val.subToken.Source().Addr() + if _, ok := accounts[val.accountID].authorities[provider][entityID]; !ok { + panic(auth.ErrInvalidToken) + } + + return val.accountID +} + +func authenticateAccount(autok auth.Token) *account { + return accounts[authenticateID(autok)] +} + +type Authority struct { + Provider std.Address + EntityID string +} + +type token struct { + accountID seqid.ID + subToken auth.Token +} + +func (t *token) Source() std.Realm { + return source +} + +type account struct { + authorities map[std.Address]map[string]struct{} +} + +func (a *account) addAuthorities(authorities ...Authority) { + for _, authority := range authorities { + if _, ok := a.authorities[authority.Provider]; !ok { + a.authorities[authority.Provider] = make(map[string]struct{}) + } + a.authorities[authority.Provider][authority.EntityID] = struct{}{} + } +} + +func (a *account) removeAuthorities(authorities ...Authority) { + for _, authority := range authorities { + if _, ok := a.authorities[authority.Provider]; !ok { + continue + } + if authority.EntityID == "" { + delete(a.authorities, authority.Provider) + continue + } + if _, ok := a.authorities[authority.Provider][authority.EntityID]; !ok { + continue + } + if len(a.authorities[authority.Provider]) == 1 { + delete(a.authorities, authority.Provider) + } else { + delete(a.authorities[authority.Provider], authority.EntityID) + } + } + if len(a.authorities) == 1 { + panic(errors.New("must keep at least one authority, otherwise the account will be locked")) + } +} diff --git a/examples/gno.land/r/demo/absacc/gno.mod b/examples/gno.land/r/demo/absacc/gno.mod new file mode 100644 index 00000000000..b11da9335c1 --- /dev/null +++ b/examples/gno.land/r/demo/absacc/gno.mod @@ -0,0 +1 @@ +module gno.land/r/demo/absacc diff --git a/examples/gno.land/r/demo/authbanker/authbanker.gno b/examples/gno.land/r/demo/authbanker/authbanker.gno new file mode 100644 index 00000000000..5715764233c --- /dev/null +++ b/examples/gno.land/r/demo/authbanker/authbanker.gno @@ -0,0 +1,80 @@ +// Package authbanker provide an example auth-consumer allowing to manipulate coins with auth tokens. +// +// It is quite limited, it only supports ugnots and only EOAs can fund accounts. +// +// XXX: support grc20 to allow for realm-realm interactions +package authbanker + +import ( + "errors" + "std" + "strings" + + "gno.land/p/demo/auth" + "gno.land/p/demo/ufmt" + "gno.land/r/demo/authreg" +) + +const denom = "ugnot" + +var vaults = make(map[string]int64) + +// GetCoins returns the amount of coins in the vault identified by `entityID` +func GetCoins(entityID string) int64 { + return vaults[entityID] +} + +// SendCoins sends `amount` coins from the vault identified by `atok` to the account identified by `to`. +// +// `to` can be an entity ID or an address. +func SendCoins(atok auth.Token, to string, amount int64) { + if amount < 1 { + panic("sent amount must be >= 0") + } + + from := authreg.Authenticate(atok) + + if from == to { + panic("cannot send to self") + } + if vaultAmount := vaults[from]; amount > vaultAmount { + panic(ufmt.Errorf("not enough in account %q", from)) + } + + vaults[from] -= amount + + if isEntityID(to) { + vaults[to] += amount + } else { + realmBanker := std.GetBanker(std.BankerTypeRealmSend) + from := std.CurrentRealm().Addr() + coins := std.Coins{std.NewCoin(denom, amount)} + realmBanker.SendCoins(from, std.Address(to), coins) + } +} + +// FundVault funds the vault identified by `entityID` with the `OrigSend` coins. +// It panics if it's not an `OriginCall`. +func FundVault(entityID string) { + // XXX: maybe replace the following with `authreg.Validate(to)` + if !isEntityID(entityID) { + panic("invalid destination") + } + + std.AssertOriginCall() + + sentCoins := std.GetOrigSend() + for _, coin := range sentCoins { + if coin.Denom != denom { + panic(ufmt.Errorf("only %q supported", denom)) + } + if coin.Amount <= 0 { + panic(errors.New("send amount must be > 0")) + } + vaults[entityID] += coin.Amount + } +} + +func isEntityID(str string) bool { + return strings.HasPrefix(str, "/") +} diff --git a/examples/gno.land/r/demo/authbanker/gno.mod b/examples/gno.land/r/demo/authbanker/gno.mod new file mode 100644 index 00000000000..e4c2bf0f13f --- /dev/null +++ b/examples/gno.land/r/demo/authbanker/gno.mod @@ -0,0 +1 @@ +module gno.land/r/demo/authbanker diff --git a/examples/gno.land/r/demo/authbanker/integration_test.gno b/examples/gno.land/r/demo/authbanker/integration_test.gno new file mode 100644 index 00000000000..2d2b2f8e4fa --- /dev/null +++ b/examples/gno.land/r/demo/authbanker/integration_test.gno @@ -0,0 +1,47 @@ +package authbanker_test + +import ( + "std" + "testing" + + "gno.land/p/demo/testutils" + "gno.land/p/demo/urequire" + "gno.land/r/demo/authbanker" + "gno.land/r/demo/subacc" +) + +func TestAuthIntegration(t *testing.T) { + authbankerAddr := std.DerivePkgAddr("gno.land/r/demo/authbanker") + + alice := testutils.TestAddress("alice") + aliceAccountID := subacc.EntityID(alice, "savings") + + bob := testutils.TestAddress("bob") + bobAccountID := subacc.EntityID(bob, "savings") + + std.TestSetOrigSend(std.Coins{{"ugnot", 42}}, nil) + authbanker.FundVault(aliceAccountID) + urequire.Equal(t, int64(42), authbanker.GetCoins(aliceAccountID)) + // XXX: uncoment next line when tx send in tests is fixed + // urequire.Equal(t, int64(42), std.GetBanker(std.BankerTypeReadonly).GetCoins(authbankerAddr)[0].Amount) + + std.TestSetRealm(std.NewUserRealm(alice)) + aliceToken := subacc.AuthToken("savings") + + authbanker.SendCoins(aliceToken, bobAccountID, 14) + urequire.Equal(t, int64(28), authbanker.GetCoins(aliceAccountID)) + urequire.Equal(t, int64(14), authbanker.GetCoins(bobAccountID)) + + std.TestSetRealm(std.NewUserRealm(bob)) + bobToken := subacc.AuthToken("savings") + + urequire.PanicsWithMessage(t, "not enough in account", func() { authbanker.SendCoins(bobToken, alice.String(), 15) }) + + std.TestIssueCoins(authbankerAddr, std.Coins{{"ugnot", 7}}) // XXX: we need this line because in tests, tx send does not work, remove when tx send in test is fixed + authbanker.SendCoins(bobToken, alice.String(), 7) + urequire.Equal(t, int64(28), authbanker.GetCoins(aliceAccountID)) + urequire.Equal(t, int64(7), authbanker.GetCoins(bobAccountID)) + urequire.Equal(t, int64(7), std.GetBanker(std.BankerTypeReadonly).GetCoins(alice)[0].Amount) + urequire.Equal(t, 0, len(std.GetBanker(std.BankerTypeReadonly).GetCoins(authbankerAddr))) // XXX: replace with next line when tx send in tests is fixed + // urequire.Equal(t, int64(35), std.GetBanker(std.BankerTypeReadonly).GetCoins(authbankerAddr)[0].Amount) +} diff --git a/examples/gno.land/r/demo/authreg/authreg.gno b/examples/gno.land/r/demo/authreg/authreg.gno new file mode 100644 index 00000000000..d4d3d17376c --- /dev/null +++ b/examples/gno.land/r/demo/authreg/authreg.gno @@ -0,0 +1,62 @@ +// Package authreg provides a permissionless authenticators registry. +// +// Authenticated entities get namespaced with the authenticator address, preventing +// malicious authenticators from impersonating another authenticator. +package authreg + +import ( + "errors" + "std" + + "gno.land/p/demo/auth" +) + +var fns = make(map[string]auth.AuthenticateFn) + +// XXX: we could add a slug there +func Register(authenticate auth.AuthenticateFn) { + caller := std.PrevRealm() + /* + XXX: this check makes the linter panic + if caller.IsUser() { + panic("can't register from user realm") + } + */ + callerStr := caller.Addr().String() + + if authenticate == nil { + if _, ok := fns[callerStr]; !ok { + panic(errors.New("not registered")) + } + + delete(fns, callerStr) + std.Emit( + unregisterEvent, + "caller", callerStr, + ) + return + } + + fns[callerStr] = authenticate + std.Emit( + registerEvent, + "caller", callerStr, + ) +} + +func Authenticate(autok auth.Token) string { + provider := autok.Source().Addr().String() + authFn, ok := fns[provider] + if !ok { + panic(errors.New("unknown auth provider")) + } + + // XXX: maybe emit event for audit purposes + + return auth.NamespacedEntityID(provider, authFn(autok)) +} + +const ( + registerEvent = "register" + unregisterEvent = "unregister" +) diff --git a/examples/gno.land/r/demo/authreg/authreg_test.gno b/examples/gno.land/r/demo/authreg/authreg_test.gno new file mode 100644 index 00000000000..dce5b27a41b --- /dev/null +++ b/examples/gno.land/r/demo/authreg/authreg_test.gno @@ -0,0 +1,52 @@ +package authreg + +import ( + "std" + "testing" + + "gno.land/p/demo/auth" + "gno.land/p/demo/ufmt" + "gno.land/p/demo/urequire" +) + +func TestAuthreg(t *testing.T) { + autok := &testAuthToken{source: std.NewCodeRealm("gno.land/r/testing/tester")} + unknownAutok := &testAuthToken{source: std.NewCodeRealm("gno.land/r/testing/unknown")} + called := false + mockAuthFn := func(autok auth.Token) string { + called = true + return "alice" + } + + std.TestSetRealm(autok.Source()) + Register(mockAuthFn) + + // test auth + val := Authenticate(autok) + urequire.True(t, called) + expected := ufmt.Sprintf("/%s/%s", autok.Source().Addr().String(), "alice") + urequire.Equal(t, expected, val) + + // test unknown provider + called = false + urequire.PanicsWithMessage(t, "unknown auth provider", func() { + Authenticate(unknownAutok) + }) + urequire.False(t, called) + + // test unregistering + Register(nil) + urequire.PanicsWithMessage(t, "unknown auth provider", func() { + Authenticate(autok) + }) +} + +type testAuthToken struct { + source std.Realm +} + +func (t *testAuthToken) Source() std.Realm { + return t.source +} + +var _ auth.Token = (*testAuthToken)(nil) diff --git a/examples/gno.land/r/demo/authreg/gno.mod b/examples/gno.land/r/demo/authreg/gno.mod new file mode 100644 index 00000000000..3a069e51d98 --- /dev/null +++ b/examples/gno.land/r/demo/authreg/gno.mod @@ -0,0 +1 @@ +module gno.land/r/demo/authreg diff --git a/examples/gno.land/r/demo/sessions/gno.mod b/examples/gno.land/r/demo/sessions/gno.mod new file mode 100644 index 00000000000..1e92e0a4420 --- /dev/null +++ b/examples/gno.land/r/demo/sessions/gno.mod @@ -0,0 +1 @@ +module gno.land/r/demo/sessions diff --git a/examples/gno.land/r/demo/sessions/sessions.gno b/examples/gno.land/r/demo/sessions/sessions.gno new file mode 100644 index 00000000000..4685fcad11d --- /dev/null +++ b/examples/gno.land/r/demo/sessions/sessions.gno @@ -0,0 +1,145 @@ +// Package sessions implements an authenticator that allows a mother address to +// whitelist session addresses that can then act as the mother address using auth tokens. +package sessions + +import ( + "errors" + "std" + "time" + + "gno.land/p/demo/auth" + "gno.land/r/demo/authreg" +) + +var ( + sessions map[string]map[string]*time.Time + source std.Realm +) + +const NoExpiry = int64(0) + +func init() { + sessions = make(map[string]map[string]*time.Time) + source = std.CurrentRealm() + + authreg.Register(Authenticate) +} + +// Login creates a session allowing sessionAddr to act as the caller until we're past the expiry time. +// A nil expiry create a session with no expiration +func Login(sessionAddr std.Address, unixSecondsExpiry int64) { + if !sessionAddr.IsValid() { + panic(errors.New("invalid session address")) + } + + caller := std.PrevRealm().Addr() + if caller == sessionAddr { + panic(errors.New("can't login as self")) + } + + if unixSecondsExpiry < 0 { + panic(errors.New("negative expiry")) + } + expiry := (*time.Time)(nil) + if unixSecondsExpiry > 0 { + timeExpiry := time.Unix(unixSecondsExpiry, 0) + expiry = &timeExpiry + if !expiry.After(time.Now()) { + panic(errors.New("expiry is now or in the past")) + } + } + + callerStr := caller.String() + if _, ok := sessions[callerStr]; !ok { + sessions[callerStr] = make(map[string]*time.Time) + } + + sessions[callerStr][sessionAddr.String()] = expiry +} + +// Logout allows to delete sessions. +// +// The caller must be one of addr or sessionAddr. +// addr is the mother address. +// sessionAddr is the allowed address. +// +// If the caller is addr and a zero sessionAddr is passed, Logout will delete all sessions for addr +func Logout(addr std.Address, sessionAddr std.Address) { + caller := std.PrevRealm().Addr() + if caller != sessionAddr && caller != addr { + panic(errors.New("caller must be addr or sessionAddr")) + } + + addrSessions, ok := sessions[addr.String()] + if !ok { + panic(errors.New("addr has no sessions")) + } + + // delete all sessions if requested + if sessionAddr == std.Address("") { + delete(sessions, addr.String()) + return + } + + if _, ok := addrSessions[sessionAddr.String()]; !ok { + panic(errors.New("sessionAddr is not a session of addr")) + } + // delete all sessions if it was the last one + if len(addrSessions) == 1 { + delete(sessions, addr.String()) + return + } + + delete(addrSessions, sessionAddr.String()) +} + +// AuthToken constructs a session token. +// `as` is the mother address and the caller is the allowed address. +// +// It is not validated at creation. +// We need to validate at authentication anyway since prevalidated tokens could be stored and reused after the session is deleted or expired. +// +// A token is always considered valid if caller == as +func AuthToken(as std.Address) auth.Token { + return &token{ + addr: as, + sessionAddr: std.PrevRealm().Addr(), + } +} + +// Authenticate validates a token generated by [AuthToken]. It panics if the session does not exists or is expired +func Authenticate(autok auth.Token) string { + val, ok := autok.(*token) + if !ok || val == nil { + panic(auth.ErrInvalidToken) + } + + addr := val.addr.String() + sessionAddr := val.sessionAddr.String() + + if addr != sessionAddr { + expiry, ok := sessions[addr][sessionAddr] + if !ok { + panic(auth.ErrInvalidToken) + } + if expiry != nil && !time.Now().Before(*expiry) { + panic(auth.ErrInvalidToken) + } + } + + return "/" + addr +} + +// EntityID returns the full entity ID for an address +func EntityID(addr std.Address) string { + return "/" + source.Addr().String() + "/" + addr.String() +} + +type token struct { + addr std.Address + sessionAddr std.Address +} + +func (t *token) Source() std.Realm { + return source +} diff --git a/examples/gno.land/r/demo/sessions/sessions_test.gno b/examples/gno.land/r/demo/sessions/sessions_test.gno new file mode 100644 index 00000000000..361e6865b09 --- /dev/null +++ b/examples/gno.land/r/demo/sessions/sessions_test.gno @@ -0,0 +1,50 @@ +package sessions + +import ( + "std" + "testing" + + "gno.land/p/demo/auth" + "gno.land/p/demo/testutils" + "gno.land/p/demo/urequire" +) + +// TODO: more extensive tests + +func TestSessions(t *testing.T) { + aliceAddr := testutils.TestAddress("alice") + aliceRealm := std.NewUserRealm(aliceAddr) + + bobAddr := testutils.TestAddress("bob") + bobRealm := std.NewUserRealm(bobAddr) + + // no session created + { + std.TestSetRealm(bobRealm) + + autok := AuthToken(aliceAddr) + urequire.PanicsWithMessage(t, auth.ErrInvalidToken.Error(), func() { + Authenticate(autok) + }) + } + + // happy path + { + std.TestSetRealm(aliceRealm) + + Login(bobAddr, NoExpiry) + + std.TestSetRealm(bobRealm) + + autok := AuthToken(aliceAddr) + entityID := Authenticate(autok) + urequire.Equal(t, "/"+aliceAddr.String(), entityID) + + Logout(aliceAddr, bobAddr) + + autok = AuthToken(aliceAddr) + urequire.PanicsWithMessage(t, auth.ErrInvalidToken.Error(), func() { + Authenticate(autok) + }) + } +} diff --git a/examples/gno.land/r/demo/subacc/gno.mod b/examples/gno.land/r/demo/subacc/gno.mod new file mode 100644 index 00000000000..8d5d24a0cbf --- /dev/null +++ b/examples/gno.land/r/demo/subacc/gno.mod @@ -0,0 +1 @@ +module gno.land/r/demo/subacc diff --git a/examples/gno.land/r/demo/subacc/subacc.gno b/examples/gno.land/r/demo/subacc/subacc.gno new file mode 100644 index 00000000000..edf2459e4a0 --- /dev/null +++ b/examples/gno.land/r/demo/subacc/subacc.gno @@ -0,0 +1,57 @@ +// Package subacc (short for sub-accounts) implements an authenticator based on the caller and an user-provided slug. +package subacc + +import ( + "path" + "std" + + "gno.land/p/demo/auth" + "gno.land/r/demo/authreg" +) + +var source std.Realm + +func init() { + source = std.CurrentRealm() + authreg.Register(Authenticate) +} + +// AuthToken constructs an [auth.Token] based on the caller and a slug. +// +// Returned tokens have an entity ID namespaced with the caller address, preventing +// malicious callers from impersonating another address. +func AuthToken(slug string) auth.Token { + caller := std.PrevRealm().Addr() + return &token{accountKey: accountID(caller, slug)} +} + +type token struct { + accountKey string +} + +func (a *token) Source() std.Realm { + return source +} + +var _ auth.Token = (*token)(nil) + +// Authenticate implements [auth.AuthenticateFn] +func Authenticate(autok auth.Token) string { + val, ok := autok.(*token) + if !ok || val == nil { + panic(auth.ErrInvalidToken) + } + + return val.accountKey +} + +var _ auth.AuthenticateFn = Authenticate + +// EntityID returns the full entity ID for an address and a slug +func EntityID(addr std.Address, slug string) string { + return path.Join("/", source.Addr().String(), accountID(addr, slug)) +} + +func accountID(creator std.Address, slug string) string { + return auth.NamespacedEntityID(creator.String(), slug) +} diff --git a/examples/gno.land/r/demo/subacc/subacc_test.gno b/examples/gno.land/r/demo/subacc/subacc_test.gno new file mode 100644 index 00000000000..602dbba9532 --- /dev/null +++ b/examples/gno.land/r/demo/subacc/subacc_test.gno @@ -0,0 +1,15 @@ +package subacc_test + +import ( + "std" + "testing" + + "gno.land/p/demo/urequire" + "gno.land/r/demo/subacc" +) + +func TestEntityID(t *testing.T) { + test1 := std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // test1 + expected := "/g1u5hdzuqfln65xyx5dvz9ldl9e45pmew5exg302/g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5/savings" + urequire.Equal(t, expected, subacc.EntityID(test1, "savings")) +} diff --git a/gno.land/pkg/integration/testdata/authbanker_absacc.txtar b/gno.land/pkg/integration/testdata/authbanker_absacc.txtar new file mode 100644 index 00000000000..e9c7c7b4366 --- /dev/null +++ b/gno.land/pkg/integration/testdata/authbanker_absacc.txtar @@ -0,0 +1,91 @@ +adduserfrom alice 'garbage still snack cake identify girl used spoil all reform allow cargo control model spider sound urban rural avoid obvious august oil large cake' +stdout 'g1xd348e34sg4ndt3shpepv85dq8zw8dlufp9t6e' + +adduserfrom bob 'trap silent cross fold brisk burger teach end history sunset ignore swear proud female faculty burden upper change apart wink view stove surge answer' +stdout 'g1gvcsfj492u6cpxudrprzz23ulmppxgv9pqmnpj' + +adduserfrom alice-session 'flee junk glad guitar require truth exhibit visit absurd february town decrease lobster pear mix path average depart wet rebel detail conduct often salon' +stdout 'g15e6e70nujtydkwt655k68z8ytzytu4ys6q4typ' + +loadpkg gno.land/r/demo/authbanker +loadpkg gno.land/r/demo/absacc +loadpkg gno.land/r/demo/sessions + +## start a new node +gnoland start + + +# create alice's abstract account +gnokey maketx call alice -pkgpath gno.land/r/demo/absacc -func CreateCallerAccount -gas-fee 1000000ugnot -gas-wanted 15000000 -broadcast -chainid=tendermint_test +stdout '1' + +# create bob's abstract account +gnokey maketx call alice -pkgpath gno.land/r/demo/absacc -func CreateCallerAccount -gas-fee 1000000ugnot -gas-wanted 15000000 -broadcast -chainid=tendermint_test +stdout '2' + + + +# fund alice's abstract account +gnokey maketx call alice -send 42ugnot -pkgpath gno.land/r/demo/authbanker -func FundVault -args "/g147gkrl6u5txjcvqpcm8ucamscg3f6qdnkht3lp/1" -gas-fee 1000000ugnot -gas-wanted 15000000 -broadcast -chainid=tendermint_test + +# check alice account +gnokey maketx call -pkgpath gno.land/r/demo/authbanker -func "GetCoins" -args "/g147gkrl6u5txjcvqpcm8ucamscg3f6qdnkht3lp/1" -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +stdout '42' + + +# send from alice's abstract account to bob's abstract account +gnokey maketx run alice $WORK/send_from_alice_to_bob.gno -gas-fee 1000000ugnot -gas-wanted 30000000 -broadcast -chainid=tendermint_test + +# check alice account +gnokey maketx call -pkgpath gno.land/r/demo/authbanker -func "GetCoins" -args "/g147gkrl6u5txjcvqpcm8ucamscg3f6qdnkht3lp/1" -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +stdout '28' + +# check bob account +gnokey maketx call -pkgpath gno.land/r/demo/authbanker -func "GetCoins" -args "/g147gkrl6u5txjcvqpcm8ucamscg3f6qdnkht3lp/2" -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +stdout '14' + + +# create session +gnokey maketx call alice -pkgpath gno.land/r/demo/sessions -func "Login" -args "g15e6e70nujtydkwt655k68z8ytzytu4ys6q4typ" -args "0" -gas-fee 1000000ugnot -gas-wanted 20000000 -broadcast -chainid=tendermint_test + +# send from alice's abstract account via a session to bob's abstract account +gnokey maketx run alice-session $WORK/send_from_alice_to_bob.gno -gas-fee 1000000ugnot -gas-wanted 30000000 -broadcast -chainid=tendermint_test + +# check alice account +gnokey maketx call -pkgpath gno.land/r/demo/authbanker -func "GetCoins" -args "/g147gkrl6u5txjcvqpcm8ucamscg3f6qdnkht3lp/1" -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +stdout '14' + +# check bob account +gnokey maketx call -pkgpath gno.land/r/demo/authbanker -func "GetCoins" -args "/g147gkrl6u5txjcvqpcm8ucamscg3f6qdnkht3lp/2" -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +stdout '28' + + +# delete sessions +gnokey maketx call alice -pkgpath gno.land/r/demo/sessions -func "Logout" -args "g1xd348e34sg4ndt3shpepv85dq8zw8dlufp9t6e" -args "" -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test + +# try to send from alice's abstract account via a session, should fail +! gnokey maketx run alice-session $WORK/send_from_alice_to_bob.gno -gas-fee 1000000ugnot -gas-wanted 30000000 -broadcast -chainid=tendermint_test +stderr 'invalid token' + + +-- send_from_alice_to_bob.gno -- +package main + +import ( + "std" + + "gno.land/r/demo/absacc" + "gno.land/r/demo/sessions" + "gno.land/r/demo/authbanker" +) + +func main() { + aliceAddr := std.Address("g1xd348e34sg4ndt3shpepv85dq8zw8dlufp9t6e") + autok := absacc.AuthToken(1, sessions.AuthToken(aliceAddr)) + + bobsAbstractAccount := absacc.EntityID(2) + + amount := int64(14) + + authbanker.SendCoins(autok, bobsAbstractAccount, amount) +} \ No newline at end of file diff --git a/gno.land/pkg/integration/testdata/authbanker_sessions.txtar b/gno.land/pkg/integration/testdata/authbanker_sessions.txtar new file mode 100644 index 00000000000..8e58b165f07 --- /dev/null +++ b/gno.land/pkg/integration/testdata/authbanker_sessions.txtar @@ -0,0 +1,59 @@ +adduserfrom alice 'garbage still snack cake identify girl used spoil all reform allow cargo control model spider sound urban rural avoid obvious august oil large cake' +stdout 'g1xd348e34sg4ndt3shpepv85dq8zw8dlufp9t6e' + +adduserfrom bob 'trap silent cross fold brisk burger teach end history sunset ignore swear proud female faculty burden upper change apart wink view stove surge answer' +stdout 'g1gvcsfj492u6cpxudrprzz23ulmppxgv9pqmnpj' + +loadpkg gno.land/r/demo/authbanker +loadpkg gno.land/r/demo/sessions + +## start a new node +gnoland start + + +# fund alice's sessions account +gnokey maketx call alice -send 42ugnot -pkgpath gno.land/r/demo/authbanker -func FundVault -args "/g1w4yzwsdjge80nvltnrrh6c66sm607lr828j32f/g1xd348e34sg4ndt3shpepv85dq8zw8dlufp9t6e" -gas-fee 1000000ugnot -gas-wanted 15000000 -broadcast -chainid=tendermint_test + +# check alice account +gnokey maketx call -pkgpath gno.land/r/demo/authbanker -func "GetCoins" -args "/g1w4yzwsdjge80nvltnrrh6c66sm607lr828j32f/g1xd348e34sg4ndt3shpepv85dq8zw8dlufp9t6e" -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +stdout '42' + +# login, allowing bob to act as alice indefinitely +gnokey maketx call alice -pkgpath gno.land/r/demo/sessions -func Login -args "g1gvcsfj492u6cpxudrprzz23ulmppxgv9pqmnpj" -args "0" -gas-fee 1000000ugnot -gas-wanted 15000000 -broadcast -chainid=tendermint_test + +# as bob, send from alice's session account +gnokey maketx run bob $WORK/send_to_bob.gno -gas-fee 1000000ugnot -gas-wanted 25000000 -broadcast -chainid=tendermint_test + +# check alice account +gnokey maketx call -pkgpath gno.land/r/demo/authbanker -func "GetCoins" -args "/g1w4yzwsdjge80nvltnrrh6c66sm607lr828j32f/g1xd348e34sg4ndt3shpepv85dq8zw8dlufp9t6e" -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +stdout '28' + +# XXX: check bob's address coins + +# logout +gnokey maketx call bob -pkgpath gno.land/r/demo/sessions -func Logout -args "g1xd348e34sg4ndt3shpepv85dq8zw8dlufp9t6e" -args "g1gvcsfj492u6cpxudrprzz23ulmppxgv9pqmnpj" -gas-fee 1000000ugnot -gas-wanted 15000000 -broadcast -chainid=tendermint_test + +# as bob, try send from alice's session account, should fail +! gnokey maketx run bob $WORK/send_to_bob.gno -gas-fee 1000000ugnot -gas-wanted 15000000 -broadcast -chainid=tendermint_test + + +-- send_to_bob.gno -- +package main + +import ( + "std" + + "gno.land/r/demo/sessions" + "gno.land/r/demo/authbanker" +) + +func main() { + aliceAddr := std.Address("g1xd348e34sg4ndt3shpepv85dq8zw8dlufp9t6e") + autok := sessions.AuthToken(aliceAddr) + + bobsAddr := "g1gvcsfj492u6cpxudrprzz23ulmppxgv9pqmnpj" + + amount := int64(14) + + authbanker.SendCoins(autok, bobsAddr, amount) +} \ No newline at end of file diff --git a/gno.land/pkg/integration/testdata/authbanker_subacc.txtar b/gno.land/pkg/integration/testdata/authbanker_subacc.txtar new file mode 100644 index 00000000000..7d4ec15136b --- /dev/null +++ b/gno.land/pkg/integration/testdata/authbanker_subacc.txtar @@ -0,0 +1,82 @@ +adduserfrom alice 'garbage still snack cake identify girl used spoil all reform allow cargo control model spider sound urban rural avoid obvious august oil large cake' +stdout 'g1xd348e34sg4ndt3shpepv85dq8zw8dlufp9t6e' + +adduserfrom bob 'trap silent cross fold brisk burger teach end history sunset ignore swear proud female faculty burden upper change apart wink view stove surge answer' +stdout 'g1gvcsfj492u6cpxudrprzz23ulmppxgv9pqmnpj' + +loadpkg gno.land/r/demo/authbanker +loadpkg gno.land/r/demo/subacc + +## start a new node +gnoland start + + +# fund alice's 'savings' sub-account +gnokey maketx call alice -send 42ugnot -pkgpath gno.land/r/demo/authbanker -func FundVault -args "/g1u5hdzuqfln65xyx5dvz9ldl9e45pmew5exg302/g1xd348e34sg4ndt3shpepv85dq8zw8dlufp9t6e/savings" -gas-fee 1000000ugnot -gas-wanted 15000000 -broadcast -chainid=tendermint_test + +# check alice account +gnokey maketx call -pkgpath gno.land/r/demo/authbanker -func "GetCoins" -args "/g1u5hdzuqfln65xyx5dvz9ldl9e45pmew5exg302/g1xd348e34sg4ndt3shpepv85dq8zw8dlufp9t6e/savings" -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +stdout '42' + + +# send from alice's 'savings' sub-account to bob's 'cashier' sub-account +gnokey maketx run alice $WORK/send_to_bob_cashier.gno -gas-fee 1000000ugnot -gas-wanted 15000000 -broadcast -chainid=tendermint_test + +# check alice account +gnokey maketx call -pkgpath gno.land/r/demo/authbanker -func "GetCoins" -args "/g1u5hdzuqfln65xyx5dvz9ldl9e45pmew5exg302/g1xd348e34sg4ndt3shpepv85dq8zw8dlufp9t6e/savings" -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +stdout '28' + +# check bob account +gnokey maketx call -pkgpath gno.land/r/demo/authbanker -func "GetCoins" -args "/g1u5hdzuqfln65xyx5dvz9ldl9e45pmew5exg302/g1gvcsfj492u6cpxudrprzz23ulmppxgv9pqmnpj/cashier" -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +stdout '14' + + +# send from bob's 'cashier' sub-account to bob's address +gnokey maketx run bob $WORK/send_to_bob_address.gno -gas-fee 1000000ugnot -gas-wanted 15000000 -broadcast -chainid=tendermint_test + +# check bob account +gnokey maketx call -pkgpath gno.land/r/demo/authbanker -func "GetCoins" -args "/g1u5hdzuqfln65xyx5dvz9ldl9e45pmew5exg302/g1gvcsfj492u6cpxudrprzz23ulmppxgv9pqmnpj/cashier" -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +stdout '7' + +# XXX: check bob's address coins + + +-- send_to_bob_cashier.gno -- +package main + +import ( + "std" + + "gno.land/r/demo/subacc" + "gno.land/r/demo/authbanker" +) + +func main() { + autok := subacc.AuthToken("savings") + + bobsAddr := std.Address("g1gvcsfj492u6cpxudrprzz23ulmppxgv9pqmnpj") + bobsCashier := subacc.EntityID(bobsAddr, "cashier") + + amount := int64(14) + + authbanker.SendCoins(autok, bobsCashier, amount) +} + + +-- send_to_bob_address.gno -- +package main + +import ( + "gno.land/r/demo/subacc" + "gno.land/r/demo/authbanker" +) + +func main() { + autok := subacc.AuthToken("cashier") + + bobsAddr := "g1gvcsfj492u6cpxudrprzz23ulmppxgv9pqmnpj" + + amount := int64(7) + + authbanker.SendCoins(autok, bobsAddr, amount) +} \ No newline at end of file