From cad2707fa4013d5af76e449c1a043ccd43882b44 Mon Sep 17 00:00:00 2001 From: adamw Date: Wed, 10 Jul 2019 19:07:39 +0200 Subject: [PATCH] Rewriting the backend to use tapir/http4s/doobie --- .gitignore | 3 +- .scalafmt.conf | 11 +- .travis.yml | 28 +-- README.md | 1 - activator.properties | 9 - backend/src/main/resources/application.conf | 77 +++++++ .../db/migration/V1__create_schema.sql | 72 ++++--- .../db/migration/V2__add_indexes.sql | 2 - backend/src/main/resources/logback.xml | 18 +- backend/src/main/resources/reference.conf | 103 --------- .../templates/email/emailSignature.txt | 2 +- .../templates/email/resetPassword.txt | 3 +- .../softwaremill/bootzooka/BaseModule.scala | 8 + .../bootzooka/BootzookaConfig.scala | 3 + .../com/softwaremill/bootzooka/Clock.scala | 11 + .../com/softwaremill/bootzooka/Config.scala | 7 + .../softwaremill/bootzooka/ConfigModule.scala | 37 ++++ .../bootzooka/DependencyWiring.scala | 68 ------ .../com/softwaremill/bootzooka/Fail.scala | 15 ++ .../bootzooka/HttpAPIModule.scala | 64 ++++++ .../softwaremill/bootzooka/IdGenerator.scala | 12 ++ .../softwaremill/bootzooka/InitModule.scala | 7 + .../softwaremill/bootzooka/LowerCased.scala | 3 + .../com/softwaremill/bootzooka/Main.scala | 78 ++----- .../softwaremill/bootzooka/MainModule.scala | 32 +++ .../com/softwaremill/bootzooka/Routes.scala | 28 --- .../softwaremill/bootzooka/ServerConfig.scala | 10 - .../softwaremill/bootzooka/bootzooka.scala | 20 ++ .../bootzooka/common/ConfigWithDefault.scala | 30 --- .../bootzooka/common/FutureHelpers.scala | 9 - .../softwaremill/bootzooka/common/Utils.scala | 87 -------- .../bootzooka/common/api/CirceEncoders.scala | 26 --- .../common/api/RoutesRequestWrapper.scala | 32 --- .../bootzooka/common/api/RoutesSupport.scala | 91 -------- .../common/api/securityHeaders.scala | 86 -------- .../crypto/Argon2dPasswordHashing.scala | 23 -- .../common/crypto/CryptoConfig.scala | 12 -- .../common/crypto/PasswordHashing.scala | 16 -- .../bootzooka/common/crypto/Salt.scala | 17 -- .../bootzooka/common/sql/DatabaseConfig.scala | 27 --- .../bootzooka/common/sql/SqlDatabase.scala | 110 ---------- .../bootzooka/email/EmailConfig.scala | 16 ++ .../bootzooka/email/EmailModel.scala | 39 ++++ .../bootzooka/email/EmailModule.scala | 19 ++ .../bootzooka/email/EmailService.scala | 37 ++++ .../EmailTemplatingEngine.scala | 30 +-- .../email/application/DummyEmailService.scala | 41 ---- .../email/application/EmailConfig.scala | 19 -- .../email/application/EmailService.scala | 11 - .../email/application/SmtpEmailService.scala | 33 --- .../domain/EmailContentWithSubject.scala | 3 - .../email/sender/DummyEmailSender.scala | 28 +++ .../bootzooka/email/sender/EmailSender.scala | 8 + .../SmtpEmailSender.scala | 55 +++-- .../infrastructure/CorrelationId.scala | 105 ++++++++++ .../bootzooka/infrastructure/DB.scala | 59 ++++++ .../bootzooka/infrastructure/DBConfig.scala | 10 + .../bootzooka/infrastructure/Doobie.scala | 46 ++++ .../bootzooka/infrastructure/Http.scala | 90 ++++++++ .../bootzooka/infrastructure/HttpConfig.scala | 3 + .../bootzooka/infrastructure/Json.scala | 27 +++ .../infrastructure/LoggingSttpBackend.scala | 19 ++ .../bootzooka/metrics/AppMetrics.scala | 16 ++ .../bootzooka/metrics/MetricsApi.scala | 22 ++ .../bootzooka/metrics/MetricsModule.scala | 11 + .../passwordreset/PasswordResetApi.scala | 48 +++++ .../PasswordResetCodeModel.scala | 40 ++++ .../passwordreset/PasswordResetConfig.scala | 3 + .../passwordreset/PasswordResetModule.scala | 19 ++ .../passwordreset/PasswordResetService.scala | 55 +++++ .../api/PasswordResetRoutes.scala | 34 --- .../application/PasswordResetCodeDao.scala | 77 ------- .../application/PasswordResetConfig.scala | 11 - .../application/PasswordResetService.scala | 82 -------- .../domain/PasswordResetCode.scala | 20 -- .../bootzooka/security/ApiKeyModel.scala | 38 ++++ .../bootzooka/security/ApiKeyService.scala | 21 ++ .../bootzooka/security/Auth.scala | 61 ++++++ .../bootzooka/security/SecurityModule.scala | 14 ++ .../bootzooka/security/model.scala | 9 + .../bootzooka/sql/H2BrowserConsole.scala | 16 -- .../bootzooka/sql/H2ShellConsole.scala | 13 -- .../bootzooka/swagger/SwaggerDocService.scala | 17 -- .../softwaremill/bootzooka/user/User.scala | 25 +++ .../softwaremill/bootzooka/user/UserApi.scala | 98 +++++++++ .../bootzooka/user/UserModel.scala | 47 +++++ .../bootzooka/user/UserModule.scala | 17 ++ .../bootzooka/user/UserService.scala | 132 ++++++++++++ .../bootzooka/user/api/SessionSupport.scala | 37 ---- .../bootzooka/user/api/UsersRoutes.scala | 106 ---------- .../application/RefreshTokenStorageImpl.scala | 42 ---- .../user/application/RememberMeTokenDao.scala | 45 ---- .../bootzooka/user/application/Session.scala | 17 -- .../bootzooka/user/application/UserDao.scala | 81 ------- .../user/application/UserService.scala | 151 ------------- .../user/domain/RememberMeToken.scala | 6 - .../bootzooka/user/domain/User.scala | 43 ---- .../softwaremill/bootzooka/user/package.scala | 6 - .../bootzooka/version/VersionApi.scala | 25 +++ .../bootzooka/version/VersionRoutes.scala | 57 ----- .../common/api/RoutesRequestWrapperSpec.scala | 25 --- .../crypto/Argon2dPasswordHashingSpec.scala | 28 --- .../email/EmailTemplatingEngineTest.scala | 19 ++ .../DummyEmailSendingServiceSpec.scala | 12 -- .../EmailTemplatingEngineSpec.scala | 38 ---- .../email/sender/DummyEmailSenderTest.scala | 12 ++ .../passwordreset/PasswordResetApiTest.scala | 108 ++++++++++ .../api/PasswordResetRoutesSpec.scala | 89 -------- .../PasswordResetCodeDaoSpec.scala | 64 ------ .../PasswordResetServiceSpec.scala | 90 -------- .../bootzooka/test/BaseRoutesSpec.scala | 26 --- .../bootzooka/test/BaseTest.scala | 9 + .../bootzooka/test/FlatSpecWithDb.scala | 52 ----- .../bootzooka/test/HttpTestSupport.scala | 92 ++++++++ .../bootzooka/test/Requests.scala | 59 ++++++ .../bootzooka/test/TestClock.scala | 34 +++ .../softwaremill/bootzooka/test/TestDB.scala | 91 ++++++++ .../bootzooka/test/TestEmbeddedPostgres.scala | 44 ++++ .../bootzooka/test/TestHelpers.scala | 30 --- .../bootzooka/test/TestHelpersWithDb.scala | 27 --- .../softwaremill/bootzooka/test/package.scala | 8 + .../user/EncryptPasswordBenchmark.scala | 32 --- .../bootzooka/user/UserApiTest.scala | 186 ++++++++++++++++ .../UserRegisterValidatorSpec.scala | 27 ++- .../bootzooka/user/api/UsersRoutesSpec.scala | 140 ------------- .../user/application/UserDaoSpec.scala | 198 ------------------ .../user/application/UserServiceSpec.scala | 147 ------------- .../bootzooka/user/domain/UserSpec.scala | 46 ---- build.sbt | 181 +++++++++------- project/build.properties | 2 +- project/plugins.sbt | 8 +- .../src/test/scala/uitest/BaseUiSpec.scala | 13 +- .../scala/uitest/PasswordResetUiSpec.scala | 2 +- .../test/scala/uitest/RegisterUiSpec.scala | 1 - version.sbt | 1 + 135 files changed, 2443 insertions(+), 3056 deletions(-) delete mode 100644 activator.properties create mode 100644 backend/src/main/resources/application.conf delete mode 100644 backend/src/main/resources/db/migration/V2__add_indexes.sql delete mode 100644 backend/src/main/resources/reference.conf create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/BaseModule.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/BootzookaConfig.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/Clock.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/Config.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/ConfigModule.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/DependencyWiring.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/Fail.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/HttpAPIModule.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/IdGenerator.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/InitModule.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/LowerCased.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/MainModule.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/Routes.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/ServerConfig.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/bootzooka.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/common/ConfigWithDefault.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/common/FutureHelpers.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/common/Utils.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/common/api/CirceEncoders.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/common/api/RoutesRequestWrapper.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/common/api/RoutesSupport.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/common/api/securityHeaders.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/common/crypto/Argon2dPasswordHashing.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/common/crypto/CryptoConfig.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/common/crypto/PasswordHashing.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/common/crypto/Salt.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/common/sql/DatabaseConfig.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/common/sql/SqlDatabase.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/email/EmailConfig.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModule.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala rename backend/src/main/scala/com/softwaremill/bootzooka/email/{application => }/EmailTemplatingEngine.scala (58%) delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/email/application/DummyEmailService.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/email/application/EmailConfig.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/email/application/EmailService.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/email/application/SmtpEmailService.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/email/domain/EmailContentWithSubject.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/email/sender/DummyEmailSender.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/email/sender/EmailSender.scala rename backend/src/main/scala/com/softwaremill/bootzooka/email/{application => sender}/SmtpEmailSender.scala (77%) create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/CorrelationId.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DBConfig.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Doobie.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Http.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/HttpConfig.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Json.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/LoggingSttpBackend.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/metrics/AppMetrics.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/metrics/MetricsApi.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/metrics/MetricsModule.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetApi.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetConfig.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetModule.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/api/PasswordResetRoutes.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/application/PasswordResetCodeDao.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/application/PasswordResetConfig.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/application/PasswordResetService.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/domain/PasswordResetCode.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyModel.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyService.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/security/SecurityModule.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/security/model.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/sql/H2BrowserConsole.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/sql/H2ShellConsole.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/swagger/SwaggerDocService.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/user/User.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/user/UserApi.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/user/UserModel.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/user/UserModule.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/user/UserService.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/user/api/SessionSupport.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/user/api/UsersRoutes.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/user/application/RefreshTokenStorageImpl.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/user/application/RememberMeTokenDao.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/user/application/Session.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/user/application/UserDao.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/user/application/UserService.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/user/domain/RememberMeToken.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/user/domain/User.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/user/package.scala create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/version/VersionApi.scala delete mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/version/VersionRoutes.scala delete mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/common/api/RoutesRequestWrapperSpec.scala delete mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/common/crypto/Argon2dPasswordHashingSpec.scala create mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/email/EmailTemplatingEngineTest.scala delete mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/email/application/DummyEmailSendingServiceSpec.scala delete mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/email/application/EmailTemplatingEngineSpec.scala create mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/email/sender/DummyEmailSenderTest.scala create mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetApiTest.scala delete mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/api/PasswordResetRoutesSpec.scala delete mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/application/PasswordResetCodeDaoSpec.scala delete mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/application/PasswordResetServiceSpec.scala delete mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/test/BaseRoutesSpec.scala create mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/test/BaseTest.scala delete mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/test/FlatSpecWithDb.scala create mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/test/HttpTestSupport.scala create mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/test/Requests.scala create mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/test/TestClock.scala create mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/test/TestDB.scala create mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/test/TestEmbeddedPostgres.scala delete mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/test/TestHelpers.scala delete mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/test/TestHelpersWithDb.scala create mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/test/package.scala delete mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/user/EncryptPasswordBenchmark.scala create mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/user/UserApiTest.scala rename backend/src/test/scala/com/softwaremill/bootzooka/user/{application => }/UserRegisterValidatorSpec.scala (73%) delete mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/user/api/UsersRoutesSpec.scala delete mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/user/application/UserDaoSpec.scala delete mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/user/application/UserServiceSpec.scala delete mode 100644 backend/src/test/scala/com/softwaremill/bootzooka/user/domain/UserSpec.scala create mode 100644 version.sbt 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"