switched to auth 0.3

This commit is contained in:
Umputun
2019-01-04 18:09:18 -06:00
parent dcd40c42e2
commit 7b54b00c01
13 changed files with 123 additions and 86 deletions
+3 -3
View File
@@ -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"
+3 -3
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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",
})
+1 -1
View File
@@ -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{},
+2 -2
View File
@@ -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")
}
+8 -14
View File
@@ -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")
}
+8 -13
View File
@@ -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
+1 -1
View File
@@ -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)
}
+12
View File
@@ -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
+6 -4
View File
@@ -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")
+1 -4
View File
@@ -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
}
+76 -39
View File
@@ -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()
}