From 0108d496e020fae407f3331bedad6b37b96228c8 Mon Sep 17 00:00:00 2001 From: Fabrice Lecomte Date: Fri, 23 Aug 2019 09:41:41 +0200 Subject: [PATCH] feature #9: Create Voter for Article --- src/main/kotlin/fr/dcproject/Application.kt | 23 +++-- .../kotlin/fr/dcproject/entity/Citizen.kt | 2 +- .../kotlin/fr/dcproject/routes/Article.kt | 28 ++---- .../dcproject/security/voter/ArticleVoter.kt | 39 ++++++++ .../fr/dcproject/security/voter/Voter.kt | 89 +++++++++++++++++++ 5 files changed, 154 insertions(+), 27 deletions(-) create mode 100644 src/main/kotlin/fr/dcproject/security/voter/ArticleVoter.kt create mode 100644 src/main/kotlin/fr/dcproject/security/voter/Voter.kt diff --git a/src/main/kotlin/fr/dcproject/Application.kt b/src/main/kotlin/fr/dcproject/Application.kt index 202c080..543b1df 100644 --- a/src/main/kotlin/fr/dcproject/Application.kt +++ b/src/main/kotlin/fr/dcproject/Application.kt @@ -11,10 +11,13 @@ import fr.dcproject.entity.Citizen import fr.dcproject.entity.Constitution import fr.dcproject.entity.User import fr.dcproject.routes.* +import fr.dcproject.security.voter.ArticleVoter +import fr.dcproject.security.voter.AuthorizationVoter import fr.postgresjson.migration.Migrations import io.ktor.application.Application import io.ktor.application.install import io.ktor.auth.Authentication +import io.ktor.auth.authenticate import io.ktor.auth.jwt.jwt import io.ktor.features.AutoHeadResponse import io.ktor.features.CallLogging @@ -95,6 +98,12 @@ fun Application.module() { install(Locations) { } + install(AuthorizationVoter) { + voters = mutableListOf( + ArticleVoter() + ) + } + install(Authentication) { /** * Setup the JWT authentication to be used in [Routing]. @@ -131,12 +140,14 @@ fun Application.module() { } install(Routing) { - article(get()) - auth(get()) - citizen(get()) - constitution(get()) - followArticle(get()) - followConstitution(get()) + authenticate(optional = true) { + article(get()) + auth(get()) + citizen(get()) + constitution(get()) + followArticle(get()) + followConstitution(get()) + } } // TODO move to postgresJson lib diff --git a/src/main/kotlin/fr/dcproject/entity/Citizen.kt b/src/main/kotlin/fr/dcproject/entity/Citizen.kt index 3d8f5d8..b7cb8f3 100644 --- a/src/main/kotlin/fr/dcproject/entity/Citizen.kt +++ b/src/main/kotlin/fr/dcproject/entity/Citizen.kt @@ -11,7 +11,7 @@ class Citizen( id: UUID = UUID.randomUUID(), var name: Name?, var birthday: DateTime?, - var userId: String? = null, + var userId: UUID? = null, var voteAnnonymous: Boolean? = null, var followAnnonymous: Boolean? = null, var user: User? diff --git a/src/main/kotlin/fr/dcproject/routes/Article.kt b/src/main/kotlin/fr/dcproject/routes/Article.kt index a31c142..fe6d233 100644 --- a/src/main/kotlin/fr/dcproject/routes/Article.kt +++ b/src/main/kotlin/fr/dcproject/routes/Article.kt @@ -1,11 +1,9 @@ package fr.dcproject.routes import Paths -import io.ktor.application.ApplicationCall +import fr.dcproject.security.voter.ArticleVoter +import fr.dcproject.security.voter.assertCan import io.ktor.application.call -import io.ktor.auth.authenticate -import io.ktor.auth.authentication -import io.ktor.http.HttpStatusCode import io.ktor.locations.KtorExperimentalLocationsAPI import io.ktor.locations.get import io.ktor.locations.post @@ -13,11 +11,8 @@ import io.ktor.request.receive import io.ktor.response.respond import io.ktor.routing.Route import fr.dcproject.entity.Article as ArticleEntity -import fr.dcproject.entity.User as UserEntity import fr.dcproject.repository.Article as ArticleRepository -val ApplicationCall.user get() = authentication.principal() - @KtorExperimentalLocationsAPI fun Route.article(repo: ArticleRepository) { get { @@ -29,17 +24,10 @@ fun Route.article(repo: ArticleRepository) { call.respond(it.article) } - authenticate(optional = true) { - post() { - // TODO replace to voter - val user = call.user - if (user == null) { - call.respond(HttpStatusCode.Unauthorized) - } else { - val article = call.receive() - repo.upsert(article) - call.respond(article) - } - } + post { + call.assertCan(ArticleVoter.Action.CREATE) + val article = call.receive() + repo.upsert(article) + call.respond(article) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/fr/dcproject/security/voter/ArticleVoter.kt b/src/main/kotlin/fr/dcproject/security/voter/ArticleVoter.kt new file mode 100644 index 0000000..65d5220 --- /dev/null +++ b/src/main/kotlin/fr/dcproject/security/voter/ArticleVoter.kt @@ -0,0 +1,39 @@ +package fr.dcproject.security.voter + +import fr.dcproject.entity.Article +import fr.dcproject.entity.User +import io.ktor.application.ApplicationCall + +class ArticleVoter: Voter { + enum class Action: ActionI { + CREATE, + UPDATE, + VIEW, + DELETE + } + + override fun supports(action: ActionI, call: ApplicationCall, subject: Any?): Boolean { + return action is Action && subject is Article? + } + + override fun vote(action: ActionI, call: ApplicationCall, subject: Any?): Vote { + val user = call.user + if (action == Action.CREATE && user != null) { + return Vote.GRANTED + } + + if (action == Action.VIEW) { + return Vote.GRANTED + } + + if (action == Action.DELETE && user is User && subject is Article && subject.createdBy?.userId == user.id) { + return Vote.GRANTED + } + + if (action == Action.UPDATE && user is User && subject is Article && subject.createdBy?.userId == user.id) { + return Vote.GRANTED + } + + return Vote.ABSTAIN + } +} diff --git a/src/main/kotlin/fr/dcproject/security/voter/Voter.kt b/src/main/kotlin/fr/dcproject/security/voter/Voter.kt new file mode 100644 index 0000000..29f833a --- /dev/null +++ b/src/main/kotlin/fr/dcproject/security/voter/Voter.kt @@ -0,0 +1,89 @@ +package fr.dcproject.security.voter + +import fr.dcproject.entity.User +import io.ktor.application.ApplicationCall +import io.ktor.application.ApplicationCallPipeline +import io.ktor.application.ApplicationFeature +import io.ktor.auth.authentication +import io.ktor.http.HttpStatusCode +import io.ktor.response.respond +import io.ktor.util.AttributeKey +import io.ktor.util.KtorExperimentalAPI + +interface ActionI + +interface Voter { + fun supports(action: ActionI, call: ApplicationCall, subject: Any? = null): Boolean + fun vote(action: ActionI, call: ApplicationCall, subject: Any? = null): Vote +} + +fun List.can(action: ActionI, call: ApplicationCall, subject: Any? = null): Boolean { + val votes = this + .filter { it.supports(action, call, subject) } + .ifEmpty { throw NoVoterException(action) } + .map { it.vote(action, call, subject) } + + return votes.all { it in listOf(Vote.GRANTED, Vote.ABSTAIN) } and votes.any { it == Vote.GRANTED } +} + +enum class Vote { + GRANTED, + ABSTAIN, + DENIED +} + +private val votersAttributeKey = AttributeKey>("voters") + +fun ApplicationCall.assertCan(action: ActionI, subject: Any? = null) { + if (!can(action, subject)) { + throw UnauthorizedException(action) + } +} +fun ApplicationCall.can(action: ActionI, subject: Any? = null): Boolean { + val voters = attributes[votersAttributeKey] + + return voters.can(action, this, subject) +} + +abstract class VoterException(message: String) : Throwable(message) +class NoVoterException(action: ActionI) : VoterException("No voter found for action '$action'") +class UnauthorizedException(action: ActionI) : VoterException("Unauthorized for action '$action'") +class ForbiddenException : Throwable() + +val ApplicationCall.user get() = authentication.principal() + +class AuthorizationVoter { + + /** + * Configuration for [AuthorizationVoter] feature. + */ + class Configuration { + var voters = mutableListOf() + fun voter(voter: Voter) = voters.add(voter) + } + + /** + * Object for installing feature + */ + companion object Feature : ApplicationFeature { + + override val key = AttributeKey("Voter") + + @KtorExperimentalAPI + override fun install(pipeline: ApplicationCallPipeline, configure: Configuration.() -> Unit): AuthorizationVoter { + val configuration = Configuration().apply(configure) + + pipeline.intercept(ApplicationCallPipeline.Features) { + context.attributes.put(votersAttributeKey, configuration.voters) + + try { + proceed() + } catch (e: VoterException) { + context.respond(HttpStatusCode.Forbidden) + } + } + + return AuthorizationVoter() + } + } +}