auth

Paddy 2015-05-17 Parent:807d20a0b197 Child:b0d1b3e39fc8

172:8ecb60d29b0d Browse Files

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.

.hgignore client.go client/client.go client/login.go client_test.go context.go listeners/email_verification/basic.html listeners/email_verification/basic.txt listeners/email_verification/listener.go profile.go profile_postgres.go profile_test.go request.go request_test.go session.go sql/postgres_init.sql

     1.1 --- a/.hgignore	Sun May 17 02:18:07 2015 -0400
     1.2 +++ b/.hgignore	Sun May 17 02:27:36 2015 -0400
     1.3 @@ -1,2 +1,3 @@
     1.4  cover.out
     1.5  authd/authd
     1.6 +listeners/email_verification/email_verification
     2.1 --- a/client.go	Sun May 17 02:18:07 2015 -0400
     2.2 +++ b/client.go	Sun May 17 02:27:36 2015 -0400
     2.3 @@ -455,22 +455,22 @@
     2.4  }
     2.5  
     2.6  func CreateClientHandler(w http.ResponseWriter, r *http.Request, c Context) {
     2.7 -	errors := []requestError{}
     2.8 +	errors := []RequestError{}
     2.9  	username, password, ok := r.BasicAuth()
    2.10  	if !ok {
    2.11 -		errors = append(errors, requestError{Slug: requestErrAccessDenied})
    2.12 -		encode(w, r, http.StatusUnauthorized, response{Errors: errors})
    2.13 +		errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
    2.14 +		encode(w, r, http.StatusUnauthorized, Response{Errors: errors})
    2.15  		return
    2.16  	}
    2.17  	profile, err := authenticate(username, password, c)
    2.18  	if err != nil {
    2.19  		if isAuthError(err) {
    2.20 -			errors = append(errors, requestError{Slug: requestErrAccessDenied})
    2.21 -			encode(w, r, http.StatusUnauthorized, response{Errors: errors})
    2.22 +			errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
    2.23 +			encode(w, r, http.StatusUnauthorized, Response{Errors: errors})
    2.24  		} else {
    2.25  			log.Printf("Error authenticating: %#+v\n", err)
    2.26 -			errors = append(errors, requestError{Slug: requestErrActOfGod})
    2.27 -			encode(w, r, http.StatusInternalServerError, response{Errors: errors})
    2.28 +			errors = append(errors, RequestError{Slug: RequestErrActOfGod})
    2.29 +			encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
    2.30  		}
    2.31  		return
    2.32  	}
    2.33 @@ -482,19 +482,19 @@
    2.34  		return
    2.35  	}
    2.36  	if req.Type == "" {
    2.37 -		errors = append(errors, requestError{Slug: requestErrMissing, Field: "/type"})
    2.38 +		errors = append(errors, RequestError{Slug: RequestErrMissing, Field: "/type"})
    2.39  	} else if req.Type != clientTypePublic && req.Type != clientTypeConfidential {
    2.40 -		errors = append(errors, requestError{Slug: requestErrInvalidValue, Field: "/type"})
    2.41 +		errors = append(errors, RequestError{Slug: RequestErrInvalidValue, Field: "/type"})
    2.42  	}
    2.43  	if req.Name == "" {
    2.44 -		errors = append(errors, requestError{Slug: requestErrMissing, Field: "/name"})
    2.45 +		errors = append(errors, RequestError{Slug: RequestErrMissing, Field: "/name"})
    2.46  	} else if len(req.Name) < minClientNameLen {
    2.47 -		errors = append(errors, requestError{Slug: requestErrInsufficient, Field: "/name"})
    2.48 +		errors = append(errors, RequestError{Slug: RequestErrInsufficient, Field: "/name"})
    2.49  	} else if len(req.Name) > maxClientNameLen {
    2.50 -		errors = append(errors, requestError{Slug: requestErrOverflow, Field: "/name"})
    2.51 +		errors = append(errors, RequestError{Slug: RequestErrOverflow, Field: "/name"})
    2.52  	}
    2.53  	if len(errors) > 0 {
    2.54 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
    2.55 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
    2.56  		return
    2.57  	}
    2.58  	client := Client{
    2.59 @@ -518,8 +518,8 @@
    2.60  	err = c.SaveClient(client)
    2.61  	if err != nil {
    2.62  		if err == ErrClientAlreadyExists {
    2.63 -			errors = append(errors, requestError{Slug: requestErrConflict, Field: "/id"})
    2.64 -			encode(w, r, http.StatusBadRequest, response{Errors: errors})
    2.65 +			errors = append(errors, RequestError{Slug: RequestErrConflict, Field: "/id"})
    2.66 +			encode(w, r, http.StatusBadRequest, Response{Errors: errors})
    2.67  			return
    2.68  		}
    2.69  		log.Printf("Error saving client: %#+v\n", err)
    2.70 @@ -530,11 +530,11 @@
    2.71  	for pos, u := range req.Endpoints {
    2.72  		uri, err := url.Parse(u)
    2.73  		if err != nil {
    2.74 -			errors = append(errors, requestError{Slug: requestErrInvalidFormat, Field: "/endpoints/" + strconv.Itoa(pos)})
    2.75 +			errors = append(errors, RequestError{Slug: RequestErrInvalidFormat, Field: "/endpoints/" + strconv.Itoa(pos)})
    2.76  			continue
    2.77  		}
    2.78  		if !uri.IsAbs() {
    2.79 -			errors = append(errors, requestError{Slug: requestErrInvalidValue, Field: "/endpoints/" + strconv.Itoa(pos)})
    2.80 +			errors = append(errors, RequestError{Slug: RequestErrInvalidValue, Field: "/endpoints/" + strconv.Itoa(pos)})
    2.81  			continue
    2.82  		}
    2.83  		endpoint := Endpoint{
    2.84 @@ -548,11 +548,11 @@
    2.85  	err = c.AddEndpoints(endpoints)
    2.86  	if err != nil {
    2.87  		log.Printf("Error adding endpoints: %#+v\n", err)
    2.88 -		errors = append(errors, requestError{Slug: requestErrActOfGod})
    2.89 -		encode(w, r, http.StatusInternalServerError, response{Errors: errors, Clients: []Client{client}})
    2.90 +		errors = append(errors, RequestError{Slug: RequestErrActOfGod})
    2.91 +		encode(w, r, http.StatusInternalServerError, Response{Errors: errors, Clients: []Client{client}})
    2.92  		return
    2.93  	}
    2.94 -	resp := response{
    2.95 +	resp := Response{
    2.96  		Clients:   []Client{client},
    2.97  		Endpoints: endpoints,
    2.98  		Errors:    errors,
    2.99 @@ -561,28 +561,28 @@
   2.100  }
   2.101  
   2.102  func GetClientHandler(w http.ResponseWriter, r *http.Request, c Context) {
   2.103 -	errors := []requestError{}
   2.104 +	errors := []RequestError{}
   2.105  	vars := mux.Vars(r)
   2.106  	if vars["id"] == "" {
   2.107 -		errors = append(errors, requestError{Slug: requestErrMissing, Param: "id"})
   2.108 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
   2.109 +		errors = append(errors, RequestError{Slug: RequestErrMissing, Param: "id"})
   2.110 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
   2.111  		return
   2.112  	}
   2.113  	id, err := uuid.Parse(vars["id"])
   2.114  	if err != nil {
   2.115 -		errors = append(errors, requestError{Slug: requestErrInvalidFormat, Param: "id"})
   2.116 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
   2.117 +		errors = append(errors, RequestError{Slug: RequestErrInvalidFormat, Param: "id"})
   2.118 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
   2.119  		return
   2.120  	}
   2.121  	client, err := c.GetClient(id)
   2.122  	if err != nil {
   2.123  		if err == ErrClientNotFound {
   2.124 -			errors = append(errors, requestError{Slug: requestErrNotFound, Param: "id"})
   2.125 -			encode(w, r, http.StatusNotFound, response{Errors: errors})
   2.126 +			errors = append(errors, RequestError{Slug: RequestErrNotFound, Param: "id"})
   2.127 +			encode(w, r, http.StatusNotFound, Response{Errors: errors})
   2.128  			return
   2.129  		}
   2.130 -		errors = append(errors, requestError{Slug: requestErrActOfGod})
   2.131 -		encode(w, r, http.StatusInternalServerError, response{Errors: errors})
   2.132 +		errors = append(errors, RequestError{Slug: RequestErrActOfGod})
   2.133 +		encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
   2.134  		return
   2.135  	}
   2.136  	username, password, ok := r.BasicAuth()
   2.137 @@ -592,11 +592,11 @@
   2.138  		profile, err := authenticate(username, password, c)
   2.139  		if err != nil {
   2.140  			if isAuthError(err) {
   2.141 -				errors = append(errors, requestError{Slug: requestErrAccessDenied})
   2.142 -				encode(w, r, http.StatusUnauthorized, response{Errors: errors})
   2.143 +				errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
   2.144 +				encode(w, r, http.StatusUnauthorized, Response{Errors: errors})
   2.145  			} else {
   2.146 -				errors = append(errors, requestError{Slug: requestErrActOfGod})
   2.147 -				encode(w, r, http.StatusInternalServerError, response{Errors: errors})
   2.148 +				errors = append(errors, RequestError{Slug: RequestErrActOfGod})
   2.149 +				encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
   2.150  			}
   2.151  			return
   2.152  		}
   2.153 @@ -604,7 +604,7 @@
   2.154  			client.Secret = ""
   2.155  		}
   2.156  	}
   2.157 -	resp := response{
   2.158 +	resp := Response{
   2.159  		Clients: []Client{client},
   2.160  		Errors:  errors,
   2.161  	}
   2.162 @@ -612,7 +612,7 @@
   2.163  }
   2.164  
   2.165  func ListClientsHandler(w http.ResponseWriter, r *http.Request, c Context) {
   2.166 -	errors := []requestError{}
   2.167 +	errors := []RequestError{}
   2.168  	var err error
   2.169  	// BUG(paddy): If ids are provided in query params, retrieve only those clients
   2.170  	num := defaultClientResponseSize
   2.171 @@ -623,38 +623,38 @@
   2.172  	if numStr != "" {
   2.173  		num, err = strconv.Atoi(numStr)
   2.174  		if err != nil {
   2.175 -			errors = append(errors, requestError{Slug: requestErrInvalidFormat, Param: "num"})
   2.176 +			errors = append(errors, RequestError{Slug: RequestErrInvalidFormat, Param: "num"})
   2.177  		}
   2.178  		if num > maxClientResponseSize {
   2.179 -			errors = append(errors, requestError{Slug: requestErrOverflow, Param: "num"})
   2.180 +			errors = append(errors, RequestError{Slug: RequestErrOverflow, Param: "num"})
   2.181  		}
   2.182  		if num < 1 {
   2.183 -			errors = append(errors, requestError{Slug: requestErrInsufficient, Param: "num"})
   2.184 +			errors = append(errors, RequestError{Slug: RequestErrInsufficient, Param: "num"})
   2.185  		}
   2.186  	}
   2.187  	if offsetStr != "" {
   2.188  		offset, err = strconv.Atoi(offsetStr)
   2.189  		if err != nil {
   2.190 -			errors = append(errors, requestError{Slug: requestErrInvalidFormat, Param: "offset"})
   2.191 +			errors = append(errors, RequestError{Slug: RequestErrInvalidFormat, Param: "offset"})
   2.192  		}
   2.193  	}
   2.194  	if ownerIDStr == "" {
   2.195 -		errors = append(errors, requestError{Slug: requestErrMissing, Param: "owner_id"})
   2.196 +		errors = append(errors, RequestError{Slug: RequestErrMissing, Param: "owner_id"})
   2.197  	}
   2.198  	if len(errors) > 0 {
   2.199 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
   2.200 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
   2.201  		return
   2.202  	}
   2.203  	ownerID, err := uuid.Parse(ownerIDStr)
   2.204  	if err != nil {
   2.205 -		errors = append(errors, requestError{Slug: requestErrInvalidFormat, Param: "owner_id"})
   2.206 -		encode(w, r, http.StatusInternalServerError, response{Errors: errors})
   2.207 +		errors = append(errors, RequestError{Slug: RequestErrInvalidFormat, Param: "owner_id"})
   2.208 +		encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
   2.209  		return
   2.210  	}
   2.211  	clients, err := c.ListClientsByOwner(ownerID, num, offset)
   2.212  	if err != nil {
   2.213 -		errors = append(errors, requestError{Slug: requestErrActOfGod})
   2.214 -		encode(w, r, http.StatusInternalServerError, response{Errors: errors})
   2.215 +		errors = append(errors, RequestError{Slug: RequestErrActOfGod})
   2.216 +		encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
   2.217  		return
   2.218  	}
   2.219  	username, password, ok := r.BasicAuth()
   2.220 @@ -667,11 +667,11 @@
   2.221  		profile, err := authenticate(username, password, c)
   2.222  		if err != nil {
   2.223  			if isAuthError(err) {
   2.224 -				errors = append(errors, requestError{Slug: requestErrAccessDenied})
   2.225 -				encode(w, r, http.StatusUnauthorized, response{Errors: errors})
   2.226 +				errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
   2.227 +				encode(w, r, http.StatusUnauthorized, Response{Errors: errors})
   2.228  			} else {
   2.229 -				errors = append(errors, requestError{Slug: requestErrActOfGod})
   2.230 -				encode(w, r, http.StatusInternalServerError, response{Errors: errors})
   2.231 +				errors = append(errors, RequestError{Slug: RequestErrActOfGod})
   2.232 +				encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
   2.233  			}
   2.234  			return
   2.235  		}
   2.236 @@ -682,7 +682,7 @@
   2.237  			}
   2.238  		}
   2.239  	}
   2.240 -	resp := response{
   2.241 +	resp := Response{
   2.242  		Clients: clients,
   2.243  		Errors:  errors,
   2.244  	}
   2.245 @@ -690,80 +690,80 @@
   2.246  }
   2.247  
   2.248  func UpdateClientHandler(w http.ResponseWriter, r *http.Request, c Context) {
   2.249 -	errors := []requestError{}
   2.250 +	errors := []RequestError{}
   2.251  	vars := mux.Vars(r)
   2.252  	if _, ok := vars["id"]; !ok {
   2.253 -		errors = append(errors, requestError{Slug: requestErrMissing, Param: "id"})
   2.254 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
   2.255 +		errors = append(errors, RequestError{Slug: RequestErrMissing, Param: "id"})
   2.256 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
   2.257  		return
   2.258  	}
   2.259  	id, err := uuid.Parse(vars["id"])
   2.260  	if err != nil {
   2.261 -		errors = append(errors, requestError{Slug: requestErrInvalidFormat, Param: "id"})
   2.262 +		errors = append(errors, RequestError{Slug: RequestErrInvalidFormat, Param: "id"})
   2.263  	}
   2.264  	username, password, ok := r.BasicAuth()
   2.265  	if !ok {
   2.266 -		errors = append(errors, requestError{Slug: requestErrAccessDenied})
   2.267 -		encode(w, r, http.StatusUnauthorized, response{Errors: errors})
   2.268 +		errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
   2.269 +		encode(w, r, http.StatusUnauthorized, Response{Errors: errors})
   2.270  		return
   2.271  	}
   2.272  	profile, err := authenticate(username, password, c)
   2.273  	if err != nil {
   2.274  		if isAuthError(err) {
   2.275 -			errors = append(errors, requestError{Slug: requestErrAccessDenied})
   2.276 -			encode(w, r, http.StatusUnauthorized, response{Errors: errors})
   2.277 +			errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
   2.278 +			encode(w, r, http.StatusUnauthorized, Response{Errors: errors})
   2.279  		} else {
   2.280 -			errors = append(errors, requestError{Slug: requestErrActOfGod})
   2.281 -			encode(w, r, http.StatusInternalServerError, response{Errors: errors})
   2.282 +			errors = append(errors, RequestError{Slug: RequestErrActOfGod})
   2.283 +			encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
   2.284  		}
   2.285  		return
   2.286  	}
   2.287  	var change ClientChange
   2.288  	err = decode(r, &change)
   2.289  	if err != nil {
   2.290 -		errors = append(errors, requestError{Slug: requestErrInvalidFormat, Field: "/"})
   2.291 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
   2.292 +		errors = append(errors, RequestError{Slug: RequestErrInvalidFormat, Field: "/"})
   2.293 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
   2.294  		return
   2.295  	}
   2.296  	errs := change.Validate()
   2.297  	for _, err := range errs {
   2.298  		switch err {
   2.299  		case ErrEmptyChange:
   2.300 -			errors = append(errors, requestError{Slug: requestErrMissing, Field: "/"})
   2.301 +			errors = append(errors, RequestError{Slug: RequestErrMissing, Field: "/"})
   2.302  		case ErrClientNameTooShort:
   2.303 -			errors = append(errors, requestError{Slug: requestErrInsufficient, Field: "/name"})
   2.304 +			errors = append(errors, RequestError{Slug: RequestErrInsufficient, Field: "/name"})
   2.305  		case ErrClientNameTooLong:
   2.306 -			errors = append(errors, requestError{Slug: requestErrOverflow, Field: "/name"})
   2.307 +			errors = append(errors, RequestError{Slug: RequestErrOverflow, Field: "/name"})
   2.308  		case ErrClientLogoTooLong:
   2.309 -			errors = append(errors, requestError{Slug: requestErrOverflow, Field: "/logo"})
   2.310 +			errors = append(errors, RequestError{Slug: RequestErrOverflow, Field: "/logo"})
   2.311  		case ErrClientLogoNotURL:
   2.312 -			errors = append(errors, requestError{Slug: requestErrInvalidFormat, Field: "/logo"})
   2.313 +			errors = append(errors, RequestError{Slug: RequestErrInvalidFormat, Field: "/logo"})
   2.314  		case ErrClientWebsiteTooLong:
   2.315 -			errors = append(errors, requestError{Slug: requestErrOverflow, Field: "/website"})
   2.316 +			errors = append(errors, RequestError{Slug: RequestErrOverflow, Field: "/website"})
   2.317  		case ErrClientWebsiteNotURL:
   2.318 -			errors = append(errors, requestError{Slug: requestErrInvalidFormat, Field: "/website"})
   2.319 +			errors = append(errors, RequestError{Slug: RequestErrInvalidFormat, Field: "/website"})
   2.320  		default:
   2.321  			log.Println("Unrecognised error from client change validation:", err)
   2.322  		}
   2.323  	}
   2.324  	if len(errors) > 0 {
   2.325 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
   2.326 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
   2.327  		return
   2.328  	}
   2.329  	client, err := c.GetClient(id)
   2.330  	if err == ErrClientNotFound {
   2.331 -		errors = append(errors, requestError{Slug: requestErrNotFound, Param: "id"})
   2.332 -		encode(w, r, http.StatusNotFound, response{Errors: errors})
   2.333 +		errors = append(errors, RequestError{Slug: RequestErrNotFound, Param: "id"})
   2.334 +		encode(w, r, http.StatusNotFound, Response{Errors: errors})
   2.335  		return
   2.336  	} else if err != nil {
   2.337  		log.Println("Error retrieving client:", err)
   2.338 -		errors = append(errors, requestError{Slug: requestErrActOfGod})
   2.339 -		encode(w, r, http.StatusInternalServerError, response{Errors: errors})
   2.340 +		errors = append(errors, RequestError{Slug: RequestErrActOfGod})
   2.341 +		encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
   2.342  		return
   2.343  	}
   2.344  	if !client.OwnerID.Equal(profile.ID) {
   2.345 -		errors = append(errors, requestError{Slug: requestErrAccessDenied})
   2.346 -		encode(w, r, http.StatusForbidden, response{Errors: errors})
   2.347 +		errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
   2.348 +		encode(w, r, http.StatusForbidden, Response{Errors: errors})
   2.349  		return
   2.350  	}
   2.351  	if change.Secret != nil && client.Type == clientTypeConfidential {
   2.352 @@ -779,59 +779,59 @@
   2.353  	err = c.UpdateClient(id, change)
   2.354  	if err != nil {
   2.355  		log.Println("Error updating client:", err)
   2.356 -		errors = append(errors, requestError{Slug: requestErrActOfGod})
   2.357 -		encode(w, r, http.StatusInternalServerError, response{Errors: errors})
   2.358 +		errors = append(errors, RequestError{Slug: RequestErrActOfGod})
   2.359 +		encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
   2.360  		return
   2.361  	}
   2.362  	client.ApplyChange(change)
   2.363 -	encode(w, r, http.StatusOK, response{Clients: []Client{client}, Errors: errors})
   2.364 +	encode(w, r, http.StatusOK, Response{Clients: []Client{client}, Errors: errors})
   2.365  	return
   2.366  }
   2.367  
   2.368  func RemoveClientHandler(w http.ResponseWriter, r *http.Request, c Context) {
   2.369 -	errors := []requestError{}
   2.370 +	errors := []RequestError{}
   2.371  	vars := mux.Vars(r)
   2.372  	if _, ok := vars["id"]; !ok {
   2.373 -		errors = append(errors, requestError{Slug: requestErrMissing, Param: "id"})
   2.374 -		encode(w, r, http.StatusNotFound, response{Errors: errors})
   2.375 +		errors = append(errors, RequestError{Slug: RequestErrMissing, Param: "id"})
   2.376 +		encode(w, r, http.StatusNotFound, Response{Errors: errors})
   2.377  		return
   2.378  	}
   2.379  	id, err := uuid.Parse(vars["id"])
   2.380  	if err != nil {
   2.381 -		errors = append(errors, requestError{Slug: requestErrNotFound, Param: "id"})
   2.382 +		errors = append(errors, RequestError{Slug: RequestErrNotFound, Param: "id"})
   2.383  	}
   2.384  	username, password, ok := r.BasicAuth()
   2.385  	if !ok {
   2.386 -		errors = append(errors, requestError{Slug: requestErrAccessDenied})
   2.387 -		encode(w, r, http.StatusUnauthorized, response{Errors: errors})
   2.388 +		errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
   2.389 +		encode(w, r, http.StatusUnauthorized, Response{Errors: errors})
   2.390  		return
   2.391  	}
   2.392  	profile, err := authenticate(username, password, c)
   2.393  	if err != nil {
   2.394  		if isAuthError(err) {
   2.395 -			errors = append(errors, requestError{Slug: requestErrAccessDenied})
   2.396 -			encode(w, r, http.StatusUnauthorized, response{Errors: errors})
   2.397 +			errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
   2.398 +			encode(w, r, http.StatusUnauthorized, Response{Errors: errors})
   2.399  		} else {
   2.400 -			errors = append(errors, requestError{Slug: requestErrActOfGod})
   2.401 -			encode(w, r, http.StatusInternalServerError, response{Errors: errors})
   2.402 +			errors = append(errors, RequestError{Slug: RequestErrActOfGod})
   2.403 +			encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
   2.404  		}
   2.405  		return
   2.406  	}
   2.407  	client, err := c.GetClient(id)
   2.408  	if err != nil {
   2.409  		if err == ErrClientNotFound {
   2.410 -			errors = append(errors, requestError{Slug: requestErrNotFound})
   2.411 -			encode(w, r, http.StatusNotFound, response{Errors: errors})
   2.412 +			errors = append(errors, RequestError{Slug: RequestErrNotFound})
   2.413 +			encode(w, r, http.StatusNotFound, Response{Errors: errors})
   2.414  			return
   2.415  		}
   2.416  		log.Println("Error retrieving client:", err)
   2.417 -		errors = append(errors, requestError{Slug: requestErrActOfGod})
   2.418 -		encode(w, r, http.StatusInternalServerError, response{Errors: errors})
   2.419 +		errors = append(errors, RequestError{Slug: RequestErrActOfGod})
   2.420 +		encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
   2.421  		return
   2.422  	}
   2.423  	if !client.OwnerID.Equal(profile.ID) {
   2.424 -		errors = append(errors, requestError{Slug: requestErrAccessDenied})
   2.425 -		encode(w, r, http.StatusForbidden, response{Errors: errors})
   2.426 +		errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
   2.427 +		encode(w, r, http.StatusForbidden, Response{Errors: errors})
   2.428  		return
   2.429  	}
   2.430  	deleted := true
   2.431 @@ -839,16 +839,16 @@
   2.432  	err = c.UpdateClient(id, change)
   2.433  	if err != nil {
   2.434  		if err == ErrClientNotFound {
   2.435 -			errors = append(errors, requestError{Slug: requestErrNotFound})
   2.436 -			encode(w, r, http.StatusNotFound, response{Errors: errors})
   2.437 +			errors = append(errors, RequestError{Slug: RequestErrNotFound})
   2.438 +			encode(w, r, http.StatusNotFound, Response{Errors: errors})
   2.439  			return
   2.440  		}
   2.441  		log.Println("Error deleting client:", err)
   2.442 -		errors = append(errors, requestError{Slug: requestErrActOfGod})
   2.443 -		encode(w, r, http.StatusInternalServerError, response{Errors: errors})
   2.444 +		errors = append(errors, RequestError{Slug: RequestErrActOfGod})
   2.445 +		encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
   2.446  		return
   2.447  	}
   2.448 -	encode(w, r, http.StatusOK, response{Errors: errors})
   2.449 +	encode(w, r, http.StatusOK, Response{Errors: errors})
   2.450  	go cleanUpAfterClientDeletion(id, c)
   2.451  	return
   2.452  }
   2.453 @@ -857,50 +857,50 @@
   2.454  	type addEndpointReq struct {
   2.455  		Endpoints []string `json:"endpoints"`
   2.456  	}
   2.457 -	errors := []requestError{}
   2.458 +	errors := []RequestError{}
   2.459  	vars := mux.Vars(r)
   2.460  	if vars["id"] == "" {
   2.461 -		errors = append(errors, requestError{Slug: requestErrMissing, Param: "id"})
   2.462 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
   2.463 +		errors = append(errors, RequestError{Slug: RequestErrMissing, Param: "id"})
   2.464 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
   2.465  		return
   2.466  	}
   2.467  	id, err := uuid.Parse(vars["id"])
   2.468  	if err != nil {
   2.469 -		errors = append(errors, requestError{Slug: requestErrInvalidFormat, Param: "id"})
   2.470 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
   2.471 +		errors = append(errors, RequestError{Slug: RequestErrInvalidFormat, Param: "id"})
   2.472 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
   2.473  		return
   2.474  	}
   2.475  	username, password, ok := r.BasicAuth()
   2.476  	if !ok {
   2.477 -		errors = append(errors, requestError{Slug: requestErrAccessDenied})
   2.478 -		encode(w, r, http.StatusUnauthorized, response{Errors: errors})
   2.479 +		errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
   2.480 +		encode(w, r, http.StatusUnauthorized, Response{Errors: errors})
   2.481  		return
   2.482  	}
   2.483  	profile, err := authenticate(username, password, c)
   2.484  	if err != nil {
   2.485  		if isAuthError(err) {
   2.486 -			errors = append(errors, requestError{Slug: requestErrAccessDenied})
   2.487 -			encode(w, r, http.StatusUnauthorized, response{Errors: errors})
   2.488 +			errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
   2.489 +			encode(w, r, http.StatusUnauthorized, Response{Errors: errors})
   2.490  		} else {
   2.491 -			errors = append(errors, requestError{Slug: requestErrActOfGod})
   2.492 -			encode(w, r, http.StatusInternalServerError, response{Errors: errors})
   2.493 +			errors = append(errors, RequestError{Slug: RequestErrActOfGod})
   2.494 +			encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
   2.495  		}
   2.496  		return
   2.497  	}
   2.498  	client, err := c.GetClient(id)
   2.499  	if err != nil {
   2.500  		if err == ErrClientNotFound {
   2.501 -			errors = append(errors, requestError{Slug: requestErrNotFound, Param: "id"})
   2.502 -			encode(w, r, http.StatusBadRequest, response{Errors: errors})
   2.503 +			errors = append(errors, RequestError{Slug: RequestErrNotFound, Param: "id"})
   2.504 +			encode(w, r, http.StatusBadRequest, Response{Errors: errors})
   2.505  			return
   2.506  		}
   2.507 -		errors = append(errors, requestError{Slug: requestErrActOfGod})
   2.508 -		encode(w, r, http.StatusInternalServerError, response{Errors: errors})
   2.509 +		errors = append(errors, RequestError{Slug: RequestErrActOfGod})
   2.510 +		encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
   2.511  		return
   2.512  	}
   2.513  	if !client.OwnerID.Equal(profile.ID) {
   2.514 -		errors = append(errors, requestError{Slug: requestErrAccessDenied})
   2.515 -		encode(w, r, http.StatusUnauthorized, response{Errors: errors})
   2.516 +		errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
   2.517 +		encode(w, r, http.StatusUnauthorized, Response{Errors: errors})
   2.518  		return
   2.519  	}
   2.520  	var req addEndpointReq
   2.521 @@ -911,17 +911,17 @@
   2.522  		return
   2.523  	}
   2.524  	if len(req.Endpoints) < 1 {
   2.525 -		errors = append(errors, requestError{Slug: requestErrMissing, Field: "/endpoints"})
   2.526 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
   2.527 +		errors = append(errors, RequestError{Slug: RequestErrMissing, Field: "/endpoints"})
   2.528 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
   2.529  		return
   2.530  	}
   2.531  	endpoints := []Endpoint{}
   2.532  	for pos, u := range req.Endpoints {
   2.533  		if parsed, err := url.Parse(u); err != nil {
   2.534 -			errors = append(errors, requestError{Slug: requestErrInvalidFormat, Field: "/endpoints/" + strconv.Itoa(pos)})
   2.535 +			errors = append(errors, RequestError{Slug: RequestErrInvalidFormat, Field: "/endpoints/" + strconv.Itoa(pos)})
   2.536  			continue
   2.537  		} else if !parsed.IsAbs() {
   2.538 -			errors = append(errors, requestError{Slug: requestErrInvalidValue, Field: "/endpoints" + strconv.Itoa(pos)})
   2.539 +			errors = append(errors, RequestError{Slug: RequestErrInvalidValue, Field: "/endpoints" + strconv.Itoa(pos)})
   2.540  			continue
   2.541  		}
   2.542  		e := Endpoint{
   2.543 @@ -933,7 +933,7 @@
   2.544  		endpoints = append(endpoints, e)
   2.545  	}
   2.546  	if len(errors) > 0 {
   2.547 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
   2.548 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
   2.549  		return
   2.550  	}
   2.551  	err = c.AddEndpoints(endpoints)
   2.552 @@ -941,7 +941,7 @@
   2.553  		encode(w, r, http.StatusInternalServerError, actOfGodResponse)
   2.554  		return
   2.555  	}
   2.556 -	resp := response{
   2.557 +	resp := Response{
   2.558  		Errors:    errors,
   2.559  		Endpoints: endpoints,
   2.560  	}
   2.561 @@ -949,12 +949,12 @@
   2.562  }
   2.563  
   2.564  func ListEndpointsHandler(w http.ResponseWriter, r *http.Request, c Context) {
   2.565 -	errors := []requestError{}
   2.566 +	errors := []RequestError{}
   2.567  	vars := mux.Vars(r)
   2.568  	clientID, err := uuid.Parse(vars["id"])
   2.569  	if err != nil {
   2.570 -		errors = append(errors, requestError{Slug: requestErrInvalidFormat, Param: "client_id"})
   2.571 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
   2.572 +		errors = append(errors, RequestError{Slug: RequestErrInvalidFormat, Param: "client_id"})
   2.573 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
   2.574  		return
   2.575  	}
   2.576  	num := defaultEndpointResponseSize
   2.577 @@ -964,29 +964,29 @@
   2.578  	if numStr != "" {
   2.579  		num, err = strconv.Atoi(numStr)
   2.580  		if err != nil {
   2.581 -			errors = append(errors, requestError{Slug: requestErrInvalidFormat, Param: "num"})
   2.582 +			errors = append(errors, RequestError{Slug: RequestErrInvalidFormat, Param: "num"})
   2.583  		}
   2.584  		if num > maxEndpointResponseSize {
   2.585 -			errors = append(errors, requestError{Slug: requestErrOverflow, Param: "num"})
   2.586 +			errors = append(errors, RequestError{Slug: RequestErrOverflow, Param: "num"})
   2.587  		}
   2.588  	}
   2.589  	if offsetStr != "" {
   2.590  		offset, err = strconv.Atoi(offsetStr)
   2.591  		if err != nil {
   2.592 -			errors = append(errors, requestError{Slug: requestErrInvalidFormat, Param: "offset"})
   2.593 +			errors = append(errors, RequestError{Slug: RequestErrInvalidFormat, Param: "offset"})
   2.594  		}
   2.595  	}
   2.596  	if len(errors) > 0 {
   2.597 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
   2.598 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
   2.599  		return
   2.600  	}
   2.601  	endpoints, err := c.ListEndpoints(clientID, num, offset)
   2.602  	if err != nil {
   2.603 -		errors = append(errors, requestError{Slug: requestErrActOfGod})
   2.604 -		encode(w, r, http.StatusInternalServerError, response{Errors: errors})
   2.605 +		errors = append(errors, RequestError{Slug: RequestErrActOfGod})
   2.606 +		encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
   2.607  		return
   2.608  	}
   2.609 -	resp := response{
   2.610 +	resp := Response{
   2.611  		Endpoints: endpoints,
   2.612  		Errors:    errors,
   2.613  	}
   2.614 @@ -994,72 +994,72 @@
   2.615  }
   2.616  
   2.617  func RemoveEndpointHandler(w http.ResponseWriter, r *http.Request, c Context) {
   2.618 -	errors := []requestError{}
   2.619 +	errors := []RequestError{}
   2.620  	vars := mux.Vars(r)
   2.621  	if vars["client_id"] == "" {
   2.622 -		errors = append(errors, requestError{Slug: requestErrMissing, Param: "client_id"})
   2.623 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
   2.624 +		errors = append(errors, RequestError{Slug: RequestErrMissing, Param: "client_id"})
   2.625 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
   2.626  		return
   2.627  	}
   2.628  	clientID, err := uuid.Parse(vars["client_id"])
   2.629  	if err != nil {
   2.630 -		errors = append(errors, requestError{Slug: requestErrInvalidFormat, Param: "client_id"})
   2.631 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
   2.632 +		errors = append(errors, RequestError{Slug: RequestErrInvalidFormat, Param: "client_id"})
   2.633 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
   2.634  		return
   2.635  	}
   2.636  	if vars["id"] == "" {
   2.637 -		errors = append(errors, requestError{Slug: requestErrMissing, Param: "id"})
   2.638 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
   2.639 +		errors = append(errors, RequestError{Slug: RequestErrMissing, Param: "id"})
   2.640 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
   2.641  		return
   2.642  	}
   2.643  	id, err := uuid.Parse(vars["id"])
   2.644  	if err != nil {
   2.645 -		errors = append(errors, requestError{Slug: requestErrInvalidFormat, Param: "id"})
   2.646 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
   2.647 +		errors = append(errors, RequestError{Slug: RequestErrInvalidFormat, Param: "id"})
   2.648 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
   2.649  		return
   2.650  	}
   2.651  	username, password, ok := r.BasicAuth()
   2.652  	if !ok {
   2.653 -		errors = append(errors, requestError{Slug: requestErrAccessDenied})
   2.654 -		encode(w, r, http.StatusUnauthorized, response{Errors: errors})
   2.655 +		errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
   2.656 +		encode(w, r, http.StatusUnauthorized, Response{Errors: errors})
   2.657  		return
   2.658  	}
   2.659  	profile, err := authenticate(username, password, c)
   2.660  	if err != nil {
   2.661  		if isAuthError(err) {
   2.662 -			errors = append(errors, requestError{Slug: requestErrAccessDenied})
   2.663 -			encode(w, r, http.StatusUnauthorized, response{Errors: errors})
   2.664 +			errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
   2.665 +			encode(w, r, http.StatusUnauthorized, Response{Errors: errors})
   2.666  		} else {
   2.667 -			errors = append(errors, requestError{Slug: requestErrActOfGod})
   2.668 -			encode(w, r, http.StatusInternalServerError, response{Errors: errors})
   2.669 +			errors = append(errors, RequestError{Slug: RequestErrActOfGod})
   2.670 +			encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
   2.671  		}
   2.672  		return
   2.673  	}
   2.674  	client, err := c.GetClient(clientID)
   2.675  	if err != nil {
   2.676  		if err == ErrClientNotFound {
   2.677 -			errors = append(errors, requestError{Slug: requestErrNotFound, Param: "client_id"})
   2.678 -			encode(w, r, http.StatusBadRequest, response{Errors: errors})
   2.679 +			errors = append(errors, RequestError{Slug: RequestErrNotFound, Param: "client_id"})
   2.680 +			encode(w, r, http.StatusBadRequest, Response{Errors: errors})
   2.681  			return
   2.682  		}
   2.683 -		errors = append(errors, requestError{Slug: requestErrActOfGod})
   2.684 -		encode(w, r, http.StatusInternalServerError, response{Errors: errors})
   2.685 +		errors = append(errors, RequestError{Slug: RequestErrActOfGod})
   2.686 +		encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
   2.687  		return
   2.688  	}
   2.689  	if !client.OwnerID.Equal(profile.ID) {
   2.690 -		errors = append(errors, requestError{Slug: requestErrAccessDenied})
   2.691 -		encode(w, r, http.StatusUnauthorized, response{Errors: errors})
   2.692 +		errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
   2.693 +		encode(w, r, http.StatusUnauthorized, Response{Errors: errors})
   2.694  		return
   2.695  	}
   2.696  	endpoint, err := c.GetEndpoint(clientID, id)
   2.697  	if err != nil {
   2.698  		if err == ErrEndpointNotFound {
   2.699 -			errors = append(errors, requestError{Slug: requestErrNotFound, Param: "id"})
   2.700 -			encode(w, r, http.StatusBadRequest, response{Errors: errors})
   2.701 +			errors = append(errors, RequestError{Slug: RequestErrNotFound, Param: "id"})
   2.702 +			encode(w, r, http.StatusBadRequest, Response{Errors: errors})
   2.703  			return
   2.704  		}
   2.705 -		errors = append(errors, requestError{Slug: requestErrActOfGod})
   2.706 -		encode(w, r, http.StatusInternalServerError, response{Errors: errors})
   2.707 +		errors = append(errors, RequestError{Slug: RequestErrActOfGod})
   2.708 +		encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
   2.709  		return
   2.710  	}
   2.711  	err = c.RemoveEndpoint(clientID, id)
   2.712 @@ -1067,7 +1067,7 @@
   2.713  		encode(w, r, http.StatusInternalServerError, actOfGodResponse)
   2.714  		return
   2.715  	}
   2.716 -	resp := response{
   2.717 +	resp := Response{
   2.718  		Errors:    errors,
   2.719  		Endpoints: []Endpoint{endpoint},
   2.720  	}
     3.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     3.2 +++ b/client/client.go	Sun May 17 02:27:36 2015 -0400
     3.3 @@ -0,0 +1,81 @@
     3.4 +package client
     3.5 +
     3.6 +import (
     3.7 +	"bytes"
     3.8 +	"encoding/json"
     3.9 +	"errors"
    3.10 +	"io"
    3.11 +	"net/http"
    3.12 +	"strings"
    3.13 +
    3.14 +	"code.secondbit.org/auth.hg"
    3.15 +)
    3.16 +
    3.17 +var (
    3.18 +	ErrNilClient     = errors.New("nil client wrapper")
    3.19 +	ErrNilHTTPClient = errors.New("nil client")
    3.20 +)
    3.21 +
    3.22 +type Client struct {
    3.23 +	client  *http.Client
    3.24 +	address string
    3.25 +}
    3.26 +
    3.27 +func New(address string) *Client {
    3.28 +	address = strings.TrimRight(address, "/")
    3.29 +	return &Client{
    3.30 +		address: address,
    3.31 +		client:  &http.Client{},
    3.32 +	}
    3.33 +}
    3.34 +
    3.35 +func (c *Client) do(method, url string, request interface{}) (auth.Response, error) {
    3.36 +	if c == nil {
    3.37 +		return auth.Response{}, ErrNilClient
    3.38 +	}
    3.39 +	if c.client == nil {
    3.40 +		return auth.Response{}, ErrNilHTTPClient
    3.41 +	}
    3.42 +	var response auth.Response
    3.43 +	if !strings.HasPrefix(url, "http") {
    3.44 +		url = strings.TrimLeft(url, "/")
    3.45 +		url = "/" + url
    3.46 +		url = c.address + url
    3.47 +	}
    3.48 +	var body io.Reader
    3.49 +	if request != nil {
    3.50 +		data, err := json.Marshal(request)
    3.51 +		if err != nil {
    3.52 +			return response, err
    3.53 +		}
    3.54 +		body = bytes.NewBuffer(data)
    3.55 +	}
    3.56 +	req, err := http.NewRequest(method, url, body)
    3.57 +	if err != nil {
    3.58 +		return response, err
    3.59 +	}
    3.60 +	resp, err := c.client.Do(req)
    3.61 +	if err != nil {
    3.62 +		return response, err
    3.63 +	}
    3.64 +	defer resp.Body.Close()
    3.65 +	switch resp.Header.Get("Content-Type") {
    3.66 +	case "application/json":
    3.67 +		dec := json.NewDecoder(resp.Body)
    3.68 +		err = dec.Decode(&response)
    3.69 +		if err != nil {
    3.70 +			return response, err
    3.71 +		}
    3.72 +	default:
    3.73 +		dec := json.NewDecoder(resp.Body)
    3.74 +		err = dec.Decode(&response)
    3.75 +		if err != nil {
    3.76 +			return response, err
    3.77 +		}
    3.78 +	}
    3.79 +	return response, nil
    3.80 +}
    3.81 +
    3.82 +func (c *Client) Get(url string) (auth.Response, error) {
    3.83 +	return c.do("GET", url, nil)
    3.84 +}
     4.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     4.2 +++ b/client/login.go	Sun May 17 02:27:36 2015 -0400
     4.3 @@ -0,0 +1,16 @@
     4.4 +package client
     4.5 +
     4.6 +import (
     4.7 +	"code.secondbit.org/auth.hg"
     4.8 +)
     4.9 +
    4.10 +func (c *Client) GetLogin(value string) (auth.Login, error) {
    4.11 +	resp, err := c.Get("/logins/" + value)
    4.12 +	if err != nil {
    4.13 +		return auth.Login{}, err
    4.14 +	}
    4.15 +	if len(resp.Logins) < 1 {
    4.16 +		return auth.Login{}, auth.ErrLoginNotFound
    4.17 +	}
    4.18 +	return resp.Logins[0], nil
    4.19 +}
     5.1 --- a/client_test.go	Sun May 17 02:18:07 2015 -0400
     5.2 +++ b/client_test.go	Sun May 17 02:27:36 2015 -0400
     5.3 @@ -756,24 +756,24 @@
     5.4  	type testStruct struct {
     5.5  		request string
     5.6  		code    int
     5.7 -		resp    response
     5.8 +		resp    Response
     5.9  	}
    5.10  	tests := []testStruct{
    5.11 -		{``, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrInvalidFormat, Field: "/"}}}},
    5.12 -		{`{}`, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrMissing, Field: "/type"}, {Slug: requestErrMissing, Field: "/name"}}}},
    5.13 -		{`{"type":"notarealtype"}`, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrInvalidValue, Field: "/type"}, {Slug: requestErrMissing, Field: "/name"}}}},
    5.14 -		{`{"type":"notarealtype","name":"myreallylongnameislongerthatthemaximumnamelength"}`, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrInvalidValue, Field: "/type"}, {Slug: requestErrOverflow, Field: "/name"}}}},
    5.15 -		{`{"type":"notarealtype","name":"a"}`, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrInvalidValue, Field: "/type"}, {Slug: requestErrInsufficient, Field: "/name"}}}},
    5.16 -		{`{"type":"public"}`, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrMissing, Field: "/name"}}}},
    5.17 -		{`{"type":"public","name":"myreallylongnameislongerthatthemaximumnamelength"}`, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrOverflow, Field: "/name"}}}},
    5.18 -		{`{"type":"public","name":"a"}`, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrInsufficient, Field: "/name"}}}},
    5.19 -		{`{"name":"My Client"}`, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrMissing, Field: "/type"}}}},
    5.20 -		{`{"type":"notarealtype","name":"My Client"}`, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrInvalidValue, Field: "/type"}}}},
    5.21 -		{`{"type":"public","name":"My Client"}`, http.StatusCreated, response{Clients: []Client{{Name: "My Client", OwnerID: profile.ID, Type: "public"}}}},
    5.22 -		{`{"type":"public","name":"My Client", "endpoints": ["https://test.secondbit.org/", "https://paddy.io"]}`, http.StatusCreated, response{Clients: []Client{{Name: "My Client", OwnerID: profile.ID, Type: "public"}}, Endpoints: []Endpoint{{URI: "https://test.secondbit.org/"}, {URI: "https://paddy.io"}}}},
    5.23 -		{`{"type":"public","name":"My Client", "endpoints": [":/not a url", "https://paddy.io"]}`, http.StatusCreated, response{Clients: []Client{{Name: "My Client", OwnerID: profile.ID, Type: "public"}}, Endpoints: []Endpoint{{URI: "https://paddy.io"}}, Errors: []requestError{{Slug: requestErrInvalidFormat, Field: "/endpoints/0"}}}},
    5.24 -		{`{"type":"public","name":"My Client", "endpoints": [":/not a url", "/relative/uri", "https://paddy.io"]}`, http.StatusCreated, response{Clients: []Client{{Name: "My Client", OwnerID: profile.ID, Type: "public"}}, Endpoints: []Endpoint{{URI: "https://paddy.io"}}, Errors: []requestError{{Slug: requestErrInvalidFormat, Field: "/endpoints/0"}, {Slug: requestErrInvalidValue, Field: "/endpoints/1"}}}},
    5.25 -		{`{"type":"confidential","name":"Secret Client", "endpoints": ["https://secondbit.org"]}`, http.StatusCreated, response{Clients: []Client{{Name: "Secret Client", OwnerID: profile.ID, Type: "confidential"}}, Endpoints: []Endpoint{{URI: "https://secondbit.org"}}}},
    5.26 +		{``, http.StatusBadRequest, Response{Errors: []RequestError{{Slug: RequestErrInvalidFormat, Field: "/"}}}},
    5.27 +		{`{}`, http.StatusBadRequest, Response{Errors: []RequestError{{Slug: RequestErrMissing, Field: "/type"}, {Slug: RequestErrMissing, Field: "/name"}}}},
    5.28 +		{`{"type":"notarealtype"}`, http.StatusBadRequest, Response{Errors: []RequestError{{Slug: RequestErrInvalidValue, Field: "/type"}, {Slug: RequestErrMissing, Field: "/name"}}}},
    5.29 +		{`{"type":"notarealtype","name":"myreallylongnameislongerthatthemaximumnamelength"}`, http.StatusBadRequest, Response{Errors: []RequestError{{Slug: RequestErrInvalidValue, Field: "/type"}, {Slug: RequestErrOverflow, Field: "/name"}}}},
    5.30 +		{`{"type":"notarealtype","name":"a"}`, http.StatusBadRequest, Response{Errors: []RequestError{{Slug: RequestErrInvalidValue, Field: "/type"}, {Slug: RequestErrInsufficient, Field: "/name"}}}},
    5.31 +		{`{"type":"public"}`, http.StatusBadRequest, Response{Errors: []RequestError{{Slug: RequestErrMissing, Field: "/name"}}}},
    5.32 +		{`{"type":"public","name":"myreallylongnameislongerthatthemaximumnamelength"}`, http.StatusBadRequest, Response{Errors: []RequestError{{Slug: RequestErrOverflow, Field: "/name"}}}},
    5.33 +		{`{"type":"public","name":"a"}`, http.StatusBadRequest, Response{Errors: []RequestError{{Slug: RequestErrInsufficient, Field: "/name"}}}},
    5.34 +		{`{"name":"My Client"}`, http.StatusBadRequest, Response{Errors: []RequestError{{Slug: RequestErrMissing, Field: "/type"}}}},
    5.35 +		{`{"type":"notarealtype","name":"My Client"}`, http.StatusBadRequest, Response{Errors: []RequestError{{Slug: RequestErrInvalidValue, Field: "/type"}}}},
    5.36 +		{`{"type":"public","name":"My Client"}`, http.StatusCreated, Response{Clients: []Client{{Name: "My Client", OwnerID: profile.ID, Type: "public"}}}},
    5.37 +		{`{"type":"public","name":"My Client", "endpoints": ["https://test.secondbit.org/", "https://paddy.io"]}`, http.StatusCreated, Response{Clients: []Client{{Name: "My Client", OwnerID: profile.ID, Type: "public"}}, Endpoints: []Endpoint{{URI: "https://test.secondbit.org/"}, {URI: "https://paddy.io"}}}},
    5.38 +		{`{"type":"public","name":"My Client", "endpoints": [":/not a url", "https://paddy.io"]}`, http.StatusCreated, Response{Clients: []Client{{Name: "My Client", OwnerID: profile.ID, Type: "public"}}, Endpoints: []Endpoint{{URI: "https://paddy.io"}}, Errors: []RequestError{{Slug: RequestErrInvalidFormat, Field: "/endpoints/0"}}}},
    5.39 +		{`{"type":"public","name":"My Client", "endpoints": [":/not a url", "/relative/uri", "https://paddy.io"]}`, http.StatusCreated, Response{Clients: []Client{{Name: "My Client", OwnerID: profile.ID, Type: "public"}}, Endpoints: []Endpoint{{URI: "https://paddy.io"}}, Errors: []RequestError{{Slug: RequestErrInvalidFormat, Field: "/endpoints/0"}, {Slug: RequestErrInvalidValue, Field: "/endpoints/1"}}}},
    5.40 +		{`{"type":"confidential","name":"Secret Client", "endpoints": ["https://secondbit.org"]}`, http.StatusCreated, Response{Clients: []Client{{Name: "Secret Client", OwnerID: profile.ID, Type: "confidential"}}, Endpoints: []Endpoint{{URI: "https://secondbit.org"}}}},
    5.41  	}
    5.42  	for pos, test := range tests {
    5.43  		t.Logf("Test #%d: `%s`", pos, test.request)
    5.44 @@ -785,7 +785,7 @@
    5.45  			t.Errorf("Expected response code to be %d, got %d", test.code, w.Code)
    5.46  		}
    5.47  		t.Logf("Response: %s", w.Body.String())
    5.48 -		var res response
    5.49 +		var res Response
    5.50  		err = json.Unmarshal(w.Body.Bytes(), &res)
    5.51  		if err != nil {
    5.52  			t.Error("Unexpected error unmarshalling response:", err)
    5.53 @@ -870,7 +870,7 @@
    5.54  		t.Errorf("Expected response code to be %d, got %d", http.StatusOK, w.Code)
    5.55  	}
    5.56  	t.Logf("Response: %s", w.Body.String())
    5.57 -	var res response
    5.58 +	var res Response
    5.59  	err = json.Unmarshal(w.Body.Bytes(), &res)
    5.60  	if err != nil {
    5.61  		t.Error("Unexpected error unmarshalling response:", err)
    5.62 @@ -901,7 +901,7 @@
    5.63  		t.Errorf("Expected response code to be %d, got %d", http.StatusBadRequest, w.Code)
    5.64  	}
    5.65  	t.Logf("Response: %s", w.Body.String())
    5.66 -	res = response{}
    5.67 +	res = Response{}
    5.68  	err = json.Unmarshal(w.Body.Bytes(), &res)
    5.69  	if err != nil {
    5.70  		t.Error("Unexpected error unmarshalling response:", err)
    5.71 @@ -909,7 +909,7 @@
    5.72  	if len(res.Errors) != 1 {
    5.73  		t.Errorf("Expected %d results in response, got %d", 1, len(res.Errors))
    5.74  	}
    5.75 -	e := requestError{Slug: requestErrInvalidFormat, Param: "id"}
    5.76 +	e := RequestError{Slug: RequestErrInvalidFormat, Param: "id"}
    5.77  	success, field, expectation, result = compareErrors(e, res.Errors[0])
    5.78  	if !success {
    5.79  		t.Errorf("Unexpected result for %s in error response: expected %v, got %v", field, expectation, result)
    5.80 @@ -928,7 +928,7 @@
    5.81  		t.Errorf("Expected response code to be %d, got %d", http.StatusNotFound, w.Code)
    5.82  	}
    5.83  	t.Logf("Response: %s", w.Body.String())
    5.84 -	res = response{}
    5.85 +	res = Response{}
    5.86  	err = json.Unmarshal(w.Body.Bytes(), &res)
    5.87  	if err != nil {
    5.88  		t.Error("Unexpected error unmarshalling response:", err)
    5.89 @@ -936,7 +936,7 @@
    5.90  	if len(res.Errors) != 1 {
    5.91  		t.Errorf("Expected %d results in response, got %d", 1, len(res.Errors))
    5.92  	}
    5.93 -	e = requestError{Slug: requestErrNotFound, Param: "id"}
    5.94 +	e = RequestError{Slug: RequestErrNotFound, Param: "id"}
    5.95  	success, field, expectation, result = compareErrors(e, res.Errors[0])
    5.96  	if !success {
    5.97  		t.Errorf("Unexpected result for %s in error response: expected %v, got %v", field, expectation, result)
    5.98 @@ -1036,7 +1036,7 @@
    5.99  		t.Errorf("Expected response code to be %d, got %d", http.StatusOK, w.Code)
   5.100  	}
   5.101  	t.Logf("Response: %s", w.Body.String())
   5.102 -	var res response
   5.103 +	var res Response
   5.104  	err = json.Unmarshal(w.Body.Bytes(), &res)
   5.105  	if err != nil {
   5.106  		t.Error("Unexpected error unmarshalling response:", err)
   5.107 @@ -1063,7 +1063,7 @@
   5.108  		t.Errorf("Expected response code to be %d, got %d", http.StatusBadRequest, w.Code)
   5.109  	}
   5.110  	t.Logf("Response: %s", w.Body.String())
   5.111 -	res = response{}
   5.112 +	res = Response{}
   5.113  	err = json.Unmarshal(w.Body.Bytes(), &res)
   5.114  	if err != nil {
   5.115  		t.Error("Unexpected error unmarshalling response:", err)
   5.116 @@ -1071,7 +1071,7 @@
   5.117  	if len(res.Errors) != 1 {
   5.118  		t.Errorf("Expected %d results in response, got %d", 1, len(res.Errors))
   5.119  	}
   5.120 -	e := requestError{Slug: requestErrInvalidFormat, Param: "id"}
   5.121 +	e := RequestError{Slug: RequestErrInvalidFormat, Param: "id"}
   5.122  	success, field, expectation, result = compareErrors(e, res.Errors[0])
   5.123  	if !success {
   5.124  		t.Errorf("Unexpected result for %s in error response: expected %v, got %v", field, expectation, result)
   5.125 @@ -1091,7 +1091,7 @@
   5.126  		t.Errorf("Expected response code to be %d, got %d", http.StatusNotFound, w.Code)
   5.127  	}
   5.128  	t.Logf("Response: %s", w.Body.String())
   5.129 -	res = response{}
   5.130 +	res = Response{}
   5.131  	err = json.Unmarshal(w.Body.Bytes(), &res)
   5.132  	if err != nil {
   5.133  		t.Error("Unexpected error unmarshalling response:", err)
   5.134 @@ -1099,7 +1099,7 @@
   5.135  	if len(res.Errors) != 1 {
   5.136  		t.Errorf("Expected %d results in response, got %d", 1, len(res.Errors))
   5.137  	}
   5.138 -	e = requestError{Slug: requestErrNotFound, Param: "id"}
   5.139 +	e = RequestError{Slug: RequestErrNotFound, Param: "id"}
   5.140  	success, field, expectation, result = compareErrors(e, res.Errors[0])
   5.141  	if !success {
   5.142  		t.Errorf("Unexpected result for %s in error response: expected %v, got %v", field, expectation, result)
   5.143 @@ -1119,7 +1119,7 @@
   5.144  		t.Errorf("Expected response code to be %d, got %d", http.StatusUnauthorized, w.Code)
   5.145  	}
   5.146  	t.Logf("Response: %s", w.Body.String())
   5.147 -	res = response{}
   5.148 +	res = Response{}
   5.149  	err = json.Unmarshal(w.Body.Bytes(), &res)
   5.150  	if err != nil {
   5.151  		t.Error("Unexpected error unmarshalling response:", err)
   5.152 @@ -1127,7 +1127,7 @@
   5.153  	if len(res.Errors) != 1 {
   5.154  		t.Errorf("Expected %d results in response, got %d", 1, len(res.Errors))
   5.155  	}
   5.156 -	e = requestError{Slug: requestErrAccessDenied}
   5.157 +	e = RequestError{Slug: RequestErrAccessDenied}
   5.158  	success, field, expectation, result = compareErrors(e, res.Errors[0])
   5.159  	if !success {
   5.160  		t.Errorf("Unexpected result for %s in error response: expected %v, got %v", field, expectation, result)
   5.161 @@ -1147,7 +1147,7 @@
   5.162  		t.Errorf("Expected response code to be %d, got %d", http.StatusOK, w.Code)
   5.163  	}
   5.164  	t.Logf("Response: %s", w.Body.String())
   5.165 -	res = response{}
   5.166 +	res = Response{}
   5.167  	err = json.Unmarshal(w.Body.Bytes(), &res)
   5.168  	if err != nil {
   5.169  		t.Error("Unexpected error unmarshalling response:", err)
     6.1 --- a/context.go	Sun May 17 02:18:07 2015 -0400
     6.2 +++ b/context.go	Sun May 17 02:27:36 2015 -0400
     6.3 @@ -14,15 +14,16 @@
     6.4  // be used as the main point of interaction for the data storage
     6.5  // layer.
     6.6  type Context struct {
     6.7 -	template  *template.Template
     6.8 -	loginURI  *url.URL
     6.9 -	clients   clientStore
    6.10 -	authCodes authorizationCodeStore
    6.11 -	profiles  profileStore
    6.12 -	tokens    tokenStore
    6.13 -	sessions  sessionStore
    6.14 -	scopes    scopeStore
    6.15 -	config    Config
    6.16 +	template                  *template.Template
    6.17 +	loginURI                  *url.URL
    6.18 +	clients                   clientStore
    6.19 +	authCodes                 authorizationCodeStore
    6.20 +	profiles                  profileStore
    6.21 +	tokens                    tokenStore
    6.22 +	sessions                  sessionStore
    6.23 +	scopes                    scopeStore
    6.24 +	loginVerificationNotifier loginVerificationNotifier
    6.25 +	config                    Config
    6.26  }
    6.27  
    6.28  // NewContext takes a Config instance and uses it to bootstrap a Context
    6.29 @@ -38,8 +39,9 @@
    6.30  		tokens:    config.TokenStore,
    6.31  		sessions:  config.SessionStore,
    6.32  		scopes:    config.ScopeStore,
    6.33 -		template:  config.Template,
    6.34 -		config:    config,
    6.35 +		loginVerificationNotifier: config.LoginVerificationNotifier,
    6.36 +		template:                  config.Template,
    6.37 +		config:                    config,
    6.38  	}
    6.39  	var err error
    6.40  	context.loginURI, err = url.Parse(config.LoginURI)
    6.41 @@ -293,6 +295,14 @@
    6.42  	return c.profiles.addLogin(login)
    6.43  }
    6.44  
    6.45 +// GetLogin returns the Login specified from the profileStore associated with the Context.
    6.46 +func (c Context) GetLogin(value string) (Login, error) {
    6.47 +	if c.profiles == nil {
    6.48 +		return Login{}, ErrNoProfileStore
    6.49 +	}
    6.50 +	return c.profiles.getLogin(value)
    6.51 +}
    6.52 +
    6.53  // RemoveLogin removes the specified Login from the profileStore associated with the Context, provided
    6.54  // the Login has a ProfileID property that matches the profile ID passed in. It also disassociates the
    6.55  // deleted Login from the Profile in login.ProfileID.
    6.56 @@ -321,6 +331,16 @@
    6.57  	return c.profiles.recordLoginUse(value, when)
    6.58  }
    6.59  
    6.60 +// VerifyLogin sets the Verified property of the Login specied to true in the profileStore associated with
    6.61 +// the Context, assuming the Verification property of the Login in the profileStore matches the verification
    6.62 +// passed. If the verifications do not match, an ErrLoginVerificationInvalid error is returned.
    6.63 +func (c Context) VerifyLogin(value, verification string) error {
    6.64 +	if c.profiles == nil {
    6.65 +		return ErrNoProfileStore
    6.66 +	}
    6.67 +	return c.profiles.verifyLogin(value, verification)
    6.68 +}
    6.69 +
    6.70  // ListLogins returns a slice of up to num Logins associated with the specified Profile from the profileStore
    6.71  // associated with the Context, skipping offset Profiles.
    6.72  func (c Context) ListLogins(profile uuid.ID, num, offset int) ([]Login, error) {
    6.73 @@ -470,3 +490,10 @@
    6.74  	}
    6.75  	return c.scopes.listScopes()
    6.76  }
    6.77 +
    6.78 +func (c Context) SendLoginVerification(login Login) {
    6.79 +	if c.loginVerificationNotifier == nil {
    6.80 +		log.Println("Login verification notifier not set!")
    6.81 +	}
    6.82 +	c.loginVerificationNotifier.SendLoginVerification(login)
    6.83 +}
     7.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     7.2 +++ b/listeners/email_verification/basic.html	Sun May 17 02:27:36 2015 -0400
     7.3 @@ -0,0 +1,15 @@
     7.4 +<html>
     7.5 +  <body>
     7.6 +    <p>Hello, friend!</p>
     7.7 +    <p>Welcome to Ducky! We’re so glad to meet you. We’re excited. Are you excited? Of course you’re excited. This is exciting!</p>
     7.8 +    <p>Could you do us a quick favour and just verify this email address? We just want to know that we can reach you, in case you forget your password, something goes wrong with billing, or (knock on wood) we have a security problem we need to tell you about.</p>
     7.9 +    <p>Verifying your email can be really quick. Just <a href="http://useducky.com/logins/verify?code={{ .login.Verification }}">click here</a> or enter your verification code on the website or in one of the apps:</p>
    7.10 +    <div stlye="text-align: center;">{{ .login.Verification }}</div>
    7.11 +    <p>Thanks a tonne. You’re the best. We’re going to get along <em>famously</em>. Let’s be best friends.</p>
    7.12 +    <p>This is going <em>swimmingly</em>. (Just get used to the duck puns.)</p>
    7.13 +    <p>Hugs,<br/>
    7.14 +    Team Ducky</p>
    7.15 +    <p>PS: We’re friendly. This is <em>not</em> one of those “noreply” email addresses. We would actually be <em>really ecstatic</em> if you replied. We weren’t kidding about that whole “best friends” thing.</p>
    7.16 +    <p>PPS: Look, if email’s not really your thing, we get it. You can also call us: (716) 771-2486. We’d love to chat with you.</p>
    7.17 +  </body>
    7.18 +</html>
     8.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     8.2 +++ b/listeners/email_verification/basic.txt	Sun May 17 02:27:36 2015 -0400
     8.3 @@ -0,0 +1,18 @@
     8.4 +Hello, friend!
     8.5 +
     8.6 +Welcome to Ducky! We’re so glad to meet you. We’re excited. Are you excited? Of course you’re excited. This is exciting!
     8.7 +
     8.8 +Could you do us a quick favour and just verify this email address? We just want to know that we can reach you, in case you forget your password, something goes wrong with billing, or (knock on wood) we have a security problem we need to tell you about.
     8.9 +
    8.10 +Verifying your email can be really quick. Just enter your verification code: {{ .login.Verification }}
    8.11 +
    8.12 +Thanks a tonne. You’re the best. We’re going to get along famously. Let’s be best friends.
    8.13 +
    8.14 +This is going swimmingly. (Just get used to the duck puns.)
    8.15 +
    8.16 +Hugs,
    8.17 +Team Ducky
    8.18 +
    8.19 +PS: We’re friendly. This is not one of those “noreply” email addresses. We would actually be really ecstatic if you replied. We weren’t kidding about that whole “best friends” thing.
    8.20 +
    8.21 +PPS: Look, if email’s not really your thing, we get it. You can also call us: (716) 771-2486. We’d love to chat with you.
     9.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     9.2 +++ b/listeners/email_verification/listener.go	Sun May 17 02:27:36 2015 -0400
     9.3 @@ -0,0 +1,134 @@
     9.4 +package main
     9.5 +
     9.6 +import (
     9.7 +	"bytes"
     9.8 +	"encoding/json"
     9.9 +	"fmt"
    9.10 +	htmltmpl "html/template"
    9.11 +	"log"
    9.12 +	"os"
    9.13 +	"strings"
    9.14 +	texttmpl "text/template"
    9.15 +
    9.16 +	"code.secondbit.org/auth.hg"
    9.17 +	"code.secondbit.org/auth.hg/client"
    9.18 +	"code.secondbit.org/events.hg"
    9.19 +
    9.20 +	"github.com/bitly/go-nsq"
    9.21 +	"github.com/mailgun/mailgun-go"
    9.22 +)
    9.23 +
    9.24 +const (
    9.25 +	channel = "verification_email_sender"
    9.26 +)
    9.27 +
    9.28 +var (
    9.29 +	authClient        *client.Client
    9.30 +	mgClient          mailgun.Mailgun
    9.31 +	emailHTMLTemplate *htmltmpl.Template
    9.32 +	emailTextTemplate *texttmpl.Template
    9.33 +)
    9.34 +
    9.35 +func main() {
    9.36 +	lookupdEnv := os.Getenv("AUTH_NSQ_LOOKUPDS")
    9.37 +	if lookupdEnv == "" {
    9.38 +		log.Fatal("AUTH_NSQ_LOOKUPDS environment variable must be set.")
    9.39 +	}
    9.40 +	lookupds := strings.Split(lookupdEnv, ",")
    9.41 +	clientAddr := os.Getenv("AUTH_API")
    9.42 +	if clientAddr == "" {
    9.43 +		log.Fatal("AUTH_API environment variable must be set.")
    9.44 +	}
    9.45 +	mgDomain := os.Getenv("AUTH_EMAIL_DOMAIN")
    9.46 +	if mgDomain == "" {
    9.47 +		log.Fatal("AUTH_EMAIL_DOMAIN environment variable must be set.")
    9.48 +	}
    9.49 +	mgAPIKey := os.Getenv("AUTH_EMAIL_KEY")
    9.50 +	if mgAPIKey == "" {
    9.51 +		log.Fatal("AUTH_EMAIL_KEY environment variable must be set.")
    9.52 +	}
    9.53 +	mgHTMLTemplateFile := os.Getenv("AUTH_EMAIL_HTML_TEMPLATE")
    9.54 +	if mgHTMLTemplateFile == "" {
    9.55 +		log.Fatal("AUTH_EMAIL_HTML_TEMPLATE environment variable must be set.")
    9.56 +	}
    9.57 +	mgTextTemplateFile := os.Getenv("AUTH_EMAIL_TEXT_TEMPLATE")
    9.58 +	if mgTextTemplateFile == "" {
    9.59 +		log.Fatal("AUTH_EMAIL_TEXT_TEMPLATE environment variable must be set.")
    9.60 +	}
    9.61 +	authClient = client.New(clientAddr)
    9.62 +	mgClient = mailgun.NewMailgun(mgDomain, mgAPIKey, "")
    9.63 +	emailHTMLTemplate = htmltmpl.Must(htmltmpl.ParseGlob(mgHTMLTemplateFile))
    9.64 +	emailTextTemplate = texttmpl.Must(texttmpl.ParseGlob(mgTextTemplateFile))
    9.65 +	sub, err := events.NewNSQSubscriber(lookupds, nsq.HandlerFunc(messageHandler), auth.EventTopicLoginVerification, channel, "email_verification/"+auth.Version)
    9.66 +	if err != nil {
    9.67 +		log.Fatalf("Error creating subscriber: %+v\n", err)
    9.68 +	}
    9.69 +	sub.Block()
    9.70 +}
    9.71 +
    9.72 +func messageHandler(msg *nsq.Message) error {
    9.73 +	var event events.Event
    9.74 +	err := json.Unmarshal(msg.Body, &event)
    9.75 +	if err != nil {
    9.76 +		log.Printf("Error decoding event (%s), discarding: %+v\n", err)
    9.77 +		return nil
    9.78 +	}
    9.79 +	if event.System != auth.EventSystem {
    9.80 +		log.Printf("Ignoring event originating from %s\n", event.System)
    9.81 +		return nil
    9.82 +	}
    9.83 +	if event.Model != auth.EventModelLogin {
    9.84 +		log.Printf("Ignoring event for model %s\n", event.Model)
    9.85 +		return nil
    9.86 +	}
    9.87 +	if event.Action != auth.EventActionSendVerification {
    9.88 +		log.Printf("Ignoring event caused by %s\n", event.Action)
    9.89 +		return nil
    9.90 +	}
    9.91 +	if event.Data == nil {
    9.92 +		log.Printf("Ignoring event with data not set.\n")
    9.93 +		return nil
    9.94 +	}
    9.95 +	data, ok := event.Data.(map[string]interface{})
    9.96 +	if !ok {
    9.97 +		log.Printf("Ignoring event with data not map[string]string.\n")
    9.98 +		return nil
    9.99 +	}
   9.100 +	if _, ok := data["verification"]; !ok {
   9.101 +		log.Printf("Ignoring event with no verification included.\n")
   9.102 +		return nil
   9.103 +	}
   9.104 +	verification, ok := data["verification"].(string)
   9.105 +	if !ok {
   9.106 +		log.Printf("Ignoring event with non-string verification.\n")
   9.107 +		return nil
   9.108 +	}
   9.109 +	login, err := authClient.GetLogin(event.ID)
   9.110 +	if err != nil {
   9.111 +		log.Printf("Error retrieving login %s: %+v", event.ID, err)
   9.112 +		return err // requeue the message for later processing
   9.113 +	}
   9.114 +	login.Verification = verification
   9.115 +	fmt.Printf("Pretending to email %s their verification code (%s)\n", login.Value, login.Verification)
   9.116 +	var body bytes.Buffer
   9.117 +	var htmlBody bytes.Buffer
   9.118 +	err = emailTextTemplate.Execute(&body, map[string]interface{}{
   9.119 +		"login": login,
   9.120 +	})
   9.121 +	if err != nil {
   9.122 +		log.Printf("Error generating email body: %+v\n", err)
   9.123 +		return err
   9.124 +	}
   9.125 +	err = emailHTMLTemplate.Execute(&htmlBody, map[string]interface{}{
   9.126 +		"login": login,
   9.127 +	})
   9.128 +	m := mailgun.NewMessage("Team Ducky <quack@useducky.com>", "Welcome to Ducky! Please verify your address.", string(body.Bytes()), login.Value)
   9.129 +	m.SetHtml(string(htmlBody.Bytes()))
   9.130 +	mgmsg, id, err := mgClient.Send(m)
   9.131 +	if err != nil {
   9.132 +		log.Printf("Error sending message to mailgun: %+v\n", err)
   9.133 +		return err
   9.134 +	}
   9.135 +	log.Printf("Sent message %s to mailgun: %s", id, mgmsg)
   9.136 +	return nil
   9.137 +}
    10.1 --- a/profile.go	Sun May 17 02:18:07 2015 -0400
    10.2 +++ b/profile.go	Sun May 17 02:27:36 2015 -0400
    10.3 @@ -39,6 +39,8 @@
    10.4  	ErrLoginAlreadyExists = errors.New("login already exists in profileStore")
    10.5  	// ErrLoginNotFound is returned when a Login is requested but not found in the profileStore.
    10.6  	ErrLoginNotFound = errors.New("login not found in profileStore")
    10.7 +	// ErrLoginVerificationInvalid is returned when a Login is verified with the wrong verification code.
    10.8 +	ErrLoginVerificationInvalid = errors.New("login verification code incorrect")
    10.9  
   10.10  	// ErrMissingPassphrase is returned when a ProfileChange is validated but does not contain a
   10.11  	// Passphrase, and requires one.
   10.12 @@ -194,11 +196,18 @@
   10.13  // a given Profile that can be used to log into that Profile.
   10.14  // Each Profile may only have one Login for each Type.
   10.15  type Login struct {
   10.16 -	Type      string    `json:"type,omitempty"`
   10.17 -	Value     string    `json:"value,omitempty"`
   10.18 -	ProfileID uuid.ID   `json:"profile_id,omitempty"`
   10.19 -	Created   time.Time `json:"created,omitempty"`
   10.20 -	LastUsed  time.Time `json:"last_used,omitempty"`
   10.21 +	Type         string    `json:"type,omitempty"`
   10.22 +	Value        string    `json:"value,omitempty"`
   10.23 +	ProfileID    uuid.ID   `json:"profile_id,omitempty"`
   10.24 +	Created      time.Time `json:"created,omitempty"`
   10.25 +	LastUsed     time.Time `json:"last_used,omitempty"`
   10.26 +	Verification string    `json:"-"`
   10.27 +	Verified     bool      `json:"verified"`
   10.28 +}
   10.29 +
   10.30 +type LoginChange struct {
   10.31 +	Verification       *string `json:"verification,omitempty"`
   10.32 +	ResendVerification *bool   `json:"resend_verification,omitempty"`
   10.33  }
   10.34  
   10.35  type newProfileRequest struct {
   10.36 @@ -207,44 +216,44 @@
   10.37  	Name       string `json:"name"`
   10.38  }
   10.39  
   10.40 -func validateNewProfileRequest(req *newProfileRequest) []requestError {
   10.41 -	errors := []requestError{}
   10.42 +func validateNewProfileRequest(req *newProfileRequest) []RequestError {
   10.43 +	errors := []RequestError{}
   10.44  	req.Name = strings.TrimSpace(req.Name)
   10.45  	req.Email = strings.TrimSpace(req.Email)
   10.46  	if len(req.Passphrase) < MinPassphraseLength {
   10.47 -		errors = append(errors, requestError{
   10.48 -			Slug:  requestErrInsufficient,
   10.49 +		errors = append(errors, RequestError{
   10.50 +			Slug:  RequestErrInsufficient,
   10.51  			Field: "/passphrase",
   10.52  		})
   10.53  	}
   10.54  	if len(req.Passphrase) > MaxPassphraseLength {
   10.55 -		errors = append(errors, requestError{
   10.56 -			Slug:  requestErrOverflow,
   10.57 +		errors = append(errors, RequestError{
   10.58 +			Slug:  RequestErrOverflow,
   10.59  			Field: "/passphrase",
   10.60  		})
   10.61  	}
   10.62  	if len(req.Name) > MaxNameLength {
   10.63 -		errors = append(errors, requestError{
   10.64 -			Slug:  requestErrOverflow,
   10.65 +		errors = append(errors, RequestError{
   10.66 +			Slug:  RequestErrOverflow,
   10.67  			Field: "/name",
   10.68  		})
   10.69  	}
   10.70  	if req.Email == "" {
   10.71 -		errors = append(errors, requestError{
   10.72 -			Slug:  requestErrMissing,
   10.73 +		errors = append(errors, RequestError{
   10.74 +			Slug:  RequestErrMissing,
   10.75  			Field: "/email",
   10.76  		})
   10.77  	}
   10.78  	if len(req.Email) > MaxEmailLength {
   10.79 -		errors = append(errors, requestError{
   10.80 -			Slug:  requestErrOverflow,
   10.81 +		errors = append(errors, RequestError{
   10.82 +			Slug:  RequestErrOverflow,
   10.83  			Field: "/email",
   10.84  		})
   10.85  	}
   10.86  	re := regexp.MustCompile(".+@.+\\..+")
   10.87  	if !re.Match([]byte(req.Email)) {
   10.88 -		errors = append(errors, requestError{
   10.89 -			Slug:  requestErrInvalidFormat,
   10.90 +		errors = append(errors, RequestError{
   10.91 +			Slug:  RequestErrInvalidFormat,
   10.92  			Field: "/email",
   10.93  		})
   10.94  	}
   10.95 @@ -260,9 +269,11 @@
   10.96  	deleteProfile(id uuid.ID) error
   10.97  
   10.98  	addLogin(login Login) error
   10.99 +	getLogin(value string) (Login, error)
  10.100  	removeLogin(value string, profile uuid.ID) error
  10.101  	removeLoginsByProfile(profile uuid.ID) error
  10.102  	recordLoginUse(value string, when time.Time) error
  10.103 +	verifyLogin(value, verification string) error
  10.104  	listLogins(profile uuid.ID, num, offset int) ([]Login, error)
  10.105  }
  10.106  
  10.107 @@ -352,6 +363,16 @@
  10.108  	return nil
  10.109  }
  10.110  
  10.111 +func (m *memstore) getLogin(value string) (Login, error) {
  10.112 +	m.loginLock.RLock()
  10.113 +	defer m.loginLock.RUnlock()
  10.114 +	l, ok := m.logins[value]
  10.115 +	if !ok {
  10.116 +		return Login{}, ErrLoginNotFound
  10.117 +	}
  10.118 +	return l, nil
  10.119 +}
  10.120 +
  10.121  func (m *memstore) removeLogin(value string, profile uuid.ID) error {
  10.122  	m.loginLock.Lock()
  10.123  	defer m.loginLock.Unlock()
  10.124 @@ -402,6 +423,21 @@
  10.125  	return nil
  10.126  }
  10.127  
  10.128 +func (m *memstore) verifyLogin(value, verification string) error {
  10.129 +	m.loginLock.Lock()
  10.130 +	defer m.loginLock.Unlock()
  10.131 +	l, ok := m.logins[value]
  10.132 +	if !ok {
  10.133 +		return ErrLoginNotFound
  10.134 +	}
  10.135 +	if l.Verification != verification {
  10.136 +		return ErrLoginVerificationInvalid
  10.137 +	}
  10.138 +	l.Verified = true
  10.139 +	m.logins[value] = l
  10.140 +	return nil
  10.141 +}
  10.142 +
  10.143  func (m *memstore) listLogins(profile uuid.ID, num, offset int) ([]Login, error) {
  10.144  	m.loginLock.RLock()
  10.145  	defer m.loginLock.RUnlock()
  10.146 @@ -466,35 +502,37 @@
  10.147  	// BUG(paddy): We need to implement a handler that will add a login to a profile.
  10.148  	// BUG(paddy): We need to implement a handler that will remove a login from a profile. What happens to sessions created with that login?
  10.149  	// BUG(paddy): We need to implement a handler that will list the logins attached to a profile.
  10.150 +	r.Handle("/logins/{login}", wrap(context, GetLoginHandler)).Methods("GET", "OPTIONS")
  10.151 +	r.Handle("/logins/{login}", wrap(context, UpdateLoginHandler)).Methods("PUT", "PATCH", "OPTIONS")
  10.152  }
  10.153  
  10.154  // GetProfileHandler is an HTTP handler for retrieving a profile.
  10.155  func GetProfileHandler(w http.ResponseWriter, r *http.Request, context Context) {
  10.156 -	errors := []requestError{}
  10.157 +	errors := []RequestError{}
  10.158  	authz := r.Header.Get("Authorization")
  10.159  	if !strings.HasPrefix(authz, "Bearer ") {
  10.160 -		errors = append(errors, requestError{Slug: requestErrAccessDenied})
  10.161 -		encode(w, r, http.StatusUnauthorized, response{Errors: errors})
  10.162 +		errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
  10.163 +		encode(w, r, http.StatusUnauthorized, Response{Errors: errors})
  10.164  		return
  10.165  	}
  10.166  	authz = strings.TrimPrefix(authz, "Bearer ")
  10.167  	vars := mux.Vars(r)
  10.168  	if vars["id"] == "" {
  10.169 -		errors = append(errors, requestError{Slug: requestErrMissing, Param: "id"})
  10.170 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
  10.171 +		errors = append(errors, RequestError{Slug: RequestErrMissing, Param: "id"})
  10.172 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
  10.173  		return
  10.174  	}
  10.175  	id, err := uuid.Parse(vars["id"])
  10.176  	if err != nil {
  10.177 -		errors = append(errors, requestError{Slug: requestErrInvalidFormat, Param: "id"})
  10.178 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
  10.179 +		errors = append(errors, RequestError{Slug: RequestErrInvalidFormat, Param: "id"})
  10.180 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
  10.181  		return
  10.182  	}
  10.183  	token, err := context.GetToken(authz, false)
  10.184  	if err != nil || token.Revoked {
  10.185  		if err == ErrTokenNotFound || token.Revoked {
  10.186 -			errors = append(errors, requestError{Slug: requestErrAccessDenied})
  10.187 -			encode(w, r, http.StatusUnauthorized, response{Errors: errors})
  10.188 +			errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
  10.189 +			encode(w, r, http.StatusUnauthorized, Response{Errors: errors})
  10.190  			return
  10.191  		} else {
  10.192  			encode(w, r, http.StatusInternalServerError, actOfGodResponse)
  10.193 @@ -502,21 +540,21 @@
  10.194  		}
  10.195  	}
  10.196  	if !id.Equal(token.ProfileID) {
  10.197 -		errors = append(errors, requestError{Slug: requestErrAccessDenied})
  10.198 -		encode(w, r, http.StatusForbidden, response{Errors: errors})
  10.199 +		errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
  10.200 +		encode(w, r, http.StatusForbidden, Response{Errors: errors})
  10.201  		return
  10.202  	}
  10.203  	profile, err := context.GetProfileByID(id)
  10.204  	if err != nil {
  10.205  		if err == ErrProfileNotFound {
  10.206 -			errors = append(errors, requestError{Slug: requestErrNotFound, Param: "id"})
  10.207 -			encode(w, r, http.StatusNotFound, response{Errors: errors})
  10.208 +			errors = append(errors, RequestError{Slug: RequestErrNotFound, Param: "id"})
  10.209 +			encode(w, r, http.StatusNotFound, Response{Errors: errors})
  10.210  			return
  10.211  		}
  10.212  		encode(w, r, http.StatusInternalServerError, actOfGodResponse)
  10.213  		return
  10.214  	}
  10.215 -	encode(w, r, http.StatusOK, response{Profiles: []Profile{profile}})
  10.216 +	encode(w, r, http.StatusOK, Response{Profiles: []Profile{profile}})
  10.217  	return
  10.218  }
  10.219  
  10.220 @@ -529,7 +567,7 @@
  10.221  		return
  10.222  	}
  10.223  	var req newProfileRequest
  10.224 -	errors := []requestError{}
  10.225 +	errors := []RequestError{}
  10.226  	decoder := json.NewDecoder(r.Body)
  10.227  	err := decoder.Decode(&req)
  10.228  	if err != nil {
  10.229 @@ -538,7 +576,7 @@
  10.230  	}
  10.231  	errors = append(errors, validateNewProfileRequest(&req)...)
  10.232  	if len(errors) > 0 {
  10.233 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
  10.234 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
  10.235  		return
  10.236  	}
  10.237  	passphrase, salt, err := scheme.create(req.Passphrase, context.config.iterations)
  10.238 @@ -560,7 +598,7 @@
  10.239  	err = context.SaveProfile(profile)
  10.240  	if err != nil {
  10.241  		if err == ErrProfileAlreadyExists {
  10.242 -			encode(w, r, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrConflict, Field: "/id"}}})
  10.243 +			encode(w, r, http.StatusBadRequest, Response{Errors: []RequestError{{Slug: RequestErrConflict, Field: "/id"}}})
  10.244  			return
  10.245  		}
  10.246  		log.Printf("Error saving profile: %#+v\n", err)
  10.247 @@ -569,16 +607,17 @@
  10.248  	}
  10.249  	logins := []Login{}
  10.250  	login := Login{
  10.251 -		Type:      "email",
  10.252 -		Value:     req.Email,
  10.253 -		Created:   profile.Created,
  10.254 -		LastUsed:  profile.Created,
  10.255 -		ProfileID: profile.ID,
  10.256 +		Type:         "email",
  10.257 +		Value:        req.Email,
  10.258 +		Created:      profile.Created,
  10.259 +		LastUsed:     profile.Created,
  10.260 +		ProfileID:    profile.ID,
  10.261 +		Verification: uuid.NewID().String(),
  10.262  	}
  10.263  	err = context.AddLogin(login)
  10.264  	if err != nil {
  10.265  		if err == ErrLoginAlreadyExists {
  10.266 -			encode(w, r, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrConflict, Field: "/email"}}})
  10.267 +			encode(w, r, http.StatusBadRequest, Response{Errors: []RequestError{{Slug: RequestErrConflict, Field: "/email"}}})
  10.268  			return
  10.269  		}
  10.270  		log.Printf("Error adding login: %#+v\n", err)
  10.271 @@ -586,48 +625,48 @@
  10.272  		return
  10.273  	}
  10.274  	logins = append(logins, login)
  10.275 -	resp := response{
  10.276 +	resp := Response{
  10.277  		Logins:   logins,
  10.278  		Profiles: []Profile{profile},
  10.279  	}
  10.280  	encode(w, r, http.StatusCreated, resp)
  10.281 -	// TODO(paddy): should we kick off the email validation flow?
  10.282 +	go context.SendLoginVerification(login)
  10.283  }
  10.284  
  10.285  func UpdateProfileHandler(w http.ResponseWriter, r *http.Request, context Context) {
  10.286 -	errors := []requestError{}
  10.287 +	errors := []RequestError{}
  10.288  	vars := mux.Vars(r)
  10.289  	if vars["id"] == "" {
  10.290 -		errors = append(errors, requestError{Slug: requestErrMissing, Param: "id"})
  10.291 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
  10.292 +		errors = append(errors, RequestError{Slug: RequestErrMissing, Param: "id"})
  10.293 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
  10.294  		return
  10.295  	}
  10.296  	id, err := uuid.Parse(vars["id"])
  10.297  	if err != nil {
  10.298 -		errors = append(errors, requestError{Slug: requestErrAccessDenied})
  10.299 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
  10.300 +		errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
  10.301 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
  10.302  		return
  10.303  	}
  10.304  	username, password, ok := r.BasicAuth()
  10.305  	if !ok {
  10.306 -		errors = append(errors, requestError{Slug: requestErrAccessDenied})
  10.307 -		encode(w, r, http.StatusUnauthorized, response{Errors: errors})
  10.308 +		errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
  10.309 +		encode(w, r, http.StatusUnauthorized, Response{Errors: errors})
  10.310  		return
  10.311  	}
  10.312  	profile, err := authenticate(username, password, context)
  10.313  	if err != nil {
  10.314  		if isAuthError(err) {
  10.315 -			errors = append(errors, requestError{Slug: requestErrAccessDenied})
  10.316 -			encode(w, r, http.StatusUnauthorized, response{Errors: errors})
  10.317 +			errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
  10.318 +			encode(w, r, http.StatusUnauthorized, Response{Errors: errors})
  10.319  		} else {
  10.320 -			errors = append(errors, requestError{Slug: requestErrActOfGod})
  10.321 -			encode(w, r, http.StatusInternalServerError, response{Errors: errors})
  10.322 +			errors = append(errors, RequestError{Slug: RequestErrActOfGod})
  10.323 +			encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
  10.324  		}
  10.325  		return
  10.326  	}
  10.327  	if !profile.ID.Equal(id) {
  10.328 -		errors = append(errors, requestError{Slug: requestErrAccessDenied})
  10.329 -		encode(w, r, http.StatusForbidden, response{Errors: errors})
  10.330 +		errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
  10.331 +		encode(w, r, http.StatusForbidden, Response{Errors: errors})
  10.332  		return
  10.333  	}
  10.334  	var req ProfileChange
  10.335 @@ -646,13 +685,13 @@
  10.336  	req.LastSeen = nil
  10.337  	if req.Passphrase != nil {
  10.338  		if len(*req.Passphrase) < MinPassphraseLength {
  10.339 -			errors = append(errors, requestError{Slug: requestErrInsufficient, Field: "/passphrase"})
  10.340 -			encode(w, r, http.StatusBadRequest, response{Errors: errors})
  10.341 +			errors = append(errors, RequestError{Slug: RequestErrInsufficient, Field: "/passphrase"})
  10.342 +			encode(w, r, http.StatusBadRequest, Response{Errors: errors})
  10.343  			return
  10.344  		}
  10.345  		if len(*req.Passphrase) > MaxPassphraseLength {
  10.346 -			errors = append(errors, requestError{Slug: requestErrOverflow, Field: "/passphrase"})
  10.347 -			encode(w, r, http.StatusBadRequest, response{Errors: errors})
  10.348 +			errors = append(errors, RequestError{Slug: RequestErrOverflow, Field: "/passphrase"})
  10.349 +			encode(w, r, http.StatusBadRequest, Response{Errors: errors})
  10.350  			return
  10.351  		}
  10.352  		iterations := context.config.iterations
  10.353 @@ -679,13 +718,13 @@
  10.354  	err = req.Validate()
  10.355  	if err != nil {
  10.356  		var status int
  10.357 -		var resp response
  10.358 +		var resp Response
  10.359  		switch err {
  10.360  		case ErrEmptyChange:
  10.361  			resp.Profiles = []Profile{profile}
  10.362  			status = http.StatusOK
  10.363  		default:
  10.364 -			errors = append(errors, requestError{Slug: requestErrActOfGod})
  10.365 +			errors = append(errors, RequestError{Slug: RequestErrActOfGod})
  10.366  			resp.Errors = errors
  10.367  			status = http.StatusInternalServerError
  10.368  		}
  10.369 @@ -695,64 +734,145 @@
  10.370  	err = context.UpdateProfile(id, req)
  10.371  	if err != nil {
  10.372  		if err == ErrProfileNotFound {
  10.373 -			errors = append(errors, requestError{Slug: requestErrNotFound})
  10.374 -			encode(w, r, http.StatusNotFound, response{Errors: errors})
  10.375 +			errors = append(errors, RequestError{Slug: RequestErrNotFound})
  10.376 +			encode(w, r, http.StatusNotFound, Response{Errors: errors})
  10.377  			return
  10.378  		}
  10.379  		encode(w, r, http.StatusInternalServerError, actOfGodResponse)
  10.380  		return
  10.381  	}
  10.382  	profile.ApplyChange(req)
  10.383 -	encode(w, r, http.StatusOK, response{Profiles: []Profile{profile}})
  10.384 +	encode(w, r, http.StatusOK, Response{Profiles: []Profile{profile}})
  10.385  	return
  10.386  }
  10.387  
  10.388  func DeleteProfileHandler(w http.ResponseWriter, r *http.Request, context Context) {
  10.389 -	errors := []requestError{}
  10.390 +	errors := []RequestError{}
  10.391  	vars := mux.Vars(r)
  10.392  	if vars["id"] == "" {
  10.393 -		errors = append(errors, requestError{Slug: requestErrMissing, Param: "id"})
  10.394 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
  10.395 +		errors = append(errors, RequestError{Slug: RequestErrMissing, Param: "id"})
  10.396 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
  10.397  		return
  10.398  	}
  10.399  	id, err := uuid.Parse(vars["id"])
  10.400  	if err != nil {
  10.401 -		errors = append(errors, requestError{Slug: requestErrAccessDenied})
  10.402 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
  10.403 +		errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
  10.404 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
  10.405  		return
  10.406  	}
  10.407  	username, password, ok := r.BasicAuth()
  10.408  	if !ok {
  10.409 -		errors = append(errors, requestError{Slug: requestErrAccessDenied})
  10.410 -		encode(w, r, http.StatusUnauthorized, response{Errors: errors})
  10.411 +		errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
  10.412 +		encode(w, r, http.StatusUnauthorized, Response{Errors: errors})
  10.413  		return
  10.414  	}
  10.415  	profile, err := authenticate(username, password, context)
  10.416  	if err != nil {
  10.417  		if isAuthError(err) {
  10.418 -			errors = append(errors, requestError{Slug: requestErrAccessDenied})
  10.419 -			encode(w, r, http.StatusUnauthorized, response{Errors: errors})
  10.420 +			errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
  10.421 +			encode(w, r, http.StatusUnauthorized, Response{Errors: errors})
  10.422  		} else {
  10.423 -			errors = append(errors, requestError{Slug: requestErrActOfGod})
  10.424 -			encode(w, r, http.StatusInternalServerError, response{Errors: errors})
  10.425 +			errors = append(errors, RequestError{Slug: RequestErrActOfGod})
  10.426 +			encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
  10.427  		}
  10.428  		return
  10.429  	}
  10.430  	if !profile.ID.Equal(id) {
  10.431 -		errors = append(errors, requestError{Slug: requestErrAccessDenied})
  10.432 -		encode(w, r, http.StatusForbidden, response{Errors: errors})
  10.433 +		errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
  10.434 +		encode(w, r, http.StatusForbidden, Response{Errors: errors})
  10.435  		return
  10.436  	}
  10.437  	err = context.DeleteProfile(id)
  10.438  	if err != nil {
  10.439  		if err == ErrProfileNotFound {
  10.440 -			errors = append(errors, requestError{Slug: requestErrNotFound})
  10.441 -			encode(w, r, http.StatusNotFound, response{Errors: errors})
  10.442 +			errors = append(errors, RequestError{Slug: RequestErrNotFound})
  10.443 +			encode(w, r, http.StatusNotFound, Response{Errors: errors})
  10.444  			return
  10.445  		}
  10.446  		encode(w, r, http.StatusInternalServerError, actOfGodResponse)
  10.447  		return
  10.448  	}
  10.449 -	encode(w, r, http.StatusOK, response{Profiles: []Profile{profile}})
  10.450 +	encode(w, r, http.StatusOK, Response{Profiles: []Profile{profile}})
  10.451  	go cleanUpAfterProfileDeletion(profile.ID, context)
  10.452  }
  10.453 +
  10.454 +func GetLoginHandler(w http.ResponseWriter, r *http.Request, context Context) {
  10.455 +	var errors []RequestError
  10.456 +	vars := mux.Vars(r)
  10.457 +	if vars["login"] == "" {
  10.458 +		errors = append(errors, RequestError{Slug: RequestErrMissing, Param: "login"})
  10.459 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
  10.460 +		return
  10.461 +	}
  10.462 +	login, err := context.GetLogin(vars["login"])
  10.463 +	if err != nil {
  10.464 +		if err == ErrLoginNotFound {
  10.465 +			errors = append(errors, RequestError{Slug: RequestErrNotFound, Param: "login"})
  10.466 +			encode(w, r, http.StatusNotFound, Response{Errors: errors})
  10.467 +			return
  10.468 +		}
  10.469 +		log.Printf("Error retrieving login: %#+v\n", err)
  10.470 +		encode(w, r, http.StatusInternalServerError, actOfGodResponse)
  10.471 +		return
  10.472 +	}
  10.473 +	encode(w, r, http.StatusOK, Response{Logins: []Login{login}})
  10.474 +}
  10.475 +
  10.476 +func UpdateLoginHandler(w http.ResponseWriter, r *http.Request, context Context) {
  10.477 +	var errors []RequestError
  10.478 +	vars := mux.Vars(r)
  10.479 +	if vars["login"] == "" {
  10.480 +		errors = append(errors, RequestError{Slug: RequestErrMissing, Param: "login"})
  10.481 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
  10.482 +		return
  10.483 +	}
  10.484 +	var req LoginChange
  10.485 +	decoder := json.NewDecoder(r.Body)
  10.486 +	err := decoder.Decode(&req)
  10.487 +	if err != nil {
  10.488 +		log.Printf("Error decoding request: %#+v\n", err)
  10.489 +		encode(w, r, http.StatusBadRequest, invalidFormatResponse)
  10.490 +		return
  10.491 +	}
  10.492 +	login, err := context.GetLogin(vars["login"])
  10.493 +	if err != nil {
  10.494 +		if err == ErrLoginNotFound {
  10.495 +			errors = append(errors, RequestError{Slug: RequestErrNotFound, Param: "login"})
  10.496 +			encode(w, r, http.StatusNotFound, Response{Errors: errors})
  10.497 +			return
  10.498 +		}
  10.499 +		log.Printf("Error retrieving login: %#+v\n", err)
  10.500 +		encode(w, r, http.StatusInternalServerError, actOfGodResponse)
  10.501 +		return
  10.502 +	}
  10.503 +	if req.Verification != nil {
  10.504 +		err = context.VerifyLogin(vars["login"], *req.Verification)
  10.505 +		if err != nil {
  10.506 +			if err == ErrLoginNotFound {
  10.507 +				errors = append(errors, RequestError{Slug: RequestErrNotFound, Param: "login"})
  10.508 +				encode(w, r, http.StatusNotFound, Response{Errors: errors})
  10.509 +				return
  10.510 +			} else if err == ErrLoginVerificationInvalid {
  10.511 +				errors = append(errors, RequestError{Slug: RequestErrInvalidValue, Field: "/verification"})
  10.512 +				encode(w, r, http.StatusBadRequest, Response{Errors: errors})
  10.513 +				return
  10.514 +			}
  10.515 +			log.Printf("Error verifying login with verification '%s': %#+v\n", *req.Verification, err)
  10.516 +			encode(w, r, http.StatusInternalServerError, actOfGodResponse)
  10.517 +			return
  10.518 +		}
  10.519 +		login.Verified = true
  10.520 +	} else if req.ResendVerification != nil {
  10.521 +		if !*req.ResendVerification {
  10.522 +			errors = append(errors, RequestError{Slug: RequestErrInvalidValue, Field: "/resend_verification"})
  10.523 +			encode(w, r, http.StatusBadRequest, Response{Errors: errors})
  10.524 +			return
  10.525 +		}
  10.526 +		context.SendLoginVerification(login)
  10.527 +	} else {
  10.528 +		errors = append(errors, RequestError{Slug: RequestErrMissing, Field: "/"})
  10.529 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
  10.530 +		return
  10.531 +	}
  10.532 +	encode(w, r, http.StatusOK, Response{Logins: []Login{login}})
  10.533 +}
    11.1 --- a/profile_postgres.go	Sun May 17 02:18:07 2015 -0400
    11.2 +++ b/profile_postgres.go	Sun May 17 02:27:36 2015 -0400
    11.3 @@ -259,6 +259,66 @@
    11.4  	return err
    11.5  }
    11.6  
    11.7 +func (p *postgres) verifyLoginSQL(value, verification string) *pan.Query {
    11.8 +	var login Login
    11.9 +	query := pan.New(pan.POSTGRES, "UPDATE "+pan.GetTableName(login)+" SET ")
   11.10 +	query.Include(pan.GetUnquotedColumn(login, "Verified")+" = ?", true)
   11.11 +	query.IncludeWhere()
   11.12 +	query.FlushExpressions(" ")
   11.13 +	query.Include(pan.GetUnquotedColumn(login, "Value")+" = ?", value)
   11.14 +	query.Include(pan.GetUnquotedColumn(login, "Verification")+" = ?", verification)
   11.15 +	return query.FlushExpressions(" AND ")
   11.16 +}
   11.17 +
   11.18 +func (p *postgres) verifyLogin(value, verification string) error {
   11.19 +	query := p.verifyLoginSQL(value, verification)
   11.20 +	res, err := p.db.Exec(query.String(), query.Args...)
   11.21 +	if err != nil {
   11.22 +		return err
   11.23 +	}
   11.24 +	rows, err := res.RowsAffected()
   11.25 +	if err != nil {
   11.26 +		return err
   11.27 +	}
   11.28 +	if rows == 0 {
   11.29 +		return ErrLoginVerificationInvalid
   11.30 +	}
   11.31 +	return nil
   11.32 +}
   11.33 +
   11.34 +func (p *postgres) getLoginSQL(value string) *pan.Query {
   11.35 +	var login Login
   11.36 +	fields, _ := pan.GetFields(login)
   11.37 +	query := pan.New(pan.POSTGRES, "SELECT "+pan.QueryList(fields)+" FROM "+pan.GetTableName(login))
   11.38 +	query.IncludeWhere()
   11.39 +	query.Include(pan.GetUnquotedColumn(login, "Value")+" = ?", value)
   11.40 +	return query.FlushExpressions(" ")
   11.41 +}
   11.42 +
   11.43 +func (p *postgres) getLogin(value string) (Login, error) {
   11.44 +	query := p.getLoginSQL(value)
   11.45 +	rows, err := p.db.Query(query.String(), query.Args...)
   11.46 +	if err != nil {
   11.47 +		return Login{}, err
   11.48 +	}
   11.49 +	var login Login
   11.50 +	var found bool
   11.51 +	for rows.Next() {
   11.52 +		err := pan.Unmarshal(rows, &login)
   11.53 +		if err != nil {
   11.54 +			return Login{}, err
   11.55 +		}
   11.56 +		found = true
   11.57 +	}
   11.58 +	if err := rows.Err(); err != nil {
   11.59 +		return Login{}, err
   11.60 +	}
   11.61 +	if !found {
   11.62 +		return login, ErrLoginNotFound
   11.63 +	}
   11.64 +	return login, nil
   11.65 +}
   11.66 +
   11.67  func (p *postgres) listLoginsSQL(profile uuid.ID, num, offset int) *pan.Query {
   11.68  	var login Login
   11.69  	fields, _ := pan.GetFields(login)
    12.1 --- a/profile_test.go	Sun May 17 02:18:07 2015 -0400
    12.2 +++ b/profile_test.go	Sun May 17 02:27:36 2015 -0400
    12.3 @@ -90,6 +90,12 @@
    12.4  	if !login1.LastUsed.Equal(login2.LastUsed) {
    12.5  		return false, "LastUsed", login1.LastUsed, login2.LastUsed
    12.6  	}
    12.7 +	if login1.Verification != login2.Verification {
    12.8 +		return false, "Verification", login1.Verification, login2.Verification
    12.9 +	}
   12.10 +	if login1.Verified != login2.Verified {
   12.11 +		return false, "Verified", login1.Verified, login2.Verified
   12.12 +	}
   12.13  	return true, "", nil, nil
   12.14  }
   12.15  
    13.1 --- a/request.go	Sun May 17 02:18:07 2015 -0400
    13.2 +++ b/request.go	Sun May 17 02:27:36 2015 -0400
    13.3 @@ -9,26 +9,26 @@
    13.4  )
    13.5  
    13.6  const (
    13.7 -	requestErrAccessDenied  = "access_denied"
    13.8 -	requestErrInsufficient  = "insufficient"
    13.9 -	requestErrOverflow      = "overflow"
   13.10 -	requestErrInvalidValue  = "invalid_value"
   13.11 -	requestErrInvalidFormat = "invalid_format"
   13.12 -	requestErrMissing       = "missing"
   13.13 -	requestErrNotFound      = "not_found"
   13.14 -	requestErrConflict      = "conflict"
   13.15 -	requestErrActOfGod      = "act_of_god"
   13.16 +	RequestErrAccessDenied  = "access_denied"
   13.17 +	RequestErrInsufficient  = "insufficient"
   13.18 +	RequestErrOverflow      = "overflow"
   13.19 +	RequestErrInvalidValue  = "invalid_value"
   13.20 +	RequestErrInvalidFormat = "invalid_format"
   13.21 +	RequestErrMissing       = "missing"
   13.22 +	RequestErrNotFound      = "not_found"
   13.23 +	RequestErrConflict      = "conflict"
   13.24 +	RequestErrActOfGod      = "act_of_god"
   13.25  )
   13.26  
   13.27  var (
   13.28 -	actOfGodResponse      = response{Errors: []requestError{requestError{Slug: requestErrActOfGod}}}
   13.29 -	invalidFormatResponse = response{Errors: []requestError{requestError{Slug: requestErrInvalidFormat, Field: "/"}}}
   13.30 +	actOfGodResponse      = Response{Errors: []RequestError{RequestError{Slug: RequestErrActOfGod}}}
   13.31 +	invalidFormatResponse = Response{Errors: []RequestError{RequestError{Slug: RequestErrInvalidFormat, Field: "/"}}}
   13.32  
   13.33  	encoders = []string{"application/json"}
   13.34  )
   13.35  
   13.36 -type response struct {
   13.37 -	Errors    []requestError `json:"errors,omitempty"`
   13.38 +type Response struct {
   13.39 +	Errors    []RequestError `json:"errors,omitempty"`
   13.40  	Logins    []Login        `json:"logins,omitempty"`
   13.41  	Profiles  []Profile      `json:"profiles,omitempty"`
   13.42  	Clients   []Client       `json:"clients,omitempty"`
   13.43 @@ -36,7 +36,7 @@
   13.44  	Sessions  []Session      `json:"sessions,omitempty"`
   13.45  }
   13.46  
   13.47 -type requestError struct {
   13.48 +type RequestError struct {
   13.49  	Slug   string `json:"error,omitempty"`
   13.50  	Field  string `json:"field,omitempty"`
   13.51  	Param  string `json:"param,omitempty"`
   13.52 @@ -57,7 +57,7 @@
   13.53  	})
   13.54  }
   13.55  
   13.56 -func encode(w http.ResponseWriter, r *http.Request, status int, resp response) {
   13.57 +func encode(w http.ResponseWriter, r *http.Request, status int, resp Response) {
   13.58  	contentType := goautoneg.Negotiate(r.Header.Get("Accept"), encoders)
   13.59  	w.Header().Set("content-type", contentType)
   13.60  	w.WriteHeader(status)
    14.1 --- a/request_test.go	Sun May 17 02:18:07 2015 -0400
    14.2 +++ b/request_test.go	Sun May 17 02:27:36 2015 -0400
    14.3 @@ -2,7 +2,7 @@
    14.4  
    14.5  import "fmt"
    14.6  
    14.7 -func compareErrors(err1, err2 requestError) (success bool, field string, val1, val2 interface{}) {
    14.8 +func compareErrors(err1, err2 RequestError) (success bool, field string, val1, val2 interface{}) {
    14.9  	if err1.Slug != err2.Slug {
   14.10  		return false, "Slug", err1.Slug, err2.Slug
   14.11  	}
   14.12 @@ -18,7 +18,7 @@
   14.13  	return true, "", nil, nil
   14.14  }
   14.15  
   14.16 -func compareResponses(resp1, resp2 response) (success bool, field string, val1, val2 interface{}) {
   14.17 +func compareResponses(resp1, resp2 Response) (success bool, field string, val1, val2 interface{}) {
   14.18  	if len(resp1.Errors) != len(resp2.Errors) {
   14.19  		return false, "Errors", resp1.Errors, resp2.Errors
   14.20  	}
   14.21 @@ -72,7 +72,7 @@
   14.22  	return true, "", nil, nil
   14.23  }
   14.24  
   14.25 -func fillInServerGenerated(expectation, result response) {
   14.26 +func fillInServerGenerated(expectation, result Response) {
   14.27  	if len(expectation.Profiles) > 0 {
   14.28  		for pos, profile := range expectation.Profiles {
   14.29  			profile.ID = result.Profiles[pos].ID
    15.1 --- a/session.go	Sun May 17 02:18:07 2015 -0400
    15.2 +++ b/session.go	Sun May 17 02:27:36 2015 -0400
    15.3 @@ -357,60 +357,60 @@
    15.4  
    15.5  // TerminateSessionHandler allows the user to end their session before it expires.
    15.6  func TerminateSessionHandler(w http.ResponseWriter, r *http.Request, context Context) {
    15.7 -	var errors []requestError
    15.8 +	var errors []RequestError
    15.9  	vars := mux.Vars(r)
   15.10  	if vars["id"] == "" {
   15.11 -		errors = append(errors, requestError{Slug: requestErrMissing, Param: "id"})
   15.12 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
   15.13 +		errors = append(errors, RequestError{Slug: RequestErrMissing, Param: "id"})
   15.14 +		encode(w, r, http.StatusBadRequest, Response{Errors: errors})
   15.15  		return
   15.16  	}
   15.17  	id := vars["id"]
   15.18  	un, pw, ok := r.BasicAuth()
   15.19  	if !ok {
   15.20 -		errors = append(errors, requestError{Slug: requestErrAccessDenied})
   15.21 -		encode(w, r, http.StatusUnauthorized, response{Errors: errors})
   15.22 +		errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
   15.23 +		encode(w, r, http.StatusUnauthorized, Response{Errors: errors})
   15.24  		return
   15.25  	}
   15.26  	profile, err := authenticate(un, pw, context)
   15.27  	if err != nil {
   15.28  		if isAuthError(err) {
   15.29 -			errors = append(errors, requestError{Slug: requestErrAccessDenied})
   15.30 -			encode(w, r, http.StatusForbidden, response{Errors: errors})
   15.31 +			errors = append(errors, RequestError{Slug: RequestErrAccessDenied})
   15.32 +			encode(w, r, http.StatusForbidden, Response{Errors: errors})
   15.33  			return
   15.34  		}
   15.35 -		errors = append(errors, requestError{Slug: requestErrActOfGod})
   15.36 -		encode(w, r, http.StatusInternalServerError, response{Errors: errors})
   15.37 +		errors = append(errors, RequestError{Slug: RequestErrActOfGod})
   15.38 +		encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
   15.39  		return
   15.40  	}
   15.41  	session, err := context.GetSession(id)
   15.42  	if err != nil {
   15.43  		if err == ErrSessionNotFound {
   15.44 -			errors = append(errors, requestError{Slug: requestErrNotFound, Param: "id"})
   15.45 -			encode(w, r, http.StatusNotFound, response{Errors: errors})
   15.46 +			errors = append(errors, RequestError{Slug: RequestErrNotFound, Param: "id"})
   15.47 +			encode(w, r, http.StatusNotFound, Response{Errors: errors})
   15.48  			return
   15.49  		}
   15.50 -		errors = append(errors, requestError{Slug: requestErrActOfGod})
   15.51 -		encode(w, r, http.StatusInternalServerError, response{Errors: errors})
   15.52 +		errors = append(errors, RequestError{Slug: RequestErrActOfGod})
   15.53 +		encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
   15.54  		return
   15.55  	}
   15.56  	if !session.ProfileID.Equal(profile.ID) {
   15.57 -		errors = append(errors, requestError{Slug: requestErrAccessDenied, Param: "id"})
   15.58 -		encode(w, r, http.StatusForbidden, response{Errors: errors})
   15.59 +		errors = append(errors, RequestError{Slug: RequestErrAccessDenied, Param: "id"})
   15.60 +		encode(w, r, http.StatusForbidden, Response{Errors: errors})
   15.61  		return
   15.62  	}
   15.63  	err = context.TerminateSession(id)
   15.64  	if err != nil {
   15.65  		if err == ErrSessionNotFound {
   15.66 -			errors = append(errors, requestError{Slug: requestErrNotFound, Param: "id"})
   15.67 -			encode(w, r, http.StatusNotFound, response{Errors: errors})
   15.68 +			errors = append(errors, RequestError{Slug: RequestErrNotFound, Param: "id"})
   15.69 +			encode(w, r, http.StatusNotFound, Response{Errors: errors})
   15.70  			return
   15.71  		}
   15.72 -		errors = append(errors, requestError{Slug: requestErrActOfGod})
   15.73 -		encode(w, r, http.StatusInternalServerError, response{Errors: errors})
   15.74 +		errors = append(errors, RequestError{Slug: RequestErrActOfGod})
   15.75 +		encode(w, r, http.StatusInternalServerError, Response{Errors: errors})
   15.76  		return
   15.77  	}
   15.78  	session.Active = false
   15.79 -	encode(w, r, http.StatusOK, response{Sessions: []Session{session}, Errors: errors})
   15.80 +	encode(w, r, http.StatusOK, Response{Sessions: []Session{session}, Errors: errors})
   15.81  }
   15.82  
   15.83  func credentialsValidate(w http.ResponseWriter, r *http.Request, context Context) (scopes Scopes, profileID uuid.ID, valid bool) {
    16.1 --- a/sql/postgres_init.sql	Sun May 17 02:18:07 2015 -0400
    16.2 +++ b/sql/postgres_init.sql	Sun May 17 02:27:36 2015 -0400
    16.3 @@ -18,7 +18,9 @@
    16.4  	value VARCHAR(64) PRIMARY KEY,
    16.5  	profile_id VARCHAR(36) NOT NULL,
    16.6  	created TIMESTAMPTZ NOT NULL,
    16.7 -	last_used TIMESTAMPTZ NOT NULL
    16.8 +	last_used TIMESTAMPTZ NOT NULL,
    16.9 +	verification VARCHAR(36) NOT NULL,
   16.10 +	verified BOOLEAN NOT NULL
   16.11  );
   16.12  
   16.13  CREATE TABLE IF NOT EXISTS clients (