loginsrv

Unnamed repository; edit this file 'description' to name the repository.
git clone git@jamesshield.xyz:repos/loginsrv.git
Log | Files | Refs | README | LICENSE

commit 2f1b169e560d5deb4d54a2984cac32c8fdfe3959
parent 25912cbf31688cd0e47c2adfbfaaa4b9f7e7fb07
Author: Gregor Weckbecker <gregor@feiner-fug.net>
Date:   Fri, 16 Feb 2018 17:42:19 +0100

Merge pull request #68 from tarent/user-file

support for custom claims in a user file
Diffstat:
MREADME.md | 44++++++++++++++++++++++++++++++++++++++++++++
Mcaddy/demo/Caddyfile | 1+
Acaddy/demo/userfile.yml | 11+++++++++++
Mhtpasswd/backend.go | 5++++-
Mhttpupstream/backend.go | 5++++-
Mlogin/config.go | 3+++
Mlogin/config_test.go | 2++
Mlogin/handler.go | 28+++++++++++++++++++++++-----
Mlogin/handler_test.go | 20++++++++++++++++++++
Mlogin/simple_backend.go | 5++++-
Alogin/user_claims.go | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alogin/user_claims_test.go | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmodel/user_info.go | 28++++++++++++++++++++++++++++
Mmodel/user_info_test.go | 32++++++++++++++++++++++++++++++++
Mosiam/backend.go | 3++-
Mosiam/backend_test.go | 3++-
16 files changed, 361 insertions(+), 10 deletions(-)

diff --git a/README.md b/README.md @@ -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 +``` diff --git a/caddy/demo/Caddyfile b/caddy/demo/Caddyfile @@ -16,6 +16,7 @@ http://localhost:8080 { success_url /private htpasswd file=passwords redirect_host_file redirect_hosts.txt + user_file userfile.yml } } diff --git a/caddy/demo/userfile.yml b/caddy/demo/userfile.yml @@ -0,0 +1,10 @@ + +- sub: demo + claims: + some: "custom claims for user demo" + role: admin + projects: + - example + - foo + - bar + + \ No newline at end of file diff --git a/htpasswd/backend.go b/htpasswd/backend.go @@ -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 } diff --git a/httpupstream/backend.go b/httpupstream/backend.go @@ -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 } diff --git a/login/config.go b/login/config.go @@ -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 { diff --git a/login/config_test.go b/login/config_test.go @@ -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) diff --git a/login/handler.go b/login/handler.go @@ -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) } diff --git a/login/handler_test.go b/login/handler_test.go @@ -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{ diff --git a/login/simple_backend.go b/login/simple_backend.go @@ -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 } diff --git a/login/user_claims.go b/login/user_claims.go @@ -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 +} diff --git a/login/user_claims_test.go b/login/user_claims_test.go @@ -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) +} diff --git a/model/user_info.go b/model/user_info.go @@ -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 +} diff --git a/model/user_info_test.go b/model/user_info_test.go @@ -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) +} diff --git a/osiam/backend.go b/osiam/backend.go @@ -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 } diff --git a/osiam/backend_test.go b/osiam/backend_test.go @@ -21,7 +21,8 @@ func TestBackend_Authenticate(t *testing.T) { True(t, authenticated) Equal(t, model.UserInfo{ - Sub: "admin", + Origin: "osiam", + Sub: "admin", }, userInfo)