2025. 4. 7. 19:19ㆍProject Log/학부 졸업프로젝트
Fitbit OAuth 로그인 구현
Android/iOS 호환되는 Flutter 앱에서 'Fitbit 로그인' 기능을 구현하였다. 전체 코드를 다 넣기보다는 주요 코드 위주로 구현 내용을 정리하려고 한다. (메인 할 일 목록 페이지에 출력된 사용자 및 토큰 정보는 플러터 저장소의 정상 작동 여부를 테스트하는 용도이다.)
Fitbit Developer 앱 등록
Fitbit Developer > Register An App 사이트(https://dev.fitbit.com/apps/new)에 접속한다.
Application을 등록한다.
웹이 아닌 앱에 연결할 것이므로, OAuth 2.0 Application Type을 'Client'로, Redirect URL을 'myapp://callback'으로 설정하는 부분이 중요하다. 조직명이나 나머지 URL 등은 아래 사진과 같이, 실존하지 않는 주소여도 칸만 채워놓으면 된다.
앱을 등록하면 Client ID와 Client Secret이 생성된 것을 볼 수 있다.
이를 복사하고 이후 코드에서 활용할 것이다.
플러터 패키지 설치
flutter pub add 커맨드로 아래 패키지들을 설치하였다.
dependencies:
flutter:
sdk: flutter
(...중략...)
url_launcher: ^6.3.1
http: ^1.3.0
smooth_page_indicator: ^1.2.1
flutter_web_auth_2: ^4.1.0
flutter_secure_storage: ^9.2.4
flutter_inappwebview: ^6.1.5
flutter_dotenv: ^5.2.1
모바일 OS별 설정 파일 수정
Android와 iOS 설정 파일을 수정한다.
AndroidManifest.xml
- 콜백 액티비티와 flutter_web_auth_2 인텐트 필터를 추가한다.
<activity android:name="com.linusu.flutter_web_auth_2.CallbackActivity" android:exported="true">
<intent-filter android:label="flutter_web_auth_2">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" android:host="callback" />
</intent-filter>
</activity>
Info.plist
- LSApplicationQueriesSchemes에 myapp을 등록한다.
- 로컬 네트워크 에러 제거와 관련된 설정을 추가한다.
<!--Fitbit OAuth 로그인-->
<key>LSApplicationQueriesSchemes</key>
<array>
<string>myapp</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
<!--로컬 네트워크 에러 제거-->
<key>NSLocalNetworkUsageDescription</key>
<string>Flutter 디버그 서비스를 위해 로컬 네트워크 접근이 필요합니다.</string>
.env에 Fitbit Client ID & Client Secret 저장
FITBIT_CLIENT_ID와 FITBIT_CLIENT_SECRET은 .env에 따로 저장하였다. 따옴표 필요없이 그대로 실제 값을 넣으면 된다.
FITBIT_CLIENT_ID={Fitbit Dev에 등록한 ClientId 입력}
FITBIT_CLIENT_SECRET={Fitbit Dev에 등록한 ClientSecret 입력}
.gitignore에 .env 파일을 등록한다.
/assets/.env
OAuth 로그인 코드 구현
main.dart
- dotenv로 .env 파일의 Client ID와 Client Secret 정보를 로드한다.
- FitbitAuthService 클래스에 있는 clearIfNotAutoLogin 함수를 호출한다.
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await dotenv.load(fileName: "assets/.env");
await FitbitAuthService.clearIfNotAutoLogin();
FlutterNativeSplash.remove();
runApp(const MyApp());
}
- FutureBuilder는 비동기 작업의 결과를 기다리며 UI를 구성하게 해준다.
- isLoggedIn 함수가 반환한 값이 true이면 /main 화면으로, false이면 /login 화면으로 라우팅한다.
@override
Widget build(BuildContext context) {
return FutureBuilder<bool>(
future: FitbitAuthService.isLoggedIn(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const MaterialApp(
home: Scaffold(body: Center(child: CircularProgressIndicator())),
);
}
final initialLocation = snapshot.data! ? '/main' : '/login';
router_without_animation.dart
- appRouter는 initialLocation을 인자로 받으며, 로그인 여부에 따라 유동적으로 첫 화면을 설정한다.
GoRouter appRouter(String initialLocation) => GoRouter(
initialLocation: initialLocation,
routes: [
GoRoute(
path: '/login',
pageBuilder: (context, state) => NoTransitionPage(child: LoginScreen()),
routes: [
GoRoute(
path: 'inputUserInfo',
pageBuilder: (context, state) => NoTransitionPage(child: InputUserInfoScreen()),
),
],
),
ShellRoute(
builder: (context, state, child) {
return MainScreen(child: child);
},
routes: [
GoRoute(
path: '/main',
pageBuilder: (context, state) => NoTransitionPage(child: MissionScreen()),
),
fitbit_auth_service.dart
- FlutterSecureStorage를 storage 변수에 저장한다.
- clearIfNotAutoLogin은 storage로부터 auto_login 설정값을 불러와서 autoLogin 변수에 저장한다. autoLogin 값이 false이면 logout 함수를 호출한다.
- isLoggedIn 함수는 auto_login 값이 true이고, access_token이 null이 아니면 true를 반환하며, 그렇지 않으면 false를 반환한다.
class FitbitAuthService {
static final FlutterSecureStorage _storage = FlutterSecureStorage();
static Future<void> clearIfNotAutoLogin() async {
final autoLogin = await _storage.read(key: 'auto_login');
if (autoLogin != 'true') {
await logout();
}
}
static Future<bool> isLoggedIn() async {
final autoLogin = await _storage.read(key: 'auto_login');
final token = await _storage.read(key: 'access_token');
return autoLogin == 'true' && token != null;
}
- loginWithFitbit 함수는 clientId, clientSecret, redirectUri, scopes 정보를 바탕으로, authUrl을 설정한다.
- MyInAppBrowser로 앱 내부에서 웹 화면을 띄운다.
- oauth2에 POST 요청을 보내고, response로 토큰 정보를 받아온다.
- flutter secure storage에 access_token, refresh_token, is_first_login, auto_login 정보를 저장한다.
static Future<Map<String, dynamic>?> loginWithFitbit({required bool autoLogin}) async {
final clientId = dotenv.env['FITBIT_CLIENT_ID']!;
final clientSecret = dotenv.env['FITBIT_CLIENT_SECRET']!;
const redirectUri = 'myapp://callback';
const scopes = 'activity heartrate sleep profile';
final authUrl =
'https://www.fitbit.com/oauth2/authorize'
'?response_type=code'
'&client_id=$clientId'
'&redirect_uri=$redirectUri'
'&scope=$scopes'
'&prompt=login';
final completer = Completer<Map<String, dynamic>?>();
final browser = MyInAppBrowser(onRedirect: (url) async {
final code = Uri.parse(url).queryParameters['code'];
if (code == null) {
completer.completeError(Exception('코드가 없음'));
return;
}
final response = await http.post(
Uri.parse('https://api.fitbit.com/oauth2/token'),
headers: {
'Authorization': 'Basic ${base64Encode(utf8.encode('$clientId:$clientSecret'))}',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: {
'client_id': clientId,
'grant_type': 'authorization_code',
'redirect_uri': redirectUri,
'code': code,
},
);
if (response.statusCode == 200) {
final tokenData = jsonDecode(response.body);
final accessToken = tokenData['access_token'];
final refreshToken = tokenData['refresh_token'];
await _storage.write(key: 'access_token', value: accessToken);
await _storage.write(key: 'refresh_token', value: refreshToken);
await _storage.write(key: 'is_first_login', value: 'true');
await _storage.write(key: 'auto_login', value: autoLogin.toString());
completer.complete({
"access_token": accessToken,
"refresh_token": refreshToken,
});
} else {
completer.completeError(Exception('토큰 교환 실패: ${response.body}'));
}
});
await browser.openUrlRequest(
urlRequest: URLRequest(
url: WebUri(authUrl),
),
);
return completer.future;
}
- logout 함수는 storage에 저장된 모든 정보를 삭제한다.
static Future<void> logout() async {
await _storage.deleteAll();
}
- getAccessToken, getRefreshToken은 storage에 저장된 토큰 정보를 읽어온다.
- getUserId는 토큰 정보를 바탕으로 Fitbit 서버에 사용자 프로필을 요청하여, 유저 아이디를 리턴한다.
static Future<String?> getAccessToken() async {
return await _storage.read(key: 'access_token');
}
static Future<String?> getRefreshToken() async {
return await _storage.read(key: 'refresh_token');
}
static Future<String?> getUserId() async {
final token = await _storage.read(key: 'access_token');
if (token == null) return null;
final response = await http.get(
Uri.parse('https://api.fitbit.com/1/user/-/profile.json'),
headers: {'Authorization': 'Bearer $token'},
);
if (response.statusCode == 200) {
final json = jsonDecode(response.body);
return json['user']['encodedId'];
} else {
print('사용자 정보 요청 실패: ${response.statusCode} - ${response.body}');
return null;
}
}
- user_info_entered는 앱 설치 후, 유저 정보 입력창에 접속한 적이 있는지 나타내는 변수이다.
- isUserInfoEntered 함수는 stoage로부터 user_info_entered 값을 읽어온다.
- setUserInfoEntered 함수는 user_info_entered 값을 true로 설정한다.
static Future<bool> isUserInfoEntered() async {
final result = await _storage.read(key: 'user_info_entered');
return result == 'true';
}
static Future<void> setUserInfoEntered() async {
await _storage.write(key: 'user_info_entered', value: 'true');
}
login_screen.dart
- 로그인 가이드 텍스트를 설정한다.
- autoLogin을 false로 설정한다.
class _LoginScreenState extends State<LoginScreen> {
final PageController _pageController = PageController();
final List<String> guideTexts = [
"1. Fitbit 로그인 버튼을 누른 후\nGoogle 아이디로 로그인하세요.",
"2. Google 계정 정보를 입력하세요.",
"3. 첫 로그인 이후에는\n저장된 Google 계정으로\n간편하게 로그인 할 수 있어요.",
];
bool _autoLogin = false;
- 체크박스 체크 여부에 따라 autoLogin 변수에 저장되는 불린값을 변경한다.
Checkbox(
value: _autoLogin,
onChanged: (value) {
setState(() {
_autoLogin = value!;
});
},
activeColor: Colors.orange,
),
const Text(
"자동 로그인은 네모를 클릭해주세요.",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
- 로그인 버튼을 누르면 FitbitAuthService 클래스의 loginWithFitbit 함수를 호출한다. 인자로 autoLogin 값을 전달한다.
- FitbitAuthService 클래스의 isUserInfoEntered 함수를 호출한다. 함수가 반환한 값이 false이면 /login/inputUserInfo 화면으로, true이면 /main 화면으로 바로 이동한다.
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
),
onPressed: () async {
try {
final result = await FitbitAuthService.loginWithFitbit(autoLogin: _autoLogin);
final alreadyEntered = await FitbitAuthService.isUserInfoEntered();
if (!mounted) return;
if (!alreadyEntered) {
context.go('/login/inputUserInfo');
} else {
context.go('/main');
}
} catch (e) {
print("로그인 에러: $e");
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("로그인 실패. 다시 시도해주세요.")),
);
}
},
child: const Text(
"Fitbit 로그인",
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
),
),
myinapp_browser.dart
- MyInAppBrowser는 flutter_inappwebview 패키지를 사용해, 웹 인증 과정에서 리디렉션 처리를 위한 커스텀 브라우저 클래스를 정의한 것이다. 즉, 앱 내에서 브라우저가 실행된다.
- onLoadStop과 onUpdateVisitedHistory는 리디렉션 URL을 감지하면 콜백을 호출하고 브라우저를 자동으로 닫는다.
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
class MyInAppBrowser extends InAppBrowser {
final void Function(String url) onRedirect;
MyInAppBrowser({required this.onRedirect});
// Android
@override
void onLoadStop(WebUri? url) async {
if (url != null && url.toString().startsWith('myapp://callback')) {
onRedirect(url.toString());
await close();
}
}
// iOS
@override
void onUpdateVisitedHistory(WebUri? url, bool? isReload) async {
if (url != null && url.toString().startsWith('myapp://callback')) {
onRedirect(url.toString());
await close();
}
}
}
input_user_info_screen.dart
- 입력 완료 버튼을 누르면 onComplete가 실행된다.
- FitbitAuthService.setUserInfoEntered 함수를 실행하여 storage의 user_info_entered 값을 true로 변경한다.
void _onComplete() {
(...중략...)
FitbitAuthService.setUserInfoEntered();
context.go('/main');
}
mission_screen.dart
- FitbitAuthService 클래스의 getAccessToken, getUserId, getRefreshToken 함수를 호출한다.
- 액세스 토큰, 리프레시 토큰, 유저 아이디를 출력하여 storage에 사용자 정보가 정상적으로 저장되었지 테스트한다.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: CustomAppBar(title: '꽃이 되는 하루'),
body: FutureBuilder(
future: Future.wait([
FitbitAuthService.getAccessToken(),
FitbitAuthService.getUserId(),
FitbitAuthService.getRefreshToken(),
]),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
if (!snapshot.hasData || snapshot.data == null) {
return const Text("로그인 정보 없음");
}
final accessToken = snapshot.data![0] as String?;
final userId = snapshot.data![1] as String?;
final refreshToken = snapshot.data![2] as String?;
return Column(
children: [
if (accessToken != null)
Text(
"Access Token: $accessToken",
style: const TextStyle(fontSize: 12),
),
if (refreshToken != null)
Text(
"Refresh Token: $refreshToken",
style: const TextStyle(fontSize: 12),
),
if (userId != null)
Text(
"User ID: $userId",
style: const TextStyle(fontSize: 12),
),
logout_cancel_screen.dart
- 로그아웃 버튼을 누르면 FitbitAuthService 클래스의 logout 함수를 호출한다.
- /login 화면으로 이동한다.
_buildSettingOption(
context,
title: '계정 로그아웃 (나가기)',
imagePath: 'assets/setting_icon/logout.png',
onTap: () async {
await FitbitAuthService.logout();
if (context.mounted) {
context.go('/login');
}
},
),
구현 소감
아무래도 앱 내에서 Fitbit 인증을 하는 부분에서 시간이 오래 걸렸던 것 같다.
처음에는 fitbitter를 사용하다가 함수 호출에서 자꾸 에러가 나왔다.
그래서 fitbitter 대신 flutter_web_auth_2와 inappwebview 패키지를 활용하여, 앱 내에서 브라우저를 띄우기로 했다.
inappwebview는 iOS와 Android에서 동작 결과에 차이가 있었고, 이리저리 코드를 많이 수정했었다.
자동 로그인 및 로그아웃 로직에 부족한 부분이 없는지 다시 한번 검토해 보면 좋을 것 같다.