diff --git a/acl.go b/acl.go new file mode 100644 index 0000000..709dfcb --- /dev/null +++ b/acl.go @@ -0,0 +1,189 @@ +package main + +import ( + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/Luzifer/go_helpers/str" +) + +type aclRule struct { + Field string `yaml:"field"` + Invert bool `yaml:"invert"` + IsPresent *bool `yaml:"present"` + MatchRegex *string `yaml:"regexp"` + MatchString *string `yaml:"equals"` +} + +func (a aclRule) Validate() error { + if a.Field == "" { + return fmt.Errorf("Field is not set") + } + + if a.IsPresent == nil && a.MatchRegex == nil && a.MatchString == nil { + return fmt.Errorf("No matcher (present, regexp, equals) is set") + } + + if a.MatchRegex != nil { + if _, err := regexp.Compile(*a.MatchRegex); err != nil { + return fmt.Errorf("Regexp is invalid: %s", err) + } + } + + return nil +} + +func (a aclRule) AppliesToFields(fields map[string]string) bool { + var field, value string + + for f, v := range fields { + if strings.ToLower(a.Field) == f { + field = f + value = v + break + } + } + + if a.IsPresent != nil { + if !a.Invert && *a.IsPresent && field == "" { + // Field is expected to be present but isn't, rule does not apply + return false + } + if !a.Invert && !*a.IsPresent && field != "" { + // Field is expected not to be present but is, rule does not apply + return false + } + if a.Invert && *a.IsPresent && field != "" { + // Field is expected not to be present but is, rule does not apply + return false + } + if a.Invert && !*a.IsPresent && field == "" { + // Field is expected to be present but isn't, rule does not apply + return false + } + + return true + } + + if field == "" { + // We found a rule which has no matching field, rule does not apply + return false + } + + if a.MatchString != nil { + if (*a.MatchString != value) == !a.Invert { + // Value does not match expected string, rule does not apply + return false + } + } + + if a.MatchRegex != nil { + if regexp.MustCompile(*a.MatchRegex).MatchString(value) == a.Invert { + // Value does not match expected regexp, rule does not apply + return false + } + } + + return true +} + +type aclAccessResult uint + +const ( + accessDunno aclAccessResult = iota + accessAllow + accessDeny +) + +type aclRuleSet struct { + Rules []aclRule `yaml:"rules"` + + Allow []string `yaml:"allow"` + Deny []string `yaml:"deny"` +} + +func (a aclRuleSet) buildFieldSet(r *http.Request) map[string]string { + result := map[string]string{} + + for k := range r.Header { + result[strings.ToLower(k)] = r.Header.Get(k) + } + + return result +} + +func (a aclRuleSet) HasAccess(user string, groups []string, r *http.Request) aclAccessResult { + fields := a.buildFieldSet(r) + + for _, rule := range a.Rules { + if !rule.AppliesToFields(fields) { + // At least one rule does not match the request + return accessDunno + } + } + + // All rules do apply to this request, we can judge + + if str.StringInSlice(user, a.Deny) { + // Explicit deny, final result + return accessDeny + } + + if str.StringInSlice(user, a.Allow) { + // Explicit allow, final result + return accessAllow + } + + for _, group := range groups { + if str.StringInSlice("@"+group, a.Deny) { + // Deny through group, final result + return accessDeny + } + + if str.StringInSlice("@"+group, a.Allow) { + // Allow through group, final result + return accessAllow + } + } + + // Neither user nor group are handled + return accessDunno +} + +func (a aclRuleSet) Validate() error { + for i, r := range a.Rules { + if err := r.Validate(); err != nil { + return fmt.Errorf("Rule on position %d is invalid: %s", i+1, err) + } + } + + return nil +} + +type acl struct { + RuleSets []aclRuleSet `yaml:"rule_sets"` +} + +func (a acl) Validate() error { + for i, r := range a.RuleSets { + if err := r.Validate(); err != nil { + return fmt.Errorf("RuleSet on position %d is invalid: %s", i+1, err) + } + } + + return nil +} + +func (a acl) HasAccess(user string, groups []string, r *http.Request) bool { + result := accessDunno + + for _, rs := range a.RuleSets { + if intermediateResult := rs.HasAccess(user, groups, r); intermediateResult > result { + result = intermediateResult + } + } + + return result == accessAllow +} diff --git a/acl_test.go b/acl_test.go new file mode 100644 index 0000000..b379236 --- /dev/null +++ b/acl_test.go @@ -0,0 +1,219 @@ +package main + +import ( + "net/http" + "testing" +) + +var ( + aclTestUser = "test" + aclTestGroups = []string{"group_a", "group_b"} +) + +func aclTestRequest(headers map[string]string) *http.Request { + req, _ := http.NewRequest("GET", "http://localhost/auth", nil) + for k, v := range headers { + req.Header.Set(k, v) + } + return req +} + +func aclTestString(in string) *string { return &in } +func aclTestBool(in bool) *bool { return &in } + +func TestEmptyACL(t *testing.T) { + a := acl{} + + if a.HasAccess(aclTestUser, aclTestGroups, aclTestRequest(map[string]string{})) { + t.Fatal("Empty ACL (= default action) was ALLOW instead of DENY") + } +} + +func TestRuleSetMatcher(t *testing.T) { + r := aclRuleSet{ + Rules: []aclRule{ + { + Field: "field_a", + MatchString: aclTestString("expected"), + }, + { + Field: "field_c", + MatchString: aclTestString("expected"), + }, + }, + Allow: []string{aclTestUser}, + } + fields := map[string]string{ + "field_a": "expected", + "field_b": "unchecked", + "field_c": "expected", + } + + if r.HasAccess(aclTestUser, aclTestGroups, aclTestRequest(fields)) != accessAllow { + t.Error("Access was denied") + } + + delete(fields, "field_c") + if r.HasAccess(aclTestUser, aclTestGroups, aclTestRequest(fields)) != accessDunno { + t.Error("Access was not unknown") + } +} + +func TestInvertedRegexMatcher(t *testing.T) { + fields := map[string]string{ + "field_a": "expected", + "field_b": "unchecked", + } + + ar := aclRule{ + Field: "field_a", + Invert: true, + MatchRegex: aclTestString("^expected$"), + } + + if ar.AppliesToFields(fields) { + t.Errorf("Rule %#v matches fields %#v", ar, fields) + } + + fields["field_a"] = "unexpected" + + if !ar.AppliesToFields(fields) { + t.Errorf("Rule %#v does not match fields %#v", ar, fields) + } +} + +func TestRegexMatcher(t *testing.T) { + fields := map[string]string{ + "field_a": "expected", + "field_b": "unchecked", + } + + ar := aclRule{ + Field: "field_a", + MatchRegex: aclTestString("^expected$"), + } + + if !ar.AppliesToFields(fields) { + t.Errorf("Rule %#v does not match fields %#v", ar, fields) + } + + fields["field_a"] = "unexpected" + + if ar.AppliesToFields(fields) { + t.Errorf("Rule %#v matches fields %#v", ar, fields) + } +} + +func TestInvertedEqualsMatcher(t *testing.T) { + fields := map[string]string{ + "field_a": "expected", + "field_b": "unchecked", + } + + ar := aclRule{ + Field: "field_a", + Invert: true, + MatchString: aclTestString("expected"), + } + + if ar.AppliesToFields(fields) { + t.Errorf("Rule %#v matches fields %#v", ar, fields) + } + + fields["field_a"] = "unexpected" + + if !ar.AppliesToFields(fields) { + t.Errorf("Rule %#v does not match fields %#v", ar, fields) + } +} + +func TestEqualsMatcher(t *testing.T) { + fields := map[string]string{ + "field_a": "expected", + "field_b": "unchecked", + } + + ar := aclRule{ + Field: "field_a", + MatchString: aclTestString("expected"), + } + + if !ar.AppliesToFields(fields) { + t.Errorf("Rule %#v does not match fields %#v", ar, fields) + } + + fields["field_a"] = "unexpected" + + if ar.AppliesToFields(fields) { + t.Errorf("Rule %#v matches fields %#v", ar, fields) + } +} + +func TestInvertedIsPresentMatcher(t *testing.T) { + fields := map[string]string{ + "field_a": "expected", + "field_b": "unchecked", + } + + ar := aclRule{ + Field: "field_a", + Invert: true, + IsPresent: aclTestBool(true), + } + + if ar.AppliesToFields(fields) { + t.Errorf("Rule %#v matches fields %#v", ar, fields) + } + + ar.IsPresent = aclTestBool(false) + + if !ar.AppliesToFields(fields) { + t.Errorf("Rule %#v does not match fields %#v", ar, fields) + } + + ar.IsPresent = aclTestBool(true) + delete(fields, "field_a") + + if !ar.AppliesToFields(fields) { + t.Errorf("Rule %#v does not match fields %#v", ar, fields) + } + + ar.IsPresent = aclTestBool(false) + if ar.AppliesToFields(fields) { + t.Errorf("Rule %#v matches fields %#v", ar, fields) + } +} + +func TestIsPresentMatcher(t *testing.T) { + fields := map[string]string{ + "field_a": "expected", + "field_b": "unchecked", + } + + ar := aclRule{ + Field: "field_a", + IsPresent: aclTestBool(true), + } + + if !ar.AppliesToFields(fields) { + t.Errorf("Rule %#v does not match fields %#v", ar, fields) + } + + ar.IsPresent = aclTestBool(false) + + if ar.AppliesToFields(fields) { + t.Errorf("Rule %#v matches fields %#v", ar, fields) + } + + ar.IsPresent = aclTestBool(true) + delete(fields, "field_a") + + if ar.AppliesToFields(fields) { + t.Errorf("Rule %#v matches fields %#v", ar, fields) + } + + ar.IsPresent = aclTestBool(false) + if !ar.AppliesToFields(fields) { + t.Errorf("Rule %#v does not match fields %#v", ar, fields) + } +} diff --git a/auth_simple.go b/auth_simple.go new file mode 100644 index 0000000..d6df589 --- /dev/null +++ b/auth_simple.go @@ -0,0 +1,129 @@ +package main + +import ( + "net/http" + "strings" + + "github.com/Luzifer/go_helpers/str" + "golang.org/x/crypto/bcrypt" + yaml "gopkg.in/yaml.v2" +) + +func init() { + registerAuthenticator(&authSimple{}) +} + +type authSimple struct { + Users map[string]string `yaml:"users"` + Groups map[string][]string `yaml:"groups"` +} + +// AuthenticatorID needs to return an unique string to identify +// this special authenticator +func (a authSimple) AuthenticatorID() string { return "simple" } + +// Configure loads the configuration for the Authenticator from the +// global config.yaml file which is passed as a byte-slice. +// If no configuration for the Authenticator is supplied the function +// needs to return the errAuthenticatorUnconfigured +func (a *authSimple) Configure(yamlSource []byte) error { + envelope := struct { + Providers struct { + Simple *authSimple `yaml:"simple"` + } `yaml:"providers"` + }{} + + if err := yaml.Unmarshal(yamlSource, &envelope); err != nil { + return err + } + + if envelope.Providers.Simple == nil { + return errAuthenticatorUnconfigured + } + + a.Users = envelope.Providers.Simple.Users + a.Groups = envelope.Providers.Simple.Groups + + return nil +} + +// DetectUser is used to detect a user without a login form from +// a cookie, header or other methods +// If no user was detected the errNoValidUserFound needs to be +// returned +func (a authSimple) DetectUser(r *http.Request) (string, []string, error) { + sess, err := cookieStore.Get(r, strings.Join([]string{mainCfg.Cookie.Prefix, a.AuthenticatorID()}, "-")) + if err != nil { + return "", nil, errNoValidUserFound + } + + user, ok := sess.Values["user"].(string) + if !ok { + return "", nil, errNoValidUserFound + } + + groups := []string{} + for group, users := range a.Groups { + if str.StringInSlice(user, users) { + groups = append(groups, group) + } + } + + return user, groups, nil +} + +// Login is called when the user submits the login form and needs +// to authenticate the user or throw an error. If the user has +// successfully logged in the persistent cookie should be written +// in order to use DetectUser for the next login. +// If the user did not login correctly the errNoValidUserFound +// needs to be returned +func (a authSimple) Login(res http.ResponseWriter, r *http.Request) error { + username := r.FormValue(strings.Join([]string{a.AuthenticatorID(), "username"}, "-")) + password := r.FormValue(strings.Join([]string{a.AuthenticatorID(), "password"}, "-")) + + for u, p := range a.Users { + if u != username { + continue + } + if bcrypt.CompareHashAndPassword([]byte(p), []byte(password)) != nil { + continue + } + + sess, _ := cookieStore.Get(r, strings.Join([]string{mainCfg.Cookie.Prefix, a.AuthenticatorID()}, "-")) + sess.Options = mainCfg.GetSessionOpts() + sess.Values["user"] = u + return sess.Save(r, res) + } + + return errNoValidUserFound +} + +// LoginFields needs to return the fields required for this login +// method. If no login using this method is possible the function +// needs to return nil. +func (a authSimple) LoginFields() (fields []loginField) { + return []loginField{ + { + Label: "Username", + Name: "username", + Placeholder: "Username", + Type: "text", + }, + { + Label: "Password", + Name: "password", + Placeholder: "****", + Type: "password", + }, + } +} + +// Logout is called when the user visits the logout endpoint and +// needs to destroy any persistent stored cookies +func (a authSimple) Logout(res http.ResponseWriter, r *http.Request) (err error) { + sess, _ := cookieStore.Get(r, strings.Join([]string{mainCfg.Cookie.Prefix, a.AuthenticatorID()}, "-")) + sess.Options = mainCfg.GetSessionOpts() + sess.Options.MaxAge = -1 // Instant delete + return sess.Save(r, res) +} diff --git a/auth_token.go b/auth_token.go new file mode 100644 index 0000000..ac86264 --- /dev/null +++ b/auth_token.go @@ -0,0 +1,84 @@ +package main + +import ( + "net/http" + "strings" + + yaml "gopkg.in/yaml.v2" +) + +func init() { + registerAuthenticator(&authToken{}) +} + +type authToken struct { + Tokens map[string]string `yaml:"tokens"` +} + +// AuthenticatorID needs to return an unique string to identify +// this special authenticator +func (a authToken) AuthenticatorID() string { return "token" } + +// Configure loads the configuration for the Authenticator from the +// global config.yaml file which is passed as a byte-slice. +// If no configuration for the Authenticator is supplied the function +// needs to return the errAuthenticatorUnconfigured +func (a *authToken) Configure(yamlSource []byte) error { + envelope := struct { + Providers struct { + Token *authToken `yaml:"token"` + } `yaml:"providers"` + }{} + + if err := yaml.Unmarshal(yamlSource, &envelope); err != nil { + return err + } + + if envelope.Providers.Token == nil { + return errAuthenticatorUnconfigured + } + + a.Tokens = envelope.Providers.Token.Tokens + + return nil +} + +// DetectUser is used to detect a user without a login form from +// a cookie, header or other methods +// If no user was detected the errNoValidUserFound needs to be +// returned +func (a authToken) DetectUser(r *http.Request) (string, []string, error) { + authHeader := r.Header.Get("Authorization") + + if !strings.HasPrefix(authHeader, "Token ") { + return "", nil, errNoValidUserFound + } + + tmp := strings.SplitN(authHeader, " ", 2) + suppliedToken := tmp[1] + + for user, token := range a.Tokens { + if token == suppliedToken { + return user, nil, nil + } + } + + return "", nil, errNoValidUserFound +} + +// Login is called when the user submits the login form and needs +// to authenticate the user or throw an error. If the user has +// successfully logged in the persistent cookie should be written +// in order to use DetectUser for the next login. +// If the user did not login correctly the errNoValidUserFound +// needs to be returned +func (a authToken) Login(res http.ResponseWriter, r *http.Request) error { return errNoValidUserFound } + +// LoginFields needs to return the fields required for this login +// method. If no login using this method is possible the function +// needs to return nil. +func (a authToken) LoginFields() []loginField { return nil } + +// Logout is called when the user visits the logout endpoint and +// needs to destroy any persistent stored cookies +func (a authToken) Logout(res http.ResponseWriter, r *http.Request) error { return nil } diff --git a/auth_yubikey.go b/auth_yubikey.go new file mode 100644 index 0000000..4567537 --- /dev/null +++ b/auth_yubikey.go @@ -0,0 +1,136 @@ +package main + +import ( + "net/http" + "strings" + + "github.com/GeertJohan/yubigo" + "github.com/Luzifer/go_helpers/str" + yaml "gopkg.in/yaml.v2" +) + +func init() { + registerAuthenticator(&authYubikey{}) +} + +type authYubikey struct { + ClientID string `yaml:"client_id"` + SecretKey string `yaml:"secret_key"` + Devices map[string]string `yaml:"devices"` + Groups map[string][]string `yaml:"groups"` +} + +// AuthenticatorID needs to return an unique string to identify +// this special authenticator +func (a authYubikey) AuthenticatorID() string { return "yubikey" } + +// Configure loads the configuration for the Authenticator from the +// global config.yaml file which is passed as a byte-slice. +// If no configuration for the Authenticator is supplied the function +// needs to return the errAuthenticatorUnconfigured +func (a *authYubikey) Configure(yamlSource []byte) error { + envelope := struct { + Providers struct { + Yubikey *authYubikey `yaml:"yubikey"` + } `yaml:"providers"` + }{} + + if err := yaml.Unmarshal(yamlSource, &envelope); err != nil { + return err + } + + if envelope.Providers.Yubikey == nil { + return errAuthenticatorUnconfigured + } + + a.ClientID = envelope.Providers.Yubikey.ClientID + a.SecretKey = envelope.Providers.Yubikey.SecretKey + a.Devices = envelope.Providers.Yubikey.Devices + a.Groups = envelope.Providers.Yubikey.Groups + + return nil +} + +// DetectUser is used to detect a user without a login form from +// a cookie, header or other methods +// If no user was detected the errNoValidUserFound needs to be +// returned +func (a authYubikey) DetectUser(r *http.Request) (string, []string, error) { + sess, err := cookieStore.Get(r, strings.Join([]string{mainCfg.Cookie.Prefix, a.AuthenticatorID()}, "-")) + if err != nil { + return "", nil, errNoValidUserFound + } + + user, ok := sess.Values["user"].(string) + if !ok { + return "", nil, errNoValidUserFound + } + + groups := []string{} + for group, users := range a.Groups { + if str.StringInSlice(user, users) { + groups = append(groups, group) + } + } + + return user, groups, nil +} + +// Login is called when the user submits the login form and needs +// to authenticate the user or throw an error. If the user has +// successfully logged in the persistent cookie should be written +// in order to use DetectUser for the next login. +// If the user did not login correctly the errNoValidUserFound +// needs to be returned +func (a authYubikey) Login(res http.ResponseWriter, r *http.Request) error { + keyInput := r.FormValue(strings.Join([]string{a.AuthenticatorID(), "key-input"}, "-")) + + yubiAuth, err := yubigo.NewYubiAuth(a.ClientID, a.SecretKey) + if err != nil { + return err + } + + _, ok, err := yubiAuth.Verify(keyInput) + if err != nil { + return err + } + + if !ok { + // Not a valid authentication + return errNoValidUserFound + } + + user, ok := a.Devices[keyInput[:12]] + if !ok { + // We do not have a definition for that key + return errNoValidUserFound + } + + sess, _ := cookieStore.Get(r, strings.Join([]string{mainCfg.Cookie.Prefix, a.AuthenticatorID()}, "-")) + sess.Options = mainCfg.GetSessionOpts() + sess.Values["user"] = user + return sess.Save(r, res) +} + +// LoginFields needs to return the fields required for this login +// method. If no login using this method is possible the function +// needs to return nil. +func (a authYubikey) LoginFields() (fields []loginField) { + return []loginField{ + { + Label: "Yubikey One-Time-Password", + Name: "key-input", + Placeholder: "Press the button of your Yubikey...", + Type: "text", + }, + } +} + +// Logout is called when the user visits the logout endpoint and +// needs to destroy any persistent stored cookies +func (a authYubikey) Logout(res http.ResponseWriter, r *http.Request) (err error) { + sess, _ := cookieStore.Get(r, strings.Join([]string{mainCfg.Cookie.Prefix, a.AuthenticatorID()}, "-")) + sess.Options = mainCfg.GetSessionOpts() + sess.Options.MaxAge = -1 // Instant delete + return sess.Save(r, res) +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..a7d060d --- /dev/null +++ b/config.yaml @@ -0,0 +1,65 @@ +--- + +login: + title: "luzifer.io - Login" + default_method: "simple" + names: + simple: "Username / Password" + yubikey: "Yubikey" + +cookie: + domain: ".example.com" + authentication_key: "Ff1uWJcLouKu9kwxgbnKcU3ps47gps72sxEz79TGHFCpJNCPtiZAFDisM4MWbstH" + expire: 3600 # Optional, default: 3600 + prefix: "nginx-sso" # Optional, default: nginx-sso + secure: true # Optional, default: false + +# Optional, default: 127.0.0.1:8082 +listen: + addr: "127.0.0.1" + port: 8082 + +acl: + rule_sets: + - rules: + - field: "host" + equals: "test.example.com" + - field: "x-origin-uri" + regexp: "^/api" + allow: ["luzifer", "@admins"] + +providers: + # Authentication against embedded user database + # Supports: Users, Groups + simple: + # Unique username mapped to bcrypt hashed password + users: + luzifer: "$2a$10$FSGAF8qDWX52aBID8.WpxOyCvfSQ3JIUVFiwyd1jolb4jM3BzJmNu" + + # Groupname to users mapping + groups: + admins: ["luzifer"] + + # Authentication against embedded token directory + # Supports: Users + token: + # Mapping of unique token names to the token + tokens: + tokenname: "MYTOKEN" + + # Authentication against Yubikey cloud validation servers + # Supports: Users, Groups + yubikey: + # Get your client / secret from https://upgrade.yubico.com/getapikey/ + client_id: "12345" + secret_key: "foobar" + + # First 12 characters of the OTP string mapped to the username + devices: + ccccccfcvuul: "luzifer" + + # Groupname to users mapping + groups: + admins: ["luzifer"] + +... diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..424c4ca --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,105 @@ + + +
+ + + + +