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
This commit is contained in:
Rubén Soleto Buenvarón 2016-02-23 12:40:00 +01:00
parent f51125f555
commit 8156870862
8 changed files with 595 additions and 13 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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