[Flutter] Firebase로 앱 만들기 - 전화번호 인증 구현

이재현·2024년 11월 24일
0

Flutter와 Firebase를 사용해 전화번호 인증 로그인 기능을 간단하게 구현해보았다.
Firebase Authentication을 통해 사용자에게 OTP(문자인증번호)를 전송하고 이를 확인하여 로그인 처리하는 기능을 구현하였다.

  • 사용자가 전화번호를 입력하면 Firebase가 인증번호를 전송.
  • 인증번호를 입력하면 로그인 성공 후 사용자 정보를 Firebase에 저장.

1. Firebase 프로젝트 설정

1. Firebase Authenticaiton 에서 전화번호 인증 방식 활성화 하기

  1. Firebase Console에 접속 후 Authentication에 접속한다.

    https://console.firebase.google.com

  2. 로그인 방법로그인 제공업자 에서 전화를 선택, 다음 버튼을 클릭한 후 사용 설정에 체크 한 후 저장을 누른다. 원한다면 테스트용 전화번호를 입력한다.

2. Android: SHA-1 키 및 SHA-256 키 생성.

  1. Android Studio를 실행하여 Flutter Project를 연다.

  2. 터미널을 실행 후 cd android를 입력해 android 폴더로 이동한다.

  3. 터미널에서 ./gredlew signingReport 명령어를 실행한다.

  4. 생성된 SHA-1 키와 SHA-246 를 메모장에 저장해둔다.

3. Firebase 안드로이드 프로젝트에 디지털 지문 추가

  1. Firebase 프로젝트 안드로이드 앱 설정으로 이동한다.
  1. 안드로이드 앱설정에서 디지털 지문 추가 버튼을 눌러 생성한 키를 추가한다.

2. 전화번호 인증 로직 구현

Firebase AuthRepository 설계와 구현

AuthRepository는 Firebase Authentication을 활용해 전화번호 인증과 사용자 인증 관련 기능을 담당한다.
인터페이스와 구현체를 분리해 확장성을 고려하며, Firebase에 의존하지 않는 상위 계층의 설계를 목표로 한다.

주요목표 :

  • 확장성 : Firebase 외 다른 인증 방식으로 쉽게 전환 가능.
  • 유지보수성 : 인터페이스를 사용해 인증 로직의 명확한 계약 정의.
  • 테스트 가능성 : Firebase와 독립적으로 인증 로직을 테스트할 수 있도록 Mocking 지원.

1. AuthRepository 인터페이스 설계

AuthRepository는 전화번호 인증, SMS 코드 인증, 사용자 로그인 상태 확인 및 로그아웃과 같은 인증 관련 기능을 정의한다.
Firebase에 의존하지 않고 추상화된 기능만을 포함하여, 다른 인증 서비스로 쉽게 확장 가능하다.
코드는 다음과 같다.

/// Firebase Authentication을 통한 인증 관련 기능을 정의하는 AuthRepository 인터페이스
abstract class AuthRepository {
  /// 전화번호로 인증 요청을 시작한다.
  /// [phoneNumber]는 인증할 사용자의 전화번호다.
  Future<void> requestPhoneVerification({required String phoneNumber});

  /// SMS 코드로 사용자 인증을 수행한다.
  /// [smsCode]는 사용자가 입력한 인증 코드다.
  Future<String> verifySMSCode({required String smsCode});

  /// Firebase 인증 상태 변화 감지 및 자동 로그인 여부를 확인한다.
  /// 자동 로그인을 위해 앱 실행 시 호출되어 로그인 상태를 확인한다.
  Future<bool> isUserLoggedIn();

  /// Firebase 로그아웃 기능을 수행한다.
  Future<void> logout();

  /// 현재 인증된 사용자의 UID를 반환한다.
  /// 로그인되지 않은 상태에서는 null을 반환한다.
  String? getCurrentUserId();
}

2. AuthRepository 구현체: AuthRepositoryImplement

AuthRepositoryImplement는 Firebase Authentication을 활용해 AuthRepository 인터페이스를 구현한다.
전화번호 인증, SMS 코드 인증, 로그아웃, 현재 사용자 확인과 같은 기능을 처리하며, Firebase의 메서드들을 캡슐화한다.

주요 목표 :
상태 관리 : 인증 ID(verificationId)와 재전송 토큰(forceResendToken)을 내부 상태로 관리.
재사용성 : Firebase Authentication의 복잡한 API를 단순화하여 서비스 계층에서 쉽게 호출 가능.
예외 처리 : Firebase 예외를 상위 계층으로 전달해 글로벌 예외 처리 로직과 통합.

코드는 다음과 같다.

/// Firebase Authentication을 통한 인증 관련 기능을 구현한 AuthRepositoryImplement
class AuthRepositoryImplement implements AuthRepository {
  final FirebaseAuth _auth; // FirebaseAuth 인스턴스 주입
  int? _forceResendToken; // 인증 코드 재전송을 위한 토큰
  String? _verificationId; // 인증 ID 저장

  AuthRepositoryImplement(this._auth);

  /// 전화번호 인증 요청
  
  Future<void> requestPhoneVerification({
    required String phoneNumber,
  }) async {
    try {
      await _auth.verifyPhoneNumber(
        phoneNumber: phoneNumber,
        verificationCompleted: (PhoneAuthCredential credential) async {
          try {
            await _auth.signInWithCredential(credential); // 자동 인증 처리
          } catch (e) {
            rethrow; // 예외를 상위로 전달
          }
        },
        verificationFailed: (FirebaseAuthException error) {
          throw error; // 인증 실패 예외 전달
        },
        codeSent: (String verificationId, int? resendToken) {
          _verificationId = verificationId; // 인증 ID 저장
          _forceResendToken = resendToken; // 재전송 토큰 저장
        },
        codeAutoRetrievalTimeout: (String verificationId) {
          _verificationId = verificationId; // 타임아웃 시 인증 ID 저장
        },
        timeout: const Duration(seconds: 60),
        forceResendingToken: _forceResendToken,
      );
    } catch (e) {
      rethrow; // 예외를 상위 레이어로 전달
    }
  }

  /// SMS 코드로 사용자 인증
  
  Future<String> verifySMSCode({
    required String smsCode,
  }) async {
    try {
      final credential = PhoneAuthProvider.credential(
        verificationId: _verificationId!,
        smsCode: smsCode,
      );
      final userCredential = await _auth.signInWithCredential(credential);
      return userCredential.user?.uid ?? ''; // UID 반환
    } catch (e) {
      rethrow; // 예외를 상위로 전달
    }
  }

  /// 현재 로그인 여부 확인
  
  Future<bool> isUserLoggedIn() async {
    return _auth.currentUser != null;
  }

  /// 로그아웃
  
  Future<void> logout() async {
    await _auth.signOut();
  }

  /// 현재 사용자 UID 반환
  
  String? getCurrentUserId() {
    return _auth.currentUser?.uid;
  }
}

전화번호 인증 화면 구현

전화번호 인증을 구현하기 위해 다음과 같은 단계로 화면을 구성하였다.

1. 사용자가 전화번호 입력 화면에서 국가를 선택하고 전화번호를 입력.
2. 입력된 전화번호로 Firebase 인증 요청.
3. 인증번호 입력 화면으로 이동해 OTP를 확인하고 사용자 인증 처리.

1. 전화번호 입력 화면 구현

설계 개요

1. 전화번호 입력:
- 전화번호는 숫자만 입력 가능하며, 유효성 검사가 포함된다.
2. 다음 버튼:
- 사용자가 전화번호 입력 후 "다음" 버튼을 누르면 Firebase로 인증 요청이 전송된다.

전화번호 입력 화면 코드 구현은 다음과 같다.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class PhoneNumberInputScreen extends StatefulWidget {
  const PhoneNumberInputScreen({super.key});

  
  State<PhoneNumberInputScreen> createState() => _PhoneNumberInputScreenState();
}

class _PhoneNumberInputScreenState extends State<PhoneNumberInputScreen> {
  final phoneNumberController = TextEditingController();
  final globalKey = GlobalKey<FormState>();
  final String phoneCode = '82'; // 한국 국가 코드

  
  void dispose() {
    phoneNumberController.dispose();
    super.dispose();
  }

  Future<void> sendOTP() async {
    final phoneNumber = phoneNumberController.text;
    final fullPhoneNumber = '+$phoneCode$phoneNumber';
    // Firebase 인증 요청 (테스트용 로그)
    print('Sending OTP to: $fullPhoneNumber');
    // 실제 요청 코드 추가 가능
  }

  
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => FocusScope.of(context).unfocus(),
      child: Scaffold(
        appBar: AppBar(
          title: const Text('전화번호를 입력해주세요'),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const Text(
                'Obri에서 당신의 계정을 인증합니다.',
                style: TextStyle(color: Colors.grey),
              ),
              const SizedBox(height: 20),
              SizedBox(
                width: 250,
                child: Row(
                  children: [
                    Expanded(
                      flex: 1,
                      child: TextFormField(
                        initialValue: '+$phoneCode', // 한국 국가 코드 자동 설정
                        readOnly: true,
                        decoration: const InputDecoration(
                          isDense: true,
                          prefixIconConstraints: BoxConstraints(
                            minWidth: 0,
                            minHeight: 0,
                          ),
                        ),
                        textAlign: TextAlign.center,
                      ),
                    ),
                    Expanded(
                      flex: 2,
                      child: Form(
                        key: globalKey,
                        child: TextFormField(
                          controller: phoneNumberController,
                          decoration: const InputDecoration(
                            labelText: '전화번호 입력',
                            isDense: true,
                          ),
                          keyboardType: TextInputType.phone,
                          inputFormatters: [
                            FilteringTextInputFormatter.digitsOnly,
                          ],
                          validator: (value) {
                            if (value == null || value.trim().isEmpty) {
                              return '전화번호를 입력해주세요';
                            }
                            if (value.length < 9 || value.length > 11) {
                              return '유효한 전화번호를 입력해주세요';
                            }
                            return null;
                          },
                        ),
                      ),
                    ),
                  ],
                ),
              ),
              const SizedBox(height: 40),
              ElevatedButton(
                onPressed: () {
                  FocusScope.of(context).unfocus();
                  final form = globalKey.currentState;
                  if (form == null || !form.validate()) {
                    return;
                  }
                  sendOTP();
                  ScaffoldMessenger.of(context).showSnackBar(
                    const SnackBar(content: Text('OTP 요청이 전송되었습니다.')),
                  );
                },
                child: const Text('다음'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

주요 구현 설명

1. 전화번호 입력

  • 전화번호 입력 필드는 숫자만 입력 가능하며, 유효성 검사를 통해 비어 있거나 잘못된 번호는 "다음" 버튼을 누를 수 없도록 설정했다.
    Form(
      key: globalKey,
      child: TextFormField(
        controller: phoneNumberController,
        keyboardType: TextInputType.phone,
        inputFormatters: [
          FilteringTextInputFormatter.digitsOnly,
        ],
        validator: (value) {
          if (value == null || value.trim().isEmpty) {
            return '전화번호를 입력해주세요';
          }
          return null;
        },
      ),
    );

2. Firebase로 인증 요청

  • 사용자가 입력한 전화번호를 authProvider를 통해 Firebase로 인증 요청을 보낸다.
    await ref.read(authProvider.notifier).requestPhoneVerification(
          phoneNumber: '+$phoneCode$phoneNumber',
        );


2. 인증번호 입력 화면 구현

설계 개요

1. OTP 입력:
- 사용자가 Firebase로부터 받은 인증번호(OTP)를 입력.
2. 인증 처리:
- 입력된 OTP를 Firebase로 전송해 사용자 인증을 완료.
3. 에러 처리:
- 인증 실패 시 사용자에게 오류 메시지를 표시.

인증번호 입력 화면 구현 코드는 다음과 같다.

class OPTScreen extends ConsumerWidget {
  static const String routeName = '/otp-screen';
  const OPTScreen({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    return GestureDetector(
      onTap: () => FocusScope.of(context).unfocus(),
      child: Scaffold(
        appBar: AppBar(
          title: const Text('전화번호 인증 중'),
        ),
        body: Center(
          child: Column(
            children: [
              const Text('인증 번호를 전송했습니다'),
              Container(
                width: 240,
                decoration: const BoxDecoration(
                    border: Border(
                  bottom: BorderSide(color: Colors.green),
                )),
                child: OtpTextField(
                  numberOfFields: 6,
                  fieldWidth: 35,
                  hasCustomInputDecoration: true,
                  decoration: const InputDecoration(
                    hintText: '-',
                    counterText: '',
                    border: InputBorder.none,
                    hintStyle: TextStyle(fontWeight: FontWeight.bold),
                  ),
                  onSubmit: (value) async {
                    await ref
                        .read(authProvider.notifier)
                        .verifySmsCode(smsCode: value);
                  },
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

주요 구현 설명

1. OTP 입력 필드

  • OtpTextField 위젯을 활용해 6자리 숫자 입력 필드를 구성.
  • 사용자 입력이 완료되면 onSubmit을 통해 Firebase 인증 요청을 보낸다.

2. 인증 결과 처리

  • Firebase 인증 성공 시, 인증된 사용자 정보를 반환하거나 에러 메시지를 표시한다.

실제 테스트 진행하기

  1. VSCode의 터미널에서 flutter run 명령어를 통해 flutter 앱을 실행한다.
    > flutter run
  1. 실행된 앱 화면에서 테스트용 전화번호나 실제로 인증번호를 받을 전화번호를 입력한다.

  2. 다음 버튼을 클릭하여 reCAPCHA를 진행한다.

  3. 실제 번호로 테스트를 진행한다면 다음화면과 같은 문자가 온다.

  4. 인즌번호 입력 화면에서 인증번호를 입력한다.

  5. Firebase Authentication Console 에서 사용자가 추가된걸 확인할 수 있다.

profile
백엔드 개발자를 향해

0개의 댓글