├── .gitignore ├── README.md ├── cmd └── passwordless │ └── main.go ├── go.mod ├── go.sum ├── jsconfig.json ├── notification ├── compose.go ├── magic_link.go └── smtp │ ├── magic_link.go │ └── sender.go ├── passwordless.go ├── repo └── cockroach │ ├── migrations │ ├── 000_schema.sql │ ├── migrate.sh │ └── schema.go │ ├── repo.go │ ├── user.go │ └── verification_code.go ├── transport ├── http │ ├── auth.go │ ├── handler.go │ └── static.go └── service.go └── web ├── files.go ├── static ├── auth.js ├── authenticated-view.js ├── guest-view.js ├── http.js ├── index.html ├── main.js └── styles.css └── template └── mail ├── magic-link.html.tmpl └── magic-link.txt.tmpl /.gitignore: -------------------------------------------------------------------------------- 1 | /.env 2 | /cockroach-data 3 | /passwordless 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-passwordless-demo 2 | 3 | [Demo](https://go-passwordless-demo.herokuapp.com/) 4 | 5 | ## Build instructions 6 | 7 | Make sure you have [CockroachDB](https://www.cockroachlabs.com/) installed, then: 8 | 9 | ```bash 10 | cockroach start-single-node --insecure -listen-addr 127.0.0.1 11 | ``` 12 | 13 | Then make sure you have [Golang](https://golang.org/) installed too and build the code: 14 | 15 | ```bash 16 | go build ./cmd/passwordless 17 | ``` 18 | 19 | Then run the server:
20 | _(Add `-migrate` the first time)_ 21 | ``` 22 | ./passwordless -migrate 23 | ``` 24 | -------------------------------------------------------------------------------- /cmd/passwordless/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "flag" 8 | "fmt" 9 | "log" 10 | "net" 11 | "net/http" 12 | "net/url" 13 | "os" 14 | "os/signal" 15 | "strconv" 16 | "syscall" 17 | "time" 18 | 19 | "github.com/joho/godotenv" 20 | _ "github.com/lib/pq" 21 | "github.com/nicolasparada/go-passwordless-demo" 22 | smtpnotification "github.com/nicolasparada/go-passwordless-demo/notification/smtp" 23 | "github.com/nicolasparada/go-passwordless-demo/repo/cockroach" 24 | "github.com/nicolasparada/go-passwordless-demo/repo/cockroach/migrations" 25 | httptransport "github.com/nicolasparada/go-passwordless-demo/transport/http" 26 | ) 27 | 28 | func main() { 29 | _ = godotenv.Load() 30 | 31 | ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 32 | defer cancel() 33 | 34 | logger := log.Default() 35 | err := run(ctx, logger, os.Args[1:]) 36 | if err != nil { 37 | logger.Println(err) 38 | os.Exit(1) 39 | } 40 | } 41 | 42 | func run(ctx context.Context, logger *log.Logger, args []string) error { 43 | var ( 44 | port, _ = strconv.ParseUint(env("PORT", "3000"), 10, 64) 45 | databaseURL = env("DATABASE_URL", "postgresql://root@127.0.0.1:26257/passwordless?sslmode=disable") 46 | usePostgres, _ = strconv.ParseBool(os.Getenv("USE_POSTGRES")) 47 | migrate, _ = strconv.ParseBool(os.Getenv("MIGRATE")) 48 | smtpHost = os.Getenv("SMTP_HOST") 49 | smtpPort, _ = strconv.ParseUint(os.Getenv("SMTP_PORT"), 10, 64) 50 | smtpUsername = os.Getenv("SMTP_USERNAME") 51 | smtpPassword = os.Getenv("SMTP_PASSWORD") 52 | originStr = env("ORIGIN", fmt.Sprintf("http://localhost:%d", port)) 53 | authTokenKey = env("AUTH_TOKEN_KEY", "supersecretkeyyoushouldnotcommit") 54 | ) 55 | 56 | fs := flag.NewFlagSet("passwordless", flag.ExitOnError) 57 | fs.Uint64Var(&port, "port", port, "HTTP port in which this very server listen") 58 | fs.StringVar(&databaseURL, "db", databaseURL, "Cockroach database URL") 59 | fs.BoolVar(&usePostgres, "use-postgres", usePostgres, "Tries to use postgres instead of cockroach") 60 | fs.BoolVar(&migrate, "migrate", migrate, "Whether migrate database schema") 61 | fs.StringVar(&originStr, "origin", originStr, "URL origin of this very server") 62 | 63 | if err := fs.Parse(args); err != nil { 64 | return fmt.Errorf("could not parse flags: %w", err) 65 | } 66 | 67 | db, err := sql.Open("postgres", databaseURL) 68 | if err != nil { 69 | return fmt.Errorf("could not open cockroach db: %w", err) 70 | } 71 | 72 | defer db.Close() 73 | 74 | if err := db.PingContext(ctx); err != nil { 75 | return fmt.Errorf("could not ping cockroach: %w", err) 76 | } 77 | 78 | if migrate { 79 | if usePostgres { 80 | _, err := db.ExecContext(ctx, `CREATE EXTENSION IF NOT EXISTS "pgcrypto"`) 81 | if err != nil { 82 | return fmt.Errorf("could not migrate sql schema: %w", err) 83 | } 84 | } 85 | _, err := db.ExecContext(ctx, migrations.Schema) 86 | if err != nil { 87 | return fmt.Errorf("could not migrate sql schema: %w", err) 88 | } 89 | } 90 | 91 | origin, err := url.Parse(originStr) 92 | if err != nil { 93 | return fmt.Errorf("could not parse origin: %w", err) 94 | } 95 | 96 | if !origin.IsAbs() { 97 | return errors.New("origin must be absolute") 98 | } 99 | 100 | repo := &cockroach.Repository{DB: db, DisableCRDBRetries: usePostgres} 101 | mailFromName := "Passwordless" 102 | mailFromAddress := "noreply@" + origin.Hostname() 103 | magicLinkComposer, err := smtpnotification.MagicLinkComposer( 104 | mailFromName, mailFromAddress, 105 | ) 106 | if err != nil { 107 | return fmt.Errorf("could not create magic link composer: %w", err) 108 | } 109 | 110 | magicLinkSender := &smtpnotification.Sender{ 111 | FromName: mailFromName, 112 | FromAddress: mailFromAddress, 113 | Host: smtpHost, 114 | Port: smtpPort, 115 | Username: smtpUsername, 116 | Password: smtpPassword, 117 | ComposeFunc: magicLinkComposer, 118 | } 119 | svc := &passwordless.Service{ 120 | Logger: logger, 121 | Origin: origin, 122 | Repository: repo, 123 | MagicLinkSender: magicLinkSender, 124 | AuthTokenKey: authTokenKey, 125 | } 126 | h := httptransport.NewHandler(svc, logger) 127 | 128 | srv := &http.Server{ 129 | Handler: h, 130 | Addr: fmt.Sprintf(":%d", port), 131 | BaseContext: func(net.Listener) context.Context { 132 | return ctx 133 | }, 134 | } 135 | 136 | go func() { 137 | <-ctx.Done() 138 | fmt.Println() 139 | 140 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) 141 | defer cancel() 142 | 143 | if err := srv.Shutdown(ctx); err != nil { 144 | logger.Printf("could not shutdown http server: %v\n", err) 145 | os.Exit(1) 146 | } 147 | }() 148 | 149 | logger.Printf("accepting http connections at %s\n", srv.Addr) 150 | err = srv.ListenAndServe() 151 | if err != nil && err != http.ErrServerClosed { 152 | return fmt.Errorf("could not http listen and serve: %w", err) 153 | } 154 | 155 | return nil 156 | } 157 | 158 | func env(key, fallback string) string { 159 | v, ok := os.LookupEnv(key) 160 | if !ok { 161 | return fallback 162 | } 163 | 164 | return v 165 | } 166 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | // +heroku goVersion go1.16 2 | // +heroku install ./cmd/passwordless 3 | 4 | module github.com/nicolasparada/go-passwordless-demo 5 | 6 | go 1.16 7 | 8 | require ( 9 | github.com/cockroachdb/cockroach-go v2.0.1+incompatible 10 | github.com/go-mail/mail v2.3.1+incompatible 11 | github.com/hako/branca v0.0.0-20200807062402-6052ac720505 12 | github.com/hako/durafmt v0.0.0-20210316092057-3a2c319c1acd 13 | github.com/joho/godotenv v1.3.0 14 | github.com/lib/pq v1.10.1 15 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect 16 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cockroachdb/cockroach-go v2.0.1+incompatible h1:rkk9T7FViadPOz28xQ68o18jBSpyShru0mayVumxqYA= 2 | github.com/cockroachdb/cockroach-go v2.0.1+incompatible/go.mod h1:XGLbWH/ujMcbPbhZq52Nv6UrCghb1yGn//133kEsvDk= 3 | github.com/eknkc/basex v1.0.0 h1:R2zGRGJAcqEES03GqHU9leUF5n4Pg6ahazPbSTQWCWc= 4 | github.com/eknkc/basex v1.0.0/go.mod h1:k/F/exNEHFdbs3ZHuasoP2E7zeWwZblG84Y7Z59vQRo= 5 | github.com/go-mail/mail v2.3.1+incompatible h1:UzNOn0k5lpfVtO31cK3hn6I4VEVGhe3lX8AJBAxXExM= 6 | github.com/go-mail/mail v2.3.1+incompatible/go.mod h1:VPWjmmNyRsWXQZHVHT3g0YbIINUkSmuKOiLIDkWbL6M= 7 | github.com/hako/branca v0.0.0-20200807062402-6052ac720505 h1:+sMksliTexVa8g56h4RkilJghUmsW5FujoD1AWb3Ak4= 8 | github.com/hako/branca v0.0.0-20200807062402-6052ac720505/go.mod h1:rg2Mhi85BDi/JlegTSj3hgLPNJ0iNvWgDrnM306nbWQ= 9 | github.com/hako/durafmt v0.0.0-20210316092057-3a2c319c1acd h1:FsX+T6wA8spPe4c1K9vi7T0LvNCO1TTqiL8u7Wok2hw= 10 | github.com/hako/durafmt v0.0.0-20210316092057-3a2c319c1acd/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0= 11 | github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= 12 | github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= 13 | github.com/lib/pq v1.10.1 h1:6VXZrLU0jHBYyAqrSPa+MgPfnSvTPuMgK+k0o5kVFWo= 14 | github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 15 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 16 | golang.org/x/crypto v0.0.0-20191219195013-becbf705a915 h1:aJ0ex187qoXrJHPo8ZasVTASQB7llQP6YeNzgDALPRk= 17 | golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 18 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 19 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 20 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 21 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 22 | golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= 23 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 24 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 25 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= 26 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= 27 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "checkJs": true, 4 | "module": "esnext", 5 | "target": "esnext" 6 | }, 7 | "include": [ 8 | "web/static" 9 | ], 10 | } 11 | -------------------------------------------------------------------------------- /notification/compose.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "context" 5 | "io" 6 | ) 7 | 8 | type ComposeFunc func(ctx context.Context, to string, w io.Writer, data interface{}) error 9 | -------------------------------------------------------------------------------- /notification/magic_link.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "net/url" 5 | "time" 6 | ) 7 | 8 | type MagicLinkData struct { 9 | Origin *url.URL 10 | TTL time.Duration 11 | MagicLink *url.URL 12 | } 13 | -------------------------------------------------------------------------------- /notification/smtp/magic_link.go: -------------------------------------------------------------------------------- 1 | package smtp 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "html/template" 8 | "io" 9 | "net/mail" 10 | 11 | "github.com/nicolasparada/go-passwordless-demo/notification" 12 | "github.com/nicolasparada/go-passwordless-demo/web" 13 | "golang.org/x/sync/errgroup" 14 | ) 15 | 16 | // MagicLinkComposer handles web/template/mail/magic-link.{html,txt}.tmpl composing. 17 | // Uses notification.MagicLinkData as data. 18 | func MagicLinkComposer(fromName, fromAddr string) (notification.ComposeFunc, error) { 19 | b, err := web.Files.ReadFile("template/mail/magic-link.html.tmpl") 20 | if err != nil { 21 | return nil, fmt.Errorf("could not read magic link html template file: %w", err) 22 | } 23 | 24 | htmlTmpl, err := template.New("mail/magic-link.html").Funcs(tmplFuncs).Parse(string(b)) 25 | if err != nil { 26 | return nil, fmt.Errorf("could not parse magic link html template: %w", err) 27 | } 28 | 29 | b, err = web.Files.ReadFile("template/mail/magic-link.txt.tmpl") 30 | if err != nil { 31 | return nil, fmt.Errorf("could not read magic link plain text template file: %w", err) 32 | } 33 | 34 | plainTextTmpl, err := template.New("mail/magic-link.txt").Funcs(tmplFuncs).Parse(string(b)) 35 | if err != nil { 36 | return nil, fmt.Errorf("could not parse magic link plain text template: %w", err) 37 | } 38 | 39 | from := &mail.Address{Name: fromName, Address: fromAddr} 40 | 41 | composeFunc := func(ctx context.Context, email string, w io.Writer, v interface{}) error { 42 | data, ok := v.(notification.MagicLinkData) 43 | if !ok { 44 | return fmt.Errorf("unexpected magic link data type %T", v) 45 | } 46 | 47 | htmlRenderer, plainTextRenderer := &bytes.Buffer{}, &bytes.Buffer{} 48 | g := &errgroup.Group{} 49 | g.Go(func() error { 50 | err := htmlTmpl.Execute(htmlRenderer, data) 51 | if err != nil { 52 | return fmt.Errorf("could not render magic link html template: %w", err) 53 | } 54 | 55 | return nil 56 | }) 57 | g.Go(func() error { 58 | err := plainTextTmpl.Execute(plainTextRenderer, data) 59 | if err != nil { 60 | return fmt.Errorf("could not render magic link plain text template: %w", err) 61 | } 62 | 63 | return nil 64 | }) 65 | 66 | if err := g.Wait(); err != nil { 67 | return err 68 | } 69 | 70 | to := &mail.Address{Address: email} 71 | subject := "Login to Golang Passwordless Demo" 72 | html := htmlRenderer.String() 73 | plainText := plainTextRenderer.String() 74 | err = buildMessage(w, from, to, subject, html, plainText) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | return nil 80 | } 81 | 82 | return composeFunc, nil 83 | } 84 | -------------------------------------------------------------------------------- /notification/smtp/sender.go: -------------------------------------------------------------------------------- 1 | package smtp 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "html/template" 8 | "io" 9 | "net" 10 | "net/mail" 11 | "net/smtp" 12 | "strconv" 13 | "sync" 14 | "time" 15 | 16 | mailutil "github.com/go-mail/mail" 17 | "github.com/hako/durafmt" 18 | "github.com/nicolasparada/go-passwordless-demo/notification" 19 | ) 20 | 21 | type Sender struct { 22 | FromName string 23 | FromAddress string 24 | Host string 25 | Port uint64 26 | Username string 27 | Password string 28 | ComposeFunc func(ctx context.Context, to string, w io.Writer, data interface{}) error 29 | 30 | once sync.Once 31 | 32 | addr string 33 | auth smtp.Auth 34 | fromAddr *mail.Address 35 | } 36 | 37 | func (s *Sender) Send(ctx context.Context, data notification.MagicLinkData, to string) error { 38 | s.once.Do(func() { 39 | s.addr = net.JoinHostPort(s.Host, strconv.FormatUint(s.Port, 10)) 40 | s.auth = smtp.PlainAuth("", s.Username, s.Password, s.Host) 41 | s.fromAddr = &mail.Address{Name: s.FromName, Address: s.FromAddress} 42 | }) 43 | 44 | msg := &bytes.Buffer{} 45 | err := s.ComposeFunc(ctx, to, msg, data) 46 | if err != nil { 47 | return fmt.Errorf("could not compose magic link message: %w", err) 48 | } 49 | 50 | err = smtp.SendMail(s.addr, s.auth, s.fromAddr.String(), []string{to}, msg.Bytes()) 51 | if err != nil { 52 | return fmt.Errorf("could not smtp send magic link: %w", err) 53 | } 54 | 55 | return nil 56 | } 57 | 58 | func buildMessage(w io.Writer, from, to *mail.Address, subject, html, text string) error { 59 | m := mailutil.NewMessage() 60 | m.SetHeader("From", from.String()) 61 | m.SetHeader("To", to.String()) 62 | m.SetHeader("Subject", subject) 63 | m.SetBody("text/html", html) 64 | m.AddAlternative("text/plain", text) 65 | 66 | _, err := m.WriteTo(w) 67 | if err != nil { 68 | return fmt.Errorf("could not build mail body: %w", err) 69 | } 70 | 71 | return nil 72 | } 73 | 74 | var tmplFuncs = template.FuncMap{ 75 | "human_duration": func(d time.Duration) string { 76 | return durafmt.Parse(d).LimitFirstN(1).String() 77 | }, 78 | } 79 | -------------------------------------------------------------------------------- /passwordless.go: -------------------------------------------------------------------------------- 1 | package passwordless 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "net/url" 10 | "regexp" 11 | "time" 12 | 13 | "github.com/hako/branca" 14 | "github.com/nicolasparada/go-passwordless-demo/notification" 15 | ) 16 | 17 | const ( 18 | verificationCodeTTL = time.Minute * 20 19 | authTokenTTL = time.Hour * 24 * 14 20 | ) 21 | 22 | var KeyAuthUserID = struct{ name string }{name: "key-auth-user-id"} 23 | 24 | var ( 25 | ErrInvalidEmail = errors.New("invalid email") 26 | ErrInvalidRedirectURI = errors.New("invalid redirect URI") 27 | ErrUntrustedRedirectURI = errors.New("untrusted redirect URI") 28 | ErrInvalidVerificationCode = errors.New("invalid verification code") 29 | ErrInvalidUsername = errors.New("invalid username") 30 | ErrVerificationCodeNotFound = errors.New("verification code not found") 31 | ErrVerificationCodeExpired = errors.New("verification code expired") 32 | ErrUserNotFound = errors.New("user not found") 33 | ErrEmailTaken = errors.New("email taken") 34 | ErrUsernameTaken = errors.New("username taken") 35 | ErrUnauthenticated = errors.New("unauthenticated") 36 | ) 37 | 38 | type Service struct { 39 | Logger *log.Logger 40 | Origin *url.URL 41 | Repository Repository 42 | MagicLinkSender NotificationSender 43 | AuthTokenKey string 44 | } 45 | 46 | type Repository interface { 47 | ExecuteTx(ctx context.Context, txFunc func(ctx context.Context) error) error 48 | 49 | StoreVerificationCode(ctx context.Context, email string) (VerificationCode, error) 50 | VerificationCode(ctx context.Context, email, code string) (VerificationCode, error) 51 | DeleteVerificationCode(ctx context.Context, email, code string) (bool, error) 52 | 53 | UserExistsByEmail(ctx context.Context, email string) (bool, error) 54 | UserByEmail(ctx context.Context, email string) (User, error) 55 | StoreUser(ctx context.Context, email, username string) (User, error) 56 | User(ctx context.Context, userID string) (User, error) 57 | } 58 | 59 | type VerificationCode struct { 60 | Email string 61 | Code string 62 | CreatedAt time.Time 63 | } 64 | 65 | func (vc VerificationCode) Expired() bool { 66 | return vc.CreatedAt.Add(verificationCodeTTL).Before(time.Now()) 67 | } 68 | 69 | type NotificationSender interface { 70 | Send(ctx context.Context, data notification.MagicLinkData, to string) error 71 | } 72 | 73 | type Auth struct { 74 | Token string `json:"token"` 75 | ExpiresAt time.Time `json:"expiresAt"` 76 | User User `json:"user"` 77 | } 78 | 79 | type User struct { 80 | ID string `json:"id"` 81 | Email string `json:"email"` 82 | Username string `json:"username"` 83 | } 84 | 85 | func (svc *Service) SendMagicLink(ctx context.Context, email, redirectURI string) error { 86 | if !isValidEmail(email) { 87 | return ErrInvalidEmail 88 | } 89 | 90 | _, err := svc.ValidateRedirectURI(redirectURI) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | vc, err := svc.Repository.StoreVerificationCode(ctx, email) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | // See transport/http/handler.go 101 | q := url.Values{} 102 | q.Set("email", email) 103 | q.Set("code", vc.Code) 104 | q.Set("redirect_uri", redirectURI) 105 | magicLink := cloneURL(svc.Origin) 106 | magicLink.Path = "/api/verify-magic-link" 107 | magicLink.RawQuery = q.Encode() 108 | 109 | err = svc.MagicLinkSender.Send(ctx, notification.MagicLinkData{ 110 | Origin: svc.Origin, 111 | TTL: verificationCodeTTL, 112 | MagicLink: magicLink, 113 | }, email) 114 | if err != nil { 115 | return fmt.Errorf("could not send magic link to user: %w", err) 116 | } 117 | 118 | return nil 119 | } 120 | 121 | func (svc *Service) ValidateRedirectURI(rawurl string) (*url.URL, error) { 122 | uri, err := url.Parse(rawurl) 123 | if err != nil || !uri.IsAbs() { 124 | return nil, ErrInvalidRedirectURI 125 | } 126 | 127 | if uri.Host != svc.Origin.Host { 128 | return nil, ErrUntrustedRedirectURI 129 | } 130 | 131 | return uri, nil 132 | } 133 | 134 | func (svc *Service) VerifyMagicLink(ctx context.Context, email, code string, username *string) (Auth, error) { 135 | var auth Auth 136 | 137 | if !isValidEmail(email) { 138 | return auth, ErrInvalidEmail 139 | } 140 | 141 | if !isValidVerificationCode(code) { 142 | return auth, ErrInvalidVerificationCode 143 | } 144 | 145 | if username != nil && !isValidUsername(*username) { 146 | return auth, ErrInvalidUsername 147 | } 148 | 149 | vc, err := svc.Repository.VerificationCode(ctx, email, code) 150 | if err != nil { 151 | return auth, err 152 | } 153 | 154 | if vc.Expired() { 155 | return auth, ErrVerificationCodeExpired 156 | } 157 | 158 | err = svc.Repository.ExecuteTx(ctx, func(ctx context.Context) error { 159 | exists, err := svc.Repository.UserExistsByEmail(ctx, vc.Email) 160 | if err != nil { 161 | return err 162 | } 163 | 164 | if exists { 165 | auth.User, err = svc.Repository.UserByEmail(ctx, vc.Email) 166 | return err 167 | } 168 | 169 | if username == nil { 170 | return ErrUserNotFound 171 | } 172 | 173 | auth.User, err = svc.Repository.StoreUser(ctx, vc.Email, *username) 174 | if err != nil { 175 | return err 176 | } 177 | 178 | return nil 179 | }) 180 | if err != nil { 181 | return auth, err 182 | } 183 | 184 | auth.ExpiresAt = time.Now().Add(authTokenTTL) 185 | auth.Token, err = svc.authTokenCodec().EncodeToString(auth.User.ID) 186 | if err != nil { 187 | return auth, fmt.Errorf("could not generate auth token: %w", err) 188 | } 189 | 190 | go func() { 191 | _, err := svc.Repository.DeleteVerificationCode(context.Background(), email, code) 192 | if err != nil { 193 | svc.Logger.Printf("failed to delete verification code: %v\n", err) 194 | } 195 | }() 196 | 197 | return auth, nil 198 | } 199 | 200 | func (svc *Service) authTokenCodec() *branca.Branca { 201 | cdc := branca.NewBranca(svc.AuthTokenKey) 202 | cdc.SetTTL(uint32(authTokenTTL.Seconds())) 203 | return cdc 204 | } 205 | 206 | func (svc *Service) ParseAuthToken(token string) (userID string, err error) { 207 | userID, err = svc.authTokenCodec().DecodeToString(token) 208 | if err == branca.ErrInvalidToken || err == branca.ErrInvalidTokenVersion { 209 | return "", ErrUnauthenticated 210 | } 211 | 212 | if _, ok := err.(*branca.ErrExpiredToken); ok { 213 | return "", ErrUnauthenticated 214 | } 215 | 216 | return userID, err 217 | } 218 | 219 | func (svc *Service) AuthUser(ctx context.Context) (User, error) { 220 | var u User 221 | 222 | authUserID, ok := ctx.Value(KeyAuthUserID).(string) 223 | if !ok { 224 | return u, ErrUnauthenticated 225 | } 226 | 227 | return svc.Repository.User(ctx, authUserID) 228 | } 229 | 230 | var reEmail = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) 231 | 232 | func isValidEmail(s string) bool { 233 | return reEmail.MatchString(s) 234 | } 235 | 236 | var reUsername = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]{0,17}$`) 237 | 238 | func isValidUsername(s string) bool { 239 | return reUsername.MatchString(s) 240 | } 241 | 242 | var reUUID4 = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) 243 | 244 | func isValidVerificationCode(s string) bool { 245 | return reUUID4.MatchString(s) 246 | } 247 | 248 | func cloneURL(u *url.URL) *url.URL { 249 | if u == nil { 250 | return nil 251 | } 252 | u2 := new(url.URL) 253 | *u2 = *u 254 | if u.User != nil { 255 | u2.User = new(url.Userinfo) 256 | *u2.User = *u.User 257 | } 258 | return u2 259 | } 260 | -------------------------------------------------------------------------------- /repo/cockroach/migrations/000_schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS verification_codes ( 2 | email VARCHAR NOT NULL, 3 | code UUID NOT NULL DEFAULT gen_random_uuid(), 4 | created_at TIMESTAMP NOT NULL DEFAULT now(), 5 | PRIMARY KEY (email, code) 6 | ); 7 | 8 | CREATE TABLE IF NOT EXISTS users ( 9 | id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), 10 | email VARCHAR NOT NULL UNIQUE, 11 | username VARCHAR NOT NULL UNIQUE 12 | ); 13 | -------------------------------------------------------------------------------- /repo/cockroach/migrations/migrate.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | force=false 4 | pgcrypto=false 5 | 6 | OPTIND=1 7 | 8 | while getopts 'f;e' opt; do 9 | case $opt in 10 | f) force=true ;; 11 | e) pgcrypto=true;; 12 | *) echo 'Error in command line parsing' >&2 13 | exit 1 14 | esac 15 | done 16 | shift "$(( OPTIND - 1 ))" 17 | 18 | if "$force"; then 19 | cockroach sql --insecure -e "DROP DATABASE IF EXISTS passwordless CASCADE" 20 | fi 21 | 22 | cockroach sql --insecure -e "CREATE DATABASE IF NOT EXISTS passwordless" 23 | 24 | if "$pgcrypto"; then 25 | cockroach sql --insecure -e "CREATE EXTENSION IF NOT EXISTS \"pgcrypto\"" 26 | fi 27 | 28 | cat $(dirname $0)/000_schema.sql | cockroach sql --insecure -d passwordless 29 | -------------------------------------------------------------------------------- /repo/cockroach/migrations/schema.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | _ "embed" 5 | ) 6 | 7 | //go:embed 000_schema.sql 8 | var Schema string 9 | -------------------------------------------------------------------------------- /repo/cockroach/repo.go: -------------------------------------------------------------------------------- 1 | package cockroach 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | 8 | "github.com/cockroachdb/cockroach-go/crdb" 9 | ) 10 | 11 | var keyTx = struct{ name string }{name: "key-tx"} 12 | 13 | type Repository struct { 14 | DB *sql.DB 15 | DisableCRDBRetries bool 16 | } 17 | 18 | type ext interface { 19 | ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) 20 | QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) 21 | QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row 22 | } 23 | 24 | func (repo *Repository) ext(ctx context.Context) ext { 25 | tx, ok := ctx.Value(keyTx).(*sql.Tx) 26 | if !ok { 27 | return repo.DB 28 | } 29 | 30 | return tx 31 | } 32 | 33 | func (repo *Repository) ExecuteTx(ctx context.Context, txFunc func(ctx context.Context) error) error { 34 | if repo.DisableCRDBRetries { 35 | tx, err := repo.DB.BeginTx(ctx, nil) 36 | if err != nil { 37 | return fmt.Errorf("could not begin tx: %w", err) 38 | } 39 | 40 | defer func() { 41 | _ = tx.Rollback() 42 | }() 43 | 44 | err = txFunc(context.WithValue(ctx, keyTx, tx)) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | err = tx.Commit() 50 | if err != nil { 51 | return fmt.Errorf("could not commit tx: %w", err) 52 | } 53 | 54 | return nil 55 | } 56 | 57 | return crdb.ExecuteTx(ctx, repo.DB, nil, func(tx *sql.Tx) error { 58 | return txFunc(context.WithValue(ctx, keyTx, tx)) 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /repo/cockroach/user.go: -------------------------------------------------------------------------------- 1 | package cockroach 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/lib/pq" 10 | passwordless "github.com/nicolasparada/go-passwordless-demo" 11 | ) 12 | 13 | func (repo *Repository) UserExistsByEmail(ctx context.Context, email string) (bool, error) { 14 | var exists bool 15 | 16 | query := "SELECT EXISTS (SELECT 1 FROM users WHERE email = $1)" 17 | row := repo.ext(ctx).QueryRowContext(ctx, query, email) 18 | err := row.Scan(&exists) 19 | if err != nil { 20 | return false, fmt.Errorf("could not sql query select or scan user existence by email: %w", err) 21 | } 22 | 23 | return exists, nil 24 | } 25 | 26 | func (repo *Repository) UserByEmail(ctx context.Context, email string) (passwordless.User, error) { 27 | var u passwordless.User 28 | query := "SELECT id, username FROM users WHERE email = $1" 29 | row := repo.ext(ctx).QueryRowContext(ctx, query, email) 30 | err := row.Scan(&u.ID, &u.Username) 31 | if err == sql.ErrNoRows { 32 | return u, passwordless.ErrUserNotFound 33 | } 34 | 35 | if err != nil { 36 | return u, fmt.Errorf("could not sql query select or scan user by email: %w", err) 37 | } 38 | 39 | u.Email = email 40 | 41 | return u, nil 42 | } 43 | 44 | func (repo *Repository) StoreUser(ctx context.Context, email, username string) (passwordless.User, error) { 45 | var u passwordless.User 46 | query := "INSERT INTO users (email, username) VALUES ($1, $2) RETURNING id" 47 | row := repo.ext(ctx).QueryRowContext(ctx, query, email, username) 48 | err := row.Scan(&u.ID) 49 | if isUniqueViolationError(err) { 50 | if strings.Contains(err.Error(), "email") { 51 | return u, passwordless.ErrEmailTaken 52 | } 53 | 54 | if strings.Contains(err.Error(), "username") { 55 | return u, passwordless.ErrUsernameTaken 56 | } 57 | } 58 | if err != nil { 59 | return u, fmt.Errorf("could not sql insert or scan user: %w", err) 60 | } 61 | 62 | u.Email = email 63 | u.Username = username 64 | 65 | return u, nil 66 | } 67 | 68 | func (repo *Repository) User(ctx context.Context, userID string) (passwordless.User, error) { 69 | var u passwordless.User 70 | query := "SELECT email, username FROM users WHERE id = $1" 71 | row := repo.ext(ctx).QueryRowContext(ctx, query, userID) 72 | err := row.Scan(&u.Email, &u.Username) 73 | if err == sql.ErrNoRows { 74 | return u, passwordless.ErrUserNotFound 75 | } 76 | 77 | if err != nil { 78 | return u, fmt.Errorf("could not sql query select or scan user: %w", err) 79 | } 80 | 81 | u.ID = userID 82 | 83 | return u, nil 84 | } 85 | 86 | func isUniqueViolationError(err error) bool { 87 | e, ok := err.(*pq.Error) 88 | return ok && e.Code == "23505" 89 | } 90 | -------------------------------------------------------------------------------- /repo/cockroach/verification_code.go: -------------------------------------------------------------------------------- 1 | package cockroach 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | 8 | passwordless "github.com/nicolasparada/go-passwordless-demo" 9 | ) 10 | 11 | func (repo *Repository) StoreVerificationCode(ctx context.Context, email string) (passwordless.VerificationCode, error) { 12 | var vc passwordless.VerificationCode 13 | 14 | query := "INSERT INTO verification_codes (email) VALUES ($1) RETURNING code, created_at" 15 | row := repo.ext(ctx).QueryRowContext(ctx, query, email) 16 | err := row.Scan(&vc.Code, &vc.CreatedAt) 17 | if err != nil { 18 | return vc, fmt.Errorf("could not sql insert or scan verification code: %w", err) 19 | } 20 | 21 | return vc, nil 22 | } 23 | 24 | func (repo *Repository) VerificationCode(ctx context.Context, email, code string) (passwordless.VerificationCode, error) { 25 | var data passwordless.VerificationCode 26 | 27 | query := "SELECT created_at FROM verification_codes WHERE email = $1 AND code = $2" 28 | row := repo.ext(ctx).QueryRowContext(ctx, query, email, code) 29 | err := row.Scan(&data.CreatedAt) 30 | if err == sql.ErrNoRows { 31 | return data, passwordless.ErrVerificationCodeNotFound 32 | } 33 | 34 | if err != nil { 35 | return data, fmt.Errorf("could not sql query select or scan verification code: %w", err) 36 | } 37 | 38 | data.Email = email 39 | data.Code = code 40 | 41 | return data, nil 42 | } 43 | 44 | func (repo *Repository) DeleteVerificationCode(ctx context.Context, email, code string) (bool, error) { 45 | query := "DELETE FROM verification_codes WHERE email = $1 AND code = $2" 46 | result, err := repo.ext(ctx).ExecContext(ctx, query, email, code) 47 | if err != nil { 48 | return false, fmt.Errorf("could not sql delete verification code: %w", err) 49 | } 50 | 51 | ra, err := result.RowsAffected() 52 | if err != nil { 53 | return false, fmt.Errorf("could not sql count deleted verification code rows: %w", err) 54 | } 55 | 56 | return ra != 0, nil 57 | } 58 | -------------------------------------------------------------------------------- /transport/http/auth.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "net/url" 8 | "strings" 9 | "time" 10 | 11 | "github.com/nicolasparada/go-passwordless-demo" 12 | ) 13 | 14 | func (h *handler) withAuthUserID(next http.Handler) http.Handler { 15 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | auth := r.Header.Get("Authorization") 17 | if strings.HasPrefix(auth, "Bearer ") { 18 | authUserID, err := h.service.ParseAuthToken(auth[7:]) 19 | if err != nil { 20 | h.respondErr(w, err) 21 | return 22 | } 23 | 24 | ctx := r.Context() 25 | ctx = context.WithValue(ctx, passwordless.KeyAuthUserID, authUserID) 26 | r = r.WithContext(ctx) 27 | } 28 | 29 | next.ServeHTTP(w, r) 30 | }) 31 | } 32 | 33 | type sendMagicLinkReqBody struct { 34 | Email string 35 | RedirectURI string 36 | } 37 | 38 | func (h *handler) sendMagicLink(w http.ResponseWriter, r *http.Request) { 39 | if r.Method != http.MethodPost { 40 | w.Header().Set("Allow", "POST") 41 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 42 | return 43 | } 44 | 45 | defer r.Body.Close() 46 | 47 | var reqBody sendMagicLinkReqBody 48 | err := json.NewDecoder(r.Body).Decode(&reqBody) 49 | if err != nil { 50 | h.respondErr(w, errBadRequest) 51 | return 52 | } 53 | 54 | ctx := r.Context() 55 | err = h.service.SendMagicLink(ctx, reqBody.Email, reqBody.RedirectURI) 56 | if err != nil { 57 | h.respondErr(w, err) 58 | return 59 | } 60 | 61 | w.WriteHeader(http.StatusNoContent) 62 | } 63 | 64 | func (h *handler) verifyMagicLink(w http.ResponseWriter, r *http.Request) { 65 | if r.Method != http.MethodGet { 66 | w.Header().Set("Allow", "GET") 67 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 68 | return 69 | } 70 | 71 | q := r.URL.Query() 72 | redirectURI, err := h.service.ValidateRedirectURI(q.Get("redirect_uri")) 73 | if err != nil { 74 | h.respondErr(w, err) 75 | return 76 | } 77 | 78 | email := q.Get("email") 79 | code := q.Get("code") 80 | username := emptyStringPtr(strings.TrimSpace(q.Get("username"))) 81 | 82 | ctx := r.Context() 83 | auth, err := h.service.VerifyMagicLink(ctx, email, code, username) 84 | isRetryableError := err == passwordless.ErrUserNotFound || 85 | err == passwordless.ErrInvalidUsername || 86 | err == passwordless.ErrUsernameTaken 87 | if isRetryableError { 88 | h.redirectWithData(w, r, redirectURI, url.Values{ 89 | "error": []string{err.Error()}, 90 | "retry_uri": []string{r.RequestURI}, 91 | }) 92 | return 93 | } 94 | if err != nil { 95 | h.redirectWithErr(w, r, redirectURI, err) 96 | return 97 | } 98 | 99 | h.redirectWithData(w, r, redirectURI, url.Values{ 100 | "token": []string{auth.Token}, 101 | "expires_at": []string{auth.ExpiresAt.Format(time.RFC3339Nano)}, 102 | "user.id": []string{auth.User.ID}, 103 | "user.email": []string{auth.User.Email}, 104 | "user.username": []string{auth.User.Username}, 105 | }) 106 | } 107 | 108 | func (h *handler) authUser(w http.ResponseWriter, r *http.Request) { 109 | ctx := r.Context() 110 | u, err := h.service.AuthUser(ctx) 111 | if err != nil { 112 | h.respondErr(w, err) 113 | return 114 | } 115 | 116 | h.respond(w, u, http.StatusOK) 117 | } 118 | 119 | func emptyStringPtr(s string) *string { 120 | if s != "" { 121 | return &s 122 | } 123 | 124 | return nil 125 | } 126 | -------------------------------------------------------------------------------- /transport/http/handler.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "net/http" 10 | "net/url" 11 | "strings" 12 | 13 | passwordless "github.com/nicolasparada/go-passwordless-demo" 14 | "github.com/nicolasparada/go-passwordless-demo/transport" 15 | ) 16 | 17 | var errBadRequest = errors.New("bad request") 18 | 19 | func NewHandler(svc transport.Service, l *log.Logger) http.Handler { 20 | h := &handler{service: svc, logger: l} 21 | api := http.NewServeMux() 22 | api.HandleFunc("/api/send-magic-link", h.sendMagicLink) 23 | api.HandleFunc("/api/verify-magic-link", h.verifyMagicLink) 24 | api.HandleFunc("/api/auth-user", h.authUser) 25 | 26 | mux := http.NewServeMux() 27 | mux.Handle("/api/", h.withAuthUserID(api)) 28 | mux.Handle("/", h.staticHandler()) 29 | return mux 30 | } 31 | 32 | type handler struct { 33 | service transport.Service 34 | logger *log.Logger 35 | } 36 | 37 | func (h *handler) respond(w http.ResponseWriter, v interface{}, statusCode int) { 38 | b, err := json.Marshal(v) 39 | if err != nil { 40 | h.respondErr(w, fmt.Errorf("could not json marshall http response body: %w", err)) 41 | return 42 | } 43 | 44 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 45 | w.WriteHeader(statusCode) 46 | _, err = w.Write(b) 47 | if err != nil && !errors.Is(err, context.Canceled) { 48 | h.logger.Printf("could not write http response: %v\n", err) 49 | } 50 | } 51 | 52 | func (h *handler) respondErr(w http.ResponseWriter, err error) { 53 | statusCode := err2code(err) 54 | if statusCode != http.StatusInternalServerError { 55 | http.Error(w, err.Error(), statusCode) 56 | return 57 | } 58 | 59 | h.logger.Println(err) 60 | http.Error(w, "internal server error", statusCode) 61 | } 62 | 63 | func (h *handler) redirectWithErr(w http.ResponseWriter, r *http.Request, uri *url.URL, err error) { 64 | statusCode := err2code(err) 65 | if statusCode != http.StatusInternalServerError { 66 | h.redirectWithData(w, r, uri, url.Values{"error": []string{err.Error()}}) 67 | return 68 | } 69 | 70 | h.logger.Println(err) 71 | h.redirectWithData(w, r, uri, url.Values{"error": []string{"internal server error"}}) 72 | } 73 | 74 | func (h *handler) redirectWithData(w http.ResponseWriter, r *http.Request, uri *url.URL, data url.Values) { 75 | // Initially using query string instead of hash fragment 76 | // and replacing "?" by "#" later 77 | // because golang's RawFragment is a no-op. 78 | uri.RawQuery = data.Encode() 79 | location := uri.String() 80 | location = strings.Replace(location, "?", "#", 1) 81 | http.Redirect(w, r, location, http.StatusFound) 82 | } 83 | 84 | func err2code(err error) int { 85 | if err == nil { 86 | return http.StatusOK 87 | } 88 | 89 | switch err { 90 | case errBadRequest: 91 | return http.StatusBadRequest 92 | case passwordless.ErrInvalidEmail, 93 | passwordless.ErrInvalidRedirectURI, 94 | passwordless.ErrInvalidVerificationCode, 95 | passwordless.ErrInvalidUsername: 96 | return http.StatusUnprocessableEntity 97 | case passwordless.ErrUntrustedRedirectURI: 98 | return http.StatusForbidden 99 | case passwordless.ErrVerificationCodeNotFound, 100 | passwordless.ErrUserNotFound: 101 | return http.StatusNotFound 102 | case passwordless.ErrVerificationCodeExpired: 103 | return http.StatusUnauthorized 104 | case passwordless.ErrEmailTaken, 105 | passwordless.ErrUsernameTaken: 106 | return http.StatusConflict 107 | case passwordless.ErrUnauthenticated: 108 | return http.StatusUnauthorized 109 | } 110 | 111 | return http.StatusInternalServerError 112 | } 113 | -------------------------------------------------------------------------------- /transport/http/static.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "io/fs" 5 | "net/http" 6 | "os" 7 | 8 | "github.com/nicolasparada/go-passwordless-demo/web" 9 | ) 10 | 11 | func (h *handler) staticHandler() http.Handler { 12 | root, err := fs.Sub(web.Files, "static") 13 | if err != nil { 14 | h.logger.Printf("could not embed static files: %v\n", err) 15 | os.Exit(1) 16 | } 17 | 18 | return http.FileServer(&spaFileSystem{root: http.FS(root)}) 19 | } 20 | 21 | type spaFileSystem struct { 22 | root http.FileSystem 23 | } 24 | 25 | func (fs *spaFileSystem) Open(name string) (http.File, error) { 26 | f, err := fs.root.Open(name) 27 | if os.IsNotExist(err) { 28 | return fs.root.Open("index.html") 29 | } 30 | 31 | return f, err 32 | } 33 | -------------------------------------------------------------------------------- /transport/service.go: -------------------------------------------------------------------------------- 1 | package transport 2 | 3 | import ( 4 | "context" 5 | "net/url" 6 | 7 | passwordless "github.com/nicolasparada/go-passwordless-demo" 8 | ) 9 | 10 | type Service interface { 11 | SendMagicLink(ctx context.Context, email, redirectURI string) error 12 | ValidateRedirectURI(rawurl string) (*url.URL, error) 13 | VerifyMagicLink(ctx context.Context, email, code string, username *string) (passwordless.Auth, error) 14 | ParseAuthToken(token string) (userID string, err error) 15 | AuthUser(ctx context.Context) (passwordless.User, error) 16 | } 17 | -------------------------------------------------------------------------------- /web/files.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import "embed" 4 | 5 | //go:embed * 6 | var Files embed.FS 7 | -------------------------------------------------------------------------------- /web/static/auth.js: -------------------------------------------------------------------------------- 1 | export function loginCallback() { 2 | const data = new URLSearchParams(location.hash.substring(1)) 3 | if (data.has("error")) { 4 | const errMsg = decodeURIComponent(data.get("error")) 5 | alert(errMsg) 6 | 7 | if (!data.has("retry_uri")) { 8 | location.assign("/") 9 | return 10 | } 11 | 12 | if (errMsg === "user not found") { 13 | const ok = confirm("do you want to create a new account?") 14 | if (!ok) { 15 | location.assign("/") 16 | return 17 | } 18 | } 19 | 20 | 21 | const username = prompt("Username") 22 | if (username === null) { 23 | location.assign("/") 24 | return 25 | } 26 | 27 | const retryURI = new URL(decodeURIComponent(data.get("retry_uri")), location.origin) 28 | retryURI.searchParams.set("username", username) 29 | location.replace(retryURI.toString()) 30 | return 31 | } 32 | 33 | if (["token", "expires_at", "user.id", "user.email", "user.username"].every(k => data.has(k))) { 34 | setLocalAuth(data) 35 | location.replace("/") 36 | return 37 | } 38 | 39 | location.assign("/") 40 | } 41 | 42 | /** 43 | * @param {URLSearchParams} data 44 | */ 45 | export function setLocalAuth(data) { 46 | localStorage.setItem("auth", JSON.stringify({ 47 | user: { 48 | id: decodeURIComponent(data.get("user.id")), 49 | email: decodeURIComponent(data.get("user.email")), 50 | username: decodeURIComponent(data.get("user.username")), 51 | }, 52 | token: decodeURIComponent(data.get("token")), 53 | expiresAt: decodeURIComponent(data.get("expires_at")), 54 | })) 55 | } 56 | 57 | /** 58 | * @typedef {object} User 59 | * @prop {string} id 60 | * @prop {string} email 61 | * @prop {string} username 62 | * 63 | * @typedef {object} Auth 64 | * @prop {User} user 65 | * @prop {string} token 66 | * @prop {Date} expiresAt 67 | * 68 | * @returns {Auth|null} 69 | */ 70 | export function getLocalAuth() { 71 | const authItem = localStorage.getItem("auth") 72 | if (authItem === null) { 73 | null 74 | } 75 | 76 | try { 77 | const auth = JSON.parse(authItem) 78 | if (typeof auth !== "object" 79 | || auth === null 80 | || typeof auth.token !== "string" 81 | || typeof auth.expiresAt !== "string") { 82 | return null 83 | } 84 | 85 | auth.expiresAt = new Date(auth.expiresAt) 86 | if (isNaN(auth.expiresAt.valueOf()) || auth.expiresAt < new Date()) { 87 | return null 88 | } 89 | 90 | const user = auth["user"] 91 | if (typeof user !== "object" 92 | || user === null 93 | || typeof user.id !== "string" 94 | || typeof user.email !== "string" 95 | || typeof user.username !== "string") { 96 | return null 97 | } 98 | 99 | return auth 100 | } catch (_) { } 101 | 102 | return null 103 | } 104 | -------------------------------------------------------------------------------- /web/static/authenticated-view.js: -------------------------------------------------------------------------------- 1 | import { parseResponse } from "./http.js" 2 | 3 | const tmpl = document.createElement("template") 4 | tmpl.innerHTML = ` 5 |
6 |

Welcome

7 |

Logged-in as 😉

8 |
9 | 10 |
11 | ` 12 | 13 | /** 14 | * @param {import("./auth.js").Auth} auth 15 | */ 16 | export function authenticatedView(auth) { 17 | const view = /** @type {DocumentFragment} */ (tmpl.content.cloneNode(true)) 18 | view.querySelector("[data-ref=username]").textContent = auth.user.username 19 | view.querySelector("#logout-btn").addEventListener("click", onLogoutBtnClick) 20 | 21 | setTimeout(() => { 22 | fetchAuthUser(auth.token).then(authUser => { 23 | console.log(authUser) 24 | }).catch(err => { 25 | console.error(err) 26 | }) 27 | }) 28 | 29 | return view 30 | } 31 | 32 | /** 33 | * @param {Event} ev 34 | */ 35 | function onLogoutBtnClick(ev) { 36 | const btn = /** @type {HTMLButtonElement} */ (ev.currentTarget) 37 | btn.disabled = true 38 | localStorage.removeItem("auth") 39 | location.replace("/") 40 | } 41 | 42 | /** 43 | * @param {string} token 44 | * @returns {Promise} 45 | */ 46 | function fetchAuthUser(token) { 47 | return fetch("/api/auth-user", { 48 | method: "GET", 49 | headers: { 50 | "authorization": "Bearer " + token, 51 | }, 52 | }).then(parseResponse) 53 | } 54 | -------------------------------------------------------------------------------- /web/static/guest-view.js: -------------------------------------------------------------------------------- 1 | import { parseResponse } from "./http.js" 2 | 3 | const tmpl = document.createElement("template") 4 | tmpl.innerHTML = ` 5 |
6 |

Login

7 |
8 |
9 | 10 | 11 |
12 | 13 |
14 |
15 | ` 16 | 17 | export function guestView() { 18 | const view = /** @type {DocumentFragment} */ (tmpl.content.cloneNode(true)) 19 | view.querySelector("[name=login-form]").addEventListener("submit", onLoginFormSubmit) 20 | return view 21 | } 22 | /** 23 | * @param {Event} ev 24 | */ 25 | function onLoginFormSubmit(ev) { 26 | ev.preventDefault() 27 | 28 | const form = /** @type {HTMLFormElement} */ (ev.currentTarget) 29 | const input = form.querySelector("input") 30 | const button = form.querySelector("button") 31 | 32 | const email = input.value 33 | 34 | input.disabled = true 35 | button.disabled = true 36 | 37 | sendMagicLink(email).then(() => { 38 | alert("Magic link sent. Go check your inbox to login") 39 | }).catch(err => { 40 | console.error(err) 41 | alert(err.message) 42 | }).finally(() => { 43 | input.disabled = false 44 | button.disabled = false 45 | }) 46 | } 47 | 48 | /** 49 | * @param {string} email 50 | * @param {string=} redirectURI 51 | * @returns {Promise} 52 | */ 53 | function sendMagicLink(email, redirectURI = location.origin + "/login-callback") { 54 | return fetch("/api/send-magic-link", { 55 | method: "POST", 56 | headers: { 57 | "content-type": "application/json; charset=utf-8", 58 | }, 59 | body: JSON.stringify({ email, redirectURI }), 60 | }).then(parseResponse) 61 | } 62 | -------------------------------------------------------------------------------- /web/static/http.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {Response} resp 3 | */ 4 | export function parseResponse(resp) { 5 | return resp.clone().json().catch(() => resp.text()).then(body => { 6 | if (!resp.ok) { 7 | const msg = typeof body === "string" && body !== "" ? body : resp.statusText 8 | const err = new Error(msg) 9 | return Promise.reject(err) 10 | } 11 | 12 | return body 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /web/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Passwordless 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /web/static/main.js: -------------------------------------------------------------------------------- 1 | import { getLocalAuth, loginCallback } from "./auth.js" 2 | 3 | void async function main() { 4 | if (location.pathname === "/login-callback") { 5 | loginCallback() 6 | return 7 | } 8 | 9 | const auth = getLocalAuth() 10 | if (auth === null) { 11 | import("./guest-view.js").then(m => { 12 | update(m.guestView()) 13 | }) 14 | return 15 | } 16 | 17 | import("./authenticated-view.js").then(m => { 18 | update(m.authenticatedView(auth)) 19 | }) 20 | }() 21 | 22 | /** 23 | * @param {Node} node 24 | * @param {Node} target 25 | */ 26 | function update(node, target = document.body) { 27 | while (target.firstChild !== null) { 28 | target.removeChild(target.lastChild) 29 | } 30 | target.appendChild(node) 31 | } 32 | -------------------------------------------------------------------------------- /web/static/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | box-sizing: border-box; 3 | } 4 | 5 | *, 6 | ::before, 7 | ::after { 8 | box-sizing: inherit; 9 | } 10 | 11 | body { 12 | margin: 0; 13 | background-color: black; 14 | color: white; 15 | font-family: sans-serif; 16 | } 17 | 18 | .container { 19 | width: calc(100% - 2rem); 20 | max-width: 65ch; 21 | margin: 0 auto; 22 | } 23 | 24 | main { 25 | margin-top: 2rem; 26 | margin-bottom: 2rem; 27 | } 28 | 29 | :focus:not(:focus-visible) { 30 | outline: none; 31 | } 32 | 33 | input { 34 | font: inherit; 35 | color: inherit; 36 | padding: 1rem; 37 | background-color: hsl(0, 0%, 4%); 38 | border: .0625rem solid hsl(0, 0%, 17%); 39 | width: 100%; 40 | } 41 | 42 | input:hover { 43 | border-color: hsl(0, 0%, 34%); 44 | } 45 | 46 | label { 47 | display: block; 48 | margin-bottom: .25rem; 49 | user-select: none; 50 | touch-action: manipulation; 51 | font-size: .8rem; 52 | } 53 | 54 | button { 55 | font: inherit; 56 | color: inherit; 57 | padding: 1rem 2rem; 58 | background-color: hsl(0, 0%, 4%); 59 | border: .0625rem solid hsl(0, 0%, 17%); 60 | touch-action: manipulation; 61 | user-select: none; 62 | } 63 | 64 | button:hover { 65 | border-color: hsl(0, 0%, 34%); 66 | } 67 | 68 | button:active { 69 | background-color: black; 70 | border-color: white; 71 | } 72 | 73 | .btn-grp { 74 | margin-bottom: 1rem; 75 | } 76 | 77 | form { 78 | max-width: 40ch; 79 | } 80 | -------------------------------------------------------------------------------- /web/template/mail/magic-link.html.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Login to Golang Passwordless Demo 7 | 8 | 49 | 50 | 51 |
52 |

Golang Passwordless Demo

53 |

Click the link down below to login to {{ .Origin.Hostname }}.

54 |

This link expires in {{ human_duration .TTL }}.

55 | Login 56 |
57 | 58 | 59 | -------------------------------------------------------------------------------- /web/template/mail/magic-link.txt.tmpl: -------------------------------------------------------------------------------- 1 | # Golang Passwordless Demo 2 | 3 | Open the link down below to login to {{ .Origin.Hostname }}. 4 | This link expires in {{ human_duration .TTL }}. 5 | 6 | {{ .MagicLink }} 7 | --------------------------------------------------------------------------------