Move all file in fr.dcproject.
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
package fr.dcproject.component.auth
|
||||
|
||||
import fr.dcproject.component.citizen.CitizenRepository
|
||||
import io.ktor.application.ApplicationCall
|
||||
import io.ktor.auth.authentication
|
||||
import io.ktor.util.AttributeKey
|
||||
import io.ktor.util.pipeline.PipelineContext
|
||||
import org.koin.core.context.GlobalContext
|
||||
import fr.dcproject.component.citizen.Citizen as CitizenEntity
|
||||
|
||||
class ForbiddenException(message: String) : Exception(message)
|
||||
|
||||
private val citizenAttributeKey = AttributeKey<CitizenEntity>("CitizenContext")
|
||||
|
||||
val ApplicationCall.citizen: CitizenEntity
|
||||
get() = attributes.computeIfAbsent(citizenAttributeKey) {
|
||||
val user = authentication.principal<UserI>() ?: throw ForbiddenException("No User Connected")
|
||||
GlobalContext.get().koin.get<CitizenRepository>().findByUser(user)
|
||||
?: throw ForbiddenException("Citizen not found for this user id \"${user.id}\"")
|
||||
}
|
||||
|
||||
val ApplicationCall.citizenOrNull: CitizenEntity?
|
||||
get() = authentication.principal<UserI>()?.let {
|
||||
GlobalContext.get().koin.get<CitizenRepository>().findByUser(it)
|
||||
}
|
||||
|
||||
val PipelineContext<Unit, ApplicationCall>.citizen get() = context.citizen
|
||||
val PipelineContext<Unit, ApplicationCall>.citizenOrNull get() = context.citizenOrNull
|
||||
|
||||
val ApplicationCall.user get() = authentication.principal<User>()
|
||||
14
src/main/kotlin/fr/dcproject/component/auth/KoinModule.kt
Normal file
14
src/main/kotlin/fr/dcproject/component/auth/KoinModule.kt
Normal file
@@ -0,0 +1,14 @@
|
||||
package fr.dcproject.component.auth
|
||||
|
||||
import fr.dcproject.application.Configuration
|
||||
import fr.dcproject.common.email.Mailer
|
||||
import org.koin.dsl.module
|
||||
|
||||
val authKoinModule = module {
|
||||
single { UserRepository(get()) }
|
||||
// Used to send a connexion link by email
|
||||
single {
|
||||
val config: Configuration = get()
|
||||
PasswordlessAuth(get<Mailer>(), config.domain, get())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package fr.dcproject.component.auth
|
||||
|
||||
import com.sendgrid.helpers.mail.Mail
|
||||
import com.sendgrid.helpers.mail.objects.Content
|
||||
import com.sendgrid.helpers.mail.objects.Email
|
||||
import fr.dcproject.common.email.Mailer
|
||||
import fr.dcproject.component.auth.jwt.makeToken
|
||||
import fr.dcproject.component.citizen.CitizenRepository
|
||||
import fr.dcproject.component.citizen.CitizenWithEmail
|
||||
import fr.dcproject.component.citizen.CitizenWithUserI
|
||||
import io.ktor.http.URLBuilder
|
||||
|
||||
/**
|
||||
* Send a connexion link by email
|
||||
*/
|
||||
class PasswordlessAuth(
|
||||
private val mailer: Mailer,
|
||||
private val domain: String,
|
||||
private val citizenRepo: CitizenRepository
|
||||
) {
|
||||
fun sendEmail(email: String, url: String) {
|
||||
val citizen = citizenRepo.findByEmail(email) ?: noEmail(email)
|
||||
sendEmail(citizen, url)
|
||||
}
|
||||
|
||||
fun <C> sendEmail(citizen: C, url: String) where C : CitizenWithEmail, C : CitizenWithUserI {
|
||||
mailer.sendEmail {
|
||||
val token = citizen.user.makeToken()
|
||||
Mail(
|
||||
Email("passwordless-auth@$domain"),
|
||||
"Connection",
|
||||
Email(citizen.email),
|
||||
Content("text/plain", generateContent(token, url))
|
||||
).apply {
|
||||
addContent(Content("text/html", generateHtmlContent(token, url)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateHtmlContent(token: String, url: String): String? {
|
||||
val urlObject = URLBuilder(url)
|
||||
urlObject.parameters.append("token", token)
|
||||
return "Click <a href=\"${urlObject.buildString()}\">here</a> for connect to $domain"
|
||||
}
|
||||
|
||||
private fun generateContent(token: String, url: String): String {
|
||||
val urlObject = URLBuilder(url)
|
||||
urlObject.parameters.append("token", token)
|
||||
return "Copy this link into your browser for connect to $domain: \n${urlObject.buildString()}"
|
||||
}
|
||||
|
||||
class EmailNotFound(val email: String) : Exception() {
|
||||
override val message: String = "No Citizen with this email : $email"
|
||||
}
|
||||
|
||||
private fun noEmail(email: String): Nothing = throw EmailNotFound(email)
|
||||
}
|
||||
54
src/main/kotlin/fr/dcproject/component/auth/User.kt
Normal file
54
src/main/kotlin/fr/dcproject/component/auth/User.kt
Normal file
@@ -0,0 +1,54 @@
|
||||
package fr.dcproject.component.auth
|
||||
|
||||
import fr.dcproject.component.auth.UserI.Roles
|
||||
import fr.postgresjson.entity.EntityCreatedAt
|
||||
import fr.postgresjson.entity.EntityCreatedAtImp
|
||||
import fr.postgresjson.entity.EntityUpdatedAt
|
||||
import fr.postgresjson.entity.EntityUpdatedAtImp
|
||||
import fr.postgresjson.entity.UuidEntity
|
||||
import fr.postgresjson.entity.UuidEntityI
|
||||
import io.ktor.auth.Principal
|
||||
import org.joda.time.DateTime
|
||||
import java.util.UUID
|
||||
|
||||
class UserForCreate(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
username: String,
|
||||
override val password: String,
|
||||
blockedAt: DateTime? = null,
|
||||
roles: List<Roles> = emptyList()
|
||||
) : User(id, username, blockedAt, roles),
|
||||
UserWithPasswordI
|
||||
|
||||
open class User(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
var username: String,
|
||||
var blockedAt: DateTime? = null,
|
||||
var roles: List<Roles> = emptyList()
|
||||
) : UserRef(id),
|
||||
EntityCreatedAt by EntityCreatedAtImp(),
|
||||
EntityUpdatedAt by EntityUpdatedAtImp()
|
||||
|
||||
interface UserWithPasswordI {
|
||||
val id: UUID
|
||||
val password: String
|
||||
}
|
||||
|
||||
class UserWithPassword(
|
||||
id: UUID,
|
||||
override val password: String,
|
||||
) : UserWithPasswordI,
|
||||
UserRef(id)
|
||||
|
||||
open class UserRef(
|
||||
id: UUID = UUID.randomUUID()
|
||||
) : UserI, UuidEntity(id)
|
||||
|
||||
interface UserI : UuidEntityI, Principal {
|
||||
enum class Roles { ROLE_USER, ROLE_ADMIN }
|
||||
}
|
||||
|
||||
interface UserForAuthI : UserI {
|
||||
var roles: List<Roles>
|
||||
var blockedAt: DateTime?
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package fr.dcproject.component.auth
|
||||
|
||||
import fr.postgresjson.connexion.Requester
|
||||
import fr.postgresjson.repository.RepositoryI
|
||||
import io.ktor.auth.UserPasswordCredential
|
||||
import java.util.UUID
|
||||
|
||||
class UserRepository(override var requester: Requester) : RepositoryI {
|
||||
fun findByCredentials(credentials: UserPasswordCredential): User? {
|
||||
return requester
|
||||
.getFunction("check_user")
|
||||
.selectOne(
|
||||
"username" to credentials.name,
|
||||
"password" to credentials.password
|
||||
)
|
||||
}
|
||||
|
||||
fun findById(id: UUID): User {
|
||||
return requester
|
||||
.getFunction("find_user_by_id")
|
||||
.selectOne(
|
||||
"id" to id
|
||||
) ?: throw UserNotFound(id)
|
||||
}
|
||||
|
||||
fun insert(user: User): User? {
|
||||
return requester
|
||||
.getFunction("insert_user")
|
||||
.selectOne("resource" to user)
|
||||
}
|
||||
|
||||
fun changePassword(user: UserWithPassword) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
14
src/main/kotlin/fr/dcproject/component/auth/jwt/JWTMaker.kt
Normal file
14
src/main/kotlin/fr/dcproject/component/auth/jwt/JWTMaker.kt
Normal file
@@ -0,0 +1,14 @@
|
||||
package fr.dcproject.component.auth.jwt
|
||||
|
||||
import com.auth0.jwt.JWT
|
||||
import fr.dcproject.component.auth.UserI
|
||||
|
||||
/**
|
||||
* Produce a token for this combination of User and Account
|
||||
*/
|
||||
fun UserI.makeToken(): String = JWT.create()
|
||||
.withSubject("Authentication")
|
||||
.withIssuer(JwtConfig.issuer)
|
||||
.withClaim("id", id.toString())
|
||||
.withExpiresAt(JwtConfig.getExpiration())
|
||||
.sign(JwtConfig.algorithm)
|
||||
25
src/main/kotlin/fr/dcproject/component/auth/jwt/JwtConfig.kt
Normal file
25
src/main/kotlin/fr/dcproject/component/auth/jwt/JwtConfig.kt
Normal file
@@ -0,0 +1,25 @@
|
||||
package fr.dcproject.component.auth.jwt
|
||||
|
||||
import com.auth0.jwt.JWT
|
||||
import com.auth0.jwt.JWTVerifier
|
||||
import com.auth0.jwt.algorithms.Algorithm
|
||||
import java.util.Date
|
||||
|
||||
object JwtConfig {
|
||||
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
|
||||
val algorithm: Algorithm = Algorithm.HMAC512(secret)
|
||||
|
||||
val verifier: JWTVerifier = JWT
|
||||
.require(algorithm)
|
||||
.withIssuer(issuer)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Calculate the expiration Date based on current time + the given validity
|
||||
*/
|
||||
fun getExpiration() = Date(System.currentTimeMillis() + validityInMs)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package fr.dcproject.component.auth.jwt
|
||||
|
||||
import fr.dcproject.component.auth.User
|
||||
import fr.dcproject.component.auth.UserRepository
|
||||
import io.ktor.application.ApplicationCall
|
||||
import io.ktor.auth.Authentication
|
||||
import io.ktor.auth.jwt.jwt
|
||||
import io.ktor.http.auth.HttpAuthHeader
|
||||
import io.ktor.routing.Routing
|
||||
import java.util.UUID
|
||||
|
||||
fun jwtInstallation(userRepo: UserRepository): Authentication.Configuration.() -> Unit = {
|
||||
/**
|
||||
* Setup the JWT authentication to be used in [Routing].
|
||||
* If the token is valid, the corresponding [User] is fetched from the database.
|
||||
* The [User] can then be accessed in each [ApplicationCall].
|
||||
*/
|
||||
jwt {
|
||||
verifier(JwtConfig.verifier)
|
||||
realm = "dc-project.fr"
|
||||
validate {
|
||||
it.payload.getClaim("id").asString()?.let { id ->
|
||||
userRepo.findById(UUID.fromString(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Token in URL */
|
||||
jwt("url") {
|
||||
verifier(JwtConfig.verifier)
|
||||
realm = "dc-project.fr"
|
||||
authHeader { call ->
|
||||
call.request.queryParameters["token"]?.let {
|
||||
HttpAuthHeader.Single("Bearer", it)
|
||||
}
|
||||
}
|
||||
validate {
|
||||
it.payload.getClaim("id").asString()?.let { id ->
|
||||
userRepo.findById(UUID.fromString(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/main/kotlin/fr/dcproject/component/auth/routes/Login.kt
Normal file
43
src/main/kotlin/fr/dcproject/component/auth/routes/Login.kt
Normal file
@@ -0,0 +1,43 @@
|
||||
package fr.dcproject.component.auth.routes
|
||||
|
||||
import com.fasterxml.jackson.databind.exc.MismatchedInputException
|
||||
import fr.dcproject.common.utils.receiveOrBadRequest
|
||||
import fr.dcproject.component.auth.UserRepository
|
||||
import fr.dcproject.component.auth.jwt.makeToken
|
||||
import fr.dcproject.component.auth.routes.Login.LoginRequest.Input
|
||||
import io.ktor.application.call
|
||||
import io.ktor.auth.UserPasswordCredential
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.post
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.response.respondText
|
||||
import io.ktor.routing.Route
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object Login {
|
||||
@Location("/login")
|
||||
class LoginRequest {
|
||||
data class Input(
|
||||
val username: String,
|
||||
val password: String,
|
||||
)
|
||||
}
|
||||
|
||||
fun Route.authLogin(userRepo: UserRepository) {
|
||||
post<LoginRequest> {
|
||||
try {
|
||||
val credentials = call.receiveOrBadRequest<Input>().run {
|
||||
UserPasswordCredential(username, password)
|
||||
}
|
||||
|
||||
userRepo.findByCredentials(credentials)?.let { user ->
|
||||
call.respondText(user.makeToken())
|
||||
} ?: call.respond(HttpStatusCode.BadRequest, "Username not exist or password is wrong")
|
||||
} catch (e: MismatchedInputException) {
|
||||
call.respond(HttpStatusCode.BadRequest, "You must be send name and password to the request")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package fr.dcproject.component.auth.routes
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException
|
||||
import fr.dcproject.common.utils.receiveOrBadRequest
|
||||
import fr.dcproject.component.auth.User
|
||||
import fr.dcproject.component.auth.UserForCreate
|
||||
import fr.dcproject.component.auth.UserI
|
||||
import fr.dcproject.component.auth.jwt.makeToken
|
||||
import fr.dcproject.component.auth.routes.Register.RegisterRequest.Input
|
||||
import fr.dcproject.component.citizen.CitizenForCreate
|
||||
import fr.dcproject.component.citizen.CitizenI
|
||||
import fr.dcproject.component.citizen.CitizenRepository
|
||||
import io.ktor.application.call
|
||||
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.response.respond
|
||||
import io.ktor.response.respondText
|
||||
import io.ktor.routing.Route
|
||||
import org.joda.time.DateTime
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object Register {
|
||||
@Location("/register")
|
||||
class RegisterRequest {
|
||||
data class Input(
|
||||
val name: Name,
|
||||
val email: String,
|
||||
val birthday: DateTime,
|
||||
val voteAnonymous: Boolean = true,
|
||||
val followAnonymous: Boolean = true,
|
||||
val user: User
|
||||
) {
|
||||
data class Name(
|
||||
val firstName: String,
|
||||
val lastName: String,
|
||||
val civility: String? = null
|
||||
)
|
||||
data class User(
|
||||
val username: String,
|
||||
val password: String
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Route.authRegister(citizenRepo: CitizenRepository) {
|
||||
fun Input.toCitizen(): CitizenForCreate = CitizenForCreate(
|
||||
name = CitizenI.Name(name.firstName, name.lastName, name.civility),
|
||||
birthday = birthday,
|
||||
email = email,
|
||||
followAnonymous = followAnonymous,
|
||||
voteAnonymous = voteAnonymous,
|
||||
user = UserForCreate(
|
||||
username = user.username,
|
||||
password = user.password,
|
||||
roles = listOf(UserI.Roles.ROLE_USER)
|
||||
)
|
||||
)
|
||||
|
||||
post<RegisterRequest> {
|
||||
try {
|
||||
val citizen = call.receiveOrBadRequest<Input>().toCitizen()
|
||||
val createdCitizen = citizenRepo.insertWithUser(citizen)?.user ?: throw BadRequestException("Bad request")
|
||||
call.respondText(createdCitizen.makeToken())
|
||||
} catch (e: MissingKotlinParameterException) {
|
||||
call.respond(HttpStatusCode.BadRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/main/kotlin/fr/dcproject/component/auth/routes/Sso.kt
Normal file
36
src/main/kotlin/fr/dcproject/component/auth/routes/Sso.kt
Normal file
@@ -0,0 +1,36 @@
|
||||
package fr.dcproject.component.auth.routes
|
||||
|
||||
import fr.dcproject.common.utils.receiveOrBadRequest
|
||||
import fr.dcproject.component.auth.PasswordlessAuth
|
||||
import fr.dcproject.component.auth.routes.Sso.PasswordlessRequest.Input
|
||||
import io.ktor.application.call
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.post
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object Sso {
|
||||
@Location("/auth/passwordless")
|
||||
class PasswordlessRequest {
|
||||
data class Input(val email: String, val url: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email to the citizen with a link to automatically connect
|
||||
*/
|
||||
fun Route.authPasswordless(passwordlessAuth: PasswordlessAuth) {
|
||||
post<PasswordlessRequest> {
|
||||
call.receiveOrBadRequest<Input>().run {
|
||||
try {
|
||||
passwordlessAuth.sendEmail(email, url)
|
||||
} catch (e: PasswordlessAuth.EmailNotFound) {
|
||||
call.respond(HttpStatusCode.NotFound)
|
||||
}
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package fr.dcproject.component.auth.routes
|
||||
|
||||
import fr.dcproject.component.auth.routes.Login.authLogin
|
||||
import fr.dcproject.component.auth.routes.Register.authRegister
|
||||
import fr.dcproject.component.auth.routes.Sso.authPasswordless
|
||||
import io.ktor.auth.authenticate
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.routing.Routing
|
||||
import org.koin.ktor.ext.get
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
fun Routing.installAuthRoutes() {
|
||||
authenticate(optional = true) {
|
||||
authLogin(get())
|
||||
authRegister(get())
|
||||
authPasswordless(get())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user