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:
parent
2cdb6c0adb
commit
0c854a21d6
17 changed files with 466 additions and 102 deletions
|
@ -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
|
||||
|
|
|
@ -39,6 +39,7 @@ var (
|
|||
httpPathVerifyEmailResend = "/resend-verify-email"
|
||||
httpPathSendResetPassword = "/send-reset-password"
|
||||
httpPathResetPassword = "/reset-password"
|
||||
httpPathAcceptInvitation = "/accept-invitation"
|
||||
httpPathDebugVars = "/debug/vars"
|
||||
|
||||
cookieLastSeen = "LastSeen"
|
||||
|
|
99
server/invitation.go
Normal file
99
server/invitation.go
Normal 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
189
server/invitation_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -22,6 +22,7 @@ var (
|
|||
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"
|
||||
)
|
||||
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Reference in a new issue