Compare commits
8 Commits
969c3797af
...
8d6cd79ed6
Author | SHA1 | Date |
---|---|---|
Neale Pickett | 8d6cd79ed6 | |
Neale Pickett | 5293240b89 | |
Neale Pickett | 0dd54351e4 | |
Neale Pickett | f4430c6aae | |
Neale Pickett | 04e3352e6a | |
Neale Pickett | 53d04746b3 | |
Neale Pickett | 78d532a88b | |
Neale Pickett | e013bb6a9a |
179
README.md
179
README.md
|
@ -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.
|
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
#! /bin/sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
tag=git.woozle.org/neale/simpleauth:latest
|
||||||
|
|
||||||
|
docker buildx build --push --tag $tag $(dirname $0)/.
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -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
10
go.mod
|
@ -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
14
go.sum
|
@ -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=
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
|
@ -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
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in New Issue