This commit is contained in:
Neale Pickett 2023-02-18 16:39:18 -07:00
parent e013bb6a9a
commit 78d532a88b
5 changed files with 130 additions and 106 deletions

View File

@ -4,4 +4,4 @@ set -e
tag=git.woozle.org/neale/simpleauth:latest tag=git.woozle.org/neale/simpleauth:latest
docker buildx --push --tag $tag $(dirname $0)/. docker buildx build --push --tag $tag $(dirname $0)/.

View File

@ -12,9 +12,9 @@ import (
"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"
@ -34,23 +34,46 @@ 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 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") return t.Username
return
} }
} }
acceptsHtml := false authUsername, authPassword, ok := req.BasicAuth()
if strings.Contains(req.Header.Get("Accept"), "text/html") { if ok {
acceptsHtml = true if authenticationValid(authUsername, authPassword) {
return authUsername
}
} }
authenticated := false return ""
if username, password, ok := req.BasicAuth(); ok { }
authenticated = authenticationValid(username, password)
func rootHandler(w http.ResponseWriter, req *http.Request) {
var status string
username := usernameIfAuthenticated(req)
if username == "" {
status = "failed"
} else {
status = "succeeded"
w.Header().Set("X-Simpleauth-Username", username)
if req.Header.Get("X-Simpleauth-Login") != "true" {
// 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
}
// 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())
// 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.
} }
// Log the request // Log the request
@ -58,40 +81,17 @@ 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.Println(clientIP, req.Method, req.URL, status, username)
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 !strings.Contains(req.Header.Get("Accept"), "text/html") {
} // Make browsers use our login form instead of basic auth
w.WriteHeader(http.StatusUnauthorized) w.Header().Add("WWW-Authenticate", "Basic realm=\"simpleauth\"")
w.Write(loginHtml)
return
} }
w.WriteHeader(http.StatusUnauthorized)
// Set Cookie w.Write(loginHtml)
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() {
@ -154,7 +154,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)

View File

@ -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 {

26
pkg/token/token_test.go Normal file
View File

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

View File

@ -19,45 +19,45 @@
</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")
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) if (domain) {
document.cookie = `simpleauth-token=${token}; expires=${expiration.toUTCString()}; path=/; Secure; SameSite=Strict` cookieStr += `; domain=${domain}`
}
location.reload()
return
} }
console.log(req.headers) document.cookie = cookieStr // JavaScript butt-magic!
error(req.statusText || "Authentication failed") location.reload()
}
console.log(req.headers)
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)