Compare commits
No commits in common. "3bcc903be2147206adf5c55aa683dab5f1bf82ff" and "d10c46c434f70695edb9552a7dc90b14ac9377be" have entirely different histories.
3bcc903be2
...
d10c46c434
46
README.md
46
README.md
|
@ -9,6 +9,7 @@ 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`.
|
||||||
|
@ -21,25 +22,11 @@ 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
|
||||||
|
|
||||||
I don't use Traefik any longer, but when I did,
|
You need to have traefik forward the Path `/` to this application.
|
||||||
I had it set up like this:
|
|
||||||
|
I only use docker swarm. You'd do something like the following:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
|
@ -66,23 +53,10 @@ secrets:
|
||||||
name: password-v1
|
name: password-v1
|
||||||
```
|
```
|
||||||
|
|
||||||
# How It Works
|
## Note
|
||||||
|
|
||||||
Simpleauth uses a token cookie, in addition to HTTP Basic authentication.
|
For some reason that I haven't bothered looking into,
|
||||||
The token is an HMAC digest of an expiration timestamp,
|
I have to first load `/` in the browser.
|
||||||
plus the timestamp.
|
I think it has something to do with cookies going through traefik simpleauth,
|
||||||
When the HMAC is good, and the timestamp is in the future,
|
and I could probably fix it with some JavaScript,
|
||||||
the token is a valid authentication.
|
but this is good enough for me.
|
||||||
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
|
||||||
ENTRYPOINT ["/bin/simpleauth"]
|
CMD ["/bin/simpleauth"]
|
||||||
|
|
|
@ -17,12 +17,13 @@ import (
|
||||||
"github.com/nealey/simpleauth/pkg/token"
|
"github.com/nealey/simpleauth/pkg/token"
|
||||||
)
|
)
|
||||||
|
|
||||||
const CookieName = "simpleauth-token"
|
const CookieName = "auth"
|
||||||
|
|
||||||
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()
|
||||||
|
@ -38,19 +39,23 @@ 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) {
|
||||||
fmt.Print(w, "Valid token")
|
// Bypass logging and cookie setting:
|
||||||
|
// otherwise there is a torrent of logs
|
||||||
|
w.Write(successHtml)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
acceptsHtml := false
|
authenticated := ""
|
||||||
if strings.Contains(req.Header.Get("Accept"), "text/html") {
|
|
||||||
acceptsHtml = true
|
if username, password, ok := req.BasicAuth(); ok {
|
||||||
|
if authenticationValid(username, password) {
|
||||||
|
authenticated = "HTTP-Basic"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
authenticated := false
|
if authenticationValid(req.FormValue("username"), req.FormValue("password")) {
|
||||||
if username, password, ok := req.BasicAuth(); ok {
|
authenticated = "Form"
|
||||||
authenticated = authenticationValid(username, password)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the request
|
// Log the request
|
||||||
|
@ -58,40 +63,23 @@ func rootHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
if clientIP == "" {
|
if clientIP == "" {
|
||||||
clientIP = req.RemoteAddr
|
clientIP = req.RemoteAddr
|
||||||
}
|
}
|
||||||
log.Printf("%s %s %s [auth:%v]", clientIP, req.Method, req.URL, authenticated)
|
log.Printf("%s %s %s [%s]", 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)
|
||||||
return
|
} else {
|
||||||
|
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() {
|
||||||
|
@ -146,6 +134,10 @@ 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,6 +3,7 @@
|
||||||
<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;
|
||||||
|
@ -19,48 +20,39 @@
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
function error(msg) {
|
function error(msg) {
|
||||||
document.querySelector("#error").textContent = msg
|
document.querySelector("#error").textContent = msg
|
||||||
}
|
|
||||||
|
|
||||||
async function login(evt) {
|
|
||||||
evt.preventDefault()
|
|
||||||
let data = new FormData(evt.target)
|
|
||||||
let username = data.get("username")
|
|
||||||
let password = data.get("password")
|
|
||||||
|
|
||||||
url = new URL(evt.target.action)
|
|
||||||
url.username = ""
|
|
||||||
url.password = ""
|
|
||||||
|
|
||||||
let headers = new Headers({
|
|
||||||
"Authorization": "Basic " + btoa(username + ":" + password),
|
|
||||||
"X-Simpleauth-Login": "true",
|
|
||||||
})
|
|
||||||
let req = await fetch(url, {
|
|
||||||
method: "GET",
|
|
||||||
headers: headers,
|
|
||||||
credentials: "same-origin",
|
|
||||||
})
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function login(evt) {
|
||||||
document.querySelector("form").addEventListener("submit", login)
|
evt.preventDefault()
|
||||||
|
let data = new FormData(evt.target)
|
||||||
|
let username = data.get("username")
|
||||||
|
let password = data.get("password")
|
||||||
|
|
||||||
|
let headers = new Headers({
|
||||||
|
"Authorization": "Basic " + btoa(username + ":" + password),
|
||||||
|
})
|
||||||
|
let req = await fetch(evt.target.action, {
|
||||||
|
method: "GET",
|
||||||
|
headers: headers,
|
||||||
|
credentials: "same-origin",
|
||||||
|
})
|
||||||
|
if (! req.ok) {
|
||||||
|
error(req.statusText || "Authentication failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
location.reload(true)
|
||||||
|
}
|
||||||
|
function init() {
|
||||||
|
document.querySelector("form").addEventListener("submit", login)
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("load", init)
|
window.addEventListener("load", init)
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Login</h1>
|
<h1>Log In</h1>
|
||||||
<form>
|
<form action="/" method="post">
|
||||||
<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>
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
<!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