diff --git a/.gitignore b/.gitignore index c426c32..2c55ad9 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,5 @@ out/ /.nb-gradle/ ### VS Code ### -.vscode/ \ No newline at end of file +.vscode/ +/config/ diff --git a/build.gradle.kts b/build.gradle.kts index bcb20c3..674d0a0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,6 +3,7 @@ val kotlin_version: String by project val logback_version: String by project val kmongo_version: String by project val koin_version: String by project +val firebase_version: String by project plugins { application @@ -47,6 +48,19 @@ dependencies { // Koin implementation("io.insert-koin:koin-ktor:$koin_version") implementation("io.insert-koin:koin-logger-slf4j:$koin_version") + + // Firebase + implementation("com.google.firebase:firebase-admin:$firebase_version") + + // BCrypt + implementation("org.mindrot:jbcrypt:0.4") + + // Google auth + implementation("com.google.api-client:google-api-client:2.1.1") + + // JavaMail + implementation("javax.mail:javax.mail-api:1.6.2") + implementation("com.sun.mail:javax.mail:1.6.2") } ktor { diff --git a/gradle.properties b/gradle.properties index 0c9830e..4b31c03 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,7 @@ -ktor_version=2.1.3 +ktor_version=2.2.1 kotlin_version=1.7.21 logback_version=1.4.4 kmongo_version=4.7.2 koin_version=3.2.2 +firebase_version=9.1.1 kotlin.code.style=official diff --git a/src/main/kotlin/es/wokis/Application.kt b/src/main/kotlin/es/wokis/Application.kt index a758f7f..aa9d056 100644 --- a/src/main/kotlin/es/wokis/Application.kt +++ b/src/main/kotlin/es/wokis/Application.kt @@ -1,16 +1,26 @@ package es.wokis +import com.typesafe.config.ConfigFactory +import es.wokis.plugins.* import io.ktor.server.application.* +import io.ktor.server.config.* import io.ktor.server.engine.* import io.ktor.server.netty.* -import es.wokis.plugins.* fun main() { - embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module) - .start(wait = true) + embeddedServer(Netty, environment = applicationEngineEnvironment { + config = HoconApplicationConfig(ConfigFactory.load("application.conf")) + + connector { + host = config.host + port = config.port + } + }).start(wait = true) } fun Application.module() { + initConfig() + configureFirebase() configureKoin() configureSerialization() configureMonitoring() diff --git a/src/main/kotlin/es/wokis/data/bo/invoice/InvoiceBO.kt b/src/main/kotlin/es/wokis/data/bo/invoice/InvoiceBO.kt new file mode 100644 index 0000000..7417fee --- /dev/null +++ b/src/main/kotlin/es/wokis/data/bo/invoice/InvoiceBO.kt @@ -0,0 +1,37 @@ +package es.wokis.data.bo.invoice + +import es.wokis.data.constants.ServerConstants.EMPTY_TEXT +import java.util.* + +data class InvoiceBO( + val id: String? = null, + val idApp: Long = 0L, + val title: String = EMPTY_TEXT, + val description: String = EMPTY_TEXT, + val quantity: Int = 0, + val date: Date = Date(), + val type: InvoiceType, + val userId: String, + val category: CategoryBO? = null, + val reactions: List = emptyList() +) + +data class CategoryBO( + val id: Long, + val title: String, + val color: String, +) + +data class ReactionBO( + val id: Long, + val unicode: String +) + +enum class InvoiceType(val key: String) { + DEPOSIT("DEPOSIT"), + EXPENSE("EXPENSE"); + + companion object { + fun getFromKey(key: String) = values().find { it.key == key } ?: DEPOSIT + } +} diff --git a/src/main/kotlin/es/wokis/data/bo/user/UserBO.kt b/src/main/kotlin/es/wokis/data/bo/user/UserBO.kt new file mode 100644 index 0000000..67905f3 --- /dev/null +++ b/src/main/kotlin/es/wokis/data/bo/user/UserBO.kt @@ -0,0 +1,15 @@ +package es.wokis.data.bo.user + +import es.wokis.data.constants.ServerConstants.DEFAULT_LANG +import es.wokis.data.constants.ServerConstants.EMPTY_TEXT +import io.ktor.server.auth.* + +data class UserBO( + val id: String? = null, + val username: String, + val email: String, + val password: String, + val image: String = EMPTY_TEXT, + val lang: String = DEFAULT_LANG, + val devices: List = emptyList() +) : Principal \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/data/bo/verification/VerificationBO.kt b/src/main/kotlin/es/wokis/data/bo/verification/VerificationBO.kt new file mode 100644 index 0000000..a521127 --- /dev/null +++ b/src/main/kotlin/es/wokis/data/bo/verification/VerificationBO.kt @@ -0,0 +1,10 @@ +package es.wokis.data.bo.verification + +import java.util.* + +data class VerificationBO( + val id: Long? = null, + val email: String, + val verificationToken: String, + val timeStamp: Date = Date() +) \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/data/constants/ServerConstants.kt b/src/main/kotlin/es/wokis/data/constants/ServerConstants.kt new file mode 100644 index 0000000..8dd4254 --- /dev/null +++ b/src/main/kotlin/es/wokis/data/constants/ServerConstants.kt @@ -0,0 +1,6 @@ +package es.wokis.data.constants + +object ServerConstants { + const val EMPTY_TEXT = "" + const val DEFAULT_LANG = "en" +} \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/data/database/AppDataBase.kt b/src/main/kotlin/es/wokis/data/database/AppDataBase.kt new file mode 100644 index 0000000..371f3f3 --- /dev/null +++ b/src/main/kotlin/es/wokis/data/database/AppDataBase.kt @@ -0,0 +1,30 @@ +package es.wokis.data.database + +import com.mongodb.ConnectionString +import com.mongodb.MongoClientSettings +import com.mongodb.MongoCredential +import es.wokis.data.dbo.invoice.InvoiceDBO +import es.wokis.data.dbo.user.UserDBO +import es.wokis.plugins.config +import org.litote.kmongo.KMongo +import org.litote.kmongo.getCollection + +class AppDataBase { + private val username = config.getString("db.user") + private val password = config.getString("db.password") + private val dataBaseUrl = "$MONGODB_PREFIX$username:$password@${config.getString("db.ip")}:${config.getString("db.port")}" + private val databaseName = config.getString("db.databaseName") + private val client = KMongo.createClient( + MongoClientSettings.builder() + .credential(MongoCredential.createCredential(username, databaseName, password.toCharArray())) + .applyConnectionString(ConnectionString(dataBaseUrl)).build() + ) + + val database by lazy { client.getDatabase(databaseName) } + val usersCollection by lazy { database.getCollection("users") } + val invoicesCollection by lazy { database.getCollection("invoices") } + + companion object { + private const val MONGODB_PREFIX = "mongodb://" + } +} \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/data/datasource/UserLocalDataSource.kt b/src/main/kotlin/es/wokis/data/datasource/UserLocalDataSource.kt new file mode 100644 index 0000000..6b44867 --- /dev/null +++ b/src/main/kotlin/es/wokis/data/datasource/UserLocalDataSource.kt @@ -0,0 +1,69 @@ +package es.wokis.data.datasource + +import com.mongodb.client.MongoCollection +import es.wokis.data.bo.user.UserBO +import es.wokis.data.constants.ServerConstants.EMPTY_TEXT +import es.wokis.data.dbo.user.UserDBO +import es.wokis.data.mapper.user.toBO +import es.wokis.data.mapper.user.toDBO +import org.bson.types.ObjectId +import org.litote.kmongo.* +import org.litote.kmongo.id.toId +import java.util.regex.Pattern + +interface UserLocalDataSource { + suspend fun getAllUsers(): List + suspend fun getUserById(id: String): UserBO? + suspend fun getUserByEmail(email: String): UserBO? + suspend fun getUserByUsername(username: String): UserBO? + + suspend fun getUserByUsernameOrEmail(username: String, email: String = EMPTY_TEXT): UserBO? + + suspend fun createUser(user: UserBO): Boolean + suspend fun updateUser(user: UserBO): Boolean +} + +class UserLocalDataSourceImpl(private val userCollection: MongoCollection) : UserLocalDataSource { + + private val getCaseInsensitive: (element: String) -> Pattern = { + Pattern.compile(it, Pattern.CASE_INSENSITIVE) + } + + override suspend fun getAllUsers(): List = userCollection.find().map { + it.toBO() + }.toList() + + override suspend fun getUserById(id: String): UserBO? { + val bsonId: Id = ObjectId(id).toId() + return userCollection.findOne(UserDBO::id eq bsonId)?.toBO() + } + + override suspend fun getUserByEmail(email: String): UserBO? = + userCollection.findOne(UserDBO::email.regex(getCaseInsensitive(email)))?.toBO() + + override suspend fun getUserByUsername(username: String): UserBO? = + userCollection.findOne(UserDBO::username.regex(getCaseInsensitive(username)))?.toBO() + + override suspend fun getUserByUsernameOrEmail(username: String, email: String): UserBO? = + userCollection.findOne( + or( + UserDBO::username.regex(getCaseInsensitive(username)), + UserDBO::email.regex(getCaseInsensitive(email.takeIf { it.isNotBlank() } ?: username)) + ) + )?.toBO() + + override suspend fun createUser(user: UserBO): Boolean { + return try { + userCollection.insertOne(user.toDBO()).wasAcknowledged() + + } catch (e: Throwable) { + println(e.stackTraceToString()) + false + } + } + + override suspend fun updateUser(user: UserBO): Boolean { + val bsonId: Id = ObjectId(user.id).toId() + return userCollection.updateOne(UserDBO::id eq bsonId, user.toDBO()).wasAcknowledged() + } +} \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/data/dbo/invoice/InvoiceDBO.kt b/src/main/kotlin/es/wokis/data/dbo/invoice/InvoiceDBO.kt new file mode 100644 index 0000000..5366524 --- /dev/null +++ b/src/main/kotlin/es/wokis/data/dbo/invoice/InvoiceDBO.kt @@ -0,0 +1,28 @@ +package es.wokis.data.dbo.invoice + +import es.wokis.data.constants.ServerConstants +import org.litote.kmongo.Id + +data class InvoiceDBO( + val id: Id? = null, + val idApp: Long = 0L, + val title: String = ServerConstants.EMPTY_TEXT, + val description: String = ServerConstants.EMPTY_TEXT, + val quantity: Int = 0, + val date: Long = 0L, + val type: String? = null, + val userId: String? = null, + val category: CategoryDBO? = null, + val reactions: List = emptyList() +) + +data class CategoryDBO( + val id: Long, + val title: String, + val color: String, +) + +data class ReactionDBO( + val id: Long, + val unicode: String +) \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/data/dbo/user/UserDBO.kt b/src/main/kotlin/es/wokis/data/dbo/user/UserDBO.kt new file mode 100644 index 0000000..59dabec --- /dev/null +++ b/src/main/kotlin/es/wokis/data/dbo/user/UserDBO.kt @@ -0,0 +1,16 @@ +package es.wokis.data.dbo.user + +import es.wokis.data.constants.ServerConstants +import org.bson.codecs.pojo.annotations.BsonId +import org.litote.kmongo.Id + +data class UserDBO( + @BsonId + val id: Id? = null, + val username: String, + val email: String, + val password: String, + val lang: String, + val image: String = ServerConstants.EMPTY_TEXT, + val devices: List = emptyList() +) \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/data/dto/invoice/InvoiceDTO.kt b/src/main/kotlin/es/wokis/data/dto/invoice/InvoiceDTO.kt new file mode 100644 index 0000000..33f1960 --- /dev/null +++ b/src/main/kotlin/es/wokis/data/dto/invoice/InvoiceDTO.kt @@ -0,0 +1,43 @@ +package es.wokis.data.dto.invoice + +import com.google.gson.annotations.SerializedName +import es.wokis.data.constants.ServerConstants.EMPTY_TEXT + +data class InvoiceDTO( + @SerializedName("server_id") + val id: String? = null, + @SerializedName("id") + val idApp: Long = 0L, + @SerializedName("title") + val title: String = EMPTY_TEXT, + @SerializedName("description") + val description: String = EMPTY_TEXT, + @SerializedName("quantity") + val quantity: Int = 0, + @SerializedName("date") + val date: Long = 0L, + @SerializedName("type") + val type: String? = null, + @SerializedName("userId") + val userId: String? = null, + @SerializedName("category") + val category: CategoryDTO? = null, + @SerializedName("reactions") + val reactions: List = emptyList() +) + +data class CategoryDTO( + @SerializedName("id") + val id: Long, + @SerializedName("title") + val title: String, + @SerializedName("color") + val color: String, +) + +data class ReactionDTO( + @SerializedName("id") + val id: Long, + @SerializedName("unicode") + val unicode: String +) \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/data/dto/user/UserDTO.kt b/src/main/kotlin/es/wokis/data/dto/user/UserDTO.kt new file mode 100644 index 0000000..28bf509 --- /dev/null +++ b/src/main/kotlin/es/wokis/data/dto/user/UserDTO.kt @@ -0,0 +1,12 @@ +package es.wokis.data.dto.user + +import es.wokis.data.constants.ServerConstants + +data class UserDTO( + val id: String, + val username: String, + val email: String, + val image: String = ServerConstants.EMPTY_TEXT, + val lang: String, + val devices: List = emptyList() +) \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/data/dto/user/auth/Auth.kt b/src/main/kotlin/es/wokis/data/dto/user/auth/Auth.kt new file mode 100644 index 0000000..45bb9ae --- /dev/null +++ b/src/main/kotlin/es/wokis/data/dto/user/auth/Auth.kt @@ -0,0 +1,17 @@ +package es.wokis.data.dto.user.auth + +import es.wokis.data.constants.ServerConstants.DEFAULT_LANG + +data class LoginDTO( + val username: String, + val password: String, + val isGoogleAuth: Boolean = false +) + +data class RegisterDTO( + val username: String, + val email: String, + val password: String, + val lang: String = DEFAULT_LANG, + val isGoogleAuth: Boolean = false +) \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/data/mapper/invoice/InvoiceMapper.kt b/src/main/kotlin/es/wokis/data/mapper/invoice/InvoiceMapper.kt new file mode 100644 index 0000000..82b5005 --- /dev/null +++ b/src/main/kotlin/es/wokis/data/mapper/invoice/InvoiceMapper.kt @@ -0,0 +1,36 @@ +package es.wokis.data.mapper.invoice + +import es.wokis.data.bo.invoice.CategoryBO +import es.wokis.data.bo.invoice.InvoiceBO +import es.wokis.data.bo.invoice.InvoiceType +import es.wokis.data.bo.invoice.ReactionBO +import es.wokis.data.dto.invoice.CategoryDTO +import es.wokis.data.dto.invoice.InvoiceDTO +import es.wokis.data.dto.invoice.ReactionDTO +import java.util.* + +fun InvoiceDTO.toBO() = InvoiceBO( + id = id, + idApp = idApp, + title = title, + description = description, + quantity = quantity, + date = Date(date), + type = InvoiceType.valueOf(type.orEmpty()), + userId = userId.orEmpty(), + category = category?.toBO(), + reactions = reactions.toBO() +) + +fun CategoryDTO.toBO() = CategoryBO( + id = id, + title = title, + color = color +) + +fun List?.toBO() = this?.map { it.toBO() }.orEmpty() + +fun ReactionDTO.toBO() = ReactionBO( + id = id, + unicode = unicode +) \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/data/mapper/user/UserMapper.kt b/src/main/kotlin/es/wokis/data/mapper/user/UserMapper.kt new file mode 100644 index 0000000..ff24351 --- /dev/null +++ b/src/main/kotlin/es/wokis/data/mapper/user/UserMapper.kt @@ -0,0 +1,64 @@ +package es.wokis.data.mapper.user + +import es.wokis.data.bo.user.UserBO +import es.wokis.data.constants.ServerConstants.EMPTY_TEXT +import es.wokis.data.dbo.user.UserDBO +import es.wokis.data.dto.user.UserDTO +import es.wokis.data.dto.user.auth.LoginDTO +import es.wokis.data.dto.user.auth.RegisterDTO +import org.bson.types.ObjectId +import org.litote.kmongo.id.toId +import org.mindrot.jbcrypt.BCrypt + +fun RegisterDTO.toBO() = UserBO( + username = username, + email = email, + lang = lang, + password = BCrypt.hashpw(password, BCrypt.gensalt()), +) + +fun RegisterDTO.toLoginDTO() = LoginDTO( + username = username, + password = password +) + +fun UserDTO.toBO() = UserBO( + id = id, + username = username, + email = email, + password = EMPTY_TEXT, + image = image, + lang = lang, + devices = devices +) + +fun UserBO.toDBO() = UserDBO( + id = id?.let { ObjectId(it).toId() }, + username = username, + email = email, + password = password, + lang = lang, + image = image, + devices = devices +) + +fun UserDBO.toBO() = UserBO( + id = id.toString(), + username = username, + email = email, + password = password, + image = image, + lang = lang, + devices = devices +) + +fun List?.toDTO() = this?.map { it.toDTO() }.orEmpty() + +fun UserBO.toDTO() = UserDTO( + id = id.orEmpty(), + username = username, + email = email, + image = image, + lang = lang, + devices = devices +) \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/data/repository/user/UserRepository.kt b/src/main/kotlin/es/wokis/data/repository/user/UserRepository.kt new file mode 100644 index 0000000..26eb4b7 --- /dev/null +++ b/src/main/kotlin/es/wokis/data/repository/user/UserRepository.kt @@ -0,0 +1,129 @@ +package es.wokis.data.repository.user + +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier +import com.google.api.client.http.javanet.NetHttpTransport +import com.google.api.client.json.gson.GsonFactory +import es.wokis.data.bo.user.UserBO +import es.wokis.data.constants.ServerConstants.DEFAULT_LANG +import es.wokis.data.constants.ServerConstants.EMPTY_TEXT +import es.wokis.data.datasource.UserLocalDataSource +import es.wokis.data.dto.user.auth.LoginDTO +import es.wokis.data.dto.user.auth.RegisterDTO +import es.wokis.data.mapper.user.toBO +import es.wokis.plugins.config +import es.wokis.plugins.makeToken +import es.wokis.utils.HashGenerator +import es.wokis.utils.generatePassword +import org.mindrot.jbcrypt.BCrypt + +interface UserRepository { + suspend fun login(login: LoginDTO): String? + suspend fun loginWithGoogle(googleToken: String): String? + suspend fun register(register: RegisterDTO): String? + suspend fun getUsers(): List + suspend fun getUserById(id: String?): UserBO? + suspend fun getUserByUsername(name: String?): UserBO? + suspend fun getUserByEmail(email: String?): UserBO? + suspend fun updateUser(user: UserBO): Boolean +} + +class UserRepositoryImpl(private val userLocalDataSource: UserLocalDataSource) : UserRepository { + + override suspend fun login(login: LoginDTO): String? { + val user = userLocalDataSource.getUserByUsernameOrEmail(login.username) + return user?.let { + if (BCrypt.checkpw(login.password, it.password)) { + makeToken(it) + + } else { + null + } + } + } + + override suspend fun loginWithGoogle(googleToken: String): String? { + val verifier: GoogleIdTokenVerifier = + GoogleIdTokenVerifier.Builder( + NetHttpTransport(), + GsonFactory() + ) + .setAudience(listOf(config.getString("google.clientId"))) + .setIssuer("https://accounts.google.com") + .build() + + return verifier.verify(googleToken)?.let { + val payload: GoogleIdToken.Payload = it.payload + + // Print user identifier + val userId: String = payload.subject + println("User ID: $userId") + + // Get profile information from payload + val email: String = payload.email + val imageUrl: String = (payload["picture"] as? String).orEmpty() + val locale: String = payload["locale"] as? String ?: DEFAULT_LANG + val username = email.split("@").firstOrNull() ?: HashGenerator.generateHash() + val user = getUserByEmail(email) + val token = if (user == null) { + val token = register( + RegisterDTO( + username = username, + email = email, + password = EMPTY_TEXT, + isGoogleAuth = true, + lang = locale + ) + ) + getUserByEmail(email)?.let { userNotNull -> + updateUser( + userNotNull.copy( + image = imageUrl + ) + ) + } + token + + } else { + login( + LoginDTO(username = username, password = EMPTY_TEXT, isGoogleAuth = true) + ) + } + token + } + } + + override suspend fun register(register: RegisterDTO): String? { + val currentUser = userLocalDataSource.getUserByUsernameOrEmail(register.username, register.email) + val user = register.toBO() + return if (currentUser == null) { + val wasRegistered = userLocalDataSource.createUser(user) + if (wasRegistered) { + makeToken(user) + + } else { + null + } + + } else { + null + } + } + + override suspend fun getUsers(): List = userLocalDataSource.getAllUsers() + + override suspend fun getUserById(id: String?): UserBO? = id?.let { + userLocalDataSource.getUserById(id) + } + + override suspend fun getUserByUsername(name: String?): UserBO? = name?.let { + userLocalDataSource.getUserByUsername(it) + } + + + override suspend fun getUserByEmail(email: String?): UserBO? = email?.let { + userLocalDataSource.getUserByEmail(it) + } + + override suspend fun updateUser(user: UserBO): Boolean = userLocalDataSource.updateUser(user) +} \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/di/DataSourceModule.kt b/src/main/kotlin/es/wokis/di/DataSourceModule.kt new file mode 100644 index 0000000..4d39a30 --- /dev/null +++ b/src/main/kotlin/es/wokis/di/DataSourceModule.kt @@ -0,0 +1,21 @@ +package es.wokis.di + +import com.mongodb.client.MongoCollection +import es.wokis.data.database.AppDataBase +import es.wokis.data.datasource.UserLocalDataSource +import es.wokis.data.datasource.UserLocalDataSourceImpl +import es.wokis.data.dbo.invoice.InvoiceDBO +import es.wokis.data.dbo.user.UserDBO +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val dataSourceModule = module { + single { AppDataBase() } + single(named("usersCollection")) { getUsersCollection(get()) as MongoCollection } + single(named("invoicesCollection")) { getInvoicesCollection(get()) as MongoCollection } + single { UserLocalDataSourceImpl(get(named("usersCollection"))) as UserLocalDataSource } +} + +private fun getUsersCollection(database: AppDataBase) = database.usersCollection + +private fun getInvoicesCollection(database: AppDataBase) = database.invoicesCollection \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/di/RepositoryModule.kt b/src/main/kotlin/es/wokis/di/RepositoryModule.kt new file mode 100644 index 0000000..b425df8 --- /dev/null +++ b/src/main/kotlin/es/wokis/di/RepositoryModule.kt @@ -0,0 +1,9 @@ +package es.wokis.di + +import es.wokis.data.repository.user.UserRepository +import es.wokis.data.repository.user.UserRepositoryImpl +import org.koin.dsl.module + +val repositoryModule = module { + single { UserRepositoryImpl(get()) as UserRepository } +} \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/plugins/Config.kt b/src/main/kotlin/es/wokis/plugins/Config.kt new file mode 100644 index 0000000..c6b5e33 --- /dev/null +++ b/src/main/kotlin/es/wokis/plugins/Config.kt @@ -0,0 +1,24 @@ +package es.wokis.plugins + +import com.typesafe.config.Config +import com.typesafe.config.ConfigFactory +import io.ktor.server.application.* +import java.io.File +import java.nio.file.Files +import java.nio.file.Paths +import java.nio.file.StandardCopyOption + +private val configFile = File("config/", "app.conf") +lateinit var config: Config + +fun Application.initConfig() { + // si no existe la config interna, tira una excepción + val internalConf = this::class.java.getResourceAsStream("/app.conf") ?: throw IllegalAccessException() + + if (!configFile.exists()) { + configFile.mkdirs() + Files.copy(internalConf, Paths.get(configFile.path), StandardCopyOption.REPLACE_EXISTING) + } + + config = ConfigFactory.parseFile(File("config/app.conf")) +} diff --git a/src/main/kotlin/es/wokis/plugins/Firebase.kt b/src/main/kotlin/es/wokis/plugins/Firebase.kt new file mode 100644 index 0000000..b27dcbd --- /dev/null +++ b/src/main/kotlin/es/wokis/plugins/Firebase.kt @@ -0,0 +1,21 @@ +package es.wokis.plugins + +import com.google.auth.oauth2.GoogleCredentials +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import io.ktor.server.application.* +import java.io.FileInputStream + + +fun Application.configureFirebase() { + val fileDirectory = config.getString("firebaseSdkDir") + + val serviceAccount = FileInputStream(fileDirectory) + + val options = FirebaseOptions + .builder() + .setCredentials(GoogleCredentials.fromStream(serviceAccount)) + .build() + + FirebaseApp.initializeApp(options) +} \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/plugins/HTTP.kt b/src/main/kotlin/es/wokis/plugins/HTTP.kt index 5ac312b..059df4d 100644 --- a/src/main/kotlin/es/wokis/plugins/HTTP.kt +++ b/src/main/kotlin/es/wokis/plugins/HTTP.kt @@ -1,19 +1,28 @@ package es.wokis.plugins import io.ktor.http.* -import io.ktor.server.plugins.cors.routing.* import io.ktor.server.application.* -import io.ktor.server.response.* -import io.ktor.server.request.* +import io.ktor.server.plugins.cors.routing.* +import java.time.Duration fun Application.configureHTTP() { install(CORS) { allowMethod(HttpMethod.Options) + allowMethod(HttpMethod.Get) + allowMethod(HttpMethod.Post) allowMethod(HttpMethod.Put) allowMethod(HttpMethod.Delete) allowMethod(HttpMethod.Patch) + allowHeader(HttpHeaders.AccessControlAllowHeaders) + allowHeader(HttpHeaders.ContentType) + allowHeader(HttpHeaders.AccessControlAllowOrigin) allowHeader(HttpHeaders.Authorization) - allowHeader("MyCustomHeader") - anyHost() // @TODO: Don't do this in production if possible. Try to limit it. + + allowNonSimpleContentTypes = true + allowCredentials = true + allowSameOrigin = true + maxAgeInSeconds = Duration.ofDays(1).toMinutes() * 60L + + anyHost() // TODO: No dejarlo como anyhost, limitarlo al host final } } diff --git a/src/main/kotlin/es/wokis/plugins/Koin.kt b/src/main/kotlin/es/wokis/plugins/Koin.kt index c775efd..44de897 100644 --- a/src/main/kotlin/es/wokis/plugins/Koin.kt +++ b/src/main/kotlin/es/wokis/plugins/Koin.kt @@ -1,17 +1,19 @@ package es.wokis.plugins +import es.wokis.di.dataSourceModule +import es.wokis.di.repositoryModule import io.ktor.server.application.* -import org.koin.dsl.module import org.koin.ktor.plugin.Koin import org.koin.logger.slf4jLogger -val appModules = module { - // TODO: Modules -} - fun Application.configureKoin() { install(Koin) { slf4jLogger() - modules(appModules) + modules( + listOf( + dataSourceModule, + repositoryModule + ) + ) } } \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/plugins/Routing.kt b/src/main/kotlin/es/wokis/plugins/Routing.kt index 9410408..9569603 100644 --- a/src/main/kotlin/es/wokis/plugins/Routing.kt +++ b/src/main/kotlin/es/wokis/plugins/Routing.kt @@ -1,14 +1,20 @@ package es.wokis.plugins import es.wokis.routing.setUpInvoicesRouting +import es.wokis.routing.setUpAuthRouting +import es.wokis.routing.setUpUserRouting import io.ktor.server.routing.* -import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.response.* -import io.ktor.server.request.* fun Application.configureRouting() { routing { + get("/") { + call.respondText("Hello World from Project Finance!") + } + + setUpAuthRouting() setUpInvoicesRouting() + setUpUserRouting() } } diff --git a/src/main/kotlin/es/wokis/plugins/Security.kt b/src/main/kotlin/es/wokis/plugins/Security.kt index 215724a..d1b5221 100644 --- a/src/main/kotlin/es/wokis/plugins/Security.kt +++ b/src/main/kotlin/es/wokis/plugins/Security.kt @@ -1,31 +1,67 @@ package es.wokis.plugins -import io.ktor.server.auth.* -import io.ktor.util.* -import io.ktor.server.auth.jwt.* import com.auth0.jwt.JWT import com.auth0.jwt.JWTVerifier import com.auth0.jwt.algorithms.Algorithm +import com.google.firebase.auth.hash.Bcrypt +import es.wokis.data.bo.user.UserBO +import es.wokis.data.repository.user.UserRepository +import es.wokis.utils.orGeneratePassword import io.ktor.server.application.* -import io.ktor.server.response.* -import io.ktor.server.request.* +import io.ktor.server.auth.* +import io.ktor.server.auth.jwt.* +import org.koin.ktor.ext.inject +import org.mindrot.jbcrypt.BCrypt +import java.util.* + +private lateinit var jwtIssuer: String +private lateinit var jwtAudience: String +private lateinit var algorithm: Algorithm fun Application.configureSecurity() { - + val privateKey = config.getString("secretKey") + val userRepository by inject() + val jwtRealm = environment.config.property("jwt.realm").getString() + jwtIssuer = environment.config.property("jwt.domain").getString() + jwtAudience = environment.config.property("jwt.audience").getString() + algorithm = Algorithm.HMAC256(privateKey) + authentication { - jwt { - val jwtAudience = this@configureSecurity.environment.config.property("jwt.audience").getString() - realm = this@configureSecurity.environment.config.property("jwt.realm").getString() - verifier( - JWT - .require(Algorithm.HMAC256("secret")) - .withAudience(jwtAudience) - .withIssuer(this@configureSecurity.environment.config.property("jwt.domain").getString()) - .build() - ) - validate { credential -> - if (credential.payload.audience.contains(jwtAudience)) JWTPrincipal(credential.payload) else null - } + jwt { + realm = jwtRealm + verifier(makeJwtVerifier(jwtIssuer, jwtAudience)) + validate { credential -> + val name = credential.payload.getClaim("username").asString() + val password = credential.payload.getClaim("password").asString() + + val user = userRepository.getUserByUsername(name) + + user?.takeIf { password == it.password } } } + } + } + +private fun makeJwtVerifier(issuer: String, audience: String): JWTVerifier = + JWT + .require(algorithm) + .withAudience(audience) + .withIssuer(issuer).build() + +/** + * A function that creates a token with given user data. + * + * @param user the user whose token is being made + */ + +fun makeToken(user: UserBO): String = + JWT + .create() + .withSubject("Authentication") + .withIssuer(jwtIssuer) + .withAudience(jwtAudience) + .withClaim("username", user.username) + .withClaim("password", user.password.takeIf { it.isNotBlank() }.orGeneratePassword() ) + .withClaim("timestamp", Date().time) + .sign(algorithm) diff --git a/src/main/kotlin/es/wokis/routing/AuthRouting.kt b/src/main/kotlin/es/wokis/routing/AuthRouting.kt new file mode 100644 index 0000000..cc21e4e --- /dev/null +++ b/src/main/kotlin/es/wokis/routing/AuthRouting.kt @@ -0,0 +1,46 @@ +package es.wokis.routing + +import es.wokis.data.dto.user.auth.LoginDTO +import es.wokis.data.dto.user.auth.RegisterDTO +import es.wokis.data.repository.user.UserRepository +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import org.koin.ktor.ext.inject + +fun Routing.setUpAuthRouting() { + val userRepository by inject() + + post("/login") { + val user = call.receive() + val token: String? = userRepository.login(user) + + token?.let { + call.respond(HttpStatusCode.OK, it) + } ?: run { + call.respond(HttpStatusCode.NotFound, "Wrong username or password") + } + } + + post("/register") { + val user = call.receive() + + val token: String? = userRepository.register(user) + + token?.let { + call.respond(HttpStatusCode.OK, it) + } ?: run { + call.respond(HttpStatusCode.Conflict, "That user already exists") + } + } + + post("/google-auth") { + val googleToken = call.receive() + val token: String? = userRepository.loginWithGoogle(googleToken) + token?.let { + call.respond(HttpStatusCode.OK, it) + } ?: call.respond(HttpStatusCode.NotFound, "That user doesn't exists.") + } +} \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/routing/InvoicesRouting.kt b/src/main/kotlin/es/wokis/routing/InvoicesRouting.kt index c1924b0..c67d2f2 100644 --- a/src/main/kotlin/es/wokis/routing/InvoicesRouting.kt +++ b/src/main/kotlin/es/wokis/routing/InvoicesRouting.kt @@ -1,7 +1,12 @@ package es.wokis.routing +import es.wokis.data.repository.user.UserRepository +import io.ktor.server.application.* +import io.ktor.server.response.* import io.ktor.server.routing.* +import org.koin.ktor.ext.inject fun Routing.setUpInvoicesRouting() { + val userRepository by inject() // TODO: change to invoices repository } \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/routing/UserRouting.kt b/src/main/kotlin/es/wokis/routing/UserRouting.kt new file mode 100644 index 0000000..5a37939 --- /dev/null +++ b/src/main/kotlin/es/wokis/routing/UserRouting.kt @@ -0,0 +1,19 @@ +package es.wokis.routing + +import es.wokis.data.mapper.user.toDTO +import es.wokis.data.repository.user.UserRepository +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import org.koin.ktor.ext.inject + +fun Routing.setUpUserRouting() { + val userRepository by inject() + authenticate { + get("/users") { + val users = userRepository.getUsers().toDTO() + call.respond(users) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/services/EmailService.kt b/src/main/kotlin/es/wokis/services/EmailService.kt new file mode 100644 index 0000000..14e4383 --- /dev/null +++ b/src/main/kotlin/es/wokis/services/EmailService.kt @@ -0,0 +1,61 @@ +package es.wokis.services + +import es.wokis.data.bo.user.UserBO +import es.wokis.data.bo.verification.VerificationBO +import es.wokis.plugins.config +import es.wokis.utils.HashGenerator +import java.util.* +import javax.mail.Message +import javax.mail.MessagingException +import javax.mail.Session +import javax.mail.internet.InternetAddress +import javax.mail.internet.MimeMessage + +class EmailService { + private val fromEmail = config.getString("mail.user") + private val fromPassword = config.getString("mail.pass") + + private val about = "Invitacion" + + fun sendEmail(user: UserBO): VerificationBO { + val emailHtml = this::class.java.getResource("/emails/${user.lang}/email-verify.html") ?: throw IllegalAccessException() + + val properties: Properties = System.getProperties().apply { + put("mail.smtp.host", "email.wokis.es") + put("mail.smtp.user", fromEmail) + put("mail.smtp.clave", fromPassword) + put("mail.smtp.auth", "true") + put("mail.smtp.starttls.enable", "true") + put("mail.smtp.ssl.trust", "email.wokis.es"); + put("mail.smtp.port", 587) + } + val hash = HashGenerator.generateHash(20) + val cuerpo = emailHtml.readText() + + val session = Session.getDefaultInstance(properties) + val message = MimeMessage(session) + + try { + with(message) { + setFrom(InternetAddress(fromEmail)) + addRecipients(Message.RecipientType.TO, user.email) + subject = about + setContent(cuerpo, "text/html") + } + + with(session.getTransport("smtp")) { + connect("email.wokis.es", fromEmail, fromPassword) + sendMessage(message, message.allRecipients) + close() + } + + } catch (e: MessagingException) { + println(e.message) + } + + return VerificationBO( + email = user.email, + verificationToken = hash + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/services/NotificationService.kt b/src/main/kotlin/es/wokis/services/NotificationService.kt new file mode 100644 index 0000000..1431cac --- /dev/null +++ b/src/main/kotlin/es/wokis/services/NotificationService.kt @@ -0,0 +1,24 @@ +package es.wokis.services + +import com.google.firebase.messaging.FirebaseMessaging +import com.google.firebase.messaging.MulticastMessage +import es.wokis.data.bo.user.UserBO + + +fun sendNotifications(users: List) { + val registrationTokens: List = users.flatMap { it.devices } + + val message = MulticastMessage.builder() + .putData("score", "850") + .putData("time", "2:45") + .addAllTokens(registrationTokens) + .build() + val response = FirebaseMessaging.getInstance().sendMulticast(message) + if (response.failureCount > 0) { + val responses = response.responses + val failedTokens: List = responses.mapIndexedNotNull { index, sendResponse -> + if (!sendResponse.isSuccessful) registrationTokens[index] else null + } + println("List of tokens that caused failures: $failedTokens") + } +} diff --git a/src/main/kotlin/es/wokis/utils/BooleanUtils.kt b/src/main/kotlin/es/wokis/utils/BooleanUtils.kt new file mode 100644 index 0000000..562b6ae --- /dev/null +++ b/src/main/kotlin/es/wokis/utils/BooleanUtils.kt @@ -0,0 +1,5 @@ +package es.wokis.utils + +fun Boolean?.isTrue() = this == true + +fun Boolean?.isFalse() = this == false \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/utils/CredentialsUtils.kt b/src/main/kotlin/es/wokis/utils/CredentialsUtils.kt new file mode 100644 index 0000000..d78dd8f --- /dev/null +++ b/src/main/kotlin/es/wokis/utils/CredentialsUtils.kt @@ -0,0 +1,12 @@ +package es.wokis.utils + +import es.wokis.data.bo.user.UserBO +import io.ktor.server.application.* +import io.ktor.server.auth.* +import org.mindrot.jbcrypt.BCrypt + +val ApplicationCall.user: UserBO? get() = authentication.principal() + +fun String?.orGeneratePassword(): String = this ?: generatePassword() + +fun generatePassword() = BCrypt.hashpw(HashGenerator.generateHash(20), BCrypt.gensalt()) \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/utils/DateUtils.kt b/src/main/kotlin/es/wokis/utils/DateUtils.kt new file mode 100644 index 0000000..d387270 --- /dev/null +++ b/src/main/kotlin/es/wokis/utils/DateUtils.kt @@ -0,0 +1,15 @@ +package es.wokis.projectfinance.utils + +import java.text.SimpleDateFormat +import java.util.* + +private const val DATE_FORMAT = "dd/MM/yyyy" + +private val dateFormatter = SimpleDateFormat(DATE_FORMAT, Locale.ROOT) + +fun String.toDate(): Date = dateFormatter.parse(this) ?: Date() + +fun Date.toStringFormatted(): String = dateFormatter.format(this) + +fun getDateFormatted(day: Int, month: Int, year: Int): String = + "$day/$month/$year".toDate().toStringFormatted() \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/utils/HashGenerator.kt b/src/main/kotlin/es/wokis/utils/HashGenerator.kt new file mode 100644 index 0000000..69a09bd --- /dev/null +++ b/src/main/kotlin/es/wokis/utils/HashGenerator.kt @@ -0,0 +1,23 @@ + +package es.wokis.utils + +import es.wokis.data.constants.ServerConstants +import java.util.* +import kotlin.streams.asSequence + +object HashGenerator { + /** + * Genera un hash aleatorio con la longitud indicada. + * + * @param length la longitud del hash. Es opcional, tiene una de serie de 12 caracteres. + * @return el hash generado como [String] + * + */ + fun generateHash(length: Long = 12L): String { + val hashSource = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_" + return Random().ints(length, 0, hashSource.length-1) + .asSequence() + .map(hashSource::get) + .joinToString(ServerConstants.EMPTY_TEXT) + } +} \ No newline at end of file diff --git a/src/main/resources/app.conf b/src/main/resources/app.conf new file mode 100644 index 0000000..e24c1a7 --- /dev/null +++ b/src/main/resources/app.conf @@ -0,0 +1,29 @@ +# Hello, human! This is ProjectFinance's Server config. +# If you're seeing this, welcome! Just edit whatever you want, but be sure what are you editing. + +# Used to generate the token +secretKey = "pestillo" + +# Some DB conf, if you don't know what you're modifying, leave it at it is. +db { + ip = "localhost" + port = "27017" + user = "user" + password = "pass" + databaseName = "project-finance" +} + +# used to store avatars +imageFolder = "images/" + +mail { + user = "test@test.es" + pass = "abc123." +} + +firebaseSdkDir = "config/firebaseSdk.json" + +google { + clientId = "google_client_id" + clientSecret = "google_client_secret" +} \ No newline at end of file diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf new file mode 100644 index 0000000..8ac2e4d --- /dev/null +++ b/src/main/resources/application.conf @@ -0,0 +1,24 @@ +# ktor config +ktor { + deployment { + host = "0.0.0.0" + port = 8080 + } + + application { + modules = [ es.wokis.ApplicationKt.module ] + development = false + } +} + +#jwt config +jwt { + domain = "https://finance.projectalpha.es/" + audience = "wokis" + realm = "projectfinance wokis" +} + +# secret key for JWT encryption +secret { + key = "pestillo" +} \ No newline at end of file