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

レッスン 11: 認証 - JWT と OAuth2

golang-jwt を使用した JWT、bcrypt パスワード ハッシュ。アクセストークン、リフレッシュトークン、トークンローテーション。 OAuth2 フロー、Google/GitHub によるソーシャル ログイン。セッション管理。

💻 プログラミング — レッスン 11 レッスン 11: 認証 - JWT と OAuth2

Golang: 基本から高度まで

パート 3: データベースと認証

xdev.asia

1. 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認証

2.1.セットアップ

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

2.2.サービストークン

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. 認証サービス

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. 認証ミドルウェア

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. 認証ハンドラー

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. セキュリティのベストプラクティス

  • パスワード: bcrypt (コスト ≥ 10)、md5/sha256 のみなし
  • JWT: 有効期間の短いアクセス トークン (15 分)、有効期間の長いリフレッシュ トークン (7 日間)
  • トークンローテーション: リフレッシュ時に新しいトークンペアを発行します
  • 秘密: 環境変数を使用し、コード内にハードコードを使用しないでください。
  • HTTPS: 運用環境では常に HTTPS を使用します
  • レート制限: ログイン試行を制限します (ブルート フォース保護)
  • エラーメッセージ: 一般的な「無効な資格情報」、電子メールの存在は明らかにされません

次の記事: 認可、セキュリティ、ミドルウェア — RBAC、Casbin、および高度なセキュリティ。