Sunday, April 11, 2021

Still using Java: Authentication Service (Part 1)

Today we will be building an Authentication Service. Starts with a simple REST service. 


v1

Initial Design


The service allows us to:
- register user
- login

Requirement
Remember to set JAVA_HOME to the GraalVM location.

My environment
$ mvn -v
Apache Maven 3.6.3 (cecedd343002696d0abb50b32b541b8a6ba2883f)
Maven home: C:\development\apache-maven-3.6.3
Java version: 11.0.10, vendor: GraalVM Community, runtime: C:\Program Files\GraalVM\graalvm-ce-java11-21.0.0.2
Default locale: en_AU, platform encoding: Cp1252
OS name: "windows 10", version: "10.0", arch: "amd64", family: "windows"

Instructions

1. scaffold a new project
mvn io.quarkus:quarkus-maven-plugin:1.13.1.Final:create \
    -DprojectGroupId=blog.technodeck \
    -DprojectArtifactId=auth-service \
    -Dverison=0.0.1-SNAPSHOT \
    -Dextensions="resteasy-jackson,jdbc-h2,hibernate-orm-panache"

2. Configure application.properties
# configure your datasource
quarkus.datasource.db-kind = h2
quarkus.datasource.username = sa
quarkus.datasource.password = sa
quarkus.datasource.jdbc.url = jdbc:h2:mem:authdatabase
# drop and create the database at startup (use `update` to only update the schema)
quarkus.hibernate-orm.database.generation = drop-and-create

3. Create User entity.
package blog.technodeck.auth.entity;

import javax.persistence.Entity;

import io.quarkus.hibernate.orm.panache.PanacheEntity;

@Entity
public class User extends PanacheEntity {
    public String username;
    public String password;
}

4. Create the AuthResource class with a POST endpoint.
package blog.technodeck.auth.resource;

import javax.transaction.Transactional;
import javax.ws.rs.Consumes;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import blog.technodeck.auth.entity.User;

@Path("/auth")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class AuthResource {

    Logger logger = LoggerFactory.getLogger(getClass());
    
    @Transactional
    @POST
    @Path("/register")
    public User register(User user) {
        logger.info("Registering user: {}", user.username);
        user.persist();
        return user;
    }
    
}

5. Start the application
mvn compile quarkus:dev
You should see some output like:
2021-04-11 00:57:06,428 INFO  [io.quarkus] (Quarkus Main Thread) auth-service stopped in 0.011s
__  ____  __  _____   ___  __ ____  ______
 --/ __ \/ / / / _ | / _ \/ //_/ / / / __/
 -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/
2021-04-11 00:57:07,720 INFO  [io.quarkus] (Quarkus Main Thread) auth-service 0.0.1-SNAPSHOT on JVM (powered by Quarkus 1.13.1.Final) started in 1.287s. Listening on: http://localhost:8080
2021-04-11 00:57:07,723 INFO  [io.quarkus] (Quarkus Main Thread) Profile dev activated. Live Coding activated.
2021-04-11 00:57:07,724 INFO  [io.quarkus] (Quarkus Main Thread) Installed features: [agroal, cdi, hibernate-orm, hibernate-orm-panache, jdbc-h2, mutiny, narayana-jta, resteasy, resteasy-jackson, smallrye-context-propagation]
2021-04-11 00:57:07,724 INFO  [io.qua.dep.dev.RuntimeUpdatesProcessor] (vert.x-worker-thread-15) Hot replace total time: 1.495s

6. Time to get serious, call a Postman
Hit the POST request and you will get a created User returned with an ID
  

7. The basic functionality is working. Now we need to change the config a little bit to continue.

We now want to keep the data after reboot the application, so we put the DB file in the home folder.
# quarkus.datasource.jdbc.url = jdbc:h2:mem:authdatabase
quarkus.datasource.jdbc.url = jdbc:h2:~/auth-database
And only update the schema if needed.
#quarkus.hibernate-orm.database.generation = drop-and-create
quarkus.hibernate-orm.database.generation = update

8. Add "/list" and "/login" endpoints.

    @POST
    @Path("/login")
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
    public User login(
            @FormParam("username") String username, 
            @FormParam("password") String password) {
        logger.info("{} : {}", username, password);
        return User.find("username = ?1 and password = ?2", username, password)
                .singleResult();
    }
    
    @GET
    @Path("/list")
    public List<User> list() {
        return User.findAll().list();
    }
    
9. Now do:
  - Register POST (sane as step 6)
  - List GET


























  - Login POST






10. Add AccessToken
Create AccessToken entity class.
package blog.technodeck.auth.entity;

import java.time.ZonedDateTime;

import io.quarkus.hibernate.orm.panache.PanacheEntity;

public class AccessToken extends PanacheEntity {

    public Long userId;
    public String token;
    public ZonedDateTime expires;
    
}

Update "/login" endpoint to be @Transactional and return AccessToken.
    @Transactional
    @POST
    @Path("/login")
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
    public AccessToken login(
            @FormParam("username") String username, 
            @FormParam("password") String password) {
        logger.info("{} : {}", username, password);
        User user = User.find("username = ?1 and password = ?2", username, password)
                .singleResult();
        AccessToken accessToken = new AccessToken();
        accessToken.userId = user.id;
        accessToken.token = UUID.randomUUID().toString();
        accessToken.expires = ZonedDateTime.now().plusMinutes(30);
        accessToken.persist();
        return accessToken;
    }
    
11. Do login again and now you will get a token JSON returned.


12. Try login with an incorrect username or password. You will get an default error page.


13. Wrapping our code with a try-catch clause and returns a friendly message.
        try {
            User user = User.find("username = ?1 and password = ?2", username, password)
                    .singleResult();
            AccessToken accessToken = new AccessToken();
            accessToken.userId = user.id;
            accessToken.token = UUID.randomUUID().toString();
            accessToken.expires = ZonedDateTime.now().plusMinutes(30);
            accessToken.persist();
            return accessToken;
        } catch(Exception e) {
            Response response = Response.status(Response.Status.NOT_FOUND)
                                        .entity(e.getMessage())
                                        .build();
            throw new NotFoundException(response);
        }


14. Finally we add a "/verify" endpoint for future use.

    @GET
    @Path("/verify/{id}/{token}")
    public void verify(@PathParam("id") Long id, @PathParam("token") String token) {
        AccessToken accessToken = 
                        AccessToken.find(
                            "userId = ?1 and token = ?2", Sort.by("id").descending(), id, token
                        ).firstResult();
        if(accessToken == null) {
            logger.info("Invalid token.");
            throw new ForbiddenException("Invalid token");
        }
        if(accessToken.expires.isBefore(ZonedDateTime.now())) {
            logger.info("Token expired.");
            throw new ForbiddenException("Token Expired");
        }
    }
A valid token will return HTTP 204 empty content.



The invalid token will get an HTTP 403 error.


Conclusion

I have demonstrated how to use quarkus to build a simple authentication service. Using quarkus, the code is so minimal and clean. It is just amazing. 


References:









No comments: