├── .gitignore
├── web
├── files.go
├── template
│ └── mail
│ │ ├── magic-link.txt.tmpl
│ │ └── magic-link.html.tmpl
└── static
│ ├── http.js
│ ├── index.html
│ ├── main.js
│ ├── styles.css
│ ├── authenticated-view.js
│ ├── guest-view.js
│ └── auth.js
├── repo
└── cockroach
│ ├── migrations
│ ├── schema.go
│ ├── 000_schema.sql
│ └── migrate.sh
│ ├── repo.go
│ ├── verification_code.go
│ └── user.go
├── notification
├── compose.go
├── magic_link.go
└── smtp
│ ├── sender.go
│ └── magic_link.go
├── jsconfig.json
├── transport
├── service.go
└── http
│ ├── static.go
│ ├── auth.go
│ └── handler.go
├── README.md
├── go.mod
├── go.sum
├── cmd
└── passwordless
│ └── main.go
└── passwordless.go
/.gitignore:
--------------------------------------------------------------------------------
1 | /.env
2 | /cockroach-data
3 | /passwordless
4 |
--------------------------------------------------------------------------------
/web/files.go:
--------------------------------------------------------------------------------
1 | package web
2 |
3 | import "embed"
4 |
5 | //go:embed *
6 | var Files embed.FS
7 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "checkJs": true,
4 | "module": "esnext",
5 | "target": "esnext"
6 | },
7 | "include": [
8 | "web/static"
9 | ],
10 | }
11 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/web/static/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Passwordless
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------