server: endpoint and system for sending invitations to dex

An invitation allows users to both verify their email address and set
a new password.
This commit is contained in:
Joe Bowers 2015-11-09 14:35:11 -08:00
parent 2cdb6c0adb
commit 0c854a21d6
17 changed files with 466 additions and 102 deletions

View file

@ -302,7 +302,9 @@ func setEmailer(srv *Server, issuerName, fromAddress, emailerConfigFile string,
tMailer, tMailer,
fromAddress, fromAddress,
srv.absURL(httpPathResetPassword), srv.absURL(httpPathResetPassword),
srv.absURL(httpPathEmailVerify)) srv.absURL(httpPathEmailVerify),
srv.absURL(httpPathAcceptInvitation),
)
srv.UserEmailer = ue srv.UserEmailer = ue
return nil return nil

View file

@ -39,6 +39,7 @@ var (
httpPathVerifyEmailResend = "/resend-verify-email" httpPathVerifyEmailResend = "/resend-verify-email"
httpPathSendResetPassword = "/send-reset-password" httpPathSendResetPassword = "/send-reset-password"
httpPathResetPassword = "/reset-password" httpPathResetPassword = "/reset-password"
httpPathAcceptInvitation = "/accept-invitation"
httpPathDebugVars = "/debug/vars" httpPathDebugVars = "/debug/vars"
cookieLastSeen = "LastSeen" cookieLastSeen = "LastSeen"

99
server/invitation.go Normal file
View file

@ -0,0 +1,99 @@
package server
import (
"net/http"
"net/url"
"time"
"github.com/coreos/dex/pkg/log"
"github.com/coreos/dex/user"
"github.com/coreos/go-oidc/jose"
"github.com/coreos/go-oidc/key"
)
type invitationTemplateData struct {
Error, Message string
}
type InvitationHandler struct {
issuerURL url.URL
passwordResetURL url.URL
um *user.Manager
keysFunc func() ([]key.PublicKey, error)
signerFunc func() (jose.Signer, error)
redirectValidityWindow time.Duration
}
func (h *InvitationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
h.handleGET(w, r)
default:
writeAPIError(w, http.StatusMethodNotAllowed, newAPIError(errorInvalidRequest,
"method not allowed"))
}
}
func (h *InvitationHandler) handleGET(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
token := q.Get("token")
keys, err := h.keysFunc()
if err != nil {
log.Errorf("internal error getting public keys: %v", err)
writeAPIError(w, http.StatusInternalServerError, newAPIError(errorServerError,
"There's been an error processing your request."))
return
}
invite, err := user.ParseAndVerifyInvitationToken(token, h.issuerURL, keys)
if err != nil {
log.Debugf("invalid invitation token: %v (%v)", err, token)
writeAPIError(w, http.StatusBadRequest, newAPIError(errorInvalidRequest,
"Your invitation could not be verified"))
return
}
_, err = h.um.VerifyEmail(invite)
if err != nil && err != user.ErrorEmailAlreadyVerified {
// Allow AlreadyVerified folks to pass through- otherwise
// folks who encounter an error after passing this point will
// never be able to set their passwords.
log.Debugf("error attempting to verify email: %v", err)
switch err {
case user.ErrorEVEmailDoesntMatch:
writeAPIError(w, http.StatusBadRequest, newAPIError(errorInvalidRequest,
"Your email does not match the email address on file"))
return
default:
log.Errorf("internal error verifying email: %v", err)
writeAPIError(w, http.StatusInternalServerError, newAPIError(errorServerError,
"There's been an error processing your request."))
return
}
}
passwordReset := invite.PasswordReset(h.issuerURL, h.redirectValidityWindow)
signer, err := h.signerFunc()
if err != nil || signer == nil {
log.Errorf("error getting signer: %v (signer: %v)", err, signer)
writeAPIError(w, http.StatusInternalServerError, newAPIError(errorServerError,
"There's been an error processing your request."))
return
}
jwt, err := jose.NewSignedJWT(passwordReset.Claims, signer)
if err != nil {
log.Errorf("error constructing or signing PasswordReset from Invitation JWT: %v", err)
writeAPIError(w, http.StatusInternalServerError, newAPIError(errorServerError,
"There's been an error processing your request."))
return
}
passwordResetToken := jwt.Encode()
passwordResetURL := h.passwordResetURL
newQuery := passwordResetURL.Query()
newQuery.Set("token", passwordResetToken)
passwordResetURL.RawQuery = newQuery.Encode()
http.Redirect(w, r, passwordResetURL.String(), http.StatusSeeOther)
}

189
server/invitation_test.go Normal file
View file

@ -0,0 +1,189 @@
package server
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/jonboulle/clockwork"
"github.com/coreos/dex/user"
"github.com/coreos/go-oidc/jose"
"github.com/coreos/go-oidc/key"
)
var (
clock = clockwork.NewRealClock()
)
func TestInvitationHandler(t *testing.T) {
invUserID := "ID-1"
invVerifiedID := "ID-Verified"
invGoodSigner := key.NewPrivateKeySet([]*key.PrivateKey{testPrivKey},
time.Now().Add(time.Minute)).Active().Signer()
badKey, err := key.GeneratePrivateKey()
if err != nil {
panic(fmt.Sprintf("couldn't make new key: %q", err))
}
invBadSigner := key.NewPrivateKeySet([]*key.PrivateKey{badKey},
time.Now().Add(time.Minute)).Active().Signer()
makeInvitationToken := func(password, userID, clientID, email string, callback url.URL, expires time.Duration, signer jose.Signer) string {
iv := user.NewInvitation(
user.User{ID: userID, Email: email},
user.Password(password),
testIssuerURL,
clientID,
callback,
expires)
jwt, err := jose.NewSignedJWT(iv.Claims, signer)
if err != nil {
t.Fatalf("couldn't make token: %q", err)
}
token := jwt.Encode()
return token
}
tests := []struct {
userID string
query url.Values
signer jose.Signer
wantCode int
wantCallback url.URL
wantEmailVerified bool
}{
{ // Case 0 Happy Path
userID: invUserID,
query: url.Values{
"token": []string{makeInvitationToken("password", invUserID, testClientID, "Email-1@example.com", testRedirectURL, time.Hour*1, invGoodSigner)},
},
signer: invGoodSigner,
wantCode: http.StatusSeeOther,
wantCallback: testRedirectURL,
wantEmailVerified: true,
},
{ // Case 1 user already verified
userID: invVerifiedID,
query: url.Values{
"token": []string{makeInvitationToken("password", invVerifiedID, testClientID, "Email-Verified@example.com", testRedirectURL, time.Hour*1, invGoodSigner)},
},
signer: invGoodSigner,
wantCode: http.StatusSeeOther,
wantCallback: testRedirectURL,
wantEmailVerified: true,
},
{ // Case 2 bad email
userID: invUserID,
query: url.Values{
"token": []string{makeInvitationToken("password", invVerifiedID, testClientID, "NOPE@NOPE.com", testRedirectURL, time.Hour*1, invGoodSigner)},
},
signer: invGoodSigner,
wantCode: http.StatusBadRequest,
wantCallback: testRedirectURL,
wantEmailVerified: false,
},
{ // Case 3 bad signer
userID: invUserID,
query: url.Values{
"token": []string{makeInvitationToken("password", invUserID, testClientID, "Email-1@example.com", testRedirectURL, time.Hour*1, invBadSigner)},
},
signer: invGoodSigner,
wantCode: http.StatusBadRequest,
wantCallback: testRedirectURL,
wantEmailVerified: false,
},
}
for i, tt := range tests {
f, err := makeTestFixtures()
if err != nil {
t.Fatalf("case %d: could not make test fixtures: %v", i, err)
}
keys, err := f.srv.KeyManager.PublicKeys()
if err != nil {
t.Fatalf("case %d: test fixture key infrastructure is broken: %v", i, err)
}
tZero := clock.Now()
handler := &InvitationHandler{
passwordResetURL: f.srv.absURL("RESETME"),
issuerURL: testIssuerURL,
um: f.srv.UserManager,
keysFunc: f.srv.KeyManager.PublicKeys,
signerFunc: func() (jose.Signer, error) { return tt.signer, nil },
redirectValidityWindow: 100 * time.Second,
}
w := httptest.NewRecorder()
u := testIssuerURL
u.RawQuery = tt.query.Encode()
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
t.Fatalf("case %d: impossible error: %v", i, err)
}
handler.ServeHTTP(w, req)
if tt.wantCode != w.Code {
t.Errorf("case %d: wantCode=%v, got=%v", i, tt.wantCode, w.Code)
continue
}
usr, err := f.srv.UserManager.Get(tt.userID)
if err != nil {
t.Fatalf("case %d: unexpected error: %v", i, err)
}
if usr.EmailVerified != tt.wantEmailVerified {
t.Errorf("case %d: wantEmailVerified=%v got=%v", i, tt.wantEmailVerified, usr.EmailVerified)
}
if w.Code == http.StatusSeeOther {
locString := w.HeaderMap.Get("Location")
loc, err := url.Parse(locString)
if err != nil {
t.Fatalf("case %d: redirect returned nonsense url: '%v', %v", i, locString, err)
}
pwrToken := loc.Query().Get("token")
pwrReset, err := user.ParseAndVerifyPasswordResetToken(pwrToken, testIssuerURL, keys)
if err != nil {
t.Errorf("case %d: password token is invalid: %v", i, err)
}
expTime := pwrReset.Claims["exp"].(float64)
if expTime > float64(tZero.Add(handler.redirectValidityWindow).Unix()) ||
expTime < float64(tZero.Unix()) {
t.Errorf("case %d: funny expiration time detected: %d", i, pwrReset.Claims["exp"])
}
if pwrReset.Claims["aud"] != testClientID {
t.Errorf("case %d: wanted \"aud\"=%v got=%v", i, testClientID, pwrReset.Claims["aud"])
}
if pwrReset.Claims["iss"] != testIssuerURL.String() {
t.Errorf("case %d: wanted \"iss\"=%v got=%v", i, testIssuerURL, pwrReset.Claims["iss"])
}
if pwrReset.UserID() != tt.userID {
t.Errorf("case %d: wanted UserID=%v got=%v", i, tt.userID, pwrReset.UserID())
}
if bytes.Compare(pwrReset.Password(), user.Password("password")) != 0 {
t.Errorf("case %d: wanted Password=%v got=%v", i, user.Password("password"), pwrReset.Password())
}
if *pwrReset.Callback() != testRedirectURL {
t.Errorf("case %d: wanted callback=%v got=%v", i, testRedirectURL, pwrReset.Callback())
}
}
}
}

View file

@ -357,7 +357,7 @@ func TestSendResetPasswordEmailHandler(t *testing.T) {
func TestResetPasswordHandler(t *testing.T) { func TestResetPasswordHandler(t *testing.T) {
makeToken := func(userID, password, clientID string, callback url.URL, expires time.Duration, signer jose.Signer) string { makeToken := func(userID, password, clientID string, callback url.URL, expires time.Duration, signer jose.Signer) string {
pr := user.NewPasswordReset(user.User{ID: "ID-1"}, pr := user.NewPasswordReset("ID-1",
user.Password(password), user.Password(password),
testIssuerURL, testIssuerURL,
clientID, clientID,

View file

@ -231,6 +231,15 @@ func (s *Server) HTTPHandler() http.Handler {
keysFunc: s.KeyManager.PublicKeys, keysFunc: s.KeyManager.PublicKeys,
}) })
mux.Handle(httpPathAcceptInvitation, &InvitationHandler{
passwordResetURL: s.absURL(httpPathResetPassword),
issuerURL: s.IssuerURL,
um: s.UserManager,
keysFunc: s.KeyManager.PublicKeys,
signerFunc: s.KeyManager.Signer,
redirectValidityWindow: s.SessionManager.ValidityWindow,
})
mux.HandleFunc(httpPathDebugVars, health.ExpvarHandler) mux.HandleFunc(httpPathDebugVars, health.ExpvarHandler)
pcfg := s.ProviderConfig() pcfg := s.ProviderConfig()

View file

@ -42,6 +42,19 @@ var (
}, },
}, },
}, },
{
User: user.User{
ID: "ID-Verified",
Email: "Email-Verified@example.com",
EmailVerified: true,
},
RemoteIdentities: []user.RemoteIdentity{
{
ConnectorID: "IDPC-1",
ID: "RID-2",
},
},
},
} }
testPasswordInfos = []user.PasswordInfo{ testPasswordInfos = []user.PasswordInfo{
@ -49,6 +62,10 @@ var (
UserID: "ID-1", UserID: "ID-1",
Password: []byte("password"), Password: []byte("password"),
}, },
{
UserID: "ID-Verified",
Password: []byte("password"),
},
} }
testPrivKey, _ = key.GeneratePrivateKey() testPrivKey, _ = key.GeneratePrivateKey()
@ -162,7 +179,9 @@ func makeTestFixtures() (*testFixtures, error) {
emailer, emailer,
"noreply@example.com", "noreply@example.com",
srv.absURL(httpPathResetPassword), srv.absURL(httpPathResetPassword),
srv.absURL(httpPathEmailVerify)) srv.absURL(httpPathEmailVerify),
srv.absURL(httpPathAcceptInvitation),
)
return &testFixtures{ return &testFixtures{
srv: srv, srv: srv,

View file

@ -88,7 +88,6 @@ type UsersAPI struct {
} }
type Emailer interface { type Emailer interface {
SendResetPasswordEmail(string, url.URL, string) (*url.URL, error)
SendInviteEmail(string, url.URL, string) (*url.URL, error) SendInviteEmail(string, url.URL, string) (*url.URL, error)
} }

View file

@ -23,6 +23,7 @@ type UserEmailer struct {
passwordResetURL url.URL passwordResetURL url.URL
verifyEmailURL url.URL verifyEmailURL url.URL
invitationURL url.URL
} }
// NewUserEmailer creates a new UserEmailer. // NewUserEmailer creates a new UserEmailer.
@ -35,6 +36,7 @@ func NewUserEmailer(ur user.UserRepo,
fromAddress string, fromAddress string,
passwordResetURL url.URL, passwordResetURL url.URL,
verifyEmailURL url.URL, verifyEmailURL url.URL,
invitationURL url.URL,
) *UserEmailer { ) *UserEmailer {
return &UserEmailer{ return &UserEmailer{
ur: ur, ur: ur,
@ -46,76 +48,65 @@ func NewUserEmailer(ur user.UserRepo,
fromAddress: fromAddress, fromAddress: fromAddress,
passwordResetURL: passwordResetURL, passwordResetURL: passwordResetURL,
verifyEmailURL: verifyEmailURL, verifyEmailURL: verifyEmailURL,
invitationURL: invitationURL,
} }
} }
func (u *UserEmailer) userPasswordInfo(email string) (user.User, user.PasswordInfo, error) {
usr, err := u.ur.GetByEmail(nil, email)
if err != nil {
log.Errorf("Error getting user: %q", err)
return user.User{}, user.PasswordInfo{}, err
}
pwi, err := u.pwi.Get(nil, usr.ID)
if err != nil {
log.Errorf("Error getting password: %q", err)
return user.User{}, user.PasswordInfo{}, err
}
return usr, pwi, nil
}
func (u *UserEmailer) signedClaimsToken(claims jose.Claims) (string, error) {
signer, err := u.signerFn()
if err != nil || signer == nil {
log.Errorf("error getting signer: %v (%v)", err, signer)
return "", err
}
jwt, err := jose.NewSignedJWT(claims, signer)
if err != nil {
log.Errorf("error constructing or signing a JWT: %v", err)
return "", err
}
return jwt.Encode(), nil
}
// SendResetPasswordEmail sends a password reset email to the user specified by the email addresss, containing a link with a signed token which can be visitied to initiate the password change/reset process. // SendResetPasswordEmail sends a password reset email to the user specified by the email addresss, containing a link with a signed token which can be visitied to initiate the password change/reset process.
// This method DOES NOT check for client ID, redirect URL validity - it is expected that upstream users have already done so. // This method DOES NOT check for client ID, redirect URL validity - it is expected that upstream users have already done so.
// If there is no emailer is configured, the URL of the aforementioned link is returned, otherwise nil is returned. // A link that can be used to reset the given user's password is returned.
func (u *UserEmailer) SendResetPasswordEmail(email string, redirectURL url.URL, clientID string) (*url.URL, error) { func (u *UserEmailer) SendResetPasswordEmail(email string, redirectURL url.URL, clientID string) (*url.URL, error) {
return u.sendResetPasswordOrInviteEmail(email, redirectURL, clientID, false) usr, pwi, err := u.userPasswordInfo(email)
}
// SendInviteEmail is exactly the same as SendResetPasswordEmail, except that it uses the invite template and subject name.
// In the near future, invite emails might diverge further.
func (u *UserEmailer) SendInviteEmail(email string, redirectURL url.URL, clientID string) (*url.URL, error) {
return u.sendResetPasswordOrInviteEmail(email, redirectURL, clientID, true)
}
func (u *UserEmailer) sendResetPasswordOrInviteEmail(email string, redirectURL url.URL, clientID string, invite bool) (*url.URL, error) {
usr, err := u.ur.GetByEmail(nil, email)
if err == user.ErrorNotFound {
log.Errorf("No Such user for email: %q", email)
return nil, err
}
if err != nil { if err != nil {
log.Errorf("Error getting user: %q", err)
return nil, err return nil, err
} }
pwi, err := u.pwi.Get(nil, usr.ID) passwordReset := user.NewPasswordReset(usr.ID, pwi.Password, u.issuerURL,
if err == user.ErrorNotFound {
// TODO(bobbyrullo): In this case, maybe send a different email explaining that
// they don't have a local password.
log.Errorf("No Password for userID: %q", usr.ID)
return nil, err
}
if err != nil {
log.Errorf("Error getting password: %q", err)
return nil, err
}
signer, err := u.signerFn()
if err != nil || signer == nil {
log.Errorf("error getting signer: %v (%v)", err, signer)
return nil, err
}
passwordReset := user.NewPasswordReset(usr, pwi.Password, u.issuerURL,
clientID, redirectURL, u.tokenValidityWindow) clientID, redirectURL, u.tokenValidityWindow)
jwt, err := jose.NewSignedJWT(passwordReset.Claims, signer)
token, err := u.signedClaimsToken(passwordReset.Claims)
if err != nil { if err != nil {
log.Errorf("error constructing or signing PasswordReset JWT: %v", err)
return nil, err return nil, err
} }
token := jwt.Encode()
resetURL := u.passwordResetURL resetURL := u.passwordResetURL
q := resetURL.Query() q := resetURL.Query()
q.Set("token", token) q.Set("token", token)
resetURL.RawQuery = q.Encode() resetURL.RawQuery = q.Encode()
var tmplName, subj string
if invite {
tmplName = "invite"
subj = "Activate Your Account"
} else {
tmplName = "password-reset"
subj = "Reset Your Password"
}
if u.emailer != nil { if u.emailer != nil {
err = u.emailer.SendMail(u.fromAddress, subj, tmplName, err = u.emailer.SendMail(u.fromAddress, "Reset Your Password", "password-reset",
map[string]interface{}{ map[string]interface{}{
"email": usr.Email, "email": usr.Email,
"link": resetURL.String(), "link": resetURL.String(),
@ -126,7 +117,44 @@ func (u *UserEmailer) sendResetPasswordOrInviteEmail(email string, redirectURL u
return nil, err return nil, err
} }
return &resetURL, nil return &resetURL, nil
}
// SendInviteEmail is sends an email that allows the user to both
// reset their password *and* verify their email address. Similar to
// SendResetPasswordEmail, the given url and client id are assumed
// valid. A link that can be used to validate the given email address
// and reset the password is returned.
func (u *UserEmailer) SendInviteEmail(email string, redirectURL url.URL, clientID string) (*url.URL, error) {
usr, pwi, err := u.userPasswordInfo(email)
if err != nil {
return nil, err
}
invitation := user.NewInvitation(usr, pwi.Password, u.issuerURL,
clientID, redirectURL, u.tokenValidityWindow)
token, err := u.signedClaimsToken(invitation.Claims)
if err != nil {
return nil, err
}
resetURL := u.invitationURL
q := resetURL.Query()
q.Set("token", token)
resetURL.RawQuery = q.Encode()
if u.emailer != nil {
err = u.emailer.SendMail(u.fromAddress, "Activate Your Account", "invite",
map[string]interface{}{
"email": usr.Email,
"link": resetURL.String(),
}, usr.Email)
if err != nil {
log.Errorf("error sending password reset email %v: ", err)
}
return nil, err
}
return &resetURL, nil
} }
// SendEmailVerification sends an email to the user with the given userID containing a link which when visited marks the user as having had their email verified. // SendEmailVerification sends an email to the user with the given userID containing a link which when visited marks the user as having had their email verified.

View file

@ -17,13 +17,14 @@ import (
) )
var ( var (
validityWindow = time.Hour * 1 validityWindow = time.Hour * 1
issuerURL = url.URL{Host: "dex.example.com"} issuerURL = url.URL{Host: "dex.example.com"}
fromAddress = "dex@example.com" fromAddress = "dex@example.com"
passwordResetURL = url.URL{Host: "dex.example.com", Path: "passwordReset"} passwordResetURL = url.URL{Host: "dex.example.com", Path: "passwordReset"}
verifyEmailURL = url.URL{Host: "dex.example.com", Path: "verifyEmail"} verifyEmailURL = url.URL{Host: "dex.example.com", Path: "verifyEmail"}
redirURL = url.URL{Host: "client.example.com", Path: "/redirURL"} acceptInvitationURL = url.URL{Host: "dex.example.com", Path: "acceptInvitation"}
clientID = "XXX" redirURL = url.URL{Host: "client.example.com", Path: "/redirURL"}
clientID = "XXX"
) )
type testEmailer struct { type testEmailer struct {
@ -98,7 +99,7 @@ func makeTestFixtures() (*UserEmailer, *testEmailer, *key.PublicKey) {
emailer := &testEmailer{} emailer := &testEmailer{}
tEmailer := email.NewTemplatizedEmailerFromTemplates(textTemplates, htmlTemplates, emailer) tEmailer := email.NewTemplatizedEmailerFromTemplates(textTemplates, htmlTemplates, emailer)
userEmailer := NewUserEmailer(ur, pwr, signerFn, validityWindow, issuerURL, tEmailer, fromAddress, passwordResetURL, verifyEmailURL) userEmailer := NewUserEmailer(ur, pwr, signerFn, validityWindow, issuerURL, tEmailer, fromAddress, passwordResetURL, verifyEmailURL, acceptInvitationURL)
return userEmailer, emailer, publicKey return userEmailer, emailer, publicKey
} }

View file

@ -5,19 +5,13 @@ import (
"net/url" "net/url"
"time" "time"
"github.com/jonboulle/clockwork"
"github.com/coreos/go-oidc/jose" "github.com/coreos/go-oidc/jose"
"github.com/coreos/go-oidc/key" "github.com/coreos/go-oidc/key"
"github.com/coreos/go-oidc/oidc" "github.com/coreos/go-oidc/oidc"
) )
var (
clock = clockwork.NewRealClock()
)
// NewEmailVerification creates an object which can be sent to a user // NewEmailVerification creates an object which can be sent to a user
// in serialized form to verify that they control an email addwress. // in serialized form to verify that they control an email address.
// The clientID is the ID of the registering user. The callback is // The clientID is the ID of the registering user. The callback is
// where a user should land after verifying their email. // where a user should land after verifying their email.
func NewEmailVerification(user User, clientID string, issuer url.URL, callback url.URL, expires time.Duration) EmailVerification { func NewEmailVerification(user User, clientID string, issuer url.URL, callback url.URL, expires time.Duration) EmailVerification {

View file

@ -18,6 +18,10 @@ func NewInvitation(user User, password Password, issuer url.URL, clientID string
return Invitation{claims} return Invitation{claims}
} }
// An Invitation is a token that can be used for verifying an email
// address and resetting a password in a single stroke. It will be
// sent as part of a link in an email automatically to newly created
// users if email is configured.
type Invitation struct { type Invitation struct {
Claims jose.Claims Claims jose.Claims
} }
@ -78,3 +82,14 @@ func (iv Invitation) ClientID() string {
func (iv Invitation) Callback() *url.URL { func (iv Invitation) Callback() *url.URL {
return assertURLClaim(iv.Claims, ClaimInvitationCallback) return assertURLClaim(iv.Claims, ClaimInvitationCallback)
} }
func (iv Invitation) PasswordReset(issuer url.URL, expires time.Duration) PasswordReset {
return NewPasswordReset(
iv.UserID(),
iv.Password(),
issuer,
iv.ClientID(),
*iv.Callback(),
expires,
)
}

View file

@ -23,63 +23,53 @@ func TestInvitationParseAndVerify(t *testing.T) {
signer := privKey.Signer() signer := privKey.Signer()
publicKeys := []key.PublicKey{*key.NewPublicKey(privKey.JWK())} publicKeys := []key.PublicKey{*key.NewPublicKey(privKey.JWK())}
goodInvitation := NewInvitation(user, password, *issuer, client, *callback, expires)
goodNoCB := NewInvitation(user, password, *issuer, client, *callback, expires)
expired := NewInvitation(user, password, *issuer, client, *callback, -expires)
wrongIssuer := NewInvitation(user, password, *notIssuer, client, *callback, expires)
noSub := NewInvitation(User{Email: "noid@noid.com"}, password, *issuer, client, *callback, expires)
noEmail := NewInvitation(User{ID: "JONNY_NO_EMAIL"}, password, *issuer, client, *callback, expires)
noPassword := NewInvitation(user, Password(""), *issuer, client, *callback, expires)
noClient := NewInvitation(user, password, *issuer, "", *callback, expires)
noClientNoCB := NewInvitation(user, password, *issuer, "", url.URL{}, expires)
tests := []struct { tests := []struct {
invite Invitation invite Invitation
wantErr bool wantErr bool
signer jose.Signer signer jose.Signer
}{ }{
{ {
invite: goodInvitation, invite: NewInvitation(user, password, *issuer, client, *callback, expires),
signer: signer, signer: signer,
wantErr: false, wantErr: false,
}, },
{ {
invite: goodNoCB, invite: NewInvitation(user, password, *issuer, client, *callback, expires),
signer: signer, signer: signer,
wantErr: false, wantErr: false,
}, },
{ {
invite: expired, invite: NewInvitation(user, password, *issuer, client, *callback, -expires),
signer: signer, signer: signer,
wantErr: true, wantErr: true,
}, },
{ {
invite: wrongIssuer, invite: NewInvitation(user, password, *notIssuer, client, *callback, expires),
signer: signer, signer: signer,
wantErr: true, wantErr: true,
}, },
{ {
invite: noSub, invite: NewInvitation(User{Email: "noid@noid.com"}, password, *issuer, client, *callback, expires),
signer: signer, signer: signer,
wantErr: true, wantErr: true,
}, },
{ {
invite: noEmail, invite: NewInvitation(User{ID: "JONNY_NO_EMAIL"}, password, *issuer, client, *callback, expires),
signer: signer, signer: signer,
wantErr: true, wantErr: true,
}, },
{ {
invite: noPassword, invite: NewInvitation(user, Password(""), *issuer, client, *callback, expires),
signer: signer, signer: signer,
wantErr: true, wantErr: true,
}, },
{ {
invite: noClient, invite: NewInvitation(user, password, *issuer, "", *callback, expires),
signer: signer, signer: signer,
wantErr: true, wantErr: true,
}, },
{ {
invite: noClientNoCB, invite: NewInvitation(user, password, *issuer, "", url.URL{}, expires),
signer: signer, signer: signer,
wantErr: true, wantErr: true,
}, },

View file

@ -205,6 +205,12 @@ func (m *Manager) RegisterWithPassword(email, plaintext, connID string) (string,
return user.ID, nil return user.ID, nil
} }
type EmailVerifiable interface {
UserID() string
Email() string
Callback() *url.URL
}
// VerifyEmail sets EmailVerified to true for the user for the given EmailVerification. // VerifyEmail sets EmailVerified to true for the user for the given EmailVerification.
// The email in the EmailVerification must match the User's email in the // The email in the EmailVerification must match the User's email in the
// repository, and it must not already be verified. // repository, and it must not already be verified.
@ -212,7 +218,7 @@ func (m *Manager) RegisterWithPassword(email, plaintext, connID string) (string,
// create it, ensuring that the token was signed and that the JWT was not // create it, ensuring that the token was signed and that the JWT was not
// expired. // expired.
// The callback url (i.e. where to send the user after the verification) is returned. // The callback url (i.e. where to send the user after the verification) is returned.
func (m *Manager) VerifyEmail(ev EmailVerification) (*url.URL, error) { func (m *Manager) VerifyEmail(ev EmailVerifiable) (*url.URL, error) {
tx, err := m.begin() tx, err := m.begin()
if err != nil { if err != nil {
return nil, err return nil, err
@ -250,7 +256,13 @@ func (m *Manager) VerifyEmail(ev EmailVerification) (*url.URL, error) {
return ev.Callback(), nil return ev.Callback(), nil
} }
func (m *Manager) ChangePassword(pwr PasswordReset, plaintext string) (*url.URL, error) { type PasswordChangeable interface {
UserID() string
Password() Password
Callback() *url.URL
}
func (m *Manager) ChangePassword(pwr PasswordChangeable, plaintext string) (*url.URL, error) {
tx, err := m.begin() tx, err := m.begin()
if err != nil { if err != nil {
return nil, err return nil, err

View file

@ -214,8 +214,8 @@ func NewPasswordInfoRepoFromFile(loc string) (PasswordInfoRepo, error) {
return NewPasswordInfoRepoFromPasswordInfos(pws), nil return NewPasswordInfoRepoFromPasswordInfos(pws), nil
} }
func NewPasswordReset(user User, password Password, issuer url.URL, clientID string, callback url.URL, expires time.Duration) PasswordReset { func NewPasswordReset(userID string, password Password, issuer url.URL, clientID string, callback url.URL, expires time.Duration) PasswordReset {
claims := oidc.NewClaims(issuer.String(), user.ID, clientID, clock.Now(), clock.Now().Add(expires)) claims := oidc.NewClaims(issuer.String(), userID, clientID, clock.Now(), clock.Now().Add(expires))
claims.Add(ClaimPasswordResetPassword, string(password)) claims.Add(ClaimPasswordResetPassword, string(password))
claims.Add(ClaimPasswordResetCallback, callback.String()) claims.Add(ClaimPasswordResetCallback, callback.String())
return PasswordReset{claims} return PasswordReset{claims}

View file

@ -122,7 +122,7 @@ func TestNewPasswordReset(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("case %d: non-nil err: %q", i, err) t.Fatalf("case %d: non-nil err: %q", i, err)
} }
ev := NewPasswordReset(tt.user, tt.password, tt.issuer, tt.clientID, *cbURL, tt.expires) ev := NewPasswordReset(tt.user.ID, tt.password, tt.issuer, tt.clientID, *cbURL, tt.expires)
if diff := pretty.Compare(tt.want, ev.Claims); diff != "" { if diff := pretty.Compare(tt.want, ev.Claims); diff != "" {
t.Errorf("case %d: Compare(want, got): %v", i, diff) t.Errorf("case %d: Compare(want, got): %v", i, diff)
@ -143,15 +143,16 @@ func TestPasswordResetParseAndVerify(t *testing.T) {
callback, _ := url.Parse("http://client.example.com") callback, _ := url.Parse("http://client.example.com")
expires := time.Hour * 3 expires := time.Hour * 3
password := Password("passy") password := Password("passy")
userID := user.ID
goodPR := NewPasswordReset(user, password, *issuer, client, *callback, expires) goodPR := NewPasswordReset(userID, password, *issuer, client, *callback, expires)
goodPRNoCB := NewPasswordReset(user, password, *issuer, client, url.URL{}, expires) goodPRNoCB := NewPasswordReset(userID, password, *issuer, client, url.URL{}, expires)
expiredPR := NewPasswordReset(user, password, *issuer, client, *callback, -expires) expiredPR := NewPasswordReset(userID, password, *issuer, client, *callback, -expires)
wrongIssuerPR := NewPasswordReset(user, password, *otherIssuer, client, *callback, expires) wrongIssuerPR := NewPasswordReset(userID, password, *otherIssuer, client, *callback, expires)
noSubPR := NewPasswordReset(User{}, password, *issuer, client, *callback, expires) noSubPR := NewPasswordReset("", password, *issuer, client, *callback, expires)
noPWPR := NewPasswordReset(user, Password(""), *issuer, client, *callback, expires) noPWPR := NewPasswordReset(userID, Password(""), *issuer, client, *callback, expires)
noClientPR := NewPasswordReset(user, password, *issuer, "", *callback, expires) noClientPR := NewPasswordReset(userID, password, *issuer, "", *callback, expires)
noClientNoCBPR := NewPasswordReset(user, password, *issuer, "", url.URL{}, expires) noClientNoCBPR := NewPasswordReset(userID, password, *issuer, "", url.URL{}, expires)
privKey, err := key.GeneratePrivateKey() privKey, err := key.GeneratePrivateKey()
if err != nil { if err != nil {

View file

@ -13,6 +13,7 @@ import (
"sort" "sort"
"code.google.com/p/go-uuid/uuid" "code.google.com/p/go-uuid/uuid"
"github.com/jonboulle/clockwork"
"github.com/coreos/dex/repo" "github.com/coreos/dex/repo"
"github.com/coreos/go-oidc/jose" "github.com/coreos/go-oidc/jose"
@ -42,10 +43,14 @@ const (
ClaimInvitationCallback = "http://coreos.com/invitation/callback" ClaimInvitationCallback = "http://coreos.com/invitation/callback"
) )
var (
clock = clockwork.NewRealClock()
)
func assertStringClaim(claims jose.Claims, k string) string { func assertStringClaim(claims jose.Claims, k string) string {
s, ok, err := claims.StringClaim(k) s, ok, err := claims.StringClaim(k)
if !ok || err != nil { if !ok || err != nil {
panic("claims were not validated correctly") panic(fmt.Sprintf("claims were not validated correctly, missing or wrong claim: %v", k))
} }
return s return s
} }
@ -54,7 +59,7 @@ func assertURLClaim(claims jose.Claims, k string) *url.URL {
ustring := assertStringClaim(claims, k) ustring := assertStringClaim(claims, k)
ret, err := url.Parse(ustring) ret, err := url.Parse(ustring)
if err != nil { if err != nil {
panic("url claim was not validated correctly") panic(fmt.Sprintf("url claim was not validated correctly: %v", k))
} }
return ret return ret
} }