Text Me!가 단국대학교 총학생회와 제휴를 맺게 됐다. 우리 사이트를 통해서 총학생회 이벤트에 참여할 수 있도록 하려고 하는데, 회원가입을 하는 것보다 총학생회에 가입되어 있는 정보를 이용해야 유저가 편안하게 느끼고 이용률도 높을 것이라 생각했다.
따라서 총학생회 홈페이지에 OAuth 시스템을 구축해서 Text Me!에도 사용하고 앞으로 다른 곳에서도 활용할 수 있게 만들기로 했다. 특히 PKCE를 적용한 OAuth 구현은 내가 꼭 해보고 싶었던 개발이라서, 무페이 외주를 맡기로 했다 (^^)...
OAuth(Open Authorization)은 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는, 접근 위임을 위한 개방형 표준이다.
쉽게 말해 별도의 회원가입 없이, 다른 웹사이트의 정보를 이용해서 접근 권한을 부여할 수 있는 인증 방식이다. 예를 들어 구글이나 카카오, 네이버 등을 통해 다른 웹 사이트에 로그인하는 것을 OAuth라고 한다.
사용자가 로그인 하기
를 선택하면 OAuth 로그인 사이트로 이동하고, 로그인에 성공하면 서버는 일회용 Authorization Code(이하 Auth Code)와 함께 사용자를 애플리케이션으로 리다이렉션한다. URI 파라미터를 통해 전달된 Auth code를 서버로 전달하여 Access Token을 얻을 수 있다.
Auth Code는 일회용이므로, 동일한 코드로 새로운 Access Token을 요청하면 실패한다. 또한 Auth Code의 만료 시간은 짧으므로 코드를 받으면 곧바로 토큰 발급을 하는 게 좋다. OAuth 2.0은 Auth Code 만료 시간을 10분 이하로 제한하도록 권장하고 있다.
Authorization Code Flow처럼 클라이언트에게 Auth Code를 발행하여 다시 Access Token으로 교환하는 과정 없이, 바로 Access Token을 발급 받도록 하는 플로우이다.
애플리케이션이 OAuth 서버에 요청하여, 브라우저를 통해 띄워진 인증 프롬프트로 로그인하여 Access Token을 받는다. 이 플로우의 최대 단점은 Access Token이 URI을 통해 직접 반환되는 것이다. URI은 로그나 캐시, 브라우저 히스토리 등에 저장되므로 보안에 취약할 수밖에 없다.
PKCE(Proof Key for Code Exchange)는 Authorization Code Flow를 사용할 때 코드 교환 과정의 보안을 강화하기 위해 OAuth 2.1에서 필수적으로 사용하도록 지정되어 있는 기술이다.
PKCE는 다음과 같은 필드들을 추가적으로 가진다.
code_verifier
: 앱으로부터 생성되는 임의의 랜덤 문자열이다.code_challenge
: code_verifier
와 짝을 이루는 문자열이다. code_verifier
를 그대로 사용하는 경우도 있지만 S256 해싱 알고리즘으로 암호화하는 것이 권장된다. OAuth 서버는 이 문자열을 복호화하여, code_verifier
와 같은지 확인함으로써 클라이언트를 검증한다.code_challenge_method
: code_verifier
의 변환에 어떤 함수가 사용되는지(S256 또는 plain) 지정하는 필드이다. 기본값은 plain으로, 이 경우 code_verifier
와 code_challenge
는 같은 것으로 간주된다. 이 필드들을 통해 OAuth 서버는 인증 요청과 토큰 요청이 동일한 클라이언트로부터 온 것인지 확인할 수 있다.
code_verifier
와 code_challenge
를 생성한다./authorize
)로 code_challenge
와 함께 Auth Code 요청을 보낸다.code_challenge
를 저장하고 Auth code
와 함께 유저를 앱으로 리다이렉트한다. code
와 code_verifier
를 인가 서버의 엔드포인트(/oauth/token
)로 보낸다.code_challenge
와 code_verifier
를 검증한다.이 플로우를 사용할 때 몇 가지 주의사항이 있다. 우선, code_verifier
와 code_challenge
는 토큰별로 한번의 요청 사이클에만 사용될 수 있다. 매번 Authorization 요청이 일어날 때마다 새로운 code_challenge
가 보내져야 한다. 유저가 인가 플로우를 마친 이후 공격자가 정보를 탈취해서 인가 플로우를 재실행하는 것을 막기 위해서이다.
또한 code_challenge
와 code_verifier
는 반드시 달라야 한다. code_challenge_method
의 plain 속성은 S256의 사용이 불가능할 때만 사용한다. 그렇지 않을 경우, code_challenge
가 탈취된다면 공격자는 손쉽게 Access Token을 얻을 수 있다.
OAuth Playground에서 PKCE Flow를 체험해볼 수 있다.
Authorization Code Flow에서 보안적 위협은 Auth Code가 URL에 노출되는 것이다. 공격자가 Auth Code를 탈취하여 OAuth 서버에게 요청을 보내면 쉽게 Access Token을 발급받을 수 있다. 따라서, Auth Code를 요청한 클라이언트가 Access Token을 요청한 클라이언트가 맞는지 확인하는 과정이 필요한 것이다.
공격자가 Auth Code를 탈취한다고 해도, code_verifier
가 없다면 Access Token을 발급받을 수 없다. code_challenge
가 탈취된 경우에도 Auth Code 발급에 필요한 code_verifier
는 클라이언트가 보유하고 있으며 토큰을 요청할 때 TLS 등의 보안 채널을 통해 전송되므로 공격자가 가로채기 어렵다.
구현은 crypto-js
라이브러리를 활용한다.
32bytes의 랜덤 문자열을 생성하고 base64 url로 인코딩하여 codeVerifier
를 생성하고, 이것을 sha256으로 해싱하면 codeChallenge
가 된다.
pkce.ts
import CryptoJS from "crypto-js";
const getCodeVerifier = () => {
return CryptoJS.lib.WordArray.random(32).toString(
CryptoJS.enc.Base64url
) as string;
};
const getCodeChallenge = (codeVerifier: string) => {
return CryptoJS.SHA256(codeVerifier).toString(
CryptoJS.enc.Base64url
) as string;
};
export { getCodeVerifier, getCodeChallenge };
이렇게 만든 codeChallenge
와 함께 서버로 요청을 보내면 서버가 이를 저장해뒀다가 이후 codeVerifier
와 함께 요청이 들어왔을 때 사용자를 확인한다.
서버 요청부는 Mock 서버로 테스트를 완료했지만, 아직 API 명세가 마무리 되지 않아서 개발이 완료된 후 추가하려고 한다.
OAuth를 구현해본다는 게 흔치 않은 기회인데, 좋은 기회를 잡은 것 같아서 끝까지 열심히 해보고 싶다. 또, OAuth 구현뿐만 아니라 라이브러리를 만들어보고 배포해보는 과정도 처음 해보는 부분이라 기대가 되고, 여러 사람이 실제로 이용할 수 있는 결과물이 되었으면 좋겠다.