forked from mystiq/dex
server: unify password reset and email verification code and behavior
This patch proposes behavioral changes. In particular, referring systems will need to provide client ids under all circumstances.
This commit is contained in:
parent
86a2f997d7
commit
85113748a8
6 changed files with 242 additions and 238 deletions
|
@ -66,31 +66,49 @@ func (h *SendResetPasswordEmailHandler) handleGET(w http.ResponseWriter, r *http
|
|||
log.Errorf("could not exchange sessionKey: %v", err)
|
||||
}
|
||||
data := sendResetPasswordEmailData{}
|
||||
h.fillData(r, &data)
|
||||
if err := h.fillData(r, &data); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err)
|
||||
}
|
||||
|
||||
if data.ClientID == "" {
|
||||
writeAPIError(w, http.StatusBadRequest, newAPIError(errorInvalidRequest,
|
||||
"missing required parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
execTemplate(w, h.tpl, data)
|
||||
}
|
||||
|
||||
func (h *SendResetPasswordEmailHandler) fillData(r *http.Request, data *sendResetPasswordEmailData) {
|
||||
func (h *SendResetPasswordEmailHandler) fillData(r *http.Request, data *sendResetPasswordEmailData) *apiError {
|
||||
data.Email = r.FormValue("email")
|
||||
clientID := r.FormValue("client_id")
|
||||
data.ClientID = r.FormValue("client_id")
|
||||
redirectURL := r.FormValue("redirect_uri")
|
||||
|
||||
if redirectURL != "" && clientID != "" {
|
||||
if parsed, ok := h.validateRedirectURL(clientID, redirectURL); ok {
|
||||
data.ClientID = clientID
|
||||
if redirectURL != "" && data.ClientID != "" {
|
||||
if parsed, ok := h.validateRedirectURL(data.ClientID, redirectURL); ok {
|
||||
data.RedirectURL = redirectURL
|
||||
data.RedirectURLParsed = parsed
|
||||
} else {
|
||||
return newAPIError(errorInvalidRequest, "invalid redirect url")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *SendResetPasswordEmailHandler) handlePOST(w http.ResponseWriter, r *http.Request) {
|
||||
data := sendResetPasswordEmailData{}
|
||||
h.fillData(r, &data)
|
||||
if err := h.fillData(r, &data); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err)
|
||||
}
|
||||
|
||||
if data.ClientID == "" {
|
||||
writeAPIError(w, http.StatusBadRequest, newAPIError(errorInvalidRequest, "client id missing"))
|
||||
return
|
||||
}
|
||||
|
||||
if !user.ValidEmail(data.Email) {
|
||||
h.errPage(w, "Please supply a valid email addresss.", http.StatusBadRequest, &data)
|
||||
h.errPage(w, "Please supply a valid email address.", http.StatusBadRequest, &data)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -48,7 +48,7 @@ func TestSendResetPasswordEmailHandler(t *testing.T) {
|
|||
wantPRPassword string
|
||||
}{
|
||||
// First we'll test all the requests for happy path #1:
|
||||
{
|
||||
{ // Case 0
|
||||
|
||||
// STEP 1.1 - User clicks on link from local-login page and has a
|
||||
// session_key, which will prompt a redirect to page which has
|
||||
|
@ -69,7 +69,7 @@ func TestSendResetPasswordEmailHandler(t *testing.T) {
|
|||
}.Encode(),
|
||||
},
|
||||
},
|
||||
{
|
||||
{ // Case 1
|
||||
|
||||
// STEP 1.2 - This is the request that happens as a result of the
|
||||
// redirect. The client_id and redirect_uri should be in the form on
|
||||
|
@ -87,7 +87,7 @@ func TestSendResetPasswordEmailHandler(t *testing.T) {
|
|||
"email": str(""),
|
||||
},
|
||||
},
|
||||
{
|
||||
{ // Case 2
|
||||
// STEP 1.3 - User enters a valid email, gets success page. The
|
||||
// values from the GET redirect are resent in the form POST along
|
||||
// with the email.
|
||||
|
@ -109,25 +109,28 @@ func TestSendResetPasswordEmailHandler(t *testing.T) {
|
|||
wantPRPassword: "password",
|
||||
},
|
||||
|
||||
// Happy Path #2 - same as above but without session_key
|
||||
{
|
||||
// Happy Path #2 - no email or redirect
|
||||
{ // Case 3
|
||||
|
||||
// STEP 2.1 - user somehow ends up on reset page without a session_key
|
||||
query: url.Values{},
|
||||
// STEP 2.1 - user somehow ends up on reset page with nothing but a client id
|
||||
query: url.Values{
|
||||
"client_id": str(testClientID),
|
||||
},
|
||||
method: "GET",
|
||||
|
||||
wantCode: http.StatusOK,
|
||||
wantFormValues: &url.Values{
|
||||
"client_id": str(""),
|
||||
"client_id": str(testClientID),
|
||||
"redirect_uri": str(""),
|
||||
"email": str(""),
|
||||
},
|
||||
},
|
||||
{
|
||||
{ // Case 4
|
||||
|
||||
// STEP 2.3 - There is no STEP 2 because we don't have the redirect.
|
||||
query: url.Values{
|
||||
"email": str("Email-1@example.com"),
|
||||
"client_id": str(testClientID),
|
||||
},
|
||||
method: "POST",
|
||||
|
||||
|
@ -142,7 +145,7 @@ func TestSendResetPasswordEmailHandler(t *testing.T) {
|
|||
},
|
||||
|
||||
// Some error conditions:
|
||||
{
|
||||
{ // Case 5
|
||||
// STEP 1.3.1 - User enters an invalid email, gets form again.
|
||||
query: url.Values{
|
||||
"client_id": str(testClientID),
|
||||
|
@ -158,7 +161,7 @@ func TestSendResetPasswordEmailHandler(t *testing.T) {
|
|||
"email": str(""),
|
||||
},
|
||||
},
|
||||
{
|
||||
{ // Case 6
|
||||
// STEP 1.3.2 - User enters a valid email but for a user not in the
|
||||
// system. They still get the success page, but no email is sent.
|
||||
query: url.Values{
|
||||
|
@ -170,55 +173,32 @@ func TestSendResetPasswordEmailHandler(t *testing.T) {
|
|||
|
||||
wantCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
// STEP 1.3.3 - User enters a valid email but for a user not in the
|
||||
// system. They still get the success page, but no email is sent.
|
||||
query: url.Values{
|
||||
"client_id": str(testClientID),
|
||||
"redirect_uri": str(testRedirectURL.String()),
|
||||
"email": str("NOSUCHUSER@example.com"),
|
||||
},
|
||||
method: "POST",
|
||||
|
||||
wantCode: http.StatusOK,
|
||||
}, {
|
||||
{ // Case 7
|
||||
|
||||
// STEP 1.1.1 - User clicks on link from local-login page and has a
|
||||
// session_key, but it is not-recognized. There is no redirect, the
|
||||
// user goes right to the form which has no client_id or
|
||||
// redirect_uri
|
||||
// session_key, but it is not-recognized.
|
||||
query: url.Values{
|
||||
"session_key": str("code-UNKNOWN"),
|
||||
},
|
||||
method: "GET",
|
||||
|
||||
wantCode: http.StatusOK,
|
||||
wantFormValues: &url.Values{
|
||||
"client_id": str(""),
|
||||
"redirect_uri": str(""),
|
||||
"email": str(""),
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
}, {
|
||||
{ // Case 8
|
||||
|
||||
// STEP 1.2.1 - Someone trying to replace a valid redirect_url with
|
||||
// an invalid one; in this case we just give them the form but
|
||||
// ignore client_id and redirect_uri.
|
||||
// an invalid one.
|
||||
query: url.Values{
|
||||
"client_id": str(testClientID),
|
||||
"redirect_uri": str("http://evilhackers.example.com"),
|
||||
},
|
||||
method: "GET",
|
||||
|
||||
wantCode: http.StatusOK,
|
||||
wantFormValues: &url.Values{
|
||||
"client_id": str(""),
|
||||
"redirect_uri": str(""),
|
||||
"email": str(""),
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
}, {
|
||||
{ // Case 9
|
||||
// STEP 1.3.4 - User enters a valid email for a user in the system,
|
||||
// but with an invalid redirect_uri. They still get an email, but
|
||||
// with no redirect url.
|
||||
// but with an invalid redirect_uri.
|
||||
query: url.Values{
|
||||
"client_id": str(testClientID),
|
||||
"redirect_uri": str("http://evilhackers.example.com"),
|
||||
|
@ -226,15 +206,43 @@ func TestSendResetPasswordEmailHandler(t *testing.T) {
|
|||
},
|
||||
method: "POST",
|
||||
|
||||
wantCode: http.StatusOK,
|
||||
wantEmailer: &testEmailer{
|
||||
to: str("Email-1@example.com"),
|
||||
from: "noreply@example.com",
|
||||
subject: "Reset your password.",
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
wantPRPassword: "password",
|
||||
wantPRUserID: "ID-1",
|
||||
wantPRRedirect: nil,
|
||||
{ // Case 10
|
||||
|
||||
// User hits the page with a valid email but no client id
|
||||
query: url.Values{
|
||||
"email": str("Email-1@example.com"),
|
||||
},
|
||||
method: "GET",
|
||||
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{ // Case 10
|
||||
|
||||
// Don't send an email without a client id
|
||||
query: url.Values{
|
||||
"email": str("Email-1@example.com"),
|
||||
},
|
||||
method: "POST",
|
||||
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{ // Case 11
|
||||
|
||||
// Empty requests lack a client id
|
||||
query: url.Values{},
|
||||
method: "GET",
|
||||
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{ // Case 12
|
||||
|
||||
// Empty requests lack a client id
|
||||
query: url.Values{},
|
||||
method: "POST",
|
||||
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -348,23 +356,19 @@ func TestSendResetPasswordEmailHandler(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestResetPasswordHandler(t *testing.T) {
|
||||
makeToken := func(userID, password string, callback url.URL, expires time.Duration, signer jose.Signer) string {
|
||||
var clientID string
|
||||
if callback.String() == "" {
|
||||
clientID = ""
|
||||
} else {
|
||||
clientID = testClientID
|
||||
}
|
||||
makeToken := func(userID, password, clientID string, callback url.URL, expires time.Duration, signer jose.Signer) string {
|
||||
pr := user.NewPasswordReset(user.User{ID: "ID-1"},
|
||||
user.Password(password),
|
||||
testIssuerURL,
|
||||
clientID,
|
||||
callback,
|
||||
expires)
|
||||
token, err := pr.Token(signer)
|
||||
|
||||
jwt, err := jose.NewSignedJWT(pr.Claims, signer)
|
||||
if err != nil {
|
||||
t.Fatalf("couldn't make token: %q", err)
|
||||
}
|
||||
token := jwt.Encode()
|
||||
return token
|
||||
}
|
||||
goodSigner := key.NewPrivateKeySet([]*key.PrivateKey{testPrivKey},
|
||||
|
@ -398,24 +402,24 @@ func TestResetPasswordHandler(t *testing.T) {
|
|||
wantPassword string
|
||||
}{
|
||||
// Scenario 1: Happy Path
|
||||
{
|
||||
{ // Case 0
|
||||
// Step 1.1 - User clicks link in email, has valid token.
|
||||
query: url.Values{
|
||||
"token": str(makeToken("ID-1", "password", testRedirectURL, time.Hour*1, goodSigner)),
|
||||
"token": str(makeToken("ID-1", "password", testClientID, testRedirectURL, time.Hour*1, goodSigner)),
|
||||
},
|
||||
method: "GET",
|
||||
|
||||
wantCode: http.StatusOK,
|
||||
wantFormValues: &url.Values{
|
||||
"password": str(""),
|
||||
"token": str(makeToken("ID-1", "password", testRedirectURL, time.Hour*1, goodSigner)),
|
||||
"token": str(makeToken("ID-1", "password", testClientID, testRedirectURL, time.Hour*1, goodSigner)),
|
||||
},
|
||||
wantPassword: "password",
|
||||
},
|
||||
{
|
||||
{ // Case 1
|
||||
// Step 1.2 - User enters in new valid password, password is changed, user is redirected.
|
||||
query: url.Values{
|
||||
"token": str(makeToken("ID-1", "password", testRedirectURL, time.Hour*1, goodSigner)),
|
||||
"token": str(makeToken("ID-1", "password", testClientID, testRedirectURL, time.Hour*1, goodSigner)),
|
||||
"password": str("new_password"),
|
||||
},
|
||||
method: "POST",
|
||||
|
@ -424,26 +428,25 @@ func TestResetPasswordHandler(t *testing.T) {
|
|||
wantFormValues: &url.Values{},
|
||||
wantPassword: "NEW_PASSWORD",
|
||||
},
|
||||
|
||||
// Scenario 2: Happy Path, but without redirect.
|
||||
{
|
||||
{ // Case 2
|
||||
// Step 2.1 - User clicks link in email, has valid token.
|
||||
query: url.Values{
|
||||
"token": str(makeToken("ID-1", "password", url.URL{}, time.Hour*1, goodSigner)),
|
||||
"token": str(makeToken("ID-1", "password", testClientID, url.URL{}, time.Hour*1, goodSigner)),
|
||||
},
|
||||
method: "GET",
|
||||
|
||||
wantCode: http.StatusOK,
|
||||
wantFormValues: &url.Values{
|
||||
"password": str(""),
|
||||
"token": str(makeToken("ID-1", "password", url.URL{}, time.Hour*1, goodSigner)),
|
||||
"token": str(makeToken("ID-1", "password", testClientID, url.URL{}, time.Hour*1, goodSigner)),
|
||||
},
|
||||
wantPassword: "password",
|
||||
},
|
||||
{
|
||||
{ // Case 3
|
||||
// Step 2.2 - User enters in new valid password, password is changed, user is redirected.
|
||||
query: url.Values{
|
||||
"token": str(makeToken("ID-1", "password", url.URL{}, time.Hour*1, goodSigner)),
|
||||
"token": str(makeToken("ID-1", "password", testClientID, url.URL{}, time.Hour*1, goodSigner)),
|
||||
"password": str("new_password"),
|
||||
},
|
||||
method: "POST",
|
||||
|
@ -454,10 +457,10 @@ func TestResetPasswordHandler(t *testing.T) {
|
|||
wantPassword: "NEW_PASSWORD",
|
||||
},
|
||||
// Errors
|
||||
{
|
||||
{ // Case 4
|
||||
// Step 1.1.1 - User clicks link in email, has invalid token.
|
||||
query: url.Values{
|
||||
"token": str(makeToken("ID-1", "password", testRedirectURL, time.Hour*1, badSigner)),
|
||||
"token": str(makeToken("ID-1", "password", testClientID, testRedirectURL, time.Hour*1, badSigner)),
|
||||
},
|
||||
method: "GET",
|
||||
|
||||
|
@ -466,10 +469,10 @@ func TestResetPasswordHandler(t *testing.T) {
|
|||
wantPassword: "password",
|
||||
},
|
||||
|
||||
{
|
||||
// Step 2.2.1 - User enters in new valid password, password is changed, user is redirected.
|
||||
{ // Case 5
|
||||
// Step 2.2.1 - User enters in new valid password, password is changed, no redirect
|
||||
query: url.Values{
|
||||
"token": str(makeToken("ID-1", "password", url.URL{}, time.Hour*1, goodSigner)),
|
||||
"token": str(makeToken("ID-1", "password", testClientID, url.URL{}, time.Hour*1, goodSigner)),
|
||||
"password": str("shrt"),
|
||||
},
|
||||
method: "POST",
|
||||
|
@ -478,14 +481,14 @@ func TestResetPasswordHandler(t *testing.T) {
|
|||
wantCode: http.StatusBadRequest,
|
||||
wantFormValues: &url.Values{
|
||||
"password": str(""),
|
||||
"token": str(makeToken("ID-1", "password", url.URL{}, time.Hour*1, goodSigner)),
|
||||
"token": str(makeToken("ID-1", "password", testClientID, url.URL{}, time.Hour*1, goodSigner)),
|
||||
},
|
||||
wantPassword: "password",
|
||||
},
|
||||
{
|
||||
{ // Case 6
|
||||
// Step 2.2.2 - User enters in new valid password, with suspicious token.
|
||||
query: url.Values{
|
||||
"token": str(makeToken("ID-1", "password", url.URL{}, time.Hour*1, badSigner)),
|
||||
"token": str(makeToken("ID-1", "password", testClientID, url.URL{}, time.Hour*1, badSigner)),
|
||||
"password": str("shrt"),
|
||||
},
|
||||
method: "POST",
|
||||
|
@ -495,6 +498,28 @@ func TestResetPasswordHandler(t *testing.T) {
|
|||
wantFormValues: &url.Values{},
|
||||
wantPassword: "password",
|
||||
},
|
||||
{ // Case 7
|
||||
// Token lacking client id
|
||||
query: url.Values{
|
||||
"token": str(makeToken("ID-1", "password", "", url.URL{}, time.Hour*1, goodSigner)),
|
||||
"password": str("shrt"),
|
||||
},
|
||||
method: "GET",
|
||||
|
||||
wantCode: http.StatusBadRequest,
|
||||
wantPassword: "password",
|
||||
},
|
||||
{ // Case 8
|
||||
// Token lacking client id
|
||||
query: url.Values{
|
||||
"token": str(makeToken("ID-1", "password", "", url.URL{}, time.Hour*1, goodSigner)),
|
||||
"password": str("shrt"),
|
||||
},
|
||||
method: "POST",
|
||||
|
||||
wantCode: http.StatusBadRequest,
|
||||
wantPassword: "password",
|
||||
},
|
||||
}
|
||||
for i, tt := range tests {
|
||||
f, err := makeTestFixtures()
|
||||
|
|
|
@ -76,18 +76,19 @@ func (u *UserEmailer) SendResetPasswordEmail(email string, redirectURL url.URL,
|
|||
}
|
||||
|
||||
signer, err := u.signerFn()
|
||||
if err != nil {
|
||||
log.Errorf("error getting signer: %v", err)
|
||||
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)
|
||||
token, err := passwordReset.Token(signer)
|
||||
jwt, err := jose.NewSignedJWT(passwordReset.Claims, signer)
|
||||
if err != nil {
|
||||
log.Errorf("error getting tokenizing PasswordReset: %v", err)
|
||||
log.Errorf("error constructing or signing PasswordReset JWT: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
token := jwt.Encode()
|
||||
|
||||
resetURL := u.passwordResetURL
|
||||
q := resetURL.Query()
|
||||
|
@ -124,15 +125,17 @@ func (u *UserEmailer) SendEmailVerification(userID, clientID string, redirectURL
|
|||
ev := user.NewEmailVerification(usr, clientID, u.issuerURL, redirectURL, u.tokenValidityWindow)
|
||||
|
||||
signer, err := u.signerFn()
|
||||
if err != nil {
|
||||
log.Errorf("error getting signer: %v", err)
|
||||
if err != nil || signer == nil {
|
||||
log.Errorf("error getting signer: %v (signer: %v)", err, signer)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token, err := ev.Token(signer)
|
||||
jwt, err := jose.NewSignedJWT(ev.Claims, signer)
|
||||
if err != nil {
|
||||
log.Errorf("error constructing or signing EmailVerification JWT: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
token := jwt.Encode()
|
||||
|
||||
verifyURL := u.verifyEmailURL
|
||||
q := verifyURL.Query()
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
@ -13,15 +12,6 @@ import (
|
|||
"github.com/coreos/go-oidc/oidc"
|
||||
)
|
||||
|
||||
const (
|
||||
// Claim representing where a user should be sent after verifying their email address.
|
||||
ClaimEmailVerificationCallback = "http://coreos.com/email/verification-callback"
|
||||
|
||||
// ClaimEmailVerificationEmail represents the email to be verified. Note
|
||||
// that we are intentionally not using the "email" claim for this purpose.
|
||||
ClaimEmailVerificationEmail = "http://coreos.com/email/verificationEmail"
|
||||
)
|
||||
|
||||
var (
|
||||
clock = clockwork.NewRealClock()
|
||||
)
|
||||
|
@ -29,7 +19,6 @@ var (
|
|||
// 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())
|
||||
claims.Add(ClaimEmailVerificationEmail, user.Email)
|
||||
|
@ -37,90 +26,46 @@ func NewEmailVerification(user User, clientID string, issuer url.URL, callback u
|
|||
}
|
||||
|
||||
type EmailVerification struct {
|
||||
claims jose.Claims
|
||||
Claims jose.Claims
|
||||
}
|
||||
|
||||
// Token serializes the EmailVerification into a signed JWT.
|
||||
func (e EmailVerification) Token(signer jose.Signer) (string, error) {
|
||||
if signer == nil {
|
||||
return "", errors.New("no signer")
|
||||
}
|
||||
|
||||
jwt, err := jose.NewSignedJWT(e.claims, signer)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return jwt.Encode(), 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) {
|
||||
jwt, err := jose.ParseJWT(token)
|
||||
// Assumes that parseAndVerifyTokenClaims has already been called on claims
|
||||
func verifyEmailVerificationClaims(claims jose.Claims) (EmailVerification, error) {
|
||||
email, ok, err := claims.StringClaim(ClaimEmailVerificationEmail)
|
||||
if err != nil {
|
||||
return EmailVerification{}, err
|
||||
}
|
||||
|
||||
claims, err := jwt.Claims()
|
||||
if err != nil {
|
||||
return EmailVerification{}, err
|
||||
}
|
||||
|
||||
clientID, ok, err := claims.StringClaim("aud")
|
||||
if err != nil {
|
||||
return EmailVerification{}, err
|
||||
}
|
||||
if !ok {
|
||||
return EmailVerification{}, errors.New("no aud(client ID) claim")
|
||||
if !ok || email == "" {
|
||||
return EmailVerification{}, fmt.Errorf("no %q claim", ClaimEmailVerificationEmail)
|
||||
}
|
||||
|
||||
cb, ok, err := claims.StringClaim(ClaimEmailVerificationCallback)
|
||||
if err != nil {
|
||||
return EmailVerification{}, err
|
||||
}
|
||||
if cb == "" {
|
||||
if !ok || cb == "" {
|
||||
return EmailVerification{}, fmt.Errorf("no %q claim", ClaimEmailVerificationCallback)
|
||||
}
|
||||
if _, err := url.Parse(cb); err != nil {
|
||||
return EmailVerification{}, fmt.Errorf("callback URL not parseable: %v", cb)
|
||||
}
|
||||
|
||||
email, ok, err := claims.StringClaim(ClaimEmailVerificationEmail)
|
||||
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
|
||||
}
|
||||
if email == "" {
|
||||
return EmailVerification{}, fmt.Errorf("no %q claim", ClaimEmailVerificationEmail)
|
||||
}
|
||||
|
||||
sub, ok, err := claims.StringClaim("sub")
|
||||
if err != nil {
|
||||
return EmailVerification{}, err
|
||||
}
|
||||
if sub == "" {
|
||||
return EmailVerification{}, errors.New("no sub claim")
|
||||
}
|
||||
|
||||
noop := func() error { return nil }
|
||||
|
||||
keysFunc := func() []key.PublicKey {
|
||||
return keys
|
||||
}
|
||||
|
||||
verifier := oidc.NewJWTVerifier(issuer.String(), clientID, noop, keysFunc)
|
||||
if err := verifier.Verify(jwt); err != nil {
|
||||
return EmailVerification{}, err
|
||||
}
|
||||
|
||||
return EmailVerification{
|
||||
claims: claims,
|
||||
}, nil
|
||||
|
||||
return verifyEmailVerificationClaims(tokenClaims.Claims)
|
||||
}
|
||||
|
||||
func (e EmailVerification) UserID() string {
|
||||
uid, ok, err := e.claims.StringClaim("sub")
|
||||
uid, ok, err := e.Claims.StringClaim("sub")
|
||||
if !ok || err != nil {
|
||||
panic("EmailVerification: no sub claim. This should be impossible.")
|
||||
}
|
||||
|
@ -128,7 +73,7 @@ func (e EmailVerification) UserID() string {
|
|||
}
|
||||
|
||||
func (e EmailVerification) Email() string {
|
||||
email, ok, err := e.claims.StringClaim(ClaimEmailVerificationEmail)
|
||||
email, ok, err := e.Claims.StringClaim(ClaimEmailVerificationEmail)
|
||||
if !ok || err != nil {
|
||||
panic("EmailVerification: no email claim. This should be impossible.")
|
||||
}
|
||||
|
@ -136,7 +81,7 @@ func (e EmailVerification) Email() string {
|
|||
}
|
||||
|
||||
func (e EmailVerification) Callback() *url.URL {
|
||||
cb, ok, err := e.claims.StringClaim(ClaimEmailVerificationCallback)
|
||||
cb, ok, err := e.Claims.StringClaim(ClaimEmailVerificationCallback)
|
||||
if !ok || err != nil {
|
||||
panic("EmailVerification: no callback claim. This should be impossible.")
|
||||
}
|
||||
|
|
|
@ -26,14 +26,6 @@ const (
|
|||
// since the bcrypt library will silently ignore portions of
|
||||
// a password past the first 72 characters.
|
||||
maxSecretLength = 72
|
||||
|
||||
// ClaimPasswordResetCallback represents where a user should be sent after
|
||||
// resetting their password.
|
||||
ClaimPasswordResetCallback = "http://coreos.com/password/reset-callback"
|
||||
|
||||
// ClaimPasswordResetPassword represents the hash of the password to be
|
||||
// reset; in other words, the old password.
|
||||
ClaimPasswordResetPassword = "http://coreos.com/password/old-hash"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -224,56 +216,22 @@ func NewPasswordInfoRepoFromFile(loc string) (PasswordInfoRepo, error) {
|
|||
|
||||
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))
|
||||
claims.Add(ClaimPasswordResetCallback, callback.String())
|
||||
claims.Add(ClaimPasswordResetPassword, string(password))
|
||||
claims.Add(ClaimPasswordResetCallback, callback.String())
|
||||
return PasswordReset{claims}
|
||||
}
|
||||
|
||||
type PasswordReset struct {
|
||||
claims jose.Claims
|
||||
}
|
||||
|
||||
// Token serializes the PasswordReset into a signed JWT.
|
||||
func (e PasswordReset) Token(signer jose.Signer) (string, error) {
|
||||
if signer == nil {
|
||||
return "", errors.New("no signer")
|
||||
}
|
||||
|
||||
jwt, err := jose.NewSignedJWT(e.claims, signer)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return jwt.Encode(), 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) {
|
||||
jwt, err := jose.ParseJWT(token)
|
||||
if err != nil {
|
||||
return PasswordReset{}, err
|
||||
}
|
||||
|
||||
claims, err := jwt.Claims()
|
||||
if err != nil {
|
||||
return PasswordReset{}, err
|
||||
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)
|
||||
if err != nil {
|
||||
return PasswordReset{}, err
|
||||
}
|
||||
var clientID string
|
||||
if ok && cb != "" {
|
||||
clientID, ok, err = claims.StringClaim("aud")
|
||||
if err != nil {
|
||||
return PasswordReset{}, err
|
||||
}
|
||||
if !ok || clientID == "" {
|
||||
return PasswordReset{}, errors.New("no aud(client ID) claim")
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := url.Parse(cb); err != nil {
|
||||
return PasswordReset{}, fmt.Errorf("callback URL not parseable: %v", cb)
|
||||
}
|
||||
|
@ -282,37 +240,26 @@ func ParseAndVerifyPasswordResetToken(token string, issuer url.URL, keys []key.P
|
|||
if err != nil {
|
||||
return PasswordReset{}, err
|
||||
}
|
||||
if pw == "" {
|
||||
if !ok || pw == "" {
|
||||
return PasswordReset{}, fmt.Errorf("no %q claim", ClaimPasswordResetPassword)
|
||||
}
|
||||
|
||||
sub, ok, err := claims.StringClaim("sub")
|
||||
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
|
||||
}
|
||||
if sub == "" {
|
||||
return PasswordReset{}, errors.New("no sub claim")
|
||||
}
|
||||
|
||||
noop := func() error { return nil }
|
||||
|
||||
keysFunc := func() []key.PublicKey {
|
||||
return keys
|
||||
}
|
||||
|
||||
verifier := oidc.NewJWTVerifier(issuer.String(), clientID, noop, keysFunc)
|
||||
if err := verifier.Verify(jwt); err != nil {
|
||||
return PasswordReset{}, err
|
||||
}
|
||||
|
||||
return PasswordReset{
|
||||
claims: claims,
|
||||
}, nil
|
||||
|
||||
return verifyPasswordResetClaims(tokenClaims.Claims)
|
||||
}
|
||||
|
||||
func (e PasswordReset) UserID() string {
|
||||
uid, ok, err := e.claims.StringClaim("sub")
|
||||
uid, ok, err := e.Claims.StringClaim("sub")
|
||||
if !ok || err != nil {
|
||||
panic("PasswordReset: no sub claim. This should be impossible.")
|
||||
}
|
||||
|
@ -320,7 +267,7 @@ func (e PasswordReset) UserID() string {
|
|||
}
|
||||
|
||||
func (e PasswordReset) Password() Password {
|
||||
pw, ok, err := e.claims.StringClaim(ClaimPasswordResetPassword)
|
||||
pw, ok, err := e.Claims.StringClaim(ClaimPasswordResetPassword)
|
||||
if !ok || err != nil {
|
||||
panic("PasswordReset: no password claim. This should be impossible.")
|
||||
}
|
||||
|
@ -328,7 +275,7 @@ func (e PasswordReset) Password() Password {
|
|||
}
|
||||
|
||||
func (e PasswordReset) Callback() *url.URL {
|
||||
cb, ok, err := e.claims.StringClaim(ClaimPasswordResetCallback)
|
||||
cb, ok, err := e.Claims.StringClaim(ClaimPasswordResetCallback)
|
||||
if err != nil {
|
||||
panic("PasswordReset: error getting string claim. This should be impossible.")
|
||||
}
|
||||
|
|
66
user/user.go
66
user/user.go
|
@ -8,6 +8,7 @@ import (
|
|||
"time"
|
||||
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
|
@ -15,10 +16,30 @@ import (
|
|||
|
||||
"github.com/coreos/dex/repo"
|
||||
"github.com/coreos/go-oidc/jose"
|
||||
"github.com/coreos/go-oidc/key"
|
||||
"github.com/coreos/go-oidc/oidc"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxEmailLength = 200
|
||||
|
||||
// ClaimPasswordResetPassword represents the hash of the password to be
|
||||
// reset; in other words, the old password
|
||||
ClaimPasswordResetPassword = "http://coreos.com/password/old-hash"
|
||||
|
||||
// ClaimEmailVerificationEmail represents the email to be verified. Note
|
||||
// that we are intentionally not using the "email" claim for this purpose.
|
||||
ClaimEmailVerificationEmail = "http://coreos.com/email/verificationEmail"
|
||||
|
||||
// ClaimPasswordResetCallback represents where a user should be sent after
|
||||
// resetting their password.
|
||||
ClaimPasswordResetCallback = "http://coreos.com/password/reset-callback"
|
||||
|
||||
// Claim representing where a user should be sent after verifying their email address.
|
||||
ClaimEmailVerificationCallback = "http://coreos.com/email/verification-callback"
|
||||
|
||||
// Claim representing where a user should be sent after responding to an invitation
|
||||
ClaimInvitationCallback = "http://coreos.com/invitation/callback"
|
||||
)
|
||||
|
||||
type UserIDGenerator func() (string, error)
|
||||
|
@ -422,3 +443,48 @@ func (u *RemoteIdentity) UnmarshalJSON(data []byte) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
type TokenClaims struct {
|
||||
Claims jose.Claims
|
||||
}
|
||||
|
||||
func parseAndVerifyTokenClaims(token string, issuer url.URL, keys []key.PublicKey) (TokenClaims, error) {
|
||||
jwt, err := jose.ParseJWT(token)
|
||||
if err != nil {
|
||||
return TokenClaims{}, err
|
||||
}
|
||||
|
||||
claims, err := jwt.Claims()
|
||||
if err != nil {
|
||||
return TokenClaims{}, err
|
||||
}
|
||||
|
||||
clientID, ok, err := claims.StringClaim("aud")
|
||||
if err != nil {
|
||||
return TokenClaims{}, err
|
||||
}
|
||||
if !ok || clientID == "" {
|
||||
return TokenClaims{}, errors.New("no aud(client ID) claim")
|
||||
}
|
||||
|
||||
sub, ok, err := claims.StringClaim("sub")
|
||||
if err != nil {
|
||||
return TokenClaims{}, err
|
||||
}
|
||||
if !ok || sub == "" {
|
||||
return TokenClaims{}, errors.New("no sub claim")
|
||||
}
|
||||
|
||||
noop := func() error { return nil }
|
||||
|
||||
keysFunc := func() []key.PublicKey {
|
||||
return keys
|
||||
}
|
||||
|
||||
verifier := oidc.NewJWTVerifier(issuer.String(), clientID, noop, keysFunc)
|
||||
if err := verifier.Verify(jwt); err != nil {
|
||||
return TokenClaims{}, err
|
||||
}
|
||||
|
||||
return TokenClaims{claims}, nil
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue