
이번에는 JWT(JSON Web Tokens)를 구현 해 보겠습니다. JWT는 선택적 서명 및 선택적 암호화를 사용하여 데이터를 만들기 위한 인터넷 표준으로 정해진 규칙에 따라 base64형식으로 인코딩된 데이터를 디코드하여 인증을 확인하는 시스템입니다.
golang에 관심이 있어 시작해보려고한다면 공식 홈페이지의 "A Tour of Go"를 해보는것을 강력하게 추천합니다. go를 설치하지않고 따라가기만하면 금방 습득할 수 있고 기초적인 문법들을 이해하기 쉽습니다. 한국어도 지원합니다.
https://go.dev/tour/welcome/1
개발순서
A. gin을 이용한 기본적인 웹 서버 구동
B. mariaDB를 이용한 회원가입(register)과 로그인 구현
C. jwt(JSON Web Tokens) 구현
D. 블로그 아티클을 DB에 저장 및 RESTFUL API 구현
사용하는 프로그램들과 버전
linux debian 12.0
golang 1.23.1 / https://go.dev/
gin-gonic / https://github.com/gin-gonic/gin
gorm / https://gorm.io/
golang-jwt / https://github.com/dgrijalva/jwt-go
(github.com/golang-jwt/jwt로 대체됨)
godotenv / https://github.com/joho/godotenv
go-crypto / https://cs.opensource.google/go/x/crypto
vscode
vscode extenstion - Go, Thunder-client
저번 글에서 LoginCheck()에서 로그인 정보가 맞을 시에 JWT토큰을 발행하는 GenerateToken()을 구현해 보겠습니다.
utils라는 폴더를 생성한 후에 token.go라는 파일을 생성하겠습니다.
$mkdir utils
$mkdir ./utils/token
$touch ./utils/token/token.go
token.go파일에 다음 코드를 추가합니다.
package token
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
jwt "github.com/dgrijalva/jwt-go"
)
func GenerateToken(user_id uint) (string, error) {
token_lifespan,err := strconv.Atoi(os.Getenv("TOKEN_HOUR_LIFESPAN"))
if err != nil {
return "",err
}
claims := jwt.MapClaims{}
claims["authorized"] = true
claims["user_id"] = user_id
claims["exp"] = time.Now().Add(time.Hour * time.Duration(token_lifespan)).Unix()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(os.Getenv("API_SECRET")))
}
func TokenValid(c *gin.Context) error {
tokenString := ExtractToken(c)
_, err := jwt.Parse(tokenString, 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 []byte(os.Getenv("API_SECRET")), nil
})
if err != nil {
return err
}
return nil
}
func ExtractToken(c *gin.Context) string {
token := c.Query("token")
if token != "" {
return token
}
bearerToken := c.Request.Header.Get("Authorization")
if len(strings.Split(bearerToken, " ")) == 2 {
return strings.Split(bearerToken, " ")[1]
}
return ""
}
func ExtractTokenID(c *gin.Context) (uint, error) {
tokenString := ExtractToken(c)
token, err := jwt.Parse(tokenString, 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 []byte(os.Getenv("API_SECRET")), nil
})
if err != nil {
return 0, err
}
claims, ok := token.Claims.(jwt.MapClaims)
if ok && token.Valid {
uid, err := strconv.ParseUint(fmt.Sprintf("%.0f", claims["user_id"]), 10, 32)
if err != nil {
return 0, err
}
return uint(uid), nil
}
return 0, nil
}
GenerateToken()은 토튼에 필요한 정보를 저장하고 이것을 JWT Token형식으로 변환해 리턴합니다. claims["exp"]는 토큰의 유효기간을 정하는것인데, .env파일에 TOKEN_HOUR_LIFESPAN 이라는 새로운 인자를 추가하여 값에 따라 최대 유효기간을 설정할 수 있습니다.
TokenValid()는 사용자가 보낸 데이터에 Authorization부분에 있는 데이터를 받아와 정상적인 토큰인지 확인합니다. 현재 구현한 valid의 경우 헤더에 algoritm을 입력하는 부분이 변조되었을경우 오류를 반환합니다. JWT의 경우 no-sql 데이터베이스를 이용하여 저장, 운용하는것이 바람직합니다. 현 예제에서는 mariaDB를 사용하고있기 떄문에, access token의 유효 여부만 확인하는것으로 하겠습니다.

(이미지 출처: Eon Kims님의 JWT란 무엇인가? 그리고 어떻게 사용하는가? (2) - 사용처 )
.env파일에 다음 코드를 추가하고 작동해봅시다.
API_SECRET=<secretstring>
TOKEN_HOUR_LIFESPAN=1

token이라는 key값에 토큰이 반환되어 나왔습니다.
그렇다면 이 토큰을 가지고 JWT인증을 하는 미들웨어를 제작하고 인증이 필요한 api콜을 만들어봅시다.
미들웨어를 위한 새로운 폴더와 파일을 만들어 봅시다.
$mkdir middlewares
$touch middlewares/middlewares.go
middlewares.go파일에 다음 코드를 추가해줍시다.
// middlewares/middlewares.go
package middlewares
import (
"net/http"
"github.com/gin-gonic/gin"
"blog/utils/token"
)
func JwtAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
err := token.TokenValid(c)
if err != nil {
c.String(http.StatusUnauthorized, "Unauthorized")
c.Abort()
return
}
c.Next()
}
}
이 미들웨어를 요청에 있는 토큰이 올바른 토큰인지 확인하는 펑션입니다.
/api/auth라는 경로로 요청이 들어올 시에 다음 미들웨어를 실행하도록 main.go를 수정하겠습니다.
package main
import (
"github.com/gin-gonic/gin"
"blog/controllers"
"blog/models"
"blog/middlewares"
)
func main() {
models.ConnectDataBase()
r := gin.Default()
public := r.Group("/api")
public.POST("/register", controllers.Register)
public.POST("/login",controllers.Login)
protected := r.Group("/api/admin")
protected.Use(middlewares.JwtAuthMiddleware())
protected.GET("/user",controllers.CurrentUser)
r.Run(":8080")
}
protected라는 새로운 그룹을 제작하고, 사용시에 JwtAuthMiddleware() 미들웨어를 거치도록 설정합니다. 그럼 현재 유저를 반환하는 API를 추가해 봅시다.
...
// controllers/auth.go
func CurrentUser(c *gin.Context){
user_id, err := token.ExtractTokenID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
u,err := models.GetUserByID(user_id)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message":"success","data":u})
}
...
데이터베이스에서 유저 아이디을 키로 데이터를 읽고 이 유저 데이터를 반환하는 코드를 작성해봅시다
//models.user.go
...
func GetUserByID(uid uint) (User,error) {
var u User
if err := DB.First(&u,uid).Error; err != nil {
return u,errors.New("User not found!")
}
u.PrepareGive()
return u,nil
}
func (u *User) PrepareGive(){
u.Password = ""
}
...
데이터베이스에서 유저 스트럭처를 반환시에 해시화된 패스워드 데이터를 포함하고 있기 때문에, PrepareGive()를 통해 빈 데이터로 바꿔줍시다.
이제 로그인 데이터에서 받은 토큰을 header에 추가한 후에 유저 데이터를 요청해봅시다.

Thunder-client에서 Bearer부분에 토큰을 복사하여 입력한 후에 GET요청을 보내 확인해봅시다.
다음글에서는 블로그글을 관리하는 RESTFUL API를 제작해봅시다.