Skip to content

Commit

Permalink
feature(U-6932054538): Implement the /api/hasPermission endpoint
Browse files Browse the repository at this point in the history
The update includes new and updated dependencies in build.gradle for authentication and permissions-related functionality. Changes to the /api/hasPermission and /login endpoints allow for handling of permissions and user login respectively. New exceptions have been added to handle validation of users, and changes to the UserRepo, User, and Tenant entity classes have been made to accommodate these changes. The schema for the `user` table has also been updated. Unit tests for these new functionalities have also been added.

The expanded authentication tests now cover multiple permission-scenarios (system, tenant, subtenant) to improve robustness of the code. New entities Service and Permission have been created to refine the attribute cluster and support database operations. Minor adjustments were made to the AuthController to align with the test and entity changes.

The expanded authentication tests now cover multiple permission-scenarios (system, tenant, subtenant) to improve robustness of the code. New entities Service and Permission have been created to refine the attribute cluster and support database operations. Minor adjustments were made to the AuthController to align with the test and entity changes.

Added TRUNCATE TABLE commands to 'afterMigrate.sql' to clear database before insertion of new testing data. Also introduced a new insert into the 'V1__initial_schema.sql' file to support the migration tests. This will ensure a blank slate for each test and maintain database consistency across different test runs.

The email addresses used in tests have been updated to a new domain. The change affects the "afterMigrate.sql" script, various methods in "UnityIamTest.java" and the "authenticate" method in "MockAuthenticationProvider.java".

Add, configure and test password encoding functionality

Implemented password encoding and verification using BCrypt. Added a new interface `PasswordEncoder` and two implementing classes `BCryptPasswordEncoder` and `UnityAuthenticationProvider`. Adjusted the `User` and `UserRepo` models accordingly and added necessary dependencies in `build.gradle`. The new implementation helps providing secure authentication mechanism by password validating and hashing.

The MockAuthenticationProvider file was deleted as it has become obsolete. Altered the UnityAuthenticationProvider class to remove the restriction of not being activated in the test environment. Also, updated test user passwords in the afterMigrate.sql file, from a plain text format to a hashed one for increased security.

Removed unwanted comment that prevents UnityAuthenticationProvider from running in test environment. Also, this commit includes the deletion of MockAuthenticationProvider which became obsolete. Further, passwords in afterMigrate.sql have been updated to hashed ones from plain text for enhanced security.

Added a new test method to check disabled user status. Refactored how permissions are checked in the authentication process. Also, removed unused code related to the subtenant flag as it is no longer necessary. Updated the user-role queries in UserRepo to return a new TenantPermission object which contains the information necessary for adequate permission checking. These changes introduce a clearer, more intuitive way of managing user permissions.

This commit simplifies the return statement in the UnityIamTest file. It eliminates the need for an extra variable by directly returning the result from the getAccessToken() method, making the code neater and more efficient.

Updated the parameters on tenant and service entities to use the name instead of numerical IDs in the 'hasPermission' requests. Refactored tests accordingly in 'UnityIamTest.java'. Also, have added description field in the tenant structure in database schema and tests. Updated the authentication method to use executor service for blocking tasks.

Updated the schema of several database tables (role, tenant_service, service, etc.) to include uniqueness constraints on the 'name' field. Additionally, modified the authentication error message when the user is not found from 'USER_NOT_FOUND' to 'CREDENTIALS_DO_NOT_MATCH' in UnityAuthenticationProvider.java.
  • Loading branch information
romannaglic authored and jrw972 committed Jan 23, 2024
1 parent 8327ff1 commit b3ace4a
Show file tree
Hide file tree
Showing 22 changed files with 764 additions and 27 deletions.
13 changes: 10 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,26 @@ repositories {
}

dependencies {
annotationProcessor("io.micronaut.data:micronaut-data-processor")
annotationProcessor("io.micronaut:micronaut-http-validation")
annotationProcessor("io.micronaut.security:micronaut-security-annotations")
annotationProcessor("io.micronaut.serde:micronaut-serde-processor")
implementation("io.micronaut.security:micronaut-security-jwt")
implementation("io.micronaut.data:micronaut-data-jdbc")
implementation("io.micronaut.sql:micronaut-jdbc-hikari")
implementation("io.micronaut.flyway:micronaut-flyway")
implementation("io.micronaut.serde:micronaut-serde-jackson")
implementation("io.micronaut.sql:micronaut-jdbc-hikari")
implementation("io.micronaut.reactor:micronaut-reactor")
implementation("at.favre.lib:bcrypt:0.10.2")
compileOnly("io.micronaut:micronaut-http-client")
runtimeOnly("ch.qos.logback:logback-classic")
runtimeOnly("mysql:mysql-connector-java")
runtimeOnly("org.flywaydb:flyway-mysql")
runtimeOnly("org.yaml:snakeyaml")
testImplementation("io.micronaut:micronaut-http-client")
aotPlugins platform("io.micronaut.platform:micronaut-platform:4.2.3")
aotPlugins("io.micronaut.security:micronaut-security-aot")
}

application {
Expand Down Expand Up @@ -55,8 +64,6 @@ micronaut {
optimizeClassLoading = true
deduceEnvironment = true
optimizeNetty = true
configurationProperties.put("micronaut.security.jwks.enabled","false")
}
}



124 changes: 124 additions & 0 deletions src/main/java/io/unityfoundation/auth/AuthController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package io.unityfoundation.auth;

import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.annotation.Body;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Post;
import io.micronaut.security.annotation.Secured;
import io.micronaut.security.authentication.Authentication;
import io.micronaut.security.rules.SecurityRule;
import io.micronaut.serde.annotation.Serdeable;
import io.unityfoundation.auth.entities.Permission.PermissionScope;
import io.unityfoundation.auth.entities.Service;
import io.unityfoundation.auth.entities.Service.ServiceStatus;
import io.unityfoundation.auth.entities.ServiceRepo;
import io.unityfoundation.auth.entities.Tenant;
import io.unityfoundation.auth.entities.TenantRepo;
import io.unityfoundation.auth.entities.User;
import io.unityfoundation.auth.entities.UserRepo;
import java.util.List;
import java.util.Optional;

@Secured(SecurityRule.IS_AUTHENTICATED)
@Controller("/api")
public class AuthController {

private final UserRepo userRepo;
private final ServiceRepo serviceRepo;
private final TenantRepo tenantRepo;

public AuthController(UserRepo userRepo, ServiceRepo serviceRepo, TenantRepo tenantRepo) {
this.userRepo = userRepo;
this.serviceRepo = serviceRepo;
this.tenantRepo = tenantRepo;
}

@Post("/hasPermission")
public HttpResponse<HasPermissionResponse> hasPermission(@Body HasPermissionRequest requestDTO,
Authentication authentication) {

User user = userRepo.findByEmail(authentication.getName()).orElse(null);
if (checkUserStatus(user)) {
return createHasPermissionResponse(false, "The user’s account has been disabled!");
}

Optional<Service> service = serviceRepo.findByName(requestDTO.serviceId());

String serviceStatusCheckResult = checkServiceStatus(service);
if (serviceStatusCheckResult != null) {
return createHasPermissionResponse(false, serviceStatusCheckResult);
}

if (!userRepo.isServiceAvailable(user.getId(), service.get().getId())) {
return createHasPermissionResponse(false,
"The requested service is not enabled for the requested tenant!");
}

if (!checkUserPermission(user, requestDTO)) {
return createHasPermissionResponse(false, "The user does not have permission!");
}

return createHasPermissionResponse(true, null);
}

private boolean checkUserStatus(User user) {
return user == null || user.getStatus() != User.UserStatus.ENABLED;
}

private String checkServiceStatus(Optional<Service> service) {
if (service.isEmpty()) {
return "The service does not exists!";
} else {
ServiceStatus status = service.get().getStatus();
if (ServiceStatus.DISABLED.equals(status)) {
return "The service is disabled!";
} else if (ServiceStatus.DOWN_FOR_MAINTENANCE.equals(status)) {
return "The service is temporarily down for maintenance!";
}
}
return null;
}

private boolean checkUserPermission(User user, HasPermissionRequest requestDTO) {
Tenant tenant = tenantRepo.findByName(requestDTO.tenantId());
List<TenantPermission> userPermissions = userRepo.getTenantPermissionsFor(user.getId()).stream()
.filter(tenantPermission ->
PermissionScope.SYSTEM.equals(tenantPermission.permissionScope()) ||
((PermissionScope.TENANT.equals(tenantPermission.permissionScope()) || PermissionScope.SUBTENANT.equals(tenantPermission.permissionScope()))
&& tenantPermission.tenantId == tenant.getId()))
.toList();

List<String> commonPermissions = userPermissions.stream()
.map(TenantPermission::permissionName)
.filter(requestDTO.permissions()::contains)
.toList();

return !commonPermissions.isEmpty();
}

private HttpResponse<HasPermissionResponse> createHasPermissionResponse(boolean hasPermission,
String message) {
return HttpResponse.ok(new HasPermissionResponse(hasPermission, message));
}

@Serdeable
public record HasPermissionResponse(
boolean hasPermission,
@Nullable String errorMessage
) {

}

@Introspected
public record TenantPermission(
long tenantId,
String permissionName,
PermissionScope permissionScope

) {

}

}
32 changes: 32 additions & 0 deletions src/main/java/io/unityfoundation/auth/BCryptPasswordEncoder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package io.unityfoundation.auth;

import io.micronaut.core.annotation.NonNull;
import jakarta.inject.Singleton;
import jakarta.validation.constraints.NotBlank;
import at.favre.lib.crypto.bcrypt.BCrypt;

@Singleton
class BCryptPasswordEncoder implements PasswordEncoder {

private final BCrypt.Verifyer pwdVerifier = BCrypt.verifyer();

public String encode(@NotBlank @NonNull String rawPassword) {
char[] passwordCharacters = rawPassword.toCharArray();
return hashPassword(passwordCharacters);
}

private String hashPassword(char[] passwordCharacters) {
return BCrypt.withDefaults().hashToString(10, passwordCharacters);
}

@Override
public boolean matches(@NotBlank @NonNull String rawPassword,
@NotBlank @NonNull String encodedPassword) {
BCrypt.Result verificationResult = verifyPassword(rawPassword, encodedPassword);
return verificationResult.verified;
}

private BCrypt.Result verifyPassword(String rawPassword, String encodedPassword) {
return pwdVerifier.verify(rawPassword.getBytes(), encodedPassword.getBytes());
}
}
13 changes: 13 additions & 0 deletions src/main/java/io/unityfoundation/auth/HasPermissionRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package io.unityfoundation.auth;

import io.micronaut.serde.annotation.Serdeable;
import jakarta.validation.constraints.NotNull;
import java.util.List;

@Serdeable
public record HasPermissionRequest(
@NotNull String tenantId,
@NotNull String serviceId,
List<String> permissions

) {}
18 changes: 18 additions & 0 deletions src/main/java/io/unityfoundation/auth/PasswordEncoder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package io.unityfoundation.auth;

import jakarta.validation.constraints.NotBlank;

public interface PasswordEncoder {

String encode(@NotBlank String rawPassword);

/**
* Checks if the provided raw password matches the encoded password.
*
* @param rawPassword The raw password that needs to be checked.
* @param encodedPassword The encoded password to compare against.
* @return {@code true} if the raw password matches the encoded password, otherwise {@code false}.
*/
boolean matches(@NotBlank String rawPassword,
@NotBlank String encodedPassword);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package io.unityfoundation.auth;

import static io.micronaut.security.authentication.AuthenticationFailureReason.CREDENTIALS_DO_NOT_MATCH;

import io.micronaut.core.annotation.Nullable;
import io.micronaut.http.HttpRequest;
import io.micronaut.security.authentication.AuthenticationException;
import io.micronaut.security.authentication.AuthenticationFailed;
import io.micronaut.security.authentication.AuthenticationProvider;
import io.micronaut.security.authentication.AuthenticationRequest;
import io.micronaut.security.authentication.AuthenticationResponse;
import io.unityfoundation.auth.entities.User;
import io.unityfoundation.auth.entities.UserRepo;
import jakarta.inject.Singleton;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

@Singleton
public class UnityAuthenticationProvider implements AuthenticationProvider<HttpRequest<?>> {

private final UserRepo userRepo;
private final PasswordEncoder passwordEncoder;

public UnityAuthenticationProvider(UserRepo userRepo,
PasswordEncoder passwordEncoder) {
this.userRepo = userRepo;
this.passwordEncoder = passwordEncoder;
}

/**
* Authenticates the user with the given authentication request.
*
* @param httpRequest The HTTP request associated with the authentication.
* @param authenticationRequest The authentication request containing user credentials.
* @return A Publisher emitting an AuthenticationResponse upon successful authentication, or throwing
* an AuthenticationException if authentication fails.
*/
@Override
public Publisher<AuthenticationResponse> authenticate(@Nullable HttpRequest<?> httpRequest,
AuthenticationRequest<?, ?> authenticationRequest) {
return Mono.fromCallable(() -> findUser(authenticationRequest))
.subscribeOn(Schedulers.boundedElastic())
.flatMap(user -> {
AuthenticationFailed authenticationFailed = validate(user, authenticationRequest);
if (authenticationFailed != null) {
return Mono.error(new AuthenticationException(authenticationFailed));
} else {
return Mono.just(AuthenticationResponse.success((String) authenticationRequest.getIdentity()));
}
}); }

private AuthenticationFailed validate(User user,
AuthenticationRequest<?, ?> authenticationRequest) {
AuthenticationFailed authenticationFailed = null;
if (user == null) {
authenticationFailed = new AuthenticationFailed(CREDENTIALS_DO_NOT_MATCH);
} else if (!passwordEncoder.matches(authenticationRequest.getSecret().toString(),
user.getPassword())) {
authenticationFailed = new AuthenticationFailed(CREDENTIALS_DO_NOT_MATCH);
}

return authenticationFailed;
}

private User findUser(AuthenticationRequest<?, ?> authRequest) {
final Object username = authRequest.getIdentity();
return userRepo.findUserForAuthentication(username.toString()).orElse(null);
}

}

21 changes: 21 additions & 0 deletions src/main/java/io/unityfoundation/auth/entities/Permission.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package io.unityfoundation.auth.entities;

import io.micronaut.data.annotation.GeneratedValue;
import io.micronaut.data.annotation.Id;
import io.micronaut.data.annotation.MappedEntity;

@MappedEntity()
public class Permission {
@Id
@GeneratedValue
private Long id;

private String name;
private String description;
private PermissionScope scope;

public enum PermissionScope {
SYSTEM, TENANT, SUBTENANT
}

}
54 changes: 54 additions & 0 deletions src/main/java/io/unityfoundation/auth/entities/Service.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package io.unityfoundation.auth.entities;

import io.micronaut.data.annotation.GeneratedValue;
import io.micronaut.data.annotation.Id;
import io.micronaut.data.annotation.MappedEntity;

@MappedEntity()
public class Service {
@Id
@GeneratedValue
private Long id;

private String name;
private String description;
private ServiceStatus status;

public enum ServiceStatus {
ENABLED, DISABLED, DOWN_FOR_MAINTENANCE
}

public String getDescription() {
return description;
}

public void setDescription(String description) {
this.description = description;
}


public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public ServiceStatus getStatus() {
return status;
}

public void setStatus(ServiceStatus status) {
this.status = status;
}

}
Loading

0 comments on commit b3ace4a

Please sign in to comment.