'Spring Boot 2단계 보안인증 Google OTP, Authenticator'
2FA (Two-factory authentication) 2단계 보안인증이란,
이메일, 문자 메시지 또는 Google Authenticator 앱으로 전송된 6자리 코드를 입력하도록 요청하는 아이디, 비밀번호 로그인 다음의 2번째 인증 단계로 이때 발급된 코드는 30초 또는 60초 후에 만료됩니다.
만약 타인에게 계정의 아이디와 비밀번호가 노출된 경우 2단계 인증이 설정되어 있다면 2단계 인증에서는 계정의 주인에게 전송된 2FA 코드를 확인하기 위해 계정의 주인이 등록한 모바일 장치가 필요하기 때문에 계정을 보다 안전하게 보호할 수 있습니다.
해당 포스팅에서는 Spring Boot 프로젝트에서 2FA 중 Google Authenticator 앱을 사용하여 인증하는 방법을 알아보겠습니다.
***
Google OTP의 경우 가장 대중적으로 사용되고 있는 2단계 보안인증 방법이지만 문제점으로 이야기되는 부분도 다소 있기 때문에 사용하기 전에 문제가 될만한 부분은 한번 파악하고 사용하는 것이 좋을 것 같습니다.
(자세한 내용은 포스팅 맨 하단에 링크해두겠습니다.)
// https://mvnrepository.com/artifact/de.taimos/totp
implementation group: 'de.taimos', name: 'totp', version: '1.0'
// https://mvnrepository.com/artifact/commons-codec/commons-codec
implementation group: 'commons-codec', name: 'commons-codec', version: '1.15'
// https://mvnrepository.com/artifact/com.google.zxing/javase
implementation group: 'com.google.zxing', name: 'javase', version: '3.4.1'
'totp, commons-codec, zxing 의존성 추가'
totp
: 시간 기반 일회용 암호 알고리즘(TOTP)은 HMAC 기반 일회용 암호 알고리즘(HOTP)의 확장으로 현재 시간의 고유성을 대신 사용하여 일회용 암호를 생성합니다.
commons-codec
: 입력을 16진수 및 base32로 변환하며, 최초 1회 실행되는 개인키 발급에 사용됩니다.
zxing
: QR 코드 생성을 위한 라이브러리입니다.
* TOTP(Time based One-time Password)
시간 동기화 방식, OTP를 생성하기 위해 사용하는 입력 값으로 시간을 사용하는 방식입니다.
Authenticator 앱을 예로 설명하면 Authenticator 앱과 서버는 같은 알고리즘을 바탕으로 하기 때문에 직접적인 인증을 위한 통신이 필요하지 않습니다. 작동 원리는 처음에 서버 쪽에서 해당 알고리즘으로 Key(또는 바코드 주소)를 생성해주면 클라이언트는 그것을 Authenticator 앱에 입력해줍니다. 그러면 앱에서는 그 Key(바코드 내부에 Key정보)를 가지고 30초마다 계속해서 새로운 일회성 비밀번호를 생성합니다. 클라이언트는 앱에서 생성되는 일회용 비밀번호를 서버에 입력하고, 서버에서는 그 비밀번호가 맞는지 알고리즘으로 확인하는 방법입니다.
시간 값을 사용하기 때문에 임의의 입력값이 필요하지 않다는 점에서 사용하기 간편하고, 클라이언트가 서버와 통신해야 하는 횟수가 비교적 적습니다. 하지만 클라이언트와 서버의 시간 동기화가 정확하지 않으면 인증에 실패하게 된다는 단점이 있으며, 이를 보완하기 위해 일반적으로 1~2분 정도의 OTP 생성 간격을 둡니다.
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.security.SecureRandom;
import org.apache.commons.codec.binary.Base32;
public class TOTPTokenGenerator {
private TOTPTokenGenerator() {
}
private static String GOOGLE_URL = "https://www.google.com/chart?chs=200x200&chld=M|0&cht=qr&chl=";
// 최초 개인 Security Key 생성
public static String generateSecretKey() {
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[20];
random.nextBytes(bytes);
Base32 base32 = new Base32();
return base32.encodeToString(bytes);
}
// 개인키, 계정명(시스템 사용자 ID), 발급자를 받아서 구글OTP 인증용 링크를 생성
public static String getGoogleAuthenticatorBarcode(String secretKey, String account, String issuer) {
try {
return GOOGLE_URL + "otpauth://totp/"
+ URLEncoder.encode(issuer + ":" + account, "UTF-8").replace("+", "%20")
+ "?secret=" + URLEncoder.encode(secretKey, "UTF-8").replace("+", "%20")
+ "&issuer=" + URLEncoder.encode(issuer, "UTF-8").replace("+", "%20");
} catch (UnsupportedEncodingException e) {
throw new IllegalStateException(e);
}
}
}
'TOTPTokenGenerator Class'
개인 키(비밀 키) 및 QR바코드 생성을 위한 클래스입니다.
Google OTP에는 base32 문자열로 인코딩 된 20바이트의 secretKey가 필요합니다.
generateSecretKey() 메소드는 32자의 문자열(비밀키)을 반환하며, 이 비밀키는 해당하는 회원의 google authenticator 앱을 2단계 인증에 사용될 것이기 때문에 따로 저장해두어야 합니다.
import org.apache.commons.codec.binary.Base32;
import org.apache.commons.codec.binary.Hex;
public class TOTPTokenValidation {
private static String secretKey = "로그인한 회원에게 생성된 개인키";
public static boolean validate(String inputCode) {
String code = getTOTPCode();
return code.equals(inputCode);
}
// OTP 검증 요청 때마다 개인키로 OTP 생성
public static String getTOTPCode() {
Base32 base32 = new Base32();
// 실제로는 로그인한 회원에게 생성된 개인키가 필요합니다.
byte[] bytes = base32.decode(TOTPTokenValidation.secretKey);
String hexKey = Hex.encodeHexString(bytes);
return TOTP.getOTP(hexKey);
}
}
'TOTPTokenValidation Class'
클라이언트로부터 입력되는 코드의 유효성을 검사하는 클래스입니다.
secretKey는 각각의 회원의 Authenticator 앱에서 code를 생성하기 위한 값으로 실제 로직에서는 로그인한 회원의 secretKey 값을 DB에서 가지고 와서 code 비교를 할 수 있습니다.
import java.lang.reflect.UndeclaredThrowableException;
import java.math.BigInteger;
import java.security.GeneralSecurityException;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class TOTP {
private TOTP() {
// private utility class constructor
}
public static String getOTP(String key) {
return TOTP.getOTP(TOTP.getStep(), key);
}
private static long getStep() {
// 30 seconds StepSize (ID TOTP)
return System.currentTimeMillis() / 30000;
}
private static String getOTP(final long step, final String key) {
String steps = Long.toHexString(step).toUpperCase();
while (steps.length() < 16) {
steps = "0" + steps;
}
// Get the HEX in a Byte[]
final byte[] msg = TOTP.hexStr2Bytes(steps);
final byte[] k = TOTP.hexStr2Bytes(key);
final byte[] hash = TOTP.hmac_sha1(k, msg);
// put selected bytes into result int
final int offset = hash[hash.length - 1] & 0xf;
final int binary = ((hash[offset] & 0x7f) << 24) | ((hash[offset + 1] & 0xff) << 16) | ((hash[offset + 2] & 0xff) << 8) | (hash[offset + 3] & 0xff);
final int otp = binary % 1000000;
String result = Integer.toString(otp);
while (result.length() < 6) {
result = "0" + result;
}
return result;
}
private static byte[] hexStr2Bytes(final String hex) {
// Adding one byte to get the right conversion
// values starting with "0" can be converted
final byte[] bArray = new BigInteger("10" + hex, 16).toByteArray();
final byte[] ret = new byte[bArray.length - 1];
// Copy all the REAL bytes, not the "first"
System.arraycopy(bArray, 1, ret, 0, ret.length);
return ret;
}
private static byte[] hmac_sha1(final byte[] keyBytes, final byte[] text) {
try {
final Mac hmac = Mac.getInstance("HmacSHA1");
final SecretKeySpec macKey = new SecretKeySpec(keyBytes, "RAW");
hmac.init(macKey);
return hmac.doFinal(text);
} catch (final GeneralSecurityException gse) {
throw new UndeclaredThrowableException(gse);
}
}
}
'TOTP Class'
* final이 붙은 클래스는 더이상 확장할 수 없습니다. (= 상속받아서 사용할 수 없습니다.)
자신이 정의한 클래스가 잘못된 방식으로 상속, 확장되는 것을 막고자 할 때 final 키워드를 사용할 수 있습니다.
import com.example.authenticator.api.TOTPTokenGenerator;
import com.example.authenticator.api.TOTPTokenValidation;
import java.util.Scanner;
public class AuthenticatorApplication {
public static void main(String[] args) {
generateSecurityKey();
validAuthenticatorCode();
}
private static void validAuthenticatorCode() {
Scanner scanner = new Scanner(System.in);
String code = scanner.nextLine();
if (TOTPTokenValidation.validate(code)) {
System.out.println("Logged in successfully");
} else {
System.out.println("Invalid 2FA Code");
}
}
private static void generateSecurityKey() {
// secretKey 생성
String secretKey = TOTPTokenGenerator.generateSecretKey();
System.out.println(secretKey);
String account = "otptest@google.com";
String issuer = "otpTest";
// secretKey + account + issuer => QR 바코드 생성
String barcodeUrl = TOTPTokenGenerator.getGoogleAuthenticatorBarcode(secretKey, account, issuer);
System.out.println(barcodeUrl);
}
}
'AuthenticatorApplication Class'
실제 로직이라면 바코드 url이 아닌 실제 바코드 이미지와 secretKey 값을 클라이언트에게 보여주고, 클라이언트는 바코드를 찍어서 등록하거나 key를 직접 등록하는 방법을 사용할 수 있습니다.
***
google Authenticator 2단계 인증이 동작하는 개념에 중점을 두고 포스팅하여 코드 설명에 부족한 부분이 많습니다. 아래 참고한 자료 및 2단계 인증 다른 예시도 링크해두겠습니다.
< 참고 자료 >
< 다른 예시 >
< Google Authenticator 문제점 >
***
구글 OTP가 설정된 기기가 분실되거나 파손 시 해당 구글 OTP를 설정해둔 각 사이트의 OTP 초기화 문의를 하지 않는 이상, 구글 OTP가 설정된 모든 계정을 사용할 수 없습니다.
(구글 OTP가 설정된 계정은 로그인 시도는 가능하지만 정작 임의로 배열받은 비밀번호를 입력할 수가 없어 계정 진입이 불가능하며, 처음 구글 OTP를 설정했을 때 인증 QR코드를 가지고 있다면 복구가 가능합니다.)
'Programming > Spring Boot' 카테고리의 다른 글
스프링 프레임워크 Reactive Stack, Servlet Stack 개념 (0) | 2021.12.28 |
---|---|
RestTemplate Logging 요청과 응답 로그 남기기 (0) | 2021.12.23 |
Querydsl DTO 조회하는 방법(Projection, @QueryProjection) (0) | 2021.12.10 |
Querydsl 개념 및 Gradle 환경설정 (gradle-7.x.x) (0) | 2021.12.07 |
SpringBoot 웹소켓(WebSocket) 채팅 프로그램 파헤치기 (1) | 2021.11.20 |