controllers.U2FController.scala Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of otoroshi_2.12 Show documentation
Show all versions of otoroshi_2.12 Show documentation
Lightweight api management on top of a modern http reverse proxy
The newest version!
package otoroshi.controllers
import java.security.SecureRandom
import java.util
import java.util.Optional
import java.util.concurrent.TimeUnit
import otoroshi.actions.{BackOfficeAction, BackOfficeActionAuth}
import akka.http.scaladsl.model.Uri
import akka.http.scaladsl.util.FastFuture
import com.fasterxml.jackson.annotation.JsonInclude.Include
import com.fasterxml.jackson.databind.{ObjectMapper, SerializationFeature}
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module
import com.yubico.webauthn._
import com.yubico.webauthn.data._
import otoroshi.env.Env
import otoroshi.events._
import otoroshi.models.BackOfficeUser
import org.joda.time.DateTime
import org.mindrot.jbcrypt.BCrypt
import otoroshi.models.RightsChecker.{SuperAdminOnly, TenantAdminOnly}
import otoroshi.models._
import play.api.Logger
import play.api.libs.json._
import play.api.mvc._
import otoroshi.security.IdGenerator
import otoroshi.utils.syntax.implicits._
import scala.concurrent.duration.Duration
import scala.util.{Failure, Success, Try}
class U2FController(
BackOfficeAction: BackOfficeAction,
BackOfficeActionAuth: BackOfficeActionAuth,
cc: ControllerComponents
)(implicit env: Env)
extends AbstractController(cc) {
implicit lazy val ec = env.otoroshiExecutionContext
lazy val logger = Logger("otoroshi-u2f-controller")
private val base64Encoder = java.util.Base64.getUrlEncoder
private val base64Decoder = java.util.Base64.getUrlDecoder
private val random = new SecureRandom()
private val jsonMapper = new ObjectMapper()
.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false)
.setSerializationInclusion(Include.NON_ABSENT)
.registerModule(new Jdk8Module())
def loginPage() =
BackOfficeAction { ctx =>
Ok(otoroshi.views.html.backoffice.u2flogin(env))
}
/////////// Simple admins ////////////////////////////////////////////////////////////////////////////////////////////
def simpleLogin =
BackOfficeAction.async(parse.json) { ctx =>
implicit val req = ctx.request
val usernameOpt = (ctx.request.body \ "username").asOpt[String]
val passwordOpt = (ctx.request.body \ "password").asOpt[String]
(usernameOpt, passwordOpt) match {
case (Some(username), Some(pass)) => {
env.datastores.simpleAdminDataStore.findByUsername(username).flatMap {
case Some(user) => {
val password = user.password
val label = user.label
if (BCrypt.checkpw(pass, password)) {
if (logger.isDebugEnabled) logger.debug(s"Login successful for simple admin '$username'")
BackOfficeUser(
randomId = IdGenerator.token(64),
name = username,
email = username,
profile = Json.obj(
"name" -> label,
"email" -> username
),
token = Json.obj(),
authConfigId = "none",
simpleLogin = true,
tags = Seq.empty,
metadata = Map.empty,
rights = user.rights,
location = user.location,
adminEntityValidators = user.adminEntityValidators
).save(Duration(env.backOfficeSessionExp, TimeUnit.MILLISECONDS)).map { boUser =>
env.datastores.simpleAdminDataStore.hasAlreadyLoggedIn(username).map {
case false => {
env.datastores.simpleAdminDataStore.alreadyLoggedIn(username)
Alerts
.send(AdminFirstLogin(env.snowflakeGenerator.nextIdStr(), env.env, boUser, ctx.from, ctx.ua))
}
case true => {
Alerts
.send(
AdminLoggedInAlert(
env.snowflakeGenerator.nextIdStr(),
env.env,
boUser,
ctx.from,
ctx.ua,
"local"
)
)
}
}
Ok(Json.obj("username" -> username)).addingToSession("bousr" -> boUser.randomId)
}
} else {
Unauthorized(Json.obj("error" -> "not authorized")).future
}
}
case None => Unauthorized(Json.obj("error" -> "not authorized")).future
}
}
case _ => Unauthorized(Json.obj("error" -> "not authorized")).future
}
}
/*
def registerSimpleAdmin = BackOfficeActionAuth.async(parse.json) { ctx =>
ctx.checkRights(TenantAdminOnly) {
val usernameOpt = (ctx.request.body \ "username").asOpt[String]
val passwordOpt = (ctx.request.body \ "password").asOpt[String]
val labelOpt = (ctx.request.body \ "label").asOpt[String]
val rights = UserRights(Seq(UserRight(TenantAccess(ctx.currentTenant.value), Seq(TeamAccess("*"))))) // UserRights.readFromObject(ctx.request.body)
(usernameOpt, passwordOpt, labelOpt) match {
case (Some(username), Some(password), Some(label)) => {
val saltedPassword = BCrypt.hashpw(password, BCrypt.gensalt())
env.datastores.simpleAdminDataStore.registerUser(SimpleOtoroshiAdmin(
username = username,
password = saltedPassword,
label = label,
createdAt = DateTime.now(),
typ = OtoroshiAdminType.SimpleAdmin,
metadata = Map.empty,
rights = rights,
location = EntityLocation(ctx.currentTenant, Seq(TeamId.all)) // EntityLocation.readFromKey(ctx.request.body)
)).map { _ =>
Ok(Json.obj("username" -> username))
}
}
case _ => FastFuture.successful(BadRequest(Json.obj("error" -> "no username or token provided")))
}
}
}
def simpleAdmins = BackOfficeActionAuth.async { ctx =>
ctx.checkRights(TenantAdminOnly) {
val paginationPage: Int = ctx.request.queryString.get("page").flatMap(_.headOption).map(_.toInt).getOrElse(1)
val paginationPageSize: Int =
ctx.request.queryString.get("pageSize").flatMap(_.headOption).map(_.toInt).getOrElse(Int.MaxValue)
val paginationPosition = (paginationPage - 1) * paginationPageSize
env.datastores.simpleAdminDataStore.findAll() map { users =>
Ok(JsArray(users.filter(ctx.canUserRead).drop(paginationPosition).take(paginationPageSize).map(_.json)))
}
}
}
def deleteAdmin(username: String) = BackOfficeActionAuth.async { ctx =>
ctx.checkRights(TenantAdminOnly) {
env.datastores.simpleAdminDataStore.findByUsername(username).flatMap {
case None => NotFound(Json.obj("error" -> "User not found !")).future
case Some(user) if !ctx.canUserWrite(user) => ctx.fforbidden
case Some(_) => {
env.datastores.simpleAdminDataStore.deleteUser(username).map { d =>
val event = BackOfficeEvent(
env.snowflakeGenerator.nextIdStr(),
env.env,
ctx.user,
"DELETE_ADMIN",
s"Admin deleted an Admin",
ctx.from,
ctx.ua,
Json.obj("username" -> username)
)
Audit.send(event)
Alerts.send(U2FAdminDeletedAlert(env.snowflakeGenerator.nextIdStr(), env.env, ctx.user, event, ctx.from, ctx.ua))
Ok(Json.obj("done" -> true))
}
}
}
}
}
*/
/////////// WebAuthn admins ////////////////////////////////////////////////////////////////////////////////////////////
/*
def webAuthnAdmins() = BackOfficeActionAuth.async { ctx =>
ctx.checkRights(TenantAdminOnly) {
val paginationPage: Int = ctx.request.queryString.get("page").flatMap(_.headOption).map(_.toInt).getOrElse(1)
val paginationPageSize: Int =
ctx.request.queryString.get("pageSize").flatMap(_.headOption).map(_.toInt).getOrElse(Int.MaxValue)
val paginationPosition = (paginationPage - 1) * paginationPageSize
env.datastores.webAuthnAdminDataStore.findAll() map { users =>
Ok(JsArray(users.filter(ctx.canUserRead).drop(paginationPosition).take(paginationPageSize).map(_.json)))
}
}
}
def webAuthnDeleteAdmin(username: String, id: String) = BackOfficeActionAuth.async { ctx =>
ctx.checkRights(TenantAdminOnly) {
env.datastores.webAuthnAdminDataStore.findByUsername(username).flatMap {
case None => NotFound(Json.obj("error" -> "User not found !")).future
case Some(user) if !ctx.canUserWrite(user) => ctx.fforbidden
case Some(_) => {
env.datastores.webAuthnAdminDataStore.deleteUser(username).map { d =>
val event = BackOfficeEvent(
env.snowflakeGenerator.nextIdStr(),
env.env,
ctx.user,
"DELETE_WEBAUTHN_ADMIN",
s"Admin deleted a WebAuthn Admin",
ctx.from,
ctx.ua,
Json.obj("username" -> username, "id" -> id)
)
Audit.send(event)
Alerts
.send(WebAuthnAdminDeletedAlert(env.snowflakeGenerator.nextIdStr(), env.env, ctx.user, event, ctx.from, ctx.ua))
Ok(Json.obj("done" -> true))
}
}
}
}
}
*/
def webAuthnRegistrationStart() =
BackOfficeActionAuth.async(parse.json) { ctx =>
ctx.checkRights(TenantAdminOnly) {
import collection.JavaConverters._
val username = (ctx.request.body \ "username").as[String]
val label = (ctx.request.body \ "label").as[String]
val reqOrigin = (ctx.request.body \ "origin").as[String]
val reqOriginHost = Uri(reqOrigin).authority.host.address()
val reqOriginDomain: String = reqOriginHost.split("\\.").toList.reverse match {
case tld :: domain :: _ => s"$domain.$tld"
case value => value.mkString(".")
}
env.datastores.webAuthnAdminDataStore.findAll().flatMap { users =>
val rpIdentity: RelyingPartyIdentity = RelyingPartyIdentity.builder.id(reqOriginDomain).name("Otoroshi").build
val rp: RelyingParty = RelyingParty.builder
.identity(rpIdentity)
.credentialRepository(new LocalCredentialRepository(users, jsonMapper, base64Decoder))
.origins(Seq(reqOrigin, reqOriginDomain).toSet.asJava)
.build
val userHandle = new Array[Byte](64)
random.nextBytes(userHandle)
val registrationRequestId = IdGenerator.token(32)
val request: PublicKeyCredentialCreationOptions = rp.startRegistration(
StartRegistrationOptions.builder
.user(
UserIdentity.builder
.name(username)
.displayName(label)
.id(new ByteArray(userHandle))
.build
)
.build
)
val jsonRequest = jsonMapper.writeValueAsString(request)
val finalRequest = Json.obj(
"requestId" -> registrationRequestId,
"request" -> Json.parse(jsonRequest),
"username" -> username,
"label" -> label,
"handle" -> base64Encoder.encodeToString(userHandle)
)
env.datastores.webAuthnRegistrationsDataStore
.setRegistrationRequest(registrationRequestId, finalRequest)
.map { _ =>
Ok(finalRequest)
}
}
}
}
def webAuthnRegistrationFinish() =
BackOfficeActionAuth.async(parse.json) { ctx =>
ctx.checkRights(SuperAdminOnly) {
import collection.JavaConverters._
val json = ctx.request.body
val responseJson = Json.stringify((json \ "webauthn").as[JsValue])
val otoroshi = (json \ "otoroshi").as[JsObject]
val reqOrigin = (otoroshi \ "origin").as[String]
val reqId = (json \ "requestId").as[String]
val handle = (otoroshi \ "handle").as[String]
val reqOriginHost = Uri(reqOrigin).authority.host.address()
val reqOriginDomain: String = reqOriginHost.split("\\.").toList.reverse match {
case tld :: domain :: _ => s"$domain.$tld"
case value => value.mkString(".")
}
env.datastores.webAuthnAdminDataStore.findAll().flatMap { users =>
env.datastores.webAuthnRegistrationsDataStore.getRegistrationRequest(reqId).flatMap {
case None => FastFuture.successful(BadRequest(Json.obj("error" -> "bad request")))
case Some(rawRequest) => {
Try {
val request = jsonMapper.readValue(
Json.stringify((rawRequest \ "request").as[JsValue]),
classOf[PublicKeyCredentialCreationOptions]
)
val rpIdentity: RelyingPartyIdentity =
RelyingPartyIdentity.builder.id(reqOriginDomain).name("Otoroshi").build
val rp: RelyingParty = RelyingParty.builder
.identity(rpIdentity)
.credentialRepository(new LocalCredentialRepository(users, jsonMapper, base64Decoder))
.origins(Seq(reqOrigin, reqOriginDomain).toSet.asJava)
.build
val pkc = PublicKeyCredential.parseRegistrationResponseJson(responseJson)
rp.finishRegistration(
FinishRegistrationOptions
.builder()
.request(request)
.response(pkc)
.build()
)
} match {
case Failure(e) =>
e.printStackTrace()
FastFuture.successful(BadRequest(Json.obj("error" -> "bad request 111")))
case Success(result) => {
val username = (otoroshi \ "username").as[String]
val password = (otoroshi \ "password").as[String]
val label = (otoroshi \ "label").as[String]
val rights =
UserRights(
Seq(UserRight(TenantAccess(ctx.currentTenant.value), Seq(TeamAccess("*"))))
) // UserRights.readFromObject(otoroshi)
val saltedPassword = BCrypt.hashpw(password, BCrypt.gensalt())
val credential = Json.parse(jsonMapper.writeValueAsString(result))
env.datastores.webAuthnAdminDataStore.findByUsername(username).flatMap {
case None => {
env.datastores.webAuthnAdminDataStore
.registerUser(
WebAuthnOtoroshiAdmin(
username = username,
password = saltedPassword,
label = label,
handle = handle,
credentials = Map((credential \ "keyId" \ "id").as[String] -> credential),
createdAt = DateTime.now(),
typ = OtoroshiAdminType.WebAuthnAdmin,
metadata = Map.empty,
rights = rights,
adminEntityValidators = Map.empty,
location = EntityLocation(
ctx.currentTenant,
Seq(TeamId.all)
) //EntityLocation.readFromKey(ctx.request.body)
)
)
.map { _ =>
Ok(Json.obj("username" -> username))
}
}
case Some(user) if BCrypt.checkpw(password, user.password) => {
// update usrer
env.datastores.webAuthnAdminDataStore
.registerUser(
user.copy(
credentials = user.credentials + (
(credential \ "keyId" \ "id").as[String] -> credential
)
)
)
.map { _ =>
Ok(Json.obj("username" -> username))
}
}
case Some(user) => Unauthorized(Json.obj("error" -> "bad credentials")).future
}
}
}
}
}
}
}
}
def webAuthnLoginStart() =
BackOfficeAction.async(parse.json) { ctx =>
import collection.JavaConverters._
val usernameOpt = (ctx.request.body \ "username").asOpt[String]
val passwordOpt = (ctx.request.body \ "password").asOpt[String]
val reqOrigin = (ctx.request.body \ "origin").as[String]
val reqOriginHost = Uri(reqOrigin).authority.host.address()
val reqOriginDomain: String = reqOriginHost.split("\\.").toList.reverse match {
case tld :: domain :: _ => s"$domain.$tld"
case value => value.mkString(".")
}
(usernameOpt, passwordOpt) match {
case (Some(username), Some(password)) => {
env.datastores.webAuthnAdminDataStore.findAll().flatMap { users =>
users.find(u => u.username == username) match {
case Some(user) if BCrypt.checkpw(password, user.password) => {
val rpIdentity: RelyingPartyIdentity =
RelyingPartyIdentity.builder.id(reqOriginDomain).name("Otoroshi").build
val rp: RelyingParty = RelyingParty.builder
.identity(rpIdentity)
.credentialRepository(new LocalCredentialRepository(users, jsonMapper, base64Decoder))
.origins(Seq(reqOrigin, reqOriginDomain).toSet.asJava)
.build
val request: AssertionRequest =
rp.startAssertion(StartAssertionOptions.builder.username(Optional.of(username)).build)
val registrationRequestId = IdGenerator.token(32)
val jsonRequest: String = jsonMapper.writeValueAsString(request)
val finalRequest = Json.obj(
"requestId" -> registrationRequestId,
"request" -> Json.parse(jsonRequest),
"username" -> username,
"label" -> "--"
)
env.datastores.webAuthnRegistrationsDataStore
.setRegistrationRequest(registrationRequestId, finalRequest)
.map { _ =>
Ok(finalRequest)
}
}
case _ => FastFuture.successful(BadRequest(Json.obj("error" -> "bad request")))
}
}
}
case (_, _) => {
FastFuture.successful(BadRequest(Json.obj("error" -> "bad request")))
}
}
}
def webAuthnLoginFinish() =
BackOfficeAction.async(parse.json) { ctx =>
import collection.JavaConverters._
implicit val req = ctx.request
val json = ctx.request.body
val webauthn = (json \ "webauthn").as[JsObject]
val otoroshi = (json \ "otoroshi").as[JsObject]
val reqOrigin = (otoroshi \ "origin").as[String]
val reqId = (json \ "requestId").as[String]
val reqOriginHost = Uri(reqOrigin).authority.host.address()
val reqOriginDomain: String = reqOriginHost.split("\\.").toList.reverse match {
case tld :: domain :: _ => s"$domain.$tld"
case value => value.mkString(".")
}
val usernameOpt = (otoroshi \ "username").asOpt[String]
val passwordOpt = (otoroshi \ "password").asOpt[String]
(usernameOpt, passwordOpt) match {
case (Some(username), Some(pass)) => {
env.datastores.webAuthnAdminDataStore.findAll().flatMap { users =>
users.find(u => u.username == username) match {
case None => FastFuture.successful(BadRequest(Json.obj("error" -> "Bad user")))
case Some(user) => {
env.datastores.webAuthnRegistrationsDataStore.getRegistrationRequest(reqId).flatMap {
case None => FastFuture.successful(BadRequest(Json.obj("error" -> "bad request")))
case Some(rawRequest) => {
val request = jsonMapper
.readValue(Json.stringify((rawRequest \ "request").as[JsValue]), classOf[AssertionRequest])
val password = user.password
val label = user.label
if (BCrypt.checkpw(pass, password)) {
Try {
val rpIdentity: RelyingPartyIdentity =
RelyingPartyIdentity.builder.id(reqOriginDomain).name("Otoroshi").build
val rp: RelyingParty = RelyingParty.builder
.identity(rpIdentity)
.credentialRepository(new LocalCredentialRepository(users, jsonMapper, base64Decoder))
.origins(Seq(reqOrigin, reqOriginDomain).toSet.asJava)
.build
val pkc = PublicKeyCredential.parseAssertionResponseJson(Json.stringify(webauthn))
rp.finishAssertion(
FinishAssertionOptions
.builder()
.request(request)
.response(pkc)
.build()
)
} match {
case Failure(e) =>
FastFuture.successful(BadRequest(Json.obj("error" -> "bad request")))
case Success(result) if !result.isSuccess =>
FastFuture.successful(BadRequest(Json.obj("error" -> "bad request")))
case Success(result) if result.isSuccess => {
if (logger.isDebugEnabled) logger.debug(s"Login successful for user '$username'")
BackOfficeUser(
randomId = IdGenerator.token(64),
name = username,
email = username,
profile = Json.obj(
"name" -> label,
"email" -> username
),
token = Json.obj(),
authConfigId = "none",
simpleLogin = false,
tags = Seq.empty,
metadata = Map.empty,
rights = user.rights,
location = user.location,
adminEntityValidators = user.adminEntityValidators
).save(Duration(env.backOfficeSessionExp, TimeUnit.MILLISECONDS)).map { boUser =>
env.datastores.webAuthnAdminDataStore.hasAlreadyLoggedIn(username).map {
case false => {
env.datastores.webAuthnAdminDataStore.alreadyLoggedIn(username)
Alerts.send(
AdminFirstLogin(env.snowflakeGenerator.nextIdStr(), env.env, boUser, ctx.from, ctx.ua)
)
}
case true => {
Alerts.send(
AdminLoggedInAlert(
env.snowflakeGenerator.nextIdStr(),
env.env,
boUser,
ctx.from,
ctx.ua,
"local"
)
)
}
}
Ok(
Json.obj("username" -> username)
).addingToSession("bousr" -> boUser.randomId)
}
}
}
} else {
FastFuture.successful(Unauthorized(Json.obj("error" -> "Not Authorized")))
}
}
}
}
}
}
}
case (_, _) => FastFuture.successful(Unauthorized(Json.obj("error" -> "Not Authorized")))
}
}
}
class LocalCredentialRepository(
users: Seq[WebAuthnOtoroshiAdmin],
jsonMapper: ObjectMapper,
base64Decoder: java.util.Base64.Decoder
) extends CredentialRepository {
import collection.JavaConverters._
// changes in webauthn-server-core 2.1.0 from 1.7.0 forces us to do some shenanigans
def handleVersion210Upgrade(json: JsValue): JsValue = {
json
.applyOnIf(json.select("keyId").select("transports").asOpt[JsValue].isEmpty) { js =>
js.asObject ++ Json.obj("keyId" -> (json.select("keyId").asObject ++ Json.obj("transports" -> JsArray())))
}
.applyOnIf(json.select("aaguid").asOpt[JsValue].isEmpty) { js =>
js.asObject ++ Json.obj("aaguid" -> "AAAAAAAAAAAAAAAAAAAAAA")
}
.applyOnIf(json.select("signatureCount").asOpt[JsValue].isEmpty) { js =>
js.asObject ++ Json.obj("signatureCount" -> 1)
}
.applyOnIf(json.select("clientExtensionOutputs").asOpt[JsValue].isEmpty) { js =>
js.asObject ++ Json.obj("clientExtensionOutputs" -> Json.obj("credProps" -> Json.obj()))
}
.applyOnIf(json.select("authenticatorExtensionOutputs").asOpt[JsValue].isEmpty) { js =>
js.asObject ++ Json.obj("authenticatorExtensionOutputs" -> Json.obj())
}
.applyOnIf(json.select("attestationTrustPath").asOpt[JsValue].isEmpty) { js =>
js.asObject ++ Json.obj("attestationTrustPath" -> Json.arr())
}
.applyOnIf(json.select("warnings").asOpt[JsValue].isDefined) { js =>
js.asObject - "warnings"
}
}
override def getCredentialIdsForUsername(username: String): util.Set[PublicKeyCredentialDescriptor] = {
users
.filter(_.username == username)
.flatMap { user =>
user.credentials.values.map { credential =>
val regResult =
jsonMapper.readValue(handleVersion210Upgrade(credential).stringify, classOf[RegistrationResult])
regResult.getKeyId
}
}
.toSet
.asJava
}
override def getUserHandleForUsername(username: String): Optional[ByteArray] = {
users
.find(_.username == username)
.map { user =>
new ByteArray(base64Decoder.decode(user.handle))
} match {
case None => Optional.empty()
case Some(r) => Optional.of(r)
}
}
override def getUsernameForUserHandle(userHandle: ByteArray): Optional[String] = {
users
.find { user =>
val handle = new ByteArray(base64Decoder.decode(user.handle))
handle.equals(userHandle)
}
.map(_.username) match {
case None => Optional.empty()
case Some(r) => Optional.of(r)
}
}
override def lookup(credentialId: ByteArray, userHandle: ByteArray): Optional[RegisteredCredential] = {
users
.flatMap { user =>
user.credentials.map { case (id, reg) =>
val handle = new ByteArray(base64Decoder.decode(user.handle))
val regResult = jsonMapper.readValue(handleVersion210Upgrade(reg).stringify, classOf[RegistrationResult])
(handle, regResult.getKeyId.getId, regResult)
}.toSeq
}
.find { case (handle, id, reg) =>
handle.equals(userHandle) && credentialId.equals(id)
}
.map { case (handle, id, regResult) =>
RegisteredCredential
.builder()
.credentialId(regResult.getKeyId.getId)
.userHandle(handle)
.publicKeyCose(regResult.getPublicKeyCose)
.signatureCount(0L)
.build()
} match {
case None => Optional.empty()
case Some(r) => Optional.of(r)
}
}
override def lookupAll(credentialId: ByteArray): util.Set[RegisteredCredential] = {
users
.flatMap { user =>
user.credentials.map { case (id, reg) =>
val handle = new ByteArray(base64Decoder.decode(user.handle))
val regResult = jsonMapper.readValue(handleVersion210Upgrade(reg).stringify, classOf[RegistrationResult])
(handle, regResult.getKeyId.getId, regResult)
}.toSeq
}
.filter { case (handle, id, reg) =>
credentialId.equals(id)
}
.map { case (handle, id, regResult) =>
RegisteredCredential
.builder()
.credentialId(regResult.getKeyId.getId)
.userHandle(handle)
.publicKeyCose(regResult.getPublicKeyCose)
.signatureCount(0L)
.build()
}
.toSet
.asJava
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy