diff --git a/extensions/core/runtime/src/main/java/dev/cloudeko/zenei/extension/core/feature/AuthorizationManager.java b/extensions/core/runtime/src/main/java/dev/cloudeko/zenei/extension/core/feature/AuthorizationManager.java new file mode 100644 index 0000000..090e3ee --- /dev/null +++ b/extensions/core/runtime/src/main/java/dev/cloudeko/zenei/extension/core/feature/AuthorizationManager.java @@ -0,0 +1,12 @@ +package dev.cloudeko.zenei.extension.core.feature; + +import dev.cloudeko.zenei.extension.core.model.session.SessionToken; + +public interface AuthorizationManager { + + SessionToken loginWithPassword(String identifier, String password); + + SessionToken loginWithAuthorizationCode(String provider, String code); + + SessionToken swapRefreshToken(String refreshToken); +} diff --git a/extensions/core/runtime/src/main/java/dev/cloudeko/zenei/extension/core/feature/UserDataManager.java b/extensions/core/runtime/src/main/java/dev/cloudeko/zenei/extension/core/feature/UserDataManager.java new file mode 100644 index 0000000..1ce9148 --- /dev/null +++ b/extensions/core/runtime/src/main/java/dev/cloudeko/zenei/extension/core/feature/UserDataManager.java @@ -0,0 +1,19 @@ +package dev.cloudeko.zenei.extension.core.feature; + +import dev.cloudeko.zenei.extension.core.model.user.CreateUserInput; +import dev.cloudeko.zenei.extension.core.model.user.User; + +import java.util.List; + +public interface UserDataManager { + + User findUserByIdentifier(String identifier); + + List listUsers(int offset, int limit); + + User createUser(CreateUserInput input); + + void updateUser(CreateUserInput input); + + void deleteUser(String identifier); +} diff --git a/extensions/core/runtime/src/main/java/dev/cloudeko/zenei/extension/core/feature/impl/DefaultAuthorizationManager.java b/extensions/core/runtime/src/main/java/dev/cloudeko/zenei/extension/core/feature/impl/DefaultAuthorizationManager.java new file mode 100644 index 0000000..ed25195 --- /dev/null +++ b/extensions/core/runtime/src/main/java/dev/cloudeko/zenei/extension/core/feature/impl/DefaultAuthorizationManager.java @@ -0,0 +1,180 @@ +package dev.cloudeko.zenei.extension.core.feature.impl; + +import dev.cloudeko.zenei.extension.core.exception.*; +import dev.cloudeko.zenei.extension.core.feature.AuthorizationManager; +import dev.cloudeko.zenei.extension.core.feature.util.TokenUtil; +import dev.cloudeko.zenei.extension.core.model.account.ExternalAccessToken; +import dev.cloudeko.zenei.extension.core.model.account.ExternalAccount; +import dev.cloudeko.zenei.extension.core.model.email.EmailAddress; +import dev.cloudeko.zenei.extension.core.model.session.SessionToken; +import dev.cloudeko.zenei.extension.core.model.user.User; +import dev.cloudeko.zenei.extension.core.provider.HashProvider; +import dev.cloudeko.zenei.extension.core.provider.RefreshTokenProvider; +import dev.cloudeko.zenei.extension.core.provider.TokenProvider; +import dev.cloudeko.zenei.extension.core.repository.RefreshTokenRepository; +import dev.cloudeko.zenei.extension.core.repository.UserPasswordRepository; +import dev.cloudeko.zenei.extension.core.repository.UserRepository; +import dev.cloudeko.zenei.extension.external.ExternalAuthProvider; +import dev.cloudeko.zenei.extension.external.ExternalAuthResolver; +import dev.cloudeko.zenei.extension.external.ExternalUserProfile; +import dev.cloudeko.zenei.extension.external.providers.AvailableProvider; +import dev.cloudeko.zenei.extension.external.web.client.ExternalProviderAccessToken; +import dev.cloudeko.zenei.extension.external.web.client.LoginOAuthClient; +import io.quarkus.rest.client.reactive.QuarkusRestClientBuilder; +import jakarta.enterprise.context.ApplicationScoped; +import lombok.AllArgsConstructor; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +@ApplicationScoped +@AllArgsConstructor +public class DefaultAuthorizationManager implements AuthorizationManager { + + private final UserPasswordRepository userPasswordRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final UserRepository userRepository; + + private final RefreshTokenProvider refreshTokenProvider; + private final ExternalAuthResolver externalAuthenticationProvider; + private final TokenProvider tokenProvider; + private final HashProvider hashProvider; + + @Override + public SessionToken loginWithPassword(String identifier, String password) { + final var userPassword = userPasswordRepository.getUserPasswordByEmail(identifier); + + if (userPassword.isEmpty()) { + throw new UserNotFoundException(); + } + + if (hashProvider.checkPassword(password, userPassword.get().getPasswordHash())) { + final var user = userPassword.get().getUser(); + + final var refreshTokenData = refreshTokenProvider.generateRefreshToken(user); + final var accessTokenData = tokenProvider.generateToken(user); + + final var refreshToken = TokenUtil.createRefreshToken(user, refreshTokenData); + final var token = refreshTokenRepository.createRefreshToken(refreshToken); + + return TokenUtil.createToken(user, accessTokenData, refreshToken); + } else { + throw new InvalidPasswordException(); + } + } + + @Override + public SessionToken loginWithAuthorizationCode(String provider, String code) { + final var externalProvider = getExternalAuthProvider(provider); + + final var client = QuarkusRestClientBuilder.newBuilder() + .baseUri(URI.create(externalProvider.getTokenEndpoint())) + .build(LoginOAuthClient.class); + + final var accessToken = client.getAccessToken("authorization_code", + externalProvider.config().clientId(), + externalProvider.config().clientSecret(), + code, AvailableProvider.getProvider(provider).getRedirectUri()); + + if (accessToken == null) { + throw new IllegalArgumentException("Invalid authorization code"); + } + + final var externalUserProfile = externalProvider.getExternalUserProfile(accessToken); + + final var externalEmail = getEmailFromUserProfile(externalUserProfile); + + if (externalEmail.isEmpty()) { + throw new EmailNotFoundException(); + } + + var user = userRepository.getByAccountProviderId(externalUserProfile.getId()) + .orElse(createUserFromExternalUserProfile(externalUserProfile, externalEmail.get(), provider, accessToken)); + + final var refreshTokenData = refreshTokenProvider.generateRefreshToken(user); + final var accessTokenData = tokenProvider.generateToken(user); + + final var refreshToken = TokenUtil.createRefreshToken(user, refreshTokenData); + final var token = refreshTokenRepository.createRefreshToken(refreshToken); + + return TokenUtil.createToken(user, accessTokenData, refreshToken); + } + + @Override + public SessionToken swapRefreshToken(String refreshToken) { + final var token = refreshTokenRepository.findRefreshTokenByToken(refreshToken); + + if (token.isEmpty()) { + throw new InvalidRefreshTokenException(); + } + + final var user = token.get().getUser(); + + final var refreshTokenData = refreshTokenProvider.generateRefreshToken(user); + final var accessTokenData = tokenProvider.generateToken(user); + + final var newRefreshToken = TokenUtil.createRefreshToken(user, refreshTokenData); + final var newToken = refreshTokenRepository.swapRefreshToken(token.get(), newRefreshToken); + if (newToken == null) { + throw new InvalidRefreshTokenException(); + } + + return TokenUtil.createToken(user, accessTokenData, newRefreshToken); + } + + private ExternalAuthProvider getExternalAuthProvider(String provider) { + return externalAuthenticationProvider.getAuthProvider(provider).orElseThrow(InvalidExternalAuthProvider::new); + } + + private Optional getEmailFromUserProfile(ExternalUserProfile externalUserProfile) { + return externalUserProfile.getEmails().stream().filter(email -> email.primary() && email.verified()).findFirst(); + } + + private User createUserFromExternalUserProfile(ExternalUserProfile externalUserProfile, + ExternalUserProfile.ExternalUserEmail externalEmail, + String provider, + ExternalProviderAccessToken accessToken) { + + final var emailAddress = EmailAddress.builder().email(externalEmail.email()).emailVerified(true).build(); + final var externalAccessToken = ExternalAccessToken.builder() + .accessToken(accessToken.getAccessToken()) + .refreshToken(accessToken.getRefreshToken()) + .tokenType(accessToken.getTokenType()) + .scope(accessToken.getScope()) + .build(); + + final var account = ExternalAccount.builder() + .provider(provider) + .providerId(externalUserProfile.getId()) + .accessTokens(new ArrayList<>(List.of(externalAccessToken))) + .build(); + + final var user = User.builder() + .username(externalUserProfile.getUsername()) + .primaryEmailAddress(emailAddress.getEmail()) + .emailAddresses(new ArrayList<>(List.of(emailAddress))) + .accounts(new ArrayList<>(List.of(account))) + .build(); + + checkExistingUsername(user.getUsername()); + checkExistingEmail(user.getPrimaryEmailAddress().getEmail()); + + userRepository.createUser(user); + + return user; + } + + private void checkExistingUsername(String username) { + if (userRepository.existsByUsername(username)) { + throw new UsernameAlreadyExistsException(); + } + } + + private void checkExistingEmail(String email) { + if (userRepository.existsByEmail(email)) { + throw new EmailAlreadyExistsException(); + } + } +} diff --git a/extensions/core/runtime/src/main/java/dev/cloudeko/zenei/extension/core/feature/impl/DefaultUserDataManager.java b/extensions/core/runtime/src/main/java/dev/cloudeko/zenei/extension/core/feature/impl/DefaultUserDataManager.java new file mode 100644 index 0000000..5a0bb37 --- /dev/null +++ b/extensions/core/runtime/src/main/java/dev/cloudeko/zenei/extension/core/feature/impl/DefaultUserDataManager.java @@ -0,0 +1,110 @@ +package dev.cloudeko.zenei.extension.core.feature.impl; + +import dev.cloudeko.zenei.extension.core.config.ApplicationConfig; +import dev.cloudeko.zenei.extension.core.exception.EmailAlreadyExistsException; +import dev.cloudeko.zenei.extension.core.exception.UserNotFoundException; +import dev.cloudeko.zenei.extension.core.exception.UsernameAlreadyExistsException; +import dev.cloudeko.zenei.extension.core.feature.UserDataManager; +import dev.cloudeko.zenei.extension.core.model.email.EmailAddress; +import dev.cloudeko.zenei.extension.core.model.user.CreateUserInput; +import dev.cloudeko.zenei.extension.core.model.user.User; +import dev.cloudeko.zenei.extension.core.model.user.UserPassword; +import dev.cloudeko.zenei.extension.core.provider.HashProvider; +import dev.cloudeko.zenei.extension.core.provider.StringTokenProvider; +import dev.cloudeko.zenei.extension.core.repository.UserPasswordRepository; +import dev.cloudeko.zenei.extension.core.repository.UserRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; +import lombok.AllArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@ApplicationScoped +@AllArgsConstructor +public class DefaultUserDataManager implements UserDataManager { + + private final ApplicationConfig config; + + private final HashProvider hashProvider; + private final StringTokenProvider stringTokenProvider; + + private final UserRepository userRepository; + private final UserPasswordRepository userPasswordRepository; + + @Override + public User findUserByIdentifier(String identifier) { + if (identifier == null) { + throw new UserNotFoundException(); + } + + if (identifier.matches("\\d+")) { + return userRepository.getUserById(Long.parseLong(identifier)).orElseThrow(UserNotFoundException::new); + } + + return userRepository.getUserByUsername(identifier).orElseThrow(UserNotFoundException::new); + } + + @Override + public List listUsers(int offset, int limit) { + return userRepository.listUsers(offset, limit); + } + + @Override + @Transactional + public User createUser(CreateUserInput input) { + final var emailAddress = EmailAddress.builder().email(input.getEmail()).emailVerified(true).build(); + if (!config.getAutoConfirm()) { + final var token = stringTokenProvider.generateToken("mail", emailAddress.getEmail() + UUID.randomUUID()); + + emailAddress.setEmailVerificationToken(token); + emailAddress.setEmailVerificationTokenExpiresAt(LocalDateTime.now().plusDays(1)); + emailAddress.setEmailVerified(false); + } + + final var user = User.builder() + .username(input.getUsername()) + .primaryEmailAddress(emailAddress.getEmail()) + .emailAddresses(new ArrayList<>(List.of(emailAddress))).build(); + + checkExistingUsername(user.getUsername()); + checkExistingEmail(user.getPrimaryEmailAddress().getEmail()); + + userRepository.createUser(user); + + if (input.isPasswordEnabled()) { + final var userPassword = UserPassword.builder() + .user(user) + .passwordHash(hashProvider.hashPassword(input.getPassword())) + .build(); + + userPasswordRepository.createUserPassword(userPassword); + } + + return user; + } + + @Override + public void updateUser(CreateUserInput input) { + + } + + @Override + public void deleteUser(String identifier) { + + } + + private void checkExistingUsername(String username) { + if (userRepository.existsByUsername(username)) { + throw new UsernameAlreadyExistsException(); + } + } + + private void checkExistingEmail(String email) { + if (userRepository.existsByEmail(email)) { + throw new EmailAlreadyExistsException(); + } + } +} diff --git a/extensions/rest/deployment/src/main/java/dev/cloudeko/zenei/extension/rest/deployment/ExternalAuthBuildSteps.java b/extensions/rest/deployment/src/main/java/dev/cloudeko/zenei/extension/rest/deployment/ExternalAuthBuildSteps.java index 84a7ed2..11accdb 100644 --- a/extensions/rest/deployment/src/main/java/dev/cloudeko/zenei/extension/rest/deployment/ExternalAuthBuildSteps.java +++ b/extensions/rest/deployment/src/main/java/dev/cloudeko/zenei/extension/rest/deployment/ExternalAuthBuildSteps.java @@ -1,5 +1,6 @@ package dev.cloudeko.zenei.extension.rest.deployment; +import dev.cloudeko.zenei.extension.rest.endpoint.client.UserInfoHandler; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.builditem.FeatureBuildItem; import io.quarkus.vertx.http.deployment.NonApplicationRootPathBuildItem; @@ -17,8 +18,8 @@ public FeatureBuildItem feature() { @BuildStep public RouteBuildItem route(NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem) { return nonApplicationRootPathBuildItem.routeBuilder() - .route("custom-endpoint") - .handler(new MyCustomHandler()) + .route("user") + .handler(new UserInfoHandler()) .displayOnNotFoundPage() .build(); } diff --git a/extensions/rest/runtime/src/main/java/dev/cloudeko/zenei/extension/rest/endpoint/client/OperationHandler.java b/extensions/rest/runtime/src/main/java/dev/cloudeko/zenei/extension/rest/endpoint/client/OperationHandler.java new file mode 100644 index 0000000..c1839c3 --- /dev/null +++ b/extensions/rest/runtime/src/main/java/dev/cloudeko/zenei/extension/rest/endpoint/client/OperationHandler.java @@ -0,0 +1,45 @@ +package dev.cloudeko.zenei.extension.rest.endpoint.client; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.vertx.core.Handler; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.ext.web.RoutingContext; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +public abstract class OperationHandler implements Handler { + + protected final ObjectMapper objectMapper = new ObjectMapper(); + protected final HttpMethod method; + + public OperationHandler(HttpMethod method) { + this.method = method; + } + + protected abstract Response handleRequest(RoutingContext event); + + @Override + public void handle(RoutingContext event) { + if (event.request().method().equals(HttpMethod.OPTIONS)) { + event.response().putHeader("Allow", method.toString() + ", OPTIONS"); + event.next(); + return; + } + + if (!event.request().method().equals(method)) { + event.response().setStatusCode(405).end(); + return; + } + + try (Response response = handleRequest(event)) { + event.response() + .putHeader("Content-Type", MediaType.APPLICATION_JSON) + .setStatusCode(response.getStatus()) + .end(objectMapper.writeValueAsString(response.getEntity())); + } catch (JsonProcessingException e) { + event.response().setStatusCode(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode()).end(); + } + } +} diff --git a/extensions/rest/runtime/src/main/java/dev/cloudeko/zenei/extension/rest/endpoint/client/UserInfoHandler.java b/extensions/rest/runtime/src/main/java/dev/cloudeko/zenei/extension/rest/endpoint/client/UserInfoHandler.java index a0f75b7..17f769b 100644 --- a/extensions/rest/runtime/src/main/java/dev/cloudeko/zenei/extension/rest/endpoint/client/UserInfoHandler.java +++ b/extensions/rest/runtime/src/main/java/dev/cloudeko/zenei/extension/rest/endpoint/client/UserInfoHandler.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import dev.cloudeko.zenei.extension.core.feature.FindUserByIdentifier; +import dev.cloudeko.zenei.extension.core.feature.UserDataManager; import dev.cloudeko.zenei.extension.core.model.user.User; import io.quarkus.arc.Arc; import io.quarkus.security.identity.SecurityIdentity; @@ -12,44 +13,36 @@ import io.vertx.core.http.HttpServerResponse; import io.vertx.ext.web.RoutingContext; import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; /** * Handler that serves the user information of the authenticated user. */ -public class UserInfoHandler implements Handler { +public class UserInfoHandler extends OperationHandler { private static final String ALLOWED_METHODS = "GET, HEAD, OPTIONS"; private final ObjectMapper objectMapper = new ObjectMapper(); - private volatile FindUserByIdentifier findUserByIdentifier; + private volatile UserDataManager userDataManager; + + public UserInfoHandler() { + super(HttpMethod.GET); + } @Override - public void handle(RoutingContext routingContext) { - HttpServerRequest req = routingContext.request(); - HttpServerResponse resp = routingContext.response(); - - QuarkusHttpUser quarkusUser = (QuarkusHttpUser) routingContext.user(); - - if (req.method().equals(HttpMethod.OPTIONS)) { - resp.headers().set("Allow", ALLOWED_METHODS); - routingContext.next(); - } else { - resp.headers().set("Content-Type", MediaType.APPLICATION_JSON + ";charset=UTF-8"); - SecurityIdentity securityIdentity = quarkusUser.getSecurityIdentity(); - User user = findUserByIdentifier(securityIdentity.getPrincipal().getName()); - try { - resp.end(objectMapper.writeValueAsString(user)); - } catch (Exception e) { - resp.setStatusCode(500).end(); - } - } + protected Response handleRequest(RoutingContext event) { + QuarkusHttpUser quarkusUser = (QuarkusHttpUser) event.user(); + SecurityIdentity securityIdentity = quarkusUser.getSecurityIdentity(); + + User user = getUserDataManager().findUserByIdentifier(securityIdentity.getPrincipal().getName()); + return Response.ok(user).build(); } - public User findUserByIdentifier(String identifier) { - if (this.findUserByIdentifier == null) { - this.findUserByIdentifier = Arc.container().instance(FindUserByIdentifier.class).get(); + public UserDataManager getUserDataManager() { + if (this.userDataManager == null) { + this.userDataManager = Arc.container().instance(UserDataManager.class).get(); } - return this.findUserByIdentifier.handle(identifier); + return this.userDataManager; } } diff --git a/generate-dev-keys.sh b/generate-dev-keys.sh index 1c40610..8234507 100755 --- a/generate-dev-keys.sh +++ b/generate-dev-keys.sh @@ -4,7 +4,7 @@ mkdir -p core/src/main/resources # Generate a development private key -openssl genpkey -algorithm RSA -out core/src/main/resources/dev-private-key.pem -pkeyopt rsa_keygen_bits:2048 +openssl genpkey -algorithm RSA -out platform/src/main/resources/dev-private-key.pem -pkeyopt rsa_keygen_bits:2048 # Extract the public key from the private key -openssl rsa -in core/src/main/resources/dev-private-key.pem -pubout -out core/src/main/resources/dev-public-key.pem \ No newline at end of file +openssl rsa -in platform/src/main/resources/dev-private-key.pem -pubout -out platform/src/main/resources/dev-public-key.pem \ No newline at end of file