1
0
Fork 0
mirror of https://github.com/Luzifer/nginx-sso.git synced 2024-10-18 07:34:22 +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:
Knut Ahlers 2018-01-28 15:16:52 +01:00 committed by GitHub
parent 95321c666b
commit 87d719367d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 1260 additions and 0 deletions

189
acl.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}