Let's have a look at the following steps:
- Let's create the authentication service. Create a new Java project with the following build.gradle file:
group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath group: 'org.springframework.boot', name: 'spring-boot-gradle-plugin', version: '1.5.9.RELEASE'
}
}
apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
sourceCompatibility = 1.8
repositories {
mavenCentral()
}
dependencies {
compile group: 'org.springframework.boot', name: 'spring-boot-starter-web'
compile group: 'org.springframework.security', name: 'spring-security-core'
compile group: 'org.springframework.security', name: 'spring-security-config'
compile group: 'org.springframework.boot', name: 'spring-boot-starter-data-jpa'
compile group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'
compile group: 'mysql', name: 'mysql-connector-java'
testCompile group: 'junit', name: 'junit', version: '4.12'
}
We'll be storing user credentials in a MySQL database, so we declare mysql-connector-java as a dependency. We'll also use an open source JWT library called jjwt.
- Create a new class called Application. It will contain our main method as well as PasswordEncoder:
package com.packtpub.microservices.ch06.auth;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@SpringBootApplication
public class Application {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
- We'll model the user credentials as a simple POJO with email and password fields. Create a new package called com.packtpub.microservices.ch06.auth.models and a new class called UserCredential:
package com.packtpub.microservices.ch06.auth.models;
import org.hibernate.annotations.GenericGenerator;
import javax.persistence.*;
@Entity
public class UserCredential {
@Id
@GeneratedValue(generator = "uuid")
@GenericGenerator(name = "uuid", strategy = "uuid2")
private String id;
@Column(unique=true)
private String email;
private String password;
public UserCredential(String email) {
this.email = email;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
- Create a model to represent the response to successful login and registration requests. Successful responses will contain a JSON document containing a JWT. Create a new class called AuthenticationToken:
package com.packtpub.microservices.ch06.auth.models;
import com.fasterxml.jackson.annotation.JsonProperty;
public class AuthenticationToken {
@JsonProperty("auth_token")
private String authToken;
public AuthenticationToken() {}
public AuthenticationToken(String authToken) {
this.authToken = authToken;
}
public String getAuthToken() {
return this.authToken;
}
public void setAuthToken(String authToken) {
this.authToken = authToken;
}
}
- The UserCredential class will be accessed using the Java Persistence API. To do this, we have to first create CrudRepository. Create a new package called com.packtpub.microservices.ch06.auth.data and a new class called UserCredentialRepository. In addition to inheriting from CrudRepository, we'll define a single method used to retrieve a UserCredential instance by email:
package com.packtpub.microservices.ch06.auth.data;
import com.packtpub.microservices.ch06.auth.models.UserCredential;
import org.springframework.data.repository.CrudRepository;
public interface UserCredentialRepository extends CrudRepository<UserCredential, String> {
UserCredential findByEmail(String email);
}
- When a user attempts to register or log in with invalid credentials, we want to return an HTTP 401 status code as well as a message indicating that they provided invalid credentials. In order to do this, we'll create a single exception that will be thrown in our controller methods:
package com.packtpub.microservices.ch06.auth.exceptions;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public class InvalidCredentialsException extends Exception {
public InvalidCredentialsException(String message) { super(message); }
}
- Create the controller. The login and registration endpoints will be served from a single controller. The registration method will simply validate input and create a new UserCredential instance, persisting it using the CrudRepository package we created earlier. It will then encode a JWT with the user ID of the newly registered user as the subject. The login method will verify the provided credentials and provide a JWT with the user ID as its subject. The controller will need access to UserCredentialRepository and PasswordEncoder defined in the main class. Create a new package called com.packtpub.microservices.ch06.auth.controllers and a new class called UserCredentialController:
package com.packtpub.microservices.ch06.auth.controllers;
import com.packtpub.microservices.ch06.auth.data.UserCredentialRepository;
import com.packtpub.microservices.ch06.auth.exceptions.InvalidCredentialsException;
import com.packtpub.microservices.ch06.auth.models.AuthenticationToken;
import com.packtpub.microservices.ch06.auth.models.UserCredential;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;
import java.security.Key;
@RestController
public class UserCredentialController {
@Autowired
private UserCredentialRepository userCredentialRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@Value("${secretKey}")
private String keyString;
private String encodeJwt(String userId) {
System.out.println("SIGNING KEY: " + keyString);
Key key = new SecretKeySpec(
DatatypeConverter.parseBase64Binary(keyString),
SignatureAlgorithm.HS256.getJcaName());
JwtBuilder builder = Jwts.builder().setId(userId)
.setSubject(userId)
.setIssuer("authentication-service")
.signWith(SignatureAlgorithm.HS256, key);
return builder.compact();
}
@RequestMapping(path = "/register", method = RequestMethod.POST, produces = "application/json")
public AuthenticationToken register(@RequestParam String email, @RequestParam String password, @RequestParam String passwordConfirmation) throws InvalidCredentialsException {
if (!password.equals(passwordConfirmation)) {
throw new InvalidCredentialsException("Password and confirmation do not match");
}
UserCredential cred = new UserCredential(email);
cred.setPassword(passwordEncoder.encode(password));
userCredentialRepository.save(cred);
String jws = encodeJwt(cred.getId());
return new AuthenticationToken(jws);
}
@RequestMapping(path = "/login", method = RequestMethod.POST, produces = "application/json")
public AuthenticationToken login(@RequestParam String email, @RequestParam String password) throws InvalidCredentialsException {
UserCredential user = userCredentialRepository.findByEmail(email);
if (user == null || !passwordEncoder.matches(password, user.getPassword())) {
throw new InvalidCredentialsException("Username or password invalid");
}
String jws = encodeJwt(user.getId());
return new AuthenticationToken(jws);
}
}
- Because we are connecting to a local database, and because we use a shared secret when signing JWTs, we need to create a small properties file. Create a file called application.yml in the src/main/resources directory:
server:
port: 8081
spring:
jpa.hibernate.ddl-auto: create
datasource.url: jdbc:mysql://localhost:3306/user_credentials
datasource.username: root
datasource.password:
secretKey: supers3cr3t
Now that we have a functioning authentication service, the next step is to create a simple API Gateway using the open source gateway service, Zuul. In addition to routing requests to downstream services, the API Gateway will also use an authentication filter to verify that valid JWTs are passed in headers for requests that require authentication.
- Create a new Java project with the following build.gradle file:
group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath group: 'org.springframework.boot', name: 'spring-boot-gradle-plugin', version: '1.5.9.RELEASE'
}
}
apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
sourceCompatibility = 1.8
targetCompatibility = 1.8
repositories {
mavenCentral()
}
dependencyManagement {
imports {
mavenBom 'org.springframework.cloud:spring-cloud-netflix:1.4.4.RELEASE'
}
}
dependencies {
compile group: 'org.springframework.boot', name: 'spring-boot-starter-web'
compile group: 'org.springframework.cloud', name: 'spring-cloud-starter-zuul'
compile group: 'org.springframework.security', name: 'spring-security-core'
compile group: 'org.springframework.security', name: 'spring-security-config'
compile group: 'org.springframework.security', name: 'spring-security-web'
compile group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'
testCompile group: 'junit', name: 'junit', version: '4.12'
}
Note that we're using the same JWT library as the Authentication service.
- Create a new package called com.packtpub.microservices.ch06.gateway and a new class called Application:
package com.packtpub.microservices.ch06.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;
@EnableZuulProxy
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
- We'll create an authentication filter by creating a subclass of OncePerRequestFilter, which aims to provide a single execution per request dispatch. The filter will parse the JWT out of the Authorization header and try to decode it using a shared secret. If the JWT can be verified and decoded, we can be sure that it was encoded by an issuer that had access to the shared secret. We'll treat this as our trust boundary; anyone with access to the shared secret can be trusted, and therefore we can trust that the subject of the JWT is the ID of the authenticated user. Create a new class called AuthenticationFilter:
package com.packtpub.microservices.ch06.gateway;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.xml.bind.DatatypeConverter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Optional;
public class AuthenticationFilter extends OncePerRequestFilter {
private String signingSecret;
AuthenticationFilter(String signingSecret) {
this.signingSecret = signingSecret;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
Optional<String> token = Optional.ofNullable(request.getHeader("Authorization"));
Optional<Authentication> auth = token.filter(t -> t.startsWith("Bearer")).flatMap(this::authentication);
auth.ifPresent(a -> SecurityContextHolder.getContext().setAuthentication(a));
filterChain.doFilter(request, response);
}
private Optional<Authentication> authentication(String t) {
System.out.println(signingSecret);
String actualToken = t.substring("Bearer ".length());
try {
Claims claims = Jwts.parser()
.setSigningKey(DatatypeConverter.parseBase64Binary(signingSecret))
.parseClaimsJws(actualToken).getBody();
Optional<String> userId = Optional.ofNullable(claims.getSubject()).map(Object::toString);
return userId.map(u -> new UsernamePasswordAuthenticationToken(u, null, new ArrayList<SimpleGrantedAuthority>()));
} catch (Exception e) {
return Optional.empty();
}
}
}
- Wire this together with a security configuration for the API Gateway project. Create a new class called SecurityConfig:
package com.packtpub.microservices.ch06.gateway;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.servlet.http.HttpServletResponse;
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Value("${jwt.secret}")
private String signingSecret;
@Override
protected void configure(HttpSecurity security) throws Exception {
security
.csrf().disable()
.logout().disable()
.formLogin().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.anonymous()
.and()
.exceptionHandling().authenticationEntryPoint(
(req, rsp, e) -> rsp.sendError(HttpServletResponse.SC_UNAUTHORIZED))
.and()
.addFilterAfter(new AuthenticationFilter(signingSecret),
UsernamePasswordAuthenticationFilter.class)
.authorizeRequests()
.antMatchers("/auth/**").permitAll()
.antMatchers("/messages/**").authenticated()
.antMatchers("/users/**").authenticated();
}
}
As we can see, we're permitting any requests to the authentication service (requests prefixed with /auth/...). We require that requests to the users or messages service be authenticated.
- We need a configuration file to store the shared secret as well as the routing information for the Zuul server. Create a file called application.yml in the src/main/resources directory:
server:
port: 8080
jwt:
secret: supers3cr3t
zuul:
routes:
authentication-service:
path: /auth/**
url: http://127.0.0.1:8081
message-service:
path: /messages/**
url: http://127.0.0.1:8082
user-service:
path: /users/**
url: http://127.0.0.1:8083
- Now that we have a working authentication service and an API Gateway capable of verifying JWTs, we can test our authentication scheme by running the API Gateway, authentication service, and message service using the ports defined in the preceding configuration file. The following CURL requests now show that valid credentials can be exchanged for a JWT and the JWT can be used to access protected resources. We can also show that requests to protected resources are rejected without a valid JWT.
- We can use curl to test registering a new user account:
$ curl -X POST -D - http://localhost:8080/auth/register -d'email=p@eval.ca&password=foobar123&passwordConfirmation=foobar123'
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
X-Application-Context: application:8080
Date: Mon, 16 Jul 2018 03:27:17 GMT
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
{"auth_token":"eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJmYWQzMGZiMi03MzhmLTRiM2QtYTIyZC0zZGNmN2NmNGQ1NGIiLCJzdWIiOiJmYWQzMGZiMi03MzhmLTRiM2QtYTIyZC0zZGNmN2NmNGQ1NGIiLCJpc3MiOiJhdXRoZW50aWNhdGlvbi1zZXJ2aWNlIn0.TzOKItjBU-AtRMqIB_D1n-qv6IO_zCBIK8ksGzsTC90"}
- Now that we have a JWT, we can include it in the headers of requests to the message service to test that the API Gateway is able to verify and decode the token:
$ curl -D - -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI3YmU4N2U3Mi03ZjhhLTQ3ZjktODk3NS1mYzM5ZTE0NjNmODAiLCJzdWIiOiI3YmU4N2U3Mi03ZjhhLTQ3ZjktODk3NS1mYzM5ZTE0NjNmODAiLCJpc3MiOiJhdXRoZW50aWNhdGlvbi1zZXJ2aWNlIn0.fpFbHhdSEVKk95m5Q7iNjkKyM-eHkCGGKchTTKgbGWw" http://localhost:8080/messages/123
HTTP/1.1 404
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
X-Application-Context: application:8080
Date: Mon, 16 Jul 2018 04:05:40 GMT
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
{"timestamp":1532318740403,"status":404,"error":"Not Found","exception":"com.packtpub.microservices.ch06.message.exceptions.MessageNotFoundException","message":"Message 123 could not be found","path":"/123"}
The fact that we get a 404 from the message service shows that the request is getting to that service. If we modify the JWT in the request headers, we should get a 401:
$ curl -D - -H "Authorization: Bearer not-the-right-jwt" http://localhost:8080/messages/123
HTTP/1.1 401
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Mon, 23 Jul 2018 04:06:47 GMT
{"timestamp":1532318807874,"status":401,"error":"Unauthorized","message":"No message available","path":"/messages/123"}