From 160cdf7e07ba58a07b828dc2fc1eb3826442c93c Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Thu, 23 Oct 2025 22:37:32 +0300 Subject: [PATCH 01/24] fix: remove extra blank lines in README.md --- README.md | 2 -- 1 file changed, 2 deletions(-) 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 From 8f8ada7d1ce559143f3a024ae60b85cbab33a618 Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Thu, 23 Oct 2025 22:37:47 +0300 Subject: [PATCH 02/24] feat(docker): mount users directory in docker-compose --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) 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 From 10d2c65791ac370fd286435cfd572340517ebef2 Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Thu, 23 Oct 2025 22:38:16 +0300 Subject: [PATCH 03/24] feat(auth): implement user management system with permissions (#8) --- internal/auth/users.go | 398 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 398 insertions(+) create mode 100644 internal/auth/users.go diff --git a/internal/auth/users.go b/internal/auth/users.go new file mode 100644 index 0000000..ac66acb --- /dev/null +++ b/internal/auth/users.go @@ -0,0 +1,398 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 LuaGuard Softworks + +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +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) { + store := &UserStore{ + users: make(map[string]*UserInfo), + tokens: make(map[string]string), + filePath: filepath.Join(storagePath, "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 + } + + user := &UserInfo{ + Username: username, + PasswordHash: hashPassword(password), + Token: generateToken(), + Email: email, + FullName: fullName, + Active: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Permissions: make(map[string]Permission), + GlobalAdmin: false, + } + + s.users[username] = user + s.tokens[user.Token] = username + + if err := s.save(); err != nil { + return nil, 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] + if !exists { + return nil, ErrUserNotFound + } + + if !user.Active { + return nil, errors.New("user account is disabled") + } + + if !verifyPassword(password, user.PasswordHash) { + return nil, ErrUnauthorized + } + + return user, nil +} + +// 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 + } + + user.PasswordHash = hashPassword(newPassword) + user.UpdatedAt = time.Now() + + 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 + } + + // Remove old token + delete(s.tokens, user.Token) + + // Generate new token + newToken := generateToken() + user.Token = newToken + user.UpdatedAt = time.Now() + + s.tokens[newToken] = username + + if err := s.save(); err != nil { + return "", 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 + } + + if perm == PermissionNone { + delete(user.Permissions, repo) + } else { + user.Permissions[repo] = perm + } + + user.UpdatedAt = time.Now() + + 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 + } + + if perm, ok := user.Permissions[repo]; 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() + + 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() + + 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 + } + + delete(s.tokens, user.Token) + delete(s.users, username) + + return s.save() +} + +// 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 + +func hashPassword(password string) string { + hash := sha256.Sum256([]byte(password)) + return base64.StdEncoding.EncodeToString(hash[:]) +} + +func verifyPassword(password, hash string) bool { + computed := hashPassword(password) + return subtle.ConstantTimeCompare([]byte(computed), []byte(hash)) == 1 +} + +func generateToken() string { + b := make([]byte, 32) + rand.Read(b) + return base64.URLEncoding.EncodeToString(b) +} + +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] +} From 095794797fff3cad29ec7b5226be5420828a5071 Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Thu, 23 Oct 2025 22:39:03 +0300 Subject: [PATCH 04/24] feat(auth): enhance user authentication with user store and permission checks (#8) --- internal/auth/auth.go | 134 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 120 insertions(+), 14 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index b5a6875..2972ff6 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -30,23 +30,34 @@ 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) { +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 != "" { + userStore, err := NewUserStore(storagePath) + 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 +105,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 +121,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 +137,66 @@ 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) { + return &User{ + ID: username, + Username: username, + }, nil } } return nil, ErrUnauthorized } -// validateToken validates an API token +// 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:" + token[:8], + }, nil + } + + return nil, ErrUnauthorized +} + +// validateToken validates an API token (legacy) func (a *Authenticator) validateToken(token string) bool { if len(a.tokens) == 0 { return false @@ -185,6 +246,14 @@ func UserFromContext(ctx context.Context) (*User, bool) { return user, ok } +// 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 + } + return a.userStore.CheckAccess(username, repo, 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 +269,38 @@ 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 + } + + if !a.CheckPermission(user.Username, repo, 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 +316,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 +} From ea973a1e4dadc7476e63d56574744ae9d1fd3372 Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Thu, 23 Oct 2025 22:39:18 +0300 Subject: [PATCH 05/24] fix(auth): pass storage root path to authenticator initialization (#8) --- internal/server/handler.go | 2 +- internal/server/server.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/server/handler.go b/internal/server/handler.go index a458678..2c2c878 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -31,7 +31,7 @@ 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) + authenticator, _ := auth.NewAuthenticator(cfg.Auth, cfg.Storage.Root) return &GitHandler{ store: store, logger: logger, diff --git a/internal/server/server.go b/internal/server/server.go index 682c32e..a380e18 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) } From c756ee63662dfffa79d44bbbba14ceb879c23013 Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Thu, 23 Oct 2025 22:39:40 +0300 Subject: [PATCH 06/24] chore(deps): add golang.org/x/term and update x/sys to v0.37.0 --- go.mod | 3 ++- go.sum | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ad6af44..ee2ee4e 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.1 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,6 @@ 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/sys v0.37.0 // indirect google.golang.org/protobuf v1.36.8 // indirect ) diff --git a/go.sum b/go.sum index 5bb3b03..bc27fb8 100644 --- a/go.sum +++ b/go.sum @@ -43,8 +43,10 @@ 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/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= From f9ddfdc57a88812b23c3fb3085a89d291789b3ed Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Thu, 23 Oct 2025 22:39:51 +0300 Subject: [PATCH 07/24] feat(user): add comprehensive user management CLI commands --- cmd/workgit/user.go | 432 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 432 insertions(+) create mode 100644 cmd/workgit/user.go diff --git a/cmd/workgit/user.go b/cmd/workgit/user.go new file mode 100644 index 0000000..bee8bab --- /dev/null +++ b/cmd/workgit/user.go @@ -0,0 +1,432 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 LuaGuard Softworks + +package main + +import ( + "fmt" + "os" + "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") + } + + userStore, err := auth.NewUserStore(cfg.Storage.Root) + 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) + } + + userStore, err := auth.NewUserStore(cfg.Storage.Root) + 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 + } + } + + userStore, err := auth.NewUserStore(cfg.Storage.Root) + 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) + } + + userStore, err := auth.NewUserStore(cfg.Storage.Root) + if err != nil { + return fmt.Errorf("initialize user store: %w", err) + } + + if err := userStore.SetPermission(username, repo, perm); err != nil { + return fmt.Errorf("set permission: %w", err) + } + + if jsonOutput { + fmt.Printf(`{"status":"updated","username":"%s","repo":"%s","permission":"%s"}%s`, + username, repo, perm, "\n") + } else { + fmt.Printf("Permission set: %s -> %s (%s)\n", username, repo, 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] + + userStore, err := auth.NewUserStore(cfg.Storage.Root) + 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" + + userStore, err := auth.NewUserStore(cfg.Storage.Root) + 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") + } + + userStore, err := auth.NewUserStore(cfg.Storage.Root) + 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] + + userStore, err := auth.NewUserStore(cfg.Storage.Root) + 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" +} From 8d0835da514a99a1a40245c00257d3bd5c454e45 Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Thu, 23 Oct 2025 23:10:32 +0300 Subject: [PATCH 08/24] fix(auth): update authenticator initialization to use users directory (#9) --- internal/server/handler.go | 4 +++- internal/server/server.go | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/server/handler.go b/internal/server/handler.go index 2c2c878..a924909 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -10,6 +10,7 @@ import ( "io" "net/http" "path" + "path/filepath" "strings" "time" @@ -31,7 +32,8 @@ 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, cfg.Storage.Root) + usersDir := filepath.Join(cfg.Storage.Root, "users") + authenticator, _ := auth.NewAuthenticator(cfg.Auth, usersDir) return &GitHandler{ store: store, logger: logger, diff --git a/internal/server/server.go b/internal/server/server.go index a380e18..1cb983e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "syscall" "time" @@ -34,7 +35,8 @@ 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, cfg.Storage.Root) + usersDir := filepath.Join(cfg.Storage.Root, "users") + authenticator, err := auth.NewAuthenticator(cfg.Auth, usersDir) if err != nil { return nil, fmt.Errorf("initialize authenticator: %w", err) } From c585667958ea221d499a697645de428fbeb7d1c5 Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Thu, 23 Oct 2025 23:17:03 +0300 Subject: [PATCH 09/24] feat(auth): ensure users.json is stored in dedicated users directory --- internal/auth/auth.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 2972ff6..29d3ef0 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" @@ -42,6 +43,9 @@ type Authenticator struct { } // NewAuthenticator creates a new authenticator +// 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, @@ -50,7 +54,10 @@ func NewAuthenticator(cfg config.AuthConfig, storagePath string) (*Authenticator // Initialize user store if storage path provided if storagePath != "" { - userStore, err := NewUserStore(storagePath) + // Ensure users.json is created inside a users directory + // to prevent it from being created outside the intended directory + userStorePath := filepath.Join(storagePath, "users") + userStore, err := NewUserStore(userStorePath) if err != nil { return nil, fmt.Errorf("initialize user store: %w", err) } From a3b940f1c7fd5f8f735897ea676761e123dd958a Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Thu, 23 Oct 2025 23:20:10 +0300 Subject: [PATCH 10/24] fix(auth): standardize token-based user ID generation with helper --- internal/auth/auth.go | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 29d3ef0..c86a8ba 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -170,16 +170,21 @@ func (a *Authenticator) authenticate(r *http.Request) (*User, error) { // Fall back to legacy token validation if a.validateToken(password) { - return &User{ - ID: username, - Username: username, - }, nil + 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 @@ -195,9 +200,7 @@ func (a *Authenticator) authenticateToken(token string) (*User, error) { // Fall back to legacy token validation if a.validateToken(token) { - return &User{ - ID: "token:" + token[:8], - }, nil + return &User{ID: "token:" + shortTokenPrefix(token)}, nil } return nil, ErrUnauthorized From 71398320cca0413bc75e27c609d32201cfa438a6 Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Thu, 23 Oct 2025 23:25:31 +0300 Subject: [PATCH 11/24] chore(deps): add golang.org/x/crypto dependency for secure operations --- go.mod | 1 + go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go.mod b/go.mod index ee2ee4e..f001fc4 100644 --- a/go.mod +++ b/go.mod @@ -21,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/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 bc27fb8..7e84177 100644 --- a/go.sum +++ b/go.sum @@ -43,6 +43,8 @@ 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/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= From 999b23e3f116b4453ac7ef651a91568d513b245b Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Thu, 23 Oct 2025 23:25:41 +0300 Subject: [PATCH 12/24] feat(auth): upgrade password hashing to bcrypt and improve token generation --- internal/auth/users.go | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/internal/auth/users.go b/internal/auth/users.go index ac66acb..4f19766 100644 --- a/internal/auth/users.go +++ b/internal/auth/users.go @@ -5,8 +5,6 @@ package auth import ( "crypto/rand" - "crypto/sha256" - "crypto/subtle" "encoding/base64" "encoding/json" "errors" @@ -15,6 +13,8 @@ import ( "path/filepath" "sync" "time" + + "golang.org/x/crypto/bcrypt" ) var ( @@ -362,19 +362,22 @@ func (s *UserStore) save() error { // Helper functions func hashPassword(password string) string { - hash := sha256.Sum256([]byte(password)) - return base64.StdEncoding.EncodeToString(hash[:]) + h, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(h) } func verifyPassword(password, hash string) bool { - computed := hashPassword(password) - return subtle.ConstantTimeCompare([]byte(computed), []byte(hash)) == 1 + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil } func generateToken() string { b := make([]byte, 32) - rand.Read(b) - return base64.URLEncoding.EncodeToString(b) + if _, err := rand.Read(b); err != nil { + // extremely unlikely; fall back to time-based entropy + ts := time.Now().UnixNano() + return base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%d", ts))) + } + return base64.RawURLEncoding.EncodeToString(b) } func isValidPermission(perm Permission) bool { From 9a3ab031bf53d83da7e3e4192c7c9de291756dd8 Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Thu, 23 Oct 2025 23:27:26 +0300 Subject: [PATCH 13/24] feat(server): return error from NewGitHandler for proper auth init --- internal/server/handler.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/server/handler.go b/internal/server/handler.go index a924909..47df538 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -31,15 +31,18 @@ type GitHandler struct { } // NewGitHandler creates a new Git handler -func NewGitHandler(store storage.Store, logger *log.Logger, cfg *config.Config) *GitHandler { +func NewGitHandler(store storage.Store, logger *log.Logger, cfg *config.Config) (*GitHandler, error) { usersDir := filepath.Join(cfg.Storage.Root, "users") - authenticator, _ := auth.NewAuthenticator(cfg.Auth, usersDir) + authenticator, err := auth.NewAuthenticator(cfg.Auth, usersDir) + 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) { From 715944aecdb240d7ae6f8fe36096259e7d95bdc3 Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Fri, 24 Oct 2025 00:02:56 +0300 Subject: [PATCH 14/24] fix(auth): handle hash error when creating users Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- internal/auth/users.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/auth/users.go b/internal/auth/users.go index 4f19766..7819b3b 100644 --- a/internal/auth/users.go +++ b/internal/auth/users.go @@ -79,15 +79,19 @@ func (s *UserStore) CreateUser(username, password, email, fullName string) (*Use return nil, ErrUserAlreadyExists } + hashed, err := hashPassword(password) + if err != nil { + return nil, fmt.Errorf("create user: %w", err) + } user := &UserInfo{ Username: username, - PasswordHash: hashPassword(password), + PasswordHash: hashed, Token: generateToken(), Email: email, FullName: fullName, Active: true, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), Permissions: make(map[string]Permission), GlobalAdmin: false, } From 27eecb3c0ff9eefc469ab1cec3a8a1ba4241b45c Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Fri, 24 Oct 2025 00:12:38 +0300 Subject: [PATCH 15/24] **chore(deps): downgrade Go version from 1.25.1 to 1.25 (#9)** --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index f001fc4..1a15e91 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/lluaguard/workgit -go 1.25.1 +go 1.25 require ( github.com/prometheus/client_golang v1.23.2 From 95b510e38c05955973352fc5d1a5afb8680594d4 Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Fri, 24 Oct 2025 00:13:01 +0300 Subject: [PATCH 16/24] fix(auth): prevent redundant "users" dir in storage path (#9) --- internal/auth/auth.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index c86a8ba..96163c4 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -54,9 +54,12 @@ func NewAuthenticator(cfg config.AuthConfig, storagePath string) (*Authenticator // Initialize user store if storage path provided if storagePath != "" { - // Ensure users.json is created inside a users directory - // to prevent it from being created outside the intended directory - userStorePath := filepath.Join(storagePath, "users") + // 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) From cc79ed18b9da49a4209e0b01e326e2eaaa251f76 Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Fri, 24 Oct 2025 00:13:12 +0300 Subject: [PATCH 17/24] fix(auth): improve password hashing and timestamp consistency (#9) --- internal/auth/users.go | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/internal/auth/users.go b/internal/auth/users.go index 7819b3b..e2b413c 100644 --- a/internal/auth/users.go +++ b/internal/auth/users.go @@ -163,8 +163,12 @@ func (s *UserStore) UpdatePassword(username, newPassword string) error { return ErrUserNotFound } - user.PasswordHash = hashPassword(newPassword) - user.UpdatedAt = time.Now() + 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() } @@ -182,11 +186,17 @@ func (s *UserStore) RegenerateToken(username string) (string, error) { // Remove old token delete(s.tokens, user.Token) - // Generate new token - newToken := generateToken() + // Generate new token (ensure uniqueness) + var newToken string + for { + cand := generateToken() + if _, taken := s.tokens[cand]; !taken { + newToken = cand + break + } + } user.Token = newToken - user.UpdatedAt = time.Now() - + user.UpdatedAt = time.Now().UTC() s.tokens[newToken] = username if err := s.save(); err != nil { @@ -216,7 +226,7 @@ func (s *UserStore) SetPermission(username, repo string, perm Permission) error user.Permissions[repo] = perm } - user.UpdatedAt = time.Now() + user.UpdatedAt = time.Now().UTC() return s.save() } @@ -257,7 +267,7 @@ func (s *UserStore) SetGlobalAdmin(username string, isAdmin bool) error { } user.GlobalAdmin = isAdmin - user.UpdatedAt = time.Now() + user.UpdatedAt = time.Now().UTC() return s.save() } @@ -273,7 +283,7 @@ func (s *UserStore) SetActive(username string, active bool) error { } user.Active = active - user.UpdatedAt = time.Now() + user.UpdatedAt = time.Now().UTC() return s.save() } @@ -365,9 +375,12 @@ func (s *UserStore) save() error { // Helper functions -func hashPassword(password string) string { - h, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - return string(h) +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 { From c3ccb8f0deed2d524f9f866156f143af87a2e840 Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Fri, 24 Oct 2025 00:13:24 +0300 Subject: [PATCH 18/24] fix: enforce per-repo permissions and use filepath.Base for lock (#9) --- internal/server/handler.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/server/handler.go b/internal/server/handler.go index 47df538..2460b8f 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "net/http" - "path" "path/filepath" "strings" "time" @@ -72,6 +71,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 { @@ -201,7 +211,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 From 8d8a88f2cc148ffc6d320d04b3ea26f3785d56af Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Fri, 24 Oct 2025 00:13:32 +0300 Subject: [PATCH 19/24] fix: handle GitHandler initialization error in route registration (#9) --- internal/server/server.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/server/server.go b/internal/server/server.go index 1cb983e..3618b02 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -88,7 +88,11 @@ 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 { + fmt.Errorf("failed to create Git handler: %w", err) + return + } // Info refs (discovery) mux.Handle("/", s.wrapHandler(handler)) From 56e4426f60a79a862a63287a3deeb3500441bf64 Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Fri, 24 Oct 2025 00:34:11 +0300 Subject: [PATCH 20/24] fix(user): normalize repository names in permission setting --- cmd/workgit/user.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cmd/workgit/user.go b/cmd/workgit/user.go index bee8bab..747c4ad 100644 --- a/cmd/workgit/user.go +++ b/cmd/workgit/user.go @@ -257,15 +257,17 @@ func runUserSetPermission(cmd *cobra.Command, args []string) error { return fmt.Errorf("initialize user store: %w", err) } - if err := userStore.SetPermission(username, repo, perm); err != nil { + // 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, repo, perm, "\n") + username, normalizedRepo, perm, "\n") } else { - fmt.Printf("Permission set: %s -> %s (%s)\n", username, repo, perm) + fmt.Printf("Permission set: %s -> %s (%s)\n", username, normalizedRepo, perm) } return nil From c842fb9a91dc2c323233ca27b34e4ab2b049d75a Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Fri, 24 Oct 2025 00:34:25 +0300 Subject: [PATCH 21/24] feat(auth): normalize repository names with .git suffix --- internal/auth/auth.go | 16 ++++++++++++++-- internal/auth/users.go | 15 ++++++++++++--- internal/server/handler.go | 3 +-- internal/server/server.go | 4 +--- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 96163c4..60a3681 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -259,12 +259,22 @@ 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 } - return a.userStore.CheckAccess(username, repo, required) + // 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 @@ -307,7 +317,9 @@ func (a *Authenticator) RequirePermission(ctx context.Context, repo, service str return ErrForbidden } - if !a.CheckPermission(user.Username, repo, required) { + // Normalize repository name for consistent lookup + normalizedRepo := NormalizeRepo(repo) + if !a.CheckPermission(user.Username, normalizedRepo, required) { return ErrForbidden } diff --git a/internal/auth/users.go b/internal/auth/users.go index e2b413c..eb73689 100644 --- a/internal/auth/users.go +++ b/internal/auth/users.go @@ -220,10 +220,12 @@ func (s *UserStore) SetPermission(username, repo string, perm Permission) error return ErrInvalidPermission } + // Normalize repository name for consistent storage + normalizedRepo := normalizeRepo(repo) if perm == PermissionNone { - delete(user.Permissions, repo) + delete(user.Permissions, normalizedRepo) } else { - user.Permissions[repo] = perm + user.Permissions[normalizedRepo] = perm } user.UpdatedAt = time.Now().UTC() @@ -249,7 +251,9 @@ func (s *UserStore) GetPermission(username, repo string) Permission { return PermissionAdmin } - if perm, ok := user.Permissions[repo]; ok { + // Normalize repository name for consistent lookup + normalizedRepo := normalizeRepo(repo) + if perm, ok := user.Permissions[normalizedRepo]; ok { return perm } @@ -375,6 +379,11 @@ func (s *UserStore) save() error { // 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 { diff --git a/internal/server/handler.go b/internal/server/handler.go index 2460b8f..7266d0d 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -31,8 +31,7 @@ type GitHandler struct { // NewGitHandler creates a new Git handler func NewGitHandler(store storage.Store, logger *log.Logger, cfg *config.Config) (*GitHandler, error) { - usersDir := filepath.Join(cfg.Storage.Root, "users") - authenticator, err := auth.NewAuthenticator(cfg.Auth, usersDir) + authenticator, err := auth.NewAuthenticator(cfg.Auth, cfg.Storage.Root) if err != nil { return nil, fmt.Errorf("auth init: %w", err) } diff --git a/internal/server/server.go b/internal/server/server.go index 3618b02..4166199 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -11,7 +11,6 @@ import ( "net/http" "os" "os/signal" - "path/filepath" "syscall" "time" @@ -35,8 +34,7 @@ type Server struct { // New creates a new server func New(cfg *config.Config, store storage.Store, logger *log.Logger) (*Server, error) { - usersDir := filepath.Join(cfg.Storage.Root, "users") - authenticator, err := auth.NewAuthenticator(cfg.Auth, usersDir) + authenticator, err := auth.NewAuthenticator(cfg.Auth, cfg.Storage.Root) if err != nil { return nil, fmt.Errorf("initialize authenticator: %w", err) } From 407fccc9c9a8359ac3534a20f00f7bd56d0607fe Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Fri, 24 Oct 2025 13:36:50 +0300 Subject: [PATCH 22/24] feat(auth): ensure unique tokens and dedicated users directory (#9) - Add `path/filepath` import to handle path manipulation - Modify user store initialization to use a dedicated "users" subdirectory for storage, ensuring consistent path structure - Update token generation to guarantee uniqueness by: - Implementing retry logic (max 10 attempts) - Verifying token doesn't already exist in the store - Returning error if unique token cannot be generated - Apply changes consistently across all user management functions: `Create`, `List`, `Delete`, `SetPermission`, `Show`, `SetAdmin`, `ResetPassword`, and `RegenerateToken` --- cmd/workgit/user.go | 25 +++++++++----- internal/auth/users.go | 71 ++++++++++++++++++++++++++------------- internal/server/server.go | 11 +++--- 3 files changed, 71 insertions(+), 36 deletions(-) diff --git a/cmd/workgit/user.go b/cmd/workgit/user.go index 747c4ad..569587d 100644 --- a/cmd/workgit/user.go +++ b/cmd/workgit/user.go @@ -6,6 +6,7 @@ package main import ( "fmt" "os" + "path/filepath" "strings" "syscall" "text/tabwriter" @@ -127,7 +128,8 @@ func runUserCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("passwords do not match") } - userStore, err := auth.NewUserStore(cfg.Storage.Root) + usersDir := filepath.Join(cfg.Storage.Root, "users") + userStore, err := auth.NewUserStore(usersDir) if err != nil { return fmt.Errorf("initialize user store: %w", err) } @@ -161,7 +163,8 @@ func runUserList(cmd *cobra.Command, args []string) error { return fmt.Errorf("load config: %w", err) } - userStore, err := auth.NewUserStore(cfg.Storage.Root) + usersDir := filepath.Join(cfg.Storage.Root, "users") + userStore, err := auth.NewUserStore(usersDir) if err != nil { return fmt.Errorf("initialize user store: %w", err) } @@ -219,7 +222,8 @@ func runUserDelete(cmd *cobra.Command, args []string) error { } } - userStore, err := auth.NewUserStore(cfg.Storage.Root) + usersDir := filepath.Join(cfg.Storage.Root, "users") + userStore, err := auth.NewUserStore(usersDir) if err != nil { return fmt.Errorf("initialize user store: %w", err) } @@ -252,7 +256,8 @@ func runUserSetPermission(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid permission: %s (use: read, write, admin, none)", permStr) } - userStore, err := auth.NewUserStore(cfg.Storage.Root) + usersDir := filepath.Join(cfg.Storage.Root, "users") + userStore, err := auth.NewUserStore(usersDir) if err != nil { return fmt.Errorf("initialize user store: %w", err) } @@ -281,7 +286,8 @@ func runUserShow(cmd *cobra.Command, args []string) error { username := args[0] - userStore, err := auth.NewUserStore(cfg.Storage.Root) + usersDir := filepath.Join(cfg.Storage.Root, "users") + userStore, err := auth.NewUserStore(usersDir) if err != nil { return fmt.Errorf("initialize user store: %w", err) } @@ -328,7 +334,8 @@ func runUserSetAdmin(cmd *cobra.Command, args []string) error { username := args[0] isAdmin := args[1] == "true" - userStore, err := auth.NewUserStore(cfg.Storage.Root) + usersDir := filepath.Join(cfg.Storage.Root, "users") + userStore, err := auth.NewUserStore(usersDir) if err != nil { return fmt.Errorf("initialize user store: %w", err) } @@ -372,7 +379,8 @@ func runUserResetPassword(cmd *cobra.Command, args []string) error { return fmt.Errorf("passwords do not match") } - userStore, err := auth.NewUserStore(cfg.Storage.Root) + usersDir := filepath.Join(cfg.Storage.Root, "users") + userStore, err := auth.NewUserStore(usersDir) if err != nil { return fmt.Errorf("initialize user store: %w", err) } @@ -398,7 +406,8 @@ func runUserRegenerateToken(cmd *cobra.Command, args []string) error { username := args[0] - userStore, err := auth.NewUserStore(cfg.Storage.Root) + usersDir := filepath.Join(cfg.Storage.Root, "users") + userStore, err := auth.NewUserStore(usersDir) if err != nil { return fmt.Errorf("initialize user store: %w", err) } diff --git a/internal/auth/users.go b/internal/auth/users.go index eb73689..021e2b8 100644 --- a/internal/auth/users.go +++ b/internal/auth/users.go @@ -57,16 +57,18 @@ type UserStore struct { // 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(storagePath, "users.json"), + 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 } @@ -83,10 +85,28 @@ func (s *UserStore) CreateUser(username, password, email, fullName string) (*Use 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: generateToken(), + Token: token, Email: email, FullName: fullName, Active: true, @@ -97,10 +117,10 @@ func (s *UserStore) CreateUser(username, password, email, fullName string) (*Use } s.users[username] = user - s.tokens[user.Token] = username + s.tokens[token] = username if err := s.save(); err != nil { - return nil, err + return nil, fmt.Errorf("create user: %w", err) } return user, nil @@ -138,15 +158,8 @@ func (s *UserStore) AuthenticateUser(username, password string) (*UserInfo, erro defer s.mu.RUnlock() user, exists := s.users[username] - if !exists { - return nil, ErrUserNotFound - } - - if !user.Active { - return nil, errors.New("user account is disabled") - } - - if !verifyPassword(password, user.PasswordHash) { + if !exists || !user.Active || !verifyPassword(password, user.PasswordHash) { + // Optional: run a dummy bcrypt compare here to flatten timing. return nil, ErrUnauthorized } @@ -188,19 +201,31 @@ func (s *UserStore) RegenerateToken(username string) (string, error) { // Generate new token (ensure uniqueness) var newToken string - for { - cand := generateToken() + 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[user.Token] = 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 { - return "", err + // Rollback on save failure + delete(s.tokens, newToken) + user.Token = "" // Will be invalid, but consistent + return "", fmt.Errorf("regenerate token: %w", err) } return newToken, nil @@ -396,14 +421,12 @@ func verifyPassword(password, hash string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil } -func generateToken() string { - b := make([]byte, 32) +func generateToken() (string, error) { + b := make([]byte, 32) // 256-bit if _, err := rand.Read(b); err != nil { - // extremely unlikely; fall back to time-based entropy - ts := time.Now().UnixNano() - return base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%d", ts))) + return "", fmt.Errorf("generate token: %w", err) } - return base64.RawURLEncoding.EncodeToString(b) + return base64.RawURLEncoding.EncodeToString(b), nil } func isValidPermission(perm Permission) bool { diff --git a/internal/server/server.go b/internal/server/server.go index 4166199..6c05c7c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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)) @@ -88,12 +90,12 @@ func (s *Server) registerRoutes(mux *http.ServeMux) { // Git Smart HTTP endpoints handler, err := NewGitHandler(s.store, s.logger, s.cfg) if err != nil { - fmt.Errorf("failed to create Git handler: %w", err) - return + return fmt.Errorf("create Git handler: %w", err) } // Info refs (discovery) mux.Handle("/", s.wrapHandler(handler)) + return nil } // wrapHandler wraps handler with middleware @@ -103,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 } From b3bae48bfb553553b09d7aab506a8f38f8c5f041 Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Fri, 24 Oct 2025 13:54:38 +0300 Subject: [PATCH 23/24] fix(auth): improve security and error handling in user operations (#9) - Add rollback mechanism in CreateUser and DeleteUser when save fails to prevent data inconsistency after failed operations - Implement constant-time password comparison in AuthenticateUser to prevent timing attacks by: * Always performing bcrypt comparison regardless of user existence * Using generated hash for non-existent/inactive users * Maintaining consistent execution time for all authentication attempts - Improve error handling with proper fallback in bcrypt operations to ensure minimum security guarantees even in edge cases --- internal/auth/users.go | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/internal/auth/users.go b/internal/auth/users.go index 021e2b8..3a86dea 100644 --- a/internal/auth/users.go +++ b/internal/auth/users.go @@ -120,6 +120,8 @@ func (s *UserStore) CreateUser(username, password, email, fullName string) (*Use 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) } @@ -158,12 +160,33 @@ func (s *UserStore) AuthenticateUser(username, password string) (*UserInfo, erro defer s.mu.RUnlock() user, exists := s.users[username] - if !exists || !user.Active || !verifyPassword(password, user.PasswordHash) { - // Optional: run a dummy bcrypt compare here to flatten timing. - return nil, ErrUnauthorized + + // 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) } - return user, nil + // 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 @@ -327,10 +350,16 @@ func (s *UserStore) DeleteUser(username string) error { return ErrUserNotFound } + userCopy := *user // Preserve for rollback delete(s.tokens, user.Token) delete(s.users, username) - return s.save() + if err := s.save(); err != nil { + s.users[username] = &userCopy + s.tokens[userCopy.Token] = username + return err + } + return nil } // ListUsers returns all users From 542ed22a6a695af68296023e7cfc90c147d03db1 Mon Sep 17 00:00:00 2001 From: tech <154914363+techplayz32@users.noreply.github.com> Date: Fri, 24 Oct 2025 14:00:03 +0300 Subject: [PATCH 24/24] fix(auth): improve token regeneration rollback safety (#9) The token regeneration process now properly preserves the old token value throughout the operation, ensuring complete rollback capability in case of failures. Previously, the rollback could leave the system in an inconsistent state by clearing the user's token instead of restoring the original one. Key changes: - Store old token value before modification - Use stored old token for all rollback operations - Ensure token map consistency during rollback - Maintain proper token association during failure scenarios --- internal/auth/users.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/auth/users.go b/internal/auth/users.go index 3a86dea..9082ecd 100644 --- a/internal/auth/users.go +++ b/internal/auth/users.go @@ -219,8 +219,11 @@ func (s *UserStore) RegenerateToken(username string) (string, error) { return "", ErrUserNotFound } + // Capture old token for rollback + oldToken := user.Token + // Remove old token - delete(s.tokens, user.Token) + delete(s.tokens, oldToken) // Generate new token (ensure uniqueness) var newToken string @@ -237,7 +240,7 @@ func (s *UserStore) RegenerateToken(username string) (string, error) { } if newToken == "" { // Re-add old token if new one couldn't be generated - s.tokens[user.Token] = username + s.tokens[oldToken] = username return "", errors.New("failed to generate unique token after retries") } user.Token = newToken @@ -245,9 +248,10 @@ func (s *UserStore) RegenerateToken(username string) (string, error) { s.tokens[newToken] = username if err := s.save(); err != nil { - // Rollback on save failure + // Rollback on save failure: restore old token delete(s.tokens, newToken) - user.Token = "" // Will be invalid, but consistent + user.Token = oldToken + s.tokens[oldToken] = username return "", fmt.Errorf("regenerate token: %w", err) }