Compare commits

...

8 Commits

18 changed files with 667 additions and 215 deletions

179
README.md
View File

@ -1,91 +1,136 @@
The canonical home for this project is
https://git.woozle.org/neale/simpleauth
# Simple Auth # Simple Auth
All this does is present a login page. This is a stateless forward-auth provider.
Upon successful login, the browser gets a cookie, I tested it with Caddy, but it should work fine with Traefik.
and further attempts to access will get the success page.
I made this to use with the Traefik forward-auth middleware. # Theory of Operation
I now use Caddy: it works with that too.
All I need is a simple password, that's easy to fill with a password manager.
This checks those boxes.
## Format of the `passwd` file This issues cryptographically signed authentication tokens to the client.
Some JavaScript stores the token in a cookie.
It's just like `/etc/shadow`. When a client presents an authentication token in a cookie,
they are allowed in if the token was properly signed,
and has not expired.
username:crypted-password Authentication tokens consist of:
We use sha256, * Username
until there's a Go library that supports everything. * Expiration date
* Hashed Message Authentication Code (HMAC)
There's a program included called `crypt` that will output lines for this file. Simpleauth also works with HTTP Basic authentication.
# Setup
Simpleauth needs two (2) files:
* A secret key, to sign authentication tokens
* A list of usernames and hashed passwords
## Installation with Caddy ## Create secret key
Run simpleauth as a service. This will use `/dev/urandom` to generate a 64-byte secret key.
Make sure it can read your `passwd` file,
which you set up in the previous section.
You'll want a section like this in your Caddyfile: ```sh
SASECRET=/run/secrets/simpleauth.key # Set to wherever you want your secret to live
dd if=/dev/urandom of=$SASECRET bs=1 count=64
```
## Create password file
It's just a text file with hashed passwords.
Each line is of the format `username:password_hash`
```sh
alias sacrypt="docker run --rm --entrypoint=/crypt git.woozle.org/neale/simpleauth"
SAPASSWD=/run/secrets/passwd # Set to wherever you want your password file to live
: > $SAPASSWD # Reset password file
sacrypt user1 password1 >> $SAPASSWD
sacrypt user2 password2 >> $SAPASSWD
sacrypt user3 password3 >> $SAPASSWD
```
## Start it
Turning this into the container orchestration system you prefer
(Docker Swarm, Kubernetes, Docker Compose)
is left as an exercise for the reader.
```sh
docker run \
--name=simpleauth \
--detach \
--restart=always \
--port 8080:8080 \
--volume $SASECRET:/run/secrets/simpleauth.key:ro \
--volume $SAPASSWD:/run/secrets/passwd:ro \
git.woozle.org/neale/simpleauth
```
## Make your web server use it
### Caddy
You'll want a `forward-auth` section like this:
``` ```
forward_auth simpleauth:8080 { private.example.com {
uri / forward_auth localhost:8080 {
copy_headers X-Simpleauth-Token uri /
copy_headers X-Simpleauth-Username
header_down X-Simpleauth-Domain example.com # Set cookie for all of example.com
}
respond "Hello, friend!"
} }
``` ```
## Installation with Traefik The `copy_headers` directive tells Caddy to pass
Simpleauth's `X-Simpleauth-Username` header
along in the HTTP request.
If you are reverse proxying to some other app,
it can look at this header to determine who's logged in.
I don't use Traefik any longer, but when I did, `header_down` sets the
I had it set up like this: `X-Simpleauth-Domain` header in HTTP responses.
The only time a client would get an HTTP response is when it is not yet authenticated.
The built-in JavaScript login page uses this header to set the cookie domain:
this way, you can protect multiple sites within a single cookie
```yaml ### Traefik
services:
my-cool-service:
# All your cool stuff here
deploy:
labels:
# Keep all your existing traefik stuff
traefik.http.routers.dashboard.middlewares: forward-auth
traefik.http.middlewares.forward-auth.forwardauth.address: http://simpleauth:8080/
simpleauth:
image: ghcr.io/nealey/simpleauth
secrets:
- password
deploy:
labels:
traefik.enable: "true"
traefik.http.routers.simpleauth.rules: "PathPrefix(`/`)"
traefik.http.services.simpleauth.loadbalancer.server.port: "8080"
secrets: I need someone to send me equivalent
password: traefik
file: password configuration,
name: password-v1 to include here.
```
# How It Works
Simpleauth uses a token cookie, in addition to HTTP Basic authentication. ### nginx
The token is an HMAC digest of an expiration timestamp,
plus the timestamp.
When the HMAC is good, and the timestamp is in the future,
the token is a valid authentication.
This technique means there is no persistent server storage.
If you use the default of pulling the session secret from the OS PRNG, I need someone to send me equivalent
then everybody will have to log in again every time the server restarts. nginx
You can use the `-secret` argument to provide a persistent secret, configuration,
so this won't happen. to include here.
# Why not some other thing?
The main reason is that I couldn't get the freedesktop.org
WebDAV client code to work with anything else I found.
* Authelia - I like it, but I couldn't get WebDAV to work. Also, it used 4.8GB of RAM and wanted a Redis server.
* Authentik - Didn't try it, looked too complicated.
* Keycloak - Didn't try it, looked way too complicated.
# Todo
* [ ] Performance testing: somehow this takes more CPU than caddy?
# Project Home
The canonical home for this project is
https://git.woozle.org/neale/simpleauth
Some things,
like WebDAV,
will only ever use HTTP Basic auth.
That's okay:
Simpleauth will issue a new token for every request,
and the client will ignore it.

7
build.sh Executable file
View File

@ -0,0 +1,7 @@
#! /bin/sh
set -e
tag=git.woozle.org/neale/simpleauth:latest
docker buildx build --push --tag $tag $(dirname $0)/.

View File

@ -1,9 +0,0 @@
#! /bin/sh
set -e
tag=git.woozle.org/neale/simpleauth
cd $(dirname $0)/..
docker build -t $tag -f build/Dockerfile .
docker push $tag

View File

@ -1,25 +0,0 @@
#! /bin/sh
case $1 in
-h|-help|--help)
echo "Usage: $0 TARGET"
echo
echo "Sets CI build variables for gitlab"
exit 1
;;
esac
branch=$(git symbolic-ref -q --short HEAD)
if [ "$branch" = "main" ]; then
branch=latest
fi
printf "Branch: %s\n" "$branch"
printf "::set-output name=branch::%s\n" "$branch"
printf "::set-output name=tag::%s\n" "$branch"
# I think it will use whichever comes last
git tag --points-at HEAD | while read tag; do
printf "Tag: %s\n" "$tag"
printf "::set-output name=tag::%s\n" "$tag"
done

View File

@ -7,14 +7,15 @@ import (
"io/ioutil" "io/ioutil"
"log" "log"
"net/http" "net/http"
"net/url"
"os" "os"
"path" "path"
"strings" "strings"
"time" "time"
"git.woozle.org/neale/simpleauth/pkg/token"
"github.com/GehirnInc/crypt" "github.com/GehirnInc/crypt"
_ "github.com/GehirnInc/crypt/sha256_crypt" _ "github.com/GehirnInc/crypt/sha256_crypt"
"git.woozle.org/neale/simpleauth/pkg/token"
) )
const CookieName = "simpleauth-token" const CookieName = "simpleauth-token"
@ -23,6 +24,19 @@ var secret []byte = make([]byte, 256)
var lifespan time.Duration var lifespan time.Duration
var cryptedPasswords map[string]string var cryptedPasswords map[string]string
var loginHtml []byte 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 { func authenticationValid(username, password string) bool {
c := crypt.SHA256.New() c := crypt.SHA256.New()
@ -34,64 +48,94 @@ func authenticationValid(username, password string) bool {
return false return false
} }
func rootHandler(w http.ResponseWriter, req *http.Request) { func usernameIfAuthenticated(req *http.Request) string {
if cookie, err := req.Cookie(CookieName); err == nil { 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) t, _ := token.ParseString(cookie.Value)
if t.Valid(secret) { valid := t.Valid(secret)
fmt.Print(w, "Valid token") 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 ""
}
func rootHandler(w http.ResponseWriter, req *http.Request) {
var status string
username := usernameIfAuthenticated(req)
login := req.Header.Get("X-Simpleauth-Login") == "true"
browser := strings.Contains(req.Header.Get("Accept"), "text/html")
if username == "" {
status = "failed"
} else {
status = "succeeded"
w.Header().Set("X-Simpleauth-Username", username)
if login {
// Send back a token; this will turn into a cookie
t := token.New(secret, username, time.Now().Add(lifespan))
w.Header().Set("X-Simpleauth-Cookie", fmt.Sprintf("%s=%s", CookieName, t.String()))
w.Header().Set("X-Simpleauth-Token", t.String())
} else {
// This is the only time simpleauth returns 200
// That will cause Caddy to proceed with the original request
http.Error(w, "Success", http.StatusOK)
return return
} }
// Fall through to the 401 response, though,
// so that Caddy will send our response back to the client,
// which needs these headers to set the cookie and try again.
} }
acceptsHtml := false
if strings.Contains(req.Header.Get("Accept"), "text/html") {
acceptsHtml = true
}
authenticated := false
if username, password, ok := req.BasicAuth(); ok {
authenticated = authenticationValid(username, password)
}
// Log the request
clientIP := req.Header.Get("X-Real-IP") clientIP := req.Header.Get("X-Real-IP")
if clientIP == "" { if clientIP == "" {
clientIP = req.RemoteAddr clientIP = req.RemoteAddr
} }
log.Printf("%s %s %s [auth:%v]", clientIP, req.Method, req.URL, authenticated) forwardedMethod := req.Header.Get("X-Forwarded-Method")
forwardedURL := url.URL{
if !authenticated { Scheme: req.Header.Get("X-Forwarded-Proto"),
w.Header().Set("Content-Type", "text/html") Host: req.Header.Get("X-Forwarded-Host"),
if !acceptsHtml { Path: req.Header.Get("X-Forwarded-Uri"),
w.Header().Set("WWW-Authenticate", "Basic realm=\"simpleauth\"") User: url.UserPassword(username, ""),
}
w.WriteHeader(http.StatusUnauthorized)
w.Write(loginHtml)
return
} }
// Set Cookie // Log the request
t := token.New(secret, time.Now().Add(lifespan)) if false {
http.SetCookie(w, &http.Cookie{ log.Printf("%s %s %s login:%v %s",
Name: CookieName, clientIP, forwardedMethod, forwardedURL.String(),
Value: t.String(), login, status,
Path: "/", )
Secure: true,
SameSite: http.SameSiteStrictMode,
})
// Set cookie value in our fancypants header
w.Header().Set("X-Simpleauth-Token", t.String())
if req.Header.Get("X-Simpleauth-Login") != "" {
// Caddy treats any response <300 as "please serve original content",
// so we'll use 302 (Found).
// According to RFC9110, the server SHOULD send a Location header with 302.
// We don't do that, because we don't know where to send you.
// It's possible 300 is a less incorrect code to use here.
w.WriteHeader(http.StatusFound)
} }
fmt.Fprintln(w, "Authenticated")
w.Header().Set("Content-Type", "text/html")
w.Header().Set("X-Simpleauth-Authentication", status)
w.Header().Set("WWW-Authenticate", "Simpleauth-Login")
if !login && !browser {
// Make browsers use our login form instead of basic auth
w.Header().Add("WWW-Authenticate", "Basic realm=\"simpleauth\"")
}
w.WriteHeader(http.StatusUnauthorized)
w.Write(loginHtml)
} }
func main() { func main() {
@ -113,7 +157,7 @@ func main() {
) )
secretPath := flag.String( secretPath := flag.String(
"secret", "secret",
"/dev/urandom", "/run/secrets/simpleauth.key",
"Path to a file containing some sort of secret, for signing requests", "Path to a file containing some sort of secret, for signing requests",
) )
htmlPath := flag.String( htmlPath := flag.String(
@ -121,6 +165,12 @@ func main() {
"web", "web",
"Path to HTML files", "Path to HTML files",
) )
flag.BoolVar(
&verbose,
"verbose",
false,
"Print verbose logs, for debugging",
)
flag.Parse() flag.Parse()
cryptedPasswords = make(map[string]string, 10) cryptedPasswords = make(map[string]string, 10)
@ -154,7 +204,7 @@ func main() {
} }
defer f.Close() defer f.Close()
l, err := f.Read(secret) l, err := f.Read(secret)
if l < 8 { if l < 64 {
log.Fatalf("Secret file provided %d bytes. That's not enough bytes!", l) log.Fatalf("Secret file provided %d bytes. That's not enough bytes!", l)
} else if err != nil { } else if err != nil {
log.Fatal(err) log.Fatal(err)

10
go.mod
View File

@ -1,8 +1,14 @@
module git.woozle.org/neale/simpleauth module git.woozle.org/neale/simpleauth
go 1.13 go 1.18
require ( require (
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
github.com/stretchr/testify v1.8.1 // indirect 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
) )

14
go.sum
View File

@ -1,10 +1,21 @@
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw= github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw=
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo= github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -12,8 +23,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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

@ -5,38 +5,47 @@ import (
"crypto/hmac" "crypto/hmac"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/binary" "encoding/gob"
"log" "log"
"time" "time"
) )
type T struct { type T struct {
expiration time.Time Expiration time.Time
mac []byte Username string
Mac []byte
} }
func (t T) computeMac(secret []byte) []byte { func (t T) computeMac(secret []byte) []byte {
zt := t
zt.Mac = nil
mac := hmac.New(sha256.New, secret) mac := hmac.New(sha256.New, secret)
binary.Write(mac, binary.BigEndian, t.expiration) mac.Write(zt.Bytes())
return mac.Sum([]byte{}) return mac.Sum([]byte{})
} }
// String returns the string encoding of the token // Bytes encodes the token
func (t T) String() string { func (t T) Bytes() []byte {
f := new(bytes.Buffer) f := new(bytes.Buffer)
if err := binary.Write(f, binary.BigEndian, t.expiration.Unix()); err != nil { enc := gob.NewEncoder(f)
if err := enc.Encode(t); err != nil {
log.Fatal(err) log.Fatal(err)
} }
f.Write(t.mac) return f.Bytes()
return base64.StdEncoding.EncodeToString(f.Bytes()) }
// String returns the ASCII string encoding of the token
func (t T) String() string {
return base64.StdEncoding.EncodeToString(t.Bytes())
} }
// Valid returns true iff the token is valid for the given secret and current time // Valid returns true iff the token is valid for the given secret and current time
func (t T) Valid(secret []byte) bool { func (t T) Valid(secret []byte) bool {
if time.Now().After(t.expiration) { if time.Now().After(t.Expiration) {
return false return false
} }
if !hmac.Equal(t.mac, t.computeMac(secret)) { if !hmac.Equal(t.Mac, t.computeMac(secret)) {
return false return false
} }
@ -44,36 +53,25 @@ func (t T) Valid(secret []byte) bool {
} }
// New returns a new token // New returns a new token
func New(secret []byte, expiration time.Time) T { func New(secret []byte, username string, expiration time.Time) T {
t := T{ t := T{
expiration: expiration, Username: username,
Expiration: expiration,
} }
t.mac = t.computeMac(secret) t.Mac = t.computeMac(secret)
return t return t
} }
// Parse returns a new token from the given bytes // Parse returns a new token from the given bytes
func Parse(b []byte) (T, error) { func Parse(b []byte) (T, error) {
t := T{ var t T
mac: make([]byte, sha256.Size),
}
f := bytes.NewReader(b) f := bytes.NewReader(b)
{ dec := gob.NewDecoder(f)
var sec int64 err := dec.Decode(&t)
if err := binary.Read(f, binary.BigEndian, &sec); err != nil { return t, err
return t, err
}
t.expiration = time.Unix(sec, 0)
}
if n, err := f.Read(t.mac); err != nil {
return t, err
} else {
t.mac = t.mac[:n]
}
return t, nil
} }
// ParseString parses a base64-encoded string, as created by T.String() // ParseString parses an ASCII-encoded string, as created by T.String()
func ParseString(s string) (T, error) { func ParseString(s string) (T, error) {
b, err := base64.StdEncoding.DecodeString(s) b, err := base64.StdEncoding.DecodeString(s)
if err != nil { if err != nil {

36
pkg/token/token_test.go Normal file
View File

@ -0,0 +1,36 @@
package token
import (
"testing"
"time"
)
func TestToken(t *testing.T) {
secret := []byte("bloop")
username := "rodney"
token := New(secret, username, time.Now().Add(10*time.Second))
if token.Username != username {
t.Error("Wrong username")
}
if !token.Valid(secret) {
t.Error("Not valid")
}
tokenStr := token.String()
if nt, err := ParseString(tokenStr); err != nil {
t.Error("ParseString", err)
} else if nt.Username != token.Username {
t.Error("Decoded username wrong")
}
}
func TestExpired(t *testing.T) {
secret := []byte("bloop")
username := "rodney"
token := New(secret, username, time.Now().Add(-10*time.Second))
if token.Valid(secret) {
t.Error("Expired token still valid")
}
}

View File

@ -19,45 +19,51 @@
</style> </style>
<script> <script>
function error(msg) { function error(msg) {
document.querySelector("#error").textContent = msg document.querySelector("#error").textContent = msg
} }
function message(msg) {
document.querySelector("#message").textContent = msg
}
async function login(evt) { async function login(evt) {
evt.preventDefault() evt.preventDefault()
let data = new FormData(evt.target) let data = new FormData(evt.target)
let username = data.get("username") let username = data.get("username")
let password = data.get("password") let password = data.get("password")
url = new URL(evt.target.action) url = new URL(evt.target.action)
url.username = "" url.username = ""
url.password = "" url.password = ""
let headers = new Headers({ let headers = new Headers({
"Authorization": "Basic " + btoa(username + ":" + password), "Authorization": "Basic " + btoa(username + ":" + password),
"X-Simpleauth-Login": "true", "X-Simpleauth-Login": "true",
}) })
let req = await fetch(url, { let req = await fetch(url, {
method: "GET", method: "GET",
headers: headers, headers: headers,
credentials: "same-origin", })
}) let cookie = req.headers.get("X-Simpleauth-Cookie")
if (req.status != 401) { let domain = req.headers.get("X-Simpleauth-Domain")
let token = req.headers.get("X-Simpleauth-Token") if (cookie) {
if (token) { let expiration = new Date()
// Set a cookie, just in case expiration.setFullYear(expiration.getFullYear() + 1)
let expiration = new Date() let cookieStr = `${cookie}; expires=${expiration.toUTCString()}; path=/; Secure; SameSite=Strict`
expiration.setFullYear(expiration.getFullYear() + 1) document.cookie = "simpleauth-token=; expires=Thu, 01 Jan 1970 00:00:00 GMT" // Clear any old cookies
document.cookie = `simpleauth-token=${token}; expires=${expiration.toUTCString()}; path=/; Secure; SameSite=Strict` if (domain) {
} cookieStr += `; domain=${domain}`
location.reload() document.cookie = `simpleauth-token=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=${domain}`
return
} }
console.log(req.headers) document.cookie = cookieStr // JavaScript butt-magic!
error(req.statusText || "Authentication failed") location.reload()
message("Logged In!")
return
}
error(req.statusText || "Authentication failed")
} }
async function init() { async function init() {
document.querySelector("form").addEventListener("submit", login) document.querySelector("form").addEventListener("submit", login)
} }
window.addEventListener("load", init) window.addEventListener("load", init)
@ -71,5 +77,6 @@
<div><input type="submit" value="Log In"></div> <div><input type="submit" value="Log In"></div>
</form> </form>
<div id="error"></div> <div id="error"></div>
<div id="message"></div>
</body> </body>
</html> </html>