Skip to content
This repository was archived by the owner on Sep 2, 2024. It is now read-only.

Commit 4c0fab7

Browse files
committed
implemented magic link close #21
1 parent bd9a9b9 commit 4c0fab7

File tree

3 files changed

+171
-23
lines changed

3 files changed

+171
-23
lines changed

membership.go

Lines changed: 131 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import (
55
"errors"
66
"fmt"
77
"log"
8+
"math/rand"
89
"net/http"
910
"strings"
1011
"time"
1112

13+
"github.com/staticbackendhq/core/config"
1214
"github.com/staticbackendhq/core/internal"
1315
"github.com/staticbackendhq/core/middleware"
1416

@@ -64,34 +66,12 @@ func (m *membership) login(w http.ResponseWriter, r *http.Request) {
6466
return
6567
}
6668

67-
token := fmt.Sprintf("%s|%s", tok.ID, tok.Token)
68-
69-
// get their JWT
70-
jwtBytes, err := m.getJWT(token)
69+
jwtBytes, err := m.getAuthToken(tok, conf)
7170
if err != nil {
7271
http.Error(w, err.Error(), http.StatusInternalServerError)
7372
return
7473
}
7574

76-
auth := internal.Auth{
77-
AccountID: tok.AccountID,
78-
UserID: tok.ID,
79-
Email: tok.Email,
80-
Role: tok.Role,
81-
Token: tok.Token,
82-
}
83-
84-
//TODO: find a good way to find all occurences of those two
85-
// and make them easily callable via a shared function
86-
if err := volatile.SetTyped(token, auth); err != nil {
87-
http.Error(w, err.Error(), http.StatusInternalServerError)
88-
return
89-
}
90-
if err := volatile.SetTyped("base:"+token, conf); err != nil {
91-
http.Error(w, err.Error(), http.StatusInternalServerError)
92-
return
93-
}
94-
9575
respond(w, http.StatusOK, string(jwtBytes))
9676
}
9777

@@ -163,6 +143,35 @@ func (m *membership) register(w http.ResponseWriter, r *http.Request) {
163143
respond(w, http.StatusOK, token)
164144
}
165145

146+
func (m *membership) getAuthToken(tok internal.Token, conf internal.BaseConfig) (jwtBytes []byte, err error) {
147+
token := fmt.Sprintf("%s|%s", tok.ID, tok.Token)
148+
149+
// get their JWT
150+
jwtBytes, err = m.getJWT(token)
151+
if err != nil {
152+
return
153+
}
154+
155+
auth := internal.Auth{
156+
AccountID: tok.AccountID,
157+
UserID: tok.ID,
158+
Email: tok.Email,
159+
Role: tok.Role,
160+
Token: tok.Token,
161+
}
162+
163+
//TODO: find a good way to find all occurences of those two
164+
// and make them easily callable via a shared function
165+
if err = volatile.SetTyped(token, auth); err != nil {
166+
return
167+
}
168+
if err = volatile.SetTyped("base:"+token, conf); err != nil {
169+
return
170+
}
171+
172+
return
173+
}
174+
166175
func (m *membership) createAccountAndUser(dbName, email, password string, role int) ([]byte, internal.Token, error) {
167176
acctID, err := datastore.CreateUserAccount(dbName, email)
168177
if err != nil {
@@ -411,3 +420,102 @@ func (m *membership) me(w http.ResponseWriter, r *http.Request) {
411420

412421
respond(w, http.StatusOK, auth)
413422
}
423+
424+
func (m *membership) magicLink(w http.ResponseWriter, r *http.Request) {
425+
conf, _, err := middleware.Extract(r, false)
426+
if err != nil {
427+
http.Error(w, "invalid StaticBackend key", http.StatusUnauthorized)
428+
return
429+
}
430+
431+
if r.Method == http.MethodGet {
432+
// we use GET to validate magic link code
433+
email := r.URL.Query().Get("email")
434+
code := r.URL.Query().Get("code")
435+
436+
val, err := volatile.Get("ml-" + email)
437+
if err != nil {
438+
http.Error(w, err.Error(), http.StatusInternalServerError)
439+
return
440+
}
441+
442+
parts := strings.Split(val, " ")
443+
if len(parts) != 2 {
444+
http.Error(w, "invalid data", http.StatusBadRequest)
445+
return
446+
}
447+
448+
// if the code isn't what was set we make sure they're not trying to
449+
// "brute force" random code.
450+
if parts[0] != code {
451+
if len(parts[1]) >= 10 {
452+
http.Error(w, "maximum retry reched", http.StatusTooManyRequests)
453+
return
454+
}
455+
456+
if err := volatile.Set("ml-"+email, val+"a"); err != nil {
457+
http.Error(w, err.Error(), http.StatusInternalServerError)
458+
return
459+
}
460+
461+
respond(w, http.StatusBadRequest, false)
462+
return
463+
}
464+
465+
// they got the right code, return a session token
466+
467+
tok, err := datastore.FindTokenByEmail(conf.Name, email)
468+
if err != nil {
469+
http.Error(w, err.Error(), http.StatusInternalServerError)
470+
return
471+
}
472+
473+
jwtBytes, err := m.getAuthToken(tok, conf)
474+
if err != nil {
475+
http.Error(w, err.Error(), http.StatusInternalServerError)
476+
return
477+
}
478+
479+
respond(w, http.StatusOK, string(jwtBytes))
480+
return
481+
}
482+
483+
data := new(struct {
484+
FromEmail string `json:"fromEmail"`
485+
FromName string `json:"fromName"`
486+
Email string `json:"email"`
487+
Subject string `json:"subject"`
488+
Body string `json:"body"`
489+
MagicLink string `json:"link"`
490+
})
491+
if err := parseBody(r.Body, &data); err != nil {
492+
http.Error(w, err.Error(), http.StatusBadRequest)
493+
return
494+
}
495+
496+
code := rand.Intn(987654) + 123456
497+
// to accomodate unit test, we hard code a magic link code in dev mode
498+
if config.Current.AppEnv == AppEnvDev {
499+
code = 666333
500+
}
501+
data.MagicLink += fmt.Sprintf("?code=%d&email=%s", code, data.Email)
502+
503+
if err := volatile.Set("ml-"+data.Email, fmt.Sprintf("%d a", code)); err != nil {
504+
http.Error(w, err.Error(), http.StatusInternalServerError)
505+
return
506+
}
507+
508+
mail := internal.SendMailData{
509+
From: data.FromEmail,
510+
FromName: data.FromName,
511+
To: data.Email,
512+
Subject: data.Subject,
513+
HTMLBody: strings.Replace(data.Body, "[link]", data.MagicLink, -1),
514+
}
515+
if err := emailer.Send(mail); err != nil {
516+
http.Error(w, err.Error(), http.StatusInternalServerError)
517+
return
518+
}
519+
520+
respond(w, http.StatusOK, true)
521+
}

membership_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package staticbackend
22

33
import (
4+
"fmt"
45
"strings"
56
"testing"
67

@@ -24,3 +25,41 @@ func TestGetCurrentAuthUser(t *testing.T) {
2425
t.Errorf("expected role to be 100 got %d", me.Role)
2526
}
2627
}
28+
29+
func TestMagicLink(t *testing.T) {
30+
// note, even though the magic link route allow public (un-unthenticated) req
31+
// I'm using dbReq (which enforce the stdauth) for ease of re-using the
32+
// function.
33+
34+
data := new(struct {
35+
FromEmail string `json:"fromEmail"`
36+
FromName string `json:"fromName"`
37+
Email string `json:"email"`
38+
Subject string `json:"subject"`
39+
Body string `json:"body"`
40+
MagicLink string `json:"link"`
41+
})
42+
43+
data.FromEmail = "[email protected]"
44+
data.FromName = "unit test"
45+
data.Email = admEmail
46+
data.Subject = "Magic link from unit test"
47+
data.Body = "<p>Hello</p><p>Please click on the following link to sign-in</p><p>[ink]</p>"
48+
data.MagicLink = "https://mycustom.link/with-code"
49+
50+
resp := dbReq(t, mship.magicLink, "POST", "/login/magic", data)
51+
defer resp.Body.Close()
52+
53+
if resp.StatusCode > 299 {
54+
t.Fatal(GetResponseBody(t, resp))
55+
}
56+
57+
// in dev mode, the code is always 666333
58+
u := fmt.Sprintf("/login/magic?email=%s&code=666333", admEmail)
59+
resp2 := dbReq(t, mship.magicLink, "GET", u, nil)
60+
defer resp2.Body.Close()
61+
62+
if resp2.StatusCode > 299 {
63+
t.Fatal(GetResponseBody(t, resp2))
64+
}
65+
}

server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ func Start(c config.AppConfig) {
140140

141141
m := &membership{}
142142

143+
http.Handle("/login/magic", middleware.Chain(http.HandlerFunc(m.magicLink), pubWithDB...))
143144
http.Handle("/login", middleware.Chain(http.HandlerFunc(m.login), pubWithDB...))
144145
http.Handle("/register", middleware.Chain(http.HandlerFunc(m.register), pubWithDB...))
145146
http.Handle("/email", middleware.Chain(http.HandlerFunc(m.emailExists), pubWithDB...))

0 commit comments

Comments
 (0)