forked from mystiq/dex
Merge pull request #179 from coreos/new-invitation-endpoint
New invitation endpoint
This commit is contained in:
commit
a9ab63893d
19 changed files with 702 additions and 155 deletions
|
@ -302,7 +302,9 @@ func setEmailer(srv *Server, issuerName, fromAddress, emailerConfigFile string,
|
||||||
tMailer,
|
tMailer,
|
||||||
fromAddress,
|
fromAddress,
|
||||||
srv.absURL(httpPathResetPassword),
|
srv.absURL(httpPathResetPassword),
|
||||||
srv.absURL(httpPathEmailVerify))
|
srv.absURL(httpPathEmailVerify),
|
||||||
|
srv.absURL(httpPathAcceptInvitation),
|
||||||
|
)
|
||||||
|
|
||||||
srv.UserEmailer = ue
|
srv.UserEmailer = ue
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -21,7 +21,7 @@ import (
|
||||||
// This handler is meant to be wrapped in clientTokenMiddleware, so a valid
|
// This handler is meant to be wrapped in clientTokenMiddleware, so a valid
|
||||||
// bearer token for the client is expected to be present.
|
// 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
|
// 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(
|
func handleVerifyEmailResendFunc(
|
||||||
issuerURL url.URL,
|
issuerURL url.URL,
|
||||||
srvKeysFunc func() ([]key.PublicKey, error),
|
srvKeysFunc func() ([]key.PublicKey, error),
|
||||||
|
@ -200,7 +200,7 @@ func handleEmailVerifyFunc(verifiedTpl *template.Template, issuer url.URL, keysF
|
||||||
if err != nil {
|
if err != nil {
|
||||||
execTemplateWithStatus(w, verifiedTpl, emailVerifiedTemplateData{
|
execTemplateWithStatus(w, verifiedTpl, emailVerifiedTemplateData{
|
||||||
Error: "There's been an error processing your request.",
|
Error: "There's been an error processing your request.",
|
||||||
Message: "Plesae try again later.",
|
Message: "Please try again later.",
|
||||||
}, http.StatusInternalServerError)
|
}, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,6 +39,7 @@ var (
|
||||||
httpPathVerifyEmailResend = "/resend-verify-email"
|
httpPathVerifyEmailResend = "/resend-verify-email"
|
||||||
httpPathSendResetPassword = "/send-reset-password"
|
httpPathSendResetPassword = "/send-reset-password"
|
||||||
httpPathResetPassword = "/reset-password"
|
httpPathResetPassword = "/reset-password"
|
||||||
|
httpPathAcceptInvitation = "/accept-invitation"
|
||||||
httpPathDebugVars = "/debug/vars"
|
httpPathDebugVars = "/debug/vars"
|
||||||
|
|
||||||
cookieLastSeen = "LastSeen"
|
cookieLastSeen = "LastSeen"
|
||||||
|
|
99
server/invitation.go
Normal file
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -250,11 +250,10 @@ func (r *resetPasswordRequest) handlePOST() {
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
r.data.Error = "Error Processing Request"
|
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)
|
execTemplateWithStatus(r.w, r.h.tpl, r.data, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
if cbURL == nil {
|
if cbURL == nil {
|
||||||
r.data.Success = true
|
r.data.Success = true
|
||||||
|
|
|
@ -357,7 +357,7 @@ func TestSendResetPasswordEmailHandler(t *testing.T) {
|
||||||
|
|
||||||
func TestResetPasswordHandler(t *testing.T) {
|
func TestResetPasswordHandler(t *testing.T) {
|
||||||
makeToken := func(userID, password, clientID string, callback url.URL, expires time.Duration, signer jose.Signer) string {
|
makeToken := func(userID, password, clientID string, callback url.URL, expires time.Duration, signer jose.Signer) string {
|
||||||
pr := user.NewPasswordReset(user.User{ID: "ID-1"},
|
pr := user.NewPasswordReset("ID-1",
|
||||||
user.Password(password),
|
user.Password(password),
|
||||||
testIssuerURL,
|
testIssuerURL,
|
||||||
clientID,
|
clientID,
|
||||||
|
|
|
@ -231,6 +231,15 @@ func (s *Server) HTTPHandler() http.Handler {
|
||||||
keysFunc: s.KeyManager.PublicKeys,
|
keysFunc: s.KeyManager.PublicKeys,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mux.Handle(httpPathAcceptInvitation, &InvitationHandler{
|
||||||
|
passwordResetURL: s.absURL(httpPathResetPassword),
|
||||||
|
issuerURL: s.IssuerURL,
|
||||||
|
um: s.UserManager,
|
||||||
|
keysFunc: s.KeyManager.PublicKeys,
|
||||||
|
signerFunc: s.KeyManager.Signer,
|
||||||
|
redirectValidityWindow: s.SessionManager.ValidityWindow,
|
||||||
|
})
|
||||||
|
|
||||||
mux.HandleFunc(httpPathDebugVars, health.ExpvarHandler)
|
mux.HandleFunc(httpPathDebugVars, health.ExpvarHandler)
|
||||||
|
|
||||||
pcfg := s.ProviderConfig()
|
pcfg := s.ProviderConfig()
|
||||||
|
|
|
@ -42,6 +42,19 @@ var (
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
User: user.User{
|
||||||
|
ID: "ID-Verified",
|
||||||
|
Email: "Email-Verified@example.com",
|
||||||
|
EmailVerified: true,
|
||||||
|
},
|
||||||
|
RemoteIdentities: []user.RemoteIdentity{
|
||||||
|
{
|
||||||
|
ConnectorID: "IDPC-1",
|
||||||
|
ID: "RID-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
testPasswordInfos = []user.PasswordInfo{
|
testPasswordInfos = []user.PasswordInfo{
|
||||||
|
@ -49,6 +62,10 @@ var (
|
||||||
UserID: "ID-1",
|
UserID: "ID-1",
|
||||||
Password: []byte("password"),
|
Password: []byte("password"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
UserID: "ID-Verified",
|
||||||
|
Password: []byte("password"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
testPrivKey, _ = key.GeneratePrivateKey()
|
testPrivKey, _ = key.GeneratePrivateKey()
|
||||||
|
@ -162,7 +179,9 @@ func makeTestFixtures() (*testFixtures, error) {
|
||||||
emailer,
|
emailer,
|
||||||
"noreply@example.com",
|
"noreply@example.com",
|
||||||
srv.absURL(httpPathResetPassword),
|
srv.absURL(httpPathResetPassword),
|
||||||
srv.absURL(httpPathEmailVerify))
|
srv.absURL(httpPathEmailVerify),
|
||||||
|
srv.absURL(httpPathAcceptInvitation),
|
||||||
|
)
|
||||||
|
|
||||||
return &testFixtures{
|
return &testFixtures{
|
||||||
srv: srv,
|
srv: srv,
|
||||||
|
|
|
@ -88,7 +88,6 @@ type UsersAPI struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Emailer interface {
|
type Emailer interface {
|
||||||
SendResetPasswordEmail(string, url.URL, string) (*url.URL, error)
|
|
||||||
SendInviteEmail(string, url.URL, string) (*url.URL, error)
|
SendInviteEmail(string, url.URL, string) (*url.URL, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,7 @@ type UserEmailer struct {
|
||||||
|
|
||||||
passwordResetURL url.URL
|
passwordResetURL url.URL
|
||||||
verifyEmailURL url.URL
|
verifyEmailURL url.URL
|
||||||
|
invitationURL url.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUserEmailer creates a new UserEmailer.
|
// NewUserEmailer creates a new UserEmailer.
|
||||||
|
@ -35,6 +36,7 @@ func NewUserEmailer(ur user.UserRepo,
|
||||||
fromAddress string,
|
fromAddress string,
|
||||||
passwordResetURL url.URL,
|
passwordResetURL url.URL,
|
||||||
verifyEmailURL url.URL,
|
verifyEmailURL url.URL,
|
||||||
|
invitationURL url.URL,
|
||||||
) *UserEmailer {
|
) *UserEmailer {
|
||||||
return &UserEmailer{
|
return &UserEmailer{
|
||||||
ur: ur,
|
ur: ur,
|
||||||
|
@ -46,76 +48,65 @@ func NewUserEmailer(ur user.UserRepo,
|
||||||
fromAddress: fromAddress,
|
fromAddress: fromAddress,
|
||||||
passwordResetURL: passwordResetURL,
|
passwordResetURL: passwordResetURL,
|
||||||
verifyEmailURL: verifyEmailURL,
|
verifyEmailURL: verifyEmailURL,
|
||||||
|
invitationURL: invitationURL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *UserEmailer) userPasswordInfo(email string) (user.User, user.PasswordInfo, error) {
|
||||||
|
usr, err := u.ur.GetByEmail(nil, email)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Error getting user: %q", err)
|
||||||
|
return user.User{}, user.PasswordInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pwi, err := u.pwi.Get(nil, usr.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Error getting password: %q", err)
|
||||||
|
return user.User{}, user.PasswordInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return usr, pwi, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UserEmailer) signedClaimsToken(claims jose.Claims) (string, error) {
|
||||||
|
signer, err := u.signerFn()
|
||||||
|
if err != nil || signer == nil {
|
||||||
|
log.Errorf("error getting signer: %v (%v)", err, signer)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jwt, err := jose.NewSignedJWT(claims, signer)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("error constructing or signing a JWT: %v", err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return jwt.Encode(), nil
|
||||||
|
}
|
||||||
|
|
||||||
// SendResetPasswordEmail sends a password reset email to the user specified by the email addresss, containing a link with a signed token which can be visitied to initiate the password change/reset process.
|
// SendResetPasswordEmail sends a password reset email to the user specified by the email addresss, containing a link with a signed token which can be visitied to initiate the password change/reset process.
|
||||||
// This method DOES NOT check for client ID, redirect URL validity - it is expected that upstream users have already done so.
|
// This method DOES NOT check for client ID, redirect URL validity - it is expected that upstream users have already done so.
|
||||||
// If there is no emailer is configured, the URL of the aforementioned link is returned, otherwise nil is returned.
|
// A link that can be used to reset the given user's password is returned.
|
||||||
func (u *UserEmailer) SendResetPasswordEmail(email string, redirectURL url.URL, clientID string) (*url.URL, error) {
|
func (u *UserEmailer) SendResetPasswordEmail(email string, redirectURL url.URL, clientID string) (*url.URL, error) {
|
||||||
return u.sendResetPasswordOrInviteEmail(email, redirectURL, clientID, false)
|
usr, pwi, err := u.userPasswordInfo(email)
|
||||||
}
|
|
||||||
|
|
||||||
// SendInviteEmail is exactly the same as SendResetPasswordEmail, except that it uses the invite template and subject name.
|
|
||||||
// In the near future, invite emails might diverge further.
|
|
||||||
func (u *UserEmailer) SendInviteEmail(email string, redirectURL url.URL, clientID string) (*url.URL, error) {
|
|
||||||
return u.sendResetPasswordOrInviteEmail(email, redirectURL, clientID, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UserEmailer) sendResetPasswordOrInviteEmail(email string, redirectURL url.URL, clientID string, invite bool) (*url.URL, error) {
|
|
||||||
usr, err := u.ur.GetByEmail(nil, email)
|
|
||||||
if err == user.ErrorNotFound {
|
|
||||||
log.Errorf("No Such user for email: %q", email)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("Error getting user: %q", err)
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
pwi, err := u.pwi.Get(nil, usr.ID)
|
passwordReset := user.NewPasswordReset(usr.ID, pwi.Password, u.issuerURL,
|
||||||
if err == user.ErrorNotFound {
|
|
||||||
// TODO(bobbyrullo): In this case, maybe send a different email explaining that
|
|
||||||
// they don't have a local password.
|
|
||||||
log.Errorf("No Password for userID: %q", usr.ID)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Error getting password: %q", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
signer, err := u.signerFn()
|
|
||||||
if err != nil || signer == nil {
|
|
||||||
log.Errorf("error getting signer: %v (%v)", err, signer)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
passwordReset := user.NewPasswordReset(usr, pwi.Password, u.issuerURL,
|
|
||||||
clientID, redirectURL, u.tokenValidityWindow)
|
clientID, redirectURL, u.tokenValidityWindow)
|
||||||
jwt, err := jose.NewSignedJWT(passwordReset.Claims, signer)
|
|
||||||
|
token, err := u.signedClaimsToken(passwordReset.Claims)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("error constructing or signing PasswordReset JWT: %v", err)
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
token := jwt.Encode()
|
|
||||||
|
|
||||||
resetURL := u.passwordResetURL
|
resetURL := u.passwordResetURL
|
||||||
q := resetURL.Query()
|
q := resetURL.Query()
|
||||||
q.Set("token", token)
|
q.Set("token", token)
|
||||||
resetURL.RawQuery = q.Encode()
|
resetURL.RawQuery = q.Encode()
|
||||||
|
|
||||||
var tmplName, subj string
|
|
||||||
if invite {
|
|
||||||
tmplName = "invite"
|
|
||||||
subj = "Activate Your Account"
|
|
||||||
} else {
|
|
||||||
tmplName = "password-reset"
|
|
||||||
subj = "Reset Your Password"
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.emailer != nil {
|
if u.emailer != nil {
|
||||||
err = u.emailer.SendMail(u.fromAddress, subj, tmplName,
|
err = u.emailer.SendMail(u.fromAddress, "Reset Your Password", "password-reset",
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"email": usr.Email,
|
"email": usr.Email,
|
||||||
"link": resetURL.String(),
|
"link": resetURL.String(),
|
||||||
|
@ -126,7 +117,44 @@ func (u *UserEmailer) sendResetPasswordOrInviteEmail(email string, redirectURL u
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &resetURL, nil
|
return &resetURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendInviteEmail is sends an email that allows the user to both
|
||||||
|
// reset their password *and* verify their email address. Similar to
|
||||||
|
// SendResetPasswordEmail, the given url and client id are assumed
|
||||||
|
// valid. A link that can be used to validate the given email address
|
||||||
|
// and reset the password is returned.
|
||||||
|
func (u *UserEmailer) SendInviteEmail(email string, redirectURL url.URL, clientID string) (*url.URL, error) {
|
||||||
|
usr, pwi, err := u.userPasswordInfo(email)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
invitation := user.NewInvitation(usr, pwi.Password, u.issuerURL,
|
||||||
|
clientID, redirectURL, u.tokenValidityWindow)
|
||||||
|
|
||||||
|
token, err := u.signedClaimsToken(invitation.Claims)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resetURL := u.invitationURL
|
||||||
|
q := resetURL.Query()
|
||||||
|
q.Set("token", token)
|
||||||
|
resetURL.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
if u.emailer != nil {
|
||||||
|
err = u.emailer.SendMail(u.fromAddress, "Activate Your Account", "invite",
|
||||||
|
map[string]interface{}{
|
||||||
|
"email": usr.Email,
|
||||||
|
"link": resetURL.String(),
|
||||||
|
}, usr.Email)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("error sending password reset email %v: ", err)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resetURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendEmailVerification sends an email to the user with the given userID containing a link which when visited marks the user as having had their email verified.
|
// SendEmailVerification sends an email to the user with the given userID containing a link which when visited marks the user as having had their email verified.
|
||||||
|
|
|
@ -22,6 +22,7 @@ var (
|
||||||
fromAddress = "dex@example.com"
|
fromAddress = "dex@example.com"
|
||||||
passwordResetURL = url.URL{Host: "dex.example.com", Path: "passwordReset"}
|
passwordResetURL = url.URL{Host: "dex.example.com", Path: "passwordReset"}
|
||||||
verifyEmailURL = url.URL{Host: "dex.example.com", Path: "verifyEmail"}
|
verifyEmailURL = url.URL{Host: "dex.example.com", Path: "verifyEmail"}
|
||||||
|
acceptInvitationURL = url.URL{Host: "dex.example.com", Path: "acceptInvitation"}
|
||||||
redirURL = url.URL{Host: "client.example.com", Path: "/redirURL"}
|
redirURL = url.URL{Host: "client.example.com", Path: "/redirURL"}
|
||||||
clientID = "XXX"
|
clientID = "XXX"
|
||||||
)
|
)
|
||||||
|
@ -98,7 +99,7 @@ func makeTestFixtures() (*UserEmailer, *testEmailer, *key.PublicKey) {
|
||||||
emailer := &testEmailer{}
|
emailer := &testEmailer{}
|
||||||
tEmailer := email.NewTemplatizedEmailerFromTemplates(textTemplates, htmlTemplates, emailer)
|
tEmailer := email.NewTemplatizedEmailerFromTemplates(textTemplates, htmlTemplates, emailer)
|
||||||
|
|
||||||
userEmailer := NewUserEmailer(ur, pwr, signerFn, validityWindow, issuerURL, tEmailer, fromAddress, passwordResetURL, verifyEmailURL)
|
userEmailer := NewUserEmailer(ur, pwr, signerFn, validityWindow, issuerURL, tEmailer, fromAddress, passwordResetURL, verifyEmailURL, acceptInvitationURL)
|
||||||
|
|
||||||
return userEmailer, emailer, publicKey
|
return userEmailer, emailer, publicKey
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,19 +5,15 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jonboulle/clockwork"
|
|
||||||
|
|
||||||
"github.com/coreos/go-oidc/jose"
|
"github.com/coreos/go-oidc/jose"
|
||||||
"github.com/coreos/go-oidc/key"
|
"github.com/coreos/go-oidc/key"
|
||||||
"github.com/coreos/go-oidc/oidc"
|
"github.com/coreos/go-oidc/oidc"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
// NewEmailVerification creates an object which can be sent to a user
|
||||||
clock = clockwork.NewRealClock()
|
// 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 {
|
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 := oidc.NewClaims(issuer.String(), user.ID, clientID, clock.Now(), clock.Now().Add(expires))
|
||||||
claims.Add(ClaimEmailVerificationCallback, callback.String())
|
claims.Add(ClaimEmailVerificationCallback, callback.String())
|
||||||
|
@ -29,9 +25,18 @@ type EmailVerification struct {
|
||||||
Claims jose.Claims
|
Claims jose.Claims
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assumes that parseAndVerifyTokenClaims has already been called on claims
|
// ParseAndVerifyEmailVerificationToken parses a string into a an
|
||||||
func verifyEmailVerificationClaims(claims jose.Claims) (EmailVerification, error) {
|
// EmailVerification, verifies the signature, and ensures that
|
||||||
email, ok, err := claims.StringClaim(ClaimEmailVerificationEmail)
|
// 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 {
|
if err != nil {
|
||||||
return EmailVerification{}, err
|
return EmailVerification{}, err
|
||||||
}
|
}
|
||||||
|
@ -39,7 +44,7 @@ func verifyEmailVerificationClaims(claims jose.Claims) (EmailVerification, error
|
||||||
return EmailVerification{}, fmt.Errorf("no %q claim", ClaimEmailVerificationEmail)
|
return EmailVerification{}, fmt.Errorf("no %q claim", ClaimEmailVerificationEmail)
|
||||||
}
|
}
|
||||||
|
|
||||||
cb, ok, err := claims.StringClaim(ClaimEmailVerificationCallback)
|
cb, ok, err := tokenClaims.Claims.StringClaim(ClaimEmailVerificationCallback)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return EmailVerification{}, err
|
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{}, fmt.Errorf("callback URL not parseable: %v", cb)
|
||||||
}
|
}
|
||||||
|
|
||||||
return EmailVerification{claims}, nil
|
return EmailVerification{tokenClaims.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e EmailVerification) UserID() string {
|
func (e EmailVerification) UserID() string {
|
||||||
uid, ok, err := e.Claims.StringClaim("sub")
|
return assertStringClaim(e.Claims, "sub")
|
||||||
if !ok || err != nil {
|
|
||||||
panic("EmailVerification: no sub claim. This should be impossible.")
|
|
||||||
}
|
|
||||||
return uid
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e EmailVerification) Email() string {
|
func (e EmailVerification) Email() string {
|
||||||
email, ok, err := e.Claims.StringClaim(ClaimEmailVerificationEmail)
|
return assertStringClaim(e.Claims, ClaimEmailVerificationEmail)
|
||||||
if !ok || err != nil {
|
|
||||||
panic("EmailVerification: no email claim. This should be impossible.")
|
|
||||||
}
|
|
||||||
return email
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e EmailVerification) Callback() *url.URL {
|
func (e EmailVerification) Callback() *url.URL {
|
||||||
cb, ok, err := e.Claims.StringClaim(ClaimEmailVerificationCallback)
|
return assertURLClaim(e.Claims, 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
|
|
||||||
}
|
}
|
||||||
|
|
95
user/invitation.go
Normal file
95
user/invitation.go
Normal file
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
103
user/invitation_test.go
Normal file
103
user/invitation_test.go
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -205,6 +205,12 @@ func (m *Manager) RegisterWithPassword(email, plaintext, connID string) (string,
|
||||||
return user.ID, nil
|
return user.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EmailVerifiable interface {
|
||||||
|
UserID() string
|
||||||
|
Email() string
|
||||||
|
Callback() *url.URL
|
||||||
|
}
|
||||||
|
|
||||||
// VerifyEmail sets EmailVerified to true for the user for the given EmailVerification.
|
// VerifyEmail sets EmailVerified to true for the user for the given EmailVerification.
|
||||||
// The email in the EmailVerification must match the User's email in the
|
// The email in the EmailVerification must match the User's email in the
|
||||||
// repository, and it must not already be verified.
|
// repository, and it must not already be verified.
|
||||||
|
@ -212,7 +218,7 @@ func (m *Manager) RegisterWithPassword(email, plaintext, connID string) (string,
|
||||||
// create it, ensuring that the token was signed and that the JWT was not
|
// create it, ensuring that the token was signed and that the JWT was not
|
||||||
// expired.
|
// expired.
|
||||||
// The callback url (i.e. where to send the user after the verification) is returned.
|
// The callback url (i.e. where to send the user after the verification) is returned.
|
||||||
func (m *Manager) VerifyEmail(ev EmailVerification) (*url.URL, error) {
|
func (m *Manager) VerifyEmail(ev EmailVerifiable) (*url.URL, error) {
|
||||||
tx, err := m.begin()
|
tx, err := m.begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -250,7 +256,13 @@ func (m *Manager) VerifyEmail(ev EmailVerification) (*url.URL, error) {
|
||||||
return ev.Callback(), nil
|
return ev.Callback(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) ChangePassword(pwr PasswordReset, plaintext string) (*url.URL, error) {
|
type PasswordChangeable interface {
|
||||||
|
UserID() string
|
||||||
|
Password() Password
|
||||||
|
Callback() *url.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) ChangePassword(pwr PasswordChangeable, plaintext string) (*url.URL, error) {
|
||||||
tx, err := m.begin()
|
tx, err := m.begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
@ -214,8 +214,8 @@ func NewPasswordInfoRepoFromFile(loc string) (PasswordInfoRepo, error) {
|
||||||
return NewPasswordInfoRepoFromPasswordInfos(pws), nil
|
return NewPasswordInfoRepoFromPasswordInfos(pws), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPasswordReset(user User, password Password, issuer url.URL, clientID string, callback url.URL, expires time.Duration) PasswordReset {
|
func NewPasswordReset(userID string, password Password, issuer url.URL, clientID string, callback url.URL, expires time.Duration) PasswordReset {
|
||||||
claims := oidc.NewClaims(issuer.String(), user.ID, clientID, clock.Now(), clock.Now().Add(expires))
|
claims := oidc.NewClaims(issuer.String(), userID, clientID, clock.Now(), clock.Now().Add(expires))
|
||||||
claims.Add(ClaimPasswordResetPassword, string(password))
|
claims.Add(ClaimPasswordResetPassword, string(password))
|
||||||
claims.Add(ClaimPasswordResetCallback, callback.String())
|
claims.Add(ClaimPasswordResetCallback, callback.String())
|
||||||
return PasswordReset{claims}
|
return PasswordReset{claims}
|
||||||
|
@ -225,9 +225,26 @@ type PasswordReset struct {
|
||||||
Claims jose.Claims
|
Claims jose.Claims
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assumes that parseAndVerifyTokenClaims has already been called on claims
|
// ParseAndVerifyPasswordResetToken parses a string into a an
|
||||||
func verifyPasswordResetClaims(claims jose.Claims) (PasswordReset, error) {
|
// PasswordReset, verifies the signature, and ensures that required
|
||||||
cb, ok, err := claims.StringClaim(ClaimPasswordResetCallback)
|
// 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 {
|
if err != nil {
|
||||||
return PasswordReset{}, err
|
return PasswordReset{}, err
|
||||||
}
|
}
|
||||||
|
@ -236,41 +253,15 @@ func verifyPasswordResetClaims(claims jose.Claims) (PasswordReset, error) {
|
||||||
return PasswordReset{}, fmt.Errorf("callback URL not parseable: %v", cb)
|
return PasswordReset{}, fmt.Errorf("callback URL not parseable: %v", cb)
|
||||||
}
|
}
|
||||||
|
|
||||||
pw, ok, err := claims.StringClaim(ClaimPasswordResetPassword)
|
return PasswordReset{tokenClaims.Claims}, nil
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e PasswordReset) UserID() string {
|
func (e PasswordReset) UserID() string {
|
||||||
uid, ok, err := e.Claims.StringClaim("sub")
|
return assertStringClaim(e.Claims, "sub")
|
||||||
if !ok || err != nil {
|
|
||||||
panic("PasswordReset: no sub claim. This should be impossible.")
|
|
||||||
}
|
|
||||||
return uid
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e PasswordReset) Password() Password {
|
func (e PasswordReset) Password() Password {
|
||||||
pw, ok, err := e.Claims.StringClaim(ClaimPasswordResetPassword)
|
pw := assertStringClaim(e.Claims, ClaimPasswordResetPassword)
|
||||||
if !ok || err != nil {
|
|
||||||
panic("PasswordReset: no password claim. This should be impossible.")
|
|
||||||
}
|
|
||||||
return Password(pw)
|
return Password(pw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -286,7 +277,7 @@ func (e PasswordReset) Callback() *url.URL {
|
||||||
|
|
||||||
cbURL, err := url.Parse(cb)
|
cbURL, err := url.Parse(cb)
|
||||||
if err != nil {
|
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
|
return cbURL
|
||||||
}
|
}
|
||||||
|
|
|
@ -122,7 +122,7 @@ func TestNewPasswordReset(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("case %d: non-nil err: %q", i, err)
|
t.Fatalf("case %d: non-nil err: %q", i, err)
|
||||||
}
|
}
|
||||||
ev := NewPasswordReset(tt.user, tt.password, tt.issuer, tt.clientID, *cbURL, tt.expires)
|
ev := NewPasswordReset(tt.user.ID, tt.password, tt.issuer, tt.clientID, *cbURL, tt.expires)
|
||||||
|
|
||||||
if diff := pretty.Compare(tt.want, ev.Claims); diff != "" {
|
if diff := pretty.Compare(tt.want, ev.Claims); diff != "" {
|
||||||
t.Errorf("case %d: Compare(want, got): %v", i, diff)
|
t.Errorf("case %d: Compare(want, got): %v", i, diff)
|
||||||
|
@ -143,15 +143,16 @@ func TestPasswordResetParseAndVerify(t *testing.T) {
|
||||||
callback, _ := url.Parse("http://client.example.com")
|
callback, _ := url.Parse("http://client.example.com")
|
||||||
expires := time.Hour * 3
|
expires := time.Hour * 3
|
||||||
password := Password("passy")
|
password := Password("passy")
|
||||||
|
userID := user.ID
|
||||||
|
|
||||||
goodPR := NewPasswordReset(user, password, *issuer, client, *callback, expires)
|
goodPR := NewPasswordReset(userID, password, *issuer, client, *callback, expires)
|
||||||
goodPRNoCB := NewPasswordReset(user, password, *issuer, client, url.URL{}, expires)
|
goodPRNoCB := NewPasswordReset(userID, password, *issuer, client, url.URL{}, expires)
|
||||||
expiredPR := NewPasswordReset(user, password, *issuer, client, *callback, -expires)
|
expiredPR := NewPasswordReset(userID, password, *issuer, client, *callback, -expires)
|
||||||
wrongIssuerPR := NewPasswordReset(user, password, *otherIssuer, client, *callback, expires)
|
wrongIssuerPR := NewPasswordReset(userID, password, *otherIssuer, client, *callback, expires)
|
||||||
noSubPR := NewPasswordReset(User{}, password, *issuer, client, *callback, expires)
|
noSubPR := NewPasswordReset("", password, *issuer, client, *callback, expires)
|
||||||
noPWPR := NewPasswordReset(user, Password(""), *issuer, client, *callback, expires)
|
noPWPR := NewPasswordReset(userID, Password(""), *issuer, client, *callback, expires)
|
||||||
noClientPR := NewPasswordReset(user, password, *issuer, "", *callback, expires)
|
noClientPR := NewPasswordReset(userID, password, *issuer, "", *callback, expires)
|
||||||
noClientNoCBPR := NewPasswordReset(user, password, *issuer, "", url.URL{}, expires)
|
noClientNoCBPR := NewPasswordReset(userID, password, *issuer, "", url.URL{}, expires)
|
||||||
|
|
||||||
privKey, err := key.GeneratePrivateKey()
|
privKey, err := key.GeneratePrivateKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
22
user/user.go
22
user/user.go
|
@ -13,6 +13,7 @@ import (
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"code.google.com/p/go-uuid/uuid"
|
"code.google.com/p/go-uuid/uuid"
|
||||||
|
"github.com/jonboulle/clockwork"
|
||||||
|
|
||||||
"github.com/coreos/dex/repo"
|
"github.com/coreos/dex/repo"
|
||||||
"github.com/coreos/go-oidc/jose"
|
"github.com/coreos/go-oidc/jose"
|
||||||
|
@ -42,6 +43,27 @@ const (
|
||||||
ClaimInvitationCallback = "http://coreos.com/invitation/callback"
|
ClaimInvitationCallback = "http://coreos.com/invitation/callback"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
clock = clockwork.NewRealClock()
|
||||||
|
)
|
||||||
|
|
||||||
|
func assertStringClaim(claims jose.Claims, k string) string {
|
||||||
|
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)
|
type UserIDGenerator func() (string, error)
|
||||||
|
|
||||||
func DefaultUserIDGenerator() (string, error) {
|
func DefaultUserIDGenerator() (string, error) {
|
||||||
|
|
Loading…
Reference in a new issue