diff --git a/.github/workflows/ci_server.yml b/.github/workflows/ci_server.yml index ee2ab3f..7f89dbb 100644 --- a/.github/workflows/ci_server.yml +++ b/.github/workflows/ci_server.yml @@ -25,6 +25,8 @@ jobs: IVY_LEARN_DB_PORT: 5432 IVY_LEARN_DB_PASSWORD: password IVY_LEARN_GITHUB_PAT: ${{ secrets.IVY_LEARN_GITHUB_PAT }} + IVY_GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.IVY_GOOGLE_OAUTH_CLIENT_ID }} + IVY_GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.IVY_GOOGLE_OAUTH_CLIENT_SECRET }} steps: - name: Checkout GIT uses: actions/checkout@v4 diff --git a/README.md b/README.md index e3ca4d1..b81474c 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ export IVY_LEARN_DB_USER="postgres" export IVY_LEARN_DB_PORT="5432" export IVY_LEARN_DB_PASSWORD="password" export IVY_LEARN_GITHUB_PAT="your_github_personal_access_token" + +export IVY_GOOGLE_OAUTH_CLIENT_ID="your-client-id" +export IVY_GOOGLE_OAUTH_CLIENT_SECRET="your-client-secret" ``` **To run the sever:** diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6d500eb..0e296f2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -66,6 +66,7 @@ ktor-server-netty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "k ktor-server-tests = { module = "io.ktor:ktor-server-tests-jvm", version.ref = "ktor" } ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" } ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" } +ktor-server-logging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" } logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } mockk = { module = "io.mockk:mockk", version = "1.13.13" } postgresql-driver = { module = "org.postgresql:postgresql", version = "42.7.4" } diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 343b9f0..de0243b 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { implementation(libs.kotlin.serialization) implementation(libs.ktor.server.contentNegotiation) implementation(libs.ktor.server.cors) + implementation(libs.ktor.server.logging) implementation(libs.ktor.serialization.json) implementation(libs.bundles.ktor.client.common) implementation(libs.ktor.client.java) diff --git a/server/src/main/kotlin/ivy/learn/Application.kt b/server/src/main/kotlin/ivy/learn/Application.kt index 589150f..b287430 100644 --- a/server/src/main/kotlin/ivy/learn/Application.kt +++ b/server/src/main/kotlin/ivy/learn/Application.kt @@ -6,6 +6,7 @@ import ivy.di.Di import ivy.di.SharedModule import ivy.learn.data.di.DataModule import ivy.learn.di.AppModule +import ivy.learn.domain.di.DomainModule fun main(args: Array) { val devMode = "dev" in args @@ -14,6 +15,9 @@ fun main(args: Array) { val port = System.getenv("PORT")?.toInt() ?: 8081 println("Starting server on port $port...") + if (devMode) { + println("[WARNING][DEV MODE] Server running in dev mode!") + } embeddedServer( Netty, port = port, @@ -31,9 +35,13 @@ fun initDi(devMode: Boolean) { modules = setOf( SharedModule, DataModule, - AppModule(devMode = devMode) + AppModule(devMode = devMode), + DomainModule, ) ) } +@JvmInline +value class ServerMode(val devMode: Boolean) + class ServerInitializationException(reason: String) : Exception("Server initialization failed: $reason") \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/LearnServer.kt b/server/src/main/kotlin/ivy/learn/LearnServer.kt index bb1e8b4..db881a9 100644 --- a/server/src/main/kotlin/ivy/learn/LearnServer.kt +++ b/server/src/main/kotlin/ivy/learn/LearnServer.kt @@ -5,16 +5,23 @@ import arrow.core.raise.either import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* +import io.ktor.server.plugins.calllogging.* import io.ktor.server.plugins.contentnegotiation.* import io.ktor.server.plugins.cors.routing.* +import io.ktor.server.request.* import io.ktor.server.routing.* import ivy.di.Di import ivy.di.Di.register +import ivy.di.Di.singleton import ivy.di.autowire.autoWire import ivy.learn.api.* import ivy.learn.api.common.Api +import ivy.learn.config.ServerConfigurationProvider import ivy.learn.data.database.Database import kotlinx.serialization.json.Json +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.slf4j.event.Level class LearnServer( private val database: Database, @@ -32,6 +39,7 @@ class LearnServer( } private fun injectDependencies() = Di.appScope { + singleton { LoggerFactory.getLogger("Application") } autoWire(::AnalyticsApi) autoWire(::LessonsApi) autoWire(::StatusApi) @@ -44,6 +52,7 @@ class LearnServer( with(ktorApp) { configureCORS() configureContentNegotiation() + configureLogging() } val config = configurationProvider.fromEnvironment().bind() Di.appScope { register { config } } @@ -77,4 +86,16 @@ class LearnServer( anyHost() } } + + private fun Application.configureLogging() { + install(CallLogging) { + level = Level.INFO // Log INFO-level messages + format { call -> + val status = call.response.status()?.value ?: "Unknown" + val method = call.request.httpMethod.value + val uri = call.request.uri + "HTTP $method $uri -> $status" + } + } + } } \ 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 cfb07a0..cb179ef 100644 --- a/server/src/main/kotlin/ivy/learn/api/GoogleAuthenticationApi.kt +++ b/server/src/main/kotlin/ivy/learn/api/GoogleAuthenticationApi.kt @@ -5,33 +5,42 @@ import arrow.core.raise.ensureNotNull import io.ktor.server.response.* import io.ktor.server.routing.* import ivy.IvyUrls +import ivy.learn.ServerMode import ivy.learn.api.common.Api import ivy.learn.api.common.getEndpointBase +import ivy.learn.api.common.model.ServerError import ivy.learn.api.common.model.ServerError.BadRequest -import java.util.* +import ivy.learn.domain.auth.AuthenticationService +import ivy.learn.domain.auth.GoogleAuthorizationCode +import org.slf4j.Logger -class GoogleAuthenticationApi : Api { +class GoogleAuthenticationApi( + private val serverMode: ServerMode, + private val authService: AuthenticationService, + private val logger: Logger, +) : Api { override fun Routing.endpoints() { googleAuthCallback() } private fun Routing.googleAuthCallback() { - get(IvyConstants.GoogleAuthCallbackEndpoint) { - call.parameters[""] - } getEndpointBase(IvyConstants.GoogleAuthCallbackEndpoint) { call -> val googleAuthCode = call.parameters["code"]?.let(::GoogleAuthorizationCode) ensureNotNull(googleAuthCode) { BadRequest("Google authorization code is required as 'code' parameter.") } - // TODO: 1. Validate authorization code - // TODO: 2. Created session token - val sessionToken = UUID.randomUUID().toString() - val frontEndUrl = IvyUrls.debugFrontEnd + val auth = authService.authenticate(googleAuthCode) + .mapLeft(ServerError::Unknown) + .bind() + val sessionToken = auth.session.token + val frontEndUrl = if (serverMode.devMode) { + IvyUrls.devFrontEnd + } else { + IvyUrls.frontEnd + } + logger.info("User '${auth.user.email}' logged on $frontEndUrl.") call.respondRedirect("${frontEndUrl}?${IvyConstants.SessionTokenParam}=$sessionToken") } } - @JvmInline - value class GoogleAuthorizationCode(val value: String) } \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/api/StatusApi.kt b/server/src/main/kotlin/ivy/learn/api/StatusApi.kt index 912b15c..5dd6666 100644 --- a/server/src/main/kotlin/ivy/learn/api/StatusApi.kt +++ b/server/src/main/kotlin/ivy/learn/api/StatusApi.kt @@ -1,16 +1,23 @@ package ivy.learn.api import io.ktor.server.routing.* +import ivy.learn.ServerMode import ivy.learn.api.common.Api import ivy.learn.api.common.getEndpoint import kotlinx.serialization.Serializable +import org.slf4j.Logger -class StatusApi : Api { +class StatusApi( + private val serverMode: ServerMode, + private val logger: Logger, +) : Api { override fun Routing.endpoints() { - getEndpoint("/hello") { + getEndpoint("/status") { + val time = System.currentTimeMillis() + logger.debug("Requesting status at $time") HelloResponse( - message = "Hello, world!", - time = System.currentTimeMillis(), + message = "Hello, world! (devMode = ${serverMode.devMode})", + time = time, ) } } diff --git a/server/src/main/kotlin/ivy/learn/api/common/ApiUtils.kt b/server/src/main/kotlin/ivy/learn/api/common/ApiUtils.kt index c3ecaa3..b4896e0 100644 --- a/server/src/main/kotlin/ivy/learn/api/common/ApiUtils.kt +++ b/server/src/main/kotlin/ivy/learn/api/common/ApiUtils.kt @@ -1,32 +1,40 @@ package ivy.learn.api.common +import arrow.core.Either import arrow.core.raise.Raise import arrow.core.raise.either import io.ktor.http.* +import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import ivy.learn.api.common.model.ServerError import ivy.learn.api.common.model.ServerErrorResponse + @IvyServerDsl -inline fun Routing.getEndpoint( +inline fun Routing.postEndpoint( path: String, - crossinline handler: suspend Raise.(Parameters) -> T + crossinline handler: suspend Raise.(Body, Parameters) -> Response ) { - get(path) { - either { - handler(call.parameters) - }.onLeft { error -> - call.respond( - status = when (error) { - is ServerError.BadRequest -> HttpStatusCode.BadRequest - is ServerError.Unknown -> HttpStatusCode.InternalServerError - }, - message = ServerErrorResponse(error.msg) - ) - }.onRight { response -> - call.respond(HttpStatusCode.OK, response) + postEndpointBase(path) { call -> + val body = try { + call.receive() + } catch (e: Exception) { + raise(ServerError.BadRequest("Malformed request body: $e")) } + val response = handler(body, call.parameters) + call.respond(HttpStatusCode.OK, response) + } +} + +@IvyServerDsl +inline fun Routing.getEndpoint( + path: String, + crossinline handler: suspend Raise.(Parameters) -> Response +) { + getEndpointBase(path) { call -> + val response = handler(call.parameters) + call.respond(HttpStatusCode.OK, response) } } @@ -36,19 +44,45 @@ inline fun Routing.getEndpointBase( crossinline handler: suspend Raise.(RoutingCall) -> Unit ) { get(path) { - either { + handleRequest(handler) + } +} + +@IvyServerDsl +inline fun Routing.postEndpointBase( + path: String, + crossinline handler: suspend Raise.(RoutingCall) -> Unit +) { + post(path) { + handleRequest(handler) + } +} + +suspend inline fun RoutingContext.handleRequest( + crossinline handler: suspend Raise.(RoutingCall) -> Unit +) { + val result = try { + either { handler(call) - }.onLeft { error -> - call.respond( - status = when (error) { - is ServerError.BadRequest -> HttpStatusCode.BadRequest - is ServerError.Unknown -> HttpStatusCode.InternalServerError - }, - message = ServerErrorResponse(error.msg) - ) } + } catch (e: Throwable) { + Either.Left(ServerError.Unknown("Unexpected error occurred.")) + } + result.onLeft { error -> + respondError(error) } } +@IvyServerDsl +suspend fun RoutingContext.respondError(error: ServerError) { + call.respond( + status = when (error) { + is ServerError.BadRequest -> HttpStatusCode.BadRequest + is ServerError.Unknown -> HttpStatusCode.InternalServerError + }, + message = ServerErrorResponse(error.msg) + ) +} + @DslMarker annotation class IvyServerDsl \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/Environment.kt b/server/src/main/kotlin/ivy/learn/config/Environment.kt similarity index 93% rename from server/src/main/kotlin/ivy/learn/Environment.kt rename to server/src/main/kotlin/ivy/learn/config/Environment.kt index 4ecfa45..cd4ad62 100644 --- a/server/src/main/kotlin/ivy/learn/Environment.kt +++ b/server/src/main/kotlin/ivy/learn/config/Environment.kt @@ -1,4 +1,4 @@ -package ivy.learn +package ivy.learn.config import arrow.core.Either import arrow.core.right diff --git a/server/src/main/kotlin/ivy/learn/ServerConfiguration.kt b/server/src/main/kotlin/ivy/learn/config/ServerConfiguration.kt similarity index 67% rename from server/src/main/kotlin/ivy/learn/ServerConfiguration.kt rename to server/src/main/kotlin/ivy/learn/config/ServerConfiguration.kt index 8d54092..a66605b 100644 --- a/server/src/main/kotlin/ivy/learn/ServerConfiguration.kt +++ b/server/src/main/kotlin/ivy/learn/config/ServerConfiguration.kt @@ -1,4 +1,4 @@ -package ivy.learn +package ivy.learn.config import arrow.core.Either import arrow.core.raise.either @@ -11,9 +11,15 @@ data class DatabaseConfig( val password: String ) +data class GoogleOAuthConfig( + val clientId: String, + val clientSecret: String, +) + data class ServerConfiguration( val database: DatabaseConfig, - val privateContentGitHubPat: String + val privateContentGitHubPat: String, + val googleOAuth: GoogleOAuthConfig, ) class ServerConfigurationProvider( @@ -28,7 +34,11 @@ class ServerConfigurationProvider( port = environment.getVariable("IVY_LEARN_DB_PORT").bind(), password = environment.getVariable("IVY_LEARN_DB_PASSWORD").bind() ), - privateContentGitHubPat = environment.getVariable("IVY_LEARN_GITHUB_PAT").bind() + privateContentGitHubPat = environment.getVariable("IVY_LEARN_GITHUB_PAT").bind(), + googleOAuth = GoogleOAuthConfig( + clientId = environment.getVariable("IVY_GOOGLE_OAUTH_CLIENT_ID").bind(), + clientSecret = environment.getVariable("IVY_GOOGLE_OAUTH_CLIENT_SECRET").bind(), + ) ) } } \ No newline at end of file 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 bc1d7d5..bdb504a 100644 --- a/server/src/main/kotlin/ivy/learn/data/database/Database.kt +++ b/server/src/main/kotlin/ivy/learn/data/database/Database.kt @@ -3,8 +3,8 @@ package ivy.learn.data.database import arrow.core.Either import arrow.core.raise.catch import arrow.core.raise.either -import ivy.learn.DatabaseConfig -import ivy.learn.data.database.tables.AnalyticsEvents +import ivy.learn.config.DatabaseConfig +import ivy.learn.data.database.tables.Analytics import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.SchemaUtils import org.jetbrains.exposed.sql.transactions.transaction @@ -30,7 +30,7 @@ class Database { private fun createDbSchema(database: Database): Either = catch({ transaction { - SchemaUtils.create(AnalyticsEvents) + SchemaUtils.create(Analytics) } Either.Right(database) }) { diff --git a/server/src/main/kotlin/ivy/learn/data/database/tables/Analytics.kt b/server/src/main/kotlin/ivy/learn/data/database/tables/Analytics.kt new file mode 100644 index 0000000..007539c --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/data/database/tables/Analytics.kt @@ -0,0 +1,16 @@ +package ivy.learn.data.database.tables + +import org.jetbrains.exposed.dao.id.UUIDTable +import org.jetbrains.exposed.sql.ReferenceOption +import org.jetbrains.exposed.sql.kotlin.datetime.timestamp + +object Analytics : UUIDTable() { + val userId = reference( + name = "user_id", + refColumn = Users.id, + onDelete = ReferenceOption.CASCADE, + onUpdate = ReferenceOption.CASCADE, + ) + val name = varchar("name", 320) + val time = timestamp("time") +} diff --git a/server/src/main/kotlin/ivy/learn/data/database/tables/AnalyticsEvents.kt b/server/src/main/kotlin/ivy/learn/data/database/tables/AnalyticsEvents.kt deleted file mode 100644 index e26f8fc..0000000 --- a/server/src/main/kotlin/ivy/learn/data/database/tables/AnalyticsEvents.kt +++ /dev/null @@ -1,7 +0,0 @@ -package ivy.learn.data.database.tables - -import org.jetbrains.exposed.dao.id.UUIDTable - -object AnalyticsEvents : UUIDTable() { - val name = varchar("name", 50) -} 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 new file mode 100644 index 0000000..abdfc59 --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/data/database/tables/Sessions.kt @@ -0,0 +1,20 @@ +package ivy.learn.data.database.tables + +import org.jetbrains.exposed.sql.ReferenceOption +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.kotlin.datetime.CurrentTimestamp +import org.jetbrains.exposed.sql.kotlin.datetime.timestamp + +object Sessions : Table() { + val token = varchar("token", length = 128).uniqueIndex() + val userId = Analytics.reference( + name = "user_id", + refColumn = Users.id, + onDelete = ReferenceOption.CASCADE, + onUpdate = ReferenceOption.CASCADE, + ) + val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp) + val expiresAt = timestamp("expires_at¬") + + override val primaryKey = PrimaryKey(token) +} \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/data/database/tables/Users.kt b/server/src/main/kotlin/ivy/learn/data/database/tables/Users.kt new file mode 100644 index 0000000..0636487 --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/data/database/tables/Users.kt @@ -0,0 +1,9 @@ +package ivy.learn.data.database.tables + +import org.jetbrains.exposed.dao.id.UUIDTable + +object Users : UUIDTable() { + val email = varchar("email", length = 320).uniqueIndex() + val names = varchar("names", length = 320).nullable() + val profilePictureUrl = varchar("profile_picture_url", length = 2048).nullable() +} \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/data/di/DataModule.kt b/server/src/main/kotlin/ivy/learn/data/di/DataModule.kt index 75d8145..e7b6b0d 100644 --- a/server/src/main/kotlin/ivy/learn/data/di/DataModule.kt +++ b/server/src/main/kotlin/ivy/learn/data/di/DataModule.kt @@ -1,19 +1,24 @@ package ivy.learn.data.di import ivy.di.Di -import ivy.di.Di.register +import ivy.di.autowire.autoWire +import ivy.di.autowire.autoWireSingleton import ivy.learn.data.database.Database import ivy.learn.data.repository.CoursesRepository import ivy.learn.data.repository.LessonsRepository import ivy.learn.data.repository.TopicsRepository +import ivy.learn.data.repository.auth.SessionRepository +import ivy.learn.data.repository.auth.UserRepository import ivy.learn.data.source.LessonContentDataSource object DataModule : Di.Module { override fun init() = Di.appScope { - register { Database() } - register { LessonContentDataSource(Di.get(), Di.get()) } - register { LessonsRepository(Di.get()) } - register { CoursesRepository() } - register { TopicsRepository() } + autoWireSingleton(::Database) + autoWire(::LessonContentDataSource) + autoWire(::LessonsRepository) + autoWire(::CoursesRepository) + autoWire(::TopicsRepository) + autoWire(::SessionRepository) + autoWire(::UserRepository) } } \ No newline at end of file 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 new file mode 100644 index 0000000..81d5c7e --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/data/repository/auth/SessionRepository.kt @@ -0,0 +1,60 @@ +package ivy.learn.data.repository.auth + +import arrow.core.Either +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 org.jetbrains.exposed.sql.ResultRow +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.deleteWhere +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.transactions.transaction + +class SessionRepository { + suspend fun findSessionByToken(token: SessionToken): Either = catch({ + transaction { + Sessions.selectAll() + .where { Sessions.token eq token.value } + .map(::rowToSession) + .singleOrNull() + }.right() + }) { e -> + Either.Left("Failed to find session by $token because $e") + } + + private fun rowToSession(row: ResultRow): Session { + return Session( + token = SessionToken(row[Sessions.token]), + userId = UserId(row[Sessions.userId].value), + createdAt = row[Sessions.createdAt], + expiresAt = row[Sessions.expiresAt], + ) + } + + suspend fun create(session: Session): Either = catch({ + transaction { + Sessions.insert { + it[userId] = session.userId.value + it[token] = session.token.value + it[createdAt] = session.createdAt + it[expiresAt] = session.expiresAt + } + } + Either.Right(session) + }) { e -> + Either.Left("Failed to insert session because $e") + } + + suspend fun delete(token: SessionToken): Either = catch({ + transaction { + Sessions.deleteWhere { Sessions.token eq token.value } + } + Either.Right(Unit) + }) { e -> + Either.Left("Failed to delete session because $e") + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/data/repository/auth/UserRepository.kt b/server/src/main/kotlin/ivy/learn/data/repository/auth/UserRepository.kt new file mode 100644 index 0000000..ac2f78a --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/data/repository/auth/UserRepository.kt @@ -0,0 +1,90 @@ +package ivy.learn.data.repository.auth + +import arrow.core.Either +import arrow.core.raise.catch +import arrow.core.right +import ivy.learn.data.database.tables.Users +import ivy.learn.domain.model.User +import ivy.learn.domain.model.UserId +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.transactions.transaction + +class UserRepository { + suspend fun findUserById(id: UserId): Either = catch({ + transaction { + Users.selectAll() + .where { Users.id eq id.value } + .limit(1) + .map(::rowToUser) + .singleOrNull() + }.right() + }) { e -> + Either.Left("Failed to find user by ID $id because $e") + } + + // Find user by Email + suspend fun findUserByEmail(email: String): Either = catch({ + transaction { + Users.selectAll() + .where { Users.email eq email } + .limit(1) + .map(::rowToUser) + .singleOrNull() + }.right() + }) { e -> + Either.Left("Failed to find user by email $email because $e") + } + + private fun rowToUser(row: ResultRow): User { + return User( + id = UserId(row[Users.id].value), + email = row[Users.email], + names = row[Users.names], + profilePicture = row[Users.profilePictureUrl] + ) + } + + suspend fun create(user: User): Either = catch({ + transaction { + Users.insert { + it[id] = user.id.value + it[email] = user.email + it[names] = user.names + it[profilePictureUrl] = user.profilePicture + } + } + Either.Right(user) + }) { e -> + Either.Left("Failed to insert user $user because $e") + } + + suspend fun update(user: User): Either = catch({ + transaction { + val updatedRows = Users.update({ Users.id eq user.id.value }) { + it[id] = user.id.value + it[email] = user.email + it[names] = user.names + it[profilePictureUrl] = user.profilePicture + } + if (updatedRows != 1) { + throw IllegalStateException("Unexpected number of users updated! Updated $updatedRows rows.") + } + } + Either.Right(Unit) + }) { e -> + Either.Left("Failed to update user $user because $e") + } + + suspend fun delete(id: UserId): Either = catch({ + transaction { + val deletedRows = Users.deleteWhere { Users.id eq id.value } + if (deletedRows != 1) { + throw IllegalStateException("Unexpected number of users deleted! Deleted $deletedRows rows.") + } + } + Either.Right(Unit) + }) { e -> + Either.Left("Failed to delete user with $id because $e") + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/data/source/LessonContentDataSource.kt b/server/src/main/kotlin/ivy/learn/data/source/LessonContentDataSource.kt index 32b929f..4956d4d 100644 --- a/server/src/main/kotlin/ivy/learn/data/source/LessonContentDataSource.kt +++ b/server/src/main/kotlin/ivy/learn/data/source/LessonContentDataSource.kt @@ -6,7 +6,7 @@ import arrow.core.right import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.request.* -import ivy.learn.ServerConfiguration +import ivy.learn.config.ServerConfiguration import ivy.model.CourseId import ivy.model.LessonContent import ivy.model.LessonId diff --git a/server/src/main/kotlin/ivy/learn/di/AppModule.kt b/server/src/main/kotlin/ivy/learn/di/AppModule.kt index 38d25b4..4f0eb74 100644 --- a/server/src/main/kotlin/ivy/learn/di/AppModule.kt +++ b/server/src/main/kotlin/ivy/learn/di/AppModule.kt @@ -1,18 +1,26 @@ package ivy.learn.di import ivy.di.Di +import ivy.di.Di.bind import ivy.di.Di.register import ivy.di.autowire.autoWire import ivy.di.autowire.autoWireSingleton -import ivy.learn.Environment -import ivy.learn.EnvironmentImpl import ivy.learn.LearnServer -import ivy.learn.ServerConfigurationProvider +import ivy.learn.ServerMode +import ivy.learn.config.Environment +import ivy.learn.config.EnvironmentImpl +import ivy.learn.config.ServerConfigurationProvider +import ivy.learn.util.Crypto +import ivy.learn.util.TimeProvider class AppModule(private val devMode: Boolean) : Di.Module { override fun init() = Di.appScope { - register { EnvironmentImpl() } + register { ServerMode(devMode) } + autoWire(::EnvironmentImpl) + bind() autoWire(::ServerConfigurationProvider) autoWireSingleton(::LearnServer) + autoWire(::Crypto) + autoWire(::TimeProvider) } } \ 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 new file mode 100644 index 0000000..f734aeb --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/domain/auth/AuthenticationService.kt @@ -0,0 +1,67 @@ +package ivy.learn.domain.auth + +import arrow.core.Either +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 kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import java.util.* + +class AuthenticationService( + private val oauthUseCase: GoogleOAuthUseCase, + private val userRepository: UserRepository, + private val sessionRepository: SessionRepository, + private val crypto: Crypto, + private val timeProvider: TimeProvider, +) { + companion object { + const val SESSION_EXPIRATION_DAYS = 30 + } + + suspend fun authenticate( + authCode: GoogleAuthorizationCode + ): Either = either { + val googleProfile = oauthUseCase.verify(authCode).bind() + // 1. Get or create user + var user = userRepository.findUserByEmail(googleProfile.email).bind() + if (user == null) { + user = userRepository.create( + User( + id = UserId(UUID.randomUUID()), + email = googleProfile.email, + names = googleProfile.names, + profilePicture = googleProfile.profilePictureUrl, + ) + ).bind() + } + + // 2. Create a new session + val timeNow = timeProvider.instantNow() + val session = sessionRepository.create( + Session( + token = SessionToken(crypto.generateSecureToken()), + userId = user.id, + createdAt = timeNow, + expiresAt = timeNow.plus(SESSION_EXPIRATION_DAYS, DateTimeUnit.DAY, TimeZone.UTC), + ) + ).bind() + + Auth( + user = user, + session = session, + ) + } +} + +data class Auth( + val user: User, + val session: Session, +) \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/domain/auth/GoogleOAuthUseCase.kt b/server/src/main/kotlin/ivy/learn/domain/auth/GoogleOAuthUseCase.kt new file mode 100644 index 0000000..3bf2462 --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/domain/auth/GoogleOAuthUseCase.kt @@ -0,0 +1,110 @@ +package ivy.learn.domain.auth + +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.config.ServerConfiguration +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.util.* + +class GoogleOAuthUseCase( + private val config: ServerConfiguration, + private val httpClient: HttpClient, +) { + + suspend fun verify( + authCode: GoogleAuthorizationCode + ): Either = either { + ensure(authCode.value.isNotBlank()) { + "Google authorization code is blank!" + } + val tokenResponse = exchangeAuthCodeForTokens(authCode).bind() + extractPublicProfile(tokenResponse.idToken).bind() + } + + /* + * API: + * https://developers.google.com/identity/protocols/oauth2/web-server#exchange-authorization-code + */ + private suspend fun exchangeAuthCodeForTokens( + code: GoogleAuthorizationCode + ): Either = catch({ + either { + val response = httpClient.post( + "https://oauth2.googleapis.com/token" + ) { + contentType(ContentType.Application.Json) + setBody( + mapOf( + "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", + "grant_type" to "authorization_code" + ) + ) + } + ensure(response.status.isSuccess()) { + "Failed to verify Google authorization code: status - ${response.status}" + } + response.body() + } + }) { + 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" + } + 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()) + } + + + @Serializable + data class GoogleTokenResponse( + @SerialName("id_token") + val idToken: String, + ) +} + + +data class GooglePublicProfile( + val email: String, + val names: String?, + val profilePictureUrl: String?, +) + +@JvmInline +value class GoogleAuthorizationCode(val value: String) \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/domain/di/DomainModule.kt b/server/src/main/kotlin/ivy/learn/domain/di/DomainModule.kt new file mode 100644 index 0000000..faf476f --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/domain/di/DomainModule.kt @@ -0,0 +1,13 @@ +package ivy.learn.domain.di + +import ivy.di.Di +import ivy.di.autowire.autoWire +import ivy.learn.domain.auth.AuthenticationService +import ivy.learn.domain.auth.GoogleOAuthUseCase + +object DomainModule : Di.Module { + override fun init() = Di.appScope { + autoWire(::AuthenticationService) + autoWire(::GoogleOAuthUseCase) + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/domain/model/Session.kt b/server/src/main/kotlin/ivy/learn/domain/model/Session.kt new file mode 100644 index 0000000..df1c405 --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/domain/model/Session.kt @@ -0,0 +1,13 @@ +package ivy.learn.domain.model + +import kotlinx.datetime.Instant + +data class Session( + val token: SessionToken, + val userId: UserId, + val createdAt: Instant, + val expiresAt: Instant, +) + +@JvmInline +value class SessionToken(val value: String) \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/domain/model/User.kt b/server/src/main/kotlin/ivy/learn/domain/model/User.kt new file mode 100644 index 0000000..6c2af95 --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/domain/model/User.kt @@ -0,0 +1,13 @@ +package ivy.learn.domain.model + +import java.util.* + +data class User( + val id: UserId, + val email: String, + val names: String?, + val profilePicture: String?, +) + +@JvmInline +value class UserId(val value: UUID) \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/util/Crypto.kt b/server/src/main/kotlin/ivy/learn/util/Crypto.kt new file mode 100644 index 0000000..5f99f2b --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/util/Crypto.kt @@ -0,0 +1,13 @@ +package ivy.learn.util + +import java.security.SecureRandom +import java.util.* + +class Crypto { + fun generateSecureToken(byteLength: Int = 32): String { + val secureRandom = SecureRandom() + val tokenBytes = ByteArray(byteLength) + secureRandom.nextBytes(tokenBytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes) + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/util/TimeProvider.kt b/server/src/main/kotlin/ivy/learn/util/TimeProvider.kt new file mode 100644 index 0000000..ef9b565 --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/util/TimeProvider.kt @@ -0,0 +1,8 @@ +package ivy.learn.util + +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +class TimeProvider { + fun instantNow(): Instant = Clock.System.now() +} \ No newline at end of file diff --git a/server/src/main/resources/logback.xml b/server/src/main/resources/logback.xml index bdbb64e..1eb549c 100644 --- a/server/src/main/resources/logback.xml +++ b/server/src/main/resources/logback.xml @@ -1,12 +1,16 @@ + - %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + %d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %logger{36} - %msg%n - + + + - - - + + + + \ No newline at end of file diff --git a/server/src/test/kotlin/ivy/learn/ServerConfigurationTest.kt b/server/src/test/kotlin/ivy/learn/config/ServerConfigurationTest.kt similarity index 81% rename from server/src/test/kotlin/ivy/learn/ServerConfigurationTest.kt rename to server/src/test/kotlin/ivy/learn/config/ServerConfigurationTest.kt index 8a8424a..ef7cebf 100644 --- a/server/src/test/kotlin/ivy/learn/ServerConfigurationTest.kt +++ b/server/src/test/kotlin/ivy/learn/config/ServerConfigurationTest.kt @@ -1,4 +1,4 @@ -package ivy.learn +package ivy.learn.config import com.google.testing.junit.testparameterinjector.TestParameter import com.google.testing.junit.testparameterinjector.TestParameterInjector @@ -40,7 +40,11 @@ class ServerConfigurationTest { port = "5432", password = "password" ), - privateContentGitHubPat = "pat" + privateContentGitHubPat = "pat", + googleOAuth = GoogleOAuthConfig( + clientId = "googleClientId", + clientSecret = "googleClientSecret", + ) ) } @@ -52,7 +56,9 @@ class ServerConfigurationTest { USER("IVY_LEARN_DB_USER"), DB_PORT("IVY_LEARN_DB_PORT"), PASSWORD("IVY_LEARN_DB_PASSWORD"), - GITHUB_PAT("IVY_LEARN_GITHUB_PAT") + GITHUB_PAT("IVY_LEARN_GITHUB_PAT"), + GOOGLE_CLIENT_ID("IVY_GOOGLE_OAUTH_CLIENT_ID"), + GOOGLE_CLIENT_SECRET("IVY_GOOGLE_OAUTH_CLIENT_SECRET"), } @Test @@ -77,5 +83,7 @@ class ServerConfigurationTest { environment.setVariable("IVY_LEARN_DB_PORT", "5432") environment.setVariable("IVY_LEARN_DB_PASSWORD", "password") environment.setVariable("IVY_LEARN_GITHUB_PAT", "pat") + environment.setVariable("IVY_GOOGLE_OAUTH_CLIENT_ID", "googleClientId") + environment.setVariable("IVY_GOOGLE_OAUTH_CLIENT_SECRET", "googleClientSecret") } } \ No newline at end of file diff --git a/server/src/test/kotlin/ivy/learn/fake/FakeEnvironment.kt b/server/src/test/kotlin/ivy/learn/fake/FakeEnvironment.kt index 3b69551..63d1cf4 100644 --- a/server/src/test/kotlin/ivy/learn/fake/FakeEnvironment.kt +++ b/server/src/test/kotlin/ivy/learn/fake/FakeEnvironment.kt @@ -2,7 +2,7 @@ package ivy.learn.fake import arrow.core.Either import arrow.core.right -import ivy.learn.Environment +import ivy.learn.config.Environment class FakeEnvironment : Environment { private val variables = mutableMapOf() diff --git a/server/src/test/kotlin/ivy/learn/util/CryptoTest.kt b/server/src/test/kotlin/ivy/learn/util/CryptoTest.kt new file mode 100644 index 0000000..8c68459 --- /dev/null +++ b/server/src/test/kotlin/ivy/learn/util/CryptoTest.kt @@ -0,0 +1,37 @@ +package ivy.learn.util + +import io.kotest.matchers.equals.shouldNotBeEqual +import io.kotest.matchers.ints.shouldBeInRange +import io.kotest.matchers.string.shouldNotBeBlank +import org.junit.Before +import org.junit.Test + +class CryptoTest { + + private lateinit var crypto: Crypto + + @Before + fun setup() { + crypto = Crypto() + } + + @Test + fun `generates valid token`() { + // When + val token = crypto.generateSecureToken() + + // Then + token.shouldNotBeBlank() + token.length shouldBeInRange 20..128 + } + + @Test + fun `generates random tokens`() { + // When + val token1 = crypto.generateSecureToken() + val token2 = crypto.generateSecureToken() + + // Then + token1 shouldNotBeEqual token2 + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/ivy/IvyUrls.kt b/shared/src/commonMain/kotlin/ivy/IvyUrls.kt index 2486fd2..69f483c 100644 --- a/shared/src/commonMain/kotlin/ivy/IvyUrls.kt +++ b/shared/src/commonMain/kotlin/ivy/IvyUrls.kt @@ -4,5 +4,5 @@ object IvyUrls { const val tos = "https://github.com/Ivy-Apps/legal/blob/main/ivy-learn-tos.md" const val privacy = "https://github.com/Ivy-Apps/legal/blob/main/ivy-learn-privacy.md" const val frontEnd = "https://ivylearn.app" - const val debugFrontEnd = "http://localhost:8080/" + const val devFrontEnd = "http://localhost:8080/" } \ No newline at end of file