New Sign in

fierj

Public

Tiny personal git forge

← fierj / auth.go
package main

import (
	"context"
	"crypto/hmac"
	"crypto/rand"
	"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,
	})
}

// ----- CSRF protection -----

const csrfCookieName = "fierj_csrf"

func generateCSRFToken() string {
	b := make([]byte, 32)
	rand.Read(b)
	return hex.EncodeToString(b)
}

// csrfToken extracts the CSRF token from the cookie, generating a new one if absent.
// Returns the token and a boolean indicating whether a Set-Cookie header is needed.
func csrfToken(r *http.Request) (string, bool) {
	cookie, err := r.Cookie(csrfCookieName)
	if err == nil && cookie.Value != "" {
		return cookie.Value, false
	}
	return generateCSRFToken(), true
}

func setCSRFCookie(w http.ResponseWriter, token string) {
	http.SetCookie(w, &http.Cookie{
		Name:     csrfCookieName,
		Value:    token,
		Path:     "/",
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
		MaxAge:   7 * 24 * 3600, // 7 days
	})
}

// csrfMiddleware sets a CSRF cookie on GET/HEAD and validates the double-submit
// token on state-changing methods (POST, PUT, PATCH, DELETE).
func csrfMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		token, needsCookie := csrfToken(r)

		// Always set the cookie if it's missing — this also refreshes it.
		if needsCookie {
			setCSRFCookie(w, token)
		}

		// On state-changing methods, verify the double-submit.
		switch r.Method {
		case "POST", "PUT", "PATCH", "DELETE":
			formToken := r.FormValue("csrf_token")
			if formToken == "" || token == "" || !hmac.Equal([]byte(formToken), []byte(token)) {
				http.Error(w, "invalid CSRF token", http.StatusForbidden)
				return
			}
		}

		// Stash the token so handlers can pass it to templates.
		ctx := context.WithValue(r.Context(), contextKey("csrf"), token)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// CSRF returns the CSRF token for the current request.
func CSRF(r *http.Request) string {
	t, _ := r.Context().Value(contextKey("csrf")).(string)
	return t
}

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