From 0c854a21d6c2866e047aff09cf797dee81f533b4 Mon Sep 17 00:00:00 2001 From: Joe Bowers Date: Mon, 9 Nov 2015 14:35:11 -0800 Subject: [PATCH] server: endpoint and system for sending invitations to dex An invitation allows users to both verify their email address and set a new password. --- server/config.go | 4 +- server/http.go | 1 + server/invitation.go | 99 +++++++++++++++++++ server/invitation_test.go | 189 +++++++++++++++++++++++++++++++++++++ server/password_test.go | 2 +- server/server.go | 9 ++ server/testutil.go | 21 ++++- user/api/api.go | 1 - user/email/email.go | 126 +++++++++++++++---------- user/email/email_test.go | 17 ++-- user/email_verification.go | 8 +- user/invitation.go | 15 +++ user/invitation_test.go | 28 ++---- user/manager.go | 16 +++- user/password.go | 4 +- user/password_test.go | 19 ++-- user/user.go | 9 +- 17 files changed, 466 insertions(+), 102 deletions(-) create mode 100644 server/invitation.go create mode 100644 server/invitation_test.go diff --git a/server/config.go b/server/config.go index 9e1e742b..3b775c5e 100644 --- a/server/config.go +++ b/server/config.go @@ -302,7 +302,9 @@ func setEmailer(srv *Server, issuerName, fromAddress, emailerConfigFile string, tMailer, fromAddress, srv.absURL(httpPathResetPassword), - srv.absURL(httpPathEmailVerify)) + srv.absURL(httpPathEmailVerify), + srv.absURL(httpPathAcceptInvitation), + ) srv.UserEmailer = ue return nil diff --git a/server/http.go b/server/http.go index b864fdcf..93d6b296 100644 --- a/server/http.go +++ b/server/http.go @@ -39,6 +39,7 @@ var ( httpPathVerifyEmailResend = "/resend-verify-email" httpPathSendResetPassword = "/send-reset-password" httpPathResetPassword = "/reset-password" + httpPathAcceptInvitation = "/accept-invitation" httpPathDebugVars = "/debug/vars" cookieLastSeen = "LastSeen" diff --git a/server/invitation.go b/server/invitation.go new file mode 100644 index 00000000..4acba885 --- /dev/null +++ b/server/invitation.go @@ -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) +} diff --git a/server/invitation_test.go b/server/invitation_test.go new file mode 100644 index 00000000..4481914d --- /dev/null +++ b/server/invitation_test.go @@ -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()) + } + } + } +} diff --git a/server/password_test.go b/server/password_test.go index c4dfbca5..758d169f 100644 --- a/server/password_test.go +++ b/server/password_test.go @@ -357,7 +357,7 @@ func TestSendResetPasswordEmailHandler(t *testing.T) { func TestResetPasswordHandler(t *testing.T) { 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), testIssuerURL, clientID, diff --git a/server/server.go b/server/server.go index 91f7b5d0..f0a4cf97 100644 --- a/server/server.go +++ b/server/server.go @@ -231,6 +231,15 @@ func (s *Server) HTTPHandler() http.Handler { 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) pcfg := s.ProviderConfig() diff --git a/server/testutil.go b/server/testutil.go index b43d61b5..4f2aae07 100644 --- a/server/testutil.go +++ b/server/testutil.go @@ -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{ @@ -49,6 +62,10 @@ var ( UserID: "ID-1", Password: []byte("password"), }, + { + UserID: "ID-Verified", + Password: []byte("password"), + }, } testPrivKey, _ = key.GeneratePrivateKey() @@ -162,7 +179,9 @@ func makeTestFixtures() (*testFixtures, error) { emailer, "noreply@example.com", srv.absURL(httpPathResetPassword), - srv.absURL(httpPathEmailVerify)) + srv.absURL(httpPathEmailVerify), + srv.absURL(httpPathAcceptInvitation), + ) return &testFixtures{ srv: srv, diff --git a/user/api/api.go b/user/api/api.go index 8c27173e..687e9757 100644 --- a/user/api/api.go +++ b/user/api/api.go @@ -88,7 +88,6 @@ type UsersAPI struct { } type Emailer interface { - SendResetPasswordEmail(string, url.URL, string) (*url.URL, error) SendInviteEmail(string, url.URL, string) (*url.URL, error) } diff --git a/user/email/email.go b/user/email/email.go index 2d671167..f9c5a6fb 100644 --- a/user/email/email.go +++ b/user/email/email.go @@ -23,6 +23,7 @@ type UserEmailer struct { passwordResetURL url.URL verifyEmailURL url.URL + invitationURL url.URL } // NewUserEmailer creates a new UserEmailer. @@ -35,6 +36,7 @@ func NewUserEmailer(ur user.UserRepo, fromAddress string, passwordResetURL url.URL, verifyEmailURL url.URL, + invitationURL url.URL, ) *UserEmailer { return &UserEmailer{ ur: ur, @@ -46,76 +48,65 @@ func NewUserEmailer(ur user.UserRepo, fromAddress: fromAddress, passwordResetURL: passwordResetURL, 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. // 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) { - return u.sendResetPasswordOrInviteEmail(email, redirectURL, clientID, false) -} - -// 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 - } + usr, pwi, err := u.userPasswordInfo(email) if err != nil { - log.Errorf("Error getting user: %q", err) return nil, err } - pwi, err := u.pwi.Get(nil, usr.ID) - 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, + passwordReset := user.NewPasswordReset(usr.ID, pwi.Password, u.issuerURL, clientID, redirectURL, u.tokenValidityWindow) - jwt, err := jose.NewSignedJWT(passwordReset.Claims, signer) + + token, err := u.signedClaimsToken(passwordReset.Claims) if err != nil { - log.Errorf("error constructing or signing PasswordReset JWT: %v", err) return nil, err } - token := jwt.Encode() resetURL := u.passwordResetURL q := resetURL.Query() q.Set("token", token) 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 { - err = u.emailer.SendMail(u.fromAddress, subj, tmplName, + err = u.emailer.SendMail(u.fromAddress, "Reset Your Password", "password-reset", map[string]interface{}{ "email": usr.Email, "link": resetURL.String(), @@ -126,7 +117,44 @@ func (u *UserEmailer) sendResetPasswordOrInviteEmail(email string, redirectURL u return nil, err } 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. diff --git a/user/email/email_test.go b/user/email/email_test.go index 9ed6c804..b533160c 100644 --- a/user/email/email_test.go +++ b/user/email/email_test.go @@ -17,13 +17,14 @@ import ( ) var ( - validityWindow = time.Hour * 1 - issuerURL = url.URL{Host: "dex.example.com"} - fromAddress = "dex@example.com" - passwordResetURL = url.URL{Host: "dex.example.com", Path: "passwordReset"} - verifyEmailURL = url.URL{Host: "dex.example.com", Path: "verifyEmail"} - redirURL = url.URL{Host: "client.example.com", Path: "/redirURL"} - clientID = "XXX" + validityWindow = time.Hour * 1 + issuerURL = url.URL{Host: "dex.example.com"} + fromAddress = "dex@example.com" + passwordResetURL = url.URL{Host: "dex.example.com", Path: "passwordReset"} + verifyEmailURL = url.URL{Host: "dex.example.com", Path: "verifyEmail"} + acceptInvitationURL = url.URL{Host: "dex.example.com", Path: "acceptInvitation"} + redirURL = url.URL{Host: "client.example.com", Path: "/redirURL"} + clientID = "XXX" ) type testEmailer struct { @@ -98,7 +99,7 @@ func makeTestFixtures() (*UserEmailer, *testEmailer, *key.PublicKey) { emailer := &testEmailer{} 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 } diff --git a/user/email_verification.go b/user/email_verification.go index 79e40328..0ad74638 100644 --- a/user/email_verification.go +++ b/user/email_verification.go @@ -5,19 +5,13 @@ import ( "net/url" "time" - "github.com/jonboulle/clockwork" - "github.com/coreos/go-oidc/jose" "github.com/coreos/go-oidc/key" "github.com/coreos/go-oidc/oidc" ) -var ( - clock = clockwork.NewRealClock() -) - // 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 // 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 { diff --git a/user/invitation.go b/user/invitation.go index 4daa503c..fdf12aa0 100644 --- a/user/invitation.go +++ b/user/invitation.go @@ -18,6 +18,10 @@ func NewInvitation(user User, password Password, issuer url.URL, clientID string 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 { Claims jose.Claims } @@ -78,3 +82,14 @@ func (iv Invitation) ClientID() string { func (iv Invitation) Callback() *url.URL { 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, + ) +} diff --git a/user/invitation_test.go b/user/invitation_test.go index b797a028..ccb466e2 100644 --- a/user/invitation_test.go +++ b/user/invitation_test.go @@ -23,63 +23,53 @@ func TestInvitationParseAndVerify(t *testing.T) { signer := privKey.Signer() 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 { invite Invitation wantErr bool signer jose.Signer }{ { - invite: goodInvitation, + invite: NewInvitation(user, password, *issuer, client, *callback, expires), signer: signer, wantErr: false, }, { - invite: goodNoCB, + invite: NewInvitation(user, password, *issuer, client, *callback, expires), signer: signer, wantErr: false, }, { - invite: expired, + invite: NewInvitation(user, password, *issuer, client, *callback, -expires), signer: signer, wantErr: true, }, { - invite: wrongIssuer, + invite: NewInvitation(user, password, *notIssuer, client, *callback, expires), signer: signer, wantErr: true, }, { - invite: noSub, + invite: NewInvitation(User{Email: "noid@noid.com"}, password, *issuer, client, *callback, expires), signer: signer, wantErr: true, }, { - invite: noEmail, + invite: NewInvitation(User{ID: "JONNY_NO_EMAIL"}, password, *issuer, client, *callback, expires), signer: signer, wantErr: true, }, { - invite: noPassword, + invite: NewInvitation(user, Password(""), *issuer, client, *callback, expires), signer: signer, wantErr: true, }, { - invite: noClient, + invite: NewInvitation(user, password, *issuer, "", *callback, expires), signer: signer, wantErr: true, }, { - invite: noClientNoCB, + invite: NewInvitation(user, password, *issuer, "", url.URL{}, expires), signer: signer, wantErr: true, }, diff --git a/user/manager.go b/user/manager.go index 1e046589..aa8654af 100644 --- a/user/manager.go +++ b/user/manager.go @@ -205,6 +205,12 @@ func (m *Manager) RegisterWithPassword(email, plaintext, connID string) (string, 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. // The email in the EmailVerification must match the User's email in the // 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 // expired. // 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() if err != nil { return nil, err @@ -250,7 +256,13 @@ func (m *Manager) VerifyEmail(ev EmailVerification) (*url.URL, error) { 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() if err != nil { return nil, err diff --git a/user/password.go b/user/password.go index 2c604029..4b93f349 100644 --- a/user/password.go +++ b/user/password.go @@ -214,8 +214,8 @@ func NewPasswordInfoRepoFromFile(loc string) (PasswordInfoRepo, error) { return NewPasswordInfoRepoFromPasswordInfos(pws), nil } -func NewPasswordReset(user User, 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)) +func NewPasswordReset(userID string, password Password, issuer url.URL, clientID string, callback url.URL, expires time.Duration) PasswordReset { + claims := oidc.NewClaims(issuer.String(), userID, clientID, clock.Now(), clock.Now().Add(expires)) claims.Add(ClaimPasswordResetPassword, string(password)) claims.Add(ClaimPasswordResetCallback, callback.String()) return PasswordReset{claims} diff --git a/user/password_test.go b/user/password_test.go index b0f78807..9fd17cf4 100644 --- a/user/password_test.go +++ b/user/password_test.go @@ -122,7 +122,7 @@ func TestNewPasswordReset(t *testing.T) { if err != nil { 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 != "" { 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") expires := time.Hour * 3 password := Password("passy") + userID := user.ID - goodPR := NewPasswordReset(user, password, *issuer, client, *callback, expires) - goodPRNoCB := NewPasswordReset(user, password, *issuer, client, url.URL{}, expires) - expiredPR := NewPasswordReset(user, password, *issuer, client, *callback, -expires) - wrongIssuerPR := NewPasswordReset(user, password, *otherIssuer, client, *callback, expires) - noSubPR := NewPasswordReset(User{}, password, *issuer, client, *callback, expires) - noPWPR := NewPasswordReset(user, Password(""), *issuer, client, *callback, expires) - noClientPR := NewPasswordReset(user, password, *issuer, "", *callback, expires) - noClientNoCBPR := NewPasswordReset(user, password, *issuer, "", url.URL{}, expires) + goodPR := NewPasswordReset(userID, password, *issuer, client, *callback, expires) + goodPRNoCB := NewPasswordReset(userID, password, *issuer, client, url.URL{}, expires) + expiredPR := NewPasswordReset(userID, password, *issuer, client, *callback, -expires) + wrongIssuerPR := NewPasswordReset(userID, password, *otherIssuer, client, *callback, expires) + noSubPR := NewPasswordReset("", password, *issuer, client, *callback, expires) + noPWPR := NewPasswordReset(userID, Password(""), *issuer, client, *callback, expires) + noClientPR := NewPasswordReset(userID, password, *issuer, "", *callback, expires) + noClientNoCBPR := NewPasswordReset(userID, password, *issuer, "", url.URL{}, expires) privKey, err := key.GeneratePrivateKey() if err != nil { diff --git a/user/user.go b/user/user.go index 0f79b9d9..fd606d7c 100644 --- a/user/user.go +++ b/user/user.go @@ -13,6 +13,7 @@ import ( "sort" "code.google.com/p/go-uuid/uuid" + "github.com/jonboulle/clockwork" "github.com/coreos/dex/repo" "github.com/coreos/go-oidc/jose" @@ -42,10 +43,14 @@ const ( ClaimInvitationCallback = "http://coreos.com/invitation/callback" ) +var ( + clock = clockwork.NewRealClock() +) + func assertStringClaim(claims jose.Claims, k string) string { s, ok, err := claims.StringClaim(k) 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 } @@ -54,7 +59,7 @@ func assertURLClaim(claims jose.Claims, k string) *url.URL { ustring := assertStringClaim(claims, k) ret, err := url.Parse(ustring) if err != nil { - panic("url claim was not validated correctly") + panic(fmt.Sprintf("url claim was not validated correctly: %v", k)) } return ret }