Can login with SSO & change Password

This commit is contained in:
2019-10-09 21:57:56 +02:00
parent 20416ce108
commit a6f25bcbb2
16 changed files with 197 additions and 41 deletions

View File

@@ -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<RepositoryCitizen>().findById(id) ?: throw InternalError("Citizen $values not found")
get<RepositoryCitizen>().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())

View File

@@ -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)

View File

@@ -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<Mailer>(), config.domain, get()) }
}

View File

@@ -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()

View File

@@ -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 <a href=\"$urlObject\">here</a> 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"
}
}

View File

@@ -30,6 +30,12 @@ class Citizen(override var requester: Requester) : RepositoryI<CitizenEntity> {
.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,

View File

@@ -32,6 +32,12 @@ class User(override var requester: Requester) : RepositoryI<UserEntity> {
.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)
}

View File

@@ -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 <AuthPaths.LoginRequest> {
fun Route.auth(
userRepo: UserRepository,
citizenRepo: CitizenRepository,
ssoManager: SsoManager
) {
post <LoginRequest> {
try {
val credentials = call.receive<UserPasswordCredential>()
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 <AuthPaths.RegisterRequest> {
post <RegisterRequest> {
val citizen = call.receive<CitizenEntity>()
citizen.user?.roles = listOf(User.Roles.ROLE_USER)
val created = citizenRepo.insertWithUser(citizen)?.user ?: throw BadRequestException("Bad request")
call.respondText(JwtConfig.makeToken(created))
}
post<SsoRequest> {
val content = call.receive<SsoRequest.Content>()
ssoManager.sendMail(content.email, content.url)
call.respond(HttpStatusCode.NoContent)
}
}

View File

@@ -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<CitizenPaths.CitizensRequest> {
fun Route.citizen(
repo: CitizenRepository,
userRepository: UserRepository
) {
get<CitizensRequest> {
val citizens = repo.find(it.page, it.limit, it.sort, it.direction, it.search)
assertCan(VIEW, citizens.result)
call.respond(citizens)
}
get<CitizenPaths.CitizenRequest> {
get<CitizenRequest> {
assertCan(VIEW, it.citizen)
call.respond(it.citizen)
}
get<CitizenPaths.CurrentCitizenRequest> {
get<CurrentCitizenRequest> {
assertCan(VIEW, citizen)
call.respond(citizen)
}
put<ChangePasswordCitizenRequest> {
assertCan(CHANGE_PASSWORD, it.citizen)
val content = call.receive<ChangePasswordCitizenRequest.Content>()
val user = it.citizen.user ?: error("Citizen must have User")
user.plainPassword = content.password
userRepository.changePassword(user)
call.respond(HttpStatusCode.Created)
}
}

View File

@@ -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
}

View File

@@ -10,6 +10,7 @@ ktor {
app {
envName = prod
domain = dc-project.fr
}
db {

View File

@@ -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);

View File

@@ -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

View File

@@ -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<Mailer>().sendEmail(
"reset-password@dc-project.fr",
"fabrice.lecomte.be@gmail.com",
"Email Work !",
"Test"
)
get<Mailer>().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 <b>Work</b> !"))
}
}
}
}
}

View File

@@ -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
)

View File

@@ -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