HTTPS 통신을 구현하다가 아래와 같은 오류를 보게 되었습니다. "CertPathValidatorException: Trust anchor for certification path not found" 이것저것 찾다보니까 몰랐던 내용...

HTTPS 통신을 구현하다가 아래와 같은 오류를 보게 되었습니다.

"CertPathValidatorException: Trust anchor for certification path not found"

이것저것 찾다보니까 몰랐던 내용들도 많고 해서

해당 오류에 대한 해결 방법을 간단하게 정리해보려고 합니다.


 

 

 

 

 

 

1. 원인

HTTP와 다르게 HTTPS는 SSL(Secure Socket Layer)이 더해져서 보안이 강화된 프로토콜입니다.

SSL을 활용한 통신은 클라이언트와 서버 간의 암호화된 통신을 하면서 외부로부터 보호하는 역할을 합니다.


HTTPS 통신을 하기 위해서는 SSL 인증서에 대해 확인하는 동작이 필요하며,

이는 클라이언트측 OkHttpClient을 통해 적용이 가능합니다.

이를 별도로 구현하지 않았을 경우 위와 같은 오류를 볼 수 있습니다..!


해당 오류없이 HTTPS 통신을 구현하는 방법은 크게 두가지로 나눠서 볼 수 있습니다.

 - 인증서 없이 HTTPS 연결 (안전하지 않은 방법)

 - 인증서를 활용하여 HTTPS 연결 (권장)

 


 

 

 

 

 

2. 인증서 없이 HTTPS 연결

https:// 도메인에 접속했을 때 아래와 같은 화면을 보신적이 있을겁니다!

이때 "위험을 감수하고 계속 진행" 또는 "안전하지 않음으로 이동"으로 접속하는 방법과 비슷하게 

인증서 없이 HTTPS 연결하는 방법으로보면 좋을 것 같습니다.



Retrofit 생성

fun getInstance(context: Context): Retrofit {
if (instance == null) {
// 안전하지 않음으로 HTTPS 연결 시도
val okHttpClient = unSafeOkHttpClient()

instance = Retrofit.Builder()
.baseUrl(serverUrl)
.addConverterFactory(GsonConverterFactory.create(gson))
.client(okHttpClient)
.build()
}
return instance!!
}


별도의 SSL 인증서 없이 우회하기

private fun unSafeOkHttpClient(): OkHttpClient {
val okHttpClient = OkHttpClient.Builder()
try {
val trustAllCerts: Array<TrustManager> = arrayOf(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
})

val sslContext = SSLContext.getInstance("SSL")
sslContext.init(null, trustAllCerts, SecureRandom())

if (trustAllCerts.isNotEmpty() && trustAllCerts.first() is X509TrustManager) {
okHttpClient.sslSocketFactory(sslContext.socketFactory, trustAllCerts.first() as X509TrustManager)
okHttpClient.hostnameVerifier { _, _ -> true }
}
} catch (e: Exception) {
e.printStackTrace()
}
return okHttpClient.build()
}

 

 

 

 

 

 

3. 인증서를 통해 HTTPS 접속하기

먼저 연결하고자하는 도메인의 인증서를 다운받아야합니다.

아래 화면과 같이 "인증서 보기"에서 다운받을 수 있습니다.




인증서 보기가 따로 없다면 아래 그림과 같이 주소창 왼쪽에 배치된 자물쇠 또는 뭔가 누를 수 있는 버튼을 클릭해서 다운 받을 수 있습니다.




다운받은 인증서를 raw에 배치시켜줍니다.

 

 


Retrofit 생성

fun getInstance(context: Context): Retrofit {
if (instance == null) {
// SSL 인증서로 HTTPS 연결 시도
val okHttpClient = sslOkHttpClient(context)

instance = Retrofit.Builder()
.baseUrl(serverUrl)
.addConverterFactory(GsonConverterFactory.create(gson))
.client(okHttpClient)
.build()
}
return instance!!
}


SSL 인증서 확인

private fun sslOkHttpClient(context: Context): OkHttpClient {
val okHttpClient = OkHttpClient.Builder()
try {
val cf = CertificateFactory.getInstance("X.509")
val caInput = context.resources.openRawResource(R.raw.my_cert)
var ca: Certificate? = null
try {
ca = cf.generateCertificate(caInput)
Log.d("[HTTP]", "ca=" + (ca as X509Certificate?)!!.subjectDN)
} catch (e: CertificateException) {
e.printStackTrace()
} finally {
caInput.close()
}

if (ca != null) {
val keyStoreType = KeyStore.getDefaultType()
val keyStore = KeyStore.getInstance(keyStoreType)
keyStore.load(null, null)
keyStore.setCertificateEntry("ca", ca)

val tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm()
val tmf = TrustManagerFactory.getInstance(tmfAlgorithm)
tmf.init(keyStore)

val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, tmf.trustManagers, null)

if (tmf.trustManagers.isNotEmpty() && tmf.trustManagers.first() is X509TrustManager) {
okHttpClient.sslSocketFactory(sslContext.socketFactory, tmf.trustManagers.first() as X509TrustManager)
okHttpClient.hostnameVerifier { _, _ -> true }
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return okHttpClient.build()
}

 

 

 

 

 

 

 

3. 끝으로...(추가내용)

찾아보다가 해당 오류가 꼭 위와 같은이유로만 출력되는 오류는 아니였네요.

위의 내용은 제가 겪고 해결한 방법을 정리한 것이니 참고만 해주시고,

자세한 내용은 아래 공식문서를 확인해주세요!

Android Developer : 서버 인증서 확인과 관련된 일반적인 문제

 

그리고..

인증서 없이 연결했을 때와 인증서를 포함하여 연결했을 때의 차이를 확인하고싶었습니다.

대부분 인증서를 포함시키는걸 권장한다고는하는데...

정확히 어떤 차이가 생기는지를 알고 싶었지만 찾지 못했네요ㅠㅠ

혹시 차이를 알고 계신분이 있다면 댓글로 공유 부탁드립니다!!!

 







[Reference]

stackOverflow

Retrofit2로 SSL을 이용한 HTTPS 통신하기(Okhttp3)

HTTPS 통신 원리 쉽게 이해하기




     위와 같이 EditText를 사용하다보면 키보드가 올라가면서 화면이 밀리게 되는데요, windowSoftInputMode 속성으로 소프트 키보드가 출력될 때 화면 변화를 설정할 수도 있지만 또 다른 방법으로 내용을 편집할 수 있는 입력 창을...

위와 같이 EditText를 사용하다보면 키보드가 올라가면서 화면이 밀리게 되는데요,

windowSoftInputMode 속성으로 소프트 키보드가 출력될 때 화면 변화를 설정할 수도 있지만

또 다른 방법으로 내용을 편집할 수 있는 입력 창을 따로 띄워주는 Custom EditText을 

활용하는 것도 방법이 될 수 있을 것 같습니다.

 

 

 

잠깐 시작하기 전에

그래서 어떤 Custom EditText인지 사진으로 보는게 확실하기 때문에 결과 화면부터 보고 갑시다.


 




 

 

 

1. Custom EditText : InputTextView

Custom EditText 네이밍은 InputTextView로 했습니다.

InputTextView라는 클래스를 생성하고 아래와 같이 작성합니다.

 

class InputTextView(context: Context, attrs: AttributeSet) : LinearLayout(context, attrs) {
private lateinit var inputTextViewBinding: ViewInputTextBinding

init {
initView()
}

private fun initView() {
val layoutInflater = LayoutInflater.from(context)
inputTextViewBinding = ViewInputTextBinding.inflate(layoutInflater, this, true)

setOnClickListener { v: View? -> showEditDialog() }
}

private fun showEditDialog() {
val builder = AlertDialog.Builder(context)
val alertDialog = builder.create()
alertDialog.window?.setBackgroundDrawableResource(R.color.transparent)

val layoutInflater = LayoutInflater.from(context)
val inputTextDialogBinding = DialogInputTextBinding.inflate(layoutInflater, this, false)

inputTextDialogBinding.etText.setText(inputTextViewBinding.tvText.text)
inputTextDialogBinding.btnOk.setOnClickListener { v: View? ->
setText(inputTextDialogBinding.etText.text.toString())
alertDialog.dismiss()
}
inputTextDialogBinding.btnCancel.setOnClickListener { v: View? ->
alertDialog.dismiss()
}
alertDialog.setView(inputTextDialogBinding.root)
alertDialog.show()
}

fun setText(text: String) {
inputTextViewBinding.tvText.text = text
}

fun getText(): String {
return inputTextViewBinding.tvText.text.toString()
}
}

 

 

(참고)

R.color.transparent는 values 경로에 있는 colors.xml에 아래와 같이 추가해주면 됩니다.

colors.xml

<resources>
<color name="transparent">#00000000</color>
...
</resources>

 

 

 

 

 

 

 

2. InputTextView : layout

InputTextView에 대한 layout을 아래와 같이 작성합니다.


view_input_text.xml

<layout xmlns:android="http://schemas.android.com/apk/res/android">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<TextView
android:id="@+id/tvText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="3dp"
android:maxLines="1"
android:paddingStart="2dp"
android:paddingEnd="2dp"
android:singleLine="true"
android:textColor="@color/black"
android:textSize="17dp" />

<TextView
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_margin="3dp"
android:background="#222222" />
</LinearLayout>
</layout>

 

dialog_input_text.xml

<layout>

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center">

<LinearLayout
android:layout_width="350dp"
android:layout_height="wrap_content"
android:background="#888888"
android:gravity="center"
android:orientation="vertical">

<TextView
android:id="@+id/tvTitle"
android:layout_width="match_parent"
android:layout_height="40dp"
android:background="#000000"
android:gravity="center"
android:text="편집"
android:textColor="@color/white"
android:textSize="20sp"
android:textStyle="bold" />

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="10dp"
android:orientation="vertical">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingStart="10dp"
android:paddingEnd="10dp">

<EditText
android:id="@+id/etText"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_margin="3dp"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp"
android:gravity="center"
android:selectAllOnFocus="true"
android:textSize="17sp" />
</LinearLayout>

<LinearLayout
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginTop="25dp"
android:orientation="horizontal">

<Space
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="2" />

<Button
android:id="@+id/btnCancel"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="3dp"
android:layout_weight="1"
android:text="취소"
android:textAllCaps="false"
android:textSize="17sp" />

<Button
android:id="@+id/btnOk"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="3dp"
android:layout_weight="1"
android:text="확인"
android:textAllCaps="false"
android:textSize="17sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>
</layout>

 

 

 

 

 

 

 

3. InputTextView 사용

Custom EditText가 준비되었으니 원하시는 layout에 아래와 같이 정의해서 사용해주시면 됩니다.

<com.example.customedittext.view.InputTextView
android:id="@+id/inputText"
android:layout_width="match_parent"
android:layout_height="wrap_content" />

 

 


 

 

 

 

 

FTP(File Transfer Protocol)를 활용해서 파일 업로드 및 다운로드 예제를 정리하려고 합니다.         1. FTP 사전 준비 AndroidManifest 권한 추가  (파일 읽고/쓰기 권한은 MainActivity 에서도 따로...

FTP(File Transfer Protocol)를 활용해서 파일 업로드 및 다운로드 예제를 정리하려고 합니다.



 

 

 

 

1. FTP 사전 준비

AndroidManifest 권한 추가 

(파일 읽고/쓰기 권한은 MainActivity 에서도 따로 권한 요청을 해주시기 바랍니다.)

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

<application
android:requestLegacyExternalStorage="true"

...
</application>

 

Gradle 라이브러리 추가

implementation("commons-net:commons-net:3.6")

 

 

 

 

 

 

 

2. FTPUtil 클래스

FTPUtil 이름을 가진 object class를 생성하였습니다.

코틀린 코루틴을 활용하여 파일 업로드 및 다운로드 동작을 구현하였습니다.

 

사용하실 때 FTP 설정을 빠트리면 안됩니다! (경험담..)

 

object FTPUtil {
private val coroutineScope = CoroutineScope(Dispatchers.Default)

private var clientFTP = FTPClient()

// Input your default setting
private var host = ""
private var user = ""
private var password = ""
private var port = 21

fun changeFTPSetting(host: String, user: String, password: String, port: Int) {
this.host = host
this.user = user
this.password = password
this.port = port
}

suspend fun downloadFile(srcFilePath: String, desFilePath: String): Boolean {
return coroutineScope.async {
var result = false
try {
if (connect()) {
val fileOutputStream = FileOutputStream(desFilePath)
result = clientFTP.retrieveFile(srcFilePath, fileOutputStream)
fileOutputStream.close()
}
} catch (e: Exception) {
println("[FTPUtil] e $e")
e.printStackTrace()
}

disconnect()

println("[FTPUtil] downloadFile result : $result")

result
}.await()
}

suspend fun uploadFile(srcFilePath: String, desFileName: String, desDirectory: String): Boolean {
return coroutineScope.async {
var result = false
try {
if (connect()) {
val fileInputStream = FileInputStream(srcFilePath)
if (changeDirectory(desDirectory)) {
result = clientFTP.storeFile(desFileName, fileInputStream)
}
fileInputStream.close()
}
} catch (e: Exception) {
println("[FTPUtil] e $e")
e.printStackTrace()
}

disconnect()

println("[FTPUtil] uploadFile $result")

result
}.await()
}

suspend fun getFiles(directory: String): ArrayList<FTPFile> {
return coroutineScope.async {
val fileList = ArrayList<FTPFile>()
try {
if (connect()) {
val ftpFiles = clientFTP.listFiles(directory)
for (file in ftpFiles) fileList.add(file)
}
} catch (e: Exception) {
println("[FTPUtil] e $e")
e.printStackTrace()
}

disconnect()

println("[FTPUtil] getFiles size ${fileList.size}")

fileList
}.await()
}

private fun connect(): Boolean {
clientFTP.controlEncoding = "euc-kr"
clientFTP.connect(host, port)
if (!FTPReply.isPositiveCompletion(clientFTP.replyCode)) {
println("[FTPUtil] $host, $port connect fail")
return false
}

if (!clientFTP.login(user, password)) {
println("[FTPUtil] $user, $password login fail")
return false
}

clientFTP.enterLocalPassiveMode()

println("[FTPUtil] FTP connected : host($host), user($user), password($password), port($port)")

return true
}

private fun disconnect(): Boolean {
try {
clientFTP.logout()
clientFTP.disconnect()
} catch (e: Exception) {
e.printStackTrace()
println("[FTPUtil] e $e")
}

return true
}

private fun changeDirectory(directory: String): Boolean {
try {
clientFTP.changeWorkingDirectory(directory)
return true
} catch (e: Exception) {
e.printStackTrace()
println("[FTPUtil] e $e")
}
return false
}

private fun getCurrentDirectory(): String {
return try {
clientFTP.printWorkingDirectory()
} catch (e: Exception) {
""
}
}
}

 

 

 

 

 

 

 

3. FTP 업로드 / 다운로드 사용 예시

Upload, Download, GetFiles 버튼을 생성하여 아래와 같이 동작시켜 확인할 수 있었습니다. 

 

class MainActivity : AppCompatActivity() {

private lateinit var binding: ActivityMainBinding

private val coroutineScope = CoroutineScope(Dispatchers.Default)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_main)

permissionCheck()

binding.btnUpload.setOnClickListener {
coroutineScope.launch {
val srcFilePath = "sdcard/Download/test.txt"
val desFileName = "test.txt"
val desDirectory = "/user/tempDir"
FTPUtil.uploadFile(srcFilePath, desFileName, desDirectory)
}
}

binding.btnDownload.setOnClickListener {
coroutineScope.launch {
val srcFilePath = "/user/tempDir/test.txt"
val desFilePath = "sdcard/Download/test.txt"
FTPUtil.downloadFile(srcFilePath, desFilePath)
}
}

binding.btnGetFiles.setOnClickListener {
coroutineScope.launch {
val directory = "/user/tempDir"
val files = FTPUtil.getFiles(directory)
for (file in files) {
println("getFiles : ${file.name}")
}
}
}
}

...