New Sign in

fierj

Public

Tiny personal git forge

← fierj / auth.go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"

	"golang.org/x/crypto/bcrypt"
)

// UserStore holds username → bcrypt-hash mappings loaded from a JSON file.
type UserStore map[string]string

func LoadUsers(path string) (UserStore, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return UserStore{}, nil // first run, no users yet
		}
		return nil, err
	}
	var store UserStore
	if err := json.Unmarshal(data, &store); err != nil {
		return nil, err
	}
	return store, nil
}

func (u UserStore) Verify(username, password string) bool {
	if u == nil || len(u) == 0 {
		return false
	}
	hash, ok := u[username]
	if !ok {
		return false
	}
	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}

func (u UserStore) Save(path string) error {
	if u == nil {
		return fmt.Errorf("user store not configured")
	}
	data, err := json.MarshalIndent(u, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(path, data, 0644)
}

func (u UserStore) Add(username, password string) error {
	if u == nil {
		return fmt.Errorf("user store not configured; set users_path and cookie_secret in config")
	}
	if username == "" || password == "" {
		return fmt.Errorf("username and password are required")
	}
	if _, exists := u[username]; exists {
		return fmt.Errorf("user %s already exists", username)
	}
	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		return err
	}
	u[username] = string(hash)
	return nil
}

func (u UserStore) Remove(username string) {
	if u != nil {
		delete(u, username)
	}
}

func (u UserStore) ChangePassword(username, newPassword string) error {
	if u == nil {
		return fmt.Errorf("user store not configured")
	}
	if newPassword == "" {
		return fmt.Errorf("password is required")
	}
	hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
	if err != nil {
		return err
	}
	u[username] = string(hash)
	return nil
}

// ----- Cookie signing -----

func signCookie(username string, secret []byte) string {
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(username))
	sig := hex.EncodeToString(mac.Sum(nil))
	return username + ":" + sig
}

func verifyCookie(value string, secret []byte) (string, bool) {
	parts := strings.SplitN(value, ":", 2)
	if len(parts) != 2 {
		return "", false
	}
	expected := signCookie(parts[0], secret)
	return parts[0], hmac.Equal([]byte(expected), []byte(value))
}

func setAuthCookie(w http.ResponseWriter, username string, secret []byte) {
	http.SetCookie(w, &http.Cookie{
		Name:     "fierj_auth",
		Value:    signCookie(username, secret),
		Path:     "/",
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
		MaxAge:   7 * 24 * 3600, // 7 days
	})
}

func clearAuthCookie(w http.ResponseWriter) {
	http.SetCookie(w, &http.Cookie{
		Name:     "fierj_auth",
		Value:    "",
		Path:     "/",
		HttpOnly: true,
		MaxAge:   -1,
	})
}

// ----- Middleware -----

// AuthUser extracts the logged-in username from the cookie, or returns "".
func AuthUser(r *http.Request, secret []byte) string {
	if len(secret) == 0 {
		return ""
	}
	cookie, err := r.Cookie("fierj_auth")
	if err != nil {
		return ""
	}
	username, ok := verifyCookie(cookie.Value, secret)
	if !ok {
		return ""
	}
	return username
}

// SetupRedirect middleware: if no users exist, all requests go to /setup.
func SetupRedirect(users UserStore) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if len(users) == 0 && r.URL.Path != "/setup" {
				http.Redirect(w, r, "/setup", http.StatusSeeOther)
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}