Merge pull request #590 from ericchiang/dev-add-password-resource

dev branch: add a password resource for local email/password login
This commit is contained in:
Eric Chiang 2016-10-06 10:41:24 -07:00 committed by GitHub
commit 182f14fb30
18 changed files with 643 additions and 45 deletions

View file

@ -33,7 +33,7 @@ Documentation
Storage Storage
- [ ] Add SQL storage implementation - [x] Add SQL storage implementation
- [ ] Utilize fixes for third party resources in Kubernetes 1.4 - [ ] Utilize fixes for third party resources in Kubernetes 1.4
UX UX
@ -48,3 +48,4 @@ Backend
- [ ] Improve logging, possibly switch to logrus - [ ] Improve logging, possibly switch to logrus
- [ ] Standardize OAuth2 error handling - [ ] Standardize OAuth2 error handling
- [ ] Switch to github.com/ghodss/yaml for []byte to base64 string logic

View file

@ -1,6 +1,7 @@
package main package main
import ( import (
"encoding/base64"
"fmt" "fmt"
"github.com/coreos/dex/connector" "github.com/coreos/dex/connector"
@ -26,7 +27,46 @@ type Config struct {
Templates server.TemplateConfig `yaml:"templates"` 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"` 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. // OAuth2 describes enabled OAuth2 extensions.

View file

@ -55,7 +55,8 @@ func serve(cmd *cobra.Command, args []string) error {
errMsg string errMsg string
}{ }{
{c.Issuer == "", "no issuer specified in config file"}, {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.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.HTTP == "" && c.Web.HTTPS == "", "must supply a HTTP/HTTPS address to listen on"},
{c.Web.HTTPS != "" && c.Web.TLSCert == "", "no cert specified for HTTPS"}, {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 { if len(c.StaticClients) > 0 {
s = storage.WithStaticClients(s, c.StaticClients) 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{ serverConfig := server.Config{
SupportedResponseTypes: c.OAuth2.ResponseTypes, SupportedResponseTypes: c.OAuth2.ResponseTypes,
@ -110,6 +120,7 @@ func serve(cmd *cobra.Command, args []string) error {
Connectors: connectors, Connectors: connectors,
Storage: s, Storage: s,
TemplateConfig: c.Templates, TemplateConfig: c.Templates,
EnablePasswordDB: c.EnablePasswordDB,
} }
serv, err := server.NewServer(serverConfig) serv, err := server.NewServer(serverConfig)

View file

@ -11,16 +11,23 @@ connectors:
- type: mockCallback - type: mockCallback
id: mock-callback id: mock-callback
name: Mock 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: staticClients:
- id: example-app - id: example-app
redirectURIs: redirectURIs:
- 'http://127.0.0.1:5555/callback' - 'http://127.0.0.1:5555/callback'
name: 'Example App' name: 'Example App'
secret: ZXhhbXBsZS1hcHAtc2VjcmV0 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"

View file

@ -46,3 +46,12 @@ kind: ThirdPartyResource
description: "Refresh tokens for clients to continuously act on behalf of an end user." description: "Refresh tokens for clients to continuously act on behalf of an end user."
versions: versions:
- name: v1 - name: v1
---
metadata:
name: password.passwords.oidc.coreos.com
apiVersion: extensions/v1beta1
kind: ThirdPartyResource
description: "Passwords managed by the OIDC server."
versions:
- name: v1

View file

@ -3,12 +3,15 @@ package server
import ( import (
"errors" "errors"
"fmt" "fmt"
"log"
"net/http" "net/http"
"net/url" "net/url"
"path" "path"
"sync/atomic" "sync/atomic"
"time" "time"
"golang.org/x/crypto/bcrypt"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/coreos/dex/connector" "github.com/coreos/dex/connector"
@ -44,6 +47,8 @@ type Config struct {
// If specified, the server will use this function for determining time. // If specified, the server will use this function for determining time.
Now func() time.Time Now func() time.Time
EnablePasswordDB bool
TemplateConfig TemplateConfig TemplateConfig TemplateConfig
} }
@ -91,6 +96,14 @@ func newServer(c Config, rotationStrategy rotationStrategy) (*Server, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("server: can't parse issuer URL") 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 { if len(c.Connectors) == 0 {
return nil, errors.New("server: no connectors specified") return nil, errors.New("server: no connectors specified")
} }
@ -182,6 +195,38 @@ func (s *Server) absURL(pathItems ...string) string {
return u.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 // newKeyCacher returns a storage which caches keys so long as the next
func newKeyCacher(s storage.Storage, now func() time.Time) storage.Storage { func newKeyCacher(s storage.Storage, now func() time.Time) storage.Storage {
if now == nil { if now == nil {

View file

@ -16,9 +16,12 @@ import (
"time" "time"
"github.com/ericchiang/oidc" "github.com/ericchiang/oidc"
"github.com/kylelemons/godebug/pretty"
"golang.org/x/crypto/bcrypt"
"golang.org/x/net/context" "golang.org/x/net/context"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"github.com/coreos/dex/connector"
"github.com/coreos/dex/connector/mock" "github.com/coreos/dex/connector/mock"
"github.com/coreos/dex/storage" "github.com/coreos/dex/storage"
"github.com/coreos/dex/storage/memory" "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 { type storageWithKeysTrigger struct {
storage.Storage storage.Storage
f func() f func()

View file

@ -8,6 +8,8 @@ import (
"testing" "testing"
"time" "time"
"golang.org/x/crypto/bcrypt"
"github.com/coreos/dex/storage" "github.com/coreos/dex/storage"
"github.com/kylelemons/godebug/pretty" "github.com/kylelemons/godebug/pretty"
@ -30,6 +32,7 @@ func RunTestSuite(t *testing.T, sf StorageFactory) {
{"AuthRequestCRUD", testAuthRequestCRUD}, {"AuthRequestCRUD", testAuthRequestCRUD},
{"ClientCRUD", testClientCRUD}, {"ClientCRUD", testClientCRUD},
{"RefreshTokenCRUD", testRefreshTokenCRUD}, {"RefreshTokenCRUD", testRefreshTokenCRUD},
{"PasswordCRUD", testPasswordCRUD},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { 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 { if _, err := s.GetRefresh(id); err != storage.ErrNotFound {
t.Errorf("after deleting refresh expected storage.ErrNotFound, got %v", err) 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)
}
} }

View file

@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@ -165,6 +166,26 @@ func (c *client) delete(resource, name string) error {
return checkHTTPErr(resp, http.StatusOK) 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 { func (c *client) put(resource, name string, v interface{}) error {
body, err := json.Marshal(v) body, err := json.Marshal(v)
if err != nil { 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) { func newClient(cluster k8sapi.Cluster, user k8sapi.AuthInfo, namespace string) (*client, error) {
tlsConfig := cryptopasta.DefaultTLSConfig() tlsConfig := cryptopasta.DefaultTLSConfig()
data := func(b []byte, file string) ([]byte, error) { data := func(b string, file string) ([]byte, error) {
if b != nil { if b != "" {
return b, nil return base64.StdEncoding.DecodeString(b)
} }
if file == "" { if file == "" {
return nil, nil return nil, nil

View file

@ -62,7 +62,9 @@ type Cluster struct {
// CertificateAuthority is the path to a cert file for the certificate authority. // CertificateAuthority is the path to a cert file for the certificate authority.
CertificateAuthority string `yaml:"certificate-authority,omitempty"` CertificateAuthority string `yaml:"certificate-authority,omitempty"`
// CertificateAuthorityData contains PEM-encoded certificate authority certificates. Overrides CertificateAuthority // 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 holds additional information. This is useful for extenders so that reads and writes don't clobber unknown fields
Extensions []NamedExtension `yaml:"extensions,omitempty"` Extensions []NamedExtension `yaml:"extensions,omitempty"`
} }
@ -72,11 +74,15 @@ type AuthInfo struct {
// ClientCertificate is the path to a client cert file for TLS. // ClientCertificate is the path to a client cert file for TLS.
ClientCertificate string `yaml:"client-certificate,omitempty"` ClientCertificate string `yaml:"client-certificate,omitempty"`
// ClientCertificateData contains PEM-encoded data from a client cert file for TLS. Overrides ClientCertificate // 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 is the path to a client key file for TLS.
ClientKey string `yaml:"client-key,omitempty"` ClientKey string `yaml:"client-key,omitempty"`
// ClientKeyData contains PEM-encoded data from a client key file for TLS. Overrides ClientKey // 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 is the bearer token for authentication to the kubernetes cluster.
Token string `yaml:"token,omitempty"` Token string `yaml:"token,omitempty"`
// Impersonate is the username to imperonate. The name matches the flag. // Impersonate is the username to imperonate. The name matches the flag.

View file

@ -20,6 +20,7 @@ const (
kindClient = "OAuth2Client" kindClient = "OAuth2Client"
kindRefreshToken = "RefreshToken" kindRefreshToken = "RefreshToken"
kindKeys = "SigningKey" kindKeys = "SigningKey"
kindPassword = "Password"
) )
const ( const (
@ -28,6 +29,7 @@ const (
resourceClient = "oauth2clients" resourceClient = "oauth2clients"
resourceRefreshToken = "refreshtokens" resourceRefreshToken = "refreshtokens"
resourceKeys = "signingkeies" // Kubernetes attempts to pluralize. resourceKeys = "signingkeies" // Kubernetes attempts to pluralize.
resourcePassword = "passwords"
) )
// Config values for the Kubernetes storage type. // 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)) 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 { func (cli *client) CreateRefresh(r storage.RefreshToken) error {
refresh := RefreshToken{ refresh := RefreshToken{
TypeMeta: k8sapi.TypeMeta{ TypeMeta: k8sapi.TypeMeta{
@ -152,6 +158,14 @@ func (cli *client) GetClient(id string) (storage.Client, error) {
return toStorageClient(c), nil 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) { func (cli *client) GetKeys() (storage.Keys, error) {
var keys Keys var keys Keys
if err := cli.get(resourceKeys, keysName, &keys); err != nil { 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) 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 { func (cli *client) UpdateClient(id string, updater func(old storage.Client) (storage.Client, error)) error {
var c Client var c Client
if err := cli.get(resourceClient, id, &c); err != nil { 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) 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 { func (cli *client) UpdateKeys(updater func(old storage.Keys) (storage.Keys, error)) error {
firstUpdate := false firstUpdate := false
var keys Keys var keys Keys

View file

@ -75,7 +75,18 @@ func TestURLFor(t *testing.T) {
func TestStorage(t *testing.T) { func TestStorage(t *testing.T) {
client := loadClient(t) client := loadClient(t)
conformance.RunTestSuite(t, func() storage.Storage { 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 return client
}) })
} }

View file

@ -1,6 +1,8 @@
package kubernetes package kubernetes
import ( import (
"encoding/base32"
"strings"
"time" "time"
jose "gopkg.in/square/go-jose.v2" jose "gopkg.in/square/go-jose.v2"
@ -182,6 +184,60 @@ func (cli *client) fromStorageAuthRequest(a storage.AuthRequest) AuthRequest {
return req 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 // AuthCode is a mirrored struct from storage with JSON struct tags and
// Kubernetes type metadata. // Kubernetes type metadata.
type AuthCode struct { type AuthCode struct {

View file

@ -2,7 +2,7 @@
package memory package memory
import ( import (
"errors" "strings"
"sync" "sync"
"github.com/coreos/dex/storage" "github.com/coreos/dex/storage"
@ -15,6 +15,7 @@ func New() storage.Storage {
authCodes: make(map[string]storage.AuthCode), authCodes: make(map[string]storage.AuthCode),
refreshTokens: make(map[string]storage.RefreshToken), refreshTokens: make(map[string]storage.RefreshToken),
authReqs: make(map[string]storage.AuthRequest), authReqs: make(map[string]storage.AuthRequest),
passwords: make(map[string]storage.Password),
} }
} }
@ -37,6 +38,7 @@ type memStorage struct {
authCodes map[string]storage.AuthCode authCodes map[string]storage.AuthCode
refreshTokens map[string]storage.RefreshToken refreshTokens map[string]storage.RefreshToken
authReqs map[string]storage.AuthRequest authReqs map[string]storage.AuthRequest
passwords map[string]storage.Password
keys storage.Keys keys storage.Keys
} }
@ -47,28 +49,73 @@ func (s *memStorage) tx(f func()) {
f() f()
} }
var errAlreadyExists = errors.New("already exists")
func (s *memStorage) Close() error { return nil } func (s *memStorage) Close() error { return nil }
func (s *memStorage) CreateClient(c storage.Client) error { func (s *memStorage) CreateClient(c storage.Client) (err error) {
s.tx(func() { s.clients[c.ID] = c }) s.tx(func() {
return nil 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 { func (s *memStorage) CreateAuthCode(c storage.AuthCode) (err error) {
s.tx(func() { s.authCodes[c.ID] = c }) s.tx(func() {
return nil 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 { func (s *memStorage) CreateRefresh(r storage.RefreshToken) (err error) {
s.tx(func() { s.refreshTokens[r.RefreshToken] = r }) s.tx(func() {
return nil 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 { func (s *memStorage) CreateAuthRequest(a storage.AuthRequest) (err error) {
s.tx(func() { s.authReqs[a.ID] = a }) s.tx(func() {
return nil 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) { 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 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) { func (s *memStorage) DeleteClient(id string) (err error) {
s.tx(func() { s.tx(func() {
if _, ok := s.clients[id]; !ok { if _, ok := s.clients[id]; !ok {
@ -235,9 +294,24 @@ func (s *memStorage) UpdateAuthRequest(id string, updater func(old storage.AuthR
err = storage.ErrNotFound err = storage.ErrNotFound
return return
} }
if req, err := updater(req); err == nil { if req, err = updater(req); err == nil {
s.authReqs[id] = req s.authReqs[id] = req
} }
}) })
return 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
}

View file

@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings"
"github.com/coreos/dex/storage" "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, a.Claims.UserID, a.Claims.Username, a.Claims.Email, a.Claims.EmailVerified,
encoder(a.Claims.Groups), encoder(a.Claims.Groups),
a.ConnectorID, a.ConnectorData, a.ConnectorID, a.ConnectorData,
a.Expiry, a.ID, a.Expiry, r.ID,
) )
if err != nil { if err != nil {
return fmt.Errorf("update auth request: %v", err) return fmt.Errorf("update auth request: %v", err)
@ -462,14 +463,83 @@ func scanClient(s scanner) (cli storage.Client, err error) {
return cli, nil return cli, nil
} }
func (c *conn) DeleteAuthRequest(id string) error { return c.delete("auth_request", id) } func (c *conn) CreatePassword(p storage.Password) error {
func (c *conn) DeleteAuthCode(id string) error { return c.delete("auth_code", id) } p.Email = strings.ToLower(p.Email)
func (c *conn) DeleteClient(id string) error { return c.delete("client", id) } _, err := c.Exec(`
func (c *conn) DeleteRefresh(id string) error { return c.delete("refresh_token", id) } 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. // Do NOT call directly. Does not escape table.
func (c *conn) delete(table, id string) error { func (c *conn) delete(table, field, id string) error {
result, err := c.Exec(`delete from `+table+` where id = $1`, id) result, err := c.Exec(`delete from `+table+` where `+field+` = $1`, id)
if err != nil { if err != nil {
return fmt.Errorf("delete %s: %v", table, id) return fmt.Errorf("delete %s: %v", table, id)
} }

View file

@ -138,6 +138,13 @@ var migrations = []migration{
connector_data bytea 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 -- keys is a weird table because we only ever expect there to be a single row
create table keys ( create table keys (
id text not null primary key, id text not null primary key,

View file

@ -1,6 +1,9 @@
package storage package storage
import "errors" import (
"errors"
"strings"
)
// Tests for this code are in the "memory" package, since this package doesn't // Tests for this code are in the "memory" package, since this package doesn't
// define a concrete storage implementation. // 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 { func (s staticClientsStorage) UpdateClient(id string, updater func(old Client) (Client, error)) error {
return errors.New("static clients: read-only cannot update client") 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")
}

View file

@ -16,12 +16,12 @@ import (
) )
var ( var (
// stubbed out for testing // ErrNotFound is the error returned by storages if a resource cannot be found.
now = time.Now ErrNotFound = errors.New("not found")
)
// ErrNotFound is the error returned by storages if a resource cannot be found. // ErrAlreadyExists is the error returned by storages if a resource ID is taken during a create.
var ErrNotFound = errors.New("not found") ErrAlreadyExists = errors.New("ID already exists")
)
// Kubernetes only allows lower case letters for names. // Kubernetes only allows lower case letters for names.
// //
@ -51,6 +51,7 @@ type Storage interface {
CreateClient(c Client) error CreateClient(c Client) error
CreateAuthCode(c AuthCode) error CreateAuthCode(c AuthCode) error
CreateRefresh(r RefreshToken) error CreateRefresh(r RefreshToken) error
CreatePassword(p Password) error
// TODO(ericchiang): return (T, bool, error) so we can indicate not found // TODO(ericchiang): return (T, bool, error) so we can indicate not found
// requests that way instead of using ErrNotFound. // requests that way instead of using ErrNotFound.
@ -59,6 +60,7 @@ type Storage interface {
GetClient(id string) (Client, error) GetClient(id string) (Client, error)
GetKeys() (Keys, error) GetKeys() (Keys, error)
GetRefresh(id string) (RefreshToken, error) GetRefresh(id string) (RefreshToken, error)
GetPassword(email string) (Password, error)
ListClients() ([]Client, error) ListClients() ([]Client, error)
ListRefreshTokens() ([]RefreshToken, error) ListRefreshTokens() ([]RefreshToken, error)
@ -68,6 +70,7 @@ type Storage interface {
DeleteAuthCode(code string) error DeleteAuthCode(code string) error
DeleteClient(id string) error DeleteClient(id string) error
DeleteRefresh(id string) error DeleteRefresh(id string) error
DeletePassword(email string) error
// Update functions are assumed to be a performed within a single object transaction. // 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 UpdateClient(id string, updater func(old Client) (Client, error)) error
UpdateKeys(updater func(old Keys) (Keys, error)) error UpdateKeys(updater func(old Keys) (Keys, error)) error
UpdateAuthRequest(id string, updater func(a AuthRequest) (AuthRequest, 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 // TODO(ericchiang): Add a GarbageCollect(now time.Time) method so conformance tests
// can test implementations. // can test implementations.
@ -217,6 +221,28 @@ type RefreshToken struct {
Nonce string 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 // VerificationKey is a rotated signing key which can still be used to verify
// signatures. // signatures.
type VerificationKey struct { type VerificationKey struct {