OAuth2.0 방식을 이용해 카카오 소셜 로그인/회원가입을 구현한다.
OAuth는 Open Authorization의 약자로 카카오, 네이버, 구글 등의 사이트의 개인정보로 타사이트를 가입할 수 있는 기능이다. 간편한 회원가입과정으로 유저의 유입을 늘리기 위해 사용한다.
OAuth2.0에 대해서는 이전 포스팅에서 자세하게 다뤘으므로 이번 포스팅에서는 인증과 구현 방식에 대해 자세히 다뤄본다.
프론트엔드 관점에서 작성된 글이며, 서버는 msw로 구축했다.
로그인 인증 방식은 jwt 토큰을 사용하고 access token은 local variable, refresh token을 secure cookie로 저장한다.
- 로그인 인증 방식
- 세션 Id
- jwt 토큰
- 토큰 저장 위치
- 웹애플리케이션 공격
- 로컬 스토리지
- 세션 스토리지
- 쿠키
- secure 쿠키
- OAuth 로그인/회원가입 flow
- 카카오 OAuth 구현하기
일반적으로 로그인 인증방식에는 token을 사용한다.
인증 방식은 크게 세션 id와 token 방식으로 나뉘어져 있다.
각 방식의 장단점을 알아본다.
세션 방식 | 토큰 방식 | |
---|---|---|
장점 | 사용자의 상태를 원하는대로 통제 가능 | 상태를 따로 기억해 둘 필요가 없음 |
단점 | 메모리에 로그인되어 있는 사용자의 상태를 보관해야 함 | 한 번 로그인한 사용자 상태의 토큰을 삭제할 수 없음 |
access token의 정보 탈취 약점을 보완하고자 나왔다.
서버에 별도의 저장 공간이 없어도 jwt 토큰에 있는 정보를 secret key로 복호화하여 사용하기 때문에 보안과 저장 용량 측면에서 큰 이점이 있다.
보안 면에서 access token과 refresh token 두 가지를 발급하여 access token의 유효기간을 짧게 하였다. 상대적으로 유효기간이 길고 access token을 재발급 할 수 있는 refresh token은 안전한 곳에 저장하는 방식을 택했다.
토큰은 클라이언트 측에서 저장한다.
클라이언트 저장소는 대표적으로 로컬 스토리지, 세션 스토리지, 쿠키가 있다.
이때 보안을 고려하여 저장소를 정해야하는데, 웹앱의 대표적인 취약점은 XSS, CSRF가 있다.
먼저 공격에 대해 알아보고, 그에 따라 저장 위치를 정해보자.
사용자가 자신의 의지와는 무관하게 침입자가 의도한 행위를 서버에 요청하게 만드는 공격
특정 사이트가 사용자를 신뢰하기 때문에 서버에서 발생하는 문제다.
서버로부터 권한을 탈취할 수 있다.
침입자는 하이퍼링크에 자금 전송 요청에 대한 스크립트를 삽입하고 사이트에 로그인할 사람들에게 전송한다.
사용자는 링크를 누르고 의도치 않게 서버로 요청을 보낸다.
서버는 로그인 된 사용자의 요청이기 때문에 정상적으로 인식하고 침입자에게 돈을 전송한다.
웹사이트에 의도치 않은 스크립트를 넣어서 실행시키는 것으로 유저의 쿠키 정보를 탈취하거나, 유저 비밀번호를 변경하는 API를 호출할 수 있다.
쿠키를 탈취하는 스크립트를 사이트에 삽입한다.
사용자가 웹사이트에 접근하면 스크립트가 작동한다.
스크립트를 통해 사용자의 쿠키가 전달된다.
XSS는 크게 세가지 종류다.
Stored XSS
XSS 공격 스크립트를 웹 사이트 방명록이나 게시판에 삽입하고, 다른 사용자들이 그 글을 확인할 때 스크립코드가 사용자에게 전달되고 쿠키가 침입자에게 전달되는 방식이다.
스크립트를 웹 서버에 저장하므로 Stored 방식이라 한다.
Reflected XSS
공격 스크립트가 삽입된 URL을 사용자가 클릭하도록 유도하는 방식이다.
클릭 요청이 발생하면 바로 스크립트가 반사되어 돌아온다고해서 Reflected 이라 한다.
보통 피싱 공격에 많이 사용된다.
DOM-based XSS
사용자 브라우저에서 DOM 환경을 수정하면 공격 스크립트가 실행된다.
페이지 자체는 변경되지 않지만 DOM에서 발생한 수정으로 인해 페이지에 포함된 클라이언트측 코드가 다르게 실행된다.
즉 스크립트는 HTML 페이지가 구문 분석이 될 때마다 실행된다.
다른 XSS 방식과 다르게 서버와 관계가 없다.
XSS | CSRF | |
---|---|---|
개념 | 사용자가 특정 사이트를 신뢰 | 특정 사이트가 사용자를 신뢰 |
문제가 발생하는 곳 | 브라우저 | 서버 |
탈취 | 사용자의 쿠키 | 서버로부터의 권한 |
방지책 | 1. 쿠키가 아닌 서버에 민감 정보 저장 2. HTTPOnly 쿠키 | 1. referrer 속성 확인 2. CAPTCHA 3. CSRF Token |
웹앱의 대표적인 취약점과 그에 대한 방지책을 알아보았다.
이제 이를 고려하여 어떤 클라이언트 저장소에 토큰을 저장하면 좋을지 알아보자.
쿠키는 JS로 접근이 가능하므로 보안에 취약하다.
httpOnly, Secure, SameSite 옵션들로 보안을 강화할 수 있다.
<a>
, window.location.replace
를 통한 이동 httpOnly
속성으로 자바스크립트에서 쿠키에 접근을 막을 수 있다. Refresh Token은 쿠키로 발급받는다.
httpOnly
속성으로 JS가 읽지 못하도록 한다.(XSS 방어)secure=true
HTTPS 통해서 전송SameSite=strict
인증된 웹사이트에서만 토큰 전송하기 (CSRF 방어)Access Token을 HTTP Response로 응답받는다. - 응답받은 Access Token은 local variable에 저장
Refresh Token으로 Access Token 갱신
AccessToken & Refresh Token
+--------+ +---------------+
| |--(A)------- Authorization Grant --------->| |
| | | |
| |<-(B)----------- Access Token -------------| |
| | & Refresh Token | |
| | | |
| | +----------+ | |
| |--(C)---- Access Token ---->| | | |
| | | | | |
| |<-(D)- Protected Resource --| Resource | | Authorization |
| Client | | Server | | Server |
| |--(E)---- Access Token ---->| | | |
| | | | | |
| |<-(F)- Invalid Token Error -| | | |
| | +----------+ | |
| | | |
| |--(G)----------- Refresh Token ----------->| |
| | | |
| |<-(H)----------- Access Token -------------| |
+--------+ & Optional Refresh Token +---------------+
client는 우리 서버이고, autorization server와 resource server는 카카오를 의미한다.
프론트와 백엔드를 나누어 어떻게 구현해야 할지 flow를 그려본다.
카카오로 간편 로그인/회원가입
버튼을 클릭하면 카카오 Authorization URL로 리다이렉트한다. 토스의 개발자 커뮤니티에서 OAuth 2.0 프레임워크를 도식화한 것이 굉장히 깔끔해보여서 이미지를 가져왔다!
위의 내용과 동일하며 과정이 잘 요약되어 있다.
다음은 카카오 developers의 공식문서의 카카오 로그인 과정이다.
앞선 OAuth flow 와 상당히 유사하다.
그럼 차근차근 OAuth를 구현해보자!
우선, FE는 버튼을 클릭하면 카카오 Authorization URL로 리다이렉트 한다.
Authorization URL은 FE, BE 누가 만들던 상관없다.
앱 설정> 팀 관리 > 팀원 초대를 통해 owner 외에 editor를 추가해 함께 관리할 수 있다.
메서드 | URL |
---|---|
GET | https://kauth.kakao.com/oauth/authorize |
필수 파라미터는 다음과 같다.
이름 | 타입 | 설명 | 필수 |
---|---|---|---|
client_id | String | 앱 REST API 키 [내 애플리케이션] > [앱 키]에서 확인 가능 | O |
redirect_uri | String | 인가 코드를 전달받을 서비스 서버의 URI [내 애플리케이션] > [카카오 로그인] > [Redirect URI]에서 등록 | O |
response_type | String | code로 고정 | O |
http://localhost:3000/redirect-auth
https://배포주소/redirect-auth
최종적으로 SNS 로그인 버튼을 클릭했을 때 이동할 Authroization URL은 다음과 같다.
다음 URL을 <a>
태그의 href
속성에 넣어 사용자가 버튼을 클릭하면 리다이렉트시킨다.
https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}
<Link>
태그는 라우팅을 위한 것으로 외부 주소로 이동이 안된다. 반드시 <a>
태그를 사용하자.위 과정을 통해 유저는 Authorization URL에 접근해 카카오 계정에 로그인 후 정보 제공을 동의하고 Redirect URL을 통해 코드를 발급받았다.
FE는 이 코드를 파싱해서 BE에게 로그인 API 요청을 통해 OAuth 로그인해주세요! 라고 요청한다.
그럼 BE는 코드라는 허가증을 가지고 카카오에게 토큰을 발급받는다.
여기서 한번 정리!
- code: 토큰을 발급받기 위한 허가증, resource owner(유저)에게 발급한다.
(유저<->카카오)
- 토큰: 카카오 정보에 접근하기 위한 권한, code라는 허가증을 가지고 우리 서버가 카카오에게 발급을 요청한다.
(서버<->카카오)
🚨 만약 프론트엔드만 구현해도 된다면 토큰 발급 부분은 백엔드가 담당하기 때문에 step4. 로그인/회원가입 처리로 넘어가면 된다.
하지만 JavaScript로 백엔드를 포함해 전체 flow를 구현하고 있다면
1. Authorization Server에게 카카오 토큰을 발급받고
2. Resource Server에게 유저 정보를 얻어오고
3. 서비스 자체 토큰을 발급하는 과정이 필요하다.
토큰을 요청할 URL을 만들어보자.
메서드 | URL |
---|---|
POST | https://kauth.kakao.com/oauth/token |
필수 파라미터는 다음과 같다.
이름 | 설명 | 필수 |
---|---|---|
Content-type | Content-type: application/x-www-form-urlencoded;charset=utf-8 요청 데이터 타입 | O |
이름 | 타입 | 설명 | 필수 |
---|---|---|---|
grant_type | String | authorization_conde로 고정 | O |
client_id | String | 앱 REST API 키 [내 애플리케이션] > [앱 키]에서 확인 가능 | O |
redirect_uri | String | 인가 코드를 전달받을 서비스 서버의 URI [내 애플리케이션] > [카카오 로그인] > [Redirect URI]에서 등록 | O |
code | String | 인가 코드 받기 요청으로 얻은 인가 코드 | O |
client_secret | String | 토큰 발급 시, 보안을 강화하기 위해 추가 확인하는 코드 [내 애플리케이션] > [보안]에서 설정 가능 ON 상태인 경우 필수 설정해야 함 | X |
유저->FE->BE
)application/x-www-form-urlencoded
Header에 컨텐츠 타입을 application/x-www-form-urlencoded
으로 설정했다.
일반적으로 컨텐츠 타입을 application/json
으로 하고 body는 객체를 JSON.stringify(object)
로 직렬화하여 json 형태로 보냈다.
(json 형태로 보내면 KOE010
에러를 만날 수 있다. 나도 만나고 싶지 않았다)
이번에는 HTTP method와, 컨텐츠 타입을 고려해서 body를 작성해야 한다.
MDN에서 HTTP POST 메서드를 살펴보자.
간단한 예제도 나와있다.
POST / HTTP/1.1
Host: foo.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 13
say=Hi&to=Mom
요약해보면 다음과 같다.
{ key: value }
형태를 사용하는 것과 달리 urlencoded는 key=value & key=value
형식으로 인코딩한다.multipart/form-data
라는 컨텐츠 타입을 사용한다. 결론적으로 body에 key=value & key=value
형식으로 담고 url 인코딩을 해서 보내주면 된다!
msw에서 fetch한 코드지만 작성법은 비슷하니 참고용으로 올려본다.
client_secret 은 토큰 발급에 보안을 강화하기 위해 추가한 선택 요소다.
사용되는 환경변수들은 env 파일로 옮겨서 관리해야 보안을 강화할 수 있다.
const authResponse = await ctx.fetch(kakaoOAuth, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8"',
},
body: encodeURI(
Object.entries({
grant_type: 'authorization_code',
client_id: 'REST_KEY',
redirect_uri: 'http://localhost:3000/redirect-auth?provider=kakao',
code: AUTHORIZE_CODE,
client_secret: 'secret key',
})
.map(([key, value]) => `${key}=${value}`)
.join('&'),
),
});
드디어 성공적으로 토큰을 발급받았다!
이제 카카오 Authorization server로부터 access token을 발급받았으므로 이것을 가지고 카카오 Resource server에 유저 정보를 요청할 수 있다.
나는 프로필 이미지, 닉네임, 이메일을 받아와서 서비스 회원가입/로그인에 이용할 것이다.
GET
요청이기 때문에 훨씬 간단한다.
content-type을 urlendcoded로 설정하고 발급받은 access token을 Authorization에 담아 Bearer 인증을 완료한다.
이제 카카오 Resource server가 access token의 진위여부를 판별해서 요청하는 리소스를 응답한다.
// 발급받은 access token으로 서드 파티 Resource server에 리소스 요청
const authResponse = await ctx.fetch('https://kapi.kakao.com/v2/user/me', {
method: 'GET',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8"',
Authorization: `Bearer ${token.access_token}`,
},
});
const authData = await authResponse.json();
카카오 유저 정보를 받아왔으므로 우리 서비스에 가입되어있는 유저인지 확인하고 로그인/회원가입 처리를 한다.
회원 DB에 일치하는 이메일이 있으면 서비스에 가입된 유저로 구분했다.
1. 서비스에 가입된 유저: 로그인 처리
2. 서비스에 미가입된 유저: 회원가입 처리
회원가입 처리에 대해서는 추가적인 논의가 필요하다.
앞서 서버에서 받은 유저 정보로 회원가입 절차 없이 자체적으로 서비스에 가입시키고 로그인까지 처리할 수 있다.
또는 서비스 가입을 위한 추가적인 정보를 입력하게 할 수 있다.
다음 사진은 이슈트래커 프로젝트에서 추가로 가입정보를 입력하도록 만든 폼이다.
이메일은 카카오 Resource server에서 받아온 유저 정보를 입력하여 수정할 수 없게 하고, 닉네임은 유저에게 입력받고 validation을 거쳐 가입시킨다.
추가적인 정보를 회원에게 받을 경우 FE는 회원가입 폼 컴포넌트를 구현하고 입력받은 정보를 BE에게 보낸다.
또는 BE가 서드파티 Resource server에서 받은 정보로 바로 회원가입 처리를 할 수 있다.
회원가입 처리를 성공적으로 하면 FE는
1. 로그인 페이지로 이동시키거나 (유저가 로그인해야 한다)
2. 로그인 처리하여 홈페이지로 이동시킨다. (서버가 자동으로 로그인시킨다)
BE는 로그인에 성공하면 FE에게 서비스 자체 토큰을 발급한다.
왜 서비스 자체 토큰을 발급하는가?
- 카카오 access token은 카카오 리소스에 접근하기 위한 것이다.
- 우리 서비스의 리소스에 접근하기 위한 토큰을 발급해야 한다.
프로젝트에서는 access token, refresh token을 발급했다.
FE는 access token을 request headers에 Bearer token 방식으로 전달하고 refresh token은 cookie에 저장한다.
jose
라이브러리를 이용하면 클라이언트에서 JWT 토큰을 발급할 수 있다.
import * as jose from 'jose';
const secret = new TextEncoder().encode('SECRET-ACCESS');
const refresh = new TextEncoder().encode('SECRET-REFRESH);
export const getAccessToken = ({ userId }: { userId: string }) =>
new jose.SignJWT({ userId }).setProtectedHeader({ alg: 'HS256' }).setIssuedAt().setExpirationTime('2h').sign(secret);
export const getRefreshToken = ({ userId }: { userId: string }) =>
new jose.SignJWT({ userId }).setProtectedHeader({ alg: 'HS256' }).setIssuedAt().setExpirationTime('2w').sign(secret);
- https://codeburst.io/localstorage-vs-cookies-all-you-need-to-know-about-storing-jwt-tokens-securely-in-the-front-end-70dc0a9b3ad3
- https://www.chromium.org/administrators/policy-list-3/cookie-legacy-samesite-policies/
- https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-code
- https://hackernoon.com/using-session-cookies-vs-jwt-for-authentication-sd2v3vci
- https://velopert.com/2350