-
Notifications
You must be signed in to change notification settings - Fork 389
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
feat(examples): auth pattern exploration #3406
base: master
Are you sure you want to change the base?
Changes from all commits
d1f0fce
8ba0626
f0b169b
095aa6a
ea3e5df
39a78ae
f564760
322bfa7
c479c9c
60fc391
9012eec
2903fcc
1d0ee2b
9da86f1
2a70395
6195e49
1490451
ef0b843
b1b68aa
9a31a60
ab6c3ac
024c5e2
79e39d2
6a39e9a
ce9ea3c
de7d7b2
607cbef
4b8daf4
129acbf
faa0df8
9108640
1743373
651ab8d
e9f39fb
68b6e6b
e3d5f78
7c835b4
eae7282
23e45a3
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,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) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
}) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
module gno.land/p/demo/auth |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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")) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
module gno.land/r/demo/absacc |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
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 prevents me from creating my own authenticator? I don't understand where you expect to whitelist the approved authenticators. 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. Nothing, the pattern is meant to be extendable. The registry namespaces the authenticators so you can't authenticate an entity from an other authenticator. How would you exploit this? 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. Actually there was a vulnerability in namespacing if you pass paths with 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. Also if you want to whitelist providers somewhere, you can check the token's 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 was considering creating a new authenticator, registering it, and then using it for universal acceptance. I see two secure approaches: one is to implement a whitelist system on the registry itself, and the other is to establish a whitelist system within the contract that checks the authentication. However, allowing anyone to create an authenticator and contracts to simply "verify if a token is valid" is definitely insecure. |
||
|
||
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, "/") | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
module gno.land/r/demo/authbanker |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you write a txtar or share a few
maketx
calls that you expect to use with this?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is an integration test here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also added txtar here