Compare commits
3 Commits
d10c46c434
...
3bcc903be2
Author | SHA1 | Date |
---|---|---|
Neale Pickett | 3bcc903be2 | |
Neale Pickett | 6d67ee3bfb | |
Neale Pickett | cc53ee7ad8 |
46
README.md
46
README.md
|
@ -9,7 +9,6 @@ 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.
|
All I need is a simple password, that's easy to fill with a password manager.
|
||||||
This checks those boxes.
|
This checks those boxes.
|
||||||
|
|
||||||
|
|
||||||
## Format of the `passwd` file
|
## Format of the `passwd` file
|
||||||
|
|
||||||
It's just like `/etc/shadow`.
|
It's just like `/etc/shadow`.
|
||||||
|
@ -22,11 +21,25 @@ until there's a Go library that supports everything.
|
||||||
There's a program included called `crypt` that will output lines for this file.
|
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
|
## Installation with Traefik
|
||||||
|
|
||||||
You need to have traefik forward the Path `/` to this application.
|
I don't use Traefik any longer, but when I did,
|
||||||
|
I had it set up like this:
|
||||||
I only use docker swarm. You'd do something like the following:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
|
@ -53,10 +66,23 @@ secrets:
|
||||||
name: password-v1
|
name: password-v1
|
||||||
```
|
```
|
||||||
|
|
||||||
## Note
|
# How It Works
|
||||||
|
|
||||||
For some reason that I haven't bothered looking into,
|
Simpleauth uses a token cookie, in addition to HTTP Basic authentication.
|
||||||
I have to first load `/` in the browser.
|
The token is an HMAC digest of an expiration timestamp,
|
||||||
I think it has something to do with cookies going through traefik simpleauth,
|
plus the timestamp.
|
||||||
and I could probably fix it with some JavaScript,
|
When the HMAC is good, and the timestamp is in the future,
|
||||||
but this is good enough for me.
|
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.
|
||||||
|
|
|
@ -9,4 +9,4 @@ RUN go install -v ./...
|
||||||
FROM alpine
|
FROM alpine
|
||||||
COPY --from=builder /go/bin/simpleauth /bin
|
COPY --from=builder /go/bin/simpleauth /bin
|
||||||
COPY --from=builder /go/src/app/static /static
|
COPY --from=builder /go/src/app/static /static
|
||||||
CMD ["/bin/simpleauth"]
|
ENTRYPOINT ["/bin/simpleauth"]
|
||||||
|
|
|
@ -17,13 +17,12 @@ import (
|
||||||
"github.com/nealey/simpleauth/pkg/token"
|
"github.com/nealey/simpleauth/pkg/token"
|
||||||
)
|
)
|
||||||
|
|
||||||
const CookieName = "auth"
|
const CookieName = "simpleauth-token"
|
||||||
|
|
||||||
var secret []byte = make([]byte, 256)
|
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 successHtml []byte
|
|
||||||
|
|
||||||
func authenticationValid(username, password string) bool {
|
func authenticationValid(username, password string) bool {
|
||||||
c := crypt.SHA256.New()
|
c := crypt.SHA256.New()
|
||||||
|
@ -39,23 +38,19 @@ func rootHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
if cookie, err := req.Cookie(CookieName); err == nil {
|
if cookie, err := req.Cookie(CookieName); err == nil {
|
||||||
t, _ := token.ParseString(cookie.Value)
|
t, _ := token.ParseString(cookie.Value)
|
||||||
if t.Valid(secret) {
|
if t.Valid(secret) {
|
||||||
// Bypass logging and cookie setting:
|
fmt.Print(w, "Valid token")
|
||||||
// otherwise there is a torrent of logs
|
|
||||||
w.Write(successHtml)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
authenticated := ""
|
acceptsHtml := false
|
||||||
|
if strings.Contains(req.Header.Get("Accept"), "text/html") {
|
||||||
if username, password, ok := req.BasicAuth(); ok {
|
acceptsHtml = true
|
||||||
if authenticationValid(username, password) {
|
|
||||||
authenticated = "HTTP-Basic"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if authenticationValid(req.FormValue("username"), req.FormValue("password")) {
|
authenticated := false
|
||||||
authenticated = "Form"
|
if username, password, ok := req.BasicAuth(); ok {
|
||||||
|
authenticated = authenticationValid(username, password)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the request
|
// Log the request
|
||||||
|
@ -63,23 +58,40 @@ func rootHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
if clientIP == "" {
|
if clientIP == "" {
|
||||||
clientIP = req.RemoteAddr
|
clientIP = req.RemoteAddr
|
||||||
}
|
}
|
||||||
log.Printf("%s %s %s [%s]", clientIP, req.Method, req.URL, authenticated)
|
log.Printf("%s %s %s [auth:%v]", clientIP, req.Method, req.URL, authenticated)
|
||||||
|
|
||||||
if authenticated == "" {
|
if !authenticated {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
if !acceptsHtml {
|
||||||
|
w.Header().Set("WWW-Authenticate", "Basic realm=\"simpleauth\"")
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
w.Write(loginHtml)
|
w.Write(loginHtml)
|
||||||
} else {
|
return
|
||||||
t := token.New(secret, time.Now().Add(lifespan))
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: CookieName,
|
|
||||||
Value: t.String(),
|
|
||||||
Path: "/",
|
|
||||||
Secure: true,
|
|
||||||
SameSite: http.SameSiteStrictMode,
|
|
||||||
})
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write(successHtml)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
@ -134,10 +146,6 @@ func main() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
successHtml, err = ioutil.ReadFile(path.Join(*htmlPath, "success.html"))
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read in secret
|
// Read in secret
|
||||||
f, err := os.Open(*secretPath)
|
f, err := os.Open(*secretPath)
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
<head>
|
<head>
|
||||||
<title>Login</title>
|
<title>Login</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="icon" href="data:,">
|
|
||||||
<style>
|
<style>
|
||||||
html {
|
html {
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
|
@ -20,39 +19,48 @@
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
function error(msg) {
|
function error(msg) {
|
||||||
document.querySelector("#error").textContent = msg
|
document.querySelector("#error").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")
|
||||||
|
|
||||||
let headers = new Headers({
|
url = new URL(evt.target.action)
|
||||||
"Authorization": "Basic " + btoa(username + ":" + password),
|
url.username = ""
|
||||||
})
|
url.password = ""
|
||||||
let req = await fetch(evt.target.action, {
|
|
||||||
method: "GET",
|
let headers = new Headers({
|
||||||
headers: headers,
|
"Authorization": "Basic " + btoa(username + ":" + password),
|
||||||
credentials: "same-origin",
|
"X-Simpleauth-Login": "true",
|
||||||
})
|
})
|
||||||
if (! req.ok) {
|
let req = await fetch(url, {
|
||||||
error(req.statusText || "Authentication failed")
|
method: "GET",
|
||||||
return
|
headers: headers,
|
||||||
}
|
credentials: "same-origin",
|
||||||
location.reload(true)
|
})
|
||||||
|
let token = req.headers.get("X-Simpleauth-Token")
|
||||||
|
if (token) {
|
||||||
|
// Set a cookie, just in case
|
||||||
|
document.cookie = `simpleauth-token=${token}; path=/; Secure; SameSite=Strict`
|
||||||
|
location.reload(true)
|
||||||
|
} else {
|
||||||
|
error(req.statusText || "Authentication failed")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function init() {
|
|
||||||
document.querySelector("form").addEventListener("submit", login)
|
async function init() {
|
||||||
|
document.querySelector("form").addEventListener("submit", login)
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("load", init)
|
window.addEventListener("load", init)
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Log In</h1>
|
<h1>Login</h1>
|
||||||
<form action="/" method="post">
|
<form>
|
||||||
<div>Username: <input type="text" autocomplete="username" name="username"></div>
|
<div>Username: <input type="text" autocomplete="username" name="username"></div>
|
||||||
<div>Password: <input type="password" autocomplete="current-password" name="password"></div>
|
<div>Password: <input type="password" autocomplete="current-password" name="password"></div>
|
||||||
<div><input type="submit" value="Log In"></div>
|
<div><input type="submit" value="Log In"></div>
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Welcome</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<link rel="icon" href="data:,">
|
|
||||||
<style>
|
|
||||||
html {
|
|
||||||
font-family: sans-serif;
|
|
||||||
color: white;
|
|
||||||
background: seagreen linear-gradient(315deg, rgba(255,255,255,0.2), transparent);
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
margin: 1em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Welcome</h1>
|
|
||||||
<div>You have logged in successfully.</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
Loading…
Reference in New Issue