Skip to content

Commit

Permalink
feat(auth): add OAuth provider configuration and restricted authentic…
Browse files Browse the repository at this point in the history
…ation profile

- introduce OAuthProviderConfig and OAuthProvidersConfig for handling OAuth configurations.
- add RestrictedAuthenticationProfile for testing authentication with disabled sign-up.
- implement OAuthResource for handling OAuth login and callback flows.
- update ApplicationConfig and UserResource to respect sign-up enabled config.
- enhance Javadocs for various domain features.
  • Loading branch information
zZHorizonZz committed Aug 12, 2024
1 parent a3ac13d commit 78247c7
Show file tree
Hide file tree
Showing 16 changed files with 276 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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.</p>
*
* @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);
}
17 changes: 17 additions & 0 deletions src/main/java/dev/cloudeko/zenei/domain/feature/ListUsers.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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<User> listUsers(int offset, int limit);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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.
* </p>
* 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.
*
* <p>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.</p>
*
* @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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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.</p>
*
* @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.
*
* <p>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.</p>
*
* @param refreshTokenInput the input object that contains the refresh token
* @return the refreshed access token
* @see Token
* @see RefreshTokenInput
*/
Token handle(RefreshTokenInput refreshTokenInput);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
@AllArgsConstructor
public class CreateUserInput {
private String username;

private String email;
private boolean autoConfirmEmail;

private boolean passwordEnabled;
private String password;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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<String, OAuthProviderConfig> providers();
}
10 changes: 9 additions & 1 deletion src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,12 @@ zenei.jwt.issuer=https://example.com/issuer
zenei.user.default.admin.username=admin
zenei.user.default.admin.email=[email protected]
zenei.user.default.admin.password=test
zenei.user.default.admin.role=admin
zenei.user.default.admin.role=admin

# Github OAuth
#oauth.github.client-id=<your-client-id>
#oauth.github.client-secret=<your-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
Original file line number Diff line number Diff line change
@@ -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", "[email protected]")
.formParam("password", "test-password")
.formParam("strategy", "PASSWORD")
.post("/user")
.then()
.statusCode(Response.Status.FORBIDDEN.getStatusCode());
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> getConfigOverrides() {
return Map.of(
"zenei.auth.sign-up.enabled", "false",
"zenei.user.default.admin.username", "admin",
"zenei.user.default.admin.email", "[email protected]",
"zenei.user.default.admin.password", "test",
"zenei.user.default.admin.role", "admin"
);
}
}

0 comments on commit 78247c7

Please sign in to comment.