Skip to content

Commit

Permalink
Merge pull request #4
Browse files Browse the repository at this point in the history
feat: custom OTP for testers
  • Loading branch information
cybersokari authored May 14, 2024
2 parents f44fd3f + 7514728 commit b5f9b6c
Show file tree
Hide file tree
Showing 12 changed files with 104 additions and 33 deletions.
23 changes: 11 additions & 12 deletions src/main/kotlin/ng/cove/web/component/SmsOtpService.kt
Original file line number Diff line number Diff line change
@@ -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.*


Expand All @@ -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()
Expand All @@ -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", "<otp>")
requestBody.addProperty("message_text", "Your login OTP is: <otp>")

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 {
Expand All @@ -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
Expand Down
5 changes: 2 additions & 3 deletions src/main/kotlin/ng/cove/web/data/model/Access.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,7 +24,7 @@ class Access {
var headCount: Int = 1

@field:NonNull
@Indexed
@field:Indexed
@DBRef(lazy = true)
var host: Member? = null

Expand Down Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions src/main/kotlin/ng/cove/web/data/model/Member.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -1,23 +1,34 @@
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
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")
Expand Down
4 changes: 4 additions & 0 deletions src/main/kotlin/ng/cove/web/data/model/SecurityGuard.kt
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,8 @@ class SecurityGuard {
var lastLoginAt: Date? = null
/** Device info end**/

@JsonIgnore
@Field("test_otp")
var testOtp: String? = null

}
7 changes: 4 additions & 3 deletions src/main/kotlin/ng/cove/web/data/repo/MemberPhoneOtpRepo.kt
Original file line number Diff line number Diff line change
@@ -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<MemberPhoneOtp, String> {
fun findByRef(ref: String): MemberPhoneOtp?
interface MemberPhoneOtpRepo: MongoRepository<PhoneOtp, String> {
fun countByCreatedAtIsAfter(createdAt: Date): Long
}
2 changes: 2 additions & 0 deletions src/main/kotlin/ng/cove/web/data/repo/MemberRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ interface MemberRepo : MongoRepository<Member?, String?> {
fun findByPhone(phone: String): Member?
fun existsByPhoneAndCommunityIsNotNull(phone: String): Boolean
fun findByFirstNameEquals(name: String, pageable: Pageable): Page<Member>
fun findFirstByTestOtpIsNotNullAndPhone(phone: String): Member?
fun findByIdAndTestOtp(id: String, testOtp: String): Member?
}
4 changes: 3 additions & 1 deletion src/main/kotlin/ng/cove/web/data/repo/SecurityGuardRepo.kt
Original file line number Diff line number Diff line change
@@ -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<SecurityGuard?, String?>{
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?
}
2 changes: 1 addition & 1 deletion src/main/kotlin/ng/cove/web/http/body/OtpRefBody.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
data class OtpRefBody(var ref: String, var phone: String, @field:JsonProperty("expire_at") var expireAt: Date, @field:JsonProperty("daily_trial_left") var dailyTrialLeft: Int?)
51 changes: 48 additions & 3 deletions src/main/kotlin/ng/cove/web/service/UserService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.*


Expand All @@ -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) {
Expand All @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
3 changes: 1 addition & 2 deletions src/main/resources/application-prod.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 5 additions & 1 deletion src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
spring.cloud.gcp.logging.enabled=true
#This resolves CORS issues on Swagger UI
server.forward-headers-strategy=framework

0 comments on commit b5f9b6c

Please sign in to comment.