fierj
PublicTiny personal git forge
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)
})
}
}