diff --git a/README.md b/README.md index ae97fb2..cce90ad 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ Mostly, we started this project, because relaying on `git-http-backend` as the m We suggest to deploy from Docker, as it uses Git and may messed up your config. - ```bash # Build docker build -t workgit:latest . @@ -26,7 +25,6 @@ docker run -d \ workgit:latest ``` - Or build from source: ```bash diff --git a/cmd/workgit/user.go b/cmd/workgit/user.go new file mode 100644 index 0000000..569587d --- /dev/null +++ b/cmd/workgit/user.go @@ -0,0 +1,443 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 LuaGuard Softworks + +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + "text/tabwriter" + + "github.com/lluaguard/workgit/internal/auth" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var userCmd = &cobra.Command{ + Use: "user", + Short: "User management commands", +} + +var userCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create a new user", + Args: cobra.ExactArgs(1), + RunE: runUserCreate, +} + +var userListCmd = &cobra.Command{ + Use: "list", + Short: "List all users", + RunE: runUserList, +} + +var userDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a user", + Args: cobra.ExactArgs(1), + RunE: runUserDelete, +} + +var userSetPermCmd = &cobra.Command{ + Use: "set-permission ", + Short: "Set user permission for a repository", + Long: `Set user permission for a repository. + +Permissions: + read - Can clone and fetch (read-only) + write - Can push changes + admin - Full control over repository + none - Remove access`, + Args: cobra.ExactArgs(3), + RunE: runUserSetPermission, +} + +var userShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show user details", + Args: cobra.ExactArgs(1), + RunE: runUserShow, +} + +var userSetAdminCmd = &cobra.Command{ + Use: "set-admin ", + Short: "Set global admin status", + Args: cobra.ExactArgs(2), + RunE: runUserSetAdmin, +} + +var userResetPasswordCmd = &cobra.Command{ + Use: "reset-password ", + Short: "Reset user password", + Args: cobra.ExactArgs(1), + RunE: runUserResetPassword, +} + +var userRegenerateTokenCmd = &cobra.Command{ + Use: "regenerate-token ", + Short: "Regenerate user API token", + Args: cobra.ExactArgs(1), + RunE: runUserRegenerateToken, +} + +func init() { + userCreateCmd.Flags().String("email", "", "user email") + userCreateCmd.Flags().String("name", "", "full name") + userCreateCmd.Flags().Bool("admin", false, "make user global admin") + + userCmd.AddCommand( + userCreateCmd, + userListCmd, + userDeleteCmd, + userSetPermCmd, + userShowCmd, + userSetAdminCmd, + userResetPasswordCmd, + userRegenerateTokenCmd, + ) + + rootCmd.AddCommand(userCmd) +} + +func runUserCreate(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + username := args[0] + email, _ := cmd.Flags().GetString("email") + fullName, _ := cmd.Flags().GetString("name") + isAdmin, _ := cmd.Flags().GetBool("admin") + + // Prompt for password + password, err := promptPassword("Enter password: ") + if err != nil { + return err + } + + confirm, err := promptPassword("Confirm password: ") + if err != nil { + return err + } + + if password != confirm { + return fmt.Errorf("passwords do not match") + } + + usersDir := filepath.Join(cfg.Storage.Root, "users") + userStore, err := auth.NewUserStore(usersDir) + if err != nil { + return fmt.Errorf("initialize user store: %w", err) + } + + user, err := userStore.CreateUser(username, password, email, fullName) + if err != nil { + return fmt.Errorf("create user: %w", err) + } + + if isAdmin { + if err := userStore.SetGlobalAdmin(username, true); err != nil { + return fmt.Errorf("set admin: %w", err) + } + } + + if jsonOutput { + fmt.Printf(`{"status":"created","username":"%s","token":"%s"}%s`, + user.Username, user.Token, "\n") + } else { + fmt.Printf("User created: %s\n", user.Username) + fmt.Printf("API Token: %s\n", user.Token) + fmt.Println("\nKeep this token secure! It won't be shown again.") + } + + return nil +} + +func runUserList(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + usersDir := filepath.Join(cfg.Storage.Root, "users") + userStore, err := auth.NewUserStore(usersDir) + if err != nil { + return fmt.Errorf("initialize user store: %w", err) + } + + users := userStore.ListUsers() + + if jsonOutput { + fmt.Print(`{"users":[`) + for i, u := range users { + if i > 0 { + fmt.Print(",") + } + fmt.Printf(`{"username":"%s","email":"%s","active":%t,"admin":%t}`, + u.Username, u.Email, u.Active, u.GlobalAdmin) + } + fmt.Println("]}") + } else { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "USERNAME\tEMAIL\tACTIVE\tADMIN\tREPOS") + + for _, u := range users { + active := "yes" + if !u.Active { + active = "no" + } + admin := "no" + if u.GlobalAdmin { + admin = "yes" + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\n", + u.Username, u.Email, active, admin, len(u.Permissions)) + } + + w.Flush() + } + + return nil +} + +func runUserDelete(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + username := args[0] + + if !jsonOutput { + fmt.Printf("Delete user '%s'? [y/N]: ", username) + var confirm string + fmt.Scanln(&confirm) + if confirm != "y" && confirm != "Y" { + fmt.Println("Cancelled") + return nil + } + } + + usersDir := filepath.Join(cfg.Storage.Root, "users") + userStore, err := auth.NewUserStore(usersDir) + if err != nil { + return fmt.Errorf("initialize user store: %w", err) + } + + if err := userStore.DeleteUser(username); err != nil { + return fmt.Errorf("delete user: %w", err) + } + + if jsonOutput { + fmt.Printf(`{"status":"deleted","username":"%s"}%s`, username, "\n") + } else { + fmt.Printf("User deleted: %s\n", username) + } + + return nil +} + +func runUserSetPermission(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + username := args[0] + repo := args[1] + permStr := args[2] + + perm := auth.Permission(permStr) + if !isValidPermString(permStr) { + return fmt.Errorf("invalid permission: %s (use: read, write, admin, none)", permStr) + } + + usersDir := filepath.Join(cfg.Storage.Root, "users") + userStore, err := auth.NewUserStore(usersDir) + if err != nil { + return fmt.Errorf("initialize user store: %w", err) + } + + // Normalize repository name for consistent storage + normalizedRepo := auth.NormalizeRepo(repo) + if err := userStore.SetPermission(username, normalizedRepo, perm); err != nil { + return fmt.Errorf("set permission: %w", err) + } + + if jsonOutput { + fmt.Printf(`{"status":"updated","username":"%s","repo":"%s","permission":"%s"}%s`, + username, normalizedRepo, perm, "\n") + } else { + fmt.Printf("Permission set: %s -> %s (%s)\n", username, normalizedRepo, perm) + } + + return nil +} + +func runUserShow(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + username := args[0] + + usersDir := filepath.Join(cfg.Storage.Root, "users") + userStore, err := auth.NewUserStore(usersDir) + if err != nil { + return fmt.Errorf("initialize user store: %w", err) + } + + user, err := userStore.GetUser(username) + if err != nil { + return fmt.Errorf("get user: %w", err) + } + + if jsonOutput { + // Omit token for security + fmt.Printf(`{"username":"%s","email":"%s","full_name":"%s","active":%t,"admin":%t,"created_at":"%s"}%s`, + user.Username, user.Email, user.FullName, user.Active, user.GlobalAdmin, + user.CreatedAt.Format("2006-01-02 15:04:05"), "\n") + } else { + fmt.Printf("Username: %s\n", user.Username) + fmt.Printf("Email: %s\n", user.Email) + fmt.Printf("Full Name: %s\n", user.FullName) + fmt.Printf("Active: %t\n", user.Active) + fmt.Printf("Global Admin: %t\n", user.GlobalAdmin) + fmt.Printf("Created: %s\n", user.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Printf("Updated: %s\n", user.UpdatedAt.Format("2006-01-02 15:04:05")) + + if len(user.Permissions) > 0 { + fmt.Println("\nRepository Permissions:") + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "REPOSITORY\tPERMISSION") + for repo, perm := range user.Permissions { + fmt.Fprintf(w, "%s\t%s\n", repo, perm) + } + w.Flush() + } + } + + return nil +} + +func runUserSetAdmin(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + username := args[0] + isAdmin := args[1] == "true" + + usersDir := filepath.Join(cfg.Storage.Root, "users") + userStore, err := auth.NewUserStore(usersDir) + if err != nil { + return fmt.Errorf("initialize user store: %w", err) + } + + if err := userStore.SetGlobalAdmin(username, isAdmin); err != nil { + return fmt.Errorf("set admin: %w", err) + } + + if jsonOutput { + fmt.Printf(`{"status":"updated","username":"%s","admin":%t}%s`, username, isAdmin, "\n") + } else { + status := "removed from" + if isAdmin { + status = "granted" + } + fmt.Printf("Global admin %s: %s\n", status, username) + } + + return nil +} + +func runUserResetPassword(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + username := args[0] + + password, err := promptPassword("Enter new password: ") + if err != nil { + return err + } + + confirm, err := promptPassword("Confirm new password: ") + if err != nil { + return err + } + + if password != confirm { + return fmt.Errorf("passwords do not match") + } + + usersDir := filepath.Join(cfg.Storage.Root, "users") + userStore, err := auth.NewUserStore(usersDir) + if err != nil { + return fmt.Errorf("initialize user store: %w", err) + } + + if err := userStore.UpdatePassword(username, password); err != nil { + return fmt.Errorf("reset password: %w", err) + } + + if jsonOutput { + fmt.Printf(`{"status":"updated","username":"%s"}%s`, username, "\n") + } else { + fmt.Printf("Password reset for user: %s\n", username) + } + + return nil +} + +func runUserRegenerateToken(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + username := args[0] + + usersDir := filepath.Join(cfg.Storage.Root, "users") + userStore, err := auth.NewUserStore(usersDir) + if err != nil { + return fmt.Errorf("initialize user store: %w", err) + } + + token, err := userStore.RegenerateToken(username) + if err != nil { + return fmt.Errorf("regenerate token: %w", err) + } + + if jsonOutput { + fmt.Printf(`{"status":"updated","username":"%s","token":"%s"}%s`, username, token, "\n") + } else { + fmt.Printf("New token for %s:\n%s\n", username, token) + fmt.Println("\nKeep this token secure! It won't be shown again.") + } + + return nil +} + +func promptPassword(prompt string) (string, error) { + fmt.Print(prompt) + password, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + if err != nil { + return "", err + } + return string(password), nil +} + +func isValidPermString(s string) bool { + s = strings.ToLower(s) + return s == "none" || s == "read" || s == "write" || s == "admin" +} diff --git a/docker-compose.yml b/docker-compose.yml index 9b94ec3..5d1b5b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: # Mount your local config.yaml into the container - ./config.yaml:/etc/workgit/config.yaml:ro # Mount a local repos directory so the container can write to it + - ./users:/var/lib/workgit/users - ./repos:/var/lib/workgit/repos command: ["serve", "--config", "/etc/workgit/config.yaml"] restart: unless-stopped diff --git a/go.mod b/go.mod index ad6af44..1a15e91 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,11 @@ module github.com/lluaguard/workgit -go 1.25.1 +go 1.25 require ( github.com/prometheus/client_golang v1.23.2 github.com/spf13/cobra v1.10.1 + golang.org/x/term v0.36.0 golang.org/x/time v0.14.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -20,6 +21,7 @@ require ( github.com/prometheus/procfs v0.16.1 // indirect github.com/spf13/pflag v1.0.9 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/sys v0.35.0 // indirect + golang.org/x/crypto v0.43.0 + golang.org/x/sys v0.37.0 // indirect google.golang.org/protobuf v1.36.8 // indirect ) diff --git a/go.sum b/go.sum index 5bb3b03..7e84177 100644 --- a/go.sum +++ b/go.sum @@ -43,8 +43,12 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index b5a6875..60a3681 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -12,6 +12,7 @@ import ( "fmt" "net/http" "os" + "path/filepath" "strings" "github.com/lluaguard/workgit/internal/config" @@ -30,23 +31,43 @@ const userKey ctxKey = "user" type User struct { ID string Anonymous bool + Username string // Added for user management } // Authenticator handles authentication type Authenticator struct { - cfg config.AuthConfig - tokens map[string]bool - caPool *x509.CertPool + cfg config.AuthConfig + tokens map[string]bool + caPool *x509.CertPool + userStore *UserStore // Added user store } // NewAuthenticator creates a new authenticator -func NewAuthenticator(cfg config.AuthConfig) (*Authenticator, error) { +// storagePath should be a directory where the users data will be stored. +// If storagePath is a root-like path, it will be joined with "users" to ensure +// the users.json file is created inside a users directory. +func NewAuthenticator(cfg config.AuthConfig, storagePath string) (*Authenticator, error) { a := &Authenticator{ cfg: cfg, tokens: make(map[string]bool), } - // Build token map + // Initialize user store if storage path provided + if storagePath != "" { + // Ensure users.json lives under a "users" dir, but don't double-append. + userStorePath := storagePath + if filepath.Base(storagePath) != "users" { + userStorePath = filepath.Join(storagePath, "users") + } + + userStore, err := NewUserStore(userStorePath) + if err != nil { + return nil, fmt.Errorf("initialize user store: %w", err) + } + a.userStore = userStore + } + + // Build token map for backward compatibility for _, token := range cfg.Tokens { a.tokens[token] = true } @@ -94,7 +115,10 @@ func (a *Authenticator) authenticate(r *http.Request) (*User, error) { if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 { cert := r.TLS.PeerCertificates[0] if a.validateClientCert(cert) { - return &User{ID: cert.Subject.CommonName}, nil + return &User{ + ID: cert.Subject.CommonName, + Username: cert.Subject.CommonName, + }, nil } } @@ -107,10 +131,7 @@ func (a *Authenticator) authenticate(r *http.Request) (*User, error) { // Bearer token if strings.HasPrefix(auth, "Bearer ") { token := strings.TrimPrefix(auth, "Bearer ") - if a.validateToken(token) { - return &User{ID: "token:" + token[:8]}, nil - } - return nil, ErrUnauthorized + return a.authenticateToken(token) } // Basic auth @@ -126,16 +147,69 @@ func (a *Authenticator) authenticate(r *http.Request) (*User, error) { return nil, ErrUnauthorized } - // Use password as token - if a.validateToken(parts[1]) { - return &User{ID: parts[0]}, nil + username := parts[0] + password := parts[1] + + // Try user store first if available + if a.userStore != nil { + // Try as username/password + user, err := a.userStore.AuthenticateUser(username, password) + if err == nil { + return &User{ + ID: user.Username, + Username: user.Username, + }, nil + } + + // Try password as token + user, err = a.userStore.GetUserByToken(password) + if err == nil { + return &User{ + ID: user.Username, + Username: user.Username, + }, nil + } + } + + // Fall back to legacy token validation + if a.validateToken(password) { + id := "token:" + shortTokenPrefix(password) + return &User{ID: id}, nil + } + } + + return nil, ErrUnauthorized +} + +func shortTokenPrefix(tok string) string { + if len(tok) <= 8 { + return tok + } + return tok[:8] +} + +// authenticateToken authenticates using a bearer token +func (a *Authenticator) authenticateToken(token string) (*User, error) { + // Try user store first + if a.userStore != nil { + user, err := a.userStore.GetUserByToken(token) + if err == nil { + return &User{ + ID: user.Username, + Username: user.Username, + }, nil } } + // Fall back to legacy token validation + if a.validateToken(token) { + return &User{ID: "token:" + shortTokenPrefix(token)}, nil + } + return nil, ErrUnauthorized } -// validateToken validates an API token +// validateToken validates an API token (legacy) func (a *Authenticator) validateToken(token string) bool { if len(a.tokens) == 0 { return false @@ -185,6 +259,24 @@ func UserFromContext(ctx context.Context) (*User, bool) { return user, ok } +// NormalizeRepo ensures repository names have a consistent format by appending ".git" if missing +func NormalizeRepo(repo string) string { + if !strings.HasSuffix(repo, ".git") { + return repo + ".git" + } + return repo +} + +// CheckPermission checks if user has required permission for repository +func (a *Authenticator) CheckPermission(username, repo string, required Permission) bool { + if a.userStore == nil { + return false + } + // Normalize repository name for consistent lookup + normalizedRepo := NormalizeRepo(repo) + return a.userStore.CheckAccess(username, normalizedRepo, required) +} + // RequireAuth returns true if authentication is required for the operation func RequireAuth(method, service string, anonymousRead bool) bool { // Push always requires auth @@ -200,6 +292,40 @@ func RequireAuth(method, service string, anonymousRead bool) bool { return true } +// RequirePermission checks if user has required permission for git operation +func (a *Authenticator) RequirePermission(ctx context.Context, repo, service string) error { + user, ok := UserFromContext(ctx) + if !ok || user.Anonymous { + if a.cfg.AnonymousRead && service == "git-upload-pack" { + return nil // Allow anonymous read + } + return ErrUnauthorized + } + + // Skip permission check if user store not configured + if a.userStore == nil { + return nil + } + + var required Permission + switch service { + case "git-upload-pack": + required = PermissionRead + case "git-receive-pack": + required = PermissionWrite + default: + return ErrForbidden + } + + // Normalize repository name for consistent lookup + normalizedRepo := NormalizeRepo(repo) + if !a.CheckPermission(user.Username, normalizedRepo, required) { + return ErrForbidden + } + + return nil +} + // TLSConfig returns TLS configuration with optional mTLS func (a *Authenticator) TLSConfig(baseCfg *tls.Config) *tls.Config { if baseCfg == nil { @@ -215,3 +341,8 @@ func (a *Authenticator) TLSConfig(baseCfg *tls.Config) *tls.Config { return baseCfg } + +// GetUserStore returns the user store (for CLI commands) +func (a *Authenticator) GetUserStore() *UserStore { + return a.userStore +} diff --git a/internal/auth/users.go b/internal/auth/users.go new file mode 100644 index 0000000..9082ecd --- /dev/null +++ b/internal/auth/users.go @@ -0,0 +1,483 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 LuaGuard Softworks + +package auth + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrUserNotFound = errors.New("user not found") + ErrUserAlreadyExists = errors.New("user already exists") + ErrInvalidPermission = errors.New("invalid permission level") +) + +// Permission levels +type Permission string + +const ( + PermissionNone Permission = "none" + PermissionRead Permission = "read" + PermissionWrite Permission = "write" + PermissionAdmin Permission = "admin" +) + +// UserInfo represents a user account +type UserInfo struct { + Username string `json:"username"` + PasswordHash string `json:"password_hash"` + Token string `json:"token"` + Email string `json:"email,omitempty"` + FullName string `json:"full_name,omitempty"` + Active bool `json:"active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Permissions map[string]Permission `json:"permissions"` // repo -> permission + GlobalAdmin bool `json:"global_admin"` +} + +// UserStore manages user accounts and permissions +type UserStore struct { + users map[string]*UserInfo + tokens map[string]string // token -> username + mu sync.RWMutex + filePath string +} + +// NewUserStore creates a new user store +func NewUserStore(storagePath string) (*UserStore, error) { + dir := storagePath + if filepath.Base(storagePath) != "users" { + dir = filepath.Join(storagePath, "users") + } + store := &UserStore{ + users: make(map[string]*UserInfo), + tokens: make(map[string]string), + filePath: filepath.Join(dir, "users.json"), + } + if err := store.load(); err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("load users: %w", err) + } + return store, nil +} + +// CreateUser creates a new user +func (s *UserStore) CreateUser(username, password, email, fullName string) (*UserInfo, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.users[username]; exists { + return nil, ErrUserAlreadyExists + } + + hashed, err := hashPassword(password) + if err != nil { + return nil, fmt.Errorf("create user: %w", err) + } + + // Generate unique token (ensure uniqueness) + var token string + const maxRetries = 10 + for i := 0; i < maxRetries; i++ { + cand, err := generateToken() + if err != nil { + return nil, fmt.Errorf("create user: %w", err) + } + if _, taken := s.tokens[cand]; !taken { + token = cand + break + } + } + if token == "" { + return nil, errors.New("failed to generate unique token after retries") + } + + user := &UserInfo{ + Username: username, + PasswordHash: hashed, + Token: token, + Email: email, + FullName: fullName, + Active: true, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + Permissions: make(map[string]Permission), + GlobalAdmin: false, + } + + s.users[username] = user + s.tokens[token] = username + + if err := s.save(); err != nil { + delete(s.users, username) + delete(s.tokens, token) + return nil, fmt.Errorf("create user: %w", err) + } + + return user, nil +} + +// GetUser retrieves a user by username +func (s *UserStore) GetUser(username string) (*UserInfo, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + user, exists := s.users[username] + if !exists { + return nil, ErrUserNotFound + } + + return user, nil +} + +// GetUserByToken retrieves a user by token +func (s *UserStore) GetUserByToken(token string) (*UserInfo, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + username, exists := s.tokens[token] + if !exists { + return nil, ErrUserNotFound + } + + return s.users[username], nil +} + +// AuthenticateUser authenticates a user with username and password +func (s *UserStore) AuthenticateUser(username, password string) (*UserInfo, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + user, exists := s.users[username] + + // To prevent timing attacks, always perform a bcrypt comparison + // Use the actual user's hash if they exist, otherwise generate one on-the-fly + var hashToCompare string + if exists && user.Active { + hashToCompare = user.PasswordHash + } else { + // Generate a bcrypt hash on-the-fly for non-existent/inactive users + // This ensures we can't be distinguished even with code inspection + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + // Fallback: at minimum still do some CPU work + _ = bcrypt.CompareHashAndPassword([]byte("$2a$10$AAAAAAAAAAAAAAAAAAAAAO4Z8K6cF3YYPPVYJJJJJJJJJJJJJJJJJa"), []byte(password)) + return nil, ErrUnauthorized + } + hashToCompare = string(hash) + } + + // Always perform the comparison + valid := verifyPassword(password, hashToCompare) + + // Only return success if user exists, is active, and password matches + if exists && user.Active && valid { + return user, nil + } + + return nil, ErrUnauthorized +} + +// UpdatePassword updates a user's password +func (s *UserStore) UpdatePassword(username, newPassword string) error { + s.mu.Lock() + defer s.mu.Unlock() + + user, exists := s.users[username] + if !exists { + return ErrUserNotFound + } + + hashed, err := hashPassword(newPassword) + if err != nil { + return fmt.Errorf("update password: %w", err) + } + user.PasswordHash = hashed + user.UpdatedAt = time.Now().UTC() + + return s.save() +} + +// RegenerateToken generates a new token for a user +func (s *UserStore) RegenerateToken(username string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + user, exists := s.users[username] + if !exists { + return "", ErrUserNotFound + } + + // Capture old token for rollback + oldToken := user.Token + + // Remove old token + delete(s.tokens, oldToken) + + // Generate new token (ensure uniqueness) + var newToken string + const maxRetries = 10 + for i := 0; i < maxRetries; i++ { + cand, err := generateToken() + if err != nil { + return "", fmt.Errorf("regenerate token: %w", err) + } + if _, taken := s.tokens[cand]; !taken { + newToken = cand + break + } + } + if newToken == "" { + // Re-add old token if new one couldn't be generated + s.tokens[oldToken] = username + return "", errors.New("failed to generate unique token after retries") + } + user.Token = newToken + user.UpdatedAt = time.Now().UTC() + s.tokens[newToken] = username + + if err := s.save(); err != nil { + // Rollback on save failure: restore old token + delete(s.tokens, newToken) + user.Token = oldToken + s.tokens[oldToken] = username + return "", fmt.Errorf("regenerate token: %w", err) + } + + return newToken, nil +} + +// SetPermission sets a user's permission for a repository +func (s *UserStore) SetPermission(username, repo string, perm Permission) error { + s.mu.Lock() + defer s.mu.Unlock() + + user, exists := s.users[username] + if !exists { + return ErrUserNotFound + } + + if !isValidPermission(perm) { + return ErrInvalidPermission + } + + // Normalize repository name for consistent storage + normalizedRepo := normalizeRepo(repo) + if perm == PermissionNone { + delete(user.Permissions, normalizedRepo) + } else { + user.Permissions[normalizedRepo] = perm + } + + user.UpdatedAt = time.Now().UTC() + + return s.save() +} + +// GetPermission gets a user's permission for a repository +func (s *UserStore) GetPermission(username, repo string) Permission { + s.mu.RLock() + defer s.mu.RUnlock() + + user, exists := s.users[username] + if !exists { + return PermissionNone + } + + if !user.Active { + return PermissionNone + } + + if user.GlobalAdmin { + return PermissionAdmin + } + + // Normalize repository name for consistent lookup + normalizedRepo := normalizeRepo(repo) + if perm, ok := user.Permissions[normalizedRepo]; ok { + return perm + } + + return PermissionNone +} + +// SetGlobalAdmin sets or removes global admin status +func (s *UserStore) SetGlobalAdmin(username string, isAdmin bool) error { + s.mu.Lock() + defer s.mu.Unlock() + + user, exists := s.users[username] + if !exists { + return ErrUserNotFound + } + + user.GlobalAdmin = isAdmin + user.UpdatedAt = time.Now().UTC() + + return s.save() +} + +// SetActive enables or disables a user account +func (s *UserStore) SetActive(username string, active bool) error { + s.mu.Lock() + defer s.mu.Unlock() + + user, exists := s.users[username] + if !exists { + return ErrUserNotFound + } + + user.Active = active + user.UpdatedAt = time.Now().UTC() + + return s.save() +} + +// DeleteUser deletes a user account +func (s *UserStore) DeleteUser(username string) error { + s.mu.Lock() + defer s.mu.Unlock() + + user, exists := s.users[username] + if !exists { + return ErrUserNotFound + } + + userCopy := *user // Preserve for rollback + delete(s.tokens, user.Token) + delete(s.users, username) + + if err := s.save(); err != nil { + s.users[username] = &userCopy + s.tokens[userCopy.Token] = username + return err + } + return nil +} + +// ListUsers returns all users +func (s *UserStore) ListUsers() []*UserInfo { + s.mu.RLock() + defer s.mu.RUnlock() + + users := make([]*UserInfo, 0, len(s.users)) + for _, user := range s.users { + users = append(users, user) + } + + return users +} + +// CheckAccess checks if a user has the required permission for a repository +func (s *UserStore) CheckAccess(username, repo string, required Permission) bool { + perm := s.GetPermission(username, repo) + return hasPermission(perm, required) +} + +// load loads users from file +func (s *UserStore) load() error { + data, err := os.ReadFile(s.filePath) + if err != nil { + return err + } + + var users []*UserInfo + if err := json.Unmarshal(data, &users); err != nil { + return fmt.Errorf("unmarshal users: %w", err) + } + + for _, user := range users { + s.users[user.Username] = user + s.tokens[user.Token] = user.Username + } + + return nil +} + +// save saves users to file +func (s *UserStore) save() error { + users := make([]*UserInfo, 0, len(s.users)) + for _, user := range s.users { + users = append(users, user) + } + + data, err := json.MarshalIndent(users, "", " ") + if err != nil { + return fmt.Errorf("marshal users: %w", err) + } + + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(s.filePath), 0700); err != nil { + return fmt.Errorf("create directory: %w", err) + } + + // Write atomically + tmpFile := s.filePath + ".tmp" + if err := os.WriteFile(tmpFile, data, 0600); err != nil { + return fmt.Errorf("write temp file: %w", err) + } + + if err := os.Rename(tmpFile, s.filePath); err != nil { + return fmt.Errorf("rename file: %w", err) + } + + return nil +} + +// Helper functions + +// normalizeRepo ensures repository names have a consistent format by appending ".git" if missing +func normalizeRepo(repo string) string { + return NormalizeRepo(repo) +} + +func hashPassword(password string) (string, error) { + h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", fmt.Errorf("hash password: %w", err) + } + return string(h), nil +} + +func verifyPassword(password, hash string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} + +func generateToken() (string, error) { + b := make([]byte, 32) // 256-bit + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate token: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func isValidPermission(perm Permission) bool { + switch perm { + case PermissionNone, PermissionRead, PermissionWrite, PermissionAdmin: + return true + default: + return false + } +} + +func hasPermission(user, required Permission) bool { + levels := map[Permission]int{ + PermissionNone: 0, + PermissionRead: 1, + PermissionWrite: 2, + PermissionAdmin: 3, + } + + return levels[user] >= levels[required] +} diff --git a/internal/server/handler.go b/internal/server/handler.go index a458678..7266d0d 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -9,7 +9,7 @@ import ( "fmt" "io" "net/http" - "path" + "path/filepath" "strings" "time" @@ -30,14 +30,17 @@ type GitHandler struct { } // NewGitHandler creates a new Git handler -func NewGitHandler(store storage.Store, logger *log.Logger, cfg *config.Config) *GitHandler { - authenticator, _ := auth.NewAuthenticator(cfg.Auth) +func NewGitHandler(store storage.Store, logger *log.Logger, cfg *config.Config) (*GitHandler, error) { + authenticator, err := auth.NewAuthenticator(cfg.Auth, cfg.Storage.Root) + if err != nil { + return nil, fmt.Errorf("auth init: %w", err) + } return &GitHandler{ store: store, logger: logger, cfg: cfg, auth: authenticator, - } + }, nil } func (h *GitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -67,6 +70,17 @@ func (h *GitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } + // Enforce per-repo permissions (allows anonymous read when configured) + if err := h.auth.RequirePermission(r.Context(), repoName, service); err != nil { + if errors.Is(err, auth.ErrUnauthorized) { + w.Header().Set("WWW-Authenticate", `Basic realm="workgit"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + // Get repository path repoPath, err := h.store.Path(r.Context(), repoName) if err != nil { @@ -196,7 +210,7 @@ func (h *GitHandler) handleService(w http.ResponseWriter, r *http.Request, repoP case "git-receive-pack": // Lock repository for receive-pack - unlock, lockErr := h.store.Lock(ctx, path.Base(repoPath)) + unlock, lockErr := h.store.Lock(ctx, filepath.Base(repoPath)) if lockErr != nil { http.Error(w, "Failed to acquire lock", http.StatusServiceUnavailable) return diff --git a/internal/server/server.go b/internal/server/server.go index 682c32e..6c05c7c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -34,7 +34,7 @@ type Server struct { // New creates a new server func New(cfg *config.Config, store storage.Store, logger *log.Logger) (*Server, error) { - authenticator, err := auth.NewAuthenticator(cfg.Auth) + authenticator, err := auth.NewAuthenticator(cfg.Auth, cfg.Storage.Root) if err != nil { return nil, fmt.Errorf("initialize authenticator: %w", err) } @@ -47,7 +47,9 @@ func New(cfg *config.Config, store storage.Store, logger *log.Logger) (*Server, } mux := http.NewServeMux() - s.registerRoutes(mux) + if err := s.registerRoutes(mux); err != nil { + return nil, fmt.Errorf("register routes: %w", err) + } s.http = &http.Server{ Addr: cfg.Server.Addr, @@ -75,7 +77,7 @@ func New(cfg *config.Config, store storage.Store, logger *log.Logger) (*Server, } // registerRoutes registers HTTP routes -func (s *Server) registerRoutes(mux *http.ServeMux) { +func (s *Server) registerRoutes(mux *http.ServeMux) error { // Health endpoints mux.HandleFunc("/health/live", health.LivenessHandler) mux.HandleFunc("/health/ready", health.ReadinessHandler(s.store)) @@ -86,10 +88,14 @@ func (s *Server) registerRoutes(mux *http.ServeMux) { } // Git Smart HTTP endpoints - handler := NewGitHandler(s.store, s.logger, s.cfg) + handler, err := NewGitHandler(s.store, s.logger, s.cfg) + if err != nil { + return fmt.Errorf("create Git handler: %w", err) + } // Info refs (discovery) mux.Handle("/", s.wrapHandler(handler)) + return nil } // wrapHandler wraps handler with middleware @@ -99,6 +105,7 @@ func (s *Server) wrapHandler(h http.Handler) http.Handler { h = s.metricsMiddleware(h) h = s.rateLimitMiddleware(h) h = s.requestIDMiddleware(h) + h = s.auth.Middleware(false)(h) return h }