diff options
author | Michael Muré <batolettre@gmail.com> | 2019-12-10 00:42:23 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-12-10 00:42:23 +0100 |
commit | f1ed857cbd3a253d77b31c0c896fdc4ade40844f (patch) | |
tree | d1efe28a1fa666039bf8180bbed0202f0437910f /bridge/core/auth | |
parent | 69af7a1e0c2647c354fd9c5b55a254ba677200e1 (diff) | |
parent | 58c0e5aac97eabc02fa890123f3845ae6fe632a8 (diff) | |
download | git-bug-f1ed857cbd3a253d77b31c0c896fdc4ade40844f.tar.gz |
Merge pull request #271 from MichaelMure/bridge-credentials
bridge: huge refactor to accept multiple kind of credentials
Diffstat (limited to 'bridge/core/auth')
-rw-r--r-- | bridge/core/auth/credential.go | 232 | ||||
-rw-r--r-- | bridge/core/auth/credential_test.go | 109 | ||||
-rw-r--r-- | bridge/core/auth/options.go | 62 | ||||
-rw-r--r-- | bridge/core/auth/token.go | 95 |
4 files changed, 498 insertions, 0 deletions
diff --git a/bridge/core/auth/credential.go b/bridge/core/auth/credential.go new file mode 100644 index 00000000..a462a116 --- /dev/null +++ b/bridge/core/auth/credential.go @@ -0,0 +1,232 @@ +package auth + +import ( + "errors" + "fmt" + "regexp" + "strings" + "time" + + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/repository" +) + +const ( + configKeyPrefix = "git-bug.auth" + configKeyKind = "kind" + configKeyUserId = "userid" + configKeyTarget = "target" + configKeyCreateTime = "createtime" +) + +type CredentialKind string + +const ( + KindToken CredentialKind = "token" + KindLoginPassword CredentialKind = "login-password" +) + +var ErrCredentialNotExist = errors.New("credential doesn't exist") + +func NewErrMultipleMatchCredential(matching []entity.Id) *entity.ErrMultipleMatch { + return entity.NewErrMultipleMatch("credential", matching) +} + +type Credential interface { + ID() entity.Id + UserId() entity.Id + Target() string + Kind() CredentialKind + CreateTime() time.Time + Validate() error + + // Return all the specific properties of the credential that need to be saved into the configuration. + // This does not include Target, User, Kind and CreateTime. + ToConfig() map[string]string +} + +// Load loads a credential from the repo config +func LoadWithId(repo repository.RepoConfig, id entity.Id) (Credential, error) { + keyPrefix := fmt.Sprintf("%s.%s.", configKeyPrefix, id) + + // read token config pairs + rawconfigs, err := repo.GlobalConfig().ReadAll(keyPrefix) + if err != nil { + // Not exactly right due to the limitation of ReadAll() + return nil, ErrCredentialNotExist + } + + return loadFromConfig(rawconfigs, id) +} + +// LoadWithPrefix load a credential from the repo config with a prefix +func LoadWithPrefix(repo repository.RepoConfig, prefix string) (Credential, error) { + creds, err := List(repo) + if err != nil { + return nil, err + } + + // preallocate but empty + matching := make([]Credential, 0, 5) + + for _, cred := range creds { + if cred.ID().HasPrefix(prefix) { + matching = append(matching, cred) + } + } + + if len(matching) > 1 { + ids := make([]entity.Id, len(matching)) + for i, cred := range matching { + ids[i] = cred.ID() + } + return nil, NewErrMultipleMatchCredential(ids) + } + + if len(matching) == 0 { + return nil, ErrCredentialNotExist + } + + return matching[0], nil +} + +func loadFromConfig(rawConfigs map[string]string, id entity.Id) (Credential, error) { + keyPrefix := fmt.Sprintf("%s.%s.", configKeyPrefix, id) + + // trim key prefix + configs := make(map[string]string) + for key, value := range rawConfigs { + newKey := strings.TrimPrefix(key, keyPrefix) + configs[newKey] = value + } + + var cred Credential + + switch CredentialKind(configs[configKeyKind]) { + case KindToken: + cred = NewTokenFromConfig(configs) + case KindLoginPassword: + default: + return nil, fmt.Errorf("unknown credential type %s", configs[configKeyKind]) + } + + return cred, nil +} + +// List load all existing credentials +func List(repo repository.RepoConfig, opts ...Option) ([]Credential, error) { + rawConfigs, err := repo.GlobalConfig().ReadAll(configKeyPrefix + ".") + if err != nil { + return nil, err + } + + re, err := regexp.Compile(configKeyPrefix + `.([^.]+).([^.]+)`) + if err != nil { + panic(err) + } + + mapped := make(map[string]map[string]string) + + for key, val := range rawConfigs { + res := re.FindStringSubmatch(key) + if res == nil { + continue + } + if mapped[res[1]] == nil { + mapped[res[1]] = make(map[string]string) + } + mapped[res[1]][res[2]] = val + } + + matcher := matcher(opts) + + var credentials []Credential + for id, kvs := range mapped { + cred, err := loadFromConfig(kvs, entity.Id(id)) + if err != nil { + return nil, err + } + if matcher.Match(cred) { + credentials = append(credentials, cred) + } + } + + return credentials, nil +} + +// IdExist return whether a credential id exist or not +func IdExist(repo repository.RepoConfig, id entity.Id) bool { + _, err := LoadWithId(repo, id) + return err == nil +} + +// PrefixExist return whether a credential id prefix exist or not +func PrefixExist(repo repository.RepoConfig, prefix string) bool { + _, err := LoadWithPrefix(repo, prefix) + return err == nil +} + +// Store stores a credential in the global git config +func Store(repo repository.RepoConfig, cred Credential) error { + confs := cred.ToConfig() + + prefix := fmt.Sprintf("%s.%s.", configKeyPrefix, cred.ID()) + + // Kind + err := repo.GlobalConfig().StoreString(prefix+configKeyKind, string(cred.Kind())) + if err != nil { + return err + } + + // UserId + err = repo.GlobalConfig().StoreString(prefix+configKeyUserId, cred.UserId().String()) + if err != nil { + return err + } + + // Target + err = repo.GlobalConfig().StoreString(prefix+configKeyTarget, cred.Target()) + if err != nil { + return err + } + + // CreateTime + err = repo.GlobalConfig().StoreTimestamp(prefix+configKeyCreateTime, cred.CreateTime()) + if err != nil { + return err + } + + // Custom + for key, val := range confs { + err := repo.GlobalConfig().StoreString(prefix+key, val) + if err != nil { + return err + } + } + + return nil +} + +// Remove removes a credential from the global git config +func Remove(repo repository.RepoConfig, id entity.Id) error { + keyPrefix := fmt.Sprintf("%s.%s", configKeyPrefix, id) + return repo.GlobalConfig().RemoveAll(keyPrefix) +} + +/* + * Sorting + */ + +type ById []Credential + +func (b ById) Len() int { + return len(b) +} + +func (b ById) Less(i, j int) bool { + return b[i].ID() < b[j].ID() +} + +func (b ById) Swap(i, j int) { + b[i], b[j] = b[j], b[i] +} diff --git a/bridge/core/auth/credential_test.go b/bridge/core/auth/credential_test.go new file mode 100644 index 00000000..f91d273d --- /dev/null +++ b/bridge/core/auth/credential_test.go @@ -0,0 +1,109 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/identity" + "github.com/MichaelMure/git-bug/repository" +) + +func TestCredential(t *testing.T) { + repo := repository.NewMockRepoForTest() + + user1 := identity.NewIdentity("user1", "email") + err := user1.Commit(repo) + assert.NoError(t, err) + + user2 := identity.NewIdentity("user2", "email") + err = user2.Commit(repo) + assert.NoError(t, err) + + storeToken := func(user identity.Interface, val string, target string) *Token { + token := NewToken(user.Id(), val, target) + err = Store(repo, token) + require.NoError(t, err) + return token + } + + token := storeToken(user1, "foobar", "github") + + // Store + Load + err = Store(repo, token) + assert.NoError(t, err) + + token2, err := LoadWithId(repo, token.ID()) + assert.NoError(t, err) + assert.Equal(t, token.createTime.Unix(), token2.CreateTime().Unix()) + token.createTime = token2.CreateTime() + assert.Equal(t, token, token2) + + prefix := string(token.ID())[:10] + + // LoadWithPrefix + token3, err := LoadWithPrefix(repo, prefix) + assert.NoError(t, err) + assert.Equal(t, token.createTime.Unix(), token3.CreateTime().Unix()) + token.createTime = token3.CreateTime() + assert.Equal(t, token, token3) + + token4 := storeToken(user1, "foo", "gitlab") + token5 := storeToken(user2, "bar", "github") + + // List + options + creds, err := List(repo, WithTarget("github")) + assert.NoError(t, err) + sameIds(t, creds, []Credential{token, token5}) + + creds, err = List(repo, WithTarget("gitlab")) + assert.NoError(t, err) + sameIds(t, creds, []Credential{token4}) + + creds, err = List(repo, WithUser(user1)) + assert.NoError(t, err) + sameIds(t, creds, []Credential{token, token4}) + + creds, err = List(repo, WithUserId(user1.Id())) + assert.NoError(t, err) + sameIds(t, creds, []Credential{token, token4}) + + creds, err = List(repo, WithKind(KindToken)) + assert.NoError(t, err) + sameIds(t, creds, []Credential{token, token4, token5}) + + creds, err = List(repo, WithKind(KindLoginPassword)) + assert.NoError(t, err) + sameIds(t, creds, []Credential{}) + + // Exist + exist := IdExist(repo, token.ID()) + assert.True(t, exist) + + exist = PrefixExist(repo, prefix) + assert.True(t, exist) + + // Remove + err = Remove(repo, token.ID()) + assert.NoError(t, err) + + creds, err = List(repo) + assert.NoError(t, err) + sameIds(t, creds, []Credential{token4, token5}) +} + +func sameIds(t *testing.T, a []Credential, b []Credential) { + t.Helper() + + ids := func(creds []Credential) []entity.Id { + result := make([]entity.Id, len(creds)) + for i, cred := range creds { + result[i] = cred.ID() + } + return result + } + + assert.ElementsMatch(t, ids(a), ids(b)) +} diff --git a/bridge/core/auth/options.go b/bridge/core/auth/options.go new file mode 100644 index 00000000..7bcda68e --- /dev/null +++ b/bridge/core/auth/options.go @@ -0,0 +1,62 @@ +package auth + +import ( + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/identity" +) + +type options struct { + target string + userId entity.Id + kind CredentialKind +} + +type Option func(opts *options) + +func matcher(opts []Option) *options { + result := &options{} + for _, opt := range opts { + opt(result) + } + return result +} + +func (opts *options) Match(cred Credential) bool { + if opts.target != "" && cred.Target() != opts.target { + return false + } + + if opts.userId != "" && cred.UserId() != opts.userId { + return false + } + + if opts.kind != "" && cred.Kind() != opts.kind { + return false + } + + return true +} + +func WithTarget(target string) Option { + return func(opts *options) { + opts.target = target + } +} + +func WithUser(user identity.Interface) Option { + return func(opts *options) { + opts.userId = user.Id() + } +} + +func WithUserId(userId entity.Id) Option { + return func(opts *options) { + opts.userId = userId + } +} + +func WithKind(kind CredentialKind) Option { + return func(opts *options) { + opts.kind = kind + } +} diff --git a/bridge/core/auth/token.go b/bridge/core/auth/token.go new file mode 100644 index 00000000..12a3bfc0 --- /dev/null +++ b/bridge/core/auth/token.go @@ -0,0 +1,95 @@ +package auth + +import ( + "crypto/sha256" + "fmt" + "time" + + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/repository" +) + +const ( + tokenValueKey = "value" +) + +var _ Credential = &Token{} + +// Token holds an API access token data +type Token struct { + userId entity.Id + target string + createTime time.Time + Value string +} + +// NewToken instantiate a new token +func NewToken(userId entity.Id, value, target string) *Token { + return &Token{ + userId: userId, + target: target, + createTime: time.Now(), + Value: value, + } +} + +func NewTokenFromConfig(conf map[string]string) *Token { + token := &Token{} + + token.userId = entity.Id(conf[configKeyUserId]) + token.target = conf[configKeyTarget] + if createTime, ok := conf[configKeyCreateTime]; ok { + if t, err := repository.ParseTimestamp(createTime); err == nil { + token.createTime = t + } + } + + token.Value = conf[tokenValueKey] + + return token +} + +func (t *Token) ID() entity.Id { + sum := sha256.Sum256([]byte(t.target + t.Value)) + return entity.Id(fmt.Sprintf("%x", sum)) +} + +func (t *Token) UserId() entity.Id { + return t.userId +} + +func (t *Token) Target() string { + return t.target +} + +func (t *Token) Kind() CredentialKind { + return KindToken +} + +func (t *Token) CreateTime() time.Time { + return t.createTime +} + +// Validate ensure token important fields are valid +func (t *Token) Validate() error { + if t.Value == "" { + return fmt.Errorf("missing value") + } + if t.target == "" { + return fmt.Errorf("missing target") + } + if t.createTime.IsZero() || t.createTime.Equal(time.Time{}) { + return fmt.Errorf("missing creation time") + } + if !core.TargetExist(t.target) { + return fmt.Errorf("unknown target") + } + return nil +} + +func (t *Token) ToConfig() map[string]string { + return map[string]string{ + tokenValueKey: t.Value, + } +} |