diff --git a/internal/handler/admin_handler.go b/internal/handler/admin_handler.go index 80f1cb4..b85cfce 100644 --- a/internal/handler/admin_handler.go +++ b/internal/handler/admin_handler.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "net/http" "github.com/gin-gonic/gin" @@ -39,8 +40,30 @@ func (h *AdminHandler) GetUsers(c *gin.Context) { // @Success 200 {object} utils.Response // @Router /api/admin/users/{id}/lock [post] func (h *AdminHandler) LockUser(c *gin.Context) { + adminID := c.GetString("userID") userID := c.Param("id") - // TODO: Implement LockUser in AuthService + ipAddress := c.ClientIP() + userAgent := c.GetHeader("User-Agent") + if err := h.authService.LockUser(userID, adminID, ipAddress, userAgent); err != nil { + switch { + case errors.Is(err, service.ErrUserNotFound): + c.JSON(http.StatusNotFound, + utils.ErrorResponse("User not found", err)) + case errors.Is(err, service.ErrSelfLock): + c.JSON(http.StatusBadRequest, + utils.ErrorResponse("Cannot lock your own account", err)) + case errors.Is(err, service.ErrAdminLock): + c.JSON(http.StatusForbidden, + utils.ErrorResponse("Admin accounts cannot be locked", err)) + case errors.Is(err, service.ErrAlreadyLocked): + c.JSON(http.StatusConflict, + utils.ErrorResponse("Account is already locked", err)) + default: + c.JSON(http.StatusInternalServerError, + utils.ErrorResponse("Failed to lock user", err)) + } + return + } c.JSON(http.StatusOK, utils.SuccessResponse("User locked successfully", map[string]string{"userID": userID})) } @@ -52,8 +75,24 @@ func (h *AdminHandler) LockUser(c *gin.Context) { // @Success 200 {object} utils.Response // @Router /api/admin/users/{id}/unlock [post] func (h *AdminHandler) UnlockUser(c *gin.Context) { + adminID := c.GetString("userID") userID := c.Param("id") - // TODO: Implement UnlockUser in AuthService + ipAddress := c.ClientIP() + userAgent := c.GetHeader("User-Agent") + if err := h.authService.UnlockUser(userID, adminID, ipAddress, userAgent); err != nil { + switch { + case errors.Is(err, service.ErrUserNotFound): + c.JSON(http.StatusNotFound, + utils.ErrorResponse("User not found", err)) + case errors.Is(err, service.ErrNotLocked): + c.JSON(http.StatusBadRequest, + utils.ErrorResponse("Account is not locked", err)) + default: + c.JSON(http.StatusInternalServerError, + utils.ErrorResponse("Failed to unlock user", err)) + } + return + } c.JSON(http.StatusOK, utils.SuccessResponse("User unlocked successfully", map[string]string{"userID": userID})) } diff --git a/internal/handler/admin_handler_test.go b/internal/handler/admin_handler_test.go new file mode 100644 index 0000000..9273493 --- /dev/null +++ b/internal/handler/admin_handler_test.go @@ -0,0 +1,184 @@ +package handler_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/roshankumar0036singh/auth-server/internal/handler" + + "github.com/roshankumar0036singh/auth-server/internal/config" + "github.com/roshankumar0036singh/auth-server/internal/dto" + "github.com/roshankumar0036singh/auth-server/internal/middleware" + "github.com/roshankumar0036singh/auth-server/internal/repository" + "github.com/roshankumar0036singh/auth-server/internal/service" + "github.com/roshankumar0036singh/auth-server/internal/testutils" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupAdminLockRouter(t *testing.T) ( + *gin.Engine, + *service.AuthService, + *service.TokenService, + *repository.UserRepository, +) { + t.Helper() + + authSvc, db, mr := testutils.SetupIntegrationTest(t) + + t.Cleanup(func() { + mr.Close() + }) + + userRepo := repository.NewUserRepository(db) + + cfg := &config.Config{ + JWT: config.JWTConfig{ + AccessSecret: "test-access-secret", + RefreshSecret: "test-refresh-secret", + }, + } + + tokenSvc := service.NewTokenService(cfg) + + adminHandler := handler.NewAdminHandler(authSvc) + + gin.SetMode(gin.TestMode) + + r := gin.New() + + admin := r.Group("/api/admin") + admin.Use(middleware.AuthMiddleware(tokenSvc)) + admin.Use(middleware.RequireRole("admin")) + + admin.POST("/users/:id/lock", adminHandler.LockUser) + admin.POST("/users/:id/unlock", adminHandler.UnlockUser) + + return r, authSvc, tokenSvc, userRepo +} + +func TestAdminHandler_LockUser_Errors(t *testing.T) { + r, authSvc, tokenSvc, userRepo := setupAdminLockRouter(t) + + admin, err := authSvc.Register(&dto.RegisterRequest{ + Email: "lock-admin@test.com", + Password: "Password123!", + }) + require.NoError(t, err) + + require.NoError(t, + userRepo.Update(admin.ID, map[string]interface{}{"role": "admin"})) + admin.Role = "admin" + + user, err := authSvc.Register(&dto.RegisterRequest{ + Email: "lock-user@test.com", + Password: "Password123!", + }) + require.NoError(t, err) + + otherAdmin, err := authSvc.Register(&dto.RegisterRequest{ + Email: "lock-other-admin@test.com", + Password: "Password123!", + }) + require.NoError(t, err) + + require.NoError(t, + userRepo.Update(otherAdmin.ID, map[string]interface{}{"role": "admin"})) + + // ensure already locked case is valid + require.NoError(t, + authSvc.LockUser(user.ID, admin.ID, "", "")) + + token, err := tokenSvc.GenerateAccessToken(admin) + require.NoError(t, err) + + tests := []struct { + name string + userID string + status int + }{ + {"user not found", "00000000-0000-0000-0000-000000000000", http.StatusNotFound}, + {"self lock", admin.ID, http.StatusBadRequest}, + {"admin account", otherAdmin.ID, http.StatusForbidden}, + {"already locked", user.ID, http.StatusConflict}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest( + http.MethodPost, + "/api/admin/users/"+tt.userID+"/lock", + nil, + ) + req.Header.Set("Authorization", "Bearer "+token) + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, tt.status, w.Code) + }) + } +} + +func TestAdminHandler_UnlockUser_Errors(t *testing.T) { + r, authSvc, tokenSvc, userRepo := setupAdminLockRouter(t) + + admin, err := authSvc.Register(&dto.RegisterRequest{ + Email: "unlock-admin@test.com", + Password: "Password123!", + }) + require.NoError(t, err) + + require.NoError(t, + userRepo.Update(admin.ID, map[string]interface{}{"role": "admin"})) + admin.Role = "admin" + + // user 1: will be NOT locked + unlockedUser, err := authSvc.Register(&dto.RegisterRequest{ + Email: "unlock-user@test.com", + Password: "Password123!", + }) + require.NoError(t, err) + + // user 2: will be locked + lockedUser, err := authSvc.Register(&dto.RegisterRequest{ + Email: "unlock-user-locked@test.com", + Password: "Password123!", + }) + require.NoError(t, err) + + require.NoError(t, + authSvc.LockUser(lockedUser.ID, admin.ID, "", ""), + ) + + token, err := tokenSvc.GenerateAccessToken(admin) + require.NoError(t, err) + + tests := []struct { + name string + userID string + status int + }{ + {"user not found", "00000000-0000-0000-0000-000000000000", http.StatusNotFound}, + {"not locked", unlockedUser.ID, http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest( + http.MethodPost, + "/api/admin/users/"+tt.userID+"/unlock", + nil, + ) + req.Header.Set("Authorization", "Bearer "+token) + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, tt.status, w.Code) + }) + } +} diff --git a/internal/models/user.go b/internal/models/user.go index afa580a..ddb040d 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -77,3 +77,8 @@ func (u *User) ToPublic() *PublicUser { LastLoginAt: u.LastLoginAt, } } + +func (u *User) IsLocked() bool { + return u.LockedUntil != nil && + time.Now().Before(*u.LockedUntil) +} diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go index 53d45b9..7f8416f 100644 --- a/internal/repository/user_repository.go +++ b/internal/repository/user_repository.go @@ -2,6 +2,7 @@ package repository import ( "errors" + "time" "github.com/roshankumar0036singh/auth-server/internal/models" "gorm.io/gorm" @@ -76,3 +77,38 @@ func (r *UserRepository) EmailExists(email string) (bool, error) { err := r.db.Model(&models.User{}).Where("email = ?", email).Count(&count).Error return count > 0, err } + +func (r *UserRepository) LockUser(userID string, lockedUntil time.Time) error { + result := r.db.Model(&models.User{}). + Where("id = ?", userID). + Update("locked_until", lockedUntil) + + if result.Error != nil { + return result.Error + } + + if result.RowsAffected == 0 { + return ErrUserNotFound + } + + return nil +} + +func (r *UserRepository) UnlockUser(userID string) error { + result := r.db.Model(&models.User{}). + Where("id = ?", userID). + Updates(map[string]interface{}{ + "locked_until": nil, + "failed_login_attempts": 0, + }) + + if result.Error != nil { + return result.Error + } + + if result.RowsAffected == 0 { + return ErrUserNotFound + } + + return nil +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 95791a0..38e57c8 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -48,6 +48,7 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, redisClient *redis.Client, cfg auditService, mfaService, cfg, + db, ) // OAuth Provider service diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index 31a594d..2ae8996 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -13,6 +13,15 @@ import ( "github.com/roshankumar0036singh/auth-server/internal/repository" "github.com/roshankumar0036singh/auth-server/internal/utils" "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +var ( + ErrUserNotFound = errors.New("user not found") + ErrSelfLock = errors.New("admin cannot lock their own account") + ErrAdminLock = errors.New("admin accounts cannot be locked") + ErrAlreadyLocked = errors.New("account is already locked") + ErrNotLocked = errors.New("account is not locked") ) const ( @@ -34,6 +43,7 @@ type AuthService struct { auditService *AuditService mfaService *MFAService config *config.Config + db *gorm.DB } func NewAuthService( @@ -47,6 +57,7 @@ func NewAuthService( auditService *AuditService, mfaService *MFAService, cfg *config.Config, + db *gorm.DB, ) *AuthService { return &AuthService{ userRepo: userRepo, @@ -59,6 +70,7 @@ func NewAuthService( auditService: auditService, mfaService: mfaService, config: cfg, + db: db, } } @@ -781,3 +793,67 @@ func (s *AuthService) RevokeSession(userID, tokenID string) error { return nil } + +func (s *AuthService) LockUser(userID, adminID, ipAddress, userAgent string) error { + if userID == adminID { + return ErrSelfLock + } + + return s.db.Transaction(func(tx *gorm.DB) error { + + user, err := s.userRepo.FindByID(userID) + if err != nil { + if errors.Is(err, repository.ErrUserNotFound) { + return ErrUserNotFound + } + return err + } + + if user.Role == "admin" { + return ErrAdminLock + } + + if user.IsLocked() { + return ErrAlreadyLocked + } + + // effective until manually unlocked + lockedUntil := time.Now().AddDate(100, 0, 0) + + if err := s.userRepo.LockUser(userID, lockedUntil); err != nil { + return fmt.Errorf("lock user: %w", err) + } + + if err := s.tokenRepo.RevokeAllUserTokens(userID); err != nil { + return fmt.Errorf("revoke user tokens: %w", err) + } + + s.auditService.LogEvent(&adminID, "USER_LOCKED", "USER", userID, ipAddress, userAgent, map[string]interface{}{"locked_until": lockedUntil}) + + return nil + }) +} + +func (s *AuthService) UnlockUser(userID, adminID, ipAddress, userAgent string) error { + return s.db.Transaction(func(tx *gorm.DB) error { + user, err := s.userRepo.FindByID(userID) + if err != nil { + if errors.Is(err, repository.ErrUserNotFound) { + return ErrUserNotFound + } + return err + } + + if !user.IsLocked() { + return ErrNotLocked + } + + if err := s.userRepo.UnlockUser(userID); err != nil { + return fmt.Errorf("unlock user: %w", err) + } + + s.auditService.LogEvent(&adminID, "USER_UNLOCKED", "USER", userID, ipAddress, userAgent, map[string]interface{}{"locked_until": nil}) + + return nil + }) +} diff --git a/internal/service/auth_service_integration_test.go b/internal/service/auth_service_integration_test.go index c265a50..2752d54 100644 --- a/internal/service/auth_service_integration_test.go +++ b/internal/service/auth_service_integration_test.go @@ -4,8 +4,13 @@ import ( "testing" "github.com/roshankumar0036singh/auth-server/internal/dto" + "github.com/roshankumar0036singh/auth-server/internal/models" + "github.com/roshankumar0036singh/auth-server/internal/repository" + "github.com/roshankumar0036singh/auth-server/internal/service" "github.com/roshankumar0036singh/auth-server/internal/testutils" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" ) func TestAuthService_Register_Integration(t *testing.T) { @@ -58,3 +63,161 @@ func TestAuthService_Login_Integration(t *testing.T) { _, err = service.Login(loginReqFail, "127.0.0.1", "UserAgent") assert.Error(t, err) } + +func registerUser(t *testing.T, authSvc *service.AuthService, email string) *models.User { + t.Helper() + + user, err := authSvc.Register(&dto.RegisterRequest{ + Email: email, + Password: "Password123!", + FirstName: "Test", + LastName: "User", + }) + require.NoError(t, err) + + return user +} + +func promoteAdmin(t *testing.T, db *gorm.DB, userID string) { + t.Helper() + + require.NoError(t, + db.Model(&models.User{}). + Where("id = ?", userID). + Update("role", "admin").Error, + ) +} + +func TestAuthService_LockUser(t *testing.T) { + authSvc, db, _ := testutils.SetupIntegrationTest(t) + + admin := registerUser(t, authSvc, "admin@example.com") + user := registerUser(t, authSvc, "user@example.com") + + promoteAdmin(t, db, admin.ID) + + _, err := authSvc.Login(&dto.LoginRequest{ + Email: "user@example.com", + Password: "Password123!", + }, "127.0.0.1", "test-agent") + require.NoError(t, err) + + require.NoError(t, + authSvc.LockUser(user.ID, admin.ID, "127.0.0.1", "test-agent"), + ) + + updatedUser, err := repository.NewUserRepository(db).FindByID(user.ID) + require.NoError(t, err) + + require.NotNil(t, updatedUser.LockedUntil) + require.True(t, updatedUser.IsLocked()) + + var tokenCount int64 + + require.NoError(t, + db.Model(&models.RefreshToken{}). + Where("user_id = ? AND is_revoked = ?", user.ID, false). + Count(&tokenCount).Error, + ) + + assert.Equal(t, int64(0), tokenCount) +} + +func TestAuthService_LockUser_TokenRevocation(t *testing.T) { + authSvc, db, _ := testutils.SetupIntegrationTest(t) + + admin := registerUser(t, authSvc, "admin2@example.com") + user := registerUser(t, authSvc, "user2@example.com") + + promoteAdmin(t, db, admin.ID) + + loginResp, err := authSvc.Login(&dto.LoginRequest{ + Email: "user2@example.com", + Password: "Password123!", + }, "127.0.0.1", "test-agent") + require.NoError(t, err) + require.NotEmpty(t, loginResp.AccessToken) + + var before int64 + require.NoError(t, + db.Model(&models.RefreshToken{}). + Where("user_id = ?", user.ID). + Count(&before).Error, + ) + require.Greater(t, before, int64(0)) + + require.NoError(t, + authSvc.LockUser(user.ID, admin.ID, "", ""), + ) + + updatedUser, err := repository.NewUserRepository(db).FindByID(user.ID) + require.NoError(t, err) + + assert.NotNil(t, updatedUser.LockedUntil) + + var after int64 + + require.NoError(t, + db.Model(&models.RefreshToken{}). + Where("user_id = ? AND is_revoked = ?", user.ID, false). + Count(&after).Error, + ) + + assert.Equal(t, int64(0), after) +} + +func TestAuthService_LockUser_SelfLock(t *testing.T) { + authSvc, db, _ := testutils.SetupIntegrationTest(t) + + user := registerUser(t, authSvc, "self@example.com") + promoteAdmin(t, db, user.ID) + + err := authSvc.LockUser(user.ID, user.ID, "", "") + assert.ErrorIs(t, err, service.ErrSelfLock) +} + +func TestAuthService_LockUser_AdminLock(t *testing.T) { + authSvc, db, _ := testutils.SetupIntegrationTest(t) + + admin := registerUser(t, authSvc, "admin5@example.com") + user := registerUser(t, authSvc, "user5@example.com") + + promoteAdmin(t, db, admin.ID) + + err := authSvc.LockUser(admin.ID, user.ID, "", "") + assert.ErrorIs(t, err, service.ErrAdminLock) +} + +func TestAuthService_UnlockUser(t *testing.T) { + authSvc, db, _ := testutils.SetupIntegrationTest(t) + + admin := registerUser(t, authSvc, "admin3@example.com") + user := registerUser(t, authSvc, "user3@example.com") + + promoteAdmin(t, db, admin.ID) + + require.NoError(t, + authSvc.LockUser(user.ID, admin.ID, "", ""), + ) + + require.NoError(t, + authSvc.UnlockUser(user.ID, admin.ID, "", ""), + ) + + updatedUser, err := repository.NewUserRepository(db).FindByID(user.ID) + require.NoError(t, err) + + assert.Nil(t, updatedUser.LockedUntil) +} + +func TestAuthService_UnlockUser_WhenNotLocked(t *testing.T) { + authSvc, db, _ := testutils.SetupIntegrationTest(t) + + admin := registerUser(t, authSvc, "admin4@example.com") + user := registerUser(t, authSvc, "user4@example.com") + + promoteAdmin(t, db, admin.ID) + + err := authSvc.UnlockUser(user.ID, admin.ID, "", "") + assert.ErrorIs(t, err, service.ErrNotLocked) +} diff --git a/internal/testutils/setup.go b/internal/testutils/setup.go index e89ba30..382d318 100644 --- a/internal/testutils/setup.go +++ b/internal/testutils/setup.go @@ -90,6 +90,7 @@ func SetupIntegrationTest(t *testing.T) (*service.AuthService, *gorm.DB, *minire auditService, mfaService, cfg, + db, ) return authService, db, mr