~jan0sch/smederee
Showing details for patch 165d002e8e6688c228dba115255aa39fa3171181.
diff -rN -u old-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-with-comment.pub new-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-with-comment.pub --- old-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-with-comment.pub 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-with-comment.pub 2025-02-01 22:02:23.224439541 +0000 @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH8ZU2xquZvstbesPktthwY2r5sanULBQKuM5bGHVdeP Some optional comment... diff -rN -u old-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-without-comment.pub new-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-without-comment.pub --- old-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-without-comment.pub 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-without-comment.pub 2025-02-01 22:02:23.224439541 +0000 @@ -0,0 +1 @@ +ssh-dss AAAAB3NzaC1kc3MAAACBAKn1DHh6DaIg/cN6vNVh1VXvHhH86eKelfsolIfvTPQSb3vkqoWPG3T3DGmrUjbqvrrfzaKILTBRv05KqMCbJKETGR0fuY7G1/Nkd/6dZjw1ngYkGd0fr2ERGuq87+gdd1A3TeIqvdjnl7MG3bEGf+fIEJOrRJraZ+u/tDFlSYq/AAAAFQCAUrv94uu1dVTTiyoagKV4Y4QWuQAAAIAuR5mFFYAgT1+t1u16eRCou1nPO4+q35/6uNNCyXtP0BmZaxXqQw25foJz5OzSQWXjjianfRfUyjsHt5DgM0PAIZaqmxMUiVw7BT7zUTa7ucl9NQmFBexiedCtokVb8++vHVZ7Y42tf2CpqVW8T2lw5b8sWb7rHYGarI935qv2bgAAAIABfRnu0PkvysY6QJhUCD4ZKt3qZ6E1cYDivLhDb4GAZxmxSeN5cFPXU3Gst0oNmNjUW55rsZwZP+KkXi3NwAsTd9dZBxkcc+28m8Dr4hGtPTnPp+4p8wzw/X6Lmyr6RSykCK6xuv9rc2td+1fgNyPoWwcLZZQclDj+OdgQVHWj3A== diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala 2025-02-01 22:02:23.220439533 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala 2025-02-01 22:02:23.224439541 +0000 @@ -17,12 +17,14 @@ package de.smederee.hub +import java.time.{ OffsetDateTime, ZoneOffset } import java.util.UUID import cats.effect._ import cats.syntax.all._ import de.smederee.hub.Generators._ import de.smederee.hub.config.SmedereeHubConfig +import de.smederee.ssh._ import doobie._ import org.flywaydb.core.Flyway @@ -46,6 +48,80 @@ val _ = flyway.clean() } + val sshKeyWithComment = ResourceSuiteLocalFixture( + "ssh-key-with-comment", + Resource.make(IO { + val input = scala.io.Source + .fromInputStream( + getClass().getClassLoader().getResourceAsStream("de/smederee/ssh/ssh-key-with-comment.pub"), + "UTF-8" + ) + .getLines() + .mkString + val keyString = SshPublicKeyString(input) + PublicSshKey.from(UUID.randomUUID())(UserId.randomUserId)(OffsetDateTime.now(ZoneOffset.UTC))(keyString) + })(_ => IO.unit) + ) + + val sshKeyWithoutComment = ResourceSuiteLocalFixture( + "ssh-key-without-comment", + Resource.make(IO { + val input = scala.io.Source + .fromInputStream( + getClass().getClassLoader().getResourceAsStream("de/smederee/ssh/ssh-key-without-comment.pub"), + "UTF-8" + ) + .getLines() + .mkString + val keyString = SshPublicKeyString(input) + PublicSshKey.from(UUID.randomUUID())(UserId.randomUserId)(OffsetDateTime.now(ZoneOffset.UTC))(keyString) + })(_ => IO.unit) + ) + + override def munitFixtures = List(sshKeyWithComment, sshKeyWithoutComment) + + test("addSshKey must save the key to the database") { + (genValidAccount.sample, sshKeyWithComment()) match { + case (Some(account), Some(sshKey)) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAccountManagementRepository[IO](tx) + val attempts = scala.util.Random.nextInt(128) + val hash = PasswordHash("Yet another weak password!") + val test = for { + _ <- createAccount(account, hash, None, Option(attempts)) + w <- repo.addSshKey(sshKey.copy(ownerId = account.uid)) + keys <- repo.listSshKeys(account.uid).compile.toList + } yield (w, keys) + test.map { result => + val (written, keys) = result + assert(written === 1, "No database rows written!") + assert(keys.exists(_.id === sshKey.id), "Key must be in the key list of the user!") + } + case _ => fail("Could not generate data samples!") + } + } + + test("addSshKey must fail if a key with the same fingerprint already exists") { + (genValidAccount.sample, sshKeyWithoutComment()) match { + case (Some(account), Some(sshKey)) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAccountManagementRepository[IO](tx) + val attempts = scala.util.Random.nextInt(128) + val hash = PasswordHash("Yet another weak password!") + val test = for { + _ <- createAccount(account, hash, None, Option(attempts)) + _ <- repo.addSshKey(sshKey.copy(ownerId = account.uid)) + _ <- repo.addSshKey(sshKey.copy(ownerId = account.uid)) + } yield () + test.attempt.map { result => + assert(result.isLeft, "Writing a key with a duplicate fingerprint must fail!") + } + case _ => fail("Could not generate data samples!") + } + } + test("deleteAccount must remove the account from the database") { genValidAccount.sample match { case None => fail("Could not generate data samples!") @@ -99,6 +175,29 @@ } } + test("listSshKeys must return all keys for the user") { + (genValidAccount.sample, sshKeyWithComment(), sshKeyWithoutComment()) match { + case (Some(account), Some(sshKeyA), Some(sshKeyB)) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAccountManagementRepository[IO](tx) + val attempts = scala.util.Random.nextInt(128) + val hash = PasswordHash("Yet another weak password!") + val test = for { + _ <- createAccount(account, hash, None, Option(attempts)) + _ <- repo.addSshKey(sshKeyA.copy(ownerId = account.uid)) + _ <- repo.addSshKey(sshKeyB.copy(ownerId = account.uid)) + keys <- repo.listSshKeys(account.uid).compile.toList + } yield keys + test.map { keys => + assertEquals(keys.length, 2, "Expected 2 keys in the key list!") + assert(keys.exists(_.id === sshKeyA.id), "Key A must be in the key list of the user!") + assert(keys.exists(_.id === sshKeyB.id), "Key B must be in the key list of the user!") + } + case _ => fail("Could not generate data samples!") + } + } + test("markAsValidated must clear the validation token and set the validated column to true") { genValidAccount.sample match { case None => fail("Could not generate data samples!") diff -rN -u old-smederee/modules/hub/src/main/resources/assets/css/main.css new-smederee/modules/hub/src/main/resources/assets/css/main.css --- old-smederee/modules/hub/src/main/resources/assets/css/main.css 2025-02-01 22:02:23.220439533 +0000 +++ new-smederee/modules/hub/src/main/resources/assets/css/main.css 2025-02-01 22:02:23.224439541 +0000 @@ -8,6 +8,15 @@ padding: 0px 10px; } +.add-ssh-key-form { + border: 1px solid black; + padding: 0px 10px; +} + +.ssh-key-item-icon { + margin-right: 5px; +} + .validate-email-form { border: 1px solid black; padding: 0px 10px; @@ -152,6 +161,11 @@ word-wrap: break-word; } +.account-settings-description { + background-color: #eee; + padding: 0em 0.5em 0em 0.5em; +} + .overview-latest-changes { font-size: 85%; } @@ -229,6 +243,18 @@ padding: 0; } +.left-floated { + float: left; +} + +.right-floated { + float: right; +} + +.clearfix { + clear: both; +} + @media (min-width: 48em) { /* We increase the body font size */ diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql --- old-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql 2025-02-01 22:02:23.220439533 +0000 +++ new-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql 2025-02-01 22:02:23.224439541 +0000 @@ -58,13 +58,14 @@ CREATE TABLE "ssh_keys" ( - "id" UUID NOT NULL, - "uid" UUID NOT NULL, - "key_type" CHARACTER VARYING(32) NOT NULL, - "key" TEXT NOT NULL, - "fingerprint" CHARACTER VARYING(256) NOT NULL, - "comment" CHARACTER VARYING(256) DEFAULT NULL, - "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "id" UUID NOT NULL, + "uid" UUID NOT NULL, + "key_type" CHARACTER VARYING(32) NOT NULL, + "key" TEXT NOT NULL, + "fingerprint" CHARACTER VARYING(256) NOT NULL, + "comment" CHARACTER VARYING(256) DEFAULT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "last_used_at" TIMESTAMP WITH TIME ZONE DEFAULT NULL, CONSTRAINT "ssh_keys_pk" PRIMARY KEY ("id"), CONSTRAINT "ssh_keys_unique_fp" UNIQUE ("fingerprint"), CONSTRAINT "ssh_keys_fk_uid" FOREIGN KEY ("uid") @@ -82,3 +83,4 @@ COMMENT ON COLUMN "ssh_keys"."fingerprint" IS 'The fingerprint of the ssh key. It must be unique because a key can only be used by one account.'; COMMENT ON COLUMN "ssh_keys"."comment" IS 'An optional comment for the ssh key limited to 256 characters.'; COMMENT ON COLUMN "ssh_keys"."created_at" IS 'The timestamp of when the ssh key was created.'; +COMMENT ON COLUMN "ssh_keys"."last_used_at" IS 'The timestamp of when the ssh key was last used by the user.'; diff -rN -u old-smederee/modules/hub/src/main/resources/messages_en.properties new-smederee/modules/hub/src/main/resources/messages_en.properties --- old-smederee/modules/hub/src/main/resources/messages_en.properties 2025-02-01 22:02:23.220439533 +0000 +++ new-smederee/modules/hub/src/main/resources/messages_en.properties 2025-02-01 22:02:23.224439541 +0000 @@ -62,6 +62,13 @@ form.signup.username=Username form.signup.username.help=A username is required because it will be used to group your projects and other stuff. It must be between 2 and 31 characters long and contain only lowercase alphanumeric characters and start with a character (letter). form.signup.username.placeholder=Please choose your username. +form.ssh.add.button.reset=Cancel +form.ssh.add.button.submit=Add SSH key +form.ssh.add.key=Key content +form.ssh.add.key.help=Please note that we currently only support RSA keys! +form.ssh.add.key.placeholder=The content of your key file (usually located in ~/.ssh/id_rsa.pub) starting with ssh-rsa or ssh-ed25519. +form.ssh.add.name=Name +form.ssh.add.name.help=If left empty it will be taken from the ssh key comment (if that exists). # Global / generic translations global.alpha=Alpha Notice @@ -175,5 +182,13 @@ # User management / settings user.settings.account.delete.title=Delete your account +user.settings.account.description=On this page you can manage your basic account settings and validate or delete your account. +user.settings.account.title=Account user.settings.account.validate-email.title=Validate your email address +user.settings.ssh.add.title=Add a new public ssh key. +user.settings.ssh.description=Here you can manage your SSH keys. +user.settings.ssh.key.created=Uploaded on {0,date,yyyy-MM-dd (E)} +user.settings.ssh.key.last-used=Last used on {0,date,yyyy-MM-dd (E)} +user.settings.ssh.title=SSH-Keys +user.settings.title=Settings diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala 2025-02-01 22:02:23.220439533 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala 2025-02-01 22:02:23.224439541 +0000 @@ -17,6 +17,11 @@ package de.smederee.hub +import java.util.UUID + +import de.smederee.ssh.PublicSshKey +import fs2.Stream + /** The base class for database operations related to account management for users. * * @tparam F @@ -24,6 +29,16 @@ */ abstract class AccountManagementRepository[F[_]] { + /** Add the given ssh key to the database. The database MUST ensure that a key is unique across the system! Usually by + * having a unique constraint on the fingerprint of the key. + * + * @param key + * The public ssh key to be saved to the database. + * @return + * The number of affected database rows. + */ + def addSshKey(key: PublicSshKey): F[Int] + /** Delete the account with the given user id from the database. * * The internal database logic (foreign keys, cascading) SHALL ensure that everything related to the user is deleted @@ -36,6 +51,17 @@ */ def deleteAccount(uid: UserId): F[Int] + /** Delete the ssh key with the given id and owner from the database. + * + * @param keyId + * The unique id of the public ssh key. + * @param ownerId + * The unique user id of the owner of the ssh key. + * @return + * The number of affected database rows. + */ + def deleteSshKey(keyId: UUID, ownerId: UserId): F[Int] + /** Find the account with the given validation token. * * @param token @@ -54,6 +80,15 @@ */ def findPasswordHash(uid: UserId): F[Option[PasswordHash]] + /** Return all public ssh keys for the given user. + * + * @param uid + * The unique id of the user account. + * @return + * A stream of public ssh keys that may be empty. + */ + def listSshKeys(uid: UserId): Stream[F, PublicSshKey] + /** Mark the account with the given user id as validated. This includes the following operations in the database: * * {{{ diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala 2025-02-01 22:02:23.220439533 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala 2025-02-01 22:02:23.224439541 +0000 @@ -19,6 +19,8 @@ import java.io.IOException import java.nio.file.{ FileVisitResult, FileVisitor, Files } +import java.time.{ OffsetDateTime, ZoneOffset } +import java.util.UUID import cats._ import cats.data._ @@ -28,7 +30,9 @@ import de.smederee.html.LinkTools._ import de.smederee.hub.RequestHelpers.instances.given import de.smederee.hub.config._ +import de.smederee.hub.forms.types._ import de.smederee.security.SignAndValidate +import de.smederee.ssh._ import org.http4s._ import org.http4s.dsl._ import org.http4s.headers.Location @@ -112,6 +116,91 @@ } } yield deleted + private val addSshKey: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ POST -> Root / "user" / "settings" / "ssh" / "add" as user => + ar.req.decodeStrict[F, UrlForm] { urlForm => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + formData <- Sync[F].delay { + urlForm.values.map { t => + val (key, values) = t + ( + key, + values.headOption.getOrElse("") + ) // Pick the first value (a field might get submitted multiple times)! + } + } + form <- Sync[F].delay(AddPublicSshKeyForm.validate(formData)) + actionBaseUri <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings")) + addAction <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/ssh/add")) + deleteAction <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/ssh/delete")) + keys <- accountManagementRepo.listSshKeys(user.uid).compile.toList + resp <- form match { + case Validated.Invalid(errors) => + BadRequest( + views.html.account.sshSettings()(csrf, Option(s"Smederee/~${user.name} - SSH Settings"), user)( + actionBaseUri, + addAction, + deleteAction, + keys + )(formData, FormErrors.fromNec(errors)) + ) + case Validated.Valid(validSshKeyForm) => + for { + id <- Sync[F].delay(UUID.randomUUID()) + createdAt <- Sync[F].delay(OffsetDateTime.now(ZoneOffset.UTC)) + convertedKey <- Sync[F].delay(PublicSshKey.from(id)(user.uid)(createdAt)(validSshKeyForm.keyString)) + key <- Sync[F].delay( + convertedKey.map(key => validSshKeyForm.name.fold(key)(name => key.copy(comment = Option(name)))) + ) // Override comment with the one from the form if given. + written <- key.traverse(key => accountManagementRepo.addSshKey(key).recoverWith(_ => Sync[F].pure(0))) + resp <- written match { + case None => + // There has been no write at all, implying that the conversion failed. + BadRequest( + views.html.account.sshSettings()(csrf, Option(s"Smederee/~${user.name} - SSH Settings"), user)( + actionBaseUri, + addAction, + deleteAction, + keys + )( + formData, + Map( + AddPublicSshKeyForm.fieldGlobal -> List( + FormFieldError("The key could not be properly converted!") + ) + ) + ) + ) + case Some(1) => + // One row was written to the database implying that everything went well. + SeeOther(Location(actionBaseUri.addSegment("ssh"))) + case Some(_) => + // Any other result implies that there has been an error. + accountManagementRepo.listSshKeys(user.uid).compile.toList.flatMap { keys => + BadRequest( + views.html.account.sshSettings()(csrf, Option(s"Smederee/~${user.name} - SSH Settings"), user)( + actionBaseUri, + addAction, + deleteAction, + keys + )( + formData, + Map( + AddPublicSshKeyForm.fieldGlobal -> List( + FormFieldError("An error occured while saving the key to the database!") + ) + ) + ) + ) + } + } + } yield resp + } + } yield resp + } + } + private val deleteAccount: AuthedRoutes[Account, F] = AuthedRoutes.of { case ar @ POST -> Root / "user" / "settings" / "delete" as user => ar.req.decodeStrict[F, UrlForm] { urlForm => @@ -129,6 +218,7 @@ passwordField <- Sync[F].delay(formData.get("password").flatMap(Password.from)) userIsSure <- Sync[F].delay(formData.get("i-am-sure").exists(_ === "yes")) rootUri <- Sync[F].delay(configuration.external.createFullUri(uri"/")) + actionBaseUri <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings")) deleteAction <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/delete")) validateAction <- Sync[F].delay( configuration.external.createFullUri(uri"user/settings/email/validate") @@ -162,6 +252,7 @@ } else BadRequest( views.html.account.settings()(csrf, Option(s"Smederee/~${user.name} - Settings"), user)( + actionBaseUri, deleteAction, validateAction ) @@ -195,13 +286,15 @@ private val showAccountSettings: AuthedRoutes[Account, F] = AuthedRoutes.of { case ar @ GET -> Root / "user" / "settings" as user => for { - csrf <- Sync[F].delay(ar.req.getCsrfToken) - deleteAction <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/delete")) + csrf <- Sync[F].delay(ar.req.getCsrfToken) + actionBaseUri <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings")) + deleteAction <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/delete")) validateAction <- Sync[F].delay( configuration.external.createFullUri(uri"user/settings/email/validate") ) resp <- Ok( views.html.account.settings()(csrf, Option(s"Smederee/~${user.name} - Settings"), user)( + actionBaseUri, deleteAction, validateAction ) @@ -209,6 +302,25 @@ } yield resp } + private val showAccountSshSettings: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ GET -> Root / "user" / "settings" / "ssh" as user => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + actionBaseUri <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings")) + addAction <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/ssh/add")) + deleteAction <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/ssh/delete")) + keys <- accountManagementRepo.listSshKeys(user.uid).compile.toList + resp <- Ok( + views.html.account.sshSettings()(csrf, Option(s"Smederee/~${user.name} - SSH Settings"), user)( + actionBaseUri, + addAction, + deleteAction, + keys + )() + ) + } yield resp + } + private val validateEmailAddress: HttpRoutes[F] = HttpRoutes.of { case req @ GET -> Root / "user" / "settings" / "email" / "validate" / ValidationTokenPathParameter( token @@ -221,7 +333,8 @@ } yield resp } - val protectedRoutes = deleteAccount <+> sendValidationEmail <+> showAccountSettings + val protectedRoutes = + addSshKey <+> deleteAccount <+> sendValidationEmail <+> showAccountSettings <+> showAccountSshSettings val routes = validateEmailAddress diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AddPublicSshKeyForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AddPublicSshKeyForm.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AddPublicSshKeyForm.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AddPublicSshKeyForm.scala 2025-02-01 22:02:23.224439541 +0000 @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.hub + +import cats.data._ +import cats.syntax.all._ +import de.smederee.hub.forms._ +import de.smederee.hub.forms.types._ +import de.smederee.ssh._ + +/** Data container for the form to add a new public ssh key. + * + * @param name + * An optional name for the key. + * @param keyString + * A string containing a valid public key. + */ +final case class AddPublicSshKeyForm(name: Option[KeyComment], keyString: SshPublicKeyString) + +object AddPublicSshKeyForm extends FormValidator[AddPublicSshKeyForm] { + val fieldName: FormField = FormField("name") + val fieldKey: FormField = FormField("key") + + override def validate(data: Map[String, String]): ValidatedNec[FormErrors, AddPublicSshKeyForm] = { + val name = data.get(fieldName).fold(None.validNec)(name => KeyComment.from(name).validNec) + val key = data + .get(fieldKey) + .fold(FormFieldError("No ssh public key given!").invalidNec)(string => + SshPublicKeyString.from(string.trim).fold(FormFieldError("Invalid ssh public key!").invalidNec)(_.validNec) + ) + .leftMap(errors => NonEmptyChain.of(Map(fieldKey -> errors.toList))) + (name, key).mapN { case (validName, validKey) => + AddPublicSshKeyForm(validName, validKey) + } + } + +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala 2025-02-01 22:02:23.220439533 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala 2025-02-01 22:02:23.224439541 +0000 @@ -22,6 +22,7 @@ import cats.effect._ import cats.syntax.all._ import de.smederee.hub.VcsMetadataRepositoriesOrdering._ +import de.smederee.ssh._ import doobie._ import doobie.Fragments._ import doobie.implicits._ @@ -31,16 +32,28 @@ final class DoobieAccountManagementRepository[F[_]: Sync](tx: Transactor[F]) extends AccountManagementRepository[F] { given Meta[Email] = Meta[String].timap(Email.apply)(_.toString) + given Meta[EncodedKeyBytes] = Meta[String].timap(EncodedKeyBytes.unsafeFrom)(_.toString) + given Meta[KeyComment] = Meta[String].timap(KeyComment.apply)(_.toString) + given Meta[KeyFingerprint] = Meta[String].timap(KeyFingerprint.apply)(_.toString) given Meta[PasswordHash] = Meta[String].timap(PasswordHash.apply)(_.toString) + given Meta[SshKeyType] = Meta[String].timap(id => SshKeyType.Mappings(id))(_.identifier) given Meta[UserId] = Meta[UUID].timap(UserId.apply)(_.toUUID) given Meta[Username] = Meta[String].timap(Username.apply)(_.toString) given Meta[ValidationToken] = Meta[String].timap(ValidationToken.apply)(_.toString) private val selectAccountColumns = fr"""SELECT uid, name, email, validated_email FROM "accounts"""" + override def addSshKey(key: PublicSshKey): F[Int] = + sql"""INSERT INTO "ssh_keys" (id, uid, key_type, key, fingerprint, comment, created_at) + VALUES(${key.id}, ${key.ownerId}, ${key.keyType}, ${key.keyBytes}, ${key.fingerprint}, ${key.comment}, NOW())""".update.run + .transact(tx) + override def deleteAccount(uid: UserId): F[Int] = sql"""DELETE FROM "accounts" WHERE uid = $uid""".update.run.transact(tx) + override def deleteSshKey(keyId: UUID, ownerId: UserId): F[Int] = + sql"""DELETE FROM "ssh_keys" WHERE id = $keyId AND uid = $ownerId""".update.run.transact(tx) + override def findByValidationToken(token: ValidationToken): F[Option[Account]] = { val query = selectAccountColumns ++ fr"""WHERE validation_token = $token""" ++ fr"""LIMIT 1""" query.query[Account].option.transact(tx) @@ -49,6 +62,12 @@ override def findPasswordHash(uid: UserId): F[Option[PasswordHash]] = sql"""SELECT password FROM "accounts" WHERE uid = $uid LIMIT 1""".query[PasswordHash].option.transact(tx) + override def listSshKeys(uid: UserId): Stream[F, PublicSshKey] = + sql"""SELECT id, uid, key_type, key, fingerprint, comment, created_at, last_used_at FROM "ssh_keys" WHERE uid = $uid""" + .query[PublicSshKey] + .stream + .transact(tx) + override def markAsValidated(uid: UserId): F[Int] = sql"""UPDATE "accounts" SET validated_email = TRUE, validation_token = NULL WHERE uid = $uid""".update.run .transact(tx) diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala new-smederee/modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala 2025-02-01 22:02:23.220439533 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala 2025-02-01 22:02:23.224439541 +0000 @@ -18,6 +18,7 @@ package de.smederee.ssh import java.security.MessageDigest +import java.time.OffsetDateTime import java.util.{ Base64, UUID } import cats._ @@ -50,6 +51,19 @@ */ def from(source: String): Option[EncodedKeyBytes] = Option(source).filter(string => Format.matches(string)) + /** An unsafe method to create an instance of EncodedKeyBytes from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a EncodedKeyBytes. + * @return + * The converted EncodedKeyBytes instance. + */ + @throws[IllegalArgumentException]("if the given string is not syntactically valid") + def unsafeFrom(source: String): EncodedKeyBytes = + from(source) match { + case None => throw new IllegalArgumentException(s"Illegal format for EncodedKeyBytes: $source") + case Some(keyBytes) => keyBytes + } } extension (keyBytes: EncodedKeyBytes) { @@ -62,79 +76,51 @@ def toByteArray: Array[Byte] = Base64.getDecoder().decode(keyBytes) } -opaque type KeyFingerprint = String -object KeyFingerprint { +opaque type KeyComment = String +object KeyComment { + val MaxLength: Int = 256 - /** Create an instance of KeyFingerprint from the given String type. + /** Create an instance of KeyComment from the given String type. * * @param source - * An instance of type String which will be returned as a KeyFingerprint. + * An instance of type String which will be returned as a KeyComment. * @return - * The appropriate instance of KeyFingerprint. + * The appropriate instance of KeyComment. */ - def apply(source: String): KeyFingerprint = source + def apply(source: String): KeyComment = source - /** Try to create an instance of KeyFingerprint from the given String. + /** Try to create an instance of KeyComment from the given String. * * @param source - * A String that should fulfil the requirements to be converted into a KeyFingerprint. + * A String that should fulfil the requirements to be converted into a KeyComment. * @return - * An option to the successfully converted KeyFingerprint. + * An option to the successfully converted KeyComment. */ - def from(source: String): Option[KeyFingerprint] = Option(source).filter(_.nonEmpty) + def from(source: String): Option[KeyComment] = Option(source).map(_.take(MaxLength)).filter(_.nonEmpty) } -opaque type PublicSshKeyId = UUID -object PublicSshKeyId { - val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r - - given Eq[PublicSshKeyId] = Eq.fromUniversalEquals - - /** Create an instance of PublicSshKeyId from the given UUID type. - * - * @param source - * An instance of type UUID which will be returned as a PublicSshKeyId. - * @return - * The appropriate instance of PublicSshKeyId. - */ - def apply(source: UUID): PublicSshKeyId = source +opaque type KeyFingerprint = String +object KeyFingerprint { - /** Try to create an instance of PublicSshKeyId from the given UUID. + /** Create an instance of KeyFingerprint from the given String type. * * @param source - * A UUID that should fulfil the requirements to be converted into a PublicSshKeyId. + * An instance of type String which will be returned as a KeyFingerprint. * @return - * An option to the successfully converted PublicSshKeyId. + * The appropriate instance of KeyFingerprint. */ - def from(source: UUID): Option[PublicSshKeyId] = Option(source) + def apply(source: String): KeyFingerprint = source - /** Try to create an instance of PublicSshKeyId from the given String. + /** Try to create an instance of KeyFingerprint from the given String. * * @param source - * A String that should fulfil the requirements to be converted into a PublicSshKeyId. - * @return - * An option to the successfully converted PublicSshKeyId. - */ - def fromString(source: String): Either[String, PublicSshKeyId] = - Option(source) - .filter(s => Format.matches(s)) - .flatMap { uuidString => - Either.catchNonFatal(UUID.fromString(uuidString)).toOption - } - .toRight("Illegal value for PublicSshKeyId!") - - /** Generate a new random key id. - * + * A String that should fulfil the requirements to be converted into a KeyFingerprint. * @return - * A key id which is pseudo randomly generated. + * An option to the successfully converted KeyFingerprint. */ - def randomId: PublicSshKeyId = UUID.randomUUID - -} + def from(source: String): Option[KeyFingerprint] = Option(source).filter(_.nonEmpty) -extension (keyId: PublicSshKeyId) { - def toUUID: UUID = keyId } opaque type SshPublicKeyString = String @@ -161,31 +147,34 @@ } /** Possible ssh key types. + * + * @param identifier + * A unique identifier (the code written into openssh key files). */ -enum SshKeyType { - case SshDsa +enum SshKeyType(val identifier: String) { + case SshDsa extends SshKeyType("ssh-dss") - case SshEcDsa + case SshEcDsa extends SshKeyType("ecdsa-sha2-nistp256") - case SshEcDsaSk + // case SshEcDsaSk /** Recommended key type because smaller payload and more secure but some servers still don't support them. */ - case SshEd25519 + case SshEd25519 extends SshKeyType("ssh-ed25519") - case SshEd25519Sk + // case SshEd25519Sk /** RSA keys are (still) the most common ones and the most compatible. */ - case SshRsa + case SshRsa extends SshKeyType("ssh-rsa") } object SshKeyType { val Mappings: Map[String, SshKeyType] = Map( - "ssh-dss" -> SshDsa, - "ecdsa-sha2-nistp256" -> SshEcDsa, - "ssh-ed25519" -> SshEd25519, - "ssh-rsa" -> SshRsa + SshDsa.identifier -> SshDsa, + SshEcDsa.identifier -> SshEcDsa, + SshEd25519.identifier -> SshEd25519, + SshRsa.identifier -> SshRsa ) given Eq[SshKeyType] = Eq.fromUniversalEquals @@ -219,17 +208,24 @@ * @param keyBytes * The actual key (base 64 encoded). * @param fingerprint - * The fingerprint of the key. + * The fingerprint of the key using SHA-256. * @param comment - * An optional comment for the key, quite often this is an email address. + * An optional comment for the key, quite often this is an email address. Currently this is limited to 256 + * characters. + * @param createdAt + * The timestamp of when the ssh key was created. + * @param lastUsedAt + * The timestamp of when the ssh key was last used by the user. */ final case class PublicSshKey( - id: PublicSshKeyId, + id: UUID, ownerId: UserId, keyType: SshKeyType, keyBytes: EncodedKeyBytes, fingerprint: KeyFingerprint, - comment: Option[String] + comment: Option[KeyComment], + createdAt: OffsetDateTime, + lastUsedAt: Option[OffsetDateTime] ) object PublicSshKey { @@ -241,19 +237,21 @@ * The globally unique id of the ssh key. * @param ownerId * The unique id of the user account associated with this key. + * @param createdAt + * The timestamp of when the ssh key (this [[PublicSshKey]]) was created. * @param sshKey * A string containing a valid OpenSSH public key. * @return * An option to the sucessfully created [[PublicSshKey]]. */ - def from(id: PublicSshKeyId)(ownerId: UserId)(sshKey: SshPublicKeyString): Option[PublicSshKey] = { + def from(id: UUID)(ownerId: UserId)(createdAt: OffsetDateTime)(sshKey: SshPublicKeyString): Option[PublicSshKey] = { val keyType = SshKeyType.from(sshKey.toString) val base64Key = EncodedKeyBytes.from( sshKey.toString.dropWhile(char => !char.isWhitespace).trim.takeWhile(char => !char.isWhitespace) ) val comment = sshKey.toString.split("\\s").drop(2).toList match { case Nil => None - case commentParts => Option(commentParts.mkString(" ")) + case commentParts => KeyComment.from(commentParts.mkString(" ")) } val fingerprint = base64Key.flatMap { base64Key => val rawFingerprint = Try { @@ -264,7 +262,7 @@ rawFingerprint.flatMap(KeyFingerprint.from) } (keyType, base64Key, fingerprint).mapN { case (keyType, base64Key, fingerprint) => - PublicSshKey(id, ownerId, keyType, base64Key, fingerprint, comment) + PublicSshKey(id, ownerId, keyType, base64Key, fingerprint, comment, createdAt, None) } } } diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html 2025-02-01 22:02:23.220439533 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html 2025-02-01 22:02:23.224439541 +0000 @@ -1,7 +1,23 @@ -@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(deleteAction: Uri, validateAction: Uri) +@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(actionBaseUri: Uri, deleteAction: Uri, validateAction: Uri) @main(baseUri, lang)()(csrf, title, user.some) { @defining(lang.toLocale) { implicit locale => <div class="content"> + <div class="pure-g"> + <div class="pure-u-1-1 pure-u-md-1-1"> + <div class="l-box-left-right"> + <h2>~@user.name / Settings</h2> + <nav class="pure-menu pure-menu-horizontal"> + <ul class="pure-menu-list"> + <li class="pure-menu-item pure-menu-active"><a class="pure-menu-link" href="@actionBaseUri">@Messages("user.settings.account.title")</a></li> + <li class="pure-menu-item"><a class="pure-menu-link" href="@actionBaseUri.addSegment("ssh")">@Messages("user.settings.ssh.title")</a></li> + </ul> + </nav> + <div class="account-settings-description"> + @Messages("user.settings.account.description") + </div> + </div> + </div> + </div> <div class="pure-g"> @if(user.validatedEmail) { } else { diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/sshSettings.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/sshSettings.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/sshSettings.scala.html 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/sshSettings.scala.html 2025-02-01 22:02:23.224439541 +0000 @@ -0,0 +1,88 @@ +@import de.smederee.hub.AddPublicSshKeyForm._ +@import de.smederee.ssh.PublicSshKey + +@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(actionBaseUri: Uri, addAction: Uri, deleteAction: Uri, keys: List[PublicSshKey])(formData: Map[String, String] = Map.empty, formErrors: FormErrors = FormErrors.empty) +@main(baseUri, lang)()(csrf, title, user.some) { +@defining(lang.toLocale) { implicit locale => +<div class="content"> + <div class="pure-g"> + <div class="pure-u-1-1 pure-u-md-1-1"> + <div class="l-box-left-right"> + <h2>~@user.name / Settings</h2> + <nav class="pure-menu pure-menu-horizontal"> + <ul class="pure-menu-list"> + <li class="pure-menu-item"><a class="pure-menu-link" href="@actionBaseUri">@Messages("user.settings.account.title")</a></li> + <li class="pure-menu-item pure-menu-active"><a class="pure-menu-link" href="@actionBaseUri.addSegment("ssh")">@Messages("user.settings.ssh.title")</a></li> + </ul> + </nav> + <div class="account-settings-description"> + @Messages("user.settings.ssh.description") + </div> + </div> + </div> + </div> + <div class="pure-g"> + <div class="pure-u-1-1 pure-u-md-1-1"> + <div class="l-box"> + <div class="add-ssh-key-form"> + <h4>@Messages("user.settings.ssh.add.title")</h4> + <div class="form-errors"> + @formErrors.get(fieldGlobal).map { es => + @for(error <- es) { + <p class="alert alert-error"> + <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> + <span class="sr-only">Fehler:</span> + @error + </p> + } + } + </div> + <form action="@addAction" class="pure-form" method="POST" accept-charset="UTF-8"> + <fieldset class="pure-group"> + <div class="pure-control-group"> + <label for="@{fieldName}">@Messages("form.ssh.add.name")</label> + <input class="pure-input-1" id="@{fieldName}" name="@{fieldName}" maxlength="256" type="text" value="@{formData.get(fieldName)}"> + <span class="pure-form-message" id="@{fieldName}.help">@Messages("form.ssh.add.name.help")</span> + @renderFormErrors(fieldName, formErrors) + </div> + <div class="pure-control-group"> + <label for="@{fieldKey}">@Messages("form.ssh.add.key")</label> + <textarea class="pure-input-1" id="@{fieldKey}" name="@{fieldKey}" placeholder="@Messages("form.ssh.add.key.placeholder")" maxlength="4096" rows="8">@{formData.get(fieldKey)}</textarea> + <span class="pure-form-message" id="@{fieldKey}.help"><strong>@Messages("form.ssh.add.key.help")</strong></span> + @renderFormErrors(fieldKey, formErrors) + </div> + @csrfToken(csrf) + <button type="submit" class="pure-button pure-button-primary">@Messages("form.ssh.add.button.submit")</button> + <button type="reset" class="button-secondary pure-button">@Messages("form.ssh.add.button.reset")</button> + </fieldset> + </form> + </div> + </div> + </div> + </div> + <div class="pure-g"> + <div class="pure-u-1-1 pure-u-md-1-1"> + <div class="l-box"> + <div class="ssh-key-list"> + @for(key <- keys) { + <div class="ssh-key-item"> + <div class="ssh-key-item-icon left-floated"> + <i class="fa-solid fa-key fa-3x"></i> + </div> + <div class="ssh-key-item-details"> + <strong>@key.comment</strong> + <pre>SHA256:@key.fingerprint</pre> + <div class="ssh-key-item-timestamps"> + @Messages("user.settings.ssh.key.created", java.util.Date.from(key.createdAt.toInstant)) @key.lastUsedAt.map(timestamp => Messages("user.settings.ssh.key.last-used", java.util.Date.from(timestamp.toInstant))) + </div> + </div> + <div class="clearfix"></div> + </div> + } + </div> + </div> + </div> + </div> +</div> +} +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/ssh/PublicSshKeyTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/ssh/PublicSshKeyTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/ssh/PublicSshKeyTest.scala 2025-02-01 22:02:23.224439541 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/ssh/PublicSshKeyTest.scala 2025-02-01 22:02:23.224439541 +0000 @@ -17,6 +17,9 @@ package de.smederee.ssh +import java.time.{ OffsetDateTime, ZoneOffset } +import java.util.UUID + import de.smederee.hub._ import munit._ @@ -61,11 +64,11 @@ .getLines() .mkString val sshKey = SshPublicKeyString(input) - val id = PublicSshKeyId.randomId + val id = UUID.randomUUID() val ownerId = UserId.randomUserId - PublicSshKey.from(id)(ownerId)(sshKey) match { + PublicSshKey.from(id)(ownerId)(OffsetDateTime.now(ZoneOffset.UTC))(sshKey) match { case Some(key) => - assertEquals(key.comment, Option("Some optional comment...")) + assertEquals(key.comment, Option(KeyComment("Some optional comment..."))) assertEquals(key.fingerprint, KeyFingerprint("qduYGwQlx7kMHo7GBNwx6tULMTxbuEbDJ6pdgC88ZSo")) assertEquals(key.id, id) assertEquals( @@ -87,9 +90,9 @@ .getLines() .mkString val sshKey = SshPublicKeyString(input) - val id = PublicSshKeyId.randomId + val id = UUID.randomUUID() val ownerId = UserId.randomUserId - PublicSshKey.from(id)(ownerId)(sshKey) match { + PublicSshKey.from(id)(ownerId)(OffsetDateTime.now(ZoneOffset.UTC))(sshKey) match { case Some(key) => assertEquals(key.comment, None) assertEquals(key.fingerprint, KeyFingerprint("tPwxtBT8SN5nq1kbT/vERM/pkJUAtMP6j+sf3m75UqE"))