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 +45 -1
- controllers/company_controller.go +15 -1
- main.go +2 -0
- middleware/auth.go +34 -4
- models/models.go +15 -2
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 20 |
-
Role
|
| 21 |
-
CompanyID
|
|
|
|
| 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
|
| 39 |
-
MaxCashiers
|
|
|
|
| 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"`
|