auth
auth/token.go
Support email verification. The bulk of this commit is auto-modifying files to export variables (mostly our request error types and our response type) so that they can be reused in a Go client for that API. We also implement the beginnings of a Go client for that API, implementing the bare minimum we need for our immediate purposes: the ability to retrieve information about a Login. This, of course, means we need an API endpoint that will return information about a Login, which in turn required us to implement a GetLogin method in our profileStore. Which got in-memory and postgres implementations. That done, we could add the Verification field and Verified field to the Login type, to keep track of whether we've verified the user's ownership of those communication methods (if the Login is, in fact, a communication method). This required us to update sql/postgres_init.sql to account for the new fields we're tracking. It also means that when creating a Login, we had to generate a UUID to use as the Verification field. To make things complete, we needed a verifyLogin method on the profileStore to mark a Login as verified. That, in turn, required an endpoint to control this through the API. While doing so, I lumped things together in an UpdateLogin handler just so we could reuse the endpoint and logic when resending a verification email that may have never reached the user, for whatever reason (the quintessential "send again" button). Finally, we implemented an email_verification listener that will pull email_verification events off NSQ, check for the requisite data integrity, and use mailgun to email out a verification/welcome email.
| paddy@28 | 1 package auth |
| paddy@28 | 2 |
| paddy@28 | 3 import ( |
| paddy@123 | 4 "encoding/json" |
| paddy@28 | 5 "errors" |
| paddy@123 | 6 "log" |
| paddy@123 | 7 "net/http" |
| paddy@168 | 8 "strings" |
| paddy@28 | 9 "time" |
| paddy@28 | 10 |
| paddy@107 | 11 "code.secondbit.org/uuid.hg" |
| paddy@168 | 12 |
| paddy@168 | 13 "github.com/dgrijalva/jwt-go" |
| paddy@28 | 14 ) |
| paddy@28 | 15 |
| paddy@69 | 16 const ( |
| paddy@168 | 17 defaultTokenExpiration = 900 // fifteen minutes |
| paddy@69 | 18 ) |
| paddy@69 | 19 |
| paddy@123 | 20 func init() { |
| paddy@123 | 21 RegisterGrantType("refresh_token", GrantType{ |
| paddy@123 | 22 Validate: refreshTokenValidate, |
| paddy@123 | 23 Invalidate: refreshTokenInvalidate, |
| paddy@123 | 24 IssuesRefresh: true, |
| paddy@123 | 25 ReturnToken: RenderJSONToken, |
| paddy@124 | 26 AuditString: refreshTokenAuditString, |
| paddy@123 | 27 }) |
| paddy@123 | 28 } |
| paddy@123 | 29 |
| paddy@28 | 30 var ( |
| paddy@57 | 31 // ErrNoTokenStore is returned when a Context tries to act on a tokenStore without setting one first. |
| paddy@57 | 32 ErrNoTokenStore = errors.New("no tokenStore was specified for the Context") |
| paddy@57 | 33 // ErrTokenNotFound is returned when a Token is requested but not found in a tokenStore. |
| paddy@57 | 34 ErrTokenNotFound = errors.New("token not found in tokenStore") |
| paddy@57 | 35 // ErrTokenAlreadyExists is returned when a Token is added to a tokenStore, but another Token with |
| paddy@57 | 36 // the same AccessToken property already exists in the tokenStore. |
| paddy@57 | 37 ErrTokenAlreadyExists = errors.New("token already exists in tokenStore") |
| paddy@28 | 38 ) |
| paddy@28 | 39 |
| paddy@57 | 40 // Token represents an access and/or refresh token that the Client can use to access user data |
| paddy@57 | 41 // or obtain a new access token. |
| paddy@28 | 42 type Token struct { |
| paddy@168 | 43 AccessToken string |
| paddy@168 | 44 RefreshToken string |
| paddy@168 | 45 Created time.Time |
| paddy@168 | 46 CreatedFrom string |
| paddy@168 | 47 ExpiresIn int32 |
| paddy@168 | 48 TokenType string |
| paddy@168 | 49 Scopes Scopes |
| paddy@168 | 50 ProfileID uuid.ID |
| paddy@168 | 51 ClientID uuid.ID |
| paddy@168 | 52 Revoked bool |
| paddy@28 | 53 } |
| paddy@28 | 54 |
| paddy@168 | 55 func (t Token) GenerateAccessToken(privateKey []byte) (string, error) { |
| paddy@168 | 56 access := jwt.New(jwt.SigningMethodHS256) |
| paddy@168 | 57 access.Claims["iss"] = t.ClientID |
| paddy@168 | 58 access.Claims["sub"] = t.ProfileID |
| paddy@168 | 59 access.Claims["exp"] = t.Created.Add(defaultTokenExpiration * time.Second).Unix() |
| paddy@168 | 60 access.Claims["nbf"] = t.Created.Add(-2 * time.Minute).Unix() |
| paddy@168 | 61 access.Claims["iat"] = t.Created.Unix() |
| paddy@168 | 62 access.Claims["scope"] = strings.Join(t.Scopes.Strings(), " ") |
| paddy@168 | 63 return access.SignedString(privateKey) |
| paddy@168 | 64 } |
| paddy@168 | 65 |
| paddy@168 | 66 // BUG(paddy): Now that access tokens are generated and have a meaning, refresh tokens should be the primary key |
| paddy@168 | 67 |
| paddy@57 | 68 type tokenStore interface { |
| paddy@57 | 69 getToken(token string, refresh bool) (Token, error) |
| paddy@57 | 70 saveToken(token Token) error |
| paddy@168 | 71 revokeToken(token string) error |
| paddy@57 | 72 getTokensByProfileID(profileID uuid.ID, num, offset int) ([]Token, error) |
| paddy@162 | 73 revokeTokensByProfileID(profileID uuid.ID) error |
| paddy@164 | 74 revokeTokensByClientID(clientID uuid.ID) error |
| paddy@28 | 75 } |
| paddy@28 | 76 |
| paddy@57 | 77 func (m *memstore) getToken(token string, refresh bool) (Token, error) { |
| paddy@28 | 78 if refresh { |
| paddy@28 | 79 t, err := m.lookupTokenByRefresh(token) |
| paddy@28 | 80 if err != nil { |
| paddy@28 | 81 return Token{}, err |
| paddy@28 | 82 } |
| paddy@28 | 83 token = t |
| paddy@28 | 84 } |
| paddy@28 | 85 m.tokenLock.RLock() |
| paddy@28 | 86 defer m.tokenLock.RUnlock() |
| paddy@28 | 87 result, ok := m.tokens[token] |
| paddy@28 | 88 if !ok { |
| paddy@28 | 89 return Token{}, ErrTokenNotFound |
| paddy@28 | 90 } |
| paddy@28 | 91 return result, nil |
| paddy@28 | 92 } |
| paddy@28 | 93 |
| paddy@57 | 94 func (m *memstore) saveToken(token Token) error { |
| paddy@28 | 95 m.tokenLock.Lock() |
| paddy@28 | 96 defer m.tokenLock.Unlock() |
| paddy@28 | 97 _, ok := m.tokens[token.AccessToken] |
| paddy@28 | 98 if ok { |
| paddy@28 | 99 return ErrTokenAlreadyExists |
| paddy@28 | 100 } |
| paddy@28 | 101 m.tokens[token.AccessToken] = token |
| paddy@28 | 102 if token.RefreshToken != "" { |
| paddy@28 | 103 m.refreshTokenLookup[token.RefreshToken] = token.AccessToken |
| paddy@28 | 104 } |
| paddy@28 | 105 if _, ok = m.profileTokenLookup[token.ProfileID.String()]; ok { |
| paddy@28 | 106 m.profileTokenLookup[token.ProfileID.String()] = append(m.profileTokenLookup[token.ProfileID.String()], token.AccessToken) |
| paddy@28 | 107 } else { |
| paddy@28 | 108 m.profileTokenLookup[token.ProfileID.String()] = []string{token.AccessToken} |
| paddy@28 | 109 } |
| paddy@28 | 110 return nil |
| paddy@28 | 111 } |
| paddy@28 | 112 |
| paddy@168 | 113 func (m *memstore) revokeToken(token string) error { |
| paddy@168 | 114 token, err := m.lookupTokenByRefresh(token) |
| paddy@168 | 115 if err != nil { |
| paddy@168 | 116 return err |
| paddy@91 | 117 } |
| paddy@91 | 118 m.tokenLock.Lock() |
| paddy@91 | 119 defer m.tokenLock.Unlock() |
| paddy@91 | 120 t, ok := m.tokens[token] |
| paddy@91 | 121 if !ok { |
| paddy@91 | 122 return ErrTokenNotFound |
| paddy@91 | 123 } |
| paddy@168 | 124 t.Revoked = true |
| paddy@91 | 125 m.tokens[token] = t |
| paddy@91 | 126 return nil |
| paddy@91 | 127 } |
| paddy@91 | 128 |
| paddy@162 | 129 func (m *memstore) revokeTokensByProfileID(profileID uuid.ID) error { |
| paddy@162 | 130 ids, err := m.lookupTokensByProfileID(profileID.String()) |
| paddy@162 | 131 if err != nil { |
| paddy@162 | 132 return err |
| paddy@162 | 133 } |
| paddy@162 | 134 if len(ids) < 1 { |
| paddy@162 | 135 return ErrProfileNotFound |
| paddy@162 | 136 } |
| paddy@162 | 137 m.tokenLock.Lock() |
| paddy@162 | 138 defer m.tokenLock.Unlock() |
| paddy@162 | 139 for _, id := range ids { |
| paddy@164 | 140 token := m.tokens[id] |
| paddy@164 | 141 token.Revoked = true |
| paddy@164 | 142 m.tokens[id] = token |
| paddy@164 | 143 } |
| paddy@164 | 144 return nil |
| paddy@164 | 145 } |
| paddy@164 | 146 |
| paddy@164 | 147 func (m *memstore) revokeTokensByClientID(clientID uuid.ID) error { |
| paddy@164 | 148 m.tokenLock.Lock() |
| paddy@164 | 149 defer m.tokenLock.Unlock() |
| paddy@164 | 150 for id, token := range m.tokens { |
| paddy@164 | 151 if !token.ClientID.Equal(clientID) { |
| paddy@164 | 152 continue |
| paddy@164 | 153 } |
| paddy@164 | 154 token.Revoked = true |
| paddy@164 | 155 m.tokens[id] = token |
| paddy@162 | 156 } |
| paddy@162 | 157 return nil |
| paddy@162 | 158 } |
| paddy@162 | 159 |
| paddy@57 | 160 func (m *memstore) getTokensByProfileID(profileID uuid.ID, num, offset int) ([]Token, error) { |
| paddy@28 | 161 ids, err := m.lookupTokensByProfileID(profileID.String()) |
| paddy@28 | 162 if err != nil { |
| paddy@28 | 163 return []Token{}, err |
| paddy@28 | 164 } |
| paddy@28 | 165 if len(ids) > num+offset { |
| paddy@28 | 166 ids = ids[offset : num+offset] |
| paddy@28 | 167 } else if len(ids) > offset { |
| paddy@28 | 168 ids = ids[offset:] |
| paddy@28 | 169 } else { |
| paddy@28 | 170 return []Token{}, nil |
| paddy@28 | 171 } |
| paddy@28 | 172 tokens := []Token{} |
| paddy@28 | 173 for _, id := range ids { |
| paddy@57 | 174 token, err := m.getToken(id, false) |
| paddy@28 | 175 if err != nil { |
| paddy@28 | 176 return []Token{}, err |
| paddy@28 | 177 } |
| paddy@28 | 178 tokens = append(tokens, token) |
| paddy@28 | 179 } |
| paddy@28 | 180 return tokens, nil |
| paddy@28 | 181 } |
| paddy@123 | 182 |
| paddy@163 | 183 func refreshTokenValidate(w http.ResponseWriter, r *http.Request, context Context) (scopes Scopes, profileID uuid.ID, valid bool) { |
| paddy@123 | 184 enc := json.NewEncoder(w) |
| paddy@123 | 185 refresh := r.PostFormValue("refresh_token") |
| paddy@123 | 186 if refresh == "" { |
| paddy@123 | 187 w.WriteHeader(http.StatusBadRequest) |
| paddy@123 | 188 renderJSONError(enc, "invalid_request") |
| paddy@123 | 189 return |
| paddy@123 | 190 } |
| paddy@123 | 191 token, err := context.GetToken(refresh, true) |
| paddy@123 | 192 if err != nil { |
| paddy@123 | 193 if err == ErrTokenNotFound { |
| paddy@123 | 194 w.WriteHeader(http.StatusBadRequest) |
| paddy@123 | 195 renderJSONError(enc, "invalid_grant") |
| paddy@123 | 196 return |
| paddy@123 | 197 } |
| paddy@123 | 198 log.Println("Error exchanging refresh token:", err) |
| paddy@123 | 199 w.WriteHeader(http.StatusInternalServerError) |
| paddy@123 | 200 renderJSONError(enc, "server_error") |
| paddy@123 | 201 return |
| paddy@123 | 202 } |
| paddy@123 | 203 clientID, _, ok := getClientAuth(w, r, true) |
| paddy@123 | 204 if !ok { |
| paddy@123 | 205 return |
| paddy@123 | 206 } |
| paddy@123 | 207 if !token.ClientID.Equal(clientID) { |
| paddy@123 | 208 w.WriteHeader(http.StatusBadRequest) |
| paddy@123 | 209 renderJSONError(enc, "invalid_grant") |
| paddy@123 | 210 return |
| paddy@123 | 211 } |
| paddy@168 | 212 if token.Revoked { |
| paddy@123 | 213 w.WriteHeader(http.StatusBadRequest) |
| paddy@123 | 214 renderJSONError(enc, "invalid_grant") |
| paddy@123 | 215 return |
| paddy@123 | 216 } |
| paddy@135 | 217 return token.Scopes, token.ProfileID, true |
| paddy@123 | 218 } |
| paddy@123 | 219 |
| paddy@123 | 220 func refreshTokenInvalidate(r *http.Request, context Context) error { |
| paddy@123 | 221 refresh := r.PostFormValue("refresh_token") |
| paddy@123 | 222 if refresh == "" { |
| paddy@123 | 223 return ErrTokenNotFound |
| paddy@123 | 224 } |
| paddy@168 | 225 return context.RevokeToken(refresh) |
| paddy@123 | 226 } |
| paddy@124 | 227 |
| paddy@124 | 228 func refreshTokenAuditString(r *http.Request) string { |
| paddy@124 | 229 return "refresh_token:" + r.PostFormValue("refresh_token") |
| paddy@124 | 230 } |