Start working on ACLs

This commit is contained in:
Neale Pickett 2023-02-20 17:38:51 -07:00
parent f4430c6aae
commit 0dd54351e4
10 changed files with 389 additions and 17 deletions

View File

@ -7,6 +7,7 @@ import (
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"path"
"strings"
@ -23,6 +24,19 @@ var secret []byte = make([]byte, 256)
var lifespan time.Duration
var cryptedPasswords map[string]string
var loginHtml []byte
var verbose bool
func debugln(v ...any) {
if verbose {
log.Println(v...)
}
}
func debugf(fmt string, v ...any) {
if verbose {
log.Printf(fmt, v...)
}
}
func authenticationValid(username, password string) bool {
c := crypt.SHA256.New()
@ -35,19 +49,32 @@ func authenticationValid(username, password string) bool {
}
func usernameIfAuthenticated(req *http.Request) string {
if cookie, err := req.Cookie(CookieName); err == nil {
t, _ := token.ParseString(cookie.Value)
if t.Valid(secret) {
return t.Username
}
}
authUsername, authPassword, ok := req.BasicAuth()
if ok {
if authenticationValid(authUsername, authPassword) {
if authUsername, authPassword, ok := req.BasicAuth(); ok {
valid := authenticationValid(authUsername, authPassword)
debugf("basic auth valid:%v username:%v", valid, authUsername)
if valid {
return authUsername
}
} else {
debugf("no basic auth")
}
ncookies := 0
for i, cookie := range req.Cookies() {
if cookie.Name != CookieName {
continue
}
t, _ := token.ParseString(cookie.Value)
valid := t.Valid(secret)
debugf("cookie %d valid:%v username:%v", i, valid, t.Username)
if valid {
return t.Username
}
ncookies += 1
}
if ncookies == 0 {
debugf("no cookies")
}
return ""
}
@ -79,12 +106,23 @@ func rootHandler(w http.ResponseWriter, req *http.Request) {
// which needs these headers to set the cookie and try again.
}
// Log the request
clientIP := req.Header.Get("X-Real-IP")
if clientIP == "" {
clientIP = req.RemoteAddr
}
log.Println(clientIP, req.Method, req.URL, status, username)
forwardedMethod := req.Header.Get("X-Forwarded-Method")
forwardedURL := url.URL{
Scheme: req.Header.Get("X-Forwarded-Proto"),
Host: req.Header.Get("X-Forwarded-Host"),
Path: req.Header.Get("X-Forwarded-Uri"),
User: url.UserPassword(username, ""),
}
// Log the request
log.Printf("%s %s %s login:%v %s",
clientIP, forwardedMethod, forwardedURL.String(),
login, status,
)
w.Header().Set("Content-Type", "text/html")
w.Header().Set("X-Simpleauth-Authentication", status)
@ -124,6 +162,12 @@ func main() {
"web",
"Path to HTML files",
)
flag.BoolVar(
&verbose,
"verbose",
false,
"Print verbose logs, for debugging",
)
flag.Parse()
cryptedPasswords = make(map[string]string, 10)

6
go.mod
View File

@ -1,9 +1,13 @@
module git.woozle.org/neale/simpleauth
go 1.13
go 1.18
require (
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/kr/pretty v0.3.1 // indirect
github.com/stretchr/testify v1.8.1 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect

46
pkg/acl/acl.go Normal file
View File

@ -0,0 +1,46 @@
package acl
import (
"io"
"log"
"net/http"
"gopkg.in/yaml.v3"
)
type ACL struct {
Rules []Rule
}
func Read(r io.Reader) (*ACL, error) {
acl := ACL{}
ydec := yaml.NewDecoder(r)
if err := ydec.Decode(&acl); err != nil {
return nil, err
}
if err := acl.CompileURLs(); err != nil {
return nil, err
}
return &acl, nil
}
// CompileURLs compiles regular expressions for all URLs.
func (acl *ACL) CompileURLs() error {
for i := range acl.Rules {
rule := &acl.Rules[i]
if err := rule.CompileURL(); err != nil {
return err
}
}
return nil
}
func (acl *ACL) Match(req *http.Request) Action {
for _, rule := range acl.Rules {
log.Println(rule)
if rule.Match(req) {
return rule.Action
}
}
return Deny
}

89
pkg/acl/acl_test.go Normal file
View File

@ -0,0 +1,89 @@
package acl
import (
"net/http"
"net/url"
"os"
"testing"
)
type testAcl struct {
t *testing.T
acl *ACL
}
func readAcl(filename string) (*ACL, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
acl, err := Read(f)
if err != nil {
return nil, err
}
return acl, nil
}
func (ta *testAcl) try(method string, URL string, expected Action) {
u, err := url.Parse(URL)
if err != nil {
ta.t.Errorf("Parsing %s: %v", URL, err)
}
req := &http.Request{
Method: method,
URL: u,
}
action := ta.acl.Match(req)
if action != expected {
ta.t.Errorf("%s %s expected %v but got %v", method, URL, expected, action)
}
}
func TestRegexen(t *testing.T) {
acl, err := readAcl("testdata/acl.yaml")
if err != nil {
t.Fatal(err)
}
for i, rule := range acl.Rules {
if rule.urlRegexp == nil {
t.Errorf("Regexp not precompiled on rule %d", i)
}
}
}
func TestUsers(t *testing.T) {
acl, err := readAcl("testdata/acl.yaml")
if err != nil {
t.Fatal(err)
}
if acl.Rules[0].Users != nil {
t.Errorf("Rules[0].Users != nil")
}
if acl.Rules[1].Users == nil {
t.Errorf("Rules[0].Users == nil")
}
}
func TestAclMatching(t *testing.T) {
acl, err := readAcl("testdata/acl.yaml")
if err != nil {
t.Fatal(err)
}
ta := testAcl{
t: t,
acl: acl,
}
ta.try("GET", "https://example.com/moo", Deny)
ta.try("GET", "https://example.com/blargh", Deny)
ta.try("GET", "https://example.com/public/moo", Public)
ta.try("BLARGH", "https://example.com/blargh", Public)
ta.try("GET", "https://example.com/only-alice/boog", Deny)
ta.try("GET", "https://alice:@example.com/only-alice/boog", Auth)
ta.try("GET", "https://alice:@example.com/bob/", Deny)
ta.try("GET", "https://bob:@example.com/bob/", Auth)
}

40
pkg/acl/action.go Normal file
View File

@ -0,0 +1,40 @@
package acl
import (
"fmt"
"strings"
)
type Action int
const (
Deny Action = iota
Auth
Public
)
var actions = [...]string{
"deny",
"auth",
"public",
}
func (a Action) String() string {
return actions[a]
}
func (a *Action) UnmarshalYAML(unmarshal func(any) error) error {
var val string
if err := unmarshal(&val); err != nil {
return err
}
val = strings.ToLower(val)
for i, s := range actions {
if val == s {
*a = (Action)(i)
return nil
}
}
return fmt.Errorf("Unknown action type: %s", val)
}

38
pkg/acl/action_test.go Normal file
View File

@ -0,0 +1,38 @@
package acl
import (
"testing"
"gopkg.in/yaml.v3"
)
func TestActions(t *testing.T) {
if Deny.String() != "deny" {
t.Error("Deny string wrong")
}
if Auth.String() != "auth" {
t.Error("Auth string wrong")
}
if Public.String() != "public" {
t.Error("Public string wrong")
}
}
func TestYamlActions(t *testing.T) {
var out []Action
yamlDoc := "[Deny, Auth, Public, dEnY, pUBLiC]"
expected := []Action{Deny, Auth, Public, Deny, Public}
if err := yaml.Unmarshal([]byte(yamlDoc), &out); err != nil {
t.Fatal(err)
}
if len(out) != len(expected) {
t.Error("Wrong length of unmarshalled yaml")
}
for i, a := range out {
if expected[i] != a {
t.Errorf("Wrong value at position %d. Wanted %v, got %v", i, expected[i], a)
}
}
}

73
pkg/acl/rule.go Normal file
View File

@ -0,0 +1,73 @@
package acl
import (
"net/http"
"net/url"
"regexp"
)
type Rule struct {
URL string
urlRegexp *regexp.Regexp
Users []string
Methods []string
Action Action
}
// CompileURL compiles regular expressions for the URL.
// This is an startup optimization that speeds up rule processing.
func (r *Rule) CompileURL() error {
if re, err := regexp.Compile(r.URL); err != nil {
return err
} else {
r.urlRegexp = re
}
return nil
}
// Match returns true if req is matched by the rule
func (r *Rule) Match(req *http.Request) bool {
if r.urlRegexp == nil {
// Womp womp. Things will be slow, because the compiled regex won't get cached.
r.CompileURL()
}
requestUser := req.URL.User.Username()
anonURL := url.URL(*req.URL)
anonURL.User = nil
found := r.urlRegexp.FindStringSubmatch(anonURL.String())
if len(found) == 0 {
return false
}
// Match any listed method
methodMatch := (len(r.Methods) == 0)
for _, method := range r.Methods {
if method == req.Method {
methodMatch = true
}
}
if !methodMatch {
return false
}
// If they used (?P<user>),
// make sure that matches the username in the request URL
userIndex := r.urlRegexp.SubexpIndex("user")
if (userIndex != -1) && (found[userIndex] != requestUser) {
return false
}
// Match any listed user
userMatch := (len(r.Users) == 0)
for _, user := range r.Users {
if user == requestUser {
userMatch = true
}
}
if !userMatch {
// If no user match
return false
}
return true
}

22
pkg/acl/testdata/acl.yaml vendored Normal file
View File

@ -0,0 +1,22 @@
groups:
- &any [.]
- &all
- alice
- bob
- carol
rules:
- url: ^https://example.org/public/
action: public
- url: ^https://example.com/private/
users: *any
action: auth
- url: ^https://example.com/blargh
methods:
- BLARGH
action: public
- url: ^https://example.com/only-alice/
users:
- alice
action: auth
- url: ^https://example.com/(?P<user>)/
action: auth

17
pkg/acl/url.go Normal file
View File

@ -0,0 +1,17 @@
package acl
import "net/url"
type URL struct {
*url.URL
}
func (u *URL) UnmarshalYAML(unmarshal func(any) error) error {
var s string
if err := unmarshal(&s); err != nil {
return err
}
nu, err := url.Parse(s)
u.URL = nu
return err
}

View File

@ -30,8 +30,7 @@ func TestExpired(t *testing.T) {
username := "rodney"
token := New(secret, username, time.Now().Add(-10*time.Second))
if token.Valid(secret) {
t.Error("Expired token still valid")
}
if token.Valid(secret) {
t.Error("Expired token still valid")
}
}