Compare commits

...

8 Commits

18 changed files with 667 additions and 215 deletions

177
README.md
View File

@ -1,91 +1,136 @@
The canonical home for this project is
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.
This is a stateless forward-auth provider.
I tested it with Caddy, but it should work fine with Traefik.
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.
# Theory of Operation
## 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,
until there's a Go library that supports everything.
* Username
* 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.
Make sure it can read your `passwd` file,
which you set up in the previous section.
This will use `/dev/urandom` to generate a 64-byte secret key.
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 {
forward_auth localhost:8080 {
uri /
copy_headers X-Simpleauth-Token
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,
I had it set up like this:
`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
```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"
### Traefik
secrets:
password:
file: password
name: password-v1
```
I need someone to send me equivalent
traefik
configuration,
to include here.
# 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.
### nginx
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.
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
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"
"log"
"net/http"
"net/url"
"os"
"path"
"strings"
"time"
"git.woozle.org/neale/simpleauth/pkg/token"
"github.com/GehirnInc/crypt"
_ "github.com/GehirnInc/crypt/sha256_crypt"
"git.woozle.org/neale/simpleauth/pkg/token"
)
const CookieName = "simpleauth-token"
@ -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()
@ -34,64 +48,94 @@ func authenticationValid(username, password string) bool {
return false
}
func rootHandler(w http.ResponseWriter, req *http.Request) {
if cookie, err := req.Cookie(CookieName); err == nil {
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)
if t.Valid(secret) {
fmt.Print(w, "Valid token")
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) {
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
}
// 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")
if clientIP == "" {
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{
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")
if !acceptsHtml {
w.Header().Set("WWW-Authenticate", "Basic realm=\"simpleauth\"")
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)
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() {
@ -113,7 +157,7 @@ func main() {
)
secretPath := flag.String(
"secret",
"/dev/urandom",
"/run/secrets/simpleauth.key",
"Path to a file containing some sort of secret, for signing requests",
)
htmlPath := flag.String(
@ -121,6 +165,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)
@ -154,7 +204,7 @@ func main() {
}
defer f.Close()
l, err := f.Read(secret)
if l < 8 {
if l < 64 {
log.Fatalf("Secret file provided %d bytes. That's not enough bytes!", l)
} else if err != nil {
log.Fatal(err)

10
go.mod
View File

@ -1,8 +1,14 @@
module git.woozle.org/neale/simpleauth
go 1.13
go 1.18
require (
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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
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/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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
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.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
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 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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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/sha256"
"encoding/base64"
"encoding/binary"
"encoding/gob"
"log"
"time"
)
type T struct {
expiration time.Time
mac []byte
Expiration time.Time
Username string
Mac []byte
}
func (t T) computeMac(secret []byte) []byte {
zt := t
zt.Mac = nil
mac := hmac.New(sha256.New, secret)
binary.Write(mac, binary.BigEndian, t.expiration)
mac.Write(zt.Bytes())
return mac.Sum([]byte{})
}
// String returns the string encoding of the token
func (t T) String() string {
// Bytes encodes the token
func (t T) Bytes() []byte {
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)
}
f.Write(t.mac)
return base64.StdEncoding.EncodeToString(f.Bytes())
return 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
func (t T) Valid(secret []byte) bool {
if time.Now().After(t.expiration) {
if time.Now().After(t.Expiration) {
return false
}
if !hmac.Equal(t.mac, t.computeMac(secret)) {
if !hmac.Equal(t.Mac, t.computeMac(secret)) {
return false
}
@ -44,36 +53,25 @@ func (t T) Valid(secret []byte) bool {
}
// 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{
expiration: expiration,
Username: username,
Expiration: expiration,
}
t.mac = t.computeMac(secret)
t.Mac = t.computeMac(secret)
return t
}
// Parse returns a new token from the given bytes
func Parse(b []byte) (T, error) {
t := T{
mac: make([]byte, sha256.Size),
}
var t T
f := bytes.NewReader(b)
{
var sec int64
if err := binary.Read(f, binary.BigEndian, &sec); err != nil {
dec := gob.NewDecoder(f)
err := dec.Decode(&t)
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) {
b, err := base64.StdEncoding.DecodeString(s)
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

@ -21,6 +21,9 @@
function error(msg) {
document.querySelector("#error").textContent = msg
}
function message(msg) {
document.querySelector("#message").textContent = msg
}
async function login(evt) {
evt.preventDefault()
@ -39,20 +42,23 @@
let req = await fetch(url, {
method: "GET",
headers: headers,
credentials: "same-origin",
})
if (req.status != 401) {
let token = req.headers.get("X-Simpleauth-Token")
if (token) {
// Set a cookie, just in case
let cookie = req.headers.get("X-Simpleauth-Cookie")
let domain = req.headers.get("X-Simpleauth-Domain")
if (cookie) {
let expiration = new Date()
expiration.setFullYear(expiration.getFullYear() + 1)
document.cookie = `simpleauth-token=${token}; expires=${expiration.toUTCString()}; path=/; Secure; SameSite=Strict`
let cookieStr = `${cookie}; 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()
message("Logged In!")
return
}
console.log(req.headers)
error(req.statusText || "Authentication failed")
}
@ -71,5 +77,6 @@
<div><input type="submit" value="Log In"></div>
</form>
<div id="error"></div>
<div id="message"></div>
</body>
</html>