Chuyển đến nội dung chính

Bài 11: Authentication - JWT & OAuth2

JWT với golang-jwt, bcrypt password hashing. Access token, refresh token, token rotation. OAuth2 flows, social login với Google/GitHub. Session management.

💻 Lập trình — Bài 11 Bài 11: Authentication - JWT & OAuth2

Golang: Từ Cơ bản đến Nâng cao

Phần 3: Database & Authentication

xdev.asia

1. Password Hashing với bcrypt

go get golang.org/x/crypto/bcrypt
import "golang.org/x/crypto/bcrypt"

func HashPassword(password string) (string, error) {
    bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    return string(bytes), err
}

func CheckPassword(password, hash string) bool {
    err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
    return err == nil
}

// ⚠️ bcrypt rules:
// - DefaultCost = 10 (OK cho 2026)
// - Max password length = 72 bytes
// - Nếu password > 72 bytes, hash SHA-256 trước rồi bcrypt
// - KHÔNG dùng MD5, SHA-256 alone cho passwords

2. JWT Authentication

2.1. Setup

go get github.com/golang-jwt/jwt/v5

2.2. Token Service

package auth

import (
    "fmt"
    "time"
    
    "github.com/golang-jwt/jwt/v5"
)

type TokenService struct {
    accessSecret  []byte
    refreshSecret []byte
    accessTTL     time.Duration
    refreshTTL    time.Duration
}

func NewTokenService(accessSecret, refreshSecret string) *TokenService {
    return &TokenService{
        accessSecret:  []byte(accessSecret),
        refreshSecret: []byte(refreshSecret),
        accessTTL:     15 * time.Minute,
        refreshTTL:    7 * 24 * time.Hour, // 7 days
    }
}

// Custom claims
type AccessClaims struct {
    UserID uint   `json:"user_id"`
    Email  string `json:"email"`
    Role   string `json:"role"`
    jwt.RegisteredClaims
}

type RefreshClaims struct {
    UserID uint `json:"user_id"`
    jwt.RegisteredClaims
}

// Generate access token
func (s *TokenService) GenerateAccessToken(userID uint, email, role string) (string, error) {
    claims := AccessClaims{
        UserID: userID,
        Email:  email,
        Role:   role,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.accessTTL)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            Issuer:    "my-api",
            Subject:   fmt.Sprintf("%d", userID),
        },
    }
    
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(s.accessSecret)
}

// Generate refresh token
func (s *TokenService) GenerateRefreshToken(userID uint) (string, error) {
    claims := RefreshClaims{
        UserID: userID,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.refreshTTL)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            Issuer:    "my-api",
        },
    }
    
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(s.refreshSecret)
}

// Generate token pair
type TokenPair struct {
    AccessToken  string `json:"access_token"`
    RefreshToken string `json:"refresh_token"`
    ExpiresIn    int64  `json:"expires_in"` // seconds
}

func (s *TokenService) GenerateTokenPair(userID uint, email, role string) (*TokenPair, error) {
    accessToken, err := s.GenerateAccessToken(userID, email, role)
    if err != nil {
        return nil, fmt.Errorf("generate access token: %w", err)
    }
    
    refreshToken, err := s.GenerateRefreshToken(userID)
    if err != nil {
        return nil, fmt.Errorf("generate refresh token: %w", err)
    }
    
    return &TokenPair{
        AccessToken:  accessToken,
        RefreshToken: refreshToken,
        ExpiresIn:    int64(s.accessTTL.Seconds()),
    }, nil
}

// Validate access token
func (s *TokenService) ValidateAccessToken(tokenString string) (*AccessClaims, error) {
    token, err := jwt.ParseWithClaims(tokenString, &AccessClaims{}, func(token *jwt.Token) (interface{}, error) {
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
        }
        return s.accessSecret, nil
    })
    
    if err != nil {
        return nil, fmt.Errorf("parse token: %w", err)
    }
    
    claims, ok := token.Claims.(*AccessClaims)
    if !ok || !token.Valid {
        return nil, fmt.Errorf("invalid token claims")
    }
    
    return claims, nil
}

// Validate refresh token
func (s *TokenService) ValidateRefreshToken(tokenString string) (*RefreshClaims, error) {
    token, err := jwt.ParseWithClaims(tokenString, &RefreshClaims{}, func(token *jwt.Token) (interface{}, error) {
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
        }
        return s.refreshSecret, nil
    })
    
    if err != nil {
        return nil, fmt.Errorf("parse refresh token: %w", err)
    }
    
    claims, ok := token.Claims.(*RefreshClaims)
    if !ok || !token.Valid {
        return nil, fmt.Errorf("invalid refresh token")
    }
    
    return claims, nil
}

3. Auth Service

package service

type AuthService struct {
    userRepo     repository.UserRepository
    tokenService *auth.TokenService
}

func NewAuthService(userRepo repository.UserRepository, tokenService *auth.TokenService) *AuthService {
    return &AuthService{
        userRepo:     userRepo,
        tokenService: tokenService,
    }
}

type LoginInput struct {
    Email    string `json:"email"    binding:"required,email"`
    Password string `json:"password" binding:"required"`
}

type RegisterInput struct {
    Name     string `json:"name"     binding:"required,min=2,max=50"`
    Email    string `json:"email"    binding:"required,email"`
    Password string `json:"password" binding:"required,min=8"`
}

func (s *AuthService) Login(ctx context.Context, input LoginInput) (*auth.TokenPair, error) {
    user, err := s.userRepo.GetByEmail(ctx, input.Email)
    if err != nil {
        return nil, fmt.Errorf("invalid credentials")
    }
    
    if !auth.CheckPassword(input.Password, user.Password) {
        return nil, fmt.Errorf("invalid credentials")
    }
    
    if !user.IsActive {
        return nil, fmt.Errorf("account is deactivated")
    }
    
    tokens, err := s.tokenService.GenerateTokenPair(user.ID, user.Email, user.Role)
    if err != nil {
        return nil, fmt.Errorf("generate tokens: %w", err)
    }
    
    return tokens, nil
}

func (s *AuthService) Register(ctx context.Context, input RegisterInput) (*auth.TokenPair, error) {
    // Check duplicate email
    existing, _ := s.userRepo.GetByEmail(ctx, input.Email)
    if existing != nil {
        return nil, fmt.Errorf("email already registered")
    }
    
    hashedPassword, err := auth.HashPassword(input.Password)
    if err != nil {
        return nil, fmt.Errorf("hash password: %w", err)
    }
    
    user := &model.User{
        Name:     input.Name,
        Email:    input.Email,
        Password: hashedPassword,
        Role:     "user",
        IsActive: true,
    }
    
    if err := s.userRepo.Create(ctx, user); err != nil {
        return nil, fmt.Errorf("create user: %w", err)
    }
    
    return s.tokenService.GenerateTokenPair(user.ID, user.Email, user.Role)
}

func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*auth.TokenPair, error) {
    claims, err := s.tokenService.ValidateRefreshToken(refreshToken)
    if err != nil {
        return nil, fmt.Errorf("invalid refresh token")
    }
    
    user, err := s.userRepo.GetByID(ctx, claims.UserID)
    if err != nil {
        return nil, fmt.Errorf("user not found")
    }
    
    if !user.IsActive {
        return nil, fmt.Errorf("account is deactivated")
    }
    
    // Token rotation: generate new token pair
    return s.tokenService.GenerateTokenPair(user.ID, user.Email, user.Role)
}

4. Auth Middleware

package middleware

import (
    "net/http"
    "strings"
    
    "github.com/gin-gonic/gin"
)

type contextKey string

const (
    UserIDKey   contextKey = "user_id"
    UserRoleKey contextKey = "user_role"
)

func AuthMiddleware(tokenService *auth.TokenService) gin.HandlerFunc {
    return func(c *gin.Context) {
        authHeader := c.GetHeader("Authorization")
        if authHeader == "" {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
                "error": "Authorization header required",
            })
            return
        }
        
        // Extract Bearer token
        parts := strings.SplitN(authHeader, " ", 2)
        if len(parts) != 2 || parts[0] != "Bearer" {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
                "error": "Invalid authorization format. Use: Bearer {token}",
            })
            return
        }
        
        // Validate token
        claims, err := tokenService.ValidateAccessToken(parts[1])
        if err != nil {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
                "error": "Invalid or expired token",
            })
            return
        }
        
        // Set user info in context
        c.Set(string(UserIDKey), claims.UserID)
        c.Set(string(UserRoleKey), claims.Role)
        c.Set("user_email", claims.Email)
        
        c.Next()
    }
}

// Helper: get current user ID from context
func GetUserID(c *gin.Context) uint {
    id, _ := c.Get(string(UserIDKey))
    return id.(uint)
}

func GetUserRole(c *gin.Context) string {
    role, _ := c.Get(string(UserRoleKey))
    return role.(string)
}

// Role-based middleware
func RequireRole(roles ...string) gin.HandlerFunc {
    return func(c *gin.Context) {
        userRole := GetUserRole(c)
        
        for _, role := range roles {
            if userRole == role {
                c.Next()
                return
            }
        }
        
        c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
            "error": "Insufficient permissions",
        })
    }
}

5. Auth Handlers

package handler

type AuthHandler struct {
    authService *service.AuthService
}

func NewAuthHandler(authService *service.AuthService) *AuthHandler {
    return &AuthHandler{authService: authService}
}

func (h *AuthHandler) Login(c *gin.Context) {
    var input service.LoginInput
    if err := c.ShouldBindJSON(&input); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input"})
        return
    }
    
    tokens, err := h.authService.Login(c.Request.Context(), input)
    if err != nil {
        c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
        return
    }
    
    c.JSON(http.StatusOK, gin.H{
        "success": true,
        "data":    tokens,
    })
}

func (h *AuthHandler) Register(c *gin.Context) {
    var input service.RegisterInput
    if err := c.ShouldBindJSON(&input); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input"})
        return
    }
    
    tokens, err := h.authService.Register(c.Request.Context(), input)
    if err != nil {
        c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
        return
    }
    
    c.JSON(http.StatusCreated, gin.H{
        "success": true,
        "data":    tokens,
    })
}

func (h *AuthHandler) RefreshToken(c *gin.Context) {
    var input struct {
        RefreshToken string `json:"refresh_token" binding:"required"`
    }
    if err := c.ShouldBindJSON(&input); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input"})
        return
    }
    
    tokens, err := h.authService.RefreshToken(c.Request.Context(), input.RefreshToken)
    if err != nil {
        c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
        return
    }
    
    c.JSON(http.StatusOK, gin.H{
        "success": true,
        "data":    tokens,
    })
}

func (h *AuthHandler) GetProfile(c *gin.Context) {
    userID := middleware.GetUserID(c)
    
    user, err := h.authService.GetProfile(c.Request.Context(), userID)
    if err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
        return
    }
    
    c.JSON(http.StatusOK, gin.H{
        "success": true,
        "data":    user,
    })
}

// Router setup
func SetupAuthRoutes(r *gin.Engine, authHandler *AuthHandler, tokenService *auth.TokenService) {
    auth := r.Group("/auth")
    {
        auth.POST("/login", authHandler.Login)
        auth.POST("/register", authHandler.Register)
        auth.POST("/refresh", authHandler.RefreshToken)
    }
    
    // Protected routes
    protected := r.Group("/api")
    protected.Use(middleware.AuthMiddleware(tokenService))
    {
        protected.GET("/profile", authHandler.GetProfile)
        
        // Admin only
        admin := protected.Group("/admin")
        admin.Use(middleware.RequireRole("admin"))
        {
            admin.GET("/users", adminListUsers)
            admin.DELETE("/users/:id", adminDeleteUser)
        }
    }
}

6. Security Best Practices

  • Passwords: bcrypt (cost ≥ 10), KHÔNG md5/sha256 alone
  • JWT: Short-lived access tokens (15 min), long-lived refresh tokens (7 days)
  • Token rotation: Issue new token pair khi refresh
  • Secrets: Dùng env vars, KHÔNG hardcode trong code
  • HTTPS: Luôn dùng HTTPS trong production
  • Rate limiting: Limit login attempts (brute force protection)
  • Error messages: Generic "Invalid credentials", không tiết lộ email tồn tại

Bài tiếp theo: Authorization, Security & Middleware — RBAC, Casbin, và security nâng cao.