diff --git a/pom.xml b/pom.xml index 7615992..5549ff8 100644 --- a/pom.xml +++ b/pom.xml @@ -50,6 +50,11 @@ rest-assured test + + org.apache.commons + commons-lang3 + 3.4 + diff --git a/src/main/java/com/ysoft/geecon/OAuthResource.java b/src/main/java/com/ysoft/geecon/OAuthResource.java index 5e3945b..d811871 100644 --- a/src/main/java/com/ysoft/geecon/OAuthResource.java +++ b/src/main/java/com/ysoft/geecon/OAuthResource.java @@ -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 scopes, String error); + public static native TemplateInstance consents(User user, OAuthClient client, List 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 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; - } - } - } diff --git a/src/main/java/com/ysoft/geecon/dto/AccessTokenResponse.java b/src/main/java/com/ysoft/geecon/dto/AccessTokenResponse.java new file mode 100644 index 0000000..8b7ce51 --- /dev/null +++ b/src/main/java/com/ysoft/geecon/dto/AccessTokenResponse.java @@ -0,0 +1,4 @@ +package com.ysoft.geecon.dto; + +public record AccessTokenResponse(String token, String scope, String idToken, long expiresIn) { +} diff --git a/src/main/java/com/ysoft/geecon/dto/AuthParams.java b/src/main/java/com/ysoft/geecon/dto/AuthParams.java new file mode 100644 index 0000000..0017160 --- /dev/null +++ b/src/main/java/com/ysoft/geecon/dto/AuthParams.java @@ -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 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; + } +} diff --git a/src/main/java/com/ysoft/geecon/dto/AuthorizationSession.java b/src/main/java/com/ysoft/geecon/dto/AuthorizationSession.java new file mode 100644 index 0000000..0924fec --- /dev/null +++ b/src/main/java/com/ysoft/geecon/dto/AuthorizationSession.java @@ -0,0 +1,17 @@ +package com.ysoft.geecon.dto; + +import java.util.List; + +public record AuthorizationSession(AuthParams params, OAuthClient client, User user, List 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 acceptedScopes) { + return new AuthorizationSession(params, client, user, acceptedScopes); + } +} diff --git a/src/main/java/com/ysoft/geecon/dto/OAuthClient.java b/src/main/java/com/ysoft/geecon/dto/OAuthClient.java index 9147bb3..88f0eee 100644 --- a/src/main/java/com/ysoft/geecon/dto/OAuthClient.java +++ b/src/main/java/com/ysoft/geecon/dto/OAuthClient.java @@ -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); } diff --git a/src/main/java/com/ysoft/geecon/error/OAuthException.java b/src/main/java/com/ysoft/geecon/error/OAuthException.java new file mode 100644 index 0000000..7e17808 --- /dev/null +++ b/src/main/java/com/ysoft/geecon/error/OAuthException.java @@ -0,0 +1,7 @@ +package com.ysoft.geecon.error; + +public class OAuthException extends RuntimeException { + public OAuthException(String message) { + super(message); + } +} diff --git a/src/main/java/com/ysoft/geecon/repo/ClientsRepo.java b/src/main/java/com/ysoft/geecon/repo/ClientsRepo.java index 442bae4..88d33f7 100644 --- a/src/main/java/com/ysoft/geecon/repo/ClientsRepo.java +++ b/src/main/java/com/ysoft/geecon/repo/ClientsRepo.java @@ -13,7 +13,7 @@ public class ClientsRepo { private final Map 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 getClient(String clientId) { diff --git a/src/main/java/com/ysoft/geecon/repo/SecureRandomStrings.java b/src/main/java/com/ysoft/geecon/repo/SecureRandomStrings.java new file mode 100644 index 0000000..f2e7eab --- /dev/null +++ b/src/main/java/com/ysoft/geecon/repo/SecureRandomStrings.java @@ -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); + } +} diff --git a/src/main/java/com/ysoft/geecon/repo/SessionsRepo.java b/src/main/java/com/ysoft/geecon/repo/SessionsRepo.java new file mode 100644 index 0000000..951f08d --- /dev/null +++ b/src/main/java/com/ysoft/geecon/repo/SessionsRepo.java @@ -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 authorizationSessions = new HashMap<>(); + private final Map sessionsByAuthorizationCode = new HashMap<>(); + + public Optional 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 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 redeemAuthorizationCode(String authorizationCode) { + var sessionId = Optional.ofNullable(sessionsByAuthorizationCode.get(authorizationCode)); + sessionId.ifPresent(_id -> sessionsByAuthorizationCode.remove(authorizationCode)); + return sessionId.map(authorizationSessions::get); + } +} diff --git a/src/main/resources/templates/OAuthResource/consents.html b/src/main/resources/templates/OAuthResource/consents.html index 603d557..e7444e7 100644 --- a/src/main/resources/templates/OAuthResource/consents.html +++ b/src/main/resources/templates/OAuthResource/consents.html @@ -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 @@ diff --git a/src/main/resources/templates/OAuthResource/login.html b/src/main/resources/templates/OAuthResource/login.html index 398dbf5..a0a19d2 100644 --- a/src/main/resources/templates/OAuthResource/login.html +++ b/src/main/resources/templates/OAuthResource/login.html @@ -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 @@
Invalid credentials
{/if}
+