Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Start work on security log #966

Draft
wants to merge 1 commit into
base: refactor/vue-user-frontend
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 58 additions & 4 deletions apiV2/app/controllers/apiv2/Keys.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
}
}
Expand Down
5 changes: 4 additions & 1 deletion models/src/main/scala/ore/db/impl/OrePostgresDriver.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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
))
}
32 changes: 32 additions & 0 deletions models/src/main/scala/ore/models/user/SecurityLogEvent.scala
Original file line number Diff line number Diff line change
@@ -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
}
}
37 changes: 33 additions & 4 deletions ore/app/controllers/Users.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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, _}
Expand All @@ -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}

Expand Down Expand Up @@ -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(_))
}
Expand Down Expand Up @@ -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 <- (
Expand Down Expand Up @@ -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 =>
Expand Down
24 changes: 24 additions & 0 deletions ore/app/views/users/securityLog.scala.html
Original file line number Diff line number Diff line change
@@ -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) {

<ul class="list-group">
@for(event <- events) {
<li class="list-group-item">
Action: @event.eventType.value
Date: @StringFormatterUtils.prettifyDateAndTime(event.createdAt.value)
IP address: @event.ipAddress.value
User agent: @event.userAgent.getOrElse("<None>")
Location: @event.location.getOrElse("Unknown")
Extra data: @event.extraData.fold("")(_.spaces4))
</li>
}
</ul>
}
22 changes: 22 additions & 0 deletions ore/conf/evolutions/default/137.sql
Original file line number Diff line number Diff line change
@@ -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;

3 changes: 2 additions & 1 deletion ore/conf/routes
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ GET /$user<^\w[\w.-]+[A-Za-z0-9](?<!\.js|\.json|\.css|\.htm|\.html|\.xml|\.j
GET /:user/sitemap.xml @controllers.Users.userSitemap(user)
POST /:user/settings/tagline @controllers.Users.saveTagline(user)
POST /:user/settings/lock/:locked @controllers.Users.setLocked(user, locked: Boolean, sso: Option[String], sig: Option[String])
GET /:user/settings/apiKeys @controllers.Users.editApiKeys(user)
GET /:user/settings/apiKeys @controllers.Users.editApiKeys(user, sso: Option[String], sig: Option[String])
GET /:user/securityLog @controllers.Users.showSecurityLog(user, sso: Option[String], sig: Option[String])
# -------- End Users --------

GET /:author/:slug @controllers.project.Projects.show(author, slug, vuePage = "")
Expand Down