[Flutter 앱] Fitbit OAuth 로그인 구현

2025. 4. 7. 19:19Project 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에서 동작 결과에 차이가 있었고, 이리저리 코드를 많이 수정했었다.

    자동 로그인 및 로그아웃 로직에 부족한 부분이 없는지 다시 한번 검토해 보면 좋을 것 같다.