Commit c92aae64 authored by Eric Chiang's avatar Eric Chiang

Merge pull request #331 from Tecsisa/184-resend-email-invitation

add support for resend an invite email
parents 60b843e7 81568708
...@@ -47,6 +47,7 @@ var ( ...@@ -47,6 +47,7 @@ var (
User: user.User{ User: user.User{
ID: "ID-2", ID: "ID-2",
Email: "Email-2@example.com", Email: "Email-2@example.com",
EmailVerified: true,
}, },
}, },
{ {
...@@ -582,6 +583,184 @@ func TestDisableUser(t *testing.T) { ...@@ -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 { type testEmailer struct {
cantEmail bool cantEmail bool
lastEmail string lastEmail string
......
...@@ -59,6 +59,27 @@ __Version:__ v1 ...@@ -59,6 +59,27 @@ __Version:__ v1
} }
``` ```
### ResendEmailInvitationRequest
```
{
redirectURL: string
}
```
### ResendEmailInvitationResponse
```
{
emailSent: boolean,
resetPasswordLink: string
}
```
### User ### User
...@@ -303,3 +324,30 @@ __Version:__ v1 ...@@ -303,3 +324,30 @@ __Version:__ v1
| default | Unexpected error | | | 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 | |
...@@ -14,13 +14,12 @@ import ( ...@@ -14,13 +14,12 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"google.golang.org/api/googleapi"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"google.golang.org/api/googleapi"
) )
// Always reference these packages, just in case the auto-generated code // Always reference these packages, just in case the auto-generated code
...@@ -103,6 +102,16 @@ type Error struct { ...@@ -103,6 +102,16 @@ type Error struct {
Error_description string `json:"error_description,omitempty"` 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 { type User struct {
Admin bool `json:"admin,omitempty"` Admin bool `json:"admin,omitempty"`
...@@ -607,3 +616,87 @@ func (c *UsersListCall) Do() (*UsersResponse, error) { ...@@ -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"
// }
// }
}
...@@ -188,6 +188,28 @@ const DiscoveryJSON = `{ ...@@ -188,6 +188,28 @@ const DiscoveryJSON = `{
"type": "boolean" "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": { "resources": {
...@@ -295,6 +317,28 @@ const DiscoveryJSON = `{ ...@@ -295,6 +317,28 @@ const DiscoveryJSON = `{
"response": { "response": {
"$ref": "UserDisableResponse" "$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"
}
} }
} }
} }
......
...@@ -182,6 +182,28 @@ ...@@ -182,6 +182,28 @@
"type": "boolean" "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": { "resources": {
...@@ -289,6 +311,28 @@ ...@@ -289,6 +311,28 @@
"response": { "response": {
"$ref": "UserDisableResponse" "$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"
}
} }
} }
} }
......
...@@ -29,6 +29,7 @@ var ( ...@@ -29,6 +29,7 @@ var (
UsersCreateEndpoint = addBasePath(UsersSubTree) UsersCreateEndpoint = addBasePath(UsersSubTree)
UsersGetEndpoint = addBasePath(UsersSubTree + "/:id") UsersGetEndpoint = addBasePath(UsersSubTree + "/:id")
UsersDisableEndpoint = addBasePath(UsersSubTree + "/:id/disable") UsersDisableEndpoint = addBasePath(UsersSubTree + "/:id/disable")
UsersResendInvitationEndpoint = addBasePath(UsersSubTree + "/:id/resend-invitation")
) )
type UserMgmtServer struct { type UserMgmtServer struct {
...@@ -55,6 +56,7 @@ func (s *UserMgmtServer) HTTPHandler() http.Handler { ...@@ -55,6 +56,7 @@ func (s *UserMgmtServer) HTTPHandler() http.Handler {
r.POST(UsersCreateEndpoint, s.authAPIHandle(s.createUser)) r.POST(UsersCreateEndpoint, s.authAPIHandle(s.createUser))
r.POST(UsersDisableEndpoint, s.authAPIHandle(s.disableUser)) r.POST(UsersDisableEndpoint, s.authAPIHandle(s.disableUser))
r.GET(UsersGetEndpoint, s.authAPIHandle(s.getUser)) r.GET(UsersGetEndpoint, s.authAPIHandle(s.getUser))
r.POST(UsersResendInvitationEndpoint, s.authAPIHandle(s.resendInvitationEmail))
return r return r
} }
...@@ -161,6 +163,34 @@ func (s *UserMgmtServer) disableUser(w http.ResponseWriter, r *http.Request, ps ...@@ -161,6 +163,34 @@ func (s *UserMgmtServer) disableUser(w http.ResponseWriter, r *http.Request, ps
writeResponseWithBody(w, http.StatusOK, resp) 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) { func (s *UserMgmtServer) writeError(w http.ResponseWriter, err error) {
log.Errorf("Error calling user management API: %v: ", err) log.Errorf("Error calling user management API: %v: ", err)
if apiErr, ok := err.(api.Error); ok { if apiErr, ok := err.(api.Error); ok {
......
...@@ -25,6 +25,7 @@ var ( ...@@ -25,6 +25,7 @@ var (
} }
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) 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 ...@@ -188,6 +189,50 @@ func (u *UsersAPI) CreateUser(creds Creds, usr schema.User, redirURL url.URL) (s
}, nil }, 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) { func (u *UsersAPI) ListUsers(creds Creds, maxResults int, nextPageToken string) ([]*schema.User, string, error) {
log.Infof("userAPI: ListUsers") log.Infof("userAPI: ListUsers")
......
...@@ -101,6 +101,7 @@ func makeTestFixtures() (*UsersAPI, *testEmailer) { ...@@ -101,6 +101,7 @@ func makeTestFixtures() (*UsersAPI, *testEmailer) {
User: user.User{ User: user.User{
ID: "ID-2", ID: "ID-2",
Email: "id2@example.com", Email: "id2@example.com",
EmailVerified: true,
CreatedAt: clock.Now(), CreatedAt: clock.Now(),
}, },
}, { }, {
...@@ -463,3 +464,101 @@ func TestDisableUsers(t *testing.T) { ...@@ -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)
}
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment