Skip to content

Commit 384dff6

Browse files
committed
Lock service
1 parent 30210bc commit 384dff6

15 files changed

+712
-21
lines changed

app/config/FrontendAppConfig.scala

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ class FrontendAppConfig @Inject()(val servicesConfig: ServicesConfig, runModeCon
4545
lazy val eligibilityQuestionUrl: String = loadConfig("microservice.services.vat-registration-eligibility-frontend.question")
4646
implicit val appConfig: FrontendAppConfig = this
4747

48+
lazy val ttlLockSeconds:Int = 86400
49+
lazy val knownFactsLockAttemptLimit:Int = 3
50+
lazy val isKnownFactsCheckEnabled:Boolean = true
51+
52+
53+
54+
4855
private lazy val thresholdString: String = runModeConfiguration.get[ConfigList]("vat-threshold").render(ConfigRenderOptions.concise())
4956
lazy val thresholds: Seq[VatThreshold] = Json.parse(thresholdString).as[List[VatThreshold]]
5057

@@ -288,6 +295,8 @@ class FrontendAppConfig @Inject()(val servicesConfig: ServicesConfig, runModeCon
288295

289296
lazy val govukHowToRegister: String = "https://www.gov.uk/register-for-vat/how-register-for-vat"
290297

298+
lazy val vatTaskList: String = s"$host/register-for-vat/application-progress"
299+
291300
lazy val govukTogcVatNotice: String = "https://www.gov.uk/guidance/transfer-a-business-as-a-going-concern-and-vat-notice-7009"
292301

293302
lazy val businessDescriptionMaxLength: Int = servicesConfig.getInt("constants.businessDescriptionMaxLength")
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright 2026 HM Revenue & Customs
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package controllers.bankdetails
18+
19+
import config.{AuthClientConnector, BaseControllerComponents, FrontendAppConfig}
20+
import controllers.BaseController
21+
import play.api.data.Form
22+
import play.api.data.Forms.{boolean, single}
23+
import play.api.mvc.{Action, AnyContent}
24+
import services.{LockService, SessionService}
25+
import views.html.bankdetails.AccountDetailsNotVerifiedView
26+
27+
import javax.inject.Inject
28+
import scala.concurrent.ExecutionContext
29+
30+
class AccountDetailsNotVerified @Inject()(val authConnector: AuthClientConnector,
31+
val sessionService: SessionService,
32+
lockService: LockService,
33+
view: AccountDetailsNotVerifiedView)
34+
(implicit appConfig: FrontendAppConfig,
35+
val executionContext: ExecutionContext,
36+
baseControllerComponents: BaseControllerComponents) extends BaseController {
37+
38+
private val AttemptForm: Form[Boolean] = Form(single("value" -> boolean))
39+
40+
def show: Action[AnyContent] = isAuthenticatedWithProfile {
41+
implicit request => implicit profile =>
42+
lockService.getBarsAttemptsUsed(profile.registrationId).map { attemptsUsed =>
43+
if (attemptsUsed >= appConfig.knownFactsLockAttemptLimit) {
44+
Redirect(controllers.errors.routes.ThirdAttemptLockoutController.show)
45+
} else {
46+
val formWithAttempts = AttemptForm.bind(Map(
47+
"value" -> "true",
48+
"attempts" -> attemptsUsed.toString
49+
))
50+
Ok(view(formWithAttempts))
51+
}
52+
}
53+
}
54+
}

app/controllers/bankdetails/UkBankAccountDetailsController.scala

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,9 @@ package controllers.bankdetails
1818

1919
import config.{AuthClientConnector, BaseControllerComponents, FrontendAppConfig}
2020
import controllers.BaseController
21-
import forms.EnterBankAccountDetailsForm
2221
import forms.EnterBankAccountDetailsForm.{form => enterBankAccountDetailsForm}
2322
import play.api.mvc.{Action, AnyContent}
24-
import services.{BankAccountDetailsService, SessionService}
23+
import services.{BankAccountDetailsService, LockService, SessionService}
2524
import views.html.bankdetails.EnterCompanyBankAccountDetails
2625

2726
import javax.inject.Inject
@@ -30,6 +29,7 @@ import scala.concurrent.{ExecutionContext, Future}
3029
class UkBankAccountDetailsController @Inject()(val authConnector: AuthClientConnector,
3130
val bankAccountDetailsService: BankAccountDetailsService,
3231
val sessionService: SessionService,
32+
val lockService: LockService,
3333
view: EnterCompanyBankAccountDetails)
3434
(implicit appConfig: FrontendAppConfig,
3535
val executionContext: ExecutionContext,
@@ -38,10 +38,15 @@ class UkBankAccountDetailsController @Inject()(val authConnector: AuthClientConn
3838
def show: Action[AnyContent] = isAuthenticatedWithProfile {
3939
implicit request =>
4040
implicit profile =>
41-
for {
42-
bankDetails <- bankAccountDetailsService.fetchBankAccountDetails
43-
filledForm = bankDetails.flatMap(_.details).fold(enterBankAccountDetailsForm)(enterBankAccountDetailsForm.fill)
44-
} yield Ok(view(filledForm))
41+
lockService.getBarsAttemptsUsed(profile.registrationId).map(_ >= appConfig.knownFactsLockAttemptLimit).flatMap {
42+
case true =>
43+
Future.successful(Redirect(controllers.errors.routes.ThirdAttemptLockoutController.show))
44+
case false =>
45+
for {
46+
bankDetails <- bankAccountDetailsService.fetchBankAccountDetails
47+
filledForm = bankDetails.flatMap(_.details).fold(enterBankAccountDetailsForm)(enterBankAccountDetailsForm.fill)
48+
} yield Ok(view(filledForm))
49+
}
4550
}
4651

4752
def submit: Action[AnyContent] = isAuthenticatedWithProfile {
@@ -51,13 +56,17 @@ class UkBankAccountDetailsController @Inject()(val authConnector: AuthClientConn
5156
formWithErrors =>
5257
Future.successful(BadRequest(view(formWithErrors))),
5358
accountDetails =>
54-
bankAccountDetailsService.saveEnteredBankAccountDetails(accountDetails).map { accountDetailsValid =>
59+
bankAccountDetailsService.saveEnteredBankAccountDetails(accountDetails).flatMap { accountDetailsValid =>
5560
if (accountDetailsValid) {
56-
Redirect(controllers.routes.TaskListController.show.url)
57-
}
58-
else {
59-
val invalidDetails = EnterBankAccountDetailsForm.formWithInvalidAccountReputation.fill(accountDetails)
60-
BadRequest(view(invalidDetails))
61+
Future.successful(Redirect(controllers.routes.TaskListController.show.url))
62+
} else {
63+
lockService.incrementBarsAttempts(profile.registrationId).map { attempts =>
64+
if (attempts >= appConfig.knownFactsLockAttemptLimit) {
65+
Redirect(controllers.errors.routes.ThirdAttemptLockoutController.show)
66+
} else {
67+
Redirect(controllers.bankdetails.routes.AccountDetailsNotVerified.show)
68+
}
69+
}
6170
}
6271
}
6372
)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Copyright 2026 HM Revenue & Customs
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package controllers.errors
18+
19+
20+
import play.api.mvc.{Action, AnyContent, MessagesControllerComponents}
21+
import uk.gov.hmrc.auth.core.{AuthConnector, AuthorisedFunctions}
22+
import config.FrontendAppConfig
23+
import uk.gov.hmrc.play.bootstrap.frontend.controller.FrontendController
24+
import services.SessionService
25+
import views.html.errors.ThirdAttemptLockoutPage
26+
27+
import javax.inject.{Inject, Singleton}
28+
import scala.concurrent.{ExecutionContext, Future}
29+
30+
@Singleton
31+
class ThirdAttemptLockoutController @Inject()(mcc: MessagesControllerComponents,
32+
view: ThirdAttemptLockoutPage,
33+
val authConnector: AuthConnector
34+
)(implicit appConfig: FrontendAppConfig, ec: ExecutionContext) extends FrontendController(mcc) with AuthorisedFunctions {
35+
36+
def show(): Action[AnyContent] = Action.async {
37+
implicit request =>
38+
authorised() {
39+
Future.successful(Ok(view()))
40+
}
41+
}
42+
}

app/models/Lock.scala

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright 2026 HM Revenue & Customs
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package models
18+
19+
import play.api.libs.json.{Format, Json, OFormat}
20+
import uk.gov.hmrc.mongo.play.json.formats.MongoJavatimeFormats
21+
22+
import java.time.Instant
23+
24+
case class Lock(identifier: String,
25+
failedAttempts: Int,
26+
lastAttemptedAt: Instant)
27+
28+
object Lock {
29+
implicit val instantFormat: Format[Instant] = MongoJavatimeFormats.instantFormat
30+
implicit lazy val format: OFormat[Lock] = Json.format
31+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
* Copyright 2026 HM Revenue & Customs
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package repositories
18+
19+
import org.mongodb.scala.model.Indexes.ascending
20+
import org.mongodb.scala.model._
21+
import play.api.libs.json._
22+
import config.FrontendAppConfig
23+
import models.Lock
24+
import models.Lock._
25+
import uk.gov.hmrc.mongo.MongoComponent
26+
import uk.gov.hmrc.mongo.play.json.PlayMongoRepository
27+
28+
import java.time.Instant
29+
import java.util.concurrent.TimeUnit
30+
import javax.inject.{Inject, Singleton}
31+
import scala.concurrent.{ExecutionContext, Future}
32+
33+
@Singleton
34+
class UserLockRepository @Inject()(
35+
mongoComponent: MongoComponent,
36+
appConfig: FrontendAppConfig
37+
)(implicit ec: ExecutionContext) extends PlayMongoRepository[Lock](
38+
collectionName = "user-lock",
39+
mongoComponent = mongoComponent,
40+
domainFormat = implicitly[Format[Lock]],
41+
indexes = Seq(
42+
IndexModel(
43+
keys = ascending("lastAttemptedAt"),
44+
indexOptions = IndexOptions()
45+
.name("CVEInvalidDataLockExpires")
46+
.expireAfter(appConfig.ttlLockSeconds, TimeUnit.SECONDS)
47+
),
48+
IndexModel(
49+
keys = ascending("identifier"),
50+
indexOptions = IndexOptions()
51+
.name("IdentifierIdx")
52+
.sparse(true)
53+
.unique(true)
54+
)
55+
),
56+
replaceIndexes = true
57+
) {
58+
59+
def getFailedAttempts(identifier: String): Future[Int] =
60+
collection
61+
.find(Filters.eq("identifier", identifier))
62+
.headOption()
63+
.map(_.map(_.failedAttempts).getOrElse(0))
64+
65+
def isUserLocked(userId: String): Future[Boolean] = {
66+
collection
67+
.find(Filters.in("identifier", userId))
68+
.toFuture()
69+
.map { _.exists { _.failedAttempts >= appConfig.knownFactsLockAttemptLimit }}
70+
}
71+
72+
def updateAttempts(userId: String): Future[Map[String, Int]] = {
73+
def updateAttemptsForLockWith(identifier: String): Future[Lock] = {
74+
collection
75+
.find(Filters.eq("identifier", identifier))
76+
.headOption()
77+
.flatMap {
78+
case Some(existingLock) =>
79+
val newLock = existingLock.copy(
80+
failedAttempts = existingLock.failedAttempts + 1,
81+
lastAttemptedAt = Instant.now()
82+
)
83+
collection.replaceOne(
84+
Filters.and(
85+
Filters.eq("identifier", identifier)
86+
),
87+
newLock
88+
)
89+
.toFuture()
90+
.map(_ => newLock)
91+
case _ =>
92+
val newLock = Lock(identifier, 1, Instant.now)
93+
collection.insertOne(newLock)
94+
.toFuture()
95+
.map(_ => newLock)
96+
}
97+
}
98+
val updateUserLock = updateAttemptsForLockWith(userId)
99+
100+
for {
101+
userLock <- updateUserLock
102+
} yield {
103+
Map(
104+
"user" -> userLock.failedAttempts
105+
)
106+
}
107+
}
108+
}

app/services/LockService.scala

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright 2026 HM Revenue & Customs
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package services
18+
19+
20+
import play.api.mvc.Result
21+
import play.api.mvc.Results.Redirect
22+
import config.FrontendAppConfig
23+
import controllers.errors
24+
import repositories.UserLockRepository
25+
import utils.LoggingUtil
26+
27+
import javax.inject.{Inject, Singleton}
28+
import scala.concurrent.{ExecutionContext, Future}
29+
30+
@Singleton
31+
class LockService @Inject()(userLockRepository: UserLockRepository,
32+
config: FrontendAppConfig)(implicit ec: ExecutionContext) extends LoggingUtil{
33+
34+
35+
36+
def updateAttempts(userId: String): Future[Map[String, Int]] = {
37+
if (config.isKnownFactsCheckEnabled) {
38+
userLockRepository.updateAttempts(userId)
39+
} else {
40+
Future.successful(Map.empty)
41+
}
42+
}
43+
44+
def isJourneyLocked(userId: String): Future[Boolean] = {
45+
if (config.isKnownFactsCheckEnabled) {
46+
userLockRepository.isUserLocked(userId)
47+
} else {
48+
Future.successful(false)
49+
}
50+
}
51+
52+
// ---- BARs bank account lock methods ----
53+
54+
def getBarsAttemptsUsed(registrationId: String): Future[Int] =
55+
userLockRepository.getFailedAttempts(registrationId)
56+
57+
def incrementBarsAttempts(registrationId: String): Future[Int] =
58+
userLockRepository.updateAttempts(registrationId).map(_.getOrElse("user", 0))
59+
60+
def isBarsLocked(registrationId: String): Future[Boolean] =
61+
userLockRepository.isUserLocked(registrationId)
62+
}

0 commit comments

Comments
 (0)