diff --git a/.gitignore b/.gitignore
index 2aa1ac895..0fb6180e6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,5 +5,4 @@
target/
*.log
.DS_Store
-application.conf
-data/
\ No newline at end of file
+data/
diff --git a/.scalafmt.conf b/.scalafmt.conf
index 08c18aa5e..ff8705982 100644
--- a/.scalafmt.conf
+++ b/.scalafmt.conf
@@ -1,9 +1,2 @@
-style = defaultWithAlign
-maxColumn = 120
-align.openParenCallSite = false
-align.openParenDefnSite = false
-danglingParentheses = true
-
-rewrite.rules = [RedundantBraces, RedundantParens, SortImports, PreferCurlyFors]
-rewrite.redundantBraces.includeUnitMethods = true
-rewrite.redundantBraces.stringInterpolation = true
+version = 2.0.0-RC8
+maxColumn = 140
diff --git a/.travis.yml b/.travis.yml
index 582607be9..eb08aa01b 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,23 +1,25 @@
language: scala
-scala:
- - 2.11.8
- - 2.12.4
-
-jdk:
- - oraclejdk8
-sudo: false
+scala:
+ - 2.12.8
before_cache:
- # Tricks to avoid unnecessary cache updates
- - find $HOME/.ivy2 -name "ivydata-*.properties" -delete
- - find $HOME/.sbt -name "*.lock" -delete
-
+ - du -h -d 1 $HOME/.ivy2/
+ - du -h -d 2 $HOME/.sbt/
+ - du -h -d 4 $HOME/.coursier/
+ - find $HOME/.sbt -name "*.lock" -type f -delete
+ - find $HOME/.ivy2/cache -name "ivydata-*.properties" -type f -delete
+ - find $HOME/.coursier/cache -name "*.lock" -type f -delete
cache:
directories:
+ - $HOME/.sbt/1.0
+ - $HOME/.sbt/boot/scala*
+ - $HOME/.sbt/cache
+ - $HOME/.sbt/launchers
- $HOME/.ivy2/cache
- - $HOME/.sbt/boot/
+ - $HOME/.coursier
- ui/node_modules
+ -
env:
- TRAVIS_NODE_VERSION="5"
@@ -27,4 +29,4 @@ install:
- (cd ui;npm install)
script:
- - sbt ++$TRAVIS_SCALA_VERSION -Dfile.encoding=UTF8 -J-XX:ReservedCodeCacheSize=256M test
+ - sbt ++$TRAVIS_SCALA_VERSION test
diff --git a/README.md b/README.md
index c354f4867..dd16bdc8c 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,6 @@
# Bootzooka
[![Build Status](https://travis-ci.org/softwaremill/bootzooka.svg?branch=master)](https://travis-ci.org/softwaremill/bootzooka)
-[![Dependencies](https://app.updateimpact.com/badge/634276070333485056/bootzooka.svg?config=compile)](https://app.updateimpact.com/latest/634276070333485056/bootzooka)
Bootzooka is a simple application scaffolding project to allow quick start of development for modern web based
applications.
diff --git a/activator.properties b/activator.properties
deleted file mode 100644
index b2283439c..000000000
--- a/activator.properties
+++ /dev/null
@@ -1,9 +0,0 @@
-name=bootzooka
-title=Bootzooka
-description=Simple project to quickly start developing a web application using AngularJS and Akka HTTP, without the need to write login, user registration etc.
-tags=scala,akka,angularjs,slick,akkahttp,reactive,seed
-authorName=SoftwareMill
-authorLink=https://softwaremill.com
-authorLogo=https://softwaremill.com/img/sml-200x140.png
-authorBio=Scala and the surrounding ecosystem are one of our specialities. We are a Typesafe consulting partner, we organize Scalar - a middle-European Scala conference, we do some open-source Scala projects (take a look at Reactive Kafka, MacWire, Bootzooka among others), and first and foremost, more and more of our projects are Scala/Akka/Play/(Angular|React) based.
-authorTwitter=@softwaremill
\ No newline at end of file
diff --git a/backend/src/main/resources/application.conf b/backend/src/main/resources/application.conf
new file mode 100644
index 000000000..3028185e0
--- /dev/null
+++ b/backend/src/main/resources/application.conf
@@ -0,0 +1,77 @@
+api {
+ host = "localhost"
+ host = ${?API_HOST}
+
+ port = 8080
+ port = ${?API_PORT}
+}
+
+db {
+ username = "postgres"
+ username = ${?SQL_USERNAME}
+
+ password = ""
+ password = ${?SQL_PASSWORD}
+
+ name = "bootzooka"
+ name = ${?SQL_DBNAME}
+ host = "localhost"
+ host = ${?SQL_HOST}
+ port = 25432
+ port = ${?SQL_PORT}
+
+ url = "jdbc:postgresql://"${db.host}":"${db.port}"/"${db.name}
+
+ migrate-on-start = true
+ migrate-on-start = ${?MIGRATE_ON_START}
+
+ driver = "org.postgresql.Driver"
+
+ connect-thread-pool-size = 32
+}
+
+email {
+ enabled = false
+ enabled = ${?EMAIL_ENABLED}
+
+ smtp {
+ host = ""
+ host = ${?EMAIL_HOST}
+
+ port = 25
+ port = ${?EMAIL_PORT}
+
+ username = ""
+ username = ${?EMAIL_USERNAME}
+
+ password = ""
+ password = ${?EMAIL_PASSWORD}
+
+ ssl-connection = false
+ ssl-connection = ${?EMAIL_SSL_CONNECTION}
+
+ verify-ssl-certificate = true
+ verify-ssl-certificate = ${?EMAIL_VERIFY_SSL_CERTIFICATE}
+ }
+
+ encoding = "UTF-8"
+ encoding = ${?EMAIL_ENCODING}
+
+ from = "info@bootzooka.com"
+ from = ${?EMAIL_FROM}
+
+ batch-size = 10
+ batch-size = ${?EMAIL_BATCH_SIZE}
+
+ email-send-interval = 1 second
+ email-send-interval = ${?EMAIL_SEND_INTERVAL}
+}
+
+password-reset {
+ reset-link-pattern = "http://localhost:8080/#/password-reset?code=%s"
+ code-valid-hours = 24
+}
+
+bootzooka {
+ default-api-key-valid-hours = 24
+}
diff --git a/backend/src/main/resources/db/migration/V1__create_schema.sql b/backend/src/main/resources/db/migration/V1__create_schema.sql
index 335b14d93..a0a5f3d92 100644
--- a/backend/src/main/resources/db/migration/V1__create_schema.sql
+++ b/backend/src/main/resources/db/migration/V1__create_schema.sql
@@ -1,35 +1,51 @@
-- USERS
-CREATE TABLE "users"(
- "id" UUID NOT NULL,
- "login" VARCHAR NOT NULL,
- "login_lowercase" VARCHAR NOT NULL,
- "email" VARCHAR NOT NULL NOT NULL,
- "password" VARCHAR NOT NULL NOT NULL,
- "salt" VARCHAR NOT NULL NOT NULL,
- "created_on" TIMESTAMP NOT NULL
+CREATE TABLE "users"
+(
+ "id" TEXT NOT NULL,
+ "login" TEXT NOT NULL,
+ "login_lowercase" TEXT NOT NULL,
+ "email_lowercase" TEXT NOT NULL,
+ "password" TEXT NOT NULL,
+ "created_on" TIMESTAMPTZ NOT NULL
);
-ALTER TABLE "users" ADD CONSTRAINT "users_id" PRIMARY KEY("id");
+ALTER TABLE "users"
+ ADD CONSTRAINT "users_id" PRIMARY KEY ("id");
+CREATE UNIQUE INDEX "users_login_lowercase" ON "users" ("login_lowercase");
+CREATE UNIQUE INDEX "users_email_lowercase" ON "users" ("email_lowercase");
+
+-- API KEYS
+CREATE TABLE "api_keys"
+(
+ "id" TEXT NOT NULL,
+ "user_id" TEXT NOT NULL,
+ "created_on" TIMESTAMPTZ NOT NULL,
+ "valid_until" TIMESTAMPTZ NOT NULL
+);
+ALTER TABLE "api_keys"
+ ADD CONSTRAINT "api_keys_id" PRIMARY KEY ("id");
+ALTER TABLE "api_keys"
+ ADD CONSTRAINT "api_keys_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- PASSWORD RESET CODES
-CREATE TABLE "password_reset_codes"(
- "id" UUID NOT NULL,
- "code" VARCHAR NOT NULL,
- "user_id" UUID NOT NULL,
- "valid_to" TIMESTAMP NOT NULL
+CREATE TABLE "password_reset_codes"
+(
+ "id" TEXT NOT NULL,
+ "user_id" TEXT NOT NULL,
+ "valid_until" TIMESTAMPTZ NOT NULL
);
-ALTER TABLE "password_reset_codes" ADD CONSTRAINT "password_reset_codes_id" PRIMARY KEY("id");
-ALTER TABLE "password_reset_codes" ADD CONSTRAINT "password_reset_codes_user_fk"
- FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "password_reset_codes"
+ ADD CONSTRAINT "password_reset_codes_id" PRIMARY KEY ("id");
+ALTER TABLE "password_reset_codes"
+ ADD CONSTRAINT "password_reset_codes_user_fk"
+ FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE;
--- REMEMBER ME TOKENS
-CREATE TABLE "remember_me_tokens"(
- "id" UUID NOT NULL,
- "selector" VARCHAR NOT NULL,
- "token_hash" VARCHAR NOT NULL,
- "user_id" UUID NOT NULL,
- "valid_to" TIMESTAMP NOT NULL
+-- EMAILS
+CREATE TABLE "scheduled_emails"
+(
+ "id" TEXT NOT NULL,
+ "recipient" TEXT NOT NULL,
+ "subject" TEXT NOT NULL,
+ "content" TEXT NOT NULL
);
-ALTER TABLE "remember_me_tokens" ADD CONSTRAINT "remember_me_tokens_id" PRIMARY KEY("id");
-ALTER TABLE "remember_me_tokens" ADD CONSTRAINT "remember_me_tokens_user_fk"
- FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-CREATE UNIQUE INDEX "remember_me_tokens_selector" ON "remember_me_tokens"("selector");
\ No newline at end of file
+ALTER TABLE "scheduled_emails"
+ ADD CONSTRAINT "scheduled_emails_id" PRIMARY KEY ("id");
diff --git a/backend/src/main/resources/db/migration/V2__add_indexes.sql b/backend/src/main/resources/db/migration/V2__add_indexes.sql
deleted file mode 100644
index b94b6228a..000000000
--- a/backend/src/main/resources/db/migration/V2__add_indexes.sql
+++ /dev/null
@@ -1,2 +0,0 @@
-CREATE UNIQUE INDEX "users_login_lowercase" ON "users"("login_lowercase");
-CREATE UNIQUE INDEX "users_email" ON "users"("email");
\ No newline at end of file
diff --git a/backend/src/main/resources/logback.xml b/backend/src/main/resources/logback.xml
index 184a8fcb9..5b89164be 100644
--- a/backend/src/main/resources/logback.xml
+++ b/backend/src/main/resources/logback.xml
@@ -3,25 +3,17 @@
-
- %d{HH:mm:ss.SSS} [%thread] %-5level %logger{5} - %msg%n%rEx
+ %d{HH:mm:ss.SSS}%boldYellow(%replace( [%X{cid}] ){' \[\] ', ' '})[%thread] %-5level %logger{5} - %msg%n%rEx
-
-
-
-
-
-
+
+
-
-
-
-
+
+
diff --git a/backend/src/main/resources/reference.conf b/backend/src/main/resources/reference.conf
deleted file mode 100644
index f0172e2d7..000000000
--- a/backend/src/main/resources/reference.conf
+++ /dev/null
@@ -1,103 +0,0 @@
-bootzooka {
- reset-link-pattern = "http://localhost:8080/#/password-reset?code=%s"
-
- db {
- h2 {
- queueSize = 3000
- numThreads = 16
- dataSourceClass = "org.h2.jdbcx.JdbcDataSource"
- properties = {
- url = "jdbc:h2:file:./data/bootzooka"
- }
- }
- postgres {
- queueSize = 3000
- # intended to match maxConnections below
- numThreads = 16
- dataSourceClass = "org.postgresql.ds.PGSimpleDataSource"
- maxConnections = 16
- properties = {
- serverName = ""
- portNumber = "5432"
- databaseName = ""
- user = ""
- password = ""
- }
- }
- }
-
- crypto {
- argon2 {
- iterations = 2
- memory = 16383
- parallelism = 4
- }
- }
-}
-
-email {
- enabled = false
- smtp-host = "smtp.gmail.com"
- smtp-port = "465"
- smtp-username = ""
- smtp-password = ""
- from = "notifications@example.com"
- encoding = "UTF-8"
- ssl-connection = true
- verify-ssl-certificate = true
-}
-
-server {
- host = "0.0.0.0"
- port = 8080
- port = ${?PORT}
-}
-
-akka.http.session {
- server-secret = "d07ll3lesrinf39t7mc5h6un6r0c69lgfno69dsak4vabeqamouq4328cuaekros401ajdpkh61aatpd8ro24rbuqmgtnd1ebag6ljnb65i8a55d482ok7o0nch0bfbe"
- server-secret = ${?SERVER_SECRET}
-}
-
-# the below dispatchers are to bulkhead layers and also not use default dispatcher
-akka-http-routes-dispatcher {
- # these are the default dispatcher settings
- type = "Dispatcher"
- executor = "fork-join-executor"
-
- fork-join-executor {
- parallelism-min = 8
- parallelism-factor = 3.0
- parallelism-max = 64
- }
-
- throughput = 5
-}
-
-dao-dispatcher {
- # these are the default dispatcher settings
- type = "Dispatcher"
- executor = "fork-join-executor"
-
- fork-join-executor {
- parallelism-min = 8
- parallelism-factor = 3.0
- parallelism-max = 64
- }
-
- throughput = 5
-}
-
-
-service-dispatcher {
- # these are the default dispatcher settings
- type = "Dispatcher"
- executor = "fork-join-executor"
-
- fork-join-executor {
- parallelism-min = 8
- parallelism-factor = 3.0
- parallelism-max = 64
- }
-
- throughput = 5
-}
diff --git a/backend/src/main/resources/templates/email/emailSignature.txt b/backend/src/main/resources/templates/email/emailSignature.txt
index 55e387057..4a8cbff88 100644
--- a/backend/src/main/resources/templates/email/emailSignature.txt
+++ b/backend/src/main/resources/templates/email/emailSignature.txt
@@ -2,4 +2,4 @@
--
Regards,
SoftwareMill Bootzooka Dev Team
-http://SoftwareMill.com
\ No newline at end of file
+http://softwaremill.com
diff --git a/backend/src/main/resources/templates/email/resetPassword.txt b/backend/src/main/resources/templates/email/resetPassword.txt
index cd8d6af90..47536c4e7 100644
--- a/backend/src/main/resources/templates/email/resetPassword.txt
+++ b/backend/src/main/resources/templates/email/resetPassword.txt
@@ -1,7 +1,6 @@
SoftwareMill Bootzooka password reset
-
Dear {{userName}},
To be able to set a new password, please visit the link below:
-{{resetLink}}
\ No newline at end of file
+{{resetLink}}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/BaseModule.scala b/backend/src/main/scala/com/softwaremill/bootzooka/BaseModule.scala
new file mode 100644
index 000000000..15ae687e9
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/BaseModule.scala
@@ -0,0 +1,8 @@
+package com.softwaremill.bootzooka
+
+trait BaseModule {
+ def idGenerator: IdGenerator
+ def clock: Clock
+ def config: Config
+}
+
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/BootzookaConfig.scala b/backend/src/main/scala/com/softwaremill/bootzooka/BootzookaConfig.scala
new file mode 100644
index 000000000..57c7acd93
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/BootzookaConfig.scala
@@ -0,0 +1,3 @@
+package com.softwaremill.bootzooka
+
+case class BootzookaConfig(defaultApiKeyValidHours: Int)
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/Clock.scala b/backend/src/main/scala/com/softwaremill/bootzooka/Clock.scala
new file mode 100644
index 000000000..8a63ceee7
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/Clock.scala
@@ -0,0 +1,11 @@
+package com.softwaremill.bootzooka
+
+import java.time.Instant
+
+trait Clock {
+ def now(): Instant
+}
+
+object DefaultClock extends Clock {
+ override def now(): Instant = Instant.now()
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/Config.scala b/backend/src/main/scala/com/softwaremill/bootzooka/Config.scala
new file mode 100644
index 000000000..e08194e16
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/Config.scala
@@ -0,0 +1,7 @@
+package com.softwaremill.bootzooka
+
+import com.softwaremill.bootzooka.email.EmailConfig
+import com.softwaremill.bootzooka.infrastructure.{DBConfig, HttpConfig}
+import com.softwaremill.bootzooka.passwordreset.PasswordResetConfig
+
+case class Config(db: DBConfig, api: HttpConfig, email: EmailConfig, passwordReset: PasswordResetConfig, bootzooka: BootzookaConfig)
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/ConfigModule.scala b/backend/src/main/scala/com/softwaremill/bootzooka/ConfigModule.scala
new file mode 100644
index 000000000..e342debcd
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/ConfigModule.scala
@@ -0,0 +1,37 @@
+package com.softwaremill.bootzooka
+
+import com.softwaremill.bootzooka.version.BuildInfo
+import com.softwaremill.tagging.@@
+import com.typesafe.scalalogging.StrictLogging
+import pureconfig.ConfigReader
+import pureconfig.generic.auto._
+
+import scala.collection.immutable.TreeMap
+
+trait ConfigModule extends StrictLogging {
+
+ private implicit def idReader[T]: ConfigReader[Id @@ T] = ConfigReader[String].map(_.asId)
+
+ lazy val config: Config = pureconfig.loadConfigOrThrow[Config]
+
+ def logConfig(): Unit = {
+ val baseInfo = s"""
+ |Bootzooka configuration:
+ |-----------------------
+ |DB: ${config.db}
+ |API: ${config.api}
+ |Email: ${config.email}
+ |Password reset: ${config.passwordReset}
+ |Bootzooka: ${config.bootzooka}
+ |
+ |Build & env info:
+ |-----------------
+ |""".stripMargin
+
+ val info = TreeMap(BuildInfo.toMap.toSeq: _*).foldLeft(baseInfo) {
+ case (str, (k, v)) => str + s"$k: $v\n"
+ }
+
+ logger.info(info)
+ }
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/DependencyWiring.scala b/backend/src/main/scala/com/softwaremill/bootzooka/DependencyWiring.scala
deleted file mode 100644
index af7908e2e..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/DependencyWiring.scala
+++ /dev/null
@@ -1,68 +0,0 @@
-package com.softwaremill.bootzooka
-
-import akka.actor.ActorSystem
-import com.softwaremill.bootzooka.common.crypto.{Argon2dPasswordHashing, CryptoConfig, PasswordHashing}
-import com.softwaremill.bootzooka.common.sql.{DatabaseConfig, SqlDatabase}
-import com.softwaremill.bootzooka.email.application.{
- DummyEmailService,
- EmailConfig,
- EmailTemplatingEngine,
- SmtpEmailService
-}
-import com.softwaremill.bootzooka.passwordreset.application.{
- PasswordResetCodeDao,
- PasswordResetConfig,
- PasswordResetService
-}
-import com.softwaremill.bootzooka.user.application.{RefreshTokenStorageImpl, RememberMeTokenDao, UserDao, UserService}
-import com.typesafe.config.ConfigFactory
-import com.typesafe.scalalogging.StrictLogging
-
-trait DependencyWiring extends StrictLogging {
- def system: ActorSystem
-
- lazy val config = new PasswordResetConfig with EmailConfig with DatabaseConfig with ServerConfig with CryptoConfig {
- override def rootConfig = ConfigFactory.load()
- }
-
- lazy val passwordHashing: PasswordHashing = new Argon2dPasswordHashing(config)
-
- lazy val daoExecutionContext = system.dispatchers.lookup("dao-dispatcher")
-
- lazy val userDao = new UserDao(sqlDatabase)(daoExecutionContext)
-
- lazy val codeDao = new PasswordResetCodeDao(sqlDatabase)(daoExecutionContext)
-
- lazy val rememberMeTokenDao = new RememberMeTokenDao(sqlDatabase)(daoExecutionContext)
-
- lazy val sqlDatabase = SqlDatabase.create(config)
-
- lazy val serviceExecutionContext = system.dispatchers.lookup("service-dispatcher")
-
- lazy val emailService = if (config.emailEnabled) {
- new SmtpEmailService(config)(serviceExecutionContext)
- } else {
- logger.info("Starting with fake email sending service. No emails will be sent.")
- new DummyEmailService
- }
-
- lazy val emailTemplatingEngine = new EmailTemplatingEngine
-
- lazy val userService = new UserService(
- userDao,
- emailService,
- emailTemplatingEngine,
- passwordHashing
- )(serviceExecutionContext)
-
- lazy val passwordResetService = new PasswordResetService(
- userDao,
- codeDao,
- emailService,
- emailTemplatingEngine,
- config,
- passwordHashing
- )(serviceExecutionContext)
-
- lazy val refreshTokenStorage = new RefreshTokenStorageImpl(rememberMeTokenDao, system)(serviceExecutionContext)
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/Fail.scala b/backend/src/main/scala/com/softwaremill/bootzooka/Fail.scala
new file mode 100644
index 000000000..f32a8d78f
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/Fail.scala
@@ -0,0 +1,15 @@
+package com.softwaremill.bootzooka
+
+abstract class Fail extends Exception
+
+object Fail {
+ case class NotFound(what: String) extends Fail
+ case class Conflict(msg: String) extends Fail
+ case class IncorrectInput(msg: String) extends Fail
+ case object Unauthorized extends Fail
+ case object Forbidden extends Fail
+}
+
+trait ClassName[T] {
+ def show: String
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/HttpAPIModule.scala b/backend/src/main/scala/com/softwaremill/bootzooka/HttpAPIModule.scala
new file mode 100644
index 000000000..0b5ba3eb4
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/HttpAPIModule.scala
@@ -0,0 +1,64 @@
+package com.softwaremill.bootzooka
+
+import cats.effect.ExitCode
+import com.softwaremill.bootzooka.infrastructure.{CorrelationId, Http}
+import io.prometheus.client.CollectorRegistry
+import monix.eval.Task
+import monix.execution.Scheduler.Implicits.global
+import org.http4s.metrics.prometheus.Prometheus
+import org.http4s.server.Router
+import org.http4s.server.blaze.BlazeServerBuilder
+import org.http4s.server.middleware.{CORS, CORSConfig, Metrics}
+import org.http4s.syntax.kleisli._
+import org.http4s.{HttpApp, HttpRoutes}
+import tapir.docs.openapi._
+import tapir.openapi.Server
+import tapir.openapi.circe.yaml._
+import tapir.server.http4s._
+import tapir.swagger.http4s.SwaggerHttp4s
+
+trait HttpAPIModule extends BaseModule {
+ private val apiContextPath = "api/v1"
+ private val docsContextPath = s"$apiContextPath/docs"
+
+ def endpoints: ServerEndpoints
+ def metricsEndpoints: ServerEndpoints
+ def http: Http
+
+ lazy val httpRoutes: HttpRoutes[Task] = CorrelationId.setCorrelationIdMiddleware(toRoutes(endpoints))
+ lazy val corsConfig: CORSConfig = CORS.DefaultCORSConfig
+ lazy val docsRoutes: HttpRoutes[Task] = {
+ val openapi = endpoints.toList.toOpenAPI("Bootzooka", "1.0").copy(servers = List(Server(s"/$apiContextPath", None)))
+ val yaml = openapi.toYaml
+ new SwaggerHttp4s(yaml, docsContextPath).routes[Task]
+ }
+
+ lazy val serveHttp: fs2.Stream[Task, ExitCode] = {
+ implicit val collectorRegistry: CollectorRegistry = CollectorRegistry.defaultRegistry
+ val prometheusHttp4sMetrics = Prometheus[Task](collectorRegistry)
+ fs2.Stream
+ .eval(prometheusHttp4sMetrics.map(m => Metrics[Task](m)(httpRoutes)))
+ .flatMap { monitoredServices =>
+ val app: HttpApp[Task] =
+ Router(
+ s"/$apiContextPath" -> CORS(monitoredServices, corsConfig),
+ "/metrics" -> toRoutes(metricsEndpoints),
+ s"/$docsContextPath" -> docsRoutes
+ ).orNotFound
+
+ BlazeServerBuilder[Task]
+ .bindHttp(config.api.port, config.api.host)
+ .withHttpApp(app)
+ .serve
+ }
+ }
+
+ private def toRoutes(es: ServerEndpoints): HttpRoutes[Task] = {
+ implicit val serverOptions: Http4sServerOptions[Task] = Http4sServerOptions
+ .default[Task]
+ .copy(
+ decodeFailureHandler = http.decodeFailureHandler
+ )
+ es.toList.toRoutes
+ }
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/IdGenerator.scala b/backend/src/main/scala/com/softwaremill/bootzooka/IdGenerator.scala
new file mode 100644
index 000000000..9f8734a97
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/IdGenerator.scala
@@ -0,0 +1,12 @@
+package com.softwaremill.bootzooka
+
+import com.softwaremill.tagging._
+import tsec.common.SecureRandomId
+
+trait IdGenerator {
+ def nextId[U](): Id @@ U
+}
+
+object DefaultIdGenerator extends IdGenerator {
+ override def nextId[U](): Id @@ U = SecureRandomId.Strong.generate.taggedWith[U]
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/InitModule.scala b/backend/src/main/scala/com/softwaremill/bootzooka/InitModule.scala
new file mode 100644
index 000000000..e0a24f7fe
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/InitModule.scala
@@ -0,0 +1,7 @@
+package com.softwaremill.bootzooka
+
+import com.softwaremill.bootzooka.infrastructure.DB
+
+trait InitModule extends ConfigModule {
+ lazy val db: DB = new DB(config.db)
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/LowerCased.scala b/backend/src/main/scala/com/softwaremill/bootzooka/LowerCased.scala
new file mode 100644
index 000000000..cbd519c01
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/LowerCased.scala
@@ -0,0 +1,3 @@
+package com.softwaremill.bootzooka
+
+trait LowerCased
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/Main.scala b/backend/src/main/scala/com/softwaremill/bootzooka/Main.scala
index 15730262b..8ed61ef5e 100644
--- a/backend/src/main/scala/com/softwaremill/bootzooka/Main.scala
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/Main.scala
@@ -1,66 +1,26 @@
package com.softwaremill.bootzooka
-import java.util.Locale
-
-import akka.actor.ActorSystem
-import akka.http.scaladsl.Http
-import akka.http.scaladsl.Http.ServerBinding
-import akka.stream.ActorMaterializer
-import com.softwaremill.bootzooka.user.application.Session
-import com.softwaremill.session.{SessionConfig, SessionManager}
-import com.typesafe.scalalogging.StrictLogging
-
-import scala.concurrent.Future
-import scala.util.{Failure, Success}
-
-class Main() extends StrictLogging {
- def start(): (Future[ServerBinding], DependencyWiring) = {
- Locale.setDefault(Locale.US) // set default locale to prevent from sending cookie expiration date in polish format
-
- implicit val _system = ActorSystem("main")
- implicit val _materializer = ActorMaterializer()
- import _system.dispatcher
-
- val modules = new DependencyWiring with Routes {
-
- lazy val sessionConfig = SessionConfig.fromConfig(config.rootConfig).copy(sessionEncryptData = true)
+import com.softwaremill.bootzooka.infrastructure.CorrelationId
+import doobie.util.transactor
+import monix.eval.Task
+import monix.execution.Scheduler.Implicits.global
+
+object Main {
+ def main(args: Array[String]): Unit = {
+ CorrelationId.init()
+
+ val initModule = new InitModule {}
+ initModule.logConfig()
+
+ val mainTask = initModule.db.transactorResource.use { _xa =>
+ val modules = new MainModule {
+ override def xa: transactor.Transactor[Task] = _xa
+ override def config: Config = initModule.config
+ }
- implicit lazy val ec = _system.dispatchers.lookup("akka-http-routes-dispatcher")
- implicit lazy val sessionManager: SessionManager[Session] = new SessionManager[Session](sessionConfig)
- implicit lazy val materializer = _materializer
- lazy val system = _system
+ (modules.backgroundProcesses ++ modules.serveHttp).compile.drain
}
- logger.info("Server secret: " + modules.sessionConfig.serverSecret.take(3) + "...")
-
- modules.sqlDatabase.updateSchema()
-
- (Http().bindAndHandle(modules.routes, modules.config.serverHost, modules.config.serverPort), modules)
- }
-}
-
-object Main extends App with StrictLogging {
- val (startFuture, bl) = new Main().start()
-
- val host = bl.config.serverHost
- val port = bl.config.serverPort
-
- val system = bl.system
- import system.dispatcher
-
- startFuture.onComplete {
- case Success(b) =>
- logger.info(s"Server started on $host:$port")
- sys.addShutdownHook {
- b.unbind()
- bl.system.terminate()
- logger.info("Server stopped")
- }
- case Failure(e) =>
- logger.error(s"Cannot start server on $host:$port", e)
- sys.addShutdownHook {
- bl.system.terminate()
- logger.info("Server stopped")
- }
+ mainTask.runSyncUnsafe()
}
}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/MainModule.scala b/backend/src/main/scala/com/softwaremill/bootzooka/MainModule.scala
new file mode 100644
index 000000000..a103fe2e2
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/MainModule.scala
@@ -0,0 +1,32 @@
+package com.softwaremill.bootzooka
+
+import cats.data.NonEmptyList
+import com.softwaremill.bootzooka.email.EmailModule
+import com.softwaremill.bootzooka.infrastructure.Http
+import com.softwaremill.bootzooka.metrics.MetricsModule
+import com.softwaremill.bootzooka.passwordreset.PasswordResetModule
+import com.softwaremill.bootzooka.security.SecurityModule
+import com.softwaremill.bootzooka.user.UserModule
+import doobie.util.transactor.Transactor
+import io.prometheus.client.CollectorRegistry
+import monix.eval.Task
+
+trait MainModule
+ extends SecurityModule
+ with EmailModule
+ with UserModule
+ with PasswordResetModule
+ with MetricsModule
+ with HttpAPIModule {
+
+ override lazy val idGenerator: IdGenerator = DefaultIdGenerator
+ override lazy val clock: Clock = DefaultClock
+ override lazy val collectorRegistry: CollectorRegistry = CollectorRegistry.defaultRegistry
+ override lazy val http: Http = new Http(xa)
+
+ lazy val endpoints: ServerEndpoints = userApi.endpoints concatNel passwordResetApi.endpoints
+ lazy val metricsEndpoints: ServerEndpoints = NonEmptyList.of(metricsApi.metricsEndpoint)
+ lazy val backgroundProcesses: fs2.Stream[Task, Nothing] = fs2.Stream.eval_(emailService.startSender())
+
+ def xa: Transactor[Task]
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/Routes.scala b/backend/src/main/scala/com/softwaremill/bootzooka/Routes.scala
deleted file mode 100644
index 8fbdf4157..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/Routes.scala
+++ /dev/null
@@ -1,28 +0,0 @@
-package com.softwaremill.bootzooka
-
-import akka.actor.ActorSystem
-import akka.http.scaladsl.server.Directives._
-import com.softwaremill.bootzooka.common.api.RoutesRequestWrapper
-import com.softwaremill.bootzooka.passwordreset.api.PasswordResetRoutes
-import com.softwaremill.bootzooka.swagger.SwaggerDocService
-import com.softwaremill.bootzooka.user.api.UsersRoutes
-import com.softwaremill.bootzooka.version.VersionRoutes
-
-trait Routes extends RoutesRequestWrapper with UsersRoutes with PasswordResetRoutes with VersionRoutes {
-
- def system: ActorSystem
- def config: ServerConfig
-
- lazy val routes = requestWrapper {
- pathPrefix("api") {
- passwordResetRoutes ~
- usersRoutes ~
- versionRoutes
- } ~
- getFromResourceDirectory("webapp") ~
- new SwaggerDocService(config.serverHost, config.serverPort, system).routes ~
- path("") {
- getFromResource("webapp/index.html")
- }
- }
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/ServerConfig.scala b/backend/src/main/scala/com/softwaremill/bootzooka/ServerConfig.scala
deleted file mode 100644
index 3d3403d39..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/ServerConfig.scala
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.softwaremill.bootzooka
-
-import com.typesafe.config.Config
-
-trait ServerConfig {
- def rootConfig: Config
-
- lazy val serverHost: String = rootConfig.getString("server.host")
- lazy val serverPort: Int = rootConfig.getInt("server.port")
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/bootzooka.scala b/backend/src/main/scala/com/softwaremill/bootzooka/bootzooka.scala
new file mode 100644
index 000000000..f27617bc0
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/bootzooka.scala
@@ -0,0 +1,20 @@
+package com.softwaremill
+
+import java.util.Locale
+
+import cats.data.NonEmptyList
+import com.softwaremill.tagging._
+import monix.eval.Task
+import tapir.server.ServerEndpoint
+import tsec.common.SecureRandomId
+
+package object bootzooka {
+ type Id = SecureRandomId
+
+ implicit class RichString(val s: String) extends AnyVal {
+ def asId[T]: Id @@ T = s.asInstanceOf[Id @@ T]
+ def lowerCased: String @@ LowerCased = s.toLowerCase(Locale.ENGLISH).taggedWith[LowerCased]
+ }
+
+ type ServerEndpoints = NonEmptyList[ServerEndpoint[_, _, _, Nothing, Task]]
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/common/ConfigWithDefault.scala b/backend/src/main/scala/com/softwaremill/bootzooka/common/ConfigWithDefault.scala
deleted file mode 100644
index 12a9d6568..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/common/ConfigWithDefault.scala
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.softwaremill.bootzooka.common
-
-import java.util.concurrent.TimeUnit
-
-import com.typesafe.config.Config
-
-trait ConfigWithDefault {
-
- def rootConfig: Config
-
- def getBoolean(path: String, default: Boolean) = ifHasPath(path, default) { _.getBoolean(path) }
- def getString(path: String, default: String) = ifHasPath(path, default) { _.getString(path) }
- def getInt(path: String, default: Int) = ifHasPath(path, default) { _.getInt(path) }
- def getConfig(path: String, default: Config) = ifHasPath(path, default) { _.getConfig(path) }
- def getMilliseconds(path: String, default: Long) = ifHasPath(path, default) {
- _.getDuration(path, TimeUnit.MILLISECONDS)
- }
- def getOptionalString(path: String, default: Option[String] = None) = getOptional(path) { _.getString(path) }
-
- private def ifHasPath[T](path: String, default: T)(get: Config => T): T =
- if (rootConfig.hasPath(path)) get(rootConfig) else default
-
- private def getOptional[T](fullPath: String, default: Option[T] = None)(get: Config => T) =
- if (rootConfig.hasPath(fullPath)) {
- Some(get(rootConfig))
- } else {
- default
- }
-
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/common/FutureHelpers.scala b/backend/src/main/scala/com/softwaremill/bootzooka/common/FutureHelpers.scala
deleted file mode 100644
index b8fb5cdd7..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/common/FutureHelpers.scala
+++ /dev/null
@@ -1,9 +0,0 @@
-package com.softwaremill.bootzooka.common
-
-import scala.concurrent.{ExecutionContext, Future}
-
-object FutureHelpers {
- implicit class PimpedFuture[T](future: Future[T])(implicit val ec: ExecutionContext) {
- def mapToUnit = future.map(_ => ())
- }
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/common/Utils.scala b/backend/src/main/scala/com/softwaremill/bootzooka/common/Utils.scala
deleted file mode 100644
index a716ee988..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/common/Utils.scala
+++ /dev/null
@@ -1,87 +0,0 @@
-package com.softwaremill.bootzooka.common
-
-import java.security.SecureRandom
-
-object Utils {
- private val secureRandom = new SecureRandom()
-
- def randomString(length: Int, bound: Int = 25, range: Int = 65) = {
- val sb = new StringBuffer()
-
- for (i <- 1 to length) {
- sb.append((secureRandom.nextInt(bound) + range).toChar) // A - Z
- }
-
- sb.toString
- }
-
- /**
- * Based on scala.xml.Utility.escape.
- * Escapes the characters < > & and " from string.
- */
- def escapeHtml(text: String): String = {
- object Escapes {
-
- /**
- * For reasons unclear escape and unescape are a long ways from
- * being logical inverses.
- */
- val pairs = Map(
- "lt" -> '<',
- "gt" -> '>',
- "amp" -> '&',
- "quot" -> '"'
- // enigmatic comment explaining why this isn't escaped --
- // is valid xhtml but not html, and IE doesn't know it, says jweb
- // "apos" -> '\''
- )
- val escMap = pairs map { case (s, c) => c -> ("&%s;" format s) }
- val unescMap = pairs ++ Map("apos" -> '\'')
- }
-
- /**
- * Appends escaped string to `s`.
- */
- def escape(text: String, s: StringBuilder): StringBuilder = {
- // Implemented per XML spec:
- // http://www.w3.org/International/questions/qa-controls
- // imperative code 3x-4x faster than current implementation
- // dpp (David Pollak) 2010/02/03
- val len = text.length
- var pos = 0
- while (pos < len) {
- text.charAt(pos) match {
- case '<' => s.append("<")
- case '>' => s.append(">")
- case '&' => s.append("&")
- case '"' => s.append(""")
- case '\n' => s.append('\n')
- case '\r' => s.append('\r')
- case '\t' => s.append('\t')
- case c => if (c >= ' ') s.append(c)
- }
-
- pos += 1
- }
- s
- }
-
- val sb = new StringBuilder
- escape(text, sb)
- sb.toString()
- }
-
- // Do not change this unless you understand the security issues behind timing attacks.
- // This method intentionally runs in constant time if the two strings have the same length.
- // If it didn't, it would be vulnerable to a timing attack.
- def constantTimeEquals(a: String, b: String): Boolean =
- if (a.length != b.length) {
- false
- } else {
- var equal = 0
- for (i <- Array.range(0, a.length)) {
- equal |= a(i) ^ b(i)
- }
- equal == 0
- }
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/common/api/CirceEncoders.scala b/backend/src/main/scala/com/softwaremill/bootzooka/common/api/CirceEncoders.scala
deleted file mode 100644
index 55e358c16..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/common/api/CirceEncoders.scala
+++ /dev/null
@@ -1,26 +0,0 @@
-package com.softwaremill.bootzooka.common.api
-
-import java.time.OffsetDateTime
-import java.time.format.DateTimeFormatter
-import java.util.UUID
-
-import akka.http.scaladsl.model.StatusCodes.ClientError
-import io.circe._
-import io.circe.syntax._
-
-trait CirceEncoders {
-
- val dateTimeFormat = DateTimeFormatter.ISO_DATE_TIME
-
- implicit object DateTimeEncoder extends Encoder[OffsetDateTime] {
- override def apply(dt: OffsetDateTime): Json = dateTimeFormat.format(dt).asJson
- }
-
- implicit object UuidEncoder extends Encoder[UUID] {
- override def apply(u: UUID): Json = u.toString.asJson
- }
-
- implicit object ClientErrorEncoder extends Encoder[ClientError] {
- override def apply(a: ClientError): Json = a.defaultMessage.asJson
- }
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/common/api/RoutesRequestWrapper.scala b/backend/src/main/scala/com/softwaremill/bootzooka/common/api/RoutesRequestWrapper.scala
deleted file mode 100644
index b3ea0f208..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/common/api/RoutesRequestWrapper.scala
+++ /dev/null
@@ -1,32 +0,0 @@
-package com.softwaremill.bootzooka.common.api
-
-import akka.http.scaladsl.model.StatusCodes
-import akka.http.scaladsl.server.Directives._
-import akka.http.scaladsl.server.{ExceptionHandler, RejectionHandler}
-import com.typesafe.scalalogging.StrictLogging
-
-trait RoutesRequestWrapper extends CacheSupport with SecuritySupport with StrictLogging {
-
- private val exceptionHandler = ExceptionHandler {
- case e: Exception =>
- logger.error(s"Exception during client request processing: ${e.getMessage}", e)
- _.complete(StatusCodes.InternalServerError, "Internal server error")
- }
-
- private val rejectionHandler = RejectionHandler.default
- private val logDuration = extractRequestContext.flatMap { ctx =>
- val start = System.currentTimeMillis()
- // handling rejections here so that we get proper status codes
- mapResponse { resp =>
- val d = System.currentTimeMillis() - start
- logger.info(s"[${resp.status.intValue()}] ${ctx.request.method.name} ${ctx.request.uri} took: ${d}ms")
- resp
- } & handleRejections(rejectionHandler)
- }
-
- val requestWrapper = logDuration &
- handleExceptions(exceptionHandler) &
- cacheImages &
- addSecurityHeaders &
- encodeResponse
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/common/api/RoutesSupport.scala b/backend/src/main/scala/com/softwaremill/bootzooka/common/api/RoutesSupport.scala
deleted file mode 100644
index 069a14dd3..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/common/api/RoutesSupport.scala
+++ /dev/null
@@ -1,91 +0,0 @@
-package com.softwaremill.bootzooka.common.api
-
-import akka.http.scaladsl.marshalling._
-import akka.http.scaladsl.model._
-import akka.http.scaladsl.model.headers.CacheDirectives._
-import akka.http.scaladsl.model.headers.{`Cache-Control`, `Last-Modified`, _}
-import akka.http.scaladsl.server.Directive1
-import akka.http.scaladsl.server.Directives._
-import akka.http.scaladsl.unmarshalling.{FromEntityUnmarshaller, Unmarshaller}
-import akka.stream.Materializer
-import com.softwaremill.bootzooka.common.api.`X-Content-Type-Options`.`nosniff`
-import com.softwaremill.bootzooka.common.api.`X-Frame-Options`.`DENY`
-import com.softwaremill.bootzooka.common.api.`X-XSS-Protection`.`1; mode=block`
-import io.circe._
-import io.circe.jawn.decode
-import cats.implicits._
-
-trait RoutesSupport extends JsonSupport {
- def completeOk = complete("ok")
-}
-
-trait JsonSupport extends CirceEncoders {
-
- implicit def materializer: Materializer
-
- implicit def circeUnmarshaller[A <: Product: Manifest](implicit d: Decoder[A]): FromEntityUnmarshaller[A] =
- Unmarshaller.byteStringUnmarshaller
- .forContentTypes(MediaTypes.`application/json`)
- .mapWithCharset { (data, charset) =>
- val input =
- if (charset == HttpCharsets.`UTF-8`) data.utf8String else data.decodeString(charset.nioCharset.name)
- decode[A](input) match {
- case Right(obj) => obj
- case Left(failure) => throw new IllegalArgumentException(failure.getMessage, failure.getCause)
- }
- }
-
- implicit def circeMarshaller[A <: AnyRef](implicit e: Encoder[A], cbs: CanBeSerialized[A]): ToEntityMarshaller[A] =
- Marshaller.StringMarshaller.wrap(MediaTypes.`application/json`) {
- e(_).noSpaces
- }
-
- /**
- * To limit what data can be serialized to the client, only classes of type `T` for which an implicit
- * `CanBeSerialized[T]` value is in scope will be allowed. You only need to provide an implicit for the base value,
- * any containers like `List` or `Option` will be automatically supported.
- */
- trait CanBeSerialized[T]
-
- object CanBeSerialized {
- def apply[T] = new CanBeSerialized[T] {}
- implicit def listCanBeSerialized[T](implicit cbs: CanBeSerialized[T]): CanBeSerialized[List[T]] = null
- implicit def setCanBeSerialized[T](implicit cbs: CanBeSerialized[T]): CanBeSerialized[Set[T]] = null
- implicit def optionCanBeSerialized[T](implicit cbs: CanBeSerialized[T]): CanBeSerialized[Option[T]] = null
- }
-
-}
-
-trait CacheSupport {
-
- import akka.http.scaladsl.model.DateTime
-
- private val doNotCacheResponse = respondWithHeaders(
- `Last-Modified`(DateTime.now),
- `Expires`(DateTime.now),
- `Cache-Control`(`no-cache`, `no-store`, `must-revalidate`, `max-age`(0))
- )
- private val cacheSeconds = 60L * 60L * 24L * 30L
- private val cacheResponse = respondWithHeaders(
- `Expires`(DateTime(System.currentTimeMillis() + cacheSeconds * 1000L)),
- `Cache-Control`(`public`, `max-age`(cacheSeconds))
- )
-
- private def extensionTest(ext: String): Directive1[String] = pathSuffixTest((".*\\." + ext + "$").r)
-
- private def extensionsTest(exts: String*): Directive1[String] = exts.map(extensionTest).reduceLeft(_ | _)
-
- val cacheImages =
- extensionsTest("png", "svg", "gif", "woff", "jpg").flatMap { _ =>
- cacheResponse
- } |
- doNotCacheResponse
-}
-
-trait SecuritySupport {
- val addSecurityHeaders = respondWithHeaders(
- `X-Frame-Options`(`DENY`),
- `X-Content-Type-Options`(`nosniff`),
- `X-XSS-Protection`(`1; mode=block`)
- )
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/common/api/securityHeaders.scala b/backend/src/main/scala/com/softwaremill/bootzooka/common/api/securityHeaders.scala
deleted file mode 100644
index 689e60560..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/common/api/securityHeaders.scala
+++ /dev/null
@@ -1,86 +0,0 @@
-package com.softwaremill.bootzooka.common.api
-
-import akka.http.scaladsl.model.headers.{ModeledCustomHeader, ModeledCustomHeaderCompanion}
-
-import scala.util.Try
-
-// https://www.owasp.org/index.php/List_of_useful_HTTP_headers
-// https://www.owasp.org/index.php/Clickjacking_Defense_Cheat_Sheet
-private[api] sealed trait SecurityHeaderDirective[H] {
- def value: String
-}
-
-private[api] sealed trait RequestSecurityHeaderDirective[H] extends SecurityHeaderDirective[H]
-
-private[api] sealed trait ResponseSecurityHeaderDirective[H] extends SecurityHeaderDirective[H]
-
-object `X-Frame-Options` extends ModeledCustomHeaderCompanion[`X-Frame-Options`] {
- override val name = "X-Frame-Options"
-
- override def parse(value: String) = Try(new `X-Frame-Options`(value))
-
- def apply(value: ResponseSecurityHeaderDirective[`X-Frame-Options`]) = new `X-Frame-Options`(value.value)
-
- case object `DENY` extends ResponseSecurityHeaderDirective[`X-Frame-Options`] {
- override def value: String = "DENY"
- }
-
- case object `SAMEORIGIN` extends ResponseSecurityHeaderDirective[`X-Frame-Options`] {
- override def value: String = "SAMEORIGIN"
- }
-
- final case class `ALLOW-FROM`(uri: String) extends ResponseSecurityHeaderDirective[`X-Frame-Options`] {
- override def value: String = s"ALLOW-FROM $uri"
- }
-}
-
-final case class `X-Frame-Options`(value: String) extends ModeledCustomHeader[`X-Frame-Options`] {
- override def renderInRequests = false
- override def renderInResponses = true
- override val companion = `X-Frame-Options`
-}
-
-object `X-Content-Type-Options` extends ModeledCustomHeaderCompanion[`X-Content-Type-Options`] {
- override val name = "X-Content-Type-Options"
-
- override def parse(value: String) = Try(new `X-Content-Type-Options`(value))
-
- def apply(value: ResponseSecurityHeaderDirective[`X-Content-Type-Options`]) =
- new `X-Content-Type-Options`(value.value)
-
- case object `nosniff` extends ResponseSecurityHeaderDirective[`X-Content-Type-Options`] {
- override def value: String = "nosniff"
- }
-}
-
-final case class `X-Content-Type-Options`(value: String) extends ModeledCustomHeader[`X-Content-Type-Options`] {
- override def renderInRequests = false
- override def renderInResponses = true
- override val companion = `X-Content-Type-Options`
-}
-
-object `X-XSS-Protection` extends ModeledCustomHeaderCompanion[`X-XSS-Protection`] {
- override val name = "X-XSS-Protection"
-
- override def parse(value: String) = Try(new `X-XSS-Protection`(value))
-
- def apply(value: ResponseSecurityHeaderDirective[`X-XSS-Protection`]) = new `X-XSS-Protection`(value.value)
-
- case object `0` extends ResponseSecurityHeaderDirective[`X-XSS-Protection`] {
- override def value: String = "0"
- }
-
- case object `1; mode=block` extends ResponseSecurityHeaderDirective[`X-XSS-Protection`] {
- override def value: String = "1; mode=block"
- }
-
- final case class `1; report=`(uri: String) extends ResponseSecurityHeaderDirective[`X-XSS-Protection`] {
- override def value: String = s"1; report=$uri"
- }
-}
-
-final case class `X-XSS-Protection`(value: String) extends ModeledCustomHeader[`X-XSS-Protection`] {
- override def renderInRequests = false
- override def renderInResponses = true
- override val companion = `X-XSS-Protection`
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/common/crypto/Argon2dPasswordHashing.scala b/backend/src/main/scala/com/softwaremill/bootzooka/common/crypto/Argon2dPasswordHashing.scala
deleted file mode 100644
index 042525202..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/common/crypto/Argon2dPasswordHashing.scala
+++ /dev/null
@@ -1,23 +0,0 @@
-package com.softwaremill.bootzooka.common.crypto
-
-import com.typesafe.scalalogging.StrictLogging
-import de.mkammerer.argon2.Argon2Factory.Argon2Types
-import de.mkammerer.argon2.{Argon2, Argon2Factory}
-
-class Argon2dPasswordHashing(config: CryptoConfig) extends PasswordHashing with StrictLogging {
- private val argon2: Argon2 = Argon2Factory.create(Argon2Types.ARGON2d)
-
- def hashPassword(password: String, salt: String): String =
- argon2.hash(config.iterations, config.memory, config.parallelism, salt + password)
-
- def verifyPassword(hash: String, password: String, salt: String): Boolean =
- argon2.verify(hash, salt + password)
-
- def requiresRehashing(hash: String): Boolean = {
- //argon2 stores hash in the form $argon2$v=19$m=1024,t=2,p=4$hashvalueprobablywithsalt
- //so split here needs to take fourth value (first is before first $)
- val hashParams = hash.split('$')(3)
- val configParams = s"m=${config.memory},t=${config.iterations},p=${config.parallelism}"
- configParams != hashParams
- }
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/common/crypto/CryptoConfig.scala b/backend/src/main/scala/com/softwaremill/bootzooka/common/crypto/CryptoConfig.scala
deleted file mode 100644
index 71fa8c78a..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/common/crypto/CryptoConfig.scala
+++ /dev/null
@@ -1,12 +0,0 @@
-package com.softwaremill.bootzooka.common.crypto
-
-import com.softwaremill.bootzooka.common.ConfigWithDefault
-import com.typesafe.config.Config
-
-trait CryptoConfig extends ConfigWithDefault {
- def rootConfig: Config
-
- lazy val iterations = getInt("bootzooka.crypto.argon2.iterations", 2)
- lazy val memory = getInt("bootzooka.crypto.argon2.memory", 16383)
- lazy val parallelism = getInt("bootzooka.crypto.argon2.parallelism", 4)
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/common/crypto/PasswordHashing.scala b/backend/src/main/scala/com/softwaremill/bootzooka/common/crypto/PasswordHashing.scala
deleted file mode 100644
index 66fb5f130..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/common/crypto/PasswordHashing.scala
+++ /dev/null
@@ -1,16 +0,0 @@
-package com.softwaremill.bootzooka.common.crypto
-
-trait PasswordHashing {
- def hashPassword(password: String, salt: String): String
- def verifyPassword(hash: String, password: String, salt: String): Boolean
-
- /**
- * Method that checks whether hash should be rehashed.
- *
- * Typical use-case would be to check parameters used for calculating the hash
- * (for example memory, iterations and parallelism settings in case of Argon2)
- * and check current configuration. If hashing settings were updated since the creation
- * of the hash, it should return true.
- */
- def requiresRehashing(hash: String): Boolean
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/common/crypto/Salt.scala b/backend/src/main/scala/com/softwaremill/bootzooka/common/crypto/Salt.scala
deleted file mode 100644
index 9b5a54ec0..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/common/crypto/Salt.scala
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.softwaremill.bootzooka.common.crypto
-
-import com.softwaremill.bootzooka.common.Utils
-
-object Salt {
- //the default salt length is 128 bits
- val DefaultSaltLength = 16
-
- /**
- * Generates a new salt.
- *
- * Uses characters 33-126 from ASCII table.
- * @param length the length of the salt
- * @return string with generated salt
- */
- def newSalt(length: Int = DefaultSaltLength): String = Utils.randomString(length, 93, 33)
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/common/sql/DatabaseConfig.scala b/backend/src/main/scala/com/softwaremill/bootzooka/common/sql/DatabaseConfig.scala
deleted file mode 100644
index 103d27b39..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/common/sql/DatabaseConfig.scala
+++ /dev/null
@@ -1,27 +0,0 @@
-package com.softwaremill.bootzooka.common.sql
-
-import com.softwaremill.bootzooka.common.ConfigWithDefault
-import com.softwaremill.bootzooka.common.sql.DatabaseConfig._
-import com.typesafe.config.Config
-
-trait DatabaseConfig extends ConfigWithDefault {
- def rootConfig: Config
-
- // format: OFF
- lazy val dbH2Url = getString(s"bootzooka.db.h2.properties.url", "jdbc:h2:file:./data/bootzooka")
- lazy val dbPostgresServerName = getString(PostgresServerNameKey, "")
- lazy val dbPostgresPort = getString(PostgresPortKey, "5432")
- lazy val dbPostgresDbName = getString(PostgresDbNameKey, "")
- lazy val dbPostgresUsername = getString(PostgresUsernameKey, "")
- lazy val dbPostgresPassword = getString(PostgresPasswordKey, "")
-}
-
-object DatabaseConfig {
- val PostgresDSClass = "bootzooka.db.postgres.dataSourceClass"
- val PostgresServerNameKey = "bootzooka.db.postgres.properties.serverName"
- val PostgresPortKey = "bootzooka.db.postgres.properties.portNumber"
- val PostgresDbNameKey = "bootzooka.db.postgres.properties.databaseName"
- val PostgresUsernameKey = "bootzooka.db.postgres.properties.user"
- val PostgresPasswordKey = "bootzooka.db.postgres.properties.password"
- // format: ON
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/common/sql/SqlDatabase.scala b/backend/src/main/scala/com/softwaremill/bootzooka/common/sql/SqlDatabase.scala
deleted file mode 100644
index a8f6893ad..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/common/sql/SqlDatabase.scala
+++ /dev/null
@@ -1,110 +0,0 @@
-package com.softwaremill.bootzooka.common.sql
-
-import java.net.URI
-import java.time.{OffsetDateTime, ZoneOffset}
-
-import com.softwaremill.bootzooka.common.sql.DatabaseConfig._
-import com.typesafe.config.ConfigValueFactory._
-import com.typesafe.config.{Config, ConfigFactory}
-import com.typesafe.scalalogging.StrictLogging
-import org.flywaydb.core.Flyway
-import slick.jdbc.JdbcProfile
-import slick.jdbc.JdbcBackend._
-
-case class SqlDatabase(
- db: slick.jdbc.JdbcBackend.Database,
- driver: JdbcProfile,
- connectionString: JdbcConnectionString
-) {
-
- import driver.api._
-
- implicit val offsetDateTimeColumnType = MappedColumnType.base[OffsetDateTime, java.sql.Timestamp](
- dt => new java.sql.Timestamp(dt.toInstant.toEpochMilli),
- t => t.toInstant.atOffset(ZoneOffset.UTC)
- )
-
- def updateSchema() {
- val flyway = new Flyway()
- flyway.setDataSource(connectionString.url, connectionString.username, connectionString.password)
- flyway.migrate()
- }
-
- def close() {
- db.close()
- }
-}
-
-case class JdbcConnectionString(url: String, username: String = "", password: String = "")
-
-object SqlDatabase extends StrictLogging {
-
- def embeddedConnectionStringFromConfig(config: DatabaseConfig): String = {
- val url = config.dbH2Url
- val fullPath = url.split(":")(3)
- logger.info(s"Using an embedded database, with data files located at: $fullPath")
- url
- }
-
- def create(config: DatabaseConfig): SqlDatabase = {
- val envDatabaseUrl = System.getenv("DATABASE_URL")
-
- if (config.dbPostgresServerName.length > 0)
- createPostgresFromConfig(config)
- else if (envDatabaseUrl != null)
- createPostgresFromEnv(envDatabaseUrl)
- else
- createEmbedded(config)
- }
-
- def createPostgresFromEnv(envDatabaseUrl: String) = {
- /*
- The DATABASE_URL is set by Heroku (if deploying there) and must be converted to a proper object
- of type Config (for Slick). Expected format:
- postgres://:@:/
- */
- val dbUri = new URI(envDatabaseUrl)
- val username = dbUri.getUserInfo.split(":")(0)
- val password = dbUri.getUserInfo.split(":")(1)
- val intermediaryConfig = new DatabaseConfig {
- override def rootConfig: Config =
- ConfigFactory
- .empty()
- .withValue(PostgresDSClass, fromAnyRef("org.postgresql.ds.PGSimpleDataSource"))
- .withValue(PostgresServerNameKey, fromAnyRef(dbUri.getHost))
- .withValue(PostgresPortKey, fromAnyRef(dbUri.getPort))
- .withValue(PostgresDbNameKey, fromAnyRef(dbUri.getPath.tail))
- .withValue(PostgresUsernameKey, fromAnyRef(username))
- .withValue(PostgresPasswordKey, fromAnyRef(password))
- .withFallback(ConfigFactory.load())
- }
- createPostgresFromConfig(intermediaryConfig)
- }
-
- def postgresUrl(host: String, port: String, dbName: String) =
- s"jdbc:postgresql://$host:$port/$dbName"
-
- def postgresConnectionString(config: DatabaseConfig) = {
- val host = config.dbPostgresServerName
- val port = config.dbPostgresPort
- val dbName = config.dbPostgresDbName
- val username = config.dbPostgresUsername
- val password = config.dbPostgresPassword
- JdbcConnectionString(postgresUrl(host, port, dbName), username, password)
- }
-
- def createPostgresFromConfig(config: DatabaseConfig) = {
- val db = Database.forConfig("bootzooka.db.postgres", config.rootConfig)
- SqlDatabase(db, slick.jdbc.PostgresProfile, postgresConnectionString(config))
- }
-
- private def createEmbedded(config: DatabaseConfig): SqlDatabase = {
- val db = Database.forConfig("bootzooka.db.h2")
- SqlDatabase(db, slick.jdbc.H2Profile, JdbcConnectionString(embeddedConnectionStringFromConfig(config)))
- }
-
- def createEmbedded(connectionString: String): SqlDatabase = {
- val db = Database.forURL(connectionString)
- SqlDatabase(db, slick.jdbc.H2Profile, JdbcConnectionString(connectionString))
- }
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailConfig.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailConfig.scala
new file mode 100644
index 000000000..b78d1b881
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailConfig.scala
@@ -0,0 +1,16 @@
+package com.softwaremill.bootzooka.email
+
+import scala.concurrent.duration.FiniteDuration
+
+case class EmailConfig(
+ enabled: Boolean,
+ smtp: SmtpConfig,
+ from: String,
+ encoding: String,
+ batchSize: Int,
+ emailSendInterval: FiniteDuration
+)
+
+case class SmtpConfig(host: String, port: Int, username: String, password: String, sslConnection: Boolean, verifySslCertificate: Boolean) {
+ override def toString: String = s"SmtpConfig($host,$port,$username,***,$sslConnection,$verifySslCertificate)"
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala
new file mode 100644
index 000000000..e48b11985
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala
@@ -0,0 +1,39 @@
+package com.softwaremill.bootzooka.email
+
+import cats.data.NonEmptyList
+import com.softwaremill.bootzooka.Id
+import com.softwaremill.bootzooka.infrastructure.Doobie._
+import com.softwaremill.tagging.@@
+import cats.implicits._
+
+object EmailModel {
+
+ def insert(email: Email): ConnectionIO[Unit] = {
+ sql"""INSERT INTO scheduled_emails (id, recipient, subject, content)
+ |VALUES (${email.id}, ${email.data.recipient}, ${email.data.subject}, ${email.data.content})""".stripMargin.update.run.void
+ }
+
+ def find(limit: Int): ConnectionIO[List[Email]] = {
+ sql"SELECT id, recipient, subject, content FROM scheduled_emails LIMIT $limit"
+ .query[Email]
+ .to[List]
+ }
+
+ def delete(ids: List[Id @@ Email]): ConnectionIO[Unit] = {
+ NonEmptyList.fromList(ids) match {
+ case None => ().pure[ConnectionIO]
+ case Some(l) => (sql"DELETE FROM scheduled_emails WHERE " ++ Fragments.in(fr"id", l)).update.run.void
+ }
+ }
+}
+
+case class Email(id: Id @@ Email, data: EmailData)
+
+case class EmailData(recipient: String, subject: String, content: String)
+object EmailData {
+ def apply(recipient: String, subjectContent: EmailSubjectContent): EmailData = {
+ EmailData(recipient, subjectContent.subject, subjectContent.content)
+ }
+}
+
+case class EmailSubjectContent(subject: String, content: String)
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModule.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModule.scala
new file mode 100644
index 000000000..1e2d545ed
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModule.scala
@@ -0,0 +1,19 @@
+package com.softwaremill.bootzooka.email
+
+import com.softwaremill.bootzooka.BaseModule
+import com.softwaremill.bootzooka.email.sender.{DummyEmailSender, EmailSender, SmtpEmailSender}
+import doobie.util.transactor.Transactor
+import monix.eval.Task
+
+trait EmailModule extends BaseModule {
+ lazy val emailService = new EmailService(idGenerator, emailSender, config.email, xa)
+ lazy val emailScheduler: EmailScheduler = emailService
+ lazy val emailTemplatingEngine = new EmailTemplatingEngine()
+ lazy val emailSender: EmailSender = if (config.email.enabled) {
+ new SmtpEmailSender(config.email)
+ } else {
+ DummyEmailSender
+ }
+
+ def xa: Transactor[Task]
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala
new file mode 100644
index 000000000..57731dd31
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala
@@ -0,0 +1,37 @@
+package com.softwaremill.bootzooka.email
+
+import com.softwaremill.bootzooka.IdGenerator
+import com.softwaremill.bootzooka.infrastructure.Doobie._
+import monix.eval.{Fiber, Task}
+import cats.implicits._
+import com.softwaremill.bootzooka.email.sender.EmailSender
+import com.typesafe.scalalogging.StrictLogging
+
+class EmailService(idGenerator: IdGenerator, emailSender: EmailSender, config: EmailConfig, xa: Transactor[Task])
+ extends EmailScheduler
+ with StrictLogging {
+
+ def apply(data: EmailData): ConnectionIO[Unit] = EmailModel.insert(Email(idGenerator.nextId(), data))
+
+ def sendBatch(): Task[Unit] = {
+ for {
+ emails <- EmailModel.find(config.batchSize).transact(xa)
+ _ = if (emails.nonEmpty) logger.info(s"Sending ${emails.size} emails")
+ _ <- Task.sequence(emails.map(_.data).map(emailSender.apply))
+ _ <- EmailModel.delete(emails.map(_.id)).transact(xa)
+ } yield ()
+ }
+
+ def startSender(): Task[Fiber[Nothing]] = {
+ (sendBatch() >> Task.sleep(config.emailSendInterval))
+ .onErrorHandle { e =>
+ logger.error("Exception when sending emails", e)
+ }
+ .loopForever
+ .start
+ }
+}
+
+trait EmailScheduler {
+ def apply(data: EmailData): ConnectionIO[Unit]
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/application/EmailTemplatingEngine.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailTemplatingEngine.scala
similarity index 58%
rename from backend/src/main/scala/com/softwaremill/bootzooka/email/application/EmailTemplatingEngine.scala
rename to backend/src/main/scala/com/softwaremill/bootzooka/email/EmailTemplatingEngine.scala
index 58ddb126a..b9531348a 100644
--- a/backend/src/main/scala/com/softwaremill/bootzooka/email/application/EmailTemplatingEngine.scala
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailTemplatingEngine.scala
@@ -1,33 +1,35 @@
-package com.softwaremill.bootzooka.email.application
-
-import com.softwaremill.bootzooka.email.domain.EmailContentWithSubject
+package com.softwaremill.bootzooka.email
import scala.io.Source
class EmailTemplatingEngine {
- def registrationConfirmation(userName: String): EmailContentWithSubject = {
+ def registrationConfirmation(userName: String): EmailSubjectContent = {
val template = prepareEmailTemplate("registrationConfirmation", Map("userName" -> userName))
addSignature(splitToContentAndSubject(template))
}
- def passwordReset(userName: String, resetLink: String) = {
+ def passwordReset(userName: String, resetLink: String): EmailSubjectContent = {
val template = prepareEmailTemplate("resetPassword", Map("userName" -> userName, "resetLink" -> resetLink))
addSignature(splitToContentAndSubject(template))
}
private def prepareEmailTemplate(templateNameWithoutExtension: String, params: Map[String, Object]): String = {
- val rawTemplate = Source
+ val source = Source
.fromURL(getClass.getResource(s"/templates/email/$templateNameWithoutExtension.txt"), "UTF-8")
- .getLines()
- .mkString("\n")
- params.foldLeft(rawTemplate) {
- case (template, (param, paramValue)) =>
- template.replaceAll(s"\\{\\{$param\\}\\}", paramValue.toString)
+ try {
+ val rawTemplate = source.getLines().mkString("\n")
+
+ params.foldLeft(rawTemplate) {
+ case (template, (param, paramValue)) =>
+ template.replaceAll(s"\\{\\{$param\\}\\}", paramValue.toString)
+ }
+ } finally {
+ source.close()
}
}
- private[email] def splitToContentAndSubject(template: String): EmailContentWithSubject = {
+ private def splitToContentAndSubject(template: String): EmailSubjectContent = {
// First line of template is used as an email subject, rest of the template goes to content
val emailLines = template.split('\n')
require(
@@ -35,11 +37,11 @@ class EmailTemplatingEngine {
"Invalid email template. It should consist of at least two lines: one for subject and one for content"
)
- EmailContentWithSubject(emailLines.tail.mkString("\n"), emailLines.head)
+ EmailSubjectContent(emailLines.head, emailLines.tail.mkString("\n"))
}
private lazy val signature = prepareEmailTemplate("emailSignature", Map())
- private def addSignature(email: EmailContentWithSubject): EmailContentWithSubject =
+ private def addSignature(email: EmailSubjectContent): EmailSubjectContent =
email.copy(content = s"${email.content}\n$signature")
}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/application/DummyEmailService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/application/DummyEmailService.scala
deleted file mode 100644
index 55ee54580..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/email/application/DummyEmailService.scala
+++ /dev/null
@@ -1,41 +0,0 @@
-package com.softwaremill.bootzooka.email.application
-
-import com.softwaremill.bootzooka.email.application.SmtpEmailSender.EmailDescription
-import com.softwaremill.bootzooka.email.domain.EmailContentWithSubject
-import com.typesafe.scalalogging.StrictLogging
-
-import scala.collection.mutable.ListBuffer
-import scala.concurrent.Future
-
-class DummyEmailService extends EmailService with StrictLogging {
-
- private val sentEmails: ListBuffer[EmailDescription] = ListBuffer()
-
- def emailToString(email: EmailDescription): String =
- email.emails.mkString + ", " + email.subject + ", " + email.message
-
- def reset() {
- sentEmails.clear()
- }
-
- override def scheduleEmail(address: String, emailData: EmailContentWithSubject) = {
- val email = new EmailDescription(List(address), emailData.content, emailData.subject)
-
- this.synchronized {
- sentEmails += email
- }
-
- logger.debug(
- s"Would send email to $address, if this wasn't a dummy email service implementation: ${emailData.content}"
- )
- Future.successful(())
- }
-
- def wasEmailSent(address: String, subject: String): Boolean = this.synchronized {
- sentEmails.exists(email => email.emails.contains(address) && email.subject == subject)
- }
-
- def wasEmailSentTo(address: String): Boolean = this.synchronized {
- sentEmails.exists(email => email.emails.contains(address))
- }
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/application/EmailConfig.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/application/EmailConfig.scala
deleted file mode 100644
index 91a30e003..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/email/application/EmailConfig.scala
+++ /dev/null
@@ -1,19 +0,0 @@
-package com.softwaremill.bootzooka.email.application
-
-import com.typesafe.config.Config
-
-trait EmailConfig {
- def rootConfig: Config
-
- private lazy val emailConfig = rootConfig.getConfig("email")
-
- lazy val emailEnabled = emailConfig.getBoolean("enabled")
- lazy val emailSmtpHost = emailConfig.getString("smtp-host")
- lazy val emailSmtpPort = emailConfig.getString("smtp-port")
- lazy val emailSmtpUserName = emailConfig.getString("smtp-username")
- lazy val emailSmtpPassword = emailConfig.getString("smtp-password")
- lazy val emailFrom = emailConfig.getString("from")
- lazy val emailEncoding = emailConfig.getString("encoding")
- lazy val emailSslConnection = emailConfig.getBoolean("ssl-connection")
- lazy val emailVerifySSLCertificate = emailConfig.getBoolean("verify-ssl-certificate")
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/application/EmailService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/application/EmailService.scala
deleted file mode 100644
index 78b4f8210..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/email/application/EmailService.scala
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.softwaremill.bootzooka.email.application
-
-import com.softwaremill.bootzooka.email.domain.EmailContentWithSubject
-
-import scala.concurrent.Future
-
-trait EmailService {
-
- def scheduleEmail(address: String, emailData: EmailContentWithSubject): Future[Unit]
-
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/application/SmtpEmailService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/application/SmtpEmailService.scala
deleted file mode 100644
index 1ac46edb5..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/email/application/SmtpEmailService.scala
+++ /dev/null
@@ -1,33 +0,0 @@
-package com.softwaremill.bootzooka.email.application
-
-import com.softwaremill.bootzooka.email.domain.EmailContentWithSubject
-import com.typesafe.scalalogging.StrictLogging
-
-import scala.concurrent._
-import scala.util.{Failure, Success}
-
-class SmtpEmailService(emailConfig: EmailConfig)(implicit ec: ExecutionContext)
- extends EmailService
- with StrictLogging {
- def scheduleEmail(address: String, email: EmailContentWithSubject) = {
- val result = Future {
- val emailToSend = new SmtpEmailSender.EmailDescription(List(address), email.content, email.subject)
- SmtpEmailSender.send(
- emailConfig.emailSmtpHost,
- emailConfig.emailSmtpPort,
- emailConfig.emailSmtpUserName,
- emailConfig.emailSmtpPassword,
- emailConfig.emailVerifySSLCertificate,
- emailConfig.emailSslConnection,
- emailConfig.emailFrom,
- emailConfig.emailEncoding,
- emailToSend
- )
- } andThen {
- case Success(_) => logger.debug(s"Email to $address sent.")
- case Failure(cause) => logger.warn(s"Couldn't send email to $address", cause)
- }
- logger.debug(s"Email to $address scheduled.")
- result
- }
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/domain/EmailContentWithSubject.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/domain/EmailContentWithSubject.scala
deleted file mode 100644
index 3cd574182..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/email/domain/EmailContentWithSubject.scala
+++ /dev/null
@@ -1,3 +0,0 @@
-package com.softwaremill.bootzooka.email.domain
-
-case class EmailContentWithSubject(content: String, subject: String)
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/DummyEmailSender.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/DummyEmailSender.scala
new file mode 100644
index 000000000..d6b9074d6
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/DummyEmailSender.scala
@@ -0,0 +1,28 @@
+package com.softwaremill.bootzooka.email.sender
+
+import com.softwaremill.bootzooka.email.EmailData
+import com.typesafe.scalalogging.StrictLogging
+import monix.eval.Task
+
+import scala.collection.mutable.ListBuffer
+
+object DummyEmailSender extends EmailSender with StrictLogging {
+
+ private val sentEmails: ListBuffer[EmailData] = ListBuffer()
+
+ def reset(): Unit = this.synchronized {
+ sentEmails.clear()
+ }
+
+ override def apply(email: EmailData): Task[Unit] = Task {
+ this.synchronized {
+ sentEmails += email
+ }
+
+ logger.info(s"Would send email, if this wasn't a dummy email service implementation: $email")
+ }
+
+ def findSendEmail(recipient: String, subjectContains: String): Option[EmailData] = this.synchronized {
+ sentEmails.find(email => email.recipient == recipient && email.subject.contains(subjectContains))
+ }
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/EmailSender.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/EmailSender.scala
new file mode 100644
index 000000000..b9e36100e
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/EmailSender.scala
@@ -0,0 +1,8 @@
+package com.softwaremill.bootzooka.email.sender
+
+import com.softwaremill.bootzooka.email.EmailData
+import monix.eval.Task
+
+trait EmailSender {
+ def apply(email: EmailData): Task[Unit]
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/application/SmtpEmailSender.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/SmtpEmailSender.scala
similarity index 77%
rename from backend/src/main/scala/com/softwaremill/bootzooka/email/application/SmtpEmailSender.scala
rename to backend/src/main/scala/com/softwaremill/bootzooka/email/sender/SmtpEmailSender.scala
index de3511c2f..3f6777a3d 100644
--- a/backend/src/main/scala/com/softwaremill/bootzooka/email/application/SmtpEmailSender.scala
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/SmtpEmailSender.scala
@@ -1,12 +1,33 @@
-package com.softwaremill.bootzooka.email.application
+package com.softwaremill.bootzooka.email.sender
import java.io.{ByteArrayInputStream, ByteArrayOutputStream}
import java.util.{Date, Properties}
+
+import com.softwaremill.bootzooka.email.{EmailConfig, EmailData}
+import com.typesafe.scalalogging.StrictLogging
import javax.activation.{DataHandler, DataSource}
import javax.mail.internet.{InternetAddress, MimeBodyPart, MimeMessage, MimeMultipart}
import javax.mail.{Address, Message, Session, Transport}
-
-import com.typesafe.scalalogging.StrictLogging
+import monix.eval.Task
+
+class SmtpEmailSender(config: EmailConfig) extends EmailSender with StrictLogging {
+ def apply(email: EmailData): Task[Unit] = Task {
+ val emailToSend = new SmtpEmailSender.EmailDescription(List(email.recipient), email.content, email.subject)
+ SmtpEmailSender.send(
+ config.smtp.host,
+ config.smtp.port,
+ config.smtp.username,
+ config.smtp.password,
+ config.smtp.verifySslCertificate,
+ config.smtp.sslConnection,
+ config.from,
+ config.encoding,
+ emailToSend
+ )
+
+ logger.debug(s"Email to: ${email.recipient} sent")
+ }
+}
/**
* Copied from softwaremill-common:
@@ -16,7 +37,7 @@ object SmtpEmailSender extends StrictLogging {
def send(
smtpHost: String,
- smtpPort: String,
+ smtpPort: Int,
smtpUsername: String,
smtpPassword: String,
verifySSLCertificate: Boolean,
@@ -25,7 +46,7 @@ object SmtpEmailSender extends StrictLogging {
encoding: String,
emailDescription: EmailDescription,
attachmentDescriptions: AttachmentDescription*
- ) {
+ ): Unit = {
val props = setupSmtpServerProperties(sslConnection, smtpHost, smtpPort, verifySSLCertificate)
@@ -35,10 +56,10 @@ object SmtpEmailSender extends StrictLogging {
val m = new MimeMessage(session)
m.setFrom(new InternetAddress(from))
- val to = convertStringEmailsToAddresses(emailDescription.emails)
+ val to = convertStringEmailsToAddresses(emailDescription.emails)
val replyTo = convertStringEmailsToAddresses(emailDescription.replyToEmails)
- val cc = convertStringEmailsToAddresses(emailDescription.ccEmails)
- val bcc = convertStringEmailsToAddresses(emailDescription.bccEmails)
+ val cc = convertStringEmailsToAddresses(emailDescription.ccEmails)
+ val bcc = convertStringEmailsToAddresses(emailDescription.bccEmails)
m.setRecipients(javax.mail.Message.RecipientType.TO, to)
m.setRecipients(Message.RecipientType.CC, cc)
@@ -65,14 +86,14 @@ object SmtpEmailSender extends StrictLogging {
private def setupSmtpServerProperties(
sslConnection: Boolean,
smtpHost: String,
- smtpPort: String,
+ smtpPort: Int,
verifySSLCertificate: Boolean
): Properties = {
// Setup mail server
val props = new Properties()
if (sslConnection) {
props.put("mail.smtps.host", smtpHost)
- props.put("mail.smtps.port", smtpPort)
+ props.put("mail.smtps.port", smtpPort.toString)
props.put("mail.smtps.starttls.enable", "true")
if (!verifySSLCertificate) {
props.put("mail.smtps.ssl.checkserveridentity", "false")
@@ -80,7 +101,7 @@ object SmtpEmailSender extends StrictLogging {
}
} else {
props.put("mail.smtp.host", smtpHost)
- props.put("mail.smtp.port", smtpPort)
+ props.put("mail.smtp.port", smtpPort.toString)
}
props
}
@@ -88,12 +109,12 @@ object SmtpEmailSender extends StrictLogging {
private def createSmtpTransportFrom(session: Session, sslConnection: Boolean): Transport =
if (sslConnection) session.getTransport("smtps") else session.getTransport("smtp")
- private def sendEmail(transport: Transport, m: MimeMessage, emailDescription: EmailDescription, to: Array[Address]) {
+ private def sendEmail(transport: Transport, m: MimeMessage, emailDescription: EmailDescription, to: Array[Address]): Unit = {
transport.sendMessage(m, m.getAllRecipients)
logger.debug("Mail '" + emailDescription.subject + "' sent to: " + to.mkString(","))
}
- private def connectToSmtpServer(transport: Transport, smtpUsername: String, smtpPassword: String) {
+ private def connectToSmtpServer(transport: Transport, smtpUsername: String, smtpPassword: String): Unit = {
if (smtpUsername != null && smtpUsername.nonEmpty) {
transport.connect(smtpUsername, smtpPassword)
} else {
@@ -109,7 +130,7 @@ object SmtpEmailSender extends StrictLogging {
msg: String,
encoding: String,
attachmentDescriptions: AttachmentDescription*
- ) {
+ ): Unit = {
val multiPart = new MimeMultipart()
val textPart = new MimeBodyPart()
@@ -124,16 +145,16 @@ object SmtpEmailSender extends StrictLogging {
def getInputStream =
new ByteArrayInputStream(attachmentDescription.content)
- def getOutputStream = {
+ def getOutputStream: ByteArrayOutputStream = {
val byteStream = new ByteArrayOutputStream()
byteStream.write(attachmentDescription.content)
byteStream
}
- def getContentType =
+ def getContentType: String =
attachmentDescription.contentType
- def getName =
+ def getName: String =
attachmentDescription.filename
}
binaryPart.setDataHandler(new DataHandler(ds))
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/CorrelationId.scala b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/CorrelationId.scala
new file mode 100644
index 000000000..121c2cdac
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/CorrelationId.scala
@@ -0,0 +1,105 @@
+package com.softwaremill.bootzooka.infrastructure
+
+import java.{util => ju}
+
+import cats.data.{Kleisli, OptionT}
+import ch.qos.logback.classic.util.LogbackMDCAdapter
+import com.softwaremill.sttp
+import com.softwaremill.sttp.{MonadError, Response, SttpBackend}
+import com.typesafe.scalalogging.StrictLogging
+import monix.eval.Task
+import monix.execution.misc.Local
+import org.http4s.util.CaseInsensitiveString
+import org.http4s.{HttpRoutes, Request}
+import org.slf4j.MDC
+
+import scala.util.Random
+
+// https://blog.softwaremill.com/correlation-ids-in-scala-using-monix-3aa11783db81
+object CorrelationId extends StrictLogging {
+ System.setProperty("monix.environment.localContextPropagation", "1")
+ def init(): Unit = {
+ MonixMDCAdapter.init()
+ }
+
+ private val MdcKey = "cid"
+
+ def apply(): Task[Option[String]] = Task(Option(MDC.get(MdcKey)))
+
+ val CorrelationIdHeader = "X-Correlation-ID"
+
+ def setCorrelationIdMiddleware(service: HttpRoutes[Task]): HttpRoutes[Task] = Kleisli { req: Request[Task] =>
+ val cid = req.headers.get(CaseInsensitiveString(CorrelationIdHeader)) match {
+ case None => newCorrelationId()
+ case Some(cidHeader) => cidHeader.value
+ }
+
+ val setupAndService = for {
+ _ <- Task(MDC.put(MdcKey, cid))
+ _ <- Task(logger.debug(s"Starting request with id: $cid, to: ${req.uri.path}"))
+ r <- service(req).value
+ } yield r
+
+ OptionT(setupAndService.guarantee(Task(MDC.remove(MdcKey))))
+ }
+
+ private val random = new Random()
+
+ private def newCorrelationId(): String = {
+ def randomUpperCaseChar() = (random.nextInt(91 - 65) + 65).toChar
+ def segment = (1 to 3).map(_ => randomUpperCaseChar()).mkString
+ s"$segment-$segment-$segment"
+ }
+}
+
+class SetCorrelationIdBackend(delegate: SttpBackend[Task, Nothing]) extends SttpBackend[Task, Nothing] {
+ override def send[T](request: sttp.Request[T, Nothing]): Task[Response[T]] = {
+ // suspending the calculation of the correlation id until the request send is evaluated
+ CorrelationId()
+ .map {
+ case Some(cid) => request.header(CorrelationId.CorrelationIdHeader, cid)
+ case None => request
+ }
+ .flatMap(delegate.send)
+ }
+
+ override def close(): Unit = delegate.close()
+
+ override def responseMonad: MonadError[Task] = delegate.responseMonad
+}
+
+// from https://olegpy.com/better-logging-monix-1/
+class MonixMDCAdapter extends LogbackMDCAdapter {
+ private[this] val map = Local[ju.Map[String, String]](ju.Collections.emptyMap())
+
+ override def put(key: String, `val`: String): Unit = {
+ if (map() eq ju.Collections.EMPTY_MAP) {
+ map := new ju.HashMap()
+ }
+ map().put(key, `val`)
+ ()
+ }
+
+ override def get(key: String): String = map().get(key)
+ override def remove(key: String): Unit = {
+ map().remove(key)
+ ()
+ }
+
+ // Note: we're resetting the Local to default, not clearing the actual hashmap
+ override def clear(): Unit = map.clear()
+ override def getCopyOfContextMap: ju.Map[String, String] = new ju.HashMap(map())
+ override def setContextMap(contextMap: ju.Map[String, String]): Unit =
+ map := new ju.HashMap(contextMap)
+
+ override def getPropertyMap: ju.Map[String, String] = map()
+ override def getKeys: ju.Set[String] = map().keySet()
+}
+
+object MonixMDCAdapter {
+ def init(): Unit = {
+ val field = classOf[MDC].getDeclaredField("mdcAdapter")
+ field.setAccessible(true)
+ field.set(null, new MonixMDCAdapter)
+ }
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala
new file mode 100644
index 000000000..1bb8e40fd
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala
@@ -0,0 +1,59 @@
+package com.softwaremill.bootzooka.infrastructure
+
+import cats.effect.{ContextShift, Resource}
+import cats.implicits._
+import com.typesafe.scalalogging.StrictLogging
+import doobie.hikari.HikariTransactor
+import monix.eval.Task
+import monix.execution.Scheduler.Implicits.global
+import org.flywaydb.core.Flyway
+
+import scala.concurrent.duration._
+import Doobie._
+
+class DB(config: DBConfig) extends StrictLogging {
+
+ private val flyway = {
+ Flyway
+ .configure()
+ .dataSource(config.url, config.username, config.password)
+ .placeholderPrefix("$%{") // so it won't interfere with email templates placeholders
+ .load()
+ }
+
+ val transactorResource: Resource[Task, Transactor[Task]] = {
+ implicit val contextShift: ContextShift[Task] = Task.contextShift(global)
+
+ for {
+ connectEC <- doobie.util.ExecutionContexts.fixedThreadPool[Task](config.connectThreadPoolSize)
+ transactEC <- doobie.util.ExecutionContexts.cachedThreadPool[Task]
+ xa <- HikariTransactor.newHikariTransactor[Task](
+ config.driver,
+ config.url,
+ config.username,
+ config.password,
+ connectEC,
+ transactEC
+ )
+ _ <- Resource.liftF(connectAndMigrate(xa))
+ } yield xa
+ }
+
+ private def connectAndMigrate(xa: Transactor[Task]): Task[Unit] = {
+ (migrate() >> testConnection(xa)).onErrorRecoverWith {
+ case e: Exception =>
+ logger.warn("Database not available, waiting 5 seconds to retry...", e)
+ Task.sleep(5.seconds) >> connectAndMigrate(xa)
+ }
+ }
+
+ private def migrate(): Task[Unit] = {
+ if (config.migrateOnStart) {
+ Task(flyway.migrate()).void
+ } else Task.unit
+ }
+
+ private def testConnection(xa: Transactor[Task]): Task[Unit] = Task {
+ sql"select 1".query[Int].unique.transact(xa)
+ }.void
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DBConfig.scala b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DBConfig.scala
new file mode 100644
index 000000000..5bf97176f
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DBConfig.scala
@@ -0,0 +1,10 @@
+package com.softwaremill.bootzooka.infrastructure
+
+case class DBConfig(username: String,
+ password: String,
+ url: String,
+ migrateOnStart: Boolean,
+ driver: String,
+ connectThreadPoolSize: Int) {
+ override def toString: String = s"DBConfig($username,***,$url,$migrateOnStart,$driver,$connectThreadPoolSize)"
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Doobie.scala b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Doobie.scala
new file mode 100644
index 000000000..063f75c86
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Doobie.scala
@@ -0,0 +1,46 @@
+package com.softwaremill.bootzooka.infrastructure
+
+import com.softwaremill.bootzooka.Id
+import com.softwaremill.tagging._
+import com.typesafe.scalalogging.StrictLogging
+import doobie.util.log.{ExecFailure, ProcessingFailure, Success}
+import tsec.passwordhashers.PasswordHash
+import tsec.passwordhashers.jca.SCrypt
+
+import scala.concurrent.duration._
+import scala.reflect.runtime.universe.TypeTag
+
+object Doobie
+ extends doobie.Aliases
+ with doobie.hi.Modules
+ with doobie.free.Modules
+ with doobie.free.Types
+ with doobie.postgres.Instances
+ with doobie.free.Instances
+ with doobie.syntax.AllSyntax
+ with StrictLogging {
+
+ implicit def idMeta: Meta[Id] = implicitly[Meta[String]].asInstanceOf[Meta[Id]]
+
+ // there's no TypeTag for Id +
+ // can't define a generic encoder because of https://stackoverflow.com/questions/48174799/decoding-case-class-w-tagged-type
+ implicit def taggedIdMeta[U: TypeTag]: Meta[Id @@ U] = implicitly[Meta[String]].asInstanceOf[Meta[Id @@ U]]
+
+ implicit def taggedStringMeta[U: TypeTag]: Meta[String @@ U] =
+ implicitly[Meta[String]].asInstanceOf[Meta[String @@ U]]
+
+ implicit val passwordHashMeta: Meta[PasswordHash[SCrypt]] =
+ implicitly[Meta[String]].asInstanceOf[Meta[PasswordHash[SCrypt]]]
+
+ private val SlowThreshold = 200.millis
+ implicit val doobieLogHandler: LogHandler = LogHandler {
+ case Success(sql, _, exec, processing) =>
+ if (exec > SlowThreshold || processing > SlowThreshold) {
+ logger.warn(s"Slow query: $sql")
+ }
+ case ProcessingFailure(sql, args, _, _, failure) =>
+ logger.error(s"Processing failure: $sql | args: $args", failure)
+ case ExecFailure(sql, args, _, failure) =>
+ logger.error(s"Execution failure: $sql | args: $args", failure)
+ }
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Http.scala b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Http.scala
new file mode 100644
index 000000000..c068e26a7
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Http.scala
@@ -0,0 +1,90 @@
+package com.softwaremill.bootzooka.infrastructure
+
+import cats.implicits._
+import com.softwaremill.bootzooka.infrastructure.Doobie._
+import com.softwaremill.bootzooka.infrastructure.Json._
+import com.softwaremill.bootzooka._
+import com.softwaremill.tagging._
+import com.typesafe.scalalogging.StrictLogging
+import doobie.util.transactor.Transactor
+import io.circe.Printer
+import monix.eval.Task
+import org.http4s.Request
+import tapir.Codec.PlainCodec
+import tapir.json.circe.TapirJsonCirce
+import tapir.model.{StatusCode, StatusCodes}
+import tapir.server.{DecodeFailureHandler, DecodeFailureHandling, ServerDefaults}
+import tapir.{Codec, DecodeResult, Endpoint, EndpointOutput, Schema, SchemaFor, Tapir}
+import tsec.common.SecureRandomId
+
+class Http(val xa: Transactor[Task]) extends Tapir with TapirJsonCirce with TapirSchemas with StrictLogging {
+
+ val failOutput: EndpointOutput[(StatusCode, Error_OUT)] = statusCode and jsonBody[Error_OUT]
+
+ val baseEndpoint: Endpoint[Unit, (StatusCode, Error_OUT), Unit, Nothing] =
+ endpoint.errorOut(failOutput)
+
+ val secureEndpoint: Endpoint[Id, (StatusCode, Error_OUT), Unit, Nothing] =
+ baseEndpoint.in(auth.bearer.map(_.asInstanceOf[Id])(identity))
+
+ //
+
+ private val failToResponseData: Fail => (StatusCode, String) = {
+ case Fail.NotFound(what) => (StatusCodes.NotFound, what)
+ case Fail.IncorrectInput(msg) => (StatusCodes.BadRequest, msg)
+ case Fail.Forbidden => (StatusCodes.Forbidden, "Forbidden")
+ case Fail.Unauthorized => (StatusCodes.Unauthorized, "Unauthorized")
+ case _ => (StatusCodes.InternalServerError, "Internal server error")
+ }
+
+ private def failToErrorOut(f: Fail): (StatusCode, Error_OUT) = {
+ val (statusCode, message) = failToResponseData(f)
+ logger.warn(s"Request fail: $message")
+
+ val errorOut = Error_OUT(message)
+ (statusCode, errorOut)
+ }
+
+ //
+
+ private def failResponse(code: StatusCode, msg: String): DecodeFailureHandling =
+ DecodeFailureHandling.response(failOutput)((code, Error_OUT(msg)))
+
+ val decodeFailureHandler: DecodeFailureHandler[Request[Task]] = {
+ // if an exception is thrown when decoding an input, and the exception is a Fail, responding basing on the Fail
+ case (_, _, DecodeResult.Error(_, f: Fail)) => DecodeFailureHandling.response(failOutput)(failToErrorOut(f))
+ // otherwise, converting the decode input failure into a ParsingFailure response
+ case (req, input, failure) =>
+ ServerDefaults.decodeFailureHandlerUsingResponse(failResponse, badRequestOnPathFailureIfPathShapeMatches = false)(req, input, failure)
+ }
+
+ //
+
+ implicit class TaskOut[T](f: Task[T]) {
+ def toOut: Task[Either[(StatusCode, Error_OUT), T]] = {
+ f.map(t => t.asRight[(StatusCode, Error_OUT)]).recover {
+ case fail: Fail =>
+ failToErrorOut(fail).asLeft[T]
+ }
+ }
+ }
+
+ implicit class ConnectionIOWrap[T](f: ConnectionIO[T]) {
+ def transact: Task[T] = f.transact(xa)
+ }
+
+ override def jsonPrinter: Printer = noNullsPrinter
+}
+
+trait TapirSchemas {
+ implicit val idPlainCodec: PlainCodec[SecureRandomId] =
+ Codec.stringPlainCodecUtf8.map(_.asInstanceOf[SecureRandomId])(identity)
+ implicit def taggedPlainCodec[U, T](implicit uc: PlainCodec[U]): PlainCodec[U @@ T] =
+ uc.map(_.taggedWith[T])(identity)
+
+ implicit val schemaForBigDecimal: SchemaFor[BigDecimal] = SchemaFor(Schema.SString)
+ implicit val schemaForId: SchemaFor[Id] = SchemaFor(Schema.SString)
+ implicit def schemaForTagged[U, T](implicit uc: SchemaFor[U]): SchemaFor[U @@ T] = uc.asInstanceOf[SchemaFor[U @@ T]]
+}
+
+case class Error_OUT(error: String)
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/HttpConfig.scala b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/HttpConfig.scala
new file mode 100644
index 000000000..db4640673
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/HttpConfig.scala
@@ -0,0 +1,3 @@
+package com.softwaremill.bootzooka.infrastructure
+
+case class HttpConfig(host: String, port: Int)
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Json.scala b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Json.scala
new file mode 100644
index 000000000..83a8e81da
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Json.scala
@@ -0,0 +1,27 @@
+package com.softwaremill.bootzooka.infrastructure
+
+import com.softwaremill.bootzooka.Id
+import com.softwaremill.tagging.@@
+import io.circe.generic.AutoDerivation
+import io.circe.java8.time.{JavaTimeDecoders, JavaTimeEncoders}
+import io.circe.{Decoder, Encoder, Printer}
+import tsec.passwordhashers.PasswordHash
+import tsec.passwordhashers.jca.SCrypt
+
+object Json extends AutoDerivation with JavaTimeDecoders with JavaTimeEncoders {
+ val noNullsPrinter: Printer = Printer.noSpaces.copy(dropNullValues = true)
+
+ implicit val passwordHashEncoder: Encoder[PasswordHash[SCrypt]] =
+ Encoder.encodeString.asInstanceOf[Encoder[PasswordHash[SCrypt]]]
+
+ // can't define a generic encoder because of https://stackoverflow.com/questions/48174799/decoding-case-class-w-tagged-type
+ implicit def taggedIdEncoder[U]: Encoder[Id @@ U] = Encoder.encodeString.asInstanceOf[Encoder[Id @@ U]]
+ implicit def taggedIdDecoder[U]: Decoder[Id @@ U] = Decoder.decodeString.asInstanceOf[Decoder[Id @@ U]]
+
+ implicit def taggedStringEncoder[U]: Encoder[String @@ U] = Encoder.encodeString.asInstanceOf[Encoder[String @@ U]]
+ implicit def taggedStringDecoder[U]: Decoder[String @@ U] = Decoder.decodeString.asInstanceOf[Decoder[String @@ U]]
+
+ // converts absent list param to empty list
+ implicit def decodeOptionalList[A: Decoder]: Decoder[List[A]] =
+ Decoder.decodeOption(Decoder.decodeList[A]).map(_.toList.flatten)
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/LoggingSttpBackend.scala b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/LoggingSttpBackend.scala
new file mode 100644
index 000000000..2cb563c6d
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/LoggingSttpBackend.scala
@@ -0,0 +1,19 @@
+package com.softwaremill.bootzooka.infrastructure
+
+import com.softwaremill.sttp.{MonadError, Request, Response, SttpBackend}
+import com.typesafe.scalalogging.StrictLogging
+
+class LoggingSttpBackend[R[_], S](delegate: SttpBackend[R, S]) extends SttpBackend[R, S] with StrictLogging {
+ override def send[T](request: Request[T, S]): R[Response[T]] = {
+ responseMonad.map(responseMonad.handleError(delegate.send(request)) {
+ case e: Exception =>
+ logger.error(s"Exception when sending request: $request", e)
+ responseMonad.error(e)
+ }) { response =>
+ logger.debug(s"For request: $request got response: $response")
+ response
+ }
+ }
+ override def close(): Unit = delegate.close()
+ override def responseMonad: MonadError[R] = delegate.responseMonad
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/metrics/AppMetrics.scala b/backend/src/main/scala/com/softwaremill/bootzooka/metrics/AppMetrics.scala
new file mode 100644
index 000000000..27d08e1e4
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/metrics/AppMetrics.scala
@@ -0,0 +1,16 @@
+package com.softwaremill.bootzooka.metrics
+
+import io.prometheus.client.{Gauge, hotspot}
+
+object AppMetrics extends HotSpotMetrics {
+ lazy val xGauge: Gauge =
+ Gauge
+ .build()
+ .name(s"x")
+ .help(s"X")
+ .register()
+}
+
+trait HotSpotMetrics {
+ hotspot.DefaultExports.initialize()
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/metrics/MetricsApi.scala b/backend/src/main/scala/com/softwaremill/bootzooka/metrics/MetricsApi.scala
new file mode 100644
index 000000000..8b2748d87
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/metrics/MetricsApi.scala
@@ -0,0 +1,22 @@
+package com.softwaremill.bootzooka.metrics
+
+import java.io.StringWriter
+
+import cats.implicits._
+import com.softwaremill.bootzooka.infrastructure.Http
+import io.prometheus.client.CollectorRegistry
+import io.prometheus.client.exporter.common.TextFormat
+import monix.eval.Task
+import tapir.server.ServerEndpoint
+
+class MetricsApi(http: Http, registry: CollectorRegistry) {
+ import http._
+
+ val metricsEndpoint: ServerEndpoint[Unit, Unit, String, Nothing, Task] = endpoint.get.out(stringBody).serverLogic { _ =>
+ Task {
+ val writer = new StringWriter
+ TextFormat.write004(writer, registry.metricFamilySamples)
+ writer.toString.asRight[Unit]
+ }
+ }
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/metrics/MetricsModule.scala b/backend/src/main/scala/com/softwaremill/bootzooka/metrics/MetricsModule.scala
new file mode 100644
index 000000000..1ef3b26df
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/metrics/MetricsModule.scala
@@ -0,0 +1,11 @@
+package com.softwaremill.bootzooka.metrics
+
+import com.softwaremill.bootzooka.infrastructure.Http
+import io.prometheus.client.CollectorRegistry
+
+trait MetricsModule {
+ lazy val metricsApi = new MetricsApi(http, collectorRegistry)
+
+ def collectorRegistry: CollectorRegistry
+ def http: Http
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetApi.scala b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetApi.scala
new file mode 100644
index 000000000..cadc2990d
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetApi.scala
@@ -0,0 +1,48 @@
+package com.softwaremill.bootzooka.passwordreset
+
+import cats.data.NonEmptyList
+import com.softwaremill.bootzooka.ServerEndpoints
+import com.softwaremill.bootzooka.infrastructure.Http
+import com.softwaremill.bootzooka.infrastructure.Json._
+
+class PasswordResetApi(http: Http, passwordResetService: PasswordResetService) {
+ import PasswordResetApi._
+ import http._
+
+ private val PasswordReset = "passwordreset"
+
+ private val passwordResetEndpoint = baseEndpoint.post
+ .in(PasswordReset / "reset")
+ .in(jsonBody[PasswordReset_IN])
+ .out(jsonBody[PasswordReset_OUT])
+ .serverLogic { data =>
+ (for {
+ _ <- passwordResetService.resetPassword(data.code, data.password)
+ } yield PasswordReset_OUT()).toOut
+ }
+
+ private val forgotPasswordEndpoint = baseEndpoint.post
+ .in(PasswordReset / "forgot")
+ .in(jsonBody[ForgotPassword_IN])
+ .out(jsonBody[ForgotPassword_OUT])
+ .serverLogic { data =>
+ (for {
+ _ <- passwordResetService.forgotPassword(data.loginOrEmail).transact
+ } yield ForgotPassword_OUT()).toOut
+ }
+
+ val endpoints: ServerEndpoints =
+ NonEmptyList
+ .of(
+ passwordResetEndpoint,
+ forgotPasswordEndpoint
+ )
+}
+
+object PasswordResetApi {
+ case class PasswordReset_IN(code: String, password: String)
+ case class PasswordReset_OUT()
+
+ case class ForgotPassword_IN(loginOrEmail: String)
+ case class ForgotPassword_OUT()
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala
new file mode 100644
index 000000000..5e61c56ec
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala
@@ -0,0 +1,40 @@
+package com.softwaremill.bootzooka.passwordreset
+
+import java.time.Instant
+
+import cats.implicits._
+
+import com.softwaremill.bootzooka.Id
+import com.softwaremill.bootzooka.user.User
+import com.softwaremill.tagging.@@
+import com.softwaremill.bootzooka.infrastructure.Doobie._
+import com.softwaremill.bootzooka.security.AuthTokenOps
+
+object PasswordResetCodeModel {
+
+ def insert(pr: PasswordResetCode): ConnectionIO[Unit] = {
+ sql"""INSERT INTO password_reset_codes (id, user_id, valid_until)
+ |VALUES (${pr.id}, ${pr.userId}, ${pr.validUntil})""".stripMargin.update.run.void
+ }
+
+ def delete(id: Id @@ PasswordResetCode): ConnectionIO[Unit] = {
+ sql"""DELETE FROM password_reset_codes WHERE id = $id""".stripMargin.update.run.void
+ }
+
+ def findById(id: Id @@ PasswordResetCode): ConnectionIO[Option[PasswordResetCode]] = {
+ sql"SELECT id, user_id, valid_until FROM password_reset_codes WHERE id = $id"
+ .query[PasswordResetCode]
+ .option
+ }
+}
+
+case class PasswordResetCode(id: Id @@ PasswordResetCode, userId: Id @@ User, validUntil: Instant)
+
+object PasswordResetAuthToken extends AuthTokenOps[PasswordResetCode] {
+ override def tokenName: String = "PasswordResetCode"
+ override def findById: Id @@ PasswordResetCode => ConnectionIO[Option[PasswordResetCode]] = PasswordResetCodeModel.findById
+ override def delete: PasswordResetCode => ConnectionIO[Unit] = ak => PasswordResetCodeModel.delete(ak.id)
+ override def userId: PasswordResetCode => Id @@ User = _.userId
+ override def validUntil: PasswordResetCode => Instant = _.validUntil
+ override def deleteWhenValid: Boolean = true
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetConfig.scala b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetConfig.scala
new file mode 100644
index 000000000..aa14b07c5
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetConfig.scala
@@ -0,0 +1,3 @@
+package com.softwaremill.bootzooka.passwordreset
+
+case class PasswordResetConfig(resetLinkPattern: String, codeValidHours: Int)
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetModule.scala b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetModule.scala
new file mode 100644
index 000000000..71f349356
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetModule.scala
@@ -0,0 +1,19 @@
+package com.softwaremill.bootzooka.passwordreset
+
+import com.softwaremill.bootzooka.BaseModule
+import com.softwaremill.bootzooka.email.{EmailScheduler, EmailTemplatingEngine}
+import com.softwaremill.bootzooka.infrastructure.Http
+import com.softwaremill.bootzooka.security.Auth
+import doobie.util.transactor.Transactor
+import monix.eval.Task
+
+trait PasswordResetModule extends BaseModule {
+ lazy val passwordResetService = new PasswordResetService(emailScheduler, emailTemplatingEngine, passwordResetCodeAuth, idGenerator, config.passwordReset, clock, xa)
+ lazy val passwordResetApi = new PasswordResetApi(http, passwordResetService)
+
+ def http: Http
+ def passwordResetCodeAuth: Auth[PasswordResetCode]
+ def emailScheduler: EmailScheduler
+ def emailTemplatingEngine: EmailTemplatingEngine
+ def xa: Transactor[Task]
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala
new file mode 100644
index 000000000..c4ee814be
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala
@@ -0,0 +1,55 @@
+package com.softwaremill.bootzooka.passwordreset
+
+import java.time.temporal.ChronoUnit
+
+import cats.implicits._
+import com.softwaremill.bootzooka._
+import com.softwaremill.bootzooka.email.{EmailData, EmailScheduler, EmailSubjectContent, EmailTemplatingEngine}
+import com.softwaremill.bootzooka.infrastructure.Doobie._
+import com.softwaremill.bootzooka.security.Auth
+import com.softwaremill.bootzooka.user.{User, UserModel}
+import com.typesafe.scalalogging.StrictLogging
+import monix.eval.Task
+
+class PasswordResetService(
+ emailScheduler: EmailScheduler,
+ emailTemplatingEngine: EmailTemplatingEngine,
+ auth: Auth[PasswordResetCode],
+ idGenerator: IdGenerator,
+ config: PasswordResetConfig,
+ clock: Clock,
+ xa: Transactor[Task]
+) extends StrictLogging {
+
+ def forgotPassword(loginOrEmail: String): ConnectionIO[Unit] = {
+ UserModel.findByLoginOrEmail(loginOrEmail.lowerCased).flatMap {
+ case None => Fail.NotFound("user").raiseError[ConnectionIO, Unit]
+ case Some(user) =>
+ createCode(user).flatMap(sendCode(user, _))
+ }
+ }
+
+ private def createCode(user: User): ConnectionIO[PasswordResetCode] = {
+ logger.debug(s"Creating password reset code for user: ${user.id}")
+ val validUntil = clock.now().plus(config.codeValidHours.toLong, ChronoUnit.HOURS)
+ val code = PasswordResetCode(idGenerator.nextId[PasswordResetCode](), user.id, validUntil)
+ PasswordResetCodeModel.insert(code).map(_ => code)
+ }
+
+ private def sendCode(user: User, code: PasswordResetCode): ConnectionIO[Unit] = {
+ logger.debug(s"Scheduling e-mail with reset code for user: ${user.id}")
+ emailScheduler(EmailData(user.emailLowerCased, prepareResetEmail(user, code)))
+ }
+
+ private def prepareResetEmail(user: User, code: PasswordResetCode): EmailSubjectContent = {
+ val resetLink = String.format(config.resetLinkPattern, code.id)
+ emailTemplatingEngine.passwordReset(user.login, resetLink)
+ }
+
+ def resetPassword(code: String, newPassword: String): Task[Unit] = {
+ for {
+ userId <- auth(code.asInstanceOf[Id])
+ _ <- UserModel.updatePassword(userId, User.hashPassword(newPassword)).transact(xa)
+ } yield ()
+ }
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/api/PasswordResetRoutes.scala b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/api/PasswordResetRoutes.scala
deleted file mode 100644
index cc5c28162..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/api/PasswordResetRoutes.scala
+++ /dev/null
@@ -1,34 +0,0 @@
-package com.softwaremill.bootzooka.passwordreset.api
-
-import akka.http.scaladsl.model.StatusCodes
-import akka.http.scaladsl.server.Directives._
-import com.softwaremill.bootzooka.common.api.RoutesSupport
-import com.softwaremill.bootzooka.passwordreset.application.PasswordResetService
-import com.softwaremill.bootzooka.user.api.SessionSupport
-import io.circe.generic.auto._
-
-trait PasswordResetRoutes extends RoutesSupport with SessionSupport {
-
- def passwordResetService: PasswordResetService
-
- val passwordResetRoutes = pathPrefix("passwordreset") {
- post {
- path(Segment) { code =>
- entity(as[PasswordResetInput]) { in =>
- onSuccess(passwordResetService.performPasswordReset(code, in.password)) {
- case Left(e) => complete(StatusCodes.Forbidden, e)
- case _ => completeOk
- }
- }
- } ~ entity(as[ForgotPasswordInput]) { in =>
- onSuccess(passwordResetService.sendResetCodeToUser(in.login)) {
- complete("success")
- }
- }
- }
- }
-}
-
-case class PasswordResetInput(password: String)
-
-case class ForgotPasswordInput(login: String)
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/application/PasswordResetCodeDao.scala b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/application/PasswordResetCodeDao.scala
deleted file mode 100644
index f6253cc34..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/application/PasswordResetCodeDao.scala
+++ /dev/null
@@ -1,77 +0,0 @@
-package com.softwaremill.bootzooka.passwordreset.application
-
-import java.time.OffsetDateTime
-import java.util.UUID
-
-import com.softwaremill.bootzooka.common.FutureHelpers._
-import com.softwaremill.bootzooka.common.sql.SqlDatabase
-import com.softwaremill.bootzooka.passwordreset.domain.PasswordResetCode
-import com.softwaremill.bootzooka.user.application.SqlUserSchema
-import com.softwaremill.bootzooka.user.domain.User
-
-import scala.concurrent.{ExecutionContext, Future}
-import scala.language.implicitConversions
-
-class PasswordResetCodeDao(protected val database: SqlDatabase)(implicit ec: ExecutionContext)
- extends SqlPasswordResetCodeSchema
- with SqlUserSchema {
-
- import database._
- import database.driver.api._
-
- def add(code: PasswordResetCode): Future[Unit] =
- db.run(passwordResetCodes += SqlPasswordResetCode(code)).mapToUnit
-
- def findByCode(code: String): Future[Option[PasswordResetCode]] = findFirstMatching(_.code === code)
-
- private def findFirstMatching(condition: PasswordResetCodes => Rep[Boolean]): Future[Option[PasswordResetCode]] = {
- val q = for {
- resetCode <- passwordResetCodes.filter(condition)
- user <- resetCode.user
- } yield (resetCode, user)
-
- val conversion: PartialFunction[(SqlPasswordResetCode, User), PasswordResetCode] = {
- case (rc, u) => PasswordResetCode(rc.id, rc.code, u, rc.validTo)
- }
-
- db.run(convertFirstResultItem(q.result.headOption, conversion))
- }
-
- def remove(code: PasswordResetCode): Future[Unit] =
- db.run(passwordResetCodes.filter(_.id === code.id).delete).mapToUnit
-
- private def convertFirstResultItem[A, B](action: DBIOAction[Option[A], _, _], conversion: (PartialFunction[A, B])) =
- action.map(_.map(conversion))
-}
-
-trait SqlPasswordResetCodeSchema { this: SqlUserSchema =>
-
- protected val database: SqlDatabase
-
- import database._
- import database.driver.api._
-
- protected val passwordResetCodes = TableQuery[PasswordResetCodes]
-
- protected case class SqlPasswordResetCode(id: UUID, code: String, userId: UUID, validTo: OffsetDateTime)
-
- protected object SqlPasswordResetCode extends ((UUID, String, UUID, OffsetDateTime) => SqlPasswordResetCode) {
- def apply(rc: PasswordResetCode): SqlPasswordResetCode =
- SqlPasswordResetCode(rc.id, rc.code, rc.user.id, rc.validTo)
- }
-
- // format: OFF
- protected class PasswordResetCodes(tag: Tag) extends Table[SqlPasswordResetCode](tag, "password_reset_codes") {
- def id = column[UUID]("id", O.PrimaryKey)
- def code = column[String]("code")
- def userId = column[UUID]("user_id")
- def validTo = column[OffsetDateTime]("valid_to")
-
- def * = (id, code, userId, validTo) <> (SqlPasswordResetCode.tupled, SqlPasswordResetCode.unapply)
-
- def user = foreignKey("password_reset_code_user_fk", userId, users)(
- _.id, onUpdate = ForeignKeyAction.Cascade, onDelete = ForeignKeyAction.Cascade)
- // format: ON
- }
-
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/application/PasswordResetConfig.scala b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/application/PasswordResetConfig.scala
deleted file mode 100644
index 2f08a9fa6..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/application/PasswordResetConfig.scala
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.softwaremill.bootzooka.passwordreset.application
-
-import com.softwaremill.bootzooka.common.ConfigWithDefault
-import com.typesafe.config.Config
-
-trait PasswordResetConfig extends ConfigWithDefault {
- def rootConfig: Config
-
- lazy val resetLinkPattern =
- getString("bootzooka.reset-link-pattern", "http://localhost:8080/#/password-reset?code=%s")
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/application/PasswordResetService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/application/PasswordResetService.scala
deleted file mode 100644
index 0789b3341..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/application/PasswordResetService.scala
+++ /dev/null
@@ -1,82 +0,0 @@
-package com.softwaremill.bootzooka.passwordreset.application
-
-import java.time.Instant
-
-import com.softwaremill.bootzooka.common.crypto.{PasswordHashing, Salt}
-import com.softwaremill.bootzooka.common.Utils
-import com.softwaremill.bootzooka.email.application.{EmailService, EmailTemplatingEngine}
-import com.softwaremill.bootzooka.email.domain.EmailContentWithSubject
-import com.softwaremill.bootzooka.passwordreset.domain.PasswordResetCode
-import com.softwaremill.bootzooka.user.application.UserDao
-import com.softwaremill.bootzooka.user.domain.User
-import com.typesafe.scalalogging.StrictLogging
-
-import scala.concurrent.{ExecutionContext, Future}
-
-class PasswordResetService(
- userDao: UserDao,
- codeDao: PasswordResetCodeDao,
- emailService: EmailService,
- emailTemplatingEngine: EmailTemplatingEngine,
- config: PasswordResetConfig,
- passwordHashing: PasswordHashing
-)(implicit ec: ExecutionContext)
- extends StrictLogging {
-
- def sendResetCodeToUser(login: String): Future[Unit] = {
- logger.debug(s"Preparing to generate and send reset code to user $login")
- val userFut = userDao.findByLoginOrEmail(login)
- userFut.flatMap {
- case Some(user) =>
- logger.debug("User found")
- val code = randomPass(user)
- storeCode(code).flatMap(_ => sendCode(code))
- case None =>
- logger.debug(s"User not found: $login")
- Future.successful((): Unit)
- }
- }
-
- private def randomPass(user: User): PasswordResetCode = PasswordResetCode(Utils.randomString(32), user)
-
- private def storeCode(code: PasswordResetCode): Future[Unit] = {
- logger.debug(s"Storing reset code for user ${code.user.login}")
- codeDao.add(code)
- }
-
- private def sendCode(code: PasswordResetCode): Future[Unit] = {
- logger.debug(s"Scheduling e-mail with reset code for user ${code.user.login}")
- emailService.scheduleEmail(code.user.email, prepareResetEmail(code.user, code))
- }
-
- private def prepareResetEmail(user: User, code: PasswordResetCode): EmailContentWithSubject = {
- val resetLink = String.format(config.resetLinkPattern, code.code)
- emailTemplatingEngine.passwordReset(user.login, resetLink)
- }
-
- def performPasswordReset(code: String, newPassword: String): Future[Either[String, Boolean]] = {
- logger.debug("Performing password reset")
- codeDao.findByCode(code).flatMap {
- case Some(c) =>
- if (c.validTo.toInstant.isAfter(Instant.now())) {
- for {
- _ <- changePassword(c, newPassword)
- _ <- invalidateResetCode(c)
- } yield Right(true)
- } else {
- invalidateResetCode(c).map(_ => Left("Your reset code is invalid. Please try again."))
- }
- case None =>
- logger.debug("Reset code not found")
- Future.successful(Left("Your reset code is invalid. Please try again."))
- }
- }
-
- private def changePassword(code: PasswordResetCode, newPassword: String): Future[Unit] = {
- val salt = Salt.newSalt()
- userDao.changePassword(code.user.id, passwordHashing.hashPassword(newPassword, salt), salt)
- }
-
- private def invalidateResetCode(code: PasswordResetCode): Future[Unit] =
- codeDao.remove(code)
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/domain/PasswordResetCode.scala b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/domain/PasswordResetCode.scala
deleted file mode 100644
index 77c79f19b..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/domain/PasswordResetCode.scala
+++ /dev/null
@@ -1,20 +0,0 @@
-package com.softwaremill.bootzooka.passwordreset.domain
-
-import java.time.temporal.ChronoUnit
-import java.time.{Instant, OffsetDateTime, ZoneOffset}
-import java.util.UUID
-
-import com.softwaremill.bootzooka.user.domain.User
-
-/**
- * Code used in the process of password reset.
- * By default this code has the `validTo` set to now plus 24 hours.
- */
-case class PasswordResetCode(id: UUID, code: String, user: User, validTo: OffsetDateTime)
-
-object PasswordResetCode {
- def apply(code: String, user: User): PasswordResetCode = {
- val nextDay = Instant.now().plus(24, ChronoUnit.HOURS).atOffset(ZoneOffset.UTC)
- PasswordResetCode(UUID.randomUUID(), code, user, nextDay)
- }
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyModel.scala b/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyModel.scala
new file mode 100644
index 000000000..a82e853d7
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyModel.scala
@@ -0,0 +1,38 @@
+package com.softwaremill.bootzooka.security
+
+import java.time.Instant
+
+import cats.implicits._
+
+import com.softwaremill.bootzooka.Id
+import com.softwaremill.bootzooka.infrastructure.Doobie
+import com.softwaremill.bootzooka.infrastructure.Doobie._
+import com.softwaremill.bootzooka.user.User
+import com.softwaremill.tagging.@@
+
+object ApiKeyModel {
+
+ def insert(apiKey: ApiKey): ConnectionIO[Unit] = {
+ sql"""INSERT INTO api_keys (id, user_id, created_on, valid_until)
+ |VALUES (${apiKey.id}, ${apiKey.userId}, ${apiKey.createdOn}, ${apiKey.validUntil})""".stripMargin.update.run.void
+ }
+
+ def findById(id: Id @@ ApiKey): ConnectionIO[Option[ApiKey]] = {
+ sql"""SELECT id, user_id, created_on, valid_until FROM api_keys WHERE id = $id"""
+ .query[ApiKey]
+ .option
+ }
+
+ def delete(id: Id @@ ApiKey): ConnectionIO[Unit] = {
+ sql"""DELETE FROM api_keys WHERE id = $id""".update.run.void
+ }
+}
+
+object ApiKeyAuthToken extends AuthTokenOps[ApiKey] {
+ override def tokenName: String = "ApiKey"
+ override def findById: Id @@ ApiKey => Doobie.ConnectionIO[Option[ApiKey]] = ApiKeyModel.findById
+ override def delete: ApiKey => Doobie.ConnectionIO[Unit] = ak => ApiKeyModel.delete(ak.id)
+ override def userId: ApiKey => Id @@ User = _.userId
+ override def validUntil: ApiKey => Instant = _.validUntil
+ override def deleteWhenValid: Boolean = false
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyService.scala
new file mode 100644
index 000000000..400df4144
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyService.scala
@@ -0,0 +1,21 @@
+package com.softwaremill.bootzooka.security
+
+import java.time.temporal.ChronoUnit
+
+import com.softwaremill.bootzooka.user.User
+import com.softwaremill.bootzooka.{Clock, Id, IdGenerator}
+import com.softwaremill.tagging.@@
+import com.softwaremill.bootzooka.infrastructure.Doobie._
+import com.typesafe.scalalogging.StrictLogging
+
+class ApiKeyService(idGenerator: IdGenerator, clock: Clock) extends StrictLogging {
+
+ def create(userId: Id @@ User, validHours: Int): ConnectionIO[ApiKey] = {
+ val now = clock.now()
+ val validUntil = now.plus(validHours.toLong, ChronoUnit.HOURS)
+ val apiKey = ApiKey(idGenerator.nextId[ApiKey](), userId, clock.now(), validUntil)
+
+ logger.info(s"Creating a new api key for user $userId, valid until: $validUntil")
+ ApiKeyModel.insert(apiKey).map(_ => apiKey)
+ }
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala b/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala
new file mode 100644
index 000000000..fd93a41d5
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala
@@ -0,0 +1,61 @@
+package com.softwaremill.bootzooka.security
+
+import java.security.SecureRandom
+import java.time.Instant
+
+import cats.data.OptionT
+import cats.effect.Timer
+import com.softwaremill.bootzooka.infrastructure.Doobie._
+import com.softwaremill.bootzooka.user.User
+import com.softwaremill.bootzooka._
+import com.softwaremill.tagging._
+import com.typesafe.scalalogging.StrictLogging
+import monix.eval.Task
+import cats.implicits._
+
+import scala.concurrent.duration._
+
+class Auth[T](
+ authTokenOps: AuthTokenOps[T],
+ xa: Transactor[Task],
+ clock: Clock
+) extends StrictLogging {
+
+ // see https://hackernoon.com/hack-how-to-use-securerandom-with-kubernetes-and-docker-a375945a7b21
+ private val random = SecureRandom.getInstance("NativePRNGNonBlocking")
+
+ def apply(id: Id): Task[Id @@ User] = {
+ val tokenOpt = (for {
+ token <- OptionT(authTokenOps.findById(id.asId[T]).transact(xa))
+ _ <- OptionT(verifyValid(token))
+ } yield token).value
+
+ tokenOpt.flatMap {
+ case None =>
+ logger.debug(s"Auth failed for: ${authTokenOps.tokenName} $id")
+ // random sleep to prevent timing attacks
+ Timer[Task].sleep(random.nextInt(1000).millis).flatMap(_ => Task.raiseError(Fail.Unauthorized))
+ case Some(token) =>
+ val delete = if (authTokenOps.deleteWhenValid) authTokenOps.delete(token).transact(xa) else Task.unit
+ delete >> Task.now(authTokenOps.userId(token))
+ }
+ }
+
+ private def verifyValid(token: T): Task[Option[Unit]] = {
+ if (clock.now().isAfter(authTokenOps.validUntil(token))) {
+ logger.info(s"${authTokenOps.tokenName} expired: $token")
+ authTokenOps.delete(token).transact(xa).map(_ => None)
+ } else {
+ Task(Some(()))
+ }
+ }
+}
+
+trait AuthTokenOps[T] {
+ def tokenName: String
+ def findById: (Id @@ T) => ConnectionIO[Option[T]]
+ def delete: T => ConnectionIO[Unit]
+ def userId: T => Id @@ User
+ def validUntil: T => Instant
+ def deleteWhenValid: Boolean
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/security/SecurityModule.scala b/backend/src/main/scala/com/softwaremill/bootzooka/security/SecurityModule.scala
new file mode 100644
index 000000000..5b9d33391
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/security/SecurityModule.scala
@@ -0,0 +1,14 @@
+package com.softwaremill.bootzooka.security
+
+import com.softwaremill.bootzooka.BaseModule
+import com.softwaremill.bootzooka.passwordreset.{PasswordResetAuthToken, PasswordResetCode}
+import doobie.util.transactor.Transactor
+import monix.eval.Task
+
+trait SecurityModule extends BaseModule {
+ lazy val apiKeyService = new ApiKeyService(idGenerator, clock)
+ lazy val apiKeyAuth: Auth[ApiKey] = new Auth(ApiKeyAuthToken, xa, clock)
+ lazy val passwordResetCodeAuth: Auth[PasswordResetCode] = new Auth(PasswordResetAuthToken, xa, clock)
+
+ def xa: Transactor[Task]
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/security/model.scala b/backend/src/main/scala/com/softwaremill/bootzooka/security/model.scala
new file mode 100644
index 000000000..c83c0eb4e
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/security/model.scala
@@ -0,0 +1,9 @@
+package com.softwaremill.bootzooka.security
+
+import java.time.Instant
+
+import com.softwaremill.bootzooka.Id
+import com.softwaremill.bootzooka.user.User
+import com.softwaremill.tagging.@@
+
+case class ApiKey(id: Id @@ ApiKey, userId: Id @@ User, createdOn: Instant, validUntil: Instant)
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/sql/H2BrowserConsole.scala b/backend/src/main/scala/com/softwaremill/bootzooka/sql/H2BrowserConsole.scala
deleted file mode 100644
index 460d4e52c..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/sql/H2BrowserConsole.scala
+++ /dev/null
@@ -1,16 +0,0 @@
-package com.softwaremill.bootzooka.sql
-
-import com.softwaremill.bootzooka.common.sql.{DatabaseConfig, SqlDatabase}
-import com.typesafe.config.ConfigFactory
-
-object H2BrowserConsole extends App {
- val config = new DatabaseConfig {
- def rootConfig = ConfigFactory.load()
- }
-
- new Thread(new Runnable {
- def run() = new org.h2.tools.Console().runTool("-url", SqlDatabase.embeddedConnectionStringFromConfig(config))
- }).start()
-
- println("The console is now running in the background.")
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/sql/H2ShellConsole.scala b/backend/src/main/scala/com/softwaremill/bootzooka/sql/H2ShellConsole.scala
deleted file mode 100644
index e35a74b5f..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/sql/H2ShellConsole.scala
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.softwaremill.bootzooka.sql
-
-import com.softwaremill.bootzooka.common.sql.{DatabaseConfig, SqlDatabase}
-import com.typesafe.config.ConfigFactory
-
-object H2ShellConsole extends App {
- val config = new DatabaseConfig {
- def rootConfig = ConfigFactory.load()
- }
-
- println("Note: when selecting from tables, enclose the table name in \" \".")
- new org.h2.tools.Shell().runTool("-url", SqlDatabase.embeddedConnectionStringFromConfig(config))
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/swagger/SwaggerDocService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/swagger/SwaggerDocService.scala
deleted file mode 100644
index 46d2f5d71..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/swagger/SwaggerDocService.scala
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.softwaremill.bootzooka.swagger
-
-import com.github.swagger.akka.model.Info
-
-import akka.actor.ActorSystem
-import com.github.swagger.akka._
-import com.softwaremill.bootzooka.version.VersionRoutes
-import com.softwaremill.bootzooka.version.BuildInfo._
-
-class SwaggerDocService(address: String, port: Int, system: ActorSystem) extends SwaggerHttpService {
- override val apiClasses: Set[Class[_]] = Set( // add here routes in order to add to swagger
- classOf[VersionRoutes]
- )
- override val host = address + ":" + port
- override val info = Info(version = buildDate, title = "Bootzooka")
- override val apiDocsPath = "api-docs"
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/User.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/User.scala
new file mode 100644
index 000000000..632ee2648
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/user/User.scala
@@ -0,0 +1,25 @@
+package com.softwaremill.bootzooka.user
+
+import java.time.Instant
+
+import com.softwaremill.bootzooka.{Id, LowerCased}
+import com.softwaremill.tagging.@@
+import tsec.common.VerificationStatus
+import tsec.passwordhashers.PasswordHash
+import tsec.passwordhashers.jca.SCrypt
+
+case class User(
+ id: Id @@ User,
+ login: String,
+ loginLowerCased: String @@ LowerCased,
+ emailLowerCased: String @@ LowerCased,
+ passwordHash: PasswordHash[SCrypt],
+ createdOn: Instant
+) {
+
+ def verifyPassword(password: String): VerificationStatus = SCrypt.checkpw[cats.Id](password, passwordHash)
+}
+
+object User {
+ def hashPassword(password: String): PasswordHash[SCrypt] = SCrypt.hashpw[cats.Id](password)
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/UserApi.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserApi.scala
new file mode 100644
index 000000000..b7ffdbbfc
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserApi.scala
@@ -0,0 +1,98 @@
+package com.softwaremill.bootzooka.user
+
+import java.time.Instant
+
+import cats.data.NonEmptyList
+import com.softwaremill.bootzooka.{LowerCased, ServerEndpoints}
+import com.softwaremill.bootzooka.infrastructure.Http
+import com.softwaremill.bootzooka.infrastructure.Json._
+import com.softwaremill.bootzooka.security.{ApiKey, Auth}
+import com.softwaremill.tagging.@@
+
+class UserApi(http: Http, auth: Auth[ApiKey], userService: UserService) {
+ import UserApi._
+ import http._
+
+ private val User = "user"
+
+ private val registerUserEndpoint = baseEndpoint.post
+ .in(User / "register")
+ .in(jsonBody[Register_IN])
+ .out(jsonBody[Register_OUT])
+ .serverLogic { data =>
+ (for {
+ apiKey <- userService.registerNewUser(data.login, data.email, data.password).transact
+ } yield Register_OUT(apiKey.id)).toOut
+ }
+
+ private val loginEndpoint = baseEndpoint.post
+ .in(User / "login")
+ .in(jsonBody[Login_IN])
+ .out(jsonBody[Login_OUT])
+ .serverLogic { data =>
+ (for {
+ apiKey <- userService.login(data.loginOrEmail, data.password, data.apiKeyValidHours).transact
+ } yield Login_OUT(apiKey.id)).toOut
+ }
+
+ private val changePasswordEndpoint = secureEndpoint.post
+ .in(User / "changepassword")
+ .in(jsonBody[ChangePassword_IN])
+ .out(jsonBody[ChangePassword_OUT])
+ .serverLogic {
+ case (authData, data) =>
+ (for {
+ userId <- auth(authData)
+ _ <- userService.changePassword(userId, data.currentPassword, data.newPassword).transact
+ } yield ChangePassword_OUT()).toOut
+ }
+
+ private val getUserEndpoint = secureEndpoint.get
+ .in(User)
+ .out(jsonBody[GetUser_OUT])
+ .serverLogic { authData =>
+ (for {
+ userId <- auth(authData)
+ user <- userService.findById(userId).transact
+ } yield GetUser_OUT(user.login, user.emailLowerCased, user.createdOn)).toOut
+ }
+
+ private val updateUserEndpoint = secureEndpoint.post
+ .in(User)
+ .in(jsonBody[UpdateUser_IN])
+ .out(jsonBody[UpdateUser_OUT])
+ .serverLogic {
+ case (authData, data) =>
+ (for {
+ userId <- auth(authData)
+ _ <- userService.changeUser(userId, data.login, data.email).transact
+ } yield UpdateUser_OUT()).toOut
+ }
+
+ val endpoints: ServerEndpoints =
+ NonEmptyList
+ .of(
+ registerUserEndpoint,
+ loginEndpoint,
+ changePasswordEndpoint,
+ getUserEndpoint,
+ updateUserEndpoint
+ )
+}
+
+object UserApi {
+
+ case class Register_IN(login: String, email: String, password: String)
+ case class Register_OUT(apiKey: String)
+
+ case class ChangePassword_IN(currentPassword: String, newPassword: String)
+ case class ChangePassword_OUT()
+
+ case class Login_IN(loginOrEmail: String, password: String, apiKeyValidHours: Option[Int])
+ case class Login_OUT(apiKey: String)
+
+ case class UpdateUser_IN(login: Option[String], email: Option[String])
+ case class UpdateUser_OUT()
+
+ case class GetUser_OUT(login: String, email: String @@ LowerCased, createdOn: Instant)
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModel.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModel.scala
new file mode 100644
index 000000000..a887ed684
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModel.scala
@@ -0,0 +1,47 @@
+package com.softwaremill.bootzooka.user
+
+import cats.implicits._
+import com.softwaremill.bootzooka.{Id, LowerCased}
+import com.softwaremill.bootzooka.infrastructure.Doobie._
+import com.softwaremill.tagging.@@
+import tsec.passwordhashers.PasswordHash
+import tsec.passwordhashers.jca.SCrypt
+
+object UserModel {
+
+ def insert(user: User): ConnectionIO[Unit] = {
+ sql"""INSERT INTO users (id, login, login_lowercase, email_lowercase, password, created_on)
+ |VALUES (${user.id}, ${user.login}, ${user.loginLowerCased}, ${user.emailLowerCased}, ${user.passwordHash}, ${user.createdOn})""".stripMargin.update.run.void
+ }
+
+ def findById(id: Id @@ User): ConnectionIO[Option[User]] = {
+ findBy(fr"id = $id")
+ }
+
+ def findByEmail(email: String @@ LowerCased): ConnectionIO[Option[User]] = {
+ findBy(fr"email_lowercase = $email")
+ }
+
+ def findByLogin(login: String @@ LowerCased): ConnectionIO[Option[User]] = {
+ findBy(fr"login_lowercase = $login")
+ }
+
+ def findByLoginOrEmail(loginOrEmail: String @@ LowerCased): ConnectionIO[Option[User]] = {
+ findBy(fr"login_lowercase = $loginOrEmail OR email_lowercase = $loginOrEmail")
+ }
+
+ private def findBy(by: Fragment): ConnectionIO[Option[User]] = {
+ (sql"SELECT id, login, login_lowercase, email_lowercase, password, created_on FROM users WHERE " ++ by)
+ .query[User]
+ .option
+ }
+
+ def updatePassword(userId: Id @@ User, newPassword: PasswordHash[SCrypt]): ConnectionIO[Unit] =
+ sql"""UPDATE users SET password = $newPassword WHERE id = $userId""".stripMargin.update.run.void
+
+ def updateLogin(userId: Id @@ User, newLogin: String, newLoginLowerCase: String @@ LowerCased): ConnectionIO[Unit] =
+ sql"""UPDATE users SET login = $newLogin, login_lowercase = $newLoginLowerCase WHERE id = $userId""".stripMargin.update.run.void
+
+ def updateEmail(userId: Id @@ User, newEmail: String @@ LowerCased): ConnectionIO[Unit] =
+ sql"""UPDATE users SET email_lowercase = $newEmail WHERE id = $userId""".stripMargin.update.run.void
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModule.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModule.scala
new file mode 100644
index 000000000..08865ebd4
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModule.scala
@@ -0,0 +1,17 @@
+package com.softwaremill.bootzooka.user
+
+import com.softwaremill.bootzooka.BaseModule
+import com.softwaremill.bootzooka.email.{EmailScheduler, EmailTemplatingEngine}
+import com.softwaremill.bootzooka.infrastructure.Http
+import com.softwaremill.bootzooka.security.{ApiKey, ApiKeyService, Auth}
+
+trait UserModule extends BaseModule {
+ lazy val userApi = new UserApi(http, apiKeyAuth, userService)
+ lazy val userService = new UserService(emailScheduler, emailTemplatingEngine, apiKeyService, idGenerator, clock, config.bootzooka)
+
+ def http: Http
+ def apiKeyAuth: Auth[ApiKey]
+ def emailScheduler: EmailScheduler
+ def emailTemplatingEngine: EmailTemplatingEngine
+ def apiKeyService: ApiKeyService
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/UserService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserService.scala
new file mode 100644
index 000000000..06a6ea335
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserService.scala
@@ -0,0 +1,132 @@
+package com.softwaremill.bootzooka.user
+
+import cats.implicits._
+import com.softwaremill.bootzooka._
+import com.softwaremill.bootzooka.email.{EmailData, EmailScheduler, EmailTemplatingEngine}
+import com.softwaremill.bootzooka.security.{ApiKey, ApiKeyService}
+import com.softwaremill.tagging.@@
+import com.typesafe.scalalogging.StrictLogging
+import tsec.common.Verified
+import com.softwaremill.bootzooka.infrastructure.Doobie._
+
+class UserService(
+ emailScheduler: EmailScheduler,
+ emailTemplatingEngine: EmailTemplatingEngine,
+ apiKeyService: ApiKeyService,
+ idGenerator: IdGenerator,
+ clock: Clock,
+ config: BootzookaConfig
+) extends StrictLogging {
+
+ private val LoginAlreadyUsed = "Login already in use!"
+ private val EmailAlreadyUsed = "E-mail already in use!"
+
+ def registerNewUser(login: String, email: String, password: String): ConnectionIO[ApiKey] = {
+ def failIfDefined(op: ConnectionIO[Option[User]], msg: String): ConnectionIO[Unit] = {
+ op.flatMap {
+ case None => ().pure[ConnectionIO]
+ case Some(_) => Fail.IncorrectInput(msg).raiseError[ConnectionIO, Unit]
+ }
+ }
+
+ def checkUserDoesNotExist(): ConnectionIO[Unit] = {
+ failIfDefined(UserModel.findByLogin(login.lowerCased), LoginAlreadyUsed) >>
+ failIfDefined(UserModel.findByEmail(email.lowerCased), EmailAlreadyUsed)
+ }
+
+ def doRegister(): ConnectionIO[ApiKey] = {
+ val user = User(idGenerator.nextId[User](), login, login.lowerCased, email.lowerCased, User.hashPassword(password), clock.now())
+ val confirmationEmail = emailTemplatingEngine.registrationConfirmation(login)
+
+ for {
+ _ <- UserModel.insert(user)
+ _ <- emailScheduler(EmailData(email, confirmationEmail))
+ apiKey <- apiKeyService.create(user.id, config.defaultApiKeyValidHours)
+ } yield apiKey
+ }
+
+ for {
+ _ <- UserRegisterValidator
+ .validate(login, email, password)
+ .fold(msg => Fail.IncorrectInput(msg).raiseError[ConnectionIO, Unit], _ => ().pure[ConnectionIO])
+ _ <- checkUserDoesNotExist()
+ apiKey <- doRegister()
+ } yield apiKey
+ }
+
+ def findById(id: Id @@ User): ConnectionIO[User] = userOrNotFound(UserModel.findById(id))
+
+ def login(loginOrEmail: String, password: String, apiKeyValidHours: Option[Int]): ConnectionIO[ApiKey] =
+ for {
+ user <- userOrNotFound(UserModel.findByLoginOrEmail(loginOrEmail.lowerCased))
+ _ <- verifyPassword(user, password)
+ apiKey <- apiKeyService.create(user.id, apiKeyValidHours.getOrElse(config.defaultApiKeyValidHours))
+ } yield apiKey
+
+ def changeUser(userId: Id @@ User, newLoginOpt: Option[String], newEmailOpt: Option[String]): ConnectionIO[Unit] = {
+ def changeLogin(newLogin: String): ConnectionIO[Unit] = {
+ val newLoginLowerCased = newLogin.lowerCased
+ UserModel.findByLogin(newLoginLowerCased).flatMap {
+ case Some(_) => Fail.IncorrectInput(LoginAlreadyUsed).raiseError[ConnectionIO, Unit]
+ case None => UserModel.updateLogin(userId, newLogin, newLoginLowerCased)
+ }
+ }
+
+ def changeEmail(newEmail: String): ConnectionIO[Unit] = {
+ val newEmailLowerCased = newEmail.lowerCased
+ UserModel.findByEmail(newEmailLowerCased).flatMap {
+ case Some(_) => Fail.IncorrectInput(EmailAlreadyUsed).raiseError[ConnectionIO, Unit]
+ case None => UserModel.updateEmail(userId, newEmailLowerCased)
+ }
+ }
+
+ newLoginOpt.map(changeLogin).getOrElse(().pure[ConnectionIO]) >>
+ newEmailOpt.map(changeEmail).getOrElse(().pure[ConnectionIO])
+ }
+
+ def changePassword(userId: Id @@ User, currentPassword: String, newPassword: String): ConnectionIO[Unit] =
+ for {
+ user <- userOrNotFound(UserModel.findById(userId))
+ _ <- verifyPassword(user, currentPassword)
+ _ <- UserModel.updatePassword(userId, User.hashPassword(newPassword))
+ } yield ()
+
+ private def userOrNotFound(op: ConnectionIO[Option[User]]): ConnectionIO[User] = {
+ op.flatMap {
+ case Some(user) => user.pure[ConnectionIO]
+ case None => Fail.NotFound("user").raiseError[ConnectionIO, User]
+ }
+ }
+
+ private def verifyPassword(user: User, password: String): ConnectionIO[Unit] = {
+ if (user.verifyPassword(password) == Verified) {
+ ().pure[ConnectionIO]
+ } else {
+ Fail.Unauthorized.raiseError[ConnectionIO, Unit]
+ }
+ }
+}
+
+object UserRegisterValidator {
+ private val ValidationOk = Right(())
+ val MinLoginLength = 3
+
+ def validate(login: String, email: String, password: String): Either[String, Unit] =
+ for {
+ _ <- validLogin(login.trim).right
+ _ <- validEmail(email.trim).right
+ _ <- validPassword(password.trim).right
+ } yield ()
+
+ private def validLogin(login: String) =
+ if (login.length >= MinLoginLength) ValidationOk else Left("Login is too short!")
+
+ private val emailRegex =
+ """^[a-zA-Z0-9\.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$""".r
+
+ private def validEmail(email: String) =
+ if (emailRegex.findFirstMatchIn(email).isDefined) ValidationOk else Left("Invalid e-mail!")
+
+ private def validPassword(password: String) =
+ if (password.nonEmpty) ValidationOk else Left("Password cannot be empty!")
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/api/SessionSupport.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/api/SessionSupport.scala
deleted file mode 100644
index 773c7fd1e..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/user/api/SessionSupport.scala
+++ /dev/null
@@ -1,37 +0,0 @@
-package com.softwaremill.bootzooka.user.api
-
-import akka.http.scaladsl.server.Directives._
-import akka.http.scaladsl.server._
-import com.softwaremill.bootzooka.user.application.{Session, UserService}
-import com.softwaremill.bootzooka.user.domain.BasicUserData
-import com.softwaremill.bootzooka.user.UserId
-import com.softwaremill.session.SessionDirectives._
-import com.softwaremill.session.SessionOptions._
-import com.softwaremill.session.{RefreshTokenStorage, SessionManager}
-
-import scala.concurrent.ExecutionContext
-
-trait SessionSupport {
-
- implicit def sessionManager: SessionManager[Session]
-
- implicit def refreshTokenStorage: RefreshTokenStorage[Session]
-
- implicit def ec: ExecutionContext
-
- def userService: UserService
-
- def userFromSession: Directive1[BasicUserData] = userIdFromSession.flatMap { userId =>
- onSuccess(userService.findById(userId)).flatMap {
- case None => reject(AuthorizationFailedRejection)
- case Some(user) => provide(user)
- }
- }
-
- def userIdFromSession: Directive1[UserId] = session(refreshable, usingCookies).flatMap {
- _.toOption match {
- case None => reject(AuthorizationFailedRejection)
- case Some(s) => provide(s.userId)
- }
- }
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/api/UsersRoutes.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/api/UsersRoutes.scala
deleted file mode 100644
index 76bfcb0b6..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/user/api/UsersRoutes.scala
+++ /dev/null
@@ -1,106 +0,0 @@
-package com.softwaremill.bootzooka.user.api
-
-import akka.http.scaladsl.model.StatusCodes
-import akka.http.scaladsl.server.AuthorizationFailedRejection
-import akka.http.scaladsl.server.Directives._
-import com.softwaremill.bootzooka.common.Utils
-import com.softwaremill.bootzooka.common.api.RoutesSupport
-import com.softwaremill.bootzooka.user.application.{Session, UserRegisterResult, UserService}
-import com.softwaremill.bootzooka.user.domain.BasicUserData
-import com.softwaremill.session.SessionDirectives._
-import com.softwaremill.session.SessionOptions._
-import com.typesafe.scalalogging.StrictLogging
-import io.circe.generic.auto._
-
-import scala.concurrent.Future
-
-trait UsersRoutes extends RoutesSupport with StrictLogging with SessionSupport {
-
- def userService: UserService
-
- implicit val basicUserDataCbs = CanBeSerialized[BasicUserData]
-
- val usersRoutes = pathPrefix("users") {
- path("logout") {
- get {
- userIdFromSession { _ =>
- invalidateSession(refreshable, usingCookies) {
- completeOk
- }
- }
- }
- } ~
- path("register") {
- post {
- entity(as[RegistrationInput]) { in =>
- onSuccess(userService.registerNewUser(in.loginEscaped, in.email, in.password)) {
- case UserRegisterResult.InvalidData(msg) => complete(StatusCodes.BadRequest, msg)
- case UserRegisterResult.UserExists(msg) => complete(StatusCodes.Conflict, msg)
- case UserRegisterResult.Success => complete("success")
- }
- }
- }
- } ~
- path("changepassword") {
- post {
- userFromSession { user =>
- entity(as[ChangePasswordInput]) { in =>
- onSuccess(userService.changePassword(user.id, in.currentPassword, in.newPassword)) {
- case Left(msg) => complete(StatusCodes.Forbidden, msg)
- case Right(_) => completeOk
- }
- }
- }
- }
- } ~
- pathEnd {
- post {
- entity(as[LoginInput]) { in =>
- onSuccess(userService.authenticate(in.login, in.password)) {
- case None => reject(AuthorizationFailedRejection)
- case Some(user) =>
- val session = Session(user.id)
- (if (in.rememberMe.getOrElse(false)) {
- setSession(refreshable, usingCookies, session)
- } else {
- setSession(oneOff, usingCookies, session)
- }) {
- complete(user)
- }
- }
- }
- } ~
- get {
- userFromSession { user =>
- complete(user)
- }
- } ~
- patch {
- userIdFromSession { userId =>
- entity(as[PatchUserInput]) { in =>
- val updateAction = (in.login, in.email) match {
- case (Some(login), _) => userService.changeLogin(userId, login)
- case (_, Some(email)) => userService.changeEmail(userId, email)
- case _ => Future.successful(Left("You have to provide new login or email"))
- }
-
- onSuccess(updateAction) {
- case Left(msg) => complete(StatusCodes.Conflict, msg)
- case Right(_) => completeOk
- }
- }
- }
- }
- }
- }
-}
-
-case class RegistrationInput(login: String, email: String, password: String) {
- def loginEscaped = Utils.escapeHtml(login)
-}
-
-case class ChangePasswordInput(currentPassword: String, newPassword: String)
-
-case class LoginInput(login: String, password: String, rememberMe: Option[Boolean])
-
-case class PatchUserInput(login: Option[String], email: Option[String])
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/application/RefreshTokenStorageImpl.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/application/RefreshTokenStorageImpl.scala
deleted file mode 100644
index 33b7c5bf1..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/user/application/RefreshTokenStorageImpl.scala
+++ /dev/null
@@ -1,42 +0,0 @@
-package com.softwaremill.bootzooka.user.application
-
-import java.time.{Instant, ZoneOffset}
-import java.util.UUID
-import java.util.concurrent.TimeUnit
-
-import akka.actor.ActorSystem
-import com.softwaremill.bootzooka.user.domain.RememberMeToken
-import com.softwaremill.session.{RefreshTokenData, RefreshTokenLookupResult, RefreshTokenStorage}
-
-import scala.concurrent.duration.{Duration, FiniteDuration}
-import scala.concurrent.{ExecutionContext, Future}
-
-class RefreshTokenStorageImpl(dao: RememberMeTokenDao, system: ActorSystem)(implicit ec: ExecutionContext)
- extends RefreshTokenStorage[Session] {
-
- override def lookup(selector: String) =
- dao
- .findBySelector(selector)
- .map(
- _.map(t => RefreshTokenLookupResult(t.tokenHash, t.validTo.toInstant.toEpochMilli, () => Session(t.userId)))
- )
-
- override def store(data: RefreshTokenData[Session]) =
- dao.add(
- RememberMeToken(
- UUID.randomUUID(),
- data.selector,
- data.tokenHash,
- data.forSession.userId,
- Instant.ofEpochMilli(data.expires).atOffset(ZoneOffset.UTC)
- )
- )
-
- override def remove(selector: String) =
- dao.remove(selector)
-
- override def schedule[S](after: Duration)(op: => Future[S]) =
- system.scheduler.scheduleOnce(FiniteDuration(after.toSeconds, TimeUnit.SECONDS), new Runnable {
- override def run() = op
- })
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/application/RememberMeTokenDao.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/application/RememberMeTokenDao.scala
deleted file mode 100644
index 1446e7b3f..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/user/application/RememberMeTokenDao.scala
+++ /dev/null
@@ -1,45 +0,0 @@
-package com.softwaremill.bootzooka.user.application
-
-import java.time.OffsetDateTime
-import java.util.UUID
-
-import com.softwaremill.bootzooka.common.FutureHelpers._
-import com.softwaremill.bootzooka.common.sql.SqlDatabase
-import com.softwaremill.bootzooka.user.domain.RememberMeToken
-
-import scala.concurrent.{ExecutionContext, Future}
-
-class RememberMeTokenDao(protected val database: SqlDatabase)(implicit ec: ExecutionContext)
- extends SqlRememberMeSchema {
-
- import database._
- import database.driver.api._
-
- def findBySelector(selector: String): Future[Option[RememberMeToken]] =
- db.run(rememberMeTokens.filter(_.selector === selector).result).map(_.headOption)
-
- def add(data: RememberMeToken): Future[Unit] =
- db.run(rememberMeTokens += data).mapToUnit
-
- def remove(selector: String): Future[Unit] =
- db.run(rememberMeTokens.filter(_.selector === selector).delete).mapToUnit
-}
-
-trait SqlRememberMeSchema {
- protected val database: SqlDatabase
-
- import database._
- import database.driver.api._
-
- protected val rememberMeTokens = TableQuery[RememberMeTokens]
-
- protected class RememberMeTokens(tag: Tag) extends Table[RememberMeToken](tag, "remember_me_tokens") {
- def id = column[UUID]("id", O.PrimaryKey)
- def selector = column[String]("selector")
- def tokenHash = column[String]("token_hash")
- def userId = column[UUID]("user_id")
- def validTo = column[OffsetDateTime]("valid_to")
-
- def * = (id, selector, tokenHash, userId, validTo) <> (RememberMeToken.tupled, RememberMeToken.unapply)
- }
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/application/Session.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/application/Session.scala
deleted file mode 100644
index 3aeb8fa70..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/user/application/Session.scala
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.softwaremill.bootzooka.user.application
-
-import java.util.UUID
-
-import com.softwaremill.bootzooka.user._
-import com.softwaremill.session.{MultiValueSessionSerializer, SessionSerializer}
-
-import scala.util.Try
-
-case class Session(userId: UserId)
-
-object Session {
- implicit val serializer: SessionSerializer[Session, String] = new MultiValueSessionSerializer[Session](
- (t: Session) => Map("id" -> t.userId.toString),
- m => Try { Session(UUID.fromString(m("id"))) }
- )
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/application/UserDao.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/application/UserDao.scala
deleted file mode 100644
index a639be8ba..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/user/application/UserDao.scala
+++ /dev/null
@@ -1,81 +0,0 @@
-package com.softwaremill.bootzooka.user.application
-
-import java.time.OffsetDateTime
-import java.util.UUID
-
-import com.softwaremill.bootzooka.common.FutureHelpers._
-import com.softwaremill.bootzooka.common.sql.SqlDatabase
-import com.softwaremill.bootzooka.user._
-import com.softwaremill.bootzooka.user.domain.{BasicUserData, User}
-
-import scala.concurrent.{ExecutionContext, Future}
-
-class UserDao(protected val database: SqlDatabase)(implicit val ec: ExecutionContext) extends SqlUserSchema {
-
- import database._
- import database.driver.api._
-
- def add(user: User): Future[Unit] = db.run(users += user).mapToUnit
-
- def findById(userId: UserId): Future[Option[User]] = findOneWhere(_.id === userId)
-
- def findBasicDataById(userId: UserId): Future[Option[BasicUserData]] =
- db.run(users.filter(_.id === userId).map(_.basic).result.headOption)
-
- private def findOneWhere(condition: Users => Rep[Boolean]) = db.run(users.filter(condition).result.headOption)
-
- def findByEmail(email: String): Future[Option[User]] = findOneWhere(_.email.toLowerCase === email.toLowerCase)
-
- def findByLowerCasedLogin(login: String): Future[Option[User]] = findOneWhere(_.loginLowerCase === login.toLowerCase)
-
- def findByLoginOrEmail(loginOrEmail: String): Future[Option[User]] =
- findByLowerCasedLogin(loginOrEmail).flatMap(
- userOpt => userOpt.map(user => Future.successful(Some(user))).getOrElse(findByEmail(loginOrEmail))
- )
-
- def changePassword(userId: UserId, newPassword: String, newSalt: String): Future[Unit] =
- db.run(users.filter(_.id === userId).map(u => (u.password, u.salt)).update((newPassword, newSalt))).mapToUnit
-
- def changeLogin(userId: UserId, newLogin: String): Future[Unit] = {
- val action = users
- .filter(_.id === userId)
- .map { user =>
- (user.login, user.loginLowerCase)
- }
- .update((newLogin, newLogin.toLowerCase))
- db.run(action).mapToUnit
- }
-
- def changeEmail(userId: UserId, newEmail: String): Future[Unit] =
- db.run(users.filter(_.id === userId).map(_.email).update(newEmail)).mapToUnit
-}
-
-/**
- * The schemas are in separate traits, so that if your DAO would require to access (e.g. join) multiple tables,
- * you can just mix in the necessary traits and have the `TableQuery` definitions available.
- */
-trait SqlUserSchema {
-
- protected val database: SqlDatabase
-
- import database._
- import database.driver.api._
-
- protected val users = TableQuery[Users]
-
- protected class Users(tag: Tag) extends Table[User](tag, "users") {
- // format: OFF
- def id = column[UUID]("id", O.PrimaryKey)
- def login = column[String]("login")
- def loginLowerCase = column[String]("login_lowercase")
- def email = column[String]("email")
- def password = column[String]("password")
- def salt = column[String]("salt")
- def createdOn = column[OffsetDateTime]("created_on")
-
- def * = (id, login, loginLowerCase, email, password, salt, createdOn) <> ((User.apply _).tupled, User.unapply)
- def basic = (id, login, email, createdOn) <> ((BasicUserData.apply _).tupled, BasicUserData.unapply)
- // format: ON
- }
-
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/application/UserService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/application/UserService.scala
deleted file mode 100644
index 5b9a0f1ce..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/user/application/UserService.scala
+++ /dev/null
@@ -1,151 +0,0 @@
-package com.softwaremill.bootzooka.user.application
-
-import java.time.{Instant, ZoneOffset}
-import java.util.UUID
-
-import com.softwaremill.bootzooka.common.crypto.{PasswordHashing, Salt}
-import com.softwaremill.bootzooka.email.application.{EmailService, EmailTemplatingEngine}
-import com.softwaremill.bootzooka.user._
-import com.softwaremill.bootzooka.user.domain.{BasicUserData, User}
-import com.typesafe.scalalogging.StrictLogging
-
-import scala.concurrent.{ExecutionContext, Future}
-
-class UserService(
- userDao: UserDao,
- emailService: EmailService,
- emailTemplatingEngine: EmailTemplatingEngine,
- passwordHashing: PasswordHashing
-)(implicit ec: ExecutionContext)
- extends StrictLogging {
-
- def findById(userId: UserId): Future[Option[BasicUserData]] =
- userDao.findBasicDataById(userId)
-
- def registerNewUser(login: String, email: String, password: String): Future[UserRegisterResult] = {
- def checkUserExistence(): Future[Either[String, Unit]] = {
- val existingLoginFuture = userDao.findByLowerCasedLogin(login)
- val existingEmailFuture = userDao.findByEmail(email)
-
- for {
- existingLoginOpt <- existingLoginFuture
- existingEmailOpt <- existingEmailFuture
- } yield {
- existingLoginOpt
- .map(_ => Left("Login already in use!"))
- .orElse(
- existingEmailOpt.map(_ => Left("E-mail already in use!"))
- )
- .getOrElse(Right((): Unit))
- }
- }
-
- def registerValidData() = checkUserExistence().flatMap {
- case Left(msg) => Future.successful(UserRegisterResult.UserExists(msg))
- case Right(_) =>
- val salt = Salt.newSalt()
- val now = Instant.now().atOffset(ZoneOffset.UTC)
- val userAddResult =
- userDao.add(User.withRandomUUID(login, email.toLowerCase, password, salt, now, passwordHashing))
- userAddResult.foreach { _ =>
- val confirmationEmail = emailTemplatingEngine.registrationConfirmation(login)
- emailService.scheduleEmail(email, confirmationEmail)
- }
- userAddResult.map(_ => UserRegisterResult.Success)
- }
-
- UserRegisterValidator
- .validate(login, email, password)
- .fold(
- msg => Future.successful(UserRegisterResult.InvalidData(msg)),
- _ => registerValidData()
- )
- }
-
- def authenticate(login: String, nonEncryptedPassword: String): Future[Option[BasicUserData]] =
- userDao
- .findByLoginOrEmail(login)
- .map(_.filter(u => passwordHashing.verifyPassword(u.password, nonEncryptedPassword, u.salt)))
- .flatMap {
- case Some(u) => rehashIfRequired(u, nonEncryptedPassword)
- case None => Future.successful(None)
- }
- .map(_.map(BasicUserData.fromUser))
-
- /**
- * Some hash algorithms (like Argon2) can use parameters to affect how they work.
- * Typically these parameters are stored along the hash and they can change to speed up or slow down hashing
- * depending on needs and security status.
- *
- * It sounds like a good idea to check whether hashing parameters were changed recently after user successfully logs in
- * and if so, rehash user password using those new parameters. That way all user passwords are stored with up to date
- * security settings.
- */
- private def rehashIfRequired(u: User, password: String): Future[Option[User]] =
- if (passwordHashing.requiresRehashing(u.password)) {
- val newSalt = Salt.newSalt()
- val newPassword = passwordHashing.hashPassword(password, newSalt)
- userDao.changePassword(u.id, newPassword, newSalt).map(_ => Some(u.copy(password = newPassword, salt = newSalt)))
- } else {
- Future.successful(Some(u))
- }
-
- def changeLogin(userId: UUID, newLogin: String): Future[Either[String, Unit]] =
- userDao.findByLowerCasedLogin(newLogin).flatMap {
- case Some(_) => Future.successful(Left("Login is already taken"))
- case None => userDao.changeLogin(userId, newLogin).map(Right(_))
- }
-
- def changeEmail(userId: UUID, newEmail: String): Future[Either[String, Unit]] =
- userDao.findByEmail(newEmail).flatMap {
- case Some(_) => Future.successful(Left("E-mail used by another user"))
- case None => userDao.changeEmail(userId, newEmail).map(Right(_))
- }
-
- def changePassword(userId: UUID, currentPassword: String, newPassword: String): Future[Either[String, Unit]] =
- userDao.findById(userId).flatMap {
- case Some(u) =>
- if (passwordHashing.verifyPassword(u.password, currentPassword, u.salt)) {
- val salt = Salt.newSalt()
- userDao.changePassword(u.id, passwordHashing.hashPassword(newPassword, salt), salt).map(Right(_))
- } else Future.successful(Left("Current password is invalid"))
-
- case None => Future.successful(Left("User not found hence cannot change password"))
- }
-}
-
-sealed trait UserRegisterResult
-
-object UserRegisterResult {
-
- case class InvalidData(msg: String) extends UserRegisterResult
-
- case class UserExists(msg: String) extends UserRegisterResult
-
- case object Success extends UserRegisterResult
-
-}
-
-object UserRegisterValidator {
- private val ValidationOk = Right(())
- val MinLoginLength = 3
-
- def validate(login: String, email: String, password: String): Either[String, Unit] =
- for {
- _ <- validLogin(login.trim).right
- _ <- validEmail(email.trim).right
- _ <- validPassword(password.trim).right
- } yield ()
-
- private def validLogin(login: String) =
- if (login.length >= MinLoginLength) ValidationOk else Left("Login is too short!")
-
- private val emailRegex =
- """^[a-zA-Z0-9\.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$""".r
-
- private def validEmail(email: String) =
- if (emailRegex.findFirstMatchIn(email).isDefined) ValidationOk else Left("Invalid e-mail!")
-
- private def validPassword(password: String) =
- if (password.nonEmpty) ValidationOk else Left("Password cannot be empty!")
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/domain/RememberMeToken.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/domain/RememberMeToken.scala
deleted file mode 100644
index b253c88c9..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/user/domain/RememberMeToken.scala
+++ /dev/null
@@ -1,6 +0,0 @@
-package com.softwaremill.bootzooka.user.domain
-
-import java.time.OffsetDateTime
-import java.util.UUID
-
-case class RememberMeToken(id: UUID, selector: String, tokenHash: String, userId: UUID, validTo: OffsetDateTime)
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/domain/User.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/domain/User.scala
deleted file mode 100644
index 7f8358e2f..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/user/domain/User.scala
+++ /dev/null
@@ -1,43 +0,0 @@
-package com.softwaremill.bootzooka.user.domain
-
-import java.time.OffsetDateTime
-import java.util.UUID
-
-import com.softwaremill.bootzooka.common.crypto.PasswordHashing
-import com.softwaremill.bootzooka.user._
-
-case class User(
- id: UserId,
- login: String,
- loginLowerCased: String,
- email: String,
- password: String,
- salt: String,
- createdOn: OffsetDateTime
-)
-
-object User {
- def withRandomUUID(
- login: String,
- email: String,
- plainPassword: String,
- salt: String,
- createdOn: OffsetDateTime,
- passwordHashing: PasswordHashing
- ) =
- User(
- UUID.randomUUID(),
- login,
- login.toLowerCase,
- email,
- passwordHashing.hashPassword(plainPassword, salt),
- salt,
- createdOn
- )
-}
-
-case class BasicUserData(id: UserId, login: String, email: String, createdOn: OffsetDateTime)
-
-object BasicUserData {
- def fromUser(user: User) = new BasicUserData(user.id, user.login, user.email, user.createdOn)
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/package.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/package.scala
deleted file mode 100644
index 37ea2de38..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/user/package.scala
+++ /dev/null
@@ -1,6 +0,0 @@
-package com.softwaremill.bootzooka
-
-import java.util.UUID
-package object user {
- type UserId = UUID
-}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/version/VersionApi.scala b/backend/src/main/scala/com/softwaremill/bootzooka/version/VersionApi.scala
new file mode 100644
index 000000000..1b3fffc14
--- /dev/null
+++ b/backend/src/main/scala/com/softwaremill/bootzooka/version/VersionApi.scala
@@ -0,0 +1,25 @@
+package com.softwaremill.bootzooka.version
+
+import cats.data.NonEmptyList
+import com.softwaremill.bootzooka.ServerEndpoints
+import com.softwaremill.bootzooka.infrastructure.Http
+import com.softwaremill.bootzooka.infrastructure.Json._
+import monix.eval.Task
+
+class VersionApi(http: Http) {
+ import http._
+ import VersionApi._
+
+ private val versionEndpoint = baseEndpoint.get
+ .in("version")
+ .out(jsonBody[Version_OUT])
+ .serverLogic { _ =>
+ Task.now(Version_OUT(BuildInfo.builtAtString, BuildInfo.lastCommitHash)).toOut
+ }
+
+ val endpoints: ServerEndpoints = NonEmptyList.of(versionEndpoint)
+}
+
+object VersionApi {
+ case class Version_OUT(buildDate: String, buildSha: String)
+}
diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/version/VersionRoutes.scala b/backend/src/main/scala/com/softwaremill/bootzooka/version/VersionRoutes.scala
deleted file mode 100644
index 638bfb181..000000000
--- a/backend/src/main/scala/com/softwaremill/bootzooka/version/VersionRoutes.scala
+++ /dev/null
@@ -1,57 +0,0 @@
-package com.softwaremill.bootzooka.version
-
-import javax.ws.rs.Path
-
-import akka.http.scaladsl.server.Directives._
-import akka.http.scaladsl.server.Route
-import com.softwaremill.bootzooka.common.api.RoutesSupport
-import com.softwaremill.bootzooka.version.BuildInfo._
-import io.circe.generic.auto._
-import io.swagger.annotations.{ApiResponse, _}
-
-import scala.annotation.meta.field
-
-trait VersionRoutes extends RoutesSupport with VersionRoutesAnnotations {
-
- implicit val versionJsonCbs = CanBeSerialized[VersionJson]
-
- val versionRoutes = pathPrefix("version") {
- pathEndOrSingleSlash {
- getVersion
- }
- }
-
- def getVersion: Route =
- complete {
- VersionJson(buildSha.substring(0, 6), buildDate)
- }
-}
-
-@Api(
- value = "Version",
- produces = "application/json",
- consumes = "application/json"
-)
-@Path("api/version")
-trait VersionRoutesAnnotations {
-
- @ApiOperation(
- httpMethod = "GET",
- response = classOf[VersionJson],
- value = "Returns an object which describes running version"
- )
- @ApiResponses(
- Array(
- new ApiResponse(code = 500, message = "Internal Server Error"),
- new ApiResponse(code = 200, message = "OK", response = classOf[VersionJson])
- )
- )
- @Path("/")
- def getVersion: Route
-}
-
-@ApiModel(description = "Short description of the version of an object")
-case class VersionJson(
- @(ApiModelProperty @field)(value = "Build number") build: String,
- @(ApiModelProperty @field)(value = "The timestamp of the build") date: String
-)
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/common/api/RoutesRequestWrapperSpec.scala b/backend/src/test/scala/com/softwaremill/bootzooka/common/api/RoutesRequestWrapperSpec.scala
deleted file mode 100644
index cae55114a..000000000
--- a/backend/src/test/scala/com/softwaremill/bootzooka/common/api/RoutesRequestWrapperSpec.scala
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.softwaremill.bootzooka.common.api
-
-import akka.http.scaladsl.server.Directives._
-import akka.http.scaladsl.testkit.ScalatestRouteTest
-import com.softwaremill.bootzooka.common.api.`X-Content-Type-Options`.`nosniff`
-import com.softwaremill.bootzooka.common.api.`X-Frame-Options`.`DENY`
-import com.softwaremill.bootzooka.common.api.`X-XSS-Protection`.`1; mode=block`
-import org.scalatest.{FlatSpec, Matchers}
-
-class RoutesRequestWrapperSpec extends FlatSpec with Matchers with ScalatestRouteTest {
-
- it should "return a response wirth security headers" in {
- val routes = new RoutesRequestWrapper {}.requestWrapper {
- get {
- complete("ok")
- }
- }
-
- Get() ~> routes ~> check {
- response.header[`X-Frame-Options`].get shouldBe `X-Frame-Options`(`DENY`)
- response.header[`X-Content-Type-Options`].get shouldBe `X-Content-Type-Options`(`nosniff`)
- response.header[`X-XSS-Protection`].get shouldBe `X-XSS-Protection`(`1; mode=block`)
- }
- }
-}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/common/crypto/Argon2dPasswordHashingSpec.scala b/backend/src/test/scala/com/softwaremill/bootzooka/common/crypto/Argon2dPasswordHashingSpec.scala
deleted file mode 100644
index 2221f10a7..000000000
--- a/backend/src/test/scala/com/softwaremill/bootzooka/common/crypto/Argon2dPasswordHashingSpec.scala
+++ /dev/null
@@ -1,28 +0,0 @@
-package com.softwaremill.bootzooka.common.crypto
-
-import com.softwaremill.bootzooka.test.TestHelpers
-import com.typesafe.config.Config
-import org.scalatest.{FlatSpec, Matchers}
-
-class Argon2dPasswordHashingSpec extends FlatSpec with Matchers with TestHelpers {
- val withChangedParams = new Argon2dPasswordHashing(new CryptoConfig {
- override def rootConfig: Config = ???
- override lazy val iterations = 3
- override lazy val memory = 16383
- override lazy val parallelism = 2
- })
-
- behavior of "Argon2d Password Hashing"
-
- it should "not indicate rehashing necessity when config doesn't change" in {
- val hash = passwordHashing.hashPassword("password", "salt")
-
- passwordHashing.requiresRehashing(hash) shouldBe false
- }
-
- it should "indicate rehashing necessity upon config change" in {
- val hash = passwordHashing.hashPassword("password", "salt")
-
- withChangedParams.requiresRehashing(hash) shouldBe true
- }
-}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/email/EmailTemplatingEngineTest.scala b/backend/src/test/scala/com/softwaremill/bootzooka/email/EmailTemplatingEngineTest.scala
new file mode 100644
index 000000000..ddf910709
--- /dev/null
+++ b/backend/src/test/scala/com/softwaremill/bootzooka/email/EmailTemplatingEngineTest.scala
@@ -0,0 +1,19 @@
+package com.softwaremill.bootzooka.email
+
+import org.scalatest.{FlatSpec, Matchers}
+
+class EmailTemplatingEngineTest extends FlatSpec with Matchers {
+ behavior of "splitToContentAndSubject"
+
+ val engine = new EmailTemplatingEngine
+
+ it should "generate the registration confirmation email" in {
+ // when
+ val email = engine.registrationConfirmation("john")
+
+ // then
+ email.subject should be("SoftwareMill Bootzooka - registration confirmation for user john")
+ email.content should include("Dear john,")
+ email.content should include("Regards,")
+ }
+}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/email/application/DummyEmailSendingServiceSpec.scala b/backend/src/test/scala/com/softwaremill/bootzooka/email/application/DummyEmailSendingServiceSpec.scala
deleted file mode 100644
index 3546640de..000000000
--- a/backend/src/test/scala/com/softwaremill/bootzooka/email/application/DummyEmailSendingServiceSpec.scala
+++ /dev/null
@@ -1,12 +0,0 @@
-package com.softwaremill.bootzooka.email.application
-
-import com.softwaremill.bootzooka.email.domain.EmailContentWithSubject
-import org.scalatest.{FlatSpec, Matchers}
-
-class DummyEmailSendingServiceSpec extends FlatSpec with Matchers {
- it should "send scheduled email" in {
- val service = new DummyEmailService
- service.scheduleEmail("test@sml.com", new EmailContentWithSubject("content", "subject"))
- service.wasEmailSent("test@sml.com", "subject") should be(true)
- }
-}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/email/application/EmailTemplatingEngineSpec.scala b/backend/src/test/scala/com/softwaremill/bootzooka/email/application/EmailTemplatingEngineSpec.scala
deleted file mode 100644
index bf7b3dd13..000000000
--- a/backend/src/test/scala/com/softwaremill/bootzooka/email/application/EmailTemplatingEngineSpec.scala
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.softwaremill.bootzooka.email.application
-
-import org.scalatest.{FlatSpec, Matchers}
-
-class EmailTemplatingEngineSpec extends FlatSpec with Matchers {
- behavior of "splitToContentAndSubject"
-
- val engine = new EmailTemplatingEngine
-
- it should "throw exception on invalid template" in {
- intercept[Exception] {
- engine.splitToContentAndSubject("invalid template")
- }
- }
-
- it should "not throw exception on correct template" in {
- engine.splitToContentAndSubject("subect\nContent")
- }
-
- it should "split template into subject and content" in {
- // When
- val email = engine.splitToContentAndSubject("subject\nContent\nsecond line")
-
- // Then
- email.subject should be("subject")
- email.content should be("Content\nsecond line")
- }
-
- it should "generate the registration confirmation email" in {
- // when
- val email = engine.registrationConfirmation("adamw")
-
- // then
- email.subject should be("SoftwareMill Bootzooka - registration confirmation for user adamw")
- email.content should include("Dear adamw,")
- email.content should include("Regards,")
- }
-}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/email/sender/DummyEmailSenderTest.scala b/backend/src/test/scala/com/softwaremill/bootzooka/email/sender/DummyEmailSenderTest.scala
new file mode 100644
index 000000000..ff83ebfd5
--- /dev/null
+++ b/backend/src/test/scala/com/softwaremill/bootzooka/email/sender/DummyEmailSenderTest.scala
@@ -0,0 +1,12 @@
+package com.softwaremill.bootzooka.email.sender
+
+import com.softwaremill.bootzooka.email.EmailData
+import com.softwaremill.bootzooka.test.BaseTest
+import monix.execution.Scheduler.Implicits.global
+
+class DummyEmailSenderTest extends BaseTest {
+ it should "send scheduled email" in {
+ DummyEmailSender(EmailData("test@sml.com", "subject", "content")).runSyncUnsafe()
+ DummyEmailSender.findSendEmail("test@sml.com", "subject") shouldBe 'defined
+ }
+}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetApiTest.scala b/backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetApiTest.scala
new file mode 100644
index 000000000..69e7f79df
--- /dev/null
+++ b/backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetApiTest.scala
@@ -0,0 +1,108 @@
+package com.softwaremill.bootzooka.passwordreset
+
+import com.softwaremill.bootzooka.email.sender.DummyEmailSender
+import com.softwaremill.bootzooka.infrastructure.Doobie._
+import com.softwaremill.bootzooka.infrastructure.Json._
+import com.softwaremill.bootzooka.passwordreset.PasswordResetApi.{ForgotPassword_IN, ForgotPassword_OUT, PasswordReset_IN, PasswordReset_OUT}
+import com.softwaremill.bootzooka.test.{BaseTest, TestConfig, Requests, TestEmbeddedPostgres}
+import com.softwaremill.bootzooka.{Config, MainModule}
+import monix.eval.Task
+import org.http4s._
+import org.scalatest.concurrent.Eventually
+
+class PasswordResetApiTest extends BaseTest with TestEmbeddedPostgres with Eventually {
+ lazy val modules: MainModule = new MainModule {
+ override def xa: Transactor[Task] = currentDb.xa
+ override def config: Config = TestConfig
+ }
+
+ val requests = new Requests(modules)
+ import requests._
+
+ "/passwordreset" should "reset the password" in {
+ // given
+ val RegisteredUser(login, email, password, _) = newRegisteredUsed()
+ val newPassword = password + password
+
+ // when
+ val response1 = forgotPassword(login)
+ response1.shouldDeserializeTo[ForgotPassword_OUT]
+
+ // then
+ val code = eventually { codeSentToEmail(email) }
+
+ // when
+ val response2 = resetPassword(code, newPassword)
+ response2.shouldDeserializeTo[PasswordReset_OUT]
+
+ // then
+ loginUser(login, password, None).status shouldBe Status.Unauthorized
+ loginUser(login, newPassword, None).status shouldBe Status.Ok
+ }
+
+ "/passwordreset" should "reset the password once using the given code" in {
+ // given
+ val RegisteredUser(login, email, password, _) = newRegisteredUsed()
+ val newPassword = password + password
+ val newerPassword = newPassword + newPassword
+
+ // when
+ val response1 = forgotPassword(login)
+ response1.shouldDeserializeTo[ForgotPassword_OUT]
+
+ // then
+ val code = eventually { codeSentToEmail(email) }
+
+ // when
+ resetPassword(code, newPassword).shouldDeserializeTo[PasswordReset_OUT]
+ resetPassword(code, newPassword).shouldDeserializeToError
+
+ // then
+ loginUser(login, newPassword, None).status shouldBe Status.Ok
+ loginUser(login, newerPassword, None).status shouldBe Status.Unauthorized
+ }
+
+ "/passwordreset" should "not reset the password given an invalid code" in {
+ // given
+ val RegisteredUser(login, _, password, _) = newRegisteredUsed()
+ val newPassword = password + password
+
+ // when
+ val response2 = resetPassword("invalid", newPassword)
+ response2.shouldDeserializeToError
+
+ // then
+ loginUser(login, password, None).status shouldBe Status.Ok
+ loginUser(login, newPassword, None).status shouldBe Status.Unauthorized
+ }
+
+ def forgotPassword(loginOrEmail: String): Response[Task] = {
+ val request = Request[Task](method = POST, uri = uri"/passwordreset/forgot")
+ .withEntity(ForgotPassword_IN(loginOrEmail))
+
+ modules.httpRoutes(request).unwrap
+ }
+
+ def resetPassword(code: String, password: String): Response[Task] = {
+ val request = Request[Task](method = POST, uri = uri"/passwordreset/reset")
+ .withEntity(PasswordReset_IN(code, password))
+
+ modules.httpRoutes(request).unwrap
+ }
+
+ def codeSentToEmail(email: String): String = {
+ modules.emailService.sendBatch().unwrap
+
+ val emailData = DummyEmailSender
+ .findSendEmail(email, "SoftwareMill Bootzooka password reset")
+ .getOrElse(throw new IllegalStateException(s"No password reset email sent to $email!"))
+
+ codeFromResetPasswordEmail(emailData.content)
+ .getOrElse(throw new IllegalStateException(s"No code found in: $emailData"))
+ }
+
+ def codeFromResetPasswordEmail(email: String): Option[String] = {
+ val regexp = "code=([\\w]*)".r
+ regexp.findFirstMatchIn(email).map(_.group(1))
+ }
+}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/api/PasswordResetRoutesSpec.scala b/backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/api/PasswordResetRoutesSpec.scala
deleted file mode 100644
index 4f987c138..000000000
--- a/backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/api/PasswordResetRoutesSpec.scala
+++ /dev/null
@@ -1,89 +0,0 @@
-package com.softwaremill.bootzooka.passwordreset.api
-
-import akka.http.scaladsl.model.StatusCodes
-import akka.http.scaladsl.server.Route
-import com.softwaremill.bootzooka.passwordreset.application.{
- PasswordResetCodeDao,
- PasswordResetConfig,
- PasswordResetService
-}
-import com.softwaremill.bootzooka.passwordreset.domain.PasswordResetCode
-import com.softwaremill.bootzooka.test.{BaseRoutesSpec, TestHelpersWithDb}
-import com.softwaremill.bootzooka.user.domain.User
-import com.typesafe.config.ConfigFactory
-
-class PasswordResetRoutesSpec extends BaseRoutesSpec with TestHelpersWithDb { spec =>
-
- lazy val config = new PasswordResetConfig {
- override def rootConfig = ConfigFactory.load()
- }
- val passwordResetCodeDao = new PasswordResetCodeDao(sqlDatabase)
- val passwordResetService =
- new PasswordResetService(
- userDao,
- passwordResetCodeDao,
- emailService,
- emailTemplatingEngine,
- config,
- passwordHashing
- )
-
- val routes = Route.seal(new PasswordResetRoutes with TestRoutesSupport {
- override val userService = spec.userService
- override val passwordResetService = spec.passwordResetService
- }.passwordResetRoutes)
-
- "POST /" should "send e-mail to user" in {
- // given
- val user = newRandomStoredUser()
-
- // when
- Post("/passwordreset", Map("login" -> user.login)) ~> routes ~> check {
- emailService.wasEmailSentTo(user.email) should be(true)
- }
- }
-
- "POST /[code] with password" should "change the password" in {
- // given
- val user = newRandomStoredUser()
- val code = PasswordResetCode(randomString(), user)
- passwordResetCodeDao.add(code).futureValue
-
- val newPassword = randomString()
-
- // when
- Post(s"/passwordreset/${code.code}", Map("password" -> newPassword)) ~> routes ~> check {
- responseAs[String] should be("ok")
- val updatedUser = userDao.findById(user.id).futureValue.get
- passwordHashing.verifyPassword(updatedUser.password, newPassword, updatedUser.salt) should be(true)
- }
- }
-
- "POST /[code] without password" should "result in an error" in {
- // given
- val user = newRandomStoredUser()
- val code = PasswordResetCode(randomString(), user)
- passwordResetCodeDao.add(code).futureValue
-
- // when
- Post("/passwordreset/123") ~> routes ~> check {
- status should be(StatusCodes.BadRequest)
- }
- }
-
- "POST /[code] with password but with invalid code" should "result in an error" in {
- // given
- val user = newRandomStoredUser()
- val code = PasswordResetCode(randomString(), user)
- passwordResetCodeDao.add(code).futureValue
-
- val newPassword = randomString()
-
- // when
- Post("/passwordreset/123", Map("password" -> newPassword)) ~> routes ~> check {
- status should be(StatusCodes.Forbidden)
- val updatedUser = userDao.findById(user.id).futureValue.get
- passwordHashing.verifyPassword(updatedUser.password, newPassword, updatedUser.salt) should be(false)
- }
- }
-}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/application/PasswordResetCodeDaoSpec.scala b/backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/application/PasswordResetCodeDaoSpec.scala
deleted file mode 100644
index 5917aa76f..000000000
--- a/backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/application/PasswordResetCodeDaoSpec.scala
+++ /dev/null
@@ -1,64 +0,0 @@
-package com.softwaremill.bootzooka.passwordreset.application
-
-import com.softwaremill.bootzooka.passwordreset.domain.PasswordResetCode
-import com.softwaremill.bootzooka.test.{FlatSpecWithDb, TestHelpersWithDb}
-
-class PasswordResetCodeDaoSpec extends FlatSpecWithDb with TestHelpersWithDb {
- behavior of "PasswordResetCodeDao"
-
- val dao = new PasswordResetCodeDao(sqlDatabase)
-
- it should "add and load code" in {
- // Given
- val user = newRandomStoredUser()
- val code = PasswordResetCode(code = "code", user = user)
-
- // When
- dao.add(code).futureValue
-
- // Then
- dao.findByCode(code.code).futureValue.map(_.code) should be(Some(code.code))
- }
-
- it should "not load when not added" in {
- dao.findByCode("code1").futureValue should be(None)
- }
-
- it should "remove code" in {
- //Given
- val user1 = newRandomStoredUser()
- val user2 = newRandomStoredUser()
-
- val code1 = PasswordResetCode(code = "code1", user = user1)
- val code2 = PasswordResetCode(code = "code2", user = user2)
-
- val bgActions = for {
- _ <- dao.add(code1)
- _ <- dao.add(code2)
- } //When
- yield dao.remove(code1).futureValue
-
- //Then
- whenReady(bgActions) { _ =>
- dao.findByCode("code1").futureValue should be(None)
- dao.findByCode("code2").futureValue should be('defined)
- }
- }
-
- it should "not delete user on code removal" in {
- // Given
- val user = newRandomStoredUser()
- val code = PasswordResetCode(code = "code", user = user)
-
- val bgActions = for {
- _ <- dao.add(code)
- } // When
- yield dao.remove(code)
-
- // Then
- whenReady(bgActions) { _ =>
- userDao.findById(user.id).futureValue should be(Some(user))
- }
- }
-
-}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/application/PasswordResetServiceSpec.scala b/backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/application/PasswordResetServiceSpec.scala
deleted file mode 100644
index c98b794f2..000000000
--- a/backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/application/PasswordResetServiceSpec.scala
+++ /dev/null
@@ -1,90 +0,0 @@
-package com.softwaremill.bootzooka.passwordreset.application
-
-import java.time.temporal.ChronoUnit
-import java.time.{Instant, ZoneOffset}
-import java.util.UUID
-
-import com.softwaremill.bootzooka.passwordreset.domain.PasswordResetCode
-import com.softwaremill.bootzooka.test.{FlatSpecWithDb, TestHelpersWithDb}
-import com.softwaremill.bootzooka.user.domain.User
-import com.typesafe.config.ConfigFactory
-
-class PasswordResetServiceSpec extends FlatSpecWithDb with TestHelpersWithDb {
-
- lazy val config = new PasswordResetConfig {
- override def rootConfig = ConfigFactory.load()
- }
- val passwordResetCodeDao = new PasswordResetCodeDao(sqlDatabase)
- val passwordResetService =
- new PasswordResetService(
- userDao,
- passwordResetCodeDao,
- emailService,
- emailTemplatingEngine,
- config,
- passwordHashing
- )
-
- "sendResetCodeToUser" should "do nothing when login doesn't exist" in {
- passwordResetService.sendResetCodeToUser("Does not exist").futureValue
- }
-
- "performPasswordReset" should "delete code after it was used once" in {
- // given
- val user = newRandomStoredUser()
- val code = PasswordResetCode(randomString(), user)
- passwordResetCodeDao.add(code).futureValue
-
- val newPassword1 = randomString()
- val newPassword2 = randomString()
-
- // when
- val result1 = passwordResetService.performPasswordReset(code.code, newPassword1).futureValue
- val result2 = passwordResetService.performPasswordReset(code.code, newPassword2).futureValue
-
- result1 should be('right)
- result2 should be('left)
-
- val updatedUser = userDao.findById(user.id).futureValue.get
- passwordHashing.verifyPassword(updatedUser.password, newPassword1, updatedUser.salt) should be(true)
- passwordHashing.verifyPassword(updatedUser.password, newPassword2, updatedUser.salt) should be(false)
-
- passwordResetCodeDao.findByCode(code.code).futureValue should be(None)
- }
-
- "performPasswordReset" should "delete code and do nothing if the code expired" in {
- // given
- val user = newRandomStoredUser()
- val previousDay = Instant.now().minus(24, ChronoUnit.HOURS).atOffset(ZoneOffset.UTC)
- val code = PasswordResetCode(UUID.randomUUID(), randomString(), user, previousDay)
- passwordResetCodeDao.add(code).futureValue
-
- val newPassword = randomString()
-
- // when
- val result = passwordResetService.performPasswordReset(code.code, newPassword).futureValue
-
- result should be('left)
- val updatedUser = userDao.findById(user.id).futureValue.get
- passwordHashing.verifyPassword(updatedUser.password, newPassword, updatedUser.salt) should be(false)
- passwordResetCodeDao.findByCode(code.code).futureValue should be(None)
- }
-
- "performPasswordReset" should "calculate different hash values for the same passwords" in {
- // given
- val password = randomString()
- val user = newRandomStoredUser(Some(password))
- val originalPasswordHash = userDao.findById(user.id).futureValue.get.password
- val code = PasswordResetCode(randomString(), user)
- passwordResetCodeDao.add(code).futureValue
-
- // when
- val result = passwordResetService.performPasswordReset(code.code, password).futureValue
-
- result should be('right)
-
- val newPasswordHash = userDao.findById(user.id).futureValue.get.password
-
- originalPasswordHash should not be equal(newPasswordHash)
- }
-}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/test/BaseRoutesSpec.scala b/backend/src/test/scala/com/softwaremill/bootzooka/test/BaseRoutesSpec.scala
deleted file mode 100644
index aa09806af..000000000
--- a/backend/src/test/scala/com/softwaremill/bootzooka/test/BaseRoutesSpec.scala
+++ /dev/null
@@ -1,26 +0,0 @@
-package com.softwaremill.bootzooka.test
-
-import akka.http.scaladsl.testkit.{RouteTestTimeout, ScalatestRouteTest}
-import com.softwaremill.bootzooka.common.api.JsonSupport
-import com.softwaremill.bootzooka.user.application.Session
-import com.softwaremill.session.{SessionConfig, SessionManager}
-import com.typesafe.config.ConfigFactory
-import org.scalatest.Matchers
-import scala.concurrent.duration._
-
-trait BaseRoutesSpec extends FlatSpecWithDb with ScalatestRouteTest with Matchers with JsonSupport { spec =>
-
- lazy val sessionConfig = SessionConfig.fromConfig(ConfigFactory.load()).copy(sessionEncryptData = true)
-
- implicit def mapCbs = CanBeSerialized[Map[String, String]]
-
- implicit val timeout = RouteTestTimeout(10 second span)
-
- trait TestRoutesSupport {
- lazy val sessionConfig = spec.sessionConfig
- implicit def materializer = spec.materializer
- implicit def ec = spec.executor
- implicit def sessionManager = new SessionManager[Session](sessionConfig)
- implicit def refreshTokenStorage = null
- }
-}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/test/BaseTest.scala b/backend/src/test/scala/com/softwaremill/bootzooka/test/BaseTest.scala
new file mode 100644
index 000000000..e89d2c0d6
--- /dev/null
+++ b/backend/src/test/scala/com/softwaremill/bootzooka/test/BaseTest.scala
@@ -0,0 +1,9 @@
+package com.softwaremill.bootzooka.test
+
+import com.softwaremill.bootzooka.infrastructure.CorrelationId
+import org.scalatest.{FlatSpec, Matchers}
+
+trait BaseTest extends FlatSpec with Matchers {
+ CorrelationId.init()
+ val testClock = new TestClock()
+}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/test/FlatSpecWithDb.scala b/backend/src/test/scala/com/softwaremill/bootzooka/test/FlatSpecWithDb.scala
deleted file mode 100644
index d9524c3ae..000000000
--- a/backend/src/test/scala/com/softwaremill/bootzooka/test/FlatSpecWithDb.scala
+++ /dev/null
@@ -1,52 +0,0 @@
-package com.softwaremill.bootzooka.test
-
-import com.softwaremill.bootzooka.common.sql.SqlDatabase
-import org.scalatest._
-import org.scalatest.concurrent.{IntegrationPatience, ScalaFutures}
-
-trait FlatSpecWithDb
- extends FlatSpec
- with Matchers
- with BeforeAndAfterAll
- with BeforeAndAfterEach
- with ScalaFutures
- with IntegrationPatience {
-
- private val connectionString = "jdbc:h2:mem:bootzooka_test" + this.getClass.getSimpleName + ";DB_CLOSE_DELAY=-1"
- val sqlDatabase = SqlDatabase.createEmbedded(connectionString)
-
- override protected def beforeAll() {
- super.beforeAll()
- createAll()
- }
-
- def clearData() {
- dropAll()
- createAll()
- }
-
- override protected def afterAll() {
- super.afterAll()
- dropAll()
- sqlDatabase.close()
- }
-
- private def dropAll() {
- import sqlDatabase.driver.api._
- sqlDatabase.db.run(sqlu"DROP ALL OBJECTS").futureValue
- }
-
- private def createAll() {
- sqlDatabase.updateSchema()
- }
-
- override protected def afterEach() {
- try {
- clearData()
- } catch {
- case e: Exception => e.printStackTrace()
- }
-
- super.afterEach()
- }
-}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/test/HttpTestSupport.scala b/backend/src/test/scala/com/softwaremill/bootzooka/test/HttpTestSupport.scala
new file mode 100644
index 000000000..55fecc689
--- /dev/null
+++ b/backend/src/test/scala/com/softwaremill/bootzooka/test/HttpTestSupport.scala
@@ -0,0 +1,92 @@
+package com.softwaremill.bootzooka.test
+
+import cats.data.OptionT
+import cats.effect.Sync
+import com.softwaremill.bootzooka.MainModule
+import com.softwaremill.bootzooka.infrastructure.Doobie._
+import com.softwaremill.bootzooka.infrastructure.Error_OUT
+import com.softwaremill.bootzooka.infrastructure.Json._
+import doobie.free.connection.ConnectionIO
+import io.circe.{Decoder, Encoder}
+import monix.eval.Task
+import monix.execution.Scheduler.Implicits.global
+import org.http4s.Credentials.Token
+import org.http4s.dsl.Http4sDsl
+import org.http4s.headers.Authorization
+import org.http4s.util.CaseInsensitiveString
+import org.http4s.{EntityDecoder, EntityEncoder, Headers, Request, Response, Status}
+import org.scalatest.Matchers._
+
+import scala.concurrent.duration._
+import scala.reflect.ClassTag
+
+trait HttpTestSupport extends Http4sDsl[Task] {
+
+ val modules: MainModule
+
+ implicit def entityEncoderFromCirce[F[_]: Sync, T: Encoder]: EntityEncoder[F, T] = {
+ org.http4s.circe.jsonEncoderWithPrinterOf[F, T](noNullsPrinter)
+ }
+
+ implicit def entityDecoderFromCirce[F[_]: Sync, T: Decoder]: EntityDecoder[F, T] = {
+ org.http4s.circe.jsonOf[F, T]
+ }
+
+ implicit class RichTask[T](t: Task[T]) {
+ def unwrap: T = t.runSyncUnsafe(1.minute)
+ }
+
+ implicit class RichConnectionIO[T](t: ConnectionIO[T]) {
+ def unwrap: T = t.transact(modules.xa).unwrap
+ }
+
+ def eventuallyTask[T](t: Task[T]): Task[T] = t.onErrorRestartLoop(100) { (err, maxRetries, retry) =>
+ if (maxRetries > 0)
+ retry(maxRetries - 1).delayExecution(100.milliseconds)
+ else
+ Task.raiseError(err)
+ }
+
+ implicit class RichOptionTResponse(t: OptionT[Task, Response[Task]]) {
+ def unwrap: Response[Task] = t.value.unwrap match {
+ case None => fail("No response!")
+ case Some(r) => r
+ }
+ }
+
+ implicit class RichResponse(r: Response[Task]) {
+ def shouldDeserializeTo[T: Decoder: ClassTag]: T = {
+ if (r.status != Status.Ok) {
+ fail(s"Response status: ${r.status}: ${r.attemptAs[String].value.unwrap}")
+ } else {
+ val attemptResult = r.attemptAs[T].value.unwrap
+ attemptResult match {
+ case Left(df) => fail(s"Cannot deserialize to ${implicitly[ClassTag[T]].runtimeClass.getName}:\n$df")
+ case Right(v) => v
+ }
+ }
+ }
+
+ def shouldDeserializeToError: String = {
+ val attemptResult = r.attemptAs[Error_OUT].value.unwrap
+ attemptResult match {
+ case Left(df) => fail(s"Cannot deserialize to error:\n$df")
+ case Right(v) => v.error
+ }
+ }
+ }
+
+ def authorizedRequest(token: String, request: Request[Task]): Request[Task] = {
+ val authHeader = Authorization(Token(CaseInsensitiveString("Bearer"), token))
+ request.withHeaders(request.headers ++ Headers.of(authHeader))
+ }
+
+ def responseBodyShouldBeEmpty(r: Response[Task]): Unit = {
+ r.body.compile.toVector.unwrap.isEmpty shouldBe true
+ ()
+ }
+
+ def responseBody(r: Response[Task]): String = {
+ new String(r.body.compile.toVector.unwrap.toArray)
+ }
+}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/test/Requests.scala b/backend/src/test/scala/com/softwaremill/bootzooka/test/Requests.scala
new file mode 100644
index 000000000..f2617d4ff
--- /dev/null
+++ b/backend/src/test/scala/com/softwaremill/bootzooka/test/Requests.scala
@@ -0,0 +1,59 @@
+package com.softwaremill.bootzooka.test
+
+import com.softwaremill.bootzooka.MainModule
+import com.softwaremill.bootzooka.infrastructure.Json._
+import com.softwaremill.bootzooka.user.UserApi._
+import monix.eval.Task
+import org.http4s._
+
+import scala.util.Random
+
+class Requests(val modules: MainModule) extends HttpTestSupport {
+
+ case class RegisteredUser(login: String, email: String, password: String, apiKey: String)
+
+ private val random = new Random()
+
+ def randomLoginEmailPassword(): (String, String, String) =
+ (random.nextString(12), s"user${random.nextInt(9000)}@bootzooka.com", random.nextString(12))
+
+ def registerUser(login: String, email: String, password: String): Response[Task] = {
+ val request = Request[Task](method = POST, uri = uri"/user/register")
+ .withEntity(Register_IN(login, email, password))
+
+ modules.httpRoutes(request).unwrap
+ }
+
+ def newRegisteredUsed(): RegisteredUser = {
+ val (login, email, password) = randomLoginEmailPassword()
+ val apiKey = registerUser(login, email, password).shouldDeserializeTo[Register_OUT].apiKey
+ RegisteredUser(login, email, password, apiKey)
+ }
+
+ def loginUser(loginOrEmail: String, password: String, apiKeyValidHours: Option[Int] = None): Response[Task] = {
+ val request = Request[Task](method = POST, uri = uri"/user/login")
+ .withEntity(Login_IN(loginOrEmail, password, apiKeyValidHours))
+
+ modules.httpRoutes(request).unwrap
+ }
+
+ def getUser(apiKey: String): Response[Task] = {
+ val request = Request[Task](method = GET, uri = uri"/user")
+ modules.httpRoutes(authorizedRequest(apiKey, request)).unwrap
+ }
+
+ def changePassword(apiKey: String, password: String, newPassword: String): Response[Task] = {
+ val request = Request[Task](method = POST, uri = uri"/user/changepassword")
+ .withEntity(ChangePassword_IN(password, newPassword))
+
+ modules.httpRoutes(authorizedRequest(apiKey, request)).unwrap
+ }
+
+ def updateUser(apiKey: String, login: Option[String], email: Option[String]): Response[Task] = {
+ val request = Request[Task](method = POST, uri = uri"/user")
+ .withEntity(UpdateUser_IN(login, email))
+
+ modules.httpRoutes(authorizedRequest(apiKey, request)).unwrap
+ }
+
+}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/test/TestClock.scala b/backend/src/test/scala/com/softwaremill/bootzooka/test/TestClock.scala
new file mode 100644
index 000000000..ed1e4247d
--- /dev/null
+++ b/backend/src/test/scala/com/softwaremill/bootzooka/test/TestClock.scala
@@ -0,0 +1,34 @@
+package com.softwaremill.bootzooka.test
+
+import java.time.temporal.ChronoUnit
+import java.time.{Instant, LocalDate, ZoneOffset}
+import java.util.concurrent.atomic.AtomicReference
+
+import com.softwaremill.bootzooka.Clock
+import com.typesafe.scalalogging.StrictLogging
+
+import scala.concurrent.duration._
+
+class TestClock(nowRef: AtomicReference[Instant]) extends Clock with StrictLogging {
+
+ logger.info(s"New test clock, the time is: ${nowRef.get()}")
+
+ def this(now: Instant) = this(new AtomicReference(now))
+ def this() = this(Instant.now())
+
+ override def now(): Instant = nowRef.get()
+
+ def forward(d: Duration): Unit = {
+ val newNow = nowRef.get().plus(d.toMillis, ChronoUnit.MILLIS)
+ logger.info(s"The time is now $newNow")
+ nowRef.set(newNow)
+ }
+
+ def forwardOneSecond(): Unit = forward(1.second)
+
+ def setTo(instant: Instant): Unit = nowRef.set(instant)
+
+ def setTo(year: Int, month: Int, day: Int): Unit = {
+ setTo(LocalDate.of(year, month, day).atStartOfDay().toInstant(ZoneOffset.UTC))
+ }
+}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/test/TestDB.scala b/backend/src/test/scala/com/softwaremill/bootzooka/test/TestDB.scala
new file mode 100644
index 000000000..af539ba2b
--- /dev/null
+++ b/backend/src/test/scala/com/softwaremill/bootzooka/test/TestDB.scala
@@ -0,0 +1,91 @@
+package com.softwaremill.bootzooka.test
+
+import cats.effect.ContextShift
+import cats.implicits._
+import com.softwaremill.bootzooka.infrastructure.DBConfig
+import com.softwaremill.bootzooka.infrastructure.Doobie._
+import com.typesafe.scalalogging.StrictLogging
+import doobie.hikari.HikariTransactor
+import monix.catnap.MVar
+import monix.eval.Task
+import monix.execution.Scheduler.Implicits.global
+import org.flywaydb.core.Flyway
+
+import scala.annotation.tailrec
+import scala.concurrent.duration._
+
+class TestDB(config: DBConfig) extends StrictLogging {
+
+ var xa: Transactor[Task] = _
+ private val xaReady: MVar[Task, Transactor[Task]] = MVar.empty[Task, Transactor[Task]]().runSyncUnsafe()
+ private val done: MVar[Task, Unit] = MVar.empty[Task, Unit]().runSyncUnsafe()
+
+ {
+ implicit val contextShift: ContextShift[Task] = Task.contextShift(monix.execution.Scheduler.global)
+
+ val xaResource = for {
+ connectEC <- doobie.util.ExecutionContexts.fixedThreadPool[Task](config.connectThreadPoolSize)
+ transactEC <- doobie.util.ExecutionContexts.cachedThreadPool[Task]
+ xa <- HikariTransactor.newHikariTransactor[Task](
+ config.driver,
+ config.url,
+ config.username,
+ config.password,
+ connectEC,
+ transactEC
+ )
+ } yield xa
+
+ // a work-around to use the xaResource imperatively: first extracting it from the use method,
+ // then stopping when the `done` mvar is filled.
+ xaResource
+ .use { _xa =>
+ xaReady.put(_xa) >> done.take
+ }
+ .forkAndForget
+ .runSyncUnsafe()
+
+ xa = xaReady.take.runSyncUnsafe()
+ }
+
+ private val flyway = {
+ Flyway
+ .configure()
+ .dataSource(config.url, config.username, config.password)
+ .placeholderPrefix("$%{") // so it won't interfere with email templates placeholders
+ .load()
+ }
+
+ @tailrec
+ final def connectAndMigrate(): Unit = {
+ try {
+ migrate()
+ testConnection()
+ } catch {
+ case e: Exception =>
+ logger.warn("Database not available, waiting 5 seconds to retry...", e)
+ Thread.sleep(5000)
+ connectAndMigrate()
+ }
+ }
+
+ def migrate(): Unit = {
+ if (config.migrateOnStart) {
+ flyway.migrate()
+ ()
+ }
+ }
+
+ def clean(): Unit = {
+ flyway.clean()
+ }
+
+ def testConnection(): Unit = {
+ sql"select 1".query[Int].unique.transact(xa).runSyncUnsafe(1.minute)
+ ()
+ }
+
+ def close(): Unit = {
+ done.put(()).runSyncUnsafe(1.minute)
+ }
+}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/test/TestEmbeddedPostgres.scala b/backend/src/test/scala/com/softwaremill/bootzooka/test/TestEmbeddedPostgres.scala
new file mode 100644
index 000000000..ee3ee6b9d
--- /dev/null
+++ b/backend/src/test/scala/com/softwaremill/bootzooka/test/TestEmbeddedPostgres.scala
@@ -0,0 +1,44 @@
+package com.softwaremill.bootzooka.test
+
+import com.opentable.db.postgres.embedded.EmbeddedPostgres
+import com.softwaremill.bootzooka.infrastructure.DBConfig
+import com.typesafe.scalalogging.StrictLogging
+import org.postgresql.jdbc.PgConnection
+import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach, Suite}
+
+trait TestEmbeddedPostgres extends BeforeAndAfterEach with BeforeAndAfterAll with StrictLogging { self: Suite =>
+ private var postgres: EmbeddedPostgres = _
+ private var currentDbConfig: DBConfig = _
+ var currentDb: TestDB = _
+
+ override protected def beforeAll(): Unit = {
+ super.beforeAll()
+ postgres = EmbeddedPostgres.builder().start()
+ val url = postgres.getJdbcUrl("postgres", "postgres")
+ postgres.getPostgresDatabase.getConnection.asInstanceOf[PgConnection].setPrepareThreshold(100)
+ currentDbConfig = TestConfig.db.copy(
+ username = "postgres",
+ password = "",
+ url = url,
+ migrateOnStart = true
+ )
+ currentDb = new TestDB(currentDbConfig)
+ currentDb.testConnection()
+ }
+
+ override protected def afterAll(): Unit = {
+ postgres.close()
+ currentDb.close()
+ super.afterAll()
+ }
+
+ override protected def beforeEach(): Unit = {
+ super.beforeEach()
+ currentDb.migrate()
+ }
+
+ override protected def afterEach(): Unit = {
+ currentDb.clean()
+ super.afterEach()
+ }
+}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/test/TestHelpers.scala b/backend/src/test/scala/com/softwaremill/bootzooka/test/TestHelpers.scala
deleted file mode 100644
index a500d7416..000000000
--- a/backend/src/test/scala/com/softwaremill/bootzooka/test/TestHelpers.scala
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.softwaremill.bootzooka.test
-
-import java.time.{OffsetDateTime, ZoneOffset}
-
-import com.softwaremill.bootzooka.common.crypto.{Argon2dPasswordHashing, CryptoConfig, PasswordHashing}
-import com.softwaremill.bootzooka.user.domain.User
-import com.typesafe.config.{Config, ConfigFactory}
-
-trait TestHelpers {
- val passwordHashing: PasswordHashing = new Argon2dPasswordHashing(new CryptoConfig {
- override def rootConfig: Config = ConfigFactory.load()
- })
-
- val createdOn = OffsetDateTime.of(2015, 6, 3, 13, 25, 3, 0, ZoneOffset.UTC)
-
- private val random = new scala.util.Random
- private val characters = "abcdefghijklmnopqrstuvwxyz0123456789"
-
- def randomString(length: Int = 10) =
- Stream.continually(random.nextInt(characters.length)).map(characters).take(length).mkString
-
- def newUser(login: String, email: String, pass: String, salt: String): User =
- User.withRandomUUID(login, email, pass, salt, createdOn, passwordHashing)
-
- def newRandomUser(password: Option[String] = None): User = {
- val login = randomString()
- val pass = password.getOrElse(randomString())
- newUser(login, s"$login@example.com", pass, "someSalt")
- }
-}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/test/TestHelpersWithDb.scala b/backend/src/test/scala/com/softwaremill/bootzooka/test/TestHelpersWithDb.scala
deleted file mode 100644
index 0dc3630c6..000000000
--- a/backend/src/test/scala/com/softwaremill/bootzooka/test/TestHelpersWithDb.scala
+++ /dev/null
@@ -1,27 +0,0 @@
-package com.softwaremill.bootzooka.test
-
-import com.softwaremill.bootzooka.common.sql.SqlDatabase
-import com.softwaremill.bootzooka.email.application.{DummyEmailService, EmailTemplatingEngine}
-import com.softwaremill.bootzooka.user.application.{UserDao, UserService}
-import com.softwaremill.bootzooka.user.domain.User
-import org.scalatest.concurrent.ScalaFutures
-
-import scala.concurrent.ExecutionContext
-
-trait TestHelpersWithDb extends TestHelpers with ScalaFutures {
-
- lazy val emailService = new DummyEmailService()
- lazy val emailTemplatingEngine = new EmailTemplatingEngine
- lazy val userDao = new UserDao(sqlDatabase)
- lazy val userService = new UserService(userDao, emailService, emailTemplatingEngine, passwordHashing)
-
- def sqlDatabase: SqlDatabase
-
- implicit lazy val ec: ExecutionContext = scala.concurrent.ExecutionContext.Implicits.global
-
- def newRandomStoredUser(password: Option[String] = None): User = {
- val u = newRandomUser(password)
- userDao.add(u).futureValue
- u
- }
-}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/test/package.scala b/backend/src/test/scala/com/softwaremill/bootzooka/test/package.scala
new file mode 100644
index 000000000..6186d9425
--- /dev/null
+++ b/backend/src/test/scala/com/softwaremill/bootzooka/test/package.scala
@@ -0,0 +1,8 @@
+package com.softwaremill.bootzooka
+
+import scala.concurrent.duration._
+
+package object test {
+ val DefaultConfig: Config = new ConfigModule {}.config
+ val TestConfig: Config = DefaultConfig.copy(email = DefaultConfig.email.copy(emailSendInterval = 100.milliseconds))
+}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/user/EncryptPasswordBenchmark.scala b/backend/src/test/scala/com/softwaremill/bootzooka/user/EncryptPasswordBenchmark.scala
deleted file mode 100644
index c4470e4eb..000000000
--- a/backend/src/test/scala/com/softwaremill/bootzooka/user/EncryptPasswordBenchmark.scala
+++ /dev/null
@@ -1,32 +0,0 @@
-package com.softwaremill.bootzooka.user
-
-import com.softwaremill.bootzooka.common.crypto.{Argon2dPasswordHashing, CryptoConfig, PasswordHashing, Salt}
-import com.softwaremill.bootzooka.common.Utils
-import com.typesafe.config.{Config, ConfigFactory}
-
-object EncryptPasswordBenchmark extends App {
- val hashing: PasswordHashing = new Argon2dPasswordHashing(new CryptoConfig {
- override def rootConfig: Config = ConfigFactory.load()
- })
-
- def timeEncrypting(pass: String, salt: String, iterations: Int): Double = {
- val start = System.currentTimeMillis()
- for (i <- 1 to iterations) {
- hashing.hashPassword(pass, salt)
- }
- val end = System.currentTimeMillis()
- (end - start).toDouble / iterations
- }
-
- def timeEncryptingAndLog(pass: String, salt: String, iterations: Int) {
- val avg = timeEncrypting(pass, salt, iterations)
- println(s"$iterations iterations of encryption took on average ${avg}ms/encryption")
- }
-
- val pass = Utils.randomString(32)
- val salt = Salt.newSalt()
-
- timeEncryptingAndLog(pass, salt, 100) // warmup
- timeEncryptingAndLog(pass, salt, 1000)
- timeEncryptingAndLog(pass, salt, 1000)
-}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/user/UserApiTest.scala b/backend/src/test/scala/com/softwaremill/bootzooka/user/UserApiTest.scala
new file mode 100644
index 000000000..750037ac3
--- /dev/null
+++ b/backend/src/test/scala/com/softwaremill/bootzooka/user/UserApiTest.scala
@@ -0,0 +1,186 @@
+package com.softwaremill.bootzooka.user
+
+import com.softwaremill.bootzooka.email.sender.DummyEmailSender
+import com.softwaremill.bootzooka.{Clock, Config, MainModule}
+import com.softwaremill.bootzooka.test.{BaseTest, Requests, TestConfig, TestEmbeddedPostgres}
+import monix.eval.Task
+import com.softwaremill.bootzooka.infrastructure.Doobie._
+import com.softwaremill.bootzooka.infrastructure.Json._
+import com.softwaremill.bootzooka.user.UserApi.{ChangePassword_OUT, GetUser_OUT, Login_OUT, Register_OUT, UpdateUser_OUT}
+import org.http4s.Status
+import org.scalatest.concurrent.Eventually
+
+import scala.concurrent.duration._
+
+class UserApiTest extends BaseTest with TestEmbeddedPostgres with Eventually {
+ lazy val modules: MainModule = new MainModule {
+ override def xa: Transactor[Task] = currentDb.xa
+ override lazy val config: Config = TestConfig
+ override lazy val clock: Clock = testClock
+ }
+
+ val requests = new Requests(modules)
+ import requests._
+
+ "/user/register" should "register" in {
+ // given
+ val (login, email, password) = randomLoginEmailPassword()
+
+ // when
+ val response1 = registerUser(login, email, password)
+
+ // then
+ response1.status shouldBe Status.Ok
+ val apiKey = response1.shouldDeserializeTo[Register_OUT].apiKey
+
+ // when
+ val response4 = getUser(apiKey)
+
+ // then
+ response4.shouldDeserializeTo[GetUser_OUT].email shouldBe email
+ }
+
+ "/user/register" should "not register if data is invalid" in {
+ // given
+ val (_, email, password) = randomLoginEmailPassword()
+
+ // when
+ val response1 = registerUser("x", email, password) // too short
+
+ // then
+ response1.status shouldBe Status.BadRequest
+ response1.shouldDeserializeToError
+ }
+
+ "/user/register" should "not register if email is taken" in {
+ // given
+ val (login, email, password) = randomLoginEmailPassword()
+
+ // when
+ val response1 = registerUser(login + "1", email, password)
+ val response2 = registerUser(login + "2", email, password)
+
+ // then
+ response1.status shouldBe Status.Ok
+ response2.status shouldBe Status.BadRequest
+ }
+
+ "/user/register" should "send a welcome email" in {
+ // when
+ val RegisteredUser(login, email, _, _) = newRegisteredUsed()
+
+ // then
+ modules.emailService.sendBatch().unwrap
+ DummyEmailSender.findSendEmail(email, s"registration confirmation for user $login") shouldBe 'defined
+ }
+
+ "/user/login" should "login the user using the login" in {
+ // given
+ val RegisteredUser(login, _, password, _) = newRegisteredUsed()
+
+ // when
+ val response1 = loginUser(login, password)
+
+ // then
+ response1.shouldDeserializeTo[Login_OUT]
+ }
+
+ "/user/login" should "login the user using the email" in {
+ // given
+ val RegisteredUser(_, email, password, _) = newRegisteredUsed()
+
+ // when
+ val response1 = loginUser(email, password)
+
+ // then
+ response1.shouldDeserializeTo[Login_OUT]
+ }
+
+ "/user/login" should "login the user using uppercase email" in {
+ // given
+ val RegisteredUser(_, email, password, _) = newRegisteredUsed()
+
+ // when
+ val response1 = loginUser(email.toUpperCase, password)
+
+ // then
+ response1.shouldDeserializeTo[Login_OUT]
+ }
+
+ "/user/login" should "login the user for the given number of hours" in {
+ // given
+ val RegisteredUser(login, _, password, _) = newRegisteredUsed()
+
+ // when
+ val apiKey = loginUser(login, password, Some(3)).shouldDeserializeTo[Login_OUT].apiKey
+
+ // then
+ getUser(apiKey).shouldDeserializeTo[GetUser_OUT]
+
+ testClock.forward(2.hours)
+ getUser(apiKey).shouldDeserializeTo[GetUser_OUT]
+
+ testClock.forward(2.hours)
+ getUser(apiKey).status shouldBe Status.Unauthorized
+ }
+
+ "/user/info" should "respond with 403 if the token is invalid" in {
+ getUser("invalid").status shouldBe Status.Unauthorized
+ }
+
+ "/user/changepassword" should "change the password" in {
+ // given
+ val RegisteredUser(login, _, password, apiKey) = newRegisteredUsed()
+ val newPassword = password + password
+
+ // when
+ val response1 = changePassword(apiKey, password, newPassword)
+
+ // then
+ response1.shouldDeserializeTo[ChangePassword_OUT]
+ loginUser(login, password, None).status shouldBe Status.Unauthorized
+ loginUser(login, newPassword, None).status shouldBe Status.Ok
+ }
+
+ "/user/changepassword" should "not change the password if the current is invalid" in {
+ // given
+ val RegisteredUser(login, _, password, apiKey) = newRegisteredUsed()
+ val newPassword = password + password
+
+ // when
+ val response1 = changePassword(apiKey, "invalid", newPassword)
+
+ // then
+ response1.shouldDeserializeToError
+ loginUser(login, password, None).status shouldBe Status.Ok
+ loginUser(login, newPassword, None).status shouldBe Status.Unauthorized
+ }
+
+ "/user" should "update the login" in {
+ // given
+ val RegisteredUser(login, email, _, apiKey) = newRegisteredUsed()
+ val newLogin = login + login
+
+ // when
+ val response1 = updateUser(apiKey, Some(newLogin), None)
+
+ // then
+ response1.shouldDeserializeTo[UpdateUser_OUT]
+ getUser(apiKey).shouldDeserializeTo[GetUser_OUT].login shouldBe newLogin
+ getUser(apiKey).shouldDeserializeTo[GetUser_OUT].email shouldBe email
+ }
+
+ "/user" should "update the email" in {
+ // given
+ val RegisteredUser(login, _, _, apiKey) = newRegisteredUsed()
+ val (_, newEmail, _) = randomLoginEmailPassword()
+
+ // when
+ val response1 = updateUser(apiKey, None, Some(newEmail))
+
+ // then
+ response1.shouldDeserializeTo[UpdateUser_OUT]
+ getUser(apiKey).shouldDeserializeTo[GetUser_OUT].login shouldBe login
+ getUser(apiKey).shouldDeserializeTo[GetUser_OUT].email shouldBe newEmail
+ }
+}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/user/application/UserRegisterValidatorSpec.scala b/backend/src/test/scala/com/softwaremill/bootzooka/user/UserRegisterValidatorSpec.scala
similarity index 73%
rename from backend/src/test/scala/com/softwaremill/bootzooka/user/application/UserRegisterValidatorSpec.scala
rename to backend/src/test/scala/com/softwaremill/bootzooka/user/UserRegisterValidatorSpec.scala
index 2238e90c9..cca70354a 100644
--- a/backend/src/test/scala/com/softwaremill/bootzooka/user/application/UserRegisterValidatorSpec.scala
+++ b/backend/src/test/scala/com/softwaremill/bootzooka/user/UserRegisterValidatorSpec.scala
@@ -1,50 +1,49 @@
-package com.softwaremill.bootzooka.user.application
+package com.softwaremill.bootzooka.user
import org.scalatest.{FlatSpec, Matchers}
class UserRegisterValidatorSpec extends FlatSpec with Matchers {
-
"validate" should "accept valid data" in {
- val dataIsValid = UserRegisterValidator.validate("login", "admin@sml.com", "password")
+ val dataIsValid = UserRegisterValidator.validate("login", "admin@bootzooka.com", "password")
- dataIsValid should be(Right(()))
+ dataIsValid shouldBe Right(())
}
"validate" should "not accept login containing only empty spaces" in {
- val dataIsValid = UserRegisterValidator.validate(" ", "admin@sml.com", "password")
+ val dataIsValid = UserRegisterValidator.validate(" ", "admin@bootzooka.com", "password")
- dataIsValid should be('left)
+ dataIsValid shouldBe 'left
}
"validate" should "not accept too short login" in {
val tooShortLogin = "a" * (UserRegisterValidator.MinLoginLength - 1)
- val dataIsValid = UserRegisterValidator.validate(tooShortLogin, "admin@sml.com", "password")
+ val dataIsValid = UserRegisterValidator.validate(tooShortLogin, "admin@bootzooka.com", "password")
- dataIsValid should be('left)
+ dataIsValid shouldBe 'left
}
"validate" should "not accept too short login after trimming" in {
val loginTooShortAfterTrim = "a" * (UserRegisterValidator.MinLoginLength - 1) + " "
- val dataIsValid = UserRegisterValidator.validate(loginTooShortAfterTrim, "admin@sml.com", "password")
+ val dataIsValid = UserRegisterValidator.validate(loginTooShortAfterTrim, "admin@bootzooka.com", "password")
- dataIsValid should be('left)
+ dataIsValid shouldBe 'left
}
"validate" should "not accept missing email with spaces only" in {
val dataIsValid = UserRegisterValidator.validate("login", " ", "password")
- dataIsValid should be('left)
+ dataIsValid shouldBe 'left
}
"validate" should "not accept invalid email" in {
val dataIsValid = UserRegisterValidator.validate("login", "invalidEmail", "password")
- dataIsValid should be('left)
+ dataIsValid shouldBe 'left
}
"validate" should "not accept password with empty spaces only" in {
- val dataIsValid = UserRegisterValidator.validate("login", "admin@sml.com", " ")
+ val dataIsValid = UserRegisterValidator.validate("login", "admin@bootzooka.com", " ")
- dataIsValid should be('left)
+ dataIsValid shouldBe 'left
}
}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/user/api/UsersRoutesSpec.scala b/backend/src/test/scala/com/softwaremill/bootzooka/user/api/UsersRoutesSpec.scala
deleted file mode 100644
index a5a214a38..000000000
--- a/backend/src/test/scala/com/softwaremill/bootzooka/user/api/UsersRoutesSpec.scala
+++ /dev/null
@@ -1,140 +0,0 @@
-package com.softwaremill.bootzooka.user.api
-
-import akka.http.scaladsl.model.StatusCodes
-import akka.http.scaladsl.model.headers.{`Set-Cookie`, Cookie}
-import akka.http.scaladsl.server.Route
-import com.softwaremill.bootzooka.test.{BaseRoutesSpec, TestHelpersWithDb}
-
-class UsersRoutesSpec extends BaseRoutesSpec with TestHelpersWithDb { spec =>
-
- val routes = Route.seal(new UsersRoutes with TestRoutesSupport {
- override val userService = spec.userService
- }.usersRoutes)
-
- "POST /register" should "register new user" in {
- Post("/users/register", Map("login" -> "newUser", "email" -> "newUser@sml.com", "password" -> "secret")) ~> routes ~> check {
- userDao.findByLowerCasedLogin("newUser").futureValue should be('defined)
- status should be(StatusCodes.OK)
- }
- }
-
- "POST /register with invalid data" should "result in an error" in {
- Post("/users/register") ~> routes ~> check {
- status should be(StatusCodes.BadRequest)
- }
- }
-
- "POST /users/whatever" should "not be bound to /users login - reject unmatchedPath request" in {
- Post("/users/whatever") ~> routes ~> check {
- status should be(StatusCodes.NotFound)
- }
- }
-
- "POST /register with an existing login" should "return 409 with an error message" in {
- userDao.add(newUser("user1", "user1@sml.com", "pass", "salt")).futureValue
- Post("/users/register", Map("login" -> "user1", "email" -> "newUser@sml.com", "password" -> "secret")) ~> routes ~> check {
- status should be(StatusCodes.Conflict)
- entityAs[String] should be("Login already in use!")
- }
- }
-
- "POST /register with an existing email" should "return 409 with an error message" in {
- userDao.add(newUser("user2", "user2@sml.com", "pass", "salt")).futureValue
- Post("/users/register", Map("login" -> "newUser", "email" -> "user2@sml.com", "password" -> "secret")) ~> routes ~> check {
- status should be(StatusCodes.Conflict)
- entityAs[String] should be("E-mail already in use!")
- }
- }
-
- "POST /register" should "use escaped Strings" in {
- Post(
- "/users/register",
- Map("login" -> "", "email" -> "newUser@sml.com", "password" -> "secret")
- ) ~> routes ~> check {
- status should be(StatusCodes.OK)
- userDao.findByEmail("newUser@sml.com").futureValue.map(_.login) should be(
- Some("<script>alert('haxor');</script>")
- )
- }
- }
-
- def withLoggedInUser(login: String, password: String)(body: RequestTransformer => Unit) =
- Post("/users", Map("login" -> login, "password" -> password)) ~> routes ~> check {
- status should be(StatusCodes.OK)
-
- val Some(sessionCookie) = header[`Set-Cookie`]
-
- body(addHeader(Cookie(sessionConfig.sessionCookieConfig.name, sessionCookie.cookie.value)))
- }
-
- "POST /" should "log in given valid credentials" in {
- userDao.add(newUser("user3", "user3@sml.com", "pass", "salt")).futureValue
- withLoggedInUser("user3", "pass") { _ =>
- // ok
- }
- }
-
- "POST /" should "not log in given invalid credentials" in {
- userDao.add(newUser("user4", "user4@sml.com", "pass", "salt")).futureValue
- Post("/users", Map("login" -> "user4", "password" -> "hacker")) ~> routes ~> check {
- status should be(StatusCodes.Forbidden)
- }
- }
-
- "PATCH /" should "update email when email is given" in {
- userDao.add(newUser("user5", "user5@sml.com", "pass", "salt")).futureValue
- val email = "coolmail@awesome.rox"
-
- withLoggedInUser("user5", "pass") { transform =>
- Patch("/users", Map("email" -> email)) ~> transform ~> routes ~> check {
- userDao.findByLowerCasedLogin("user5").futureValue.map(_.email) should be(Some(email))
- status should be(StatusCodes.OK)
- }
- }
- }
-
- "PATCH /" should "update login when login is given" in {
- userDao.add(newUser("user6", "user6@sml.com", "pass", "salt")).futureValue
- val login = "user6_changed"
-
- withLoggedInUser("user6", "pass") { transform =>
- Patch("/users", Map("login" -> login)) ~> transform ~> routes ~> check {
- userDao.findByLowerCasedLogin(login).futureValue should be('defined)
- status should be(StatusCodes.OK)
- }
- }
- }
-
- "PATCH /" should "result in an error when user is not authenticated" in {
- Patch("/users", Map("email" -> "?")) ~> routes ~> check {
- status should be(StatusCodes.Forbidden)
- }
- }
-
- "PATCH /" should "result in an error in neither email nor login is given" in {
- userDao.add(newUser("user7", "user7@sml.com", "pass", "salt")).futureValue
- withLoggedInUser("user7", "pass") { transform =>
- Patch("/users", Map.empty[String, String]) ~> transform ~> routes ~> check {
- status should be(StatusCodes.Conflict)
- }
- }
- }
-
- "POST /changepassword" should "update password if current is correct and new is present" in {
- userDao.add(newUser("user8", "user8@sml.com", "pass", "salt")).futureValue
- withLoggedInUser("user8", "pass") { transform =>
- Post("/users/changepassword", Map("currentPassword" -> "pass", "newPassword" -> "newPass")) ~> transform ~> routes ~> check {
- status should be(StatusCodes.OK)
- }
- }
- }
-
- "POST /changepassword" should "not update password if current is wrong" in {
- userDao.add(newUser("user9", "user9@sml.com", "pass", "salt")).futureValue
- withLoggedInUser("user9", "pass") { transform =>
- Post("/users/changepassword", Map("currentPassword" -> "hacker", "newPassword" -> "newPass")) ~> transform ~> routes ~> check {
- status should be(StatusCodes.Forbidden)
- }
- }
- }
-}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/user/application/UserDaoSpec.scala b/backend/src/test/scala/com/softwaremill/bootzooka/user/application/UserDaoSpec.scala
deleted file mode 100644
index 6de5c9fad..000000000
--- a/backend/src/test/scala/com/softwaremill/bootzooka/user/application/UserDaoSpec.scala
+++ /dev/null
@@ -1,198 +0,0 @@
-package com.softwaremill.bootzooka.user.application
-
-import java.util.UUID
-
-import com.softwaremill.bootzooka.common.crypto.Salt
-import com.softwaremill.bootzooka.test.{FlatSpecWithDb, TestHelpers}
-import com.softwaremill.bootzooka.user.domain.User
-import com.typesafe.scalalogging.StrictLogging
-import org.scalatest.Matchers
-
-import scala.language.implicitConversions
-
-class UserDaoSpec extends FlatSpecWithDb with StrictLogging with TestHelpers with Matchers {
- behavior of "UserDao"
-
- implicit val ec = scala.concurrent.ExecutionContext.Implicits.global
-
- val userDao = new UserDao(sqlDatabase)
- lazy val randomIds = List.fill(3)(UUID.randomUUID())
-
- override def beforeEach() {
- super.beforeEach()
- for (i <- 1 to randomIds.size) {
- val login = "user" + i
- val password = "pass" + i
- val salt = "salt" + i
- userDao
- .add(User(randomIds(i - 1), login, login.toLowerCase, i + "email@sml.com", password, salt, createdOn))
- .futureValue
- }
- }
-
- it should "add new user" in {
- // Given
- val login = "newuser"
- val email = "newemail@sml.com"
-
- // When
- userDao.add(newUser(login, email, "pass", "salt")).futureValue
-
- // Then
- userDao.findByEmail(email).futureValue should be('defined)
- }
-
- it should "fail with exception when trying to add user with existing login" in {
- // Given
- val login = "newuser"
- val email = "anotherEmaill@sml.com"
-
- userDao.add(newUser(login, "somePrefix" + email, "somePass", "someSalt")).futureValue
-
- // When & then
- userDao.add(newUser(login, email, "pass", "salt")).failed.futureValue
- }
-
- it should "fail with exception when trying to add user with existing email" in {
- // Given
- val login = "anotherUser"
- val email = "newemail@sml.com"
-
- userDao.add(newUser("somePrefixed" + login, email, "somePass", "someSalt")).futureValue
-
- // When & then
- userDao.add(newUser(login, email, "pass", "salt")).failed.futureValue
- }
-
- it should "find by email" in {
- // Given
- val email = "1email@sml.com"
-
- // When
- val userOpt = userDao.findByEmail(email).futureValue
-
- // Then
- userOpt.map(_.email) should equal(Some(email))
- }
-
- it should "find by uppercase email" in {
- // Given
- val email = "1email@sml.com".toUpperCase
-
- // When
- val userOpt = userDao.findByEmail(email).futureValue
-
- // Then
- userOpt.map(_.email) should equal(Some(email.toLowerCase))
- }
-
- it should "find by login" in {
- // Given
- val login = "user1"
-
- // When
- val userOpt = userDao.findByLowerCasedLogin(login).futureValue
-
- // Then
- userOpt.map(_.login) should equal(Some(login))
- }
-
- it should "find by uppercase login" in {
- // Given
- val login = "user1".toUpperCase
-
- // When
- val userOpt = userDao.findByLowerCasedLogin(login).futureValue
-
- // Then
- userOpt.map(_.login) should equal(Some(login.toLowerCase))
- }
-
- it should "find using login with findByLoginOrEmail" in {
- // Given
- val login = "user1"
-
- // When
- val userOpt = userDao.findByLoginOrEmail(login).futureValue
-
- // Then
- userOpt.map(_.login) should equal(Some(login.toLowerCase))
- }
-
- it should "find using uppercase login with findByLoginOrEmail" in {
- // Given
- val login = "user1".toUpperCase
-
- // When
- val userOpt = userDao.findByLoginOrEmail(login).futureValue
-
- // Then
- userOpt.map(_.login) should equal(Some(login.toLowerCase))
- }
-
- it should "find using email with findByLoginOrEmail" in {
- // Given
- val email = "1email@sml.com"
-
- // When
- val userOpt = userDao.findByLoginOrEmail(email).futureValue
-
- // Then
- userOpt.map(_.email) should equal(Some(email.toLowerCase))
- }
-
- it should "find using uppercase email with findByLoginOrEmail" in {
- // Given
- val email = "1email@sml.com".toUpperCase
-
- // When
- val userOpt = userDao.findByLoginOrEmail(email).futureValue
-
- // Then
- userOpt.map(_.email) should equal(Some(email.toLowerCase))
- }
-
- it should "change password" in {
- // Given
- val login = "user1"
- val salt = Salt.newSalt()
- val password = passwordHashing.hashPassword("pass11", salt)
- val user = userDao.findByLoginOrEmail(login).futureValue.get
-
- // When
- userDao.changePassword(user.id, password, salt).futureValue
- val postModifyUserOpt = userDao.findByLoginOrEmail(login).futureValue
- val u = postModifyUserOpt.get
-
- // Then
- u should be(user.copy(password = password, salt = salt))
- }
-
- it should "change login" in {
- // Given
- val user = userDao.findByLowerCasedLogin("user1")
- val u = user.futureValue.get
- val newLogin = "changedUser1"
-
- // When
- userDao.changeLogin(u.id, newLogin).futureValue
- val postModifyUser = userDao.findByLowerCasedLogin(newLogin).futureValue
-
- // Then
- postModifyUser should equal(Some(u.copy(login = newLogin, loginLowerCased = newLogin.toLowerCase)))
- }
-
- it should "change email" in {
- // Given
- val newEmail = "newmail@sml.pl"
- val user = userDao.findByEmail("1email@sml.com").futureValue
- val u = user.get
-
- // When
- userDao.changeEmail(u.id, newEmail).futureValue
-
- // Then
- userDao.findByEmail(newEmail).futureValue should equal(Some(u.copy(email = newEmail)))
- }
-
-}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/user/application/UserServiceSpec.scala b/backend/src/test/scala/com/softwaremill/bootzooka/user/application/UserServiceSpec.scala
deleted file mode 100644
index 9a2177ebc..000000000
--- a/backend/src/test/scala/com/softwaremill/bootzooka/user/application/UserServiceSpec.scala
+++ /dev/null
@@ -1,147 +0,0 @@
-package com.softwaremill.bootzooka.user.application
-
-import java.util.UUID
-
-import com.softwaremill.bootzooka.common.crypto.{Argon2dPasswordHashing, CryptoConfig}
-import com.softwaremill.bootzooka.test.{FlatSpecWithDb, TestHelpersWithDb}
-import com.typesafe.config.Config
-import org.scalatest.{Matchers, OptionValues}
-
-class UserServiceSpec extends FlatSpecWithDb with Matchers with TestHelpersWithDb with OptionValues {
-
- override protected def beforeEach() = {
- super.beforeEach()
-
- userDao.add(newUser("Admin", "admin@sml.com", "pass", "salt")).futureValue
- userDao.add(newUser("Admin2", "admin2@sml.com", "pass", "salt")).futureValue
- }
-
- "registerNewUser" should "add user with unique lowercase login info" in {
- // When
- val result = userService.registerNewUser("John", "newUser@sml.com", "password").futureValue
-
- // Then
- result should be(UserRegisterResult.Success)
-
- val userOpt = userDao.findByLowerCasedLogin("John").futureValue
- userOpt should be('defined)
- val user = userOpt.get
-
- user.login should be("John")
- user.loginLowerCased should be("john")
-
- emailService.wasEmailSentTo("newUser@sml.com") should be(true)
- }
-
- "registerNewUser" should "not register a user if a user with the given login/e-mail exists" in {
- // when
- val resultInitial = userService.registerNewUser("John", "newUser@sml.com", "password").futureValue
- val resultSameLogin = userService.registerNewUser("John", "newUser2@sml.com", "password").futureValue
- val resultSameEmail = userService.registerNewUser("John2", "newUser@sml.com", "password").futureValue
-
- // then
- resultInitial should be(UserRegisterResult.Success)
- resultSameLogin should matchPattern { case UserRegisterResult.UserExists(_) => }
- resultSameEmail should matchPattern { case UserRegisterResult.UserExists(_) => }
-
- userDao.findByLoginOrEmail("newUser2@sml.com").futureValue should be(None)
- userDao.findByLoginOrEmail("John2").futureValue should be(None)
- }
-
- "registerNewUser" should "not schedule an email on existing login" in {
- // When
- userService.registerNewUser("Admin", "secondEmail@sml.com", "password").futureValue
-
- // Then
- emailService.wasEmailSentTo("secondEmail@sml.com") should be(false)
- }
-
- "changeEmail" should "change email for specified user" in {
- val user = userDao.findByLowerCasedLogin("admin").futureValue
- val newEmail = "new@email.com"
- userService.changeEmail(user.get.id, newEmail).futureValue should be('right)
- userDao.findByEmail(newEmail).futureValue match {
- case Some(cu) => // ok
- case None => fail("User not found. Maybe e-mail wasn't really changed?")
- }
- }
-
- "changeEmail" should "not change email if already used by someone else" in {
- userService.changeEmail(UUID.randomUUID(), "admin2@sml.com").futureValue should be('left)
- }
-
- "changeLogin" should "change login for specified user" in {
- val user = userDao.findByLowerCasedLogin("admin").futureValue
- val newLogin = "newadmin"
- userService.changeLogin(user.get.id, newLogin).futureValue should be('right)
- userDao.findByLowerCasedLogin(newLogin).futureValue match {
- case Some(cu) =>
- case None => fail("User not found. Maybe login wasn't really changed?")
- }
- }
-
- "changeLogin" should "not change login if already used by someone else" in {
- userService.changeLogin(UUID.randomUUID(), "admin2").futureValue should be('left)
- }
-
- "changePassword" should "change password if current is correct and new is present" in {
- // Given
- val user = userDao.findByLowerCasedLogin("admin").futureValue.get
- val currentPassword = "pass"
- val newPassword = "newPass"
-
- // When
- val changePassResult = userService.changePassword(user.id, currentPassword, newPassword).futureValue
-
- // Then
- changePassResult should be('right)
- userDao.findByLowerCasedLogin("admin").futureValue match {
- case Some(cu) => passwordHashing.verifyPassword(cu.password, newPassword, cu.salt)
- case None => fail("Something bad happened, maybe mocked Dao is broken?")
- }
- }
-
- "changePassword" should "not change password if current is incorrect" in {
- // Given
- val user = userDao.findByLowerCasedLogin("admin").futureValue.get
-
- // When, Then
- userService.changePassword(user.id, "someillegalpass", "newpass").futureValue should be('left)
- }
-
- "changePassword" should "complain when user cannot be found" in {
- userService.changePassword(UUID.randomUUID(), "pass", "newpass").futureValue should be('left)
- }
-
- "authenticate" should "rehash password when configuration changes" in {
- //given
- val user = userDao.findByLoginOrEmail("Admin").futureValue.value
- val reconfiguredHashing = new Argon2dPasswordHashing(new CryptoConfig {
- override def rootConfig: Config = ???
- override lazy val iterations = 3
- override lazy val memory = 1024
- override lazy val parallelism = 3
- })
- val reconfiguredUserService = new UserService(userDao, emailService, emailTemplatingEngine, reconfiguredHashing)
-
- //when
- reconfiguredUserService.authenticate("Admin", "pass").futureValue
-
- //then
- val updatedUser = userDao.findByLoginOrEmail("Admin").futureValue.value
- updatedUser.password shouldNot be(user.password)
- }
-
- "authenticate" should "not rehash password for the same configuration" in {
- //given
- val user = userDao.findByLoginOrEmail("Admin").futureValue.value
-
- //when
- userService.authenticate("Admin", "pass").futureValue
-
- //then
- val updatedUser = userDao.findByLoginOrEmail("Admin").futureValue.value
- updatedUser.password shouldBe user.password
- }
-
-}
diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/user/domain/UserSpec.scala b/backend/src/test/scala/com/softwaremill/bootzooka/user/domain/UserSpec.scala
deleted file mode 100644
index 582aafa12..000000000
--- a/backend/src/test/scala/com/softwaremill/bootzooka/user/domain/UserSpec.scala
+++ /dev/null
@@ -1,46 +0,0 @@
-package com.softwaremill.bootzooka.user.domain
-
-import com.softwaremill.bootzooka.test.TestHelpers
-import org.scalatest.{FlatSpec, Matchers}
-
-class UserSpec extends FlatSpec with Matchers with TestHelpers {
- "encrypt password" should "take into account the password" in {
- // given
- val p1 = "pass1"
- val p2 = "pass2"
- val salt = "salt"
-
- // when
- val e1 = passwordHashing.hashPassword(p1, salt)
- val e2 = passwordHashing.hashPassword(p2, salt)
-
- // then
- info(s"$p1 encrypted is: $e1")
- info(s"$p2 encrypted is: $e2")
-
- e1.length should be >= (10)
- e2.length should be >= (10)
-
- e1 should not be (e2)
- }
-
- "encrypt password" should "take into account the salt" in {
- // given
- val pass = "pass"
- val salt1 = "salt1"
- val salt2 = "salt2"
-
- // when
- val e1 = passwordHashing.hashPassword(pass, salt1)
- val e2 = passwordHashing.hashPassword(pass, salt2)
-
- // then
- info(s"$pass encrypted with $salt1 is: $e1")
- info(s"$pass encrypted with $salt2 is: $e2")
-
- e1.length should be >= (10)
- e2.length should be >= (10)
-
- e1 should not be (e2)
- }
-}
diff --git a/build.sbt b/build.sbt
index 0dd6c82e8..f3ea82f9b 100644
--- a/build.sbt
+++ b/build.sbt
@@ -1,5 +1,6 @@
-import java.text.SimpleDateFormat
-import java.util.Date
+import sbtbuildinfo.BuildInfoKey.action
+import sbtbuildinfo.BuildInfoKeys.{buildInfoKeys, buildInfoOptions, buildInfoPackage}
+import sbtbuildinfo.{BuildInfoKey, BuildInfoOption}
import sbt._
import Keys._
@@ -8,65 +9,90 @@ import scala.util.Try
import scala.sys.process.Process
import complete.DefaultParsers._
-val slf4jVersion = "1.7.25"
-val logBackVersion = "1.2.3"
-val scalaLoggingVersion = "3.7.2"
-val slickVersion = "3.2.1"
-val seleniumVersion = "2.53.0"
-val circeVersion = "0.8.0"
-val akkaVersion = "2.5.0"
-val akkaHttpVersion = "10.0.10"
-val argon2javaVersion = "2.2"
-
-val slf4jApi = "org.slf4j" % "slf4j-api" % slf4jVersion
-val logBackClassic = "ch.qos.logback" % "logback-classic" % logBackVersion
-val scalaLogging = "com.typesafe.scala-logging" %% "scala-logging" % scalaLoggingVersion
-val loggingStack = Seq(slf4jApi, logBackClassic, scalaLogging)
-
-val typesafeConfig = "com.typesafe" % "config" % "1.3.2"
-
-val circeCore = "io.circe" %% "circe-core" % circeVersion
-val circeGeneric = "io.circe" %% "circe-generic" % circeVersion
-val circeJawn = "io.circe" %% "circe-jawn" % circeVersion
-val circe = Seq(circeCore, circeGeneric, circeJawn)
-
-val javaxMailSun = "com.sun.mail" % "javax.mail" % "1.6.0"
-
-val slick = "com.typesafe.slick" %% "slick" % slickVersion
-val slickHikari = "com.typesafe.slick" %% "slick-hikaricp" % slickVersion
-val h2 = "com.h2database" % "h2" % "1.4.196"
-val postgres = "org.postgresql" % "postgresql" % "42.1.4"
-val flyway = "org.flywaydb" % "flyway-core" % "4.2.0"
-val slickStack = Seq(slick, h2, postgres, slickHikari, flyway)
-
-val scalatest = "org.scalatest" %% "scalatest" % "3.0.4" % "test"
-val unitTestingStack = Seq(scalatest)
+val seleniumVersion = "2.53.0"
+val doobieVersion = "0.7.0"
+val http4sVersion = "0.20.4"
+val circeVersion = "0.11.1"
+val tsecVersion = "0.1.0"
+val sttpVersion = "1.6.0"
+val prometheusVersion = "0.6.0"
+val tapirVersion = "0.8.11"
+
+val dbDependencies = Seq(
+ "org.tpolecat" %% "doobie-core" % doobieVersion,
+ "org.tpolecat" %% "doobie-hikari" % doobieVersion,
+ "org.tpolecat" %% "doobie-postgres" % doobieVersion,
+ "org.flywaydb" % "flyway-core" % "5.2.4"
+)
+
+val httpDependencies = Seq(
+ "org.http4s" %% "http4s-dsl" % http4sVersion,
+ "org.http4s" %% "http4s-blaze-server" % http4sVersion,
+ "org.http4s" %% "http4s-blaze-client" % http4sVersion,
+ "org.http4s" %% "http4s-circe" % http4sVersion,
+ "org.http4s" %% "http4s-prometheus-metrics" % http4sVersion,
+ "com.softwaremill.sttp" %% "async-http-client-backend-monix" % sttpVersion,
+ "com.softwaremill.tapir" %% "tapir-http4s-server" % tapirVersion
+)
-val seleniumJava = "org.seleniumhq.selenium" % "selenium-java" % seleniumVersion % "test"
-val seleniumFirefox = "org.seleniumhq.selenium" % "selenium-firefox-driver" % seleniumVersion % "test"
-val seleniumStack = Seq(seleniumJava, seleniumFirefox)
+val monitoringDependencies = Seq(
+ "io.prometheus" % "simpleclient" % prometheusVersion,
+ "io.prometheus" % "simpleclient_hotspot" % prometheusVersion,
+ "com.softwaremill.sttp" %% "prometheus-backend" % sttpVersion
+)
-val akkaHttpCore = "com.typesafe.akka" %% "akka-http-core" % akkaHttpVersion
-val akkaHttpExperimental = "com.typesafe.akka" %% "akka-http" % akkaHttpVersion
-val akkaHttpTestkit = "com.typesafe.akka" %% "akka-http-testkit" % akkaHttpVersion % "test"
-val akkaHttpSession = "com.softwaremill.akka-http-session" %% "core" % "0.5.2"
-val akkaStack = Seq(akkaHttpCore, akkaHttpExperimental, akkaHttpTestkit, akkaHttpSession)
-val swagger = "com.github.swagger-akka-http" %% "swagger-akka-http" % "0.11.0"
+val jsonDependencies = Seq(
+ "io.circe" %% "circe-core" % circeVersion,
+ "io.circe" %% "circe-generic" % circeVersion,
+ "io.circe" %% "circe-parser" % circeVersion,
+ "io.circe" %% "circe-java8" % circeVersion,
+ "com.softwaremill.tapir" %% "tapir-json-circe" % tapirVersion,
+ "com.softwaremill.sttp" %% "circe" % sttpVersion
+)
-val argon2java = "de.mkammerer" % "argon2-jvm" % argon2javaVersion
+val loggingDependencies = Seq(
+ "com.typesafe.scala-logging" %% "scala-logging" % "3.9.2",
+ "ch.qos.logback" % "logback-classic" % "1.2.3"
+)
-val commonDependencies = unitTestingStack ++ loggingStack
+val configDependencies = Seq(
+ "com.github.pureconfig" %% "pureconfig" % "0.11.1"
+)
+
+val baseDependencies = Seq(
+ "io.monix" %% "monix" % "3.0.0-RC3",
+ "com.softwaremill.common" %% "tagging" % "2.2.1"
+)
+
+val apiDocsDependencies = Seq(
+ "com.softwaremill.tapir" %% "tapir-openapi-docs" % tapirVersion,
+ "com.softwaremill.tapir" %% "tapir-openapi-circe-yaml" % tapirVersion,
+ "com.softwaremill.tapir" %% "tapir-swagger-ui-http4s" % tapirVersion
+)
+
+val securityDependencies = Seq(
+ "io.github.jmcardon" %% "tsec-password" % tsecVersion,
+ "io.github.jmcardon" %% "tsec-cipher-jca" % tsecVersion
+)
+
+val emailDependencies = Seq(
+ "com.sun.mail" % "javax.mail" % "1.6.0"
+)
+
+val scalatest = "org.scalatest" %% "scalatest" % "3.0.8" % "test"
+val unitTestingStack = Seq(scalatest)
+
+val embeddedPostgres = "com.opentable.components" % "otj-pg-embedded" % "0.13.1"
+val dbTestingStack = Seq(embeddedPostgres)
+
+val commonDependencies = baseDependencies ++ unitTestingStack ++ loggingDependencies ++ configDependencies
lazy val updateNpm = taskKey[Unit]("Update npm")
-lazy val npmTask = inputKey[Unit]("Run npm with arguments")
-
-lazy val commonSettings = Seq(
- organization := "com.softwaremill",
- version := "0.0.1-SNAPSHOT",
- scalaVersion := "2.12.4",
- crossScalaVersions := Seq(scalaVersion.value, "2.11.8"),
- crossVersion := CrossVersion.binary,
- scalacOptions ++= Seq("-unchecked", "-deprecation"),
+lazy val npmTask = inputKey[Unit]("Run npm with arguments")
+
+lazy val commonSettings = commonSmlBuildSettings ++ Seq(
+ organization := "com.softwaremill.bootzooka",
+ scalaVersion := "2.12.8",
libraryDependencies ++= commonDependencies,
updateNpm := {
println("Updating npm dependencies")
@@ -80,9 +106,7 @@ lazy val commonSettings = Seq(
Process(localNpmCommand, baseDirectory.value / ".." / "ui").!
println("Building with Webpack : " + taskName)
haltOnCmdResultError(buildWebpack())
- },
- scalafmtOnCompile := true,
- scalafmtVersion := "1.2.0"
+ }
)
def haltOnCmdResultError(result: Int) {
@@ -105,14 +129,25 @@ lazy val backend: Project = (project in file("backend"))
.settings(commonSettings)
.settings(Revolver.settings)
.settings(
- libraryDependencies ++= slickStack ++ akkaStack ++ circe ++ Seq(javaxMailSun, typesafeConfig, swagger, argon2java),
- buildInfoPackage := "com.softwaremill.bootzooka.version",
- buildInfoObject := "BuildInfo",
buildInfoKeys := Seq[BuildInfoKey](
- BuildInfoKey.action("buildDate")(new SimpleDateFormat("yyyy-MM-dd HH:mm").format(new Date())),
- // if the build is done outside of a git repository, we still want it to succeed
- BuildInfoKey.action("buildSha")(Try(Process("git rev-parse HEAD").!!.stripLineEnd).getOrElse("?"))
+ name,
+ version,
+ scalaVersion,
+ sbtVersion,
+ action("lastCommitHash") {
+ import scala.sys.process._
+ // if the build is done outside of a git repository, we still want it to succeed
+ Try("git rev-parse HEAD".!!.trim).getOrElse("?")
+ }
),
+ buildInfoOptions += BuildInfoOption.BuildTime,
+ buildInfoOptions += BuildInfoOption.ToJson,
+ buildInfoOptions += BuildInfoOption.ToMap,
+ buildInfoPackage := "com.softwaremill.bootzooka.version",
+ buildInfoObject := "BuildInfo"
+ )
+ .settings(
+ libraryDependencies ++= dbDependencies ++ httpDependencies ++ jsonDependencies ++ apiDocsDependencies ++ monitoringDependencies ++ dbTestingStack ++ securityDependencies ++ emailDependencies,
compile in Compile := {
val compilationResult = (compile in Compile).value
IO.touch(target.value / "compilationFinished")
@@ -134,12 +169,16 @@ lazy val ui = (project in file("ui"))
.settings(commonSettings: _*)
.settings(test in Test := (test in Test).dependsOn(npmTask.toTask(" run test")).value)
-lazy val uiTests = (project in file("ui-tests"))
- .settings(commonSettings: _*)
- .settings(
- parallelExecution := false,
- libraryDependencies ++= seleniumStack,
- test in Test := (test in Test).dependsOn(npmTask.toTask(" run build")).value
- ) dependsOn backend
+//val seleniumJava = "org.seleniumhq.selenium" % "selenium-java" % seleniumVersion % "test"
+//val seleniumFirefox = "org.seleniumhq.selenium" % "selenium-firefox-driver" % seleniumVersion % "test"
+//val seleniumDependencies = Seq(seleniumJava, seleniumFirefox)
+//
+//lazy val uiTests = (project in file("ui-tests"))
+// .settings(commonSettings: _*)
+// .settings(
+// parallelExecution := false,
+// libraryDependencies ++= seleniumDependencies,
+// test in Test := (test in Test).dependsOn(npmTask.toTask(" run build")).value
+// ) dependsOn backend
RenameProject.settings
diff --git a/project/build.properties b/project/build.properties
index 7b6213bdc..1fc4b8093 100644
--- a/project/build.properties
+++ b/project/build.properties
@@ -1 +1 @@
-sbt.version=1.0.1
+sbt.version=1.2.8
\ No newline at end of file
diff --git a/project/plugins.sbt b/project/plugins.sbt
index 56918f9b0..c375efbd7 100644
--- a/project/plugins.sbt
+++ b/project/plugins.sbt
@@ -1,9 +1,9 @@
-addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.5")
-
-addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.7.0")
+addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.9.0")
addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.0")
addSbtPlugin("com.heroku" % "sbt-heroku" % "2.0.0")
-addSbtPlugin("com.lucidchart" % "sbt-scalafmt" % "1.12")
+addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill" % "1.7.5")
+
+addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.10")
\ No newline at end of file
diff --git a/ui-tests/src/test/scala/uitest/BaseUiSpec.scala b/ui-tests/src/test/scala/uitest/BaseUiSpec.scala
index ad9d7445d..910601f8f 100644
--- a/ui-tests/src/test/scala/uitest/BaseUiSpec.scala
+++ b/ui-tests/src/test/scala/uitest/BaseUiSpec.scala
@@ -2,11 +2,10 @@ package uitest
import java.util.concurrent.TimeUnit
-import akka.http.scaladsl.Http.ServerBinding
-import com.softwaremill.bootzooka.email.application.DummyEmailService
-import com.softwaremill.bootzooka.passwordreset.application.SqlPasswordResetCodeSchema
-import com.softwaremill.bootzooka.user.application.SqlUserSchema
-import com.softwaremill.bootzooka.{DependencyWiring, Main}
+import com.softwaremill.bootzooka.email.sender.DummyEmailSender
+import com.softwaremill.bootzooka.passwordreset.SqlPasswordResetCodeSchema
+import com.softwaremill.bootzooka.user.SqlUserSchema
+import com.softwaremill.bootzooka.Main
import org.openqa.selenium.firefox.FirefoxDriver
import org.openqa.selenium.support.PageFactory
import org.scalatest.concurrent.ScalaFutures
@@ -21,7 +20,7 @@ class BaseUiSpec extends FunSuite with Matchers with BeforeAndAfterAll with Befo
val RegMail = "reguser@regmail.pl"
var driver: FirefoxDriver = _
- var emailService: DummyEmailService = _
+ var emailService: DummyEmailSender = _
var loginPage: LoginPage = _
var messagesPage: MessagesPage = _
var passwordRestPage: PasswordResetPage = _
@@ -37,7 +36,7 @@ class BaseUiSpec extends FunSuite with Matchers with BeforeAndAfterAll with Befo
registerUserIfNotExists(RegUser, RegMail, RegPass)
registerUserIfNotExists("1" + RegUser, "1" + RegMail, RegPass)
- emailService = businessLogic.emailService.asInstanceOf[DummyEmailService]
+ emailService = businessLogic.emailService.asInstanceOf[DummyEmailSender]
}
/**
diff --git a/ui-tests/src/test/scala/uitest/PasswordResetUiSpec.scala b/ui-tests/src/test/scala/uitest/PasswordResetUiSpec.scala
index fabdea14d..c0b5ee936 100644
--- a/ui-tests/src/test/scala/uitest/PasswordResetUiSpec.scala
+++ b/ui-tests/src/test/scala/uitest/PasswordResetUiSpec.scala
@@ -1,6 +1,6 @@
package uitest
-import com.softwaremill.bootzooka.passwordreset.domain.PasswordResetCode
+import com.softwaremill.bootzooka.passwordreset.PasswordResetCode
class PasswordResetUiSpec extends BaseUiSpec {
diff --git a/ui-tests/src/test/scala/uitest/RegisterUiSpec.scala b/ui-tests/src/test/scala/uitest/RegisterUiSpec.scala
index e7931ed0a..f12063ab2 100644
--- a/ui-tests/src/test/scala/uitest/RegisterUiSpec.scala
+++ b/ui-tests/src/test/scala/uitest/RegisterUiSpec.scala
@@ -1,6 +1,5 @@
package uitest
-import com.softwaremill.bootzooka.common.Utils
import org.scalatest.BeforeAndAfterEach
import uitest.pages.RegistrationPage
diff --git a/version.sbt b/version.sbt
new file mode 100644
index 000000000..de734f78f
--- /dev/null
+++ b/version.sbt
@@ -0,0 +1 @@
+version in ThisBuild := "0.0.1-SNAPSHOT"