diff --git a/build.gradle.kts b/build.gradle.kts index 47774a6..674d0a0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,7 +16,6 @@ version = "1.0" application { mainClass.set("es.wokis.ApplicationKt") - mainClassName = "es.wokis.ApplicationKt" val isDevelopment: Boolean = project.ext.has("development") applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment") @@ -55,6 +54,13 @@ dependencies { // 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 6598b14..4b31c03 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -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 diff --git a/src/main/kotlin/es/wokis/data/bo/user/UserBO.kt b/src/main/kotlin/es/wokis/data/bo/user/UserBO.kt index 6e65099..67905f3 100644 --- a/src/main/kotlin/es/wokis/data/bo/user/UserBO.kt +++ b/src/main/kotlin/es/wokis/data/bo/user/UserBO.kt @@ -1,5 +1,6 @@ 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.* @@ -9,5 +10,6 @@ data class UserBO( 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 index 8d5bd23..8dd4254 100644 --- a/src/main/kotlin/es/wokis/data/constants/ServerConstants.kt +++ b/src/main/kotlin/es/wokis/data/constants/ServerConstants.kt @@ -2,4 +2,5 @@ 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/datasource/UserLocalDataSource.kt b/src/main/kotlin/es/wokis/data/datasource/UserLocalDataSource.kt index 12e6e5f..6b44867 100644 --- a/src/main/kotlin/es/wokis/data/datasource/UserLocalDataSource.kt +++ b/src/main/kotlin/es/wokis/data/datasource/UserLocalDataSource.kt @@ -20,6 +20,7 @@ interface UserLocalDataSource { 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 { @@ -60,4 +61,9 @@ class UserLocalDataSourceImpl(private val userCollection: MongoCollection = 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/user/UserDBO.kt b/src/main/kotlin/es/wokis/data/dbo/user/UserDBO.kt index 8261d35..59dabec 100644 --- a/src/main/kotlin/es/wokis/data/dbo/user/UserDBO.kt +++ b/src/main/kotlin/es/wokis/data/dbo/user/UserDBO.kt @@ -1,5 +1,6 @@ package es.wokis.data.dbo.user +import es.wokis.data.constants.ServerConstants import org.bson.codecs.pojo.annotations.BsonId import org.litote.kmongo.Id @@ -9,5 +10,7 @@ data class UserDBO( 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/user/UserDTO.kt b/src/main/kotlin/es/wokis/data/dto/user/UserDTO.kt index 175344d..28bf509 100644 --- a/src/main/kotlin/es/wokis/data/dto/user/UserDTO.kt +++ b/src/main/kotlin/es/wokis/data/dto/user/UserDTO.kt @@ -7,5 +7,6 @@ data class UserDTO( 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 index c3e7aa6..45bb9ae 100644 --- a/src/main/kotlin/es/wokis/data/dto/user/auth/Auth.kt +++ b/src/main/kotlin/es/wokis/data/dto/user/auth/Auth.kt @@ -1,12 +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 password: String, + val isGoogleAuth: Boolean = false ) data class RegisterDTO( val username: String, val email: String, - val password: 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/user/UserMapper.kt b/src/main/kotlin/es/wokis/data/mapper/user/UserMapper.kt index c49d80e..ff24351 100644 --- a/src/main/kotlin/es/wokis/data/mapper/user/UserMapper.kt +++ b/src/main/kotlin/es/wokis/data/mapper/user/UserMapper.kt @@ -13,6 +13,7 @@ import org.mindrot.jbcrypt.BCrypt fun RegisterDTO.toBO() = UserBO( username = username, email = email, + lang = lang, password = BCrypt.hashpw(password, BCrypt.gensalt()), ) @@ -27,6 +28,7 @@ fun UserDTO.toBO() = UserBO( email = email, password = EMPTY_TEXT, image = image, + lang = lang, devices = devices ) @@ -34,7 +36,9 @@ fun UserBO.toDBO() = UserDBO( id = id?.let { ObjectId(it).toId() }, username = username, email = email, - password = password.orEmpty(), + password = password, + lang = lang, + image = image, devices = devices ) @@ -43,6 +47,8 @@ fun UserDBO.toBO() = UserBO( username = username, email = email, password = password, + image = image, + lang = lang, devices = devices ) @@ -53,5 +59,6 @@ fun UserBO.toDTO() = UserDTO( 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 index 721f59b..26eb4b7 100644 --- a/src/main/kotlin/es/wokis/data/repository/user/UserRepository.kt +++ b/src/main/kotlin/es/wokis/data/repository/user/UserRepository.kt @@ -1,22 +1,31 @@ package es.wokis.data.repository.user -import com.google.firebase.auth.hash.Bcrypt +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 -import kotlin.math.log 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 { @@ -33,6 +42,57 @@ class UserRepositoryImpl(private val userLocalDataSource: UserLocalDataSource) : } } + 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() @@ -65,4 +125,5 @@ class UserRepositoryImpl(private val userLocalDataSource: UserLocalDataSource) : 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/plugins/Security.kt b/src/main/kotlin/es/wokis/plugins/Security.kt index a0b9eb9..d1b5221 100644 --- a/src/main/kotlin/es/wokis/plugins/Security.kt +++ b/src/main/kotlin/es/wokis/plugins/Security.kt @@ -3,12 +3,15 @@ package es.wokis.plugins 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.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 @@ -59,6 +62,6 @@ fun makeToken(user: UserBO): String = .withIssuer(jwtIssuer) .withAudience(jwtAudience) .withClaim("username", user.username) - .withClaim("password", user.password) + .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 index c68c415..cc21e4e 100644 --- a/src/main/kotlin/es/wokis/routing/AuthRouting.kt +++ b/src/main/kotlin/es/wokis/routing/AuthRouting.kt @@ -35,4 +35,12 @@ fun Routing.setUpAuthRouting() { 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/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/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 index 7805653..e24c1a7 100644 --- a/src/main/resources/app.conf +++ b/src/main/resources/app.conf @@ -21,4 +21,9 @@ mail { pass = "abc123." } -firebaseSdkDir = "config/firebaseSdk.json" \ No newline at end of file +firebaseSdkDir = "config/firebaseSdk.json" + +google { + clientId = "google_client_id" + clientSecret = "google_client_secret" +} \ No newline at end of file