Skip to content

Commit fdb28dc

Browse files
authored
Merge pull request #69 from Hub-of-all-Things/dev
Release v2.6.5
2 parents e245653 + c49eefa commit fdb28dc

21 files changed

+381
-29
lines changed

hat/app/org/hatdex/hat/api/controllers/Authentication.scala

+145-6
Original file line numberDiff line numberDiff line change
@@ -24,32 +24,36 @@
2424

2525
package org.hatdex.hat.api.controllers
2626

27-
import java.net.URLDecoder
28-
import javax.inject.Inject
27+
import java.net.{ URLDecoder, URLEncoder }
2928

29+
import akka.Done
30+
import javax.inject.Inject
3031
import com.mohiva.play.silhouette.api.repositories.AuthInfoRepository
3132
import com.mohiva.play.silhouette.api.util.{ Credentials, PasswordHasherRegistry }
3233
import com.mohiva.play.silhouette.api.{ LoginEvent, Silhouette }
3334
import com.mohiva.play.silhouette.impl.exceptions.{ IdentityNotFoundException, InvalidPasswordException }
3435
import com.mohiva.play.silhouette.impl.providers.CredentialsProvider
3536
import org.hatdex.hat.api.json.HatJsonFormats
3637
import org.hatdex.hat.api.models._
37-
import org.hatdex.hat.api.service.{ HatServicesService, MailTokenService, UsersService }
38+
import org.hatdex.hat.api.service.applications.ApplicationsService
39+
import org.hatdex.hat.api.service.{ HatServicesService, LogService, MailTokenService, UsersService }
3840
import org.hatdex.hat.authentication._
39-
import org.hatdex.hat.phata.models.{ ApiPasswordChange, ApiPasswordResetRequest, MailTokenUser }
41+
import org.hatdex.hat.phata.models._
4042
import org.hatdex.hat.resourceManagement.{ HatServerProvider, _ }
4143
import org.hatdex.hat.utils.{ HatBodyParsers, HatMailer }
42-
import play.api.Logger
44+
import play.api.{ Configuration, Logger }
4345
import play.api.cache.{ Cached, CachedBuilder }
4446
import play.api.libs.json.Json
45-
import play.api.mvc._
47+
import play.api.libs.ws.WSClient
48+
import play.api.mvc.{ Action, _ }
4649

4750
import scala.concurrent.ExecutionContext.Implicits.global
4851
import scala.concurrent.Future
4952

5053
class Authentication @Inject() (
5154
components: ControllerComponents,
5255
cached: Cached,
56+
configuration: Configuration,
5357
parsers: HatBodyParsers,
5458
hatServerProvider: HatServerProvider,
5559
silhouette: Silhouette[HatApiAuthEnvironment],
@@ -58,8 +62,11 @@ class Authentication @Inject() (
5862
passwordHasherRegistry: PasswordHasherRegistry,
5963
authInfoRepository: AuthInfoRepository[HatServer],
6064
usersService: UsersService,
65+
applicationsService: ApplicationsService,
66+
logService: LogService,
6167
mailer: HatMailer,
6268
tokenService: MailTokenService[MailTokenUser],
69+
wsClient: WSClient,
6370
limiter: UserLimiter) extends HatApiController(components, silhouette) with HatJsonFormats {
6471

6572
private val logger = Logger(this.getClass)
@@ -223,4 +230,136 @@ class Authentication @Inject() (
223230
Future.successful(Unauthorized(Json.toJson(ErrorMessage("Invalid Token", "Token does not exist"))))
224231
}
225232
}
233+
234+
/**
235+
* Sends an email to the owner with a link to claim the hat
236+
*/
237+
def handleClaimStart(): Action[ApiClaimHatRequest] = UserAwareAction.async(parsers.json[ApiClaimHatRequest]) { implicit request =>
238+
239+
val claimHatRequest = request.body
240+
val email = request.dynamicEnvironment.ownerEmail
241+
val response = Ok(Json.toJson(SuccessResponse("You will shortly receive an email with claim instructions")))
242+
243+
if (claimHatRequest.email == email) {
244+
usersService.listUsers.map(_.find(_.roles.contains(Owner()))).flatMap {
245+
case Some(user) =>
246+
applicationsService.applicationStatus()(request.dynamicEnvironment, user, request).flatMap { applications =>
247+
val maybeApplication = applications.find(_.application.id.equals(claimHatRequest.applicationId))
248+
val maybeAppDetails = maybeApplication.map { app =>
249+
((app.application.developer.name, app.application.developer.logo.map(_.normal).getOrElse("#")),
250+
(app.application.id, app.application.info.version.toString))
251+
}
252+
253+
val scheme = if (request.secure) {
254+
"https://"
255+
}
256+
else {
257+
"http://"
258+
}
259+
260+
tokenService.retrieve(email, isSignup = true).flatMap {
261+
case Some(existingTokenUser) if !existingTokenUser.isExpired =>
262+
val claimLink = s"$scheme${request.host}/#/hat/claim/${existingTokenUser.id}?email=${URLEncoder.encode(email, "UTF-8")}"
263+
mailer.claimHat(email, claimLink, maybeAppDetails.map(_._1))
264+
265+
Future.successful(response)
266+
case Some(_) => Future.successful(Ok(Json.toJson(SuccessResponse("The HAT is already claimed"))))
267+
268+
case None =>
269+
val token = MailClaimTokenUser(email)
270+
271+
val eventualResult = for {
272+
_ <- tokenService.create(token)
273+
_ <- logService
274+
.logAction(request.dynamicEnvironment.domain, LogRequest("unclaimed", None, None), maybeAppDetails.map(_._2))
275+
.recover {
276+
case e =>
277+
logger.error(s"LogActionError::unclaimed. Reason: ${e.getMessage}")
278+
Done
279+
}
280+
} yield {
281+
282+
val claimLink = s"$scheme${request.host}/#/hat/claim/${token.id}?email=${URLEncoder.encode(email, "UTF-8")}"
283+
mailer.claimHat(email, claimLink, maybeAppDetails.map(_._1))
284+
285+
response
286+
}
287+
288+
eventualResult.recover {
289+
case e =>
290+
logger.error(s"Could not create new HAT claim token. Reason: ${e.getMessage}")
291+
InternalServerError(Json.toJson(ErrorMessage("Internal Server Error", "Failed to initialize HAT claim process")))
292+
}
293+
}
294+
}
295+
case None => Future.successful(response)
296+
}
297+
}
298+
else {
299+
Future.successful(response)
300+
}
301+
}
302+
303+
def handleClaimComplete(claimToken: String): Action[HatClaimCompleteRequest] = UserAwareAction.async(parsers.json[HatClaimCompleteRequest]) { implicit request =>
304+
implicit val hatClaimComplete: HatClaimCompleteRequest = request.body
305+
306+
tokenService.retrieve(claimToken).flatMap {
307+
case Some(token) if token.isSignUp && !token.isExpired && token.email == request.dynamicEnvironment.ownerEmail =>
308+
usersService.listUsers.map(_.find(_.roles.contains(Owner()))).flatMap {
309+
case Some(user) =>
310+
val eventualResult = for {
311+
_ <- updateHatMembership(hatClaimComplete)
312+
_ <- authInfoRepository.update(user.loginInfo, passwordHasherRegistry.current.hash(request.body.password))
313+
_ <- tokenService.expire(token.id)
314+
authenticator <- env.authenticatorService.create(user.loginInfo)
315+
result <- env.authenticatorService.renew(authenticator, Ok(Json.toJson(SuccessResponse("HAT claimed"))))
316+
_ <- logService.logAction(request.dynamicEnvironment.domain, LogRequest("claimed", None, None), None).recover {
317+
case e =>
318+
logger.error(s"LogActionError::unclaimed. Reason: ${e.getMessage}")
319+
Done
320+
}
321+
} yield {
322+
//env.eventBus.publish(LoginEvent(user, request))
323+
//mailer.passwordChanged(token.email, user)
324+
325+
result
326+
}
327+
328+
eventualResult.recover {
329+
case e =>
330+
logger.error(s"HAT claim process failed with error ${e.getMessage}")
331+
BadRequest(Json.toJson(ErrorMessage("Bad Request", "HAT claim process failed")))
332+
}
333+
334+
case None => Future.successful(Unauthorized(Json.toJson(ErrorMessage("HAT claim unauthorized", "No user matching token"))))
335+
}
336+
337+
case Some(_) =>
338+
Future.successful(Unauthorized(Json.toJson(ErrorMessage("Invalid Token", "Token expired or invalid"))))
339+
340+
case None =>
341+
Future.successful(Unauthorized(Json.toJson(ErrorMessage("Invalid Token", "Token does not exist"))))
342+
}
343+
}
344+
345+
private def updateHatMembership(claim: HatClaimCompleteRequest): Future[Done] = {
346+
val path = "api/products/hat/claim"
347+
val hattersUrl = s"${configuration.underlying.getString("hatters.scheme")}${configuration.underlying.getString("hatters.address")}"
348+
349+
logger.info(s"Proxy POST request to $hattersUrl/$path with parameters: $claim")
350+
351+
val futureResponse = wsClient.url(s"$hattersUrl/$path")
352+
//.withHttpHeaders("x-auth-token" → token.accessToken)
353+
.post(Json.toJson(claim.copy(password = "")))
354+
355+
futureResponse.flatMap { response =>
356+
response.status match {
357+
case OK =>
358+
Future.successful(Done)
359+
case _ =>
360+
logger.error(s"Failed to claim HAT with Hatters. Claim details:\n$claim\nHatters response: ${response.body}")
361+
Future.failed(new UnknownError("HAT claim failed"))
362+
}
363+
}
364+
}
226365
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package org.hatdex.hat.api.controllers
2+
3+
import com.mohiva.play.silhouette.api.Silhouette
4+
import javax.inject.Inject
5+
import org.hatdex.hat.api.json.HatJsonFormats
6+
import org.hatdex.hat.api.models._
7+
import org.hatdex.hat.api.service.{ LogService, RemoteExecutionContext }
8+
import org.hatdex.hat.authentication.{ HatApiAuthEnvironment, HatApiController }
9+
import org.hatdex.hat.utils.HatBodyParsers
10+
import play.api.Logger
11+
import play.api.libs.json.Json
12+
import play.api.mvc.{ Action, ControllerComponents }
13+
14+
import scala.util.Try
15+
16+
class LogController @Inject() (
17+
components: ControllerComponents,
18+
parsers: HatBodyParsers,
19+
logService: LogService,
20+
silhouette: Silhouette[HatApiAuthEnvironment])(implicit val ec: RemoteExecutionContext)
21+
extends HatApiController(components, silhouette) with HatJsonFormats {
22+
23+
private val logger = Logger(this.getClass)
24+
25+
def logFrontendAction(): Action[LogRequest] = SecuredAction.async(parsers.json[LogRequest]) { request =>
26+
val logRequest = request.body
27+
val hatAddress = request.dynamicEnvironment.domain
28+
val appDetails = request.authenticator.customClaims.flatMap { customClaims =>
29+
Try(((customClaims \ "application").as[String], (customClaims \ "applicationVersion").as[String])).toOption
30+
}
31+
32+
logService.logAction(hatAddress, logRequest, appDetails).map { _ =>
33+
Ok(Json.toJson(SuccessResponse(s"${logRequest.actionCode} logged")))
34+
}.recover {
35+
case e =>
36+
logger.error(s"Failed to log action ${logRequest.actionCode}. Reason:\n${e.getMessage}")
37+
InternalServerError(Json.toJson(ErrorMessage("Internal server error", s"Could not log ${logRequest.actionCode} action")))
38+
}
39+
}
40+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright (C) 2019 HAT Data Exchange Ltd
3+
* SPDX-License-Identifier: AGPL-3.0
4+
*
5+
* This file is part of the Hub of All Things project (HAT).
6+
*
7+
* HAT is free software: you can redistribute it and/or modify
8+
* it under the terms of the GNU Affero General Public License
9+
* as published by the Free Software Foundation, version 3 of
10+
* the License.
11+
*
12+
* HAT is distributed in the hope that it will be useful, but
13+
* WITHOUT ANY WARRANTY; without even the implied warranty of
14+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
15+
* the GNU Affero General Public License for more details.
16+
*
17+
* You should have received a copy of the GNU Affero General
18+
* Public License along with this program. If not, see
19+
* <http://www.gnu.org/licenses/>.
20+
*
21+
* Written by Terry Lee <terry.lee@hatdex.org>
22+
* 2 / 2019
23+
*/
24+
25+
package org.hatdex.hat.api.service
26+
27+
import java.util.UUID
28+
29+
import akka.Done
30+
import javax.inject.Inject
31+
import org.hatdex.hat.api.models.LogRequest
32+
import play.api.Logger
33+
34+
import scala.concurrent.Future
35+
36+
class LogService @Inject() (implicit val ec: DalExecutionContext) {
37+
val logger = Logger(this.getClass)
38+
39+
def logAction(hat: String, logDetails: LogRequest, applicationDetails: Option[(String, String)]): Future[Done] = {
40+
Future {
41+
val logId = UUID.randomUUID()
42+
val applicationVersion = applicationDetails.map(a => s"${a._1}@${a._2}").getOrElse("Unknown")
43+
logger.info(s"[${logDetails.logGroup.getOrElse("STATS")}] [$hat] [$logId] [${logDetails.actionCode}] [$applicationVersion] ${logDetails.message.getOrElse("")}")
44+
45+
Done
46+
}
47+
}
48+
}

hat/app/org/hatdex/hat/api/service/MailTokenService.scala

+23-2
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,21 @@
2525
package org.hatdex.hat.api.service
2626

2727
import javax.inject.Inject
28-
2928
import akka.Done
3029
import org.hatdex.hat.dal.ModelTranslation
3130
import org.hatdex.hat.dal.Tables._
3231
import org.hatdex.hat.phata.models.{ MailToken, MailTokenUser }
3332
import org.hatdex.libs.dal.HATPostgresProfile.api._
33+
import org.joda.time.LocalDateTime
3434

3535
import scala.concurrent._
3636

3737
trait MailTokenService[T <: MailToken] {
3838
def create(token: T)(implicit db: Database): Future[Option[T]]
3939
def retrieve(id: String)(implicit db: Database): Future[Option[T]]
40+
def retrieve(email: String, isSignup: Boolean)(implicit db: Database): Future[Option[T]]
4041
def consume(id: String)(implicit db: Database): Future[Done]
42+
def expire(id: String)(implicit db: Database): Future[Done]
4143
}
4244

4345
class MailTokenUserService @Inject() (implicit val ec: DalExecutionContext) extends MailTokenService[MailTokenUser] {
@@ -50,13 +52,25 @@ class MailTokenUserService @Inject() (implicit val ec: DalExecutionContext) exte
5052
def consume(id: String)(implicit db: Database): Future[Done] = {
5153
delete(id)
5254
}
55+
def retrieve(email: String, isSignup: Boolean)(implicit db: Database): Future[Option[MailTokenUser]] = {
56+
findByEmailAndIsSignup(email, isSignup)
57+
}
58+
def expire(id: String)(implicit db: Database): Future[Done] = {
59+
expireNow(id)
60+
}
5361

5462
private def findById(id: String)(implicit db: Database): Future[Option[MailTokenUser]] = {
5563
db.run(UserMailTokens.filter(_.id === id).result).map { tokens =>
5664
tokens.headOption.map(ModelTranslation.fromDbModel)
5765
}
5866
}
5967

68+
private def findByEmailAndIsSignup(email: String, isSignup: Boolean)(implicit db: Database): Future[Option[MailTokenUser]] = {
69+
db.run(UserMailTokens.filter(t => t.email === email && t.isSignup === isSignup).result).map { tokens =>
70+
tokens.headOption.map(ModelTranslation.fromDbModel)
71+
}
72+
}
73+
6074
private def save(token: MailTokenUser)(implicit db: Database): Future[MailTokenUser] = {
6175
val query = (UserMailTokens returning UserMailTokens) += UserMailTokensRow(token.id, token.email, token.expirationTime.toLocalDateTime, token.isSignUp)
6276
db.run(query)
@@ -66,4 +80,11 @@ class MailTokenUserService @Inject() (implicit val ec: DalExecutionContext) exte
6680
private def delete(id: String)(implicit db: Database): Future[Done] = {
6781
db.run(UserMailTokens.filter(_.id === id).delete).map(_ Done)
6882
}
69-
}
83+
84+
private def expireNow(id: String)(implicit db: Database): Future[Done] = {
85+
db.run(UserMailTokens
86+
.filter(_.id === id)
87+
.map(_.expirationTime)
88+
.update(LocalDateTime.now())).map(_ => Done)
89+
}
90+
}

hat/app/org/hatdex/hat/phata/assets/js/main.bundle.js

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright (C) 2019 HAT Data Exchange Ltd
3+
* SPDX-License-Identifier: AGPL-3.0
4+
*
5+
* This file is part of the Hub of All Things project (HAT).
6+
*
7+
* HAT is free software: you can redistribute it and/or modify
8+
* it under the terms of the GNU Affero General Public License
9+
* as published by the Free Software Foundation, version 3 of
10+
* the License.
11+
*
12+
* HAT is distributed in the hope that it will be useful, but
13+
* WITHOUT ANY WARRANTY; without even the implied warranty of
14+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
15+
* the GNU Affero General Public License for more details.
16+
*
17+
* You should have received a copy of the GNU Affero General
18+
* Public License along with this program. If not, see
19+
* <http://www.gnu.org/licenses/>.
20+
*
21+
* Written by Terry Lee <terry.lee@hatdex.org>
22+
* 2 / 2019
23+
*/
24+
25+
package org.hatdex.hat.phata.models
26+
27+
import play.api.libs.json.{ JsPath, Json, Reads, Writes }
28+
import play.api.libs.functional.syntax._
29+
30+
case class ApiClaimHatRequest(applicationId: String, email: String)
31+
32+
object ApiClaimHatRequest {
33+
implicit val claimHatRequestApiReads: Reads[ApiClaimHatRequest] = (
34+
(JsPath \ "applicationId").read[String] and (JsPath \ "email").read[String](Reads.email))(ApiClaimHatRequest.apply _)
35+
36+
implicit val claimHatRequestApiWrites: Writes[ApiClaimHatRequest] = Json.format[ApiClaimHatRequest]
37+
38+
}
39+
40+
case class HatClaimCompleteRequest(
41+
email: String,
42+
termsAgreed: Boolean,
43+
optins: Array[String],
44+
hatName: String,
45+
hatCluster: String,
46+
password: String)
47+
48+
object HatClaimCompleteRequest {
49+
implicit val hatClaimRequestReads: Reads[HatClaimCompleteRequest] = Json.reads[HatClaimCompleteRequest]
50+
implicit val HatClaimRequestWrites: Writes[HatClaimCompleteRequest] = Json.format[HatClaimCompleteRequest]
51+
}

0 commit comments

Comments
 (0)