티스토리 뷰

개요

인증(Authentication), 인가(Authorization)을 다루어 보려 한다. 이 주제는 작성해보니 제법 분량이 되어 다음 셋으로 나누어 포스팅 한다. 코드의 변화가 많기에 모든 내용을 설명하기 보다는 관련한 코드만을 설명하겠다.

  • 비밀번호의 저장과 검증
  • JWT(Json Web Token)의 생성
  • JWT를 이용한 인증과 인가

링크

목차

  • 비밀번호의 생성과 검증
  • signup 과 login

비밀번호 개요

비밀번호와 해시 함수

암호화 알고리즘은 크게 대칭키, 비대칭키, 해시 알고리즘으로 구분할 수 있다(챗GPT에게 물어보면 쉽게 설명해줄 것이다). 그 중에서 해시 알고리즘은 단방향성을 가지는데 이는 평문을 입력하면 해시된 값을 계산해주는 것은 쉬우나 그 반대로 해시된 값에서 원래의 평문을 알아내기는 극도로 어렵다. 대표적인 해시 알고리즘은 MD5, SHA-256등이 있다.

비밀번호를 다루는 로직은 이러한 해시 알고리즘의 특징을 이용한다. 일반적인 해시 알고리즘은 빠를 수록 좋지만 비밀번호에서 사용하는 해시 알고리즘은 오히려 속도를 느리게 하여 보안을 강화한다. 대표적인 해시 알고리즘은 bcrypt, argon2 등이 있는데 여기서는 많이 사용해온 bcrypt 대신 좀더 보안이 강화된 argon2 를 사용해 구현했다.

비밀번호 관련 로직

비밀번호와 관련한 로직은 어떻게 동작하는지 알아보자. 기본 개념은 다음과 같이 간단하다.

  1. 저장: 사용자가 비밀번호를 설정하면 서비스는 이를 해싱하여 그 값을 저장한다.
  2. 검증: 사용자가 로그인을 시도하며 비밀번호를 함께 제공하면, 비밀번호의 해싱값과 데이터베이스에 저장해둔 해싱값을 비교하여 검증한다.

평문으로 된 비밀번호는 런타임에만 존재하게 되며, 네트워크를 통해 전달될 때에 HTTPS를 통한다면 중간에 가로챈다 해도(MITM, Man In The Middle) 보호된다.

비밀 번호 생성

비밀번호를 생성하고 검증하는 코드를 보자. 핵심 부분만 추려서 설명하겠다. 생성과 검증을 위한 코드는 security 패키지에 만들었는데 가능한 비즈니스 코드에 의존성이 없도록 하는데 신경을 썼다. validateParam 등의 헬퍼 함수는 깃헙의 코드를 참고 바란다.

salt

만약 비밀번호를 password 라고 설정하고 해시값을 계산하면 같은 알고리즘은 항상 같은 결과를 보여준다. 해커가 데이터베이스를 해킹하여 많이들 쓰는 비밀번호의 해시값과 비교하면 빠르게 실제 비밀번호가 무엇인지 알아낼 수 있다. 이를 방지하기 위해 비밀번호에 임의의 값은 salt를 추가하여 해싱을 한다. 실제로 저장할 때는 salt와 해시값을 함께 저장하기에 검증도 문제가 없다.

호환성

argon2 해시 알고리즘을 사용할 때에 최소한으로 필요한 것은 salt와 해시값이지만 여기에서는 argon2의 버전과 메모리, 반복, 스레드와 같은 파라미터 정보를 함께 넣어주었다. 비밀번호가 어떤 버전과 파라미터로 해싱되었는지를 알 수 있게 하여 범용성과 호환성을 높인 것이다.

// ./pkg/security/password.go 
// GeneratePasswordHash는 비밀번호를 Argon2로 해싱해 표준화된 문자열 반환
// 입력: 평문 비밀번호, 선택적 파라미터 (nil이면 기본값 사용)
// 출력: "$argon2id$v=버전$m=메모리,t=반복,p=스레드$솔트$해시" 형식, 에러
func GeneratePasswordHash(password string, p *HashParams) (string, error) {

	if len(password) == 0 {
		return "", errors.New("empty password not allowed")
	}

	// 파라미터가 없으면 기본값 사용
	if p == nil {
		p = &defaultParams
	}

	if err := validateParams(p); err != nil {
		return "", err
	}

	// 16바이트 솔트 생성 (무작위성으로 재사용 방지)
	salt := make([]byte, 16)
	_, err := rand.Read(salt)
	if err != nil {
		return "", fmt.Errorf("failed to generate salt: %w", err)
	}

	// Argon2id로 해시 생성
	// - password: 입력 비밀번호
	// - salt: 무작위 솔트
	// - p.Time, p.Memory, p.Threads, p.KeyLen: 설정된 파라미터
	hash := argon2.IDKey([]byte(password), salt, p.Time, p.Memory, p.Threads, p.KeyLen)

	// 솔트와 해시를 base64로 인코딩
	encodedSalt := base64.RawStdEncoding.EncodeToString(salt)
	encodedHash := base64.RawStdEncoding.EncodeToString(hash)

	// PHC 형식에 가까운 표준 포맷으로 결합
	// 예: "$argon2id$v=19$m=65536,t=3,p=4$base64salt$base64hash"
	return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
		argon2.Version, p.Memory, p.Time, p.Threads, encodedSalt, encodedHash), nil
}

비밀번호의 검증

검증은 간단하다.

  1. parshHash 헬퍼함수(깃헙 코드 참고)는 가입시 설정한 비밀번호로 생성하여 데이터베이스에 저장한 해시값(코드에서는 encodedHash)을 포함한 데이터를 받아서 해시 알고리즘의 버전과 해싱에 사용한 파라미터 값을 추출해낸다.
  2. 추출한 버전, 파라미터 정보를 이용해 입력받은 평문 비밀번호(코드에서는 password)를 동일한 조건으로 해싱한 다음 비교를 한다.
  3. 여기서, subtle 패키지의 subtle.ConstantTimeCompare 를 이용하는 것이 재미나다. 해커의 열정에 박수 쳐줄 만한 부분인데 해커는 잘못된 비밀번호를 입력하여 그 회신을 받는 시간의 차이를 이용해서도 해킹을 한다고 한다(time-based attack). subtle.ConstantTimeCompare는 언제나 동일한 시간이 지나 결과를 리턴한다. 이미 해싱한 값의 비교라 불필요해 보일 수 있지만 지켜주면 좋다.
// ./pkg/security/password.go 
// ComparePasswordHash는 입력 비밀번호가 저장된 해시와 일치하는지 확인
// 입력: 평문 비밀번호, 저장된 해시 문자열
// 출력: 일치 여부 (bool), 에러
func ComparePasswordHash(password, encodedHash string) (bool, error) {
	memory, time, threads, salt, hash, err := parseHash(encodedHash)
	if err != nil {
		return false, err
	}

	computedHash := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(hash)))
	return subtle.ConstantTimeCompare(hash, computedHash) == 1, nil
}

SignUp

실제 API 호출의 핵심 부분만 들여다보자.

가입을 시도할 때에 요청의 바디에는 이메일과 평문 비밀번호가 담겨 온다.

// ./internal/handler/domain/auth.go
type SignUpRequest struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

handler는 이 정보를 추출하여 usecase로 전달하고, 그 결과를 다시 회신한다.

// ./internal/handler/authHandler.go
func (h *AuthHandler) SignUpUser(c echo.Context) error {
	req := new(domain.SignUpRequest)
	if err := c.Bind(&req); err != nil {
		return c.JSON(http.StatusBadRequest, ErrResponse(domain.ErrInvalidInput))
	}

	if req.Email == "" || req.Password == "" {
		return c.JSON(http.StatusBadRequest, ErrResponse(domain.ErrInvalidInput))
	}

	user := &domain.User{
		Email:    req.Email,
		Password: req.Password,
	}

	ctx := c.Request().Context()
	createdUser, err := h.authUseCase.SignUpUser(ctx, user)
	if err == nil {
		return c.JSON(http.StatusCreated, domain.SignUpResponse{
			ID:    createdUser.ID,
			Email: createdUser.Email,
		})
	}

	switch {
	case errors.Is(err, domain.ErrInvalidInput):
		return c.JSON(http.StatusBadRequest, ErrResponse(err))
	case errors.Is(err, domain.ErrAlreadyExists):
		return c.JSON(http.StatusConflict, ErrResponse(err))
	default:
		return c.JSON(http.StatusInternalServerError, ErrResponse(domain.ErrInternal))
	}
}

usecase는 securety 패키지에 구현해둔 GeneratePasswordHash 메서드를 이용해 해시값을 계산한 다음 이를 데이터베이스에 저장한다. 리포지토리 저장코드는 생략한다.

// ./internal/usecase/authUsecase.go 
func (uc *authUseCase) SignUpUser(ctx context.Context, user *domain.User) (*domain.User, error) {
	// 비밀번호 해싱
	hashedPassword, err := security.GeneratePasswordHash(user.Password, nil)
	if err != nil {
		return nil, err
	}
	user.Password = hashedPassword

	return uc.authRepo.CreateUser(ctx, user)
}

Login

여기서는 로그인을 시도할 때에 비밀번호를 검증하는 것만을 다루고, 검증에 성공하여 access token 과 refresh token 을 생성하는 부분에 대한 이야기는 다음 포스팅에서 다루겠다.

다음과 같은 정보를 담아 로그인 요청을 보낼 것이다.

type LoginRequest struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

handler는 정보를 추출하여 usecase에게 비밀번호 검증 및 회신을 위한 토큰 생성을 요청한다.

func (h *AuthHandler) Login(c echo.Context) error {
	req := new(domain.LoginRequest)
	if err := c.Bind(&req); err != nil {
		return c.JSON(http.StatusBadRequest, ErrResponse(domain.ErrInvalidInput))
	}

	if req.Email == "" || req.Password == "" {
		return c.JSON(http.StatusBadRequest, ErrResponse(domain.ErrInvalidInput))
	}

	ctx := c.Request().Context()
	loginResponse, err := h.authUseCase.Login(ctx, req.Email, req.Password)
	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)
	}

	switch {
	case errors.Is(err, domain.ErrInvalidInput):
		return c.JSON(http.StatusUnauthorized, ErrResponse(errors.New("invalid email or password")))
	default:
		return c.JSON(http.StatusInternalServerError, ErrResponse(domain.ErrInternal))
	}
}

usecase는 security.ComparePasswordHash 함수를 이용해 비밀번호를 검증하고, 검증에 성공하면 토큰을 생성하여 회신한다.

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)
}

마치며

가입시에 비밀번호를 어떻게 저장하는지, 가입한 유저가 로그인을 시도할 때에 비밀번호를 어떻게 검증하는지를 알아보았다. 다음 포스팅에서는 로그인에 성공했을 때 access token 과 refresh token 을 어떻게 생성하여 회신하는지를 다루어보겠다.

반응형

'golang' 카테고리의 다른 글

Go 백엔드 10: 인증과 인가 - JWT 인증  (0) 2025.03.20
Go 백엔드 9: 인증과 인가 - JWT 생성  (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
«   2025/03   »
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
글 보관함