From eb3c1b429460656db389bbb3184a35aedb09bce9 Mon Sep 17 00:00:00 2001 From: Katrix Date: Wed, 1 Apr 2020 00:00:28 +0200 Subject: [PATCH] Start work on security log --- apiV2/app/controllers/apiv2/Keys.scala | 62 +++++++++++++++++-- .../scala/ore/db/impl/OrePostgresDriver.scala | 5 +- .../impl/schema/SecurityLogEventTable.scala | 24 +++++++ .../ore/models/user/SecurityLogEvent.scala | 32 ++++++++++ ore/app/controllers/Users.scala | 37 +++++++++-- ore/app/views/users/securityLog.scala.html | 24 +++++++ ore/conf/evolutions/default/137.sql | 22 +++++++ ore/conf/routes | 3 +- 8 files changed, 199 insertions(+), 10 deletions(-) create mode 100644 models/src/main/scala/ore/db/impl/schema/SecurityLogEventTable.scala create mode 100644 models/src/main/scala/ore/models/user/SecurityLogEvent.scala create mode 100644 ore/app/views/users/securityLog.scala.html create mode 100644 ore/conf/evolutions/default/137.sql diff --git a/apiV2/app/controllers/apiv2/Keys.scala b/apiV2/app/controllers/apiv2/Keys.scala index f0b2ecb2e..18dfa0026 100644 --- a/apiV2/app/controllers/apiv2/Keys.scala +++ b/apiV2/app/controllers/apiv2/Keys.scala @@ -12,11 +12,14 @@ import db.impl.query.APIV2Queries import models.protocols.APIV2 import ore.db.impl.OrePostgresDriver.api._ import ore.db.impl.schema.ApiKeyTable +import ore.models.user.SecurityLogEvent import ore.permission.{NamedPermission, Permission} import cats.data.NonEmptyList import cats.syntax.all._ -import io.circe.Codec +import com.github.tminglei.slickpg.InetString +import io.circe.{Codec, Json} +import io.circe.syntax._ import io.circe.derivation.annotations.SnakeCaseJsonCodec import zio.interop.catz._ import zio.{IO, ZIO} @@ -54,9 +57,35 @@ class Keys( TableQuery[ApiKeyTable].filter(t => t.name === name && t.ownerId === ownerId).exists.result val ifTaken = IO.fail(Conflict(ApiError("Name already taken"))) - val ifFree = service - .runDbCon(APIV2Queries.createApiKey(name, ownerId, tokenIdentifier, token, perm).run) - .map(_ => Ok(CreatedApiKey(s"$tokenIdentifier.$token", perm.toNamedSeq))) + + val ifFree = for { + _ <- service.runDbCon(APIV2Queries.createApiKey(name, ownerId, tokenIdentifier, token, perm).run) + _ <- service.insert( + SecurityLogEvent( + ownerId, + InetString(request.remoteAddress), + request.headers.get("User-Agent"), + ???, + SecurityLogEvent.EventType.CreateApiKey, + Some( + Json.obj( + "new_key" -> Json.obj( + "name" := name, + "permissions" := perms.map(_.entryName), + "identifier" := tokenIdentifier + ), + "used_key" := request.apiInfo.key.map { creator => + Json.obj( + "name" := creator.name, + "permissions" := creator.namedRawPermissions.map(_.entryName), + "identifier" := creator.tokenIdentifier + ) + } + ) + ) + ) + ) + } yield Ok(CreatedApiKey(s"$tokenIdentifier.$token", perm.toNamedSeq)) (service.runDBIO(nameTaken): IO[Result, Boolean]).ifM(ifTaken, ifFree) } @@ -72,6 +101,31 @@ class Keys( .fromOption(request.user) .asError(BadRequest(ApiError("Public keys can't be used to delete"))) rowsAffected <- service.runDbCon(APIV2Queries.deleteApiKey(name, user.id.value).run) + _ <- ZIO.when(rowsAffected != 0)( + service.insert( + SecurityLogEvent( + request.user.get.id.value, + InetString(request.remoteAddress), + request.headers.get("User-Agent"), + ???, + SecurityLogEvent.EventType.DeleteApiKey, + Some( + Json.obj( + "deleted_key" -> Json.obj( + "name" := name + ), + "used_key" := request.apiInfo.key.map { creator => + Json.obj( + "name" := creator.name, + "permissions" := creator.namedRawPermissions.map(_.entryName), + "identifier" := creator.tokenIdentifier + ) + } + ) + ) + ) + ) + ) } yield if (rowsAffected == 0) NotFound else NoContent } } diff --git a/models/src/main/scala/ore/db/impl/OrePostgresDriver.scala b/models/src/main/scala/ore/db/impl/OrePostgresDriver.scala index d623f5eda..a271e6aaa 100644 --- a/models/src/main/scala/ore/db/impl/OrePostgresDriver.scala +++ b/models/src/main/scala/ore/db/impl/OrePostgresDriver.scala @@ -12,7 +12,7 @@ import ore.data.{Color, DownloadType, Prompt} import ore.db.OreProfile import ore.models.Job import ore.models.project.{ReviewState, TagColor, Version, Visibility} -import ore.models.user.{LoggedActionContext, LoggedActionType} +import ore.models.user.{LoggedActionContext, LoggedActionType, SecurityLogEvent} import ore.permission.Permission import ore.permission.role.{Role, RoleCategory} @@ -90,6 +90,9 @@ trait OrePostgresDriver implicit val releaseTypeTypeMapper: BaseColumnType[Version.ReleaseType] = pgEnumForValueEnum("RELEASE_TYPE", Version.ReleaseType) + implicit val securityLogEventTypeTypeMapper: BaseColumnType[SecurityLogEvent.EventType] = + pgEnumForValueEnum("SECURITY_LOG_EVENT_TYPE", SecurityLogEvent.EventType) + implicit val langTypeMapper: BaseColumnType[Locale] = MappedJdbcType.base[Locale, String](_.toLanguageTag, Locale.forLanguageTag) diff --git a/models/src/main/scala/ore/db/impl/schema/SecurityLogEventTable.scala b/models/src/main/scala/ore/db/impl/schema/SecurityLogEventTable.scala new file mode 100644 index 000000000..22133a650 --- /dev/null +++ b/models/src/main/scala/ore/db/impl/schema/SecurityLogEventTable.scala @@ -0,0 +1,24 @@ +package ore.db.impl.schema + +import ore.db.DbRef +import ore.db.impl.OrePostgresDriver.api._ +import ore.models.user.{SecurityLogEvent, User} + +import com.github.tminglei.slickpg.InetString +import io.circe.Json + +class SecurityLogEventTable(tag: Tag) extends ModelTable[SecurityLogEvent](tag, "security_log_events") { + def userId: Rep[DbRef[User]] = column[DbRef[User]]("userId") + def ipAddress: Rep[InetString] = column[InetString]("ipAddress") + def userAgent: Rep[Option[String]] = column[Option[String]]("userAgent") + def location: Rep[Option[String]] = column[Option[String]]("location") + def eventType: Rep[SecurityLogEvent.EventType] = column[SecurityLogEvent.EventType]("eventType") + def extraData: Rep[Option[Json]] = column[Option[Json]]("extraData") + + def * = + (id.?, createdAt.?, (userId, ipAddress, userAgent, location, eventType, extraData)) <> (mkApply( + (SecurityLogEvent.apply _).tupled + ), mkUnapply( + SecurityLogEvent.unapply + )) +} diff --git a/models/src/main/scala/ore/models/user/SecurityLogEvent.scala b/models/src/main/scala/ore/models/user/SecurityLogEvent.scala new file mode 100644 index 000000000..a1eda3163 --- /dev/null +++ b/models/src/main/scala/ore/models/user/SecurityLogEvent.scala @@ -0,0 +1,32 @@ +package ore.models.user + +import ore.db.{DbRef, ModelQuery} +import ore.db.impl.DefaultModelCompanion +import ore.db.impl.schema.SecurityLogEventTable + +import com.github.tminglei.slickpg.InetString +import enumeratum.values.{StringEnum, StringEnumEntry} +import io.circe.Json +import slick.lifted.TableQuery + +case class SecurityLogEvent( + userId: DbRef[User], + ipAddress: InetString, + userAgent: Option[String], + location: Option[String], + eventType: SecurityLogEvent.EventType, + extraData: Option[Json] +) +object SecurityLogEvent + extends DefaultModelCompanion[SecurityLogEvent, SecurityLogEventTable](TableQuery[SecurityLogEventTable]) { + implicit val query: ModelQuery[SecurityLogEvent] = ModelQuery.from(this) + + sealed abstract class EventType(val value: String) extends StringEnumEntry + object EventType extends StringEnum[EventType] { + case object Login extends EventType("login") + case object CreateApiKey extends EventType("create_api_key") + case object DeleteApiKey extends EventType("delete_api_key") + + override def values: IndexedSeq[EventType] = findValues + } +} diff --git a/ore/app/controllers/Users.scala b/ore/app/controllers/Users.scala index 819ef6e6b..81161c302 100644 --- a/ore/app/controllers/Users.scala +++ b/ore/app/controllers/Users.scala @@ -17,7 +17,7 @@ import ore.data.Prompt import ore.db.access.ModelView import ore.db.impl.OrePostgresDriver.api._ import ore.db.impl.query.UserQueries -import ore.db.impl.schema.{ApiKeyTable, PageTable, ProjectTable, UserTable, VersionTable} +import ore.db.impl.schema.{ApiKeyTable, PageTable, ProjectTable, SecurityLogEventTable, UserTable, VersionTable} import ore.db.{DbRef, Model} import ore.models.user.notification.{InviteFilter, NotificationFilter} import ore.models.user.{FakeUser, _} @@ -28,6 +28,8 @@ import util.syntax._ import views.{html => views} import cats.syntax.all._ +import com.github.tminglei.slickpg.InetString +import io.circe.Json import zio.interop.catz._ import zio.{IO, Task, UIO, ZIO} @@ -88,7 +90,17 @@ class Users @Inject()( fromSponge = sponge.toUser // Complete authentication user <- users.getOrCreate(sponge.username, fromSponge, _ => IO.unit) - _ <- user.globalRoles.deleteAllFromParent + _ <- service.insert( + SecurityLogEvent( + user.id, + InetString(request.remoteAddress), + request.headers.get("User-Agent"), + ???, + SecurityLogEvent.EventType.Login, + None + ) + ) + _ <- user.globalRoles.deleteAllFromParent _ <- sponge.newGlobalRoles.fold(IO.unit) { roles => ZIO.foreachPar_(roles.map(_.toDbRole.id))(user.globalRoles.addAssoc(_)) } @@ -310,8 +322,8 @@ class Users @Inject()( } } - def editApiKeys(username: String): Action[AnyContent] = - Authenticated.asyncF { implicit request => + def editApiKeys(username: String, sso: Option[String], sig: Option[String]): Action[AnyContent] = + VerifiedAction(username, sso, sig).asyncF { implicit request => if (request.user.name == username) { for { t1 <- ( @@ -344,6 +356,23 @@ class Users @Inject()( } else IO.fail(Forbidden) } + def showSecurityLog(user: String, sso: Option[String], sig: Option[String]): Action[AnyContent] = + VerifiedAction(user, sso, sig).asyncF { implicit request => + for { + t1 <- ( + getOrga(user).option, + UserData.of(request, request.user) + ).parTupled + (orga, userData) = t1 + t2 <- ( + OrganizationData.of[Task](orga).value.orDie, + ScopedOrganizationData.of(request.currentUser, orga).value + ).parTupled + (orgaData, scopedOrgaData) = t2 + events <- service.runDBIO(TableQuery[SecurityLogEventTable].filter(_.userId === request.user.id.value).result) + } yield Ok(views.users.securityLog(userData, (orgaData, scopedOrgaData).tupled, events)) + } + import controllers.project.{routes => projectRoutes} def userSitemap(user: String): Action[AnyContent] = Action.asyncF { implicit request => diff --git a/ore/app/views/users/securityLog.scala.html b/ore/app/views/users/securityLog.scala.html new file mode 100644 index 000000000..c307adc1c --- /dev/null +++ b/ore/app/views/users/securityLog.scala.html @@ -0,0 +1,24 @@ +@import models.viewhelper.{OrganizationData, ScopedOrganizationData, UserData} +@import ore.models.user.SecurityLogEvent + +@import controllers.sugar.Requests.OreRequest +@import ore.OreConfig +@import ore.db.Model +@import util.StringFormatterUtils +@(u: UserData, o: Option[(OrganizationData, ScopedOrganizationData)], events: Seq[Model[SecurityLogEvent]])(implicit messages: Messages, flash: Flash, request: OreRequest[_], config: OreConfig, assetsFinder: AssetsFinder) + +@users.view(u, o) { + + +} \ No newline at end of file diff --git a/ore/conf/evolutions/default/137.sql b/ore/conf/evolutions/default/137.sql new file mode 100644 index 000000000..7e43bbf38 --- /dev/null +++ b/ore/conf/evolutions/default/137.sql @@ -0,0 +1,22 @@ +# --- !Ups + +CREATE TYPE SECURITY_LOG_EVENT_TYPE AS ENUM ('login', 'create_api_key', 'delete_api_key'); + +CREATE TABLE security_log_events +( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + user_id BIGINT NOT NULL REFERENCES users, + ip_address INET NOT NULL, + user_agent TEXT, + location TEXT, + event SECURITY_LOG_EVENT_TYPE NOT NULL, + extra_data JSONB +); + + +# --- !Downs + +DROP TABLE security_log_events; +DROP TYPE SECURITY_LOG_EVENT_TYPE; + diff --git a/ore/conf/routes b/ore/conf/routes index 4a89bc835..42cfffece 100644 --- a/ore/conf/routes +++ b/ore/conf/routes @@ -92,7 +92,8 @@ GET /$user<^\w[\w.-]+[A-Za-z0-9](?