simpleauth/cmd/simpleauth/main.go

216 lines
4.8 KiB
Go

package main
import (
"bufio"
"flag"
"fmt"
"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"
)
const CookieName = "simpleauth-token"
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()
if crypted, ok := cryptedPasswords[username]; ok {
if err := c.Verify(crypted, []byte(password)); err == nil {
return true
}
}
return false
}
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)
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 {
// 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.
}
clientIP := req.Header.Get("X-Real-IP")
if clientIP == "" {
clientIP = req.RemoteAddr
}
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
log.Printf("%s %s %s login:%v %s",
clientIP, forwardedMethod, forwardedURL.String(),
login, status,
)
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() {
listen := flag.String(
"listen",
":8080",
"Bind address for incoming HTTP connections",
)
flag.DurationVar(
&lifespan,
"lifespan",
100*24*time.Hour,
"How long an issued token is valid",
)
passwordPath := flag.String(
"passwd",
"/run/secrets/passwd",
"Path to a file containing passwords",
)
secretPath := flag.String(
"secret",
"/run/secrets/simpleauth.key",
"Path to a file containing some sort of secret, for signing requests",
)
htmlPath := flag.String(
"html",
"web",
"Path to HTML files",
)
flag.BoolVar(
&verbose,
"verbose",
false,
"Print verbose logs, for debugging",
)
flag.Parse()
cryptedPasswords = make(map[string]string, 10)
if f, err := os.Open(*passwordPath); err != nil {
log.Fatal(err)
} else {
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
parts := strings.Split(line, ":")
if len(parts) >= 2 {
username := parts[0]
password := parts[1]
cryptedPasswords[username] = password
}
}
}
var err error
loginHtml, err = ioutil.ReadFile(path.Join(*htmlPath, "login.html"))
if err != nil {
log.Fatal(err)
}
// Read in secret
f, err := os.Open(*secretPath)
if err != nil {
log.Fatal(err)
}
defer f.Close()
l, err := f.Read(secret)
if l < 64 {
log.Fatalf("Secret file provided %d bytes. That's not enough bytes!", l)
} else if err != nil {
log.Fatal(err)
}
secret = secret[:l]
http.HandleFunc("/", rootHandler)
fmt.Println("listening on", *listen)
log.Fatal(http.ListenAndServe(*listen, nil))
}