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"
)

// UserInfo holds a user's credentials and SSH keys.
type UserInfo struct {
	Password string   `json:"password"`
	SSHKeys  []string `json:"ssh_keys,omitempty"`
}

// UserStore holds username → UserInfo mappings.
type UserStore map[string]UserInfo

func LoadUsers(path string) (UserStore, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return UserStore{}, nil
		}
		return nil, err
	}
	// Try new UserInfo format first.
	var store UserStore
	if err := json.Unmarshal(data, &store); err == nil {
		return store, nil
	}
	// Fall back to legacy format (map[string]string) and migrate.
	var legacy map[string]string
	if err := json.Unmarshal(data, &legacy); err != nil {
		return nil, err
	}
	store = make(UserStore, len(legacy))
	for user, hash := range legacy {
		store[user] = UserInfo{Password: hash}
	}
	return store, nil
}

func (u UserStore) Verify(username, password string) bool {
	if u == nil || len(u) == 0 {
		return false
	}
	info, ok := u[username]
	if !ok {
		return false
	}
	return bcrypt.CompareHashAndPassword([]byte(info.Password), []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] = UserInfo{Password: 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")
	}
	info, ok := u[username]
	if !ok {
		return fmt.Errorf("user not found")
	}
	hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
	if err != nil {
		return err
	}
	info.Password = string(hash)
	u[username] = info
	return nil
}

// SetSSHKeys replaces the SSH keys for a user.
func (u UserStore) SetSSHKeys(username string, keys []string) error {
	if u == nil {
		return fmt.Errorf("user store not configured")
	}
	info, ok := u[username]
	if !ok {
		return fmt.Errorf("user not found")
	}
	info.SSHKeys = keys
	u[username] = info
	return nil
}

// FindUserByKey returns the username whose SSH keys contain the given key.
func (u UserStore) FindUserByKey(pubkeyFields []string) string {
	for username, info := range u {
		for _, k := range info.SSHKeys {
			kf := strings.Fields(strings.TrimSpace(k))
			if len(kf) >= 2 && pubkeyFields[0] == kf[0] && pubkeyFields[1] == kf[1] {
				return username
			}
		}
	}
	return ""
}

// ----- 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)
		})
	}
}