Initial commit

This commit is contained in:
Hubert Van De Walle 2020-09-29 17:39:15 +02:00
commit c1c05276a2
16 changed files with 579 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
target/
.idea/
*.iml
*.ipr
*.iws
*.db

230
pom.xml Normal file
View File

@ -0,0 +1,230 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>be.simplenotes</groupId>
<artifactId>minimalnotes</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<java.version>14</java.version>
<kotlin.version>1.4.10</kotlin.version>
<maven.compiler.target>${java.version}</maven.compiler.target>
<maven.compiler.source>${java.version}</maven.compiler.source>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<main.class>be.simplenotes/MinimalnotesKt</main.class>
</properties>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.koin</groupId>
<artifactId>koin-core</artifactId>
<version>2.1.6</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.8.5</version>
</dependency>
<dependency>
<groupId>org.http4k</groupId>
<artifactId>http4k-format-jackson</artifactId>
<version>3.261.0</version>
</dependency>
<dependency>
<groupId>org.http4k</groupId>
<artifactId>http4k-core</artifactId>
<version>3.261.0</version>
</dependency>
<dependency>
<groupId>org.http4k</groupId>
<artifactId>http4k-server-apache</artifactId>
<version>3.261.0</version>
</dependency>
<dependency>
<groupId>io.pebbletemplates</groupId>
<artifactId>pebble</artifactId>
<version>3.1.4</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.200</version>
</dependency>
<dependency>
<groupId>org.jooq</groupId>
<artifactId>jooq</artifactId>
<version>3.13.4</version>
</dependency>
<dependency>
<groupId>org.jooq</groupId>
<artifactId>jooq-meta</artifactId>
<version>3.13.4</version>
</dependency>
<dependency>
<groupId>org.jooq</groupId>
<artifactId>jooq-codegen</artifactId>
<version>3.13.4</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>3.4.5</version>
</dependency>
<dependency>
<groupId>de.mkammerer</groupId>
<artifactId>argon2-jvm</artifactId>
<version>2.7</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.3</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.7.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>5.7.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.17.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
<version>6.5.6</version>
</dependency>
</dependencies>
<repositories>
<repository>
<id>jcenter</id>
<url>https://jcenter.bintray.com</url>
</repository>
</repositories>
<build>
<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
<testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.1.2</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M4</version>
<dependencies>
<dependency>
<groupId>org.apache.maven.surefire</groupId>
<artifactId>surefire-junit-platform</artifactId>
<version>3.0.0-M4</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
<execution>
<id>test-compile</id>
<phase>test-compile</phase>
<goals>
<goal>test-compile</goal>
</goals>
</execution>
</executions>
<configuration>
<jvmTarget>${java.version}</jvmTarget>
<args>
<arg>-Xinline-classes</arg>
</args>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.4</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<minimizeJar>false</minimizeJar>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>${main.class}</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.jooq</groupId>
<artifactId>jooq-codegen-maven</artifactId>
<version>3.13.4</version>
<executions>
<execution>
<goals>
<goal>generate</goal>
</goals>
</execution>
</executions>
<configuration>
<jdbc>
<driver>org.h2.Driver</driver>
<url>jdbc:h2:./notes.db;MODE=MySQL;</url>
</jdbc>
<generator>
<database>
<name>org.jooq.meta.h2.H2Database</name>
<includes>.*</includes>
<excludes/>
<inputSchema/>
</database>
<target>
<packageName>be.simplenotes</packageName>
<directory>target/generated-sources/jooq</directory>
</target>
</generator>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

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

View File

@ -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<ObjectMapper>().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<Flyway>().migrate(); DSL.using(get<DataSource>(), SQLDialect.H2) }
}
val securityModule = module {
single { SimpleJwt("super secret", 1, TimeUnit.HOURS) }
single<PasswordHash> { Argon2PasswordHash() }
}
val httpModule = module {
single { UserRoutes(get(), get()) } bind RouteSupplier::class
single { getAll<RouteSupplier>().toRouter() }
single(createdAtStart = true) {
ServerFilters.CatchLensFailure()
.then(get<RoutingHttpHandler>())
.asServer(ApacheServer(7000)).start()
} onClose { it?.close() }
}
startKoin {
modules(httpModule, securityModule, jacksonModule, persistanceModule)
}.koin.addShutdownHook()
}

View File

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

View File

@ -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<StdSerializer<*>>) {
registerModule(object : SimpleModule() {
init {
serializers.forEach { addSerializer(it) }
}
})
}

View File

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

View File

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

View File

@ -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<UserId, User>()
@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
}
}

View File

@ -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<RouteSupplier>.toRouter() = routes(*map { it.get() }.toTypedArray())

View File

@ -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>("user", ContentNegotiation.StrictNoDirective).toLens()
private val msgLens = Body.auto<Message>().toLens()
private val loginLens = Body.auto<Login>().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()
}
)
}

View File

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

View File

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

View File

@ -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>(UserId::class.java) {
override fun serialize(value: UserId, gen: JsonGenerator, provider: SerializerProvider) {
gen.writeObject(value.value)
}
}
class UserSerializer : StdSerializer<User>(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()
}
}

View File

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

View File

@ -0,0 +1,18 @@
<configuration>
<appender class="ch.qos.logback.core.ConsoleAppender" name="STDOUT">
<withJansi>true</withJansi>
<encoder>
<pattern>%cyan(%d{YYYY-MM-dd HH:mm:ss.SSS}) [%thread] %highlight(%-5level) %green(%logger{36}) - %msg%n
</pattern>
</encoder>
</appender>
<root level="DEBUG">
<appender-ref ref="STDOUT"/>
</root>
<logger level="INFO" name="com.mitchellbosecke.pebble"/>
<logger level="WARN" name="com.zaxxer.hikari"/>
<logger level="INFO" name="org.flywaydb"/>
<logger level="WARN" name="org.flywaydb.core.internal.license.VersionPrinter"/>
<logger level="WARN" name="org.jooq.Constants"/>
<logger level="WARN" name="org.jooq"/>
</configuration>