Device Auth. Grant - the web browser part

This commit is contained in:
Dusan Jakub
2023-09-18 13:02:26 +02:00
parent 47cc55d87f
commit 6de8c49b12
12 changed files with 385 additions and 51 deletions

View File

@@ -14,17 +14,31 @@ import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import org.apache.commons.lang3.StringUtils;
import java.util.List;
@Path("/auth")
public class OAuthResource {
@CheckedTemplate
public static class Templates {
public static native TemplateInstance login(String loginHint, String sessionId, String error);
@POST
@Produces(MediaType.TEXT_HTML)
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Object post(AuthParams params,
@FormParam("sessionId") String sessionId,
@FormParam("username") String username,
@FormParam("password") String password,
@FormParam("scope") List<String> scopes) {
public static native TemplateInstance consents(User user, OAuthClient client, List<String> scopes, String sessionId, String error);
sessionsRepo.getSession(sessionId).orElseThrow(() -> new OAuthException("Invalid session"));
var user = validateUser(username, password);
if (user == null) {
return Templates.login(username, sessionId, "invalid_credentials");
} else {
AuthorizationSession session = sessionsRepo.assignUser(sessionId, user);
return Templates.consents(session.user(), session.client(), session.params().getScopes(), sessionId, "");
}
}
@Inject
@@ -43,49 +57,56 @@ public class OAuthResource {
}
@POST
@Path("consent")
@Produces(MediaType.TEXT_HTML)
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Object post(AuthParams params,
@FormParam("sessionId") String sessionId,
@FormParam("username") String username,
@FormParam("password") String password,
@FormParam("scope") List<String> scopes) {
public Response postConsent(
@FormParam("sessionId") String sessionId,
@FormParam("scope") List<String> scopes) {
sessionsRepo.getSession(sessionId).orElseThrow(() -> new OAuthException("Invalid session"));
var session = sessionsRepo.authorizeSession(sessionId, scopes);
var session = sessionsRepo.getSession(sessionId).orElseThrow(() -> new OAuthException("Invalid session"));
if (session.user() == null) {
var user = validateUser(username, password);
if (user == null) {
return Templates.login(username, sessionId, "invalid_credentials");
} else {
session = sessionsRepo.assignUser(sessionId, user);
String redirectUri = session.params().getRedirectUri();
if (StringUtils.isNotBlank(redirectUri)) {
var responseTypes = session.params().getResponseTypes();
UriBuilder uri = UriBuilder.fromUri(redirectUri)
.fragment("")
.queryParam("state", session.params().getState());
if (responseTypes.contains(AuthParams.ResponseType.code)) {
uri.queryParam("code", sessionsRepo.generateAuthorizationCode(sessionId));
}
}
if (session.acceptedScopes() == null) {
if (scopes == null || scopes.isEmpty()) {
return Templates.consents(session.user(), session.client(), session.params().getScopes(), sessionId, "");
if (responseTypes.contains(AuthParams.ResponseType.token)) {
uri.queryParam("access_token", session.tokens().accessToken());
}
if (responseTypes.contains(AuthParams.ResponseType.id_token)) {
uri.queryParam("id_token", session.tokens().idToken());
}
return Response.seeOther(uri.build()).build();
} else {
return Response.ok(Templates.loginSuccess()).build();
}
}
session = sessionsRepo.authorizeSession(sessionId, scopes);
@POST
@Path("/device")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public DeviceResponse device(DeviceParams params) {
var client = validateClient(params);
AuthParams authParams = new AuthParams();
authParams.setClientId(params.getClientId());
String sessionId = sessionsRepo.newAuthorizationSession(authParams, client);
var responseTypes = params.getResponseTypes();
UriBuilder uri = UriBuilder.fromUri(params.getRedirectUri())
.fragment("")
.queryParam("state", params.getState());
if (responseTypes.contains(AuthParams.ResponseType.code)) {
uri.queryParam("code", sessionsRepo.generateAuthorizationCode(sessionId));
}
if (responseTypes.contains(AuthParams.ResponseType.token)) {
uri.queryParam("access_token", session.tokens().accessToken());
}
if (responseTypes.contains(AuthParams.ResponseType.id_token)) {
uri.queryParam("id_token", session.tokens().idToken());
}
return Response.seeOther(uri.build()).build();
return new DeviceResponse(
sessionsRepo.generateAuthorizationCode(sessionId),
sessionsRepo.generateUserCode(sessionId),
"http://verificationuri/device-login",
10,
180
);
}
@POST
@@ -99,6 +120,28 @@ public class OAuthResource {
};
}
@GET
@Path("/device-login")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance enterDeviceCode() {
return Templates.deviceLogin("");
}
@POST
@Path("/device-login")
@Produces(MediaType.TEXT_HTML)
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response postDeviceCode(@FormParam("code") String code) {
return sessionsRepo.redeemUserCode(code)
.map(session -> Response.ok(Templates.login(session.params().getLoginHint(), session.sessionId(), "")))
.orElse(Response.status(404).entity(Templates.deviceLogin("invalid_code"))).build();
}
private OAuthClient validateClient(DeviceParams params) {
return clientsRepo.getClient(params.getClientId())
.orElseThrow(() -> new RuntimeException("Not a valid client"));
}
private AccessTokenResponse redeemAuthorizationCode(TokenParams params) {
validateClient(params);
var session = sessionsRepo.redeemAuthorizationCode(params.getCode())
@@ -147,6 +190,17 @@ public class OAuthResource {
}
return client;
}
@CheckedTemplate
public static class Templates {
public static native TemplateInstance login(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);
public static native TemplateInstance deviceLogin(String error);
}
}

View File

@@ -4,21 +4,22 @@ import com.ysoft.geecon.repo.SecureRandomStrings;
import java.util.List;
public record AuthorizationSession(AuthParams params,
public record AuthorizationSession(String sessionId,
AuthParams params,
OAuthClient client,
User user,
List<String> acceptedScopes,
AccessTokenResponse tokens) {
public AuthorizationSession(AuthParams params, OAuthClient client) {
this(params, client, null, null, null);
this(SecureRandomStrings.alphanumeric(50), params, client, null, null, null);
}
public AuthorizationSession withUser(User user) {
return new AuthorizationSession(params, client, user, acceptedScopes, tokens);
return new AuthorizationSession(sessionId, params, client, user, acceptedScopes, tokens);
}
public AuthorizationSession withScopes(List<String> acceptedScopes) {
return new AuthorizationSession(params, client, user, acceptedScopes, tokens);
return new AuthorizationSession(sessionId, params, client, user, acceptedScopes, tokens);
}
public AuthorizationSession withGeneratedTokens() {
@@ -30,7 +31,7 @@ public record AuthorizationSession(AuthParams params,
SecureRandomStrings.alphanumeric(50),
idToken
);
return new AuthorizationSession(params, client, user, acceptedScopes, tokens);
return new AuthorizationSession(sessionId, params, client, user, acceptedScopes, tokens);
}
public String scope() {

View File

@@ -0,0 +1,18 @@
package com.ysoft.geecon.dto;
import jakarta.ws.rs.FormParam;
public final class DeviceParams {
@FormParam("client_id")
String clientId;
public String getClientId() {
return clientId;
}
public AuthParams toAuthParams() {
var params = new AuthParams();
params.setClientId(clientId);
return params;
}
}

View File

@@ -0,0 +1,12 @@
package com.ysoft.geecon.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
public record DeviceResponse(
@JsonProperty("device_code") String deviceCode,
@JsonProperty("user_code") String userCode,
@JsonProperty("verification_uri") String verificationUri,
@JsonProperty("interval") long interval,
@JsonProperty("expires_in") long expiresIn
) {
}

View File

@@ -21,7 +21,7 @@ public class ClientsRepo {
return Optional.ofNullable(clients.get(clientId));
}
private void register(OAuthClient client) {
public void register(OAuthClient client) {
clients.put(client.clientId(), client);
}
}

View File

@@ -12,15 +12,16 @@ import java.util.*;
public class SessionsRepo {
private final Map<String, AuthorizationSession> authorizationSessions = new HashMap<>();
private final Map<String, String> sessionsByAuthorizationCode = new HashMap<>();
private final Map<String, String> sessionsByUserCode = new HashMap<>();
public Optional<AuthorizationSession> getSession(String sessionId) {
return Optional.ofNullable(authorizationSessions.get(sessionId));
}
public String newAuthorizationSession(AuthParams params, OAuthClient client) {
var id = SecureRandomStrings.alphanumeric(10);
authorizationSessions.put(id, new AuthorizationSession(params, client));
return id;
AuthorizationSession session = new AuthorizationSession(params, client);
authorizationSessions.put(session.sessionId(), session);
return session.sessionId();
}
public AuthorizationSession authorizeSession(String sessionId, List<String> acceptedScopes) {
@@ -39,9 +40,27 @@ public class SessionsRepo {
return authCode;
}
public String generateUserCode(String sessionId) {
var authCode = SecureRandomStrings.alphanumeric(8);
sessionsByUserCode.put(authCode, sessionId);
return authCode;
}
public Optional<AuthorizationSession> redeemAuthorizationCode(String authorizationCode) {
var sessionId = Optional.ofNullable(sessionsByAuthorizationCode.get(authorizationCode));
sessionId.ifPresent(_id -> sessionsByAuthorizationCode.remove(authorizationCode));
return sessionId.map(authorizationSessions::get);
}
public Optional<AuthorizationSession> redeemUserCode(String userCode) {
var sessionId = Optional.ofNullable(sessionsByUserCode.get(userCode));
sessionId.ifPresent(_id -> sessionsByUserCode.remove(userCode));
return sessionId.map(authorizationSessions::get);
}
public Optional<AuthorizationSession> getByAuthorizationCode(String authorizationCode) {
var sessionId = Optional.ofNullable(sessionsByAuthorizationCode.get(authorizationCode));
return sessionId.map(authorizationSessions::get);
}
}

View File

@@ -1,6 +1,5 @@
package com.ysoft.geecon.repo;
import com.ysoft.geecon.dto.OAuthClient;
import com.ysoft.geecon.dto.User;
import jakarta.enterprise.context.ApplicationScoped;
@@ -20,7 +19,7 @@ public class UsersRepo {
return Optional.ofNullable(users.get(username));
}
private void register(User user) {
public void register(User user) {
users.put(user.login(), user);
}
}

View File

@@ -74,7 +74,7 @@
<p>You are authorizing to <strong>{client.description}</strong>, which requests the following permissions from you:</p>
<form action="" method="post">
<form action="/auth/consent" method="post">
<input type="hidden" name="sessionId" value="{sessionId}">
<ul>
{#each scopes}

View File

@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login Page</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.login-container {
background-color: #fff;
padding: 20px;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.login-container h2 {
text-align: center;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
font-weight: bold;
display: block;
}
.form-group input {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
}
.login-button {
width: 100%;
background-color: #007BFF;
color: #fff;
border: none;
padding: 10px;
border-radius: 5px;
cursor: pointer;
}
.login-button:hover {
background-color: #0056b3;
}
.error-popup {
background-color: #ff6b6b;
color: #fff;
padding: 10px;
text-align: center;
border-radius: 5px;
margin-top: 10px;
}
</style>
</head>
<body>
<div class="login-container">
<h2>Login</h2>
{#if error == "invalid_code"}
<div class="error-popup" id="error-popup">Invalid code</div>
{/if}
<form action="" method="post">
<div class="form-group">
<label for="code">User code:</label>
<input type="text" id="code" name="code" required>
</div>
<button type="submit" class="login-button">Login</button>
</form>
</div>
</body>
</html>

View File

@@ -73,7 +73,7 @@
{#if error == "invalid_credentials"}
<div class="error-popup" id="error-popup">Invalid credentials</div>
{/if}
<form action="" method="post">
<form action="/auth" method="post">
<input type="hidden" name="sessionId" value="{sessionId}">
<div class="form-group">
<label for="username">Username:</label>

View File

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login Page</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.login-container {
background-color: #fff;
padding: 20px;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.login-container h2 {
text-align: center;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
font-weight: bold;
display: block;
}
.form-group input {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
}
.login-button {
width: 100%;
background-color: #007BFF;
color: #fff;
border: none;
padding: 10px;
border-radius: 5px;
cursor: pointer;
}
.login-button:hover {
background-color: #0056b3;
}
.error-popup {
background-color: #ff6b6b;
color: #fff;
padding: 10px;
text-align: center;
border-radius: 5px;
margin-top: 10px;
}
</style>
</head>
<body>
<div class="login-container">
<h2>Successful login</h2>
<p>You may close this window</p>
</div>
</body>
</html>