지금 진행하고 있는 웹 과제 ft_transcendence 에서는 42 intra 를 통한 로그인 이후 2FA를 요구하고 있다. 서비스에 적용한 TOTP 방식의 인증을 이해하고, 해당 프로젝트가 NestJS 를 기반으로 진행되기에 2FA 인증을 Javascript 에서 구현하는 과정을 소개하고자 한다.
2FA 란?
•
2FA 는 Two-Factor Authentication 의 준말로, 유저의 보안을 강화하기 위해 1차 로그인 과정 이외의 추가 인증 과정을 말한다.
•
최근 많은 웹 사이트, 게임 등에서 활용되고 있는 OTP, 모바일을 통한 추가 인증 등이 이에 해당된다.
•
인증은 아래의 3가지 유형으로 크게 분류할 수 있다.
•
일반적으로 지식기반의 인증에 더불어 또 다른 인증 방식을 추가하여 구현이 된다.
지식 기반 인증
사용자가 알고 있는 정보, 유출되기 쉽다는 단점이 있음.
•
아이디, 패스워드, 핀번호
소유 기반 인증
사용자가 소유하고 있는 것을 통한 인증.
•
OTP, 스마트 카드, 토큰, USB 키, SMS 인증 등...
생체 인증
사용자 그 자체를 증명할 수 있는 수단
•
지문 인식, 홍체 인식
참고
선택
Google Authenticator, EMAIL, 2차 비밀번호 총 3가지의 고려대상이 있었다.
•
2차 비밀번호의 경우 지식 기반의 인증이다. 위 해시넷에서 설명하듯, 분실 여부를 인지하기 어려우며, 대부분의 사용자가 동일한 패스워드를 여러 계정에서 활용하기 때문에 해커의 공격에 상대적으로 취약하다.
◦
1차 인증 방식이 외부 사이트에서의 지식 기반 인증을 통한 인증 방식이기 때문에, 같은 방식의 인증이 아닌 다른 인증 방식을 추구하는 것이 좋다고 생각했다.
◦
이와 더불어, 단순히 동일 채널을 통한 인증이 아니라 타 채널을 통한 인증 방식을 활용하고 싶다는 욕심이 있어, 타 인증 방식을 고려했다.
•
이메일의 경우 소유 기반의 인증이다. 등록된 이메일을 통해서 일회용 코드를 전송하고 이 코드를 통해 이중 인증을 진행한다. 혹여나 42 intra에 등록된 이메일을 활용하더라도, 42 intra 와 이메일은 분리되기 때문에 서로 다른 두 사이트에서 접속을 통한 인증이라고 인식했다.
◦
같은 방식으로 두 번 인증을 수행하는 것이 조금 꺼림칙했다. 어쩌면 2차 비밀번호와 비슷한 느낌이 될 수도 있었다.
•
Google Authenticator의 경우 명백한 소유 기반의 인증이다. Google Authenticator는 결국 TOTP 기반의 인증을 수행한다. 위에 고려했던 방법과 확실히 인증 방식의 차별점이 있었고, OTP 인증에 대한 이해를 해보고자 선택을 하게 되었다.
OTP
OTP 란?
One Time Password 의 준말로 무작위로 생성되는 1회용 비밀번호를 이용하는 방식이다. 우리가 은행에서 발급받아 사용하는 하드웨어 OTP 기기부터 게임 로그인에 활용하는 OTP 앱 모두를 포함한다.
•
•
그리고 아래 글에서 더 자세하고 친절하게 설명되어있다.
https://www.howdy-mj.me/general/otp
HOTP
간단하게 살펴보는 HOTP 인증 과정
1.
유저와 서버가 공유하는 secret key를 생성한다.
a.
이 때 생성되는 secret key 는 유저마다 할당되며, 임의의 문자열로 생성된다.
2.
해당 secret key를 유저에게 전달하는 동시에, 서버도 유저마다의 secret key를 저장한다.
a.
유저는 해당 secret key 를 OTP 생성기에 등록한다.
3.
OTP 인증이 필요한 경우 유저는 OTP 생성기로부터 생성된 토큰을 서버에게 전송한다.
a.
이 때, 서버는 유저의 secret key 를 바탕으로 토큰을 생성한다.
b.
아래 설명에 있는 counter 가 동기화되지 않은 상태일 수 있으므로, resyncronize 과정이 포함될 수 있다.
4.
서버가 생성한 토큰과 유저가 생성한 토큰이 일치하면 인증에 성공한다.
a.
만약 인증에 실패할 경우 서버는 클라이언트에게 다른 토큰을 요구한다.
토큰은 어떻게 생성되는가?
# HOTP
HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))
Plain Text
복사
•
HOTP 의 토큰의 경우 Key 와 Counter 를 통해 HMAC-SHA-1 계산을 해서 나온 20 바이트의 결과값을 dynamic truncate 하여 얻게된다. (이 과정은 rfc4226 5.3 에 잘 설명되어있다.)
•
개발하는 입장에서 눈여겨볼 요소는 Counter와 Key 이다. 서버와 유저가 서로 공유하고 있는 Key와 같거나 가까운 값으로 유지되고 있는 Counter를 통해서 토큰이 생성되고, 이를 통해서 유저가 제안한 토큰이 유효한지 검사할 수 있기 때문이다.
•
Key 의 경우 클라이언트와 서버가 모두 저장하고 있는 상황이기 때문에 서로가 정상적으로 저장했다면, 해당 과정에서는 큰 문제는 아니다.
•
그러나 counter 의 값은 서버와 클라이언트가 서로 다른 값을 바라보고 있을 수 있다. 서버는 요청이 유효할 때만 counter 의 값을 증가시키고 클라이언트는 매 요청에서 counter를 증가시키기 때문이다. 따라서 이 간극을 맞추기 위한 resynchronization 이 필요하다.
•
server counter ≤ client counter 이므로 서버는 resynchronization 위해서 이후의 counter에 대해서 매칭이 되는지 확인하고 이를 동기화하면 된다.
•
따라서 . RFC 에서는 이를 위해서 resynchronize 를 위한 look ahead 파라미터를 별도로 설정하여 다음 카운터 값 중에 매칭되는지 확인을 하는 것을 권장한다.
보안에 대한 고려
•
Throttling 이라는 파라미터도 권장 요소이다. 유저가 일정 이상의 OTP 인증에 실패한 경우, 일시적으로 인증을 잠금을 하며, 전체 세션에 대해서 적용할 수 있도록 하여 병렬적으로 진행되는 brute force attack 으로부터 방지할 수 있다.
•
유저마다 할당되는 secret key 의 경우 안전하게 생성된 임의의 문자열으로 구성되어야하며 이를 저장할 때 또한 보안을 고려하여 보호해야한다.
참고
•
RFC 4226 을 확인하면 더 많은 내용을 확인할 수 있다. 만약에 구현을 하게 된다면 꼭 확인하길 바란다.
TOTP
우리 서비스에서 활용하는 Google Authenticator의 경우 TOTP 방식을 활용한다. 간단하게 작동 과정에 대해서 살펴보자. TOTP는 HOTP의 파생 방식으로 인증 과정은 동등하다. 다만 counter대신 time-step을 활용하여 토큰을 생성한다.
TOTP 토큰은 어떻게 생성될까?
•
TOTP의 경우 RFC 에서 HOTP의 제안사항으로 등장한 시간을 카운터로 사용하는 방법을 적용한 방식이다.
RFC4426 - E.5
We could also use a Timer, either as the only moving factor or in
combination with the Counter -- in this case, e.g., Data = Timer,
where Timer could be the UNIX-time (GMT seconds since 1/1/1970)
divided by some factor (8, 16, 32, etc.) in order to give a specific
time step. The time window for the One-Time Password is then equal
to the time step multiplied by the resynchronization parameter as
defined before. For example, if we take 64 seconds as the time step
and 7 for the resynchronization parameter, we obtain an acceptance
window of +/- 3 minutes.
Plain Text
복사
•
그래서 TOTP 의 토큰은 다음과 같이 식으로 표현할 수 있다.
TOTP = HOTP(K, TIME)
Plain Text
복사
•
TOTP의 경우 counter 대신 시간을 활용한다. 이 때 사용되는 시간은 일반적으로 unix time 을 활용하게 되며, 현재의 정확한 시각이 아니라 시간대를 활용한다. RFC 6238 에서는 다음과 같이 설명하고 있다.
More specifically, T = (Current Unix time - T0) / X, where the
default floor function is used in the computation.
For example, with T0 = 0 and Time Step X = 30, T = 1 if the current
Unix time is 59 seconds, and T = 2 if the current Unix time is
60 seconds.
Plain Text
복사
이 때, 하나의 time-step 은 30초를 권장하고 있다. 이는 네트워크 지연, 보안을 모두 고려한 권장 사항이며 뒤에 있는 구현 과정에서 또한 30초로 설정되어 있다.
•
TOTP에서도 클라이언트와 서버 사이의 시간 편차가 발생할 수 있기 때문에 마찬가지로 resynchronization 과정을 권장한다. HOTP 에서 counter의 slide가 요구되었듯, 마찬가지로 time-step 에 대한 slide를 사용하여 resyncronization 을 수행한다.
주의사항
•
시간 기반으로 생성이 되기 때문에 서버와 클라이언트의 시간이 아예 다를 경우 올바른 토큰을 얻을 수 없다. 이 때문에 만약 시간이 서로 다르다고 판별되는 경우 클라이언트의 OTP 시계를 조정해야한다.
참고
Google Authenticator
Google Authenticator는 앞에서 살펴본 TOTP 기반 토큰 생성기이다.
•
구글과 통신하여 일회용 비밀번호를 얻는 특별한 인증 장치라고 생각했지만 전혀 아니다. 그래서 따로 사용하는 방법에 대한 Documentation도 따로 없다..
Google Authenticator 를 활용하는 방법은 TOTP 를 적용하는 과정과 동일하다. 서버는 유저에게 할당한 secret key를 공유하고 Google Authenticator는 이를 앱에서 등록하여 토큰을 생성하는 것이다. 따라서 서버에서의 secret key 발급 과정, Authenticator로부터 발행된 토큰의 인증 과정을 이해하면 된다.
구현
가볍게 js에서 어떤 라이브러리를 사용하여 구현할 수 있는지 살펴보겠다.
OTP
키 발행
유저가 2FA를 활성화 할 때, OTP의 secret key 발급이 필요하다. OTP Secret key에 활용할 임의의 문자열을 생성해서 클라이언트에게 전달하면 된다. otplib에서 이를 지원하기 때문에 generateSecret() 함수를 이용하면 된다.
import { authenticator } from 'otplib';
const secret = authenticator.generateSecret(); // OTP Secret을 위한 임의의 문자열 생성
TypeScript
복사
이 결과값으로 임의의 문자열을 전달 얻을 수 있다. 이를 유저의 DB에 저장하고, 추후에 활용할 인증 과정에 활용하면 된다.
그런데, Google Authenticator 의 경우 QR 코드 인식을 지원한다. QR 코드에서는 otpauth://TYPE/LABEL?PARAMETERS 형식의 URI가 담겨있다. 이 또한 otplib 라이브러리에서 지원한다.
const otpURI = authenticator.keyuri(accountName, issuer, secret); // otpURI 생성
TypeScript
복사
이 결과 우리가 생성할 결과는 다음과 같을 것이다.
•
otpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30
import * as QRCode from 'qrcode'
// nodeJS에서 stream 형태인 express.Response 에 otpURI의 데이터를 갖는 이미지 스트림을 작성해줌.
// 이 자체로 response로 클라이언트에게 response가 갈 것이다.
QRCode.toFileStream(res, otpURI);
TypeScript
복사
인증
인증 방식 또한 간단하다. otplib 라이브러리를 다시 활용할 것이다. otplib 는 앞서 우리가 공부한 TOTP 의 인증 방식을 활용하여 해당 토큰의 유효성을 검증할 것이다.
// token: 클라이언트가 제공한 토큰 문자열
// secret: 유저 DB에 저장되어 있던 secret key
const isValid: boolean = authenticator.verify({ token: token, secret: secret });
TypeScript
복사
보안
Secret key 저장 방식
유저의 OTP Secret key를 평문으로 DB에 저장하는 것은 굉장히 위험한 행위이다. DB가 유출된 경우, OTP Secret key를 그대로 임의의 OTP 생성기에 적용하여 토큰을 획득하고 인증을 할 수 있기 때문이다. 따라서, OTP Secret key 의 경우 암호화 되어 저장되어야 한다.
암호화를 고려할 때, 우리가 인증에 원본 OTP Secret key 가 필요함을 인식해야한다. 그래서 단방향으로 복호화가 불가능한 암호화 방식이 아닌, 복호화가 가능한 방식이 필요하다. 이를 위해서 나는 AES-256 방식의 암호화를 사용했다.
cryptoJS
Node.js 에서 AES 암호화를 사용하기 위해서는 cryptoJS 라이브러리를 활용하면 된다. 우리는 타입스크립트를 활용하고 있기 때문에 @types/crypto-js도 함께 설치하는 것을 추천한다. AES 암호화 방식 자체에 대해서 공부할 내용이 많지만 해당 글에서는 가장 쉽게 암호화와 복호화를 하는 과정만 코드로 간략하게 설명할 것이다.
encrypt
import { authenticator } from 'otplib';
import * as CryptoJS from 'crypto-js';
const secret = authenticator.generateSecret(); // OTP Secret을 위한 임의의 문자열 생성 (위에서 본 코드)
// 암호화를 위한 키의 경우 32바이트의 문자열을 활용함
// 예시를 위한 코드이며, 별도로 관리하는 것이 좋다.
const ENCRYPTION_SECRET = "01234567890123456789012345678901"
// AES-256 암호화
// encrypt() 의 결과 wordArray 형태의 결과를 얻는다. 이를 바로 활용할 수 없으므로 toString()을 통해서 plain text 로 변경해야한다.
const encrypted = CryptoJS.AES.encrypt(secret, ENCRYPTION_SECRET).toString();
// ... user db에 저장
TypeScript
복사
decrypt
const decrypted: string = CryptoJS.AES.decrypt(user.two_factor_secret, ENCRYPTION_SECRET).toString(
CryptoJS.enc.Utf8
);
// 마찬가지로 decrypy 의 결과로 WordArray 를 반환받는다. 이를 UTF8 인코더를 통해서 문자열로 바꾼다. 기본값은 .hex
TypeScript
복사
인증 threshold 설정
RFC에서 권장하듯, 공격에 대해 대비하기 위해서 threshold를 설정할 필요가 있다. otplib에서는 별도로 제공을 하고 있지 않기 때문에, (해당이슈) 개발자가 직접 설정해줄 필요가 있다.