mirror of
https://github.com/wahyd4/loginsrv.git
synced 2026-08-09 04:46:29 +10:00
Merge pull request #68 from tarent/user-file
support for custom claims in a user file
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -16,6 +16,7 @@ http://localhost:8080 {
|
||||
success_url /private
|
||||
htpasswd file=passwords
|
||||
redirect_host_file redirect_hosts.txt
|
||||
user_file userfile.yml
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
- sub: demo
|
||||
claims:
|
||||
some: "custom claims for user demo"
|
||||
role: admin
|
||||
projects:
|
||||
- example
|
||||
- foo
|
||||
- bar
|
||||
|
||||
+4
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ func TestBackend_Authenticate(t *testing.T) {
|
||||
True(t, authenticated)
|
||||
Equal(t,
|
||||
model.UserInfo{
|
||||
Sub: "admin",
|
||||
Origin: "osiam",
|
||||
Sub: "admin",
|
||||
},
|
||||
userInfo)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user