Private
Public Access
1
0

Backend: UI: Implements authentication.

This commit is contained in:
2024-03-02 00:38:17 -08:00
parent 222ec84855
commit d0be011053
12 changed files with 193 additions and 26 deletions

View File

@@ -5,8 +5,10 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import net.buzzert.kordophone.backend.db.CachedChatDatabase 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.ChatRepository
import net.buzzert.kordophone.backend.server.RetrofitAPIClient import net.buzzert.kordophone.backend.server.RetrofitAPIClient
import net.buzzert.kordophonedroid.ui.shared.ServerConfigRepository
import java.net.URL import java.net.URL
import javax.inject.Singleton import javax.inject.Singleton
@@ -15,9 +17,21 @@ import javax.inject.Singleton
object AppModule { object AppModule {
@Singleton @Singleton
@Provides @Provides
fun provideChatRepository(): ChatRepository { fun provideChatRepository(configRepository: ServerConfigRepository): ChatRepository {
val host = "http://192.168.1.123:5738" val serverConfig = configRepository.serverConfig.value
val client = RetrofitAPIClient(URL(host)) 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() val database = CachedChatDatabase.liveDatabase()
return ChatRepository(client, database) return ChatRepository(client, database)
} }

View File

@@ -128,7 +128,9 @@ fun ConversationListItem(
) { ) {
Text( Text(
name, 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)) Spacer(Modifier.weight(1f))
@@ -140,7 +142,8 @@ fun ConversationListItem(
.toLocalDateTime() .toLocalDateTime()
), ),
modifier = Modifier.align(Alignment.CenterVertically), 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)) Spacer(Modifier.width(horizontalPadding))
@@ -172,7 +175,7 @@ fun UnreadIndicator(size: Dp, modifier: Modifier = Modifier) {
@Composable @Composable
fun ConversationListItemPreview() { fun ConversationListItemPreview() {
Column(modifier = Modifier.background(MaterialTheme.colors.background)) { 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) {}
} }
} }

View File

@@ -12,9 +12,12 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.buzzert.kordophone.backend.model.Conversation 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.ChatRepository
import net.buzzert.kordophone.backend.server.REPO_LOG import net.buzzert.kordophone.backend.server.REPO_LOG
import net.buzzert.kordophone.backend.server.RetrofitAPIClient
import net.buzzert.kordophonedroid.ui.shared.ServerConfigRepository import net.buzzert.kordophonedroid.ui.shared.ServerConfigRepository
import java.net.URL
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
@@ -33,24 +36,32 @@ class ConversationListViewModel @Inject constructor(
} }
init { init {
// TODO: Is this the best place to put these? // Watch for config changes
// TODO: Need error handling (exceptions thrown below)
// Perform initial sync
viewModelScope.launch { viewModelScope.launch {
serverConfigRepository.serverConfig.collect { config ->
Log.d(CL_VM_LOG, "Got settings change.")
// 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) { withContext(Dispatchers.IO) {
chatRepository.synchronize() chatRepository.synchronize()
} }
} }
// Watch for config changes
viewModelScope.launch {
serverConfigRepository.serverConfig.collect {
Log.d(CL_VM_LOG, "Got settings change.")
// TODO: Respond to this change.
// Should probably just forward this directly to ChatRepository.
}
} }
// Start watching for updates // Start watching for updates

View File

@@ -45,6 +45,8 @@ class MessageListViewModel @Inject constructor(
private val pendingMessages: MutableStateFlow<List<OutgoingMessage>> = MutableStateFlow(listOf()) private val pendingMessages: MutableStateFlow<List<OutgoingMessage>> = MutableStateFlow(listOf())
init { init {
// TODO: Need to handle settings changes here!!
viewModelScope.launch { viewModelScope.launch {
// Remove pending message after message is delivered. // Remove pending message after message is delivered.
// By now, the repository should've committed this to the store. // By now, the repository should've committed this to the store.

View File

@@ -11,6 +11,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.lightColors import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.text.style.TextOverflow
private val DarkColorPalette = darkColors( private val DarkColorPalette = darkColors(
primary = Purple200, primary = Purple200,
@@ -56,7 +57,13 @@ fun KordophoneTheme(
@Composable @Composable
fun KordophoneTopAppBar(title: String, backAction: () -> Unit) { fun KordophoneTopAppBar(title: String, backAction: () -> Unit) {
TopAppBar( TopAppBar(
title = { Text(title) }, title = {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = { navigationIcon = {
IconButton(onClick = backAction) { IconButton(onClick = backAction) {
Icon(Icons.Filled.ArrowBack, null) Icon(Icons.Filled.ArrowBack, null)

View File

@@ -2,5 +2,6 @@
<network-security-config> <network-security-config>
<domain-config cleartextTrafficPermitted="true"> <domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">192.168.1.123</domain> <domain includeSubdomains="true">192.168.1.123</domain>
<domain includeSubdomains="true">tesseract.localdomain</domain>
</domain-config> </domain-config>
</network-security-config> </network-security-config>

View File

@@ -46,6 +46,7 @@ dependencies {
implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.google.code.gson:gson:2.9.0' implementation 'com.google.code.gson:gson:2.9.0'
implementation 'com.auth0.android:jwtdecode:2.0.2'
// Realm // Realm
implementation "io.realm.kotlin:library-base:${realm_version}" implementation "io.realm.kotlin:library-base:${realm_version}"

View File

@@ -62,7 +62,7 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) {
// Flow for watching for message changes for a given conversation // Flow for watching for message changes for a given conversation
fun messagesChanged(conversation: ModelConversation): Flow<List<ModelMessage>> { fun messagesChanged(conversation: ModelConversation): Flow<List<ModelMessage>> {
return realm.query(Conversation::class, "guid == '${conversation.guid}'") return realm.query(Conversation::class, "guid == $0", conversation.guid)
.find() .find()
.first() .first()
.messages .messages
@@ -142,7 +142,7 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) {
} }
private fun getManagedConversationByGuid(guid: GUID): Conversation { private fun getManagedConversationByGuid(guid: GUID): Conversation {
return realm.query(Conversation::class, "guid == '$guid'") return realm.query(Conversation::class, "guid == $0", guid)
.find() .find()
.first() .first()
} }

View File

@@ -1,12 +1,19 @@
package net.buzzert.kordophone.backend.server package net.buzzert.kordophone.backend.server
import com.google.gson.Gson
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import okhttp3.WebSocket import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.create
import java.net.URL import java.net.URL
interface APIClient { interface APIClient {
@@ -18,12 +25,86 @@ interface APIClient {
): WebSocket ): 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() private val retrofit: Retrofit = Retrofit.Builder()
.baseUrl(baseURL) .baseUrl(baseURL)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.build() .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 { override fun getAPIInterface(): APIInterface {
return retrofit.create(APIInterface::class.java) return retrofit.create(APIInterface::class.java)
} }

View File

@@ -1,5 +1,6 @@
package net.buzzert.kordophone.backend.server package net.buzzert.kordophone.backend.server
import com.auth0.android.jwt.JWT
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import net.buzzert.kordophone.backend.model.Conversation import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophone.backend.model.GUID import net.buzzert.kordophone.backend.model.GUID
@@ -30,6 +31,23 @@ data class SendMessageResponse(
val sentMessageGUID: String, 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 { interface APIInterface {
@GET("/version") @GET("/version")
suspend fun getVersion(): ResponseBody suspend fun getVersion(): ResponseBody
@@ -50,6 +68,9 @@ interface APIInterface {
@POST("/markConversation") @POST("/markConversation")
suspend fun markConversation(@Query("guid") conversationGUID: String): Response<Void> suspend fun markConversation(@Query("guid") conversationGUID: String): Response<Void>
@POST("/authenticate")
suspend fun authenticate(@Body request: AuthenticationRequest): Response<AuthenticationResponse>
} }
class ResponseDecodeError(val response: ResponseBody): Exception(response.string()) class ResponseDecodeError(val response: ResponseBody): Exception(response.string())

View File

@@ -32,7 +32,7 @@ const val REPO_LOG: String = "ChatRepository"
const val CONVERSATION_MESSAGE_SYNC_COUNT = 10 const val CONVERSATION_MESSAGE_SYNC_COUNT = 10
class ChatRepository( class ChatRepository(
private val apiClient: APIClient, apiClient: APIClient,
private val database: CachedChatDatabase, private val database: CachedChatDatabase,
) { ) {
sealed class Error { sealed class Error {
@@ -84,14 +84,26 @@ class ChatRepository(
val requestGuid: GUID, val requestGuid: GUID,
) )
private val apiInterface = apiClient.getAPIInterface() private var apiInterface = apiClient.getAPIInterface()
private val outgoingMessageQueue: ArrayBlockingQueue<OutgoingMessageInfo> = ArrayBlockingQueue(16) private val outgoingMessageQueue: ArrayBlockingQueue<OutgoingMessageInfo> = ArrayBlockingQueue(16)
private var outgoingMessageThread: Thread? = null private var outgoingMessageThread: Thread? = null
private val _messageDeliveredChannel = MutableSharedFlow<MessageDeliveredEvent>() private val _messageDeliveredChannel = MutableSharedFlow<MessageDeliveredEvent>()
private val _errorEncounteredChannel = MutableSharedFlow<Error>() private val _errorEncounteredChannel = MutableSharedFlow<Error>()
private val updateMonitor = UpdateMonitor(apiClient) private var updateMonitor = UpdateMonitor(apiClient)
private var updateWatchJob: Job? = null 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 { suspend fun getVersion(): String {
return apiInterface.getVersion().string() return apiInterface.getVersion().string()
@@ -111,6 +123,7 @@ class ChatRepository(
} }
} }
updateWatchScope = scope
updateMonitor.beginMonitoringUpdates() updateMonitor.beginMonitoringUpdates()
} }

View File

@@ -14,6 +14,8 @@ import net.buzzert.kordophone.backend.model.Message
import net.buzzert.kordophone.backend.model.UpdateItem import net.buzzert.kordophone.backend.model.UpdateItem
import net.buzzert.kordophone.backend.server.APIClient import net.buzzert.kordophone.backend.server.APIClient
import net.buzzert.kordophone.backend.server.APIInterface 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.SendMessageRequest
import net.buzzert.kordophone.backend.server.SendMessageResponse import net.buzzert.kordophone.backend.server.SendMessageResponse
import net.buzzert.kordophone.backend.server.authenticatedWebSocketURL import net.buzzert.kordophone.backend.server.authenticatedWebSocketURL
@@ -257,4 +259,15 @@ class MockServerInterface(private val server: MockServer): APIInterface {
server.markConversationAsRead(conversationGUID) server.markConversationAsRead(conversationGUID)
return Response.success(null) return Response.success(null)
} }
override suspend fun authenticate(request: AuthenticationRequest): Response<AuthenticationResponse> {
// Anything goes!
val response = AuthenticationResponse(
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
"eyJ1c2VybmFtZSI6InRlc3QiLCJleHAiOjE3MDk3OTQ5NjB9." +
"82UcI1gB4eARmgrKwAY6JnbEdWLXou1GWp29scnUhi8"
)
return Response.success(response)
}
} }