Json Web Token

2024. 11. 26. 14:47·devops/go

이번엔 JWT, 그러니까 JSON Web Token에 대해 이야기해보려고한다. 이거 좀 중요한 거기도 하고 backend에서 많이 사용하는 방식이니까 알아두면 좋다.

 

JWT가 뭐야?

JWT는 로그인하면 서버가 주는 그런 신분증 같은 거라고 보면된다. 세가지분으로 나뉘어가지고

 

    1. 헤더(Header) 이건 어떻게 JWT를 만들었는지 설명한다. 예를들어

{
  "alg": "HS256",
  "typ": "JWT"
}

 


    2. 페이로드(Payload) 여기에는 로그인하는사람의 정보가 들어간다. 예를 들면

{
  "sub": "너의 아이디",
  "name": "너의 이름",
  "iat": "토큰 발급 시간"
}


    3. 서명(signature) - 헤더하고 페이로드를 비밀번호 같은 걸로 암호화한거다. 이걸로 서버는 "이 토큰 진짜야?"하고 확인한다.

 

JWT 어떻게 써?

로그인할 때 - 로그인하면 서버에서 JWT를 준다. 이걸 브라우저의 쿠키나 로컬 스토리지에 저장해둔다. 다른 페이지에가거나 API를 요청할 때 이 JWT를 같이 보낸다. 그리고 서버는 이 토큰을 확인하고 "아, 이사람 로그인한 사람 맞네"하고 넘어간다.

장점

  • 확장성 - 서버는 세션 관리할 필요가 없다. JWT만 있으면 되니까. 그래서 여러개의 서버로 확장하기도 쉽다.
  • 안정성 - 데이터가 서명되어있으니까 중간에 누가 가로채가서 바꿔치기하면 바로 알아챌 수 있다.
  • 정보교환 - JSON 형태니까 다루기가 매우 편한다.

단점

  • 토큰 크기 - 정보가 많아지면 당연하겠지만 토큰이 커진다.
  • 재발급 - 토큰이 만료되면 새로운 토큰이 필요하다. 이부분이 해석이 필요한데 만약에 토큰 유효기간을 길게 가져갔을 때 이 토큰이 탈취당하면 로그인이 뚫린다. 그렇다고 해서 만약 유효기간을 엄청 짧게하면 뭐 할 때마다 로그인 요청을 하기 때문에 비용(단순히 돈뿐만이 아니고) 유져도 이탈할 가능성이 커진다 그래서 유효기간이 적절하게 세팅하는게 중요하다.

여기까지가 JWT에 대한 간단한 개론이었고 Golang에서 붙여보자.


폴더구조

당연한 얘기겠지만 JWT로그인을 하기 위해서는 회원가입부터 필요하다. 회원이 있어야 로그인 처리를 할테니까 말이다.

그래서 먼저 User에 관한 파일부터 손보기로한다.

user service 폴더구조

먼저 최종 JSON 리스폰스가 어떻게 나올지부터 짜면 좋을 것같다. Bottom-Up 방법보다. Top-Bottom 방식이 나는 개발하기에 더 편한거 같다.

 

package presenter

import (
	"camping-backend-with-go/pkg/entities"
	"github.com/gofiber/fiber/v2"
)

type User struct {
	Id       uint   `json:"id"`
	Email    string `json:"email"`
	Username string `json:"username"`
}

func UserErrorResponse(err error) *fiber.Map {
	return &fiber.Map{
		"status": false,
		"data":   "",
		"error":  err.Error(),
	}
}

func UserSuccessResponse(data *entities.User) *fiber.Map {
	user := User{
		Id:       data.Id,
		Email:    data.Email,
		Username: data.Username,
	}
	return &fiber.Map{
		"status": true,
		"data":   user,
		"error":  nil,
	}
}

 

이렇게 presenter를 만들었으면

Router세팅을 하자. Router에는 Service함수와 HTTP Controller를 연결한다.(아직 Service 함수는 구현하지 않았다.)

 

package routes

import (
	"camping-backend-with-go/api/handlers"
	"camping-backend-with-go/pkg/user"
	"github.com/gofiber/fiber/v2"
)

func UserRouter(app fiber.Router, service user.Service) {
	app.Post("/user", handlers.CreateUser(service))
}

 

 

Router를 세팅했으면 이제 Controller를 만들자.

package handlers

import (
	"camping-backend-with-go/api/presenter"
	"camping-backend-with-go/pkg/entities"
	"camping-backend-with-go/pkg/user"
	"errors"
	"github.com/gofiber/fiber/v2"
	"net/http"
)

func CreateUser(service user.Service) fiber.Handler {
	return func(c *fiber.Ctx) error {
		var requestBody entities.User
		err := c.BodyParser(&requestBody)
		if err != nil {
			c.Status(http.StatusBadRequest)
			return c.JSON(presenter.UserErrorResponse(err))
		}
		if requestBody.Email == "" {
			c.Status(http.StatusInternalServerError)
			return c.JSON(presenter.UserErrorResponse(errors.New(
				"Please specify title and author",
			)))
		}

		result, err := service.CreateUser(&requestBody)
		if err != nil {
			return c.Status(http.StatusBadRequest).JSON(presenter.UserErrorResponse(err))
		}

		return c.JSON(presenter.UserSuccessResponse(result))
	}
}

여기서 주목해야할 부분은 service.CreateUser(&requestBody)인데 이부분을 구현하겠다.

 

package user

import "camping-backend-with-go/pkg/entities"

type Service interface {
	CreateUser(user *entities.User) (*entities.User, error)
}

type service struct {
	repository Repository
}

func NewService(r Repository) Service {
	return &service{
		repository: r,
	}
}

func (s *service) CreateUser(user *entities.User) (*entities.User, error) {
	return s.repository.CreateUser(user)
}

이렇게 Repository와 연결하도록 하겠다.

당연히 Repository가 없으니 만들어주도록한다.

 

package auth

import (
	"camping-backend-with-go/pkg/entities"
	"errors"
	"github.com/golang-jwt/jwt/v5"
	"golang.org/x/crypto/bcrypt"
	"gorm.io/gorm"
)

type Repository interface {
	Login(loginInput *entities.Login) (string, error)
	CheckPasswordHash(password, hash string) bool
	GetUserByEmail(email string) (*entities.User, error)
}

type repository struct {
	DBConn *gorm.DB
}

func NewRepo(dbconn *gorm.DB) Repository {
	return &repository{DBConn: dbconn}
}

func (r *repository) Login(loginInput *entities.Login) (string, error) {
	user, err := r.GetUserByEmail(loginInput.Email)
	if err != nil {
		return "", err
	}

	if !r.CheckPasswordHash(loginInput.Password, user.Password) {
		return "", errors.New("password didn't not match")
	}

	token := jwt.New(jwt.SigningMethodHS256)

	claims := token.Claims.(jwt.MapClaims)
	claims["user_id"] = user.Id

	t, err := token.SignedString([]byte("SECRET"))
	if err != nil {
		return "", err
	}

	return t, nil
}

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

func (r *repository) GetUserByEmail(email string) (*entities.User, error) {
	var user entities.User
	
	if err := r.DBConn.Where(entities.User{Email: email}).First(&user).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, err
		}
		return nil, err
	}
	return &user, nil
}

 

이렇게 하면 회원가입이 완성된다.

성공적으로 회원가입이 완료되었다. test계정이라 비밀번호는 password로 간단히 주었다.

 


auth라는 package로 만들자. 원리는 위와 같으므로 넘어간다.

 

JSON Response를 만들어주고

package presenter

import "github.com/gofiber/fiber/v2"

func AuthErrorResponse(err error) *fiber.Map {
	return &fiber.Map{
		"status": false,
		"data":   "",
		"error":  err.Error(),
	}
}

func AuthSuccessfulResponse(data any) *fiber.Map {
	return &fiber.Map{
		"status": true,
		"data":   data,
		"error":  false,
	}
}

 

 

Routes를 만들고

package routes

import (
    "camping-backend-with-go/api/handlers"
    "camping-backend-with-go/pkg/auth"
    "github.com/gofiber/fiber/v2"
)

func AuthRouter(app fiber.Router, service auth.Service) {
    app.Post("/auth/login", handlers.Login(service))
}

 

 

Handler를 장착한다.

package handlers

import (
	"camping-backend-with-go/api/presenter"
	"camping-backend-with-go/pkg/auth"
	"camping-backend-with-go/pkg/entities"
	"github.com/gofiber/fiber/v2"
	"net/http"
)

func Login(service auth.Service) fiber.Handler {
	return func(c *fiber.Ctx) error {
		// request parser
		var requestBody entities.Login
		if err := c.BodyParser(&requestBody); err != nil {
			return c.Status(http.StatusBadRequest).JSON(presenter.AuthErrorResponse(err))
		}

		token, err := service.Login(&requestBody)
		if err != nil {
			return c.Status(http.StatusBadRequest).JSON(presenter.AuthErrorResponse(err))
		}

		return c.Status(http.StatusOK).JSON(presenter.AuthSuccessfulResponse(token))
	}
}

 

이제 API가 되었으니 pkg부분을 작성한다.

service부터 만든다.

package auth

import "camping-backend-with-go/pkg/entities"

type Service interface {
	Login(loginInput *entities.Login) (string, error)
}

type service struct {
	repository Repository
}

func NewService(r Repository) Service {
	return &service{repository: r}
}

func (s *service) Login(loginInput *entities.Login) (string, error) {
	return s.repository.Login(loginInput)
}

 

그리고 repository를 만들고나서

package auth

import (
	"camping-backend-with-go/pkg/entities"
	"errors"
	"github.com/golang-jwt/jwt/v5"
	"golang.org/x/crypto/bcrypt"
	"gorm.io/gorm"
)

type Repository interface {
	Login(loginInput *entities.Login) (string, error)
	CheckPasswordHash(password, hash string) bool
	GetUserByEmail(email string) (*entities.User, error)
}

type repository struct {
	DBConn *gorm.DB
}

func NewRepo(dbconn *gorm.DB) Repository {
	return &repository{DBConn: dbconn}
}

func (r *repository) Login(loginInput *entities.Login) (string, error) {
	user, err := r.GetUserByEmail(loginInput.Email)
	if err != nil {
		return "", err
	}

	if !r.CheckPasswordHash(loginInput.Password, user.Password) {
		return "", errors.New("password didn't not match")
	}

	token := jwt.New(jwt.SigningMethodHS256)

	claims := token.Claims.(jwt.MapClaims)
	claims["user_id"] = user.Id

	t, err := token.SignedString([]byte("SECRET"))
	if err != nil {
		return "", err
	}

	return t, nil
}

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

func (r *repository) GetUserByEmail(email string) (*entities.User, error) {
	var user entities.User
	
	if err := r.DBConn.Where(entities.User{Email: email}).First(&user).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, err
		}
		return nil, err
	}
	return &user, nil
}

 

 

마무리로 main에 연결해주면

package main

import (
	"camping-backend-with-go/api/routes"
	"camping-backend-with-go/pkg/auth"
	"camping-backend-with-go/pkg/entities"
	"camping-backend-with-go/pkg/healthcheck"
	"camping-backend-with-go/pkg/spot"
	"camping-backend-with-go/pkg/user"
	"github.com/gofiber/fiber/v2/middleware/cors"
	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
	"log"
	"os"

	"github.com/gofiber/fiber/v2"
)

func main() {
	db := databaseConnection()

	healthcheckService := healthcheck.NewService()

	userRepo := user.NewRepo(db)
	userService := user.NewService(userRepo)

	authRepo := auth.NewRepo(db)
	authService := auth.NewService(authRepo)

	spotRepo := spot.NewRepo(db)
	spotService := spot.NewService(spotRepo)

	app := fiber.New()
	app.Use(cors.New())

	v1 := app.Group("/v1")

	routes.UserRouter(v1, userService)
	routes.AuthRouter(v1, authService)

	routes.SpotRouter(v1, spotService)
	routes.HealthCheckRouter(v1, healthcheckService)
	log.Fatal(app.Listen(":3000"))
}

func databaseConnection() *gorm.DB {
	//dsn := "user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local"
	//db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})

	db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})

	if err != nil {
		log.Fatal("Failed to connect to database. \n", err)
		os.Exit(2)
	}

	log.Println("connected")
	err = db.AutoMigrate(
		&entities.Spot{},
		&entities.User{},
	)
	if err != nil {
		log.Println(err.Error())
	}

	return db
}

 

그리고 이제 token값을 확인해보자.

 

토큰값이 생성되는 것을 볼 수있다. 이제 이것을

https://jwt.io/

 

JWT.IO

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

jwt.io

에서 붙여넣어보자.

 

algorithm과 payload, signature가 나오게 되는데 user_id가 3으로 찍혀있는 것을 볼 수 있다. 실제로

DB에서 확인해보면 test1@example.com이라는 계정은

id가 3으로 나와있는 것을 볼 수 있다. 아주 간단히 회원가입과 로그인을 알아봤다. 세부적인 로직이나 내용은 Golang과 Web에 대한 내용이기 때문에 다른 자료를 찾아보길 권한다. 아니면 나중에 포스팅할 기회가 있을 때 하도록 하겠다.

 

이렇게 API를 따로 관리하려고 하니까 너무 많은 공수가 들어서

다음번엔 swagger를 붙이도록 하겠다.

 

이런식으로 API를 한눈에 바라볼 수 있다.

 

관련 코드는 모두 https://github.com/ggorockee/camping-backend-with-go

 

GitHub - ggorockee/camping-backend-with-go

Contribute to ggorockee/camping-backend-with-go development by creating an account on GitHub.

github.com

에 올려두었으니 참고하면 될 듯 하다.

 

'devops > go' 카테고리의 다른 글

swagger가 뭔가 좀 이상하다.  (0) 2024.11.27
go fiber에 swagger를 붙여보자.  (0) 2024.11.27
Clean Architecture with go (Feat. fiber)  (1) 2024.11.25
API(Get List)  (0) 2024.11.24
clean-architecture 구성  (30) 2024.11.24
'devops/go' 카테고리의 다른 글
  • swagger가 뭔가 좀 이상하다.
  • go fiber에 swagger를 붙여보자.
  • Clean Architecture with go (Feat. fiber)
  • API(Get List)
꼬락이
꼬락이
ggorockee 님의 블로그 입니다.
  • 꼬락이
    꼬락이의 개발일지
    꼬락이
  • 전체
    오늘
    어제
    • 분류 전체보기 (30)
      • devops (28)
        • aws (0)
        • minikube (18)
        • go (10)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Teleport
    port-forwarding
    cert-manager
    CICD
    DB 연결
    repository
    JWT
    쿠버네티스
    clean-archtiecture
    ArgoCD
    GO
    Gorm
    Minikube
    aws
    db 우회
    SWAGGER
    helm
    CI
    argoc
    istio
    Clean Architecture
    EC2
    Github
    k8s
    rds
    Kubernetes
    systemd
    Github action
    yq
    golang
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.1
꼬락이
Json Web Token
상단으로

티스토리툴바