Skip to content

Commit 22c9152

Browse files
authored
Merge pull request #133 from supabase/feat/generate-email-links
Feat: Generate email action links
2 parents 0855eea + 330f467 commit 22c9152

11 files changed

Lines changed: 242 additions & 15 deletions

File tree

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,35 @@ GoTrue exposes the following endpoints:
435435
}
436436
```
437437

438+
### **POST /admin/generate_link**
439+
Returns the corresponding email action link based on the type specified.
440+
441+
```json
442+
headers:
443+
{
444+
"Authorization": "Bearer eyJhbGciOiJI...M3A90LCkxxtX9oNP9KZO" // admin role required
445+
}
446+
447+
body:
448+
{
449+
"type": "signup" or "magiclink" or "recovery" or "invite",
450+
"email": "email@example.com",
451+
"password": "secret", // only if type = signup
452+
"data": {
453+
...
454+
}, // only if type = signup
455+
"redirect_to": "https://supabase.io" // Redirect URL to send the user to after an email action. Defaults to SITE_URL.
456+
457+
}
458+
```
459+
Returns
460+
```json
461+
{
462+
"action_link": "http://localhost:9999/verify?token=TOKEN&type=TYPE&redirect_to=REDIRECT_URL",
463+
...
464+
}
465+
```
466+
438467
### **POST /signup**
439468

440469
Register a new user with an email and password.

api/admin.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error {
179179
if exists, err := models.IsDuplicatedEmail(a.db, instanceID, params.Email, aud); err != nil {
180180
return internalServerError("Database error checking email").WithInternalError(err)
181181
} else if exists {
182-
return unprocessableEntityError("Email address already registered by another user")
182+
return unprocessableEntityError(DuplicateEmailMsg)
183183
}
184184

185185
user, err := models.NewUser(instanceID, params.Email, params.Password, aud, params.UserMetaData)

api/api.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
158158
r.Delete("/", api.adminUserDelete)
159159
})
160160
})
161+
162+
r.Post("/generate_link", api.GenerateLink)
161163
})
162164

163165
r.Route("/saml", func(r *router) {

api/errors.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import (
88
"runtime/debug"
99
)
1010

11+
// Common error messages during signup flow
12+
var (
13+
DuplicateEmailMsg = "A user with this email address has already been registered"
14+
)
15+
1116
var oauthErrorMap = map[int]string{
1217
http.StatusBadRequest: "invalid_request",
1318
http.StatusUnauthorized: "unauthorized_client",

api/invite.go

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,23 @@ func (a *API) Invite(w http.ResponseWriter, r *http.Request) error {
3636
if err != nil && !models.IsNotFoundError(err) {
3737
return internalServerError("Database error finding user").WithInternalError(err)
3838
}
39-
if user != nil {
40-
return unprocessableEntityError("Email address already registered by another user")
41-
}
4239

4340
err = a.db.Transaction(func(tx *storage.Connection) error {
44-
signupParams := SignupParams{
45-
Email: params.Email,
46-
Data: params.Data,
47-
Aud: aud,
48-
Provider: "email",
49-
}
50-
user, err = a.signupNewUser(ctx, tx, &signupParams)
51-
if err != nil {
52-
return err
41+
if user != nil {
42+
if user.IsConfirmed() {
43+
return unprocessableEntityError(DuplicateEmailMsg)
44+
}
45+
} else {
46+
signupParams := SignupParams{
47+
Email: params.Email,
48+
Data: params.Data,
49+
Aud: aud,
50+
Provider: "email",
51+
}
52+
user, err = a.signupNewUser(ctx, tx, &signupParams)
53+
if err != nil {
54+
return err
55+
}
5356
}
5457

5558
if terr := models.NewAuditLogEntry(tx, instanceID, adminUser, models.UserInvitedAction, map[string]interface{}{

api/mail.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,170 @@ package api
22

33
import (
44
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
58
"time"
69

710
"github.com/netlify/gotrue/crypto"
811
"github.com/netlify/gotrue/mailer"
912
"github.com/netlify/gotrue/models"
1013
"github.com/netlify/gotrue/storage"
1114
"github.com/pkg/errors"
15+
"github.com/sethvargo/go-password/password"
1216
)
1317

1418
var (
1519
MaxFrequencyLimitError error = errors.New("Frequency limit reached")
20+
configFile = ""
1621
)
1722

23+
type GenerateLinkParams struct {
24+
Type string `json:"type"`
25+
Email string `json:"email"`
26+
Password string `json:"password"`
27+
Data map[string]interface{} `json:"data"`
28+
RedirectTo string `json:"redirect_to"`
29+
}
30+
31+
func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error {
32+
ctx := r.Context()
33+
config := a.getConfig(ctx)
34+
mailer := a.Mailer(ctx)
35+
instanceID := getInstanceID(ctx)
36+
adminUser := getAdminUser(ctx)
37+
38+
params := &GenerateLinkParams{}
39+
jsonDecoder := json.NewDecoder(r.Body)
40+
41+
if err := jsonDecoder.Decode(params); err != nil {
42+
return badRequestError("Could not read body: %v", err)
43+
}
44+
45+
if err := a.validateEmail(ctx, params.Email); err != nil {
46+
return err
47+
}
48+
49+
aud := a.requestAud(ctx, r)
50+
user, err := models.FindUserByEmailAndAudience(a.db, instanceID, params.Email, aud)
51+
if err != nil {
52+
if models.IsNotFoundError(err) {
53+
if params.Type == "magiclink" {
54+
params.Type = "signup"
55+
params.Password, err = password.Generate(64, 10, 0, false, true)
56+
if err != nil {
57+
return internalServerError("error creating user").WithInternalError(err)
58+
}
59+
} else if params.Type == "recovery" {
60+
return notFoundError(err.Error())
61+
}
62+
} else {
63+
return internalServerError("Database error finding user").WithInternalError(err)
64+
}
65+
}
66+
67+
var url string
68+
referrer := a.getRedirectURLOrReferrer(r, params.RedirectTo)
69+
now := time.Now()
70+
err = a.db.Transaction(func(tx *storage.Connection) error {
71+
var terr error
72+
switch params.Type {
73+
case "magiclink", "recovery":
74+
if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserRecoveryRequestedAction, nil); terr != nil {
75+
return terr
76+
}
77+
user.RecoveryToken = crypto.SecureToken()
78+
user.RecoverySentAt = &now
79+
terr = errors.Wrap(tx.UpdateOnly(user, "recovery_token", "recovery_sent_at"), "Database error updating user for recovery")
80+
case "invite":
81+
if user != nil {
82+
if user.IsConfirmed() {
83+
return unprocessableEntityError(DuplicateEmailMsg)
84+
}
85+
} else {
86+
signupParams := &SignupParams{
87+
Email: params.Email,
88+
Data: params.Data,
89+
Provider: "email",
90+
Aud: aud,
91+
}
92+
user, terr = a.signupNewUser(ctx, tx, signupParams)
93+
if terr != nil {
94+
return terr
95+
}
96+
}
97+
if terr = models.NewAuditLogEntry(tx, instanceID, adminUser, models.UserInvitedAction, map[string]interface{}{
98+
"user_id": user.ID,
99+
"user_email": user.Email,
100+
}); terr != nil {
101+
return terr
102+
}
103+
user.ConfirmationToken = crypto.SecureToken()
104+
user.ConfirmationSentAt = &now
105+
user.InvitedAt = &now
106+
terr = errors.Wrap(tx.UpdateOnly(user, "confirmation_token", "confirmation_sent_at", "invited_at"), "Database error updating user for invite")
107+
case "signup":
108+
if user != nil {
109+
if user.IsConfirmed() {
110+
return unprocessableEntityError(DuplicateEmailMsg)
111+
}
112+
if err := user.UpdateUserMetaData(tx, params.Data); err != nil {
113+
return internalServerError("Database error updating user").WithInternalError(err)
114+
}
115+
} else {
116+
if params.Password == "" {
117+
return unprocessableEntityError("Signup requires a valid password")
118+
}
119+
if len(params.Password) < config.PasswordMinLength {
120+
return unprocessableEntityError(fmt.Sprintf("Password should be at least %d characters", config.PasswordMinLength))
121+
}
122+
signupParams := &SignupParams{
123+
Email: params.Email,
124+
Password: params.Password,
125+
Data: params.Data,
126+
Provider: "email",
127+
Aud: aud,
128+
}
129+
user, terr = a.signupNewUser(ctx, tx, signupParams)
130+
if terr != nil {
131+
return terr
132+
}
133+
}
134+
user.ConfirmationToken = crypto.SecureToken()
135+
user.ConfirmationSentAt = &now
136+
terr = errors.Wrap(tx.UpdateOnly(user, "confirmation_token", "confirmation_sent_at"), "Database error updating user for confirmation")
137+
default:
138+
return badRequestError("Invalid email action link type requested: %v", params.Type)
139+
}
140+
141+
if terr != nil {
142+
return terr
143+
}
144+
145+
url, terr = mailer.GetEmailActionLink(user, params.Type, referrer)
146+
if terr != nil {
147+
return terr
148+
}
149+
return nil
150+
})
151+
152+
if err != nil {
153+
return err
154+
}
155+
156+
resp := make(map[string]interface{})
157+
u, err := json.Marshal(user)
158+
if err != nil {
159+
return internalServerError("User serialization error").WithInternalError(err)
160+
}
161+
if err = json.Unmarshal(u, &resp); err != nil {
162+
return internalServerError("User serialization error").WithInternalError(err)
163+
}
164+
resp["action_link"] = url
165+
166+
return sendJSON(w, http.StatusOK, resp)
167+
}
168+
18169
func sendConfirmation(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration, referrerURL string) error {
19170
if u.ConfirmationSentAt != nil && !u.ConfirmationSentAt.Add(maxFrequency).Before(time.Now()) {
20171
return MaxFrequencyLimitError

api/signup.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
6161
var terr error
6262
if user != nil {
6363
if user.IsConfirmed() {
64-
return badRequestError("A user with this email address has already been registered")
64+
return unprocessableEntityError(DuplicateEmailMsg)
6565
}
6666

6767
if err := user.UpdateUserMetaData(tx, params.Data); err != nil {

api/user.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
125125
if exists, terr = models.IsDuplicatedEmail(tx, instanceID, params.Email, user.Aud); terr != nil {
126126
return internalServerError("Database error checking email").WithInternalError(terr)
127127
} else if exists {
128-
return unprocessableEntityError("Email address already registered by another user")
128+
return unprocessableEntityError(DuplicateEmailMsg)
129129
}
130130

131131
mailer := a.Mailer(ctx)

mailer/mailer.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type Mailer interface {
2020
MagicLinkMail(user *models.User, referrerURL string) error
2121
EmailChangeMail(user *models.User, referrerURL string) error
2222
ValidateEmail(email string) error
23+
GetEmailActionLink(user *models.User, actionType, referrerURL string) (string, error)
2324
}
2425

2526
// NewMailer returns a new gotrue mailer

mailer/noop.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,7 @@ func (m *noopMailer) EmailChangeMail(user *models.User, referrerURL string) erro
3232
func (m noopMailer) Send(user *models.User, subject, body string, data map[string]interface{}) error {
3333
return nil
3434
}
35+
36+
func (m noopMailer) GetEmailActionLink(user *models.User, actionType, referrerURL string) (string, error) {
37+
return "", nil
38+
}

0 commit comments

Comments
 (0)