Guide for adding Auth (JWT) and RBAC middleware to GoBase using ginx, including prerequisites, chain patterns, context helpers, RBAC middleware, error responses, and route recommendations.
Depends on:
ginx.Auth(jwt.Service),ginx.RequirePermission(rbac.Service, resource, action)Load also:gobase-architecture,gobase-ginx-patterns
Before integrating Auth/RBAC, the application must have:
Add credential fields to the User entity in internal/domain/user.go:
type User struct {
domain.BaseModel
Name string `gorm:"size:100;not null" json:"name"`
Email string `gorm:"size:255;uniqueIndex;not null" json:"email"`
PasswordHash string `gorm:"size:255;not null" json:"-"` // never expose in JSON
}
Key rules:
PasswordHash uses json:"-" to prevent leaking in API responses.bcrypt (golang.org/x/crypto/bcrypt) for hashing — never store plaintext passwords.max=72 on password validation.Create a login endpoint that:
PasswordHash using bcrypt.jwt.Service.GenerateToken(userID, roles).ginx Auth middleware takes jwt.Service from github.com/simp-lee/jwt directly — not a generic TokenValidator interface:
import "github.com/simp-lee/jwt"
jwtService, err := jwt.New(secretKey, // must be ≥ 32 characters
jwt.WithMaxTokenLifetime(24 * time.Hour),
)
jwt.Service provides:
GenerateToken(userID string, roles []string) (*jwt.Token, error)ValidateToken(tokenString string) (*jwt.Token, error)RevokeToken(tokenID string) errorRevokeAllUserTokens(userID string) errorClose() error — must call on shutdown (stops background cleanup goroutine)Revocation is in-memory only — lost on process restart.
Mount ginx.Auth at the chain level using conditions, not per route group.
chain.When(
ginx.And(
ginx.PathHasPrefix("/api"),
ginx.Not(ginx.PathIs(
"/api/v1/auth/login",
"/api/v1/auth/register",
)),
),
ginx.Auth(jwtService),
)
ginx.PathIs(paths...) matches exact URL paths. Use it to exclude public endpoints.
Note:
ginx.PathIn()does not exist. Useginx.PathIs(paths...)for multi-path matching.
Add Auth after the existing middleware in internal/app/app.go:
chain := ginx.NewChain().
Use(ginx.RecoveryWith(htmlRecoveryHandler, loggerOpts...)).
Use(ginx.RequestID(...)).
Use(ginx.Logger(loggerOpts...)).
Use(ginx.CORS(corsOpts...)).
Use(ginx.Timeout(ginx.WithTimeout(timeoutDuration)))
// Rate limiting (existing)
if cfg.Server.RateLimit.Enabled {
chain.When(ginx.PathHasPrefix("/api"),
ginx.RateLimit(rps, cfg.Server.RateLimit.Burst))
}
// Auth — add here, after rate limiting, before Build()
if cfg.Auth.Enabled {
chain.When(
ginx.And(
ginx.PathHasPrefix("/api"),
ginx.Not(ginx.PathIs(cfg.Auth.PublicPaths...)),
),
ginx.Auth(jwtService),
)
}
engine.Use(chain.Build())
Auth/RBAC is opt-in via config.yaml:
var jwtSvc jwt.Service
var rbacSvc rbac.Service
if cfg.Auth.Enabled {
jwtSvc, err = jwt.New(cfg.Auth.JWTSecret,
jwt.WithMaxTokenLifetime(tokenExpiry),
)
// ... error handling, defer Close()
if cfg.Auth.RBAC.Enabled {
sqlDB, _ := db.DB() // GORM → *sql.DB bridge
rbacSvc, err = rbac.New(rbac.WithCachedStorage(sqlDB, cacheConfig))
// ... error handling, defer Close()
}
authSvc := auth.NewService(jwtSvc, userRepo)
modules = append(modules, auth.NewModule(auth.NewHandler(authSvc)))
}
ginx provides context helpers to read authenticated user info from *gin.Context. These are automatically populated by ginx.Auth after successful token validation.
| Function | Return Type | Description |
|---|---|---|
ginx.GetUserID(c) | (string, bool) | Authenticated user's ID |
ginx.GetUserRoles(c) | ([]string, bool) | Roles from the JWT token |
ginx.GetTokenID(c) | (string, bool) | JWT token ID (jti) |
ginx.GetTokenExpiresAt(c) | (time.Time, bool) | Token expiration time |
ginx.GetTokenIssuedAt(c) | (time.Time, bool) | Token issuance time |
ginx.GetUserIDOrAbort(c) | (string, bool) | Gets user ID; aborts 401 if missing |
Only these getters exist. No
GetUsername,GetEmail,GetPermissions, orGetAuthClaims.
Usage in a handler:
func (h *UserHandler) GetProfile(c *gin.Context) {
userID, ok := ginx.GetUserID(c)
if !ok {
pkg.Error(c, domain.NewAppError(domain.CodeUnauthorized, "not authenticated", nil))
return
}
// userID is string — parse to uint for domain layer
id, _ := strconv.ParseUint(userID, 10, 64)
user, err := h.service.GetUser(c.Request.Context(), uint(id))
if err != nil {
pkg.Error(c, err)
return
}
pkg.Success(c, user)
}
| Function | Description |
|---|---|
ginx.SetUserID(c, id) | Set user ID in context |
ginx.SetUserRoles(c, roles) | Set user roles |
ginx.SetTokenID(c, id) | Set token ID |
ginx.SetTokenExpiresAt(c, t) | Set expiration |
ginx.SetTokenIssuedAt(c, t) | Set issuance time |
Note: Setters are called by ginx.Auth internally. Application handlers should use getters only.
ginx.Auth(jwtService, ginx.WithAuthQueryToken(true))
When enabled, ginx also looks for the token in the token query parameter (e.g., ?token=xxx).
Only use this for WebSocket scenarios where HTTP headers cannot be set by the client (e.g., browser WebSocket API does not support custom headers).
Default behavior (without this option): ginx reads the token from the Authorization: Bearer <token> header only.
new WebSocket(url)) does not allow setting custom headers. Pass the token as a query parameter instead.WithAuthQueryToken. Always use the Authorization header.ginx provides resource/action based permission checking via github.com/simp-lee/rbac.
⚠️ The RBAC model is resource + action, not simple role strings. There is no
ginx.RequireRole().
All require rbac.Service + resource + action:
| Middleware | Checks | Use Case |
|---|---|---|
ginx.RequirePermission(rbacSvc, resource, action) | Role + direct permissions | Default — most flexible |
ginx.RequireRolePermission(rbacSvc, resource, action) | Role-based only | Ignore direct user permissions |
ginx.RequireUserPermission(rbacSvc, resource, action) | Direct user only | Bypass role hierarchy |
Usage on route groups:
func (m *UserModule) RegisterRoutes(api, pages *gin.RouterGroup) {
users := api.Group("/users")
users.GET("", m.handler.List)
users.GET("/:id", m.handler.Get)
// Write access requires users:write permission
write := users.Group("")
write.Use(ginx.RequirePermission(m.rbacSvc, "users", "write"))
{
write.POST("", m.handler.Create)
write.PUT("/:id", m.handler.Update)
write.DELETE("/:id", m.handler.Delete)
}
}
| Condition | Returns true when |
|---|---|
ginx.IsAuthenticated() | User ID exists in context |
ginx.HasPermission(rbacSvc, resource, action) | Role + direct permission |
ginx.HasRolePermission(rbacSvc, resource, action) | Role permission only |
ginx.HasUserPermission(rbacSvc, resource, action) | Direct permission only |
Important: RBAC conditions require Auth middleware to have run first.
import "github.com/simp-lee/rbac"
// rbac uses database/sql — bridge from GORM:
sqlDB, err := gormDB.DB()
// CachedStorage recommended (SQL + TTL cache)
rbacSvc, err := rbac.New(rbac.WithCachedStorage(sqlDB, rbac.CacheConfig{
RoleTTL: 5 * time.Minute,
UserRoleTTL: 5 * time.Minute,
PermissionTTL: 5 * time.Minute,
}))
Key facts:
*sql.DB, not *gorm.DB — bridge via gormDB.DB()rbac_roles, rbac_user_roles, rbac_user_permissions (default prefix rbac_)AutoMigrate needed* resource, articles/* hierarchicalClose() must be called on shutdown// Roles: resource → []actions
// e.g., {"users": ["read", "write"], "*": ["*"]}
rbacSvc.HasPermission(userID, "users", "read") // checks role + direct
rbacSvc.HasPermission(userID, "*", "*") // wildcard = full access
ginx returns fixed JSON responses for authentication and authorization failures:
Returned by ginx.Auth:
{"error": "missing token"}
{"error": "invalid token"}
Returned by ginx.RequirePermission and variants:
{"error": "user not authenticated"} // 401 — no user ID in context
{"error": "permission denied"} // 403 — RequirePermission
{"error": "insufficient role permissions"} // 403 — RequireRolePermission
{"error": "insufficient user permissions"} // 403 — RequireUserPermission
{"error": "permission check failed"} // 500 — rbac service error
These use {"error": "..."} format, distinct from GoBase's pkg.Response ({"code": N, "message": "...", "data": ...}). Recommended fix: add WithAuthErrorResponse(any) option to ginx following the WithTimeoutResponse/WithRateLimitResponse pattern.
Auth/RBAC rejections are self-contained — OnError is not involved.
// In app.New(): chain-level auth
chain.When(
ginx.And(
ginx.PathHasPrefix("/api"),
ginx.Not(ginx.PathIs(cfg.Auth.PublicPaths...)),
),
ginx.Auth(jwtService),
)
// In module RegisterRoutes(): per-group RBAC
func (m *UserModule) RegisterRoutes(api, pages *gin.RouterGroup) {
users := api.Group("/users")
users.GET("", m.handler.List)
users.GET("/:id", m.handler.Get)
admin := users.Group("")
admin.Use(ginx.RequirePermission(m.rbacSvc, "users", "write"))
{
admin.POST("", m.handler.Create)
admin.PUT("/:id", m.handler.Update)
admin.DELETE("/:id", m.handler.Delete)
}
}
Both jwt.Service and rbac.Service require Close() during shutdown:
// In App.Run(), after HTTP server shutdown, before DB close:
if a.jwtService != nil {
a.jwtService.Close()
}
if a.rbacService != nil {
a.rbacService.Close()
}
ginx.RequireRole("admin")Wrong: admin.Use(ginx.RequireRole("admin"))
Right: admin.Use(ginx.RequirePermission(rbacSvc, "users", "write")) — resource/action model.
Wrong: ginx.GetEmail(c), ginx.GetUsername(c), ginx.GetRoles(c), ginx.GetPermissions(c)
Right: ginx.GetUserID(c), ginx.GetUserRoles(c), ginx.GetTokenID(c), ginx.GetTokenExpiresAt(c), ginx.GetTokenIssuedAt(c)
ginx.PathIn() for path exclusionWrong: ginx.Not(ginx.PathIn(paths...)) — PathIn does not exist.
Right: ginx.Not(ginx.PathIs(paths...)) — PathIs accepts variadic paths.
*gorm.DB to rbacWrong: rbac.New(rbac.WithSQLStorage(gormDB)) — type mismatch.
Right: sqlDB, _ := gormDB.DB() then rbac.New(rbac.WithCachedStorage(sqlDB, config))
jwt.Service runs a background goroutine. rbac.Service with CachedStorage also needs cleanup. Always call Close() in graceful shutdown.
ginx Auth() takes jwt.Service (concrete type from github.com/simp-lee/jwt), not a generic interface.
ginx Auth only stores: UserID, UserRoles, TokenID, ExpiresAt, IssuedAt. Query the database using UserID if you need more user info.