diff options
author | Jakob Odersky <jakob@odersky.com> | 2018-10-27 18:45:06 -0700 |
---|---|---|
committer | Jakob Odersky <jakob@odersky.com> | 2018-10-27 18:45:06 -0700 |
commit | de095d377859887352c7380e52ea89bcabf662a0 (patch) | |
tree | 6cee6eb17c1977b85e01078e926499b33854047d /src/main/scala/byspel | |
download | byspel-de095d377859887352c7380e52ea89bcabf662a0.tar.gz byspel-de095d377859887352c7380e52ea89bcabf662a0.tar.bz2 byspel-de095d377859887352c7380e52ea89bcabf662a0.zip |
Initial commit
Diffstat (limited to 'src/main/scala/byspel')
-rw-r--r-- | src/main/scala/byspel/Inserts.scala | 57 | ||||
-rw-r--r-- | src/main/scala/byspel/Main.scala | 12 | ||||
-rw-r--r-- | src/main/scala/byspel/Migrations.scala | 21 | ||||
-rw-r--r-- | src/main/scala/byspel/PasswordHash.scala | 23 | ||||
-rw-r--r-- | src/main/scala/byspel/Service.scala | 69 | ||||
-rw-r--r-- | src/main/scala/byspel/Tables.scala | 179 | ||||
-rw-r--r-- | src/main/scala/byspel/Ui.scala | 119 | ||||
-rw-r--r-- | src/main/scala/byspel/app/App.scala | 57 | ||||
-rw-r--r-- | src/main/scala/byspel/app/config.scala | 16 | ||||
-rw-r--r-- | src/main/scala/byspel/app/modules.scala | 51 |
10 files changed, 604 insertions, 0 deletions
diff --git a/src/main/scala/byspel/Inserts.scala b/src/main/scala/byspel/Inserts.scala new file mode 100644 index 0000000..c82b98d --- /dev/null +++ b/src/main/scala/byspel/Inserts.scala @@ -0,0 +1,57 @@ +package byspel + +import app.{DatabaseApi, DatabaseApp} +import java.security.SecureRandom +import java.sql.Timestamp +import java.time.Instant +import java.util.UUID +import scala.concurrent.Await +import scala.concurrent.duration._ + +trait Inserts extends DatabaseApp { self: DatabaseApi => + import profile.api._ + + private val root = + UsersRow(new UUID(0l, 0l).toString, + "root@crashbox.io", + Some("Root User"), + "no avatar", + Some(Timestamp.from(Instant.now()))) + + private val password = { + val prng = new SecureRandom() + val bytes = new Array[Byte](8) + prng.nextBytes(bytes) + bytes.map(b => f"$b%02x").mkString("") + } + + private def inserts = Seq( + Users insertOrUpdate root, + Shadow insertOrUpdate ShadowRow( + root.id, + PasswordHash.protect(password) + ) + ) + + override def start(): Unit = { + super.start() + log("checking for root user") + + val f = Users.filter(_.id === root.id).exists.result.flatMap { + case false => + log("creating root user") + log(s"root password is: $password") + (Users insertOrUpdate root).andThen( + Shadow insertOrUpdate ShadowRow( + root.id, + PasswordHash.protect(password) + ) + ) + case true => + log("root user exists") + DBIO.successful(()) + } + Await.result(database.run(f), 2.seconds) + } + +} diff --git a/src/main/scala/byspel/Main.scala b/src/main/scala/byspel/Main.scala new file mode 100644 index 0000000..e76af50 --- /dev/null +++ b/src/main/scala/byspel/Main.scala @@ -0,0 +1,12 @@ +package byspel + +import app.{DatabaseApi, DatabaseApp, HttpApp} + +object Main + extends DatabaseApp + with HttpApp + with DatabaseApi + with Service + with Ui + with Migrations + with Inserts diff --git a/src/main/scala/byspel/Migrations.scala b/src/main/scala/byspel/Migrations.scala new file mode 100644 index 0000000..b2129de --- /dev/null +++ b/src/main/scala/byspel/Migrations.scala @@ -0,0 +1,21 @@ +package byspel +import byspel.app.DatabaseApi +import java.io.File + +trait Migrations extends app.DatabaseApp { self: DatabaseApi => + + override def start(): Unit = { + super.start() + log("running migrations") + import sys.process._ + val cmd = Process( + s"sqitch deploy db:sqlite:${config.database.file}", + Some(new File(config.database.sqitch_base)) + ) + if (cmd.run.exitValue() != 0) { + log("fatal: applying database migrations failed") + sys.exit(1) + } + } + +} diff --git a/src/main/scala/byspel/PasswordHash.scala b/src/main/scala/byspel/PasswordHash.scala new file mode 100644 index 0000000..c97723d --- /dev/null +++ b/src/main/scala/byspel/PasswordHash.scala @@ -0,0 +1,23 @@ +package byspel + +import de.mkammerer.argon2.Argon2Factory +import java.nio.charset.StandardCharsets + +object PasswordHash { + + private val argon2 = Argon2Factory.create() + + /** Salt and hash a password. */ + def protect(plain: String): String = + argon2.hash( + 10, // iterations + 65536, // memory + 1, // parallelism + plain, // password + StandardCharsets.UTF_8 + ) + + def verify(plain: String, hashed: String): Boolean = + argon2.verify(hashed, plain) + +} diff --git a/src/main/scala/byspel/Service.scala b/src/main/scala/byspel/Service.scala new file mode 100644 index 0000000..1d1e841 --- /dev/null +++ b/src/main/scala/byspel/Service.scala @@ -0,0 +1,69 @@ +package byspel + +import app.DatabaseApi +import java.sql.Timestamp +import java.time.Instant +import java.util.UUID +import scala.concurrent.{ExecutionContext, Future} +import scala.concurrent.duration._ + +trait Service { self: DatabaseApi => + import profile.api._ + + implicit def executionContext: ExecutionContext + + def login(id: String, + password: String): Future[Option[(UsersRow, SessionsRow)]] = { + val query = for { + user <- Users + if user.primaryEmail === id || user.id === id + shadow <- Shadow + if shadow.userId === user.id + } yield { + user -> shadow.hash + } + val userResult = database.run(query.result.headOption).map { + case Some((user, hash)) if PasswordHash.verify(password, hash) => + Some(user) + case _ => + // dummy password hash to avoid timing attacks + PasswordHash.verify( + password, + "$argon2i$v=19$m=65536,t=10,p=1$gFZ4l8R2rpuhfqXDFuugNg$fOvTwLSaOMahD/5AfWlbRsSMj4E6k34VpGyl5xe24yA") + None + } + + userResult.flatMap { + case Some(u) => + val newSession = SessionsRow( + UUID.randomUUID().toString, + u.id, + Timestamp.from(Instant.now.plusSeconds(60 * 60 * 24)) + ) + database + .run( + Sessions += newSession + ) + .map(_ => Some(u -> newSession)) + case None => Future.successful(None) + } + } + + def checkSession(sessionId: String): Future[Option[UsersRow]] = database.run { + val query = for { + session <- Sessions + if session.sessionId === sessionId + if session.expires > Timestamp.from(Instant.now()) + user <- Users + if user.id === session.userId + } yield { + user + } + query.result.headOption + } + + def endSession(sessionId: String) = database.run { + Sessions.filter(_.sessionId === sessionId).delete + } + +} diff --git a/src/main/scala/byspel/Tables.scala b/src/main/scala/byspel/Tables.scala new file mode 100644 index 0000000..c86b785 --- /dev/null +++ b/src/main/scala/byspel/Tables.scala @@ -0,0 +1,179 @@ +package byspel +// AUTO-GENERATED Slick data model +/** Stand-alone Slick data model for immediate use */ +object Tables extends { + val profile = slick.jdbc.SQLiteProfile +} with Tables + +/** Slick data model trait for extension, choice of backend or usage in the cake pattern. (Make sure to initialize this late.) */ +trait Tables { + val profile: slick.jdbc.JdbcProfile + import profile.api._ + import slick.model.ForeignKeyAction + // NOTE: GetResult mappers for plain SQL are only generated for tables where Slick knows how to map the types of all columns. + import slick.jdbc.{GetResult => GR} + + /** DDL for all tables. Call .create to execute. */ + lazy val schema + : profile.SchemaDescription = Sessions.schema ++ Shadow.schema ++ Users.schema + @deprecated("Use .schema instead of .ddl", "3.0") + def ddl = schema + + /** Entity class storing rows of table Sessions + * @param sessionId Database column session_id SqlType(UUID), PrimaryKey + * @param userId Database column user_id SqlType(UUID) + * @param expires Database column expires SqlType(TIMESTAMP) */ + case class SessionsRow(sessionId: String, + userId: String, + expires: java.sql.Timestamp) + + /** GetResult implicit for fetching SessionsRow objects using plain SQL queries */ + implicit def GetResultSessionsRow( + implicit e0: GR[String], + e1: GR[java.sql.Timestamp]): GR[SessionsRow] = GR { prs => + import prs._ + SessionsRow.tupled((<<[String], <<[String], <<[java.sql.Timestamp])) + } + + /** Table description of table sessions. Objects of this class serve as prototypes for rows in queries. */ + class Sessions(_tableTag: Tag) + extends profile.api.Table[SessionsRow](_tableTag, "sessions") { + def * = + (sessionId, userId, expires) <> (SessionsRow.tupled, SessionsRow.unapply) + + /** Maps whole row to an option. Useful for outer joins. */ + def ? = + (Rep.Some(sessionId), Rep.Some(userId), Rep.Some(expires)).shaped.<>( + { r => + import r._; _1.map(_ => SessionsRow.tupled((_1.get, _2.get, _3.get))) + }, + (_: Any) => + throw new Exception("Inserting into ? projection not supported.")) + + /** Database column session_id SqlType(UUID), PrimaryKey */ + val sessionId: Rep[String] = column[String]("session_id", O.PrimaryKey) + + /** Database column user_id SqlType(UUID) */ + val userId: Rep[String] = column[String]("user_id") + + /** Database column expires SqlType(TIMESTAMP) */ + val expires: Rep[java.sql.Timestamp] = column[java.sql.Timestamp]("expires") + + /** Foreign key referencing Users (database name users_FK_1) */ + lazy val usersFk = foreignKey("users_FK_1", userId, Users)( + r => r.id, + onUpdate = ForeignKeyAction.NoAction, + onDelete = ForeignKeyAction.Cascade) + } + + /** Collection-like TableQuery object for table Sessions */ + lazy val Sessions = new TableQuery(tag => new Sessions(tag)) + + /** Entity class storing rows of table Shadow + * @param userId Database column user_id SqlType(UUID), PrimaryKey + * @param hash Database column hash SqlType(STRING) */ + case class ShadowRow(userId: String, hash: String) + + /** GetResult implicit for fetching ShadowRow objects using plain SQL queries */ + implicit def GetResultShadowRow(implicit e0: GR[String]): GR[ShadowRow] = GR { + prs => + import prs._ + ShadowRow.tupled((<<[String], <<[String])) + } + + /** Table description of table shadow. Objects of this class serve as prototypes for rows in queries. */ + class Shadow(_tableTag: Tag) + extends profile.api.Table[ShadowRow](_tableTag, "shadow") { + def * = (userId, hash) <> (ShadowRow.tupled, ShadowRow.unapply) + + /** Maps whole row to an option. Useful for outer joins. */ + def ? = + (Rep.Some(userId), Rep.Some(hash)).shaped.<>( + { r => + import r._; _1.map(_ => ShadowRow.tupled((_1.get, _2.get))) + }, + (_: Any) => + throw new Exception("Inserting into ? projection not supported.")) + + /** Database column user_id SqlType(UUID), PrimaryKey */ + val userId: Rep[String] = column[String]("user_id", O.PrimaryKey) + + /** Database column hash SqlType(STRING) */ + val hash: Rep[String] = column[String]("hash") + + /** Foreign key referencing Users (database name users_FK_1) */ + lazy val usersFk = foreignKey("users_FK_1", userId, Users)( + r => r.id, + onUpdate = ForeignKeyAction.NoAction, + onDelete = ForeignKeyAction.NoAction) + } + + /** Collection-like TableQuery object for table Shadow */ + lazy val Shadow = new TableQuery(tag => new Shadow(tag)) + + /** Entity class storing rows of table Users + * @param id Database column id SqlType(UUID), PrimaryKey + * @param primaryEmail Database column primary_email SqlType(STRING) + * @param fullName Database column full_name SqlType(STRING) + * @param avatar Database column avatar SqlType(STRING) + * @param lastLogin Database column last_login SqlType(TIMESTAMP) */ + case class UsersRow(id: String, + primaryEmail: String, + fullName: Option[String], + avatar: String, + lastLogin: Option[java.sql.Timestamp]) + + /** GetResult implicit for fetching UsersRow objects using plain SQL queries */ + implicit def GetResultUsersRow( + implicit e0: GR[String], + e1: GR[Option[String]], + e2: GR[Option[java.sql.Timestamp]]): GR[UsersRow] = GR { prs => + import prs._ + UsersRow.tupled( + (<<[String], + <<[String], + <<?[String], + <<[String], + <<?[java.sql.Timestamp])) + } + + /** Table description of table users. Objects of this class serve as prototypes for rows in queries. */ + class Users(_tableTag: Tag) + extends profile.api.Table[UsersRow](_tableTag, "users") { + def * = + (id, primaryEmail, fullName, avatar, lastLogin) <> (UsersRow.tupled, UsersRow.unapply) + + /** Maps whole row to an option. Useful for outer joins. */ + def ? = + (Rep.Some(id), + Rep.Some(primaryEmail), + fullName, + Rep.Some(avatar), + lastLogin).shaped.<>( + { r => + import r._; + _1.map(_ => UsersRow.tupled((_1.get, _2.get, _3, _4.get, _5))) + }, + (_: Any) => + throw new Exception("Inserting into ? projection not supported.")) + + /** Database column id SqlType(UUID), PrimaryKey */ + val id: Rep[String] = column[String]("id", O.PrimaryKey) + + /** Database column primary_email SqlType(STRING) */ + val primaryEmail: Rep[String] = column[String]("primary_email") + + /** Database column full_name SqlType(STRING) */ + val fullName: Rep[Option[String]] = column[Option[String]]("full_name") + + /** Database column avatar SqlType(STRING) */ + val avatar: Rep[String] = column[String]("avatar") + + /** Database column last_login SqlType(TIMESTAMP) */ + val lastLogin: Rep[Option[java.sql.Timestamp]] = + column[Option[java.sql.Timestamp]]("last_login") + } + + /** Collection-like TableQuery object for table Users */ + lazy val Users = new TableQuery(tag => new Users(tag)) +} diff --git a/src/main/scala/byspel/Ui.scala b/src/main/scala/byspel/Ui.scala new file mode 100644 index 0000000..ac26164 --- /dev/null +++ b/src/main/scala/byspel/Ui.scala @@ -0,0 +1,119 @@ +package byspel + +import akka.http.scaladsl.server.Route +import akka.http.scaladsl.marshalling.{Marshaller, ToEntityMarshaller} +import akka.http.scaladsl.model.headers.HttpCookie +import akka.http.scaladsl.model.{MediaTypes, StatusCodes, Uri} +import app.HttpApi +import scalatags.Text.all._ + +trait Ui extends HttpApi { self: Service with Tables => + + // allows using scalatags templates as HTTP responses + implicit val tagMarshaller: ToEntityMarshaller[Tag] = { + Marshaller.stringMarshaller(MediaTypes.`text/html`).compose { (tag: Tag) => + tag.render + } + } + + def page(content: Tag*) = html( + scalatags.Text.all.head( + link( + rel := "stylesheet", + `type` := "text/css", + href := "/assets/normalize.css" + ), + link( + rel := "stylesheet", + `type` := "text/css", + href := "/assets/main.css" + ) + ), + body( + content + ) + ) + + def loginForm(alert: Option[String]) = page( + img(src := "/assets/logo.svg"), + h3("Sign in to crashbox"), + alert match { + case Some(message) => div(`class` := "alert")(message) + case None => span() + }, + form(action := "/login", attr("method") := "post")( + label(`for` := "username")("Username or email address"), + input(`type` := "text", placeholder := "", name := "username", required), + label(`for` := "password")("Password"), + input(`type` := "password", + placeholder := "", + name := "password", + required), + button(`type` := "submit")("Sign in") + ) + ) + + def mainPage(user: UsersRow) = page( + h1(s"Welcome ${user.fullName.getOrElse("")}!"), + form(action := "/logout", attr("method") := "post")( + button(`type` := "submit")("Sign out") + ) + ) + + def authenticated(inner: UsersRow => Route): Route = + optionalCookie("session") { + case Some(sessionCookie) => + onSuccess(self.checkSession(sessionCookie.value)) { + case Some(user) => + inner(user) + case None => complete(StatusCodes.NotFound) + } + case None => complete(StatusCodes.NotFound) + } + + def route = + pathPrefix("assets") { + getFromResourceDirectory("assets") + } ~ path("login") { + get { + complete(loginForm(None)) + } ~ + post { + formFields("username", "password") { + case (u, p) => + onSuccess(self.login(u, p)) { + case None => + complete(StatusCodes.NotFound -> loginForm( + Some("Incorrect username or password."))) + case Some((user, session)) => + setCookie(HttpCookie("session", session.sessionId)) { + redirect(Uri(s"/${user.primaryEmail}"), StatusCodes.Found) + } + } + } + } + } ~ path("logout") { + post { + cookie("session") { cookiePair => + onSuccess(endSession(cookiePair.value)) { _ => + deleteCookie(cookiePair.name) { + redirect(Uri("/"), StatusCodes.Found) + } + } + } + } + } ~ path(Segment) { userEmail => + authenticated { user => + if (user.primaryEmail == userEmail) { + get { + complete(mainPage(user)) + } + } else { + complete(StatusCodes.NotFound) + } + } + } ~ get { + redirect(Uri("/login"), StatusCodes.Found) + } + +} diff --git a/src/main/scala/byspel/app/App.scala b/src/main/scala/byspel/app/App.scala new file mode 100644 index 0000000..65b3c3f --- /dev/null +++ b/src/main/scala/byspel/app/App.scala @@ -0,0 +1,57 @@ +package byspel +package app + +import akka.actor.ActorSystem +import akka.stream.{ActorMaterializer, Materializer} +import java.nio.file.{Files, Paths} +import scala.concurrent.ExecutionContext +import toml.Toml + +trait App { + + implicit lazy val system: ActorSystem = ActorSystem() + + implicit lazy val materializer: Materializer = ActorMaterializer() + + implicit lazy val executionContext: ExecutionContext = system.dispatcher + + def start(): Unit = {} + def stop(): Unit = {} + + def log(msg: String) = System.err.println(msg) + + private var _args: List[String] = Nil + def args = _args + + lazy val config = args match { + case Nil => + log("fatal: no config file given as first argument") + sys.exit(1) + case head :: _ if Files.isReadable(Paths.get(head)) => + log(s"loading config from '${args(0)}'") + import toml.Codecs._ + Toml.parseAs[Config](Files.readString(Paths.get(head))) match { + case Left(err) => + log(s"fatal: syntax error in config file: $err") + sys.exit(1) + case Right(value) => value + } + case head :: _ => + log(s"fatal: config file '$head' is not readable or does not exist") + sys.exit(1) + } + + def main(args: Array[String]): Unit = { + log("starting application") + _args = args.toList + config + sys.addShutdownHook { + log("stopping application") + stop() + log("bye") + } + start() + log("ready") + } + +} diff --git a/src/main/scala/byspel/app/config.scala b/src/main/scala/byspel/app/config.scala new file mode 100644 index 0000000..6ba9f80 --- /dev/null +++ b/src/main/scala/byspel/app/config.scala @@ -0,0 +1,16 @@ +package byspel +package app + +case class Config( + http: HttpConfig, + database: DatabaseConfig +) + +case class HttpConfig( + address: String, + port: Int +) +case class DatabaseConfig( + file: String, + sqitch_base: String +) diff --git a/src/main/scala/byspel/app/modules.scala b/src/main/scala/byspel/app/modules.scala new file mode 100644 index 0000000..a4b85ae --- /dev/null +++ b/src/main/scala/byspel/app/modules.scala @@ -0,0 +1,51 @@ +package byspel +package app + +import akka.http.scaladsl.Http +import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport +import akka.http.scaladsl.server.{Directives, Route} +import spray.json.DefaultJsonProtocol +import scala.concurrent.Await +import scala.concurrent.duration._ + +trait HttpApi + extends Directives + with SprayJsonSupport + with DefaultJsonProtocol { + def route: Route +} + +trait HttpApp extends App { self: HttpApi => + + override def start() = { + super.start() + log("binding to interface") + val future = + Http().bindAndHandle(route, config.http.address, config.http.port) + Await.result(future, 2.seconds) + } + +} + +trait DatabaseApi extends Tables { + val profile = Tables.profile + import profile.api._ + + def database: Database + +} + +trait DatabaseApp extends App { self: DatabaseApi => + import profile.api.Database + + lazy val database: Database = Database.forURL( + s"jdbc:sqlite:${config.database.file}", + driver = "org.sqlite.JDBC" + ) + + override def start() = { + super.start() + log("initializing database") + database + } +} |