Compare commits

..

No commits in common. "8d6cd79ed6ad81398439ab0bed5870237ceadf95" and "969c3797af7eef7842b8e266c4847ee3a27ae1c9" have entirely different histories.

18 changed files with 234 additions and 686 deletions

221
README.md
View File

@ -1,136 +1,91 @@
# Simple Auth
This is a stateless forward-auth provider.
I tested it with Caddy, but it should work fine with Traefik.
# Theory of Operation
This issues cryptographically signed authentication tokens to the client.
Some JavaScript stores the token in a cookie.
When a client presents an authentication token in a cookie,
they are allowed in if the token was properly signed,
and has not expired.
Authentication tokens consist of:
* Username
* Expiration date
* Hashed Message Authentication Code (HMAC)
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
## Create secret key
This will use `/dev/urandom` to generate a 64-byte secret key.
```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:
```
private.example.com {
forward_auth localhost:8080 {
uri /
copy_headers X-Simpleauth-Username
header_down X-Simpleauth-Domain example.com # Set cookie for all of example.com
}
respond "Hello, friend!"
}
```
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.
`header_down` sets the
`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
### Traefik
I need someone to send me equivalent
traefik
configuration,
to include here.
### nginx
I need someone to send me equivalent
nginx
configuration,
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 The canonical home for this project is
https://git.woozle.org/neale/simpleauth https://git.woozle.org/neale/simpleauth
# Simple Auth
All this does is present a login page.
Upon successful login, the browser gets a cookie,
and further attempts to access will get the success page.
I made this to use with the Traefik forward-auth middleware.
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
It's just like `/etc/shadow`.
username:crypted-password
We use sha256,
until there's a Go library that supports everything.
There's a program included called `crypt` that will output lines for this file.
## Installation with Caddy
Run simpleauth as a service.
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:
```
forward_auth simpleauth:8080 {
uri /
copy_headers X-Simpleauth-Token
}
```
## Installation with Traefik
I don't use Traefik any longer, but when I did,
I had it set up like this:
```yaml
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:
password:
file: password
name: password-v1
```
# How It Works
Simpleauth uses a token cookie, in addition to HTTP Basic authentication.
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,
then everybody will have to log in again every time the server restarts.
You can use the `-secret` argument to provide a persistent secret,
so this won't happen.
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.

View File

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

9
build/build.sh Executable file
View File

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

25
build/gitlab-vars Executable file
View File

@ -0,0 +1,25 @@
#! /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,15 +7,14 @@ 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"
@ -24,19 +23,6 @@ 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()
@ -48,94 +34,64 @@ func authenticationValid(username, password string) bool {
return false return false
} }
func usernameIfAuthenticated(req *http.Request) string {
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 ""
}
func rootHandler(w http.ResponseWriter, req *http.Request) { func rootHandler(w http.ResponseWriter, req *http.Request) {
var status string if cookie, err := req.Cookie(CookieName); err == nil {
username := usernameIfAuthenticated(req) t, _ := token.ParseString(cookie.Value)
login := req.Header.Get("X-Simpleauth-Login") == "true" if t.Valid(secret) {
browser := strings.Contains(req.Header.Get("Accept"), "text/html") fmt.Print(w, "Valid token")
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
} }
forwardedMethod := req.Header.Get("X-Forwarded-Method") log.Printf("%s %s %s [auth:%v]", clientIP, req.Method, req.URL, authenticated)
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
if false {
log.Printf("%s %s %s login:%v %s",
clientIP, forwardedMethod, forwardedURL.String(),
login, status,
)
}
if !authenticated {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
w.Header().Set("X-Simpleauth-Authentication", status) if !acceptsHtml {
w.Header().Set("WWW-Authenticate", "Simpleauth-Login") w.Header().Set("WWW-Authenticate", "Basic realm=\"simpleauth\"")
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.WriteHeader(http.StatusUnauthorized)
w.Write(loginHtml) w.Write(loginHtml)
return
}
// Set Cookie
t := token.New(secret, time.Now().Add(lifespan))
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: t.String(),
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")
} }
func main() { func main() {
@ -157,7 +113,7 @@ func main() {
) )
secretPath := flag.String( secretPath := flag.String(
"secret", "secret",
"/run/secrets/simpleauth.key", "/dev/urandom",
"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(
@ -165,12 +121,6 @@ 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)
@ -204,7 +154,7 @@ func main() {
} }
defer f.Close() defer f.Close()
l, err := f.Read(secret) l, err := f.Read(secret)
if l < 64 { if l < 8 {
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)

8
go.mod
View File

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

14
go.sum
View File

@ -1,21 +1,10 @@
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=
@ -23,9 +12,8 @@ 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=

View File

@ -1,46 +0,0 @@
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
}

View File

@ -1,89 +0,0 @@
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)
}

View File

@ -1,40 +0,0 @@
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)
}

View File

@ -1,38 +0,0 @@
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)
}
}
}

View File

@ -1,73 +0,0 @@
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
}

View File

@ -1,22 +0,0 @@
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

View File

@ -1,17 +0,0 @@
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,47 +5,38 @@ import (
"crypto/hmac" "crypto/hmac"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/gob" "encoding/binary"
"log" "log"
"time" "time"
) )
type T struct { type T struct {
Expiration time.Time expiration time.Time
Username string mac []byte
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)
mac.Write(zt.Bytes()) binary.Write(mac, binary.BigEndian, t.expiration)
return mac.Sum([]byte{}) return mac.Sum([]byte{})
} }
// Bytes encodes the token // String returns the string encoding of the token
func (t T) Bytes() []byte { func (t T) String() string {
f := new(bytes.Buffer) f := new(bytes.Buffer)
enc := gob.NewEncoder(f) if err := binary.Write(f, binary.BigEndian, t.expiration.Unix()); err != nil {
if err := enc.Encode(t); err != nil {
log.Fatal(err) log.Fatal(err)
} }
return f.Bytes() f.Write(t.mac)
} 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
} }
@ -53,25 +44,36 @@ func (t T) Valid(secret []byte) bool {
} }
// New returns a new token // New returns a new token
func New(secret []byte, username string, expiration time.Time) T { func New(secret []byte, expiration time.Time) T {
t := T{ t := T{
Username: username, expiration: expiration,
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) {
var t T t := T{
mac: make([]byte, sha256.Size),
}
f := bytes.NewReader(b) f := bytes.NewReader(b)
dec := gob.NewDecoder(f) {
err := dec.Decode(&t) var sec int64
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 an ASCII-encoded string, as created by T.String() // ParseString parses a base64-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 {

View File

@ -1,36 +0,0 @@
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

@ -21,9 +21,6 @@
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()
@ -42,23 +39,20 @@
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) {
// Set a cookie, just in case
let expiration = new Date() let expiration = new Date()
expiration.setFullYear(expiration.getFullYear() + 1) expiration.setFullYear(expiration.getFullYear() + 1)
let cookieStr = `${cookie}; expires=${expiration.toUTCString()}; path=/; Secure; SameSite=Strict` document.cookie = `simpleauth-token=${token}; expires=${expiration.toUTCString()}; path=/; Secure; SameSite=Strict`
document.cookie = "simpleauth-token=; expires=Thu, 01 Jan 1970 00:00:00 GMT" // Clear any old cookies
if (domain) {
cookieStr += `; domain=${domain}`
document.cookie = `simpleauth-token=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=${domain}`
} }
document.cookie = cookieStr // JavaScript butt-magic!
location.reload() location.reload()
message("Logged In!")
return return
} }
console.log(req.headers)
error(req.statusText || "Authentication failed") error(req.statusText || "Authentication failed")
} }
@ -77,6 +71,5 @@
<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>