├── .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 |{{ .Error.Description }}
9 |