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.
16 "code.secondbit.org/pass.hg"
17 "code.secondbit.org/uuid.hg"
18 "github.com/gorilla/mux"
22 authCookieName = "auth"
23 loginTemplateName = "login"
27 RegisterGrantType("password", GrantType{
28 Validate: credentialsValidate,
31 ReturnToken: RenderJSONToken,
32 AuditString: credentialsAuditString,
37 // ErrNoSessionStore is returned when a Context tries to act on a sessionStore without setting on first.
38 ErrNoSessionStore = errors.New("no sessionStore was specified for the Context")
39 // ErrSessionNotFound is returned when a Session is requested but not found in the sessionStore.
40 ErrSessionNotFound = errors.New("session not found in sessionStore")
41 // ErrInvalidSession is returned when a Session is specified but is not valid.
42 ErrInvalidSession = errors.New("session is not valid")
43 // ErrSessionAlreadyExists is returned when a sessionStore tries to store a Session with an ID that already exists in the sessionStore.
44 ErrSessionAlreadyExists = errors.New("session already exists")
45 // ErrCSRFAttempt is returned when a CSRF attempt is detected.
46 ErrCSRFAttempt = errors.New("CSRF attempt")
48 passphraseSchemes = map[int]passphraseScheme{
50 check: pbkdf2sha256check,
51 create: pbkdf2sha256create,
52 calculateIterations: pbkdf2sha256calc,
57 type passphraseScheme struct {
58 check func(profile Profile, passphrase string) (bool, error)
59 create func(passphrase string, iterations int) (result, salt string, err error)
60 calculateIterations func() (int, error)
63 // Session represents a user's authenticated session, associating it with a profile
64 // and some audit data.
77 type sortedSessions []Session
79 func (s sortedSessions) Len() int {
83 func (s sortedSessions) Less(i, j int) bool {
84 return s[i].Created.After(s[j].Created)
87 func (s sortedSessions) Swap(i, j int) {
88 s[i], s[j] = s[j], s[i]
91 type sessionStore interface {
92 createSession(session Session) error
93 getSession(id string) (Session, error)
94 terminateSession(id string) error
95 removeSession(id string) error
96 listSessions(profile uuid.ID, before time.Time, num int64) ([]Session, error)
97 terminateSessionsByProfile(profile uuid.ID) error
100 func (m *memstore) createSession(session Session) error {
102 defer m.sessionLock.Unlock()
103 if _, ok := m.sessions[session.ID]; ok {
104 return ErrSessionAlreadyExists
106 m.sessions[session.ID] = session
110 func (m *memstore) getSession(id string) (Session, error) {
111 m.sessionLock.RLock()
112 defer m.sessionLock.RUnlock()
113 if _, ok := m.sessions[id]; !ok {
114 return Session{}, ErrSessionNotFound
116 return m.sessions[id], nil
119 func (m *memstore) terminateSession(id string) error {
120 m.sessionLock.RLock()
121 defer m.sessionLock.RUnlock()
122 sess, ok := m.sessions[id]
124 return ErrSessionNotFound
127 m.sessions[id] = sess
131 func (m *memstore) terminateSessionsByProfile(profile uuid.ID) error {
132 m.sessionLock.RLock()
133 defer m.sessionLock.RUnlock()
135 for _, session := range m.sessions {
136 if profile.Equal(session.ProfileID) {
137 session.Active = false
138 m.sessions[session.ID] = session
143 return ErrProfileNotFound
148 func (m *memstore) removeSession(id string) error {
150 defer m.sessionLock.Unlock()
151 if _, ok := m.sessions[id]; !ok {
152 return ErrSessionNotFound
154 delete(m.sessions, id)
158 func (m *memstore) listSessions(profile uuid.ID, before time.Time, num int64) ([]Session, error) {
159 m.sessionLock.RLock()
160 defer m.sessionLock.RUnlock()
162 for _, session := range m.sessions {
163 if int64(len(res)) >= num {
166 if profile != nil && !profile.Equal(session.ProfileID) {
169 if !before.IsZero() && session.Created.After(before) {
172 res = append(res, session)
174 sorted := sortedSessions(res)
176 res = []Session(sorted)
180 // RegisterSessionHandlers adds handlers to the passed router to handle the session endpoints, like login and logout.
181 func RegisterSessionHandlers(r *mux.Router, context Context) {
182 r.Handle("/login", wrap(context, CreateSessionHandler))
183 // BUG(paddy): We need to implement a handler for listing sessions active on a profile.
184 r.Handle("/sessions/{id}", wrap(context, TerminateSessionHandler)).Methods("OPTIONS", "DELETE")
187 func checkCSRF(r *http.Request, s Session) error {
188 if r.PostFormValue("csrftoken") != s.CSRFToken {
189 return ErrCSRFAttempt
194 func checkCookie(r *http.Request, context Context) (Session, error) {
195 cookie, err := r.Cookie(authCookieName)
196 if err == http.ErrNoCookie {
197 return Session{}, ErrNoSession
198 } else if err != nil {
200 return Session{}, err
202 sess, err := context.GetSession(cookie.Value)
203 if err == ErrSessionNotFound {
204 return Session{}, ErrInvalidSession
205 } else if err != nil {
206 return Session{}, err
209 return Session{}, ErrInvalidSession
211 if time.Now().After(sess.Expires) {
212 return Session{}, ErrInvalidSession
217 func buildLoginRedirect(r *http.Request, context Context) string {
218 if context.loginURI == nil {
221 uri := *context.loginURI
223 q.Set("from", r.URL.String())
224 uri.RawQuery = q.Encode()
228 func pbkdf2sha256check(profile Profile, passphrase string) (bool, error) {
229 realPass, err := hex.DecodeString(profile.Passphrase)
233 realSalt, err := hex.DecodeString(profile.Salt)
237 candidate := pass.Check(sha256.New, profile.Iterations, []byte(passphrase), []byte(realSalt))
238 if !pass.Compare(candidate, realPass) {
239 return false, ErrIncorrectAuth
244 func pbkdf2sha256create(passphrase string, iters int) (result, salt string, err error) {
245 passBytes, saltBytes, err := pass.Create(sha256.New, iters, []byte(passphrase))
249 result = hex.EncodeToString(passBytes)
250 salt = hex.EncodeToString(saltBytes)
251 return result, salt, err
254 func pbkdf2sha256calc() (int, error) {
255 return pass.CalculateIterations(sha256.New)
258 func isAuthError(err error) bool {
259 return err == ErrIncorrectAuth || err == ErrProfileCompromised || err == ErrProfileLocked || err == ErrInvalidPassphraseScheme
262 func authenticate(user, passphrase string, context Context) (Profile, error) {
263 profile, err := context.GetProfileByLogin(user)
265 if err == ErrProfileNotFound || err == ErrLoginNotFound {
266 return Profile{}, ErrIncorrectAuth
268 return Profile{}, err
270 if profile.Compromised {
271 return Profile{}, ErrProfileCompromised
273 if !profile.LockedUntil.IsZero() && profile.LockedUntil.After(time.Now()) {
274 return profile, ErrProfileLocked
276 scheme, ok := passphraseSchemes[profile.PassphraseScheme]
278 return Profile{}, ErrInvalidPassphraseScheme
280 result, err := scheme.check(profile, passphrase)
282 return Profile{}, err
287 // CreateSessionHandler allows the user to log into their account and create their session.
288 func CreateSessionHandler(w http.ResponseWriter, r *http.Request, context Context) {
290 if r.Method == "POST" {
291 profile, err := authenticate(r.PostFormValue("login"), r.PostFormValue("passphrase"), context)
293 ip := r.Header.Get("X-Forwarded-For")
297 sessionID := make([]byte, 32)
298 csrfToken := make([]byte, 32)
299 _, err = rand.Read(sessionID)
301 log.Println("Error reading CSPRNG for session ID:", err)
302 w.WriteHeader(http.StatusInternalServerError)
303 w.Write([]byte("Internal error"))
306 _, err = rand.Read(csrfToken)
308 log.Println("Error reading CSPRNG for CSRF token:", err)
309 w.WriteHeader(http.StatusInternalServerError)
310 w.Write([]byte("internal error"))
314 ID: base64.URLEncoding.EncodeToString(sessionID),
316 UserAgent: r.UserAgent(),
317 ProfileID: profile.ID,
318 Login: r.PostFormValue("login"),
320 Expires: time.Now().Add(time.Hour),
322 CSRFToken: base64.URLEncoding.EncodeToString(csrfToken),
324 err = context.CreateSession(session)
326 w.WriteHeader(http.StatusInternalServerError)
327 w.Write([]byte(err.Error()))
330 // BUG(paddy): We really need to do a security audit on our cookies.
331 cookie := http.Cookie{
332 Name: authCookieName,
334 Expires: session.Expires,
336 Secure: context.config.secureCookie,
338 http.SetCookie(w, &cookie)
339 redirectTo := r.URL.Query().Get("from")
340 if redirectTo == "" {
343 http.Redirect(w, r, redirectTo, http.StatusFound)
345 } else if !isAuthError(err) {
346 w.WriteHeader(http.StatusInternalServerError)
347 w.Write([]byte(err.Error()))
350 errors = append(errors, err)
353 context.Render(w, loginTemplateName, map[string]interface{}{
358 // TerminateSessionHandler allows the user to end their session before it expires.
359 func TerminateSessionHandler(w http.ResponseWriter, r *http.Request, context Context) {
360 var errors []RequestError
362 if vars["id"] == "" {
363 errors = append(errors, RequestError{Slug: RequestErrMissing, Param: "id"})
364 encode(w, r, http.StatusBadRequest, Response{Errors: errors})
368 un, pw, ok := r.BasicAuth()
370 errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
371 encode(w, r, http.StatusUnauthorized, Response{Errors: errors})
374 profile, err := authenticate(un, pw, context)
376 if isAuthError(err) {
377 errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
378 encode(w, r, http.StatusForbidden, Response{Errors: errors})
381 errors = append(errors, RequestError{Slug: RequestErrActOfGod})
382 encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
385 session, err := context.GetSession(id)
387 if err == ErrSessionNotFound {
388 errors = append(errors, RequestError{Slug: RequestErrNotFound, Param: "id"})
389 encode(w, r, http.StatusNotFound, Response{Errors: errors})
392 errors = append(errors, RequestError{Slug: RequestErrActOfGod})
393 encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
396 if !session.ProfileID.Equal(profile.ID) {
397 errors = append(errors, RequestError{Slug: RequestErrAccessDenied, Param: "id"})
398 encode(w, r, http.StatusForbidden, Response{Errors: errors})
401 err = context.TerminateSession(id)
403 if err == ErrSessionNotFound {
404 errors = append(errors, RequestError{Slug: RequestErrNotFound, Param: "id"})
405 encode(w, r, http.StatusNotFound, Response{Errors: errors})
408 errors = append(errors, RequestError{Slug: RequestErrActOfGod})
409 encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
412 session.Active = false
413 encode(w, r, http.StatusOK, Response{Sessions: []Session{session}, Errors: errors})
416 func credentialsValidate(w http.ResponseWriter, r *http.Request, context Context) (scopes Scopes, profileID uuid.ID, valid bool) {
417 enc := json.NewEncoder(w)
418 username := r.PostFormValue("username")
419 password := r.PostFormValue("password")
420 scopes = stringsToScopes(strings.Split(r.PostFormValue("scope"), " "))
421 profile, err := authenticate(username, password, context)
423 if isAuthError(err) {
424 w.WriteHeader(http.StatusBadRequest)
425 renderJSONError(enc, "invalid_grant")
428 w.WriteHeader(http.StatusInternalServerError)
429 w.Write([]byte(err.Error()))
432 profileID = profile.ID
437 func credentialsAuditString(r *http.Request) string {