diff --git a/.travis.yml b/.travis.yml index e60ed11..0011814 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,10 +3,7 @@ install: - pip install --user codecov scala: -- 2.12.6 - -jdk: -- oraclejdk8 +- 2.13.0 cache: directories: diff --git a/README.md b/README.md index a4e97c0..ae05f70 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ For more information on how to this works with other frontends/backends, head ov # Getting started ## You need installed: - * Java 8, + * Java >= 8 <= 11, * sbt. Then: @@ -20,12 +20,11 @@ Then: # Project's structure -Application is divided into three main modules: commons, authentication and core. +Application is divided into three main modules: articles, authentication and users. For simplification they are not represented as sbt's subprojects/submodules. Module is represented as single class with `Components` suffix, for instance `AuthenticationComponents` or `ArticleComponents`. -Core module contains main business logic which is also divided into `articles` and `users` (and other) modules. Class `RealWorldApplicationLoader` contains "description" of whole application, it combines all modules together and set up things like logging, evolutions (db migrations), etc. @@ -33,13 +32,11 @@ Compile time dependency injection is used. # Security -Pack4j is used to simplify JWT authentication implementation. Generally authentication is implemented as external module implementing -core's API's (`authentication.api`). The idea behind it was to allow replacing module's implementation without touching core's code. +Simple - naive - JWT authentication implementation is used. Authentication module exists to separate authentication logic from business logic related to users, like update of user's profile, etc. # Database -Project uses a H2 in memory database by default, it can be changed in the `application.conf`. -Tests override that properties to use H2 nevertheless, H2 is used for convenience and can be changed easily as well in the `TestUtils.config`. +Project uses a H2 in memory database by default, it can be changed in the `application.conf` (and in `TestUtils.config` for tests). Proper JDBC driver needs to be added as dependency in build.sbt. @@ -47,8 +44,7 @@ Additionally to avoid Slick's dependent types all over the place, static imports For instance `import slick.jdbc.H2Profile.api.{DBIO => _, MappedTo => _, Rep => _, TableQuery => _, _}`. They should be changed as well in case of changing underlying database. It looks ugly but imho still better than usage of dynamic import through dependent types (check out Slick examples to see that). -Slick was used to implement data access layer mainly because it is supported by Lightbend. It also looks more "scalish" -and gives perspective for JPA standard in Java ecosystem. +Slick was used to implement data access layer mainly because it is supported by Lightbend. It also looks more "scalish". ====== diff --git a/app/articles/ArticleComponents.scala b/app/articles/ArticleComponents.scala index 1d29531..af900d4 100644 --- a/app/articles/ArticleComponents.scala +++ b/app/articles/ArticleComponents.scala @@ -7,11 +7,11 @@ import articles.controllers.{ArticleController, CommentController, TagController import articles.models.{ArticleMetaModel, CommentId, MainFeedPageRequest, UserFeedPageRequest} import articles.repositories._ import articles.services._ -import authentication.api.AuthenticatedActionBuilder import commons.CommonsComponents import users.UserComponents import play.api.routing.Router import play.api.routing.sird._ +import users.controllers.AuthenticatedActionBuilder trait ArticleComponents extends WithControllerComponents diff --git a/app/articles/controllers/ArticleController.scala b/app/articles/controllers/ArticleController.scala index d435414..e3152d1 100644 --- a/app/articles/controllers/ArticleController.scala +++ b/app/articles/controllers/ArticleController.scala @@ -5,11 +5,11 @@ import commons.services.ActionRunner import articles.exceptions.AuthorMismatchException import articles.models._ import articles.services.{ArticleReadService, ArticleWriteService} -import authentication.api.{AuthenticatedActionBuilder, OptionallyAuthenticatedActionBuilder} import commons.controllers.RealWorldAbstractController import org.apache.commons.lang3.StringUtils import play.api.libs.json._ import play.api.mvc.{Action, AnyContent, ControllerComponents} +import users.controllers.{AuthenticatedActionBuilder, OptionallyAuthenticatedActionBuilder} class ArticleController(authenticatedAction: AuthenticatedActionBuilder, optionallyAuthenticatedActionBuilder: OptionallyAuthenticatedActionBuilder, @@ -22,8 +22,8 @@ class ArticleController(authenticatedAction: AuthenticatedActionBuilder, def unfavorite(slug: String): Action[AnyContent] = authenticatedAction.async { request => require(slug != null) - val currentUserEmail = request.user.email - actionRunner.runTransactionally(articleWriteService.unfavorite(slug, currentUserEmail)) + val userId = request.user.userId + actionRunner.runTransactionally(articleWriteService.unfavorite(slug, userId)) .map(ArticleWrapper(_)) .map(Json.toJson(_)) .map(Ok(_)) @@ -35,8 +35,8 @@ class ArticleController(authenticatedAction: AuthenticatedActionBuilder, def favorite(slug: String): Action[AnyContent] = authenticatedAction.async { request => require(slug != null) - val currentUserEmail = request.user.email - actionRunner.runTransactionally(articleWriteService.favorite(slug, currentUserEmail)) + val userId = request.user.userId + actionRunner.runTransactionally(articleWriteService.favorite(slug, userId)) .map(ArticleWrapper(_)) .map(Json.toJson(_)) .map(Ok(_)) @@ -48,8 +48,8 @@ class ArticleController(authenticatedAction: AuthenticatedActionBuilder, def findBySlug(slug: String): Action[AnyContent] = optionallyAuthenticatedActionBuilder.async { request => require(StringUtils.isNotBlank(slug)) - val maybeCurrentUserEmail = request.authenticatedUserOption.map(_.email) - actionRunner.runTransactionally(articleReadService.findBySlug(slug, maybeCurrentUserEmail)) + val maybeUserId = request.authenticatedUserOption.map(_.userId) + actionRunner.runTransactionally(articleReadService.findBySlug(slug, maybeUserId)) .map(ArticleWrapper(_)) .map(Json.toJson(_)) .map(Ok(_)) @@ -61,8 +61,8 @@ class ArticleController(authenticatedAction: AuthenticatedActionBuilder, def findAll(pageRequest: MainFeedPageRequest): Action[AnyContent] = optionallyAuthenticatedActionBuilder.async { request => require(pageRequest != null) - val currentUserEmail = request.authenticatedUserOption.map(_.email) - actionRunner.runTransactionally(articleReadService.findAll(pageRequest, currentUserEmail)) + val maybeUserId = request.authenticatedUserOption.map(_.userId) + actionRunner.runTransactionally(articleReadService.findAll(pageRequest, maybeUserId)) .map(page => ArticlePage(page.models, page.count)) .map(Json.toJson(_)) .map(Ok(_)) @@ -71,8 +71,8 @@ class ArticleController(authenticatedAction: AuthenticatedActionBuilder, def findFeed(pageRequest: UserFeedPageRequest): Action[AnyContent] = authenticatedAction.async { request => require(pageRequest != null) - val currentUserEmail = request.user.email - actionRunner.runTransactionally(articleReadService.findFeed(pageRequest, currentUserEmail)) + val userId = request.user.userId + actionRunner.runTransactionally(articleReadService.findFeed(pageRequest, userId)) .map(page => ArticlePage(page.models, page.count)) .map(Json.toJson(_)) .map(Ok(_)) @@ -80,8 +80,8 @@ class ArticleController(authenticatedAction: AuthenticatedActionBuilder, def create: Action[NewArticleWrapper] = authenticatedAction.async(validateJson[NewArticleWrapper]) { request => val article = request.body.article - val currentUserEmail = request.user.email - actionRunner.runTransactionally(articleWriteService.create(article, currentUserEmail)) + val userId = request.user.userId + actionRunner.runTransactionally(articleWriteService.create(article, userId)) .map(ArticleWrapper(_)) .map(Json.toJson(_)) .map(Ok(_)) @@ -91,8 +91,8 @@ class ArticleController(authenticatedAction: AuthenticatedActionBuilder, def update(slug: String): Action[ArticleUpdateWrapper] = { authenticatedAction.async(validateJson[ArticleUpdateWrapper]) { request => val articleUpdate = request.body.article - val currentUserEmail = request.user.email - actionRunner.runTransactionally(articleWriteService.update(slug, articleUpdate, currentUserEmail)) + val userId = request.user.userId + actionRunner.runTransactionally(articleWriteService.update(slug, articleUpdate, userId)) .map(ArticleWrapper(_)) .map(Json.toJson(_)) .map(Ok(_)) @@ -105,8 +105,8 @@ class ArticleController(authenticatedAction: AuthenticatedActionBuilder, def delete(slug: String): Action[AnyContent] = authenticatedAction.async { request => require(slug != null) - val currentUserEmail = request.user.email - actionRunner.runTransactionally(articleWriteService.delete(slug, currentUserEmail)) + val userId = request.user.userId + actionRunner.runTransactionally(articleWriteService.delete(slug, userId)) .map(_ => Ok) .recover({ case _: AuthorMismatchException => Forbidden diff --git a/app/articles/controllers/CommentController.scala b/app/articles/controllers/CommentController.scala index 7923ce7..00a8504 100644 --- a/app/articles/controllers/CommentController.scala +++ b/app/articles/controllers/CommentController.scala @@ -5,11 +5,11 @@ import commons.services.ActionRunner import articles.exceptions.AuthorMismatchException import articles.models._ import articles.services.CommentService -import authentication.api.{AuthenticatedActionBuilder, OptionallyAuthenticatedActionBuilder} import commons.controllers.RealWorldAbstractController import org.apache.commons.lang3.StringUtils import play.api.libs.json._ import play.api.mvc.{Action, AnyContent, ControllerComponents} +import users.controllers.{AuthenticatedActionBuilder, OptionallyAuthenticatedActionBuilder} class CommentController(authenticatedAction: AuthenticatedActionBuilder, optionallyAuthenticatedActionBuilder: OptionallyAuthenticatedActionBuilder, @@ -20,7 +20,7 @@ class CommentController(authenticatedAction: AuthenticatedActionBuilder, def delete(id: CommentId): Action[AnyContent] = authenticatedAction.async { request => - actionRunner.runTransactionally(commentService.delete(id, request.user.email)) + actionRunner.runTransactionally(commentService.delete(id, request.user.userId)) .map(_ => Ok) .recover({ case _: AuthorMismatchException => Forbidden @@ -31,8 +31,8 @@ class CommentController(authenticatedAction: AuthenticatedActionBuilder, def findByArticleSlug(slug: String): Action[AnyContent] = optionallyAuthenticatedActionBuilder.async { request => require(StringUtils.isNotBlank(slug)) - val maybeCurrentUserEmail = request.authenticatedUserOption.map(_.email) - actionRunner.runTransactionally(commentService.findByArticleSlug(slug, maybeCurrentUserEmail)) + val maybeUserId = request.authenticatedUserOption.map(_.userId) + actionRunner.runTransactionally(commentService.findByArticleSlug(slug, maybeUserId)) .map(CommentList(_)) .map(Json.toJson(_)) .map(Ok(_)) @@ -45,9 +45,9 @@ class CommentController(authenticatedAction: AuthenticatedActionBuilder, require(StringUtils.isNotBlank(slug)) val newComment = request.body.comment - val email = request.user.email + val userId = request.user.userId - actionRunner.runTransactionally(commentService.create(newComment, slug, email) + actionRunner.runTransactionally(commentService.create(newComment, slug, userId) .map(CommentWrapper(_)) .map(Json.toJson(_)) .map(Ok(_))) diff --git a/app/articles/models/ArticleTagAssociation.scala b/app/articles/models/ArticleTagAssociation.scala index 2515ede..d31631e 100644 --- a/app/articles/models/ArticleTagAssociation.scala +++ b/app/articles/models/ArticleTagAssociation.scala @@ -10,7 +10,7 @@ case class ArticleTagAssociation(id: ArticleTagAssociationId, tagId: TagId) extends WithId[Long, ArticleTagAssociationId] object ArticleTagAssociation { - def from(article: Article, tag: Tag): ArticleTagAssociation = + def from(article: Article, tag: articles.models.Tag): ArticleTagAssociation = ArticleTagAssociation(ArticleTagAssociationId(-1), article.id, tag.id) } diff --git a/app/articles/repositories/ArticleWithTagsRepo.scala b/app/articles/repositories/ArticleWithTagsRepo.scala index e5619be..eea0bb2 100644 --- a/app/articles/repositories/ArticleWithTagsRepo.scala +++ b/app/articles/repositories/ArticleWithTagsRepo.scala @@ -1,10 +1,10 @@ package articles.repositories -import commons.models.{Email, Page} import articles.models._ +import commons.models.Page +import slick.dbio.DBIO import users.models.{Profile, User, UserId} import users.repositories.{ProfileRepo, UserRepo} -import slick.dbio.DBIO import scala.concurrent.ExecutionContext @@ -16,32 +16,30 @@ class ArticleWithTagsRepo(articleRepo: ArticleRepo, profileRepo: ProfileRepo, implicit private val ex: ExecutionContext) { - def findBySlug(slug: String, maybeUserEmail: Option[Email]): DBIO[ArticleWithTags] = { - require(slug != null && maybeUserEmail != null) + def findBySlug(slug: String, maybeUserId: Option[UserId]): DBIO[ArticleWithTags] = { + require(slug != null && maybeUserId != null) articleRepo.findBySlug(slug) - .flatMap(article => getArticleWithTags(article, maybeUserEmail)) + .flatMap(article => getArticleWithTags(article, maybeUserId)) } - private def getArticleWithTags(article: Article, maybeCurrentUserEmail: Option[Email]): DBIO[ArticleWithTags] = { + private def getArticleWithTags(article: Article, maybeUserId: Option[UserId]): DBIO[ArticleWithTags] = { for { tags <- articleTagRepo.findTagsByArticleId(article.id) - profile <- profileRepo.findByUserId(article.authorId, maybeCurrentUserEmail) - favorited <- isFavorited(article.id, maybeCurrentUserEmail) + profile <- profileRepo.findByUserId(article.authorId, maybeUserId) + favorited <- isFavorited(article.id, maybeUserId) favoritesCount <- getFavoritesCount(article.id) } yield ArticleWithTags(article, tags, profile, favorited, favoritesCount) } - private def isFavorited(articleId: ArticleId, maybeEmail: Option[Email]): DBIO[Boolean] = { - maybeEmail.map(email => isFavorited(articleId, email)) + private def isFavorited(articleId: ArticleId, maybeUserId: Option[UserId]): DBIO[Boolean] = { + maybeUserId.map(userId => isFavorited(articleId, userId)) .getOrElse(DBIO.successful(false)) } - private def isFavorited(articleId: ArticleId, email: Email) = { - for { - user <- userRepo.findByEmail(email) - maybeFavoriteAssociation <- favoriteAssociationRepo.findByUserAndArticle(user.id, articleId) - } yield maybeFavoriteAssociation.isDefined + private def isFavorited(articleId: ArticleId, userId: UserId) = { + favoriteAssociationRepo.findByUserAndArticle(userId, articleId) + .map(maybeFavoriteAssociation => maybeFavoriteAssociation.isDefined) } private def getFavoritesCount(articleId: ArticleId) = { @@ -50,50 +48,49 @@ class ArticleWithTagsRepo(articleRepo: ArticleRepo, .map(_.map(_._2).getOrElse(0)) } - def getArticleWithTags(article: Article, currentUserEmail: Email): DBIO[ArticleWithTags] = { - getArticleWithTags(article, Some(currentUserEmail)) + def getArticleWithTags(article: Article, userId: UserId): DBIO[ArticleWithTags] = { + getArticleWithTags(article, Some(userId)) } - def getArticleWithTags(article: Article, tags: Seq[Tag], currentUserEmail: Email): DBIO[ArticleWithTags] = { + def getArticleWithTags(article: Article, tags: Seq[Tag], userId: UserId): DBIO[ArticleWithTags] = { userRepo.findById(article.authorId) - .flatMap(author => getArticleWithTags(article, author, tags, Some(currentUserEmail))) + .flatMap(author => getArticleWithTags(article, author, tags, Some(userId))) } private def getArticleWithTags(article: Article, author: User, tags: Seq[Tag], - maybeCurrentUserEmail: Option[Email]): DBIO[ArticleWithTags] = { + maybeUserId: Option[UserId]): DBIO[ArticleWithTags] = { for { - profile <- profileRepo.findByUserId(author.id, maybeCurrentUserEmail) - favorited <- isFavorited(article.id, maybeCurrentUserEmail) + profile <- profileRepo.findByUserId(author.id, maybeUserId) + favorited <- isFavorited(article.id, maybeUserId) favoritesCount <- getFavoritesCount(article.id) } yield ArticleWithTags(article, tags, profile, favorited, favoritesCount) } - def findFeed(pageRequest: UserFeedPageRequest, currentUserEmail: Email): DBIO[Page[ArticleWithTags]] = { - require(pageRequest != null && currentUserEmail != null) + def findFeed(pageRequest: UserFeedPageRequest, userId: UserId): DBIO[Page[ArticleWithTags]] = { + require(pageRequest != null && userId != null) - getArticlesPage(pageRequest, currentUserEmail) - .flatMap(articlesPage => getArticlesWithTagsPage(articlesPage, Some(currentUserEmail))) + getArticlesPage(pageRequest, userId) + .flatMap(articlesPage => getArticlesWithTagsPage(articlesPage, Some(userId))) } - private def getArticlesPage(pageRequest: UserFeedPageRequest, currentUserEmail: Email) = { - userRepo.findByEmail(currentUserEmail) - .flatMap(currentUser => articleRepo.findByUserFeedPageRequest(pageRequest, currentUser.id)) + private def getArticlesPage(pageRequest: UserFeedPageRequest, userId: UserId) = { + articleRepo.findByUserFeedPageRequest(pageRequest, userId) } - def findAll(pageRequest: MainFeedPageRequest, maybeCurrentUserEmail: Option[Email]): DBIO[Page[ArticleWithTags]] = { + def findAll(pageRequest: MainFeedPageRequest, maybeUserId: Option[UserId]): DBIO[Page[ArticleWithTags]] = { require(pageRequest != null) articleRepo.findByMainFeedPageRequest(pageRequest) - .flatMap(articlesPage => getArticlesWithTagsPage(articlesPage, maybeCurrentUserEmail)) + .flatMap(articlesPage => getArticlesWithTagsPage(articlesPage, maybeUserId)) } private def getArticlesWithTagsPage(articlesPage: Page[Article], - maybeCurrentUserEmail: Option[Email]) = { + maybeUserId: Option[UserId]) = { val Page(articles, count) = articlesPage for { - profileByUserId <- getProfileByUserId(articles, maybeCurrentUserEmail) + profileByUserId <- getProfileByUserId(articles, maybeUserId) tagsByArticleId <- getGroupedTagsByArticleId(articles) - favoritedArticleIds <- getFavoritedArticleIds(articles, maybeCurrentUserEmail) + favoritedArticleIds <- getFavoritedArticleIds(articles, maybeUserId) favoritesCountByArticleId <- getFavoritesCount(articles) } yield { val articlesWithTags = createArticlesWithTags(articles, profileByUserId, tagsByArticleId, favoritedArticleIds, @@ -103,18 +100,18 @@ class ArticleWithTagsRepo(articleRepo: ArticleRepo, } } - private def getProfileByUserId(articles: Seq[Article], maybeCurrentUserEmail: Option[Email]) = { + private def getProfileByUserId(articles: Seq[Article], maybeUserId: Option[UserId]) = { val authorIds = articles.map(_.authorId) - profileRepo.getProfileByUserId(authorIds, maybeCurrentUserEmail) + profileRepo.getProfileByUserId(authorIds, maybeUserId) } - private def getFavoritedArticleIds(articles: Seq[Article], maybeFollowerEmail: Option[Email]): DBIO[Set[ArticleId]] = { - maybeFollowerEmail.map(email => getFavoritedArticleIds(articles, email)) + private def getFavoritedArticleIds(articles: Seq[Article], maybeFollowerId: Option[UserId]): DBIO[Set[ArticleId]] = { + maybeFollowerId.map(id => getFavoritedArticleIds(articles, id)) .getOrElse(DBIO.successful(Set.empty)) } - private def getFavoritedArticleIds(articles: Seq[Article], followerEmail: Email): DBIO[Set[ArticleId]] = { - userRepo.findByEmail(followerEmail) + private def getFavoritedArticleIds(articles: Seq[Article], followerId: UserId): DBIO[Set[ArticleId]] = { + userRepo.findById(followerId) .flatMap(follower => getFavoritedArticleIds(articles, follower)) } diff --git a/app/articles/repositories/CommentWithAuthorRepo.scala b/app/articles/repositories/CommentWithAuthorRepo.scala index 0b2fc8c..1724998 100644 --- a/app/articles/repositories/CommentWithAuthorRepo.scala +++ b/app/articles/repositories/CommentWithAuthorRepo.scala @@ -1,11 +1,10 @@ package articles.repositories -import commons.models._ import articles.models._ +import slick.dbio.DBIO import users.models.{Profile, UserId} import users.repositories.ProfileRepo -import slick.dbio.DBIO import scala.concurrent.ExecutionContext @@ -14,26 +13,26 @@ class CommentWithAuthorRepo(articleRepo: ArticleRepo, profileRepo: ProfileRepo, implicit private val ex: ExecutionContext) { - def findByArticleSlug(slug: String, maybeCurrentUserEmail: Option[Email]): DBIO[Seq[CommentWithAuthor]] = { - require(slug != null && maybeCurrentUserEmail != null) + def findByArticleSlug(slug: String, maybeUserId: Option[UserId]): DBIO[Seq[CommentWithAuthor]] = { + require(slug != null && maybeUserId != null) for { article <- articleRepo.findBySlug(slug) comments <- commentRepo.findByArticleId(article.id) - commentsWithAuthors <- getCommentsWithAuthors(comments, maybeCurrentUserEmail) + commentsWithAuthors <- getCommentsWithAuthors(comments, maybeUserId) } yield commentsWithAuthors } - def getCommentWithAuthor(comment: Comment, currentUserEmail: Email): DBIO[CommentWithAuthor] = { - require(comment != null && currentUserEmail != null) + def getCommentWithAuthor(comment: Comment, userId: UserId): DBIO[CommentWithAuthor] = { + require(comment != null && userId != null) - getCommentsWithAuthors(Seq(comment), Some(currentUserEmail)) + getCommentsWithAuthors(Seq(comment), Some(userId)) .map(_.head) } - private def getCommentsWithAuthors(comments: Seq[Comment], maybeCurrentUserEmail: Option[Email]) = { + private def getCommentsWithAuthors(comments: Seq[Comment], maybeUserId: Option[UserId]) = { val authorIds = comments.map(_.authorId) - profileRepo.getProfileByUserId(authorIds, maybeCurrentUserEmail) + profileRepo.getProfileByUserId(authorIds, maybeUserId) .map(profileByUserId => { comments.map(comment => getCommentWithAuthor(profileByUserId, comment)) }) diff --git a/app/articles/services/ArticleCreateUpdateService.scala b/app/articles/services/ArticleCreateUpdateService.scala index 7f5d5a0..d39f9d1 100644 --- a/app/articles/services/ArticleCreateUpdateService.scala +++ b/app/articles/services/ArticleCreateUpdateService.scala @@ -1,13 +1,13 @@ package articles.services +import articles.models._ +import articles.repositories.{ArticleRepo, ArticleTagAssociationRepo, ArticleWithTagsRepo, TagRepo} import commons.exceptions.ValidationException -import commons.models.Email import commons.repositories.DateTimeProvider import commons.utils.DbioUtils -import articles.models._ -import articles.repositories.{ArticleRepo, ArticleTagAssociationRepo, ArticleWithTagsRepo, TagRepo} -import users.repositories.UserRepo import slick.dbio.DBIO +import users.models.UserId +import users.repositories.UserRepo import scala.concurrent.ExecutionContext @@ -22,14 +22,14 @@ trait ArticleCreateUpdateService { private val articleValidator = new ArticleValidator private val slugifier = new Slugifier() - def create(newArticle: NewArticle, currentUserEmail: Email): DBIO[ArticleWithTags] = { - require(newArticle != null && currentUserEmail != null) + def create(newArticle: NewArticle, userId: UserId): DBIO[ArticleWithTags] = { + require(newArticle != null && userId != null) for { _ <- validate(newArticle) - article <- createArticle(newArticle, currentUserEmail) + article <- createArticle(newArticle, userId) tags <- handleTags(newArticle.tagList, article) - articleWithTag <- articleWithTagsRepo.getArticleWithTags(article, tags, currentUserEmail) + articleWithTag <- articleWithTagsRepo.getArticleWithTags(article, tags, userId) } yield articleWithTag } @@ -38,13 +38,10 @@ trait ArticleCreateUpdateService { .flatMap(violations => DbioUtils.fail(violations.isEmpty, new ValidationException(violations))) } - private def createArticle(newArticle: NewArticle, currentUserEmail: Email) = { - for { - user <- userRepo.findByEmail(currentUserEmail) - slug = slugifier.slugify(newArticle.title) - article = newArticle.toArticle(slug, user.id, dateTimeProvider) - persistedArticle <- articleRepo.insertAndGet(article) - } yield persistedArticle + private def createArticle(newArticle: NewArticle, userId: UserId) = { + val slug = slugifier.slugify(newArticle.title) + val article = newArticle.toArticle(slug, userId, dateTimeProvider) + articleRepo.insertAndGet(article) } private def handleTags(tagNames: Seq[String], article: Article) = { @@ -56,13 +53,13 @@ trait ArticleCreateUpdateService { } yield tags } - private def associateTagsWithArticle(tags: Seq[Tag], article: Article) = { + private def associateTagsWithArticle(tags: Seq[articles.models.Tag], article: Article) = { val articleTags = tags.map(tag => ArticleTagAssociation.from(article, tag)) articleTagAssociationRepo.insertAndGet(articleTags) } - private def createTagsIfNotExist(tagNames: Seq[String], existingTags: Seq[Tag]) = { + private def createTagsIfNotExist(tagNames: Seq[String], existingTags: Seq[articles.models.Tag]) = { val existingTagNames = existingTags.map(_.name).toSet val newTagNames = tagNames.toSet -- existingTagNames val newTags = newTagNames.map(Tag.from) @@ -70,13 +67,13 @@ trait ArticleCreateUpdateService { tagRepo.insertAndGet(newTags) } - def update(slug: String, articleUpdate: ArticleUpdate, currentUserEmail: Email): DBIO[ArticleWithTags] = { - require(slug != null && articleUpdate != null && currentUserEmail != null) + def update(slug: String, articleUpdate: ArticleUpdate, userId: UserId): DBIO[ArticleWithTags] = { + require(slug != null && articleUpdate != null && userId != null) for { _ <- validate(articleUpdate) updatedArticle <- doUpdate(slug, articleUpdate) - articleWithTags <- articleWithTagsRepo.getArticleWithTags(updatedArticle, currentUserEmail) + articleWithTags <- articleWithTagsRepo.getArticleWithTags(updatedArticle, userId) } yield articleWithTags } diff --git a/app/articles/services/ArticleDeleteService.scala b/app/articles/services/ArticleDeleteService.scala index 6dd4e3b..5881069 100644 --- a/app/articles/services/ArticleDeleteService.scala +++ b/app/articles/services/ArticleDeleteService.scala @@ -1,11 +1,11 @@ package articles.services -import commons.models.Email import articles.exceptions.AuthorMismatchException import articles.models.Article import articles.repositories._ -import users.repositories.UserRepo import slick.dbio.DBIO +import users.models.UserId +import users.repositories.UserRepo import scala.concurrent.ExecutionContext @@ -19,12 +19,12 @@ protected trait ArticleDeleteService { implicit protected val ex: ExecutionContext - def delete(slug: String, currentUserEmail: Email): DBIO[Unit] = { - require(slug != null && currentUserEmail != null) + def delete(slug: String, userId: UserId): DBIO[Unit] = { + require(slug != null && userId != null) for { article <- articleRepo.findBySlug(slug) - _ <- validate(currentUserEmail, article) + _ <- validate(userId, article) _ <- deleteComments(article) _ <- deleteArticleTags(article) _ <- deleteFavoriteAssociations(article) @@ -32,8 +32,8 @@ protected trait ArticleDeleteService { } yield () } - private def validate(currentUserEmail: Email, article: Article) = { - userRepo.findByEmail(currentUserEmail).map(currentUser => { + private def validate(userId: UserId, article: Article) = { + userRepo.findById(userId).map(currentUser => { if (article.authorId == currentUser.id) DBIO.successful(()) else DBIO.failed(new AuthorMismatchException(currentUser.id, article.authorId)) }) diff --git a/app/articles/services/ArticleFavoriteService.scala b/app/articles/services/ArticleFavoriteService.scala index b2163be..7e9ee93 100644 --- a/app/articles/services/ArticleFavoriteService.scala +++ b/app/articles/services/ArticleFavoriteService.scala @@ -1,11 +1,10 @@ package articles.services -import commons.models.Email import articles.models.{Article, ArticleWithTags, FavoriteAssociation, FavoriteAssociationId} import articles.repositories.{ArticleRepo, ArticleWithTagsRepo, FavoriteAssociationRepo} -import users.models.User -import users.repositories.UserRepo import slick.dbio.DBIO +import users.models.{User, UserId} +import users.repositories.UserRepo import scala.concurrent.ExecutionContext @@ -17,35 +16,33 @@ trait ArticleFavoriteService { implicit protected val ex: ExecutionContext - def favorite(slug: String, currentUserEmail: Email): DBIO[ArticleWithTags] = { - require(slug != null && currentUserEmail != null) + def favorite(slug: String, userId: UserId): DBIO[ArticleWithTags] = { + require(slug != null && userId != null) for { - user <- userRepo.findByEmail(currentUserEmail) article <- articleRepo.findBySlug(slug) - _ <- createFavoriteAssociation(user, article) - articleWithTags <- articleWithTagsRepo.getArticleWithTags(article, currentUserEmail) + _ <- createFavoriteAssociation(userId, article) + articleWithTags <- articleWithTagsRepo.getArticleWithTags(article, userId) } yield articleWithTags } - private def createFavoriteAssociation(user: User, article: Article) = { - val favoriteAssociation = FavoriteAssociation(FavoriteAssociationId(-1), user.id, article.id) + private def createFavoriteAssociation(userId: UserId, article: Article) = { + val favoriteAssociation = FavoriteAssociation(FavoriteAssociationId(-1), userId, article.id) favoriteAssociationRepo.insert(favoriteAssociation) } - def unfavorite(slug: String, currentUserEmail: Email): DBIO[ArticleWithTags] = { - require(slug != null && currentUserEmail != null) + def unfavorite(slug: String, userId: UserId): DBIO[ArticleWithTags] = { + require(slug != null && userId != null) for { - user <- userRepo.findByEmail(currentUserEmail) article <- articleRepo.findBySlug(slug) - _ <- deleteFavoriteAssociation(user, article) - articleWithTags <- articleWithTagsRepo.getArticleWithTags(article, currentUserEmail) + _ <- deleteFavoriteAssociation(userId, article) + articleWithTags <- articleWithTagsRepo.getArticleWithTags(article, userId) } yield articleWithTags } - private def deleteFavoriteAssociation(user: User, article: Article) = { - favoriteAssociationRepo.findByUserAndArticle(user.id, article.id) + private def deleteFavoriteAssociation(userId: UserId, article: Article) = { + favoriteAssociationRepo.findByUserAndArticle(userId, article.id) .flatMap(_.map(favoriteAssociation => favoriteAssociationRepo.delete(favoriteAssociation.id)) .getOrElse(DBIO.successful(()))) } diff --git a/app/articles/services/ArticleReadService.scala b/app/articles/services/ArticleReadService.scala index 6688d51..dff711e 100644 --- a/app/articles/services/ArticleReadService.scala +++ b/app/articles/services/ArticleReadService.scala @@ -1,28 +1,29 @@ package articles.services -import commons.models.{Email, Page} import articles.models.{ArticleWithTags, MainFeedPageRequest, UserFeedPageRequest} import articles.repositories.ArticleWithTagsRepo +import commons.models.Page import slick.dbio.DBIO +import users.models.UserId class ArticleReadService(articleWithTagsRepo: ArticleWithTagsRepo) { - def findBySlug(slug: String, maybeCurrentUserEmail: Option[Email]): DBIO[ArticleWithTags] = { - require(slug != null && maybeCurrentUserEmail != null) + def findBySlug(slug: String, maybeUserId: Option[UserId]): DBIO[ArticleWithTags] = { + require(slug != null && maybeUserId != null) - articleWithTagsRepo.findBySlug(slug, maybeCurrentUserEmail) + articleWithTagsRepo.findBySlug(slug, maybeUserId) } - def findAll(pageRequest: MainFeedPageRequest, maybeCurrentUserEmail: Option[Email]): DBIO[Page[ArticleWithTags]] = { - require(pageRequest != null && maybeCurrentUserEmail != null) + def findAll(pageRequest: MainFeedPageRequest, maybeUserId: Option[UserId]): DBIO[Page[ArticleWithTags]] = { + require(pageRequest != null && maybeUserId != null) - articleWithTagsRepo.findAll(pageRequest, maybeCurrentUserEmail) + articleWithTagsRepo.findAll(pageRequest, maybeUserId) } - def findFeed(pageRequest: UserFeedPageRequest, currentUserEmail: Email): DBIO[Page[ArticleWithTags]] = { - require(pageRequest != null && currentUserEmail != null) + def findFeed(pageRequest: UserFeedPageRequest, userId: UserId): DBIO[Page[ArticleWithTags]] = { + require(pageRequest != null && userId != null) - articleWithTagsRepo.findFeed(pageRequest, currentUserEmail) + articleWithTagsRepo.findFeed(pageRequest, userId) } } \ No newline at end of file diff --git a/app/articles/services/CommentService.scala b/app/articles/services/CommentService.scala index 65cfa14..6b2b0d9 100644 --- a/app/articles/services/CommentService.scala +++ b/app/articles/services/CommentService.scala @@ -1,12 +1,12 @@ package articles.services -import commons.models.Email -import commons.repositories.DateTimeProvider import articles.exceptions.AuthorMismatchException import articles.models._ import articles.repositories._ -import users.repositories.UserRepo +import commons.repositories.DateTimeProvider import slick.dbio.DBIO +import users.models.UserId +import users.repositories.UserRepo import scala.concurrent.ExecutionContext @@ -17,46 +17,44 @@ class CommentService(articleRepo: ArticleRepo, commentWithAuthorRepo: CommentWithAuthorRepo, implicit private val ex: ExecutionContext) { - def delete(id: CommentId, email: Email): DBIO[Unit] = { - require(email != null) + def delete(id: CommentId, userId: UserId): DBIO[Unit] = { + require(userId != null) for { - _ <- validateAuthor(id, email) + _ <- validateAuthor(id, userId) _ <- commentRepo.delete(id) } yield () } - private def validateAuthor(id: CommentId, email: Email) = { + private def validateAuthor(id: CommentId, userId: UserId) = { for { - user <- userRepo.findByEmail(email) comment <- commentRepo.findById(id) _ <- - if (user.id == comment.authorId) DBIO.successful(()) - else DBIO.failed(new AuthorMismatchException(user.id, comment.id)) + if (userId == comment.authorId) DBIO.successful(()) + else DBIO.failed(new AuthorMismatchException(userId, comment.id)) } yield () } - def findByArticleSlug(slug: String, maybeCurrentUserEmail: Option[Email]): DBIO[Seq[CommentWithAuthor]] = { - require(slug != null && maybeCurrentUserEmail != null) + def findByArticleSlug(slug: String, maybeUserId: Option[UserId]): DBIO[Seq[CommentWithAuthor]] = { + require(slug != null && maybeUserId != null) - commentWithAuthorRepo.findByArticleSlug(slug, maybeCurrentUserEmail) + commentWithAuthorRepo.findByArticleSlug(slug, maybeUserId) } - def create(newComment: NewComment, slug: String, currentUserEmail: Email): DBIO[CommentWithAuthor] = { - require(newComment != null && slug != null && currentUserEmail != null) + def create(newComment: NewComment, slug: String, userId: UserId): DBIO[CommentWithAuthor] = { + require(newComment != null && slug != null && userId != null) for { - comment <- doCreate(newComment, slug, currentUserEmail) - commentWithAuthor <- commentWithAuthorRepo.getCommentWithAuthor(comment, currentUserEmail) + comment <- doCreate(newComment, slug, userId) + commentWithAuthor <- commentWithAuthorRepo.getCommentWithAuthor(comment, userId) } yield commentWithAuthor } - private def doCreate(newComment: NewComment, slug: String, currentUserEmail: Email) = { + private def doCreate(newComment: NewComment, slug: String, userId: UserId) = { for { article <- articleRepo.findBySlug(slug) - currentUser <- userRepo.findByEmail(currentUserEmail) now = dateTimeProvider.now - comment = Comment(CommentId(-1), article.id, currentUser.id, newComment.body, now, now) + comment = Comment(CommentId(-1), article.id, userId, newComment.body, now, now) savedComment <- commentRepo.insertAndGet(comment) } yield savedComment } diff --git a/app/authentication/AuthenticationComponents.scala b/app/authentication/AuthenticationComponents.scala index 52240aa..282ae67 100644 --- a/app/authentication/AuthenticationComponents.scala +++ b/app/authentication/AuthenticationComponents.scala @@ -2,19 +2,22 @@ package authentication import com.softwaremill.macwire.wire import commons.config.{WithControllerComponents, WithExecutionContextComponents} -import authentication.api.{SecurityUserCreator, SecurityUserProvider, SecurityUserUpdater} -import authentication.pac4j.Pac4jComponents +import authentication.api.{Authenticator, SecurityUserCreator, SecurityUserProvider, SecurityUserUpdater} +import authentication.jwt.JwtAuthComponents +import authentication.models.CredentialsWrapper import authentication.repositories.SecurityUserRepo -import authentication.services.SecurityUserService +import authentication.services.{SecurityUserService, UsernameAndPasswordAuthenticator} import commons.CommonsComponents trait AuthenticationComponents extends CommonsComponents with WithControllerComponents with WithExecutionContextComponents - with Pac4jComponents { + with JwtAuthComponents { lazy val securityUserCreator: SecurityUserCreator = wire[SecurityUserService] lazy val securityUserProvider: SecurityUserProvider = wire[SecurityUserService] lazy val securityUserUpdater: SecurityUserUpdater = wire[SecurityUserService] lazy val securityUserRepo: SecurityUserRepo = wire[SecurityUserRepo] + + lazy val usernamePasswordAuthenticator: Authenticator[CredentialsWrapper] = wire[UsernameAndPasswordAuthenticator] } diff --git a/app/authentication/api/SecurityUserProvider.scala b/app/authentication/api/SecurityUserProvider.scala index 35298ae..b0c5097 100644 --- a/app/authentication/api/SecurityUserProvider.scala +++ b/app/authentication/api/SecurityUserProvider.scala @@ -1,11 +1,13 @@ package authentication.api -import authentication.models.SecurityUser +import authentication.models.{SecurityUser, SecurityUserId} import commons.models.Email import slick.dbio.DBIO trait SecurityUserProvider { + def findById(securityUserId: SecurityUserId): DBIO[SecurityUser] + def findByEmailOption(email: Email): DBIO[Option[SecurityUser]] def findByEmail(email: Email): DBIO[SecurityUser] diff --git a/app/authentication/api/SecurityUserUpdater.scala b/app/authentication/api/SecurityUserUpdater.scala index 9b58a5f..ed60744 100644 --- a/app/authentication/api/SecurityUserUpdater.scala +++ b/app/authentication/api/SecurityUserUpdater.scala @@ -1,6 +1,6 @@ package authentication.api -import authentication.models.{PlainTextPassword, SecurityUser} +import authentication.models.{PlainTextPassword, SecurityUser, SecurityUserId} import commons.models.Email import slick.dbio.DBIO @@ -8,6 +8,6 @@ case class SecurityUserUpdate(email: Option[Email], password: Option[PlainTextPa trait SecurityUserUpdater { - def update(currentEmail: Email, securityUserUpdate: SecurityUserUpdate): DBIO[SecurityUser] + def update(securityUserId: SecurityUserId, securityUserUpdate: SecurityUserUpdate): DBIO[SecurityUser] } \ No newline at end of file diff --git a/app/authentication/exceptions/ExceptionWithCode.scala b/app/authentication/exceptions/ExceptionWithCode.scala index 0ee6694..72c6201 100644 --- a/app/authentication/exceptions/ExceptionWithCode.scala +++ b/app/authentication/exceptions/ExceptionWithCode.scala @@ -1,4 +1,4 @@ package authentication.exceptions -private[authentication] class ExceptionWithCode(val exceptionCode: AuthenticationExceptionCode) +class ExceptionWithCode(val exceptionCode: AuthenticationExceptionCode) extends RuntimeException(exceptionCode.toString) \ No newline at end of file diff --git a/app/authentication/jwt/JwtAuthComponents.scala b/app/authentication/jwt/JwtAuthComponents.scala new file mode 100644 index 0000000..4ea77a9 --- /dev/null +++ b/app/authentication/jwt/JwtAuthComponents.scala @@ -0,0 +1,24 @@ +package authentication.jwt + +import authentication.api._ +import authentication.jwt.services.{JwtAuthenticator, JwtTokenGenerator, SecretProvider} +import authentication.models.{IdProfile, JwtToken} +import com.softwaremill.macwire.wire +import commons.CommonsComponents +import commons.config.WithExecutionContextComponents +import play.api.Configuration +import play.api.mvc.PlayBodyParsers + +private[authentication] trait JwtAuthComponents extends WithExecutionContextComponents with CommonsComponents { + + def configuration: Configuration + + def playBodyParsers: PlayBodyParsers + + def securityUserProvider: SecurityUserProvider + + private lazy val secretProvider = new SecretProvider(configuration) + lazy val jwtAuthenticator: JwtAuthenticator = wire[JwtAuthenticator] + + lazy val jwtTokenGenerator: TokenGenerator[IdProfile, JwtToken] = wire[JwtTokenGenerator] +} \ No newline at end of file diff --git a/app/authentication/jwt/services/JwtAuthenticator.scala b/app/authentication/jwt/services/JwtAuthenticator.scala new file mode 100644 index 0000000..9356423 --- /dev/null +++ b/app/authentication/jwt/services/JwtAuthenticator.scala @@ -0,0 +1,54 @@ +package authentication.jwt.services + +import authentication.exceptions._ +import authentication.models.SecurityUserId +import commons.repositories.DateTimeProvider +import io.jsonwebtoken._ +import play.api.mvc.RequestHeader +import play.mvc.Http + +class JwtAuthenticator(dateTimeProvider: DateTimeProvider, + secretProvider: SecretProvider) { + + def authenticate(requestHeader: RequestHeader): Either[AuthenticationExceptionCode, (SecurityUserId, String)] = { + requestHeader.headers.get(Http.HeaderNames.AUTHORIZATION) + .map(parse) + .toRight(MissingOrInvalidCredentialsCode) + .flatMap(validate) + } + + private def expirationIsMissing(jwt: Jws[Claims]): Boolean = { + jwt.getBody.getExpiration == null + } + + private def validate(rawToken: String) = { + try { + val jwt = Jwts.parser() + .setSigningKey(secretProvider.get) + .parseClaimsJws(rawToken) + + if (expirationIsMissing(jwt)) Left(MissingOrInvalidCredentialsCode) + else if (isNotExpired(jwt)) Right((getSecurityUserId(jwt), rawToken)) + else Left(ExpiredCredentialsCode) + } catch { + case _: JwtException => + Left(MissingOrInvalidCredentialsCode) + } + } + + private def parse(authorizationHeader: String) = { + authorizationHeader.stripPrefix("Token ") + } + + private def getSecurityUserId(jwt: Jws[Claims]) = { + val securityUserId = java.lang.Long.parseLong(jwt.getBody.get(JwtTokenGenerator.securityUserIdClaimName, classOf[String])) + SecurityUserId(securityUserId) + } + + private def isNotExpired(jwt: Jws[Claims]) = { + val expiration = jwt.getBody.getExpiration.toInstant + + dateTimeProvider.now.isBefore(expiration) + } + +} diff --git a/app/authentication/jwt/services/JwtTokenGenerator.scala b/app/authentication/jwt/services/JwtTokenGenerator.scala new file mode 100644 index 0000000..094a85d --- /dev/null +++ b/app/authentication/jwt/services/JwtTokenGenerator.scala @@ -0,0 +1,34 @@ +package authentication.jwt.services + +import java.time.Duration +import java.util.Date + +import authentication.api.TokenGenerator +import authentication.models.{IdProfile, JwtToken} +import commons.repositories.DateTimeProvider +import io.jsonwebtoken.{Jwts, SignatureAlgorithm} + +private[authentication] class JwtTokenGenerator(dateTimeProvider: DateTimeProvider, secretProvider: SecretProvider) + extends TokenGenerator[IdProfile, JwtToken] { + + private val tokenDuration = Duration.ofHours(1) + + override def generate(profile: IdProfile): JwtToken = { + val signedToken = Jwts.builder + .setExpiration(Date.from(expiredAt)) + .claim(JwtTokenGenerator.securityUserIdClaimName, profile.securityUserId.value.toString) + .signWith(SignatureAlgorithm.HS256, secretProvider.get) + .compact() + + JwtToken(signedToken) + } + + private def expiredAt = { + val now = dateTimeProvider.now + now.plus(tokenDuration) + } +} + +private[authentication] object JwtTokenGenerator { + val securityUserIdClaimName: String = "security_user_id" +} \ No newline at end of file diff --git a/app/authentication/jwt/services/SecretProvider.scala b/app/authentication/jwt/services/SecretProvider.scala new file mode 100644 index 0000000..6c530e9 --- /dev/null +++ b/app/authentication/jwt/services/SecretProvider.scala @@ -0,0 +1,7 @@ +package authentication.jwt.services + +import play.api.Configuration + +private[authentication] class SecretProvider(config: Configuration) { + def get: String = config.get[String]("play.http.secret.key") +} \ No newline at end of file diff --git a/app/authentication/models/AuthenticatedUser.scala b/app/authentication/models/AuthenticatedUser.scala deleted file mode 100644 index 518a1f2..0000000 --- a/app/authentication/models/AuthenticatedUser.scala +++ /dev/null @@ -1,13 +0,0 @@ -package authentication.models - -import commons.models.Email -import play.api.libs.json.{Format, Json} - -case class AuthenticatedUser(email: Email, token: String) - -object AuthenticatedUser { - - def apply(emailAndToken: (Email, String)): AuthenticatedUser = AuthenticatedUser(emailAndToken._1, emailAndToken._2) - - implicit val authenticatedUserFormat: Format[AuthenticatedUser] = Json.format[AuthenticatedUser] -} \ No newline at end of file diff --git a/app/authentication/models/IdProfile.scala b/app/authentication/models/IdProfile.scala new file mode 100644 index 0000000..ef29851 --- /dev/null +++ b/app/authentication/models/IdProfile.scala @@ -0,0 +1,5 @@ +package authentication.models + +import authentication.api.Profile + +case class IdProfile(securityUserId: SecurityUserId) extends Profile \ No newline at end of file diff --git a/app/authentication/models/JwtToken.scala b/app/authentication/models/JwtToken.scala index f386eb4..318669c 100644 --- a/app/authentication/models/JwtToken.scala +++ b/app/authentication/models/JwtToken.scala @@ -1,5 +1,3 @@ package authentication.models -import java.time.Instant - -case class JwtToken(token: String, expiredAt: Instant) \ No newline at end of file +case class JwtToken(token: String) \ No newline at end of file diff --git a/app/authentication/models/NotAuthenticatedUserRequest.scala b/app/authentication/models/NotAuthenticatedUserRequest.scala index 31ceec7..e38a135 100644 --- a/app/authentication/models/NotAuthenticatedUserRequest.scala +++ b/app/authentication/models/NotAuthenticatedUserRequest.scala @@ -1,7 +1,8 @@ package authentication.models -import authentication.api.OptionallyAuthenticatedUserRequest import play.api.mvc.{Request, WrappedRequest} +import users.authentication.AuthenticatedUser +import users.controllers.OptionallyAuthenticatedUserRequest class NotAuthenticatedUserRequest[+A](request: Request[A]) extends WrappedRequest[A](request) with OptionallyAuthenticatedUserRequest[A] { diff --git a/app/authentication/models/SecurityUserIdProfile.scala b/app/authentication/models/SecurityUserIdProfile.scala deleted file mode 100644 index 21c6945..0000000 --- a/app/authentication/models/SecurityUserIdProfile.scala +++ /dev/null @@ -1,5 +0,0 @@ -package authentication.models - -import authentication.api.Profile - -case class SecurityUserIdProfile(securityUserId: SecurityUserId) extends Profile \ No newline at end of file diff --git a/app/authentication/pac4j/Pac4jComponents.scala b/app/authentication/pac4j/Pac4jComponents.scala deleted file mode 100644 index e50318a..0000000 --- a/app/authentication/pac4j/Pac4jComponents.scala +++ /dev/null @@ -1,62 +0,0 @@ -package authentication.pac4j - -import authentication.api._ -import authentication.models.{CredentialsWrapper, JwtToken, SecurityUserIdProfile} -import authentication.pac4j.controllers.{Pack4jAuthenticatedActionBuilder, Pack4jOptionallyAuthenticatedActionBuilder} -import authentication.pac4j.services.{JwtTokenGenerator, UsernameAndPasswordAuthenticator} -import authentication.repositories.SecurityUserRepo -import com.softwaremill.macwire.wire -import commons.CommonsComponents -import commons.config.WithExecutionContextComponents -import commons.services.ActionRunner -import org.pac4j.core.profile.CommonProfile -import org.pac4j.jwt.config.signature.SecretSignatureConfiguration -import org.pac4j.jwt.credentials.authenticator.{JwtAuthenticator => Pac4jJwtAuthenticator} -import org.pac4j.jwt.profile.JwtGenerator -import org.pac4j.play.store.{PlayCacheSessionStore, PlaySessionStore} -import play.api.Configuration -import play.api.cache.AsyncCacheApi -import play.api.mvc.PlayBodyParsers -import play.cache.DefaultAsyncCacheApi - -private[authentication] trait Pac4jComponents extends WithExecutionContextComponents with CommonsComponents { - - def actionRunner: ActionRunner - - def securityUserRepo: SecurityUserRepo - - lazy val usernamePasswordAuthenticator: Authenticator[CredentialsWrapper] = wire[UsernameAndPasswordAuthenticator] - - def configuration: Configuration - - private lazy val signatureConfig = { - val secret: String = configuration.get[String]("play.http.secret.key") - new SecretSignatureConfiguration(secret) - } - - lazy val jwtAuthenticator: Pac4jJwtAuthenticator = { - new Pac4jJwtAuthenticator(signatureConfig) - } - - def playBodyParsers: PlayBodyParsers - - def securityUserProvider: SecurityUserProvider - - lazy val authenticatedAction: AuthenticatedActionBuilder = wire[Pack4jAuthenticatedActionBuilder] - lazy val optionallyAuthenticatedAction: OptionallyAuthenticatedActionBuilder = - wire[Pack4jOptionallyAuthenticatedActionBuilder] - - def defaultCacheApi: AsyncCacheApi - - private lazy val _: PlaySessionStore = { - val defaultAsyncCacheApi = new DefaultAsyncCacheApi(defaultCacheApi) - val syncCacheApi: play.cache.SyncCacheApi = new play.cache.DefaultSyncCacheApi(defaultAsyncCacheApi) - - new PlayCacheSessionStore(syncCacheApi) - } - - lazy val pack4jJwtAuthenticator: TokenGenerator[SecurityUserIdProfile, JwtToken] = { - val _: JwtGenerator[CommonProfile] = new JwtGenerator(signatureConfig) - wire[JwtTokenGenerator] - } -} \ No newline at end of file diff --git a/app/authentication/pac4j/controllers/AbstractPack4jAuthenticatedActionBuilder.scala b/app/authentication/pac4j/controllers/AbstractPack4jAuthenticatedActionBuilder.scala deleted file mode 100644 index 6a4cf03..0000000 --- a/app/authentication/pac4j/controllers/AbstractPack4jAuthenticatedActionBuilder.scala +++ /dev/null @@ -1,76 +0,0 @@ -package authentication.pac4j.controllers - -import java.time.Instant -import java.util.Date - -import authentication.exceptions.{ExceptionWithCode, ExpiredCredentialsCode, MissingOrInvalidCredentialsCode, UserDoesNotExistCode} -import authentication.repositories.SecurityUserRepo -import commons.models._ -import commons.repositories.DateTimeProvider -import commons.services.ActionRunner -import commons.utils.DbioUtils -import authentication.api._ -import authentication.models.SecurityUserId -import org.pac4j.core.profile.CommonProfile -import org.pac4j.http.client.direct.HeaderClient -import org.pac4j.jwt.credentials.authenticator.JwtAuthenticator -import org.pac4j.jwt.profile.JwtProfile -import org.pac4j.play.PlayWebContext -import org.pac4j.play.store.PlaySessionStore -import play.api.mvc.RequestHeader -import play.mvc.Http -import slick.dbio.DBIO - -import scala.concurrent.ExecutionContext - -private[authentication] abstract class AbstractPack4jAuthenticatedActionBuilder(sessionStore: PlaySessionStore, - dateTimeProvider: DateTimeProvider, - jwtAuthenticator: JwtAuthenticator, - actionRunner: ActionRunner, - securityUserRepo: SecurityUserRepo) - (implicit ec: ExecutionContext) - extends OptionallyAuthenticatedActionBuilder { - - private val prefixSpaceIsCrucialHere = "Token " - private val client = new HeaderClient(Http.HeaderNames.AUTHORIZATION, prefixSpaceIsCrucialHere, jwtAuthenticator) - - protected def authenticate(requestHeader: RequestHeader): DBIO[(Email, String)] = { - val webContext = new PlayWebContext(requestHeader, sessionStore) - val credentials = client.getCredentials(webContext) - Option(credentials) - .toRight(MissingOrInvalidCredentialsCode) - .map(client.getUserProfile(_, webContext)) - .filterOrElse(isNotExpired, ExpiredCredentialsCode) - .fold(exceptionCode => DBIO.failed(new ExceptionWithCode(exceptionCode)), profile => DBIO.successful(profile)) - .map(profile => mapToSecurityUserId(profile)) - .flatMap(existsSecurityUser) - .map(email => (email, credentials.getToken)) - } - - private def mapToSecurityUserId(profile: CommonProfile) = { - SecurityUserId(java.lang.Long.parseLong(profile.getId)) - } - - private def existsSecurityUser(securityUserId: SecurityUserId) = { - securityUserRepo.findByIdOption(securityUserId) - .flatMap(maybeSecurityUser => DbioUtils.optionToDbio(maybeSecurityUser, - new ExceptionWithCode(UserDoesNotExistCode))) - .map(securityUser => securityUser.email) - } - - private def isNotExpired(profile: CommonProfile): Boolean = - profile.isInstanceOf[JwtProfile] && isNotExpired(profile.asInstanceOf[JwtProfile]) - - private def isNotExpired(profile: JwtProfile) = { - val expirationDate = profile.getExpirationDate - val expiredAt = toInstant(expirationDate) - - dateTimeProvider.now.isBefore(expiredAt) - } - - private def toInstant(date: Date): Instant = { - if (date == null) null - else date.toInstant - } - -} diff --git a/app/authentication/pac4j/controllers/Pack4jAuthenticatedActionBuilder.scala b/app/authentication/pac4j/controllers/Pack4jAuthenticatedActionBuilder.scala deleted file mode 100644 index 6dbe9dd..0000000 --- a/app/authentication/pac4j/controllers/Pack4jAuthenticatedActionBuilder.scala +++ /dev/null @@ -1,52 +0,0 @@ -package authentication.pac4j.controllers - -import authentication.exceptions.{AuthenticationExceptionCode, ExceptionWithCode} -import authentication.repositories.SecurityUserRepo -import commons.models._ -import commons.repositories.DateTimeProvider -import commons.services.ActionRunner -import authentication.api._ -import authentication.models.{AuthenticatedUser, AuthenticatedUserRequest, HttpExceptionResponse} -import org.pac4j.jwt.credentials.authenticator.JwtAuthenticator -import org.pac4j.play.store.PlaySessionStore -import play.api.libs.json.Json -import play.api.mvc -import play.api.mvc.Results._ -import play.api.mvc._ - -import scala.concurrent.{ExecutionContext, Future} - -private[authentication] class Pack4jAuthenticatedActionBuilder(sessionStore: PlaySessionStore, - parsers: PlayBodyParsers, - dateTimeProvider: DateTimeProvider, - jwtAuthenticator: JwtAuthenticator, - securityUserRepo: SecurityUserRepo, - actionRunner: ActionRunner) - (implicit ec: ExecutionContext) - extends AbstractPack4jAuthenticatedActionBuilder(sessionStore, dateTimeProvider, jwtAuthenticator, actionRunner, - securityUserRepo) with AuthenticatedActionBuilder { - - override val parser: BodyParser[AnyContent] = new mvc.BodyParsers.Default(parsers) - - override protected def executionContext: ExecutionContext = ec - - private def onUnauthorized(exceptionCode: AuthenticationExceptionCode, requestHeader: RequestHeader) = { - val response = HttpExceptionResponse(exceptionCode) - Unauthorized(Json.toJson(response)) - } - - override def invokeBlock[A](request: Request[A], - block: AuthenticatedUserRequest[A] => Future[Result]): Future[Result] = { - actionRunner.runTransactionally(authenticate(request)) - .flatMap(emailAndToken => { - val authenticatedUserRequest = new AuthenticatedUserRequest(AuthenticatedUser(emailAndToken), request) - block(authenticatedUserRequest) - }) - .recover({ - case e: ExceptionWithCode => - onUnauthorized(e.exceptionCode, request) - }) - } - -} - diff --git a/app/authentication/pac4j/controllers/Pack4jOptionallyAuthenticatedActionBuilder.scala b/app/authentication/pac4j/controllers/Pack4jOptionallyAuthenticatedActionBuilder.scala deleted file mode 100644 index b53edfe..0000000 --- a/app/authentication/pac4j/controllers/Pack4jOptionallyAuthenticatedActionBuilder.scala +++ /dev/null @@ -1,41 +0,0 @@ -package authentication.pac4j.controllers - -import authentication.exceptions.ExceptionWithCode -import authentication.repositories.SecurityUserRepo -import commons.repositories.DateTimeProvider -import commons.services.ActionRunner -import authentication.api._ -import authentication.models.{AuthenticatedUser, AuthenticatedUserRequest, NotAuthenticatedUserRequest} -import org.pac4j.jwt.credentials.authenticator.JwtAuthenticator -import org.pac4j.play.store.PlaySessionStore -import play.api.mvc -import play.api.mvc._ - -import scala.concurrent.{ExecutionContext, Future} - -private[authentication] class Pack4jOptionallyAuthenticatedActionBuilder(sessionStore: PlaySessionStore, - parsers: PlayBodyParsers, - dateTimeProvider: DateTimeProvider, - jwtAuthenticator: JwtAuthenticator, - securityUserRepo: SecurityUserRepo, - actionRunner: ActionRunner) - (implicit ec: ExecutionContext) - extends AbstractPack4jAuthenticatedActionBuilder(sessionStore, dateTimeProvider, jwtAuthenticator, actionRunner, - securityUserRepo) { - - override val parser: BodyParser[AnyContent] = new mvc.BodyParsers.Default(parsers) - - override protected def executionContext: ExecutionContext = ec - - override def invokeBlock[A](request: Request[A], - block: OptionallyAuthenticatedUserRequest[A] => Future[Result]): Future[Result] = { - actionRunner.runTransactionally(authenticate(request)) - .map(emailAndToken => new AuthenticatedUserRequest(AuthenticatedUser(emailAndToken), request)) - .recover({ - case _: ExceptionWithCode => - new NotAuthenticatedUserRequest(request) - }) - .flatMap(block) - } - -} \ No newline at end of file diff --git a/app/authentication/pac4j/services/JwtTokenGenerator.scala b/app/authentication/pac4j/services/JwtTokenGenerator.scala deleted file mode 100644 index fc710df..0000000 --- a/app/authentication/pac4j/services/JwtTokenGenerator.scala +++ /dev/null @@ -1,41 +0,0 @@ -package authentication.pac4j.services - -import java.time.{Duration, Instant} -import java.util.Date - -import commons.repositories.DateTimeProvider -import authentication.api.TokenGenerator -import authentication.models.{JwtToken, SecurityUserIdProfile} -import org.pac4j.core.profile.CommonProfile -import org.pac4j.core.profile.jwt.JwtClaims -import org.pac4j.jwt.profile.{JwtGenerator, JwtProfile} - -private[authentication] class JwtTokenGenerator(dateTimeProvider: DateTimeProvider, - jwtGenerator: JwtGenerator[CommonProfile]) - extends TokenGenerator[SecurityUserIdProfile, JwtToken] { - - private val tokenDuration = Duration.ofHours(12) - - override def generate(profile: SecurityUserIdProfile): JwtToken = { - val expiredAt = dateTimeProvider.now.plus(tokenDuration) - val profileId = profile.securityUserId - - val rawToken = jwtGenerator.generate(buildProfile(profileId.value, expiredAt)) - - JwtToken(rawToken, expiredAt) - } - - private def buildProfile(profileId: Any, expiredAt: Instant) = { - val profile = new JwtProfile() - profile.setId(profileId) - profile.addAttribute(JwtClaims.EXPIRATION_TIME, toOldJavaDate(expiredAt)) - - profile - } - - private def toOldJavaDate(instant: Instant): Date = { - if (instant == null) null - else Date.from(instant) - } - -} \ No newline at end of file diff --git a/app/authentication/pac4j/services/UsernameAndPasswordAuthenticator.scala b/app/authentication/pac4j/services/UsernameAndPasswordAuthenticator.scala deleted file mode 100644 index 4875d03..0000000 --- a/app/authentication/pac4j/services/UsernameAndPasswordAuthenticator.scala +++ /dev/null @@ -1,36 +0,0 @@ -package authentication.pac4j.services - -import authentication.api._ -import authentication.exceptions.InvalidPasswordException -import authentication.models._ -import authentication.repositories.SecurityUserRepo -import commons.services.ActionRunner -import org.mindrot.jbcrypt.BCrypt -import play.api.mvc.Request -import slick.dbio.DBIO - -import scala.concurrent.ExecutionContext - -private[authentication] class UsernameAndPasswordAuthenticator(tokenGenerator: TokenGenerator[SecurityUserIdProfile, JwtToken], - actionRunner: ActionRunner, - securityUserRepo: SecurityUserRepo) - (implicit private val ec: ExecutionContext) - extends Authenticator[CredentialsWrapper] { - - override def authenticate(request: Request[CredentialsWrapper]): DBIO[String] = { - require(request != null) - - val EmailAndPasswordCredentials(email, password) = request.body.user - - securityUserRepo.findByEmail(email) - .map(user => { - if (authenticated(password, user)) tokenGenerator.generate(SecurityUserIdProfile(user.id)).token - else throw new InvalidPasswordException(email.toString) - }) - } - - private def authenticated(givenPassword: PlainTextPassword, secUsr: SecurityUser) = { - BCrypt.checkpw(givenPassword.value, secUsr.password.value) - } - -} \ No newline at end of file diff --git a/app/authentication/services/SecurityUserService.scala b/app/authentication/services/SecurityUserService.scala index 9f0ea62..31931c4 100644 --- a/app/authentication/services/SecurityUserService.scala +++ b/app/authentication/services/SecurityUserService.scala @@ -39,11 +39,11 @@ private[authentication] class SecurityUserService(securityUserRepo: SecurityUser securityUserRepo.findByEmailOption(email) } - override def update(currentEmail: Email, securityUserUpdate: SecurityUserUpdate): DBIO[SecurityUser] = { - require(currentEmail != null && securityUserUpdate != null) + override def update(securityUserId: SecurityUserId, securityUserUpdate: SecurityUserUpdate): DBIO[SecurityUser] = { + require(securityUserId != null && securityUserUpdate != null) for { - securityUser <- findByEmail(currentEmail) + securityUser <- findById(securityUserId) withUpdatedEmail <- maybeUpdateEmail(securityUser, securityUserUpdate.email) withUpdatedPassword <- maybeUpdatePassword(withUpdatedEmail, securityUserUpdate.password) } yield withUpdatedPassword @@ -55,6 +55,12 @@ private[authentication] class SecurityUserService(securityUserRepo: SecurityUser securityUserRepo.findByEmail(email) } + override def findById(id: SecurityUserId): DBIO[SecurityUser] = { + require(id != null) + + securityUserRepo.findById(id) + } + private def maybeUpdateEmail(securityUser: SecurityUser, maybeEmail: Option[Email]) = { maybeEmail.filter(_ != securityUser.email) .map(newEmail => { diff --git a/app/authentication/services/UsernameAndPasswordAuthenticator.scala b/app/authentication/services/UsernameAndPasswordAuthenticator.scala new file mode 100644 index 0000000..0427446 --- /dev/null +++ b/app/authentication/services/UsernameAndPasswordAuthenticator.scala @@ -0,0 +1,37 @@ +package authentication.services + +import authentication.api._ +import authentication.exceptions.InvalidPasswordException +import authentication.models._ +import org.mindrot.jbcrypt.BCrypt +import play.api.mvc.Request +import slick.dbio.DBIO + +import scala.concurrent.ExecutionContext + +private[authentication] class UsernameAndPasswordAuthenticator( + tokenGenerator: TokenGenerator[IdProfile, JwtToken], + securityUserProvider: SecurityUserProvider + ) + (implicit private val ec: ExecutionContext) + extends Authenticator[CredentialsWrapper] { + + override def authenticate(request: Request[CredentialsWrapper]): DBIO[String] = { + require(request != null) + + val EmailAndPasswordCredentials(email, password) = request.body.user + + securityUserProvider.findByEmail(email) + .map(securityUser => { + if (authenticated(password, securityUser)) + tokenGenerator.generate(IdProfile(securityUser.id)).token + else + throw new InvalidPasswordException(email.toString) + }) + } + + private def authenticated(givenPassword: PlainTextPassword, secUsr: SecurityUser) = { + BCrypt.checkpw(givenPassword.value, secUsr.password.value) + } + +} \ No newline at end of file diff --git a/app/commons/controllers/RealWorldAbstractController.scala b/app/commons/controllers/RealWorldAbstractController.scala index 6014afe..9770aa9 100644 --- a/app/commons/controllers/RealWorldAbstractController.scala +++ b/app/commons/controllers/RealWorldAbstractController.scala @@ -20,7 +20,9 @@ abstract class RealWorldAbstractController(controllerComponents: ControllerCompo case e: ValidationException => val errors = e.violations .groupBy(_.property) + .view .mapValues(_.map(propertyViolation => propertyViolation.violation.message)) + .toMap val wrapper: ValidationResultWrapper = ValidationResultWrapper(errors) UnprocessableEntity(Json.toJson(wrapper)) diff --git a/app/config/RealWorldApplicationLoader.scala b/app/config/RealWorldApplicationLoader.scala index cc00472..68d91e8 100644 --- a/app/config/RealWorldApplicationLoader.scala +++ b/app/config/RealWorldApplicationLoader.scala @@ -32,7 +32,7 @@ class RealWorldComponents(context: Context) extends BuiltInComponentsFromContext with EvolutionsComponents with AhcWSComponents with AuthenticationComponents - with UserComponents + with UserComponents with ArticleComponents with EhCacheComponents { diff --git a/app/users/UserComponents.scala b/app/users/UserComponents.scala index 5c0001d..be55cf4 100644 --- a/app/users/UserComponents.scala +++ b/app/users/UserComponents.scala @@ -3,10 +3,10 @@ package users import com.softwaremill.macwire.wire import commons.config.{WithControllerComponents, WithExecutionContextComponents} import commons.models.Username -import authentication.AuthenticationComponents +import _root_.authentication.AuthenticationComponents import play.api.routing.Router import play.api.routing.sird._ -import users.controllers.{LoginController, ProfileController, UserController} +import users.controllers._ import users.repositories.{FollowAssociationRepo, ProfileRepo, UserRepo} import users.services._ @@ -30,6 +30,10 @@ trait UserComponents extends AuthenticationComponents with WithControllerCompone lazy val loginController: LoginController = wire[LoginController] + lazy val authenticatedAction: AuthenticatedActionBuilder = wire[JwtAuthenticatedActionBuilder] + lazy val optionallyAuthenticatedAction: OptionallyAuthenticatedActionBuilder = + wire[JwtOptionallyAuthenticatedActionBuilder] + val userRoutes: Router.Routes = { case POST(p"/users") => userController.register diff --git a/app/users/authentication/AuthenticatedUser.scala b/app/users/authentication/AuthenticatedUser.scala new file mode 100644 index 0000000..97255d8 --- /dev/null +++ b/app/users/authentication/AuthenticatedUser.scala @@ -0,0 +1,14 @@ +package users.authentication + +import authentication.models.SecurityUserId +import users.models.{User, UserId} + +case class AuthenticatedUser(userId: UserId, securityUserId: SecurityUserId, token: String) + +object AuthenticatedUser { + + def apply(userAndToken: (User, String)): AuthenticatedUser = { + val (user, token) = userAndToken + AuthenticatedUser(user.id, user.securityUserId, token) + } +} \ No newline at end of file diff --git a/app/authentication/api/AuthenticatedActionBuilder.scala b/app/users/controllers/AuthenticatedActionBuilder.scala similarity index 63% rename from app/authentication/api/AuthenticatedActionBuilder.scala rename to app/users/controllers/AuthenticatedActionBuilder.scala index f13d8a3..df2b118 100644 --- a/app/authentication/api/AuthenticatedActionBuilder.scala +++ b/app/users/controllers/AuthenticatedActionBuilder.scala @@ -1,6 +1,5 @@ -package authentication.api +package users.controllers -import authentication.models.AuthenticatedUserRequest import play.api.mvc.{ActionBuilder, AnyContent} trait AuthenticatedActionBuilder extends ActionBuilder[AuthenticatedUserRequest, AnyContent] diff --git a/app/authentication/models/AuthenticatedUserRequest.scala b/app/users/controllers/AuthenticatedUserRequest.scala similarity index 81% rename from app/authentication/models/AuthenticatedUserRequest.scala rename to app/users/controllers/AuthenticatedUserRequest.scala index 8962f41..94cbd2a 100644 --- a/app/authentication/models/AuthenticatedUserRequest.scala +++ b/app/users/controllers/AuthenticatedUserRequest.scala @@ -1,8 +1,8 @@ -package authentication.models +package users.controllers -import authentication.api.OptionallyAuthenticatedUserRequest import play.api.mvc.Request import play.api.mvc.Security.AuthenticatedRequest +import users.authentication.AuthenticatedUser class AuthenticatedUserRequest[+A](authenticatedUser: AuthenticatedUser, request: Request[A]) extends AuthenticatedRequest[A, AuthenticatedUser](authenticatedUser, request) diff --git a/app/users/controllers/BaseActionBuilder.scala b/app/users/controllers/BaseActionBuilder.scala new file mode 100644 index 0000000..d752930 --- /dev/null +++ b/app/users/controllers/BaseActionBuilder.scala @@ -0,0 +1,29 @@ +package users.controllers + +import authentication.exceptions.ExceptionWithCode +import authentication.jwt.services.JwtAuthenticator +import play.api.mvc._ +import slick.dbio.DBIO +import users.models.User +import users.repositories.UserRepo + +import scala.concurrent.ExecutionContext + +private[controllers] class BaseActionBuilder( + jwtAuthenticator: JwtAuthenticator, + userRepo: UserRepo, + )(implicit ec: ExecutionContext) +{ + protected def authenticate(requestHeader: RequestHeader): DBIO[(User, String)] = { + jwtAuthenticator.authenticate(requestHeader) + .fold( + authExceptionCode => DBIO.failed(new ExceptionWithCode(authExceptionCode)), + securityUserIdAndToken => { + val (securityUserId, token) = securityUserIdAndToken + userRepo.findBySecurityUserId(securityUserId) + .map(user => (user, token)) + } + ) + } + +} \ No newline at end of file diff --git a/app/authentication/models/HttpExceptionResponse.scala b/app/users/controllers/HttpExceptionResponse.scala similarity index 57% rename from app/authentication/models/HttpExceptionResponse.scala rename to app/users/controllers/HttpExceptionResponse.scala index 85f063f..9af2bed 100644 --- a/app/authentication/models/HttpExceptionResponse.scala +++ b/app/users/controllers/HttpExceptionResponse.scala @@ -1,12 +1,12 @@ -package authentication.models +package users.controllers import authentication.exceptions.AuthenticationExceptionCode import play.api.libs.json.{Format, Json} -private[authentication] case class HttpExceptionResponse(code: AuthenticationExceptionCode) { +private[controllers] case class HttpExceptionResponse(code: AuthenticationExceptionCode) { val message: String = code.message } -private[authentication] object HttpExceptionResponse { +private[controllers] object HttpExceptionResponse { implicit val jsonWrites: Format[HttpExceptionResponse] = Json.format[HttpExceptionResponse] } \ No newline at end of file diff --git a/app/users/controllers/JwtAuthenticatedActionBuilder.scala b/app/users/controllers/JwtAuthenticatedActionBuilder.scala new file mode 100644 index 0000000..0fc4b23 --- /dev/null +++ b/app/users/controllers/JwtAuthenticatedActionBuilder.scala @@ -0,0 +1,48 @@ +package users.controllers + +import authentication.exceptions.{AuthenticationExceptionCode, ExceptionWithCode} +import authentication.jwt.services.JwtAuthenticator +import authentication.models.NotAuthenticatedUserRequest +import commons.services.ActionRunner +import play.api.libs.json.Json +import play.api.mvc +import play.api.mvc.Results._ +import play.api.mvc._ +import users.authentication.AuthenticatedUser +import users.repositories.UserRepo + +import scala.concurrent.{ExecutionContext, Future} + +private[users] class JwtAuthenticatedActionBuilder( + parsers: PlayBodyParsers, + jwtAuthenticator: JwtAuthenticator, + userRepo: UserRepo, + actionRunner: ActionRunner + ) + (implicit ec: ExecutionContext) + extends BaseActionBuilder(jwtAuthenticator, userRepo) with AuthenticatedActionBuilder { + + override val parser: BodyParser[AnyContent] = new mvc.BodyParsers.Default(parsers) + + override protected def executionContext: ExecutionContext = ec + + private def onUnauthorized(exceptionCode: AuthenticationExceptionCode, requestHeader: RequestHeader) = { + val response = HttpExceptionResponse(exceptionCode) + Unauthorized(Json.toJson(response)) + } + + override def invokeBlock[A](request: Request[A], + block: AuthenticatedUserRequest[A] => Future[Result]): Future[Result] = { + actionRunner.runTransactionally(authenticate(request)) + .flatMap(userAndToken => { + val authenticatedRequest = new AuthenticatedUserRequest(AuthenticatedUser(userAndToken), request) + block(authenticatedRequest) + }) + .recover({ + case e: ExceptionWithCode => + onUnauthorized(e.exceptionCode, request) + }) + } + +} + diff --git a/app/users/controllers/JwtOptionallyAuthenticatedActionBuilder.scala b/app/users/controllers/JwtOptionallyAuthenticatedActionBuilder.scala new file mode 100644 index 0000000..006adb2 --- /dev/null +++ b/app/users/controllers/JwtOptionallyAuthenticatedActionBuilder.scala @@ -0,0 +1,39 @@ +package users.controllers + +import authentication.exceptions.ExceptionWithCode +import authentication.jwt.services.JwtAuthenticator +import authentication.models.NotAuthenticatedUserRequest +import commons.services.ActionRunner +import play.api.mvc +import play.api.mvc._ +import users.authentication.AuthenticatedUser +import users.repositories.UserRepo + +import scala.concurrent.{ExecutionContext, Future} + +private[users] class JwtOptionallyAuthenticatedActionBuilder( + parsers: PlayBodyParsers, + jwtAuthenticator: JwtAuthenticator, + userRepo: UserRepo, + actionRunner: ActionRunner + )(implicit ec: ExecutionContext) + extends BaseActionBuilder(jwtAuthenticator, userRepo) with OptionallyAuthenticatedActionBuilder { + + override val parser: BodyParser[AnyContent] = new mvc.BodyParsers.Default(parsers) + + override protected def executionContext: ExecutionContext = ec + + override def invokeBlock[A](request: Request[A], + block: OptionallyAuthenticatedUserRequest[A] => Future[Result]): Future[Result] = { + val authenticateAction = actionRunner.runTransactionally(authenticate(request)) + + authenticateAction + .map(securityUserIdAndToken => new AuthenticatedUserRequest(AuthenticatedUser(securityUserIdAndToken), request)) + .recover({ + case _: ExceptionWithCode => + new NotAuthenticatedUserRequest(request) + }) + .flatMap(block) + } + +} \ No newline at end of file diff --git a/app/authentication/api/OptionallyAuthenticatedActionBuilder.scala b/app/users/controllers/OptionallyAuthenticatedActionBuilder.scala similarity index 60% rename from app/authentication/api/OptionallyAuthenticatedActionBuilder.scala rename to app/users/controllers/OptionallyAuthenticatedActionBuilder.scala index 822f3d3..f934413 100644 --- a/app/authentication/api/OptionallyAuthenticatedActionBuilder.scala +++ b/app/users/controllers/OptionallyAuthenticatedActionBuilder.scala @@ -1,5 +1,5 @@ -package authentication.api +package users.controllers import play.api.mvc.{ActionBuilder, AnyContent} -trait OptionallyAuthenticatedActionBuilder extends ActionBuilder[OptionallyAuthenticatedUserRequest, AnyContent] \ No newline at end of file +trait OptionallyAuthenticatedActionBuilder extends ActionBuilder[OptionallyAuthenticatedUserRequest, AnyContent] diff --git a/app/authentication/api/OptionallyAuthenticatedUserRequest.scala b/app/users/controllers/OptionallyAuthenticatedUserRequest.scala similarity index 71% rename from app/authentication/api/OptionallyAuthenticatedUserRequest.scala rename to app/users/controllers/OptionallyAuthenticatedUserRequest.scala index 78e9dba..ef128c9 100644 --- a/app/authentication/api/OptionallyAuthenticatedUserRequest.scala +++ b/app/users/controllers/OptionallyAuthenticatedUserRequest.scala @@ -1,7 +1,7 @@ -package authentication.api +package users.controllers -import authentication.models.AuthenticatedUser import play.api.mvc.Request +import users.authentication.AuthenticatedUser trait OptionallyAuthenticatedUserRequest[+BodyContentType] extends Request[BodyContentType] { diff --git a/app/users/controllers/ProfileController.scala b/app/users/controllers/ProfileController.scala index c1e66b0..9bde5eb 100644 --- a/app/users/controllers/ProfileController.scala +++ b/app/users/controllers/ProfileController.scala @@ -3,7 +3,6 @@ package users.controllers import commons.exceptions.MissingModelException import commons.models.Username import commons.services.ActionRunner -import authentication.api.{AuthenticatedActionBuilder, OptionallyAuthenticatedActionBuilder} import commons.controllers.RealWorldAbstractController import users.models._ import users.services.ProfileService @@ -20,8 +19,8 @@ class ProfileController(authenticatedAction: AuthenticatedActionBuilder, def unfollow(username: Username): Action[_] = authenticatedAction.async { request => require(username != null) - val currentUserEmail = request.user.email - actionRunner.runTransactionally(profileService.unfollow(username, currentUserEmail)) + val userId = request.user.userId + actionRunner.runTransactionally(profileService.unfollow(username, userId)) .map(ProfileWrapper(_)) .map(Json.toJson(_)) .map(Ok(_)) @@ -33,8 +32,8 @@ class ProfileController(authenticatedAction: AuthenticatedActionBuilder, def follow(username: Username): Action[_] = authenticatedAction.async { request => require(username != null) - val currentUserEmail = request.user.email - actionRunner.runTransactionally(profileService.follow(username, currentUserEmail)) + val userId = request.user.userId + actionRunner.runTransactionally(profileService.follow(username, userId)) .map(ProfileWrapper(_)) .map(Json.toJson(_)) .map(Ok(_)) @@ -46,8 +45,8 @@ class ProfileController(authenticatedAction: AuthenticatedActionBuilder, def findByUsername(username: Username): Action[_] = optionallyAuthenticatedActionBuilder.async { request => require(username != null) - val maybeEmail = request.authenticatedUserOption.map(_.email) - actionRunner.runTransactionally(profileService.findByUsername(username, maybeEmail)) + val maybeUserId = request.authenticatedUserOption.map(_.userId) + actionRunner.runTransactionally(profileService.findByUsername(username, maybeUserId)) .map(ProfileWrapper(_)) .map(Json.toJson(_)) .map(Ok(_)) diff --git a/app/users/controllers/UserController.scala b/app/users/controllers/UserController.scala index 74b2893..8732be7 100644 --- a/app/users/controllers/UserController.scala +++ b/app/users/controllers/UserController.scala @@ -1,25 +1,25 @@ package users.controllers -import commons.services.ActionRunner import authentication.api._ -import authentication.models.{JwtToken, SecurityUserId, SecurityUserIdProfile} +import authentication.models.{IdProfile, JwtToken, SecurityUserId} import commons.controllers.RealWorldAbstractController -import users.models._ -import users.services.{UserRegistrationService, UserService} +import commons.services.ActionRunner import play.api.libs.json._ import play.api.mvc._ +import users.models._ +import users.services.{UserRegistrationService, UserService} class UserController(authenticatedAction: AuthenticatedActionBuilder, actionRunner: ActionRunner, userRegistrationService: UserRegistrationService, userService: UserService, - jwtAuthenticator: TokenGenerator[SecurityUserIdProfile, JwtToken], + jwtAuthenticator: TokenGenerator[IdProfile, JwtToken], components: ControllerComponents) extends RealWorldAbstractController(components) { def update: Action[UpdateUserWrapper] = authenticatedAction.async(validateJson[UpdateUserWrapper]) { request => - val email = request.user.email - actionRunner.runTransactionally(userService.update(email, request.body.user)) + val userId = request.user.userId + actionRunner.runTransactionally(userService.update(userId, request.body.user)) .map(userDetails => UserDetailsWithToken(userDetails, request.user.token)) .map(UserDetailsWithTokenWrapper(_)) .map(Json.toJson(_)) @@ -28,8 +28,8 @@ class UserController(authenticatedAction: AuthenticatedActionBuilder, } def getCurrentUser: Action[AnyContent] = authenticatedAction.async { request => - val email = request.user.email - actionRunner.runTransactionally(userService.getUserDetails(email)) + val userId = request.user.userId + actionRunner.runTransactionally(userService.getUserDetails(userId)) .map(userDetails => UserDetailsWithToken(userDetails, request.user.token)) .map(UserDetailsWithTokenWrapper(_)) .map(Json.toJson(_)) @@ -38,9 +38,8 @@ class UserController(authenticatedAction: AuthenticatedActionBuilder, def register: Action[UserRegistrationWrapper] = Action.async(validateJson[UserRegistrationWrapper]) { request => actionRunner.runTransactionally(userRegistrationService.register(request.body.user)) - .map(userAndSecurityUserId => { - val (user, securityUserId) = userAndSecurityUserId - val jwtToken: JwtToken = generateToken(securityUserId) + .map(user => { + val jwtToken: JwtToken = generateToken(user.securityUserId) UserDetailsWithToken(user.email, user.username, user.createdAt, user.updatedAt, user.bio, user.image, jwtToken.token) }) @@ -51,9 +50,8 @@ class UserController(authenticatedAction: AuthenticatedActionBuilder, } private def generateToken(securityUserId: SecurityUserId) = { - val profile = SecurityUserIdProfile(securityUserId) - val jwtToken = jwtAuthenticator.generate(profile) - jwtToken + val profile = IdProfile(securityUserId) + jwtAuthenticator.generate(profile) } } \ No newline at end of file diff --git a/app/users/models/User.scala b/app/users/models/User.scala index e15b2c9..685f912 100644 --- a/app/users/models/User.scala +++ b/app/users/models/User.scala @@ -2,11 +2,13 @@ package users.models import java.time.Instant +import authentication.models.SecurityUserId import commons.models.{WithId, _} import play.api.libs.json._ import slick.jdbc.H2Profile.api.{DBIO => _, MappedTo => _, Rep => _, TableQuery => _, _} case class User(id: UserId, + securityUserId: SecurityUserId, username: Username, email: Email, bio: Option[String], @@ -14,10 +16,6 @@ case class User(id: UserId, createdAt: Instant, updatedAt: Instant) extends WithId[Long, UserId] -object User { - implicit val userFormat: Format[User] = Json.format[User] -} - case class UserId(override val value: Long) extends AnyVal with BaseId[Long] object UserId { diff --git a/app/users/repositories/ProfileRepo.scala b/app/users/repositories/ProfileRepo.scala index 0577e9a..9d1f240 100644 --- a/app/users/repositories/ProfileRepo.scala +++ b/app/users/repositories/ProfileRepo.scala @@ -1,6 +1,6 @@ package users.repositories -import commons.models.{Email, Username} +import commons.models.Username import slick.dbio.DBIO import users.models.{Profile, _} @@ -10,28 +10,27 @@ class ProfileRepo(userRepo: UserRepo, followAssociationRepo: FollowAssociationRepo, implicit private val ec: ExecutionContext) { - def getProfileByUserId(userIds: Iterable[UserId], maybeCurrentUserEmail: Option[Email]): DBIO[Map[UserId, Profile]] = { - require(userIds != null && maybeCurrentUserEmail != null) + def getProfileByUserId(userIds: Iterable[UserId], maybeUserId: Option[UserId]): DBIO[Map[UserId, Profile]] = { + require(userIds != null && maybeUserId != null) - findByUserIds(userIds, maybeCurrentUserEmail) + findByUserIds(userIds, maybeUserId) .map(_.map(profile => (profile.userId, profile)).toMap) } - private def findByUserIds(userIds: Iterable[UserId], maybeCurrentUserEmail: Option[Email]): DBIO[Seq[Profile]] = { - require(userIds != null && maybeCurrentUserEmail != null) + private def findByUserIds(userIds: Iterable[UserId], maybeUserId: Option[UserId]): DBIO[Seq[Profile]] = { + require(userIds != null && maybeUserId != null) for { users <- userRepo.findByIds(userIds) - followAssociations <- getFollowAssociations(userIds, maybeCurrentUserEmail) + followAssociations <- getFollowAssociations(userIds, maybeUserId) } yield { val isFollowing = isFollowingGenerator(followAssociations)(_) users.map(user => Profile(user, isFollowing(user.id))) } } - private def getFollowAssociations(userIds: Iterable[UserId], maybeCurrentUserEmail: Option[Email]) = { - maybeCurrentUserEmail.map(email => userRepo.findByEmail(email)) - .map(_.flatMap(currentUser => followAssociationRepo.findByFollowerAndFollowed(currentUser.id, userIds))) + private def getFollowAssociations(userIds: Iterable[UserId], maybeUserId: Option[UserId]) = { + maybeUserId.map(currentUserId => followAssociationRepo.findByFollowerAndFollowed(currentUserId, userIds)) .getOrElse(DBIO.successful(Seq.empty)) } @@ -40,19 +39,19 @@ class ProfileRepo(userRepo: UserRepo, followedIds.contains(userId) } - def findByUsername(username: Username, maybeCurrentUserEmail: Option[Email]): DBIO[Profile] = { - require(username != null && maybeCurrentUserEmail != null) + def findByUsername(username: Username, maybeUserId: Option[UserId]): DBIO[Profile] = { + require(username != null && maybeUserId != null) for { user <- userRepo.findByUsername(username) - profile <- findByUserId(user.id, maybeCurrentUserEmail) + profile <- findByUserId(user.id, maybeUserId) } yield profile } - def findByUserId(userId: UserId, maybeCurrentUserEmail: Option[Email]): DBIO[Profile] = { - require(userId != null && maybeCurrentUserEmail != null) + def findByUserId(userId: UserId, maybeUserId: Option[UserId]): DBIO[Profile] = { + require(userId != null && maybeUserId != null) - findByUserIds(Set(userId), maybeCurrentUserEmail) + findByUserIds(Set(userId), maybeUserId) .map(profiles => profiles.head) } diff --git a/app/users/repositories/UserRepo.scala b/app/users/repositories/UserRepo.scala index d51554a..ccf9453 100644 --- a/app/users/repositories/UserRepo.scala +++ b/app/users/repositories/UserRepo.scala @@ -2,6 +2,7 @@ package users.repositories import java.time.Instant +import authentication.models.SecurityUserId import commons.exceptions.MissingModelException import commons.models.{Email, IdMetaModel, Property, Username} import commons.repositories._ @@ -16,6 +17,21 @@ import scala.concurrent.ExecutionContext class UserRepo(implicit private val ec: ExecutionContext) extends BaseRepo[UserId, User, UserTable] { + def findBySecurityUserIdOption(securityUserId: SecurityUserId): DBIO[Option[User]] = { + require(securityUserId != null) + + query + .filter(_.securityUserId === securityUserId) + .result + .headOption + } + + def findBySecurityUserId(securityUserId: SecurityUserId): DBIO[User] = { + findBySecurityUserIdOption(securityUserId) + .flatMap(maybeUser => DbioUtils.optionToDbio(maybeUser, + new MissingModelException(s"user with security user id $securityUserId"))) + } + def findByEmailOption(email: Email): DBIO[Option[User]] = { require(email != null) @@ -65,6 +81,8 @@ class UserRepo(implicit private val ec: ExecutionContext) extends BaseRepo[UserI class UserTable(tag: Tag) extends IdTable[UserId, User](tag, "users") with JavaTimeDbMappings { + def securityUserId: Rep[SecurityUserId] = column[SecurityUserId]("security_user_id") + def username: Rep[Username] = column[Username]("username") def email: Rep[Email] = column[Email]("email") @@ -77,6 +95,6 @@ class UserTable(tag: Tag) extends IdTable[UserId, User](tag, "users") def updatedAt: Rep[Instant] = column("updated_at") - def * : ProvenShape[User] = (id, username, email, bio.?, image.?, createdAt, updatedAt) <> ((User.apply _).tupled, + def * : ProvenShape[User] = (id, securityUserId, username, email, bio.?, image.?, createdAt, updatedAt) <> ((User.apply _).tupled, User.unapply) } \ No newline at end of file diff --git a/app/users/services/ProfileService.scala b/app/users/services/ProfileService.scala index 05c7a0c..3bf68ad 100644 --- a/app/users/services/ProfileService.scala +++ b/app/users/services/ProfileService.scala @@ -1,11 +1,11 @@ package users.services -import commons.models.{Email, Username} -import commons.repositories.DateTimeProvider import authentication.api._ -import users.models.{FollowAssociation, FollowAssociationId, Profile, User} -import users.repositories.{FollowAssociationRepo, ProfileRepo, UserRepo} +import commons.models.Username +import commons.repositories.DateTimeProvider import slick.dbio.DBIO +import users.models.{FollowAssociation, FollowAssociationId, Profile, UserId} +import users.repositories.{FollowAssociationRepo, ProfileRepo, UserRepo} import scala.concurrent.ExecutionContext @@ -18,45 +18,43 @@ private[users] class ProfileService(userRepo: UserRepo, profileRepo: ProfileRepo, implicit private val ec: ExecutionContext) { - def unfollow(followedUsername: Username, followerEmail: Email): DBIO[Profile] = { - require(followedUsername != null && followerEmail != null) + def unfollow(followedUsername: Username, followerId: UserId): DBIO[Profile] = { + require(followedUsername != null && followerId != null) for { - follower <- userRepo.findByEmail(followerEmail) followed <- userRepo.findByUsername(followedUsername) - _ <- deleteFollowAssociation(follower, followed) + _ <- deleteFollowAssociation(followerId, followed.id) } yield Profile(followed, following = false) } - private def deleteFollowAssociation(follower: User, followed: User) = { - followAssociationRepo.findByFollowerAndFollowed(follower.id, followed.id) + private def deleteFollowAssociation(followerId: UserId, followedId: UserId) = { + followAssociationRepo.findByFollowerAndFollowed(followerId, followedId) .map(_.map(followAssociation => followAssociationRepo.delete(followAssociation.id))) } - def follow(followedUsername: Username, followerEmail: Email): DBIO[Profile] = { - require(followedUsername != null && followerEmail != null) + def follow(followedUsername: Username, followerId: UserId): DBIO[Profile] = { + require(followedUsername != null && followerId != null) for { - follower <- userRepo.findByEmail(followerEmail) followed <- userRepo.findByUsername(followedUsername) - _ <- createFollowAssociation(follower, followed) + _ <- createFollowAssociation(followerId, followed.id) } yield Profile(followed, following = true) } - private def createFollowAssociation(follower: User, followed: User) = { - followAssociationRepo.findByFollowerAndFollowed(follower.id, followed.id) + private def createFollowAssociation(followerId: UserId, followedId: UserId) = { + followAssociationRepo.findByFollowerAndFollowed(followerId, followedId) .flatMap(maybeFollowAssociation => if (maybeFollowAssociation.isDefined) DBIO.successful(()) else { - val followAssociation = FollowAssociation(FollowAssociationId(-1), follower.id, followed.id) + val followAssociation = FollowAssociation(FollowAssociationId(-1), followerId, followedId) followAssociationRepo.insert(followAssociation) }) } - def findByUsername(username: Username, userContext: Option[Email]): DBIO[Profile] = { - require(username != null && userContext != null) + def findByUsername(username: Username, maybeUserId: Option[UserId]): DBIO[Profile] = { + require(username != null && maybeUserId != null) - profileRepo.findByUsername(username, userContext) + profileRepo.findByUsername(username, maybeUserId) } } \ No newline at end of file diff --git a/app/users/services/UserRegistrationService.scala b/app/users/services/UserRegistrationService.scala index ebfd43e..2e995f5 100644 --- a/app/users/services/UserRegistrationService.scala +++ b/app/users/services/UserRegistrationService.scala @@ -20,11 +20,11 @@ private[users] class UserRegistrationService(userRegistrationValidator: UserRegi private val defaultImage = Some(config.get[String]("app.defaultImage")) - def register(userRegistration: UserRegistration): DBIO[(User, SecurityUserId)] = { + def register(userRegistration: UserRegistration): DBIO[User] = { for { _ <- validate(userRegistration) - userAndSecurityUserId <- doRegister(userRegistration) - } yield userAndSecurityUserId + user <- doRegister(userRegistration) + } yield user } private def validate(userRegistration: UserRegistration) = { @@ -37,9 +37,10 @@ private[users] class UserRegistrationService(userRegistrationValidator: UserRegi for { securityUser <- securityUserCreator.create(newSecurityUser) now = dateTimeProvider.now - user = User(UserId(-1), userRegistration.username, userRegistration.email, null, defaultImage, now, now) + user = User(UserId(-1), securityUser.id, userRegistration.username, userRegistration.email, null, defaultImage, + now, now) savedUser <- userRepo.insertAndGet(user) - } yield (savedUser, securityUser.id) + } yield savedUser } } diff --git a/app/users/services/UserService.scala b/app/users/services/UserService.scala index 1e205fe..29c4819 100644 --- a/app/users/services/UserService.scala +++ b/app/users/services/UserService.scala @@ -1,14 +1,14 @@ package users.services +import authentication.api._ +import authentication.models.SecurityUserId import commons.exceptions.ValidationException import commons.models.Email import commons.repositories.DateTimeProvider import commons.utils.DbioUtils -import authentication.api._ -import authentication.models.SecurityUser +import slick.dbio.DBIO import users.models._ import users.repositories.UserRepo -import slick.dbio.DBIO import scala.concurrent.ExecutionContext @@ -19,6 +19,12 @@ private[users] class UserService(userRepo: UserRepo, userUpdateValidator: UserUpdateValidator, implicit private val ec: ExecutionContext) { + def getUserDetails(userId: UserId): DBIO[UserDetails] = { + require(userId != null) + + userRepo.findById(userId) + .map(UserDetails(_)) + } def getUserDetails(email: Email): DBIO[UserDetails] = { require(email != null) @@ -37,18 +43,18 @@ private[users] class UserService(userRepo: UserRepo, userRepo.updateAndGet(updateUser) } - private def updateSecurityUser(currentEmail: Email, userUpdate: UserUpdate) = { - securityUserUpdater.update(currentEmail, SecurityUserUpdate(userUpdate.email, userUpdate.password)) + private def updateSecurityUser(securityUserId: SecurityUserId, userUpdate: UserUpdate) = { + securityUserUpdater.update(securityUserId, SecurityUserUpdate(userUpdate.email, userUpdate.password)) } - def update(currentEmail: Email, userUpdate: UserUpdate): DBIO[UserDetails] = { - require(currentEmail != null && userUpdate != null) + def update(userId: UserId, userUpdate: UserUpdate): DBIO[UserDetails] = { + require(userId != null && userUpdate != null) for { - user <- userRepo.findByEmail(currentEmail) + user <- userRepo.findById(userId) _ <- validate(userUpdate, user) updatedUser <- updateUser(user, userUpdate) - _ <- updateSecurityUser(currentEmail, userUpdate) + _ <- updateSecurityUser(user.securityUserId, userUpdate) } yield UserDetails(updatedUser) } diff --git a/build.sbt b/build.sbt index cb2e523..81eb0b4 100644 --- a/build.sbt +++ b/build.sbt @@ -5,9 +5,9 @@ version := "1.0" lazy val root = (project in file(".")) .enablePlugins(PlayScala) -scalaVersion := "2.12.7" +scalaVersion := "2.13.0" -javacOptions ++= Seq("-source", "1.8", "-target", "1.8") +javacOptions ++= Seq("-source", "11", "-target", "11") libraryDependencies ++= Seq( filters, @@ -15,21 +15,19 @@ libraryDependencies ++= Seq( ws, ehcache, cacheApi, - "com.typesafe.play" %% "play-json" % "2.6.10", - "org.julienrf" %% "play-json-derived-codecs" % "4.0.1", - "com.typesafe.play" %% "play-slick" % "3.0.3", - "com.typesafe.play" %% "play-slick-evolutions" % "3.0.3", + "com.typesafe.play" %% "play-json" % "2.7.4", + "org.julienrf" %% "play-json-derived-codecs" % "6.0.0", + "com.typesafe.play" %% "play-slick" % "4.0.2", + "com.typesafe.play" %% "play-slick-evolutions" % "4.0.2", "commons-validator" % "commons-validator" % "1.6", - "com.github.slugify" % "slugify" % "2.2", - "com.h2database" % "h2" % "1.4.197", + "com.github.slugify" % "slugify" % "2.4", + "com.h2database" % "h2" % "1.4.199", "org.mindrot" % "jbcrypt" % "0.4", - "org.pac4j" %% "play-pac4j" % "5.0.0", - "org.pac4j" % "pac4j-jwt" % "2.3.1", - "org.pac4j" % "pac4j-http" % "2.3.1", + "org.apache.commons" % "commons-lang3" % "3.9", - "com.softwaremill.macwire" %% "macros" % "2.3.1" % "provided", + "com.softwaremill.macwire" %% "macros" % "2.3.3" % "provided", - "org.scalatestplus.play" %% "scalatestplus-play" % "3.1.2" % "test", + "org.scalatestplus.play" %% "scalatestplus-play" % "4.0.3" % "test", ) fork in run := true \ No newline at end of file diff --git a/conf/application.conf b/conf/application.conf index f02b75a..fd268eb 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -26,7 +26,7 @@ slick.dbs.default = { profile = "slick.jdbc.H2Profile$" db = { driver = org.h2.Driver - url = "jdbc:h2:mem:play;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false" + url = "jdbc:h2:mem:play;DATABASE_TO_UPPER=false" user = "user" password = "" } diff --git a/conf/evolutions/default/1.sql b/conf/evolutions/default/1.sql index 422dfd8..7ac4a8d 100644 --- a/conf/evolutions/default/1.sql +++ b/conf/evolutions/default/1.sql @@ -2,25 +2,28 @@ # --- !Ups +CREATE TABLE security_users ( + id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL, + password VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT security_user_email_unique UNIQUE (email) +); + CREATE TABLE users ( id INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY, + security_user_id INT(11) NOT NULL, username VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, bio VARCHAR(1024), image VARCHAR(255), - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT user_email_unique UNIQUE (email), - CONSTRAINT user_username_unique UNIQUE (username) -); - -CREATE TABLE security_users ( - id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY, - email VARCHAR(255) NOT NULL, - password VARCHAR(255) NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - CONSTRAINT security_user_email_unique UNIQUE (email) + CONSTRAINT user_username_unique UNIQUE (username), + CONSTRAINT user_security_user_id UNIQUE (security_user_id), + FOREIGN KEY (security_user_id) REFERENCES security_users(id) ); CREATE TABLE articles ( @@ -29,8 +32,8 @@ CREATE TABLE articles ( title VARCHAR(300) NOT NULL, description VARCHAR(255) NOT NULL, body TEXT NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, author_id INTEGER NOT NULL, FOREIGN KEY (author_id) REFERENCES users(id), CONSTRAINT articles_slug_unique UNIQUE(slug) @@ -56,8 +59,8 @@ CREATE TABLE comments ( body VARCHAR(4096) NOT NULL, article_id INTEGER NOT NULL, author_id INTEGER NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, FOREIGN KEY (article_id) REFERENCES articles(id), FOREIGN KEY (author_id) REFERENCES users(id) ); diff --git a/project/build.properties b/project/build.properties index 5f528e4..1fc4b80 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.2.3 \ No newline at end of file +sbt.version=1.2.8 \ No newline at end of file diff --git a/project/plugins.sbt b/project/plugins.sbt index 70a10e5..2add8e3 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -4,4 +4,9 @@ resolvers += "Typesafe repository" at "http://repo.typesafe.com/typesafe/releases/" -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.19") \ No newline at end of file +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.7.3") + +addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.4.1") + +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.6.0") +addSbtPlugin("org.scoverage" % "sbt-coveralls" % "1.2.7") \ No newline at end of file diff --git a/test/articles/ArticleCreateTest.scala b/test/articles/ArticleCreateTest.scala index ccadf4f..663e4d0 100644 --- a/test/articles/ArticleCreateTest.scala +++ b/test/articles/ArticleCreateTest.scala @@ -5,12 +5,14 @@ import java.time.Instant import articles.models.ArticleWrapper import articles.test_helpers.Articles import commons.repositories.DateTimeProvider -import commons_test.test_helpers.{FixedDateTimeProvider, RealWorldWithServerBaseTest, WithArticleTestHelper, WithUserTestHelper} +import commons_test.test_helpers.RealWorldWithServerAndTestConfigBaseTest.RealWorldWithTestConfig +import commons_test.test_helpers.{FixedDateTimeProvider, RealWorldWithServerAndTestConfigBaseTest, WithArticleTestHelper, WithUserTestHelper} +import play.api.ApplicationLoader.Context import play.api.libs.ws.WSResponse import users.models.UserDetailsWithToken import users.test_helpers.UserRegistrations -class ArticleCreateTest extends RealWorldWithServerBaseTest with WithArticleTestHelper with WithUserTestHelper { +class ArticleCreateTest extends RealWorldWithServerAndTestConfigBaseTest with WithArticleTestHelper with WithUserTestHelper { val dateTime: Instant = Instant.now @@ -85,12 +87,15 @@ class ArticleCreateTest extends RealWorldWithServerBaseTest with WithArticleTest } override def createComponents: RealWorldWithTestConfig = { - new RealWorldWithTestConfigWithFixedDateTimeProvider + new RealWorldWithTestConfigWithFixedDateTimeProvider(new FixedDateTimeProvider(dateTime), context) } - class RealWorldWithTestConfigWithFixedDateTimeProvider extends RealWorldWithTestConfig { - override lazy val dateTimeProvider: DateTimeProvider = new FixedDateTimeProvider(dateTime) - } +} +class RealWorldWithTestConfigWithFixedDateTimeProvider(dtProvider: DateTimeProvider, context: Context) + extends RealWorldWithTestConfig(context) { + + override lazy val dateTimeProvider: DateTimeProvider = dtProvider } + diff --git a/test/articles/ArticleDeleteTest.scala b/test/articles/ArticleDeleteTest.scala index a7ba9cf..0ee5b25 100644 --- a/test/articles/ArticleDeleteTest.scala +++ b/test/articles/ArticleDeleteTest.scala @@ -2,13 +2,13 @@ package articles import articles.models.ArticleWithTags import articles.test_helpers.Articles -import commons_test.test_helpers.{RealWorldWithServerBaseTest, WithArticleTestHelper, WithUserTestHelper} +import commons_test.test_helpers.{RealWorldWithServerAndTestConfigBaseTest, WithArticleTestHelper, WithUserTestHelper} import core.tags.test_helpers.TagTestHelper import play.api.libs.ws.WSResponse import users.models.UserDetailsWithToken import users.test_helpers.UserRegistrations -class ArticleDeleteTest extends RealWorldWithServerBaseTest with WithArticleTestHelper with WithUserTestHelper { +class ArticleDeleteTest extends RealWorldWithServerAndTestConfigBaseTest with WithArticleTestHelper with WithUserTestHelper { def tagsTestHelper: TagTestHelper = new TagTestHelper(executionContext) diff --git a/test/articles/ArticleFavoriteTest.scala b/test/articles/ArticleFavoriteTest.scala index b4bbea2..b10dcd7 100644 --- a/test/articles/ArticleFavoriteTest.scala +++ b/test/articles/ArticleFavoriteTest.scala @@ -2,12 +2,12 @@ package articles import articles.models.{ArticleWithTags, ArticleWrapper} import articles.test_helpers.Articles -import commons_test.test_helpers.{RealWorldWithServerBaseTest, WithArticleTestHelper, WithUserTestHelper} +import commons_test.test_helpers.{RealWorldWithServerAndTestConfigBaseTest, WithArticleTestHelper, WithUserTestHelper} import play.api.libs.ws.WSResponse import users.models.UserDetailsWithToken import users.test_helpers.{UserRegistrations, UserTestHelper} -class ArticleFavoriteTest extends RealWorldWithServerBaseTest with WithArticleTestHelper with WithUserTestHelper { +class ArticleFavoriteTest extends RealWorldWithServerAndTestConfigBaseTest with WithArticleTestHelper with WithUserTestHelper { "Favorite article" should "mark article as favorited for current user" in await { for { diff --git a/test/articles/ArticleListTest.scala b/test/articles/ArticleListTest.scala index 55c7faf..a0cefc1 100644 --- a/test/articles/ArticleListTest.scala +++ b/test/articles/ArticleListTest.scala @@ -3,12 +3,12 @@ package articles import articles.models.{ArticlePage, ArticleWithTags, MainFeedPageRequest} import articles.test_helpers.{Articles, Tags} import commons.models.Username -import commons_test.test_helpers.{RealWorldWithServerBaseTest, WithArticleTestHelper, WithUserTestHelper} +import commons_test.test_helpers.{RealWorldWithServerAndTestConfigBaseTest, WithArticleTestHelper, WithUserTestHelper} import play.api.libs.ws.WSResponse import users.models.UserDetailsWithToken import users.test_helpers._ -class ArticleListTest extends RealWorldWithServerBaseTest with WithArticleTestHelper with WithUserTestHelper { +class ArticleListTest extends RealWorldWithServerAndTestConfigBaseTest with WithArticleTestHelper with WithUserTestHelper { it should "return single article with dragons tag and article count" in await { val newArticle = Articles.hotToTrainYourDragon.copy(tagList = Seq(Tags.dragons.name)) diff --git a/test/articles/ArticleUpdateTest.scala b/test/articles/ArticleUpdateTest.scala index b19fca5..6226477 100644 --- a/test/articles/ArticleUpdateTest.scala +++ b/test/articles/ArticleUpdateTest.scala @@ -2,11 +2,11 @@ package articles import articles.models.{ArticleUpdate, ArticleWithTags, ArticleWrapper} import articles.test_helpers.Articles -import commons_test.test_helpers.{RealWorldWithServerBaseTest, WithArticleTestHelper, WithUserTestHelper} +import commons_test.test_helpers.{RealWorldWithServerAndTestConfigBaseTest, WithArticleTestHelper, WithUserTestHelper} import users.models.UserDetailsWithToken import users.test_helpers.UserRegistrations -class ArticleUpdateTest extends RealWorldWithServerBaseTest with WithArticleTestHelper with WithUserTestHelper { +class ArticleUpdateTest extends RealWorldWithServerAndTestConfigBaseTest with WithArticleTestHelper with WithUserTestHelper { "Update article" should "update title and slug" in await { val newArticle = Articles.hotToTrainYourDragon diff --git a/test/articles/CommentCreateTest.scala b/test/articles/CommentCreateTest.scala index 8cf0f88..0e07af7 100644 --- a/test/articles/CommentCreateTest.scala +++ b/test/articles/CommentCreateTest.scala @@ -2,12 +2,12 @@ package articles import articles.models._ import articles.test_helpers.{Articles, Comments} -import commons_test.test_helpers.{RealWorldWithServerBaseTest, WithArticleTestHelper, WithUserTestHelper} +import commons_test.test_helpers.{RealWorldWithServerAndTestConfigBaseTest, WithArticleTestHelper, WithUserTestHelper} import play.api.libs.ws.WSResponse import users.models.UserDetailsWithToken import users.test_helpers.UserRegistrations -class CommentCreateTest extends RealWorldWithServerBaseTest with WithArticleTestHelper with WithUserTestHelper { +class CommentCreateTest extends RealWorldWithServerAndTestConfigBaseTest with WithArticleTestHelper with WithUserTestHelper { "Create comment" should "create comment for authenticated user" in await { val newArticle = Articles.hotToTrainYourDragon diff --git a/test/articles/CommentDeleteTest.scala b/test/articles/CommentDeleteTest.scala index ebdd03b..60c0a8e 100644 --- a/test/articles/CommentDeleteTest.scala +++ b/test/articles/CommentDeleteTest.scala @@ -2,11 +2,11 @@ package articles import articles.models.{ArticleWithTags, CommentList, CommentWithAuthor} import articles.test_helpers.{Articles, Comments} -import commons_test.test_helpers.{RealWorldWithServerBaseTest, WithArticleTestHelper, WithUserTestHelper} +import commons_test.test_helpers.{RealWorldWithServerAndTestConfigBaseTest, WithArticleTestHelper, WithUserTestHelper} import users.models.UserDetailsWithToken import users.test_helpers.UserRegistrations -class CommentDeleteTest extends RealWorldWithServerBaseTest with WithArticleTestHelper with WithUserTestHelper { +class CommentDeleteTest extends RealWorldWithServerAndTestConfigBaseTest with WithArticleTestHelper with WithUserTestHelper { "Delete comment" should "allow to delete authenticated user's comment" in await { val newArticle = Articles.hotToTrainYourDragon diff --git a/test/articles/CommentListTest.scala b/test/articles/CommentListTest.scala index ac36f01..b817273 100644 --- a/test/articles/CommentListTest.scala +++ b/test/articles/CommentListTest.scala @@ -2,12 +2,12 @@ package articles import articles.models.{ArticleWithTags, CommentList, CommentWithAuthor} import articles.test_helpers.{Articles, Comments} -import commons_test.test_helpers.{RealWorldWithServerBaseTest, WithArticleTestHelper, WithUserTestHelper} +import commons_test.test_helpers.{RealWorldWithServerAndTestConfigBaseTest, WithArticleTestHelper, WithUserTestHelper} import play.api.libs.ws.WSResponse import users.models.UserDetailsWithToken import users.test_helpers.UserRegistrations -class CommentListTest extends RealWorldWithServerBaseTest with WithArticleTestHelper with WithUserTestHelper { +class CommentListTest extends RealWorldWithServerAndTestConfigBaseTest with WithArticleTestHelper with WithUserTestHelper { "Comment list" should "return empty array if article does not have any comments" in await { for { diff --git a/test/commons/repositories/BaseRepoTest.scala b/test/commons/repositories/BaseRepoTest.scala index 17a4b99..c929e3d 100644 --- a/test/commons/repositories/BaseRepoTest.scala +++ b/test/commons/repositories/BaseRepoTest.scala @@ -5,7 +5,9 @@ import java.time.Instant import commons.models._ import commons.repositories.mappings.JavaTimeDbMappings import commons.services.ActionRunner +import commons_test.test_helpers.RealWorldWithServerAndTestConfigBaseTest.RealWorldWithTestConfig import commons_test.test_helpers.{ProgrammaticDateTimeProvider, RealWorldWithServerBaseTest, TestUtils} +import play.api.ApplicationLoader.Context import slick.lifted.{ProvenShape, Rep, Tag} class BaseRepoTest extends RealWorldWithServerBaseTest { @@ -13,15 +15,14 @@ class BaseRepoTest extends RealWorldWithServerBaseTest { val dateTime: Instant = Instant.now val programmaticDateTimeProvider: ProgrammaticDateTimeProvider = new ProgrammaticDateTimeProvider - def testModelRepo(implicit components: AppWithTestRepo): TestModelRepo = components.testModelRepo + override type TestComponents = AppWithTestRepo - override implicit def components: AppWithTestRepo = new AppWithTestRepo - - implicit def actionRunner(implicit testComponents: RealWorldWithTestConfig): ActionRunner = { - testComponents.actionRunner + override def createComponents: TestComponents = { + new AppWithTestRepo(programmaticDateTimeProvider, context) } "Base repo" should "sort by id desc by default" in runAndAwait { + val testModelRepo = components.testModelRepo val apple = testModelRepo.createBlocking(NewTestModel("apple", 21).toTestModel(dateTime)) val orange = testModelRepo.createBlocking(NewTestModel("orange", 12).toTestModel(dateTime)) @@ -38,9 +39,10 @@ class BaseRepoTest extends RealWorldWithServerBaseTest { elem3.mustBe(apple) case _ => fail() }) - } + }(components.actionRunner) it should "sort by age desc and id asc" in runAndAwait { + val testModelRepo = components.testModelRepo // given val apple = testModelRepo.createBlocking(TestModel(TestModelId(-1), "apple", 1, dateTime, dateTime)) val orange = testModelRepo.createBlocking(TestModel(TestModelId(-1), "orange", 5, dateTime, dateTime)) @@ -58,9 +60,10 @@ class BaseRepoTest extends RealWorldWithServerBaseTest { elem3.mustBe(apple) case _ => fail() }) - } + }(components.actionRunner) it should "set modified at date time when updated" in runAndAwait { + val testModelRepo = components.testModelRepo // given programmaticDateTimeProvider.currentTime = dateTime val apple = testModelRepo.createBlocking(TestModel(TestModelId(-1), "apple", 1, dateTime, dateTime)) @@ -78,31 +81,19 @@ class BaseRepoTest extends RealWorldWithServerBaseTest { result.createdAt.mustBe(dateTime) result.updatedAt.mustBe(laterDateTime) }) - } - - override protected def beforeEach(): Unit = { - super.beforeEach() - - val testModelRepo = components.testModelRepo - runAndAwait(testModelRepo.createTable)(components.actionRunner) - } - - override protected def afterEach(): Unit = { - super.afterEach() + }(components.actionRunner) - val testModelRepo = components.testModelRepo - runAndAwait(testModelRepo.dropTable)(components.actionRunner) + override def afterServerStarted(): Unit = { + runAndAwait(components.testModelRepo.createTable)(components.actionRunner) } +} - class AppWithTestRepo extends RealWorldWithTestConfig { - - override lazy val dateTimeProvider: ProgrammaticDateTimeProvider = programmaticDateTimeProvider - - lazy val testModelRepo: TestModelRepo = new TestModelRepo(actionRunner) - } +class AppWithTestRepo(dateTimeProvider: ProgrammaticDateTimeProvider, context: Context) extends RealWorldWithTestConfig(context) { + lazy val testModelRepo: TestModelRepo = new TestModelRepo(actionRunner) } + case class TestModel(id: TestModelId, name: String, age: Int, @@ -147,8 +138,8 @@ class TestModelRepo(private val actionRunner: ActionRunner) id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, age INT NOT NULL, - created_at DATETIME, - updated_at DATETIME + created_at TIMESTAMP WITH TIME ZONE, + updated_at TIMESTAMP WITH TIME ZONE ); """ diff --git a/test/commons_test/test_helpers/BaseOneServerPerTest_WithBeforeAndAfterHooks.scala b/test/commons_test/test_helpers/BaseOneServerPerTest_WithBeforeAndAfterHooks.scala new file mode 100644 index 0000000..beb1c1f --- /dev/null +++ b/test/commons_test/test_helpers/BaseOneServerPerTest_WithBeforeAndAfterHooks.scala @@ -0,0 +1,87 @@ +/* + * Copyright 2001-2016 Artima, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package commons_test.test_helpers + +import org.scalatest._ +import org.scalatestplus.play.{FakeApplicationFactory, ServerProvider} +import play.api.Application +import play.api.test._ + +/** + * Copy of BaseOneServerPerTest from scalatest plus for playframework. Two changes were made: first change adds + * "before" and "after hooks. The may be required by testes to handle context with server running. + * Second change disables synchronization. It uses whole instance of this class as a lock, deadlock is possible, when + * mixins to test suit contains lazy vals. Tests in this example projects makes are not parallel anyway, + * so synchronization is not required. + */ +trait BaseOneServerPerTest_WithBeforeAndAfterHooks extends TestSuiteMixin with ServerProvider { + this: TestSuite with FakeApplicationFactory => + + @volatile private var privateApp: Application = _ + @volatile private var privateServer: RunningServer = _ + + implicit final def app: Application = { + val a = privateApp + if (a == null) { + throw new IllegalStateException("Test isn't running yet so application is not available") + } + a + } + + implicit final def runningServer: RunningServer = { + val rs = privateServer + if (rs == null) { + throw new IllegalStateException("Test isn't running yet so the server endpoints are not available") + } + privateServer + } + + abstract override def withFixture(test: NoArgTest): Outcome = { + // Need to synchronize within a suite because we store current app/server in fields in the class + // Could possibly pass app/server info in a ScalaTest object? +// synchronized { // synchronization is disabled. Only sequential tests are supported. + privateApp = newAppForTest(test) + privateServer = newServerForTest(app, test) + afterServerStarted() + try super.withFixture(test) finally { + try { + beforeServerStopped() + } catch { + case e: Throwable => + System.err.println(s"Test could not be cleaned up: $e") + // continue to release resources + } + val rs = privateServer // Store before nulling fields + privateApp = null + privateServer = null + // Stop server and release locks + rs.stopServer.close() +// } + } + } + + + def newAppForTest(testData: TestData): Application = fakeApplication() + + protected def newServerForTest(app: Application, testData: TestData): RunningServer = + DefaultTestServerFactory.start(app) + + def afterServerStarted(): Unit = () + + def beforeServerStopped(): Unit = () + +} + diff --git a/test/commons_test/test_helpers/OneServerPerTestWithComponents_FixedForCompileTimeTestSetUp.scala b/test/commons_test/test_helpers/OneServerPerTestWithComponents_FixedForCompileTimeTestSetUp.scala new file mode 100644 index 0000000..b6b27b8 --- /dev/null +++ b/test/commons_test/test_helpers/OneServerPerTestWithComponents_FixedForCompileTimeTestSetUp.scala @@ -0,0 +1,18 @@ +package commons_test.test_helpers + +import org.scalatest.TestSuite +import org.scalatestplus.play.FakeApplicationFactory +import play.api.Application + +/** + * Copy of OneServerPerTestWithComponents from scalatest plus for playframework. Mixins fixed classes. Fixes were needed + * to set up compile time tests setup. + **/ +trait OneServerPerTestWithComponents_FixedForCompileTimeTestSetUp + extends BaseOneServerPerTest_WithBeforeAndAfterHooks + with WithApplicationComponents_FixedForCompileTimeTestSetUp + with FakeApplicationFactory { + this: TestSuite => + + override def fakeApplication(): Application = newApplication +} diff --git a/test/commons_test/test_helpers/RealWorldWithServerAndTestConfigBaseTest.scala b/test/commons_test/test_helpers/RealWorldWithServerAndTestConfigBaseTest.scala new file mode 100644 index 0000000..51f1775 --- /dev/null +++ b/test/commons_test/test_helpers/RealWorldWithServerAndTestConfigBaseTest.scala @@ -0,0 +1,58 @@ +package commons_test.test_helpers + +import java.util.concurrent.Executors + +import commons_test.test_helpers.RealWorldWithServerAndTestConfigBaseTest.RealWorldWithTestConfig +import commons_test.test_helpers.WsScalaTestClientWithHost.TestWsClient +import config.RealWorldComponents +import org.scalatest._ +import play.api.ApplicationLoader.Context +import play.api.Configuration +import play.api.http.Status +import play.api.test.DefaultAwaitTimeout + +import scala.concurrent.ExecutionContext + +trait RealWorldWithServerBaseTest extends FlatSpec + with MustMatchers + with OptionValues + with WsScalaTestClientWithHost + with OneServerPerTestWithComponents_FixedForCompileTimeTestSetUp + with Status + with DefaultAwaitTimeout + with WithAwaitUtilities + with WithTestExecutionContext { + + override implicit val executionContext: ExecutionContext = ExecutionContext.fromExecutorService(Executors.newFixedThreadPool(1)) + + implicit val host: Host = Host("http://localhost:") + + override type TestComponents <: RealWorldWithTestConfig + + implicit def wsClientWithConnectionData: TestWsClient = { + TestWsClient(host, portNumber, components.wsClient) + } + +} + +object RealWorldWithServerAndTestConfigBaseTest { + + class RealWorldWithTestConfig(context: Context) extends RealWorldComponents(context) { + + override def configuration: Configuration = { + val testConfig = Configuration.from(TestUtils.config) + val config = super.configuration + config ++ testConfig + } + + } + +} + +class RealWorldWithServerAndTestConfigBaseTest extends RealWorldWithServerBaseTest { + override type TestComponents = RealWorldWithTestConfig + + override def createComponents: TestComponents = { + new RealWorldWithTestConfig(context) + } +} \ No newline at end of file diff --git a/test/commons_test/test_helpers/RealWorldWithServerBaseTest.scala b/test/commons_test/test_helpers/RealWorldWithServerBaseTest.scala deleted file mode 100644 index df2374c..0000000 --- a/test/commons_test/test_helpers/RealWorldWithServerBaseTest.scala +++ /dev/null @@ -1,61 +0,0 @@ -package commons_test.test_helpers - -import commons_test.test_helpers.WsScalaTestClientWithHost.TestWsClient -import config.RealWorldComponents -import org.scalatest._ -import org.scalatestplus.play.components.OneServerPerTestWithComponents -import play.api.Configuration -import play.api.db.evolutions.Evolutions -import play.api.http.Status -import play.api.test.DefaultAwaitTimeout - -import scala.concurrent.{ExecutionContext, Future} - -trait RealWorldWithServerBaseTest extends FlatSpec - with MustMatchers - with OptionValues - with WsScalaTestClientWithHost - with OneServerPerTestWithComponents - with Status - with DefaultAwaitTimeout - with WithAwaitUtilities - with BeforeAndAfterEach - with WithTestExecutionContext { - - override implicit val executionContext: ExecutionContext = scala.concurrent.ExecutionContext.Implicits.global - - implicit val host: Host = Host("http://localhost:") - - implicit var testComponents: RealWorldWithTestConfig = _ - - override def components: RealWorldWithTestConfig = { - testComponents = createComponents - testComponents - } - - implicit def wsClientWithConnectionData: TestWsClient = { - TestWsClient(host, portNumber, testComponents.wsClient) - } - - def createComponents: RealWorldWithTestConfig = { - new RealWorldWithTestConfig - } - - class RealWorldWithTestConfig extends RealWorldComponents(context) { - - override def configuration: Configuration = { - val testConfig = Configuration.from(TestUtils.config) - val config = super.configuration - config ++ testConfig - } - - applicationLifecycle.addStopHook(() => { - Future(cleanUpInMemDb()) - }) - - private def cleanUpInMemDb(): Unit = { - Evolutions.cleanupEvolutions(dbApi.database("default")) - } - - } -} diff --git a/test/commons_test/test_helpers/TestUtils.scala b/test/commons_test/test_helpers/TestUtils.scala index 6f128f4..6bef902 100644 --- a/test/commons_test/test_helpers/TestUtils.scala +++ b/test/commons_test/test_helpers/TestUtils.scala @@ -1,9 +1,6 @@ package commons_test.test_helpers import commons.services.ActionRunner -import play.api.Application -import play.api.inject.guice.GuiceApplicationBuilder -import play.api.routing.Router import slick.dbio.DBIO import scala.concurrent.duration.{Duration, DurationInt} @@ -11,25 +8,16 @@ import scala.concurrent.{Await, Future} object TestUtils { - val config = Map( + val config: Map[String, String] = Map( "play.evolutions.enabled" -> "true", "play.evolutions.autoApply" -> "true", "slick.dbs.default.profile" -> "slick.jdbc.H2Profile$", "slick.dbs.default.db.driver" -> "org.h2.Driver", - "slick.dbs.default.db.url" -> "jdbc:h2:mem:play;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false", + "slick.dbs.default.db.url" -> "jdbc:h2:mem:play;DATABASE_TO_UPPER=false", "slick.dbs.default.db.user" -> "user", "slick.dbs.default.db.password" -> "" ) - def appWithEmbeddedDb: Application = new GuiceApplicationBuilder() - .configure(config) - .build - - def appWithEmbeddedDbWithFakeRoutes(router: Router): Application = new GuiceApplicationBuilder() - .configure(config) - .router(router) - .build - def runAndAwaitResult[T](action: DBIO[T])(implicit actionRunner: ActionRunner, duration: Duration = new DurationInt(1).minute): T = { val future: Future[T] = actionRunner.runTransactionally(action) diff --git a/test/commons_test/test_helpers/WithApplicationComponents_FixedForCompileTimeTestSetUp.scala b/test/commons_test/test_helpers/WithApplicationComponents_FixedForCompileTimeTestSetUp.scala new file mode 100644 index 0000000..13a7c11 --- /dev/null +++ b/test/commons_test/test_helpers/WithApplicationComponents_FixedForCompileTimeTestSetUp.scala @@ -0,0 +1,35 @@ +package commons_test.test_helpers + +import play.api.{BuiltInComponents, _} + +/** + * Copy of WithApplicationComponents from scalatest plus for playframework. Changes were made to allow compile time + * tests setup. + **/ +trait WithApplicationComponents_FixedForCompileTimeTestSetUp { + + def createComponents: TestComponents + + type TestComponents <: BuiltInComponents + + /** + * has the same purpose as BaseOneServerPerTest.app but in compile time set up + */ + var components: TestComponents = _ + + /** + * additionally stores components instance for each test case. Needed to access app's dependencies in compile time set up + */ + final def newApplication: Application = { + components = createComponents + components.application + } + + /** + * changed to def from lazy val to properly clean up all resources after each test in compile time set up + */ + def context: ApplicationLoader.Context = { + val env = Environment.simple() + ApplicationLoader.Context.create(env) + } +} diff --git a/test/core/tags/TagListTest.scala b/test/core/tags/TagListTest.scala index cff2fda..bd84ab0 100644 --- a/test/core/tags/TagListTest.scala +++ b/test/core/tags/TagListTest.scala @@ -2,12 +2,12 @@ package core.tags import articles.models.{ArticleWithTags, NewTag, TagListWrapper} import articles.test_helpers.{Articles, Tags} -import commons_test.test_helpers.{RealWorldWithServerBaseTest, WithArticleTestHelper, WithUserTestHelper} +import commons_test.test_helpers.{RealWorldWithServerAndTestConfigBaseTest, WithArticleTestHelper, WithUserTestHelper} import play.api.libs.ws.WSResponse import users.models.UserDetailsWithToken import users.test_helpers.UserRegistrations -class TagListTest extends RealWorldWithServerBaseTest with WithArticleTestHelper with WithUserTestHelper { +class TagListTest extends RealWorldWithServerAndTestConfigBaseTest with WithArticleTestHelper with WithUserTestHelper { "Tags list" should "return empty array within wrapper when there are not any tags" in await { for { diff --git a/test/authentication/JwtAuthenticationTest.scala b/test/users/controllers/AuthenticationTest.scala similarity index 53% rename from test/authentication/JwtAuthenticationTest.scala rename to test/users/controllers/AuthenticationTest.scala index 951aa75..7cafd92 100644 --- a/test/authentication/JwtAuthenticationTest.scala +++ b/test/users/controllers/AuthenticationTest.scala @@ -1,10 +1,10 @@ -package authentication +package users.controllers -import authentication.api.AuthenticatedActionBuilder import authentication.exceptions.MissingOrInvalidCredentialsCode -import authentication.models.{AuthenticatedUser, HttpExceptionResponse} import com.softwaremill.macwire.wire -import commons_test.test_helpers.{ProgrammaticDateTimeProvider, RealWorldWithServerBaseTest, WithUserTestHelper} +import commons_test.test_helpers.RealWorldWithServerAndTestConfigBaseTest.RealWorldWithTestConfig +import commons_test.test_helpers.{ProgrammaticDateTimeProvider, RealWorldWithServerAndTestConfigBaseTest, WithUserTestHelper} +import play.api.ApplicationLoader.Context import play.api.http.HeaderNames import play.api.libs.json._ import play.api.mvc._ @@ -15,7 +15,7 @@ import users.test_helpers.UserRegistrations import scala.concurrent.ExecutionContext -class JwtAuthenticationTest extends RealWorldWithServerBaseTest with WithUserTestHelper { +class AuthenticationTest extends RealWorldWithServerAndTestConfigBaseTest with WithUserTestHelper { val fakeApiPath: String = "test" @@ -43,7 +43,7 @@ class JwtAuthenticationTest extends RealWorldWithServerBaseTest with WithUserTes it should "block request with invalid jwt token" in await { for { response <- wsUrl(s"/$fakeApiPath/authenticationRequired") - .addHttpHeaders(HeaderNames.AUTHORIZATION -> "Token invalidJwtToken") + .addHttpHeaders(HeaderNames.AUTHORIZATION -> "TokenS invalidJwtToken") .get() } yield { response.status.mustBe(UNAUTHORIZED) @@ -60,39 +60,39 @@ class JwtAuthenticationTest extends RealWorldWithServerBaseTest with WithUserTes .get() } yield { response.status.mustBe(OK) - response.json.as[AuthenticatedUser].email.mustBe(userDetailsWithToken.email) } } - override def createComponents: RealWorldWithTestConfig = new RealWorldWithTestConfig { + override def createComponents: RealWorldWithTestConfig = + new JwtAuthenticationTestComponents(programmaticDateTimeProvider, context) +} - lazy val authenticationTestController: AuthenticationTestController = wire[AuthenticationTestController] +class AuthenticationTestController(authenticatedAction: AuthenticatedActionBuilder, + components: ControllerComponents, + implicit private val ex: ExecutionContext) + extends AbstractController(components) { - override lazy val router: Router = { - val testControllerRoutes: PartialFunction[RequestHeader, Handler] = { - case GET(p"/test/public") => authenticationTestController.public - case GET(p"/test/authenticationRequired") => authenticationTestController.authenticated - } - - Router.from(routes.orElse(testControllerRoutes)) - } + def public: Action[AnyContent] = Action { _ => + Results.Ok + } - override lazy val dateTimeProvider: ProgrammaticDateTimeProvider = programmaticDateTimeProvider + def authenticated: Action[AnyContent] = authenticatedAction { request => + Ok(Json.toJson(request.user.securityUserId.value)) } - class AuthenticationTestController(authenticatedAction: AuthenticatedActionBuilder, - components: ControllerComponents, - implicit private val ex: ExecutionContext) - extends AbstractController(components) { +} - def public: Action[AnyContent] = Action { _ => - Results.Ok - } +class JwtAuthenticationTestComponents(dateTimeProvider: ProgrammaticDateTimeProvider, context: Context) + extends RealWorldWithTestConfig(context) { + + lazy val authenticationTestController: AuthenticationTestController = wire[AuthenticationTestController] - def authenticated: Action[AnyContent] = authenticatedAction { request => - Ok(Json.toJson(request.user)) + override lazy val router: Router = { + val testControllerRoutes: PartialFunction[RequestHeader, Handler] = { + case GET(p"/test/public") => authenticationTestController.public + case GET(p"/test/authenticationRequired") => authenticationTestController.authenticated } + Router.from(routes.orElse(testControllerRoutes)) } - -} \ No newline at end of file +} diff --git a/test/users/controllers/LoginTest.scala b/test/users/controllers/LoginTest.scala index 1911cff..b728e8d 100644 --- a/test/users/controllers/LoginTest.scala +++ b/test/users/controllers/LoginTest.scala @@ -1,11 +1,11 @@ package users.controllers import authentication.models.PlainTextPassword -import commons_test.test_helpers.{RealWorldWithServerBaseTest, WithArticleTestHelper, WithUserTestHelper} +import commons_test.test_helpers.{RealWorldWithServerAndTestConfigBaseTest, WithArticleTestHelper, WithUserTestHelper} import users.models.{UserDetailsWithToken, UserDetailsWithTokenWrapper} import users.test_helpers.UserRegistrations -class LoginTest extends RealWorldWithServerBaseTest with WithArticleTestHelper with WithUserTestHelper { +class LoginTest extends RealWorldWithServerAndTestConfigBaseTest with WithArticleTestHelper with WithUserTestHelper { "Login" should "allow valid user and password" in await { val registration = UserRegistrations.petycjaRegistration @@ -15,7 +15,7 @@ class LoginTest extends RealWorldWithServerBaseTest with WithArticleTestHelper w response <- userTestHelper.login(registration.email, registration.password) } yield { response.status.mustBe(OK) - response.json.as[UserDetailsWithTokenWrapper].user.token.mustNot(equal("")) + response.json.as[UserDetailsWithTokenWrapper].user.token.mustNot(equal("")) } } diff --git a/test/users/controllers/UserRegistrationTest.scala b/test/users/controllers/UserRegistrationTest.scala index 759f51e..6ee91d7 100644 --- a/test/users/controllers/UserRegistrationTest.scala +++ b/test/users/controllers/UserRegistrationTest.scala @@ -3,11 +3,11 @@ package users.controllers import authentication.models.PlainTextPassword import commons.models.ValidationResultWrapper import commons.validations.constraints.{EmailAlreadyTakenViolation, MinLengthViolation, UsernameAlreadyTakenViolation} -import commons_test.test_helpers.RealWorldWithServerBaseTest +import commons_test.test_helpers.RealWorldWithServerAndTestConfigBaseTest import play.api.libs.ws.WSResponse import users.test_helpers.{UserRegistrations, UserTestHelper} -class UserRegistrationTest extends RealWorldWithServerBaseTest { +class UserRegistrationTest extends RealWorldWithServerAndTestConfigBaseTest { def userTestHelper: UserTestHelper = new UserTestHelper(executionContext) diff --git a/test/users/controllers/UserUpdateTest.scala b/test/users/controllers/UserUpdateTest.scala index 0e2823b..567d4a4 100644 --- a/test/users/controllers/UserUpdateTest.scala +++ b/test/users/controllers/UserUpdateTest.scala @@ -2,11 +2,11 @@ package users.controllers import authentication.models.PlainTextPassword import commons.models.{Email, Username} -import commons_test.test_helpers.{RealWorldWithServerBaseTest, WithUserTestHelper} +import commons_test.test_helpers.{RealWorldWithServerAndTestConfigBaseTest, WithUserTestHelper} import users.models.{UserDetailsWithToken, UserDetailsWithTokenWrapper, UserUpdate} import users.test_helpers.UserRegistrations -class UserUpdateTest extends RealWorldWithServerBaseTest with WithUserTestHelper { +class UserUpdateTest extends RealWorldWithServerAndTestConfigBaseTest with WithUserTestHelper { "User update" should "return updated user" in await { val registration = UserRegistrations.petycjaRegistration diff --git a/test/users/test_helpers/Users.scala b/test/users/test_helpers/Users.scala deleted file mode 100644 index f478cf5..0000000 --- a/test/users/test_helpers/Users.scala +++ /dev/null @@ -1,16 +0,0 @@ -package users.test_helpers - -import java.time.Instant - -import users.models.{User, UserId} -import users.test_helpers.UserRegistrations._ - -object Users { - val petycja: User = { - User(UserId(-1), petycjaRegistration.username, petycjaRegistration.email, None, None, Instant.now(), Instant.now()) - } - - val kopernik: User = { - User(UserId(-1), kopernikRegistration.username, kopernikRegistration.email, None, None, Instant.now(), Instant.now()) - } -} \ No newline at end of file