|
|
| 1번째 줄: |
1번째 줄: |
| {{플러터}} | | {{플러터}} |
|
| |
|
| = 커스텀 OAuth2 인증 (네이버, 카카오 등) = | | = 개요 = |
| Firebase는 Google/Apple/GitHub 등 주요 소셜 로그인만 공식 지원합니다. 네이버, 카카오, 디스코드 등의 OAuth2 인증은 '''직접 구현'''해야 합니다.
| |
|
| |
|
| == 방식 비교 ==
| | * Firebase는 Google/Apple/GitHub 등 주요 소셜 로그인만 공식 지원함. |
| {| class="wikitable"
| | * 이 문서에선 일반적인 표준 OAuth2 규격을 100% 준수하는 경우의 로그인을 구현함.(flutter_appauth 사용) |
| !방식
| |
| !장점
| |
| !단점
| |
| !사용 케이스
| |
| |-
| |
| |Firebase Auth 공식
| |
| |설정만으로 완성, 자동 토큰 관리
| |
| |지원 제공업체만 가능
| |
| |Google, Apple, GitHub, Microsoft
| |
| |-
| |
| |Firebase + Custom Token
| |
| |Firebase 생태계 유지
| |
| |백엔드 서버 필요
| |
| |네이버/카카오 + Firestore 함께 사용
| |
| |-
| |
| |순수 OAuth2 직접 구현
| |
| |완전한 제어, 백엔드 불필요
| |
| |토큰/세션 직접 관리
| |
| |간단한 앱, 학습용
| |
| |}
| |
|
| |
|
| == 네이버 OAuth2 예시 (순수 구현) == | | === 비고 === |
|
| |
|
| === 사전 준비 ===
| | * 네이버, 카카오, 디스코드 등의 OAuth2 인증은 직접 구현해야 하는데, 네이버, 카카오는 일반 OAuth2의 규격과 조금 다름. 아래 패키지를 이용하면 앱투앱 로그인도 간편하게 이루어짐. |
| {| class="wikitable"
| | * 카카오: <code>kakao_flutter_sdk</code> (공식) |
| !단계
| | * 네이버: <code>flutter_naver_login</code> |
| !설명
| | * 디스코드: <code>discord_oauth2</code> |
| |-
| |
| |네이버 개발자센터 설정
| |
| |
| |
| # https://developers.naver.com/apps 접속
| |
| # 애플리케이션 등록
| |
| # '''Client ID''', '''Client Secret''' 발급
| |
| # '''Callback URL''' 설정: <code>http://localhost/callback</code> (웹) 또는 커스텀 스킴
| |
| |-
| |
| |패키지 설치
| |
| |<syntaxhighlight lang="yaml">
| |
| dependencies:
| |
| http: ^1.1.0 # HTTP 요청용
| |
| flutter_secure_storage: ^9.0.0 # 토큰 안전 저장
| |
| url_launcher: ^6.2.1 # OAuth 웹뷰 열기
| |
| </syntaxhighlight>
| |
| |}
| |
| | |
| === OAuth2 흐름 ===
| |
| | |
| # 사용자가 "네이버 로그인" 버튼 클릭
| |
| # 네이버 로그인 페이지로 이동 (브라우저/웹뷰)
| |
| # 사용자 로그인 → 네이버가 '''Authorization Code''' 발급
| |
| # 앱이 Code를 받아서 '''Access Token''' 요청
| |
| # Access Token으로 사용자 정보 API 호출
| |
| | |
| === 구현 코드 ===
| |
| <syntaxhighlight lang="dart">
| |
| import 'package:flutter/material.dart';
| |
| import 'package:http/http.dart' as http;
| |
| import 'dart:convert';
| |
| import 'package:url_launcher/url_launcher.dart';
| |
| import 'package:flutter_secure_storage/flutter_secure_storage.dart';
| |
| | |
| class NaverAuthService {
| |
| // 네이버 개발자센터에서 발급받은 정보
| |
| static const String clientId = 'YOUR_CLIENT_ID';
| |
| static const String clientSecret = 'YOUR_CLIENT_SECRET';
| |
| static const String redirectUri = 'http://localhost/callback';
| |
|
| |
| final storage = const FlutterSecureStorage();
| |
| | |
| // 1단계: 네이버 로그인 페이지 열기
| |
| Future<void> signIn() async {
| |
| final authUrl = Uri.parse(
| |
| 'https://nid.naver.com/oauth2.0/authorize'
| |
| '?response_type=code'
| |
| '&client_id=$clientId'
| |
| '&redirect_uri=$redirectUri'
| |
| '&state=RANDOM_STATE', // CSRF 방지용 랜덤 문자열
| |
| );
| |
| | |
| if (await canLaunchUrl(authUrl)) {
| |
| await launchUrl(authUrl, mode: LaunchMode.externalApplication);
| |
|
| |
| // 실제로는 Callback을 받아야 함 (아래 참고)
| |
| // 웹: window.location 감지
| |
| // 모바일: Deep Link 설정 필요
| |
| }
| |
| }
| |
| | |
| // 2단계: Authorization Code로 Access Token 받기
| |
| Future<String?> getAccessToken(String code) async {
| |
| final response = await http.post(
| |
| Uri.parse('https://nid.naver.com/oauth2.0/token'),
| |
| body: {
| |
| 'grant_type': 'authorization_code',
| |
| 'client_id': clientId,
| |
| 'client_secret': clientSecret,
| |
| 'code': code,
| |
| 'state': 'RANDOM_STATE',
| |
| },
| |
| );
| |
| | |
| if (response.statusCode == 200) {
| |
| final data = json.decode(response.body);
| |
| final accessToken = data['access_token'];
| |
|
| |
| // 안전하게 토큰 저장
| |
| await storage.write(key: 'naver_token', value: accessToken);
| |
| return accessToken;
| |
| }
| |
| return null;
| |
| }
| |
| | |
| // 3단계: Access Token으로 사용자 정보 가져오기
| |
| Future<Map<String, dynamic>?> getUserInfo() async {
| |
| final token = await storage.read(key: 'naver_token');
| |
| if (token == null) return null;
| |
| | |
| final response = await http.get(
| |
| Uri.parse('https://openapi.naver.com/v1/nid/me'),
| |
| headers: {'Authorization': 'Bearer $token'},
| |
| );
| |
| | |
| if (response.statusCode == 200) {
| |
| final data = json.decode(response.body);
| |
| return data['response']; // {id, name, email, profile_image, ...}
| |
| }
| |
| return null;
| |
| }
| |
| | |
| // 로그아웃
| |
| Future<void> signOut() async {
| |
| await storage.delete(key: 'naver_token');
| |
| }
| |
| }
| |
| | |
| // UI 예시
| |
| class NaverLoginPage extends StatefulWidget {
| |
| const NaverLoginPage({super.key});
| |
| | |
| @override
| |
| State<NaverLoginPage> createState() => _NaverLoginPageState();
| |
| }
| |
| | |
| class _NaverLoginPageState extends State<NaverLoginPage> {
| |
| final NaverAuthService _authService = NaverAuthService();
| |
| Map<String, dynamic>? _userInfo;
| |
| | |
| @override
| |
| void initState() {
| |
| super.initState();
| |
| _checkLoginStatus();
| |
| }
| |
| | |
| Future<void> _checkLoginStatus() async {
| |
| final userInfo = await _authService.getUserInfo();
| |
| if (userInfo != null) {
| |
| setState(() => _userInfo = userInfo);
| |
| }
| |
| }
| |
| | |
| @override
| |
| Widget build(BuildContext context) {
| |
| return Scaffold(
| |
| appBar: AppBar(title: const Text('네이버 로그인')),
| |
| body: Center(
| |
| child: _userInfo == null
| |
| ? ElevatedButton(
| |
| onPressed: () => _authService.signIn(),
| |
| child: const Text('네이버 로그인'),
| |
| )
| |
| : Column(
| |
| mainAxisAlignment: MainAxisAlignment.center,
| |
| children: [
| |
| Text('이름: ${_userInfo!['name']}'),
| |
| Text('이메일: ${_userInfo!['email']}'),
| |
| ElevatedButton(
| |
| onPressed: () async {
| |
| await _authService.signOut();
| |
| setState(() => _userInfo = null);
| |
| },
| |
| child: const Text('로그아웃'),
| |
| ),
| |
| ],
| |
| ),
| |
| ),
| |
| );
| |
| }
| |
| }
| |
| </syntaxhighlight>
| |
| | |
| === Callback 처리 (중요!) ===
| |
| | |
| ==== 웹 ====
| |
| <syntaxhighlight lang="dart">
| |
| import 'dart:html' as html;
| |
| | |
| void main() {
| |
| // URL에서 code 파라미터 추출
| |
| final uri = Uri.parse(html.window.location.href);
| |
| final code = uri.queryParameters['code'];
| |
|
| |
| if (code != null) {
| |
| // Access Token 받기
| |
| NaverAuthService().getAccessToken(code);
| |
| }
| |
|
| |
| runApp(const MyApp());
| |
| }
| |
| </syntaxhighlight>
| |
| | |
| ==== Android (Deep Link) ====
| |
| <code>android/app/src/main/AndroidManifest.xml</code>:<syntaxhighlight lang="xml">
| |
| <intent-filter>
| |
| <action android:name="android.intent.action.VIEW" />
| |
| <category android:name="android.intent.category.DEFAULT" />
| |
| <category android:name="android.intent.category.BROWSABLE" />
| |
| <data
| |
| android:scheme="yourapp"
| |
| android:host="callback" />
| |
| </intent-filter>
| |
| </syntaxhighlight>Flutter에서 받기:<syntaxhighlight lang="dart">
| |
| import 'package:uni_links/uni_links.dart';
| |
| | |
| void initState() {
| |
| super.initState();
| |
|
| |
| // Deep Link 리스너
| |
| uriLinkStream.listen((Uri? uri) {
| |
| if (uri != null) {
| |
| final code = uri.queryParameters['code'];
| |
| if (code != null) {
| |
| _authService.getAccessToken(code);
| |
| }
| |
| }
| |
| });
| |
| }
| |
| </syntaxhighlight>
| |
| | |
| == Firebase Custom Token 방식 ==
| |
| 백엔드 서버가 있다면 더 안전한 방식:
| |
| | |
| === 흐름 ===
| |
| | |
| # Flutter → 네이버 OAuth로 Access Token 받기
| |
| # Flutter → 백엔드 서버로 Access Token 전송
| |
| # 백엔드 → 네이버 API로 사용자 정보 확인
| |
| # 백엔드 → '''Firebase Admin SDK'''로 Custom Token 생성
| |
| # 백엔드 → Flutter로 Custom Token 전송
| |
| # Flutter → Firebase.signInWithCustomToken()으로 로그인
| |
| | |
| === 장점 ===
| |
| | |
| * Client Secret이 앱에 노출 안 됨 (보안 ↑)
| |
| * Firebase Authentication + Firestore 연동 가능
| |
| * Firebase Security Rules 사용 가능 | |
| | |
| === 백엔드 예시 (Node.js) ===
| |
| <syntaxhighlight lang="javascript">
| |
| const admin = require('firebase-admin');
| |
| | |
| app.post('/auth/naver', async (req, res) => {
| |
| const { accessToken } = req.body;
| |
|
| |
| // 1. 네이버 사용자 정보 확인
| |
| const naverUser = await fetch('https://openapi.naver.com/v1/nid/me', {
| |
| headers: { 'Authorization': `Bearer ${accessToken}` }
| |
| }).then(r => r.json());
| |
|
| |
| // 2. Firebase Custom Token 생성
| |
| const customToken = await admin.auth().createCustomToken(naverUser.response.id);
| |
|
| |
| res.json({ customToken });
| |
| });
| |
| </syntaxhighlight>
| |
| | |
| === Flutter에서 사용 ===
| |
| <syntaxhighlight lang="dart">
| |
| // 1. 네이버 로그인 → Access Token 받기
| |
| final accessToken = await getNaverAccessToken();
| |
| | |
| // 2. 백엔드로 전송 → Custom Token 받기
| |
| final response = await http.post(
| |
| Uri.parse('https://yourserver.com/auth/naver'),
| |
| body: {'accessToken': accessToken},
| |
| );
| |
| final customToken = json.decode(response.body)['customToken'];
| |
| | |
| // 3. Firebase 로그인
| |
| await FirebaseAuth.instance.signInWithCustomToken(customToken);
| |
| | |
| // 4. 이제 Firebase 기능 모두 사용 가능!
| |
| final user = FirebaseAuth.instance.currentUser;
| |
| </syntaxhighlight>
| |
| | |
| == 추천 방식 정리 ==
| |
| {| class="wikitable"
| |
| !상황
| |
| !추천 방식
| |
| |-
| |
| |학습용, 간단한 앱
| |
| |'''순수 OAuth2 직접 구현'''
| |
| |-
| |
| |Firestore/Storage 사용
| |
| |'''Firebase + Custom Token''' (백엔드 필요)
| |
| |-
| |
| |이미 백엔드 서버 있음
| |
| |백엔드에서 JWT 발급 (Firebase 불필요)
| |
| |-
| |
| |Google/Apple만 사용
| |
| |'''Firebase Auth 공식''' (제일 간단)
| |
| |}
| |
| | |
| == 주의사항 ==
| |
| | |
| === 보안 ===
| |
| | |
| * '''Client Secret'''은 절대 앱 코드에 하드코딩 금지!
| |
| ** 백엔드 서버나 환경변수로 관리
| |
| ** 또는 Firebase Functions 사용
| |
| | |
| === 토큰 관리 ===
| |
| | |
| * Access Token은 <code>flutter_secure_storage</code>로 암호화 저장
| |
| * Refresh Token으로 자동 갱신 구현 권장
| |
| | |
| === Deep Link ===
| |
| | |
| * Android: <code>AndroidManifest.xml</code> 설정
| |
| * iOS: <code>Info.plist</code> + URL Scheme 설정
| |
| * 웹: Callback URL을 앱 도메인으로 설정
| |
| | |
| == 패키지 활용 ==
| |
| 직접 구현이 복잡하다면 커뮤니티 패키지 사용:
| |
| | |
| * '''카카오''': <code>kakao_flutter_sdk</code> (공식)
| |
| * '''네이버''': <code>flutter_naver_login</code> | |
| * '''디스코드''': <code>discord_oauth2</code> | |
| * '''범용 OAuth2''': <code>oauth2</code>, <code>flutter_appauth</code>
| |
| | |
| 하지만 학습 목적이라면 '''직접 구현 추천'''!
| |