From 8156870862755bee0dcf21bf231dac2465499f5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Soleto=20Buenvar=C3=B3n?= Date: Tue, 23 Feb 2016 12:40:00 +0100 Subject: [PATCH] add support for resend an invite email This change solves the User's API problem when you want to create an user that its email hasn't been verified yet but it exist. At now, you can resend invitation email using endpoint /users/{id}/resend-invitation Fixes #184 --- integration/user_api_test.go | 183 ++++++++++++++++++++++++++++++++- schema/workerschema/README.md | 48 +++++++++ schema/workerschema/v1-gen.go | 97 ++++++++++++++++- schema/workerschema/v1-json.go | 44 ++++++++ schema/workerschema/v1.json | 44 ++++++++ server/user.go | 40 ++++++- user/api/api.go | 47 ++++++++- user/api/api_test.go | 105 ++++++++++++++++++- 8 files changed, 595 insertions(+), 13 deletions(-) diff --git a/integration/user_api_test.go b/integration/user_api_test.go index 9293e3ec..8d553602 100644 --- a/integration/user_api_test.go +++ b/integration/user_api_test.go @@ -45,8 +45,9 @@ var ( }, { User: user.User{ - ID: "ID-2", - Email: "Email-2@example.com", + ID: "ID-2", + Email: "Email-2@example.com", + EmailVerified: true, }, }, { @@ -582,6 +583,184 @@ func TestDisableUser(t *testing.T) { } } +func TestResendEmailInvitation(t *testing.T) { + tests := []struct { + req schema.ResendEmailInvitationRequest + cantEmail bool + userID string + email string + token string + + wantResponse schema.ResendEmailInvitationResponse + wantCode int + }{ + { + + req: schema.ResendEmailInvitationRequest{ + RedirectURL: testRedirectURL.String(), + }, + + userID: "ID-3", + email: "Email-3@example.com", + token: userGoodToken, + + wantResponse: schema.ResendEmailInvitationResponse{ + EmailSent: true, + }, + }, + { + + req: schema.ResendEmailInvitationRequest{ + RedirectURL: testRedirectURL.String(), + }, + + userID: "ID-3", + email: "Email-3@example.com", + cantEmail: true, + token: userGoodToken, + + wantResponse: schema.ResendEmailInvitationResponse{ + ResetPasswordLink: testResetPasswordURL.String(), + }, + }, + { + + req: schema.ResendEmailInvitationRequest{ + RedirectURL: "http://scammers.com", + }, + + userID: "ID-3", + email: "Email-3@example.com", + token: userGoodToken, + + wantCode: http.StatusBadRequest, + }, + { + + req: schema.ResendEmailInvitationRequest{ + RedirectURL: testRedirectURL.String(), + }, + + userID: "ID-2", + email: "Email-2@example.com", + token: userGoodToken, + + wantCode: http.StatusBadRequest, + }, + { + req: schema.ResendEmailInvitationRequest{ + RedirectURL: testRedirectURL.String(), + }, + + userID: "ID-3", + email: "Email-3@example.com", + token: userBadTokenClientNotAdmin, + + wantCode: http.StatusForbidden, + }, + { + req: schema.ResendEmailInvitationRequest{ + RedirectURL: testRedirectURL.String(), + }, + + userID: "ID-3", + email: "Email-3@example.com", + token: userBadClientID, + + wantCode: http.StatusUnauthorized, + }, + { + req: schema.ResendEmailInvitationRequest{ + RedirectURL: testRedirectURL.String(), + }, + + userID: "ID-3", + email: "Email-3@example.com", + token: userBadTokenExpired, + + wantCode: http.StatusUnauthorized, + }, + { + req: schema.ResendEmailInvitationRequest{ + RedirectURL: testRedirectURL.String(), + }, + + userID: "ID-3", + email: "Email-3@example.com", + token: userBadTokenDisabled, + + wantCode: http.StatusUnauthorized, + }, + { + req: schema.ResendEmailInvitationRequest{ + RedirectURL: testRedirectURL.String(), + }, + + userID: "ID-3", + email: "Email-3@example.com", + token: userBadTokenNotAdmin, + + wantCode: http.StatusUnauthorized, + }, + } + for i, tt := range tests { + func() { + f := makeUserAPITestFixtures() + defer f.close() + f.trans.Token = tt.token + f.emailer.cantEmail = tt.cantEmail + + page, err := f.client.Users.ResendEmailInvitation(tt.userID, &tt.req).Do() + if tt.wantCode != 0 { + if err == nil { + t.Errorf("case %d: err was nil", i) + return + } + gErr, ok := err.(*googleapi.Error) + if !ok { + t.Errorf("case %d: not a googleapi Error: %q", i, err) + return + } + + if gErr.Code != tt.wantCode { + t.Errorf("case %d: want=%d, got=%d", i, tt.wantCode, gErr.Code) + return + } + return + } + + if err != nil { + t.Errorf("case %d: want nil err, got: %v %T ", i, err, err) + return + } + + if diff := pretty.Compare(tt.wantResponse, page); diff != "" { + t.Errorf("case %d: Compare(want, got) = %v", i, diff) + return + } + + urlParsed, err := url.Parse(tt.req.RedirectURL) + if err != nil { + t.Errorf("case %d unexpected err: %v", i, err) + return + } + + wantEmalier := testEmailer{ + cantEmail: tt.cantEmail, + lastEmail: tt.email, + lastClientID: "XXX", + lastWasInvite: true, + lastRedirectURL: *urlParsed, + } + if diff := pretty.Compare(wantEmalier, f.emailer); diff != "" { + t.Errorf("case %d: Compare(want, got) = %v", i, diff) + return + } + + }() + } +} + type testEmailer struct { cantEmail bool lastEmail string diff --git a/schema/workerschema/README.md b/schema/workerschema/README.md index 8ff8c31b..f8f2bf8c 100644 --- a/schema/workerschema/README.md +++ b/schema/workerschema/README.md @@ -59,6 +59,27 @@ __Version:__ v1 } ``` +### ResendEmailInvitationRequest + + + +``` +{ + redirectURL: string +} +``` + +### ResendEmailInvitationResponse + + + +``` +{ + emailSent: boolean, + resetPasswordLink: string +} +``` + ### User @@ -303,3 +324,30 @@ __Version:__ v1 | default | Unexpected error | | +### POST /users/{id}/resend-invitation + +> __Summary__ + +> ResendEmailInvitation Users + +> __Description__ + +> Resend invitation email to an existing user with unverified email. + + +> __Parameters__ + +> |Name|Located in|Description|Required|Type| +|:-----|:-----|:-----|:-----|:-----| +| id | path | | Yes | string | +| | body | | Yes | [ResendEmailInvitationRequest](#resendemailinvitationrequest) | + + +> __Responses__ + +> |Code|Description|Type| +|:-----|:-----|:-----| +| 200 | | [ResendEmailInvitationResponse](#resendemailinvitationresponse) | +| default | Unexpected error | | + + diff --git a/schema/workerschema/v1-gen.go b/schema/workerschema/v1-gen.go index 7b9aa784..2d410541 100644 --- a/schema/workerschema/v1-gen.go +++ b/schema/workerschema/v1-gen.go @@ -14,13 +14,12 @@ import ( "encoding/json" "errors" "fmt" + "google.golang.org/api/googleapi" "io" "net/http" "net/url" "strconv" "strings" - - "google.golang.org/api/googleapi" ) // Always reference these packages, just in case the auto-generated code @@ -103,6 +102,16 @@ type Error struct { Error_description string `json:"error_description,omitempty"` } +type ResendEmailInvitationRequest struct { + RedirectURL string `json:"redirectURL,omitempty"` +} + +type ResendEmailInvitationResponse struct { + EmailSent bool `json:"emailSent,omitempty"` + + ResetPasswordLink string `json:"resetPasswordLink,omitempty"` +} + type User struct { Admin bool `json:"admin,omitempty"` @@ -607,3 +616,87 @@ func (c *UsersListCall) Do() (*UsersResponse, error) { // } } + +// method id "dex.User.ResendEmailInvitation": + +type UsersResendEmailInvitationCall struct { + s *Service + id string + resendemailinvitationrequest *ResendEmailInvitationRequest + opt_ map[string]interface{} +} + +// ResendEmailInvitation: Resend invitation email to an existing user +// with unverified email. +func (r *UsersService) ResendEmailInvitation(id string, resendemailinvitationrequest *ResendEmailInvitationRequest) *UsersResendEmailInvitationCall { + c := &UsersResendEmailInvitationCall{s: r.s, opt_: make(map[string]interface{})} + c.id = id + c.resendemailinvitationrequest = resendemailinvitationrequest + return c +} + +// Fields allows partial responses to be retrieved. +// See https://developers.google.com/gdata/docs/2.0/basics#PartialResponse +// for more information. +func (c *UsersResendEmailInvitationCall) Fields(s ...googleapi.Field) *UsersResendEmailInvitationCall { + c.opt_["fields"] = googleapi.CombineFields(s) + return c +} + +func (c *UsersResendEmailInvitationCall) Do() (*ResendEmailInvitationResponse, error) { + var body io.Reader = nil + body, err := googleapi.WithoutDataWrapper.JSONReader(c.resendemailinvitationrequest) + if err != nil { + return nil, err + } + ctype := "application/json" + params := make(url.Values) + params.Set("alt", "json") + if v, ok := c.opt_["fields"]; ok { + params.Set("fields", fmt.Sprintf("%v", v)) + } + urls := googleapi.ResolveRelative(c.s.BasePath, "users/{id}/resend-invitation") + urls += "?" + params.Encode() + req, _ := http.NewRequest("POST", urls, body) + googleapi.Expand(req.URL, map[string]string{ + "id": c.id, + }) + req.Header.Set("Content-Type", ctype) + req.Header.Set("User-Agent", "google-api-go-client/0.5") + res, err := c.s.client.Do(req) + if err != nil { + return nil, err + } + defer googleapi.CloseBody(res) + if err := googleapi.CheckResponse(res); err != nil { + return nil, err + } + var ret *ResendEmailInvitationResponse + if err := json.NewDecoder(res.Body).Decode(&ret); err != nil { + return nil, err + } + return ret, nil + // { + // "description": "Resend invitation email to an existing user with unverified email.", + // "httpMethod": "POST", + // "id": "dex.User.ResendEmailInvitation", + // "parameterOrder": [ + // "id" + // ], + // "parameters": { + // "id": { + // "location": "path", + // "required": true, + // "type": "string" + // } + // }, + // "path": "users/{id}/resend-invitation", + // "request": { + // "$ref": "ResendEmailInvitationRequest" + // }, + // "response": { + // "$ref": "ResendEmailInvitationResponse" + // } + // } + +} diff --git a/schema/workerschema/v1-json.go b/schema/workerschema/v1-json.go index 6c408dc5..4c1e1ba8 100644 --- a/schema/workerschema/v1-json.go +++ b/schema/workerschema/v1-json.go @@ -188,6 +188,28 @@ const DiscoveryJSON = `{ "type": "boolean" } } + }, + "ResendEmailInvitationRequest": { + "id": "UserDisableRequest", + "type": "object", + "properties": { + "redirectURL": { + "type": "string", + "format": "url" + } + } + }, + "ResendEmailInvitationResponse": { + "id": "UserDisableResponse", + "type": "object", + "properties": { + "resetPasswordLink": { + "type": "string" + }, + "emailSent": { + "type": "boolean" + } + } } }, "resources": { @@ -295,6 +317,28 @@ const DiscoveryJSON = `{ "response": { "$ref": "UserDisableResponse" } + }, + "ResendEmailInvitation": { + "id": "dex.User.ResendEmailInvitation", + "description": "Resend invitation email to an existing user with unverified email.", + "httpMethod": "POST", + "path": "users/{id}/resend-invitation", + "parameters": { + "id": { + "type": "string", + "required": true, + "location": "path" + } + }, + "parameterOrder": [ + "id" + ], + "request": { + "$ref": "ResendEmailInvitationRequest" + }, + "response": { + "$ref": "ResendEmailInvitationResponse" + } } } } diff --git a/schema/workerschema/v1.json b/schema/workerschema/v1.json index 7d3570f9..1674119b 100644 --- a/schema/workerschema/v1.json +++ b/schema/workerschema/v1.json @@ -182,6 +182,28 @@ "type": "boolean" } } + }, + "ResendEmailInvitationRequest": { + "id": "UserDisableRequest", + "type": "object", + "properties": { + "redirectURL": { + "type": "string", + "format": "url" + } + } + }, + "ResendEmailInvitationResponse": { + "id": "UserDisableResponse", + "type": "object", + "properties": { + "resetPasswordLink": { + "type": "string" + }, + "emailSent": { + "type": "boolean" + } + } } }, "resources": { @@ -289,6 +311,28 @@ "response": { "$ref": "UserDisableResponse" } + }, + "ResendEmailInvitation": { + "id": "dex.User.ResendEmailInvitation", + "description": "Resend invitation email to an existing user with unverified email.", + "httpMethod": "POST", + "path": "users/{id}/resend-invitation", + "parameters": { + "id": { + "type": "string", + "required": true, + "location": "path" + } + }, + "parameterOrder": [ + "id" + ], + "request": { + "$ref": "ResendEmailInvitationRequest" + }, + "response": { + "$ref": "ResendEmailInvitationResponse" + } } } } diff --git a/server/user.go b/server/user.go index b5cfb95b..8481ee3f 100644 --- a/server/user.go +++ b/server/user.go @@ -24,11 +24,12 @@ const ( ) var ( - UsersSubTree = "/users" - UsersListEndpoint = addBasePath(UsersSubTree) - UsersCreateEndpoint = addBasePath(UsersSubTree) - UsersGetEndpoint = addBasePath(UsersSubTree + "/:id") - UsersDisableEndpoint = addBasePath(UsersSubTree + "/:id/disable") + UsersSubTree = "/users" + UsersListEndpoint = addBasePath(UsersSubTree) + UsersCreateEndpoint = addBasePath(UsersSubTree) + UsersGetEndpoint = addBasePath(UsersSubTree + "/:id") + UsersDisableEndpoint = addBasePath(UsersSubTree + "/:id/disable") + UsersResendInvitationEndpoint = addBasePath(UsersSubTree + "/:id/resend-invitation") ) type UserMgmtServer struct { @@ -55,6 +56,7 @@ func (s *UserMgmtServer) HTTPHandler() http.Handler { r.POST(UsersCreateEndpoint, s.authAPIHandle(s.createUser)) r.POST(UsersDisableEndpoint, s.authAPIHandle(s.disableUser)) r.GET(UsersGetEndpoint, s.authAPIHandle(s.getUser)) + r.POST(UsersResendInvitationEndpoint, s.authAPIHandle(s.resendInvitationEmail)) return r } @@ -161,6 +163,34 @@ func (s *UserMgmtServer) disableUser(w http.ResponseWriter, r *http.Request, ps writeResponseWithBody(w, http.StatusOK, resp) } +func (s *UserMgmtServer) resendInvitationEmail(w http.ResponseWriter, r *http.Request, ps httprouter.Params, creds api.Creds) { + id := ps.ByName("id") + if id == "" { + writeAPIError(w, http.StatusBadRequest, newAPIError(errorInvalidRequest, "id is required")) + return + } + resendEmailInvitationReq := schema.ResendEmailInvitationRequest{} + if err := json.NewDecoder(r.Body).Decode(&resendEmailInvitationReq); err != nil { + writeInvalidRequest(w, "cannot parse JSON body") + return + } + + redirURL, err := url.Parse(resendEmailInvitationReq.RedirectURL) + if err != nil { + writeAPIError(w, http.StatusBadRequest, + newAPIError(errorInvalidRequest, "redirectURL must be a valid URL")) + return + } + + resendEmailInvitationResponse, err := s.api.ResendEmailInvitation(creds, id, *redirURL) + if err != nil { + s.writeError(w, err) + return + } + + writeResponseWithBody(w, http.StatusOK, resendEmailInvitationResponse) +} + func (s *UserMgmtServer) writeError(w http.ResponseWriter, err error) { log.Errorf("Error calling user management API: %v: ", err) if apiErr, ok := err.(api.Error); ok { diff --git a/user/api/api.go b/user/api/api.go index d068cd7f..9eca4b38 100644 --- a/user/api/api.go +++ b/user/api/api.go @@ -24,7 +24,8 @@ var ( client.ErrorNotFound: ErrorInvalidClient, } - ErrorInvalidEmail = newError("invalid_email", "invalid email.", http.StatusBadRequest) + ErrorInvalidEmail = newError("invalid_email", "invalid email.", http.StatusBadRequest) + ErrorVerifiedEmail = newError("verified_email", "Email already verified.", http.StatusBadRequest) ErrorInvalidClient = newError("invalid_client", "invalid email.", http.StatusBadRequest) @@ -188,6 +189,50 @@ func (u *UsersAPI) CreateUser(creds Creds, usr schema.User, redirURL url.URL) (s }, nil } +func (u *UsersAPI) ResendEmailInvitation(creds Creds, userID string, redirURL url.URL) (schema.ResendEmailInvitationResponse, error) { + log.Infof("userAPI: ResendEmailInvitation") + if !u.Authorize(creds) { + return schema.ResendEmailInvitationResponse{}, ErrorUnauthorized + } + + metadata, err := u.clientIdentityRepo.Metadata(creds.ClientID) + if err != nil { + return schema.ResendEmailInvitationResponse{}, mapError(err) + } + + validRedirURL, err := client.ValidRedirectURL(&redirURL, metadata.RedirectURIs) + if err != nil { + return schema.ResendEmailInvitationResponse{}, ErrorInvalidRedirectURL + } + + // Retrieve user to check if it's already created + userUser, err := u.manager.Get(userID) + if err != nil { + return schema.ResendEmailInvitationResponse{}, mapError(err) + } + + // Check if email is verified + if userUser.EmailVerified { + return schema.ResendEmailInvitationResponse{}, ErrorVerifiedEmail + } + + url, err := u.emailer.SendInviteEmail(userUser.Email, validRedirURL, creds.ClientID) + + // An email is sent only if we don't get a link and there's no error. + emailSent := err == nil && url == nil + + // If email is not sent a reset link will be generated + var resetLink string + if url != nil { + resetLink = url.String() + } + + return schema.ResendEmailInvitationResponse{ + EmailSent: emailSent, + ResetPasswordLink: resetLink, + }, nil +} + func (u *UsersAPI) ListUsers(creds Creds, maxResults int, nextPageToken string) ([]*schema.User, string, error) { log.Infof("userAPI: ListUsers") diff --git a/user/api/api_test.go b/user/api/api_test.go index ac44389e..5412cb2d 100644 --- a/user/api/api_test.go +++ b/user/api/api_test.go @@ -99,9 +99,10 @@ func makeTestFixtures() (*UsersAPI, *testEmailer) { }, }, { User: user.User{ - ID: "ID-2", - Email: "id2@example.com", - CreatedAt: clock.Now(), + ID: "ID-2", + Email: "id2@example.com", + EmailVerified: true, + CreatedAt: clock.Now(), }, }, { User: user.User{ @@ -463,3 +464,101 @@ func TestDisableUsers(t *testing.T) { } } } +func TestResendEmailInvitation(t *testing.T) { + tests := []struct { + creds Creds + userID string + email string + redirURL url.URL + cantEmail bool + + wantResponse schema.ResendEmailInvitationResponse + wantErr error + }{ + { + creds: goodCreds, + userID: "ID-1", + email: "id1@example.com", + redirURL: validRedirURL, + + wantResponse: schema.ResendEmailInvitationResponse{ + EmailSent: true, + }, + }, + { + creds: goodCreds, + userID: "ID-1", + email: "id1@example.com", + redirURL: validRedirURL, + cantEmail: true, + + wantResponse: schema.ResendEmailInvitationResponse{ + EmailSent: false, + ResetPasswordLink: resetPasswordURL.String(), + }, + }, + { + creds: badCreds, + userID: "ID-1", + email: "id1@example.com", + redirURL: validRedirURL, + + wantErr: ErrorUnauthorized, + }, + { + creds: goodCreds, + userID: "ID-1", + email: "id1@example.com", + redirURL: url.URL{Host: "scammers.com"}, + + wantErr: ErrorInvalidRedirectURL, + }, + { + creds: goodCreds, + userID: "ID-2", + email: "id2@example.com", + redirURL: validRedirURL, + + wantErr: ErrorVerifiedEmail, + }, + { + creds: goodCreds, + userID: "non-existent", + email: "non-existent@example.com", + redirURL: validRedirURL, + + wantErr: ErrorResourceNotFound, + }, + } + + for i, tt := range tests { + api, emailer := makeTestFixtures() + emailer.cantEmail = tt.cantEmail + + response, err := api.ResendEmailInvitation(tt.creds, tt.userID, tt.redirURL) + if tt.wantErr != nil { + if err != tt.wantErr { + t.Errorf("case %d: want=%q, got=%q", i, tt.wantErr, err) + } + continue + } + if err != nil { + t.Errorf("case %d: want nil err, got: %q ", i, err) + } + + if diff := pretty.Compare(tt.wantResponse, response); diff != "" { + t.Errorf("case %d: Compare(want, got) = %v", i, diff) + } + + wantEmailer := testEmailer{ + cantEmail: tt.cantEmail, + lastEmail: tt.email, + lastClientID: tt.creds.ClientID, + lastRedirectURL: tt.redirURL, + lastWasInvite: true, + } + if diff := pretty.Compare(wantEmailer, emailer); diff != "" { + t.Errorf("case %d: Compare(want, got) = %v", i, diff) + } + } +}