Merge pull request #179 from coreos/new-invitation-endpoint

New invitation endpoint
This commit is contained in:
Joe Bowers 2015-11-18 14:25:09 -08:00
commit a9ab63893d
19 changed files with 702 additions and 155 deletions

View file

@ -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

View file

@ -21,7 +21,7 @@ import (
// This handler is meant to be wrapped in clientTokenMiddleware, so a valid
// bearer token for the client is expected to be present.
// The user's JWT should be in the "token" parameter and the redirect URL should
// be in the "redirect_uri" param. Note that this re
// be in the "redirect_uri" param.
func handleVerifyEmailResendFunc(
issuerURL url.URL,
srvKeysFunc func() ([]key.PublicKey, error),
@ -200,7 +200,7 @@ func handleEmailVerifyFunc(verifiedTpl *template.Template, issuer url.URL, keysF
if err != nil {
execTemplateWithStatus(w, verifiedTpl, emailVerifiedTemplateData{
Error: "There's been an error processing your request.",
Message: "Plesae try again later.",
Message: "Please try again later.",
}, http.StatusInternalServerError)
return
}

View file

@ -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
View 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
View 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())
}
}
}
}

View file

@ -250,11 +250,10 @@ func (r *resetPasswordRequest) handlePOST() {
return
default:
r.data.Error = "Error Processing Request"
r.data.Message = "Plesae try again later."
r.data.Message = "Please try again later."
execTemplateWithStatus(r.w, r.h.tpl, r.data, http.StatusInternalServerError)
return
}
}
if cbURL == nil {
r.data.Success = true

View file

@ -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,

View file

@ -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()

View file

@ -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,

View file

@ -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)
}

View file

@ -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.

View file

@ -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
}

View file

@ -5,19 +5,15 @@ import (
"net/url"
"time"
"github.com/jonboulle/clockwork"
"github.com/coreos/go-oidc/jose"
"github.com/coreos/go-oidc/key"
"github.com/coreos/go-oidc/oidc"
)
var (
clock = clockwork.NewRealClock()
)
// NewEmailVerification creates an object which can be sent to a user in serialized form to verify that they control an email address.
// The clientID is the ID of the registering user. The callback is where a user should land after verifying their email.
// NewEmailVerification creates an object which can be sent to a user
// in serialized form to verify that they control an email address.
// The clientID is the ID of the registering user. The callback is
// where a user should land after verifying their email.
func NewEmailVerification(user User, clientID string, issuer url.URL, callback url.URL, expires time.Duration) EmailVerification {
claims := oidc.NewClaims(issuer.String(), user.ID, clientID, clock.Now(), clock.Now().Add(expires))
claims.Add(ClaimEmailVerificationCallback, callback.String())
@ -29,9 +25,18 @@ type EmailVerification struct {
Claims jose.Claims
}
// Assumes that parseAndVerifyTokenClaims has already been called on claims
func verifyEmailVerificationClaims(claims jose.Claims) (EmailVerification, error) {
email, ok, err := claims.StringClaim(ClaimEmailVerificationEmail)
// ParseAndVerifyEmailVerificationToken parses a string into a an
// EmailVerification, verifies the signature, and ensures that
// required claims are present. In addition to the usual claims
// required by the OIDC spec, "aud" and "sub" must be present as well
// as ClaimEmailVerificationCallback and ClaimEmailVerificationEmail.
func ParseAndVerifyEmailVerificationToken(token string, issuer url.URL, keys []key.PublicKey) (EmailVerification, error) {
tokenClaims, err := parseAndVerifyTokenClaims(token, issuer, keys)
if err != nil {
return EmailVerification{}, err
}
email, ok, err := tokenClaims.Claims.StringClaim(ClaimEmailVerificationEmail)
if err != nil {
return EmailVerification{}, err
}
@ -39,7 +44,7 @@ func verifyEmailVerificationClaims(claims jose.Claims) (EmailVerification, error
return EmailVerification{}, fmt.Errorf("no %q claim", ClaimEmailVerificationEmail)
}
cb, ok, err := claims.StringClaim(ClaimEmailVerificationCallback)
cb, ok, err := tokenClaims.Claims.StringClaim(ClaimEmailVerificationCallback)
if err != nil {
return EmailVerification{}, err
}
@ -50,45 +55,17 @@ func verifyEmailVerificationClaims(claims jose.Claims) (EmailVerification, error
return EmailVerification{}, fmt.Errorf("callback URL not parseable: %v", cb)
}
return EmailVerification{claims}, nil
}
// ParseAndVerifyEmailVerificationToken parses a string into a an EmailVerification, verifies the signature, and ensures that required claims are present.
// In addition to the usual claims required by the OIDC spec, "aud" and "sub" must be present as well as ClaimEmailVerificationCallback and ClaimEmailVerificationEmail.
func ParseAndVerifyEmailVerificationToken(token string, issuer url.URL, keys []key.PublicKey) (EmailVerification, error) {
tokenClaims, err := parseAndVerifyTokenClaims(token, issuer, keys)
if err != nil {
return EmailVerification{}, err
}
return verifyEmailVerificationClaims(tokenClaims.Claims)
return EmailVerification{tokenClaims.Claims}, nil
}
func (e EmailVerification) UserID() string {
uid, ok, err := e.Claims.StringClaim("sub")
if !ok || err != nil {
panic("EmailVerification: no sub claim. This should be impossible.")
}
return uid
return assertStringClaim(e.Claims, "sub")
}
func (e EmailVerification) Email() string {
email, ok, err := e.Claims.StringClaim(ClaimEmailVerificationEmail)
if !ok || err != nil {
panic("EmailVerification: no email claim. This should be impossible.")
}
return email
return assertStringClaim(e.Claims, ClaimEmailVerificationEmail)
}
func (e EmailVerification) Callback() *url.URL {
cb, ok, err := e.Claims.StringClaim(ClaimEmailVerificationCallback)
if !ok || err != nil {
panic("EmailVerification: no callback claim. This should be impossible.")
}
cbURL, err := url.Parse(cb)
if err != nil {
panic("EmailVerificaiton: can't parse callback. This should be impossible.")
}
return cbURL
return assertURLClaim(e.Claims, ClaimEmailVerificationCallback)
}

95
user/invitation.go Normal file
View 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
View 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)
}
}
}

View file

@ -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

View file

@ -214,8 +214,8 @@ func NewPasswordInfoRepoFromFile(loc string) (PasswordInfoRepo, error) {
return NewPasswordInfoRepoFromPasswordInfos(pws), nil
}
func NewPasswordReset(user User, password Password, issuer url.URL, clientID string, callback url.URL, expires time.Duration) PasswordReset {
claims := oidc.NewClaims(issuer.String(), user.ID, clientID, clock.Now(), clock.Now().Add(expires))
func NewPasswordReset(userID string, password Password, issuer url.URL, clientID string, callback url.URL, expires time.Duration) PasswordReset {
claims := oidc.NewClaims(issuer.String(), userID, clientID, clock.Now(), clock.Now().Add(expires))
claims.Add(ClaimPasswordResetPassword, string(password))
claims.Add(ClaimPasswordResetCallback, callback.String())
return PasswordReset{claims}
@ -225,9 +225,26 @@ type PasswordReset struct {
Claims jose.Claims
}
// Assumes that parseAndVerifyTokenClaims has already been called on claims
func verifyPasswordResetClaims(claims jose.Claims) (PasswordReset, error) {
cb, ok, err := claims.StringClaim(ClaimPasswordResetCallback)
// ParseAndVerifyPasswordResetToken parses a string into a an
// PasswordReset, verifies the signature, and ensures that required
// claims are present. In addition to the usual claims required by
// the OIDC spec, "aud" and "sub" must be present as well as
// ClaimPasswordResetCallback and ClaimPasswordResetPassword.
func ParseAndVerifyPasswordResetToken(token string, issuer url.URL, keys []key.PublicKey) (PasswordReset, error) {
tokenClaims, err := parseAndVerifyTokenClaims(token, issuer, keys)
if err != nil {
return PasswordReset{}, err
}
pw, ok, err := tokenClaims.Claims.StringClaim(ClaimPasswordResetPassword)
if err != nil {
return PasswordReset{}, err
}
if !ok || pw == "" {
return PasswordReset{}, fmt.Errorf("no %q claim", ClaimPasswordResetPassword)
}
cb, ok, err := tokenClaims.Claims.StringClaim(ClaimPasswordResetCallback)
if err != nil {
return PasswordReset{}, err
}
@ -236,41 +253,15 @@ func verifyPasswordResetClaims(claims jose.Claims) (PasswordReset, error) {
return PasswordReset{}, fmt.Errorf("callback URL not parseable: %v", cb)
}
pw, ok, err := claims.StringClaim(ClaimPasswordResetPassword)
if err != nil {
return PasswordReset{}, err
}
if !ok || pw == "" {
return PasswordReset{}, fmt.Errorf("no %q claim", ClaimPasswordResetPassword)
}
return PasswordReset{claims}, nil
}
// ParseAndVerifyPasswordResetToken parses a string into a an PasswordReset, verifies the signature, and ensures that required claims are present.
// In addition to the usual claims required by the OIDC spec, "aud" and "sub" must be present as well as ClaimPasswordResetCallback, ClaimPasswordResetEmail and ClaimPasswordResetPassword.
func ParseAndVerifyPasswordResetToken(token string, issuer url.URL, keys []key.PublicKey) (PasswordReset, error) {
tokenClaims, err := parseAndVerifyTokenClaims(token, issuer, keys)
if err != nil {
return PasswordReset{}, err
}
return verifyPasswordResetClaims(tokenClaims.Claims)
return PasswordReset{tokenClaims.Claims}, nil
}
func (e PasswordReset) UserID() string {
uid, ok, err := e.Claims.StringClaim("sub")
if !ok || err != nil {
panic("PasswordReset: no sub claim. This should be impossible.")
}
return uid
return assertStringClaim(e.Claims, "sub")
}
func (e PasswordReset) Password() Password {
pw, ok, err := e.Claims.StringClaim(ClaimPasswordResetPassword)
if !ok || err != nil {
panic("PasswordReset: no password claim. This should be impossible.")
}
pw := assertStringClaim(e.Claims, ClaimPasswordResetPassword)
return Password(pw)
}
@ -286,7 +277,7 @@ func (e PasswordReset) Callback() *url.URL {
cbURL, err := url.Parse(cb)
if err != nil {
panic("EmailVerificaiton: can't parse callback. This should be impossible.")
panic("PasswordReset: can't parse callback. This should be impossible.")
}
return cbURL
}

View file

@ -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 {

View file

@ -13,6 +13,7 @@ import (
"sort"
"code.google.com/p/go-uuid/uuid"
"github.com/jonboulle/clockwork"
"github.com/coreos/dex/repo"
"github.com/coreos/go-oidc/jose"
@ -42,6 +43,27 @@ const (
ClaimInvitationCallback = "http://coreos.com/invitation/callback"
)
var (
clock = clockwork.NewRealClock()
)
func assertStringClaim(claims jose.Claims, k string) string {
s, ok, err := claims.StringClaim(k)
if !ok || err != nil {
panic(fmt.Sprintf("claims were not validated correctly, missing or wrong claim: %v", k))
}
return s
}
func assertURLClaim(claims jose.Claims, k string) *url.URL {
ustring := assertStringClaim(claims, k)
ret, err := url.Parse(ustring)
if err != nil {
panic(fmt.Sprintf("url claim was not validated correctly: %v", k))
}
return ret
}
type UserIDGenerator func() (string, error)
func DefaultUserIDGenerator() (string, error) {