Authentication & Authorization Deep Dive
This comprehensive guide covers authentication and authorization in Gojang, from basic login flows to advanced permission systems.
Overview
Gojang includes a complete authentication and authorization system with:
- 🔐 Session-based authentication
- 👥 User management
- 🔒 Password hashing with bcrypt
- 🛡️ CSRF protection
- 🎫 Token-based sessions
- 🚪 Middleware-based access control
- 👮 Role-based permissions
Key Components:
app/gojang/http/handlers/auth.go- Authentication handlersapp/gojang/http/middleware/auth.go- Authentication middlewareapp/gojang/utils/password.go- Password hashing utilitiesapp/schema/user.go- User model schema
Authentication System
User Model
The User model includes all necessary fields for authentication:
// app/schema/user.go
type User struct {
ent.Schema
}
func (User) Fields() []ent.Field {
return []ent.Field{
field.String("email").
Unique().
NotEmpty(),
field.String("password_hash").
Sensitive(), // Not exposed in API responses
field.Bool("is_active").
Default(true),
field.Bool("is_staff").
Default(false),
field.Time("last_login").
Optional(),
field.Time("created_at").
Default(time.Now).
Immutable(),
}
}
Field Explanations:
email- Unique identifier for loginpassword_hash- Bcrypt hashed password (never store plaintext!)is_active- Allows disabling accounts without deletionis_staff- Admin/staff permission flaglast_login- Track user activitycreated_at- Account creation timestamp
Password Security
Hashing Passwords
Gojang uses bcrypt for password hashing with appropriate cost factor:
import "github.com/gojangframework/gojang/app/gojang/utils"
// Hash a password
hash, err := utils.HashPassword("user-password")
if err != nil {
// Handle error
}
// Store hash in database
user := client.User.Create().
SetEmail("user@example.com").
SetPasswordHash(hash).
Save(ctx)
Implementation details:
// app/gojang/utils/password.go
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword(
[]byte(password),
bcrypt.DefaultCost, // Cost factor 10
)
return string(bytes), err
}
Verifying Passwords
// Check if password matches hash
match, err := utils.CheckPassword(user.PasswordHash, "user-password")
if err != nil {
// Handle error
}
if !match {
// Invalid password
return errors.New("invalid credentials")
}
// Password is valid, proceed with login
Implementation:
func CheckPassword(hash, password string) (bool, error) {
err := bcrypt.CompareHashAndPassword(
[]byte(hash),
[]byte(password),
)
if err != nil {
if err == bcrypt.ErrMismatchedHashAndPassword {
return false, nil // Wrong password
}
return false, err // Other error
}
return true, nil // Password matches
}
Security Best Practices:
- ✅ Never store plaintext passwords
- ✅ Use bcrypt with cost factor 10-12
- ✅ Mark password_hash field as
Sensitive()in schema - ✅ Never log or expose password hashes
- ✅ Always use constant-time comparison (bcrypt handles this)
Session Management
Session Configuration
Sessions are managed using alexedwards/scs:
// app/gojang/http/middleware/session.go
func NewSessionManager(cfg *config.Config) *scs.SessionManager {
sessionManager := scs.New()
sessionManager.Lifetime = cfg.SessionLifetime // Default: 24 hours
sessionManager.Cookie.Name = "session_id"
sessionManager.Cookie.HttpOnly = true // Prevent XSS access
sessionManager.Cookie.Secure = !cfg.Debug // HTTPS only in production
sessionManager.Cookie.SameSite = 2 // Lax mode
sessionManager.Cookie.Path = "/"
sessionManager.IdleTimeout = 30 * time.Minute // Auto-logout after inactivity
return sessionManager
}
Cookie Settings Explained:
HttpOnly: true- Prevents JavaScript access (XSS protection)Secure: true- Only send over HTTPS in productionSameSite: Lax- CSRF protection while allowing navigationIdleTimeout- Automatic logout after inactivity
Storing Session Data
// Store user ID in session after successful login
sessionManager.Put(ctx, "user_id", userID)
// Renew token to prevent session fixation attacks
sessionManager.RenewToken(ctx)
// Retrieve user ID from session
userID := sessionManager.GetInt(ctx, "user_id")
// Remove session data (logout)
sessionManager.Destroy(ctx)
Session Lifecycle:
- Login - Store user_id, renew token
- Request - Load user from session
- Idle timeout - Auto-destroy after 30 minutes inactivity
- Logout - Explicitly destroy session
- Expired - Auto-cleanup after 24 hours
Authentication Flow
Registration
// app/gojang/http/handlers/auth.go
func (h *AuthHandler) RegisterPOST(w http.ResponseWriter, r *http.Request) {
// 1. Parse and validate form
if err := r.ParseForm(); err != nil {
h.Renderer.RenderError(w, r, http.StatusBadRequest, "Invalid form")
return
}
email := r.Form.Get("email")
password := r.Form.Get("password")
// 2. Validate input
if email == "" || password == "" {
h.Renderer.Render(w, r, "auth/register.html", &renderers.TemplateData{
Errors: map[string]string{
"general": "Email and password required",
},
})
return
}
// 3. Check if user already exists
exists, _ := h.Client.User.Query().
Where(user.EmailEQ(email)).
Exist(r.Context())
if exists {
h.Renderer.Render(w, r, "auth/register.html", &renderers.TemplateData{
Errors: map[string]string{
"email": "Email already registered",
},
})
return
}
// 4. Hash password
hash, err := utils.HashPassword(password)
if err != nil {
h.Renderer.RenderError(w, r, http.StatusInternalServerError,
"Failed to hash password")
return
}
// 5. Create user
u, err := h.Client.User.Create().
SetEmail(email).
SetPasswordHash(hash).
Save(r.Context())
if err != nil {
h.Renderer.RenderError(w, r, http.StatusInternalServerError,
"Failed to create user")
return
}
// 6. Auto-login after registration
h.Sessions.Put(r.Context(), "user_id", u.ID)
h.Sessions.RenewToken(r.Context())
// 7. Redirect to dashboard
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
Registration Steps:
- Parse and validate form data
- Check if email already exists
- Hash password with bcrypt
- Create user in database
- Auto-login (store user_id in session)
- Renew session token (security)
- Redirect to dashboard
Login
func (h *AuthHandler) LoginPOST(w http.ResponseWriter, r *http.Request) {
// 1. Parse form
if err := r.ParseForm(); err != nil {
h.Renderer.RenderError(w, r, http.StatusBadRequest, "Invalid form")
return
}
email := r.Form.Get("email")
password := r.Form.Get("password")
// 2. Find user by email
u, err := h.Client.User.Query().
Where(user.EmailEQ(email)).
Only(r.Context())
if err != nil {
// User not found - return generic error
h.Renderer.Render(w, r, "auth/login.html", &renderers.TemplateData{
Errors: map[string]string{
"general": "Invalid email or password",
},
})
return
}
// 3. Verify password
ok, err := utils.CheckPassword(u.PasswordHash, password)
if err != nil || !ok {
// Wrong password - return generic error
h.Renderer.Render(w, r, "auth/login.html", &renderers.TemplateData{
Errors: map[string]string{
"general": "Invalid email or password",
},
})
return
}
// 4. Check if account is active
if !u.IsActive {
h.Renderer.Render(w, r, "auth/login.html", &renderers.TemplateData{
Errors: map[string]string{
"general": "Your account is inactive",
},
})
return
}
// 5. Update last login timestamp
_, err = h.Client.User.UpdateOneID(u.ID).
SetLastLogin(time.Now()).
Save(r.Context())
if err != nil {
log.Printf("WARNING: Failed to update last login for user %d: %v",
u.ID, err)
// Don't fail login for this
}
// 6. Create session
h.Sessions.Put(r.Context(), "user_id", u.ID)
h.Sessions.RenewToken(r.Context())
// 7. Handle redirect
redirectURL := r.Form.Get("next")
if redirectURL == "" {
redirectURL = "/dashboard"
}
// 8. Handle HTMX requests
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", redirectURL)
w.WriteHeader(http.StatusOK)
return
}
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
}
Login Steps:
- Parse form data
- Find user by email
- Verify password with bcrypt
- Check if account is active
- Update last_login timestamp
- Create session and renew token
- Redirect to next URL or dashboard
- Handle HTMX requests differently
Security Notes:
- ✅ Use generic error messages ("Invalid email or password") to prevent user enumeration
- ✅ Don't reveal if email exists or password is wrong
- ✅ Check is_active flag before allowing login
- ✅ Renew session token after login (prevents session fixation)
- ✅ Support "next" parameter for redirecting after login
Logout
func (h *AuthHandler) LogoutPOST(w http.ResponseWriter, r *http.Request) {
// Destroy session
h.Sessions.Destroy(r.Context())
// Redirect to home page
http.Redirect(w, r, "/", http.StatusSeeOther)
}
Important: Logout should be POST only (not GET) to prevent CSRF attacks via image tags or links.
Authorization Middleware
RequireAuth - Basic Authentication
Ensures user is logged in:
// app/gojang/http/middleware/auth.go
func RequireAuth(sm *scs.SessionManager, client *models.Client) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Get user ID from session
userID := sm.GetInt(r.Context(), "user_id")
if userID == 0 {
// Not logged in
if r.Header.Get("HX-Request") == "true" {
// HTMX request - send redirect header
w.Header().Set("HX-Redirect", "/login")
w.WriteHeader(http.StatusUnauthorized)
return
}
// Regular request - redirect with next parameter
http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
return
}
// 2. Load user from database
user, err := client.User.Get(r.Context(), userID)
if err != nil || !user.IsActive {
// User deleted or deactivated - destroy session
sm.Destroy(r.Context())
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// 3. Add user to context
ctx := context.WithValue(r.Context(), userContextKey, user)
// 4. Continue to next handler
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
Usage:
// Protect a single route
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(sessionManager, dbClient))
r.Get("/dashboard", handler.Dashboard)
})
// Protect multiple routes
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(sessionManager, dbClient))
r.Get("/profile", handler.Profile)
r.Get("/settings", handler.Settings)
r.Post("/settings", handler.SettingsUpdate)
})
LoadUser - Optional Authentication
Loads user if logged in, but doesn't require it:
func LoadUser(sm *scs.SessionManager, client *models.Client) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := sm.GetInt(r.Context(), "user_id")
if userID != 0 {
// Try to load user
user, err := client.User.Get(r.Context(), userID)
if err == nil && user.IsActive {
// Add to context
ctx := context.WithValue(r.Context(), userContextKey, user)
r = r.WithContext(ctx)
} else {
// Invalid session - clean it up
sm.Destroy(r.Context())
}
}
next.ServeHTTP(w, r)
})
}
}
Usage:
// Use on public pages that show different content for logged-in users
r.Group(func(r chi.Router) {
r.Use(middleware.LoadUser(sessionManager, dbClient))
r.Get("/", handler.Home) // Shows login/register or user menu
r.Get("/about", handler.About)
})
RequireStaff - Admin Authorization
Ensures user is staff/admin:
func RequireStaff(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get user from context (RequireAuth must be used first)
user := GetUser(r.Context())
if user == nil || !user.IsStaff {
// Not staff - forbidden
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
Usage:
// The framework admin panel already chains RequireAuth and RequireStaff.
r.Mount("/admin", admin.AdminRoutes(adminHandler, sessionManager, dbClient))
// For custom staff-only app features, use a non-admin namespace.
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(sessionManager, dbClient))
r.Use(middleware.RequireStaff)
r.Get("/staff/reports", handler.StaffReports)
})
GetUser - Retrieve Current User
func GetUser(ctx context.Context) *models.User {
user, _ := ctx.Value(userContextKey).(*models.User)
return user
}
Usage in handlers:
func (h *Handler) MyProfile(w http.ResponseWriter, r *http.Request) {
// Get current user from context
user := middleware.GetUser(r.Context())
// Use user data
h.Renderer.Render(w, r, "profile.html", &renderers.TemplateData{
Data: map[string]interface{}{
"User": user,
},
})
}
Usage in templates:
{{if .User}}
<p>Welcome, {{.User.Email}}!</p>
{{if .User.IsStaff}}
<a href="/admin">Admin Panel</a>
{{end}}
{{else}}
<a href="/login">Login</a>
{{end}}
Route Protection Patterns
Public Routes
No authentication required:
// Public routes
r.Get("/", handler.Home)
r.Get("/about", handler.About)
// Auth routes (public)
r.Get("/login", authHandler.LoginGET)
r.Post("/login", authHandler.LoginPOST)
r.Get("/register", authHandler.RegisterGET)
r.Post("/register", authHandler.RegisterPOST)
Protected Routes
Authentication required:
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(sessionManager, dbClient))
// All routes in this group require login
r.Get("/dashboard", handler.Dashboard)
r.Get("/profile", handler.Profile)
r.Post("/profile", handler.ProfileUpdate)
// Logout must be POST
r.Post("/logout", authHandler.LogoutPOST)
})
Admin Routes
Staff/admin permission required:
// Framework admin routes.
r.Mount("/admin", admin.AdminRoutes(adminHandler, sessionManager, dbClient))
// Canonical workspace URLs are /admin/t/{resource}; legacy /admin/{model}
// URLs redirect into the workspace.
Mixed Routes
Different permissions for different methods:
r.Group(func(r chi.Router) {
// Anyone can view posts
r.Get("/posts", handler.PostList)
r.Get("/posts/{id}", handler.PostView)
r.Group(func(r chi.Router) {
// Must be logged in to create/edit/delete
r.Use(middleware.RequireAuth(sessionManager, dbClient))
r.Get("/posts/new", handler.PostNew)
r.Post("/posts", handler.PostCreate)
r.Get("/posts/{id}/edit", handler.PostEdit)
r.Put("/posts/{id}", handler.PostUpdate)
r.Delete("/posts/{id}", handler.PostDelete)
})
})
Advanced Patterns
Custom Permissions
Create your own permission middleware:
// RequirePermission checks a custom permission
func RequirePermission(permissionCheck func(*models.User) bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := GetUser(r.Context())
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if !permissionCheck(user) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// Usage
r.Use(RequirePermission(func(u *models.User) bool {
return u.IsStaff && u.Email == "admin@example.com"
}))
Resource-Based Authorization
Check if user owns a resource:
func (h *PostHandler) Edit(w http.ResponseWriter, r *http.Request) {
postID, _ := strconv.Atoi(chi.URLParam(r, "id"))
user := middleware.GetUser(r.Context())
// Load post with author
post, err := h.Client.Post.Query().
Where(post.IDEQ(postID)).
WithAuthor().
Only(r.Context())
if err != nil {
h.Renderer.RenderError(w, r, http.StatusNotFound, "Post not found")
return
}
// Check ownership
if post.Edges.Author.ID != user.ID && !user.IsStaff {
h.Renderer.RenderError(w, r, http.StatusForbidden,
"You can only edit your own posts")
return
}
// User is authorized - show edit form
h.Renderer.Render(w, r, "posts/edit.partial.html", &renderers.TemplateData{
Data: map[string]interface{}{"Post": post},
})
}
Role-Based Access Control (RBAC)
Extend the User model with roles:
// app/schema/user.go
func (User) Fields() []ent.Field {
return []ent.Field{
// ... existing fields ...
field.Enum("role").
Values("user", "moderator", "admin").
Default("user"),
}
}
Create role middleware:
func RequireRole(roles ...string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := GetUser(r.Context())
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Check if user has one of the required roles
hasRole := false
for _, role := range roles {
if user.Role == role {
hasRole = true
break
}
}
if !hasRole {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// Usage
r.Use(RequireRole("admin", "moderator"))
Security Best Practices
✅ DO
Always hash passwords with bcrypt
hash, _ := utils.HashPassword(password)Use HTTPS in production (cookie Secure flag)
sessionManager.Cookie.Secure = !cfg.DebugRenew session tokens after login
sessionManager.RenewToken(ctx)Use HttpOnly cookies (prevent XSS)
sessionManager.Cookie.HttpOnly = trueImplement idle timeout (auto-logout)
sessionManager.IdleTimeout = 30 * time.MinuteCheck is_active flag before allowing login
if !user.IsActive {
return errors.New("account inactive")
}Use generic error messages (prevent user enumeration)
errors := map[string]string{
"general": "Invalid email or password",
}Protect logout with POST (not GET)
r.Post("/logout", handler.LogoutPOST)Validate on server (never trust client)
if email == "" || password == "" {
return errors.New("validation failed")
}Load user on each request (check if still active/exists)
user, err := client.User.Get(ctx, userID)
if err != nil || !user.IsActive {
sm.Destroy(ctx)
// Redirect to login
}
❌ DON'T
- Don't store plaintext passwords - Always hash
- Don't expose password hashes - Mark fields as Sensitive()
- Don't reveal user existence - Use generic error messages
- Don't skip session renewal - Prevents session fixation
- Don't trust client data - Always validate server-side
- Don't use GET for logout - Use POST to prevent CSRF
- Don't skip CSRF protection - Use built-in middleware
- Don't log sensitive data - Passwords, tokens, etc.
- Don't allow weak passwords - Implement password requirements
- Don't forget rate limiting - Prevent brute force attacks
Testing Authentication
Unit Tests
Test password hashing:
func TestHashPassword(t *testing.T) {
password := "testpassword123"
hash, err := utils.HashPassword(password)
if err != nil {
t.Fatalf("HashPassword failed: %v", err)
}
if hash == password {
t.Error("Hash should not equal plaintext")
}
match, err := utils.CheckPassword(hash, password)
if err != nil || !match {
t.Error("Password verification failed")
}
}
Test middleware:
func TestRequireAuth_NotLoggedIn(t *testing.T) {
// Create test request without session
req := httptest.NewRequest("GET", "/protected", nil)
rec := httptest.NewRecorder()
// Create handler with middleware
handler := middleware.RequireAuth(sessionManager, client)(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}),
)
handler.ServeHTTP(rec, req)
// Should redirect to login
if rec.Code != http.StatusSeeOther {
t.Errorf("Expected redirect, got %d", rec.Code)
}
}
Integration Tests
Test complete login flow:
func TestLoginFlow(t *testing.T) {
// 1. Register user
password := "testpass123"
hash, _ := utils.HashPassword(password)
user, err := client.User.Create().
SetEmail("test@example.com").
SetPasswordHash(hash).
Save(ctx)
if err != nil {
t.Fatal(err)
}
// 2. Attempt login
form := url.Values{}
form.Set("email", "test@example.com")
form.Set("password", password)
req := httptest.NewRequest("POST", "/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
authHandler.LoginPOST(rec, req)
// 3. Verify redirect
if rec.Code != http.StatusSeeOther {
t.Errorf("Expected redirect, got %d", rec.Code)
}
// 4. Verify session created
// Check session cookie, etc.
}
Common Issues & Solutions
Issue: Session Lost After Server Restart
Problem: Users are logged out when server restarts
Solution: Use persistent session store (Redis, database)
import "github.com/alexedwards/scs/mysqlstore"
// Use MySQL for session storage
sessionManager.Store = mysqlstore.New(db)
Issue: "Invalid CSRF Token"
Problem: Forms return CSRF errors
Solution: Ensure CSRF token is included in forms
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
And CSRF middleware is enabled:
r.Use(middleware.CSRF(csrfSecret))
Issue: User Enumeration
Problem: Different errors reveal if email exists
Solution: Use generic error messages
// ❌ DON'T
if !userExists {
return "User not found"
}
if !passwordMatch {
return "Wrong password"
}
// ✅ DO
return "Invalid email or password"
Issue: Session Fixation
Problem: Attacker can steal sessions
Solution: Always renew token after login
sessionManager.RenewToken(ctx)
Next Steps
- ✅ Implement: Add authentication to your custom features
- ✅ Secure: Review security best practices
- ✅ Test: Write tests for authentication flows
- ✅ Read: Deployment Guide for production setup
- ✅ Explore: Check
app/gojang/http/middleware/auth.gofor more details
Quick Reference
Common Functions
// Password hashing
hash, err := utils.HashPassword(password)
match, err := utils.CheckPassword(hash, password)
// Session management
sessionManager.Put(ctx, "user_id", id)
userID := sessionManager.GetInt(ctx, "user_id")
sessionManager.RenewToken(ctx)
sessionManager.Destroy(ctx)
// Get current user
user := middleware.GetUser(ctx)
Middleware Chain
// Public route
r.Get("/public", handler.Public)
// Protected route
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(sm, client))
r.Get("/protected", handler.Protected)
})
// Custom staff-only app route
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(sm, client))
r.Use(middleware.RequireStaff)
r.Get("/staff", handler.StaffDashboard)
})
Happy coding securely! 🔐