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