From a6f25bcbb27be5cf587d2c1d31e80af5e2f4799d Mon Sep 17 00:00:00 2001 From: Fabrice Lecomte Date: Wed, 9 Oct 2019 21:57:56 +0200 Subject: [PATCH] Can login with SSO & change Password --- src/main/kotlin/fr/dcproject/Application.kt | 6 +-- src/main/kotlin/fr/dcproject/Configuration.kt | 10 +++-- src/main/kotlin/fr/dcproject/Module.kt | 2 + .../kotlin/fr/dcproject/messages/Mailer.kt | 11 +---- .../fr/dcproject/messages/SsoManager.kt | 41 +++++++++++++++++++ .../kotlin/fr/dcproject/repository/Citizen.kt | 6 +++ .../kotlin/fr/dcproject/repository/User.kt | 6 +++ src/main/kotlin/fr/dcproject/routes/Auth.kt | 26 ++++++++++-- .../kotlin/fr/dcproject/routes/Citizen.kt | 41 +++++++++++++++---- .../dcproject/security/voter/CitizenVoter.kt | 20 +++++++-- src/main/resources/application.conf | 1 + .../citizen/find_citizen_by_email.sql | 15 +++++++ .../sql/migrations/0000-init_schema.up.sql | 2 +- src/test/kotlin/MailerTest.kt | 19 ++++++--- .../kotlin/feature/KtorServerAuthSteps.kt | 7 ++-- src/test/resources/feature/citizen.feature | 25 +++++++++++ 16 files changed, 197 insertions(+), 41 deletions(-) create mode 100644 src/main/kotlin/fr/dcproject/messages/SsoManager.kt create mode 100644 src/main/resources/sql/functions/citizen/find_citizen_by_email.sql diff --git a/src/main/kotlin/fr/dcproject/Application.kt b/src/main/kotlin/fr/dcproject/Application.kt index 9cea0c3..81c5811 100644 --- a/src/main/kotlin/fr/dcproject/Application.kt +++ b/src/main/kotlin/fr/dcproject/Application.kt @@ -98,7 +98,7 @@ fun Application.module(env: Env = PROD) { decode { values, _ -> val id = values.singleOrNull()?.let { UUID.fromString(it) } ?: throw InternalError("Cannot convert $values to UUID") - get().findById(id) ?: throw InternalError("Citizen $values not found") + get().findById(id, true) ?: throw InternalError("Citizen $values not found") } } } @@ -156,8 +156,8 @@ fun Application.module(env: Env = PROD) { // trace { application.log.trace(it.buildText()) } authenticate(optional = true) { article(get()) - auth(get(), get()) - citizen(get()) + auth(get(), get(), get()) + citizen(get(), get()) constitution(get()) followArticle(get()) followConstitution(get()) diff --git a/src/main/kotlin/fr/dcproject/Configuration.kt b/src/main/kotlin/fr/dcproject/Configuration.kt index f261b68..5bdbe31 100644 --- a/src/main/kotlin/fr/dcproject/Configuration.kt +++ b/src/main/kotlin/fr/dcproject/Configuration.kt @@ -12,6 +12,7 @@ class Config { private var config = ConfigFactory.load() val sqlFiles = File(this::class.java.getResource("/sql").toURI()) val envName: String = config.getString("app.envName") + val domain: String = config.getString("app.domain") val host: String = config.getString("db.host") var database: String = config.getString("db.database") @@ -23,11 +24,12 @@ class Config { } object JwtConfig { - const val secret = "zAP5MBA4B4Ijz0MZaS48" - private const val issuer = "dc-project.fr" - private const val validityInMs = 36_000_00 * 10 // 10 hours + private const val secret = "zAP5MBA4B4Ijz0MZaS48" + const val issuer = "dc-project.fr" + private const val validityInMs = 3_600_000 * 10 // 10 hours + // TODO change to RSA512 - private val algorithm = Algorithm.HMAC512(secret) + val algorithm = Algorithm.HMAC512(secret) val verifier: JWTVerifier = JWT .require(algorithm) diff --git a/src/main/kotlin/fr/dcproject/Module.kt b/src/main/kotlin/fr/dcproject/Module.kt index 7c14aa6..e79e472 100644 --- a/src/main/kotlin/fr/dcproject/Module.kt +++ b/src/main/kotlin/fr/dcproject/Module.kt @@ -1,6 +1,7 @@ package fr.dcproject import fr.dcproject.messages.Mailer +import fr.dcproject.messages.SsoManager import fr.postgresjson.connexion.Connection import fr.postgresjson.connexion.Requester import fr.postgresjson.migration.Migrations @@ -50,4 +51,5 @@ val Module = module { single { Migrations(connection = get(), directory = config.sqlFiles) } single { Mailer(config.sendGridKey) } + single { SsoManager(get(), config.domain, get()) } } diff --git a/src/main/kotlin/fr/dcproject/messages/Mailer.kt b/src/main/kotlin/fr/dcproject/messages/Mailer.kt index fa02143..1862fb7 100644 --- a/src/main/kotlin/fr/dcproject/messages/Mailer.kt +++ b/src/main/kotlin/fr/dcproject/messages/Mailer.kt @@ -4,20 +4,13 @@ import com.sendgrid.Method import com.sendgrid.Request import com.sendgrid.SendGrid import com.sendgrid.helpers.mail.Mail -import com.sendgrid.helpers.mail.objects.Content -import com.sendgrid.helpers.mail.objects.Email import java.io.IOException class Mailer ( private val key: String ) { - fun sendEmail(from: String, to: String, content: String, subject: String): Boolean { - val mail = Mail( - Email(from), - subject, - Email(to), - Content("text/plain", content) - ) + fun sendEmail(action: () -> Mail): Boolean { + val mail = action() val sg = SendGrid(key) val request = Request() diff --git a/src/main/kotlin/fr/dcproject/messages/SsoManager.kt b/src/main/kotlin/fr/dcproject/messages/SsoManager.kt new file mode 100644 index 0000000..d29c734 --- /dev/null +++ b/src/main/kotlin/fr/dcproject/messages/SsoManager.kt @@ -0,0 +1,41 @@ +package fr.dcproject.messages + +import com.sendgrid.helpers.mail.Mail +import com.sendgrid.helpers.mail.objects.Content +import com.sendgrid.helpers.mail.objects.Email +import fr.dcproject.JwtConfig +import io.ktor.http.URLBuilder +import fr.dcproject.entity.Citizen as CitizenEntity +import fr.dcproject.repository.Citizen as CitizenRepository + +class SsoManager ( + private val mailer: Mailer, + private val domain: String, + private val citizenRepo: CitizenRepository +) { + fun sendMail(email: String, url: String) { + val citizen = citizenRepo.findByEmail(email) ?: error("No Citizen with this email") + mailer.sendEmail { + Mail( + Email("sso@$domain"), + "Connection", + Email(email), + Content("text/plain", generateContent(citizen, url)) + ).apply { + addContent(Content("text/html", generateHtmlContent(citizen, url))) + } + } + } + + private fun generateHtmlContent(citizen: CitizenEntity, url: String): String? { + val urlObject = URLBuilder(url) + urlObject.parameters.append("token", JwtConfig.makeToken(citizen.user ?: error("Citizen must have User"))) + return "Click here for connect to $domain" + } + + private fun generateContent(citizen: CitizenEntity, url: String): String { + val urlObject = URLBuilder(url) + urlObject.parameters.append("token", JwtConfig.makeToken(citizen.user ?: error("Citizen must have User"))) + return "Copy this link into your browser for connect to $domain: \n$urlObject" + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/dcproject/repository/Citizen.kt b/src/main/kotlin/fr/dcproject/repository/Citizen.kt index 3e858b7..2c40227 100644 --- a/src/main/kotlin/fr/dcproject/repository/Citizen.kt +++ b/src/main/kotlin/fr/dcproject/repository/Citizen.kt @@ -30,6 +30,12 @@ class Citizen(override var requester: Requester) : RepositoryI { .selectOne("username" to unsername) } + fun findByEmail(email: String): CitizenEntity? { + return requester + .getFunction("find_citizen_by_email") + .selectOne("email" to email) + } + fun find( page: Int = 1, limit: Int = 50, diff --git a/src/main/kotlin/fr/dcproject/repository/User.kt b/src/main/kotlin/fr/dcproject/repository/User.kt index b99c661..5fc40a7 100644 --- a/src/main/kotlin/fr/dcproject/repository/User.kt +++ b/src/main/kotlin/fr/dcproject/repository/User.kt @@ -32,6 +32,12 @@ class User(override var requester: Requester) : RepositoryI { .selectOne("resource" to user) } + fun changePassword(user: UserEntity) { + requester + .getFunction("change_user_password") + .sendQuery("resource" to user) + } + class UserNotFound(override val message: String?, override val cause: Throwable?): Throwable(message, cause) { constructor(id: UUID): this("No User with ID $id", null) } diff --git a/src/main/kotlin/fr/dcproject/routes/Auth.kt b/src/main/kotlin/fr/dcproject/routes/Auth.kt index 942e783..3536fd1 100644 --- a/src/main/kotlin/fr/dcproject/routes/Auth.kt +++ b/src/main/kotlin/fr/dcproject/routes/Auth.kt @@ -3,13 +3,19 @@ package fr.dcproject.routes import com.fasterxml.jackson.databind.exc.MismatchedInputException import fr.dcproject.JwtConfig import fr.dcproject.entity.User +import fr.dcproject.messages.SsoManager +import fr.dcproject.routes.AuthPaths.LoginRequest +import fr.dcproject.routes.AuthPaths.RegisterRequest +import fr.dcproject.routes.AuthPaths.SsoRequest import io.ktor.application.call import io.ktor.auth.UserPasswordCredential import io.ktor.features.BadRequestException +import io.ktor.http.HttpStatusCode import io.ktor.locations.KtorExperimentalLocationsAPI import io.ktor.locations.Location import io.ktor.locations.post import io.ktor.request.receive +import io.ktor.response.respond import io.ktor.response.respondText import io.ktor.routing.Route import io.ktor.util.KtorExperimentalAPI @@ -21,12 +27,19 @@ import fr.dcproject.repository.User as UserRepository object AuthPaths { @Location("/login") class LoginRequest @Location("/register") class RegisterRequest + @Location("/sso") class SsoRequest { + data class Content(val email: String, val url: String) + } } @KtorExperimentalLocationsAPI @KtorExperimentalAPI -fun Route.auth(userRepo: UserRepository, citizenRepo: CitizenRepository) { - post { +fun Route.auth( + userRepo: UserRepository, + citizenRepo: CitizenRepository, + ssoManager: SsoManager +) { + post { try { val credentials = call.receive() val user = userRepo.findByCredentials(credentials) ?: throw BadRequestException("Username not exist or password is wrong") @@ -36,11 +49,18 @@ fun Route.auth(userRepo: UserRepository, citizenRepo: CitizenRepository) { } } - post { + post { val citizen = call.receive() citizen.user?.roles = listOf(User.Roles.ROLE_USER) val created = citizenRepo.insertWithUser(citizen)?.user ?: throw BadRequestException("Bad request") call.respondText(JwtConfig.makeToken(created)) } + + post { + val content = call.receive() + ssoManager.sendMail(content.email, content.url) + + call.respond(HttpStatusCode.NoContent) + } } diff --git a/src/main/kotlin/fr/dcproject/routes/Citizen.kt b/src/main/kotlin/fr/dcproject/routes/Citizen.kt index 41f9049..c035574 100644 --- a/src/main/kotlin/fr/dcproject/routes/Citizen.kt +++ b/src/main/kotlin/fr/dcproject/routes/Citizen.kt @@ -2,46 +2,71 @@ package fr.dcproject.routes import fr.dcproject.citizen import fr.dcproject.entity.Citizen +import fr.dcproject.routes.CitizenPaths.ChangePasswordCitizenRequest +import fr.dcproject.routes.CitizenPaths.CitizenRequest +import fr.dcproject.routes.CitizenPaths.CitizensRequest +import fr.dcproject.routes.CitizenPaths.CurrentCitizenRequest +import fr.dcproject.security.voter.CitizenVoter.Action.CHANGE_PASSWORD import fr.dcproject.security.voter.CitizenVoter.Action.VIEW import fr.dcproject.security.voter.assertCan -import fr.postgresjson.repository.RepositoryI +import fr.postgresjson.repository.RepositoryI.Direction import io.ktor.application.call +import io.ktor.http.HttpStatusCode import io.ktor.locations.KtorExperimentalLocationsAPI import io.ktor.locations.Location import io.ktor.locations.get +import io.ktor.locations.put +import io.ktor.request.receive import io.ktor.response.respond import io.ktor.routing.Route import fr.dcproject.repository.Citizen as CitizenRepository +import fr.dcproject.repository.User as UserRepository @KtorExperimentalLocationsAPI object CitizenPaths { - @Location("/citizens") class CitizensRequest(page: Int = 1, limit: Int = 50, val sort: String? = null, val direction: RepositoryI.Direction? = null, val search: String? = null) { + @Location("/citizens") class CitizensRequest(page: Int = 1, limit: Int = 50, val sort: String? = null, val direction: Direction? = null, val search: String? = null) { val page: Int = if (page < 1) 1 else page val limit: Int = if (limit > 50) 50 else if (limit < 1) 1 else limit } @Location("/citizens/{citizen}") class CitizenRequest(val citizen: Citizen) @Location("/citizens/current") class CurrentCitizenRequest - @Location("/citizens/{citizen}/follows/articles") class CitizenFollowArticleRequest(val citizen: Citizen) - @Location("/citizens/{citizen}/follows/constitutions") class CitizenFollowConstitutionRequest(val citizen: Citizen) + @Location("/citizens/{citizen}/password/change") class ChangePasswordCitizenRequest(val citizen: Citizen) { + data class Content(val password: String) + } } @KtorExperimentalLocationsAPI -fun Route.citizen(repo: CitizenRepository) { - get { +fun Route.citizen( + repo: CitizenRepository, + userRepository: UserRepository +) { + get { val citizens = repo.find(it.page, it.limit, it.sort, it.direction, it.search) assertCan(VIEW, citizens.result) call.respond(citizens) } - get { + get { assertCan(VIEW, it.citizen) call.respond(it.citizen) } - get { + get { assertCan(VIEW, citizen) call.respond(citizen) } + + put { + assertCan(CHANGE_PASSWORD, it.citizen) + val content = call.receive() + + val user = it.citizen.user ?: error("Citizen must have User") + + user.plainPassword = content.password + userRepository.changePassword(user) + + call.respond(HttpStatusCode.Created) + } } \ No newline at end of file diff --git a/src/main/kotlin/fr/dcproject/security/voter/CitizenVoter.kt b/src/main/kotlin/fr/dcproject/security/voter/CitizenVoter.kt index dbbe09a..ec6c31b 100644 --- a/src/main/kotlin/fr/dcproject/security/voter/CitizenVoter.kt +++ b/src/main/kotlin/fr/dcproject/security/voter/CitizenVoter.kt @@ -3,19 +3,24 @@ package fr.dcproject.security.voter import fr.dcproject.entity.Citizen import fr.dcproject.entity.User import io.ktor.application.ApplicationCall +import io.ktor.locations.KtorExperimentalLocationsAPI +@KtorExperimentalLocationsAPI class CitizenVoter: Voter { enum class Action: ActionI { CREATE, UPDATE, VIEW, - DELETE + DELETE, + CHANGE_PASSWORD } override fun supports(action: ActionI, call: ApplicationCall, subject: Any?): Boolean { return (action is Action) - && - (subject is List<*> || subject is Citizen?) + && ( + subject is List<*> || + subject is Citizen? + ) } override fun vote(action: ActionI, call: ApplicationCall, subject: Any?): Vote { @@ -52,6 +57,15 @@ class CitizenVoter: Voter { return Vote.GRANTED } + if (action == Action.CHANGE_PASSWORD && user != null && subject is Citizen) { + val userToChange = subject.user ?: error("Citizen must have User") + return if (user.id == userToChange.id) { + Vote.GRANTED + } else { + Vote.ABSTAIN + } + } + if (action is Action) { return Vote.DENIED } diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index d13fee4..5f1a138 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -10,6 +10,7 @@ ktor { app { envName = prod + domain = dc-project.fr } db { diff --git a/src/main/resources/sql/functions/citizen/find_citizen_by_email.sql b/src/main/resources/sql/functions/citizen/find_citizen_by_email.sql new file mode 100644 index 0000000..ef3bb40 --- /dev/null +++ b/src/main/resources/sql/functions/citizen/find_citizen_by_email.sql @@ -0,0 +1,15 @@ +create or replace function find_citizen_by_email(_email text, out resource json) language plpgsql as +$$ +begin + select to_json(t) into resource + from ( + select + z.*, + find_user_by_id(z.user_id) as "user" + from citizen as z + where z.email = _email + ) as t; +end; +$$; + +-- drop function if exists find_citizen_by_email(text, out json); \ No newline at end of file diff --git a/src/main/resources/sql/migrations/0000-init_schema.up.sql b/src/main/resources/sql/migrations/0000-init_schema.up.sql index e0a0060..528fbd2 100644 --- a/src/main/resources/sql/migrations/0000-init_schema.up.sql +++ b/src/main/resources/sql/migrations/0000-init_schema.up.sql @@ -19,7 +19,7 @@ create table citizen user_id uuid not null references "user" (id) unique, vote_anonymous boolean default true not null, follow_anonymous boolean default true not null, - email text not null check ( email ~* '.+@.+\..+' ) + email text not null check ( email ~* '.+@.+\..+' ) unique ); create table workgroup diff --git a/src/test/kotlin/MailerTest.kt b/src/test/kotlin/MailerTest.kt index ae0ff0f..4642eb8 100644 --- a/src/test/kotlin/MailerTest.kt +++ b/src/test/kotlin/MailerTest.kt @@ -1,3 +1,6 @@ +import com.sendgrid.helpers.mail.Mail +import com.sendgrid.helpers.mail.objects.Content +import com.sendgrid.helpers.mail.objects.Email import fr.dcproject.Env import fr.dcproject.messages.Mailer import fr.dcproject.module @@ -17,12 +20,16 @@ class MailerTest: KoinTest, AutoCloseKoinTest() { @Test fun `can be send an email`() { withTestApplication({ module(Env.TEST) }) { - get().sendEmail( - "reset-password@dc-project.fr", - "fabrice.lecomte.be@gmail.com", - "Email Work !", - "Test" - ) + get().sendEmail { + Mail( + Email("sso@dc-project.fr"), + "Test", + Email("fabrice.lecomte.be@gmail.com"), + Content("text/plain", "Email Work !") + ).apply { + addContent(Content("text/html", "Email Work !")) + } + } } } } \ No newline at end of file diff --git a/src/test/kotlin/feature/KtorServerAuthSteps.kt b/src/test/kotlin/feature/KtorServerAuthSteps.kt index 630fd92..1e9d618 100644 --- a/src/test/kotlin/feature/KtorServerAuthSteps.kt +++ b/src/test/kotlin/feature/KtorServerAuthSteps.kt @@ -1,7 +1,6 @@ package feature import com.auth0.jwt.JWT -import com.auth0.jwt.algorithms.Algorithm import fr.dcproject.JwtConfig import fr.dcproject.entity.Citizen import fr.dcproject.entity.User @@ -31,7 +30,7 @@ class KtorServerAuthSteps: En, KoinTest { val citizen = Citizen( id = UUID.fromString(data["id"]), name = Citizen.Name(data["firstName"], data["lastName"]), - email = ((data["firstName"] + "-" + data["lastName"]).toLowerCase()) + "@gmail.com", + email = data["email"] ?: ((data["firstName"] + "-" + data["lastName"]).toLowerCase()) + "@dc-project.com", birthday = DateTime.now(), user = user ) @@ -44,7 +43,7 @@ class KtorServerAuthSteps: En, KoinTest { val jwtAsString: String = JWT.create() .withIssuer("dc-project.fr") .withClaim("id", id) - .sign(Algorithm.HMAC512(JwtConfig.secret)) + .sign(JwtConfig.algorithm) val user = User( id = UUID.fromString(id), @@ -54,7 +53,7 @@ class KtorServerAuthSteps: En, KoinTest { val citizen = Citizen( id = UUID.fromString(id), name = Citizen.Name(firstName, lastName), - email = ("$firstName-$lastName".toLowerCase())+"@gmail.com", + email = ("$firstName-$lastName".toLowerCase())+"@dc-project.fr", birthday = DateTime.now(), user = user ) diff --git a/src/test/resources/feature/citizen.feature b/src/test/resources/feature/citizen.feature index e006407..30430ea 100644 --- a/src/test/resources/feature/citizen.feature +++ b/src/test/resources/feature/citizen.feature @@ -18,3 +18,28 @@ Feature: citizens routes Then the response status code should be 200 And the response should contain object: | id | 64b7b379-2298-43ec-b428-ba134930cabd | + + Scenario: Can be connect with SSO + Given I have citizen: + | id | c606110c-ff0e-4d09-a79e-74632d7bf7bd | + | firstName | John | + | lastName | Doe | + | email | fabrice.lecomte.be@gmail.com | + When I send a POST request to "/sso" with body: + """ + { + "url": "https://dc-project.fr/password/reset", + "email": "fabrice.lecomte.be@gmail.com" + } + """ + Then the response status code should be 204 + + Scenario: Can be change my password + Given I am authenticated as Joe Patate with id "c211dca6-aa21-45c2-95ba-c7f2179ee37e" + When I send a PUT request to "/citizens/c211dca6-aa21-45c2-95ba-c7f2179ee37e/password/change" with body: + """ + { + "password": "qwerty" + } + """ + Then the response status code should be 201