Mhamdans17 commited on
Commit
c6e89e6
·
1 Parent(s): 8879e0b

feat: single session enforcement + UI improvements

Browse files

- Add EnforceSingleSession field to Company model
- Add UserSession model for tracking active kasir sessions
- Add session_token to JWT claims
- Enforce single device login for kasir when feature is enabled
- Add Logout endpoint (POST /api/auth/logout)
- Add enforce_single_session to AdminUpdateCompanySettings
- Fix ListCompanies SELECT: include max_branches, max_cashiers, enforce_single_session
- Fix CompanyResponse: add EnforceSingleSession field + response mapping

controllers/auth_controller.go CHANGED
@@ -1,6 +1,8 @@
1
  package controllers
2
 
3
  import (
 
 
4
  "fmt"
5
  "log"
6
  "math/rand"
@@ -219,7 +221,35 @@ func Login(c *gin.Context) {
219
  return
220
  }
221
 
222
- token, err := middleware.CreateAccessToken(user.ID, user.Role, user.CompanyID)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  if err != nil {
224
  c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal men-generate token"})
225
  return
@@ -232,6 +262,20 @@ func Login(c *gin.Context) {
232
  })
233
  }
234
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  func Register(c *gin.Context) {
236
  var req RegisterRequest
237
  if err := c.ShouldBindJSON(&req); err != nil {
 
1
  package controllers
2
 
3
  import (
4
+ crand "crypto/rand"
5
+ "encoding/hex"
6
  "fmt"
7
  "log"
8
  "math/rand"
 
221
  return
222
  }
223
 
224
+ // --- Single Session Enforcement ---
225
+ var sessionTokenStr string
226
+ if user.Role == "kasir" && user.CompanyID != nil {
227
+ // Ambil company settings (termasuk enforce_single_session)
228
+ var company models.Company
229
+ if err := config.DB.Select("id, enforce_single_session").First(&company, *user.CompanyID).Error; err == nil {
230
+ if company.EnforceSingleSession {
231
+ // Generate secure session token
232
+ tokenBytes := make([]byte, 32)
233
+ crand.Read(tokenBytes)
234
+ sessionTokenStr = hex.EncodeToString(tokenBytes)
235
+
236
+ // Hapus semua session lama milik user ini
237
+ config.DB.Where("user_id = ?", user.ID).Delete(&models.UserSession{})
238
+
239
+ // Buat session baru
240
+ newSession := models.UserSession{
241
+ UserID: user.ID,
242
+ SessionToken: sessionTokenStr,
243
+ UserAgent: c.GetHeader("User-Agent"),
244
+ IPAddress: c.ClientIP(),
245
+ ExpiresAt: time.Now().Add(24 * time.Hour),
246
+ }
247
+ config.DB.Create(&newSession)
248
+ }
249
+ }
250
+ }
251
+
252
+ token, err := middleware.CreateAccessToken(user.ID, user.Role, user.CompanyID, sessionTokenStr)
253
  if err != nil {
254
  c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal men-generate token"})
255
  return
 
262
  })
263
  }
264
 
265
+ func Logout(c *gin.Context) {
266
+ userVal, exists := c.Get("user")
267
+ if !exists {
268
+ c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
269
+ return
270
+ }
271
+ user := userVal.(*models.User)
272
+
273
+ // Hapus session dari DB (jika ada)
274
+ config.DB.Where("user_id = ?", user.ID).Delete(&models.UserSession{})
275
+
276
+ c.JSON(http.StatusOK, gin.H{"message": "Berhasil logout"})
277
+ }
278
+
279
  func Register(c *gin.Context) {
280
  var req RegisterRequest
281
  if err := c.ShouldBindJSON(&req); err != nil {
controllers/company_controller.go CHANGED
@@ -41,6 +41,7 @@ type CompanyResponse struct {
41
  MaxBranches int `json:"max_branches"`
42
  EnableMultiKasir bool `json:"enable_multi_kasir"`
43
  MaxCashiers int `json:"max_cashiers"`
 
44
  ApprovedAt *time.Time `json:"approved_at"`
45
  OwnerVerified *bool `json:"owner_verified,omitempty"`
46
  OwnerName string `json:"owner_name,omitempty"`
@@ -108,7 +109,7 @@ func ListCompanies(c *gin.Context) {
108
  searchQuery := c.Query("search")
109
 
110
  query := config.DB.Model(&models.Company{}).
111
- Select("id, name, bank_account_number, bank_name, address, email, phone, logo_url, is_active, fee_percentage, cash_fee_rules, qris_fee_percentage, qris_fee_fixed, qris_fee_min, qris_min_transaction, allow_qris_below_min, charge_fee_before_discount, wallet_balance, wallet_limit, enable_member, enable_voucher, enable_multi_branch, enable_multi_kasir, approved_at, created_at")
112
 
113
  if searchQuery != "" {
114
  query = query.Where("name ILIKE ? OR email ILIKE ? OR phone ILIKE ? OR address ILIKE ?",
@@ -225,6 +226,7 @@ func ListCompanies(c *gin.Context) {
225
  MaxBranches: comp.MaxBranches,
226
  EnableMultiKasir: comp.EnableMultiKasir,
227
  MaxCashiers: comp.MaxCashiers,
 
228
  CreatedAt: comp.CreatedAt,
229
  })
230
  }
@@ -979,6 +981,7 @@ type UpdateCompanySettingsRequest struct {
979
  MaxBranches *int `json:"max_branches"`
980
  EnableMultiKasir *bool `json:"enable_multi_kasir"`
981
  MaxCashiers *int `json:"max_cashiers"`
 
982
  }
983
 
984
  func AdminUpdateCompanySettings(c *gin.Context) {
@@ -1022,6 +1025,17 @@ func AdminUpdateCompanySettings(c *gin.Context) {
1022
  if req.MaxCashiers != nil {
1023
  company.MaxCashiers = *req.MaxCashiers
1024
  }
 
 
 
 
 
 
 
 
 
 
 
1025
 
1026
  if err := config.DB.Save(&company).Error; err != nil {
1027
  c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyimpan pengaturan"})
 
41
  MaxBranches int `json:"max_branches"`
42
  EnableMultiKasir bool `json:"enable_multi_kasir"`
43
  MaxCashiers int `json:"max_cashiers"`
44
+ EnforceSingleSession bool `json:"enforce_single_session"`
45
  ApprovedAt *time.Time `json:"approved_at"`
46
  OwnerVerified *bool `json:"owner_verified,omitempty"`
47
  OwnerName string `json:"owner_name,omitempty"`
 
109
  searchQuery := c.Query("search")
110
 
111
  query := config.DB.Model(&models.Company{}).
112
+ Select("id, name, bank_account_number, bank_name, address, email, phone, logo_url, is_active, fee_percentage, cash_fee_rules, qris_fee_rules, qris_fee_percentage, qris_fee_fixed, qris_fee_min, qris_min_transaction, allow_qris_below_min, charge_fee_before_discount, wallet_balance, wallet_limit, enable_member, enable_voucher, enable_multi_branch, max_branches, enable_multi_kasir, max_cashiers, enforce_single_session, approved_at, created_at")
113
 
114
  if searchQuery != "" {
115
  query = query.Where("name ILIKE ? OR email ILIKE ? OR phone ILIKE ? OR address ILIKE ?",
 
226
  MaxBranches: comp.MaxBranches,
227
  EnableMultiKasir: comp.EnableMultiKasir,
228
  MaxCashiers: comp.MaxCashiers,
229
+ EnforceSingleSession: comp.EnforceSingleSession,
230
  CreatedAt: comp.CreatedAt,
231
  })
232
  }
 
981
  MaxBranches *int `json:"max_branches"`
982
  EnableMultiKasir *bool `json:"enable_multi_kasir"`
983
  MaxCashiers *int `json:"max_cashiers"`
984
+ EnforceSingleSession *bool `json:"enforce_single_session"`
985
  }
986
 
987
  func AdminUpdateCompanySettings(c *gin.Context) {
 
1025
  if req.MaxCashiers != nil {
1026
  company.MaxCashiers = *req.MaxCashiers
1027
  }
1028
+ if req.EnforceSingleSession != nil {
1029
+ company.EnforceSingleSession = *req.EnforceSingleSession
1030
+ // Jika fitur dimatikan, hapus semua sesi aktif kasir perusahaan ini
1031
+ if !*req.EnforceSingleSession {
1032
+ var kasirIDs []uint
1033
+ config.DB.Model(&models.User{}).Where("company_id = ? AND role = ?", company.ID, "kasir").Pluck("id", &kasirIDs)
1034
+ if len(kasirIDs) > 0 {
1035
+ config.DB.Where("user_id IN ?", kasirIDs).Delete(&models.UserSession{})
1036
+ }
1037
+ }
1038
+ }
1039
 
1040
  if err := config.DB.Save(&company).Error; err != nil {
1041
  c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyimpan pengaturan"})
main.go CHANGED
@@ -41,6 +41,7 @@ func main() {
41
  &models.TopupRequest{},
42
  &models.Voucher{},
43
  &models.PointHistory{},
 
44
  )
45
  if err != nil {
46
  log.Fatalf("Gagal melakukan Auto-Migration: %v", err)
@@ -110,6 +111,7 @@ func main() {
110
  auth.Use(middleware.GetCurrentUser())
111
  {
112
  auth.GET("/me", controllers.GetMe)
 
113
  auth.POST("/register", middleware.RequireSuperAdmin(), controllers.Register)
114
  auth.GET("/users", middleware.RequireSuperAdmin(), controllers.ListUsers)
115
  auth.PUT("/users/:user_id", middleware.RequireSuperAdmin(), controllers.UpdateUser)
 
41
  &models.TopupRequest{},
42
  &models.Voucher{},
43
  &models.PointHistory{},
44
+ &models.UserSession{},
45
  )
46
  if err != nil {
47
  log.Fatalf("Gagal melakukan Auto-Migration: %v", err)
 
111
  auth.Use(middleware.GetCurrentUser())
112
  {
113
  auth.GET("/me", controllers.GetMe)
114
+ auth.POST("/logout", controllers.Logout)
115
  auth.POST("/register", middleware.RequireSuperAdmin(), controllers.Register)
116
  auth.GET("/users", middleware.RequireSuperAdmin(), controllers.ListUsers)
117
  auth.PUT("/users/:user_id", middleware.RequireSuperAdmin(), controllers.UpdateUser)
middleware/auth.go CHANGED
@@ -16,9 +16,10 @@ import (
16
  )
17
 
18
  type JWTClaims struct {
19
- Sub uint `json:"sub"`
20
- Role string `json:"role"`
21
- CompanyID *uint `json:"company_id"`
 
22
  jwt.RegisteredClaims
23
  }
24
 
@@ -33,7 +34,7 @@ func VerifyPassword(password, hash string) bool {
33
  return err == nil
34
  }
35
 
36
- func CreateAccessToken(userID uint, role string, companyID *uint) (string, error) {
37
  expiryHours := config.GlobalConfig.JWTExpiryHours
38
  if expiryHours == 0 {
39
  expiryHours = 24
@@ -48,6 +49,9 @@ func CreateAccessToken(userID uint, role string, companyID *uint) (string, error
48
  IssuedAt: jwt.NewNumericDate(time.Now()),
49
  },
50
  }
 
 
 
51
 
52
  token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
53
  return token.SignedString([]byte(config.GlobalConfig.JWTSecretKey))
@@ -110,6 +114,32 @@ func GetCurrentUser() gin.HandlerFunc {
110
  return
111
  }
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  // Simpan user ke context Gin
114
  c.Set("user", &user)
115
  c.Next()
 
16
  )
17
 
18
  type JWTClaims struct {
19
+ Sub uint `json:"sub"`
20
+ Role string `json:"role"`
21
+ CompanyID *uint `json:"company_id"`
22
+ SessionToken string `json:"session_token,omitempty"`
23
  jwt.RegisteredClaims
24
  }
25
 
 
34
  return err == nil
35
  }
36
 
37
+ func CreateAccessToken(userID uint, role string, companyID *uint, sessionToken ...string) (string, error) {
38
  expiryHours := config.GlobalConfig.JWTExpiryHours
39
  if expiryHours == 0 {
40
  expiryHours = 24
 
49
  IssuedAt: jwt.NewNumericDate(time.Now()),
50
  },
51
  }
52
+ if len(sessionToken) > 0 && sessionToken[0] != "" {
53
+ claims.SessionToken = sessionToken[0]
54
+ }
55
 
56
  token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
57
  return token.SignedString([]byte(config.GlobalConfig.JWTSecretKey))
 
114
  return
115
  }
116
 
117
+ // Cek single session enforcement untuk kasir
118
+ if user.Role == "kasir" && user.CompanyID != nil {
119
+ var company models.Company
120
+ if err := config.DB.Select("enforce_single_session").First(&company, *user.CompanyID).Error; err == nil {
121
+ if company.EnforceSingleSession {
122
+ // Harus ada session_token di JWT
123
+ if claims.SessionToken == "" {
124
+ c.JSON(http.StatusUnauthorized, gin.H{"detail": "Sesi tidak valid, silakan login ulang.", "code": "SESSION_EXPIRED"})
125
+ c.Abort()
126
+ return
127
+ }
128
+ // Validasi session_token ke DB
129
+ var session models.UserSession
130
+ err := config.DB.Where(
131
+ "user_id = ? AND session_token = ? AND expires_at > ?",
132
+ user.ID, claims.SessionToken, time.Now(),
133
+ ).First(&session).Error
134
+ if err != nil {
135
+ c.JSON(http.StatusUnauthorized, gin.H{"detail": "Sesi Anda sudah tidak aktif. Perangkat lain telah login dengan akun ini.", "code": "SESSION_EXPIRED"})
136
+ c.Abort()
137
+ return
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
  // Simpan user ke context Gin
144
  c.Set("user", &user)
145
  c.Next()
models/models.go CHANGED
@@ -35,8 +35,9 @@ type Company struct {
35
  EnableVoucher bool `gorm:"default:true" json:"enable_voucher"`
36
  EnableMultiBranch bool `gorm:"default:false" json:"enable_multi_branch"`
37
  MaxBranches int `gorm:"default:0" json:"max_branches"`
38
- EnableMultiKasir bool `gorm:"default:false" json:"enable_multi_kasir"`
39
- MaxCashiers int `gorm:"default:0" json:"max_cashiers"`
 
40
  PointRules []PointRule `gorm:"foreignKey:CompanyID;constraint:OnDelete:CASCADE" json:"point_rules"`
41
  CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
42
  Branches []Branch `gorm:"foreignKey:CompanyID;constraint:OnDelete:CASCADE" json:"-"`
@@ -237,6 +238,18 @@ type WalletTransaction struct {
237
  Company *Company `gorm:"foreignKey:CompanyID" json:"company,omitempty"`
238
  }
239
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  type TopupRequest struct {
241
  ID uint `gorm:"primaryKey" json:"id"`
242
  CompanyID uint `gorm:"not null;index" json:"company_id"`
 
35
  EnableVoucher bool `gorm:"default:true" json:"enable_voucher"`
36
  EnableMultiBranch bool `gorm:"default:false" json:"enable_multi_branch"`
37
  MaxBranches int `gorm:"default:0" json:"max_branches"`
38
+ EnableMultiKasir bool `gorm:"default:false" json:"enable_multi_kasir"`
39
+ MaxCashiers int `gorm:"default:0" json:"max_cashiers"`
40
+ EnforceSingleSession bool `gorm:"default:false" json:"enforce_single_session"`
41
  PointRules []PointRule `gorm:"foreignKey:CompanyID;constraint:OnDelete:CASCADE" json:"point_rules"`
42
  CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
43
  Branches []Branch `gorm:"foreignKey:CompanyID;constraint:OnDelete:CASCADE" json:"-"`
 
238
  Company *Company `gorm:"foreignKey:CompanyID" json:"company,omitempty"`
239
  }
240
 
241
+ // UserSession tracks active login sessions for single-session enforcement
242
+ type UserSession struct {
243
+ ID uint `gorm:"primaryKey" json:"id"`
244
+ UserID uint `gorm:"not null;index" json:"user_id"`
245
+ SessionToken string `gorm:"type:varchar(64);not null;uniqueIndex" json:"session_token"`
246
+ UserAgent string `gorm:"type:varchar(255)" json:"user_agent"`
247
+ IPAddress string `gorm:"type:varchar(45)" json:"ip_address"`
248
+ ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
249
+ CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
250
+ User *User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"-"`
251
+ }
252
+
253
  type TopupRequest struct {
254
  ID uint `gorm:"primaryKey" json:"id"`
255
  CompanyID uint `gorm:"not null;index" json:"company_id"`