diff --git a/TODO.md b/TODO.md index 58196109..03e9bc52 100644 --- a/TODO.md +++ b/TODO.md @@ -33,7 +33,7 @@ Documentation Storage -- [ ] Add SQL storage implementation +- [x] Add SQL storage implementation - [ ] Utilize fixes for third party resources in Kubernetes 1.4 UX @@ -48,3 +48,4 @@ Backend - [ ] Improve logging, possibly switch to logrus - [ ] Standardize OAuth2 error handling +- [ ] Switch to github.com/ghodss/yaml for []byte to base64 string logic diff --git a/cmd/dex/config.go b/cmd/dex/config.go index f50cd173..482075f3 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -1,6 +1,7 @@ package main import ( + "encoding/base64" "fmt" "github.com/coreos/dex/connector" @@ -26,7 +27,46 @@ type Config struct { Templates server.TemplateConfig `yaml:"templates"` + // StaticClients cause the server to use this list of clients rather than + // querying the storage. Write operations, like creating a client, will fail. StaticClients []storage.Client `yaml:"staticClients"` + + // If enabled, the server will maintain a list of passwords which can be used + // to identify a user. + EnablePasswordDB bool `yaml:"enablePasswordDB"` + + // StaticPasswords cause the server use this list of passwords rather than + // querying the storage. Cannot be specified without enabling a passwords + // database. + // + // The "password" type is identical to the storage.Password type, but does + // unmarshaling into []byte correctly. + StaticPasswords []password `yaml:"staticPasswords"` +} + +type password struct { + Email string `yaml:"email"` + Username string `yaml:"username"` + UserID string `yaml:"userID"` + + // Because our YAML parser doesn't base64, we have to do it ourselves. + // + // TODO(ericchiang): switch to github.com/ghodss/yaml + Hash string `yaml:"hash"` +} + +// decode the hash appropriately and convert to the storage passwords. +func (p password) toPassword() (storage.Password, error) { + hash, err := base64.StdEncoding.DecodeString(p.Hash) + if err != nil { + return storage.Password{}, fmt.Errorf("decoding hash: %v", err) + } + return storage.Password{ + Email: p.Email, + Username: p.Username, + UserID: p.UserID, + Hash: hash, + }, nil } // OAuth2 describes enabled OAuth2 extensions. diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index 08309e0f..0e35e6af 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -55,7 +55,8 @@ func serve(cmd *cobra.Command, args []string) error { errMsg string }{ {c.Issuer == "", "no issuer specified in config file"}, - {len(c.Connectors) == 0, "no connectors supplied in config file"}, + {len(c.Connectors) == 0 && !c.EnablePasswordDB, "no connectors supplied in config file"}, + {!c.EnablePasswordDB && len(c.StaticPasswords) != 0, "cannot specify static passwords without enabling password db"}, {c.Storage.Config == nil, "no storage suppied in config file"}, {c.Web.HTTP == "" && c.Web.HTTPS == "", "must supply a HTTP/HTTPS address to listen on"}, {c.Web.HTTPS != "" && c.Web.TLSCert == "", "no cert specified for HTTPS"}, @@ -103,6 +104,15 @@ func serve(cmd *cobra.Command, args []string) error { if len(c.StaticClients) > 0 { s = storage.WithStaticClients(s, c.StaticClients) } + if len(c.StaticPasswords) > 0 { + p := make([]storage.Password, len(c.StaticPasswords)) + for i, pw := range c.StaticPasswords { + if p[i], err = pw.toPassword(); err != nil { + return err + } + } + s = storage.WithStaticPasswords(s, p) + } serverConfig := server.Config{ SupportedResponseTypes: c.OAuth2.ResponseTypes, @@ -110,6 +120,7 @@ func serve(cmd *cobra.Command, args []string) error { Connectors: connectors, Storage: s, TemplateConfig: c.Templates, + EnablePasswordDB: c.EnablePasswordDB, } serv, err := server.NewServer(serverConfig) diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml index 2a2736b5..d771bb7c 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -11,16 +11,23 @@ connectors: - type: mockCallback id: mock-callback name: Mock -- type: mockPassword - id: mock-password - name: Password - config: - username: "admin" - password: "PASSWORD" +# Instead of reading from an external storage, use this list of clients. staticClients: - id: example-app redirectURIs: - 'http://127.0.0.1:5555/callback' name: 'Example App' secret: ZXhhbXBsZS1hcHAtc2VjcmV0 + +# Let dex keep a list of passwords which can be used to login the user. +enablePasswordDB: true + +# A static list of passwords to login the end user. By identifying here, dex +# won't look in its undlying storage for passwords. +staticPasswords: +- email: "admin@example.com" + # bcrypt hash of the string "password" + hash: "JDJhJDE0JDh4TnlVZ3pzSmVuQm4ySlRPT2QvbmVGcUlnQzF4TEFVRFA3VlpTVzhDNWlkLnFPcmNlYUJX" + username: "admin" + userID: "08a8684b-db88-4b73-90a9-3cd1661f5466" diff --git a/examples/k8s/thirdpartyresources.yaml b/examples/k8s/thirdpartyresources.yaml index 65e02256..40f03027 100644 --- a/examples/k8s/thirdpartyresources.yaml +++ b/examples/k8s/thirdpartyresources.yaml @@ -46,3 +46,12 @@ kind: ThirdPartyResource description: "Refresh tokens for clients to continuously act on behalf of an end user." versions: - name: v1 +--- + +metadata: + name: password.passwords.oidc.coreos.com +apiVersion: extensions/v1beta1 +kind: ThirdPartyResource +description: "Passwords managed by the OIDC server." +versions: +- name: v1 diff --git a/server/server.go b/server/server.go index 81b86ec3..703af668 100644 --- a/server/server.go +++ b/server/server.go @@ -3,12 +3,15 @@ package server import ( "errors" "fmt" + "log" "net/http" "net/url" "path" "sync/atomic" "time" + "golang.org/x/crypto/bcrypt" + "github.com/gorilla/mux" "github.com/coreos/dex/connector" @@ -44,6 +47,8 @@ type Config struct { // If specified, the server will use this function for determining time. Now func() time.Time + EnablePasswordDB bool + TemplateConfig TemplateConfig } @@ -91,6 +96,14 @@ func newServer(c Config, rotationStrategy rotationStrategy) (*Server, error) { if err != nil { return nil, fmt.Errorf("server: can't parse issuer URL") } + if c.EnablePasswordDB { + c.Connectors = append(c.Connectors, Connector{ + ID: "local", + DisplayName: "Email", + Connector: newPasswordDB(c.Storage), + }) + } + if len(c.Connectors) == 0 { return nil, errors.New("server: no connectors specified") } @@ -182,6 +195,38 @@ func (s *Server) absURL(pathItems ...string) string { return u.String() } +func newPasswordDB(s storage.Storage) interface { + connector.Connector + connector.PasswordConnector +} { + return passwordDB{s} +} + +type passwordDB struct { + s storage.Storage +} + +func (db passwordDB) Close() error { return nil } + +func (db passwordDB) Login(email, password string) (connector.Identity, bool, error) { + p, err := db.s.GetPassword(email) + if err != nil { + if err != storage.ErrNotFound { + log.Printf("get password: %v", err) + } + return connector.Identity{}, false, err + } + if err := bcrypt.CompareHashAndPassword(p.Hash, []byte(password)); err != nil { + return connector.Identity{}, false, nil + } + return connector.Identity{ + UserID: p.UserID, + Username: p.Username, + Email: p.Email, + EmailVerified: true, + }, true, nil +} + // newKeyCacher returns a storage which caches keys so long as the next func newKeyCacher(s storage.Storage, now func() time.Time) storage.Storage { if now == nil { diff --git a/server/server_test.go b/server/server_test.go index 296f22cc..46fcc710 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -16,9 +16,12 @@ import ( "time" "github.com/ericchiang/oidc" + "github.com/kylelemons/godebug/pretty" + "golang.org/x/crypto/bcrypt" "golang.org/x/net/context" "golang.org/x/oauth2" + "github.com/coreos/dex/connector" "github.com/coreos/dex/connector/mock" "github.com/coreos/dex/storage" "github.com/coreos/dex/storage/memory" @@ -381,6 +384,91 @@ func TestOAuth2ImplicitFlow(t *testing.T) { } } +func TestPasswordDB(t *testing.T) { + s := memory.New() + conn := newPasswordDB(s) + defer conn.Close() + + pw := "hi" + + h, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + + s.CreatePassword(storage.Password{ + Email: "jane@example.com", + Username: "jane", + UserID: "foobar", + Hash: h, + }) + + tests := []struct { + name string + username string + password string + wantIdentity connector.Identity + wantInvalid bool + wantErr bool + }{ + { + name: "valid password", + username: "jane@example.com", + password: pw, + wantIdentity: connector.Identity{ + Email: "jane@example.com", + Username: "jane", + UserID: "foobar", + EmailVerified: true, + }, + }, + { + name: "unknown user", + username: "john@example.com", + password: pw, + wantErr: true, + }, + { + name: "invalid password", + username: "jane@example.com", + password: "not the correct password", + wantInvalid: true, + }, + } + + for _, tc := range tests { + ident, valid, err := conn.Login(tc.username, tc.password) + if err != nil { + if !tc.wantErr { + t.Errorf("%s: %v", tc.name, err) + } + continue + } + + if tc.wantErr { + t.Errorf("%s: expected error", tc.name) + continue + } + + if !valid { + if !tc.wantInvalid { + t.Errorf("%s: expected valid password", tc.name) + } + continue + } + + if tc.wantInvalid { + t.Errorf("%s: expected invalid password", tc.name) + continue + } + + if diff := pretty.Compare(tc.wantIdentity, ident); diff != "" { + t.Errorf("%s: %s", tc.name, diff) + } + } + +} + type storageWithKeysTrigger struct { storage.Storage f func() diff --git a/storage/conformance/conformance.go b/storage/conformance/conformance.go index 9ba2ec68..a2458680 100644 --- a/storage/conformance/conformance.go +++ b/storage/conformance/conformance.go @@ -8,6 +8,8 @@ import ( "testing" "time" + "golang.org/x/crypto/bcrypt" + "github.com/coreos/dex/storage" "github.com/kylelemons/godebug/pretty" @@ -30,6 +32,7 @@ func RunTestSuite(t *testing.T, sf StorageFactory) { {"AuthRequestCRUD", testAuthRequestCRUD}, {"ClientCRUD", testClientCRUD}, {"RefreshTokenCRUD", testRefreshTokenCRUD}, + {"PasswordCRUD", testPasswordCRUD}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -222,5 +225,54 @@ func testRefreshTokenCRUD(t *testing.T, s storage.Storage) { if _, err := s.GetRefresh(id); err != storage.ErrNotFound { t.Errorf("after deleting refresh expected storage.ErrNotFound, got %v", err) } - +} + +func testPasswordCRUD(t *testing.T, s storage.Storage) { + // Use bcrypt.MinCost to keep the tests short. + passwordHash, err := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + + password := storage.Password{ + Email: "jane@example.com", + Hash: passwordHash, + Username: "jane", + UserID: "foobar", + } + if err := s.CreatePassword(password); err != nil { + t.Fatalf("create password token: %v", err) + } + + getAndCompare := func(id string, want storage.Password) { + gr, err := s.GetPassword(id) + if err != nil { + t.Errorf("get password %q: %v", id, err) + return + } + if diff := pretty.Compare(want, gr); diff != "" { + t.Errorf("password retrieved from storage did not match: %s", diff) + } + } + + getAndCompare("jane@example.com", password) + getAndCompare("JANE@example.com", password) // Emails should be case insensitive + + if err := s.UpdatePassword(password.Email, func(old storage.Password) (storage.Password, error) { + old.Username = "jane doe" + return old, nil + }); err != nil { + t.Fatalf("failed to update auth request: %v", err) + } + + password.Username = "jane doe" + getAndCompare("jane@example.com", password) + + if err := s.DeletePassword(password.Email); err != nil { + t.Fatalf("failed to delete password: %v", err) + } + + if _, err := s.GetPassword(password.Email); err != storage.ErrNotFound { + t.Errorf("after deleting password expected storage.ErrNotFound, got %v", err) + } } diff --git a/storage/kubernetes/client.go b/storage/kubernetes/client.go index f6f97f17..ec703214 100644 --- a/storage/kubernetes/client.go +++ b/storage/kubernetes/client.go @@ -4,6 +4,7 @@ import ( "bytes" "crypto/tls" "crypto/x509" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -165,6 +166,26 @@ func (c *client) delete(resource, name string) error { return checkHTTPErr(resp, http.StatusOK) } +func (c *client) deleteAll(resource string) error { + var list struct { + k8sapi.TypeMeta `json:",inline"` + k8sapi.ListMeta `json:"metadata,omitempty"` + Items []struct { + k8sapi.TypeMeta `json:",inline"` + k8sapi.ObjectMeta `json:"metadata,omitempty"` + } `json:"items"` + } + if err := c.list(resource, &list); err != nil { + return err + } + for _, item := range list.Items { + if err := c.delete(resource, item.Name); err != nil { + return err + } + } + return nil +} + func (c *client) put(resource, name string, v interface{}) error { body, err := json.Marshal(v) if err != nil { @@ -190,9 +211,9 @@ func (c *client) put(resource, name string, v interface{}) error { func newClient(cluster k8sapi.Cluster, user k8sapi.AuthInfo, namespace string) (*client, error) { tlsConfig := cryptopasta.DefaultTLSConfig() - data := func(b []byte, file string) ([]byte, error) { - if b != nil { - return b, nil + data := func(b string, file string) ([]byte, error) { + if b != "" { + return base64.StdEncoding.DecodeString(b) } if file == "" { return nil, nil diff --git a/storage/kubernetes/k8sapi/client.go b/storage/kubernetes/k8sapi/client.go index c8df7341..d84fa5cc 100644 --- a/storage/kubernetes/k8sapi/client.go +++ b/storage/kubernetes/k8sapi/client.go @@ -62,7 +62,9 @@ type Cluster struct { // CertificateAuthority is the path to a cert file for the certificate authority. CertificateAuthority string `yaml:"certificate-authority,omitempty"` // CertificateAuthorityData contains PEM-encoded certificate authority certificates. Overrides CertificateAuthority - CertificateAuthorityData []byte `yaml:"certificate-authority-data,omitempty"` + // + // NOTE(ericchiang): Our yaml parser doesn't assume []byte is a base64 encoded string. + CertificateAuthorityData string `yaml:"certificate-authority-data,omitempty"` // Extensions holds additional information. This is useful for extenders so that reads and writes don't clobber unknown fields Extensions []NamedExtension `yaml:"extensions,omitempty"` } @@ -72,11 +74,15 @@ type AuthInfo struct { // ClientCertificate is the path to a client cert file for TLS. ClientCertificate string `yaml:"client-certificate,omitempty"` // ClientCertificateData contains PEM-encoded data from a client cert file for TLS. Overrides ClientCertificate - ClientCertificateData []byte `yaml:"client-certificate-data,omitempty"` + // + // NOTE(ericchiang): Our yaml parser doesn't assume []byte is a base64 encoded string. + ClientCertificateData string `yaml:"client-certificate-data,omitempty"` // ClientKey is the path to a client key file for TLS. ClientKey string `yaml:"client-key,omitempty"` // ClientKeyData contains PEM-encoded data from a client key file for TLS. Overrides ClientKey - ClientKeyData []byte `yaml:"client-key-data,omitempty"` + // + // NOTE(ericchiang): Our yaml parser doesn't assume []byte is a base64 encoded string. + ClientKeyData string `yaml:"client-key-data,omitempty"` // Token is the bearer token for authentication to the kubernetes cluster. Token string `yaml:"token,omitempty"` // Impersonate is the username to imperonate. The name matches the flag. diff --git a/storage/kubernetes/storage.go b/storage/kubernetes/storage.go index bfb48855..44920f6b 100644 --- a/storage/kubernetes/storage.go +++ b/storage/kubernetes/storage.go @@ -20,6 +20,7 @@ const ( kindClient = "OAuth2Client" kindRefreshToken = "RefreshToken" kindKeys = "SigningKey" + kindPassword = "Password" ) const ( @@ -28,6 +29,7 @@ const ( resourceClient = "oauth2clients" resourceRefreshToken = "refreshtokens" resourceKeys = "signingkeies" // Kubernetes attempts to pluralize. + resourcePassword = "passwords" ) // Config values for the Kubernetes storage type. @@ -109,6 +111,10 @@ func (cli *client) CreateAuthCode(c storage.AuthCode) error { return cli.post(resourceAuthCode, cli.fromStorageAuthCode(c)) } +func (cli *client) CreatePassword(p storage.Password) error { + return cli.post(resourcePassword, cli.fromStoragePassword(p)) +} + func (cli *client) CreateRefresh(r storage.RefreshToken) error { refresh := RefreshToken{ TypeMeta: k8sapi.TypeMeta{ @@ -152,6 +158,14 @@ func (cli *client) GetClient(id string) (storage.Client, error) { return toStorageClient(c), nil } +func (cli *client) GetPassword(email string) (storage.Password, error) { + var p Password + if err := cli.get(resourcePassword, emailToID(email), &p); err != nil { + return storage.Password{}, err + } + return toStoragePassword(p), nil +} + func (cli *client) GetKeys() (storage.Keys, error) { var keys Keys if err := cli.get(resourceKeys, keysName, &keys); err != nil { @@ -199,6 +213,10 @@ func (cli *client) DeleteRefresh(id string) error { return cli.delete(resourceRefreshToken, id) } +func (cli *client) DeletePassword(email string) error { + return cli.delete(resourcePassword, emailToID(email)) +} + func (cli *client) UpdateClient(id string, updater func(old storage.Client) (storage.Client, error)) error { var c Client if err := cli.get(resourceClient, id, &c); err != nil { @@ -214,6 +232,23 @@ func (cli *client) UpdateClient(id string, updater func(old storage.Client) (sto return cli.put(resourceClient, id, newClient) } +func (cli *client) UpdatePassword(email string, updater func(old storage.Password) (storage.Password, error)) error { + id := emailToID(email) + var p Password + if err := cli.get(resourcePassword, id, &p); err != nil { + return err + } + + updated, err := updater(toStoragePassword(p)) + if err != nil { + return err + } + + newPassword := cli.fromStoragePassword(updated) + newPassword.ObjectMeta = p.ObjectMeta + return cli.put(resourcePassword, id, newPassword) +} + func (cli *client) UpdateKeys(updater func(old storage.Keys) (storage.Keys, error)) error { firstUpdate := false var keys Keys diff --git a/storage/kubernetes/storage_test.go b/storage/kubernetes/storage_test.go index c0011f39..f41b01b1 100644 --- a/storage/kubernetes/storage_test.go +++ b/storage/kubernetes/storage_test.go @@ -75,7 +75,18 @@ func TestURLFor(t *testing.T) { func TestStorage(t *testing.T) { client := loadClient(t) conformance.RunTestSuite(t, func() storage.Storage { - // TODO(erichiang): Tear down namespaces between each iteration. + for _, resource := range []string{ + resourceAuthCode, + resourceAuthRequest, + resourceClient, + resourceRefreshToken, + resourceKeys, + resourcePassword, + } { + if err := client.deleteAll(resource); err != nil { + t.Fatalf("delete all %q failed: %v", resource, err) + } + } return client }) } diff --git a/storage/kubernetes/types.go b/storage/kubernetes/types.go index 8bc934f2..3c914e84 100644 --- a/storage/kubernetes/types.go +++ b/storage/kubernetes/types.go @@ -1,6 +1,8 @@ package kubernetes import ( + "encoding/base32" + "strings" "time" jose "gopkg.in/square/go-jose.v2" @@ -182,6 +184,60 @@ func (cli *client) fromStorageAuthRequest(a storage.AuthRequest) AuthRequest { return req } +// Password is a mirrored struct from the stroage with JSON struct tags and +// Kubernetes type metadata. +type Password struct { + k8sapi.TypeMeta `json:",inline"` + k8sapi.ObjectMeta `json:"metadata,omitempty"` + + // The Kubernetes name is actually an encoded version of this value. + // + // This field is IMMUTABLE. Do not change. + Email string `json:"email,omitempty"` + + Hash []byte `json:"hash,omitempty"` + Username string `json:"username,omitempty"` + UserID string `json:"userID,omitempty"` +} + +// Kubernetes only allows lower case letters for names. +// +// NOTE(ericchiang): This is currently copied from the storage package's NewID() +// method. Once we refactor those into the storage, just use that instead. +var encoding = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567") + +// Map an arbitrary email to a valid Kuberntes name. +func emailToID(email string) string { + return strings.TrimRight(encoding.EncodeToString([]byte(strings.ToLower(email))), "=") +} + +func (cli *client) fromStoragePassword(p storage.Password) Password { + email := strings.ToLower(p.Email) + return Password{ + TypeMeta: k8sapi.TypeMeta{ + Kind: kindPassword, + APIVersion: cli.apiVersionForResource(resourcePassword), + }, + ObjectMeta: k8sapi.ObjectMeta{ + Name: emailToID(email), + Namespace: cli.namespace, + }, + Email: email, + Hash: p.Hash, + Username: p.Username, + UserID: p.UserID, + } +} + +func toStoragePassword(p Password) storage.Password { + return storage.Password{ + Email: p.Email, + Hash: p.Hash, + Username: p.Username, + UserID: p.UserID, + } +} + // AuthCode is a mirrored struct from storage with JSON struct tags and // Kubernetes type metadata. type AuthCode struct { diff --git a/storage/memory/memory.go b/storage/memory/memory.go index d178f20b..b85d68df 100644 --- a/storage/memory/memory.go +++ b/storage/memory/memory.go @@ -2,7 +2,7 @@ package memory import ( - "errors" + "strings" "sync" "github.com/coreos/dex/storage" @@ -15,6 +15,7 @@ func New() storage.Storage { authCodes: make(map[string]storage.AuthCode), refreshTokens: make(map[string]storage.RefreshToken), authReqs: make(map[string]storage.AuthRequest), + passwords: make(map[string]storage.Password), } } @@ -37,6 +38,7 @@ type memStorage struct { authCodes map[string]storage.AuthCode refreshTokens map[string]storage.RefreshToken authReqs map[string]storage.AuthRequest + passwords map[string]storage.Password keys storage.Keys } @@ -47,28 +49,73 @@ func (s *memStorage) tx(f func()) { f() } -var errAlreadyExists = errors.New("already exists") - func (s *memStorage) Close() error { return nil } -func (s *memStorage) CreateClient(c storage.Client) error { - s.tx(func() { s.clients[c.ID] = c }) - return nil +func (s *memStorage) CreateClient(c storage.Client) (err error) { + s.tx(func() { + if _, ok := s.clients[c.ID]; ok { + err = storage.ErrAlreadyExists + } else { + s.clients[c.ID] = c + } + }) + return } -func (s *memStorage) CreateAuthCode(c storage.AuthCode) error { - s.tx(func() { s.authCodes[c.ID] = c }) - return nil +func (s *memStorage) CreateAuthCode(c storage.AuthCode) (err error) { + s.tx(func() { + if _, ok := s.authCodes[c.ID]; ok { + err = storage.ErrAlreadyExists + } else { + s.authCodes[c.ID] = c + } + }) + return } -func (s *memStorage) CreateRefresh(r storage.RefreshToken) error { - s.tx(func() { s.refreshTokens[r.RefreshToken] = r }) - return nil +func (s *memStorage) CreateRefresh(r storage.RefreshToken) (err error) { + s.tx(func() { + if _, ok := s.refreshTokens[r.RefreshToken]; ok { + err = storage.ErrAlreadyExists + } else { + s.refreshTokens[r.RefreshToken] = r + } + }) + return } -func (s *memStorage) CreateAuthRequest(a storage.AuthRequest) error { - s.tx(func() { s.authReqs[a.ID] = a }) - return nil +func (s *memStorage) CreateAuthRequest(a storage.AuthRequest) (err error) { + s.tx(func() { + if _, ok := s.authReqs[a.ID]; ok { + err = storage.ErrAlreadyExists + } else { + s.authReqs[a.ID] = a + } + }) + return +} + +func (s *memStorage) CreatePassword(p storage.Password) (err error) { + p.Email = strings.ToLower(p.Email) + s.tx(func() { + if _, ok := s.passwords[p.Email]; ok { + err = storage.ErrAlreadyExists + } else { + s.passwords[p.Email] = p + } + }) + return +} + +func (s *memStorage) GetPassword(email string) (p storage.Password, err error) { + email = strings.ToLower(email) + s.tx(func() { + var ok bool + if p, ok = s.passwords[email]; !ok { + err = storage.ErrNotFound + } + }) + return } func (s *memStorage) GetClient(id string) (client storage.Client, err error) { @@ -126,6 +173,18 @@ func (s *memStorage) ListRefreshTokens() (tokens []storage.RefreshToken, err err return } +func (s *memStorage) DeletePassword(email string) (err error) { + email = strings.ToLower(email) + s.tx(func() { + if _, ok := s.passwords[email]; !ok { + err = storage.ErrNotFound + return + } + delete(s.passwords, email) + }) + return +} + func (s *memStorage) DeleteClient(id string) (err error) { s.tx(func() { if _, ok := s.clients[id]; !ok { @@ -235,9 +294,24 @@ func (s *memStorage) UpdateAuthRequest(id string, updater func(old storage.AuthR err = storage.ErrNotFound return } - if req, err := updater(req); err == nil { + if req, err = updater(req); err == nil { s.authReqs[id] = req } }) return } + +func (s *memStorage) UpdatePassword(email string, updater func(p storage.Password) (storage.Password, error)) (err error) { + email = strings.ToLower(email) + s.tx(func() { + req, ok := s.passwords[email] + if !ok { + err = storage.ErrNotFound + return + } + if req, err = updater(req); err == nil { + s.passwords[email] = req + } + }) + return +} diff --git a/storage/sql/crud.go b/storage/sql/crud.go index 532b8648..ca941f7c 100644 --- a/storage/sql/crud.go +++ b/storage/sql/crud.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "github.com/coreos/dex/storage" ) @@ -137,7 +138,7 @@ func (c *conn) UpdateAuthRequest(id string, updater func(a storage.AuthRequest) a.Claims.UserID, a.Claims.Username, a.Claims.Email, a.Claims.EmailVerified, encoder(a.Claims.Groups), a.ConnectorID, a.ConnectorData, - a.Expiry, a.ID, + a.Expiry, r.ID, ) if err != nil { return fmt.Errorf("update auth request: %v", err) @@ -462,14 +463,83 @@ func scanClient(s scanner) (cli storage.Client, err error) { return cli, nil } -func (c *conn) DeleteAuthRequest(id string) error { return c.delete("auth_request", id) } -func (c *conn) DeleteAuthCode(id string) error { return c.delete("auth_code", id) } -func (c *conn) DeleteClient(id string) error { return c.delete("client", id) } -func (c *conn) DeleteRefresh(id string) error { return c.delete("refresh_token", id) } +func (c *conn) CreatePassword(p storage.Password) error { + p.Email = strings.ToLower(p.Email) + _, err := c.Exec(` + insert into password ( + email, hash, username, user_id + ) + values ( + $1, $2, $3, $4 + ); + `, + p.Email, p.Hash, p.Username, p.UserID, + ) + if err != nil { + return fmt.Errorf("insert password: %v", err) + } + return nil +} + +func (c *conn) UpdatePassword(email string, updater func(p storage.Password) (storage.Password, error)) error { + return c.ExecTx(func(tx *trans) error { + p, err := getPassword(tx, email) + if err != nil { + return err + } + + np, err := updater(p) + if err != nil { + return err + } + _, err = tx.Exec(` + update password + set + hash = $1, username = $2, user_id = $3 + where email = $4; + `, + np.Hash, np.Username, np.UserID, p.Email, + ) + if err != nil { + return fmt.Errorf("update password: %v", err) + } + return nil + }) +} + +func (c *conn) GetPassword(email string) (storage.Password, error) { + return getPassword(c, email) +} + +func getPassword(q querier, email string) (p storage.Password, err error) { + email = strings.ToLower(email) + err = q.QueryRow(` + select + email, hash, username, user_id + from password where email = $1; + `, email).Scan( + &p.Email, &p.Hash, &p.Username, &p.UserID, + ) + if err != nil { + if err == sql.ErrNoRows { + return p, storage.ErrNotFound + } + return p, fmt.Errorf("select password: %v", err) + } + return p, nil +} + +func (c *conn) DeleteAuthRequest(id string) error { return c.delete("auth_request", "id", id) } +func (c *conn) DeleteAuthCode(id string) error { return c.delete("auth_code", "id", id) } +func (c *conn) DeleteClient(id string) error { return c.delete("client", "id", id) } +func (c *conn) DeleteRefresh(id string) error { return c.delete("refresh_token", "id", id) } +func (c *conn) DeletePassword(email string) error { + return c.delete("password", "email", strings.ToLower(email)) +} // Do NOT call directly. Does not escape table. -func (c *conn) delete(table, id string) error { - result, err := c.Exec(`delete from `+table+` where id = $1`, id) +func (c *conn) delete(table, field, id string) error { + result, err := c.Exec(`delete from `+table+` where `+field+` = $1`, id) if err != nil { return fmt.Errorf("delete %s: %v", table, id) } diff --git a/storage/sql/migrate.go b/storage/sql/migrate.go index 8754caf5..d9c254d3 100644 --- a/storage/sql/migrate.go +++ b/storage/sql/migrate.go @@ -137,6 +137,13 @@ var migrations = []migration{ connector_id text not null, connector_data bytea ); + + create table password ( + email text not null primary key, + hash bytea not null, + username text not null, + user_id text not null + ); -- keys is a weird table because we only ever expect there to be a single row create table keys ( diff --git a/storage/static_clients.go b/storage/static.go similarity index 58% rename from storage/static_clients.go rename to storage/static.go index d7932393..8274c5f8 100644 --- a/storage/static_clients.go +++ b/storage/static.go @@ -1,6 +1,9 @@ package storage -import "errors" +import ( + "errors" + "strings" +) // Tests for this code are in the "memory" package, since this package doesn't // define a concrete storage implementation. @@ -53,3 +56,39 @@ func (s staticClientsStorage) DeleteClient(id string) error { func (s staticClientsStorage) UpdateClient(id string, updater func(old Client) (Client, error)) error { return errors.New("static clients: read-only cannot update client") } + +type staticPasswordsStorage struct { + Storage + + passwordsByEmail map[string]Password +} + +// WithStaticPasswords returns a storage with a read-only set of passwords. Write actions, +// such as creating other passwords, will fail. +func WithStaticPasswords(s Storage, staticPasswords []Password) Storage { + passwordsByEmail := make(map[string]Password, len(staticPasswords)) + for _, p := range staticPasswords { + p.Email = strings.ToLower(p.Email) + passwordsByEmail[p.Email] = p + } + return staticPasswordsStorage{s, passwordsByEmail} +} + +func (s staticPasswordsStorage) GetPassword(email string) (Password, error) { + if password, ok := s.passwordsByEmail[strings.ToLower(email)]; ok { + return password, nil + } + return Password{}, ErrNotFound +} + +func (s staticPasswordsStorage) CreatePassword(p Password) error { + return errors.New("static passwords: read-only cannot create password") +} + +func (s staticPasswordsStorage) DeletePassword(id string) error { + return errors.New("static passwords: read-only cannot create password") +} + +func (s staticPasswordsStorage) UpdatePassword(id string, updater func(old Password) (Password, error)) error { + return errors.New("static passwords: read-only cannot update password") +} diff --git a/storage/storage.go b/storage/storage.go index 4a92485b..78c162f9 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -16,12 +16,12 @@ import ( ) var ( - // stubbed out for testing - now = time.Now -) + // ErrNotFound is the error returned by storages if a resource cannot be found. + ErrNotFound = errors.New("not found") -// ErrNotFound is the error returned by storages if a resource cannot be found. -var ErrNotFound = errors.New("not found") + // ErrAlreadyExists is the error returned by storages if a resource ID is taken during a create. + ErrAlreadyExists = errors.New("ID already exists") +) // Kubernetes only allows lower case letters for names. // @@ -51,6 +51,7 @@ type Storage interface { CreateClient(c Client) error CreateAuthCode(c AuthCode) error CreateRefresh(r RefreshToken) error + CreatePassword(p Password) error // TODO(ericchiang): return (T, bool, error) so we can indicate not found // requests that way instead of using ErrNotFound. @@ -59,6 +60,7 @@ type Storage interface { GetClient(id string) (Client, error) GetKeys() (Keys, error) GetRefresh(id string) (RefreshToken, error) + GetPassword(email string) (Password, error) ListClients() ([]Client, error) ListRefreshTokens() ([]RefreshToken, error) @@ -68,6 +70,7 @@ type Storage interface { DeleteAuthCode(code string) error DeleteClient(id string) error DeleteRefresh(id string) error + DeletePassword(email string) error // Update functions are assumed to be a performed within a single object transaction. // @@ -75,6 +78,7 @@ type Storage interface { UpdateClient(id string, updater func(old Client) (Client, error)) error UpdateKeys(updater func(old Keys) (Keys, error)) error UpdateAuthRequest(id string, updater func(a AuthRequest) (AuthRequest, error)) error + UpdatePassword(email string, updater func(p Password) (Password, error)) error // TODO(ericchiang): Add a GarbageCollect(now time.Time) method so conformance tests // can test implementations. @@ -217,6 +221,28 @@ type RefreshToken struct { Nonce string } +// Password is an email to password mapping managed by the storage. +type Password struct { + // Email and identifying name of the password. Emails are assumed to be valid and + // determining that an end-user controls the address is left to an outside application. + // + // Emails are case insensitive and should be standardized by the storage. + // + // Storages that don't support an extended character set for IDs, such as '.' and '@' + // (cough cough, kubernetes), must map this value appropriately. + Email string `yaml:"email"` + + // Bcrypt encoded hash of the password. This package recommends a cost value of at + // least 14. + Hash []byte `yaml:"hash"` + + // Optional username to display. NOT used during login. + Username string `yaml:"username"` + + // Randomly generated user ID. This is NOT the primary ID of the Password object. + UserID string `yaml:"userID"` +} + // VerificationKey is a rotated signing key which can still be used to verify // signatures. type VerificationKey struct {