구글 검색하면 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(10, 10, 10, 0),
child: TextFormField(
controller: _userIDController,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'UserID',
),
focusNode: userIDFocusNode,
textInputAction: TextInputAction.next,
),
),
SizedBox(
height: 30,
),
Container(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 0),
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(50, 0, 50, 0),
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 |