From d0be0110535997114c42161ce2b7a2dcbab0d1ad Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 2 Mar 2024 00:38:17 -0800 Subject: [PATCH] Backend: UI: Implements authentication. --- .../net/buzzert/kordophonedroid/AppModule.kt | 20 ++++- .../ConversationListScreen.kt | 9 +- .../ConversationListViewModel.kt | 37 ++++++--- .../ui/messagelist/MessageListViewModel.kt | 2 + .../buzzert/kordophonedroid/ui/theme/Theme.kt | 9 +- .../main/res/xml/network_security_config.xml | 1 + backend/build.gradle | 1 + .../backend/db/CachedChatDatabase.kt | 4 +- .../kordophone/backend/server/APIClient.kt | 83 ++++++++++++++++++- .../kordophone/backend/server/APIInterface.kt | 21 +++++ .../backend/server/ChatRepository.kt | 19 ++++- .../buzzert/kordophone/backend/MockServer.kt | 13 +++ 12 files changed, 193 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/net/buzzert/kordophonedroid/AppModule.kt b/app/src/main/java/net/buzzert/kordophonedroid/AppModule.kt index 3669480..39c9a35 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/AppModule.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/AppModule.kt @@ -5,8 +5,10 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import net.buzzert.kordophone.backend.db.CachedChatDatabase +import net.buzzert.kordophone.backend.server.Authentication import net.buzzert.kordophone.backend.server.ChatRepository import net.buzzert.kordophone.backend.server.RetrofitAPIClient +import net.buzzert.kordophonedroid.ui.shared.ServerConfigRepository import java.net.URL import javax.inject.Singleton @@ -15,9 +17,21 @@ import javax.inject.Singleton object AppModule { @Singleton @Provides - fun provideChatRepository(): ChatRepository { - val host = "http://192.168.1.123:5738" - val client = RetrofitAPIClient(URL(host)) + fun provideChatRepository(configRepository: ServerConfigRepository): ChatRepository { + val serverConfig = configRepository.serverConfig.value + val authentication = serverConfig.authentication?.let { + Authentication(it.username, it.password) + } + + // TODO: This is really bad error handling... + val baseURL: URL = try { URL(serverConfig.serverName) } + catch (e: java.net.MalformedURLException) { URL("http://localhost") } + + val client = RetrofitAPIClient( + baseURL = baseURL, + authentication = authentication ?: Authentication("", "") + ) + val database = CachedChatDatabase.liveDatabase() return ChatRepository(client, database) } diff --git a/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListScreen.kt b/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListScreen.kt index cc32fab..226fc3c 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListScreen.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListScreen.kt @@ -128,7 +128,9 @@ fun ConversationListItem( ) { Text( name, - style = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.Bold) + style = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.Bold), + overflow = TextOverflow.Ellipsis, + maxLines = 1, ) Spacer(Modifier.weight(1f)) @@ -140,7 +142,8 @@ fun ConversationListItem( .toLocalDateTime() ), modifier = Modifier.align(Alignment.CenterVertically), - color = MaterialTheme.colors.onBackground.copy(alpha = 0.4f) + color = MaterialTheme.colors.onBackground.copy(alpha = 0.4f), + maxLines = 1, ) Spacer(Modifier.width(horizontalPadding)) @@ -172,7 +175,7 @@ fun UnreadIndicator(size: Dp, modifier: Modifier = Modifier) { @Composable fun ConversationListItemPreview() { Column(modifier = Modifier.background(MaterialTheme.colors.background)) { - ConversationListItem(name = "James Magahern", id = "asdf", lastMessagePreview = "This is a test", date = Date(), isUnread = true) {} + ConversationListItem(name = "James MagahernMagahernMagahernMagahernMagahernMagahernMagahern", id = "asdf", lastMessagePreview = "This is a test", date = Date(), isUnread = true) {} } } diff --git a/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListViewModel.kt b/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListViewModel.kt index 61cee6f..c632302 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListViewModel.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListViewModel.kt @@ -12,9 +12,12 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import net.buzzert.kordophone.backend.model.Conversation +import net.buzzert.kordophone.backend.server.Authentication import net.buzzert.kordophone.backend.server.ChatRepository import net.buzzert.kordophone.backend.server.REPO_LOG +import net.buzzert.kordophone.backend.server.RetrofitAPIClient import net.buzzert.kordophonedroid.ui.shared.ServerConfigRepository +import java.net.URL import javax.inject.Inject import kotlin.coroutines.CoroutineContext @@ -33,23 +36,31 @@ class ConversationListViewModel @Inject constructor( } init { - // TODO: Is this the best place to put these? - // TODO: Need error handling (exceptions thrown below) - - // Perform initial sync - viewModelScope.launch { - withContext(Dispatchers.IO) { - chatRepository.synchronize() - } - } - // Watch for config changes viewModelScope.launch { - serverConfigRepository.serverConfig.collect { + serverConfigRepository.serverConfig.collect { config -> Log.d(CL_VM_LOG, "Got settings change.") - // TODO: Respond to this change. - // Should probably just forward this directly to ChatRepository. + // Check settings + try { + val baseURL = URL(config.serverName) + val authentication = config.authentication?.let { serverAuth -> + Authentication(serverAuth.username, serverAuth.password) + } ?: throw Error("No authentication data.") + + // Make new APIClient + val apiClient = RetrofitAPIClient(baseURL, authentication) + chatRepository.updateAPIClient(apiClient) + } catch (e: Error) { + Log.e(CL_VM_LOG, "Error re-creating API client for settings change: $e") + } catch (e: java.net.MalformedURLException) { + Log.e(CL_VM_LOG, "Malformed server URL") + } + + // Perform db synchronization + withContext(Dispatchers.IO) { + chatRepository.synchronize() + } } } diff --git a/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListViewModel.kt b/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListViewModel.kt index c1c89cf..08a3a17 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListViewModel.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListViewModel.kt @@ -45,6 +45,8 @@ class MessageListViewModel @Inject constructor( private val pendingMessages: MutableStateFlow> = MutableStateFlow(listOf()) init { + // TODO: Need to handle settings changes here!! + viewModelScope.launch { // Remove pending message after message is delivered. // By now, the repository should've committed this to the store. diff --git a/app/src/main/java/net/buzzert/kordophonedroid/ui/theme/Theme.kt b/app/src/main/java/net/buzzert/kordophonedroid/ui/theme/Theme.kt index cfa39bd..0b08827 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/ui/theme/Theme.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/theme/Theme.kt @@ -11,6 +11,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.lightColors import androidx.compose.runtime.Composable +import androidx.compose.ui.text.style.TextOverflow private val DarkColorPalette = darkColors( primary = Purple200, @@ -56,7 +57,13 @@ fun KordophoneTheme( @Composable fun KordophoneTopAppBar(title: String, backAction: () -> Unit) { TopAppBar( - title = { Text(title) }, + title = { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, navigationIcon = { IconButton(onClick = backAction) { Icon(Icons.Filled.ArrowBack, null) diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 5289581..3faf9f3 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -2,5 +2,6 @@ 192.168.1.123 + tesseract.localdomain \ No newline at end of file diff --git a/backend/build.gradle b/backend/build.gradle index 853d470..ab0b9cf 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -46,6 +46,7 @@ dependencies { implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' implementation 'com.google.code.gson:gson:2.9.0' + implementation 'com.auth0.android:jwtdecode:2.0.2' // Realm implementation "io.realm.kotlin:library-base:${realm_version}" diff --git a/backend/src/main/java/net/buzzert/kordophone/backend/db/CachedChatDatabase.kt b/backend/src/main/java/net/buzzert/kordophone/backend/db/CachedChatDatabase.kt index b54ec26..3fa9405 100644 --- a/backend/src/main/java/net/buzzert/kordophone/backend/db/CachedChatDatabase.kt +++ b/backend/src/main/java/net/buzzert/kordophone/backend/db/CachedChatDatabase.kt @@ -62,7 +62,7 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) { // Flow for watching for message changes for a given conversation fun messagesChanged(conversation: ModelConversation): Flow> { - return realm.query(Conversation::class, "guid == '${conversation.guid}'") + return realm.query(Conversation::class, "guid == $0", conversation.guid) .find() .first() .messages @@ -142,7 +142,7 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) { } private fun getManagedConversationByGuid(guid: GUID): Conversation { - return realm.query(Conversation::class, "guid == '$guid'") + return realm.query(Conversation::class, "guid == $0", guid) .find() .first() } diff --git a/backend/src/main/java/net/buzzert/kordophone/backend/server/APIClient.kt b/backend/src/main/java/net/buzzert/kordophone/backend/server/APIClient.kt index e185792..e82973b 100644 --- a/backend/src/main/java/net/buzzert/kordophone/backend/server/APIClient.kt +++ b/backend/src/main/java/net/buzzert/kordophone/backend/server/APIClient.kt @@ -1,12 +1,19 @@ package net.buzzert.kordophone.backend.server +import com.google.gson.Gson +import kotlinx.coroutines.runBlocking +import okhttp3.Authenticator import okhttp3.HttpUrl +import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.Response +import okhttp3.Route import okhttp3.WebSocket import okhttp3.WebSocketListener import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.create import java.net.URL interface APIClient { @@ -18,12 +25,86 @@ interface APIClient { ): WebSocket } -class RetrofitAPIClient(private val baseURL: URL): APIClient { +data class Authentication ( + val username: String, + val password: String, +) + +class TokenStore(val authentication: Authentication) { + var authenticationToken: String? = null +} + +class AuthenticationInterceptor( + val tokenStore: TokenStore +): Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + // If empty, allow the 401 to occur so we renew our token. + val token = tokenStore.authenticationToken ?: + return chain.proceed(chain.request()) + + val newRequest = chain.request().newBuilder() + .header("Authorization", "Bearer $token") + .build() + + return chain.proceed(newRequest) + } +} + +class TokenAuthenticator( + private val tokenStore: TokenStore, + private val baseURL: URL +) : Authenticator { private val retrofit: Retrofit = Retrofit.Builder() .baseUrl(baseURL) .addConverterFactory(GsonConverterFactory.create()) .build() + private val apiInterface: APIInterface + get() = retrofit.create(APIInterface::class.java) + + override fun authenticate(route: Route?, response: Response): Request? { + // Fetch new token + val request = AuthenticationRequest( + username = tokenStore.authentication.username, + password = tokenStore.authentication.password + ) + + val token = runBlocking { + apiInterface.authenticate(request).body() + } + + if (token == null) { + // Auth failure. + // TODO: How to bubble this up? + return null + } + + // Update token store + tokenStore.authenticationToken = token.serializedToken + + return response.request().newBuilder() + .header("Authorization", "Bearer ${token.serializedToken}") + .build() + } +} + +class RetrofitAPIClient( + private val baseURL: URL, + private val authentication: Authentication, +): APIClient { + private val tokenStore: TokenStore = TokenStore(authentication) + + private val client: OkHttpClient = OkHttpClient.Builder() + .addInterceptor(AuthenticationInterceptor(tokenStore)) + .authenticator(TokenAuthenticator(tokenStore, baseURL)) + .build() + + private val retrofit: Retrofit = Retrofit.Builder() + .client(client) + .baseUrl(baseURL) + .addConverterFactory(GsonConverterFactory.create()) + .build() + override fun getAPIInterface(): APIInterface { return retrofit.create(APIInterface::class.java) } diff --git a/backend/src/main/java/net/buzzert/kordophone/backend/server/APIInterface.kt b/backend/src/main/java/net/buzzert/kordophone/backend/server/APIInterface.kt index e5da4a2..cbe9b3b 100644 --- a/backend/src/main/java/net/buzzert/kordophone/backend/server/APIInterface.kt +++ b/backend/src/main/java/net/buzzert/kordophone/backend/server/APIInterface.kt @@ -1,5 +1,6 @@ package net.buzzert.kordophone.backend.server +import com.auth0.android.jwt.JWT import com.google.gson.annotations.SerializedName import net.buzzert.kordophone.backend.model.Conversation import net.buzzert.kordophone.backend.model.GUID @@ -30,6 +31,23 @@ data class SendMessageResponse( val sentMessageGUID: String, ) +data class AuthenticationRequest( + @SerializedName("username") + val username: String, + + @SerializedName("password") + val password: String, +) + +data class AuthenticationResponse( + @SerializedName("jwt") + val serializedToken: String, +) { + fun decodeToken(): JWT { + return JWT(serializedToken) + } +} + interface APIInterface { @GET("/version") suspend fun getVersion(): ResponseBody @@ -50,6 +68,9 @@ interface APIInterface { @POST("/markConversation") suspend fun markConversation(@Query("guid") conversationGUID: String): Response + + @POST("/authenticate") + suspend fun authenticate(@Body request: AuthenticationRequest): Response } class ResponseDecodeError(val response: ResponseBody): Exception(response.string()) diff --git a/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt b/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt index a164904..50b7e5d 100644 --- a/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt +++ b/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt @@ -32,7 +32,7 @@ const val REPO_LOG: String = "ChatRepository" const val CONVERSATION_MESSAGE_SYNC_COUNT = 10 class ChatRepository( - private val apiClient: APIClient, + apiClient: APIClient, private val database: CachedChatDatabase, ) { sealed class Error { @@ -84,14 +84,26 @@ class ChatRepository( val requestGuid: GUID, ) - private val apiInterface = apiClient.getAPIInterface() + private var apiInterface = apiClient.getAPIInterface() private val outgoingMessageQueue: ArrayBlockingQueue = ArrayBlockingQueue(16) private var outgoingMessageThread: Thread? = null private val _messageDeliveredChannel = MutableSharedFlow() private val _errorEncounteredChannel = MutableSharedFlow() - private val updateMonitor = UpdateMonitor(apiClient) + private var updateMonitor = UpdateMonitor(apiClient) private var updateWatchJob: Job? = null + private var updateWatchScope: CoroutineScope? = null + + fun updateAPIClient(client: APIClient) { + this.apiInterface = client.getAPIInterface() + this.updateMonitor = UpdateMonitor(client) + + // Restart update watch job, if necessary. + if (this.updateWatchJob != null) { + stopWatchingForUpdates() + beginWatchingForUpdates(updateWatchScope!!) + } + } suspend fun getVersion(): String { return apiInterface.getVersion().string() @@ -111,6 +123,7 @@ class ChatRepository( } } + updateWatchScope = scope updateMonitor.beginMonitoringUpdates() } diff --git a/backend/src/test/java/net/buzzert/kordophone/backend/MockServer.kt b/backend/src/test/java/net/buzzert/kordophone/backend/MockServer.kt index e5bcef0..72c9d64 100644 --- a/backend/src/test/java/net/buzzert/kordophone/backend/MockServer.kt +++ b/backend/src/test/java/net/buzzert/kordophone/backend/MockServer.kt @@ -14,6 +14,8 @@ import net.buzzert.kordophone.backend.model.Message import net.buzzert.kordophone.backend.model.UpdateItem import net.buzzert.kordophone.backend.server.APIClient import net.buzzert.kordophone.backend.server.APIInterface +import net.buzzert.kordophone.backend.server.AuthenticationRequest +import net.buzzert.kordophone.backend.server.AuthenticationResponse import net.buzzert.kordophone.backend.server.SendMessageRequest import net.buzzert.kordophone.backend.server.SendMessageResponse import net.buzzert.kordophone.backend.server.authenticatedWebSocketURL @@ -257,4 +259,15 @@ class MockServerInterface(private val server: MockServer): APIInterface { server.markConversationAsRead(conversationGUID) return Response.success(null) } + + override suspend fun authenticate(request: AuthenticationRequest): Response { + // Anything goes! + val response = AuthenticationResponse( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + + "eyJ1c2VybmFtZSI6InRlc3QiLCJleHAiOjE3MDk3OTQ5NjB9." + + "82UcI1gB4eARmgrKwAY6JnbEdWLXou1GWp29scnUhi8" + ) + + return Response.success(response) + } } \ No newline at end of file