Update to use a generic event emitter.
Rather can creating a purpose-built event emitter for each and every event we
need to emit (I'm looking at you, login verification event) which is _downright
silly_, we're now using a generic event publisher that's based on saying "HEY A
MODEL UPDATED".
This means we need to change all our setup code in authd to use
events.NewNSQPublisher or events.NewStdoutPublisher instead of our homegrown
solutions. Which also means updating our config to take an events.Publisher
instead of our LoginVerificationNotifier (blergh).
Our Context also now uses an events.Publisher instead of a
LoginVerificationNotifier. Party all around! We also replaced our
SendLoginVerification helper method on Context with a SendModelEvent helper
method on Context, which is just a light wrapper around
events.PublishModelEvent.
Of course, all this means we need to update our email_verification listener to
listen to the correct channel (based on the model we want updates about) and
filter down to a Created action or our new custom action for "the customer wants
their verification resent", which I'm OK making a special case and not generic,
because c'mon. But we had a subtle change to all our constants, some of which
are unofficial constants now. I'm unsure how I feel about this.
We also updated our email_verification listener so that we're unmarshalling to a
custom loginEvent, which is just an events.Event that overwrites the Data
property to be an auth.Login instance. This is to make sure we don't need to
wrangle a map[string]interface{}, which is no fun. I'm also OK with
special-casing like this, because it's 1) a tiny amount of code, 2) properly
utilising composition, and 3) the only way I can think of to cleanly accomplish
what I want.
I also added a note about GetLogin's deficient handling of logins, namely that
it doesn't recognise admins and return Verification codes to them, which would
be a useful property for internal tools to take advantage of. Ah well.
I updated the Profile and Login implementations so they're now event.Model
instances, mainly by just exporting some strings from them through getters that
will let us automatically build an Event from them. This lets us use the
PublishModelEvent helper.
I updated our CreateProfileHandler to properly mangle the login Verification
property, and to fire off the ActionCreated events for the new Login and the new
Profile.
I updated our GetLoginHandler and UpdateLoginHandler to properly mangle the
loginVerification property. God that's annoying. :-/
You'll note I didn't start publishing the events.ActionUpdated or
events.ActionDeleted events for Profiles or Logins yet, and didn't bother
publishing any events for literally any other type. That's because I'm a lazy
piece of crap and will end up publishing them when I absolutely have to. Part of
that is because if a channel isn't created/being read for a topic, the messages
will just stack up in NSQ, and I don't want that. But mostly I'm lazy.
Finally, I got to delete the entire profile_verification.go file, because we're
no longer special-casing that. Hooray!
18 "code.secondbit.org/uuid.hg"
19 "github.com/gorilla/mux"
23 defaultAuthorizationCodeExpiration = 600 // default to ten minute grant expirations
24 getAuthorizationCodeTemplateName = "get_grant"
28 // ErrNoAuth is returned when an Authorization header is not present or is empty.
29 ErrNoAuth = errors.New("no authorization header supplied")
30 // ErrInvalidAuthFormat is returned when an Authorization header is present but not the correct format.
31 ErrInvalidAuthFormat = errors.New("authorization header is not in a valid format")
32 // ErrIncorrectAuth is returned when a user authentication attempt does not match the stored values.
33 ErrIncorrectAuth = errors.New("invalid authentication")
34 // ErrInvalidPassphraseScheme is returned when an undefined passphrase scheme is used.
35 ErrInvalidPassphraseScheme = errors.New("invalid passphrase scheme")
36 // ErrNoSession is returned when no session ID is passed with a request.
37 ErrNoSession = errors.New("no session ID found")
39 grantTypesMap = grantTypes{types: map[string]GrantType{}}
42 type grantTypes struct {
43 types map[string]GrantType
47 // GrantType defines a set of functions and metadata around a specific authorization grant strategy.
49 // The Validate function will be called when requests are made that match the GrantType, and should write any
50 // errors to the ResponseWriter. It is responsible for determining if the grant is valid and a token should be issued.
51 // It must return the scope the grant was for and the ID of the Profile that issued the grant, as well as if the grant
52 // is valid or not. It must not be nil.
54 // The Invalidate function will be called when the grant has successfully generated a token and the token has successfully
55 // been conveyed to the user. The Invalidate function is always called asynchronously, outside the request. It should take
56 // care of marking the grant as used, if the GrantType requires grants to be one-time only grants. The Invalidate function
59 // IssuesRefresh determines whether the GrantType should yield a refresh token as well as an access token. If true, the client
60 // will be issued a refresh token.
62 // AllowsPublic determines whether the GrantType should allow public clients to use that grant. If true, clients without
63 // credentials will be able to use the grant to obtain a token.
65 // AuditString should return the string that will be saved in the resulting Token's CreatedFrom field, as an audit log of how
66 // the Token was authorized.
68 // The ReturnToken will be called when a token is created and needs to be returned to the client. If it returns true, the token
69 // was successfully returned and the Invalidate function will be called asynchronously.
70 type GrantType struct {
71 Validate func(w http.ResponseWriter, r *http.Request, context Context) (scopes Scopes, profileID uuid.ID, valid bool)
72 Invalidate func(r *http.Request, context Context) error
73 ReturnToken func(w http.ResponseWriter, r *http.Request, token Token, context Context) bool
74 AuditString func(r *http.Request) string
79 type tokenResponse struct {
80 AccessToken string `json:"access_token"`
81 TokenType string `json:"token_type,omitempty"`
82 ExpiresIn int32 `json:"expires_in,omitempty"`
83 RefreshToken string `json:"refresh_token,omitempty"`
86 type errorResponse struct {
87 Error string `json:"error"`
88 Description string `json:"error_description,omitempty"`
89 URI string `json:"error_uri,omitempty"`
92 // RegisterGrantType associates a string with a GrantType. When the string is used as the value for "grant_type" when obtaining
93 // an access token, the associated GrantType's properties will be used.
95 // RegisterGrantType should be called in the `init()` function of packages, much like database/sql registers drivers. It will panic
96 // if a GrantType tries to register under a string that already has a GrantType registered for it.
97 func RegisterGrantType(name string, g GrantType) {
99 defer grantTypesMap.Unlock()
100 if _, ok := grantTypesMap.types[name]; ok {
101 panic("Duplicate registration of grant_type " + name)
103 grantTypesMap.types[name] = g
106 func findGrantType(name string) (GrantType, bool) {
107 grantTypesMap.RLock()
108 defer grantTypesMap.RUnlock()
109 t, ok := grantTypesMap.types[name]
113 func renderJSONError(enc *json.Encoder, errorType string) {
114 err := enc.Encode(errorResponse{
122 // RenderJSONToken is an implementation of the ReturnToken function for GrantTypes. It returns the token using JSON
123 // according to the spec. See RFC 6479, Section 4.1.4.
124 func RenderJSONToken(w http.ResponseWriter, r *http.Request, token Token, context Context) bool {
125 enc := json.NewEncoder(w)
126 resp := tokenResponse{
127 AccessToken: token.AccessToken,
128 RefreshToken: token.RefreshToken,
129 ExpiresIn: token.ExpiresIn,
130 TokenType: token.TokenType,
132 w.Header().Set("Content-Type", "application/json")
133 err := enc.Encode(resp)
141 // RegisterOAuth2 adds handlers to the passed router to handle the OAuth2 endpoints.
142 func RegisterOAuth2(r *mux.Router, context Context) {
143 r.Handle("/authorize", wrap(context, GetAuthorizationCodeHandler))
144 r.Handle("/token", wrap(context, GetTokenHandler))
147 // GetAuthorizationCodeHandler presents and processes the page for asking a user to grant access
148 // to their data. See RFC 6749, Section 4.1.
149 func GetAuthorizationCodeHandler(w http.ResponseWriter, r *http.Request, context Context) {
150 session, err := checkCookie(r, context)
152 if err == ErrNoSession || err == ErrInvalidSession {
153 redir := buildLoginRedirect(r, context)
155 log.Println("No login URL configured.")
156 w.WriteHeader(http.StatusInternalServerError)
157 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
158 "internal_error": template.HTML("Missing login URL."),
162 http.Redirect(w, r, redir, http.StatusFound)
165 log.Println(err.Error())
166 w.WriteHeader(http.StatusInternalServerError)
167 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
168 "internal_error": template.HTML(err.Error()),
172 if r.URL.Query().Get("client_id") == "" {
173 w.WriteHeader(http.StatusBadRequest)
174 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
175 "error": template.HTML("Client ID must be specified in the request."),
179 clientID, err := uuid.Parse(r.URL.Query().Get("client_id"))
181 w.WriteHeader(http.StatusBadRequest)
182 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
183 "error": template.HTML("client_id is not a valid Client ID."),
187 redirectURI := r.URL.Query().Get("redirect_uri")
188 client, err := context.GetClient(clientID)
190 if err == ErrClientNotFound {
191 w.WriteHeader(http.StatusBadRequest)
192 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
193 "error": template.HTML("The specified Client couldn’t be found."),
196 log.Println(err.Error())
197 w.WriteHeader(http.StatusInternalServerError)
198 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
199 "internal_error": template.HTML(err.Error()),
204 // BUG(paddy): Checking if the redirect URI is valid should be a helper function.
206 // whether a redirect URI is valid or not depends on the number of endpoints
207 // the client has registered
208 numEndpoints, err := context.CountEndpoints(clientID)
210 log.Println(err.Error())
211 w.WriteHeader(http.StatusInternalServerError)
212 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
213 "internal_error": template.HTML(err.Error()),
218 if redirectURI != "" {
219 validURI, err = context.CheckEndpoint(clientID, redirectURI)
221 if err == ErrEndpointURINotURL {
222 w.WriteHeader(http.StatusBadRequest)
223 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
224 "error": template.HTML("The redirect_uri specified is not valid."),
228 log.Println(err.Error())
229 w.WriteHeader(http.StatusInternalServerError)
230 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
231 "internal_error": template.HTML(err.Error()),
235 } else if redirectURI == "" && numEndpoints == 1 {
236 // if we don't specify the endpoint and there's only one endpoint, the
237 // request is valid, and we're redirecting to that one endpoint
239 endpoints, err := context.ListEndpoints(clientID, 1, 0)
241 log.Println(err.Error())
242 w.WriteHeader(http.StatusInternalServerError)
243 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
244 "internal_error": template.HTML(err.Error()),
248 if len(endpoints) != 1 {
251 redirectURI = endpoints[0].URI
257 w.WriteHeader(http.StatusBadRequest)
258 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
259 "error": template.HTML("The redirect_uri specified is not valid."),
263 redirectURL, err := url.Parse(redirectURI)
265 w.WriteHeader(http.StatusBadRequest)
266 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
267 "error": template.HTML("The redirect_uri specified is not valid."),
271 state := r.URL.Query().Get("state")
272 responseType := r.URL.Query().Get("response_type")
273 q := redirectURL.Query()
274 q.Add("state", state)
275 if responseType != "code" && responseType != "token" {
276 q.Add("error", "invalid_request")
277 redirectURL.RawQuery = q.Encode()
278 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
281 scopeParams := strings.Split(r.URL.Query().Get("scope"), " ")
282 scopes, err := context.GetScopes(scopeParams)
284 if err == ErrScopeNotFound {
285 q.Add("error", "invalid_scope")
286 redirectURL.RawQuery = q.Encode()
287 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
290 log.Println("Error retrieving scopes:", err)
291 q.Add("error", "server_error")
292 redirectURL.RawQuery = q.Encode()
293 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
296 if r.Method == "POST" {
297 if checkCSRF(r, session) != nil {
298 log.Println("CSRF attempt detected.")
299 w.WriteHeader(http.StatusInternalServerError)
300 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
301 "error": template.HTML("There was an error authenticating your request."),
305 if r.PostFormValue("grant") == "approved" {
307 switch responseType {
309 code := make([]byte, 16)
310 _, err := io.ReadFull(rand.Reader, code)
312 log.Printf("Error generating code: %#+v\n", err)
313 q.Add("error", "server_error")
316 authCode := AuthorizationCode{
317 Code: hex.EncodeToString(code),
319 ExpiresIn: defaultAuthorizationCodeExpiration,
322 RedirectURI: r.URL.Query().Get("redirect_uri"),
324 ProfileID: session.ProfileID,
326 err = context.SaveAuthorizationCode(authCode)
328 log.Println("Error saving authorization code:", err)
329 q.Add("error", "server_error")
332 q.Add("code", authCode.Code)
336 CreatedFrom: "implicit",
337 ExpiresIn: defaultTokenExpiration,
340 ProfileID: session.ProfileID,
343 access, err := token.GenerateAccessToken(context.config.JWTPrivateKey)
345 log.Printf("Error signing token: %+v\n", err)
346 q.Add("error", "server_error")
349 token.AccessToken = access
350 err = context.SaveToken(token)
352 log.Println("Error saving token:", err)
353 q.Add("error", "server_error")
356 q = url.Values{} // we're not altering the querystring, so don't clone it
357 q.Add("access_token", token.AccessToken)
358 q.Add("token_type", token.TokenType)
359 q.Add("expires_in", strconv.FormatInt(int64(token.ExpiresIn), 10))
360 q.Add("scope", strings.Join(token.Scopes.Strings(), " "))
361 q.Add("state", state) // we wiped out the old values, so we need to set the state again
365 redirectURL.Fragment = q.Encode()
367 redirectURL.RawQuery = q.Encode()
369 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
372 q.Add("error", "access_denied")
373 redirectURL.RawQuery = q.Encode()
374 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
377 profile, err := context.GetProfileByID(session.ProfileID)
379 log.Println("Error getting profile from session:", err)
380 q.Add("error", "server_error")
381 redirectURL.RawQuery = q.Encode()
382 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
385 w.WriteHeader(http.StatusOK)
386 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
388 "redirectURL": redirectURL,
391 "csrftoken": session.CSRFToken,
395 // GetTokenHandler allows a client to exchange an authorization grant for an
396 // access token. See RFC 6749 Section 4.1.3.
397 func GetTokenHandler(w http.ResponseWriter, r *http.Request, context Context) {
398 enc := json.NewEncoder(w)
399 grantType := r.PostFormValue("grant_type")
400 gt, ok := findGrantType(grantType)
402 w.WriteHeader(http.StatusBadRequest)
403 renderJSONError(enc, "invalid_request")
406 clientID, success := verifyClient(w, r, gt.AllowsPublic, context)
410 scopes, profileID, valid := gt.Validate(w, r, context)
415 if gt.IssuesRefresh {
416 refresh = uuid.NewID().String()
419 AccessToken: uuid.NewID().String(),
420 RefreshToken: refresh,
422 CreatedFrom: gt.AuditString(r),
423 ExpiresIn: defaultTokenExpiration,
426 ProfileID: profileID,
429 access, err := token.GenerateAccessToken(context.config.JWTPrivateKey)
431 log.Printf("Error signing token: %+v\n", err)
432 w.WriteHeader(http.StatusInternalServerError)
433 renderJSONError(enc, "server_error")
436 token.AccessToken = access
437 err = context.SaveToken(token)
439 w.WriteHeader(http.StatusInternalServerError)
440 renderJSONError(enc, "server_error")
443 if gt.ReturnToken(w, r, token, context) && gt.Invalidate != nil {
444 go gt.Invalidate(r, context)