From 751472809c13b580ab822ff102d9580f779a98ca Mon Sep 17 00:00:00 2001 From: sokari Date: Tue, 14 May 2024 09:55:00 +0100 Subject: [PATCH] feat: enable test phone OTP --- .../ng/cove/web/component/SmsOtpService.kt | 23 ++++----- .../kotlin/ng/cove/web/data/model/Access.kt | 5 +- .../kotlin/ng/cove/web/data/model/Member.kt | 5 ++ .../model/{MemberPhoneOtp.kt => PhoneOtp.kt} | 25 ++++++--- .../ng/cove/web/data/model/SecurityGuard.kt | 4 ++ .../cove/web/data/repo/MemberPhoneOtpRepo.kt | 7 +-- .../ng/cove/web/data/repo/MemberRepo.kt | 2 + .../cove/web/data/repo/SecurityGuardRepo.kt | 4 +- .../ng/cove/web/http/body/OtpRefBody.kt | 2 +- .../kotlin/ng/cove/web/service/UserService.kt | 51 +++++++++++++++++-- .../resources/application-prod.properties | 3 +- src/main/resources/application.properties | 6 ++- 12 files changed, 104 insertions(+), 33 deletions(-) rename src/main/kotlin/ng/cove/web/data/model/{MemberPhoneOtp.kt => PhoneOtp.kt} (53%) diff --git a/src/main/kotlin/ng/cove/web/component/SmsOtpService.kt b/src/main/kotlin/ng/cove/web/component/SmsOtpService.kt index 1181e41..94da7c5 100644 --- a/src/main/kotlin/ng/cove/web/component/SmsOtpService.kt +++ b/src/main/kotlin/ng/cove/web/component/SmsOtpService.kt @@ -1,14 +1,14 @@ package ng.cove.web.component -import ng.cove.web.http.body.OtpRefBody import com.google.gson.JsonObject +import ng.cove.web.http.body.OtpRefBody import org.slf4j.Logger import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.stereotype.Service import org.springframework.web.client.RestClient -import java.time.ZoneId import java.util.* @@ -19,6 +19,10 @@ class SmsOtpService { // Set in ApplicationStartup var termiiApiKey: String? = null + @Value("\${otp.expiry-mins}") + var otpExpiryMins: Int = 1 + + fun sendOtp(phone: String): OtpRefBody? { val requestBody = JsonObject() @@ -28,17 +32,14 @@ class SmsOtpService { requestBody.addProperty("message_type", "NUMERIC") requestBody.addProperty("channel", "dnd") requestBody.addProperty("pin_attempts", 5) - val expiryInMinutes = 10 - requestBody.addProperty("pin_time_to_live", expiryInMinutes) + requestBody.addProperty("pin_time_to_live", otpExpiryMins) requestBody.addProperty("pin_length", 6) requestBody.addProperty("pin_placeholder", "") requestBody.addProperty("message_text", "Your login OTP is: ") val url = "https://api.ng.termii.com/api/sms/otp/send" val client = RestClient.builder().baseUrl(url).defaultHeaders { h -> - run { - h.contentType = MediaType.APPLICATION_JSON - } + h.contentType = MediaType.APPLICATION_JSON }.build() try { @@ -54,12 +55,10 @@ class SmsOtpService { val ref = result["pinId"] as String val futureDateTime = Date().toInstant() - .atZone(ZoneId.systemDefault()) - .toLocalDateTime() - .plusMinutes(expiryInMinutes.toLong()) - val expiry = Date.from(futureDateTime.atZone(ZoneId.systemDefault()).toInstant()) + .plusSeconds(otpExpiryMins.toLong() * 60) + val expiry = Date.from(futureDateTime) - return OtpRefBody(ref, phone, expiry) + return OtpRefBody(ref, phone, expiry, null) } catch (e: Exception) { logger.error(e.localizedMessage) return null diff --git a/src/main/kotlin/ng/cove/web/data/model/Access.kt b/src/main/kotlin/ng/cove/web/data/model/Access.kt index 470a4c4..ccb0ce0 100644 --- a/src/main/kotlin/ng/cove/web/data/model/Access.kt +++ b/src/main/kotlin/ng/cove/web/data/model/Access.kt @@ -5,7 +5,6 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategies import com.fasterxml.jackson.databind.annotation.JsonNaming import com.mongodb.lang.NonNull import jakarta.validation.constraints.Future -import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.Id import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.mongodb.core.index.Indexed @@ -25,7 +24,7 @@ class Access { var headCount: Int = 1 @field:NonNull - @Indexed + @field:Indexed @DBRef(lazy = true) var host: Member? = null @@ -53,7 +52,7 @@ class Access { @field:Field("valid_until") var validUntil: Date? = null -// @field:CreatedDate //Only works if we use autogenerated ID + // @field:CreatedDate //Only works if we use autogenerated ID @field:Field("created_at") var createdAt: Date? = null diff --git a/src/main/kotlin/ng/cove/web/data/model/Member.kt b/src/main/kotlin/ng/cove/web/data/model/Member.kt index aa09fe2..e3fc109 100644 --- a/src/main/kotlin/ng/cove/web/data/model/Member.kt +++ b/src/main/kotlin/ng/cove/web/data/model/Member.kt @@ -68,5 +68,10 @@ open class Member { @Field("last_login_at") var lastLoginAt: Date? = null + /** Device info end**/ + + @JsonIgnore + @Field("test_otp") + var testOtp: String? = null } diff --git a/src/main/kotlin/ng/cove/web/data/model/MemberPhoneOtp.kt b/src/main/kotlin/ng/cove/web/data/model/PhoneOtp.kt similarity index 53% rename from src/main/kotlin/ng/cove/web/data/model/MemberPhoneOtp.kt rename to src/main/kotlin/ng/cove/web/data/model/PhoneOtp.kt index b78449a..74b9824 100644 --- a/src/main/kotlin/ng/cove/web/data/model/MemberPhoneOtp.kt +++ b/src/main/kotlin/ng/cove/web/data/model/PhoneOtp.kt @@ -1,6 +1,5 @@ package ng.cove.web.data.model -import jakarta.validation.constraints.Future import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.Id import org.springframework.data.mongodb.core.index.Indexed @@ -8,16 +7,28 @@ import org.springframework.data.mongodb.core.mapping.Document import org.springframework.data.mongodb.core.mapping.Field import java.util.* -@Document("member_phone_otp") -open class MemberPhoneOtp(phone: String, ref: String, expiry: Date) { +@Document("phone_otp") +class PhoneOtp { + + constructor(phone: String, ref: String, type: UserType, expireAt: Date?){ + this.phone = phone + this.ref = ref + this.type = type + this.expireAt = expireAt + } @Id var id: String? = null - var phone: String? = phone + + var phone: String? = null + @Indexed(unique = true) - var ref : String? = ref - @Field("expire_at") - var expireAt: @Future Date? = null + var ref: String? = null + + private var type: UserType = UserType.Member + + @field:Field("expire_at") + var expireAt: Date? = null @CreatedDate @Field("created_at") diff --git a/src/main/kotlin/ng/cove/web/data/model/SecurityGuard.kt b/src/main/kotlin/ng/cove/web/data/model/SecurityGuard.kt index 938a3da..41ebbc4 100644 --- a/src/main/kotlin/ng/cove/web/data/model/SecurityGuard.kt +++ b/src/main/kotlin/ng/cove/web/data/model/SecurityGuard.kt @@ -58,4 +58,8 @@ class SecurityGuard { var lastLoginAt: Date? = null /** Device info end**/ + @JsonIgnore + @Field("test_otp") + var testOtp: String? = null + } diff --git a/src/main/kotlin/ng/cove/web/data/repo/MemberPhoneOtpRepo.kt b/src/main/kotlin/ng/cove/web/data/repo/MemberPhoneOtpRepo.kt index c5271c4..06b92ed 100644 --- a/src/main/kotlin/ng/cove/web/data/repo/MemberPhoneOtpRepo.kt +++ b/src/main/kotlin/ng/cove/web/data/repo/MemberPhoneOtpRepo.kt @@ -1,8 +1,9 @@ package ng.cove.web.data.repo -import ng.cove.web.data.model.MemberPhoneOtp +import ng.cove.web.data.model.PhoneOtp import org.springframework.data.mongodb.repository.MongoRepository +import java.util.Date -interface MemberPhoneOtpRepo: MongoRepository { - fun findByRef(ref: String): MemberPhoneOtp? +interface MemberPhoneOtpRepo: MongoRepository { + fun countByCreatedAtIsAfter(createdAt: Date): Long } \ No newline at end of file diff --git a/src/main/kotlin/ng/cove/web/data/repo/MemberRepo.kt b/src/main/kotlin/ng/cove/web/data/repo/MemberRepo.kt index 9ded60b..84378fe 100644 --- a/src/main/kotlin/ng/cove/web/data/repo/MemberRepo.kt +++ b/src/main/kotlin/ng/cove/web/data/repo/MemberRepo.kt @@ -13,4 +13,6 @@ interface MemberRepo : MongoRepository { fun findByPhone(phone: String): Member? fun existsByPhoneAndCommunityIsNotNull(phone: String): Boolean fun findByFirstNameEquals(name: String, pageable: Pageable): Page + fun findFirstByTestOtpIsNotNullAndPhone(phone: String): Member? + fun findByIdAndTestOtp(id: String, testOtp: String): Member? } diff --git a/src/main/kotlin/ng/cove/web/data/repo/SecurityGuardRepo.kt b/src/main/kotlin/ng/cove/web/data/repo/SecurityGuardRepo.kt index 51a3139..c1212f3 100644 --- a/src/main/kotlin/ng/cove/web/data/repo/SecurityGuardRepo.kt +++ b/src/main/kotlin/ng/cove/web/data/repo/SecurityGuardRepo.kt @@ -1,11 +1,13 @@ package ng.cove.web.data.repo +import ng.cove.web.data.model.Member import ng.cove.web.data.model.SecurityGuard import org.springframework.data.mongodb.repository.MongoRepository interface SecurityGuardRepo : MongoRepository{ fun findByPhoneAndCommunityIdIsNotNull(phone: String): SecurityGuard? - fun existsByPhoneAndCommunityIdIsNotNull(phone: String): Boolean fun existsByPhone(phone: String): Boolean fun findByPhone(phone: String): SecurityGuard? + fun findFirstByTestOtpIsNotNullAndPhone(phone: String): SecurityGuard? + fun findByIdAndTestOtp(id: String, testOtp: String): SecurityGuard? } diff --git a/src/main/kotlin/ng/cove/web/http/body/OtpRefBody.kt b/src/main/kotlin/ng/cove/web/http/body/OtpRefBody.kt index 475c41a..1a70b5d 100644 --- a/src/main/kotlin/ng/cove/web/http/body/OtpRefBody.kt +++ b/src/main/kotlin/ng/cove/web/http/body/OtpRefBody.kt @@ -6,4 +6,4 @@ import com.fasterxml.jackson.databind.annotation.JsonNaming import java.util.Date -data class OtpRefBody(var ref: String, var phone: String, @field:JsonProperty("expire_at") var expireAt: Date) \ No newline at end of file +data class OtpRefBody(var ref: String, var phone: String, @field:JsonProperty("expire_at") var expireAt: Date, @field:JsonProperty("daily_trial_left") var dailyTrialLeft: Int?) \ No newline at end of file diff --git a/src/main/kotlin/ng/cove/web/service/UserService.kt b/src/main/kotlin/ng/cove/web/service/UserService.kt index bbb743b..78f1ff5 100644 --- a/src/main/kotlin/ng/cove/web/service/UserService.kt +++ b/src/main/kotlin/ng/cove/web/service/UserService.kt @@ -3,19 +3,25 @@ package ng.cove.web.service import com.google.firebase.auth.FirebaseAuth import ng.cove.web.component.SmsOtpService import ng.cove.web.data.model.Member +import ng.cove.web.data.model.PhoneOtp import ng.cove.web.data.model.SecurityGuard import ng.cove.web.data.model.UserType +import ng.cove.web.data.repo.MemberPhoneOtpRepo import ng.cove.web.data.repo.MemberRepo import ng.cove.web.data.repo.SecurityGuardRepo import ng.cove.web.http.body.LoginBody +import ng.cove.web.http.body.OtpRefBody import ng.cove.web.util.CacheNames import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value import org.springframework.cache.annotation.Cacheable import org.springframework.cache.caffeine.CaffeineCacheManager import org.springframework.http.ResponseEntity import org.springframework.stereotype.Service +import java.time.Duration +import java.time.Instant import java.util.* @@ -36,6 +42,12 @@ class UserService { @Autowired lateinit var cacheManager: CaffeineCacheManager + @Autowired + lateinit var otpRepo: MemberPhoneOtpRepo + + @Value("\${otp.trial-limit}") + var maxDailyOtpTrial: Int = 0 + fun getOtpForLogin(phone: String, userType: UserType): ResponseEntity<*> { if (userType == UserType.Member) { @@ -46,8 +58,24 @@ class UserService { ?: return ResponseEntity.badRequest().body("$phone is not guard of a community") } + + // Check if user is a tester + getTesterId(phone, userType)?.let { + val future = Date.from(Instant.now().plus(Duration.ofDays(1))) + return ResponseEntity.ok().body(OtpRefBody(it, phone, future, 100)) + } + + val aDayAgo = Date.from(Date().toInstant().minusSeconds(Duration.ofDays(1).seconds)) + var trialCount = otpRepo.countByCreatedAtIsAfter(aDayAgo).toInt() + if (trialCount >= maxDailyOtpTrial) { + return ResponseEntity.badRequest().body("Trial exceeded try again later") + } + val otpResult = smsOtp.sendOtp(phone) return if (otpResult != null) { + trialCount++ + otpResult.dailyTrialLeft = maxDailyOtpTrial - trialCount + otpRepo.save(PhoneOtp(phone, otpResult.ref, userType, otpResult.expireAt)) ResponseEntity.ok().body(otpResult) } else { ResponseEntity.internalServerError().body("OTP provider error") @@ -61,8 +89,10 @@ class UserService { fun verifyPhoneOtp(login: LoginBody, userType: UserType): ResponseEntity<*> { try { - val phone = smsOtp.verifyOtp(login.otp, login.ref) - ?: return ResponseEntity.badRequest().body("Invalid code") + + // If user is tester return the phone number, otherwise verify OTP + val phone = getTesterPhone(login.ref, login.otp, userType) ?: smsOtp.verifyOtp(login.otp, login.ref) + ?: return ResponseEntity.badRequest().body("Invalid code") val userId: String var userTypeForClaims = userType @@ -105,7 +135,6 @@ class UserService { cacheManager.getCache(CacheNames.GUARDS)?.put(userId, guard) } - val firebaseAuth = FirebaseAuth.getInstance() //Revoke refresh token for old devices if any @@ -125,6 +154,22 @@ class UserService { } } + fun getTesterId(phone: String, userType: UserType): String? { + return if (userType == UserType.Member) { + memberRepo.findFirstByTestOtpIsNotNullAndPhone(phone)?.id + } else { + guardRepo.findFirstByTestOtpIsNotNullAndPhone(phone)?.id + } + } + + fun getTesterPhone(id: String, otp: String, userType: UserType): String? { + return if (userType == UserType.Member) { + memberRepo.findByIdAndTestOtp(id, otp)?.phone + } else { + guardRepo.findByIdAndTestOtp(id, otp)?.phone + } + } + @Cacheable(value = [CacheNames.MEMBERS]) fun getMemberById(id: String): Member? { return memberRepo.findById(id).orElse(null) diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 941c6e0..68e9124 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -2,7 +2,6 @@ spring.mvc.async.request-timeout=20000 server.tomcat.threads.max=80 server.port=80 -#This resolves CORS issues on Swagger UI -server.forward-headers-strategy=framework + # Disable Swagger UI on production springdoc.swagger-ui.enabled=false \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 03b40e0..ae70d58 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -5,8 +5,12 @@ spring.data.mongodb.auto-index-creation=true #Default active profile spring.profiles.active=prod +otp.trial-limit=3 +otp.expiry-mins=10 visitor.access-code.length=8 # GCP Secret Manager spring.cloud.gcp.secretmanager.enabled=true # GCP Cloud Logging -spring.cloud.gcp.logging.enabled=true \ No newline at end of file +spring.cloud.gcp.logging.enabled=true +#This resolves CORS issues on Swagger UI +server.forward-headers-strategy=framework \ No newline at end of file