Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,5 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release

.env
46 changes: 23 additions & 23 deletions lib/core/network/dio_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,47 +31,47 @@ Dio dio(DioRef ref) {
return handler.next(options);
},
onError: (DioException e, handler) async {
// 1. 401 에러(토큰 만료) 발생 시
if (e.response?.statusCode == 401) {
debugPrint(
'⚠️ [401 Detected] 토큰 만료됨. 재발급 시도 중... (Path: ${e.requestOptions.path})',
);

const storage = FlutterSecureStorage();
final refreshToken = await storage.read(key: 'refreshToken');

if (refreshToken != null) {
try {
// 🎯 토큰 갱신 전용 가벼운 Dio 생성 (인터셉터 없음)
final refreshDio = Dio(
BaseOptions(baseUrl: e.requestOptions.baseUrl),
);

debugPrint('🔄 [Refresh] 리프레시 토큰으로 전송 중: $refreshToken');

final response = await refreshDio.post(
'/api/token',
data: {"refreshToken": refreshToken},
);

// 성공 시 로그 (여기에 새 토큰 저장 로직이 추가되어야 합니다)
debugPrint('✅ [Refresh Success] 새로운 토큰 발급 완료: ${response.data}');
// 1. 서버 응답에서 새 토큰 추출 (백엔드 응답 키값에 맞게 수정하세요)
final newAccessToken = response.data['accessToken'];
final newRefreshToken = response.data['refreshToken'];

// TODO: 여기서 발급받은 새 토큰을 storage에 저장하고
// 원래 실패했던 요청(e.requestOptions)을 다시 시도(dio.fetch)하는 로직이 필요합니다.
} catch (err) {
// 2. 재발급 과정에서 발생한 상세 에러 로그
if (err is DioException) {
debugPrint(
'❌ [Refresh Failed] 상태 코드: ${err.response?.statusCode}',
// 2. 새 토큰 스토리지에 저장
await storage.write(key: 'accessToken', value: newAccessToken);
if (newRefreshToken != null) {
await storage.write(
key: 'refreshToken',
value: newRefreshToken,
);
debugPrint('❌ [Refresh Failed] 서버 메시지: ${err.response?.data}');
debugPrint('❌ [Refresh Failed] 에러 타입: ${err.type}');
} else {
debugPrint('❌ [Refresh Failed] 알 수 없는 에러: $err');
}

// 3. 실패했던 원래 요청의 헤더를 새 토큰으로 변경
e.requestOptions.headers['Authorization'] =
'Bearer $newAccessToken';

// 4. 원래 요청 재시도 및 결과 반환
final retryResponse = await dio.fetch(e.requestOptions);
return handler.resolve(retryResponse);
} catch (err) {
// 재발급 실패 시: 토큰 찌꺼기 삭제
debugPrint("재발급 실패! 저장된 토큰 삭제");
await storage.deleteAll();
return handler.next(e);
}
} else {
debugPrint('🚫 [Refresh Aborted] 저장된 리프레시 토큰이 없습니다.');
}
}
return handler.next(e);
Expand Down
95 changes: 93 additions & 2 deletions lib/features/auth/login/login_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,42 @@ class LoginScreen extends StatelessWidget {
backgroundColor: const Color(0xFF03C75A),
textColor: Colors.white,
iconPath: 'assets/images/icons/naver_logo.svg',
onTap: navigateToSignup,
//onTap = () {},
onTap: () async {
// 1. 상태(state) 문자열 생성 (카카오의 PKCE 함수를 재사용하여 임의의 문자열 15자리 생성)
final state = AuthService.generatePkcePair()['challenge']!
.substring(0, 15);
final authUrl = AuthService.getNaverAuthUrl(state);
String? naverAuthCode;

if (!context.mounted) return;

// 2. 카카오처럼 바텀시트로 네이버 웹뷰 실행
await showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (ctx) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom,
),
child: _buildNaverWebView(
context: ctx,
authUrl: authUrl,
onCodeCaptured: (code) => naverAuthCode = code,
),
),
);

// 3. 획득한 코드가 있다면 백엔드로 전송
if (naverAuthCode != null && context.mounted) {
await AuthService.sendNaverAuthToBackend(
code: naverAuthCode!,
state: state,
context: context,
);
}
},
),

const SizedBox(height: 30),
Expand Down Expand Up @@ -294,3 +328,60 @@ class LoginScreen extends StatelessWidget {
);
}
}

Widget _buildNaverWebView({
required BuildContext context,
required String authUrl,
required Function(String) onCodeCaptured,
}) {
late final WebViewController controller;

controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setUserAgent(
"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
)
..setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: (NavigationRequest request) {
final url = request.url;

// 📍 [핵심] 백엔드의 네이버 콜백 주소 감지
if (url.contains('/oauth/naver/callback')) {
debugPrint('🎣 [감지 성공] 네이버 콜백 주소가 포착되었습니다!');
final uri = Uri.parse(url);
final code = uri.queryParameters['code'];

if (code != null) {
debugPrint('✅ 획득한 네이버 인가 코드: $code');
onCodeCaptured(code);
Navigator.pop(context); // 웹뷰 닫기
return NavigationDecision.prevent; // 리다이렉트 방지
}
}
return NavigationDecision.navigate;
},
onPageStarted: (url) {
// 이중 체크 (만약 onNavigationRequest에서 못 잡았을 경우)
if (url.startsWith(AuthService.naverRedirectUri)) {
final uri = Uri.parse(url);
final code = uri.queryParameters['code'];
if (code != null) {
onCodeCaptured(code);
Navigator.pop(context);
}
}
},
),
)
..loadRequest(Uri.parse(authUrl));

return Container(
height: MediaQuery.of(context).size.height * 0.9,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: WebViewWidget(controller: controller),
);
}
64 changes: 60 additions & 4 deletions lib/features/auth/services/auth_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:dio/dio.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../../../core/network/dio_provider.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; // 토큰 저장을 위해 필요
import 'package:haenaem/features/auth/signup/screens/signup_main_screen.dart';
import 'package:haenaem/features/main/screens/main_screen.dart';
import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';

// 구글 OAuth 2.0 기반의 사용자 인증과 JWT 토큰의 생명주기(발급, 재발급, 파기)를 전담하는 클래스
// 서버로부터 받은 userStatus(NEW/ACTIVE)를 분석하여 사용자별 맞춤형 초기 화면 진입 경로를 제어
Expand All @@ -18,15 +20,14 @@ class AuthService {
static const _storage = FlutterSecureStorage(); // 보안 저장소

// 구글 설정 정보
static const String androidClientId =
'433865217738-m3uqqdv9lumpf1ne8e3bkpsbtsa6919i.apps.googleusercontent.com';
static String androidClientId = dotenv.env['GOOGLE_ANDROID_CLIENT_ID'] ?? '';
static const String customScheme =
'com.googleusercontent.apps.433865217738-m3uqqdv9lumpf1ne8e3bkpsbtsa6919i';
static const String redirectUri = '$customScheme:/oauth2redirect';

// 카카오 설정 정보
static const String kakaoRestApiKey = '9fdd13c0777c415d8fa4055b5b26a6c5';
static const String kakaoNativeAppKey = '05a36f172ea2945260862834654385ea';
static String kakaoRestApiKey = dotenv.env['KAKAO_REST_API_KEY'] ?? '';
static String kakaoNativeAppKey = dotenv.env['KAKAO_NATIVE_APP_KEY'] ?? '';
// static const String kakaoRedirectUri =
// 'https://hanaem.onrender.com/api/oauth/kakao/token';

Expand All @@ -36,6 +37,11 @@ class AuthService {
//static const String kakaoRedirectUri =
//'kakao9fdd13c0777c415d8fa4055b5b26a6c5://oauth';

// 네이버 설정 정보
static String naverClientId = dotenv.env['NAVER_CLIENT_ID'] ?? '';
static const String naverRedirectUri =
'http://158.247.216.11:8080/oauth/naver/callback';

// ♥️ 기존 서버
static final Dio _dio = Dio(
BaseOptions(baseUrl: 'http://158.247.216.11:8080'),
Expand All @@ -52,6 +58,56 @@ class AuthService {
// ),
// );

// ----------------------------------------
// 네이버 로그인 함수
// ----------------------------------------

// 3. 네이버 인증 URL 생성 (네이버는 CSRF 방지를 위해 state 파라미터가 필수입니다)
static String getNaverAuthUrl(String state) {
final clientId = naverClientId;
final redirectUri = Uri.encodeComponent(naverRedirectUri);

return 'https://nid.naver.com/oauth2.0/authorize'
'?response_type=code'
'&client_id=$clientId'
'&redirect_uri=$redirectUri'
'&state=$state';
}

// 4. 네이버 인가 코드를 서버로 전송
static Future<void> sendNaverAuthToBackend({
required String code,
required String state,
required BuildContext context,
}) async {
try {
debugPrint("🚀 서버로 네이버 인가 데이터 전송 시작...");

final response = await _dio.post(
'/api/oauth/naver/token', // 📍 이 주소가 맞는지 백엔드 팀과 꼭 확인하세요!
data: {
"code": code,
"state": state, // 네이버는 검증을 위해 state도 같이 보내는 경우가 많습니다.
"fcmToken": "",
},
options: Options(contentType: Headers.jsonContentType),
);

debugPrint("📥 네이버 로그인 서버 응답 코드: ${response.statusCode}");

if (response.statusCode == 200 && response.data != null) {
await _handleAuthResponse(response.data, context);
}
} on DioException catch (e) {
debugPrint('🌐 네이버 서버 통신 에러: ${e.response?.statusCode}');
debugPrint('내용: ${e.response?.data}');
}
}

// ----------------------------------------
// 카카오 로그인 함수
// ----------------------------------------

// 1. PKCE 쌍 생성 (RFC 7636 표준 방식)
static Map<String, String> generatePkcePair() {
// 1-1. Verifier 생성: 표준에 정의된 [A-Z, a-z, 0-9, -, ., _, ~] 문자만 사용
Expand Down
6 changes: 6 additions & 0 deletions lib/features/auth/signup/screens/auth_gate.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ class AuthGate extends ConsumerWidget {
);
}

// 💡 에러가 발생한 경우 (예: 토큰 만료 후 재발급 실패 등) -> 로그인 화면으로
if (profileSnapshot.hasError) {
debugPrint("⚠️ 프로필 로드 실패 (에러): 로그인 화면으로 안내");
return const LoginScreen();
}

// 프로필 정보가 있고, 특정 필드(예: 태그)가 비어있다면 가입 미완료로 간주
final user = profileSnapshot.data;

Expand Down
5 changes: 4 additions & 1 deletion lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// 최초 작성자: 김채영

import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_localizations/flutter_localizations.dart';

import 'package:intl/date_symbol_data_local.dart';
Expand All @@ -17,6 +18,8 @@ import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart';
void main() async {
// 플러터 엔진 초기화 확인
WidgetsFlutterBinding.ensureInitialized();
// .env 파일 로드
await dotenv.load(fileName: ".env");

// 전역 에러 핸들러
FlutterError.onError = (FlutterErrorDetails details) {
Expand Down Expand Up @@ -47,7 +50,7 @@ void main() async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

// 카카오 SDK 초기화
KakaoSdk.init(nativeAppKey: '05a36f172ea2945260862834654385ea');
KakaoSdk.init(nativeAppKey: dotenv.get('KAKAO_NATIVE_APP_KEY'));

runApp(const ProviderScope(child: MyApp()));
}
Expand Down
24 changes: 16 additions & 8 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -245,10 +245,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
Expand Down Expand Up @@ -534,6 +534,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "11.0.0"
flutter_dotenv:
dependency: "direct main"
description:
name: flutter_dotenv
sha256: d41da11fb497314fbf89811ec30af02d1d898b47980a129f0a8c0a1720460ba2
url: "https://pub.dev"
source: hosted
version: "6.0.1"
flutter_launcher_icons:
dependency: "direct main"
description:
Expand Down Expand Up @@ -1009,18 +1017,18 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
Expand Down Expand Up @@ -1414,10 +1422,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.9"
timing:
dependency: transitive
description:
Expand Down
Loading