Go와 Svelte를 이용한 Passkey 인증 (1)

박재훈·2024년 10월 15일
1

GO

목록 보기
22/23
post-thumbnail
post-custom-banner

Passkey

패스키는 FIDO 표준으로 지정된 차세대 인증 방식이다. 대략 영지식 증명이랑 비슷한 느낌이었는데, Gemini한테 물어봤더니 딱 잘라서 정리해준다.

패스키는 사용자 인증을 위한 기술이고, 영지식 증명은 정보를 안전하게 주고받기 위한 암호학적 기술입니다.
두 기술은 서로 다른 목적을 가지고 있으며, 사용되는 분야도 다릅니다.

패스키는 사진처럼 서버에서 자물쇠를 보내주면 클라이언트가 풀어진 자물쇠를 전달하는 것으로 설명된다.
아무튼 그래서 Go와 Svelte를 이용해서 패스키를 구현해보고자 한다.

일단 이 글을 많이 참고했다.

Passkey Flow

이 그림이 꽤 잘 설명하는 것 같다.
기본적으로 패스키는 등록(registration)과 로그인(login)으로 구성된다. 그 둘의 흐름은 거의 동일하다.

정말 간단하게 전체 흐름을 요약하자면,

  • 유저가 등록을 요청하면 서버에서는 등록 세트를 만들어 세션에 저장해놓은 뒤 유저에게 보냄
  • 유저는 서버로부터 받은 데이터를 인증자에게 보내서 입증 결과(attestation)를 받음
  • 그걸 서버에 전달하면, 서버는 검증 후 세션에 저장된 등록 데이터를 바탕으로 유저 생성하여 DB에 저장 및 세션 데이터 삭제

이런 식으로 동작한다. 여기서 인증자는 클라이언트의 디바이스에 종속되어 있다. 맥OS나 윈도우 10 이상에서 가능하며, 아무래도 맥으로 개발하는게 제일 편하다. 타 디바이스(안드로이드)와 연계해서 거기서 인증을 처리할 수도 있긴 한데, 개발 단계에서는 그렇게까지 하기에는 좀 불편하다. 우분투에서 KeePassXC 익스텐션을 이용해서 할 수 있다는데 해보니까 잘 안돼서, 이 부분은 되면 추가로 글을 써보도록 하겠다.

아무튼, Flow에서 볼 수 있듯이 프론트엔드와 백엔드를 몇번씩 왔다갔다 해야 한다. 그렇지만 일단 백엔드와 프론트엔드 각각 따로 설명하도록 한다.

API

  • 회원가입
    • /api/passkey/register/start
    • /api/passkey/register/finish
  • 로그인
    • /api/passkey/login/start
    • /api/passkey/login/finish
  • 테스트를 위한 페에지, 로그인 된 유저 정보를 불러옴
    • /api/forbidden

System Flow

위 API에 맞춰 본 시스템의 흐름을 설명한다.

예시 사이트에서는 세션ID 전달에 쿠키를 사용했는데 여기서는 헤더를 사용하게끔 짰다. 개인적으로 쿠키를 별로 안좋아해서..

또, 외부 시스템을 단순하게 표현만 하기 위해 (redis나 valkey같은) 캐시 서버의 역할을 하는 cache 패키지, 그리고 DB의 역할을 하는 repository 패키지가 존재한다.

회원가입

  1. 클라이언트에서 유저 정보를 회원가입 시작 API에 전송.
  2. 백엔드에서 유저 정보를 이용해 키, 세션 데이터 생성.
  3. 캐시 서버에 유저 정보, 세션 데이터 저장 및 클라이언트에 반환.
  4. 클라이언트에서 이를 토대로 authenticator에서 인증 및 결과를 회원가입 종료 API에 전송.
  5. 서버에서 결과를 받아서 검증한 뒤 세션에 저장되어 있던 임시 데이터를 DB에 저장.

로그인

로그인 흐름은 회원가입과 아주 약간만 다르다.

  1. 클라이언트에서 유저 정보를 로그인 시작 API에 전송.
  2. 백엔드에서 유저 정보를 이용해 키, 세션 데이터 생성.
  3. 캐시 서버에 유저 정보, 세션 데이터 저장 및 클라이언트에 반환.
  4. 클라이언트에서 이를 토대로 authenticator에서 인증 및 결과를 로그인 종료 API에 전송.
  5. 서버에서 결과를 받아서 검증한 뒤 세션에 저장되어 있던 임시 데이터를 로그인 세션 데이터로 이관 및 세션ID 반환.

Backend (Golang)

Go 버전은 1.23을 사용했다.

Go에서 패스키를 이용할 수 있는 라이브러리가 몇개 있지만, go-webauthn/webauthn이 제일 표준을 잘 구현했다고 해서 이걸 이용하기로 했다. Go 1.21 버전부터 사용 가능하다.

var (
	rpProtocol = "http"
	rpHost     = "localhost"
	rpPort     = ":5173"
)

wconfig := &webauthn.Config{
	RPDisplayName: "golang passkey example",
	RPID:          rpHost,
	RPOrigins:     []string{fmt.Sprintf("%s://%s%s", rpProtocol, rpHost, rpPort)},
}

webAuthn, err := webauthn.New(wconfig)
if err != nil {
	panic(err)
}

먼저 webautnn 인스턴스를 만들어야 하는데, Relying Party 정보를 입력해야 한다. 패스키의 검증이 프론트엔드에서 이뤄지므로 본 시스템의 Relying Party는 프론트엔드가 되는 것이다.
따라서 RPID에는 프론트엔드의 호스트 정보(여기서는 localhost), 그리고 RPOrigins에는 전체 주소가 들어가면 된다. 슬라이스 형태로 되어 있어 여러 값을 넣을 수 있다.

참고로, localhost가 아니라면 프로토콜은 무조건 https같은 보안 프로토콜이어야 한다.

Registration

func BeginRegistration(w http.ResponseWriter, r *http.Request) {
	// 유저로부터 받은 유저 정보 (여기서는 이름, 이메일, 출생연도)
	var userDTO dto.UserRegistrationDTO
	if err := json.NewDecoder(r.Body).Decode(&userDTO); err != nil {
		resp.ErrorResponse(w, r, http.StatusBadRequest, err)
		return
	}

	// 패스키에 이용될 유저 ID
    // 64바이트 이내의 값이 필요하며, 64바이트를 다 쓰는 것이 권장된다.
	userID, err := utils.RandID(64)
	if err != nil {
		resp.ErrorResponse(w, r, http.StatusInternalServerError, err)
		return
	}

	// 이 구조체는 webauthn.User를 구현해야 한다.
	user := &entity.User{
		ID:        userID,
		Name:      userDTO.Name,
		Email:     userDTO.Email,
		BirthYear: userDTO.BirthYear,
	}

	// user 인스턴스를 이용해서 회원가입 시작 데이터 생성
    // 여기서 options에는 public key 정보가 들어가있다.
	options, session, err := webAuthn.BeginRegistration(user)
	if err != nil {
		resp.ErrorResponse(w, r, http.StatusInternalServerError, err)
		return
	}

	// 회원가입 시작 데이터를 캐시 서버에 저장하기 위해 json으로 serialize
	sessionJSON, err := json.Marshal(&sessionData{
		Session: session,
		User:    user,
	})
	if err != nil {
		resp.ErrorResponse(w, r, http.StatusInternalServerError, err)
		return
	}

    // uuid로 세션id를 생성해서 캐시 서버에 1시간 지정해서 저장
	sessionID := uuid.NewString()
	scache.Put(sessionID, sessionJSON, time.Hour)

	// 클라이언트에 public key와 세션id 반환
	resp.JSONResponse(w, http.StatusOK, &dto.BeginRegistrationDTO{
		Options: options,
		SID:     sessionID,
	})
}

BeginRegistration 함수는 일단 클라이언트로부터 유저 정보를 받아서 패스키 등록 관련 임시 데이터를 저장해놓는다. 여기서 반환해준 정보를 클라이언트에서 인증하게 되면 임시 데이터를 실제로 DB에 기록하는 것이다.

func FinishRegistration(w http.ResponseWriter, r *http.Request) {
	// 클라이언트에서 세션id를 커스텀 헤더(X-Session-Id)로 보낸다.
	sid := r.Header.Get("X-Session-Id")
	if sid == "" {
		resp.ErrorResponse(w, r, http.StatusBadRequest, errors.New("session id required"))
		return
	}

	// 캐시 서버로부터 임시 데이터 가져와서 unserialize
	sessionJSON, exists := scache.Get(sid)
	if !exists {
		resp.ErrorResponse(w, r, http.StatusNotFound, errors.New("session not found"))
		return
	}
	var sd sessionData
	if err := json.Unmarshal(sessionJSON, &sd); err != nil {
		resp.ErrorResponse(w, r, http.StatusInternalServerError, fmt.Errorf("unmarshal: %w", err))
		return
	}

	// 임시 데이터와 요청 r값을 가지고 회원가입을 최종 검증한다.
	credential, err := webAuthn.FinishRegistration(sd.User, *sd.Session, r)
	if err != nil {
		// 에러 디테일 확인 가능
		if perr, ok := err.(*protocol.Error); ok {
			logger.Error().Any("error", perr).Send()
		}
		resp.ErrorResponse(w, r, http.StatusInternalServerError, fmt.Errorf("finishRegistration: %w", err))
		return
	}

	// 캐시에서는 삭제하고, DB에는 집어넣는다.
    // DB에 넣기 전에 FinishRegistration의 결과로 나왔던 credential을 append한다.
	sd.User.AddCredential(credential)
	repo.Save(sd.User)
	scache.Delete(sid)

	resp.JSONResponse(w, http.StatusOK, &dto.FinishRegistrationDTO{
		Message: "registration success",
	})
}

FinishRegistration에서는 클라이언트에서 인증한 값을 토대로 검증을 마친 뒤 최종적으로 DB에 집어넣는다.
중간에 webAuthn.FinishRegistration 함수 실행할 때 리턴된 에러를 *protocol.Error로 type assertion 하는 부분이 있는데, 이렇게 해서 좀 더 디테일한 에러 정보를 확인할 수 있다. 아마 여기 말고 다른 에러들도 이렇게 할 수 있지 않나 싶은데 코드를 더 확인을 안해봐서 잘 모르겠다.

Login

func BeginLogin(w http.ResponseWriter, r *http.Request) {
	var userDTO dto.UserLoginDTO
	if err := json.NewDecoder(r.Body).Decode(&userDTO); err != nil {
		resp.ErrorResponse(w, r, http.StatusBadRequest, err)
		return
	}

	// DB에서 name을 가지고 값을 찾아온다.
    // 그걸로 BeginLogin 실행
	user := repo.Find(userDTO.Name)
	options, session, err := webAuthn.BeginLogin(user)
	if err != nil {
		resp.JSONResponse(w, http.StatusBadRequest, err)
		return
	}

	// 회원가입 때와 비슷하게 임시 로그인 데이터를 serialize 해서 캐시 서버에 저장한다.
	sessionJSON, err := json.Marshal(&sessionData{
		Session: session,
		User:    user,
	})
	if err != nil {
		resp.ErrorResponse(w, r, http.StatusInternalServerError, err)
		return
	}
	sessionID := uuid.NewString()
	scache.Put(sessionID, sessionJSON, time.Hour)

	resp.JSONResponse(w, http.StatusOK, &dto.BeginLoginDTO{
		Options: options,
		SID:     sessionID,
	})
}

로그인 부분은 회원가입이랑 거의 비슷하다. 시작 데이터를 캐시에 넣고 세션ID를 클라이언트에 반환한다.

func FinishLogin(w http.ResponseWriter, r *http.Request) {
	// 클라이언트에서 세션id를 커스텀 헤더(X-Session-Id)로 보낸다.
	sid := r.Header.Get("X-Session-Id")
	if sid == "" {
		resp.ErrorResponse(w, r, http.StatusBadRequest, errors.New("session id required"))
		return
	}

	// 캐시 서버로부터 임시 데이터 가져와서 unserialize
	sessionJSON, exists := scache.Get(sid)
	if !exists {
		resp.ErrorResponse(w, r, http.StatusNotFound, errors.New("session not found"))
		return
	}
	var sd sessionData
	if err := json.Unmarshal(sessionJSON, &sd); err != nil {
		resp.ErrorResponse(w, r, http.StatusInternalServerError, fmt.Errorf("unmarshal: %w", err))
		return
	}

	// 임시 데이터와 요청 r값을 가지고 로그인을 최종 검증한다.
	credential, err := webAuthn.FinishLogin(sd.User, *sd.Session, r)
	if err := json.Unmarshal(sessionJSON, &sd); err != nil {
		resp.ErrorResponse(w, r, http.StatusInternalServerError, fmt.Errorf("unmarshal: %w", err))
		return
	}

	// Handle credential.Authenticator.CloneWarning
	if credential.Authenticator.CloneWarning {
		logger.Warn().Msgf("[WARN] can't finish login: %s", "CloneWarning")
	}

	sd.User.AddCredential(credential)
	repo.Save(sd.User)
	scache.Delete(sid)

	// 캐시에서는 삭제하고, DB에는 집어넣는다.
    // DB에 넣기 전에 FinishLogin의 결과로 나왔던 credential을 append한다.
	loginSessionJSON, err := json.Marshal(&sessionData{
		User: sd.User,
	})
	if err != nil {
		resp.ErrorResponse(w, r, http.StatusInternalServerError, err)
		return
	}
	sessionID := uuid.NewString()
	scache.Put(sessionID, loginSessionJSON, time.Hour)

	resp.JSONResponse(w, http.StatusOK, &dto.FinishLoginDTO{
		SID: sessionID,
	})
}

FinishLogin도 크게 다를 바는 없다. 임시 데이터를 캐시 서버로부터 꺼내와 유저가 보낸 attestation과 검증하여 로그인이 성공했으면 세션에 저장한 뒤 세션ID를 반환한다.

Forbidden

func ForbiddenPage(w http.ResponseWriter, r *http.Request) {
	sid := r.Header.Get("X-Session-Id")
	if sid == "" {
		resp.ErrorResponse(w, r, http.StatusBadRequest, errors.New("session id required"))
		return
	}

	sessionJSON, exists := scache.Get(sid)
	if !exists {
		resp.ErrorResponseWithText(w, r, http.StatusForbidden)
		return
	}

	var sd sessionData
	if err := json.Unmarshal(sessionJSON, &sd); err != nil {
		resp.ErrorResponse(w, r, http.StatusInternalServerError, fmt.Errorf("unmarshal: %w", err))
		return
	}

	resp.JSONResponse(w, http.StatusOK, map[string]any{
		"name":      sd.User.Name,
		"email":     sd.User.Email,
		"birthYear": sd.User.BirthYear,
	})
}

Forbidden은 유저가 로그인이 제대로 되어 있으면 해당 유저의 데이터를 반환해주는 함수이다. 사실 뭐 이건 패스키만의 특별한 그런 건 없고 그냥 세션ID 확인해서 세션에서 정보 불러와서 던져주는 게 전부이다.

Routing

브라우저를 이용하기 때문에 CORS를 설정해줬다. 세션ID에 커스텀 헤더(X-Session-Id)를 이용해서 그것도 CORS 설정에 추가해줬다.

CORS 라이브러리는 github.com/rs/cors를 이용했다.

serveMux := http.NewServeMux()
serveMux.HandleFunc("/api/passkey/register/start", BeginRegistration)
serveMux.HandleFunc("/api/passkey/register/finish", FinishRegistration)
serveMux.HandleFunc("/api/passkey/login/start", BeginLogin)
serveMux.HandleFunc("/api/passkey/login/finish", FinishLogin)
serveMux.HandleFunc("/api/forbidden", ForbiddenPage)

logger.Info().Msg("server start")
handler := cors.Default().Handler(serveMux)
corHandler := cors.New(cors.Options{
	AllowedOrigins: []string{"*"},
	AllowedHeaders: []string{"Content-Type", "X-Session-Id"},
})
handler = corHandler.Handler(handler)
if err := http.ListenAndServe(":4000", handler); err != nil {
	logger.Error().Err(err).Send()
}

2편에서 계속

내용이 길어지는 관계로 나머지는 2편에서 계속하도록 하겠다. 2편에서 다룰 내용은 프론트엔드와 전체적인 동작 확인에 관한 내용이다.

References

profile
생각대로 되지 않을 때, 비로소 코딩은 재미있는 법.
post-custom-banner

0개의 댓글