포스팅 계기
소마(SWM) 과정에서 서비스를 기획하고, 개발하는 중
소셜 로그인 기능이 우리 서비스에 필요하여 구현에 돌입하게 됐다.
해당 서비스는 모바일 서비스로 프론트에는 Flutter를,
백엔드에는 Spring을 통해 프로젝트를 진행하고 있었다.
프로젝트에서는 구글 플레이 스토어에 먼저 출시할 예정이여서
Google, Naver, Kakao 소셜 로그인을 우선 구현하기로 결정했다.
Flutter를 처음 다뤄보기 때문에 소셜 로그인 구현에 관련해서 많은 서칭을 진행해보았다.
서칭 결과는 대부분 Flutter 라이브러리 기반의 구현 내용만 다루고 있을 뿐,
서버 기반의 소셜 로그인 구현에 관련한 내용은 잘 찾아볼 수 없었다.
예를 들면, naver sdk나 kakao sdk를 사용하여 구현하는 방법에 대해서만 많이 나올 뿐이였다.
sdk를 사용하면 구현은 비교적 간단하게 할 수 있고 자료도 많았지만,
사용하지 않으려 했던 이유는 프론트 기반의 소셜 로그인 구현이 되기 때문이었다.
우선 프론트 단에서 구현하게 되면, API Key가 노출된다는 보안 측면에서 좋지 않다고 생각했다.
또한, sdk에서 불러오게 되는 소셜 로그인 관련 API 호출을 프론트에서 받게 되면,
유저 관리 등 서버 기반으로 한 소셜 로그인을 구현할 수 없다고 생각하게 되었다.
구현에 앞서
로그인을 구현하는 과정에는 2가지 방식이 일반적인데, 바로 세션과 토큰을 통한 로그인이다.
두 로그인 방식은 다음과 같다.
세션 기반 로그인
1) 클라이언트에서 사용자가 로그인을 요청하면 서버에서는 세션을 생성한다.
2) 서버에서는 클라이언트에게 세션 ID를 보내고 클라이언트에서는 쿠키로 세션을 유지한다.
3) 권한이 필요한 요청이 들어오게 되면, 해당 쿠키를 통해 세션 ID를 서버에서 확인한다.
4) 저장된 세션 ID와 일치하면 응답을 내려주는 방식이다.
이러한 세션 기반의 로그인은 이동이 잦은 모바일 기기에서는 잘 사용하지 않는데,
그 이유는 IP 기반으로 유지되는 세션이 이동을 하면서 계속 세션이 풀리게 되기 때문이다.
따라서 모바일 서비스에서는 세션 로그인이 권장하지 않는 방법이자, 사용하지 않는다.
토큰 기반 로그인
1) 클라이언트에서 사용자가 로그인을 요청하면 서버에서는 토큰을 생성한다.
2) JWT 등 암호화된 토큰을 서버에서 클라이언트로 보내고, 클라이언트에서 저장한다.
3) 권한이 필요한 요청이 들어오게 되면, 헤더에 Bearer + 토큰을 실어서 보낸다.
4) 서버에서 토큰을 비교, 검증하여 검증에 성공하면 응답을 내려준다.
위에서 보통 발급해주는 토큰은 Access Token이라고 하며,
Access Token 만을 보내주게 되었을 때 문제가 생길 수 있어
Refresh Token 이라는 개념도 함께 도입되었다.
Access Token과 Refresh Token
위에서 나오는 토큰 기반 로그인을 사용할 때,
자주 등장하는 Aceess&Refresh 토큰 사용 과정이 도식화된 그림이다.
기존에 토큰 기반 로그인 2번 과정에서는 Token 1개인 Access Token을 발급해줬다면,
추가적으로 Refresh Token이라는 것도 서버에서 같이 보내주게 된다.
이러한 Refresh Token이 필요한 이유는 Access Token은 언제든 탈취될 수 있기 때문에
보안상 만료 시점이라는 것을 가지게 된다. 이러한 만료 시점은 짧게하여 탈취를 막을 수 있다.
하지만, 만료가 되고 나서 사용자는 로그인을 유지하지 못하게 되는데
이러한 문제를 해결하기 위해 Refresh Token 이라는 것이 생겨나게 되었다고 한다.
해당 토큰으로 Access Token이 탈취되거나 만료되었다 하더라도
사용자는 유효한 Refresh Token을 가지고 있다면, 서버에게 다시 Access Token을 요청해
다시 재발급 받아 서비스를 계속 사용할 수 있게 만들어준다.
차이점은 평소에는 3, 4번 과정처럼 권한이 필요한 응답을 받기 위해서
Access Token 만을 서버로 보내 비교하고 검증하는 과정을 거치지만,
만료가 되었을 때 앞의 내용처럼 Refresh Token의 비교와 검증이 필요하게 된다.
만약, Refresh Token이 만료되었다면 Refresh Token을 가질 수 있도록
로그인을 재요청할 수 있고, 이렇게 하면 사용자들에게 좀 더 나은 편의성을 가져다줄 수 있다.
OAuth2.0 이란
OAuth 2.0(Open Authorization 2.0)은 인증을 위한 개방형 표준 프로토콜이라고 한다.
해당 프로토콜에서는 Third-Party 프로그램에게 리소스 소유자를 대신해 리소스 서버에서
제공하는 자원에 대한 접근 권한을 위임하는 방식을 제공한다고 한다.
구글이나 페이스북, 카카오, 네이버, 애플 등 다양한 회사에서 간편 로그인을 제공하는데,
이러한 간편 로그인 기능이 OAuth2.0 프로토콜을 기반으로 한 사용자 인증 기능이다.
해당 프로토콜을 구성하는 주요 역할은 다음과 같다고 한다.
1) Resource Owner: 리소스 소유자 또는 사용자로 보호된 자원에 접근할 수 있는 자격을 부여해주는 주체,
클라이언트를 인증하는 역할 수행하며 인증이 완료되면 권한 획득 자격을 클라이언트에게 부여
2) Client: 보호된 자원을 사용하려고 접근을 요청하는 애플리케이션
3) Authorization Server: 권한 서버 및 인증/인가를 수행하는 서버로 클라이언트의 접근 자격을 확인,
Access Token을 발급해 권한을 부여하는 역할 수행
4) Resource Server: 사용자의 보호된 자원을 호스팅하는 서버
사용자는 구글로 로그인하기 등의 버튼을 클릭해 로그인을 요청하면,
로그인 페이지를 제공하고 계정 정보를 입력하게 된다.
권한 서버에서는 코드를 발급해주고, 코드를 가지고 Redirect URI로 리다이렉트한다.
그럼 사용자에게 입력받은 내용을 토대로 Access Token (+Refresh Token)이 발급되고
해당 토큰을 통해 토큰 기반 인증 과정이 진행되고 리소스를 제공받을 수 있게 된다.
즉, 인증 서버에서는 사용자로부터 로그인 요청을 받아 로그인 페이지를 제공하며,
인증 코드를 발급하고 인증 코드로 토큰을 발급해주는 역할을 하게 된다.
리소스 서버에서는 API 요청을 받고, 토큰을 검증해 요청을 승인한 뒤 서비스를 제공한다.
서버 기반의 소셜 로그인 with Flutter
스프링과 플러터를 통한 REST API를 유지하면서 서버 기반으로 소셜 로그인을 구현하는 과정에서
위의 사진과 블로그가 매우매우 참고가 되었다. (블로그를 읽어보면 소마 선배님... 귀한 자료 감사합니다ㅠ!)
소셜 로그인과 관련한 라이브러리, sdk를 사용하게 되면 앞서 공부한 Client가 앱이 되버린다.
백엔드가 중간에 개입할 요소가 없어지고 바로 프론트에서 인증을 요청하게 되기 때문에
우리 프로젝트에서 위와 동일한 과정으로 구현하게 되었다.
프론트는 백엔드로 소셜 로그인 요청 API를 보내어 백엔드에서 소셜 로그인을 진행한다.
백엔드에서 OAuth2.0 프로세스를 진행하게 되고, 자체적으로 JWT를 생성하여
토큰을 프론트엔드로 넘겨준 다음 해당 토큰을 통해 검증하는 과정으로 이루어진다.
이렇게 하게 되면, API Key도 백엔드에서 관리할 수 있게 되고 동시에 프론트에서는
백엔드 API 하나만 호출하면 되기 때문에 오히려 더 간편히 구현할 수 있게 된다.
단, 플러터 앱 자체 로그인을 진행하는 것이 아니라 모바일 웹으로 로그인을 진행하기 때문에
소셜 로그인 플랫폼 개발자 설정에 등록할 때 모바일이 아닌 Web으로 등록해주어야 한다.
트러블 슈팅 첫 번째, Redirect URI 와 Ngrok
위의 과정으로 진행하면, 무난하게 구현할 수 있을 것만 같았다. 그런건 줄 알았다..
참고한 블로그에서 정말 설명을 기가 막히게 잘 해주셨는데 잘 알아듣지 못했다..ㅎ
고유한 주소인 Redirect URI를 통해 웹 브라우저에서 수행한 로그인의 결과를
앱으로 전달할 수 있게 하는 과정을 통해 토큰을 앱에서 받아 로직을 수행할 수 있게 된다.
이를 구현하는 방법에는 flutter_web_auth라는 라이브러리를 통해 쉽게 구현할 수 있다.
import 'package:flutter_web_auth/flutter_web_auth.dart';
Future<void> signInOAuth() async {
// 백엔드 API 및 callback 데이터 반환할 URI 선언
const OAUTH_API = "/login/naver"
const REDIRECT_URI = "com.myapp.myapp"
// OAuth 로그인 페이지 요청과 동시에 redirect_url로 callback 데이터 전달
final url = Uri.parse('$OAUTH_API?redirect_uri=$REDIRECT_URI');
// REDIRECT_URI로 전달된 callback 데이터를 반환
final result = await FlutterWebAuth.authenticate(
url: url.toString(), callbackUrlScheme: REDIRECT_URI);
// callback 데이터로부터 accessToken & refreshToken 파싱
final accessToken =
Uri.parse(result).queryParameters['ACCESS_TOKEN'];
final refreshToken =
Uri.parse(result).queryParameters['REFRESH_TOKEN'];
// FlutterSecureStorage 등을 통한 accessToken & refreshToken 저장
// Token 관리 및 네비게이션 등 이후 로직 작성
}
작성된 코드로 프론트에서 소셜 로그인을 구현할 수 있고, 해당 콜백 데이터를 통해
가져온 토큰으로 로그인 정보 관리와 요청 등 구현이 마무리되게 된다.
마무리되는 게 맞았다.. 근데 그러지 못했다.
우선 이 고유한 Redirect URI 라는 것이 생소했고, 들었던 생각은 다음과 같았다.
'어떠한 값이든 상관이 없나? 도메인인가? 도메인이라면 구입해야하나? 구입하면 연결도 해줘야하나?'
https://www.oauth.com/oauth2-servers/redirect-uris/redirect-uris-native-apps/
이러한 한 주소를 발견할 수 있었는데 해당 사이트에서도 나오듯이 myapp://callback#token=??
과 같이 myapp이라는 redirect uri로 콜백데이터를 받아 토큰을 사용할 수 있다는 것을 보게 되었다.
myapp이라는 uri는 고유해야하며, 백엔드에서 redirect로 던져줄 때 상용화 된 도메인이 아니고
고유한 uri이기만 한다면 콜백데이터를 전달해줄 수 있는 것이였다.
즉, 모바일에서는 앱을 출시할 때 사용하는 도메인 com.~~~ 과 같은 고유한 값으로 지정해주면 된다.
(이거에 대한 의문이 들어서 삽질한 내용은 밑에 나온다..)
이때, 주의할 것은 flutter_web_auth 라이브러리에서 FlutterWebAuth.authenticate 인자로 받는
callbackUrlScheme 문자열이 정규표현식이 걸려있다는 점이다.
class FlutterWebAuth {
static const MethodChannel _channel = const MethodChannel('flutter_web_auth');
static RegExp _schemeRegExp = new RegExp(r"^[a-z][a-z0-9+.-]*$");
// ...
}
실제로 해당 라이브러리의 소스코드를 까보았는데, callbackUrl은 영어로 시작하면서
정해진 특수문자가 외의 특수문자가 들어가면 안된다는 정규표현식이 걸려있었다.
서버의 소셜 로그인과 통신을 하는 과정에서 ngrok으로 임시 서버를 열어서 테스트하는 과정에서
고유한 Redirect URI의 의미를 제대로 해석하지 못해 ngrok 주소로 callbackUrl을 설정하고 있었다.
ngrok 주소는 가끔 숫자부터 시작해서 32sdk.ngrok.~~ 처럼 주소가 나오게 되는데
이대로 집어넣고 사용할 때는 Invalid callbackUrlSheme이라는 오류가 계속해서 나고 있었다.
ps. 돌아보고나니 ngrok 주소가 고유하면서 연결이 가능하다고 생각해
Redirect URI로 설정하고 있던 것이 아주 잘못된 것이었다..
백엔드 팀원들과 함께 Redirect URI에 대해서 이해하고 나서는 백엔드로 요청하는 소셜 로그인 API에
로그인이 성공하였을 경우 com.myapp.myapp과 같은 고유한 주소로 리다이렉트 해달라고 요청했다.
UriComponentsBuilder.fromUriString(CALLBACK_URL)
.queryParam(ACCESS_TOKEN_PARAMETER, accessToken)
.queryParam(REFRESH_TOKEN_PARAMETER, refreshToken)
.toUriString();
Spring에서는 위와 같이 동일한 CALLBACK_URL을 설정하여
Access Token과 Refresh Token을 같이 전달해주었고,
이제는 프론트에서 해당 고유한 주소로 콜백 데이터를 받는 일만 남았다.
트러블 슈팅 두 번째, AndroidManifest.xml과 Emulator
flutter_web_auth 라이브러리를 사용할 때, 설정해야하는 부분이 있다.
바로 안드로이드에서 AndroidManifest.xml 파일에 callbackUrl 을 넣어주어야한다.
단순히 넣으면 끝나겠거니 했는데, 백엔드에서 위와 같은 요청을 보내도
앱에서는 콜백 데이터를 무한정 await 하는 상황이 발생했다.
<activity android:name="com.linusu.flutter_web_auth.CallbackActivity" android:exported="true">
<intent-filter android:label="flutter_web_auth">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.myapp.myapp" />
</intent-filter>
</activity>
라이브러리에서 요구하는 설정과 동일하게 진행했음에도 불구하고,
콜백 데이터를 받아오지 못했던 이유는 단순히 에뮬레이터를 재부팅하지 않아서였다..
앱이 처음이면서 동시에 Flutter를 처음하다보니 정확한 원리에 대해서는 이유를 알지 못했는데,
기존 xml scheme 값이 ngrok 주소인 상태에서 에뮬레이터를 켰는데,
xml에 scheme 주소를 com.myapp.myapp 으로 단순 코드만 수정하고 저장하게 되니까
에뮬레이터에서는 계속해서 scheme 주소를 ngrok 주소를 바라보고 있었던 것이다.
결국 변경된 주소가 적용된 시점은 Emulator의 재부팅으로 인해서라는 것을 깨닫게 되었고,
현재는 Android 또는 iOS 폴더에서 수정되는 사항이 생겼을 때는 꼭 에뮬레이터의 재부팅이
필요하다는 것을 인지하고 프로젝트를 진행하고 있다.
ps1. 어떻게 보면, 기초적인 부분이라고 할 수 있지만 몰랐기 때문에... (팀원들 미안..)
ps2. 저처럼 재부팅하나로 몇 시간을 잡아먹는 짓은 안하셨으면.. ㅠ
결론
1. 서버(스프링)에서 소셜 로그인 이후에 고유한 URI로 리다이렉트하면서
token을 callback 데이터로 프론트(플러터)에 전달해준다.
2. 프론트에서는 scheme을 에뮬레이터에 설정해주고, 고유한 URI에서
callback 데이터로 token을 받아서 로그인 이후 과정을 수행한다.
3. 라이브러리의 정규표현식과 에뮬레이터 재부팅에 유의하며,
서버(백엔드) 기반의 소셜 로그인을 구현한다.
참고 레퍼런스
https://blog.yjyoon.dev/flutter/2021/11/27/flutter-05/
https://hudi.blog/session-based-auth-vs-token-based-auth/
https://www.oauth.com/oauth2-servers/redirect-uris/redirect-uris-native-apps/
'개발(dev) > flutter' 카테고리의 다른 글
[Flutter] Underlying error (domain=NSPOSIXErrorDomain, code=3) 오류 해결하기 (0) | 2023.06.19 |
---|---|
[Flutter] 기본 Button과 스타일 적용하기 (0) | 2023.06.18 |
[Flutter] 화면 전환과 상태 전달 (Feat. Navigator) (0) | 2023.06.17 |
[Flutter] Assets/TextStyle/DatePicker/Theme 살펴보기 (0) | 2023.06.15 |
[Flutter] Date 및 Timer 살펴보기 (0) | 2023.06.13 |