패스키는 FIDO 표준으로 지정된 차세대 인증 방식이다. 대략 영지식 증명이랑 비슷한 느낌이었는데, Gemini한테 물어봤더니 딱 잘라서 정리해준다.
패스키는 사용자 인증을 위한 기술이고, 영지식 증명은 정보를 안전하게 주고받기 위한 암호학적 기술입니다.
두 기술은 서로 다른 목적을 가지고 있으며, 사용되는 분야도 다릅니다.
패스키는 사진처럼 서버에서 자물쇠를 보내주면 클라이언트가 풀어진 자물쇠를 전달하는 것으로 설명된다.
아무튼 그래서 Go와 Svelte를 이용해서 패스키를 구현해보고자 한다.
일단 이 글을 많이 참고했다.
이 그림이 꽤 잘 설명하는 것 같다.
기본적으로 패스키는 등록(registration)과 로그인(login)으로 구성된다. 그 둘의 흐름은 거의 동일하다.
정말 간단하게 전체 흐름을 요약하자면,
이런 식으로 동작한다. 여기서 인증자는 클라이언트의 디바이스에 종속되어 있다. 맥OS나 윈도우 10 이상에서 가능하며, 아무래도 맥으로 개발하는게 제일 편하다. 타 디바이스(안드로이드)와 연계해서 거기서 인증을 처리할 수도 있긴 한데, 개발 단계에서는 그렇게까지 하기에는 좀 불편하다. 우분투에서 KeePassXC
익스텐션을 이용해서 할 수 있다는데 해보니까 잘 안돼서, 이 부분은 되면 추가로 글을 써보도록 하겠다.
아무튼, Flow에서 볼 수 있듯이 프론트엔드와 백엔드를 몇번씩 왔다갔다 해야 한다. 그렇지만 일단 백엔드와 프론트엔드 각각 따로 설명하도록 한다.
위 API에 맞춰 본 시스템의 흐름을 설명한다.
예시 사이트에서는 세션ID 전달에 쿠키를 사용했는데 여기서는 헤더를 사용하게끔 짰다. 개인적으로 쿠키를 별로 안좋아해서..
또, 외부 시스템을 단순하게 표현만 하기 위해 (redis나 valkey같은) 캐시 서버의 역할을 하는 cache
패키지, 그리고 DB의 역할을 하는 repository
패키지가 존재한다.
회원가입 시작 API
에 전송.authenticator
에서 인증 및 결과를 회원가입 종료 API
에 전송.로그인 흐름은 회원가입과 아주 약간만 다르다.
로그인 시작 API
에 전송.authenticator
에서 인증 및 결과를 로그인 종료 API
에 전송.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같은 보안 프로토콜이어야 한다.
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 하는 부분이 있는데, 이렇게 해서 좀 더 디테일한 에러 정보를 확인할 수 있다. 아마 여기 말고 다른 에러들도 이렇게 할 수 있지 않나 싶은데 코드를 더 확인을 안해봐서 잘 모르겠다.
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를 반환한다.
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 확인해서 세션에서 정보 불러와서 던져주는 게 전부이다.
브라우저를 이용하기 때문에 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편에서 다룰 내용은 프론트엔드와 전체적인 동작 확인에 관한 내용이다.