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
- [ ] 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

View File

@ -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.

View File

@ -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)

View File

@ -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"

View File

@ -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

View File

@ -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 {

View File

@ -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()

View File

@ -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)
}
}

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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
})
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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)
}

View File

@ -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 (

View File

@ -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")
}

View File

@ -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 {