'플러터 로그인 샘플'에 해당되는 글 1건

728x90

구글 검색하면 kakao Login 또는 Firebase 기반 로그인 기능 구현에 대한 예제들이 검색된다.

이 예제는 기업/클라우드 서버와의 통신을 전제로 한 로그인 구현 기본 사항이다.

아직 더 해결해야 할 사항들로는 RSA 암호화 통신, 세션기반 처리 기능 구현이다.

더 고민하고 테스트해서 해결되면 다른 게시글에 추가 작성할 예정이다.

 

Flutter 에서 로그인 처리를 이해하는데 한걸음 더 나아간 예제라고 보면 된다.

로그인에 대한 이해를 하기 위해서는 서버에서 응답하는 메시지의 형태가 어떤지 알아야 한다.

 

1. 서버의 PHP 코드 예제

- 로그인 핵심 코드는 loginClass 에 정의되어 있다.

- 여기서는 JSON encode 처리 메시지에 대한 이해 관점으로 전체적인 흐름을 이해하면 된다.

더보기

 

<?php
// 파일을 직접 실행하는 비정상적 동작을 방지 하기 위한 목적
if(isset($_POST&& $_SERVER['REQUEST_METHOD'== "POST"){
    @extract($_POST); // $_POST['loginID'] 라고 쓰지 않고, $loginID 라고 써도 인식되게 함
    if(isset($userID&& !empty($userID&& isset($password&& !empty($password)) {
        require_once 'config/config.php';
        require_once 'phpclass/dbconnect.php';
        require_once 'phpclass/loginClass.php';
        $c = new LoginClass();
 
        header("Cache-Control: no-cache, must-revalidate");
        header("Content-type: application/json; charset=UTF-8");
 
        //키워드 확인
        if(!isset($_POST['keyword'])){
            $result = array(
                'status' => "key fail",
                'message' => "서버 KEY 정보가 없습니다.",
                'userinfo' => null
            );
            echo json_encode($result);
            exit;
        }
 
        $keyword=$c->AES_decrypt($_POST['keyword']);
        //키워드 일치 확인
        if(strcmp($keyword,$mykey)<>0){
            $result = array(
                'status' => "key fail",
                'message' => "서버와 단말의 KEY가 일치하지 않습니다.",
                'userinfo' => null
            );
            echo json_encode($result);
            exit;
        }
 
        $userID = $c->AES_decrypt($_POST['userID']);
        $password = $c->rsa_decrypt($_POST['password']);
 
        // 동일 휴대폰 번호로 로그인하면 phoneSE 업데이트 처리
        $c->LoginUserPhoneChk($userID,$password,$uID,$mfoneNO);
 
        $rs = $c->LoginUserChk($userID,$password,$uID);
        if($rs > 0){
            $user = $c->getUser($userID$password);
            if ($user != false) {
                $c->regUserPhone($userID,$mfoneNO);
 
                if(!isset($_SESSION)) {
                    session_start();
                }
 
                $_SESSION['userID'= $user['userID'];
                $_SESSION['userNM'= $user['userNM'];
                $_SESSION['admin'= $user['admin'];
 
                $row = array("userNM"=>$user['userNM'],"mobileNO"=>$user['mobileNO'],"profileImg"=>$user['idx']);
 
                $status = "success";
                $message = "";
                $userinfo = $row;
            } else {
                $status = "로그인 에러";
                $message = "다시 한번 시도하시기 바랍니다.";
                $userinfo = null;
            }
 
        } else if($rs === -1){            
            $status = "단말 불일치";
            $message = '등록 단말 정보가 일치하지 않습니다. 관리자에게 문의하시기 바랍니다.';
            $userinfo = null;
        } else {
            $status = "로그인 에러";
            $message = '로그인 정보가 일치하지 않습니다';
            $userinfo = null;
        }
        $result = array(
            'status' => $status,
            'message' => $message,
            'userinfo' => $userinfo
        );
        echo json_encode($result);
    }
else { // 비정상적인 접속인 경우
    echo 0// loginChk.php 파일을 직접 실행할 경우에는 화면에 0을 찍어준다.
    exit;
}
?>

 

 

2. JSON 메시지 형태

- PHP 서버 코드가 제공하는 JSON 메시지

{
  "status": "로그인 에러",
  "message": "로그인 정보가 일치하지 않습니다",
  "userinfo": null
}
 
{
  "status": "단말 불일치",
  "message": "등록 단말 정보가 일치하지 않습니다. 관리자에게 문의하시기 바랍니다.",
  "userinfo": null
}
 
{
  "status": "success",
  "message": "",
  "userinfo": {
    "userNM": "강감찬",
    "mobileNO": "01092010852",
    "profileImg": 1
  }
}

- 로그인이 성공했을 때 제공되는 JSON 메시지

- 로그인이 실패했을 때 제공되는 JSON 메시지

로 구분해서 UserResult 클래스를 만들도록 서버의 PHP 코드를 고려했다.

- 메시지를 보면 userinfo 가 null 일 수도 있고, 값을 반환할 수도 있다는 점을 확인하고

  UserResult 클래스 구현시 이 사항을 반드시 고려해야 한다.

 

3. UserResult 클래스 구현

- Java 에서 구현하는 클래스로 접근해서 엄청난 삽질과 어려움을 겪었다.

- Flutter 에서 JSON 메시지 파싱처리 개념을 제대로 이해해야 문제없이 구현할 수 있다.

  반드시 factory 생성자를 구현해야 한다.

- factory 생성자 : 새로운 인스턴스를 생성하지 않는 생성자를 구현할 때 사용한다.

  . 기존에 이미 생성된 인스턴스가 있다면 return 하여 재사용한다.

  . 하나의 클래스에서 하나의 인스턴스만 생성한다.(싱글톤 패턴)

  . 서브 클래스 인스턴스를 리턴할 때 사용할 수 있다.

  . factory constructor 에서는 this에 접근할 수 없다.

class UserResult {
  final String status;
  final String message;
  final UserInfo? userinfo;
 
  UserResult(
      {required this.status, required this.message, required this.userinfo});
 
  // 반드시 정의 해주어야 서버에서 전달받은 데이터를 처리하더라.
  factory UserResult.fromJson(Map<String, dynamic> parsedJson) {
    return UserResult(
      status: parsedJson['status'],
      message: parsedJson['message'],
      userinfo: parsedJson['userinfo'== null
          ? null
          : UserInfo.fromJson(parsedJson['userinfo']),
      // UserInfo 가 null 일 수도 있고, 값이 들어있을 수도 있는 걸 삼항연산자로 처리
    );
  }
}
 
class UserInfo {
  final String userNM;
  final String mobileNO;
  final int profileImg;
 
  UserInfo({
    required this.userNM,
    required this.mobileNO,
    required this.profileImg,
  });
 
  factory UserInfo.fromJson(Map<String, dynamic> parsedJson) {
    return UserInfo(
      userNM: parsedJson['userNM'],
      mobileNO: parsedJson['mobileNO'],
      profileImg: parsedJson['profileImg'],
    );
  }
 
  Map<String, dynamic> toJson() => {
        "userNM": userNM,
        "mobileNO": mobileNO,
        "profileImg": profileImg,
      };
}
 

 

// android/app/src/main/AndroidManifest.xml 에 퍼미션 추가 
 
<uses-permission android:name="android.permission.INTERNET"/>
 
// pubspec.yaml 파일 
dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  http: ^0.13.4
  dio: ^4.0.6
  logging: ^1.0.2
  get: ^4.6.5
  awesome_dialog: ^2.2.1
  validators: ^3.0.0
  shared_preferences: ^2.0.15
  platform_device_id: ^1.0.1
  mobile_number: ^1.0.4
  cached_network_image: ^3.2.1
  fluttertoast: ^8.0.9
  intl: ^0.17.0
  encrypt: ^5.0.1
  json_annotation: ^4.6.0
 

 

4. DIO 라이브러리를 이용한 Login Class 정의

import 'dart:convert';
 
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:login_ex01/model/user_result.dart';
 
import '../api/api.dart';
import '../api/logging.dart';
 
abstract class ILogin {
  Future<UserResult> login(String keyword, String userID, String password,
      String uID, String mfoneNO);
}
 
class LoginService extends ILogin {
  @override
  Future<UserResult> login(String keyword, String userID, String password,
      String uID, String mfoneNO) async {
    BaseOptions options = BaseOptions(
      baseUrl: Api.baseUrl,
      connectTimeout: 5000,
      receiveTimeout: 3000,
    );
    Dio dio = Dio(options);
    dio.interceptors.add(Logging());
 
    FormData formData = FormData.fromMap({
      "keyword": keyword,
      "userID": userID,
      "password": password,
      "uID": uID,
      "mfoneNO": mfoneNO
    });
 
    final response = await dio.post(Api.mLogin, data: formData);
      final Map<String, dynamic> body = response.data;      
      print("${body}"); // 로그 분석 목적
 
      UserResult result = UserResult.fromJson(body);
      return result;
    } else {
      return UserResult(status: "fail", message: "fail", userinfo: null);
    }
  }
}

 

5. 로그인 구현 예제

- 로그인 UI 구성이 포함되어 있으며, 로그인시 전달될 파라미터로 폰의 고유한 ID (UUID), 폰의 휴대폰번호 수집 로직, 서버와 주고받을 keyword 일치 여부를 통한 통신의 기본 체크 사항이 포함되어 있다.

- AES 암호화/복호화 메소드는 다른 게시글을 참조하면 된다.

- 로그인 성공하면 다른 UI 로의 이동은 포함하지 않았다.

  GetX 를 이용한 로그인/로그아웃 처리 로직을 구현 시 처리할 예정이다.

import 'dart:async';
import 'dart:convert';
import 'dart:io';
 
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:login_ex01/model/user_result.dart';
import 'package:platform_device_id/platform_device_id.dart';
import 'package:mobile_number/mobile_number.dart';
 
import '../api/api_cipher.dart';
import '../service/login_service.dart';
import '../utils/utils.dart';
 
class LoginView extends StatefulWidget {
  const LoginView({Key? key}) : super(key: key);
 
  @override
  State<LoginView> createState() => _LoginViewState();
}
 
class _LoginViewState extends State<LoginView> {
  final _formKey = GlobalKey<FormState>(); // 이 코드는 학습해야 함.
 
  final ILogin _login = LoginService();
  final _userIDController = TextEditingController();
  final _passwordController = TextEditingController();
 
  late String _deviceId;
 
  String _mobileNumber = '';
  List<SimCard> _simCard = <SimCard>[];
 
  late String _userID;
  late String _password;
 
  var userIDFocusNode;
  var passwdFocusNode;
 
  @override
  void initState() {
    super.initState();
    getDeviceUniqueId();
    MobileNumber.listenPhonePermission((isPermissionGranted) {
      if (isPermissionGranted) {
        initMobileNumberState();
      } else {}
    });
 
    initMobileNumberState();
  }
 
  Future<void> getDeviceUniqueId() async {
    String? deviceId;
    try {
      deviceId = await PlatformDeviceId.getDeviceId;
    } on PlatformException {
      deviceId = 'Failed to get deviceId.';
    }
 
    if (!mounted) return;
 
    setState(() {
      _deviceId = deviceId!;
      print("deviceId -> $_deviceId");
    });
  }
 
  Future<void> initMobileNumberState() async {
    if (!await MobileNumber.hasPhonePermission) {
      await MobileNumber.requestPhonePermission;
      return;
    }
    String mobileNumber = '';
    try {
      mobileNumber = (await MobileNumber.mobileNumber)!;
      _simCard = (await MobileNumber.getSimCards)!;
    } on PlatformException catch (e) {
      debugPrint("Failed to get mobile number because of '${e.message}'");
    }
 
    if (!mounted) return;
 
    setState(() {
      _mobileNumber = mobileNumber;
      _mobileNumber = Utils.getPhoneNumber(_mobileNumber);
      print("mobileNumber -> $_mobileNumber");
    });
  }
 
  Widget fillCards() {
    List<Widget> widgets = _simCard
        .map((SimCard sim) => Text(
            'Sim Card Number: (${sim.countryPhonePrefix}) - ${sim.number}\nCarrier Name: ${sim.carrierName}\nCountry Iso: ${sim.countryIso}\nDisplay Name: ${sim.displayName}\nSim Slot Index: ${sim.slotIndex}\n\n'))
        .toList();
    return Column(children: widgets);
  }
 
  onSubmit() async {
    if (_userIDController.text.trim().isEmpty ||
        _passwordController.text.trim().isEmpty) {
      if (_userIDController.text.trim().isEmpty) {
        Utils.showSnackBar(context, '아이디를 입력하세요');
        userIDFocusNode.requestFocus();
        return;
      }
 
      if (_passwordController.text.trim().isEmpty) {
        Utils.showSnackBar(context, '비밀번호를 입력하세요');
        passwdFocusNode.requestFocus();
        return;
      }
    } else {
      String keyword = CIpheR.AES_encrypt(CIpheR.URLkey());
      String passwd = _passwordController.text.trim();
     // passwd = CIpheR.rsaEncrypt(passwd);
 
      UserResult result = await _login.login(
          keyword,
          CIpheR.AES_encrypt(_userIDController.text.trim()),
          passwd,
          _deviceId,
          _mobileNumber);
 
      if (result.status.contains("success")) {
        Utils.showSnackBar(context, '로그인 성공');
      } else {
        Utils.showAlert(context, result.status, result.message);
      }
    }
  }
 
  @override
  void dispose() {
    userIDFocusNode.dispose();
    passwdFocusNode.dispose();
    super.dispose();
  }
 
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Login Page'),
      ),
      body: Center(
        child: SingleChildScrollView(
          child: Container(
            padding: const EdgeInsets.all(16.0),
            child: Form(
              key: _formKey,
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: [
                  Container(
                    padding: const EdgeInsets.fromLTRB(1010100),
                    child: TextFormField(
                      controller: _userIDController,
                      decoration: const InputDecoration(
                        border: OutlineInputBorder(),
                        labelText: 'UserID',
                      ),
                      focusNode: userIDFocusNode,
                      textInputAction: TextInputAction.next,
                    ),
                  ),
                  SizedBox(
                    height: 30,
                  ),
                  Container(
                    padding: const EdgeInsets.fromLTRB(1010100),
                    child: TextFormField(
                      controller: _passwordController,
                      obscureText: true,
                      decoration: const InputDecoration(
                        border: OutlineInputBorder(),
                        labelText: '비밀번호',
                      ),
                      focusNode: passwdFocusNode,
                      onEditingComplete: () async {},
                    ),
                  ),
                  SizedBox(
                    height: 30,
                  ),
                  Container(
                    height: 50,
                    padding: const EdgeInsets.fromLTRB(500500),
                    child: ElevatedButton(
                        child: Text(
                          'Login',
                          style: TextStyle(fontSize: 20.0),
                        ),
                        onPressed: onSubmit),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}
 

 

6. API 정의

class Api {
  static const baseUrl = "https://www.abc.com";
  static const mLogin = "/androidSample/loginChk.php";
 
  static const api_key = ""// 서버와 통신하기 위한 AES 암호화 키
  static const String URLKEY = ''// 서버 인증 수단 체크 keyword
 
  // 완벽 통신 위해서는 RSA 암호화 통신 및 Session 통신 처리해야 한다.
}

- 보안을 고려한 통신 처리 로직 구현에 많은 시간을 할애할 수 밖에 없다.

아직 해결해야 할 사항이 많지만 Flutter 기본 로그인 처리에 대한 이해를 하고 기록해 둔다.

'Flutter 앱 > Network' 카테고리의 다른 글

Flutter AES256 with PHP  (0) 2023.12.19
ListView.separated 예제  (0) 2023.11.20
Session vs JWT  (0) 2022.07.22
Flutter Login 로직 구현 예제 (오류 포함)  (0) 2022.07.22
Flutter DIO 라이브러리 예제2  (0) 2022.07.02
블로그 이미지

Link2Me

,