/jooby-api-template

Just a working Jooby backend API example project in Java

Primary LanguageJavaApache License 2.0Apache-2.0

Why Jooby?

  • Fast, light, easy to learn
  • I've been using Spring for almost my wokring time with Java/Kotlin. For large scale enterprise projects, Spring Stacks are undoubtedly the way to go, but for minimal projects, Jooby is a good choice.

Looking for Kotlin version

What's included?

  • Support Default JWT
  • Support Role Access Layer
  • Hibernate, Flyway support by default
  • Add custom JPAQueryExecutor for the better querying
  • Add Jedis support instead of Lettuce
  • Using MapStruct for Object Mapper
  • Using Guice as Dependency Injection Framework
  • Multiple language support
  • Default admin user: admin@localhost/admin

Default API

  1. Create users
curl --location 'http://localhost:8080/api/auth/register' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": "test",
    "email": "test@localhost",
    "password": "test"
}'
  1. Generate token
curl --location 'http://localhost:8080/api/auth/generate-token' \
--header 'Content-Type: application/json' \
--data-raw '{
    "username": "test@localhost",
    "password": "test"
}'
  1. Get User Info
# With JPA Query
curl --location 'http://localhost:8080/api/secure/user/info' \
--header 'Accept-Language: vi'
--header 'Authorization: ••••••'
# With JPAQueryExecutor
curl --location 'http://localhost:8080/api/secure/user/info-with-executor' \
--header 'Accept-Language: vi'
--header 'Authorization: ••••••'
  1. Test Role
curl --location 'http://localhost:8080/api/secure/test/admin-role' \
--header 'Authorization: ••••••'
  1. Logout
curl --location --request DELETE 'http://localhost:8080/api/auth/secure/logout' \
--header 'Authorization: ••••••'

HOW

Implement JWT

  • Using Pac4j Module as Security Layer
        install(new Pac4jModule().client(
                        "/api/secure/*",
                        conf -> new HeaderClient(
                                "Authorization",
                                "Bearer ",
                                new AdvancedJwtAuthenticator(
                                        require(JedisPooled.class),
                                        new SecretSignatureConfiguration(conf.getString("jwt.salt")
                                        )
                                )
                        )
                )
        );
  1. Using HeaderClient to tell Jooby read Bearer token from header
  2. By default Jooby use the JwtAuthenticator from Pac4j, the problems are:
    • Token is completed stateless
    • What if user is lock/inactivated/deleted -> token may still valid by the exp -> user still can access to system
    • There is no truly logout

So, I solved these problems by store jid of JWT in Redis, after validate raw token, before createProfile I made a simple check to ensure the jid exists in redis. If no, token is invalid

See AdvancedJwtAuthenticator.kt

public class AdvancedJwtAuthenticator extends JwtAuthenticator {
    private final JedisPooled redis;

    public AdvancedJwtAuthenticator(JedisPooled redis, SignatureConfiguration signatureConfiguration) {
        super(signatureConfiguration);
        this.redis = redis;
    }

    @Override
    protected void createJwtProfile(TokenCredentials credentials, JWT jwt, WebContext context, SessionStore sessionStore) throws ParseException {
        var jwtId = jwt.getJWTClaimsSet().getJWTID();
        var uid = jwt.getJWTClaimsSet().getClaims().get(Jwt.Attribute.UID).toString();
        if (!redis.exists(RedisNameSpace.getUserTokenExpirationKey(uid, jwtId))) {
            throw new AuthorizationException();
        }
        super.createJwtProfile(credentials, jwt, context, sessionStore);
    }
}
  1. For now, when you want logout, just delete the related jid in redis.
@AllArgsConstructor(access = AccessLevel.PUBLIC, onConstructor = @__({@Inject}))
public class AccessVerifierImpl implements AccessVerifier {
    private Context context;

    @Override
    public boolean hasAnyRoles(String... roles) {
        var rolesAsList = Arrays.asList(roles);
        return Optional.ofNullable(context.getUser())
                .map(UserProfile.class::cast)
                .map(UserProfile::getRoles)
                .stream()
                .flatMap(Set::stream)
                .anyMatch(rolesAsList::contains);
    }

    @Override
    public void requireAnyRoles(String... roles) {
        if (!hasAnyRoles(roles)) {
            throw new ForbiddenException();
        }
    }
}
  • hasRole or hasAnyRoles will check and return true/false, while requireRole and requireAnyRoles will explicitly throw exception if you do not have access.
  • Problem: Sometimes we want to retrieve data from database via native query, but we do not want manually map field's value from result to pojo class.
  • To solve this problem we have so many ways. With the usage of Hibernate, I create JPA Query Executor to parse sql result to object via Jackson
public Optional<UserDto> findCustomActivatedUserByPreferredUsername(Long preferredUsername) {
    var query =
            entityManager.createNativeQuery("select * from users where preferred_username = :preferredUsername and status = :status");
    try {
        var result = JpaQueryExecutor
                .builder(UserDto.class)
                .with(query, Map.of(
                                "preferredUsername", preferredUsername,
                                "status", Status.Code.ACTIVATED
                        )
                )
                .getSingleResult();
        return Optional.of(result);
    } catch (Exception e) {
        log.warn("Could not find the users with given preferredUsername", e);
        return Optional.empty();
    }
}

So, we directly map database field's value to Pojo object via Jackson, if Pojo class does not have correct field name, please using @JsonAlias (Like database field full_name, pojo class fullName)