ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [golang] JWT 인증 구현 (feat. 유저등록기능, 로그인기능)
    프로그래밍/golang 2021. 12. 22. 13:06
    반응형
    반응형

    개발환경

    OS : Ubuntu 21.10
    Language : Golang 1.17 with Echo(Go web framework)
    IDE : Visual Studio Code
    DB : MariaDB 10.5.13

    목차

    • 프로젝트 폴더 생성 및 사전 준비
    • DB 커넥션 설정 및 회원가입 로그인 테스트
    • JWT 발급 및 인증 구현
    • 마무리

    1. 프로젝트 폴더 생성 및 사전 준비

    워크스페이스로 사용할 폴더 하위에 이번 프로젝트로 사용할 폴더를 생성한다.

    mkdir test-jwt

    test-jwt 폴더로 이동한 후에 이번 프로젝트의 시작점이 될 main.go 파일을 생성하고 아래 내용을 입력한다.

    // main.go
    package main
    
    import "fmt"
    
    func main() {
        fmt.Println("Hello World!")
    }

    코드를 실행한다.

    go run main.go

    실행결과

    Hello World!

    이제 main.go 파일이 있는 경로에 go.mod 파일을 생성한다.

    go mod init test-jwt

    다음 단계로 넘어갑니다.


    2. DB 커넥션 설정 및 회원가입 로그인 테스트

    이번 실습은 MariaDB를 사용할 것이다.

    설치 과정 및 환경 세팅은 다루지 않는다. 그러므로 우선 설치 및 환경이 완료됐다는 가정하에 밑의 내용으로 넘어간다.

    일단 한 개의 유저 테이블을 구성해놓는다.

    필드는 3개로 id, email, password로 구성하며 실제 프로덕션에서는 관리를 위해 생성일, 가장 마지막 로그인, 상태 등으로 더 많은 필드를 갖지만 JWT를 발행시키기 위해 로그인 검증에 필요한 최소한에 필드만 생성한다.

    - id

    - email

    - password

    CREATE TABLE users (
        id INT AUTO_INCREMENT PRIMARY KEY,
        email VARCHAR(320) NOT NULL UNIQUE,
        password CHAR(60));

    유저 테이블을 생성한 뒤에 회원가입 기능을 구현하기 위해 프로젝트 구조에 폴더를 좀 추가하고자 한다.

    앞선 과정에 우리가 만든 프로젝트 구조에는 아무런 경로도 없다. 아래와 같은 구조로 폴더와 파일을 추가한다.

    프로젝트 구조

    .test-jwt
    ├── helper.go
    │    ├── jwtHelper.go
    │    └── passwordHelper.go
    ├── db
    │    └── connect.go
    ├── models
    │    └── user.go
    ├── handler
    │    └── sign.go
    ├── go.mod
    ├── .env
    └── main.go

    생성된 db/connect.go 파일에 아래의 코드를 작성한다.

    // db/connect.go
    package db
    
    import (
        "os"
    
        _ "gorm.io/driver/mysql"
        "gorm.io/gorm"
    )
    
    func Connect() *gorm.DB {
        USER := os.Getenv("DBUSER")       // DB 유저명
        PASS := os.Getenv("DBPASS")       // DB 유저의 패스워드
        PROTOCOL := "tcp(localhost:3306)" // 개발환경이므로 localhost의 3306포트로 설정한다.
        DBNAME := os.Getenv("DBNAME")     // 사용할 DB 명을 입력
    
        CONNECT := USER + ":" + PASS + "@" + PROTOCOL + "/" + DBNAME + "?charset=utf8mb4&parseTime=True&loc=Local"
        db, err := gorm.Open(mysql.Open(CONNECT), &gorm.Config{})
    
        if err != nil {
            panic(err.Error())
        }
    
        return db
    }

    gorm은 golang에서 ORM기반의 개발을 도와주는 라이브러리이다. gorm과 함께 mysql driver도 함께 임포트 한다.

    Connect함수를 통해 DB에 쿼리를 날릴 준비가 된 것이다. DB 접속에 필요한 정보들은 환경변수를 사용하고 개발환경에서는 .env파일에 설정하며 내용은 아래와 같다.

    // test-jwt/.env
    DBUSER=jwt //여기에 본인이 생성한 DB명을 적는다.
    DBPASS=**** // DB유저의 패스워드를 적는다.
    DBNAME=jeong // DB명을 적는다.

    다음으로 models/user.go 파일에 아래의 코드를 작성한다.

    // models/user.go
    package models
    
    type User struct {
        Id       int    `json:id`
        Email    string `json:email`
        Password string `json:password`
    }

    유저 테이블의 정보를 맵핑해줄 구조체를 모델 패키지에 선언한다. 추후에 다른 기능의 모델이 추가될 수 있도록 user.go라는 파일 안에 구분해 두었다.

     

    이제 회원을 생성하는 기능을 만들어보자. 그전에 비밀번호를 데이터베이스에 저장할 경우 심각한 보안의 문제를 야기하기 때문에 원본 그대로를 저장하지 않기 위해 bcrypt라는 라이브러리를 통해 해싱한 후에 저장하는 방식을 취해보자. 프로젝트 구조를 잡을 때 생성했던 passwordHelper.go 파일을 작성한다.

    bycrypt에 대한 자세한 설명은 위키피디아 https://ko.wikipedia.org/wiki/Bcrypt

     

    bcrypt - 위키백과, 우리 모두의 백과사전

    bcrypt 파일 암호화 유틸리티에 대해서는 블로피시 문서를 참고하십시오. bcypt는 블로피시 암호에 기반을 둔 암호화 해시 함수로서 Niels Provos와 David Mazières가 설계하였으며 1999년 USENIX에서 발표되

    ko.wikipedia.org

    package helper
    
    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 CheckPasswordHash(hashVal, userPw string) bool {
        err := bcrypt.CompareHashAndPassword([]byte(hashVal), []byte(userPw))
        if err != nil {
            return false
        } else {
            return true
        }
    }

    DB에 회원정보를 인서트 할 때 비밀번호를 HashPassword 함수를 통해서 솔팅 해쉬 된 값으로 넣어주고, 추후에 로그인 기능에서 CheckPasswordHash 함수를 통해 저장된 비밀번호와 유저가 입력한 패스워드와 일치하는지 검증한다.

    이제 이메일 주소와 패스워드를 받아 회원을 생성하는 API를 작성하기 위해 handler/sign.go 파일로 이동한다.

    // handler/sign.go
    package handler
    
    import (
        "net/http"
        "test-jwt/helper"
        "test-jwt/db"
        "test-jwt/models"
    
        "github.com/labstack/echo/v4"
    )
    
    func SignUp(c echo.Context) error {
        user := new(models.User)
    
        if err := c.Bind(user); err != nil {
            return c.JSON(http.StatusBadRequest, map[string]string{
                "message": "bad request",
            })
        }
        db := db.Connect()
        result := db.Find(&user, "email=?", user.Email)
    
        // 이미 이메일이 존재할 경우의 처리
        if result.RowsAffected != 0 {
            return c.JSON(http.StatusBadRequest, map[string]string{
                "message": "existing email",
            })
        }
    
        // 비밀번호를 bycrypt 라이브러리로 해싱 처리
        hashpw, err := helper.HashPassword(user.Password)
        if err != nil {
            return c.JSON(http.StatusInternalServerError, map[string]string{
                "message": err.Error(),
            })
        }
        user.Password = hashpw
    
        // 위의 두단계에서 err가 nil일 경우 DB에 유저를 생성
        if err := db.Create(&user); err.Error != nil {
            return c.JSON(http.StatusInternalServerError, map[string]string{
                "message": "Failed SignUp",
            })
        }
    
        // 모든 처리가 끝난 후 200, Success 메시지를 반환
        return c.JSON(http.StatusOK, map[string]string{
            "message": "Success",
        })
    }

    이제 루트 디렉토리에 main.go 파일에 위에서 작성한 회원가입 API를 적용하여 라우팅 한다.

    // test-jwt/main.go
    package main
    
    import (
        "log"
        "test-jwt/handler"
    
        "github.com/joho/godotenv"
        "github.com/labstack/echo/v4"
    )
    
    func main() {
    
        // godotenv는 로컬 개발환경에서 .env를 통해 환경변수를 읽어올 때 쓰는 모듈이다.
        // 프로덕션 환경에서는 필요하지 않음.
        err := godotenv.Load(".env")
        if err != nil {
            log.Fatal("Error loading .env file")
        }
        e := echo.New()
    
        // 회원가입 API
        e.POST("/api/signup", handler.SignUp)
    
        e.Logger.Fatal(e.Start(":1323"))
    }

    그럼 이제 서버를 기동하고 postman으로 회원가입 post request를 날려보자.

    go run main.go

     

    우리는 로컬 개발 환경이므로 localhost와 /api/signup 으로 body에 정보를 담아 요청을 한다.

    이메일 주소와 패스워드를 리퀘스트 바디에 담아 요청한 결과 Success 메시지를 받을 수 있었다.

    그럼 DB에는 실제로 들어갔을까 확인해본다.

    패스워드가 암호화되어 잘 저장된 것을 확인해볼 수 있다.

     

    이제 유저가 로그인을 할 경우 요청된 비밀번호와 암호화된 비밀번호가 일치하는지 확인해보기 위해 앞서 설정했던 helper/passwordHelper.go 파일의 CheckPasswordHash 함수를 사용할 때가 되었다. 코드에 추가해 로그인 검증을 해본다.

    package handler
    
    import (
        "net/http"
        "test-jwt/helper"
        "test-jwt/db"
        "test-jwt/models"
    
        "github.com/labstack/echo/v4"
    )
    
    func SignUp(c echo.Context) error {
        user := new(models.User)
    
        if err := c.Bind(user); err != nil {
            return c.JSON(http.StatusBadRequest, map[string]string{
                "message": "bad request",
            })
        }
        db := db.Connect()
        result := db.Find(&user, "email=?", user.Email)
    
        // 이미 이메일이 존재할 경우의 처리
        if result.RowsAffected != 0 {
            return c.JSON(http.StatusBadRequest, map[string]string{
                "message": "existing email",
            })
        }
    
        // 비밀번호를 bycrypt 라이브러리로 해싱 처리
        hashpw, err := helper.HashPassword(user.Password)
        if err != nil {
            return c.JSON(http.StatusInternalServerError, map[string]string{
                "message": err.Error(),
            })
        }
        user.Password = hashpw
    
        // 위의 두단계에서 err가 nil일 경우 DB에 유저를 생성
        if err := db.Create(&user); err.Error != nil {
            return c.JSON(http.StatusInternalServerError, map[string]string{
                "message": "Failed SignUp",
            })
        }
    
        // 모든 처리가 끝난 후 200, Success 메시지를 반환
        return c.JSON(http.StatusOK, map[string]string{
            "message": "Success",
        })
    }
    
    func SignIn(c echo.Context) error {
        user := new(models.User)
    
        if err := c.Bind(user); err != nil {
            return c.JSON(http.StatusBadRequest, map[string]string{
                "message": "bad request",
            })
        }
        inputpw := user.Password
    
        db := db.Connect()
        result := db.Find(user, "email=?", user.Email)
    
        // 존재하지않는 아이디일 경우
        if result.RowsAffected == 0 {
            return echo.ErrBadRequest
        }
    
        res := helper.CheckPasswordHash(user.Password, inputpw)
    
        var message string
        // 비밀번호 검증에 실패한 경우
        if !res {
            return echo.ErrUnauthorized
        } else {
            // 검증에 성공한 경우
            message = "Success"
        }
    
        return c.JSON(http.StatusOK, map[string]string{
            "message": message,
        })
    }

    기존에 회원가입 로직 아래에 SignIn 함수를 만들고 이메일, 비밀번호를 입력받아 데이터베이스에 있는 유저 정보와 대조하여 이메일이 없을 경우의 반환 처리와 비밀번호 검증을 넣어 테스트를 진행한다.

     

    구조체에 바인딩된 패스워드는 사용자가 입력한 비밀번호를 사용하므로 변수에 담아두고 이메일 존재 여부를 통해 검색된 유저 정보를 User 구조체에 담아 암호화된 비밀번호와 로그인을 하려는 유저가 보내온 입력 비밀번호의 일치 여부를 CheckPasswordHash 함수를 통해 진행한다. postman을 통해 성공 여부를 확인하기 위해 main.go 파일에 로그인 API를 라우팅 한다.

    // test-jwt/main.go
    package main
    
    import (
        "log"
        "test-jwt/handler"
    
        "github.com/joho/godotenv"
        "github.com/labstack/echo/v4"
    )
    
    func main() {
    
        // godotenv는 로컬 개발환경에서 .env를 통해 환경변수를 읽어올 때 쓰는 모듈이다.
        // 프로덕션 환경에서는 필요하지 않음.
        err := godotenv.Load(".env")
        if err != nil {
            log.Fatal("Error loading .env file")
        }
        e := echo.New()
    
        // 회원가입 API
        e.POST("/api/signup", handler.SignUp)
    
        // 로그인 API(현재는 테스트용)
        e.POST("/api/signin", handler SignIn)
    
        e.Logger.Fatal(e.Start(":1323"))
    }

    그럼 이제 서버를 기동하고 postman으로 테스트용 로그인 post request를 날려보자.

    go run main.go

    우선 회원 생성 시 입력했던 비밀번호와 다른 값을 요청해본다.

    DB에 저장된 암호화 비밀번호와 일치하지 않을 경우 에러가 반환되는 것을 볼 수가 있다.

    다음으로 생성시 입력했던 비밀번호를 넣어 다시 요청해본다.

    일치할 경우 로직에 의해 "Success" 메시지를 확인할 수 있다.

    이제 로그인 성공 시에 JWT를 발급하는 내용으로 넘어간다.


    3. JWT 발급 및 인증 구현

    프로젝트 디렉토리 구성에서 생성했던 jwtHelper.go 파일을 작성할 때가 되었다.

    토큰을 생성하는 함수 CreateJWT를 만든다.

    JWT(Json Web Token)에 대한 자세한 설명은 https://ko.wikipedia.org/wiki/JSON_웹_토큰

     

    JSON 웹 토큰 - 위키백과, 우리 모두의 백과사전

    JSON 웹 토큰상태인터넷 표준최초 출판일2010년 12월 28일 (2010-12-28)마지막 버전RFC 75192015년 5월조직IETF약어JWT JSON 웹 토큰(JSON Web Token, JWT, "jot”[1])은 선택적 서명 및 선택적 암호화를 사용하여 데

    ko.wikipedia.org

    우리는 golang-jwt/jwt 패키지를 통해서 JWT를 발행할 것이다.

    // helper/jwtHelper.go
    package helper
    
    import (
        "os"
        "time"
    
        "github.com/golang-jwt/jwt"
    )
    
    func CreateJWT(Email string) (string, error) {
        mySigningKey := []byte(os.Getenv("SECRET_KEY"))
    
        aToken := jwt.New(jwt.SigningMethodHS256)
        claims := aToken.Claims.(jwt.MapClaims)
        claims["Email"] = Email
        claims["exp"] = time.Now().Add(time.Minute * 20).Unix()
    
        tk, err := aToken.SignedString(mySigningKey)
        if err != nil {
            return "", err
        }
        return tk, nil
    }

    헤더, 페이로드, 서명을 통해 tk변수에 토큰 생성되어 리턴한다. 다음으로 서명에 필요한 시크릿 키를 환경변수에서 가져오므로 루트 디렉토리에 있는 .env 파일에 SECRET_KEY 변수를 설정할 것이다.

    // test-jwt/.env
    DBUSER=jwt //여기에 본인이 생성한 DB명을 적는다.
    DBPASS=**** // DB유저의 패스워드를 적는다.
    DBNAME=jeong // DB명을 적는다.
    SECRET_KEY=example //임의 문자열 설정

    SECRET_KEY는 JWT에서 서명에 안전하게 유지하기 위해 적절하게 설정한다.

    이제 작성한 CreateJWT를 로그인 기능에서 호출할 것이다. 그러므로 hanlder/sign.go 의 내용을 수정한다.

    package handler
    
    import (
        "net/http"
        "test-jwt/helper"
        "test-jwt/db"
        "test-jwt/models"
    
        "github.com/labstack/echo/v4"
    )
    
    func SignUp(c echo.Context) error {
        user := new(models.User)
    
        if err := c.Bind(user); err != nil {
            return c.JSON(http.StatusBadRequest, map[string]string{
                "message": "bad request",
            })
        }
        db := db.Connect()
        result := db.Find(&user, "email=?", user.Email)
    
        // 이미 이메일이 존재할 경우의 처리
        if result.RowsAffected != 0 {
            return c.JSON(http.StatusBadRequest, map[string]string{
                "message": "existing email",
            })
        }
    
        // 비밀번호를 bycrypt 라이브러리로 해싱 처리
        hashpw, err := helper.HashPassword(user.Password)
        if err != nil {
            return c.JSON(http.StatusInternalServerError, map[string]string{
                "message": err.Error(),
            })
        }
        user.Password = hashpw
    
        // 위의 두단계에서 err가 nil일 경우 DB에 유저를 생성
        if err := db.Create(&user); err.Error != nil {
            return c.JSON(http.StatusInternalServerError, map[string]string{
                "message": "Failed SignUp",
            })
        }
    
        // 모든 처리가 끝난 후 200, Success 메시지를 반환
        return c.JSON(http.StatusOK, map[string]string{
            "message": "Success",
        })
    }
    
    func SignIn(c echo.Context) error {
        user := new(models.User)
    
        if err := c.Bind(user); err != nil {
            return c.JSON(http.StatusBadRequest, map[string]string{
                "message": "bad request",
            })
        }
        inputpw := user.Password
    
        db := db.Connect()
        result := db.Find(user, "email=?", user.Email)
    
        // 존재하지않는 아이디일 경우
        if result.RowsAffected == 0 {
            return echo.ErrBadRequest
        }
    
        res := helper.CheckPasswordHash(user.Password, inputpw)
    
        // 비밀번호 검증에 실패한 경우
        if !res {
            return echo.ErrUnauthorized
        }
        // 토큰 발행
        accessToken, err := helper.CreateJWT(user.Email)
        if err != nil {
            return echo.ErrInternalServerError
        }
    
        cookie := new(http.Cookie)
        cookie.Name = "access-token"
        cookie.Value = accessToken
        cookie.HttpOnly = true
        cookie.Expires = time.Now().Add(time.Hour * 24)
    
        c.SetCookie(cookie)
    
        return c.JSON(http.StatusOK, map[string]string{
            "message": "Login Success",
        })
    }
    
    // 테스트용 API
    func MockData() echo.HandlerFunc {
        return func(c echo.Context) error {
            // Mock Data를 생성한다.
            list := map[string]string{
                "1": "고양이",
                "2": "사자",
                "3": "호랑이",
            }
            return c.JSON(http.StatusOK, list)
        }
    }

    CreateJWT 함수를 통해 생성된 토큰은 쿠키에 담아 리턴한다.

    쿠키에 담는 이유는 다음 편에 리프레시 토큰과 함께 설명하고자 한다.

    SignIn 함수 밑에 Mock 데이터를 반환하는 테스트용 함수 MockData도 함께 만들어 두었다.

     

    다음으로 main.go 파일에 테스트용 API를 라우팅 한다.

    // test-jwt/main.go
    package main
    
    import (
        "os"
        "log"
        "test-jwt/handler"
        md "github.com/labstack/echo/v4/middleware"
        "github.com/joho/godotenv"
        "github.com/labstack/echo/v4"
    )
    
    func main() {
    
        // godotenv는 로컬 개발환경에서 .env를 통해 환경변수를 읽어올 때 쓰는 모듈이다.
        // 프로덕션 환경에서는 필요하지 않음.
        err := godotenv.Load(".env")
        if err != nil {
            log.Fatal("Error loading .env file")
        }
        e := echo.New()
    
        // 회원가입 API
        e.POST("/api/signup", handler.SignUp)
    
        // 로그인 API(현재는 테스트용)
        e.POST("/api/signin", handler SignIn)
    
        // 목데이터로 테스트
        e.GET("/api/getlist", handler.MockData(), md.JWTWithConfig(md.JWTConfig{
            SigningKey:  []byte(os.Getenv("SECRET_KEY")),
            TokenLookup: "cookie:access-token",
        }))
    
        e.Logger.Fatal(e.Start(":1323"))
    }

    localhost/api/getlist로 GET 요청이 올 경우 목데이터를 반환한다. 하지만 미들웨어 검증에 의해 통과될 때만 목데이터를 Response 로 받을 수 있을 것이다.

    고맙게도 echo 프레임워크에는 JWT를 검증해주는 미들웨어를 지원한다.

    자세한 내용은 https://echo.labstack.com/middleware/jwt/ 를 참조.

     

    Echo - High performance, minimalist Go web framework

    Echo is a high performance, extensible, minimalist web framework for Go (Golang).

    echo.labstack.com

    이제 모든 준비가 끝났다. postman을 통해 우리 프로젝트의 로그인 기능을 이용해 발급받은 JWT를 통해 목데이터가 오는지 테스트해보자!

    서버를 실행하기 전에 의존성 패키지들이 설치를 하기 위해 아래의 커맨드를 입력한다.

    go mod tidy

    서버 실행

    go run main.go

    postman을 통해 로그인 요청을 한다.

    로그인 성공 메시지가 왔지만 쿠키에 토큰이 잘 왔는지 살펴본다.

    쿠키에 잘 담긴 것을 확인할 수 있다. 이제 쿠기 값을 가지고 목데이터를 요청해본다.

    localhost:1323/api/getlist

    우리가 설정한 Mock 데이터를 Response로 잘 받을 수 있다.

    그럼 반대로 토큰이 없다면 어떻게 될지 설정하기 위해 쿠키값을 지우기 똑같이 목데이터 요청을 해본다.

    토큰이 없는 상태에서는 Status 400 Bad Request와 "missing or malformed jwt" 메시지를 확인할 수 있다.


    4. 마무리

    이번 프로젝트는 golang을 통해 JWT을 발급하고 검증하는 과정을 테스트해 보았다. 이론적인 부분은 설명하지 않았으며 위키피디아 혹은 구글에 많은 설명자료가 방대하기에 발급 과정에 초점을 두었다. 다음 글에는 리프레시 토큰을 통해 액세스 토큰을 재발급받는 구성을 설명하고자 한다.


    코드는 아래에서 확인하실 수 있습니다.

    https://github.com/JEONG-YUNHO01/test-jwt

     

    GitHub - JEONG-YUNHO01/test-jwt

    Contribute to JEONG-YUNHO01/test-jwt development by creating an account on GitHub.

    github.com

     

    반응형

    '프로그래밍 > golang' 카테고리의 다른 글

    [golang] Pointer(포인터)를 쓰는 이유  (0) 2021.12.29

    댓글

Designed by Tistory.