├── .gitignore ├── GUIDE.md ├── LICENSE ├── README.md ├── appengine ├── store_memcache.go ├── transport_mail.go └── transport_xmpp.go ├── context.go ├── context_test.go ├── doc.go ├── example ├── account.go ├── main.go ├── templates │ ├── about.html │ ├── error.html │ ├── flashes.html │ ├── footer.html │ ├── header.html │ ├── index.html │ ├── secret.html │ ├── signin.html │ └── token.html └── utils.go ├── go.mod ├── go.sum ├── passwordless.go ├── passwordless_test.go ├── store.go ├── store_mem.go ├── store_mem_test.go ├── store_redis.go ├── store_redis_test.go ├── store_session.go ├── store_session_test.go ├── tokens.go ├── tokens_test.go ├── transport.go ├── transport_smtp.go └── transport_smtp_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | # Test DBs 27 | *.db 28 | -------------------------------------------------------------------------------- /GUIDE.md: -------------------------------------------------------------------------------- 1 | # go-passwordless Guide 2 | 3 | ## Overview 4 | 5 | The Passwordless flow is based on a similar principle to one-time passwords. It goes something like the following: 6 | 7 | 1. Your site prompts user for authentication method (e.g. sms, email, drone...). 8 | 2. User enters a 'recipient' string (e.g. telephone number, email address, lat/lon...) 9 | 3. A secure token/PIN is generated, stored, and sent to the recipient. 10 | 5. When the user receives the token, they enter it back into your site. 11 | 6. The token entered is checked against the one stored; if it's the same, the user is granted access. 12 | 13 | This implementation of Passwordless provides patterns and implementations for the backend services to implement this flow. Presentation, storage and user management are left to you. 14 | 15 | ## Getting Started 16 | 17 | ### 1. Get and import 18 | Install the library with `go get`: 19 | 20 | $ go get github.com/johnsto/go-passwordless/v2 21 | 22 | The base library includes implementations for both memory and cookie-based token stores (`MemStore` and `CookieStore`, respectively), as well as an email transport (`SMTPTransport`) and token generators (`PINGenerator` and `CrockfordGenerator`). 23 | 24 | Import the library thus: 25 | 26 | import "github.com/johnsto/go-passwordless/v2" 27 | 28 | This will import the base functionality under the `passwordless` namespace. 29 | 30 | ### 2. Configure 31 | Create an instance of Passwordless with your chosen token store. In this case, `MemStore` will hold tokens in memory until they expire. 32 | 33 | pw = passwordless.New(passwordless.NewMemStore()) 34 | 35 | > If you have different storage requirements, the `Store` interface is very simple and can be used to provide a custom implementation. 36 | 37 | Then add a transport strategy that describes how to send a token to the user. In this case we're using the `LogTransport` which simply writes the token to the console for testing purposes. It will be registered under the name "log". 38 | 39 | pw.SetTransport("log", passwordless.LogTransport{ 40 | MessageFunc: func(token, uid string) string { 41 | return fmt.Sprintf("Your PIN is %s", token) 42 | }, 43 | }, passwordless.NewCrockfordGenerator(8), 30*time.Minute) 44 | 45 | A production system might want to let a user authenticate via email and SMS, whereby the code might look like this instead: 46 | 47 | pw.SetTransport("email", emailTransport, passwordless.NewCrockfordGenerator(32), 30*time.Minute) 48 | pw.SetTransport("sms", smsTransport, passwordless.NewPINGenerator(8), 30*time.Minute) 49 | 50 | Each transport must specify a generator for tokens, and how long generated tokens will remain valid for. Different transports might suit different generators - for example, when using SMS, you might want to keep the token relatively short to make sign in easier. For email however, the user is likely to be emailed a link they just have to click on and therefore the token can be much longer. Of course, the longer a token is, the harder it is to guess, and therefore is more resilient to brute-force attacks. 51 | 52 | > The `CrockfordGenerator` used here is a token generator that produces random strings using [Douglas Crockford's 32-character dictionary](https://en.wikipedia.org/wiki/Base32#Crockford.27s_Base32), and is ideal in cases where human transcription errors can occur. A `Sanitize` function converts user input back into the correct alphabet and case such that token verification can occur. 53 | > 54 | > Creating a custom token generator is as simple as implementing the `TokenGenerator` interface, which consists of just two functions. 55 | 56 | ### 3. Route 57 | There are typically two routes requires to sign-in: 58 | 59 | * **/signin** - lets the user choose and enter a means to contact them (e.g. an email address) 60 | * **/token** - allows the user to enter the code they have received, and verifies it. Also used as a link included in emails to automatically verify a provided token. 61 | 62 | The example application names these two routes `/account/signin` and `/account/token`, but the library does not mandate any particular naming scheme. 63 | 64 | This library does _not_ provide implementations for these routes (besides the examples), as every site has slightly different requirements. 65 | 66 | ### 3.1 Signin endpoint 67 | The only call this route makes to Passwordless is to `passwordless.ListTransports`, which will return a list strategies to display to the user. 68 | 69 | The page can then display a form whereby the user can enter their email address. If you have multiple auth methods - for example email and SMS - it presents two forms, and the user can choose the one they prefer. The form POST's to the token endpoint, below. 70 | 71 | ### 3.2 Token endpoint 72 | This route can both generates and verifies tokens, depending on whether the request contains a token to verify. 73 | 74 | In the 'generate' case (i.e. when a token is not provided in the request, as is the case when coming from the signin endpoint), the code must call `passwordless.RequestToken` with the appropriate form values provided the 'signin' route - namely the delivery strategy (e.g. `"email"`, a recipient value entered by the user from the 'signin' page (e.g. an email address), and a user ID string for the recipient (e.g. a UUID or database ID, depending on your backend.) 75 | 76 | strategy := r.FormValue("strategy") 77 | recipient := r.FormValue("recipient") 78 | user := Users.Find(recipient) 79 | err := pw.RequestToken(ctx, strategy, user.ID, recipient) 80 | 81 | > Typically the email will contain a link directly to the /token endpoint containing the token, so one click is all it needs for the user to be signed in. 82 | 83 | The page should inform the user that a token has been generated and sent to their specified address, and display a form that the user can enter the token into. 84 | 85 | When the user enters their token, it can POST back onto itself, this time containing the entered token and the user's UID. The token can then be verified: 86 | 87 | token := r.FormValue("token") 88 | uid := r.FormValue("uid") 89 | valid, err := pw.VerifyToken(ctx, uid, token) 90 | 91 | If `valid` is `true`, the user can be considered authenticated and the login process is complete. At this point, you may want to set a secure session cookie to keep the user logged in. 92 | 93 | > The lower the cardinality of the generated token, the more susceptible the token endpoint is to brute-force guessing. It is advisable to use a rate-limiting handler like [gopkg.in/throttled/throttled.v2](gopkg.in/throttled/throttled.v2) to limit the number of requests clients can make. Throttling is also advisable to prevent the spamming of recipients with tokens. 94 | 95 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Dave Johnston 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | go-passwordless 2 | =============== 3 | 4 | **`go-passwordless` is an implementation of backend services allowing users to sign in to websites without a password, inspired by the [Node package of the same name](passwordless.net).** 5 | 6 | ## Overview 7 | The passwordless flow is very similar to the one-time-password (OTP) flow used for verification on many services. It works on the principle that if someone can prove ownership of an account such as an email address, then that is sufficient to prove they are that user. So, rather than storing passwords, the user is simply required to enter a secure code that is sent to their account when they want to log in (be it email, SMS, a Twitter DM, or some other means.) 8 | 9 | This implementation concerns itself with generating codes, sending them to the user, storing them securely, and offering a means to verify the provided token. 10 | 11 | ## Transports 12 | A Transport provides a means to transmit a token (e.g. a PIN) to the user. There is one production implementation and one development implementation provided with this library: 13 | 14 | * *SMTPTransport* - emails tokens via an SMTP server. 15 | * *LogTransport* - prints tokens to stdout, for testing purposes only. 16 | 17 | Custom transports must adhere to the `Transport` interface, which consists of just one function, making it easy to hook into third-party services (for example, your SMS provider.) 18 | 19 | ## Token Stores 20 | A Token Store provides a mean to securely store and verify a token against user input. There are three implementations provided with this library: 21 | 22 | * *MemStore* - stores encrypted tokens in ephemeral memory. 23 | * *CookieStore* - stores tokens in encrypted session cookies. Mandates that the user signs in on the same device that they generated the sign in request from. 24 | * *RedisStore* - stores encrypted tokens in a Redis instance. 25 | 26 | Custom stores need to adhere to the *TokenStore* interface, which consists of 4 functions. This interface is intentionally simple to allow for easy integration with whatever database and structure you prefer. 27 | 28 | ## Differences to Node's Passwordless 29 | While heavily inspired by [Passwordless](passwordless.net), this implementation is unique and cannot be used interchangeably. The token generation, storage and verification procedures are all different. 30 | 31 | This library does not provide a frontend/UI implementation - to integrate it, you'll need to create your own signin/verification pages and handlers. An example website is provided as reference, however. 32 | -------------------------------------------------------------------------------- /appengine/store_memcache.go: -------------------------------------------------------------------------------- 1 | package appengine 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/johnsto/go-passwordless/v2" 7 | 8 | "context" 9 | 10 | "github.com/gyepisam/mcf" 11 | _ "github.com/gyepisam/mcf/scrypt" 12 | "google.golang.org/appengine/memcache" 13 | ) 14 | 15 | type MemcacheStore struct { 16 | KeyPrefix string 17 | } 18 | 19 | type item struct { 20 | hashToken string `json:"token"` 21 | expiresAt time.Time `json:"expires_at"` 22 | } 23 | 24 | func (s MemcacheStore) Store(ctx context.Context, token, uid string, ttl time.Duration) error { 25 | hashToken, err := mcf.Create(token) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | expiresAt := time.Now().Add(ttl) 31 | return memcache.JSON.Set(ctx, &memcache.Item{ 32 | Key: s.KeyPrefix + uid, 33 | Object: item{hashToken, expiresAt}, 34 | Expiration: ttl, 35 | }) 36 | } 37 | 38 | // Exists returns true if a token for the specified user exists. 39 | func (s MemcacheStore) Exists(ctx context.Context, uid string) (bool, time.Time, error) { 40 | v := item{} 41 | _, err := memcache.JSON.Get(ctx, s.KeyPrefix+uid, &v) 42 | if err == memcache.ErrCacheMiss { 43 | // No known token for this user 44 | return false, time.Time{}, nil 45 | } else { 46 | // Token exists and is still valid 47 | return true, v.expiresAt, nil 48 | } 49 | } 50 | 51 | func (s MemcacheStore) Verify(ctx context.Context, token, uid string) (bool, error) { 52 | v := item{} 53 | _, err := memcache.JSON.Get(ctx, s.KeyPrefix+uid, &v) 54 | if err == memcache.ErrCacheMiss { 55 | // No token in database 56 | return false, passwordless.ErrTokenNotFound 57 | } else if err != nil { 58 | return false, err 59 | } 60 | 61 | if time.Now().After(v.expiresAt) { 62 | // Token has actually expired (even if still present in memcache) 63 | return false, passwordless.ErrTokenNotFound 64 | } else if valid, err := mcf.Verify(token, v.hashToken); err != nil { 65 | // Couldn't validate token 66 | return false, err 67 | } else if !valid { 68 | // Token does not validate against hashed token 69 | return false, nil 70 | } else { 71 | // Token is valid! 72 | return true, nil 73 | } 74 | } 75 | 76 | func (s MemcacheStore) Delete(ctx context.Context, uid string) error { 77 | return memcache.Delete(ctx, s.KeyPrefix+uid) 78 | } 79 | -------------------------------------------------------------------------------- /appengine/transport_mail.go: -------------------------------------------------------------------------------- 1 | package appengine 2 | 3 | import ( 4 | "context" 5 | "google.golang.org/appengine/mail" 6 | ) 7 | 8 | // MailTransport sends token messages via the mail service. 9 | type MailTransport struct { 10 | // MessageFunc should return a `mail.Message` for the given recipient and 11 | // token. 12 | MessageFunc func(ctx context.Context, token, user, recipient string) (*mail.Message, error) 13 | } 14 | 15 | // Send sends an XMPP message to the specified recipient. 16 | func (t MailTransport) Send(ctx context.Context, token, user, recipient string) error { 17 | if msg, err := t.MessageFunc(ctx, token, user, recipient); err != nil { 18 | return nil 19 | } else { 20 | return mail.Send(ctx, msg) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /appengine/transport_xmpp.go: -------------------------------------------------------------------------------- 1 | package appengine 2 | 3 | import ( 4 | "context" 5 | "google.golang.org/appengine/xmpp" 6 | ) 7 | 8 | // XMPPTransport sends tokens via the XMPP service. 9 | type XMPPTransport struct { 10 | MessageFunc func(ctx context.Context, token, user, recipient string) (*xmpp.Message, error) 11 | } 12 | 13 | // Send sends an XMPP message to the specified recipient. 14 | func (t XMPPTransport) Send(ctx context.Context, token, user, recipient string) error { 15 | if msg, err := t.MessageFunc(ctx, token, user, recipient); err != nil { 16 | return nil 17 | } else { 18 | return msg.Send(ctx) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package passwordless 2 | 3 | import ( 4 | "net/http" 5 | 6 | "context" 7 | ) 8 | 9 | type ctxKey int 10 | 11 | const ( 12 | reqKey ctxKey = 1 13 | rwKey ctxKey = 2 14 | ) 15 | 16 | // SetContext returns a Context containing the specified `ResponseWriter` and 17 | // `Request`. If a nil Context is provided, a new one is returned. 18 | func SetContext(ctx context.Context, rw http.ResponseWriter, r *http.Request) context.Context { 19 | if ctx == nil { 20 | ctx = context.Background() 21 | } 22 | ctx = context.WithValue(ctx, reqKey, r) 23 | ctx = context.WithValue(ctx, rwKey, rw) 24 | return ctx 25 | } 26 | 27 | // fromContext extracts a `ResponseWriter` and `Request` from the Context, 28 | // assuming that `SetContext` was called previously to populate it. 29 | func fromContext(ctx context.Context) (http.ResponseWriter, *http.Request) { 30 | var rw http.ResponseWriter = nil 31 | var req *http.Request = nil 32 | if ctx != nil { 33 | if v, ok := ctx.Value(rwKey).(http.ResponseWriter); ok { 34 | rw = v 35 | } 36 | if v, ok := ctx.Value(reqKey).(*http.Request); ok { 37 | req = v 38 | } 39 | } 40 | return rw, req 41 | } 42 | -------------------------------------------------------------------------------- /context_test.go: -------------------------------------------------------------------------------- 1 | package passwordless 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "context" 10 | ) 11 | 12 | type ctxTestKey int 13 | 14 | const ( 15 | testKey ctxTestKey = -1 16 | ) 17 | 18 | func TestContext(t *testing.T) { 19 | assert.NotNil(t, SetContext(nil, nil, nil)) 20 | 21 | ctx := context.Background() 22 | ctx = context.WithValue(ctx, testKey, "hello") 23 | rw := httptest.NewRecorder() 24 | req := &http.Request{} 25 | 26 | ctx = SetContext(ctx, rw, req) 27 | 28 | assert.NotNil(t, ctx) 29 | rw2, req2 := fromContext(ctx) 30 | assert.Equal(t, rw, rw2) 31 | assert.Equal(t, req, req2) 32 | assert.Equal(t, "hello", ctx.Value(testKey)) 33 | } 34 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | `go-passwordless` is an implementation of backend services allowing users to sign in to websites without a password, inspired by the [Node package of the same name](passwordless.net). 3 | 4 | Install the library with `go get`: 5 | 6 | $ go get github.com/johnsto/go-passwordless/v2 7 | 8 | Import the library into your project: 9 | 10 | import "github.com/johnsto/go-passwordless/v2" 11 | 12 | Create an instance of Passwordless with your chosen token store. In this case, `MemStore` will hold tokens in memory until they expire. 13 | 14 | pw = passwordless.New(passwordless.NewMemStore()) 15 | 16 | Then add a transport strategy that describes how to send a token to the user. In this case we're using the `LogTransport` which simply writes the token to the console for testing purposes. It will be registered under the name "log". 17 | 18 | pw.SetTransport("log", passwordless.LogTransport{ 19 | MessageFunc: func(token, uid string) string { 20 | return fmt.Sprintf("Your PIN is %s", token) 21 | }, 22 | }, passwordless.NewCrockfordGenerator(8), 30*time.Minute) 23 | 24 | When the user wants to sign in, get a list of valid transports with `passwordless.ListTransports`, and display an appropriate form to the user. You can then send a token to the user: 25 | 26 | strategy := r.FormValue("strategy") 27 | recipient := r.FormValue("recipient") 28 | user := Users.Find(recipient) 29 | err := pw.RequestToken(ctx, strategy, user.ID, recipient) 30 | 31 | Then prompt the user to enter the token they received: 32 | 33 | token := r.FormValue("token") 34 | uid := r.FormValue("uid") 35 | valid, err := pw.VerifyToken(ctx, uid, token) 36 | 37 | If `valid` is `true`, the user can be considered authenticated and the login process is complete. At this point, you may want to set a secure session cookie to keep the user logged in. 38 | 39 | A complete implementation can be found in the "example" directory. 40 | 41 | */ 42 | package passwordless 43 | -------------------------------------------------------------------------------- /example/account.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/johnsto/go-passwordless/v2" 8 | ) 9 | 10 | // signinHandler prompts the user to choose a method by which to send them 11 | // a token. 12 | func signinHandler(w http.ResponseWriter, r *http.Request) { 13 | if session, err := getSession(w, r); err == nil { 14 | if isSignedIn(session) { 15 | session.AddFlash("already_signed_in") 16 | session.Save(r, w) 17 | redirect(w, r, "/", baseURL) 18 | return 19 | } 20 | 21 | if err := tmpl.ExecuteTemplate(w, "signin", struct { 22 | Strategies map[string]passwordless.Strategy 23 | Context *Context 24 | Next string 25 | }{ 26 | Strategies: pw.ListStrategies(nil), 27 | Context: getTemplateContext(w, r, session), 28 | Next: r.FormValue("next"), 29 | }); err != nil { 30 | log.Println(err) 31 | } 32 | } 33 | } 34 | 35 | // tokenHandler has two roles. Firstly, it allows the user to input the token 36 | // they have received via their chosen method. Secondly, it verifies the 37 | // token they input, and redirects them appropriately on success. On failure, 38 | // the user is prompted to try again. 39 | func tokenHandler(w http.ResponseWriter, r *http.Request) { 40 | session, err := getSession(w, r) 41 | if err != nil { 42 | log.Println(err) 43 | return 44 | } 45 | 46 | if isSignedIn(session) { 47 | session.AddFlash("already_signed_in") 48 | session.Save(r, w) 49 | redirect(w, r, r.FormValue("next"), baseURL) 50 | return 51 | } 52 | 53 | // Create a context (required by CookieStore token store) 54 | ctx := passwordless.SetContext(nil, w, r) 55 | 56 | strategy := r.FormValue("strategy") 57 | recipient := r.FormValue("recipient") 58 | uid := r.FormValue("uid") 59 | 60 | // token is only set if the user is trying to verify a token they've got 61 | token := r.FormValue("token") 62 | 63 | // tokenError will be set if the user enters a bad token. 64 | tokenError := "" 65 | 66 | if uid == "" { 67 | // Lookup user ID. We just use the recipient value in this demo, 68 | // but typically you'd perform a database query here. 69 | uid = recipient 70 | } 71 | 72 | log.Println("strategy=", strategy, "recipient=", recipient, "uid=", uid, "token=", token) 73 | 74 | if strategy == "" { 75 | // No strategy specified in request, so send the user back to 76 | // the signin page as we can't do anything without it. 77 | session.AddFlash("token_not_found") 78 | session.Save(r, w) 79 | http.Redirect(w, r, "/account/signin", http.StatusTemporaryRedirect) 80 | return 81 | } else if token == "" { 82 | // No token provided in request, so generate a new one and send it 83 | // to the user via their preferred transport strategy. 84 | err := pw.RequestToken(ctx, strategy, uid, recipient) 85 | 86 | if err != nil { 87 | writeError(w, r, session, http.StatusInternalServerError, Error{ 88 | Name: "Internal Error", 89 | Description: err.Error(), 90 | Error: err, 91 | }) 92 | return 93 | } 94 | } else { 95 | // User has provided a token, verify it against provided uid. 96 | valid, err := pw.VerifyToken(ctx, uid, token) 97 | 98 | if valid { 99 | // User provided a valid token! We can safely use the uid as it 100 | // is validated alongside the token. 101 | session.Values["uid"] = uid 102 | session.AddFlash("signed_in") 103 | session.Save(r, w) 104 | redirect(w, r, r.FormValue("next"), baseURL) 105 | return 106 | } 107 | 108 | if err == passwordless.ErrTokenNotFound { 109 | // Token not found, maybe it was a previous one or expired. Either 110 | // way, the user will need to attempt sign-in again. 111 | session.AddFlash("token_not_found") 112 | session.Save(r, w) 113 | http.Redirect(w, r, "/account/signin", http.StatusTemporaryRedirect) 114 | return 115 | } else if err != nil { 116 | // Some other unexpected error occurred. 117 | writeError(w, r, session, http.StatusInternalServerError, Error{ 118 | Name: "Failed verifying token", 119 | Description: err.Error(), 120 | Error: err, 121 | }) 122 | return 123 | } else { 124 | // User entered bad token. Set token error string then fall 125 | // through to template. 126 | w.WriteHeader(http.StatusForbidden) 127 | tokenError = "The entered token/PIN was incorrect." 128 | } 129 | } 130 | 131 | // If we've got to this point, the user is being prompted to enter a 132 | // valid token value. 133 | if err := tmpl.ExecuteTemplate(w, "token", struct { 134 | Context *Context 135 | Strategy string 136 | Recipient string 137 | UserID string 138 | Next string 139 | TokenError string 140 | }{ 141 | Strategy: strategy, 142 | Recipient: recipient, 143 | UserID: uid, 144 | Context: getTemplateContext(w, r, session), 145 | Next: r.FormValue("next"), 146 | TokenError: tokenError, 147 | }); err != nil { 148 | log.Printf("couldn't render template: %v", err) 149 | } 150 | } 151 | 152 | func signoutHandler(w http.ResponseWriter, r *http.Request) { 153 | session, err := getSession(w, r) 154 | if err != nil { 155 | return 156 | } 157 | 158 | // Remove secure session cookie 159 | delete(session.Values, "uid") 160 | session.AddFlash("signed_out") 161 | session.Save(r, w) 162 | 163 | redirect(w, r, r.FormValue("next"), baseURL) 164 | } 165 | -------------------------------------------------------------------------------- /example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "io" 7 | "log" 8 | "net/http" 9 | "net/smtp" 10 | "net/url" 11 | "os" 12 | "time" 13 | 14 | "context" 15 | 16 | "github.com/gorilla/securecookie" 17 | "github.com/gorilla/sessions" 18 | "github.com/johnsto/go-passwordless/v2" 19 | "gopkg.in/throttled/throttled.v2" 20 | "gopkg.in/throttled/throttled.v2/store/memstore" 21 | ) 22 | 23 | const SesssionKey string = "go-passwordless-example" 24 | 25 | var pw *passwordless.Passwordless 26 | 27 | var ( 28 | tmpl *template.Template 29 | store sessions.Store 30 | // baseURL should contain the root URL of the web server 31 | baseURL string 32 | ) 33 | 34 | func main() { 35 | var err error 36 | 37 | // Read templates 38 | tmpl, err = template.ParseGlob("templates/*.html") 39 | if err != nil { 40 | log.Fatalln("couldn't load templates:", err) 41 | } 42 | 43 | // Determine base URL 44 | baseURL = os.Getenv("PWL_BASE_URL") 45 | if baseURL == "" { 46 | baseURL = "http://localhost:8080" 47 | log.Printf("PWL_BASE_URL not defined; using %s", baseURL) 48 | } 49 | 50 | // Initialise cookie store, to store user credentials once they've 51 | // signed in through Passwordless. 52 | cookieKey := []byte(os.Getenv("PWL_KEY_COOKIE_STORE")) 53 | if len(cookieKey) == 0 { 54 | log.Println("PWL_KEY_COOKIE_STORE not defined; using random key") 55 | cookieKey = securecookie.GenerateRandomKey(16) 56 | } 57 | store = sessions.NewCookieStore(cookieKey) 58 | 59 | // Init Passwordless with ephemeral memory store that will hold tokens 60 | // util they're used (or expire) 61 | tokStore := passwordless.NewMemStore() 62 | pw = passwordless.New(tokStore) 63 | 64 | // Add Passwordless email transport using SMTP credentials from env 65 | if fromAddr := os.Getenv("PWL_EMAIL_ADDR"); fromAddr != "" { 66 | log.Printf("Using email transport via %s", fromAddr) 67 | pw.SetTransport("email", passwordless.NewSMTPTransport( 68 | os.Getenv("PWL_EMAIL_ADDR"), 69 | os.Getenv("PWL_EMAIL_FROM"), 70 | smtp.PlainAuth( 71 | os.Getenv("PWL_EMAIL_AUTH_IDENTITY"), 72 | os.Getenv("PWL_EMAIL_AUTH_USERNAME"), 73 | os.Getenv("PWL_EMAIL_AUTH_PASSWORD"), 74 | os.Getenv("PWL_EMAIL_AUTH_HOST")), 75 | emailWriter, 76 | ), passwordless.NewCrockfordGenerator(10), 30*time.Minute) 77 | } else { 78 | log.Println("No email transport specified, printing codes to stdout") 79 | pw.SetTransport("debug", passwordless.LogTransport{ 80 | MessageFunc: func(token, uid string) string { 81 | return fmt.Sprintf("Login at %s/account/token?strategy=debug&token=%s&uid=%s", 82 | baseURL, token, uid) 83 | }, 84 | }, passwordless.NewCrockfordGenerator(4), 30*time.Minute) 85 | } 86 | 87 | limiter, err := rateLimiter() 88 | if err != nil { 89 | log.Fatalln(err) 90 | } 91 | 92 | // Setup routes 93 | http.HandleFunc("/", tmplHandler("index")) 94 | 95 | // Setup signin/out routes 96 | http.HandleFunc("/account/signin", signinHandler) 97 | http.Handle("/account/token", 98 | limiter.RateLimit(http.HandlerFunc(tokenHandler))) 99 | http.HandleFunc("/account/signout", signoutHandler) 100 | 101 | // Setup restricted routes that require a valid username 102 | restricted := http.NewServeMux() 103 | http.HandleFunc("/restricted", RestrictedHandler( 104 | baseURL+"/account/signin", restricted)) 105 | restricted.HandleFunc("/", tmplHandler("secret")) 106 | 107 | // Listen! 108 | log.Fatal(http.ListenAndServe(":8080", nil)) 109 | } 110 | 111 | // RestrictedHandler wraps handlers and redirects the client to the specified 112 | // signinUrl if they have not logged in. 113 | func RestrictedHandler(signinUrl string, h http.Handler) func(http.ResponseWriter, *http.Request) { 114 | if _, err := url.Parse(signinUrl); err != nil { 115 | log.Fatalln("RestrictedHandler: signinUrl is not a valid URL", err) 116 | } 117 | 118 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 119 | if session, err := getSession(w, r); err == nil { 120 | if session.Values["uid"] == nil { 121 | // Not logged in, redirect to signin page with a redirect. 122 | u, _ := url.Parse(signinUrl) 123 | u.RawQuery = u.RawQuery + "&next=" + r.URL.String() 124 | session.AddFlash("forbidden") 125 | if err := session.Save(r, w); err != nil { 126 | http.Error(w, err.Error(), http.StatusInternalServerError) 127 | return 128 | } 129 | http.Redirect(w, r, u.String(), http.StatusSeeOther) 130 | } else { 131 | // Logged in, fall through to original handler 132 | h.ServeHTTP(w, r) 133 | } 134 | } 135 | }) 136 | } 137 | 138 | // tmplHandler returns a Handler that executes the named template. 139 | func tmplHandler(name string) func(http.ResponseWriter, *http.Request) { 140 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 141 | if session, err := getSession(w, r); err == nil { 142 | tmpl.ExecuteTemplate(w, name, struct { 143 | Context *Context 144 | }{ 145 | Context: getTemplateContext(w, r, session), 146 | }) 147 | } 148 | }) 149 | } 150 | 151 | // emailWriter writes the token to email form. 152 | func emailWriter(ctx context.Context, token, uid, recipient string, w io.Writer) error { 153 | e := &passwordless.Email{ 154 | Subject: "Go-Passwordless signin", 155 | To: recipient, 156 | } 157 | 158 | link := baseURL + "/account/token" + 159 | "?strategy=email&token=" + token + "&uid=" + uid 160 | 161 | // Ideally these would be populated from templates, but... 162 | text := "You (or someone who knows your email address) wants " + 163 | "to sign in to the Go-Passwordless website.\n\n" + 164 | "Your PIN is " + token + " - or use the following link: " + 165 | link + "\n\n" + 166 | "(If you were did not request or were not expecting this email, " + 167 | "you can safely ignore it.)" 168 | html := "" + 169 | "

You (or someone who knows your email address) wants " + 170 | "to sign in to the Go-Passwordless website.

" + 171 | "

Your PIN is " + token + " - or " + 172 | "click here to sign in automatically.

" + 173 | "

(If you did not request or were not expecting this email, " + 174 | "you can safely ignore it.)

" 175 | 176 | // Add content types, from least- to most-preferable. 177 | e.AddBody("text/plain", text) 178 | e.AddBody("text/html", html) 179 | 180 | _, err := e.Write(w) 181 | 182 | return err 183 | } 184 | 185 | // rateLimiter creates and returns a new HTTPRateLimiter 186 | func rateLimiter() (*throttled.HTTPRateLimiter, error) { 187 | store, err := memstore.New(0x10000) 188 | if err != nil { 189 | return nil, err 190 | } 191 | 192 | quota := throttled.RateQuota{throttled.PerMin(10), 5} 193 | 194 | rateLimiter, err := throttled.NewGCRARateLimiter(store, quota) 195 | if err != nil { 196 | return nil, err 197 | } 198 | 199 | return &throttled.HTTPRateLimiter{ 200 | RateLimiter: rateLimiter, 201 | }, nil 202 | } 203 | -------------------------------------------------------------------------------- /example/templates/about.html: -------------------------------------------------------------------------------- 1 | {{ define "about" }} 2 | 3 | {{ template "header" . }} 4 | 5 |
6 |

Token-based authentication for Go

7 |
8 | 9 | {{ template "footer" . }} 10 | 11 | {{ end }} 12 | -------------------------------------------------------------------------------- /example/templates/error.html: -------------------------------------------------------------------------------- 1 | {{ define "error" }} 2 | 3 | {{ template "header" . }} 4 | 5 |
6 |

Oops...

7 |

{{ .Error.Name }}

8 |

{{ .Error.Description }}

9 |
10 | 11 | {{ template "footer" . }} 12 | 13 | {{ end }} 14 | -------------------------------------------------------------------------------- /example/templates/flashes.html: -------------------------------------------------------------------------------- 1 | {{ define "flashes" }} 2 | {{ if . }} 3 |
4 | {{ range $flash := . }} 5 | {{ if eq $flash "forbidden" }} 6 |
7 | 8 | You must sign in to access the requested page. 9 |
10 | {{ end }} 11 | {{ if eq $flash "signed_in" }} 12 |
13 | 14 | Signed in successfully! 15 |
16 | {{ end }} 17 | {{ if eq $flash "token_not_found" }} 18 |
19 | 20 | There was a problem signing in. Please sign in again. 21 |
22 | {{ end }} 23 | {{ if eq $flash "token_expired" }} 24 |
25 | 26 | The token you entered was too old. Pluase sign in again. 27 |
28 | {{ end }} 29 | {{ if eq $flash "already_signed_in" }} 30 |
31 | You are already signed in! Sign out? 32 |
33 | {{ end }} 34 | {{ if eq $flash "signed_out" }} 35 |
36 | 37 | You have been signed out succesfully. 38 |
39 | {{ end }} 40 | {{ end }} 41 |
42 | {{ end }} 43 | {{ end }} 44 | -------------------------------------------------------------------------------- /example/templates/footer.html: -------------------------------------------------------------------------------- 1 | {{ define "footer" }} 2 |
3 |
Passwordless demo. Built on 4 | Go, 5 | Basscss and more. 6 | Inspired by Passwordless for Node. 7 |
8 |
9 | 10 | 11 | {{ end }} 12 | -------------------------------------------------------------------------------- /example/templates/header.html: -------------------------------------------------------------------------------- 1 | {{ define "header" }} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 48 | {{ template "flashes" .Context.Flashes }} 49 |
50 |

Go-Passwordless

51 |
52 | {{ end }} 53 | -------------------------------------------------------------------------------- /example/templates/index.html: -------------------------------------------------------------------------------- 1 | {{ define "index" }} 2 | 3 | {{ template "header" . }} 4 | 5 |
6 |

Token-based authentication for Go

7 |

Passwordless allows users to sign-in to your website without a password by sending them a secure one-time password to their chosen email or device instead. Shamelessly inspired by the original Passwordless for Node.

8 |

Try it!

9 |
10 | 11 | {{ template "footer" . }} 12 | 13 | {{ end }} 14 | -------------------------------------------------------------------------------- /example/templates/secret.html: -------------------------------------------------------------------------------- 1 | {{ define "secret" }} 2 | 3 | {{ template "header" . }} 4 | 5 |
6 |

Secret stuff!

7 | 9 |
10 | 11 | {{ template "footer" . }} 12 | 13 | {{ end }} 14 | -------------------------------------------------------------------------------- /example/templates/signin.html: -------------------------------------------------------------------------------- 1 | {{ define "signin" }} 2 | 3 | {{ template "header" . }} 4 | 5 |
6 |

How would you like to sign in?

7 |
8 | {{ $next := .Next }} 9 | {{ range $name, $t := .Strategies }} 10 |
11 | {{ if eq $name "sms" }} 12 |

Send me an SMS message

13 |
14 | 15 | 16 | 17 | 19 | 20 |
21 | {{ else if eq $name "email" }} 22 |

Send me an email

23 |
24 | 25 | 26 | 27 | 29 | 30 |
31 | {{ else if eq $name "debug" }} 32 |

Emit to debug stdout

33 |
34 | 35 | 36 | 37 | 39 | 40 |
41 | {{ else }} 42 |

Unknown strategy "{{ $name }}"

43 | {{ end }} 44 |
45 | {{ end }} 46 |
47 |
48 | 49 | {{ template "footer" . }} 50 | 51 | {{ end }} 52 | -------------------------------------------------------------------------------- /example/templates/token.html: -------------------------------------------------------------------------------- 1 | {{ define "token" }} 2 | 3 | {{ template "header" . }} 4 | 5 |
6 | {{ if eq .Strategy "sms" }} 7 |

SMS Sent!

8 |

A PIN has been sent to {{ .Recipient }}. When you receive it, enter it below:

9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | {{ if .TokenError }}

{{ .TokenError }}

{{ end }} 18 | {{ else if eq .Strategy "email" }} 19 |

Email Sent!

20 |

An email has been sent to {{ .Recipient }} containing a unique PIN. Enter the PIN you receive into the box below:

21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | {{ if .TokenError }}

{{ .TokenError }}

{{ end }} 30 | {{ else if eq .Strategy "debug" }} 31 |

Link sent!

32 |

A protected link has been written to the terminal. Please click on it.

33 | {{ if .TokenError }}

{{ .TokenError }}

{{ end }} 34 | {{ end }} 35 | 36 |
37 | 38 | {{ template "footer" . }} 39 | 40 | {{ end }} 41 | 42 | -------------------------------------------------------------------------------- /example/utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "net/url" 7 | 8 | "github.com/gorilla/sessions" 9 | ) 10 | 11 | // Context holds data pertaining to the base page template. 12 | type Context struct { 13 | SignedIn bool 14 | UserID string 15 | UserName string 16 | Flashes []interface{} 17 | } 18 | 19 | // Error represents an error that is displayed to the user. 20 | type Error struct { 21 | Name string 22 | Description string 23 | Error error 24 | } 25 | 26 | // getTemplateContext returns a Context object containing the current user 27 | // and other variables required by all templates. 28 | func getTemplateContext(w http.ResponseWriter, r *http.Request, s *sessions.Session) *Context { 29 | ctx := &Context{ 30 | Flashes: s.Flashes(), 31 | } 32 | if uid, ok := s.Values["uid"].(string); ok { 33 | ctx.SignedIn = true 34 | ctx.UserName = uid 35 | ctx.UserID = uid 36 | } 37 | s.Save(r, w) 38 | return ctx 39 | } 40 | 41 | // redirect is a helper method that issues a redirect to the client for the 42 | // specified URL. If the URL is invalid, or for a different host, the client 43 | // is redirected to the base URL instead. 44 | func redirect(w http.ResponseWriter, r *http.Request, next, base string) { 45 | if nextURL, err := url.Parse(next); err != nil { 46 | log.Println("couldn't parse redirect URL " + next) 47 | next = base 48 | } else if nextURL.IsAbs() && next[:len(base)] != base { 49 | log.Println("redirect URL is not permitted: " + next) 50 | next = base 51 | } 52 | http.Redirect(w, r, next, http.StatusFound) 53 | } 54 | 55 | // writeError is a helper method that emits an error page with the given status 56 | // and session. 57 | func writeError(w http.ResponseWriter, r *http.Request, s *sessions.Session, status int, e Error) { 58 | w.WriteHeader(status) 59 | tmpl.ExecuteTemplate(w, "error", struct { 60 | Context *Context 61 | Error Error 62 | }{ 63 | Context: getTemplateContext(w, r, s), 64 | Error: e, 65 | }) 66 | } 67 | 68 | // getSession is a helper method that gets a user session, or emits an 69 | // appropriate error page (and returns the error) on failure. 70 | func getSession(w http.ResponseWriter, r *http.Request) (*sessions.Session, error) { 71 | session, err := store.Get(r, SesssionKey) 72 | if err != nil && session == nil { 73 | session, err = store.New(r, SesssionKey) 74 | if err != nil && session == nil { 75 | writeError(w, r, session, http.StatusUnauthorized, Error{ 76 | Name: "Couldn't get session", 77 | Description: err.Error(), 78 | Error: err, 79 | }) 80 | return nil, err 81 | } 82 | } 83 | return session, nil 84 | } 85 | 86 | func isSignedIn(s *sessions.Session) bool { 87 | return s != nil && s.Values["uid"] != nil 88 | } 89 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/johnsto/go-passwordless/v2 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/go-redis/redis/v8 v8.11.4 7 | github.com/golang-jwt/jwt v3.2.2+incompatible 8 | github.com/gorilla/securecookie v1.1.1 9 | github.com/gorilla/sessions v1.2.1 10 | github.com/gyepisam/mcf v0.0.0-20181020145543-a4d14a7af431 11 | github.com/hashicorp/golang-lru v0.5.4 // indirect 12 | github.com/kr/pretty v0.1.0 // indirect 13 | github.com/pzduniak/mcf v0.0.0-20160731113721-0ddac5a6d704 14 | github.com/stretchr/testify v1.7.0 15 | github.com/throttled/throttled v2.2.4+incompatible // indirect 16 | golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838 // indirect 17 | golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect 18 | google.golang.org/appengine v1.6.7 19 | google.golang.org/protobuf v1.27.1 // indirect 20 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 21 | gopkg.in/throttled/throttled.v2 v2.2.4 22 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= 2 | github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 7 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 8 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 9 | github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 10 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 11 | github.com/go-redis/redis/v8 v8.11.4 h1:kHoYkfZP6+pe04aFTnhDH6GDROa5yJdHJVNxV3F46Tg= 12 | github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= 13 | github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 14 | github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 15 | github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 16 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 17 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 18 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 19 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 20 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 21 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 22 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 23 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 24 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 25 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= 26 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 27 | github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0= 28 | github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= 29 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 30 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 31 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 32 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 33 | github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= 34 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 35 | github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= 36 | github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= 37 | github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= 38 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 39 | github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ= 40 | github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= 41 | github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= 42 | github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= 43 | github.com/gyepisam/mcf v0.0.0-20181020145543-a4d14a7af431 h1:eRibFhTs9kLmmo20IeAvnl/KmyLl6dUcK+qHoChw8vo= 44 | github.com/gyepisam/mcf v0.0.0-20181020145543-a4d14a7af431/go.mod h1:hUrUy8Xg1egngSI+0CRGU54AXtB/KaDjt9NoG439Z9E= 45 | github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= 46 | github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 47 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 48 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 49 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 50 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 51 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 52 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 53 | github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 54 | github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= 55 | github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= 56 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 57 | github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= 58 | github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= 59 | github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= 60 | github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 61 | github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 62 | github.com/onsi/gomega v1.16.0 h1:6gjqkI8iiRHMvdccRJM8rVKjCWk6ZIm6FTm3ddIe4/c= 63 | github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= 64 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 65 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 66 | github.com/pzduniak/mcf v0.0.0-20160731113721-0ddac5a6d704 h1:VUeZcZ+WSUuUgIJbWDNB8gQ9LKZxl3wJ/ZIBharIFqk= 67 | github.com/pzduniak/mcf v0.0.0-20160731113721-0ddac5a6d704/go.mod h1:CDPyr308m+R6veIczu+wD//wNYwp4p35mtW4DUgxeMU= 68 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 69 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 70 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 71 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 72 | github.com/throttled/throttled v2.2.4+incompatible h1:aVKdoH/qT5Mo1Lm/678OkX2pFg7aRpHlTn1tfgaSKxs= 73 | github.com/throttled/throttled v2.2.4+incompatible/go.mod h1:0BjlrEGQmvxps+HuXLsyRdqpSRvJpq0PNIsOtqP9Nos= 74 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 75 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 76 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 77 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 78 | golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838 h1:71vQrMauZZhcTVK6KdYM+rklehEEwb3E+ZhaE5jrPrE= 79 | golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 80 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 81 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 82 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 83 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 84 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 85 | golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 86 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 87 | golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 88 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE= 89 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 90 | golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd h1:O7DYs+zxREGLKzKoMQrtrEacpb0ZVXA5rIwylE2Xchk= 91 | golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 92 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 93 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 94 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 95 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 96 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 97 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 98 | golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 99 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 100 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 101 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 102 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 103 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 104 | golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 105 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 106 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= 107 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 108 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM= 109 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 110 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 111 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 112 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 113 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 114 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 115 | golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= 116 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 117 | golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= 118 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 119 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 120 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 121 | golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 122 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 123 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 124 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 125 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 126 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 127 | google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM= 128 | google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 129 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 130 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 131 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 132 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 133 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 134 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 135 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 136 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 137 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 138 | google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk= 139 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 140 | google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= 141 | google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 142 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 143 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 144 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 145 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 146 | gopkg.in/throttled/throttled.v2 v2.2.4 h1:cKyW79+gIvnVB+aKL9hJ3TSnfDkiFv6/vqC+aLcVdgk= 147 | gopkg.in/throttled/throttled.v2 v2.2.4/go.mod h1:L4cTNZO77XKEXtn8HNFRCMNGZPtRRKAhyuJBSvK/T90= 148 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 149 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 150 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 151 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 152 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 153 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 154 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 155 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 156 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= 157 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 158 | -------------------------------------------------------------------------------- /passwordless.go: -------------------------------------------------------------------------------- 1 | package passwordless 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "context" 8 | ) 9 | 10 | var ( 11 | ErrNoStore = errors.New("no store has been configured") 12 | ErrNoTransport = errors.New("no transports have been configured") 13 | ErrUnknownStrategy = errors.New("unknown strategy") 14 | ErrNotValidForContext = errors.New("strategy not valid for context") 15 | ) 16 | 17 | // Strategy defines how to send and what tokens to send to users. 18 | type Strategy interface { 19 | Transport 20 | TokenGenerator 21 | // TTL should return the time-to-live of generated tokens. 22 | TTL(context.Context) time.Duration 23 | // Valid should return true if this strategy is valid for the provided 24 | // context. 25 | Valid(context.Context) bool 26 | } 27 | 28 | // SimpleStrategy is a convenience wrapper combining a Transport, 29 | // TokenGenerator, and TTL. 30 | type SimpleStrategy struct { 31 | Transport 32 | TokenGenerator 33 | ttl time.Duration 34 | } 35 | 36 | // TTL returns the time-to-live of tokens generated with this strategy. 37 | func (s SimpleStrategy) TTL(context.Context) time.Duration { 38 | return s.ttl 39 | } 40 | 41 | // Valid always returns true for SimpleStrategy. 42 | func (s SimpleStrategy) Valid(context.Context) bool { 43 | return true 44 | } 45 | 46 | // Passwordless holds a set of named strategies and an associated token store. 47 | type Passwordless struct { 48 | Strategies map[string]Strategy 49 | Store TokenStore 50 | } 51 | 52 | // New returns a new Passwordless instance with the specified token store. 53 | // Register strategies against this instance with either `SetStrategy` or 54 | // `SetTransport`. 55 | func New(store TokenStore) *Passwordless { 56 | return &Passwordless{ 57 | Store: store, 58 | Strategies: make(map[string]Strategy), 59 | } 60 | } 61 | 62 | // SetStrategy registers the given strategy. 63 | func (p *Passwordless) SetStrategy(name string, s Strategy) { 64 | p.Strategies[name] = s 65 | } 66 | 67 | // SetTransport registers a transport strategy under a specified name. The 68 | // TTL specifies for how long tokens generated with the provided TokenGenerator 69 | // are valid. Some delivery mechanisms may require longer TTLs than others 70 | // depending on the nature/punctuality of the transport. 71 | func (p *Passwordless) SetTransport(name string, t Transport, g TokenGenerator, ttl time.Duration) Strategy { 72 | s := SimpleStrategy{ 73 | Transport: t, 74 | TokenGenerator: g, 75 | ttl: ttl, 76 | } 77 | p.SetStrategy(name, s) 78 | return s 79 | } 80 | 81 | // ListStrategies returns a list of strategies valid for the context mapped 82 | // to their names. If you have multiple strategies, call this in order to 83 | // provide a list of options for the user to pick from. 84 | func (p *Passwordless) ListStrategies(ctx context.Context) map[string]Strategy { 85 | s := map[string]Strategy{} 86 | for n, t := range p.Strategies { 87 | if t.Valid(ctx) { 88 | s[n] = t 89 | } 90 | } 91 | return s 92 | } 93 | 94 | // GetStrategy returns the Strategy of the given name, or nil if one does 95 | // not exist. 96 | func (p *Passwordless) GetStrategy(ctx context.Context, name string) (Strategy, error) { 97 | t, ok := p.Strategies[name] 98 | if !ok { 99 | return nil, ErrUnknownStrategy 100 | } else if !t.Valid(ctx) { 101 | return nil, ErrNotValidForContext 102 | } 103 | return t, nil 104 | } 105 | 106 | // RequestToken generates and delivers a token to the given user. If the 107 | // specified strategy is not known or not valid, an error is returned. 108 | func (p *Passwordless) RequestToken(ctx context.Context, s, uid, recipient string) error { 109 | if t, err := p.GetStrategy(ctx, s); err != nil { 110 | return err 111 | } else { 112 | return RequestToken(ctx, p.Store, t, uid, recipient) 113 | } 114 | } 115 | 116 | // VerifyToken verifies the provided token is valid. 117 | func (p *Passwordless) VerifyToken(ctx context.Context, uid, token string) (bool, error) { 118 | return VerifyToken(ctx, p.Store, uid, token) 119 | } 120 | 121 | // RequestToken generates, saves and delivers a token to the specified 122 | // recipient. 123 | func RequestToken(ctx context.Context, s TokenStore, t Strategy, uid, recipient string) error { 124 | tok, err := t.Generate(ctx) 125 | if err != nil { 126 | return err 127 | } 128 | // Store token 129 | if err := s.Store(ctx, tok, uid, t.TTL(ctx)); err != nil { 130 | return err 131 | } 132 | // Send token to user 133 | if err := t.Send(ctx, tok, uid, recipient); err != nil { 134 | return err 135 | } 136 | return nil 137 | } 138 | 139 | // VerifyToken checks the given token against the provided token store. 140 | func VerifyToken(ctx context.Context, s TokenStore, uid, token string) (bool, error) { 141 | if isValid, err := s.Verify(ctx, token, uid); err != nil { 142 | // Failed to validate 143 | return false, err 144 | } else if !isValid { 145 | // Token is not valid 146 | return false, nil 147 | } else { 148 | // Token *is* valid; remove old token 149 | return true, s.Delete(ctx, uid) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /passwordless_test.go: -------------------------------------------------------------------------------- 1 | package passwordless 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "context" 10 | ) 11 | 12 | type testTransport struct { 13 | token string 14 | recipient string 15 | err error 16 | } 17 | 18 | func (t *testTransport) Send(ctx context.Context, token, user, recipient string) error { 19 | t.token = token 20 | t.recipient = recipient 21 | return t.err 22 | } 23 | 24 | type testGenerator struct { 25 | token string 26 | err error 27 | } 28 | 29 | func (g testGenerator) Generate(ctx context.Context) (string, error) { 30 | return g.token, g.err 31 | } 32 | 33 | func (g testGenerator) Sanitize(ctx context.Context, s string) (string, error) { 34 | return s, nil 35 | } 36 | 37 | func TestPasswordless(t *testing.T) { 38 | p := New(NewMemStore()) 39 | 40 | tt := &testTransport{} 41 | tg := &testGenerator{token: "1337"} 42 | s := p.SetTransport("test", tt, tg, 5*time.Minute) 43 | 44 | // Check transports match those set 45 | assert.Equal(t, map[string]Strategy{"test": s}, p.ListStrategies(nil)) 46 | if s0, err := p.GetStrategy(nil, "test"); err != nil { 47 | assert.NoError(t, err) 48 | } else { 49 | assert.Equal(t, s, s0) 50 | } 51 | 52 | // Check returned token is as expected 53 | assert.NoError(t, p.RequestToken(nil, "test", "uid", "recipient")) 54 | assert.Equal(t, tt.token, tg.token) 55 | assert.Equal(t, tt.recipient, "recipient") 56 | 57 | // Check invalid token is rejected 58 | v, err := p.VerifyToken(nil, "uid", "badtoken") 59 | assert.NoError(t, err) 60 | assert.False(t, v) 61 | 62 | // Verify token 63 | v, err = p.VerifyToken(nil, "uid", tg.token) 64 | assert.NoError(t, err) 65 | assert.True(t, v) 66 | } 67 | 68 | type testStrategy struct { 69 | SimpleStrategy 70 | valid bool 71 | } 72 | 73 | func (s testStrategy) Valid(c context.Context) bool { 74 | return s.valid 75 | } 76 | 77 | func TestPasswordlessFailures(t *testing.T) { 78 | p := New(NewMemStore()) 79 | 80 | _, err := p.GetStrategy(nil, "madeup") 81 | assert.Equal(t, err, ErrUnknownStrategy) 82 | 83 | err = p.RequestToken(nil, "madeup", "", "") 84 | assert.Equal(t, err, ErrUnknownStrategy) 85 | 86 | p.SetStrategy("unfriendly", testStrategy{valid: false}) 87 | 88 | err = p.RequestToken(nil, "unfriendly", "", "") 89 | assert.Equal(t, err, ErrNotValidForContext) 90 | } 91 | 92 | func TestRequestToken(t *testing.T) { 93 | // Test Generate() 94 | assert.EqualError(t, RequestToken(nil, nil, &mockStrategy{ 95 | generate: func(c context.Context) (string, error) { 96 | return "", fmt.Errorf("refused generate") 97 | }, 98 | }, "", ""), "refused generate", "Generate() error should propagate") 99 | 100 | // Test Send() 101 | assert.EqualError(t, RequestToken(nil, &mockTokenStore{ 102 | store: func(ctx context.Context, token, uid string, ttl time.Duration) error { 103 | return nil 104 | }, 105 | }, &mockStrategy{ 106 | generate: func(c context.Context) (string, error) { 107 | return "", nil 108 | }, 109 | send: func(c context.Context, token, user, recipient string) error { 110 | return fmt.Errorf("refused send") 111 | }, 112 | }, "", ""), "refused send", "Send() error should propagate") 113 | 114 | // Test Store() 115 | err := RequestToken(nil, &mockTokenStore{ 116 | store: func(ctx context.Context, token, uid string, ttl time.Duration) error { 117 | return fmt.Errorf("refused store") 118 | }, 119 | }, &mockStrategy{ 120 | generate: func(c context.Context) (string, error) { 121 | return "", nil 122 | }, 123 | send: func(c context.Context, token, user, recipient string) error { 124 | return nil 125 | }, 126 | }, "", "") 127 | assert.EqualError(t, err, "refused store", "Store() error should propagate") 128 | } 129 | 130 | func TestVerifyToken(t *testing.T) { 131 | valid, err := VerifyToken(nil, &mockTokenStore{ 132 | verify: func(ctx context.Context, token, uid string) (bool, error) { 133 | return false, fmt.Errorf("refused verify") 134 | }, 135 | }, "", "") 136 | assert.False(t, valid) 137 | assert.EqualError(t, err, "refused verify", "Verify() error should propagate") 138 | 139 | valid, err = VerifyToken(nil, &mockTokenStore{ 140 | verify: func(ctx context.Context, token, uid string) (bool, error) { 141 | return false, nil 142 | }, 143 | }, "", "") 144 | assert.False(t, valid) 145 | assert.NoError(t, err) 146 | 147 | valid, err = VerifyToken(nil, &mockTokenStore{ 148 | verify: func(ctx context.Context, token, uid string) (bool, error) { 149 | return true, nil 150 | }, 151 | delete: func(ctx context.Context, uid string) error { 152 | return fmt.Errorf("delete failure") 153 | }, 154 | }, "", "") 155 | assert.True(t, valid) 156 | assert.EqualError(t, err, "delete failure") 157 | } 158 | 159 | type mockStrategy struct { 160 | SimpleStrategy 161 | generate func(context.Context) (string, error) 162 | sanitize func(context.Context, string) (string, error) 163 | send func(c context.Context, token, user, recipient string) error 164 | } 165 | 166 | func (m mockStrategy) TTL(ctx context.Context) time.Duration { 167 | return m.ttl 168 | } 169 | 170 | func (m mockStrategy) Generate(ctx context.Context) (string, error) { 171 | return m.generate(ctx) 172 | } 173 | 174 | func (m mockStrategy) Sanitize(ctx context.Context, t string) (string, error) { 175 | return m.sanitize(ctx, t) 176 | } 177 | 178 | func (m mockStrategy) Send(ctx context.Context, token, user, recipient string) error { 179 | return m.send(ctx, token, user, recipient) 180 | } 181 | 182 | type mockTokenStore struct { 183 | store func(ctx context.Context, token, uid string, ttl time.Duration) error 184 | exists func(ctx context.Context, uid string) (bool, time.Time, error) 185 | verify func(ctx context.Context, token, uid string) (bool, error) 186 | delete func(ctx context.Context, uid string) error 187 | } 188 | 189 | func (m mockTokenStore) Store(ctx context.Context, token, uid string, ttl time.Duration) error { 190 | return m.store(ctx, token, uid, ttl) 191 | } 192 | 193 | func (m mockTokenStore) Exists(ctx context.Context, uid string) (bool, time.Time, error) { 194 | return m.exists(ctx, uid) 195 | } 196 | 197 | func (m mockTokenStore) Verify(ctx context.Context, token, uid string) (bool, error) { 198 | return m.verify(ctx, token, uid) 199 | } 200 | 201 | func (m mockTokenStore) Delete(ctx context.Context, uid string) error { 202 | return m.delete(ctx, uid) 203 | } 204 | -------------------------------------------------------------------------------- /store.go: -------------------------------------------------------------------------------- 1 | package passwordless 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "context" 8 | 9 | _ "github.com/pzduniak/mcf/scrypt" 10 | ) 11 | 12 | var ( 13 | ErrTokenNotFound = errors.New("the token does not exist") 14 | ErrTokenNotValid = errors.New("the token is incorrect") 15 | ) 16 | 17 | // TokenStore is a storage mechanism for tokens. 18 | type TokenStore interface { 19 | // Store securely stores the given token with the given expiry time 20 | Store(ctx context.Context, token, uid string, ttl time.Duration) error 21 | // Exists returns true if a token is stored for the user. If the expiry 22 | // time is available this is also returned, otherwise it will be zero 23 | // and can be tested with `Time.IsZero()`. 24 | Exists(ctx context.Context, uid string) (bool, time.Time, error) 25 | // Verify returns true if the given token is valid for the user 26 | Verify(ctx context.Context, token, uid string) (bool, error) 27 | // Delete removes the token for the specified user 28 | Delete(ctx context.Context, uid string) error 29 | } 30 | -------------------------------------------------------------------------------- /store_mem.go: -------------------------------------------------------------------------------- 1 | package passwordless 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/pzduniak/mcf" 8 | "context" 9 | ) 10 | 11 | // MemStore is a Store that keeps tokens in memory, expiring them periodically 12 | // when they expire. 13 | type MemStore struct { 14 | mut sync.Mutex 15 | data map[string]memToken 16 | cleaner *time.Ticker 17 | quitCleaner chan (struct{}) 18 | } 19 | 20 | type memToken struct { 21 | UID string 22 | HashedToken []byte 23 | Expires time.Time 24 | } 25 | 26 | // NewMemStore creates and returns a new `MemStore` 27 | func NewMemStore() *MemStore { 28 | ct := time.NewTicker(time.Second) 29 | ms := &MemStore{ 30 | data: make(map[string]memToken), 31 | quitCleaner: make(chan struct{}), 32 | cleaner: ct, 33 | } 34 | // Run cleaner periodically 35 | go func(quit chan struct{}) { 36 | ticker: 37 | for { 38 | select { 39 | case <-ct.C: 40 | // Run clean cycle 41 | ms.Clean() 42 | case <-quit: 43 | // Release resources 44 | ct.Stop() 45 | break ticker 46 | } 47 | } 48 | }(ms.quitCleaner) 49 | return ms 50 | } 51 | 52 | func (s *MemStore) Store(ctx context.Context, token, uid string, 53 | ttl time.Duration) error { 54 | hashToken, err := mcf.Create([]byte(token)) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | s.mut.Lock() 60 | defer s.mut.Unlock() 61 | s.data[uid] = memToken{ 62 | UID: uid, 63 | HashedToken: hashToken, 64 | Expires: time.Now().Add(ttl), 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func (s *MemStore) Exists(ctx context.Context, uid string) (bool, time.Time, error) { 71 | if t, ok := s.data[uid]; !ok { 72 | // No known token for this user 73 | return false, time.Time{}, nil 74 | } else if time.Now().After(t.Expires) { 75 | // Token exists, but expired 76 | return false, time.Time{}, nil 77 | } else { 78 | // Token exists and is still valid 79 | return true, t.Expires, nil 80 | } 81 | } 82 | 83 | func (s *MemStore) Verify(ctx context.Context, token, uid string) (bool, error) { 84 | if t, ok := s.data[uid]; !ok { 85 | // No token in database 86 | return false, ErrTokenNotFound 87 | } else if time.Now().After(t.Expires) { 88 | // Token exists but has expired 89 | return false, ErrTokenNotFound 90 | } else if valid, err := mcf.Verify([]byte(token), t.HashedToken); err != nil { 91 | // Couldn't validate token 92 | return false, err 93 | } else if !valid { 94 | // Token does not validate against hashed token 95 | return false, nil 96 | } else { 97 | // Token is valid! 98 | return true, nil 99 | } 100 | } 101 | 102 | func (s *MemStore) Delete(ctx context.Context, uid string) error { 103 | s.mut.Lock() 104 | defer s.mut.Unlock() 105 | delete(s.data, uid) 106 | return nil 107 | } 108 | 109 | // Clean removes expired entries from the store. 110 | func (s *MemStore) Clean() { 111 | s.mut.Lock() 112 | defer s.mut.Unlock() 113 | for uid, token := range s.data { 114 | if time.Now().After(token.Expires) { 115 | delete(s.data, uid) 116 | } 117 | } 118 | } 119 | 120 | // Release disposes of the MemStore and any released resources 121 | func (s *MemStore) Release() { 122 | s.cleaner.Stop() 123 | close(s.quitCleaner) 124 | s.data = nil 125 | } 126 | -------------------------------------------------------------------------------- /store_mem_test.go: -------------------------------------------------------------------------------- 1 | package passwordless 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestMemStore(t *testing.T) { 11 | ms := NewMemStore() 12 | assert.NotNil(t, ms) 13 | 14 | b, exp, err := ms.Exists(nil, "uid") 15 | assert.False(t, b) 16 | assert.True(t, exp.IsZero()) 17 | assert.NoError(t, err) 18 | 19 | err = ms.Store(nil, "", "uid", -time.Hour) 20 | b, exp, err = ms.Exists(nil, "uid") 21 | assert.False(t, b) 22 | assert.True(t, exp.IsZero()) 23 | assert.NoError(t, err) 24 | 25 | err = ms.Store(nil, "", "uid", time.Hour) 26 | b, exp, err = ms.Exists(nil, "uid") 27 | assert.True(t, b) 28 | assert.False(t, exp.IsZero()) 29 | assert.NoError(t, err) 30 | 31 | // Test keys are expired correctly 32 | err = ms.Store(nil, "", "expuid", time.Second) 33 | assert.NoError(t, err) 34 | b, _, _ = ms.Exists(nil, "expuid") 35 | assert.True(t, b) 36 | time.Sleep(time.Second) 37 | ms.Clean() 38 | b, _, _ = ms.Exists(nil, "expuid") 39 | assert.False(t, b) 40 | 41 | // Clean up 42 | ms.Release() 43 | time.Sleep(2 * time.Second) 44 | } 45 | 46 | func TestMemStoreVerify(t *testing.T) { 47 | ms := NewMemStore() 48 | assert.NotNil(t, ms) 49 | 50 | // Token doesn't exist 51 | b, err := ms.Verify(nil, "badtoken", "uid") 52 | assert.False(t, b) 53 | assert.Equal(t, ErrTokenNotFound, err) 54 | 55 | // Token expired 56 | err = ms.Store(nil, "", "uid", -time.Hour) 57 | b, err = ms.Verify(nil, "badtoken", "uid") 58 | assert.False(t, b) 59 | assert.Equal(t, ErrTokenNotFound, err) 60 | 61 | // Token wrong 62 | err = ms.Store(nil, "token", "uid", time.Hour) 63 | b, err = ms.Verify(nil, "badtoken", "uid") 64 | assert.False(t, b) 65 | assert.NoError(t, err) 66 | 67 | // Token correct 68 | b, err = ms.Verify(nil, "token", "uid") 69 | assert.True(t, b) 70 | assert.NoError(t, err) 71 | 72 | } 73 | -------------------------------------------------------------------------------- /store_redis.go: -------------------------------------------------------------------------------- 1 | package passwordless 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/go-redis/redis/v8" 8 | "github.com/pzduniak/mcf" 9 | ) 10 | 11 | const ( 12 | redisPrefix = "passwordless-token::" 13 | ) 14 | 15 | // RedisStore is a Store that keeps tokens in Redis. 16 | type RedisStore struct { 17 | client redis.UniversalClient 18 | } 19 | 20 | // NewRedisStore creates and returns a new `RedisStore`. 21 | func NewRedisStore(client redis.UniversalClient) *RedisStore { 22 | return &RedisStore{ 23 | client: client, 24 | } 25 | } 26 | 27 | func redisKey(uid string) string { 28 | return redisPrefix + uid 29 | } 30 | 31 | // Store a generated token in redis for a user. 32 | func (s RedisStore) Store(ctx context.Context, token, uid string, ttl time.Duration) error { 33 | hashToken, err := mcf.Create([]byte(token)) 34 | if err != nil { 35 | return err 36 | } 37 | r := s.client.Set(ctx, redisKey(uid), hashToken, ttl) 38 | if r.Err() != nil { 39 | return r.Err() 40 | } 41 | 42 | return nil 43 | } 44 | 45 | // Exists checks to see if a token exists. 46 | func (s RedisStore) Exists(ctx context.Context, uid string) (bool, time.Time, error) { 47 | dur, err := s.client.TTL(ctx, redisKey(uid)).Result() 48 | if err != nil { 49 | if err == redis.Nil { 50 | return false, time.Time{}, nil 51 | } 52 | return false, time.Time{}, err 53 | } 54 | expiry := time.Now().Add(dur) 55 | if time.Now().After(expiry) { 56 | return false, time.Time{}, nil 57 | } 58 | return true, expiry, nil 59 | } 60 | 61 | // Verify checks to see if a token exists and is valid for a user. 62 | func (s RedisStore) Verify(ctx context.Context, token, uid string) (bool, error) { 63 | r, err := s.client.Get(ctx, redisKey(uid)).Result() 64 | if err != nil { 65 | if err == redis.Nil { 66 | return false, ErrTokenNotFound 67 | } 68 | return false, err 69 | } 70 | valid, err := mcf.Verify([]byte(token), []byte(r)) 71 | if err != nil { 72 | return false, err 73 | } 74 | if !valid { 75 | return false, nil 76 | } 77 | return true, nil 78 | } 79 | 80 | // Delete removes a key from the store. 81 | func (s RedisStore) Delete(ctx context.Context, uid string) error { 82 | _, err := s.client.Del(ctx, redisKey(uid)).Result() 83 | if err != nil { 84 | return err 85 | } 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /store_redis_test.go: -------------------------------------------------------------------------------- 1 | package passwordless 2 | 3 | import ( 4 | "log" 5 | "testing" 6 | "time" 7 | 8 | "github.com/go-redis/redis/v8" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | type rval struct { 13 | v string 14 | d time.Duration 15 | } 16 | 17 | type redisMock struct { 18 | redis.UniversalClient 19 | store map[string]rval 20 | } 21 | 22 | func newRedisMock() *redisMock { 23 | return &redisMock{ 24 | store: map[string]rval{}, 25 | } 26 | } 27 | 28 | func (r redisMock) Set(key string, value interface{}, expiration time.Duration) *redis.StatusCmd { 29 | val := rval{ 30 | d: expiration, 31 | } 32 | switch v := value.(type) { 33 | case []byte: 34 | val.v = string(v) 35 | } 36 | r.store[key] = val 37 | return redis.NewStatusResult(key, nil) 38 | } 39 | 40 | func (r redisMock) TTL(key string) *redis.DurationCmd { 41 | v, ok := r.store[key] 42 | if !ok { 43 | return redis.NewDurationResult(-1*time.Second, nil) 44 | } 45 | cmd := redis.NewDurationResult(v.d, nil) 46 | return cmd 47 | } 48 | 49 | func (r redisMock) Get(key string) *redis.StringCmd { 50 | v, ok := r.store[key] 51 | if !ok { 52 | return redis.NewStringResult("", redis.Nil) 53 | } 54 | if time.Now().After(time.Now().Add(v.d)) { 55 | delete(r.store, key) 56 | return redis.NewStringResult("", redis.Nil) 57 | } 58 | return redis.NewStringResult(v.v, nil) 59 | } 60 | 61 | func (r redisMock) Del(keys ...string) *redis.IntCmd { 62 | for _, k := range keys { 63 | delete(r.store, k) 64 | } 65 | return redis.NewIntResult(1, nil) 66 | } 67 | 68 | func TestRedisStore(t *testing.T) { 69 | ms := NewRedisStore(newRedisMock()) 70 | assert.NotNil(t, ms) 71 | 72 | b, exp, err := ms.Exists(nil, "uid") 73 | assert.False(t, b) 74 | assert.True(t, exp.IsZero()) 75 | assert.NoError(t, err) 76 | 77 | err = ms.Store(nil, "", "uid", -time.Hour) 78 | b, exp, err = ms.Exists(nil, "uid") 79 | assert.False(t, b) 80 | assert.True(t, exp.IsZero()) 81 | assert.NoError(t, err) 82 | 83 | err = ms.Store(nil, "", "uid", time.Hour) 84 | b, exp, err = ms.Exists(nil, "uid") 85 | log.Println(b, exp, err) 86 | assert.True(t, b) 87 | assert.False(t, exp.IsZero()) 88 | } 89 | 90 | func TestRedisStoreVerify(t *testing.T) { 91 | ms := NewRedisStore(newRedisMock()) 92 | assert.NotNil(t, ms) 93 | 94 | // Token doesn't exist 95 | b, err := ms.Verify(nil, "badtoken", "uid") 96 | assert.False(t, b) 97 | assert.Equal(t, ErrTokenNotFound, err) 98 | 99 | // Token expired 100 | err = ms.Store(nil, "", "uid", -time.Hour) 101 | b, err = ms.Verify(nil, "badtoken", "uid") 102 | assert.False(t, b) 103 | assert.Equal(t, ErrTokenNotFound, err) 104 | 105 | // Token wrong 106 | err = ms.Store(nil, "token", "uid", time.Hour) 107 | b, err = ms.Verify(nil, "badtoken", "uid") 108 | assert.False(t, b) 109 | assert.NoError(t, err) 110 | 111 | // Token correct 112 | b, err = ms.Verify(nil, "token", "uid") 113 | assert.True(t, b) 114 | assert.NoError(t, err) 115 | } 116 | -------------------------------------------------------------------------------- /store_session.go: -------------------------------------------------------------------------------- 1 | package passwordless 2 | 3 | import ( 4 | "crypto/subtle" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "time" 9 | 10 | "context" 11 | 12 | "github.com/golang-jwt/jwt" 13 | "github.com/gorilla/securecookie" 14 | ) 15 | 16 | var ( 17 | ErrNoResponseWriter = errors.New("Context passed to CookieStore.Store " + 18 | "does not contain a ResponseWriter") 19 | ErrInvalidTokenUID = errors.New("invalid UID in token") 20 | ErrInvalidTokenPIN = errors.New("invalid PIN in token") 21 | ErrWrongTokenUID = errors.New("wrong UID in token") 22 | ) 23 | 24 | // CookieStore stores tokens in a encrypted cookie on the user's browser. 25 | // This token is then decrypted and checked against the provided value to 26 | // determine of the token is valid. 27 | type CookieStore struct { 28 | sk []byte 29 | cs *securecookie.SecureCookie 30 | Path string 31 | Key string 32 | } 33 | 34 | // NewCookieStore creates a new signed and encrypted CookieStore. 35 | func NewCookieStore(signingKey, authKey, encrKey []byte) *CookieStore { 36 | return &CookieStore{ 37 | Path: "/", 38 | Key: "passwordless", 39 | sk: signingKey, 40 | cs: securecookie.New(authKey, encrKey), 41 | } 42 | } 43 | 44 | // Store encrypts and writes the token to the curent response. 45 | // 46 | // The cookie is set with an expiry equal to that of the token, but the token 47 | // expiry *must* be validated on receipt. 48 | // 49 | // This function requires that a ResponseWriter is present in the context. 50 | func (s *CookieStore) Store(ctx context.Context, token, uid string, ttl time.Duration) error { 51 | rw, _ := fromContext(ctx) 52 | if rw == nil { 53 | return ErrNoResponseWriter 54 | } 55 | 56 | // Create signed token 57 | exp := time.Now().Add(ttl) 58 | tokString, err := s.newToken(token, uid, exp) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | // Encode and encrypt cookie value 64 | encoded, err := s.cs.Encode(s.Key, tokString) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | // Emit cookie into response 70 | cookie := &http.Cookie{ 71 | Expires: exp, 72 | MaxAge: int(ttl / time.Second), 73 | Name: s.Key, 74 | Value: encoded, 75 | Path: s.Path, 76 | } 77 | http.SetCookie(rw, cookie) 78 | 79 | return nil 80 | } 81 | 82 | func (s *CookieStore) Exists(ctx context.Context, uid string) (bool, time.Time, error) { 83 | // Read cookie 84 | _, req := fromContext(ctx) 85 | var cookie *http.Cookie 86 | var err error 87 | 88 | if cookie, err = req.Cookie(s.Key); err != nil { 89 | return false, time.Time{}, err 90 | } 91 | 92 | // Read JWT string from cookie 93 | var tokString string 94 | if err = s.cs.Decode(s.Key, cookie.Value, &tokString); err != nil { 95 | return false, time.Time{}, err 96 | } 97 | // Parse JWT string 98 | tok, claims, err := s.parseToken(tokString) 99 | 100 | // Reject invalid JWTs 101 | if err != nil || !tok.Valid { 102 | return false, time.Time{}, err 103 | } 104 | 105 | // Check token is for the same UID 106 | if u, ok := claims["uid"].(string); !ok { 107 | // Token contains bad UID 108 | return false, time.Time{}, ErrInvalidTokenUID 109 | } else if u != uid { 110 | // Token is for a different UID 111 | return false, time.Time{}, ErrWrongTokenUID 112 | } 113 | 114 | exp := time.Unix(int64(claims["exp"].(float64)), 0) 115 | return true, exp, nil 116 | } 117 | 118 | // Verify reads the cookie from the request and verifies it against the 119 | // provided values, returning true on success. 120 | func (s *CookieStore) Verify(ctx context.Context, pin, uid string) (bool, error) { 121 | _, req := fromContext(ctx) 122 | var cookie *http.Cookie 123 | var err error 124 | if cookie, err = req.Cookie(s.Key); err != nil { 125 | return false, err 126 | } 127 | 128 | var tokString string 129 | if err = s.cs.Decode(s.Key, cookie.Value, &tokString); err != nil { 130 | return false, err 131 | } 132 | 133 | return s.verifyToken(tokString, pin, uid) 134 | } 135 | 136 | // Delete deletes the cookie. 137 | // 138 | // This function requires that a ResponseWriter is present in the context. 139 | func (s *CookieStore) Delete(ctx context.Context, uid string) error { 140 | rw, _ := fromContext(ctx) 141 | if rw == nil { 142 | return ErrNoResponseWriter 143 | } 144 | cookie := &http.Cookie{ 145 | MaxAge: 0, 146 | Name: s.Key, 147 | Path: s.Path, 148 | } 149 | http.SetCookie(rw, cookie) 150 | return nil 151 | } 152 | 153 | // newToken creates and returns a new *unencrypted* JWT token containing the 154 | // pin and user ID. 155 | func (s *CookieStore) newToken(pin, uid string, exp time.Time) (string, error) { 156 | tok := jwt.New(jwt.SigningMethodHS256) 157 | tok.Claims = jwt.MapClaims{ 158 | "exp": exp.Unix(), 159 | "uid": uid, 160 | "pin": pin, 161 | } 162 | return tok.SignedString(s.sk) 163 | } 164 | 165 | // parseToken parses the token stored in the given strinng. 166 | func (s *CookieStore) parseToken(t string) (*jwt.Token, jwt.MapClaims, error) { 167 | claims := jwt.MapClaims{} 168 | tok, err := jwt.ParseWithClaims(t, claims, func(token *jwt.Token) (interface{}, error) { 169 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { 170 | return nil, fmt.Errorf("verifyToken: unexpected signing method %s", token.Header["alg"]) 171 | } 172 | return s.sk, nil 173 | }) 174 | return tok, claims, err 175 | } 176 | 177 | // verifyToken verifies an *unencrypted* JWT token. 178 | func (s *CookieStore) verifyToken(t, pin, uid string) (bool, error) { 179 | tok, claims, err := s.parseToken(t) 180 | 181 | // Reject invalid JWTs 182 | if err != nil || !tok.Valid { 183 | return false, err 184 | } 185 | 186 | // Check token matches supplied data. 187 | if u, ok := claims["uid"].(string); !ok { 188 | return false, ErrInvalidTokenUID 189 | } else if p, ok := claims["pin"].(string); !ok { 190 | return false, ErrInvalidTokenPIN 191 | } else { 192 | validUID := (u == uid) 193 | validPIN := (1 == subtle.ConstantTimeCompare([]byte(p), []byte(pin))) 194 | return validUID && validPIN, nil 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /store_session_test.go: -------------------------------------------------------------------------------- 1 | package passwordless 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestSessionStoreToken(t *testing.T) { 14 | now := time.Now() 15 | cs := NewCookieStore([]byte{}, []byte{}, []byte{}) 16 | 17 | valid, err := cs.verifyToken("", "1337", "userid") 18 | assert.Error(t, err) 19 | assert.False(t, valid) 20 | 21 | tok, err := cs.newToken("1337", "userid", now.Add(time.Hour)) 22 | assert.NoError(t, err) 23 | 24 | valid, err = cs.verifyToken(tok, "1337", "userid") 25 | assert.NoError(t, err) 26 | assert.True(t, valid) 27 | 28 | valid, err = cs.verifyToken(tok, "1338", "userid") 29 | assert.NoError(t, err) 30 | assert.False(t, valid) 31 | 32 | valid, err = cs.verifyToken(tok, "1337", "userie") 33 | assert.NoError(t, err) 34 | assert.False(t, valid) 35 | 36 | valid, err = cs.verifyToken(tok+" ", "1337", "userid") 37 | assert.Error(t, err) 38 | assert.False(t, valid) 39 | 40 | // Check token expiry 41 | tok, err = cs.newToken("1337", "userid", now.Add(-time.Hour)) 42 | assert.NoError(t, err, "negative TTL should not fail") 43 | valid, err = cs.verifyToken(tok, "1337", "userid") 44 | assert.Error(t, err, "expired should produce error") 45 | assert.False(t, valid, "expired should not validate") 46 | } 47 | 48 | func TestSessionStoreExists(t *testing.T) { 49 | cs := NewCookieStore([]byte(""), []byte(""), []byte("testtesttesttest")) 50 | 51 | // Fail when attempting to Store with bad context 52 | err := cs.Store(nil, "", "", time.Hour) 53 | assert.Equal(t, err, ErrNoResponseWriter) 54 | 55 | // Fail when attempting to Verify without valid cookie 56 | req, err := http.NewRequest("", "", nil) 57 | v, tm, err := cs.Exists(SetContext(nil, nil, req), "uid") 58 | assert.Error(t, err) 59 | assert.False(t, v) 60 | assert.Equal(t, time.Time{}, tm) 61 | 62 | // Write token to cookie 63 | rec := NewResponseRecorder() 64 | ctx := SetContext(nil, rec, nil) 65 | err = cs.Store(ctx, "token", "uid", time.Hour) 66 | assert.NoError(t, err) 67 | assert.NotNil(t, rec.Header().Get("Set-Cookie")) 68 | 69 | // Read response 70 | resp := rec.Response() 71 | req, err = http.NewRequest("", "", nil) 72 | assert.NoError(t, err) 73 | for _, c := range resp.Cookies() { 74 | req.AddCookie(c) 75 | } 76 | 77 | // Check Exists 78 | v, tm, err = cs.Exists(SetContext(nil, nil, req), "uid") 79 | assert.NoError(t, err) 80 | assert.True(t, v) 81 | assert.NotEqual(t, time.Time{}, tm) 82 | 83 | // Check Exists fails for wrong uid 84 | v, tm, err = cs.Exists(SetContext(nil, nil, req), "anotheruid") 85 | assert.Equal(t, err, ErrWrongTokenUID) 86 | assert.False(t, v) 87 | assert.Equal(t, time.Time{}, tm) 88 | 89 | // Test bad cookie fails verification 90 | req, err = http.NewRequest("", "", nil) 91 | req.AddCookie(&http.Cookie{Name: "passwordless", Value: "invalid!"}) 92 | v, tm, err = cs.Exists(SetContext(nil, nil, req), "uid") 93 | assert.Error(t, err) 94 | assert.False(t, v) 95 | assert.Equal(t, time.Time{}, tm) 96 | } 97 | 98 | func TestSessionStoreVerify(t *testing.T) { 99 | cs := NewCookieStore([]byte(""), []byte(""), []byte("testtesttesttest")) 100 | 101 | // Write token to cookie 102 | rec := NewResponseRecorder() 103 | ctx := SetContext(nil, rec, nil) 104 | err := cs.Store(ctx, "token", "uid", time.Hour) 105 | assert.NoError(t, err) 106 | assert.NotNil(t, rec.Header().Get("Set-Cookie")) 107 | 108 | // Read response 109 | resp := rec.Response() 110 | req, err := http.NewRequest("", "", nil) 111 | assert.NoError(t, err) 112 | for _, c := range resp.Cookies() { 113 | req.AddCookie(c) 114 | } 115 | 116 | // Verify bad token fails 117 | v, err := cs.Verify(SetContext(nil, nil, req), "badtoken", "uid") 118 | assert.NoError(t, err) 119 | assert.False(t, v) 120 | 121 | // Verify good token succeeds 122 | v, err = cs.Verify(SetContext(nil, nil, req), "token", "uid") 123 | assert.NoError(t, err) 124 | assert.True(t, v) 125 | } 126 | 127 | func TestSessionStoreDelete(t *testing.T) { 128 | cs := NewCookieStore([]byte(""), []byte(""), []byte("")) 129 | err := cs.Delete(nil, "") 130 | assert.Equal(t, ErrNoResponseWriter, err) 131 | 132 | rec := NewResponseRecorder() 133 | err = cs.Delete(SetContext(nil, rec, nil), "") 134 | assert.Nil(t, err) 135 | assert.NotEmpty(t, rec.Header().Get("Set-Cookie")) 136 | } 137 | 138 | type ResponseRecorder struct { 139 | *httptest.ResponseRecorder 140 | } 141 | 142 | func NewResponseRecorder() *ResponseRecorder { 143 | return &ResponseRecorder{ 144 | ResponseRecorder: httptest.NewRecorder(), 145 | } 146 | } 147 | 148 | func (r ResponseRecorder) Response() *http.Response { 149 | return &http.Response{ 150 | StatusCode: r.Code, 151 | Header: r.HeaderMap, 152 | Body: ioutil.NopCloser(r.Body), 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /tokens.go: -------------------------------------------------------------------------------- 1 | package passwordless 2 | 3 | import ( 4 | "crypto/rand" 5 | "errors" 6 | "strings" 7 | 8 | "context" 9 | ) 10 | 11 | var ( 12 | crockfordBytes = []byte("0123456789abcdefghjkmnpqrstvwxyz") 13 | ) 14 | 15 | // TokenGenerator defines an interface for generating and sanitising 16 | // cryptographically-secure tokens. 17 | type TokenGenerator interface { 18 | // Generate should return a token and nil error on success, or an empty 19 | // string and error on failure. 20 | Generate(ctx context.Context) (string, error) 21 | 22 | // Sanitize should take a user provided input and sanitize it such that 23 | // it can be passed to a function that expects the same input as 24 | // `Generate()`. Useful for cases where the token may be subject to 25 | // minor transcription errors by a user. (e.g. 0 == O) 26 | Sanitize(ctx context.Context, s string) (string, error) 27 | } 28 | 29 | // ByteGenerator generates random sequences of bytes from the specified set 30 | // of the specified length. 31 | type ByteGenerator struct { 32 | Bytes []byte 33 | Length int 34 | } 35 | 36 | // NewByteGenerator creates and returns a ByteGenerator. 37 | func NewByteGenerator(b []byte, l int) *ByteGenerator { 38 | return &ByteGenerator{ 39 | Bytes: b, 40 | Length: l, 41 | } 42 | } 43 | 44 | // Generate returns a string generated from random bytes of the configured 45 | // set, of the given length. An error may be returned if there is insufficient 46 | // entropy to generate a result. 47 | func (g ByteGenerator) Generate(ctx context.Context) (string, error) { 48 | if b, err := randBytes(g.Bytes, g.Length); err != nil { 49 | return "", err 50 | } else { 51 | return string(b), nil 52 | } 53 | } 54 | 55 | func (g ByteGenerator) Sanitize(ctx context.Context, s string) (string, error) { 56 | return s, nil 57 | } 58 | 59 | // CrockfordGenerator generates random tokens using Douglas Crockford's base 60 | // 32 alphabet which limits characters of similar appearances. The 61 | // Sanitize method of this generator will deal with transcribing incorrect 62 | // characters back to the correct value. 63 | type CrockfordGenerator struct { 64 | Length int 65 | } 66 | 67 | // NewCrockfordGenerator returns a new Crockford token generator that creates 68 | // tokens of the specified length. 69 | func NewCrockfordGenerator(l int) *CrockfordGenerator { 70 | return &CrockfordGenerator{l} 71 | } 72 | 73 | func (g CrockfordGenerator) Generate(ctx context.Context) (string, error) { 74 | if b, err := randBytes(crockfordBytes, g.Length); err != nil { 75 | return "", err 76 | } else { 77 | return string(b), nil 78 | } 79 | } 80 | 81 | // Sanitize attempts to translate strings back to the correct Crockford 82 | // alphabet, in case of user transcribe errors. 83 | func (g CrockfordGenerator) Sanitize(ctx context.Context, s string) (string, error) { 84 | bs := []byte(strings.ToLower(s)) 85 | for i, b := range bs { 86 | if b == 'i' || b == 'l' || b == '|' { 87 | bs[i] = '1' 88 | } else if b == 'o' { 89 | bs[i] = '0' 90 | } 91 | } 92 | return string(bs), nil 93 | } 94 | 95 | // PINGenerator generates numerical PINs of the specifeid length. 96 | type PINGenerator struct { 97 | Length int 98 | } 99 | 100 | // Generate returns a numerical PIN of the chosen length. If there is not 101 | // enough random entropy, the returned string will be empty and an error 102 | // value present. 103 | func (g PINGenerator) Generate(ctx context.Context) (string, error) { 104 | if b, err := randBytes([]byte("0123456789"), g.Length); err != nil { 105 | return "", err 106 | } else { 107 | return string(b), nil 108 | } 109 | } 110 | 111 | func (g PINGenerator) Sanitize(ctx context.Context, s string) (string, error) { 112 | bs := []byte(strings.ToLower(s)) 113 | for i, b := range bs { 114 | if b == 'i' || b == 'l' || b == '|' { 115 | bs[i] = '1' 116 | } else if b == 'o' { 117 | bs[i] = '0' 118 | } else if s[i] == 'B' { 119 | bs[i] = '8' 120 | } else if s[i] == 'b' { 121 | bs[i] = '6' 122 | } else if b == 's' { 123 | bs[i] = '5' 124 | } 125 | } 126 | return string(bs), nil 127 | } 128 | 129 | // randBytes returns a random array of bytes picked from `p` of length `n`. 130 | func randBytes(p []byte, n int) ([]byte, error) { 131 | if len(p) > 256 { 132 | return nil, errors.New("randBytes requires a pool of <= 256 items") 133 | } 134 | c := len(p) 135 | b := make([]byte, n) 136 | if _, err := rand.Read(b); err != nil { 137 | return nil, err 138 | } 139 | // Pick items randomly out of `p`. Because it's possible that 140 | // `len(p) < size(byte)`, use remainder in next iteration to ensure all 141 | // bytes have an equal chance of being selected. 142 | j := 0 // reservoir 143 | for i := 0; i < n; i++ { 144 | bb := int(b[i]) 145 | b[i] = p[(j+bb)%c] 146 | j += (c + (c-bb)%c) % c 147 | } 148 | return b, nil 149 | } 150 | -------------------------------------------------------------------------------- /tokens_test.go: -------------------------------------------------------------------------------- 1 | package passwordless 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestByteGenerator(t *testing.T) { 10 | bg := ByteGenerator{Bytes: []byte("a"), Length: 1} 11 | s, err := bg.Generate(nil) 12 | assert.NoError(t, err) 13 | assert.Equal(t, "a", s) 14 | 15 | bg.Bytes = []byte("b") 16 | s, err = bg.Generate(nil) 17 | assert.NoError(t, err) 18 | assert.Equal(t, "b", s) 19 | 20 | bg.Length = 2 21 | s, err = bg.Generate(nil) 22 | assert.NoError(t, err) 23 | assert.Equal(t, "bb", s) 24 | 25 | d := map[string]int{"aa": 0, "ab": 0, "ba": 0, "bb": 0} 26 | bg.Bytes = []byte("ab") 27 | for len(d) > 0 { 28 | s, err = bg.Generate(nil) 29 | assert.NoError(t, err) 30 | delete(d, s) 31 | } 32 | } 33 | 34 | func TestPINGenerator(t *testing.T) { 35 | // Simple check for length 36 | for _, v := range []int{1, 2, 3, 4, 5} { 37 | ng := PINGenerator{Length: v} 38 | s, err := ng.Generate(nil) 39 | assert.NoError(t, err) 40 | assert.Len(t, s, v) 41 | } 42 | 43 | // Check sanitizer 44 | ng := PINGenerator{} 45 | s, err := ng.Sanitize(nil, "1iIlL2345sS6b78B90oO") 46 | assert.NoError(t, err) 47 | assert.Equal(t, "11111234555667889000", s) 48 | 49 | } 50 | 51 | func TestCrockfordGenerator(t *testing.T) { 52 | // Simple check for length 53 | for _, v := range []int{1, 2, 3, 4, 5} { 54 | ng := CrockfordGenerator{Length: v} 55 | s, err := ng.Generate(nil) 56 | assert.NoError(t, err) 57 | assert.Len(t, s, v) 58 | } 59 | 60 | // Check sanitizer 61 | ng := CrockfordGenerator{} 62 | s, err := ng.Sanitize(nil, "abcdefghijklmnopqrstuvwxyz"+ 63 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789|") 64 | assert.NoError(t, err) 65 | assert.Equal(t, "abcdefgh1jk1mn0pqrstuvwxyz"+ 66 | "abcdefgh1jk1mn0pqrstuvwxyz01234567891", s) 67 | 68 | } 69 | 70 | func TestRandBytes(t *testing.T) { 71 | b, err := randBytes(make([]byte, 500), 5) 72 | assert.Nil(t, b) 73 | assert.Error(t, err) 74 | } 75 | -------------------------------------------------------------------------------- /transport.go: -------------------------------------------------------------------------------- 1 | package passwordless 2 | 3 | import ( 4 | "log" 5 | 6 | "context" 7 | ) 8 | 9 | // Transport represents a mechanism that sends a named recipient a token. 10 | type Transport interface { 11 | // Send instructs the transport to send the given token for the specified 12 | // user to the given recipient, which could be an email address, phone 13 | // number, or something else. 14 | Send(ctx context.Context, token, user, recipient string) error 15 | } 16 | 17 | // LogTransport is intended for testing/debugging purposes that simply logs 18 | // the token to the console. 19 | type LogTransport struct { 20 | MessageFunc func(token, uid string) string 21 | } 22 | 23 | func (lt LogTransport) Send(ctx context.Context, token, user, recipient string) error { 24 | log.Printf(lt.MessageFunc(token, user)) 25 | return nil 26 | } 27 | -------------------------------------------------------------------------------- /transport_smtp.go: -------------------------------------------------------------------------------- 1 | package passwordless 2 | 3 | import ( 4 | "bytes" 5 | "crypto/md5" 6 | "crypto/tls" 7 | "fmt" 8 | "io" 9 | "net" 10 | "net/smtp" 11 | "time" 12 | 13 | "context" 14 | ) 15 | 16 | // ComposerFunc is called when writing the contents of an email, including 17 | // preamble headers. 18 | type ComposerFunc func(ctx context.Context, token, user, recipient string, w io.Writer) error 19 | 20 | // SMTPTransport delivers a user token via e-mail. 21 | type SMTPTransport struct { 22 | UseSSL bool 23 | auth smtp.Auth 24 | from string 25 | addr string 26 | composer ComposerFunc 27 | } 28 | 29 | // NewSMTPTransport returns a new transport capable of sending emails via 30 | // SMTP. `addr` should be in the form "host:port" of the email server. 31 | func NewSMTPTransport(addr, from string, auth smtp.Auth, c ComposerFunc) *SMTPTransport { 32 | return &SMTPTransport{ 33 | UseSSL: false, 34 | addr: addr, 35 | auth: auth, 36 | from: from, 37 | composer: c, 38 | } 39 | } 40 | 41 | // Send sends an email to the email address specified in `recipient`, 42 | // containing the user token provided. 43 | func (t *SMTPTransport) Send(ctx context.Context, token, uid, recipient string) error { 44 | host, _, _ := net.SplitHostPort(t.addr) 45 | 46 | // If UseSSL is true, need to ensure the connection is made over a 47 | // TLS channel. 48 | var c *smtp.Client 49 | if t.UseSSL { 50 | // Connect with SSL handshake 51 | tlscfg := &tls.Config{ 52 | ServerName: host, 53 | } 54 | if conn, err := tls.Dial("tcp", t.addr, tlscfg); err != nil { 55 | return err 56 | } else if c, err = smtp.NewClient(conn, host); err != nil { 57 | defer c.Close() 58 | defer conn.Close() 59 | return err 60 | } 61 | } else { 62 | // Not using SSL handshake 63 | if cl, err := smtp.Dial(t.addr); err != nil { 64 | return err 65 | } else { 66 | c = cl 67 | defer c.Close() 68 | } 69 | } 70 | 71 | // Use STARTTLS if available 72 | if ok, _ := c.Extension("STARTTLS"); ok { 73 | config := &tls.Config{ServerName: host} 74 | if err := c.StartTLS(config); err != nil { 75 | return err 76 | } 77 | } 78 | 79 | // Use auth credentials if supported and provided 80 | if ok, _ := c.Extension("AUTH"); ok && t.auth != nil { 81 | if err := c.Auth(t.auth); err != nil { 82 | return err 83 | } 84 | } 85 | 86 | // Compose email 87 | if err := c.Mail(t.from); err != nil { 88 | return err 89 | } 90 | if err := c.Rcpt(recipient); err != nil { 91 | return err 92 | } 93 | 94 | // Write body 95 | w, err := c.Data() 96 | if err != nil { 97 | return err 98 | } 99 | 100 | // Emit message body 101 | if err := t.composer(ctx, token, uid, recipient, w); err != nil { 102 | return err 103 | } 104 | 105 | // Close writer 106 | if err := w.Close(); err != nil { 107 | return err 108 | } 109 | 110 | // Succeeded; quit nicely 111 | return c.Quit() 112 | } 113 | 114 | // Email is a helper for creating multipart (text and html) emails 115 | type Email struct { 116 | Body []struct{ t, c string } 117 | To string 118 | Subject string 119 | Date time.Time 120 | } 121 | 122 | // AddBody adds a content section to the email. The `contentType` should 123 | // be a known type, such as "text/html" or "text/plain". If no `contentType` 124 | // is provided, "text/plain" is used. Call this method for each required 125 | // body, with the most preferable type last. 126 | func (e *Email) AddBody(contentType, body string) { 127 | if e.Body == nil { 128 | e.Body = make([]struct{ t, c string }, 0) 129 | } 130 | if contentType == "" { 131 | contentType = "text/plain" 132 | } 133 | e.Body = append(e.Body, struct{ t, c string }{contentType, body}) 134 | } 135 | 136 | // Write emits the Email to the specified writer. 137 | func (e Email) Write(w io.Writer) (int64, error) { 138 | return e.Buffer().WriteTo(w) 139 | } 140 | 141 | // Bytes returns the contents of the email as a series of bytes. 142 | func (e Email) Bytes() []byte { 143 | return e.Buffer().Bytes() 144 | } 145 | 146 | // Buffer generates the email header and contents as a `Buffer`. 147 | func (e Email) Buffer() *bytes.Buffer { 148 | crlf := "\r\n" 149 | b := bytes.NewBuffer(nil) 150 | 151 | if e.Date.IsZero() { 152 | b.WriteString("Date: " + time.Now().UTC().Format(time.RFC822) + crlf) 153 | } else { 154 | b.WriteString("Date: " + e.Date.UTC().Format(time.RFC822) + crlf) 155 | } 156 | 157 | if e.Subject != "" { 158 | b.WriteString("Subject: " + e.Subject + crlf) 159 | } 160 | if e.To != "" { 161 | b.WriteString("To: " + e.To + crlf) 162 | } 163 | 164 | boundary := "" 165 | 166 | // Write multipart header if email contains multiple parts 167 | if len(e.Body) > 1 { 168 | // Generate unique boundary to separate sections 169 | h := md5.New() 170 | io.WriteString(h, fmt.Sprintf("%d", time.Now().UnixNano())) 171 | boundary = fmt.Sprintf("%x", h.Sum(nil)) 172 | 173 | // Write boundary 174 | b.WriteString("MIME-Version: 1.0" + crlf) 175 | b.WriteString("Content-Type: multipart/alternative; boundary=" + 176 | boundary + crlf + crlf) 177 | } 178 | 179 | // Write each part 180 | for _, body := range e.Body { 181 | if boundary != "" { 182 | b.WriteString(crlf + "--" + boundary + crlf) 183 | } 184 | b.WriteString("Content-Type: " + body.t + "; charset=\"UTF-8\";") 185 | b.WriteString(crlf + crlf + body.c) 186 | } 187 | if boundary != "" { 188 | b.WriteString(crlf + "--" + boundary + "--") 189 | } 190 | b.WriteString(crlf) 191 | 192 | return b 193 | } 194 | -------------------------------------------------------------------------------- /transport_smtp_test.go: -------------------------------------------------------------------------------- 1 | package passwordless 2 | 3 | import ( 4 | "io/ioutil" 5 | "mime/multipart" 6 | "net/mail" 7 | "regexp" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestEmail(t *testing.T) { 15 | d := time.Date(2001, 2, 3, 4, 5, 6, 0, time.UTC) 16 | e := Email{ 17 | To: "bender@ilovebender.com", 18 | Subject: "Mom Calling", 19 | Date: d, 20 | } 21 | 22 | // Empty body 23 | m, err := mail.ReadMessage(e.Buffer()) 24 | assert.NoError(t, err) 25 | assert.Equal(t, "bender@ilovebender.com", m.Header.Get("To")) 26 | assert.Equal(t, "Mom Calling", m.Header.Get("Subject")) 27 | assert.Equal(t, d.Format(time.RFC822), m.Header.Get("Date")) 28 | 29 | // Plain body 30 | e.AddBody("", "Hello dear") 31 | m, err = mail.ReadMessage(e.Buffer()) 32 | assert.NoError(t, err) 33 | assert.Equal(t, "text/plain; charset=\"UTF-8\";", m.Header.Get("Content-Type")) 34 | body, err := ioutil.ReadAll(m.Body) 35 | assert.NoError(t, err) 36 | assert.Equal(t, "Hello dear\r\n", string(body)) 37 | 38 | // Additional HTML body (multipart) 39 | e.AddBody("text/html", "Hello dear") 40 | m, err = mail.ReadMessage(e.Buffer()) 41 | assert.Equal(t, "1.0", m.Header.Get("MIME-Version")) 42 | ct := m.Header.Get("Content-Type") 43 | re := regexp.MustCompile("^multipart/alternative; boundary=([a-z0-9]+)$") 44 | assert.Regexp(t, re, ct) 45 | boundary := re.FindStringSubmatch(ct)[1] 46 | assert.NotEmpty(t, boundary) 47 | 48 | mpr := multipart.NewReader(m.Body, boundary) 49 | 50 | // Read first part 51 | p, err := mpr.NextPart() 52 | assert.NoError(t, err, "reading first part") 53 | assert.Equal(t, "text/plain; charset=\"UTF-8\";", p.Header.Get("Content-Type")) 54 | body, err = ioutil.ReadAll(p) 55 | assert.NoError(t, err, "reading body of first part") 56 | assert.Equal(t, "Hello dear", string(body)) 57 | 58 | // Read second part 59 | p, err = mpr.NextPart() 60 | assert.NoError(t, err, "reading second part") 61 | assert.Equal(t, "text/html; charset=\"UTF-8\";", p.Header.Get("Content-Type")) 62 | body, err = ioutil.ReadAll(p) 63 | assert.NoError(t, err, "reading body of second part") 64 | assert.Equal(t, "Hello dear", string(body)) 65 | 66 | // Read (non-existent) next part 67 | p, err = mpr.NextPart() 68 | assert.Nil(t, p) 69 | } 70 | --------------------------------------------------------------------------------