Files
plainleaf/server/auth.go
T
Zef Hemel c04d7c332f First pass at "Remote API" (see docs)
Note: this is highly experimental and will likely change in the future.
2026-03-17 09:57:08 +01:00

377 lines
11 KiB
Go

package server
import (
"fmt"
"html/template"
"log"
"math"
"net/http"
"path"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
// path to auth page in the client bundle
const authPagePath = ".client/auth.html"
// These endpoints are not protected by auth
var excludedPaths = []string{
"/service_worker.js", // because browser fetch this without sending any cookie (auth) headers
"/.client/", // to allow a basic UI to load, not 100% sure this is necessary to exclude
"/.auth", // otherwise the authentication UI would be behind authentication
"/.ping", // because docker needs to be able to access this for its health check
}
func addAuthEndpoints(r chi.Router, config *ServerConfig) {
// Logout
r.Get("/.logout", func(w http.ResponseWriter, r *http.Request) {
host := extractHost(r)
cookieOptions := CookieOptions{
Path: fmt.Sprintf("%s/", config.HostURLPrefix),
}
deleteCookie(w, authCookieName(host), cookieOptions)
deleteCookie(w, "refreshLogin", cookieOptions)
http.Redirect(w, r, applyURLPrefix("/.auth", config.HostURLPrefix), http.StatusFound)
})
// Auth page
r.Get("/.auth", func(w http.ResponseWriter, r *http.Request) {
spaceConfig := spaceConfigFromContext(r.Context())
if spaceConfig.Auth == nil {
http.Error(w, "Authentication not enabled", http.StatusForbidden)
return
}
if err := spaceConfig.InitAuth(); err != nil {
http.Error(w, "Failed to initialize authentication", http.StatusInternalServerError)
return
}
data, _, err := config.ClientBundle.ReadFile(authPagePath)
if err != nil {
http.Error(w, "Auth page not found", http.StatusNotFound)
return
}
tpl := template.Must(template.New("auth").Parse(string(data)))
templateData := map[string]any{
"HostPrefix": config.HostURLPrefix,
"SpaceName": spaceConfig.SpaceName,
"EncryptionSalt": spaceConfig.JwtIssuer.Salt,
"RememberMeDays": spaceConfig.Auth.RememberMeHours / 24,
}
w.Header().Set("Content-type", "text/html")
w.WriteHeader(http.StatusOK)
if err := tpl.Execute(w, templateData); err != nil {
log.Printf("Could not render auth page: %v", err)
w.Write([]byte("Server error"))
}
})
// Auth POST endpoint
r.Post("/.auth", func(w http.ResponseWriter, r *http.Request) {
spaceConfig := spaceConfigFromContext(r.Context())
if err := spaceConfig.InitAuth(); err != nil {
http.Error(w, "Failed to initialize authentication", http.StatusInternalServerError)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Failed to parse form", http.StatusBadRequest)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
rememberMe := r.FormValue("rememberMe")
from := r.FormValue("from")
if username == "" || password == "" {
http.Redirect(w, r, applyURLPrefix("/.auth?error=0", config.HostURLPrefix), http.StatusFound)
return
}
if spaceConfig.LockoutTimer.IsLocked() {
log.Println("Authentication locked out, redirecting to auth page.")
http.Redirect(w, r, applyURLPrefix("/.auth?error=2", config.HostURLPrefix), http.StatusFound)
return
}
if spaceConfig.Authorize(username, password) {
// Generate JWT with username (not currently used)
payload := map[string]any{
"username": username,
}
var jwt string
var err error
if rememberMe != "" {
rememberMeExpirySeconds := spaceConfig.Auth.RememberMeHours * 60 * 60
jwt, err = spaceConfig.JwtIssuer.CreateJWT(payload, rememberMeExpirySeconds)
} else {
jwt, err = spaceConfig.JwtIssuer.CreateJWT(payload, authenticationExpirySeconds)
}
if err != nil {
log.Printf("Failed to create JWT: %v\n", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
host := extractHost(r)
expirySeconds := authenticationExpirySeconds
if rememberMe != "" {
expirySeconds = spaceConfig.Auth.RememberMeHours * 60 * 60
}
expires := time.Now().Add(time.Duration(expirySeconds) * time.Second)
cookieOptions := CookieOptions{
Path: fmt.Sprintf("%s/", config.HostURLPrefix),
Expires: expires,
}
setCookie(w, authCookieName(host), jwt, cookieOptions)
if rememberMe != "" {
setCookie(w, "refreshLogin", "true", cookieOptions)
}
redirectPath := applyURLPrefix("/", config.HostURLPrefix)
if from != "" {
redirectPath = from
}
render.JSON(w, r, map[string]any{
"status": "ok",
"redirect": redirectPath,
})
} else {
log.Println("Authentication failed, redirecting to auth page.")
spaceConfig.LockoutTimer.AddCount()
render.JSON(w, r, map[string]any{
"status": "error",
"error": "Invalid username and/or password",
})
}
})
}
func (spaceConfig *SpaceConfig) InitAuth() error {
if spaceConfig.JwtIssuer == nil {
spaceConfig.authMutex.Lock()
defer spaceConfig.authMutex.Unlock()
var err error
// Need to do some initialization
spaceConfig.JwtIssuer, err = CreateAuthenticator(path.Join(spaceConfig.SpaceFolderPath, ".silverbullet.auth.json"), spaceConfig.Auth)
if err != nil {
return err
}
// Initialize lockout timer
if spaceConfig.Auth.LockoutLimit > 0 {
spaceConfig.LockoutTimer = NewLockoutTimer(spaceConfig.Auth.LockoutTime*1000, spaceConfig.Auth.LockoutLimit)
} else {
spaceConfig.LockoutTimer = NewLockoutTimer(0, 0) // disabled
}
}
return nil
}
// authMiddleware provides authentication middleware
func authMiddleware(config *ServerConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
spaceConfig := spaceConfigFromContext(r.Context())
if spaceConfig.Auth == nil {
// No authentication to do, moving on
next.ServeHTTP(w, r)
return
}
path := removeURLPrefix(r.URL.Path, config.HostURLPrefix)
host := extractHost(r)
// Headless token authentication: issue a JWT cookie and proceed.
// This runs before the excluded-path check so the cookie gets set
// on the initial page load (path="/"), which is normally excluded.
if config.HeadlessToken != "" {
if queryToken := r.URL.Query().Get("token"); queryToken == config.HeadlessToken {
if err := spaceConfig.InitAuth(); err != nil {
http.Error(w, "Failed to initialize authentication", http.StatusInternalServerError)
return
}
jwt, err := spaceConfig.JwtIssuer.CreateJWT(map[string]any{"username": "headless"})
if err != nil {
log.Printf("[Headless] Failed to create JWT: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
cookieOptions := CookieOptions{
Path: fmt.Sprintf("%s/", config.HostURLPrefix),
}
setCookie(w, authCookieName(host), jwt, cookieOptions)
next.ServeHTTP(w, r)
return
}
}
if isExcludedPath(path) {
next.ServeHTTP(w, r)
return
}
if err := spaceConfig.InitAuth(); err != nil {
http.Error(w, "Failed to initialize authentication", http.StatusInternalServerError)
return
}
authCookie := getCookie(r, authCookieName(host))
// Try Bearer token authentication if no cookie
if authCookie == "" && spaceConfig.Auth.AuthToken != "" {
authHeader := r.Header.Get("Authorization")
if after, ok := strings.CutPrefix(authHeader, "Bearer "); ok {
authToken := after
if authToken == spaceConfig.Auth.AuthToken {
next.ServeHTTP(w, r)
return
} else {
log.Println("Unauthorized token access")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}
}
if authCookie == "" {
log.Printf("Unauthorized access to %s, redirecting to auth page", path)
redirectToAuth(w, "/.auth", path, config.HostURLPrefix)
return
}
// Verify JWT
claims, err := spaceConfig.JwtIssuer.VerifyAndDecodeJWT(authCookie)
if err != nil {
log.Printf("Error verifying JWT on %s, redirecting to auth page: %v\n", path, err)
redirectToAuth(w, "/.auth", path, config.HostURLPrefix)
return
}
_, ok := claims["username"].(string)
if !ok {
log.Printf("Username mismatch in JWT on %s", path)
redirectToAuth(w, "/.auth", path, config.HostURLPrefix)
return
}
refreshLogin(w, r, config, host, spaceConfig)
next.ServeHTTP(w, r)
})
}
}
// refreshLogin refreshes the login cookie if needed
func refreshLogin(w http.ResponseWriter, r *http.Request, config *ServerConfig, host string, spaceConfig *SpaceConfig) {
// if cookie doesn't exist, return
if getCookie(r, "refreshLogin") != "" {
return
}
oldJwt := getCookie(r, authCookieName(host))
if oldJwt == "" {
return
}
claims, err := spaceConfig.JwtIssuer.VerifyAndDecodeJWT(oldJwt)
if err != nil {
return
}
// Create new JWT with fresh expiry
expirySeconds := spaceConfig.Auth.RememberMeHours * 60 * 60
payload := map[string]any{"username": claims["username"]}
newJwt, err := spaceConfig.JwtIssuer.CreateJWT(payload, expirySeconds)
if err != nil {
return
}
expires := time.Now().Add(time.Duration(expirySeconds) * time.Second)
cookieOptions := CookieOptions{
Path: fmt.Sprintf("%s/", config.HostURLPrefix),
Expires: expires,
}
setCookie(w, authCookieName(host), newJwt, cookieOptions)
setCookie(w, "refreshLogin", "true", cookieOptions)
}
// LockoutTimer implements a simple rate limiter to prevent brute force attacks
type LockoutTimer struct {
mutex sync.Mutex
bucketTime int64
bucketCount int
bucketSize int64 // duration in milliseconds
limit int
disabled bool
}
// NewLockoutTimer creates a new lockout timer
// countPeriodMs: time window in milliseconds
// limit: maximum attempts allowed in the time window
func NewLockoutTimer(countPeriodMs int, limit int) *LockoutTimer {
disabled := math.IsNaN(float64(countPeriodMs)) || math.IsNaN(float64(limit)) ||
countPeriodMs < 1 || limit < 1
return &LockoutTimer{
bucketSize: int64(countPeriodMs),
limit: limit,
disabled: disabled,
}
}
// updateBucketTime updates the current bucket time and resets count if needed
func (lt *LockoutTimer) updateBucketTime() {
currentBucketTime := time.Now().UnixMilli() / lt.bucketSize
if lt.bucketTime == currentBucketTime {
return
}
// the bucket is too old - empty it
lt.bucketTime = currentBucketTime
lt.bucketCount = 0
}
// IsLocked checks if the timer is currently locked due to too many attempts
func (lt *LockoutTimer) IsLocked() bool {
if lt.disabled {
return false
}
lt.mutex.Lock()
defer lt.mutex.Unlock()
lt.updateBucketTime()
return lt.bucketCount >= lt.limit
}
// AddCount increments the attempt counter
// IsLocked() should be called first to keep bucketTime current
func (lt *LockoutTimer) AddCount() {
if lt.disabled {
return
}
lt.mutex.Lock()
defer lt.mutex.Unlock()
// updateBucketTime should have been called by IsLocked first
lt.bucketCount++
}