Merge pull request #68 from tarent/user-file

support for custom claims in a user file
This commit is contained in:
Gregor Weckbecker
2018-02-16 17:42:19 +01:00
committed by GitHub
16 changed files with 360 additions and 10 deletions
+44
View File
@@ -72,6 +72,7 @@ _Note for Caddy users_: Not all parameters are available in Caddy. See the table
| -text-logging | boolean | true | - | Log in text format instead of json |
| -jwt-refreshes | int | 0 | X | The maximum amount of jwt refreshes. |
| -grace-period | go duration | 5s | - | Duration to wait after SIGINT/SIGTERM for existing requests. No new requests are accepted. |
| -user-file | string | | X | A YAML file with user specific data for the tokens. (see below for an example) |
### Environment Variables
All of the above Config Options can also be applied as environment variable, where the name is written in the way: `LOGINSRV_OPTION_NAME`.
@@ -323,3 +324,46 @@ When you specify a custom template, only the layout of the original template is
</html>
```
## User File
To customize the content of the JWT token, a YAML file with user data can be provied.
After successful authentication against a backend system, the user is searched within the file
and the contens of the claims parameter is used to enhance the user JWT claim parameters.
To match an entry, the user file is searched in linear order and all attributes has to match
the data comming from the authentication backend. The first matching entry will be used and all parameters
below the claim attribute are written into the token. The following attributes can be used for matching:
* `sub` - the username (all backends)
* `origin` - the provider or backend name (all backends)
* `email` - the mail address (the oauth provider)
* `domain` - the domain (google only)
Example:
* The user bob will become the `"role": "superAdmin"`, when authenticating with htpasswd file
* The user admin@example.org will become `"role": "admin"` and `"projects": ["example"]`, when authenticating with google oauth
* All other google users with the domain example will become `"role": "user"` and `"projects": ["example"]`
* All others will become `"role": "unknown"`, indenpendent of the authentication provider
```
- sub: bob
origin: htpasswd
claims:
role: superAdmin
- email: admin@example.org
origin: google
claims:
role: admin
projects:
- example
- domain: example.org
origin: google
claims:
role: user
projects:
- example
- claims:
role: unknown
```
+1
View File
@@ -16,6 +16,7 @@ http://localhost:8080 {
success_url /private
htpasswd file=passwords
redirect_host_file redirect_hosts.txt
user_file userfile.yml
}
}
+10
View File
@@ -0,0 +1,10 @@
- sub: demo
claims:
some: "custom claims for user demo"
role: admin
projects:
- example
- foo
- bar
+4 -1
View File
@@ -59,7 +59,10 @@ func NewBackend(filenames []string) (*Backend, error) {
func (sb *Backend) Authenticate(username, password string) (bool, model.UserInfo, error) {
authenticated, err := sb.auth.Authenticate(username, password)
if authenticated && err == nil {
return authenticated, model.UserInfo{Sub: username}, err
return authenticated, model.UserInfo{
Origin: ProviderName,
Sub: username,
}, err
}
return false, model.UserInfo{}, err
}
+4 -1
View File
@@ -77,7 +77,10 @@ func NewBackend(upstream *url.URL, timeout time.Duration, skipverify bool) (*Bac
func (sb *Backend) Authenticate(username, password string) (bool, model.UserInfo, error) {
authenticated, err := sb.auth.Authenticate(username, password)
if authenticated && err == nil {
return authenticated, model.UserInfo{Sub: username}, err
return authenticated, model.UserInfo{
Origin: ProviderName,
Sub: username,
}, err
}
return false, model.UserInfo{}, err
}
+3
View File
@@ -42,6 +42,7 @@ func DefaultConfig() *Config {
Backends: Options{},
Oauth: Options{},
GracePeriod: 5 * time.Second,
UserFile: "",
}
}
@@ -72,6 +73,7 @@ type Config struct {
Backends Options
Oauth Options
GracePeriod time.Duration
UserFile string
}
// Options is the configuration structure for oauth and backend provider
@@ -124,6 +126,7 @@ func (c *Config) ConfigureFlagSet(f *flag.FlagSet) {
f.StringVar(&c.Template, "template", c.Template, "An alternative template for the login form")
f.StringVar(&c.LoginPath, "login-path", c.LoginPath, "The path of the login resource")
f.DurationVar(&c.GracePeriod, "grace-period", c.GracePeriod, "Graceful shutdown grace period")
f.StringVar(&c.UserFile, "user-file", c.UserFile, "A YAML file with user specific data for the tokens")
// the -backends is deprecated, but we support it for backwards compatibility
deprecatedBackends := setFunc(func(optsKvList string) error {
+2
View File
@@ -45,6 +45,7 @@ func TestConfig_ReadConfig(t *testing.T) {
"--backend=provider=foo",
"--github=client_id=foo,client_secret=bar",
"--grace-period=4s",
"--user-file=users.yml",
}
expected := &Config{
@@ -78,6 +79,7 @@ func TestConfig_ReadConfig(t *testing.T) {
},
},
GracePeriod: 4 * time.Second,
UserFile: "users.yml",
}
cfg, err := readConfig(flag.NewFlagSet("", flag.ContinueOnError), input)
+23 -5
View File
@@ -19,6 +19,8 @@ const contentTypeHTML = "text/html; charset=utf-8"
const contentTypeJWT = "application/jwt"
const contentTypePlain = "text/plain"
type userClaimsFunc func(userInfo model.UserInfo) (jwt.Claims, error)
// Handler is the mail login handler.
// It serves the login ressource and does the authentication against the backends or oauth provider.
type Handler struct {
@@ -28,6 +30,7 @@ type Handler struct {
signingMethod jwt.SigningMethod
signingKey interface{}
signingVerifyKey interface{}
userClaims userClaimsFunc
}
// NewHandler creates a login handler based on the supplied configuration.
@@ -57,10 +60,16 @@ func NewHandler(config *Config) (*Handler, error) {
}
}
userClaims, err := NewUserClaims(config)
if err != nil {
return nil, err
}
return &Handler{
backends: backends,
config: config,
oauth: oauth,
backends: backends,
config: config,
oauth: oauth,
userClaims: userClaims.Claims,
}, nil
}
@@ -248,12 +257,21 @@ func (h *Handler) respondAuthenticated(w http.ResponseWriter, r *http.Request, u
fmt.Fprintf(w, "%s", token)
}
func (h *Handler) createToken(userInfo jwt.Claims) (string, error) {
func (h *Handler) createToken(userInfo model.UserInfo) (string, error) {
var claims jwt.Claims = userInfo
if h.userClaims != nil {
var err error
claims, err = h.userClaims(userInfo)
if err != nil {
return "", err
}
}
signingMethod, key, _, err := h.signingInfo()
if err != nil {
return "", err
}
token := jwt.NewWithClaims(signingMethod, userInfo)
token := jwt.NewWithClaims(signingMethod, claims)
return token.SignedString(key)
}
+20
View File
@@ -95,6 +95,8 @@ func TestHandler_NewFromConfig(t *testing.T) {
Error(t, err)
} else {
NoError(t, err)
}
if err == nil {
Equal(t, test.backendCount, len(h.backends))
Equal(t, test.oauthCount, len(h.oauth.(*oauth2.Manager).GetConfigs()))
}
@@ -446,6 +448,24 @@ func TestHandler_getToken_InvalidNoToken(t *testing.T) {
False(t, valid)
}
func TestHandler_getToken_WithUserClaims(t *testing.T) {
h := testHandler()
input := model.UserInfo{Sub: "marvin", Expiry: time.Now().Add(time.Second).Unix()}
h.userClaims = func(userInfo model.UserInfo) (jwt.Claims, error) {
return customClaims{"sub": "Zappod", "origin": "fake", "exp": userInfo.Expiry}, nil
}
token, err := h.createToken(input)
NoError(t, err)
r := &http.Request{
Header: http.Header{"Cookie": {h.config.CookieName + "=" + token + ";"}},
}
userInfo, valid := h.GetToken(r)
True(t, valid)
Equal(t, "Zappod", userInfo.Sub)
Equal(t, "fake", userInfo.Origin)
}
func testHandler() *Handler {
return &Handler{
backends: []Backend{
+4 -1
View File
@@ -44,7 +44,10 @@ func NewSimpleBackend(userPassword map[string]string) *SimpleBackend {
// Authenticate the user
func (sb *SimpleBackend) Authenticate(username, password string) (bool, model.UserInfo, error) {
if p, exist := sb.userPassword[username]; exist && p == password {
return true, model.UserInfo{Sub: username}, nil
return true, model.UserInfo{
Origin: SimpleProviderName,
Sub: username,
}, nil
}
return false, model.UserInfo{}, nil
}
+91
View File
@@ -0,0 +1,91 @@
package login
import (
"github.com/dgrijalva/jwt-go"
"github.com/pkg/errors"
"github.com/tarent/loginsrv/model"
"gopkg.in/yaml.v2"
"io/ioutil"
"time"
)
type customClaims map[string]interface{}
func (custom customClaims) Valid() error {
if exp, ok := custom["exp"]; ok {
if exp, ok := exp.(int64); ok {
if exp < time.Now().Unix() {
return errors.New("token expired")
}
}
}
return nil
}
type userFileEntry struct {
Sub string `yaml:"sub"`
Origin string `yaml:"origin"`
Email string `yaml:"email"`
Domain string `yaml:"domain"`
Claims map[string]interface{} `yaml:"claims"`
}
type UserClaims struct {
userFile string
userFileEntries []userFileEntry
}
func NewUserClaims(config *Config) (*UserClaims, error) {
c := &UserClaims{
userFile: config.UserFile,
userFileEntries: []userFileEntry{},
}
err := c.parseUserFile()
return c, err
}
func (c *UserClaims) parseUserFile() error {
if c.userFile == "" {
return nil
}
b, err := ioutil.ReadFile(c.userFile)
if err != nil {
return errors.Wrapf(err, "can't read user file %v", c.userFile)
}
err = yaml.Unmarshal(b, &c.userFileEntries)
if err != nil {
return errors.Wrapf(err, "can't parse user file %v", c.userFile)
}
return nil
}
// Claims returns a map of the token claims for a user.
func (c *UserClaims) Claims(userInfo model.UserInfo) (jwt.Claims, error) {
for _, entry := range c.userFileEntries {
if match(userInfo, entry) {
claims := customClaims(userInfo.AsMap())
for k, v := range entry.Claims {
claims[k] = v
}
return claims, nil
}
}
return userInfo, nil
}
func match(userInfo model.UserInfo, entry userFileEntry) bool {
if entry.Sub != "" && entry.Sub != userInfo.Sub {
return false
}
if entry.Domain != "" && entry.Domain != userInfo.Domain {
return false
}
if entry.Email != "" && entry.Email != userInfo.Email {
return false
}
if entry.Origin != "" && entry.Origin != userInfo.Origin {
return false
}
return true
}
+90
View File
@@ -0,0 +1,90 @@
package login
import (
. "github.com/stretchr/testify/assert"
"github.com/tarent/loginsrv/model"
"io/ioutil"
"os"
"testing"
)
var claimsExample = `
- sub: bob
origin: htpasswd
claims:
role: superAdmin
- email: admin@example.org
origin: google
claims:
role: admin
projects:
- example
sub: overwrittenSubject
- domain: example.org
origin: google
claims:
role: user
projects:
- example
- claims:
role: unknown
`
func Test_UserClaims_ParseUserClaims(t *testing.T) {
f, _ := ioutil.TempFile("", "")
f.WriteString(claimsExample)
f.Close()
defer os.Remove(f.Name())
c, err := NewUserClaims(&Config{UserFile: f.Name()})
NoError(t, err)
Equal(t, 4, len(c.userFileEntries))
Equal(t, "admin@example.org", c.userFileEntries[1].Email)
Equal(t, "google", c.userFileEntries[1].Origin)
Equal(t, "admin", c.userFileEntries[1].Claims["role"])
Equal(t, []interface{}{"example"}, c.userFileEntries[1].Claims["projects"])
}
func Test_UserClaims_Claims(t *testing.T) {
f, _ := ioutil.TempFile("", "")
f.WriteString(claimsExample)
f.Close()
defer os.Remove(f.Name())
c, err := NewUserClaims(&Config{UserFile: f.Name()})
NoError(t, err)
// Match first entry
claims, _ := c.Claims(model.UserInfo{Sub: "bob", Origin: "htpasswd"})
Equal(t, customClaims{"sub": "bob", "origin": "htpasswd", "role": "superAdmin"}, claims)
// Match second entry
claims, _ = c.Claims(model.UserInfo{Sub: "any", Email: "admin@example.org", Origin: "google"})
Equal(t, customClaims{"sub": "overwrittenSubject", "email": "admin@example.org", "origin": "google", "role": "admin", "projects": []interface{}{"example"}}, claims)
// default case with no rules
claims, _ = c.Claims(model.UserInfo{Sub: "bob"})
Equal(t, customClaims{"sub": "bob", "role": "unknown"}, claims)
}
func Test_UserClaims_NoMatch(t *testing.T) {
f, _ := ioutil.TempFile("", "")
f.WriteString(`
- sub: bob
claims:
role: superAdmin
`)
f.Close()
defer os.Remove(f.Name())
c, err := NewUserClaims(&Config{UserFile: f.Name()})
NoError(t, err)
// Mo Match -> not Modified
claims, err := c.Claims(model.UserInfo{Sub: "foo"})
NoError(t, err)
Equal(t, model.UserInfo{Sub: "foo"}, claims)
}
+28
View File
@@ -26,3 +26,31 @@ func (u UserInfo) Valid() error {
}
return nil
}
func (u UserInfo) AsMap() map[string]interface{} {
m := map[string]interface{}{
"sub": u.Sub,
}
if u.Picture != "" {
m["picture"] = u.Picture
}
if u.Name != "" {
m["name"] = u.Name
}
if u.Email != "" {
m["email"] = u.Email
}
if u.Origin != "" {
m["origin"] = u.Origin
}
if u.Expiry != 0 {
m["exp"] = u.Expiry
}
if u.Refreshes != 0 {
m["refs"] = u.Refreshes
}
if u.Domain != "" {
m["domain"] = u.Domain
}
return m
}
+32
View File
@@ -1,6 +1,7 @@
package model
import (
"encoding/json"
. "github.com/stretchr/testify/assert"
"testing"
"time"
@@ -11,3 +12,34 @@ func Test_UserInfo_Valid(t *testing.T) {
Error(t, UserInfo{Expiry: time.Now().Add(-1 * time.Second).Unix()}.Valid())
NoError(t, UserInfo{Expiry: time.Now().Add(time.Second).Unix()}.Valid())
}
func Test_UserInfo_AsMap(t *testing.T) {
u := UserInfo{
Sub: `json:"sub"`,
Picture: `json:"picture,omitempty"`,
Name: `json:"name,omitempty"`,
Email: `json:"email,omitempty"`,
Origin: `json:"origin,omitempty"`,
Expiry: 23,
Refreshes: 42,
Domain: `json:"domain,omitempty"`,
}
givenJson, _ := json.Marshal(u.AsMap())
given := UserInfo{}
err := json.Unmarshal(givenJson, &given)
NoError(t, err)
Equal(t, u, given)
}
func Test_UserInfo_AsMap_Minimal(t *testing.T) {
u := UserInfo{
Sub: `json:"sub"`,
}
givenJson, _ := json.Marshal(u.AsMap())
given := UserInfo{}
err := json.Unmarshal(givenJson, &given)
NoError(t, err)
Equal(t, u, given)
}
+2 -1
View File
@@ -37,7 +37,8 @@ func (b *Backend) Authenticate(username, password string) (bool, model.UserInfo,
return authenticated, model.UserInfo{}, err
}
userInfo := model.UserInfo{
Sub: username,
Origin: OsiamProviderName,
Sub: username,
}
return true, userInfo, nil
}
+2 -1
View File
@@ -21,7 +21,8 @@ func TestBackend_Authenticate(t *testing.T) {
True(t, authenticated)
Equal(t,
model.UserInfo{
Sub: "admin",
Origin: "osiam",
Sub: "admin",
},
userInfo)