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

Implement WebAuthn #8109

Draft
wants to merge 1 commit into
base: master
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
3 changes: 3 additions & 0 deletions app/WebknossosModule.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import com.google.inject.AbstractModule
import com.scalableminds.webknossos.datastore.storage.DataVaultService
import controllers.InitialDataService
import controllers.AuthenticationController
import files.TempFileService
import mail.MailchimpTicker
import models.analytics.AnalyticsSessionService
Expand Down Expand Up @@ -38,5 +39,7 @@ class WebknossosModule extends AbstractModule {
bind(classOf[UsedStorageService]).asEagerSingleton()
bind(classOf[ThumbnailCachingService]).asEagerSingleton()
bind(classOf[TracingDataSourceTemporaryStore]).asEagerSingleton()
bind(classOf[AuthenticationController]).asEagerSingleton()
bind(classOf[WebAuthnService]).asEagerSingleton()
}
}
109 changes: 108 additions & 1 deletion app/controllers/AuthenticationController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import play.silhouette.api.{LoginInfo, Silhouette}
import play.silhouette.impl.providers.CredentialsProvider
import com.scalableminds.util.accesscontext.{AuthorizedAccessContext, DBAccessContext, GlobalAccessContext}
import com.scalableminds.util.tools.{Fox, FoxImplicits, TextUtils}
import com.scalableminds.webknossos.datastore.storage.TemporaryStore
import mail.{DefaultMails, MailchimpClient, MailchimpTag, Send}
import models.analytics.{AnalyticsService, InviteEvent, JoinOrganizationEvent, SignupEvent}
import models.annotation.AnnotationState.Cancelled
Expand Down Expand Up @@ -36,12 +37,19 @@ import security.{
WkSilhouetteEnvironment
}
import utils.{ObjectId, WkConf}
import com.yubico.webauthn._;
import com.yubico.webauthn.data._;
import com.yubico.webauthn.exception._;
import security.WebAuthnCredentialRepository;

import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import javax.inject.Inject
import scala.concurrent.{ExecutionContext, Future}
import scala.concurrent.duration.DurationInt
import scala.jdk.OptionConverters._
import java.security.PublicKey

class AuthenticationController @Inject()(
actorSystem: ActorSystem,
Expand All @@ -66,6 +74,9 @@ class AuthenticationController @Inject()(
openIdConnectClient: OpenIdConnectClient,
initialDataService: InitialDataService,
emailVerificationService: EmailVerificationService,
temporaryRegistrationStore: TemporaryStore[String, PublicKeyCredentialCreationOptions],
temporaryAssertionStore: TemporaryStore[ByteArray, AssertionRequest],
webauthnService: WebAuthnService,
sil: Silhouette[WkEnv])(implicit ec: ExecutionContext, bodyParsers: PlayBodyParsers)
extends Controller
with AuthForms
Expand All @@ -80,6 +91,15 @@ class AuthenticationController @Inject()(
private lazy val ssoKey =
conf.WebKnossos.User.ssoKey

private lazy val relyingParty = {
val identity = RelyingPartyIdentity
.builder()
.id("webknossos.local:9000") // TODO: Use host
.name("WebKnossos")
.build();
RelyingParty.builder().identity(identity).credentialRepository(webauthnService).build()
}

def register: Action[AnyContent] = Action.async { implicit request =>
signUpForm
.bindFromRequest()
Expand Down Expand Up @@ -708,6 +728,94 @@ class AuthenticationController @Inject()(
(errors, fN, lN)
}

def registerWebAuthnDeviceStart(): Action[AnyContent] = sil.SecuredAction { implicit request =>
val user = UserIdentity
.builder()
.name(request.identity._id.toString())
.displayName(String.format("%s %s", request.identity.firstName, request.identity.lastName))
.id(ByteArray.fromHex(request.identity._id.toStringHex))
.build();
val opts = StartRegistrationOptions.builder().user(user).build()
var registration = relyingParty.startRegistration(opts)
temporaryRegistrationStore.insert(request.identity._id.toString(), registration, Some(5 minutes))
Ok(registration.toCredentialsCreateJson())
}

def registerWebAuthnDeviceFinish(): Action[String] = sil.SecuredAction(validateJson[String]) { implicit request =>
// TODO: Proper JSON parsing
temporaryRegistrationStore.find(request.identity._id.toString()) match {
case Some(registration) =>
val clientCredentials = PublicKeyCredential.parseRegistrationResponseJson(request.body)
try {
val finish = FinishRegistrationOptions.builder().request(registration).response(clientCredentials).build();
val result = relyingParty.finishRegistration(finish);
webauthnService.store(
request.identity._id,
result.getKeyId(),
result.getPublicKeyCose(),
result.getSignatureCount(),
clientCredentials.getResponse().getAttestationObject(),
clientCredentials.getResponse().getClientDataJSON(),
)
Ok(Json.obj("success" -> true))
} catch {
case e: RegistrationFailedException => BadRequest(Json.obj("success" -> false, "err" -> e.getMessage()))
}
case None =>
BadRequest(Json.obj("success" -> false))
}
}

def authenticationWebAuthnStart(): Action[WebAuthnLogin] = Action(validateJson[WebAuthnLogin]).async {
implicit request =>
val optBuilder = AssertionRequest.builder()
val userFopt: Future[Option[User]] =
userService.userFromMultiUserEmail(request.body.email)(GlobalAccessContext).futureBox.map(_.toOption)
userFopt
.map(userOpt => userOpt.map(_._id.toStringHex).getOrElse("")) // do not fail here if there is no user for email. Fail below.
.flatMap { id =>
val opts = StartAssertionOptions.builder().username(id).build()
val assertion = relyingParty.startAssertion(opts)
temporaryAssertionStore.insert(ByteArray.fromHex(id), assertion, Some(5 minutes))
Future.successful(Ok(assertion.toCredentialsGetJson()))
}
}

def authenticationWebAuthnFinish(): Action[String] = Action(validateJson[String]) { implicit request =>
val clientCredentials = PublicKeyCredential.parseAssertionResponseJson(request.body)
clientCredentials
.getResponse()
.getUserHandle()
.asScala
.flatMap(userHandle => temporaryAssertionStore.find(userHandle)) match {
case Some(assertion) =>
try {
val opts = FinishAssertionOptions.builder().request(assertion).response(clientCredentials).build()
val result = relyingParty.finishAssertion(opts)
// TODO: Update Credential
Ok(Json.obj("success" -> result.isSuccess()))
} catch {
case e: AssertionFailedException => BadRequest(Json.obj("success" -> false, "err" -> e.getMessage()))
}
case None => BadRequest(Json.obj("success" -> false, "msg" -> "no user or login session"))
}
}

def listWebAuthnDevices(): Action[AnyContent] = sil.SecuredAction { implicit request =>
val deviceKeys = webauthnService
.iterate(request.identity._id)
.map(element => Json.obj("id" -> element.key.getId().getHex()))
.toList
Ok(JsArray(deviceKeys))
}
}

case class WebAuthnLogin(
email: String
)

object WebAuthnLogin {
implicit val jsonFormat: OFormat[WebAuthnLogin] = Json.format[WebAuthnLogin]
}

case class InviteParameters(
Expand Down Expand Up @@ -795,5 +903,4 @@ trait AuthForms {
).verifying(Messages("error.passwordsDontMatch"), password => password._1 == password._2)
)((oldPassword, password) => ChangePasswordData(oldPassword, password._1, password._2))(changePasswordData =>
Some(changePasswordData.oldPassword, (changePasswordData.password1, changePasswordData.password2))))

}
80 changes: 80 additions & 0 deletions app/models/user/WebAuthnService.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package models.user

import utils.ObjectId
import org.apache.pekko.actor.ActorSystem
import com.scalableminds.webknossos.datastore.storage.TemporaryStore
import com.yubico.webauthn.data._
import com.yubico.webauthn._
import java.util.Optional
import javax.inject.{Inject, Singleton}
import scala.concurrent.ExecutionContext
import scala.collection.mutable.ArrayBuffer
import scala.collection.JavaConverters._

case class WebAuthnRegistration (
userId: ObjectId,
key: PublicKeyCredentialDescriptor,
publicKey: ByteArray,
var signatureCount: Long,
attestation: ByteArray,
clientDataJSON: ByteArray,
)

class WebAuthnService @Inject()(registrations: ArrayBuffer[WebAuthnRegistration]) extends CredentialRepository {
def store(
userId: ObjectId,
key: PublicKeyCredentialDescriptor,
publicKey: ByteArray,
signatureCount: Long,
attestation: ByteArray,
clientDataJSON: ByteArray
): Unit = {
registrations += WebAuthnRegistration(userId, key, publicKey, signatureCount, attestation, clientDataJSON)
}

def update(
userId: ObjectId,
key: PublicKeyCredentialDescriptor,
signatureCount: Long,
): Boolean = {
registrations.find(element => element.userId.id == userId.id && element.key.compareTo(key) == 0) match {
case Some(registration) => {
val i = registrations.indexOf(registration)
registrations(i).signatureCount = signatureCount
true
}
case None => false
}
}

def iterate(userId: ObjectId): Iterator[WebAuthnRegistration] = {
registrations.filter(registration => registration.userId.id == userId.id).iterator
}

def getCredentialIdsForUsername(username: String): java.util.Set[PublicKeyCredentialDescriptor] = {
println("username:" + username)
var result: Set[PublicKeyCredentialDescriptor] = Set()
result.asJava
}

def getUserHandleForUsername(name: String): Optional[ByteArray] = {
println("name:" + name)
Optional.ofNullable(null)
}

def getUsernameForUserHandle(userHandle: ByteArray): Optional[String] = {
println("handle:" + userHandle.getHex())
Optional.ofNullable(null)
}

def lookup(credentialId: ByteArray, userHandle: ByteArray): Optional[RegisteredCredential] = {
println("credentialId: " + credentialId.getHex() + " userHandle:" + userHandle.getHex())
Optional.ofNullable(null)
}

def lookupAll(credentialId: ByteArray): java.util.Set[RegisteredCredential] = {
println("credentialId:", credentialId.getHex())
var result: Set[RegisteredCredential] = Set()
result.asJava
}
}
27 changes: 27 additions & 0 deletions app/security/WebAuthnCredentialRepository.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package security

import com.yubico.webauthn.{CredentialRepository, RegisteredCredential}
import com.yubico.webauthn.data.{PublicKeyCredentialDescriptor, ByteArray}
import scala.collection.JavaConverters._
import java.util.Optional

class WebAuthnCredentialRepository() extends CredentialRepository {
def getCredentialIdsForUsername(username: String): java.util.Set[PublicKeyCredentialDescriptor] = {
var result: Set[PublicKeyCredentialDescriptor] = Set()
result.asJava
}

def getUserHandleForUsername(name: String): Optional[ByteArray] =
Optional.ofNullable(null)

def getUsernameForUserHandle(userHandle: ByteArray): Optional[String] =
Optional.ofNullable(null)

def lookup(credentialId: ByteArray, userHandle: ByteArray): Optional[RegisteredCredential] =
Optional.ofNullable(null)

def lookupAll(credentialId: ByteArray): java.util.Set[RegisteredCredential] = {
var result: Set[RegisteredCredential] = Set()
result.asJava
}
}
1 change: 1 addition & 0 deletions app/utils/ObjectId.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import scala.concurrent.ExecutionContext

case class ObjectId(id: String) {
override def toString: String = id
def toStringHex: String = id.getBytes("UTF-8").map(byte => f"$byte%02x").mkString
}

object ObjectId extends FoxImplicits {
Expand Down
5 changes: 5 additions & 0 deletions conf/webknossos.latest.routes
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ GET /auth/oidc/login
GET /auth/oidc/callback controllers.AuthenticationController.openIdCallback()
POST /auth/createOrganizationWithAdmin controllers.AuthenticationController.createOrganizationWithAdmin()
POST /auth/createUserInOrganization/:organizationId controllers.AuthenticationController.createUserInOrganization(organizationId: String)
POST /auth/webauthn/login/start controllers.AuthenticationController.authenticationWebAuthnStart()
POST /auth/webauthn/login/finish controllers.AuthenticationController.authenticationWebAuthnFinish()
POST /auth/webauthn/registration/start controllers.AuthenticationController.registerWebAuthnDeviceStart()
POST /auth/webauthn/registration/finish controllers.AuthenticationController.registerWebAuthnDeviceFinish()
GET /auth/webauthn/devices controllers.AuthenticationController.listWebAuthnDevices()

# Configurations
GET /user/userConfiguration controllers.ConfigurationController.read()
Expand Down
32 changes: 32 additions & 0 deletions frontend/javascripts/admin/admin_rest_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2530,3 +2530,35 @@ export function requestVerificationMail() {
method: "POST",
});
}

export function requestWebAuthnRegistrationStart(): any {
return Request.receiveJSON("/api/auth/webauthn/registration/start", {
method: "POST",
})
}

export function requestWebAuthnRegistrationFinish(key: any) {
return Request.sendJSONReceiveJSON("/api/auth/webauthn/registration/finish", {
method: "POST",
data: JSON.stringify(JSON.stringify(key)) // TODO: Improve Server Site
})
}

export function requestWebAuthnLoginStart(email: string) {
return Request.sendJSONReceiveJSON("/api/auth/webauthn/login/start", {
method: "POST",
data: {
email: email
}
})
}
export function requestWebAuthnLoginFinish(key: any) {
return Request.sendJSONReceiveJSON("/api/auth/webauthn/login/finish", {
method: "POST",
data: JSON.stringify(JSON.stringify(key)) // TODO: Improve Service Site
})
}

export function listWebAuthnDevices(): any {
return Request.receiveJSON("/api/auth/webauthn/devices")
}
20 changes: 19 additions & 1 deletion frontend/javascripts/admin/auth/login_form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { LockOutlined, MailOutlined } from "@ant-design/icons";
import { Link } from "react-router-dom";
import React from "react";
import { getIsInIframe } from "libs/utils";
import { loginUser, requestSingleSignOnLogin } from "admin/admin_rest_api";
import { loginUser, requestSingleSignOnLogin, requestWebAuthnLoginStart, requestWebAuthnLoginFinish } from "admin/admin_rest_api";
import { setActiveUserAction } from "oxalis/model/actions/user_actions";
import Store from "oxalis/store";
import messages from "messages";
import features from "features";
import { setActiveOrganizationAction } from "oxalis/model/actions/organization_actions";
import * as webauthnJson from "@github/webauthn-json";

const FormItem = Form.Item;
const { Password } = Input;
Expand Down Expand Up @@ -140,6 +141,23 @@ function LoginForm({ layout, onLoggedIn, hideFooter, style }: Props) {
</FormItem>
)}
</div>
<FormItem style={{ flexGrow: 1 }}>
<Button
type="primary"
style={{
width: "100%",
}}
onClick={async () => {
const opts = await requestWebAuthnLoginStart("[email protected]") // TODO: Read user field
console.log(opts)
const publicKeyCredential = await webauthnJson.get(opts)
console.log(publicKeyCredential)
console.log(await requestWebAuthnLoginFinish(publicKeyCredential))
}}
>
Continue with Device
</Button>
</FormItem>
{hideFooter ? null : (
<FormItem
style={{
Expand Down
Loading