From 7b54b00c010a7a7b00c3a0b9d6c7a50af11482f0 Mon Sep 17 00:00:00 2001 From: Umputun Date: Fri, 4 Jan 2019 18:09:18 -0600 Subject: [PATCH] switched to auth 0.3 --- backend/Gopkg.lock | 6 +- backend/app/cmd/server.go | 6 +- backend/app/rest/api/migrator.go | 2 +- backend/app/rest/api/migrator_test.go | 2 +- backend/app/rest/api/rest_test.go | 2 +- backend/app/store/admin/admin.go | 4 +- backend/app/store/admin/admin_test.go | 22 ++-- backend/app/store/admin/mongo.go | 21 ++-- backend/app/store/service/service.go | 2 +- .../vendor/github.com/go-pkgz/auth/README.md | 12 ++ .../vendor/github.com/go-pkgz/auth/auth.go | 10 +- .../go-pkgz/auth/middleware/auth.go | 5 +- .../github.com/go-pkgz/auth/token/jwt.go | 115 ++++++++++++------ 13 files changed, 123 insertions(+), 86 deletions(-) diff --git a/backend/Gopkg.lock b/backend/Gopkg.lock index 06f1dacf..d9b7fde0 100644 --- a/backend/Gopkg.lock +++ b/backend/Gopkg.lock @@ -112,7 +112,7 @@ version = "v1.0.0" [[projects]] - digest = "1:6a297f738eb2aaca7c18129040ce7ac2af2db13c49fc948c13be4c692b2f5e19" + digest = "1:5371050ba40cd7482fa2e9f0ff17fc18e2c37a6353ac012eaaacc6250eee4748" name = "github.com/go-pkgz/auth" packages = [ ".", @@ -123,8 +123,8 @@ "token", ] pruneopts = "UT" - revision = "3d27762393e5d62d1bd0553d5978226bac2acff7" - version = "v0.2.1" + revision = "6f889bf1c6eb61c926dbb759897e6dc577b10655" + version = "v0.3.0" [[projects]] digest = "1:1212e114344a5cdcc834ea69e19d456eef230f9784659080fee67e02ba2cb574" diff --git a/backend/app/cmd/server.go b/backend/app/cmd/server.go index 906a214d..1f442dd0 100644 --- a/backend/app/cmd/server.go +++ b/backend/app/cmd/server.go @@ -412,7 +412,7 @@ func (s *ServerCommand) makeAdminStore() (admin.Store, error) { return nil, errors.Wrap(e, "failed to create mongo server") } conn := mongo.NewConnection(mgServer, s.Mongo.DB, "admin") - return admin.NewMongoStore(conn), nil + return admin.NewMongoStore(conn, s.SharedSecret), nil default: return nil, errors.Errorf("unsupported admin store type %s", s.Admin.Type) } @@ -527,8 +527,8 @@ func (s *ServerCommand) makeAuthenticator(ds *service.DataStore, avas avatar.Sto TokenDuration: s.Auth.TTL.JWT, CookieDuration: s.Auth.TTL.Cookie, SecureCookies: strings.HasPrefix(s.RemarkURL, "https://"), - SecretReader: token.SecretFunc(func(id string) (string, error) { // get secret per site - return admns.Key(id) + SecretReader: token.SecretFunc(func() (string, error) { // get secret per site + return admns.Key() }), ClaimsUpd: token.ClaimsUpdFunc(func(c token.Claims) token.Claims { // set attributes, on new token or refresh if c.User == nil { diff --git a/backend/app/rest/api/migrator.go b/backend/app/rest/api/migrator.go index 90ec7bd6..0c2fad0b 100644 --- a/backend/app/rest/api/migrator.go +++ b/backend/app/rest/api/migrator.go @@ -38,7 +38,7 @@ type Migrator struct { // KeyStore defines sub-interface for consumers needed just a key type KeyStore interface { - Key(siteID string) (key string, err error) + Key() (key string, err error) } func (m *Migrator) withRoutes(router chi.Router) chi.Router { diff --git a/backend/app/rest/api/migrator_test.go b/backend/app/rest/api/migrator_test.go index 9b2f4d5c..17e59794 100644 --- a/backend/app/rest/api/migrator_test.go +++ b/backend/app/rest/api/migrator_test.go @@ -290,7 +290,7 @@ func prepImportSrv(t *testing.T) (svc *Migrator, ds *service.DataStore, ts *http a := auth.NewService(auth.Opts{ AdminPasswd: "password", - SecretReader: token.SecretFunc(func(id string) (string, error) { return "123456", nil }), + SecretReader: token.SecretFunc(func() (string, error) { return "123456", nil }), Issuer: "test", }) diff --git a/backend/app/rest/api/rest_test.go b/backend/app/rest/api/rest_test.go index de740492..a8b7d6e6 100644 --- a/backend/app/rest/api/rest_test.go +++ b/backend/app/rest/api/rest_test.go @@ -193,7 +193,7 @@ func startupT(t *testing.T) (ts *httptest.Server, srv *Rest, teardown func()) { DataService: dataStore, Authenticator: auth.NewService(auth.Opts{ AdminPasswd: "password", - SecretReader: token.SecretFunc(func(id string) (string, error) { return "secret", nil }), + SecretReader: token.SecretFunc(func() (string, error) { return "secret", nil }), AvatarStore: avatar.NewLocalFS("/tmp/ava-remark42"), }), Cache: &cache.Nop{}, diff --git a/backend/app/store/admin/admin.go b/backend/app/store/admin/admin.go index 10799ccd..1ebebff5 100644 --- a/backend/app/store/admin/admin.go +++ b/backend/app/store/admin/admin.go @@ -8,7 +8,7 @@ import ( // Store defines interface returning admins info for given site type Store interface { - Key(siteID string) (key string, err error) + Key() (key string, err error) Admins(siteID string) (ids []string) Email(siteID string) (email string) } @@ -21,7 +21,7 @@ type StaticStore struct { } // Key returns static key for all sites, allows empty site -func (s *StaticStore) Key(siteID string) (key string, err error) { +func (s *StaticStore) Key() (key string, err error) { if s.key == "" { return "", errors.New("empty key for static key store") } diff --git a/backend/app/store/admin/admin_test.go b/backend/app/store/admin/admin_test.go index 27f4b8ea..2d587f3a 100644 --- a/backend/app/store/admin/admin_test.go +++ b/backend/app/store/admin/admin_test.go @@ -12,7 +12,7 @@ import ( func TestStaticStore_Get(t *testing.T) { var ks Store = NewStaticStore("key123", []string{"123", "xyz"}, "aa@example.com") - k, err := ks.Key("any") + k, err := ks.Key() assert.NoError(t, err, "valid store") assert.Equal(t, "key123", k, "valid site") @@ -21,20 +21,16 @@ func TestStaticStore_Get(t *testing.T) { email := ks.Email("blah") assert.Equal(t, "aa@example.com", email) - - ks = NewStaticStore("", []string{"123", "xyz"}, "aa@example.com") - _, err = ks.Key("any") - assert.NotNil(t, err, "invalid (empty key) store") } func TestMongoStore_Get(t *testing.T) { conn, err := mongo.MakeTestConnection(t) require.NoError(t, err) - var ms Store = NewMongoStore(conn) + var ms Store = NewMongoStore(conn, "secret") recs := []mongoRec{ - {"site1", "secret1", []string{"i11", "i12"}, "e1"}, - {"site2", "secret2", []string{"i21", "i22"}, "e2"}, + {"site1", []string{"i11", "i12"}, "e1"}, + {"site2", []string{"i21", "i22"}, "e2"}, } err = conn.WithCollection(func(coll *mgo.Collection) error { if e1 := coll.Insert(recs[0]); e1 != nil { @@ -48,22 +44,20 @@ func TestMongoStore_Get(t *testing.T) { assert.Equal(t, []string{"i11", "i12"}, admins) email := ms.Email("site1") assert.Equal(t, "e1", email) - key, err := ms.Key("site1") + key, err := ms.Key() assert.NoError(t, err) - assert.Equal(t, "secret1", key) + assert.Equal(t, "secret", key) admins = ms.Admins("site2") assert.Equal(t, []string{"i21", "i22"}, admins) email = ms.Email("site2") assert.Equal(t, "e2", email) - key, err = ms.Key("site2") + key, err = ms.Key() assert.NoError(t, err) - assert.Equal(t, "secret2", key) + assert.Equal(t, "secret", key) admins = ms.Admins("no-site-in-db") assert.Equal(t, []string{}, admins) email = ms.Email("no-site-in-db") assert.Equal(t, "", email) - _, err = ms.Key("no-site-in-db") - assert.Error(t, err, "can't get secret for site no-site-in-db") } diff --git a/backend/app/store/admin/mongo.go b/backend/app/store/admin/mongo.go index f519c482..0c272c99 100644 --- a/backend/app/store/admin/mongo.go +++ b/backend/app/store/admin/mongo.go @@ -5,7 +5,6 @@ import ( "github.com/globalsign/mgo" "github.com/globalsign/mgo/bson" - "github.com/pkg/errors" "github.com/go-pkgz/mongo" ) @@ -13,28 +12,24 @@ import ( // MongoStore implements admin.Store with mongo backend type MongoStore struct { connection *mongo.Connection + key string } type mongoRec struct { - SiteID string `bson:"site"` - SecretKey string `bson:"secret"` - IDs []string `bson:"admin_ids"` - Email string `bson:"admin_email"` + SiteID string `bson:"site"` + IDs []string `bson:"admin_ids"` + Email string `bson:"admin_email"` } // NewMongoStore makes admin Store for mongo's connection -func NewMongoStore(conn *mongo.Connection) *MongoStore { +func NewMongoStore(conn *mongo.Connection, key string) *MongoStore { log.Printf("[DEBUG] make mongo admin store with %+v", conn) - return &MongoStore{connection: conn} + return &MongoStore{connection: conn, key: key} } // Key executes find by siteID and returns substructure with secret key -func (m *MongoStore) Key(siteID string) (key string, err error) { - resp := mongoRec{} - err = m.connection.WithCollection(func(coll *mgo.Collection) error { - return coll.Find(bson.M{"site": siteID}).One(&resp) - }) - return resp.SecretKey, errors.Wrapf(err, "can't get secret for site %s", siteID) +func (m *MongoStore) Key() (key string, err error) { + return m.key, nil } // Admins executes find by siteID and returns admins ids diff --git a/backend/app/store/service/service.go b/backend/app/store/service/service.go index c67ebe26..3bb83e0d 100644 --- a/backend/app/store/service/service.go +++ b/backend/app/store/service/service.go @@ -76,7 +76,7 @@ func (s *DataStore) prepareNewComment(comment store.Comment) (store.Comment, err } comment.Sanitize() // clear potentially dangerous js from all parts of comment - secret, err := s.AdminStore.Key(comment.Locator.SiteID) + secret, err := s.AdminStore.Key() if err != nil { return store.Comment{}, errors.Wrapf(err, "can't get secret for site %s", comment.Locator.SiteID) } diff --git a/backend/vendor/github.com/go-pkgz/auth/README.md b/backend/vendor/github.com/go-pkgz/auth/README.md index 836344b8..63179a4c 100644 --- a/backend/vendor/github.com/go-pkgz/auth/README.md +++ b/backend/vendor/github.com/go-pkgz/auth/README.md @@ -166,6 +166,18 @@ _This technic used in the [example](https://github.com/go-pkgz/auth/blob/master/ The process can be simplified by doing all checks directly in `Validator`, but depends on particular case such solution can be too expensive because `Validator` runs on each request as a part of auth middleware. In contrast, `ClaimsUpdater` called on token creation/refresh only. +### Multi-tenant services and support for different audiences + +For complex systems a single authenticator may serve multiple distinct subsystems or multiple set of independent users. For example some SaaS offerings may need to provide different authentications for different customers and prevent use of tokens/cookies made by another customer. + +Such functionality can be implemented in 3 different ways: + +- Different instances of `auth.Service` each one with different secret. Doing this way will ensure the highest level of isolation and cookies/tokens won't be even parsable across the instances. Practically such architecture can be too complicated and not always possible. +– Handling "allowed audience" as a part of `ClaimsUpdater` and `Validator` chain. I.e. `ClaimsUpdater` sets a claim indicating expected audience code/id and `Validator` making sure it matches. This way a single `auth.Service` could handle multiple groups of auth tokens and reject some based on the audience. +- Using the standard JWT `aud` claim. This method conceptually very similar to the previous one, but done by library internally and consumer don't need to define special `ClaimsUpdater` and `Validator` logic. + +In order to allow `aud` support the list of allowed audiences should be passed in as `opts.Audiences` parameter. Non-empty value will trigger internal checks for token generation (will reject token creation fot alien `aud`) as well as `Auth` middleware. + ### Dev provider diff --git a/backend/vendor/github.com/go-pkgz/auth/auth.go b/backend/vendor/github.com/go-pkgz/auth/auth.go index 5d5d8f26..36a584ff 100644 --- a/backend/vendor/github.com/go-pkgz/auth/auth.go +++ b/backend/vendor/github.com/go-pkgz/auth/auth.go @@ -53,9 +53,10 @@ type Opts struct { AvatarResizeLimit int // resize avatar's limit in pixels AvatarRoutePath string // avatar routing prefix, i.e. "/api/v1/avatar", default `/avatar` - AdminPasswd string // if presented, allows basic auth with user admin and given password - RefreshFactor int // estimated number of request client sends in parallel during token refresh. - Logger logger.L // logger interface, default is no logging at all + AdminPasswd string // if presented, allows basic auth with user admin and given password + AudienceReader token.Audience // list of allowed aud values, default (empty) allows any + RefreshFactor int // estimated number of request client sends in parallel during token refresh. + Logger logger.L // logger interface, default is no logging at all } // NewService initializes everything @@ -93,10 +94,11 @@ func NewService(opts Opts) (res *Service) { XSRFCookieName: opts.XSRFCookieName, XSRFHeaderKey: opts.XSRFHeaderKey, Issuer: res.issuer, + AudienceReader: opts.AudienceReader, }) if opts.SecretReader == nil { - jwtService.SecretReader = token.SecretFunc(func(id string) (string, error) { + jwtService.SecretReader = token.SecretFunc(func() (string, error) { return "", errors.New("secrets reader not available") }) res.logger.Logf("[WARN] no secret reader defined") diff --git a/backend/vendor/github.com/go-pkgz/auth/middleware/auth.go b/backend/vendor/github.com/go-pkgz/auth/middleware/auth.go index 7f557b20..886e5497 100644 --- a/backend/vendor/github.com/go-pkgz/auth/middleware/auth.go +++ b/backend/vendor/github.com/go-pkgz/auth/middleware/auth.go @@ -53,10 +53,7 @@ func (a *Authenticator) Trace(next http.Handler) http.Handler { func (a *Authenticator) auth(reqAuth bool) func(http.Handler) http.Handler { onError := func(h http.Handler, w http.ResponseWriter, r *http.Request, err error) { - if err == nil { - return - } - if !reqAuth { + if !reqAuth { // if no auth required allow to proceeded on error h.ServeHTTP(w, r) return } diff --git a/backend/vendor/github.com/go-pkgz/auth/token/jwt.go b/backend/vendor/github.com/go-pkgz/auth/token/jwt.go index 15c91ccf..2bcd0b3e 100644 --- a/backend/vendor/github.com/go-pkgz/auth/token/jwt.go +++ b/backend/vendor/github.com/go-pkgz/auth/token/jwt.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "time" jwt "github.com/dgrijalva/jwt-go" @@ -57,7 +58,8 @@ type Opts struct { XSRFCookieName string XSRFHeaderKey string - Issuer string // optional value for iss claim, usually application name + AudienceReader Audience // allowed aud values + Issuer string // optional value for iss claim, usually application name } // NewService makes JWT service @@ -90,6 +92,8 @@ func NewService(opts Opts) *Service { // Token makes token with claims func (j *Service) Token(claims Claims) (string, error) { + // make token for allowed aud values only, rejects others + // update claims with ClaimsUpdFunc defined by consumer if j.ClaimsUpd != nil { claims = j.ClaimsUpd.Update(claims) @@ -98,17 +102,21 @@ func (j *Service) Token(claims Claims) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) if j.SecretReader == nil { - return "", errors.New("secretreader not defined") + return "", errors.New("secret reader not defined") } - secret, err := j.SecretReader.Get(claims.Audience) // get secret via consumer defined SecretReader + if err := j.checkAuds(&claims, j.AudienceReader); err != nil { + return "", errors.Wrap(err, "aud rejected") + } + + secret, err := j.SecretReader.Get() // get secret via consumer defined SecretReader if err != nil { return "", errors.Wrap(err, "can't get secret") } tokenString, err := token.SignedString([]byte(secret)) if err != nil { - return "", errors.Wrap(err, "can't sign token token") + return "", errors.Wrap(err, "can't sign token") } return tokenString, nil } @@ -117,31 +125,11 @@ func (j *Service) Token(claims Claims) (string, error) { func (j *Service) Parse(tokenString string) (Claims, error) { parser := jwt.Parser{SkipClaimsValidation: true} // allow parsing of expired tokens - getAud := func() (aud string, err error) { // parse token without signature check to get id (aud) - preToken, _, err := parser.ParseUnverified(tokenString, &Claims{}) - if err != nil { - return "", errors.Wrap(err, "can't pre-parse token") - } - if _, ok := preToken.Method.(*jwt.SigningMethodHMAC); !ok { - return "", errors.Errorf("unexpected signing method: %v", preToken.Header["alg"]) - } - preClaims, ok := preToken.Claims.(*Claims) - if !ok { - return "", errors.New("invalid token") - } - return preClaims.Audience, nil - } - - aud, err := getAud() - if err != nil { - return Claims{}, errors.Wrap(err, "failed to get aud from token token") - } - if j.SecretReader == nil { - return Claims{}, errors.New("secretreader not defined") + return Claims{}, errors.New("secret reader not defined") } - secret, err := j.SecretReader.Get(aud) + secret, err := j.SecretReader.Get() if err != nil { return Claims{}, errors.Wrap(err, "can't get secret") } @@ -157,11 +145,30 @@ func (j *Service) Parse(tokenString string) (Claims, error) { } claims, ok := token.Claims.(*Claims) - if !ok || !token.Valid { + if !ok { return Claims{}, errors.New("invalid token") } - return *claims, nil + if err = j.checkAuds(claims, j.AudienceReader); err != nil { + return Claims{}, errors.Wrap(err, "aud rejected") + } + return *claims, j.validate(claims) +} + +func (j *Service) validate(claims *Claims) error { + cerr := claims.Valid() + + if cerr == nil { + return nil + } + + if e, ok := cerr.(*jwt.ValidationError); ok { + e.Errors ^= jwt.ValidationErrorExpired // clear ValidationErrorExpired, allow expired token + if e.Errors != 0 { + return e + } + } + return nil } // Set creates token cookie with xsrf cookie and put it to ResponseWriter @@ -257,18 +264,43 @@ func (j *Service) Reset(w http.ResponseWriter) { http.SetCookie(w, &xsrfCookie) } +// checkAuds verifies if claims.Audience in the list of allowed by audReader +func (j *Service) checkAuds(claims *Claims, audReader Audience) error { + if audReader == nil { // lack of any allowed means any + return nil + } + auds, err := audReader.Get() + if err != nil { + return errors.Wrap(err, "failed to get auds") + } + for _, a := range auds { + if strings.EqualFold(a, claims.Audience) { + return nil + } + } + return errors.Errorf("aud %q not allowed", claims.Audience) +} + +func (c Claims) String() string { + b, err := json.Marshal(c) + if err != nil { + return fmt.Sprintf("%+v %+v", c.StandardClaims, c.User) + } + return string(b) +} + // Secret defines interface returning secret key for given id (aud) type Secret interface { - Get(id string) (string, error) + Get() (string, error) } // SecretFunc type is an adapter to allow the use of ordinary functions as Secret. If f is a function // with the appropriate signature, SecretFunc(f) is a Handler that calls f. -type SecretFunc func(id string) (string, error) +type SecretFunc func() (string, error) -// Get calls f(id) -func (f SecretFunc) Get(id string) (string, error) { - return f(id) +// Get calls f() +func (f SecretFunc) Get() (string, error) { + return f() } // ClaimsUpdater defines interface adding extras to claims @@ -300,10 +332,15 @@ func (f ValidatorFunc) Validate(token string, claims Claims) bool { return f(token, claims) } -func (c Claims) String() string { - b, err := json.Marshal(c) - if err != nil { - return fmt.Sprintf("%+v %+v", c.StandardClaims, c.User) - } - return string(b) +// Audience defines interface returning list of allowed audiences +type Audience interface { + Get() ([]string, error) +} + +// AudienceFunc type is an adapter to allow the use of ordinary functions as Audience. +type AudienceFunc func() ([]string, error) + +// Get calls f() +func (f AudienceFunc) Get() ([]string, error) { + return f() }