mirror of
https://github.com/Luzifer/nginx-sso.git
synced 2024-12-20 04:41:17 +00:00
Initial version (#1)
* Initial draft * HCL does not support int64 * Add http stubs * Login does not need to return user details * Fields should have a label * Add example configuration * Add stub for "Simple" authenticator * Add debug logging * Implement configuration loading * Implement user detection * Fix error names in doc strings * Implement session store * Implement "Token" provider * Add login frontend * Implement login and logout * Do not show tabs when there is no choice * Fix multi-tab errors, sorting * Implement "Yubikey" authenticator * Lint: Rename error to naming convention * Apply cookie security * Prevent double-login * Adjust parameters for crowd * Implement ACL * Replace HCL config with YAML config * Remove config debug output * Remove crowd config Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
parent
95321c666b
commit
87d719367d
9 changed files with 1260 additions and 0 deletions
189
acl.go
Normal file
189
acl.go
Normal file
|
@ -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
|
||||||
|
}
|
219
acl_test.go
Normal file
219
acl_test.go
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
129
auth_simple.go
Normal file
129
auth_simple.go
Normal file
|
@ -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)
|
||||||
|
}
|
84
auth_token.go
Normal file
84
auth_token.go
Normal file
|
@ -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 }
|
136
auth_yubikey.go
Normal file
136
auth_yubikey.go
Normal file
|
@ -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)
|
||||||
|
}
|
65
config.yaml
Normal file
65
config.yaml
Normal file
|
@ -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"]
|
||||||
|
|
||||||
|
...
|
105
frontend/index.html
Normal file
105
frontend/index.html
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<!-- The above 3 meta tags *must* come first in the head; any other head content must come *after* these tags -->
|
||||||
|
<title>{{ login.Title }}</title>
|
||||||
|
|
||||||
|
<!-- Bootstrap -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/css/bootstrap.min.css"
|
||||||
|
integrity="sha256-916EbMg70RQy9LHiGkXzG8hSg9EdNy97GazNG/aiY1w=" crossorigin="anonymous" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html, body, .container, .row { height: 100%; }
|
||||||
|
.vertical-align { display: flex; flex-direction: column; justify-content: center; }
|
||||||
|
.modal-content { background-color: darkcyan; }
|
||||||
|
.modal-heading h2 { color: white; }
|
||||||
|
.nav-tabs>li>a { color: white; }
|
||||||
|
.nav-tabs>li.active>a, .nav-tabs>li>a:hover { color: #333; }
|
||||||
|
.tab-pane { padding-top: 10px; color: white; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
|
||||||
|
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
||||||
|
<!--[if lt IE 9]>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.3/html5shiv.min.js"
|
||||||
|
integrity="sha256-3Jy/GbSLrg0o9y5Z5n1uw0qxZECH7C6OQpVBgNFYa0g=" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/respond.js/1.4.2/respond.min.js"
|
||||||
|
integrity="sha256-g6iAfvZp+nDQ2TdTR/VVKJf3bGro4ub5fvWSWVRi2NE=" crossorigin="anonymous"></script>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<div class="row vertical-align">
|
||||||
|
<div class="col-md-offset-2 col-md-8">
|
||||||
|
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-heading">
|
||||||
|
<h2 class="text-center">{{ login.Title }}</h2>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="modal-body">
|
||||||
|
|
||||||
|
<!-- Nav tabs -->
|
||||||
|
{% if active_methods | length > 1 %}
|
||||||
|
<ul class="nav nav-tabs" role="tablist">
|
||||||
|
{% for method in active_methods sorted %}
|
||||||
|
<li role="presentation" class="{% if method == login.DefaultMethod %}active{% endif %}">
|
||||||
|
{% for name, desc in login.Names %}{% if method == name %}
|
||||||
|
<a href="#{{ method }}" aria-controls="{{ method }}" role="tab" data-toggle="tab">{{ desc }}</a>
|
||||||
|
{% endif %}{% endfor %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Tab panes -->
|
||||||
|
<div class="tab-content">
|
||||||
|
{% for method, fields in active_methods sorted %}
|
||||||
|
<div role="tabpanel" class="tab-pane {% if method == login.DefaultMethod %}active{% endif %}" id="{{ method }}">
|
||||||
|
<form action="/login" method="post">
|
||||||
|
{% for field in fields %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ method }}-{{ field.Name }}">{{ field.Label }}</label>
|
||||||
|
<input type="{{ field.Type }}" class="form-control" placeholder="{{ field.Placeholder }}"
|
||||||
|
name="{{ method }}-{{ field.Name }}" id="{{ method }}-{{ field.Name }}" />
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="form-group text-center">
|
||||||
|
<button type="submit" class="btn btn-success btn-lg">Login</button>
|
||||||
|
<input type="hidden" name="go" value="{{ go }}">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div> <!-- /.panel-body -->
|
||||||
|
</div> <!-- /.modal-content -->
|
||||||
|
</div> <!-- /.modal-dialog -->
|
||||||
|
|
||||||
|
</div> <!-- /.col-md-8 -->
|
||||||
|
</div> <!-- /.row -->
|
||||||
|
|
||||||
|
</div> <!-- /.container -->
|
||||||
|
|
||||||
|
<!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/1.12.4/jquery.min.js"
|
||||||
|
integrity="sha256-ZosEbRLbNQzLpnKIkEdrPv7lOy9C27hHQ+Xp8a4MxAQ=" crossorigin="anonymous"></script>
|
||||||
|
<!-- Include all compiled plugins (below), or include individual files as needed -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/js/bootstrap.min.js"
|
||||||
|
integrity="sha256-U5ZEeKfGNOja007MMD3YBI0A3OSZOQbeG6z2f2Y0hu8=" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
$('a[data-toggle="tab"]').on('shown.bs.tab', function (e) {
|
||||||
|
$(e.target.hash).find('input:first').focus();
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
172
main.go
Normal file
172
main.go
Normal file
|
@ -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)
|
||||||
|
}
|
161
registry.go
Normal file
161
registry.go
Normal file
|
@ -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
|
||||||
|
}
|
Loading…
Reference in a new issue