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:
commit
182f14fb30
3
TODO.md
3
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
|
||||
|
@ -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.
|
||||
|
@ -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)
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
})
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -138,6 +138,13 @@ var migrations = []migration{
|
||||
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 (
|
||||
id text not null primary key,
|
||||
|
@ -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")
|
||||
}
|
@ -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 {
|
||||
|
Reference in New Issue
Block a user