Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package co.nilin.opex.otp.app.data

enum class SMSProviderType {
KAVENEGAR,
TWILIO,
SMSTO
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package co.nilin.opex.otp.app.model

import org.springframework.data.annotation.Id
import org.springframework.data.relational.core.mapping.Table


@Table(name = "sms_provider")
class SMSProvider(
@Id
val id: String,
val enabled: Boolean,
val baseUrl: String,
val apiKey: String?,
val template: String?,
val username: String?,
val password: String?,
val sender: String?,
val extraConfig: String?
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package co.nilin.opex.otp.app.model

import org.springframework.data.annotation.Id
import org.springframework.data.relational.core.mapping.Table

@Table(name = "sms_provider_route")
class SMSProviderRoute(
@Id
val id: Long? = null,
val prefix: String,
val provider: String,
val enabled: Boolean = true
)
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package co.nilin.opex.otp.app.proxy

import co.nilin.opex.common.OpexError
import co.nilin.opex.common.utils.LoggerDelegate
import co.nilin.opex.otp.app.data.SMSProviderType
import co.nilin.opex.otp.app.repository.SMSProviderRepository
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
Expand All @@ -10,22 +13,22 @@ import org.springframework.web.util.UriComponentsBuilder

@Component
class KaveNegarProxy(
@Value("\${otp.sms.provider.url}")
private val url: String,
@Value("\${otp.sms.provider.api-key}")
private val apiKey: String,
@Value("\${otp.sms.provider.template}")
private val template: String,
private val webClient: WebClient
) {
private val webClient: WebClient,
private val smsProviderRepository: SMSProviderRepository,
) : SMSProvider {

override val type = SMSProviderType.KAVENEGAR


private val logger by LoggerDelegate()
private val baseUrl = "${url}/$apiKey/"

suspend fun send(receiver: String, message: String, sender: String? = null, type: String? = null): Boolean {
override suspend fun send(receiver: String, message: String): Boolean {
val config = smsProviderRepository.findById(type.name) ?: throw OpexError.UnableToSendOTP.exception()
val baseUrl = "${config.baseUrl}/${config.apiKey}/"

val uri = UriComponentsBuilder.fromUriString("$baseUrl/verify/lookup.json")
.queryParam("receptor", receiver)
.queryParam("template", template)
.queryParam("template", config.template)
.queryParam("token", message)
.build().toUri()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package co.nilin.opex.otp.app.proxy

import co.nilin.opex.otp.app.data.SMSProviderType

interface SMSProvider {

val type: SMSProviderType

suspend fun send(
receiver: String,
message: String,
): Boolean
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package co.nilin.opex.otp.app.proxy

import co.nilin.opex.common.OpexError
import co.nilin.opex.common.utils.LoggerDelegate
import co.nilin.opex.otp.app.data.SMSProviderType
import co.nilin.opex.otp.app.repository.SMSProviderRepository
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.bodyToMono

@Component
class SMSTOProxy(
private val webClient: WebClient,
private val smsProviderRepository: SMSProviderRepository,
) : SMSProvider {

override val type = SMSProviderType.SMSTO

private val logger by LoggerDelegate()

override suspend fun send(
receiver: String,
message: String,
): Boolean {

val config = smsProviderRepository.findById(type.name)
?: throw OpexError.UnableToSendOTP.exception()

val request = SMSRequest(
to = receiver,
message = message,
sender_id = config.sender
)

return try {
val response = webClient.post()
.uri("${config.baseUrl}/sms/send")
.header(HttpHeaders.AUTHORIZATION, "Bearer ${config.apiKey}")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.retrieve()
.onStatus({ it.isError }) { it.createException() }
.bodyToMono<String>()
.awaitSingleOrNull()

logger.debug("Message sent to receiver $receiver.\n$response")
true
} catch (e: Exception) {
logger.error("Failed to send SMS", e)
false
}
}

data class SMSRequest(
val to: String,
val message: String,
val sender_id: String? = null,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package co.nilin.opex.otp.app.proxy

import co.nilin.opex.common.OpexError
import co.nilin.opex.common.utils.LoggerDelegate
import co.nilin.opex.otp.app.data.SMSProviderType
import co.nilin.opex.otp.app.repository.SMSProviderRepository
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.util.LinkedMultiValueMap
import org.springframework.web.reactive.function.BodyInserters
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.WebClientResponseException
import org.springframework.web.reactive.function.client.bodyToMono

@Component
class TWILIOProxy(
private val webClient: WebClient,
private val smsProviderRepository: SMSProviderRepository,
) : SMSProvider {

override val type = SMSProviderType.TWILIO

private val logger by LoggerDelegate()

override suspend fun send(
receiver: String,
message: String,
): Boolean {

val config = smsProviderRepository.findById(type.name)
?: throw OpexError.UnableToSendOTP.exception()

val accountSid = config.username
?: throw IllegalStateException("Twilio Account SID is not configured")

val authToken = config.apiKey
?: throw IllegalStateException("Twilio Auth Token is not configured")

val sender = config.sender
?: throw IllegalStateException("Twilio sender number is not configured")

val formData = LinkedMultiValueMap<String, String>().apply {
add("To", receiver)
add("From", sender)
add("Body", message)
}

return try {
val response = webClient.post()
.uri("${config.baseUrl}/2010-04-01/Accounts/$accountSid/Messages.json")
.headers {
it.setBasicAuth(accountSid, authToken)
}
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData(formData))
.retrieve()
.onStatus({ it.isError }) { it.createException() }
.bodyToMono<String>()
.awaitSingleOrNull()

logger.debug("Message sent to receiver $receiver.\n$response")
true
} catch (e: WebClientResponseException) {
logger.error(
"Twilio error: ${e.statusCode}\n${e.responseBodyAsString}",
e
)
logger.error("Failed to send SMS", e)
false
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package co.nilin.opex.otp.app.repository

import co.nilin.opex.otp.app.model.SMSProvider
import co.nilin.opex.otp.app.model.SMSProviderRoute
import co.nilin.opex.otp.app.model.TOTPConfig
import org.springframework.data.r2dbc.repository.Query
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import org.springframework.stereotype.Repository

@Repository
interface SMSProviderRepository : CoroutineCrudRepository<SMSProvider, String> {
@Query("select * from sms_provider where id=:type")
suspend fun findConfig(type: String): SMSProvider
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package co.nilin.opex.otp.app.repository

import co.nilin.opex.otp.app.model.SMSProviderRoute
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import org.springframework.stereotype.Repository

@Repository
interface SMSProviderRouteRepository : CoroutineCrudRepository<SMSProviderRoute, Long> {

fun findAllByEnabledTrue(): List<SMSProviderRoute>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package co.nilin.opex.otp.app.service;

import co.nilin.opex.otp.app.data.SMSProviderType;
import co.nilin.opex.otp.app.proxy.SMSProvider;
import co.nilin.opex.otp.app.repository.SMSProviderRouteRepository
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component;

@Component
class SMSProviderRouter(
providers: List<SMSProvider>,
private val routeRepository: SMSProviderRouteRepository,
@Value("\${otp.sms.provider.default}")
private val defaultSmsProvider: SMSProviderType?,
) {

private val providerMap: Map<SMSProviderType, SMSProvider> = providers.associateBy { it.type }

suspend fun getProvider(receiver: String): SMSProvider {
val routes = routeRepository.findAllByEnabledTrue()

val providerType = routes
.sortedByDescending { it.prefix.length }
.firstOrNull { receiver.startsWith(it.prefix) }
?.provider
?.let(SMSProviderType::valueOf)
?: defaultSmsProvider

return providerMap[providerType]
?: throw IllegalStateException(
"SMS provider $providerType is configured but no implementation was found."
)
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package co.nilin.opex.otp.app.service.message

import co.nilin.opex.otp.app.proxy.KaveNegarProxy
import co.nilin.opex.otp.app.service.SMSProviderRouter
import org.springframework.stereotype.Component

@Component
class SMSSender(private val smsProxy: KaveNegarProxy) : MessageSender {

class SMSSender(
private val router: SMSProviderRouter
) : MessageSender {
override suspend fun send(receiver: String, message: String, metadata: Map<String, Any>): Boolean {
return smsProxy.send(receiver, message)
val provider = router.getProvider(receiver)
return provider.send(receiver, message)
}
}
4 changes: 1 addition & 3 deletions otp/otp-app/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,7 @@ otp:
response-enabled: ${OTP_CODE_RESPONSE_ENABLED:false}
sms:
provider:
url: ${SMS_PROVIDER_URL}
api-key: ${SMS_PROVIDER_API_KEY}
template: ${SMS_PROVIDER_TEMPLATE}
default: ${DEFAULT_SMS_PROVIDER}
email:
host: ${SMTP_HOST}
port: ${SMTP_PORT}
Expand Down
21 changes: 20 additions & 1 deletion otp/otp-app/src/main/resources/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,23 @@ on conflict do nothing;

insert into totp_config
values (true, 128, 'Opex')
on conflict do nothing;
on conflict do nothing;

CREATE TABLE sms_provider_route (
id BIGSERIAL PRIMARY KEY,
prefix VARCHAR(32) NOT NULL,
provider VARCHAR(64) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE
);

CREATE TABLE sms_provider (
id VARCHAR(64) PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
base_url TEXT NOT NULL,
api_key TEXT,
template VARCHAR(128),
username VARCHAR(128),
password VARCHAR(128),
sender VARCHAR(64),
extra_config TEXT
);
Loading
Loading