mirror of
https://github.com/ysoftdevs/oauth-playground-server.git
synced 2026-03-17 23:04:21 +01:00
First stab at integrating webauthn login to the rest
This commit is contained in:
@@ -11,6 +11,11 @@ import com.ysoft.geecon.repo.UsersRepo;
|
||||
import io.quarkus.qute.CheckedTemplate;
|
||||
import io.quarkus.qute.TemplateInstance;
|
||||
import io.quarkus.runtime.util.StringUtil;
|
||||
import io.quarkus.security.webauthn.WebAuthnLoginResponse;
|
||||
import io.quarkus.security.webauthn.WebAuthnRegisterResponse;
|
||||
import io.quarkus.security.webauthn.WebAuthnSecurity;
|
||||
import io.vertx.ext.auth.webauthn.Authenticator;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
@@ -24,6 +29,27 @@ import java.util.List;
|
||||
@Path("/auth")
|
||||
public class OAuthResource {
|
||||
|
||||
@Inject
|
||||
ClientsRepo clientsRepo;
|
||||
|
||||
@Inject
|
||||
UsersRepo usersRepo;
|
||||
@Inject
|
||||
SessionsRepo sessionsRepo;
|
||||
@Inject
|
||||
UriInfo uriInfo;
|
||||
|
||||
@Inject
|
||||
WebAuthnSecurity webAuthnSecurity;
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.TEXT_HTML)
|
||||
public TemplateInstance get(AuthParams params) {
|
||||
var client = validateClient(params);
|
||||
String sessionId = sessionsRepo.newAuthorizationSession(params, client);
|
||||
return Templates.login(params.getLoginHint(), sessionId, "");
|
||||
}
|
||||
|
||||
@POST
|
||||
@Produces(MediaType.TEXT_HTML)
|
||||
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
@@ -43,21 +69,59 @@ public class OAuthResource {
|
||||
}
|
||||
}
|
||||
|
||||
@Inject
|
||||
ClientsRepo clientsRepo;
|
||||
@Inject
|
||||
UsersRepo usersRepo;
|
||||
@Inject
|
||||
SessionsRepo sessionsRepo;
|
||||
@Inject
|
||||
UriInfo uriInfo;
|
||||
|
||||
@GET
|
||||
@Path("passwordless")
|
||||
@Produces(MediaType.TEXT_HTML)
|
||||
public TemplateInstance get(AuthParams params) {
|
||||
public TemplateInstance getPasswordless(AuthParams params) {
|
||||
var client = validateClient(params);
|
||||
String sessionId = sessionsRepo.newAuthorizationSession(params, client);
|
||||
return Templates.login(params.getLoginHint(), sessionId, "");
|
||||
return Templates.loginPasswordless(params.getLoginHint(), sessionId, "");
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("passwordless/register")
|
||||
@Produces(MediaType.TEXT_HTML)
|
||||
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
public TemplateInstance registerPasswordless(@FormParam("sessionId") String sessionId,
|
||||
@BeanParam WebAuthnRegisterResponse webAuthnResponse,
|
||||
RoutingContext ctx) {
|
||||
|
||||
sessionsRepo.getSession(sessionId).orElseThrow(
|
||||
() -> new OAuthUserVisibleException(ErrorResponse.Error.access_denied, "Invalid session"));
|
||||
// Input validation
|
||||
if (!webAuthnResponse.isSet() || !webAuthnResponse.isValid()) {
|
||||
return Templates.loginPasswordless("", sessionId, "Invalid request");
|
||||
}
|
||||
|
||||
Authenticator authenticator = this.webAuthnSecurity.register(webAuthnResponse, ctx)
|
||||
.await().indefinitely();
|
||||
|
||||
var user = usersRepo.getUser(authenticator.getUserName()).orElseThrow();
|
||||
AuthorizationSession session = sessionsRepo.assignUser(sessionId, user);
|
||||
return Templates.consents(session.user(), session.client(), session.params().getScopes(), sessionId, "");
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("passwordless/login")
|
||||
@Produces(MediaType.TEXT_HTML)
|
||||
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
public TemplateInstance loginPasswordless(@FormParam("sessionId") String sessionId,
|
||||
@BeanParam WebAuthnLoginResponse webAuthnResponse,
|
||||
RoutingContext ctx) {
|
||||
|
||||
sessionsRepo.getSession(sessionId).orElseThrow(
|
||||
() -> new OAuthUserVisibleException(ErrorResponse.Error.access_denied, "Invalid session"));
|
||||
// Input validation
|
||||
if (!webAuthnResponse.isSet() || !webAuthnResponse.isValid()) {
|
||||
return Templates.loginPasswordless("", sessionId, "Invalid request");
|
||||
}
|
||||
|
||||
Authenticator authenticator = this.webAuthnSecurity.login(webAuthnResponse, ctx)
|
||||
.await().indefinitely();
|
||||
|
||||
var user = usersRepo.getUser(authenticator.getUserName()).orElseThrow();
|
||||
AuthorizationSession session = sessionsRepo.assignUser(sessionId, user);
|
||||
return Templates.consents(session.user(), session.client(), session.params().getScopes(), sessionId, "");
|
||||
}
|
||||
|
||||
@POST
|
||||
@@ -212,6 +276,8 @@ public class OAuthResource {
|
||||
public static class Templates {
|
||||
public static native TemplateInstance login(String loginHint, String sessionId, String error);
|
||||
|
||||
public static native TemplateInstance loginPasswordless(String loginHint, String sessionId, String error);
|
||||
|
||||
public static native TemplateInstance loginSuccess();
|
||||
|
||||
public static native TemplateInstance consents(User user, OAuthClient client, List<String> scopes, String sessionId, String error);
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
{#include base}
|
||||
{#title}Login passwordless{/title}
|
||||
{#add-header}
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script charset="UTF-8" src="/js/webauthn-debug.js" type="text/javascript"></script>
|
||||
<style>
|
||||
|
||||
|
||||
.code {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
{/add-header}
|
||||
|
||||
<div class="container">
|
||||
<input id="userName" placeholder="User name"/><br/>
|
||||
<button class="nextBtn" id="login">Login</button>
|
||||
<button class="nextBtn" id="register">Register</button>
|
||||
</div>
|
||||
|
||||
<div class="container step" id="server1-request">
|
||||
The interaction starts with an AJAX call.
|
||||
<div class="code">POST <span id="server1-url"></span>
|
||||
<div id="server1-call"></div>
|
||||
</div>
|
||||
<button class="nextBtn">Request challenge</button>
|
||||
</div>
|
||||
|
||||
<div class="container step" id="server1-response">
|
||||
The server prepares a challenge for the browser to sign.
|
||||
<div class="code" id="server1-response-body"></div>
|
||||
<button class="nextBtn">Continue</button>
|
||||
</div>
|
||||
|
||||
<div class="container step" id="navigator-request">
|
||||
The challenge is passed to the browser call:
|
||||
<div class="code" id="navigator-call"></div>
|
||||
<button class="nextBtn">Call Webauthn API</button>
|
||||
</div>
|
||||
|
||||
<div class="container step" id="navigator-response">
|
||||
Which responds:
|
||||
<div class="code" id="navigator-response-body"></div>
|
||||
The <strong>response.clientDataJSON</strong> are base64 encoded:
|
||||
<div class="code" id="navigator-clientDataJSON"></div>
|
||||
<button class="nextBtn">Finish the interaction</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="result"></div>
|
||||
<div id="trace"></div>
|
||||
|
||||
<form action="#" method="POST">
|
||||
<input name="sessionId" type="hidden" value="somesessionid">
|
||||
<div id="form-generated"></div>
|
||||
</form>
|
||||
|
||||
<script type="text/javascript">
|
||||
function tryDecodeBase64(str) {
|
||||
try {
|
||||
return atob(str);
|
||||
} catch (e) {
|
||||
return e + "";
|
||||
}
|
||||
}
|
||||
|
||||
function tracer(stage, params) {
|
||||
console.log(stage, params)
|
||||
|
||||
switch (stage) {
|
||||
case "register-request":
|
||||
return traceRegisterRequest(params);
|
||||
case "register-response":
|
||||
return traceRegisterResponse(params);
|
||||
case "credentials-create-request":
|
||||
return traceCredentialsCreateRequest(params);
|
||||
case "credentials-create-response":
|
||||
return traceCredentialsCreateResponse(params);
|
||||
case "login-request":
|
||||
return traceLoginRequest(params);
|
||||
case "login-response":
|
||||
return traceLoginResponse(params);
|
||||
case "credentials-get-request":
|
||||
return traceCredentialsGetRequest(params);
|
||||
case "credentials-get-response":
|
||||
return traceCredentialsGetResponse(params);
|
||||
default:
|
||||
return traceGeneric(stage, params);
|
||||
}
|
||||
}
|
||||
|
||||
function continueButton(where, result) {
|
||||
const button = $("button", $(where)).show();
|
||||
if (button.length) {
|
||||
return new Promise((resolve, reject) => {
|
||||
button.click(() => {
|
||||
resolve(result);
|
||||
$(button).hide();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
return Promise.resolve(result);
|
||||
}
|
||||
}
|
||||
|
||||
function traceRegisterRequest(params) {
|
||||
$(".step").hide();
|
||||
$("#server1-request").show();
|
||||
$("#server1-url").html(params.url)
|
||||
$("#server1-call").html(JSON.stringify(params.body, null, 2));
|
||||
return continueButton("#server1-request", params);
|
||||
}
|
||||
|
||||
function traceRegisterResponse(params) {
|
||||
$("#server1-response").show();
|
||||
$("#server1-response-body").html(JSON.stringify(params, null, 2));
|
||||
return continueButton("#server1-response", params);
|
||||
}
|
||||
|
||||
function traceLoginRequest(params) {
|
||||
$(".step").hide();
|
||||
$("#server1-request").show();
|
||||
$("#server1-url").html(params.url)
|
||||
$("#server1-call").html(JSON.stringify(params.body, null, 2));
|
||||
return continueButton("#server1-request", params);
|
||||
}
|
||||
|
||||
function traceLoginResponse(params) {
|
||||
$("#server1-response").show();
|
||||
$("#server1-response-body").html(JSON.stringify(params, null, 2));
|
||||
return continueButton("#server1-response", params);
|
||||
}
|
||||
|
||||
function traceCredentialsCreateRequest(challenge) {
|
||||
$("#navigator-request").show();
|
||||
$("#navigator-call").html("navigator.credentials.create({ publicKey: ... });");
|
||||
return continueButton("#navigator-request", challenge);
|
||||
}
|
||||
|
||||
function traceCredentialsCreateResponse(response) {
|
||||
$("#navigator-response").show();
|
||||
$("#navigator-response-body").html(JSON.stringify(response, null, 2));
|
||||
$("#navigator-clientDataJSON").html(JSON.stringify(JSON.parse(tryDecodeBase64(response.response.clientDataJSON)), null, 2));
|
||||
return continueButton("#navigator-response", response);
|
||||
}
|
||||
|
||||
function traceCredentialsGetRequest(challenge) {
|
||||
$("#navigator-request").show();
|
||||
$("#navigator-call").html("navigator.credentials.get({ publicKey: ... });");
|
||||
return continueButton("#navigator-request", challenge);
|
||||
}
|
||||
|
||||
function traceCredentialsGetResponse(response) {
|
||||
$("#navigator-response").show();
|
||||
$("#navigator-response-body").html(JSON.stringify(response, null, 2));
|
||||
$("#navigator-clientDataJSON").html(JSON.stringify(JSON.parse(tryDecodeBase64(response.response.clientDataJSON)), null, 2));
|
||||
return continueButton("#navigator-response", response);
|
||||
}
|
||||
|
||||
function traceGeneric(stage, params) {
|
||||
const content = JSON.stringify(params);
|
||||
const trace = $("<div class='container'></div>").attr("id", stage).html(content).appendTo("#trace");
|
||||
return continueButton(trace, params);
|
||||
}
|
||||
|
||||
function form(action, fields) {
|
||||
const $form = $("form").attr("action", action);
|
||||
const $fields = $("#form-generated", $form).empty()
|
||||
for ([key, value] of Object.entries(fields)) {
|
||||
console.log(key);
|
||||
$("<input type='hidden'>").attr("name", key).val(value).appendTo($fields)
|
||||
}
|
||||
$form.submit();
|
||||
}
|
||||
|
||||
const webAuthn = new WebAuthn({
|
||||
callbackPath: '/q/webauthn/callback',
|
||||
registerPath: '/q/webauthn/register',
|
||||
loginPath: '/q/webauthn/login',
|
||||
// loginCallbackPath: '/webauthn/login',
|
||||
// registerCallbackPath: '/webauthn/register',
|
||||
debuggingFunction: tracer
|
||||
});
|
||||
|
||||
const result = document.getElementById('result');
|
||||
|
||||
const loginButton = document.getElementById('login');
|
||||
|
||||
loginButton.onclick = () => {
|
||||
var userName = document.getElementById('userName').value;
|
||||
result.replaceChildren();
|
||||
webAuthn.loginOnly({ name: userName })
|
||||
.then(body => {
|
||||
form("/webauthn/login", {
|
||||
'webAuthnId': body.id,
|
||||
'webAuthnRawId': body.rawId,
|
||||
'webAuthnResponseClientDataJSON': body.response.clientDataJSON,
|
||||
'webAuthnResponseAuthenticatorData': body.response.authenticatorData,
|
||||
'webAuthnResponseSignature': body.response.signature,
|
||||
'webAuthnResponseUserHandle': body.response.userHandle,
|
||||
'webAuthnType': body.type
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
result.append("Login failed: " + err);
|
||||
console.error(err);
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
||||
const registerButton = document.getElementById('register');
|
||||
|
||||
registerButton.onclick = () => {
|
||||
var userName = document.getElementById('userName').value;
|
||||
// var firstName = document.getElementById('firstName').value;
|
||||
// var lastName = document.getElementById('lastName').value;
|
||||
result.replaceChildren();
|
||||
|
||||
|
||||
webAuthn.registerOnly({ name: userName, displayName: userName /*firstName + " " + lastName*/})
|
||||
.then(body => {
|
||||
form("/webauthn/register", {
|
||||
'webAuthnId': body.id,
|
||||
'webAuthnRawId': body.rawId,
|
||||
'webAuthnResponseAttestationObject': body.response.attestationObject,
|
||||
'webAuthnResponseClientDataJSON': body.response.clientDataJSON,
|
||||
'webAuthnType': body.type
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
result.append("Registration failed: " + err);
|
||||
console.error(err);
|
||||
});
|
||||
return false;
|
||||
};
|
||||
</script>
|
||||
{/include}
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
{#insert add-header}{/}
|
||||
</head>
|
||||
<body>
|
||||
{#insert}No body!{/}
|
||||
|
||||
Reference in New Issue
Block a user