mirror of https://github.com/dexidp/dex.git
Browse Source
An invitation allows users to both verify their email address and set a new password.pull/179/head
17 changed files with 457 additions and 93 deletions
@ -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) |
||||
} |
||||
@ -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()) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue