티스토리 뷰
개요
지난 포스팅에서 인증(Authentication)을 위한 비밀번호 해시값의 생성과 검증을 다루었다. 이번에는 access token 과 refresh token 이라는 JWT(Json Web Token)의 생성을 알아보자.
링크
- GitHub 브랜치: https://github.com/nicewook/gocore/tree/7_authentication
- 블로그 링크
Access Token과 Refresh Token
access token은 짧은 유효 기간을 가지며, 요청마다 포함되어 빠르게 인증한다. 하지만 만료되면 다시 로그인해야 하는 불편함이 생기는데, 이를 해결하기 위해 refresh token을 사용한다. refresh token은 더 긴 유효 기간을 가지며, 만료된 access token을 새로 발급받는 데 사용된다. 서버는 일반적으로 refresh token을 쿠키에 저장하여 응답하고, 클라이언트는 이를 받아 필요할 때 서버에 보낸다. 보안이 중요한 만큼 탈취되지 않도록 HttpOnly, Secure 등의 옵션을 설정해야 한다. 만약 refresh token이 유출되면 장기적인 피해가 발생할 수 있어 서버에서는 이를 관리하고 필요하면 무효화할 수 있는 방법을 마련해야 한다. 이렇게 access token과 refresh token을 함께 사용하면 보안성과 사용자 경험을 동시에 개선할 수 있다.
프로세스
다음의 프로세스는 절대적인 것은 아니며 하나의 예시이다. 예를 들어, refresh token을 쿠키가 아닌 응답 바디에 담아 보낼 수도 있다.
- 클라이언트가 로그인을 성공하면 서버는 access token과 refresh token 를 회신한다. 이때 refresh token은 쿠키에 안전하게 담아 전달한다.
- 클라이언트는 이후 인증이 필요한 요청의 헤더에 access token을 담아서 요청하고, 서버는 이를 인증하고 처리한다.
- access token의 인증이 만료되었다는 회신을 받으면 클라이언트는 토큰 재발급을 요청하고 서버는 쿠키속의 refresh token을 인증한 다음, access token과 refresh token을 재발급하여 응답한다.
토큰의 생성
앞선 포스팅에서 사용자의 로그인이 인정되었을 때에 토큰을 생성하는 generateTokens를 호출하는 것을 보았다.
// ./internal/usecase/authUsecase.go
func (uc *authUseCase) Login(ctx context.Context, email, password string) (*domain.LoginResponse, error) {
// 이메일로 사용자 조회
user, err := uc.userRepo.GetUserByEmail(ctx, email)
if err != nil {
return nil, err
}
// 비밀번호 검증
match, err := security.ComparePasswordHash(password, user.Password)
if err != nil {
return nil, err
}
if !match {
return nil, errors.New("invalid credentials")
}
// 토큰 생성
return uc.generateTokens(user)
}
generateTokens 메서드를 보자.
- security 패키지에서 토큰을 생성하는 메서드를 호출한다. access token, refresh token 생성 메서드를 각각 구현해두었는데 security 패키지의 의존성을 줄이려는 의도이다.
- 비대칭키를 이용하여 보안을 강화하였다. authUsecase 는 생성시에 설정에서 private key를 받아서 가지고 있는데 JWT 생성시에는 private key를 사용하여 암호화를 한다. 만에 하나 해커가 클라이언트에게 전달된 토큰에서 public key를 알아낸다 하더라도 private key를 모르기에 토큰을 생성할 수는 없다. 주기적으로 키를 변경하는 경우에 서버가 API를 통해 public key를 제공해주는 경우도 많다.
- domain.LoginResponse 를 응답해주면 이를 재료로 하여 handler가 클라이언트에게 응답을 보낼 것이다.
// ./internal/usecase/authUsecase.go
func (uc *authUseCase) generateTokens(user *domain.User) (*domain.LoginResponse, error) {
// Generate access token
accessTokenExpiration := time.Duration(uc.config.Secure.JWT.AccessExpirationMin) * time.Minute
accessToken, err := security.GenerateAccessToken(
user.ID,
user.Email,
user.Roles,
uc.privateKey,
accessTokenExpiration,
)
if err != nil {
return nil, err
}
// Generate refresh token
refreshTokenExpiration := time.Duration(uc.config.Secure.JWT.RefreshExpirationDay) * 24 * time.Hour
refreshToken, err := security.GenerateRefreshToken(
user.ID,
user.Email,
user.Roles,
uc.privateKey,
refreshTokenExpiration,
)
if err != nil {
return nil, err
}
// Create response
response := &domain.LoginResponse{
ID: user.ID,
Email: user.Email,
AccessToken: accessToken,
RefreshToken: refreshToken,
RefreshTokenExpiration: time.Now().Add(refreshTokenExpiration),
}
return response, nil
}
토큰 생성 코드를 보자.
security 패키지에서는 GenerateToken을 호출하면서 TokenType을 각각 AccessToken, RefreshToken 으로 명시해주었다.
// ./pkg/security/token.go
// GenerateAccessToken creates a new access token
func GenerateAccessToken(userID int64, email string, roles []string, privateKey *rsa.PrivateKey, expirationTime time.Duration) (string, error) {
return GenerateToken(userID, email, roles, privateKey, expirationTime, AccessToken)
}
// GenerateRefreshToken creates a new refresh token
func GenerateRefreshToken(userID int64, email string, roles []string, privateKey *rsa.PrivateKey, expirationTime time.Duration) (string, error) {
return GenerateToken(userID, email, roles, privateKey, expirationTime, RefreshToken)
}
실제 토큰을 생성하는 함수는 GenerateToken 메서드이다.
- claims에는 jwt.RegisteredClaims로 미리 정의된 표준 필드 중 ExpiresAt(만료 시간)과 IssuedAt(발급 시간)을 명시한다. 여기에 우리가 커스텀하게 추가한 필드인 UserID, Email, Roles, Type 정보도 추가한다.
- 서버가 이 토큰을 인증하면, 토큰에 포함된 정보를 다양하게 활용할 수 있다. 예를 들어:
- Email 정보를 이용하여 사용자에게 메일을 발송
- UserID를 이용해 요청한 클라이언트의 상세 정보를 데이터베이스에서 조회
- Roles 정보는 인가(Authorization) 에 사용. 다음 포스팅에서 다룬다.
- 토큰 생성 과정에서는 jwt.NewWithClaims 함수와 SignedString 메서드를 활용하여 claims를 JWT로 변환한다. private key를 사용하여 토큰에 서명하는데, 나중에 토큰 검증할 때에 쌍이 되는 public key를 통해 서명을 확인하게 된다.
// ./pkg/security/token.go
// GenerateToken creates a new JWT token for a user using RSA private key
func GenerateToken(userID int64, email string, roles []string, privateKey *rsa.PrivateKey, expirationTime time.Duration, tokenType TokenType) (string, error) {
claims := &JWTClaims{
UserID: userID,
Email: email,
Roles: roles,
Type: tokenType,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expirationTime)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
tokenString, err := token.SignedString(privateKey)
if err != nil {
return "", err
}
return tokenString, nil
}
표준 클레임
JWT(JSON Web Token)의 표준 클레임(standard claims)은 토큰에 포함될 수 있는 미리 정의된 클레임 세트이다. 이러한 표준 클레임들은 IANA JSON Web Token Registry에 등록되어 있으며, JWT 스펙(RFC 7519)에 정의되어 있다.
주요 standard claims은 다음과 같다.
- iss (Issuer): 토큰 발급자
- sub (Subject): 토큰 제목 (일반적으로 사용자 식별자)
- aud (Audience): 토큰 대상자
- exp (Expiration Time): 토큰 만료 시간
- nbf (Not Before): 이 시간 이전에는 토큰이 유효하지 않음
- iat (Issued At): 토큰 발급 시간
- jti (JWT ID): JWT의 고유 식별자
이러한 표준 클레임들은 토큰의 유효성 검증과 보안을 위해 사용한다. 대표적으로 exp 클레임은 토큰이 언제 만료되는지 지정하여 만료된 토큰이 사용되지 않도록 한다.
클라이언트에게 응답하기
usecase에서 만들어진 토큰은 handler에서 정리하여 클라이언트에게 회신한다.
// ./internal/handler/authHandler.go 의 Login 메서드중 일부
if err == nil {
// Set refresh token as HTTP-only cookie
cookie := h.createRefreshTokenCookie(
loginResponse.RefreshToken,
loginResponse.RefreshTokenExpiration)
c.SetCookie(cookie)
return c.JSON(http.StatusOK, loginResponse)
}
Access Token
access token은 응답 바디에 넣어 보낸다. 로그인을 요청한 클라이언트의 ID, Email, 그리고 생성한 access_token 을 회신한다. RefreshToken, RefreshTokenExpiration 는 쿠키를 만들때에만 사용되고 json:"-" 태그에 의해 응답 바디에는 들어가지 않는다.
// ./internal/domain/auth.go
type LoginResponse struct {
ID int64 `json:"id"`
Email string `json:"email"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"-"` // Not included in JSON response
RefreshTokenExpiration time.Time `json:"-"` // Not included in JSON response
}
Refresh Token
createRefreshTokenCookie 를 보면 기능 자체는 특별한 것이 없다. AuthHandler를 생성할 때에 넣어준 config.Config에서 쿠키 생성과 관련할 설정을 반영하여 쿠키를 만드는 것이 전부이다. 쿠키와 관련한 설정은 각 개발환경에 따라 변경할 수 있도록 설정에 추가해 두었다. ./config/config.dev.yaml 파일과 ./internal/config/config.go 파일을 참고하자.
// createRefreshTokenCookie creates a secure HTTP-only cookie for the refresh token
func (h *AuthHandler) createRefreshTokenCookie(tokenValue string, expiration time.Time) *http.Cookie {
cookieConfig := h.config.Secure.JWT.Cookie
// Parse SameSite value
sameSite := http.SameSiteLaxMode
switch strings.ToLower(cookieConfig.SameSite) {
case "strict":
sameSite = http.SameSiteStrictMode
case "lax":
sameSite = http.SameSiteLaxMode
case "none":
sameSite = http.SameSiteNoneMode
}
cookie := &http.Cookie{
Name: refreshTokenCookieName,
Value: tokenValue,
Path: "/",
Domain: cookieConfig.Domain,
Expires: expiration,
Secure: cookieConfig.Secure,
HttpOnly: cookieConfig.HTTPOnly,
SameSite: sameSite,
}
return cookie
}
쿠키의 설정
앞선 코드에서 리프레시 토큰을 위한 쿠키를 생성할 때 적용된 설정들은 다음과 같다. 보안설정들을 특히 눈여겨보자.
- HttpOnly: true로 설정하면 JavaScript를 통한 쿠키 접근이 불가능해진다. 이는 XSS(Cross-Site Scripting) 공격으로부터 토큰을 보호하는 중요한 설정이다.
- Secure: true로 설정하면 HTTPS 연결에서만 쿠키가 전송된다. 이는 중간자 공격(MITM)을 방지하고 통신 과정에서 토큰이 노출되는 것을 막는다. Dev 환경에서는 HTTPS가 적용되지 않는 경우가 많으니 config.dev.yaml 에서는 false 로 해두었다.
- SameSite: 쿠키의 크로스 사이트 요청 정책을 설정한다.
- Strict: 가장 엄격한 설정으로, 같은 도메인의 요청에만 쿠키가 전송된다.
- Lax: 기본값이다, 대부분의 크로스 사이트 요청에서 쿠키 전송을 제한하지만 최상위 탐색(링크 클릭 등)에서는 허용한다.
- None: 모든 크로스 사이트 요청에 쿠키 전송을 허용하며 권장하지는 않는다. 이 경우 Secure 속성이 반드시 필요하다.
- Domain: 쿠키가 전송될 도메인을 지정하는데 이를 통해 서브도메인을 포함한 특정 도메인에서만 쿠키가 사용되도록 제한할 수 있다.
- Path: 쿠키가 전송될 서버의 경로를 지정한다. /로 설정하면 모든 경로에서 쿠키가 전송된다. refresh token을 담은 쿠키는 토큰 재발급에만 사용한다고 보면 /auth 경로로 제한하면 보안성이 더 높아질 수 있겠다.
- Expires: 쿠키의 만료 시간을 설정한다. 리프레시 토큰의 유효 기간과 일치시켜 토큰이 만료되면 쿠키도 함께 만료되도록 하자.
이러한 설정들은 OWASP(Open Web Application Security Project)의 권장사항을 따르며, JWT 토큰 특히 리프레시 토큰과 같은 민감한 인증 정보를 안전하게 저장하고 전송하기 위한 필수적인 조치들이다.
마치며
인증 인가와 관련하여 비밀번호 해시값의 생성과 비밀번호의 검증, access token과 refresh token의 생성을 연이어서 알아보았다. 다음 포스팅에서는 인증 인가와 관련한 마지막 포스팅으로 토큰의 인증, 토큰 속 클레임을 이용한 인가, 그리고 로그아웃, 토큰 만료시의 재발행 요청을 다루어보겠다.
'golang' 카테고리의 다른 글
Go 백엔드 10: 인증과 인가 - JWT 인증 (0) | 2025.03.20 |
---|---|
Go 백엔드 8: 인증과 인가 - 비밀번호 (0) | 2025.03.20 |
Go 백엔드 7: 로깅 (0) | 2025.02.21 |
Go 백엔드 6: 미들웨어 (0) | 2025.02.20 |
Go 백엔드 5: 의존성 주입 (0) | 2025.02.16 |

- Total
- Today
- Yesterday
- API
- postgres
- 독서후기
- clean agile
- Gin
- 엉클 밥
- gocore
- 클린 애자일
- go
- 영화
- websocket
- 2023
- golang
- Echo
- solid
- Bug
- intellij
- 독서
- agile
- 티스토리챌린지
- 오블완
- 인텔리제이
- 클린 아키텍처
- middleware
- 잡학툰
- strange
- bun
- notion
- OpenAI
- ChatGPT
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 |