티스토리 뷰
개요
인증 인가와 관련한 마지막 포스팅으로 토큰의 인증, 토큰 속 클레임을 이용한 인가, 그리고 로그아웃, 토큰 만료시의 재발행 요청을 다루어 본다.
링크
- GitHub 브랜치: https://github.com/nicewook/gocore/tree/7_authentication
- 블로그 링크
인증
클라이언트는 로그인을 하면 access token, 그리고 쿠키에 refresh token을 회신받게 된다. access token이 만료된 이후에 쿠키 속 refresh token을 이용해 토큰 재발행을 요청해도 마찬가지이다.
클라이언트가 인증이 필요한 요청의 헤더에 access token을 담아서 보내면 서버는 토큰의 인증작업을 하게 된다. 서버에서 발행한 토큰이 맞는지를 확인 하는 것이다. 미들웨어에서 access token을 처리하는 코드를 분석해보자. 여기서는 echo 프레임워크에서 제공하는 미들웨어를 조금 더 고도화해서 구현했다. 각 핸들러마다 access token의 필요유무와 인가(authorization) 작업까지 하기 위한 작업으로 뒤에 자세히 설명하겠다.
- SigningKey: 서버에서 개인키(private key)로 서명한 토큰은 공개키(public key)로 검증할 수 있다. 설정에는 공개키가 PEM 형식으로 저장되어 있기에 SigningKey 필드에는 이를 파싱하여 공개키를 리턴하는 함수를 등록해준다.
- SigningMethod: RS256라는 것은 RSA-SHA256 알고리즘을 의미하는데 JWT의 헤더와 페이로드를 우선 SHA-256으로 해싱한 다음 그 해시값을 RSA 개인키로 서명(암호화) 하는 것이다. 검증은 동일하게 SHA-246 해시값을 계산한 다음, JWT 의 서명을 RSA 공개키로 복호화 한 값과 같은지 검증하면 된다.
- ErrorHandler: echojwt 를 사용한 미들웨어는 기본값으로 에러가 발생하면 검증의 실패로 간주하고 요청에 대해 실패로 응답한다. 이렇게 사용하고 미들웨어 설정의 Skipper 필드에 인증이 필요없는 API 엔드포인트 정보를 넣어줄 수도 있다. 하지만 각 API 엔드포인트에서 이를 지정하고자 여기에서는 조금의 기교를 부렸다.
- if errors.Is(err, echojwt.ErrJWTMissing) 부분이 핵심이다. 요청의 헤더에 토큰이 없는 경우에 에러가 아닌 nil 을 리턴하도록 설정한 것이다.
- 한 번 만들어두면 건드릴 일이 없기에 기타 에러들에 대한 처리도 고도화하여 각 에러에 대한 로깅과 응답을 정리해두었다.
- ContinueOnIgnoredError: ErrorHandler 에서 토큰이 없는 경우에도 nil을 리턴하도록 한 뒤에 이 필드를 true 로 해주어야 뒤이은 미들웨어를 거쳐 핸들러까지 전달되도록 할 수 있다.
미들웨어 설정이 이해가 되지 않는다면 실제 패키지의 코드를 분석해 보는 것도 추천한다. 동작을 이해하는데에 도움이 되고, 공부도 된다. (echojwt.WithConfig 코드 링크)
// ./internal/middlewares/middleware.go
// ✅ JWT 인증
e.Use(echojwt.WithConfig(echojwt.Config{
// 공개키 파싱
SigningKey: func() interface{} {
publicKey, err := jwt.ParseRSAPublicKeyFromPEM([]byte(cfg.Secure.JWT.PublicKey))
if err != nil {
logger.Error("Failed to parse RSA public key", "error", err)
return nil
}
return publicKey
}(),
SigningMethod: "RS256",
// ErrorHandler 는 JWT 가 실패해도 무시하도록 하고, ContinueOnIgnoredError 를 true 로 설정
// 이렇게 하면 JWT 검증 실패 시 미들웨어가 무시되고 다음 미들웨어가 실행된다.
// ErrorHandler: 토큰이 없는 경우만 무시하고 다른 오류는 반환
ErrorHandler: func(c echo.Context, err error) error {
// 토큰이 없는 경우는 패스해준다.
// 1. errors.Is로 ErrJWTMissing과 비교 (TokenExtractionError도 포함)
// 2. 에러 메시지로 확인 (추가 안전장치)
if errors.Is(err, echojwt.ErrJWTMissing) {
if cfg.App.Debug {
logger.Debug("JWT token is missing",
"path", c.Path(),
"method", c.Request().Method)
}
return nil // 토큰이 없는 경우만 무시
}
// 토큰이 있지만 문제가 있는 경우를 체크한다 (만료, 서명 불일치 등)
var statusCode int
var errorMsg string
switch {
case errors.Is(err, jwt.ErrTokenExpired):
statusCode = http.StatusUnauthorized
errorMsg = "Token has expired"
// Debug 모드일 때만 로깅
if cfg.App.Debug {
logger.Info("JWT token expired",
"path", c.Path(),
"method", c.Request().Method)
}
case errors.Is(err, jwt.ErrTokenSignatureInvalid):
statusCode = http.StatusUnauthorized
errorMsg = "Invalid token signature"
// 보안 관련 경고는 항상 로깅 (선택적)
logger.Warn("JWT token has invalid signature",
"path", c.Path(),
"method", c.Request().Method)
case errors.Is(err, jwt.ErrTokenNotValidYet):
statusCode = http.StatusUnauthorized
errorMsg = "Token not valid yet"
// Debug 모드일 때만 로깅
if cfg.App.Debug {
logger.Info("JWT token not valid yet",
"path", c.Path(),
"method", c.Request().Method)
}
default:
statusCode = http.StatusUnauthorized
errorMsg = "Invalid or malformed token"
if cfg.App.Debug {
logger.Warn("JWT validation failed",
"error", err.Error(),
"path", c.Path(),
"method", c.Request().Method)
}
}
return c.JSON(statusCode, map[string]string{
"error": errorMsg,
})
},
ContinueOnIgnoredError: true,
}))
중간 정리
클라이언트의 요청이 JWT 미들웨어를 거치고 나면 요청의 헤더에 토큰이 없는 경우를 제외한 에러는 모두 에러 리턴이 된다. 토큰이 있고 인증역시 성공했다면 요청의 echo.Context 안에 “user” 라는 키 안에 JWT 미들웨어가 넣어준 *jwt.Token 값이 들어가 있다. 토큰이 없다면 바로 이 키와 값이 없을 것이다.
이와 같은 특성과 정보를 기반으로 인증과 인가를 처리하게 될 것이다.
인증과 인가
앞선 단계에서 서명에 문제가 있거나 만료가 된 토큰을 이용한 인증의 시도에 대해 처리가 되었다. 이제 토큰이 없는 경우에도 접근이 가능한 엔드포인트 처리(인증), 그리고 특정한 권한이 필요한 경우에 대한 인증 처리를 알아보자.
User 정보
User 는 Roles 필드에 하나 이상의 역할을 가질 수 있도록 해두었다. 실제 프로덕션 제품에서는 고객 등급등과 같이 역할을 할당하는 로직이 들어갈 수 있겠다. gocore 프로젝트에서는 프로젝트 생성시에 admin 계정에 RoleAdmin을 할당하도록 해두었고, 가입을 하는 경우에는 모두 RoleUser를 기본값으로 할당하도록 해두었다.
// ./internal/domain/user.go
const (
RolePublic = "Public" // 공개 접근 가능, 토큰 불필요
RoleAdmin = "Admin"
RoleManager = "Manager"
RoleUser = "User"
)
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"-"`
Roles []string `json:"roles"`
}
사용자가 로그인시에 인증이 되면 해당 사용자의 Roles 필드 정보를 토큰생성시에 포함한다. 토큰 생성코드를 다시 보자.
// ./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
}
실제적인 인증, 인가를 하는 미들웨어를 볼 차례이다. AllowRoles 미들웨어는 토큰이 필요없는 경우에는 인증, 인가를 생략하도록 해주고, 토큰의 인증이 되어 echo.Context에 “user” 값이 있다면 인증이 된 것으로 간주한다. 그 다음으로 미들웨어에서 허용하는 역할에 대해서만 인가해준다.
- 미들웨어에서 허용하는 역할에 domain.RolePublic 이 있다면 토큰이 필요없다는 뜻이며 무조건 인가를 해준다. 가입, 로그인, 토큰 재발급등의 경우에는 토큰이 없이 요청해야 할 것이다.
- contextutil.TokenToUser 함수는 echo.Context에 “user” 키가 있는지 확인한다. 키가 있으면 토큰 인증이 되었다는 뜻이다. 여기에 더해 토큰 클레임 속의 UserID, Email, Roles 등을 추출해낸다. 현재는 사용하는 코드가 없지만 MyPage 기능이 있다면 UserID를 이용하여 사용자의 정보를 데이터베이스에서 꺼낼 수 있고, 메일 회신이 필요한 경우 Email을 사용할 수 있을 것이다.
- 토큰 속의 rolesInToken, 다시 말해 사용자에게 할당된 역할을 해당 엔드포인트에서 허용한 allowedRoles 와 비교하여 만족하는 경우에 엔드포인트에 대한 인가가 된다.
// AllowRoles 는 허용된 역할만 접근할 수 있도록 하는 미들웨어이다.
func AllowRoles(allowedRoles ...string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// 허용하는 역할중에 domain.RolePublic 역할이 있으면 토큰 검증을 하지 않는다.
for _, role := range allowedRoles {
if role == domain.RolePublic {
return next(c)
}
}
// token 속 사용자 roles 를 가져온다
_, _, rolesInToken, err := contextutil.TokenToUser(c)
if err != nil {
return echo.NewHTTPError(http.StatusForbidden, err.Error())
}
// allowedRoles(허용하는 역할) 중에서 하나라도 일치하는 역할이 있으면 패스
for _, role := range allowedRoles {
if slices.Contains(rolesInToken, role) {
return next(c)
}
}
// 허용하는 역할중에 일치하는 역할이 없으면 403 에러 반환
errMessage := fmt.Sprintf("insufficient permissions to access this resource. allowed: %v, user roles: %v", allowedRoles, rolesInToken)
return echo.NewHTTPError(http.StatusForbidden, errMessage)
}
}
}
실제로 사용하는 부분을 보자.
AuthHandler 의 경우부터 보면 가장 먼저 /auth 엔드포인트 그룹으로 오는 요청은 기본적으로 domain.RolePublic 이다. 토큰이 없어도 허용하는 것이다. 이 덕분에 POST /auth/signup, POST /auth/login, POST /auth/refresh-token 은 토큰 없이도 접근이 허용된다. 반면 POST /auth/logout 은 인증이 필요하고, 모든 역할에 대해 인가가 되도록 설정되었다.
// ./internal/handler/authHandler.go
func NewAuthHandler(e *echo.Echo, authUseCase domain.AuthUseCase, config *config.Config) *AuthHandler {
handler := &AuthHandler{
authUseCase: authUseCase,
config: config,
}
group := e.Group("/auth", middlewares.AllowRoles(domain.RolePublic))
group.POST("/signup", handler.SignUpUser)
group.POST("/login", handler.Login)
group.POST("/refresh-token", handler.RefreshToken)
group.POST("/logout", handler.Logout, middlewares.AllowRoles(
domain.RoleAdmin, domain.RoleManager, domain.RoleUser))
return handler
}
UserHandler는 /users 그룹에 대해서는 별도의 제한을 걸어두지 않았다, GET /users 에는 domain.RoleAdmin 역할을 두었는데 전체 회원조회는 아무나 할 수 있으면 안되기 때문이다. 엄밀하게는 GET /users/:id 역시 domain.RoleAdmin 역할만을 두는게 맞는데 여기서는 예시로서 domain.RoleUser 도 두었다. 프러덕션에서는 GET /users/me 정도의 엔드포인트를 두어 토큰속 UserID에 해당되는 User 정보만을 접근할 수 있도록 하는 것이 맞다.
func NewUserHandler(e *echo.Echo, userUseCase domain.UserUseCase) *UserHandler {
handler := &UserHandler{userUseCase: userUseCase}
group := e.Group("/users")
group.GET("", handler.GetAll, middlewares.AllowRoles(domain.RoleAdmin))
group.GET("/:id", handler.GetByID, middlewares.AllowRoles(domain.RoleAdmin, domain.RoleUser))
return handler
}
로그아웃
앞에서 언급한 대로 로그아웃의 경우는 토큰 인증을 요구한다.
우선 핸들러를 보자.
- echo.Context 에서 가지고 있는 토큰에서 파싱한 정보에서 로그아웃을 시도하는 사용자의 아이디를 추출한다.
- 그리고 usecase 에서 로그아웃을 처리하도록 하는데 현재는 하는 일이 없다.
- 마지막으로 쿠키를 리셋해준 다음 회신을 한다.
// ./internal/handler/authHandler.go
func (h *AuthHandler) Logout(c echo.Context) error {
// 컨텍스트에서 사용자 ID 가져오기
userID, _, _, err := contextutil.TokenToUser(c)
if err != nil {
return c.JSON(http.StatusUnauthorized, ErrResponse(domain.ErrUnauthorized))
}
ctx := c.Request().Context()
// 로그아웃 처리 - 현재는 간단하게 구현
err = h.authUseCase.Logout(ctx, userID)
if err != nil {
return c.JSON(http.StatusInternalServerError, ErrResponse(domain.ErrInternal))
}
// 리프레시 토큰 쿠키 만료시키기
c.SetCookie(h.CreateLogoutCookie())
// 클라이언트에 응답 - 프론트엔드에서 액세스 토큰을 삭제해야 함을 알림
return c.JSON(http.StatusOK, map[string]string{
"message": "Successfully logged out. Please remove the access token from your client storage.",
"status": "success",
})
}
usecase 에서는 현재는 하는 일이 없다.
// ./internal/usecase/authUsecase.go
// Logout 사용자 로그아웃 처리
func (uc *authUseCase) Logout(ctx context.Context, userID int64) error {
// 현재 구현에서는 클라이언트 측에서 토큰을 삭제하는 방식으로 처리
// 서버 측에서는 특별한 작업이 필요 없음
// TODO: 향후 토큰 블랙리스트 구현 시 여기에 추가
// - 사용자의 현재 토큰을 블랙리스트에 추가
// - Redis 또는 다른 인메모리 저장소 사용 권장
// - 토큰의 만료 시간까지만 블랙리스트에 유지
return nil
}
리프레시 토큰을 담은 쿠키를 리셋해주는 것이 백엔드에서의 가장 핵심이다.
- 토큰 정보 자체를 빈 문자열로 담고, 쿠키 만료시간마저 한 시간전으로 해버리는 것이다.
// ./internal/handler/authHandler.go
// CreateLogoutCookie creates a cookie that expires the refresh token
func (h *AuthHandler) CreateLogoutCookie() *http.Cookie {
// 빈 값과 과거 만료일로 쿠키를 생성하여 쿠키를 삭제하는 효과를 냅니다
return h.createRefreshTokenCookie("", time.Now().Add(-1*time.Hour))
}
보안의 고도화
현재의 구현은 access token은 프런트엔드에서 제거해주기를 기대하고, refresh token은 쿠키를 리셋해주는 것이 전부이다. 보안의 중요도에 따라 이 정도로 충분할 수도 있다. 하지만 나아가 좀더 고도화할 필요도 있다.
토큰 블랙리스트
로그아웃을 하거나 특정 토큰 사용의 이상 사용을 감지하였을 때에 해당 토큰을 블랙리스트에 추가한다. 보통은 레디스(Redis)에 저장하며, 토큰 자체를 키로 하고 값은 “1” 정도로 넣어주면 될 것이며, 토큰 발행시 설정해둔 만료시간을 레디스에도 동일하게 설정해주면 된다.
로그아웃을 한다면 access token, refresh token을 레디스에 넣는다. 모든 로그인에 대해서는 토큰이 블랙리스트에 있는지 레디스를 확인하고 진행하는 것이다. 로그아웃을 하고 난 뒤 만료시간이 남았는데 이러한 토큰들로 로그인을 시도하면 레디스에존재할 것이다.
다중 디바이스 지원
도메인의 특성에 따라 모든 디바이스 또는 특정 디바이스에서만 로그아웃을 하도록 설정할 수 있다. 이를 위해서는 토큰에 디바이스 아이디 또는 세션 아이디를 추가해주면 된다.
토큰 재발행
access token은 짧은 만료시간을 가지고 refresh token 은 상대적으로 긴 만료시간을 가진다. 매번 로그인하여 access token 을 받는 것은 사용자 경험에 좋지 않기에 만료시마다 쿠키 속의 refresh token을 이용하여 access token을 다시 발급 받도록 한다. 여기서는 refresh token 또한 갱신하여 새로 받도록 하였다.
핸들러 코드를 보자.
- echo.Context 에서 쿠키를 꺼내어 cookie.Value 를 usecase 에 전달한다.
- usecase는 로그인에서와 마찬가지로 새로운 토큰들을 생성해주고
- 로그인과 마찬가지로 loginResponse 에는 access token을, 쿠키에는 새로운 refresh token을 담아 회신한다.
// RefreshToken handles token refresh requests
func (h *AuthHandler) RefreshToken(c echo.Context) error {
// Get refresh token from cookie
cookie, err := c.Cookie(refreshTokenCookieName)
if err != nil {
return c.JSON(http.StatusUnauthorized, ErrResponse(errors.New("refresh token not found")))
}
ctx := c.Request().Context()
// Call use case to refresh tokens
loginResponse, err := h.authUseCase.RefreshToken(ctx, cookie.Value)
if err != nil {
switch {
case errors.Is(err, security.ErrInvalidToken), errors.Is(err, security.ErrExpiredToken):
return c.JSON(http.StatusUnauthorized, ErrResponse(errors.New("invalid or expired refresh token")))
case errors.Is(err, domain.ErrUnauthorized):
return c.JSON(http.StatusUnauthorized, ErrResponse(domain.ErrUnauthorized))
default:
return c.JSON(http.StatusInternalServerError, ErrResponse(domain.ErrInternal))
}
}
// Set new refresh token as HTTP-only cookie
newCookie := h.createRefreshTokenCookie(loginResponse.RefreshToken, loginResponse.RefreshTokenExpiration)
c.SetCookie(newCookie)
// Return new access token
return c.JSON(http.StatusOK, loginResponse)
}
usecase 코드를 보자.
- security.ValidateRefreshToken 헬퍼 함수로 보안을 강화하였다. 클레임 속의 tokenType까지 확인하여 refresh token 임을 검증해준다. 로그인시에도 좀더 보안을 강화하고 싶다면 access token 인지를 security.ValidateAccessToken 헬퍼 함수로 검증할 수 있다.
- 클레임 속의 UserID를 이용하여 사용자의 정보를 데이터베이스에서 가져온 다음 토큰을 생성해준다.
// ./internal/handler/authHandler.go
func (uc *authUseCase) RefreshToken(ctx context.Context, refreshToken string) (*domain.LoginResponse, error) {
// Validate the refresh token
claims, err := security.ValidateRefreshToken(refreshToken, uc.publicKey)
if err != nil {
return nil, domain.ErrUnauthorized
}
// Get user by ID
user, err := uc.userRepo.GetByID(ctx, claims.UserID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, domain.ErrUnauthorized
}
return nil, err
}
response, err := uc.generateTokens(user)
if err != nil {
return nil, err
}
return response, nil
}
마치며
세 편의 포스팅에 걸쳐 인증, 인가에 대한 이야기를 했다. 특정 주제에는 지나치게 깊이 들어간 부분이 있고, 코드에서는 오히려 모든 엔드포인트에 구현내용을 적용하지 않기도 했다. 양해를 구한다. 다음 주제는 핸들러에서의 validation을 다룬다.
'golang' 카테고리의 다른 글
Go 백엔드 9: 인증과 인가 - 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
- gocore
- 엉클 밥
- postgres
- 영화
- bun
- 독서
- 티스토리챌린지
- websocket
- Echo
- 잡학툰
- golang
- 클린 애자일
- 2023
- strange
- intellij
- ChatGPT
- notion
- OpenAI
- clean agile
- 오블완
- middleware
- agile
- 클린 아키텍처
- Bug
- 인텔리제이
- go
- 독서후기
- API
- Gin
- solid
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |