aslary
aslary

Reputation: 421

Inject current user (loaded from DB) on a per endpoint basis

In our application we are using Quarkus and the SmallRye JWT extension (see here) for role based AuthN and AuthZ.

We have defined an annotation CurrentUser:

@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class CurrentUser

The annotation has a concrete implementation in the UserProvider:

import ---.User
import ---.UserRepository
import jakarta.enterprise.context.RequestScoped
import jakarta.enterprise.inject.Produces
import org.eclipse.microprofile.jwt.Claim

@RequestScoped
class UserProvider(
    private val userRepository: UserRepository,
    @Claim("upn") private val currentUserEmail: String?
) {

    @Produces
    @CurrentUser
    fun currentUser(): User? {
        return if (currentUserEmail != null) {
            userRepository.findByEmail(currentUserEmail)
        } else {
            null
        }
    }
}

As you can quite easily see, the UserProvider loads a User from the database based on the upn claim found in a potential JWT.

Use Case 1: This is working

In the following scenario, the annotation works well:

@Path("auditions")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@PermitAll
class AuditionResource(
    @CurrentUser private val currentUser: User?
) {
// Endpoints
}

As you can see, when we inject the current user in every request, then the @CurrentUser annotation is working as intended.

Use Case 2: NOT working

However, this might become a performance penalty as not every endpoint always requires the current user loaded from the db. Therefore, we wanted to EXTEND the use of @CurrentUser to support a more granular basis, e.g. as follows:

@Path("auditions")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@PermitAll
class AuditionResource {
    @PATCH
    fun foo(
        @CurrentUser currentUser: User?
    ) {
        println("HI")
    }
}

Unfortunately, in this case, the currentUser() method of UserProvider does NOT get called.

Question

How can we achieve support for both use case 1 and use case 2?

Upvotes: 0

Views: 37

Answers (1)

Serkan
Serkan

Reputation: 1235

I've a similar usecase and I've achieved it as follows.

  • First make sure you have defined a @UserDefinition entity, see also this link

  • Then build a "hook" into Quarkus' JpaIdentityStore, this will be called from Quarkus' HttpAuthenticationMechanism, for more info check this link. Here you will basically tell Quarkus how to retrieve the user:

@Singleton
@Priority(1)
public class UserIdentityProvider extends JpaIdentityProvider {

    @Override
    public SecurityIdentity authenticate(EntityManager em, UsernamePasswordAuthenticationRequest request) {
        try {
            var user = em.createQuery("from UserEntity u where u.username = ?1", UserEntity.class)
                    .setParameter(1, request.getUsername())
                    .getSingleResult();
            
            if (!UserEntity.passwordMatches(request.getPassword().getPassword(), user.password, user.salt)) {
                throw new AuthenticationFailedException("Username and/or password incorrect: %s".formatted(request.getUsername()));
            }

            logUser(em, user.username);
            return QuarkusSecurityIdentity.builder()
                    .setPrincipal(new QuarkusPrincipal(user.username))
                    .addRoles(user.roles)
                    .addAttribute("email", user.email)
                    .build();
        } catch (Exception e) {
            Log.errorf("Unknown user login attempt: '%s'", request.getUsername());
            throw new AuthenticationFailedException("Unknown User");
        }
    }

    private static void logUser(EntityManager em, String username) {
        em.getTransaction().begin();
        UserEntity.update("lastLoggedIn = ?1 where username = ?2", OffsetDateTime.now(), username);
        em.getTransaction().commit();
        Log.infof("User %s******* logged in", username.substring(0, 3));
    }
}
  • And I also assume that you have something like this for JWT authentication:
@Path("/auth")
public class AuthenticationController {

    @Inject
    SecurityIdentity identity;

 
    @POST
    @Path("/token")
    @Authenticated
    public Response accessToken() {
        var username = this.identity.getPrincipal().getName();
        var email = this.identity.getAttribute("email").toString();
        var roles = this.identity.getRoles();

        return Response
                .ok(createUserInfo(username, email, roles))
                .cookie(cookie("access_token", generateAccessToken()))
                .build();
    }

    // will use exp.time, issuer & audience from application.properties automatically (if set)
    private String generateAccessToken() {
        return Jwt.upn(this.identity.getPrincipal().getName())
                .subject(this.identity.getPrincipal().getName())
                .groups(this.identity.getRoles())
                .sign();
    }

    private NewCookie cookie(String name, String value) {
        return new NewCookie.Builder(name)
                .secure(true)
                .sameSite(STRICT)
                .httpOnly(true)
                .path("/")
                .value(value)
                .build();
    }
   
    private Map<String, Object> createUserInfo(String username, String email, Set<String> roles) {
        return Map.of(
                "username", username,
                "roles", roles,
                "email", email);
    }
}

So this is the flow when a http basic auth. request comes in:

http basic auth request to POST: /auth/token -> HttpAuthenticationMechanism intercepts -> forwards to UserIdentityProvider -> retrieves user from db -> creates a SecurityIdentity -> forwards from the security chain to AuhtenticationController.

And if your endpoints contain @PermitAll, then the db won't hit.

Hope this helps you.

Upvotes: 0

Related Questions