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