WIP scopes screen and session management

This commit is contained in:
Dusan Jakub
2023-09-13 15:37:08 +02:00
parent 451eccfe00
commit e703ca25a1
12 changed files with 226 additions and 97 deletions

View File

@@ -50,6 +50,11 @@
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.4</version>
</dependency>
</dependencies>
<build>
<plugins>

View File

@@ -1,7 +1,11 @@
package com.ysoft.geecon;
import com.ysoft.geecon.dto.AuthParams;
import com.ysoft.geecon.dto.OAuthClient;
import com.ysoft.geecon.dto.User;
import com.ysoft.geecon.error.OAuthException;
import com.ysoft.geecon.repo.ClientsRepo;
import com.ysoft.geecon.repo.SessionsRepo;
import com.ysoft.geecon.repo.UsersRepo;
import io.quarkus.qute.CheckedTemplate;
import io.quarkus.qute.TemplateInstance;
@@ -11,9 +15,7 @@ import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import org.jboss.resteasy.reactive.RestQuery;
import java.net.URI;
import java.util.List;
@Path("/auth")
@@ -21,125 +23,78 @@ public class OAuthResource {
@CheckedTemplate
public static class Templates {
public static native TemplateInstance login(String loginHint, String error);
public static native TemplateInstance login(String loginHint, String sessionId, String error);
public static native TemplateInstance consents(List<String> scopes, String error);
public static native TemplateInstance consents(User user, OAuthClient client, List<String> scopes, String sessionId, String error);
}
@Inject
ClientsRepo clientsRepo;
@Inject
UsersRepo usersRepo;
@Inject
SessionsRepo sessionsRepo;
@GET
@Produces(MediaType.TEXT_HTML)
public TemplateInstance get(AuthParams params) {
validateClient(params);
return Templates.login(params.loginHint, "");
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)
public Object post(AuthParams params,
@FormParam("sessionId") String sessionId,
@FormParam("username") String username,
@FormParam("password") String password) {
validateClient(params);
var user = usersRepo.getUser(username);
if (user.isEmpty()) {
return Templates.login(username, "invalid_credentials");
}
if (!user.get().validatePassword(password)) {
return Templates.login(username, "invalid_credentials");
@FormParam("password") String password,
@FormParam("scope") List<String> 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);
}
}
return Response.seeOther(UriBuilder.fromUri(params.redirectUri)
.queryParam("code", "randomCode")
.queryParam("state", params.state)
.build())
if (session.acceptedScopes() == null) {
if (scopes == null || scopes.isEmpty()) {
return Templates.consents(session.user(), session.client(), session.params().getScopes(), sessionId, "");
}
}
String authCode = sessionsRepo.finishSession(sessionId, scopes);
return Response.seeOther(UriBuilder.fromUri(params.getRedirectUri())
.queryParam("code", authCode)
.queryParam("state", params.getState())
.build())
.build();
}
private User validateUser(String username, String password) {
return usersRepo.getUser(username)
.filter(u -> u.validatePassword(password)).orElse(null);
}
private OAuthClient validateClient(AuthParams params) {
var client = clientsRepo.getClient(params.clientId)
var client = clientsRepo.getClient(params.getClientId())
.orElseThrow(() -> new RuntimeException("Not a valid client"));
if (!client.validateRedirectUri(params.redirectUri)) {
if (!client.validateRedirectUri(params.getRedirectUri())) {
throw new RuntimeException("Invalid redirect URI");
}
if (StringUtil.isNullOrEmpty(params.state)) {
if (StringUtil.isNullOrEmpty(params.getState())) {
throw new RuntimeException("Invalid state");
}
return client;
}
public static class AuthParams {
public enum ResponseType {
code
}
@RestQuery("login_hint")
String loginHint;
@RestQuery("response_type")
ResponseType responseType;
@RestQuery("client_id")
String clientId;
@RestQuery("redirect_uri")
String redirectUri;
@RestQuery("scope")
String scope;
@RestQuery("state")
String state;
public String getLoginHint() {
return loginHint;
}
public void setLoginHint(String loginHint) {
this.loginHint = loginHint;
}
public ResponseType getResponseType() {
return responseType;
}
public void setResponseType(ResponseType responseType) {
this.responseType = responseType;
}
public String getClientId() {
return clientId;
}
public void setClientId(String clientId) {
this.clientId = clientId;
}
public String getRedirectUri() {
return redirectUri;
}
public void setRedirectUri(String redirectUri) {
this.redirectUri = redirectUri;
}
public String getScope() {
return scope;
}
public void setScope(String scope) {
this.scope = scope;
}
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
}
}

View File

@@ -0,0 +1,4 @@
package com.ysoft.geecon.dto;
public record AccessTokenResponse(String token, String scope, String idToken, long expiresIn) {
}

View File

@@ -0,0 +1,77 @@
package com.ysoft.geecon.dto;
import org.jboss.resteasy.reactive.RestQuery;
import java.util.Arrays;
import java.util.List;
public class AuthParams {
public enum ResponseType {
code
}
@RestQuery("login_hint")
String loginHint;
@RestQuery("response_type")
ResponseType responseType;
@RestQuery("client_id")
String clientId;
@RestQuery("redirect_uri")
String redirectUri;
@RestQuery("scope")
String scope;
@RestQuery("state")
String state;
public String getLoginHint() {
return loginHint;
}
public void setLoginHint(String loginHint) {
this.loginHint = loginHint;
}
public ResponseType getResponseType() {
return responseType;
}
public void setResponseType(ResponseType responseType) {
this.responseType = responseType;
}
public String getClientId() {
return clientId;
}
public void setClientId(String clientId) {
this.clientId = clientId;
}
public String getRedirectUri() {
return redirectUri;
}
public void setRedirectUri(String redirectUri) {
this.redirectUri = redirectUri;
}
public String getScope() {
return scope;
}
public List<String> getScopes() {
return scope == null ? List.of() : Arrays.stream(scope.split(" ")).toList();
}
public void setScope(String scope) {
this.scope = scope;
}
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
}

View File

@@ -0,0 +1,17 @@
package com.ysoft.geecon.dto;
import java.util.List;
public record AuthorizationSession(AuthParams params, OAuthClient client, User user, List<String> acceptedScopes) {
public AuthorizationSession(AuthParams params, OAuthClient client) {
this(params, client, null, null);
}
public AuthorizationSession withUser(User user) {
return new AuthorizationSession(params, client, user, acceptedScopes);
}
public AuthorizationSession withScopes(List<String> acceptedScopes) {
return new AuthorizationSession(params, client, user, acceptedScopes);
}
}

View File

@@ -1,6 +1,6 @@
package com.ysoft.geecon.dto;
public record OAuthClient(String clientId, String clientSecret, String redirectUri) {
public record OAuthClient(String clientId, String description, String clientSecret, String redirectUri) {
public boolean validateRedirectUri(String redirectUri) {
return this.redirectUri != null && this.redirectUri.equals(redirectUri);
}

View File

@@ -0,0 +1,7 @@
package com.ysoft.geecon.error;
public class OAuthException extends RuntimeException {
public OAuthException(String message) {
super(message);
}
}

View File

@@ -13,7 +13,7 @@ public class ClientsRepo {
private final Map<String, OAuthClient> clients = new HashMap<>();
public ClientsRepo() {
register(new OAuthClient("my-public-client", null, "https://localhost:8888/oauth_success"));
register(new OAuthClient("my-public-client", "Example public client", null, "https://localhost:8888/oauth_success"));
}
public Optional<OAuthClient> getClient(String clientId) {

View File

@@ -0,0 +1,13 @@
package com.ysoft.geecon.repo;
import org.apache.commons.lang3.RandomStringUtils;
import java.security.SecureRandom;
import java.util.Random;
public class SecureRandomStrings {
private static final Random RANDOM = new SecureRandom();
public static String alphanumeric(int length) {
return RandomStringUtils.random(length, 0, 0, true, true, null, RANDOM);
}
}

View File

@@ -0,0 +1,40 @@
package com.ysoft.geecon.repo;
import com.ysoft.geecon.dto.*;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.*;
@ApplicationScoped
public class SessionsRepo {
private final Map<String, AuthorizationSession> authorizationSessions = new HashMap<>();
private final Map<String, String> sessionsByAuthorizationCode = 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;
}
public String finishSession(String sessionId, List<String> acceptedScopes) {
Objects.requireNonNull(authorizationSessions.computeIfPresent(sessionId, (id, s) -> s.withScopes(acceptedScopes)));
var authCode = SecureRandomStrings.alphanumeric(10);
sessionsByAuthorizationCode.put(authCode, sessionId);
return authCode;
}
public AuthorizationSession assignUser(String sessionId, User user) {
return Objects.requireNonNull(authorizationSessions.computeIfPresent(sessionId, (id, session) -> session.withUser(user)));
}
public Optional<AuthorizationSession> redeemAuthorizationCode(String authorizationCode) {
var sessionId = Optional.ofNullable(sessionsByAuthorizationCode.get(authorizationCode));
sessionId.ifPresent(_id -> sessionsByAuthorizationCode.remove(authorizationCode));
return sessionId.map(authorizationSessions::get);
}
}

View File

@@ -20,7 +20,7 @@
background-color: #fff;
padding: 20px;
border-radius: 5px;
box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.login-container h2 {
@@ -70,11 +70,21 @@
<body>
<div class="login-container">
<h2>Consents</h2>
{#if error == "invalid_credentials"}
<div class="error-popup" id="error-popup">Invalid credentials</div>
{/if}
<p>Hello <strong>{user.login}</strong></p>
<p>You are authorizing to <strong>{client.description}</strong>, which requests the following permissions from you:</p>
<form action="" method="post">
<button type="submit" class="login-button">Login</button>
<input type="hidden" name="sessionId" value="{sessionId}">
<ul>
{#each scopes}
<li>
<label><input type="checkbox" name="scope" value="{it}" checked>{it}</label>
</li>
{/each}
</ul>
<button type="submit" class="login-button">Confirm</button>
</form>
</div>
</body>

View File

@@ -20,7 +20,7 @@
background-color: #fff;
padding: 20px;
border-radius: 5px;
box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.login-container h2 {
@@ -74,6 +74,7 @@
<div class="error-popup" id="error-popup">Invalid credentials</div>
{/if}
<form action="" method="post">
<input type="hidden" name="sessionId" value="{sessionId}">
<div class="form-group">
<label for="username">Username:</label>
<input type="text" id="username" name="username" value="{loginHint}" required>