웹뷰 형태로
PASS 인증을 할 수 없을까?
안녕하세요. 앱개발지식나눔입니다.
오늘은 PASS를 앱에서 직접 연결하는 것이 아닌 웹앱 페이지 속에서 PASS를 연동하는 방법에 대해서 설명할 예정입니다.
1. InAppWebView URL: https://inappwebview.dev/docs/in-app-webview/basic-usage/
2. PASS 기술 문의: https://www.dreamsecurity.com/customer/contact.php
3. PASS 개발자 센터: https://developers.passlogin.com/docs/develop/web
4. 플러터 PASS 패키지(앱-PASS 직접 연결): https://pub.dev/packages/iamport_flutter/changelog
글에 들어가기 앞서서(필독)
PASS를 연결하는 방법은 두 가지가 있습니다. 앱에서 직접 PASS를 실행하는 방법과 웹앱을 통해서 웹사이트를 들어간 다음 거기서 PASS를 실행하는 케이스입니다.
첫 번째 방법은 위에 있는 4번 링크를 따라가 주세요. 쉽게 연동하여 사용할 수 있을 것입니다. 하지만 개인마다 사정으로 꼭 웹페이지를 들어간 다음 거기서 PASS를 실행해야 하는 케이스가 있습니다. 오늘은 이것에 대해서 설명할 예정입니다.
일단 플러터에서 웹페이지를 실행하는 방법은 여러 가지가 있습니다.
대표적으로 InAppWebView 패키지, WebView 패키지, url_launcher 패키지가 있습니다. 저는 자바스크립트 채널이 필요가 없다면 InAppWebView 패키지 중에서 ChromeSafariBrowser와 url_launcher를 추천합니다. 크롬, 사파리 브라우저를 사용하기 때문에 개발자가 일일이 조건을 지정할 필요가 없습니다. 그 말인즉슨 PASS 연동을 위해서 따로 설정해야 할 것이 없다는 의미입니다. 난이도도 쉽고 개발 코드도 적습니다.
자세한 내용은 아래 링크를 참고해주세요.
[Flutter] - [플러터] 대표 웹뷰 패키지 소개 및 장단점
아마 자바스크립트 채널도 써야 하면서 꼭 InAppWebView 패키지 중에서 일반 InAppWebView 클래스만 사용해야겠다는 사람만 남았을 거라 생각이 듭니다. 참! WebView 패키지는 새창 열기가 불가능해서 PASS 사용에 적합한 패키지가 아닙니다.
그리고 이 글을 읽기 전에 아래에 있는 글을 먼저 읽고 와주세요. PASS 인증의 구조를 이해해야 PASS를 InAppWebView로 구현할 수 있습니다.
그리고 InAppWebView 새로운 창을 여는 방법에 대해서도 알고 있어야 합니다. 따라서 아래 글도 읽고 이 글을 읽는 것을 추천드립니다.
[Flutter] - [플러터] InAppWebView에서 새 창 여는 법
그러면 이제 진짜로 시작해볼까요??
암호화 키인 req_info를 크롤링하기
위 글에서 PASS 인증을 위해선 4가지가 필수라고 했습니다. PASS URL, req_info, rtn_url, cpid 중에서 하나라도 틀리면 PASS가 열리지 않습니다. 따라서 첫 번째 해야 할 것은 실시간으로 변하는 req_info를 크롤링하여 변수에 저장해야 합니다.
그러면 크롤링 순서를 알아보겠습니다.
- 해당 페이지 html 코드를 모두 크롤링하기
- 끌고 온 html 코드 중에서 req_info 부분만 파싱 하기
- 파싱 한 값을 변수(res)에 저장하기
req_info를 얻기 위해서는 해당 페이지 html 코드를 모두 끌고 오는 부분은 InAppWebView에서 onloadStop 메서드에서 실행됩니다. onloadstop은 웹 페이지 로딩이 끝나면 실행되는 메소드입니다. 꼭 이 메소드에서 html 파싱을 진행해야 합니다. 그럼 파싱 하는 html 코드를 살펴봅시다.
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:html/dom.dart' as Document;
import 'dart:io';
import 'package:html/parser.dart' as parser;
import 'package:cp949/cp949.dart' as cp949;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:url_launcher/url_launcher.dart';
onLoadStop: (controller, url) async {
///PASS에 들어갈시 req_info 암호화 키를 추출하는 부분.
List<Document.Element> keywordElements = await extractHTMLSource(controller);
var link;
for (var element in keywordElements) {
link = element.querySelector('input[name=req_info]');
res = link.outerHtml.split("value=").last.split(">")[0];
res = res.replaceAll("\"", "");
res = Uri.decodeFull(res);
}
pullToRefreshController.endRefreshing();
setState(() {
this.url = url.toString();
urlController.text = this.url;
});
},
해당 페이지 중에서 HTML body만 뽑는 extractHTMLSource 함수는 아래와 같이 구성되어있습니다.
Future<List> extractHTMLSource(var controller)async{
var html = await controller.evaluateJavascript(
source:
"window.document.getElementsByTagName('html')[0].outerHTML;");
Document.Document document = parser.parse(html);
List<Document.Element> keywordElements =
document.querySelectorAll('body');
return keywordElements;
}
왜 이렇게 파싱을 했을까요? 그 대답은 해당 페이지 html 코드에 있습니다.
<body>
<form name="form1" method=post>
<article class="article-index">
<input type=hidden name=req_info value=test_secret_key_abc12345==>
<input type=hidden name=rtn_url value=https://www.testpage.com>
<input type=hidden name=cpid value=testcompany>
<button type="button" onclick="openNewWindow(a, 'b', 'c', 'd', '', 'testcode11', 'app')" name="adult" class="button-blue-big color-white font-weight-bold font-size-normal-more mx-auto button-0">인증</button>
</article>
<html>
</html>
</form>
</body>
해당 req_info, rtn_url, cpid는 body - form 구조 안에 있습니다.(인증 후 리다이렉트 주소 - rtn_url, PASS 인증 회사 고유 아이디 - cpid는 고정값이니 생략하겠습니다.) 따라서 파싱을 할 때 먼저 extractHTMLSource에서 body만 파싱 했습니다. 그리고 querySelector로 input[name=req_info] 만 파싱했습니다. 그러면 'value=test_secret_key_abc12345==>'만 남는데 이것도 String 클래스의 메서드인 replaceAll로 모두 제거하여 알맹이 req_info만 남습니다.
이 알맹이 req_info를 담은 res 변수는 encodingRequestURL 함수로 인해 rtn_url, cpid와 같이 동봉되어 새로운 창에서 POST 됩니다.
var outputAsUint8List =
encodingRequestURL(requestURL: res);
List encodingRequestURL({String requestURL}) {
var stringTemp =
'req_info=$requestURL&rtn_url=https://www.test_redirectionpage.com&cpid=testcompany';
stringTemp = stringTemp.replaceAll('+', '%2b');
var result = encodingUINT(data: stringTemp);
return result;
}
initialUrlRequest: URLRequest(
url: Uri.parse(
passURL),
method: 'POST',
headers: {
'Content-Type':
'application/x-www-form-urlencoded'
},
body: outputAsUint8List,
),
여기서 중요한 점은 stringTemp 변수의 형태입니다. 원래는 req_info에 '+'가 있었지만 PASS 측에서 이것을 공백으로 받아들였습니다. 그 이유는 우리는 암호화 방식이 유니코드 형식이지만 PASS 쪽에서는 EUC-KR으로 디코딩해서 생긴 결과였습니다. 따라서 '+'가 연산자 오버라이딩되지 않게 '+'의 유니코드인 '$%2b'로 살짝 바꾸어서 동봉하였습니다.
그리고 body에 encodingRequestURL로 형식화한 req_info, rtn_url, cpid를 함께 넣었습니다. 마지막으로 PASS로 Post 할 때 파일 전송 형식인 'x-www-form-urlencoded'로 헤더를 넣었습니다. 꼭 이렇게 형식을 작성해야 합니다.
결론
정리하면 순서는 다음과 같습니다.
1. onLoadStop에서 해당 html 코드를 크롤링한다.
2. 크롤링 함수를 통해 새로고침마다 변하는 req_info의 값을 파싱 한다.
3. onCreateWindow 메서드에 showDialog를 구성한다.
4. 구성된 showDialog에 새로운 InAppWebView 클래스를 넣는다.
5. 새 창 InAppWebView의 initialURLRequest에 url과 body에 rtn_url, cpid, req_info를 같이 넣어 POST 한다.
전체 소스코드
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:html/dom.dart' as Document;
import 'dart:io';
import 'package:html/parser.dart' as parser;
import 'package:cp949/cp949.dart' as cp949;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:url_launcher/url_launcher.dart';
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
if (Platform.isAndroid) {
await AndroidInAppWebViewController.setWebContentsDebuggingEnabled(true);
}
runApp(MaterialApp(
home: MyApp(),
));
}
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => new _MyAppState();
}
class _MyAppState extends State<MyApp> {
final GlobalKey webViewKey = GlobalKey();
InAppWebViewController webViewController;
PullToRefreshController pullToRefreshController;
String url = "";
double progress = 0;
final urlController = TextEditingController();
String res;
InAppWebViewGroupOptions options;
String id;
String URL;
String passURL;
String afterPASSRedirectURL;
@override
void initState() {
super.initState();
id = 'testcode11';
URL = 'https://www.mainURL.com;
passURL = 'https://dev.mobile-ok.com/popup/common/hscert.jsp';
afterPASSRedirectURL = 'https://www.redirectionURL.com;
options = InAppWebViewGroupOptions(
crossPlatform: InAppWebViewOptions(
javaScriptEnabled: true,
javaScriptCanOpenWindowsAutomatically: true,
useShouldOverrideUrlLoading: true,
mediaPlaybackRequiresUserGesture: false,
),
android: AndroidInAppWebViewOptions(
useHybridComposition: true,
),
ios: IOSInAppWebViewOptions(
allowsInlineMediaPlayback: true,
));
pullToRefreshController = PullToRefreshController(
options: PullToRefreshOptions(
color: Colors.blue,
),
onRefresh: () async {
if (Platform.isAndroid) {
webViewController?.reload();
} else if (Platform.isIOS) {
webViewController?.loadUrl(
urlRequest: URLRequest(url: await webViewController?.getUrl()));
}
},
);
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text("일반 InAppWebView 테스트 코드")),
body: SafeArea(
child: Column(children: <Widget>[
Expanded(
child: Stack(
children: [
InAppWebView(
key: webViewKey,
initialUrlRequest: URLRequest(
url: Uri.parse(
URL)),
initialOptions: options,
pullToRefreshController: pullToRefreshController,
onLoadStart: (controller, url) async {
setState(() {
this.url = url.toString();
urlController.text = this.url;
});
},
onCreateWindow: (controller, action) {
var outputAsUint8List =
encodingRequestURL(requestURL: res);
return showDialog(
barrierDismissible: true,
context: context,
builder: (context) {
return InAppWebView(
initialOptions: options,
pullToRefreshController: pullToRefreshController,
onWebViewCreated: (controller) async {
print(await controller.getProgress());
},
onConsoleMessage: (controller, message) {
print("message is:" + message.message);
},
initialUrlRequest: URLRequest(
url: Uri.parse(
passURL),
method: 'POST',
headers: {
'Content-Type':
'application/x-www-form-urlencoded'
},
body: outputAsUint8List,
),
);
});
},
androidOnPermissionRequest:
(controller, origin, resources) async {
return PermissionRequestResponse(
resources: resources,
action: PermissionRequestResponseAction.GRANT);
},
shouldOverrideUrlLoading:
(controller, navigationAction) async {
var uri = navigationAction.request.url;
if (![
"http",
"https",
"file",
"chrome",
"data",
"javascript",
"about"
].contains(uri.scheme)) {
if (await canLaunch(url)) {
// Launch the App
await launch(
url,
);
// and cancel the request
return NavigationActionPolicy.CANCEL;
}
}
return NavigationActionPolicy.ALLOW;
},
onLoadStop: (controller, url) async {
///PASS에 들어갈시 req_info 암호화 키를 추출하는 부분.
List<Document.Element> keywordElements = await extractHTMLSource(controller);
var link;
for (var element in keywordElements) {
link = element.querySelector('input[name=req_info]');
res = link.outerHtml.split("value=").last.split(">")[0];
res = res.replaceAll("\"", "");
res = Uri.decodeFull(res);
}
pullToRefreshController.endRefreshing();
setState(() {
this.url = url.toString();
urlController.text = this.url;
});
},
onLoadError: (controller, url, code, message) {
pullToRefreshController.endRefreshing();
},
),
],
),
),
]))),
);
}
}
List encodingRequestURL({String requestURL}) {
var stringTemp =
'req_info=$requestURL&rtn_url=https://www.redirectionPageURL.php&cpid=passID';
stringTemp = stringTemp.replaceAll('+', '%2b');
var result = encodingUINT(data: stringTemp);
return result;
}
List encodingUINT({String data}) {
var temp = Uint8List.fromList(utf8.encode(data));
String s = new String.fromCharCodes(temp);
var outputAsUint8List = new Uint8List.fromList(s.codeUnits);
return outputAsUint8List;
}
Future<List> extractHTMLSource(var controller)async{
var html = await controller.evaluateJavascript(
source:
"window.document.getElementsByTagName('html')[0].outerHTML;");
Document.Document document = parser.parse(html);
List<Document.Element> keywordElements =
document.querySelectorAll('body');
return keywordElements;
}
이상 앱개발지식나눔이였습니다.
'Flutter' 카테고리의 다른 글
플러터 빌드 에러 시 해결법 (0) | 2022.12.17 |
---|---|
[플러터] InAppWebView에서 새 창 여는 법 (0) | 2022.03.06 |
[플러터] 대표 웹뷰 패키지 소개 및 장단점 (2) | 2022.03.03 |
[플러터] 부트페이 패키지 연동시 주의사항 (0) | 2022.03.03 |