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 @@ + + + + + + + + {{ login.Title }} + + + + + + + + + + + +
+ +
+
+ + + +
+
+ +
+ + + + + + + + + + diff --git a/main.go b/main.go new file mode 100644 index 0000000..c3f9668 --- /dev/null +++ b/main.go @@ -0,0 +1,172 @@ +package main + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/url" + "os" + "path" + + "github.com/Luzifer/rconfig" + "github.com/flosch/pongo2" + "github.com/gorilla/sessions" + log "github.com/sirupsen/logrus" + yaml "gopkg.in/yaml.v2" +) + +type mainConfig struct { + ACL acl `yaml:"acl"` + Cookie struct { + Domain string `yaml:"domain"` + AuthKey string `yaml:"authentication_key"` + Expire int `yaml:"expire"` + Prefix string `yaml:"prefix"` + Secure bool `yaml:"secure"` + } + Listen struct { + Addr string `yaml:"addr"` + Port int `yaml:"port"` + } `yaml:"listen"` + Login struct { + Title string `yaml:"title"` + DefaultMethod string `yaml:"default_method"` + Names map[string]string `yaml:"names"` + } `yaml:"login"` +} + +func (m mainConfig) GetSessionOpts() *sessions.Options { + return &sessions.Options{ + Path: "/", + Domain: m.Cookie.Domain, + MaxAge: m.Cookie.Expire, + Secure: m.Cookie.Secure, + HttpOnly: true, + } +} + +var ( + cfg = struct { + ConfigFile string `flag:"config,c" default:"config.yaml" env:"CONFIG" description:"Location of the configuration file"` + LogLevel string `flag:"log-level" default:"info" description:"Level of logs to display (debug, info, warn, error)"` + TemplateDir string `flag:"frontend-dir" default:"./frontend/" env:"FRONTEND_DIR" description:"Location of the directory containing the web assets"` + VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"` + }{} + + mainCfg = mainConfig{} + cookieStore *sessions.CookieStore + + version = "dev" +) + +func init() { + if err := rconfig.Parse(&cfg); err != nil { + log.WithError(err).Fatal("Unable to parse commandline options") + } + + if l, err := log.ParseLevel(cfg.LogLevel); err != nil { + log.WithError(err).Fatal("Unable to parse log level") + } else { + log.SetLevel(l) + } + + if cfg.VersionAndExit { + fmt.Printf("nginx-sso %s\n", version) + os.Exit(0) + } + + // Set sane defaults for main configuration + mainCfg.Cookie.Prefix = "nginx-sso" + mainCfg.Cookie.Expire = 3600 + mainCfg.Listen.Addr = "127.0.0.1" + mainCfg.Listen.Port = 8082 +} + +func main() { + yamlSource, err := ioutil.ReadFile(cfg.ConfigFile) + if err != nil { + log.WithError(err).Fatal("Unable to read configuration file") + } + + if err := yaml.Unmarshal(yamlSource, &mainCfg); err != nil { + log.WithError(err).Fatal("Unable to load configuration file") + } + + if err := initializeAuthenticators(yamlSource); err != nil { + log.WithError(err).Fatal("Unable to configure authentication") + } + + cookieStore = sessions.NewCookieStore([]byte(mainCfg.Cookie.AuthKey)) + + http.HandleFunc("/auth", handleAuthRequest) + http.HandleFunc("/login", handleLoginRequest) + http.HandleFunc("/logout", handleLogoutRequest) + + http.ListenAndServe(fmt.Sprintf("%s:%d", mainCfg.Listen.Addr, mainCfg.Listen.Port), nil) +} + +func handleAuthRequest(res http.ResponseWriter, r *http.Request) { + user, groups, err := detectUser(r) + + switch err { + case errNoValidUserFound: + http.Error(res, "No valid user found", http.StatusUnauthorized) + + case nil: + if !mainCfg.ACL.HasAccess(user, groups, r) { + http.Error(res, "Access denied for this resource", http.StatusForbidden) + return + } + + res.Header().Set("X-Username", user) + res.WriteHeader(http.StatusOK) + + default: + log.WithError(err).Error("Error while handling auth request") + http.Error(res, "Something went wrong", http.StatusInternalServerError) + } +} + +func handleLoginRequest(res http.ResponseWriter, r *http.Request) { + if _, _, err := detectUser(r); err == nil { + // There is already a valid user + http.Redirect(res, r, r.URL.Query().Get("go"), http.StatusFound) + return + } + + if r.Method == "POST" { + err := loginUser(res, r) + switch err { + case errNoValidUserFound: + http.Redirect(res, r, "/login?go="+url.QueryEscape(r.FormValue("go")), http.StatusFound) + return + case nil: + http.Redirect(res, r, r.FormValue("go"), http.StatusFound) + return + default: + log.WithError(err).Error("Login failed with unexpected error") + http.Redirect(res, r, "/login?go="+url.QueryEscape(r.FormValue("go")), http.StatusFound) + return + } + } + + tpl := pongo2.Must(pongo2.FromFile(path.Join(cfg.TemplateDir, "index.html"))) + if err := tpl.ExecuteWriter(pongo2.Context{ + "active_methods": getFrontendAuthenticators(), + "go": r.URL.Query().Get("go"), + "login": mainCfg.Login, + }, res); err != nil { + log.WithError(err).Error("Unable to render template") + http.Error(res, "Something went wrong", http.StatusInternalServerError) + } +} + +func handleLogoutRequest(res http.ResponseWriter, r *http.Request) { + if err := logoutUser(res, r); err != nil { + log.WithError(err).Error("Failed to logout user") + http.Error(res, "Something went wrong", http.StatusInternalServerError) + return + } + + http.Redirect(res, r, r.URL.Query().Get("go"), http.StatusFound) +} diff --git a/registry.go b/registry.go new file mode 100644 index 0000000..e41a446 --- /dev/null +++ b/registry.go @@ -0,0 +1,161 @@ +package main + +import ( + "errors" + "fmt" + "net/http" + "sync" + + log "github.com/sirupsen/logrus" +) + +type authenticator interface { + // AuthenticatorID needs to return an unique string to identify + // this special authenticator + AuthenticatorID() (id string) + + // 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 + Configure(yamlSource []byte) (err error) + + // 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 + DetectUser(r *http.Request) (user string, groups []string, err error) + + // 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 + Login(res http.ResponseWriter, r *http.Request) (err error) + + // 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. + LoginFields() (fields []loginField) + + // Logout is called when the user visits the logout endpoint and + // needs to destroy any persistent stored cookies + Logout(res http.ResponseWriter, r *http.Request) (err error) +} + +type loginField struct { + Label string + Name string + Placeholder string + Type string +} + +var ( + errAuthenticatorUnconfigured = errors.New("No valid configuration found for this authenticator") + errNoValidUserFound = errors.New("No valid users found") + + authenticatorRegistry = []authenticator{} + authenticatorRegistryMutex sync.RWMutex + + activeAuthenticators = []authenticator{} +) + +func registerAuthenticator(a authenticator) { + authenticatorRegistryMutex.Lock() + defer authenticatorRegistryMutex.Unlock() + + authenticatorRegistry = append(authenticatorRegistry, a) +} + +func initializeAuthenticators(yamlSource []byte) error { + authenticatorRegistryMutex.Lock() + defer authenticatorRegistryMutex.Unlock() + + for _, a := range authenticatorRegistry { + err := a.Configure(yamlSource) + + switch err { + case nil: + activeAuthenticators = append(activeAuthenticators, a) + log.WithFields(log.Fields{"authenticator": a.AuthenticatorID()}).Debug("Activated authenticator") + case errAuthenticatorUnconfigured: + log.WithFields(log.Fields{"authenticator": a.AuthenticatorID()}).Debug("Authenticator unconfigured") + // This is okay. + default: + return fmt.Errorf("Authenticator configuration caused an error: %s", err) + } + } + + if len(activeAuthenticators) == 0 { + return fmt.Errorf("No authenticator configurations supplied") + } + + return nil +} + +func detectUser(r *http.Request) (string, []string, error) { + authenticatorRegistryMutex.RLock() + defer authenticatorRegistryMutex.RUnlock() + + for _, a := range activeAuthenticators { + user, groups, err := a.DetectUser(r) + switch err { + case nil: + return user, groups, err + case errNoValidUserFound: + // This is okay. + default: + return "", nil, err + } + } + + return "", nil, errNoValidUserFound +} + +func loginUser(res http.ResponseWriter, r *http.Request) error { + authenticatorRegistryMutex.RLock() + defer authenticatorRegistryMutex.RUnlock() + + for _, a := range activeAuthenticators { + err := a.Login(res, r) + switch err { + case nil: + return nil + case errNoValidUserFound: + // This is okay. + default: + return err + } + } + + return errNoValidUserFound +} + +func logoutUser(res http.ResponseWriter, r *http.Request) error { + authenticatorRegistryMutex.RLock() + defer authenticatorRegistryMutex.RUnlock() + + for _, a := range activeAuthenticators { + if err := a.Logout(res, r); err != nil { + return err + } + } + + return nil +} + +func getFrontendAuthenticators() map[string][]loginField { + authenticatorRegistryMutex.RLock() + defer authenticatorRegistryMutex.RUnlock() + + output := map[string][]loginField{} + for _, a := range activeAuthenticators { + if len(a.LoginFields()) == 0 { + continue + } + output[a.AuthenticatorID()] = a.LoginFields() + } + + return output +}