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/email_verification.go b/server/email_verification.go index 08356772..b2c14723 100644 --- a/server/email_verification.go +++ b/server/email_verification.go @@ -21,7 +21,7 @@ import ( // This handler is meant to be wrapped in clientTokenMiddleware, so a valid // bearer token for the client is expected to be present. // The user's JWT should be in the "token" parameter and the redirect URL should -// be in the "redirect_uri" param. Note that this re +// be in the "redirect_uri" param. func handleVerifyEmailResendFunc( issuerURL url.URL, srvKeysFunc func() ([]key.PublicKey, error), @@ -200,7 +200,7 @@ func handleEmailVerifyFunc(verifiedTpl *template.Template, issuer url.URL, keysF if err != nil { execTemplateWithStatus(w, verifiedTpl, emailVerifiedTemplateData{ Error: "There's been an error processing your request.", - Message: "Plesae try again later.", + Message: "Please try again later.", }, http.StatusInternalServerError) return } 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.go b/server/password.go index c91ff297..0e4e42ba 100644 --- a/server/password.go +++ b/server/password.go @@ -250,11 +250,10 @@ func (r *resetPasswordRequest) handlePOST() { return default: r.data.Error = "Error Processing Request" - r.data.Message = "Plesae try again later." + r.data.Message = "Please try again later." execTemplateWithStatus(r.w, r.h.tpl, r.data, http.StatusInternalServerError) return } - } if cbURL == nil { r.data.Success = true 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 ab8204a1..0ad74638 100644 --- a/user/email_verification.go +++ b/user/email_verification.go @@ -5,19 +5,15 @@ 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 address. -// The clientID is the ID of the registering user. The callback is where a user should land after verifying their email. +// NewEmailVerification creates an object which can be sent to a user +// 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 { claims := oidc.NewClaims(issuer.String(), user.ID, clientID, clock.Now(), clock.Now().Add(expires)) claims.Add(ClaimEmailVerificationCallback, callback.String()) @@ -29,9 +25,18 @@ type EmailVerification struct { Claims jose.Claims } -// Assumes that parseAndVerifyTokenClaims has already been called on claims -func verifyEmailVerificationClaims(claims jose.Claims) (EmailVerification, error) { - email, ok, err := claims.StringClaim(ClaimEmailVerificationEmail) +// ParseAndVerifyEmailVerificationToken parses a string into a an +// EmailVerification, verifies the signature, and ensures that +// required claims are present. In addition to the usual claims +// required by the OIDC spec, "aud" and "sub" must be present as well +// as ClaimEmailVerificationCallback and ClaimEmailVerificationEmail. +func ParseAndVerifyEmailVerificationToken(token string, issuer url.URL, keys []key.PublicKey) (EmailVerification, error) { + tokenClaims, err := parseAndVerifyTokenClaims(token, issuer, keys) + if err != nil { + return EmailVerification{}, err + } + + email, ok, err := tokenClaims.Claims.StringClaim(ClaimEmailVerificationEmail) if err != nil { return EmailVerification{}, err } @@ -39,7 +44,7 @@ func verifyEmailVerificationClaims(claims jose.Claims) (EmailVerification, error return EmailVerification{}, fmt.Errorf("no %q claim", ClaimEmailVerificationEmail) } - cb, ok, err := claims.StringClaim(ClaimEmailVerificationCallback) + cb, ok, err := tokenClaims.Claims.StringClaim(ClaimEmailVerificationCallback) if err != nil { return EmailVerification{}, err } @@ -50,45 +55,17 @@ func verifyEmailVerificationClaims(claims jose.Claims) (EmailVerification, error return EmailVerification{}, fmt.Errorf("callback URL not parseable: %v", cb) } - return EmailVerification{claims}, nil -} - -// ParseAndVerifyEmailVerificationToken parses a string into a an EmailVerification, verifies the signature, and ensures that required claims are present. -// In addition to the usual claims required by the OIDC spec, "aud" and "sub" must be present as well as ClaimEmailVerificationCallback and ClaimEmailVerificationEmail. -func ParseAndVerifyEmailVerificationToken(token string, issuer url.URL, keys []key.PublicKey) (EmailVerification, error) { - tokenClaims, err := parseAndVerifyTokenClaims(token, issuer, keys) - if err != nil { - return EmailVerification{}, err - } - - return verifyEmailVerificationClaims(tokenClaims.Claims) + return EmailVerification{tokenClaims.Claims}, nil } func (e EmailVerification) UserID() string { - uid, ok, err := e.Claims.StringClaim("sub") - if !ok || err != nil { - panic("EmailVerification: no sub claim. This should be impossible.") - } - return uid + return assertStringClaim(e.Claims, "sub") } func (e EmailVerification) Email() string { - email, ok, err := e.Claims.StringClaim(ClaimEmailVerificationEmail) - if !ok || err != nil { - panic("EmailVerification: no email claim. This should be impossible.") - } - return email + return assertStringClaim(e.Claims, ClaimEmailVerificationEmail) } func (e EmailVerification) Callback() *url.URL { - cb, ok, err := e.Claims.StringClaim(ClaimEmailVerificationCallback) - if !ok || err != nil { - panic("EmailVerification: no callback claim. This should be impossible.") - } - - cbURL, err := url.Parse(cb) - if err != nil { - panic("EmailVerificaiton: can't parse callback. This should be impossible.") - } - return cbURL + return assertURLClaim(e.Claims, ClaimEmailVerificationCallback) } diff --git a/user/invitation.go b/user/invitation.go new file mode 100644 index 00000000..fdf12aa0 --- /dev/null +++ b/user/invitation.go @@ -0,0 +1,95 @@ +package user + +import ( + "fmt" + "net/url" + "time" + + "github.com/coreos/go-oidc/jose" + "github.com/coreos/go-oidc/key" + "github.com/coreos/go-oidc/oidc" +) + +func NewInvitation(user User, password Password, issuer url.URL, clientID string, callback url.URL, expires time.Duration) Invitation { + claims := oidc.NewClaims(issuer.String(), user.ID, clientID, clock.Now(), clock.Now().Add(expires)) + claims.Add(ClaimPasswordResetPassword, string(password)) + claims.Add(ClaimEmailVerificationEmail, user.Email) + claims.Add(ClaimInvitationCallback, callback.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 +} + +func ParseAndVerifyInvitationToken(token string, issuer url.URL, keys []key.PublicKey) (Invitation, error) { + tokenClaims, err := parseAndVerifyTokenClaims(token, issuer, keys) + if err != nil { + return Invitation{}, err + } + + cb, ok, err := tokenClaims.Claims.StringClaim(ClaimInvitationCallback) + if err != nil { + return Invitation{}, err + } + if !ok || cb == "" { + return Invitation{}, fmt.Errorf("no %q claim", ClaimInvitationCallback) + } + if _, err := url.Parse(cb); err != nil { + return Invitation{}, fmt.Errorf("callback URL not parseable: %v", cb) + } + + pw, ok, err := tokenClaims.Claims.StringClaim(ClaimPasswordResetPassword) + if err != nil { + return Invitation{}, err + } + if !ok || pw == "" { + return Invitation{}, fmt.Errorf("no %q claim", ClaimPasswordResetPassword) + } + + email, ok, err := tokenClaims.Claims.StringClaim(ClaimEmailVerificationEmail) + if err != nil { + return Invitation{}, err + } + if !ok || email == "" { + return Invitation{}, fmt.Errorf("no %q claim", ClaimEmailVerificationEmail) + } + + return Invitation{tokenClaims.Claims}, nil +} + +func (iv Invitation) UserID() string { + return assertStringClaim(iv.Claims, "sub") +} + +func (iv Invitation) Password() Password { + pw := assertStringClaim(iv.Claims, ClaimPasswordResetPassword) + return Password(pw) +} + +func (iv Invitation) Email() string { + return assertStringClaim(iv.Claims, ClaimEmailVerificationEmail) +} + +func (iv Invitation) ClientID() string { + return assertStringClaim(iv.Claims, "aud") +} + +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 new file mode 100644 index 00000000..ccb466e2 --- /dev/null +++ b/user/invitation_test.go @@ -0,0 +1,103 @@ +package user + +import ( + "net/url" + "testing" + "time" + + "github.com/kylelemons/godebug/pretty" + + "github.com/coreos/go-oidc/jose" + "github.com/coreos/go-oidc/key" +) + +func TestInvitationParseAndVerify(t *testing.T) { + issuer, _ := url.Parse("http://example.com") + notIssuer, _ := url.Parse("http://other.com") + client := "myclient" + user := User{ID: "1234", Email: "user@example.com"} + callback, _ := url.Parse("http://client.example.com") + expires := time.Hour * 3 + password := Password("Halloween is the best holiday") + privKey, _ := key.GeneratePrivateKey() + signer := privKey.Signer() + publicKeys := []key.PublicKey{*key.NewPublicKey(privKey.JWK())} + + tests := []struct { + invite Invitation + wantErr bool + signer jose.Signer + }{ + { + invite: NewInvitation(user, password, *issuer, client, *callback, expires), + signer: signer, + wantErr: false, + }, + { + invite: NewInvitation(user, password, *issuer, client, *callback, expires), + signer: signer, + wantErr: false, + }, + { + invite: NewInvitation(user, password, *issuer, client, *callback, -expires), + signer: signer, + wantErr: true, + }, + { + invite: NewInvitation(user, password, *notIssuer, client, *callback, expires), + signer: signer, + wantErr: true, + }, + { + invite: NewInvitation(User{Email: "noid@noid.com"}, password, *issuer, client, *callback, expires), + signer: signer, + wantErr: true, + }, + { + invite: NewInvitation(User{ID: "JONNY_NO_EMAIL"}, password, *issuer, client, *callback, expires), + signer: signer, + wantErr: true, + }, + { + invite: NewInvitation(user, Password(""), *issuer, client, *callback, expires), + signer: signer, + wantErr: true, + }, + { + invite: NewInvitation(user, password, *issuer, "", *callback, expires), + signer: signer, + wantErr: true, + }, + { + invite: NewInvitation(user, password, *issuer, "", url.URL{}, expires), + signer: signer, + wantErr: true, + }, + } + + for i, tt := range tests { + jwt, err := jose.NewSignedJWT(tt.invite.Claims, tt.signer) + if err != nil { + t.Fatalf("case %d: failed to generate JWT, error: %v", i, err) + } + token := jwt.Encode() + + parsed, err := ParseAndVerifyInvitationToken(token, *issuer, publicKeys) + + if tt.wantErr { + if err == nil { + t.Errorf("case %d: want no-nil error, got nil", i) + } + continue + } + + if err != nil { + t.Errorf("case %d: unexpected error: %v", i, err) + continue + } + + if diff := pretty.Compare(tt.invite, parsed); diff != "" { + t.Errorf("case %d: Compare(want, got): %v", i, diff) + } + } +} 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 a734d40e..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} @@ -225,9 +225,26 @@ type PasswordReset struct { Claims jose.Claims } -// Assumes that parseAndVerifyTokenClaims has already been called on claims -func verifyPasswordResetClaims(claims jose.Claims) (PasswordReset, error) { - cb, ok, err := claims.StringClaim(ClaimPasswordResetCallback) +// ParseAndVerifyPasswordResetToken parses a string into a an +// PasswordReset, verifies the signature, and ensures that required +// claims are present. In addition to the usual claims required by +// the OIDC spec, "aud" and "sub" must be present as well as +// ClaimPasswordResetCallback and ClaimPasswordResetPassword. +func ParseAndVerifyPasswordResetToken(token string, issuer url.URL, keys []key.PublicKey) (PasswordReset, error) { + tokenClaims, err := parseAndVerifyTokenClaims(token, issuer, keys) + if err != nil { + return PasswordReset{}, err + } + + pw, ok, err := tokenClaims.Claims.StringClaim(ClaimPasswordResetPassword) + if err != nil { + return PasswordReset{}, err + } + if !ok || pw == "" { + return PasswordReset{}, fmt.Errorf("no %q claim", ClaimPasswordResetPassword) + } + + cb, ok, err := tokenClaims.Claims.StringClaim(ClaimPasswordResetCallback) if err != nil { return PasswordReset{}, err } @@ -236,41 +253,15 @@ func verifyPasswordResetClaims(claims jose.Claims) (PasswordReset, error) { return PasswordReset{}, fmt.Errorf("callback URL not parseable: %v", cb) } - pw, ok, err := claims.StringClaim(ClaimPasswordResetPassword) - if err != nil { - return PasswordReset{}, err - } - if !ok || pw == "" { - return PasswordReset{}, fmt.Errorf("no %q claim", ClaimPasswordResetPassword) - } - - return PasswordReset{claims}, nil -} - -// ParseAndVerifyPasswordResetToken parses a string into a an PasswordReset, verifies the signature, and ensures that required claims are present. -// In addition to the usual claims required by the OIDC spec, "aud" and "sub" must be present as well as ClaimPasswordResetCallback, ClaimPasswordResetEmail and ClaimPasswordResetPassword. -func ParseAndVerifyPasswordResetToken(token string, issuer url.URL, keys []key.PublicKey) (PasswordReset, error) { - tokenClaims, err := parseAndVerifyTokenClaims(token, issuer, keys) - if err != nil { - return PasswordReset{}, err - } - - return verifyPasswordResetClaims(tokenClaims.Claims) + return PasswordReset{tokenClaims.Claims}, nil } func (e PasswordReset) UserID() string { - uid, ok, err := e.Claims.StringClaim("sub") - if !ok || err != nil { - panic("PasswordReset: no sub claim. This should be impossible.") - } - return uid + return assertStringClaim(e.Claims, "sub") } func (e PasswordReset) Password() Password { - pw, ok, err := e.Claims.StringClaim(ClaimPasswordResetPassword) - if !ok || err != nil { - panic("PasswordReset: no password claim. This should be impossible.") - } + pw := assertStringClaim(e.Claims, ClaimPasswordResetPassword) return Password(pw) } @@ -286,7 +277,7 @@ func (e PasswordReset) Callback() *url.URL { cbURL, err := url.Parse(cb) if err != nil { - panic("EmailVerificaiton: can't parse callback. This should be impossible.") + panic("PasswordReset: can't parse callback. This should be impossible.") } return cbURL } 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 734eaf57..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,6 +43,27 @@ 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(fmt.Sprintf("claims were not validated correctly, missing or wrong claim: %v", k)) + } + return s +} + +func assertURLClaim(claims jose.Claims, k string) *url.URL { + ustring := assertStringClaim(claims, k) + ret, err := url.Parse(ustring) + if err != nil { + panic(fmt.Sprintf("url claim was not validated correctly: %v", k)) + } + return ret +} + type UserIDGenerator func() (string, error) func DefaultUserIDGenerator() (string, error) {