Auth with Neo4j, Micronaut, & JWT

While working on a hobby project to learn more about Neo4J, I needed to implement security. While Neo might not be the ideal solution for managing authentication and users (that’s another article), I didn’t want to over complicate by adding another data store. This post assumes you’re somewhat familiar with Micronaut. If not, start here.

Once you have a micronaut application, you’ll want to add the required dependencies.

<!-- enable security using jwt -->
<dependency>
    <groupId>io.micronaut.security</groupId>
    <artifactId>micronaut-security-jwt</artifactId>
    <scope>compile</scope>
</dependency>
<!-- bolt driver for neo4j -->>
<dependency>
    <groupId>io.micronaut.configuration</groupId>
    <artifactId>micronaut-neo4j-bolt</artifactId>
    <version>2.0.0</version>
</dependency>
<!-- Spring's crypto so that we can have secure passwords -->
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-crypto</artifactId>
    <version>5.7.5</version>
    <scope>compile</scope>
</dependency>

Update micronaut’s `application.yml' file to enable security:

micronaut:
  application:
    name: auth-blog-post
  security:
    authentication: bearer
    token:
      jwt:
        signatures:
          secret:
            generator:
              secret: '"${JWT_GENERATOR_SIGNATURE_SECRET:pleaseChangeThisSecretForANewOne}"'
              jws-algorithm: HS256

For the secret, you’ll want to take better care of where you keep that than in this example. Before the next Micronaut part, you’ll need to get an instance of Neo4j. I always use docker for this and run:

docker run --publish=7474:7474 --publish=7687:7687 --volume=$HOME/neo4j/data:/data neo4j

I keep a local volume so that my data isn’t wiped when I stop the container. That’s completely optional. Add the following to the application.yml for neo:

neo4j:
  uri: bolt://localhost:7687
  username: <username>
  password: <secret>
  maxConnectionPoolSize: 50
  connectionAcquisitionTimeout: 30s

Whether you’re using docker or some other neo instance, just make sure you put the correct credentials in the config. Again, not a great idea to keep those in plain text in the yaml file, but for learning purposes, it’s fine.

With security and neo configured, the next thing we need is a way to let the Login Handler know what constitutes as a valid user. For this we need to provide an AuthenticationProvider implementation. I’m going to show this code in phases to keep the explainations as simple as possible.

@Singleton
public class UsernamePasswordAuthProvider implements AuthenticationProvider {

	@Override
	public Publisher<AuthenticationResponse> authenticate(@Nullable HttpRequest<?> httpRequest, AuthenticationRequest<?, ?> authenticationRequest) {
		return Flux.create(emitter -> {

			var success = new AtomicBoolean(false);
			final var username = (String) authenticationRequest.getIdentity();
			final var password = (String) authenticationRequest.getSecret();

            // validate username/password here
            if (username.equals("myuser") && password.equals("mypassword")) {
                success.set(true);
            }

			if (success.get()) {
				emitter.next(AuthenticationResponse.success((String) authenticationRequest.getIdentity(), Collections.singleton("ROLE_USER")));
				emitter.complete();
			} else {
				emitter.error(AuthenticationResponse.exception());
			}
		}, FluxSink.OverflowStrategy.ERROR);
	}
}

With the above code, you should be able to run the application and hit the /login endpoint successfully.

### auth
POST localhost:8080/login
Content-Type: application/json

{"username": "myuser", "password": "mypassword"}

Obviously, we don’t want to hardcode our user credentials and the goal is to validate that information in neo. So, there’s a bit more work to do. We’ll need a way to register a user as well as fetch a user to verify the credentials. Let’s start out with the user object.

@Introspected
public record SecUser(String id, String username, String password) { 
    SecUser(String username, String password) {
        this(UUID.randomUUID().toString, username, password);
    }
}

A long time ago I started naming this object SecUser because user tends to be a reserved word and I also use this object only for security/auth purposes. I’ll attach some sort of UserProfile or Actor or something that will contain less secure data. You can name it whatever you want. I like to use UUID’s for external Neo ID’s. Java Records are the bomb. Next, we’ll set up a Repository for Neo access.

@Singleton
public class SecurityRepository {

    public final Driver driver; // Neo4J Bolt Driver instance

    public SecurityRepository(Driver driver) {
		this.driver = driver;
	}

    public Optional<UUID> save(String username, String password) {

		try (var session = driver.session()) {
			final var id = UUID.randomUUID();
			session.run(
                "CREATE (u:SecUser { id: $id, username: $username, password: $password })",
				parameters("id", id.toString(), "username", username, "password", password));
			return Optional.of(id);
		}
		
        return Optional.empty();
	}

    public Optional<SecUser> findByUsername(String username) {

		try (var session = driver.session()) {

			Optional<Record> maybeUser = session.run(
                "MATCH (u:SecUser {username:$username}) RETURN u.id as id, u.username as username, u.password as password",
				parameters("username", username)).stream().findFirst();

			if (maybeUser.isPresent()) {
				final var userRecord = maybeUser.get();

				return Optional.of(new SecUser(userRecord.get("id").asString(), userRecord.get("username").asString(), userRecord.get("password").asString()));
			}
		}

		return Optional.empty();
	}
}

I like to run everything through a Service layer, so let’s create that for the sake of completeness.

public interface SecurityService {
    UUID createSecUser(String username, String password);

    Optional<SecUser> findByUsername(String username);
}
@Singleton
public class V1SecurityService implements SecurityService {

    private final SecurityRepository securityRepository;
    private final BCryptPasswordEncoderService bCryptPasswordEncoderService;

    public V1SecurityService(SecurityRepository securityRepository, BCryptPasswordEncoderService bCryptPasswordEncoderService) {
		this.securityRepository = securityRepository;
		this.bCryptPasswordEncoderService = bCryptPasswordEncoderService;
	}

    @Override
	public UUID createSecUser(String username, String password) {
        final var maybeId = this.securityRepository.save(username, bCryptPasswordEncoderService.encode(password));
		if (maybeId.isPresent()) {
			return maybeId.get();
		}
        return null;
	}

	@Override
	public Optional<SecUser> findByUsername(String username) {
		return this.securityRepository.findByUsername(username);
	}
}

Aha! Password encoding. Where did that come from? Remember that we added Spring Security’s Crypto library. To use it, we’ll implement our PasswordEncoder.

@Singleton
public class BCryptPasswordEncoderService implements PasswordEncoder {

	@Override
	public String encode(CharSequence rawPassword) {
		return BCrypt.hashpw(rawPassword.toString(), BCrypt.gensalt(8));
	}

	@Override
	public boolean matches(CharSequence rawPassword, String encodedPassword) {
		return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
	}
}

The last thing before we get back to the Auth Provider is a simple Controller to utilize the createSecUser.

@Controller("/api/security")
Secured(SecurityRule.IS_ANONYMOUS)
public class SecurityController {

    private final SecurityService securityService;

    public SecurityController(SecurityService securityService) {
			this.securityService = securityService;
	}

    @Post
	HttpResponse<URI> createSecUser(String username, String password) {
        final var id = this.securityService.createSecUser(username, password);
		if (id != null) {
		    return HttpResponse.created(URI.create("/api/users/" + id));
		}
		return HttpResponse.status(HttpStatus.BAD_REQUEST, "Unable to create User");
    }
}

Note that the controller must be secured with IS_ANONYMOUS so that micronaut security will allow an unauthorized request. There are different ways to manage this and I encourage you to read through the Micronaut Security Docs.

Finally! We can now utilize our Neo stored data to validate authentication. Here is the updated UsernamePasswordAuthProvider:

@Singleton
public class UsernamePasswordAuthProvider implements AuthenticationProvider {

    private final SecurityService securityService;

    private final BCryptPasswordEncoderService bCryptPasswordEncoderService;

	public UsernamePasswordAuthProvider(SecurityService securityService, BCryptPasswordEncoderService bCryptPasswordEncoderService) {
		this.securityService = securityService;
		this.bCryptPasswordEncoderService = bCryptPasswordEncoderService;
	}

	@Override
	public Publisher<AuthenticationResponse> authenticate(@Nullable HttpRequest<?> httpRequest, AuthenticationRequest<?, ?> authenticationRequest) {
		return Flux.create(emitter -> {

			var success = new AtomicBoolean(false);
			final var username = (String) authenticationRequest.getIdentity();
			final var password = (String) authenticationRequest.getSecret();

            var maybeUser = securityService.findByUsername((String) username);

			maybeUser.ifPresentOrElse(u -> {
				if (bCryptPasswordEncoderService.matches(password.toString(), u.password()))
					success.set(true);
			}, () -> success.set(false));

			if (success.get()) {
				emitter.next(AuthenticationResponse.success((String) authenticationRequest.getIdentity()));
				emitter.complete();
			} else {
				emitter.error(AuthenticationResponse.exception());
			}
		}, FluxSink.OverflowStrategy.ERROR);
	}
}

With that, you should be able to create users and autheneticate with them, getting back a bearer token. In a future post, I will update our code to provide roles and manage a refresh token. Until then, have fun with this and let me know if you notice any issues (lack of secure passwords in property files aside).

comments powered by Disqus