diff --git a/src/main/java/dev/cloudeko/zenei/application/web/model/request/SignupRequest.java b/src/main/java/dev/cloudeko/zenei/application/web/model/request/SignupRequest.java index 3e26d9f..e5dbb4e 100644 --- a/src/main/java/dev/cloudeko/zenei/application/web/model/request/SignupRequest.java +++ b/src/main/java/dev/cloudeko/zenei/application/web/model/request/SignupRequest.java @@ -29,7 +29,7 @@ public class SignupRequest { @FormParam("redirect_to") private URI redirectTo; - public CreateUserInput toCreateUserInput() { - return new CreateUserInput(username, email, strategy.equals(Strategy.PASSWORD), password); + public CreateUserInput toCreateUserInput(boolean autoconfirm) { + return new CreateUserInput(username, email, autoconfirm, strategy.equals(Strategy.PASSWORD), password); } } diff --git a/src/main/java/dev/cloudeko/zenei/application/web/resource/OAuthResource.java b/src/main/java/dev/cloudeko/zenei/application/web/resource/OAuthResource.java new file mode 100644 index 0000000..0bf6d24 --- /dev/null +++ b/src/main/java/dev/cloudeko/zenei/application/web/resource/OAuthResource.java @@ -0,0 +1,56 @@ +package dev.cloudeko.zenei.application.web.resource; + +import dev.cloudeko.zenei.infrastructure.config.ApplicationConfig; +import dev.cloudeko.zenei.infrastructure.config.OAuthProviderConfig; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; +import lombok.AllArgsConstructor; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; + +@Path("/oauth") +@AllArgsConstructor +@Tag(name = "OAuth Service", description = "OAuth service used for authentication") +public class OAuthResource { + + private final ApplicationConfig config; + + @GET + @Path("/login/{provider}") + public Response login(@PathParam("provider") String provider) { + final var providerConfig = getProviderConfig(provider); + + if (providerConfig == null) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + + final var uriBuilder = UriBuilder.fromUri(providerConfig.authorizationUri()) + .queryParam("client_id", providerConfig.clientId()) + .queryParam("redirect_uri", providerConfig.redirectUri()) + .queryParam("response_type", "code") + .queryParam("scope", "openid profile email"); + + return Response.temporaryRedirect(uriBuilder.build()).build(); + } + + @GET + @Path("/callback/{provider}") + public Response callback(@PathParam("provider") String provider, + @QueryParam("code") String code, + @QueryParam("state") String state) { + final var providerConfig = getProviderConfig(provider); + + if (providerConfig == null) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + + return Response.ok("Received auth code: " + code).build(); + } + + private OAuthProviderConfig getProviderConfig(String provider) { + return config.getOAuthProvidersConfig().providers().get(provider); + } +} diff --git a/src/main/java/dev/cloudeko/zenei/application/web/resource/UserResource.java b/src/main/java/dev/cloudeko/zenei/application/web/resource/UserResource.java index c473251..56be75b 100644 --- a/src/main/java/dev/cloudeko/zenei/application/web/resource/UserResource.java +++ b/src/main/java/dev/cloudeko/zenei/application/web/resource/UserResource.java @@ -8,6 +8,7 @@ import dev.cloudeko.zenei.domain.model.email.VerifyMagicLinkInput; import dev.cloudeko.zenei.domain.model.token.LoginPasswordInput; import dev.cloudeko.zenei.domain.model.token.RefreshTokenInput; +import dev.cloudeko.zenei.infrastructure.config.ApplicationConfig; import io.quarkus.security.Authenticated; import jakarta.transaction.Transactional; import jakarta.validation.Valid; @@ -26,6 +27,8 @@ @Tag(name = "User Service", description = "API for user authentication") public class UserResource { + private final ApplicationConfig applicationConfig; + private final CreateUser createUser; private final FindUserByIdentifier findUserByIdentifier; @@ -49,10 +52,14 @@ public Response getCurrentUserInfo(@Context SecurityContext securityContext) { @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_FORM_URLENCODED) public Response signup(@BeanParam @Valid SignupRequest request) { - final var user = createUser.handle(request.toCreateUserInput()); + if (!applicationConfig.getSignUpEnabled()) { + return Response.status(Response.Status.FORBIDDEN).build(); + } + + final var user = createUser.handle(request.toCreateUserInput(applicationConfig.getAutoConfirm())); final var emailAddress = user.getPrimaryEmailAddress(); - if (!emailAddress.getEmailVerified() && emailAddress.getEmailVerificationToken() != null){ + if (!emailAddress.getEmailVerified() && emailAddress.getEmailVerificationToken() != null) { sendMagicLinkVerifyEmail.handle(new EmailAddressInput(emailAddress)); } diff --git a/src/main/java/dev/cloudeko/zenei/domain/feature/CreateDefaultUser.java b/src/main/java/dev/cloudeko/zenei/domain/feature/CreateDefaultUser.java index 73abbcd..5d1b55f 100644 --- a/src/main/java/dev/cloudeko/zenei/domain/feature/CreateDefaultUser.java +++ b/src/main/java/dev/cloudeko/zenei/domain/feature/CreateDefaultUser.java @@ -2,6 +2,21 @@ import dev.cloudeko.zenei.infrastructure.config.DefaultUserConfig; +/** + * The {@code CreateDefaultUser} interface represents a contract for creating a default user based on a configuration. It + * defines a single method {@code handle} which takes a {@link DefaultUserConfig} object as a parameter and performs the + * necessary operations to create a default user based on the provided configuration. + *

+ * Implementations of this interface should handle the validation and creation of a default user based on the given + * configuration. If a user with the same email or username already exists, no action should be taken. + */ public interface CreateDefaultUser { + /** + * The {@code handle} method is used to create a default user based on a given configuration. + * + * @param config The {@link DefaultUserConfig} object that contains the user configuration, including email, username, + * password, and an optional role. + * @apiNote If a user with the same email or username already exists, no action should be taken. + */ void handle(DefaultUserConfig config); } diff --git a/src/main/java/dev/cloudeko/zenei/domain/feature/FindUserByIdentifier.java b/src/main/java/dev/cloudeko/zenei/domain/feature/FindUserByIdentifier.java index ea599b6..9ba4bcd 100644 --- a/src/main/java/dev/cloudeko/zenei/domain/feature/FindUserByIdentifier.java +++ b/src/main/java/dev/cloudeko/zenei/domain/feature/FindUserByIdentifier.java @@ -2,6 +2,24 @@ import dev.cloudeko.zenei.domain.model.user.User; +/** + * The {@code FindUserByIdentifier} interface represents a contract for finding a User object based on a provided identifier. It + * defines a single method {@code handle} which takes a {@link Long} identifier as a parameter and returns the corresponding + * {@link User} object if found. + * + *

Implementations of this interface should handle the retrieval of a User object based on the given identifier. If a user + * with the provided identifier is found, the corresponding + * User object should be returned. Otherwise, an appropriate exception should be thrown.

+ * + * @see User + */ public interface FindUserByIdentifier { - User handle(Long identifier); + /** + * The {@code handle} method handles the retrieval of a User object based on the provided identifier. It takes a Long + * identifier as a parameter and returns the corresponding User object if found. + * + * @param identifier the identifier of the user + * @return the User object corresponding to the provided identifier + */ + User handle(long identifier); } diff --git a/src/main/java/dev/cloudeko/zenei/domain/feature/ListUsers.java b/src/main/java/dev/cloudeko/zenei/domain/feature/ListUsers.java index 6995df6..f2768b2 100644 --- a/src/main/java/dev/cloudeko/zenei/domain/feature/ListUsers.java +++ b/src/main/java/dev/cloudeko/zenei/domain/feature/ListUsers.java @@ -4,6 +4,23 @@ import java.util.List; +/** + * The {@code ListUsers} interface represents a contract for retrieving a list of users based on the provided offset and limit. + * It defines a single method {@code listUsers} which takes the offset and limit as parameters and returns a list of User + * objects. + *

+ * Implementations of this interface should handle the logic for retrieving the users from a data source and returning the + * list. + * + * @see User + */ public interface ListUsers { + /** + * Retrieves a list of users based on the provided offset and limit. + * + * @param offset the starting position of the users in the list (inclusive) + * @param limit the maximum number of users to retrieve + * @return a {@code List} of {@link User} objects representing the users in the specified range + */ List listUsers(int offset, int limit); } diff --git a/src/main/java/dev/cloudeko/zenei/domain/feature/LoginUserWithPassword.java b/src/main/java/dev/cloudeko/zenei/domain/feature/LoginUserWithPassword.java index 158b8db..afc07e3 100644 --- a/src/main/java/dev/cloudeko/zenei/domain/feature/LoginUserWithPassword.java +++ b/src/main/java/dev/cloudeko/zenei/domain/feature/LoginUserWithPassword.java @@ -3,6 +3,33 @@ import dev.cloudeko.zenei.domain.model.Token; import dev.cloudeko.zenei.domain.model.token.LoginPasswordInput; +/** + * The {@code LoginUserWithPassword} interface represents a contract for authenticating a user with a password. It defines a + * single method {@code handle} which takes a {@link LoginPasswordInput} object as a parameter and returns a {@link Token} + * object if the authentication is successful. + *

+ * Implementations of this interface should handle the authentication process by verifying the user's password. If the password + * is valid, a token should be generated and returned . Otherwise, an appropriate exception should be thrown. + *

+ * The {@link Token} object represents an authentication token and contains information such as the access token, token type, + * and expiration time. + * + * @see Token + * @see LoginPasswordInput + */ public interface LoginUserWithPassword { + /** + * The {@code handle} method is a part of the {@code LoginUserWithPassword} interface. It takes a {@link LoginPasswordInput} + * object as a parameter and returns a {@link Token} object if the authentication is successful. + * + *

Implementations of this method should handle the authentication process by verifying the user's password. If the + * password + * is valid, a token should be generated and returned. Otherwise, an appropriate exception should be thrown.

+ * + * @param loginPasswordInput the input object that contains the user's login credentials (email and password) + * @return the authentication token if the authentication is successful + * @see Token + * @see LoginPasswordInput + */ Token handle(LoginPasswordInput loginPasswordInput); } diff --git a/src/main/java/dev/cloudeko/zenei/domain/feature/RefreshAccessToken.java b/src/main/java/dev/cloudeko/zenei/domain/feature/RefreshAccessToken.java index e4e8ba0..23c2b19 100644 --- a/src/main/java/dev/cloudeko/zenei/domain/feature/RefreshAccessToken.java +++ b/src/main/java/dev/cloudeko/zenei/domain/feature/RefreshAccessToken.java @@ -3,6 +3,32 @@ import dev.cloudeko.zenei.domain.model.Token; import dev.cloudeko.zenei.domain.model.token.RefreshTokenInput; +/** + * The {@code RefreshAccessToken} interface represents a contract for refreshing an access token based on a provided refresh + * token. It defines a single method {@code handle} which takes a {@link RefreshTokenInput} object as a parameter and returns a + * {@link Token} object representing the refreshed access token. + * + *

Implementations of this interface should handle the validation and generation of a new access token based on the provided + * refresh token. If the refresh token is invalid or expired, an appropriate exception should be thrown. Otherwise, a new access + * token should be generated and returned.

+ * + * @see Token + * @see RefreshTokenInput + */ public interface RefreshAccessToken { + /** + * The {@code handle} method is a part of the {@code RefreshAccessToken} interface. It takes a {@link RefreshTokenInput} + * object as a parameter and returns a {@link Token} object representing the refreshed access token. + * + *

Implementations of this method should handle the validation and generation of a new access token based on the provided + * refresh token. + * If the refresh token is invalid or expired, an appropriate exception should be thrown. Otherwise, a new access token + * should be generated and returned.

+ * + * @param refreshTokenInput the input object that contains the refresh token + * @return the refreshed access token + * @see Token + * @see RefreshTokenInput + */ Token handle(RefreshTokenInput refreshTokenInput); } diff --git a/src/main/java/dev/cloudeko/zenei/domain/feature/impl/FindUserByIdentifierImpl.java b/src/main/java/dev/cloudeko/zenei/domain/feature/impl/FindUserByIdentifierImpl.java index e7096ff..c33a34d 100644 --- a/src/main/java/dev/cloudeko/zenei/domain/feature/impl/FindUserByIdentifierImpl.java +++ b/src/main/java/dev/cloudeko/zenei/domain/feature/impl/FindUserByIdentifierImpl.java @@ -14,7 +14,7 @@ public class FindUserByIdentifierImpl implements FindUserByIdentifier { private final UserRepository userRepository; @Override - public User handle(Long identifier) { + public User handle(long identifier) { return userRepository.getUserById(identifier).orElseThrow(UserNotFoundException::new); } } diff --git a/src/main/java/dev/cloudeko/zenei/domain/model/user/CreateUserInput.java b/src/main/java/dev/cloudeko/zenei/domain/model/user/CreateUserInput.java index f104065..a68f092 100644 --- a/src/main/java/dev/cloudeko/zenei/domain/model/user/CreateUserInput.java +++ b/src/main/java/dev/cloudeko/zenei/domain/model/user/CreateUserInput.java @@ -7,7 +7,10 @@ @AllArgsConstructor public class CreateUserInput { private String username; + private String email; + private boolean autoConfirmEmail; + private boolean passwordEnabled; private String password; } diff --git a/src/main/java/dev/cloudeko/zenei/infrastructure/config/ApplicationConfig.java b/src/main/java/dev/cloudeko/zenei/infrastructure/config/ApplicationConfig.java index 20dc3b2..797f029 100644 --- a/src/main/java/dev/cloudeko/zenei/infrastructure/config/ApplicationConfig.java +++ b/src/main/java/dev/cloudeko/zenei/infrastructure/config/ApplicationConfig.java @@ -9,9 +9,15 @@ @ApplicationScoped public class ApplicationConfig { + @ConfigProperty(name = "zenei.auth.sign-up.enabled", defaultValue = "true") + Boolean signUpEnabled; + @ConfigProperty(name = "zenei.mailer.auto-confirm", defaultValue = "false") Boolean autoConfirm; @Inject DefaultUsersConfig defaultUsersConfig; + + @Inject + OAuthProvidersConfig oAuthProvidersConfig; } diff --git a/src/main/java/dev/cloudeko/zenei/infrastructure/config/OAuthProviderConfig.java b/src/main/java/dev/cloudeko/zenei/infrastructure/config/OAuthProviderConfig.java new file mode 100644 index 0000000..0ea87fa --- /dev/null +++ b/src/main/java/dev/cloudeko/zenei/infrastructure/config/OAuthProviderConfig.java @@ -0,0 +1,18 @@ +package dev.cloudeko.zenei.infrastructure.config; + +public interface OAuthProviderConfig { + + String clientId(); + + String clientSecret(); + + String authorizationUri(); + + String tokenUri(); + + String userInfoUri(); + + String redirectUri(); + + String scope(); +} diff --git a/src/main/java/dev/cloudeko/zenei/infrastructure/config/OAuthProvidersConfig.java b/src/main/java/dev/cloudeko/zenei/infrastructure/config/OAuthProvidersConfig.java new file mode 100644 index 0000000..c34394b --- /dev/null +++ b/src/main/java/dev/cloudeko/zenei/infrastructure/config/OAuthProvidersConfig.java @@ -0,0 +1,13 @@ +package dev.cloudeko.zenei.infrastructure.config; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithParentName; + +import java.util.Map; + +@ConfigMapping(prefix = "zenei.user.default") +public interface OAuthProvidersConfig { + + @WithParentName + Map providers(); +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a0bb23a..887a66b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -35,4 +35,12 @@ zenei.jwt.issuer=https://example.com/issuer zenei.user.default.admin.username=admin zenei.user.default.admin.email=admin@test.com zenei.user.default.admin.password=test -zenei.user.default.admin.role=admin \ No newline at end of file +zenei.user.default.admin.role=admin + +# Github OAuth +#oauth.github.client-id= +#oauth.github.client-secret= +#oauth.github.auth-uri=https://github.com/login/oauth/authorize +#oauth.github.token-uri=https://github.com/login/oauth/access_token +#oauth.github.user-info-uri=https://api.github.com/user +#oauth.github.redirect-uri=http://localhost:8080/oauth/callback/github \ No newline at end of file diff --git a/src/test/java/dev/cloudeko/zenei/auth/AuthenticationFlowWithDisabledSignupTest.java b/src/test/java/dev/cloudeko/zenei/auth/AuthenticationFlowWithDisabledSignupTest.java new file mode 100644 index 0000000..340af3d --- /dev/null +++ b/src/test/java/dev/cloudeko/zenei/auth/AuthenticationFlowWithDisabledSignupTest.java @@ -0,0 +1,36 @@ +package dev.cloudeko.zenei.auth; + +import dev.cloudeko.zenei.profile.RestrictedAuthenticationProfile; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.restassured.RestAssured; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.*; + +import static io.restassured.RestAssured.given; + +@QuarkusTest +@TestProfile(RestrictedAuthenticationProfile.class) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class AuthenticationFlowWithDisabledSignupTest { + + @BeforeAll + static void setup() { + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + } + + @Test + @DisplayName("Create user via email and password (POST /user) should return (403 FORBIDDEN)") + void testCreateUser() { + given() + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .formParam("username", "test-user2") + .formParam("email", "test@test.com") + .formParam("password", "test-password") + .formParam("strategy", "PASSWORD") + .post("/user") + .then() + .statusCode(Response.Status.FORBIDDEN.getStatusCode()); + } +} diff --git a/src/test/java/dev/cloudeko/zenei/profile/RestrictedAuthenticationProfile.java b/src/test/java/dev/cloudeko/zenei/profile/RestrictedAuthenticationProfile.java new file mode 100644 index 0000000..a2cff45 --- /dev/null +++ b/src/test/java/dev/cloudeko/zenei/profile/RestrictedAuthenticationProfile.java @@ -0,0 +1,19 @@ +package dev.cloudeko.zenei.profile; + +import io.quarkus.test.junit.QuarkusTestProfile; + +import java.util.Map; + +public class RestrictedAuthenticationProfile implements QuarkusTestProfile { + + @Override + public Map getConfigOverrides() { + return Map.of( + "zenei.auth.sign-up.enabled", "false", + "zenei.user.default.admin.username", "admin", + "zenei.user.default.admin.email", "admin@test.com", + "zenei.user.default.admin.password", "test", + "zenei.user.default.admin.role", "admin" + ); + } +}