diff --git a/README.md b/README.md index b81474c8..0e06e9ed 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,21 @@ Learn programming by thinking. You need to have a PostgreSQL database. -``` +```zshrc brew install postgresql brew services start postgresql@14 +psql -U postgres -c "CREATE DATABASE ivy_learn;" psql -d ivy_learn -c "CREATE USER postgres WITH PASSWORD 'password';" psql -d ivy_learn -c "ALTER USER postgres WITH SUPERUSER;" ``` +**(optional) Drop local database:** + +```zshrc +psql -U postgres -c "DROP DATABASE ivy_learn;" +psql -U postgres -c "CREATE DATABASE ivy_learn;" +``` + **Environment Variables** ```zshrc diff --git a/composeApp/src/commonMain/kotlin/App.kt b/composeApp/src/commonMain/kotlin/App.kt index ba29c12f..d9df427c 100644 --- a/composeApp/src/commonMain/kotlin/App.kt +++ b/composeApp/src/commonMain/kotlin/App.kt @@ -9,6 +9,7 @@ import domain.di.DomainModule import ivy.di.Di import ivy.di.Di.register import ivy.di.SharedModule +import ivy.di.autowire.autoWire import navigation.Navigation import org.jetbrains.compose.ui.tooling.preview.Preview import ui.theme.LearnTheme @@ -30,6 +31,7 @@ fun App() { ) Di.appScope { register { uriHandler } + autoWire(::AppViewModel) } initialized = true } @@ -43,6 +45,9 @@ fun App() { @Composable private fun NavGraph() { + val appViewModel = remember { Di.get() } + appViewModel.Init() + val navigation = remember { Di.get() } Box(modifier = Modifier.fillMaxSize()) { navigation.NavHost() diff --git a/composeApp/src/commonMain/kotlin/AppConfiguration.kt b/composeApp/src/commonMain/kotlin/AppConfiguration.kt index abe61f2d..31341110 100644 --- a/composeApp/src/commonMain/kotlin/AppConfiguration.kt +++ b/composeApp/src/commonMain/kotlin/AppConfiguration.kt @@ -1,3 +1,4 @@ class AppConfiguration { - val fakesEnabled = true + val fakesEnabled = false + val useLocalServer = true } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/AppViewModel.kt b/composeApp/src/commonMain/kotlin/AppViewModel.kt new file mode 100644 index 00000000..aba66cb7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/AppViewModel.kt @@ -0,0 +1,34 @@ +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import domain.SessionManager +import ivy.model.auth.SessionToken +import navigation.Navigation +import ui.screen.home.HomeScreen + +class AppViewModel( + private val sessionManager: SessionManager, + private val platform: Platform, + private val navigation: Navigation, +) { + + @Composable + fun Init() { + LaunchedEffect(Unit) { + redirectLoggedUsers() + } + } + + private suspend fun redirectLoggedUsers() { + if (sessionManager.getSession() != null) { + // already logged + navigation.replaceWith(HomeScreen()) + return + } + + val sessionTokenParam = platform.getUrlParam(IvyConstants.SessionTokenParam) + if (sessionTokenParam != null) { + sessionManager.authenticate(SessionToken(sessionTokenParam)) + navigation.replaceWith(HomeScreen()) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/data/di/DataModule.kt b/composeApp/src/commonMain/kotlin/data/di/DataModule.kt index 9dcad681..379dec9d 100644 --- a/composeApp/src/commonMain/kotlin/data/di/DataModule.kt +++ b/composeApp/src/commonMain/kotlin/data/di/DataModule.kt @@ -6,6 +6,8 @@ import data.LessonRepository import data.LessonRepositoryImpl import data.TopicsRepository import data.fake.FakeLessonRepository +import data.storage.LocalStorage +import data.storage.localStorage import di.bindWithFake import ivy.di.Di import ivy.di.Di.register @@ -25,5 +27,6 @@ object DataModule : Di.Module { } } bindWithFake() + register { localStorage() } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/data/storage/LocalStorage.kt b/composeApp/src/commonMain/kotlin/data/storage/LocalStorage.kt new file mode 100644 index 00000000..d84e04b9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/storage/LocalStorage.kt @@ -0,0 +1,18 @@ +package data.storage + +interface LocalStorage { + suspend fun putString(key: String, value: String) + suspend fun getString(key: String): String? + suspend fun putInt(key: String, value: Int) + suspend fun getInt(key: String): Int? + suspend fun putDouble(key: String, value: Double) + suspend fun getDouble(key: String): Double? + suspend fun putBoolean(key: String, value: Boolean) + suspend fun getBoolean(key: String): Boolean? + + suspend fun remove(key: String) + suspend fun removeAll() + suspend fun keys(): List +} + +expect fun localStorage(): LocalStorage \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/di/AppModule.kt b/composeApp/src/commonMain/kotlin/di/AppModule.kt index d29de39c..7f9a5b8f 100644 --- a/composeApp/src/commonMain/kotlin/di/AppModule.kt +++ b/composeApp/src/commonMain/kotlin/di/AppModule.kt @@ -6,7 +6,11 @@ import ivy.data.LocalServerUrlProvider import ivy.data.ServerUrlProvider import ivy.di.Di import ivy.di.Di.register +import ivy.di.Di.singleton import ivy.di.autowire.autoWireSingleton +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import navigation.Navigation import navigation.systemNavigation import util.DispatchersProvider @@ -20,7 +24,14 @@ object AppModule : Di.Module { autoWireSingleton(::Navigation) autoWireSingleton(::AppConfiguration) register { DispatchersProviderImpl() } - bindWithFake() + register { + if (Di.get().useLocalServer) { + Di.get() + } else { + Di.get() + } + } + singleton { CoroutineScope(Dispatchers.Main + CoroutineName("App")) } } } } diff --git a/composeApp/src/commonMain/kotlin/domain/SessionManager.kt b/composeApp/src/commonMain/kotlin/domain/SessionManager.kt new file mode 100644 index 00000000..bc26f89b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/domain/SessionManager.kt @@ -0,0 +1,32 @@ +package domain + +import data.storage.LocalStorage +import ivy.model.auth.SessionToken + +class SessionManager( + private val localStorage: LocalStorage, +) { + private var sessionToken: SessionToken? = null + + suspend fun authenticate(token: SessionToken) { + localStorage.putString(SESSION_TOKEN_KEY, token.value) + sessionToken = token + } + + suspend fun getSession(): SessionToken? { + return sessionToken ?: localStorage.getString(SESSION_TOKEN_KEY) + ?.let(::SessionToken) + ?.also { + sessionToken = it + } + } + + suspend fun logout() { + sessionToken = null + localStorage.remove(SESSION_TOKEN_KEY) + } + + companion object { + const val SESSION_TOKEN_KEY = "sessionToken" + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/domain/di/DomainModule.kt b/composeApp/src/commonMain/kotlin/domain/di/DomainModule.kt index 17790f44..d6aef548 100644 --- a/composeApp/src/commonMain/kotlin/domain/di/DomainModule.kt +++ b/composeApp/src/commonMain/kotlin/domain/di/DomainModule.kt @@ -3,14 +3,17 @@ package domain.di import di.bindWithFake import domain.GoogleAuthenticationUseCase import domain.GoogleAuthenticationUseCaseImpl +import domain.SessionManager import domain.fake.FakeGoogleAuthenticationUseCase import ivy.di.Di import ivy.di.autowire.autoWire +import ivy.di.autowire.autoWireSingleton object DomainModule : Di.Module { override fun init() = Di.appScope { autoWire(::GoogleAuthenticationUseCaseImpl) autoWire(::FakeGoogleAuthenticationUseCase) bindWithFake() + autoWireSingleton(::SessionManager) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/navigation/Navigation.kt b/composeApp/src/commonMain/kotlin/navigation/Navigation.kt index 0e259c3b..2a67053a 100644 --- a/composeApp/src/commonMain/kotlin/navigation/Navigation.kt +++ b/composeApp/src/commonMain/kotlin/navigation/Navigation.kt @@ -1,6 +1,5 @@ package navigation -import Platform import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -9,7 +8,6 @@ import ui.screen.NotFoundPage class Navigation( private val systemNavigation: SystemNavigation, - private val platform: Platform, ) { @Composable fun NavHost() { diff --git a/composeApp/src/commonMain/kotlin/ui/screen/home/composable/HomeContent.kt b/composeApp/src/commonMain/kotlin/ui/screen/home/composable/HomeContent.kt index 69ff1bad..dfbc551d 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/home/composable/HomeContent.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/home/composable/HomeContent.kt @@ -28,11 +28,7 @@ fun HomeContent( onEvent: (HomeViewEvent) -> Unit ) { LearnScaffold( - backButton = BackButton( - onBackClick = { - onEvent(HomeViewEvent.OnBackClick) - } - ), + backButton = null, title = "Learn", actions = { SettingsButton( diff --git a/composeApp/src/commonMain/kotlin/ui/screen/intro/IntroScreen.kt b/composeApp/src/commonMain/kotlin/ui/screen/intro/IntroScreen.kt index 7ff9bd70..5b99adb5 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/intro/IntroScreen.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/intro/IntroScreen.kt @@ -11,10 +11,10 @@ import navigation.Screen import ui.screen.intro.composable.IntroContent object IntroRouter : Router { - const val PATH = "" + const val PATH = "intro" override fun fromRoute(route: Route): Option = option { - ensure(route.path == PATH) + ensure(route.path == PATH || route.path == "") IntroScreen() } diff --git a/composeApp/src/commonMain/kotlin/ui/screen/settings/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/ui/screen/settings/SettingsViewModel.kt index 58c1aaea..190e65e2 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/settings/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/settings/SettingsViewModel.kt @@ -2,13 +2,19 @@ package ui.screen.settings import androidx.compose.runtime.* import androidx.compose.ui.platform.UriHandler +import domain.SessionManager import ivy.IvyUrls +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import navigation.Navigation import ui.ComposeViewModel +import ui.screen.intro.IntroScreen class SettingsViewModel( private val navigation: Navigation, - private val uriHandler: UriHandler + private val uriHandler: UriHandler, + private val sessionManager: SessionManager, + private val scope: CoroutineScope, ) : ComposeViewModel { private var soundEnabled by mutableStateOf(true) private var deleteDialog by mutableStateOf(null) @@ -40,7 +46,7 @@ class SettingsViewModel( SettingsViewEvent.OnPremiumClick -> handlePremiumClick() is SettingsViewEvent.OnSoundEnabledChange -> handleSoundEnabledChange(event) SettingsViewEvent.OnPrivacyClick -> handlePrivacyClick() - SettingsViewEvent.OnLogOutClick -> handleLogOutClick() + SettingsViewEvent.OnLogoutClick -> handleLogoutClick() SettingsViewEvent.OnDeleteAccountClick -> handleDeleteAccountClick() SettingsViewEvent.OnTermsOfUseClick -> handleTermsOfUseClick() SettingsViewEvent.OnPrivacyPolicyClick -> handlePrivacyPolicyClick() @@ -65,8 +71,12 @@ class SettingsViewModel( // TODO - handle event } - private fun handleLogOutClick() { - // TODO - handle event + private fun handleLogoutClick() { + println("On logout click") + scope.launch { + sessionManager.logout() + navigation.replaceWith(IntroScreen()) + } } private fun handleTermsOfUseClick() { diff --git a/composeApp/src/commonMain/kotlin/ui/screen/settings/SettingsViewState.kt b/composeApp/src/commonMain/kotlin/ui/screen/settings/SettingsViewState.kt index f082112a..518e5017 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/settings/SettingsViewState.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/settings/SettingsViewState.kt @@ -18,7 +18,7 @@ sealed interface SettingsViewEvent { data object OnPremiumClick : SettingsViewEvent data class OnSoundEnabledChange(val enabled: Boolean) : SettingsViewEvent data object OnPrivacyClick : SettingsViewEvent - data object OnLogOutClick : SettingsViewEvent + data object OnLogoutClick : SettingsViewEvent data object OnDeleteAccountClick : SettingsViewEvent data object OnTermsOfUseClick : SettingsViewEvent data object OnPrivacyPolicyClick : SettingsViewEvent diff --git a/composeApp/src/commonMain/kotlin/ui/screen/settings/composable/SettingsContent.kt b/composeApp/src/commonMain/kotlin/ui/screen/settings/composable/SettingsContent.kt index 0d7ae2bd..a0fca923 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/settings/composable/SettingsContent.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/settings/composable/SettingsContent.kt @@ -70,9 +70,9 @@ fun SettingsContent( key = "spacer 1", height = 8.dp ) - logOutButton( + logoutButton( onLogOutClick = { - onEvent(SettingsViewEvent.OnLogOutClick) + onEvent(SettingsViewEvent.OnLogoutClick) } ) spacerItem( @@ -192,7 +192,7 @@ private fun LazyListScope.privacyButton( } } -private fun LazyListScope.logOutButton( +private fun LazyListScope.logoutButton( onLogOutClick: () -> Unit ) { item(key = "log-out") { diff --git a/composeApp/src/desktopMain/kotlin/data/storage/LocalStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/data/storage/LocalStorage.desktop.kt new file mode 100644 index 00000000..31e4094b --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/data/storage/LocalStorage.desktop.kt @@ -0,0 +1,5 @@ +package data.storage + +actual fun localStorage(): LocalStorage { + TODO("Not yet implemented") +} \ No newline at end of file diff --git a/composeApp/src/jsMain/kotlin/data/storage/LocalStorage.js.kt b/composeApp/src/jsMain/kotlin/data/storage/LocalStorage.js.kt new file mode 100644 index 00000000..32f66726 --- /dev/null +++ b/composeApp/src/jsMain/kotlin/data/storage/LocalStorage.js.kt @@ -0,0 +1,70 @@ +package data.storage + +import kotlinx.browser.window + +class WebLocalLocalStorage : LocalStorage { + override suspend fun putString( + key: String, + value: String + ) { + storage.setItem(key, value) + } + + override suspend fun getString(key: String): String? { + return storage.getItem(key) + } + + override suspend fun putInt( + key: String, + value: Int + ) { + storage.setItem(key, value.toString()) + } + + override suspend fun getInt(key: String): Int? { + return storage.getItem(key)?.toIntOrNull() + } + + + override suspend fun putDouble( + key: String, + value: Double + ) { + storage.setItem(key, value.toString()) + } + + override suspend fun getDouble(key: String): Double? { + return storage.getItem(key)?.toDoubleOrNull() + } + + override suspend fun putBoolean( + key: String, + value: Boolean + ) { + storage.setItem(key, value.toString()) + } + + override suspend fun getBoolean(key: String): Boolean? { + return storage.getItem(key)?.toBooleanStrictOrNull() + } + + override suspend fun remove(key: String) { + storage.removeItem(key) + } + + override suspend fun removeAll() { + storage.clear() + } + + override suspend fun keys(): List { + return (0..storage.length) + .mapNotNull { + storage.key(it) + } + } + + private val storage + get() = window.localStorage +} + +actual fun localStorage(): LocalStorage = WebLocalLocalStorage() \ No newline at end of file diff --git a/composeApp/src/jsMain/kotlin/navigation/SystemNavigation.js.kt b/composeApp/src/jsMain/kotlin/navigation/SystemNavigation.js.kt index b9f0a8ee..425a5936 100644 --- a/composeApp/src/jsMain/kotlin/navigation/SystemNavigation.js.kt +++ b/composeApp/src/jsMain/kotlin/navigation/SystemNavigation.js.kt @@ -30,6 +30,7 @@ class WebSystemNavigation : SystemNavigation { override fun replaceWith(screen: Screen) { window.history.replaceState(js("({})"), "", screen.toFullPath()) + emitCurrentRoute() } private fun Screen.toFullPath(): String { diff --git a/composeApp/src/nativeMain/kotlin/data/storage/LocalStorage.native.kt b/composeApp/src/nativeMain/kotlin/data/storage/LocalStorage.native.kt new file mode 100644 index 00000000..31e4094b --- /dev/null +++ b/composeApp/src/nativeMain/kotlin/data/storage/LocalStorage.native.kt @@ -0,0 +1,5 @@ +package data.storage + +actual fun localStorage(): LocalStorage { + TODO("Not yet implemented") +} \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/api/GoogleAuthenticationApi.kt b/server/src/main/kotlin/ivy/learn/api/GoogleAuthenticationApi.kt index cb179ef3..3f5663f2 100644 --- a/server/src/main/kotlin/ivy/learn/api/GoogleAuthenticationApi.kt +++ b/server/src/main/kotlin/ivy/learn/api/GoogleAuthenticationApi.kt @@ -32,7 +32,7 @@ class GoogleAuthenticationApi( val auth = authService.authenticate(googleAuthCode) .mapLeft(ServerError::Unknown) .bind() - val sessionToken = auth.session.token + val sessionToken = auth.session.token.value val frontEndUrl = if (serverMode.devMode) { IvyUrls.devFrontEnd } else { diff --git a/server/src/main/kotlin/ivy/learn/data/database/Database.kt b/server/src/main/kotlin/ivy/learn/data/database/Database.kt index bdb504a9..c02ebbbc 100644 --- a/server/src/main/kotlin/ivy/learn/data/database/Database.kt +++ b/server/src/main/kotlin/ivy/learn/data/database/Database.kt @@ -5,6 +5,8 @@ import arrow.core.raise.catch import arrow.core.raise.either import ivy.learn.config.DatabaseConfig import ivy.learn.data.database.tables.Analytics +import ivy.learn.data.database.tables.Sessions +import ivy.learn.data.database.tables.Users import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.SchemaUtils import org.jetbrains.exposed.sql.transactions.transaction @@ -30,7 +32,11 @@ class Database { private fun createDbSchema(database: Database): Either = catch({ transaction { - SchemaUtils.create(Analytics) + SchemaUtils.create( + Users, + Sessions, + Analytics, + ) } Either.Right(database) }) { diff --git a/server/src/main/kotlin/ivy/learn/data/database/tables/Sessions.kt b/server/src/main/kotlin/ivy/learn/data/database/tables/Sessions.kt index abdfc597..7371b8fd 100644 --- a/server/src/main/kotlin/ivy/learn/data/database/tables/Sessions.kt +++ b/server/src/main/kotlin/ivy/learn/data/database/tables/Sessions.kt @@ -7,7 +7,7 @@ import org.jetbrains.exposed.sql.kotlin.datetime.timestamp object Sessions : Table() { val token = varchar("token", length = 128).uniqueIndex() - val userId = Analytics.reference( + val userId = reference( name = "user_id", refColumn = Users.id, onDelete = ReferenceOption.CASCADE, diff --git a/server/src/main/kotlin/ivy/learn/data/repository/auth/SessionRepository.kt b/server/src/main/kotlin/ivy/learn/data/repository/auth/SessionRepository.kt index 81d5c7ee..c989f1ae 100644 --- a/server/src/main/kotlin/ivy/learn/data/repository/auth/SessionRepository.kt +++ b/server/src/main/kotlin/ivy/learn/data/repository/auth/SessionRepository.kt @@ -5,8 +5,8 @@ import arrow.core.raise.catch import arrow.core.right import ivy.learn.data.database.tables.Sessions import ivy.learn.domain.model.Session -import ivy.learn.domain.model.SessionToken import ivy.learn.domain.model.UserId +import ivy.model.auth.SessionToken import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.deleteWhere diff --git a/server/src/main/kotlin/ivy/learn/di/AppModule.kt b/server/src/main/kotlin/ivy/learn/di/AppModule.kt index 4f0eb74e..6efed337 100644 --- a/server/src/main/kotlin/ivy/learn/di/AppModule.kt +++ b/server/src/main/kotlin/ivy/learn/di/AppModule.kt @@ -10,6 +10,7 @@ import ivy.learn.ServerMode import ivy.learn.config.Environment import ivy.learn.config.EnvironmentImpl import ivy.learn.config.ServerConfigurationProvider +import ivy.learn.util.Base64Util import ivy.learn.util.Crypto import ivy.learn.util.TimeProvider @@ -22,5 +23,6 @@ class AppModule(private val devMode: Boolean) : Di.Module { autoWireSingleton(::LearnServer) autoWire(::Crypto) autoWire(::TimeProvider) + autoWire(::Base64Util) } } \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/domain/auth/AuthenticationService.kt b/server/src/main/kotlin/ivy/learn/domain/auth/AuthenticationService.kt index f734aeb7..aa56e898 100644 --- a/server/src/main/kotlin/ivy/learn/domain/auth/AuthenticationService.kt +++ b/server/src/main/kotlin/ivy/learn/domain/auth/AuthenticationService.kt @@ -5,11 +5,11 @@ import arrow.core.raise.either import ivy.learn.data.repository.auth.SessionRepository import ivy.learn.data.repository.auth.UserRepository import ivy.learn.domain.model.Session -import ivy.learn.domain.model.SessionToken import ivy.learn.domain.model.User import ivy.learn.domain.model.UserId import ivy.learn.util.Crypto import ivy.learn.util.TimeProvider +import ivy.model.auth.SessionToken import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.TimeZone import kotlinx.datetime.plus diff --git a/server/src/main/kotlin/ivy/learn/domain/auth/GoogleOAuthUseCase.kt b/server/src/main/kotlin/ivy/learn/domain/auth/GoogleOAuthUseCase.kt index 3bf24626..a5217b1e 100644 --- a/server/src/main/kotlin/ivy/learn/domain/auth/GoogleOAuthUseCase.kt +++ b/server/src/main/kotlin/ivy/learn/domain/auth/GoogleOAuthUseCase.kt @@ -4,20 +4,21 @@ import arrow.core.Either import arrow.core.raise.catch import arrow.core.raise.either import arrow.core.raise.ensure -import arrow.core.raise.ensureNotNull import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.request.* import io.ktor.http.* +import ivy.learn.ServerMode import ivy.learn.config.ServerConfiguration import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import java.util.* +import org.slf4j.Logger class GoogleOAuthUseCase( private val config: ServerConfiguration, private val httpClient: HttpClient, + private val logger: Logger, + private val serverMode: ServerMode, ) { suspend fun verify( @@ -27,7 +28,14 @@ class GoogleOAuthUseCase( "Google authorization code is blank!" } val tokenResponse = exchangeAuthCodeForTokens(authCode).bind() - extractPublicProfile(tokenResponse.idToken).bind() + val userInfoResponse = fetchUserInfo(accessToken = tokenResponse.accessToken).bind() + GooglePublicProfile( + email = userInfoResponse.email, + names = userInfoResponse.name, + profilePictureUrl = userInfoResponse.picture, + ).also { + logger.debug("Google verification succeeded for {}", it) + } } /* @@ -47,13 +55,17 @@ class GoogleOAuthUseCase( "code" to code.value, "client_id" to config.googleOAuth.clientId, "client_secret" to config.googleOAuth.clientSecret, - "redirect_uri" to "urn:ietf:wg:oauth:2.0:oob", + "redirect_uri" to if (serverMode.devMode) { + "http://localhost:8081/auth/google/callback" + } else { + "https://api.ivylearn.app/auth/google/callback" + }, "grant_type" to "authorization_code" ) ) } ensure(response.status.isSuccess()) { - "Failed to verify Google authorization code: status - ${response.status}" + "Verify Google authorization code: status - ${response.status}" } response.body() } @@ -61,45 +73,36 @@ class GoogleOAuthUseCase( Either.Left("Failed to verify Google authorization code: ${code.value}") } - private fun extractPublicProfile(idToken: String): Either = either { - val idTokenPayload = catch({ decodeJwt(idToken) }) { e -> - raise("Failed to decode Google JWT idToken '$idToken': $e") - } - val audience = idTokenPayload["aud"] - ensure(audience != config.googleOAuth.clientId) { - "Google ID token is not intended for our client" - } - - val email = idTokenPayload["email"] - ensureNotNull(email) { - "Google ID token Email is null" + private suspend fun fetchUserInfo( + accessToken: String + ): Either = catch({ + either { + val response = httpClient.get("https://www.googleapis.com/oauth2/v3/userinfo") { + header(HttpHeaders.Authorization, "Bearer $accessToken") + } + ensure(response.status.isSuccess()) { + "Fetch Google user info status code: ${response.status}" + } + response.body() } - val name = idTokenPayload["name"] - val picture = idTokenPayload["picture"] - - - GooglePublicProfile( - email = email, - names = name, - profilePictureUrl = picture - ) - } - - private fun decodeJwt(jwt: String): Map { - val payload = jwt.split(".")[1] - val decodedBytes = Base64.getUrlDecoder().decode(payload) - return Json.decodeFromString(decodedBytes.decodeToString()) + }) { e -> + Either.Left("Fetch Google user info because $e") } + @Serializable + data class GoogleUserInfoResponse( + val email: String, + val name: String?, + val picture: String?, + ) @Serializable data class GoogleTokenResponse( - @SerialName("id_token") - val idToken: String, + @SerialName("access_token") + val accessToken: String, ) } - data class GooglePublicProfile( val email: String, val names: String?, diff --git a/server/src/main/kotlin/ivy/learn/domain/model/Session.kt b/server/src/main/kotlin/ivy/learn/domain/model/Session.kt index df1c4054..09388973 100644 --- a/server/src/main/kotlin/ivy/learn/domain/model/Session.kt +++ b/server/src/main/kotlin/ivy/learn/domain/model/Session.kt @@ -1,5 +1,6 @@ package ivy.learn.domain.model +import ivy.model.auth.SessionToken import kotlinx.datetime.Instant data class Session( @@ -7,7 +8,4 @@ data class Session( val userId: UserId, val createdAt: Instant, val expiresAt: Instant, -) - -@JvmInline -value class SessionToken(val value: String) \ No newline at end of file +) \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/util/Base64Util.kt b/server/src/main/kotlin/ivy/learn/util/Base64Util.kt new file mode 100644 index 00000000..13abd1c5 --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/util/Base64Util.kt @@ -0,0 +1,22 @@ +package ivy.learn.util + +import arrow.core.Either +import arrow.core.raise.catch +import arrow.core.right +import kotlinx.io.bytestring.decodeToByteString +import kotlinx.io.bytestring.decodeToString +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +class Base64Util { + @OptIn(ExperimentalEncodingApi::class) + fun decode( + textBase64: String, + ): Either = catch({ + Base64.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL) + .decodeToByteString(textBase64).decodeToString() + .right() + }) { e -> + Either.Left("Base64 decode of '$textBase64' failed because $e") + } +} \ No newline at end of file diff --git a/server/src/main/resources/logback.xml b/server/src/main/resources/logback.xml index 1eb549c6..be7c489f 100644 --- a/server/src/main/resources/logback.xml +++ b/server/src/main/resources/logback.xml @@ -2,7 +2,7 @@ - %d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %logger{36} - %msg%n + %d{yyyy-MM-dd HH:mm:ss} %-5level: %msg%n diff --git a/server/src/test/kotlin/ivy/learn/util/Base64UtilTest.kt b/server/src/test/kotlin/ivy/learn/util/Base64UtilTest.kt new file mode 100644 index 00000000..77b5670d --- /dev/null +++ b/server/src/test/kotlin/ivy/learn/util/Base64UtilTest.kt @@ -0,0 +1,60 @@ +package ivy.learn.util + +import com.google.testing.junit.testparameterinjector.TestParameter +import com.google.testing.junit.testparameterinjector.TestParameterInjector +import io.kotest.assertions.arrow.core.shouldBeLeft +import io.kotest.assertions.arrow.core.shouldBeRight +import io.kotest.matchers.shouldBe +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(TestParameterInjector::class) +class Base64UtilTest { + private lateinit var base64: Base64Util + + @Before + fun setup() { + base64 = Base64Util() + } + + enum class ValidBase64TestCase( + val encoded: String, + val expectedDecoded: String, + ) { + PADDED( + encoded = "SGVsbG8sIEJhc2U2NCE=", + expectedDecoded = "Hello, Base64!", + ), + NOT_PADDED( + encoded = "SGVsbG8sIEJhc2U2NCE", + expectedDecoded = "Hello, Base64!" + ) + } + + @Test + fun `decodes valid base64`( + @TestParameter testCase: ValidBase64TestCase, + ) { + // Given + val encoded = testCase.encoded + + // When + val decoded = base64.decode(encoded) + + // Then + decoded.shouldBeRight() shouldBe testCase.expectedDecoded + } + + @Test + fun `fails to decode invalid base64`() { + // Given + val encoded = "Hello, Base64!" + + // When + val decoded = base64.decode(encoded) + + // Then + decoded.shouldBeLeft() + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/ivy/model/auth/SessionToken.kt b/shared/src/commonMain/kotlin/ivy/model/auth/SessionToken.kt new file mode 100644 index 00000000..1904d6d7 --- /dev/null +++ b/shared/src/commonMain/kotlin/ivy/model/auth/SessionToken.kt @@ -0,0 +1,6 @@ +package ivy.model.auth + +import kotlin.jvm.JvmInline + +@JvmInline +value class SessionToken(val value: String) \ No newline at end of file