commit c1c05276a2e9becb1cac51a1b21a42e731c36a19 Author: Hubert Van De Walle Date: Tue Sep 29 17:39:15 2020 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3ffe18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +target/ +.idea/ +*.iml +*.ipr +*.iws +*.db diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..218e4ae --- /dev/null +++ b/pom.xml @@ -0,0 +1,230 @@ + + + 4.0.0 + be.simplenotes + minimalnotes + 1.0-SNAPSHOT + + 14 + 1.4.10 + ${java.version} + ${java.version} + UTF-8 + be.simplenotes/MinimalnotesKt + + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + ${kotlin.version} + + + org.koin + koin-core + 2.1.6 + + + ch.qos.logback + logback-classic + 1.2.3 + + + com.github.ben-manes.caffeine + caffeine + 2.8.5 + + + org.http4k + http4k-format-jackson + 3.261.0 + + + org.http4k + http4k-core + 3.261.0 + + + org.http4k + http4k-server-apache + 3.261.0 + + + io.pebbletemplates + pebble + 3.1.4 + + + com.h2database + h2 + 1.4.200 + + + org.jooq + jooq + 3.13.4 + + + org.jooq + jooq-meta + 3.13.4 + + + org.jooq + jooq-codegen + 3.13.4 + provided + + + com.zaxxer + HikariCP + 3.4.5 + + + de.mkammerer + argon2-jvm + 2.7 + + + com.auth0 + java-jwt + 3.10.3 + + + org.junit.jupiter + junit-jupiter + 5.7.0 + test + + + org.junit.jupiter + junit-jupiter-params + 5.7.0 + test + + + org.assertj + assertj-core + 3.17.2 + test + + + org.flywaydb + flyway-core + 6.5.6 + + + + + jcenter + https://jcenter.bintray.com + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/test/kotlin + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + org.apache.maven.plugins + maven-dependency-plugin + 3.1.2 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M4 + + + org.apache.maven.surefire + surefire-junit-platform + 3.0.0-M4 + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + compile + + compile + + + + test-compile + test-compile + + test-compile + + + + + ${java.version} + + -Xinline-classes + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.4 + + + package + + shade + + + false + false + + + ${main.class} + + + + + + + + org.jooq + jooq-codegen-maven + 3.13.4 + + + + generate + + + + + + org.h2.Driver + jdbc:h2:./notes.db;MODE=MySQL; + + + + org.jooq.meta.h2.H2Database + .* + + + + + be.simplenotes + target/generated-sources/jooq + + + + + + + diff --git a/src/main/kotlin/Domain.kt b/src/main/kotlin/Domain.kt new file mode 100644 index 0000000..2d8576d --- /dev/null +++ b/src/main/kotlin/Domain.kt @@ -0,0 +1,4 @@ +package be.simplenotes + +inline class UserId(val value: Long) +data class User(val id: UserId?, val username: String, val password: String) diff --git a/src/main/kotlin/Minimalnotes.kt b/src/main/kotlin/Minimalnotes.kt new file mode 100644 index 0000000..99d8b1b --- /dev/null +++ b/src/main/kotlin/Minimalnotes.kt @@ -0,0 +1,77 @@ +package be.simplenotes + +import be.simplenotes.extensions.addShutdownHook +import be.simplenotes.extensions.hikariDataSource +import be.simplenotes.extensions.registerSerializers +import be.simplenotes.repositories.UserRepository +import be.simplenotes.routes.RouteSupplier +import be.simplenotes.routes.UserRoutes +import be.simplenotes.routes.toRouter +import be.simplenotes.security.Argon2PasswordHash +import be.simplenotes.security.PasswordHash +import be.simplenotes.security.SimpleJwt +import be.simplenotes.serializers.UserIdSerializer +import be.simplenotes.serializers.UserSerializer +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import org.flywaydb.core.Flyway +import org.http4k.core.then +import org.http4k.filter.ServerFilters +import org.http4k.format.Jackson +import org.http4k.routing.RoutingHttpHandler +import org.http4k.server.ApacheServer +import org.http4k.server.asServer +import org.jooq.SQLDialect +import org.jooq.impl.DSL +import org.koin.core.context.startKoin +import org.koin.dsl.bind +import org.koin.dsl.koinApplication +import org.koin.dsl.module +import org.koin.dsl.onClose +import java.util.concurrent.TimeUnit +import javax.sql.DataSource + +fun main() { + val jacksonModule = module { + single { UserIdSerializer() } bind StdSerializer::class + single { UserSerializer() } bind StdSerializer::class + single { Jackson.mapper } + single(createdAtStart = true) { + get().registerSerializers(getAll()) + } + } + + val persistanceModule = module { + single { UserRepository(get(), get()) } + + single { + hikariDataSource { + jdbcUrl = "jdbc:h2:./notes.db;MODE=MySQL;" + driverClassName = "org.h2.Driver" + maximumPoolSize = 2 + } + } bind DataSource::class onClose { it?.close() } + + single { Flyway.configure().dataSource(get()).load() } + single { get().migrate(); DSL.using(get(), SQLDialect.H2) } + } + + val securityModule = module { + single { SimpleJwt("super secret", 1, TimeUnit.HOURS) } + single { Argon2PasswordHash() } + } + + val httpModule = module { + single { UserRoutes(get(), get()) } bind RouteSupplier::class + single { getAll().toRouter() } + single(createdAtStart = true) { + ServerFilters.CatchLensFailure() + .then(get()) + .asServer(ApacheServer(7000)).start() + } onClose { it?.close() } + } + + startKoin { + modules(httpModule, securityModule, jacksonModule, persistanceModule) + }.koin.addShutdownHook() +} diff --git a/src/main/kotlin/extensions/HikariExtension.kt b/src/main/kotlin/extensions/HikariExtension.kt new file mode 100644 index 0000000..8be5b70 --- /dev/null +++ b/src/main/kotlin/extensions/HikariExtension.kt @@ -0,0 +1,7 @@ +package be.simplenotes.extensions + +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource + +fun hikariDataSource(builder: HikariConfig.() -> Unit): HikariDataSource = + HikariDataSource(HikariConfig().apply(builder)) diff --git a/src/main/kotlin/extensions/JacksonExtension.kt b/src/main/kotlin/extensions/JacksonExtension.kt new file mode 100644 index 0000000..809299d --- /dev/null +++ b/src/main/kotlin/extensions/JacksonExtension.kt @@ -0,0 +1,13 @@ +package be.simplenotes.extensions + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.databind.ser.std.StdSerializer + +fun ObjectMapper.registerSerializers(serializers: List>) { + registerModule(object : SimpleModule() { + init { + serializers.forEach { addSerializer(it) } + } + }) +} diff --git a/src/main/kotlin/extensions/KoinExtensions.kt b/src/main/kotlin/extensions/KoinExtensions.kt new file mode 100644 index 0000000..f22234f --- /dev/null +++ b/src/main/kotlin/extensions/KoinExtensions.kt @@ -0,0 +1,10 @@ +package be.simplenotes.extensions + +import org.koin.core.Koin +import kotlin.concurrent.thread + +fun Koin.addShutdownHook() { + Runtime.getRuntime().addShutdownHook(thread(start = false) { + close() + }) +} diff --git a/src/main/kotlin/extensions/http4kExtensions.kt b/src/main/kotlin/extensions/http4kExtensions.kt new file mode 100644 index 0000000..65370b9 --- /dev/null +++ b/src/main/kotlin/extensions/http4kExtensions.kt @@ -0,0 +1,7 @@ +package be.simplenotes.extensions + +import org.http4k.core.Response +import org.http4k.core.Status + +fun Response.Companion.ok() = Response(Status.OK) +fun Response.Companion.notFound() = Response(Status.NOT_FOUND) diff --git a/src/main/kotlin/repositories/UserRepository.kt b/src/main/kotlin/repositories/UserRepository.kt new file mode 100644 index 0000000..73403dd --- /dev/null +++ b/src/main/kotlin/repositories/UserRepository.kt @@ -0,0 +1,44 @@ +package be.simplenotes.repositories + +import be.simplenotes.User +import be.simplenotes.UserId +import be.simplenotes.public_.tables.Users.USERS +import be.simplenotes.security.PasswordHash +import com.github.benmanes.caffeine.cache.Caffeine +import org.jooq.DSLContext +import org.jooq.Record +import org.jooq.exception.DataAccessException + +class UserRepository(private val db: DSLContext, private val passwordHash: PasswordHash) { + private val cache = Caffeine.newBuilder() + .maximumSize(10) + .build() + + @Throws(DataAccessException::class) + fun create(user: User) { + db.insertInto(USERS, USERS.USERNAME, USERS.PASSWORD) + .values(user.username, passwordHash.hash(user.password)) + .execute() + } + + fun find(id: UserId): User? = cache.get(id) { + db.fetchOne(USERS, USERS.ID.eq(id.value))?.map(::userMapper) + } + + fun find(username: String, password: String): User? { + val user = db.fetchOne(USERS, USERS.USERNAME.eq(username))?.map(::userMapper) ?: return null + + return if (passwordHash.verify(user.password, password)) + user + else null + } + + private fun userMapper(record: Record): User = + User(UserId(record[USERS.ID]), record[USERS.USERNAME], record[USERS.PASSWORD]) + + fun delete(id: UserId): Boolean { + val deleted = db.deleteFrom(USERS).where(USERS.ID.eq(id.value)).execute() == 1 + if (deleted) cache.invalidate(id) + return deleted + } +} diff --git a/src/main/kotlin/routes/RouteSupplier.kt b/src/main/kotlin/routes/RouteSupplier.kt new file mode 100644 index 0000000..63d37cf --- /dev/null +++ b/src/main/kotlin/routes/RouteSupplier.kt @@ -0,0 +1,10 @@ +package be.simplenotes.routes + +import org.http4k.routing.RoutingHttpHandler +import org.http4k.routing.routes + +interface RouteSupplier { + fun get(): RoutingHttpHandler +} + +fun List.toRouter() = routes(*map { it.get() }.toTypedArray()) diff --git a/src/main/kotlin/routes/UserRoutes.kt b/src/main/kotlin/routes/UserRoutes.kt new file mode 100644 index 0000000..f50b207 --- /dev/null +++ b/src/main/kotlin/routes/UserRoutes.kt @@ -0,0 +1,74 @@ +package be.simplenotes.routes + +import be.simplenotes.security.SimpleJwt +import be.simplenotes.User +import be.simplenotes.UserId +import be.simplenotes.extensions.notFound +import be.simplenotes.extensions.ok +import be.simplenotes.repositories.UserRepository +import com.auth0.jwt.exceptions.JWTVerificationException +import org.http4k.core.Body +import org.http4k.core.Method.* +import org.http4k.core.Response +import org.http4k.core.cookie.Cookie +import org.http4k.core.cookie.cookie +import org.http4k.core.with +import org.http4k.format.Jackson.auto +import org.http4k.lens.ContentNegotiation +import org.http4k.lens.Cookies +import org.http4k.lens.Path +import org.http4k.lens.long +import org.http4k.routing.bind +import org.http4k.routing.routes + +data class Message(val msg: String) +data class Login(val username: String, val password: String) + +class UserRoutes(private val userRepository: UserRepository, private val simpleJwt: SimpleJwt) : RouteSupplier { + private val userLens = Body.auto("user", ContentNegotiation.StrictNoDirective).toLens() + private val msgLens = Body.auto().toLens() + private val loginLens = Body.auto().toLens() + private val idLens = Path.long().map { UserId(it) }.of("id") + private val jwtLens = Cookies.optional("Bearer") + + private fun userNotFound() = Response.notFound().with(msgLens of Message("not found")) + + override fun get() = routes( + "/user" bind POST to { + userRepository.create(userLens(it)) + Response.ok().with(msgLens of Message("created")) + }, + "/user/{id}" bind GET to { + val user = userRepository.find(idLens(it)) + user?.let { + Response.ok().with(userLens of user) + } ?: userNotFound() + }, + "/user/{id}" bind DELETE to { + if (userRepository.delete(idLens(it))) + Response.ok().with(msgLens of Message("deleted")) + else userNotFound() + }, + "/login" bind POST to { + val (username, password) = loginLens(it) + userRepository.find(username, password)?.let { user -> + val cookie = Cookie( + "Bearer", simpleJwt.sign(user.id!!) + ) + Response.ok().with(userLens of user.copy(id = null)).cookie(cookie) + } ?: Response.notFound() + }, + "/whoami" bind GET to { + jwtLens(it)?.value + ?.let { token -> + try { + simpleJwt.extract(token) + } catch (e: JWTVerificationException) { + null + } + } + ?.let { id -> Response.ok().with(userLens of userRepository.find(id)!!) } + ?: Response.notFound() + } + ) +} diff --git a/src/main/kotlin/security/PasswordHash.kt b/src/main/kotlin/security/PasswordHash.kt new file mode 100644 index 0000000..b8291a7 --- /dev/null +++ b/src/main/kotlin/security/PasswordHash.kt @@ -0,0 +1,14 @@ +package be.simplenotes.security + +import de.mkammerer.argon2.Argon2Factory + +interface PasswordHash { + fun hash(password: String): String + fun verify(hash: String, password: String): Boolean +} + +class Argon2PasswordHash : PasswordHash { + private val argon2 = Argon2Factory.create() + override fun hash(password: String): String = argon2.hash(10, 65536 / 2, 1, password.encodeToByteArray()) + override fun verify(hash: String, password: String) = argon2.verify(hash, password.encodeToByteArray()) +} diff --git a/src/main/kotlin/security/SimpleJwt.kt b/src/main/kotlin/security/SimpleJwt.kt new file mode 100644 index 0000000..dcdcdda --- /dev/null +++ b/src/main/kotlin/security/SimpleJwt.kt @@ -0,0 +1,33 @@ +package be.simplenotes.security + +import be.simplenotes.UserId +import com.auth0.jwt.JWT +import com.auth0.jwt.JWTVerifier +import com.auth0.jwt.algorithms.Algorithm +import com.auth0.jwt.exceptions.JWTVerificationException +import java.util.* +import java.util.concurrent.TimeUnit + +class SimpleJwt(secret: String, validity: Long, timeUnit: TimeUnit) { + private val validityInMs = TimeUnit.MILLISECONDS.convert(validity, timeUnit) + private val algorithm = Algorithm.HMAC256(secret) + + private val idClaim = "id" + + private val verifier: JWTVerifier = JWT.require(algorithm).build() + fun sign(id: UserId): String = JWT.create() + .withClaim(idClaim, id.value) + .withExpiresAt(getExpiration()) + .sign(algorithm) + + fun extract(token: String): UserId? = try { + val decodedJWT = verifier.verify(token) + decodedJWT.getClaim(idClaim).asLong()?.let { UserId(it) } + } catch (e: JWTVerificationException) { + null + } catch (e: IllegalArgumentException) { + null + } + + private fun getExpiration() = Date(System.currentTimeMillis() + validityInMs) +} diff --git a/src/main/kotlin/serializers/UserSerializers.kt b/src/main/kotlin/serializers/UserSerializers.kt new file mode 100644 index 0000000..f5470e2 --- /dev/null +++ b/src/main/kotlin/serializers/UserSerializers.kt @@ -0,0 +1,24 @@ +package be.simplenotes.serializers + +import be.simplenotes.User +import be.simplenotes.UserId +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer + +// We need this because inline classes.. +class UserIdSerializer : StdSerializer(UserId::class.java) { + override fun serialize(value: UserId, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeObject(value.value) + } +} + +class UserSerializer : StdSerializer(User::class.java) { + override fun serialize(user: User, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeStartObject() + gen.writeObjectField("id", user.id) + gen.writeStringField("username", user.username) + gen.writeStringField("password", user.password) + gen.writeEndObject() + } +} diff --git a/src/main/resources/db/migration/V1__Create_users.sql b/src/main/resources/db/migration/V1__Create_users.sql new file mode 100644 index 0000000..b85255f --- /dev/null +++ b/src/main/resources/db/migration/V1__Create_users.sql @@ -0,0 +1,8 @@ +create table Users +( + id bigint auto_increment primary key, + username varchar(50) not null, + password varchar(255) not null, + + constraint username unique (username) +); diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..dda5198 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,18 @@ + + + true + + %cyan(%d{YYYY-MM-dd HH:mm:ss.SSS}) [%thread] %highlight(%-5level) %green(%logger{36}) - %msg%n + + + + + + + + + + + + +