Spring REST API Quick Start

From bibbleWiki
Jump to navigation Jump to search

Introduction

This is meant to just be a quickstart for me. I use many technologies so this reminds me how to get going.

Setup Spring

Go to start.spring.io and select JPA, Rest Repositories and H2 Database.
Spring RESTAPI.png

Import the resulting project as a maven project

Create Classes

Beer Class (Entity)

 
@Entity
public class Beer {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;
	public Long getId() {
		return id;
	}
	public void setId(Long id) {
		this.id = id;
	}
	public String getName() {
		return name;
	}
	public void setName(String name) {
		this.name = name;
	}
	public Double getApv() {
		return apv;
	}
	public void setApv(Double apv) {
		this.apv = apv;
	}
	private String name;
	private Double apv;
}

Beer Repository

 
package nz.co.bibble.restapi11;

import org.springframework.data.repository.CrudRepository;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;

@RepositoryRestResource
public interface RestRepository extends CrudRepository<Beer,Long> {

}

Resources

Set the application properties for H2

 
spring.datasource.url=jdbc:h2:mem:beers

Create the Data.sql in the resources directory

 
INSERT INTO beer(name, apv) VALUES('Jai Alai', 7.5);
INSERT INTO beer(name, apv) VALUES('Stella Artois', 5.0);
INSERT INTO beer(name, apv) VALUES('Lagunitas', 6.2);
COMMIT;

Run

Goto http://localhost:8080/beers

Add Spring Security

Add Dependencies

We can add security to a new project with

        <dependencies>
...
                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-security</artifactId>
                </dependency>

                <dependency>
                        <groupId>org.springframework.security</groupId>
                        <artifactId>spring-security-test</artifactId>
                        <scope>test</scope>
                </dependency>

        </dependencies>

Add Web Security to App

@SpringBootApplication
public class Restapi11Application extends SpringBootServletInitializer {
    
	public static void main(String[] args) {
		SpringApplication.run(Restapi11Application.class, args);
	}
}

Amend WebSecurityConfigurerAdapter

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity (
   prePostEnabled = true,
   securedEnabled = true,
   jsr250Enabled = true
)
public class Restapi11SecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(final AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("iwiseman").password(passwordEncoder().encode("pass")).roles("USER");
    }
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

Add Oauth2 Security Jwt

Setup Application

I set up a Keycloak Server to do this. Assuming this all works these are the changes to make my /beer work

Application Properties

Starting to see why yaml is nice given the alternative.

# H2 definition
spring.datasource.url=jdbc:h2:mem:beers

# Server Port
server.port=8081

# Path
server.servlet.context-path=/resource-server-jwt

# Keycloak Server with Realm
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://192.168.1.70:9999/auth/realms/bibble

# Debug
logging.level.root=debug
logging.level.org.springframework.security=DEBUG

Add Dependencies

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

Define location of Keycloak Server

Add some debug for good measure

# Keycloak Server with Realm
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://192.168.1.70:9999/auth/realms/bibble

# Debug
logging.level.root=debug
logging.level.org.springframework.security=DEBUG

Setup MVC

This section sets up the data to be used with the keycloak server. The important part is the owner. We read the user_id from the Jwt to provide filtering on owner

Create Entity

@Entity
public class TestItem {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    public Long getId() {
        return id;
    }
	
    public void setId(Long id) {
 	this.id = id;
    }

    @Column
    private String description;

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }
	
    @Column
    private UUID owner;

    public UUID getOwner() {
        return owner;
    }

    public void setOwner(UUID owner) {
        this.owner = owner;
    }
}

Create Repository

@RepositoryRestResource
public interface TestItemsRepository extends CrudRepository<TestItem,Long> {

    List<TestItem> findByOwner(UUID owner);
}

Create Initializer

@Component
public class TestItemsInitializer implements SmartInitializingSingleton {

	private final TestItemsRepository testItems;

	public TestItemsInitializer(TestItemsRepository testItems) {
		this.testItems = testItems;
	}

	@Override
	public void afterSingletonsInstantiated() {

		UUID iwiseman = UUID.fromString("cae12e7d-9c1c-4c26-a146-15b9f97f2cca");
		UUID bwiseman = UUID.fromString("e5991839-9e2f-41c9-b188-02cc112eedf3");

		this.testItems.save(create("Iain Read War and Peace", iwiseman));
		this.testItems.save(create("Iain Free Solo the Eiffel Tower", iwiseman));
		this.testItems.save(create("Iain Hang Christmas Lights", iwiseman));

		this.testItems.save(create("Run for President", bwiseman));
		this.testItems.save(create("Run a Marathon", bwiseman));
		this.testItems.save(create("Run an Errand", bwiseman));
	}

	public TestItem create(String description, UUID owner) {
		TestItem testItem = new TestItem();
		testItem.setDescription(description);
		testItem.setOwner(owner);
		return testItem;
	}

}

Create Controller

@RestController
public class TestItemController {

	private final TestItemsRepository testItems;

	public TestItemController(TestItemsRepository testItems) {
		this.testItems = testItems;
	}

	@GetMapping("/testItems")
	public List<TestItem> read(@AuthenticationPrincipal Jwt jwt) {
		UUID owner = jwt.getClaim("user_id");
		return this.testItems.findByOwner(owner);
	}

}

Configure Spring Security WebSecurityConfigurerAdapter

http configure

Restrict the requests to be authenticated and add the oauth2ResourceServer where we define the jwtAuthenticationConverter

@Configuration
@EnableWebSecurity(debug = true)
public class Restapi11SecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {

    private static final Logger logger = LoggerFactory.getLogger(FooController.class);

	@Override
	protected void configure(HttpSecurity http) throws Exception {
        http
        .authorizeRequests(a -> a
                .anyRequest().authenticated())
        .oauth2ResourceServer(o -> o
                .jwt(j -> j.jwtAuthenticationConverter(jwtAuthenticationConverter()))
        );
	}
...

Auto Wire the JwtDecoder

Define the custom ClaimSetConverter and JwtValidator to use

    @Autowired
    void jwtDecoder(JwtDecoder jwtDecoder) {
            ((NimbusJwtDecoder) jwtDecoder).setClaimSetConverter(claimSetConverter());
            ((NimbusJwtDecoder) jwtDecoder).setJwtValidator(jwtValidator());
    }

Jwt Validator

For this example we are just looking at the Audience

    private OAuth2TokenValidator<Jwt> jwtValidator() {
        OAuth2TokenValidator<Jwt> audience = jwt ->
                        Optional.ofNullable(jwt.getAudience())
                                .filter(aud -> aud.contains("account"))
                                .map(aud -> success())
                                .orElse(failure(new OAuth2Error(INVALID_TOKEN, "Bad audience", "url")));
        OAuth2TokenValidator<Jwt> defaults = JwtValidators.createDefault();
        return new DelegatingOAuth2TokenValidator<>(defaults, audience);
}

Claim Set Converter

For this in the Keycloak server I modified the client and the user. For Client I added an attribute.

      "id": "cf2a3f05-d29c-44bb-b08d-96a1b1c7b178",
      "name": "user_id",
      "protocol": "openid-connect",
      "attributes": {
        "include.in.token.scope": "true",
        "display.on.consent.screen": "true"
      },
      "protocolMappers": [
        {
          "id": "88e630bf-10e6-470e-9592-981392a906ab",
          "name": "user_id",
          "protocol": "openid-connect",
          "protocolMapper": "oidc-usermodel-attribute-mapper",
          "consentRequired": false,
          "config": {
            "userinfo.token.claim": "true",
            "user.attribute": "user_id",
            "id.token.claim": "true",
            "access.token.claim": "true",
            "claim.name": "user_id",
            "jsonType.label": "String"
          }
        }
      ]

This is then mapped to the user as an attribute.

From there we simply extract it from the Jwt.

    private MappedJwtClaimSetConverter claimSetConverter() {
        
    	Converter<Object, UUID> converter = value -> {
            if(value == null) {
            	return UUID.fromString("");
            }
    		return UUID.fromString(value.toString());
    	};
                        
        return MappedJwtClaimSetConverter.withDefaults
                        (Collections.singletonMap("user_id", converter));
    }

PreAuthorize

We can use this annotation in combination with Scope when we get a token

  • The Optional scope must exist in the keycloak server
  • The Scope must be passed when generation the token.
  • Must specify the @EnableGlobalMethodSecurity(prePostEnabled = true) on the application
  • Controller uses the @PreAuthorize

Generating the token we

   export TOKEN=`curl -X POST 'http://192.168.1.70:8086/auth/realms/one/protocol/openid-connect/token' \
 --header 'Content-Type: application/x-www-form-urlencoded' \
 --data-urlencode 'grant_type=password' \
 --data-urlencode 'client_id=app' \
 --data-urlencode 'client_secret=bfbd9f62-02ce-4638-a370-80d45514bd0a' \
 --data-urlencode 'username=carol' \
 --data-urlencode 'password=carol' \
 --data-urlencode scope="resolution:read" | jq -r .access_token`

In the controller

	@GetMapping("/resolutions")
	@PreAuthorize("hasAuthority('SCOPE_resolution:read')")
	public List<Resolution> read(@AuthenticationPrincipal Jwt jwt) {
		UUID owner = jwt.getClaim("user_id");
		return this.resolutions.findByOwner(owner);
	}

Testing

I really liked how easy it was to write the unit tests. Wouldn't normally put the whole thing up.

@WebMvcTest(controllers = ResolutionController.class)
@Import({ ResolutionsApplication.class, ResolutionsApplicationTests.JwtDecoderConfig.class })
@AutoConfigureMockMvc
class ResolutionsApplicationTests {
	UUID joshId = UUID.fromString("219168d2-1da4-4f8a-85d8-95b4377af3c1");
	UUID carolId = UUID.fromString("328167d1-2da3-5f7a-86d7-96b4376af2c0");

	@Autowired
	MockMvc mvc;

	@MockBean
	ResolutionRepository resolutions;

	@Test
	public void resolutionsWhenNoTokenThenUnauthorized() throws Exception {
		this.mvc.perform(get("/resolutions"))
				.andExpect(status().isUnauthorized());
	}

	@Test
	public void resolutionsWhenNoScopesThenForbidden() throws Exception {
		this.mvc.perform(get("/resolutions")
				.with(jwt()))
				.andExpect(status().isForbidden());
	}

	@Test
	public void resolutionsWhenWrongScopeThenForbidden() throws Exception {
		this.mvc.perform(get("/resolutions")
				.with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_resolution:write"))))
				.andExpect(status().isForbidden());
	}

	@Test
	public void resolutionsWhenReadScopeThenAuthorized() throws Exception {
		this.mvc.perform(get("/resolutions")
				.with(jwt().jwt(j -> j
						.claim("user_id", this.carolId)
						.claim("scope", "resolution:read")
				)))
				.andExpect(status().isOk());
	}

	@Test
	public void resolutionByIdWhenAdminThenAuthorized() throws Exception {
		Resolution josh = new Resolution("Mow the lawn", this.joshId);
		Resolution carol = new Resolution("Sing Christmas Carols", this.carolId);
		when(this.resolutions.findById(josh.getId())).thenReturn(Optional.of(josh));
		when(this.resolutions.findById(carol.getId())).thenReturn(Optional.of(carol));

		String joshToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMjE5MTY4ZDItMWRhNC00ZjhhLTg1ZDgtOTViNDM3N2FmM2MxIiwic2NvcGUiOiJyZXNvbHV0aW9uOnJlYWQiLCJhdWQiOiJyZXNvbHV0aW9uIn0.";
		String carolToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMzI4MTY3ZDEtMmRhMy01ZjdhLTg2ZDctOTZiNDM3NmFmMmMwIiwic2NvcGUiOiJyZXNvbHV0aW9uOnJlYWQiLCJhdWQiOiJyZXNvbHV0aW9uIn0.";

		this.mvc.perform(get("/resolution/" + josh.getId())
				.header("Authorization", "Bearer " + carolToken))
				.andExpect(status().isOk());
		this.mvc.perform(get("/resolution/" + carol.getId())
				.header("Authorization", "Bearer " + joshToken))
				.andExpect(status().isForbidden());
	}

	@Test
	public void resolutionsWhenNoAudienceThenUnauthorized() throws Exception {
		String audience = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMjE5MTY4ZDItMWRhNC00ZjhhLTg1ZDgtOTViNDM3N2FmM2MxIiwic2NvcGUiOiJyZXNvbHV0aW9uOnJlYWQiLCJhdWQiOiJyZXNvbHV0aW9uIn0.";
		String noAudience = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMjE5MTY4ZDItMWRhNC00ZjhhLTg1ZDgtOTViNDM3N2FmM2MxIiwic2NvcGUiOiJyZXNvbHV0aW9uOnJlYWQifQ.";

		this.mvc.perform(get("/resolutions")
				.header("Authorization", "Bearer " + audience))
				.andExpect(status().isOk());
		this.mvc.perform(get("/resolutions")
				.header("Authorization", "Bearer " + noAudience))
				.andExpect(status().isUnauthorized());
	}

	@Configuration
	static class JwtDecoderConfig {
		@Bean
		JwtDecoder jwtDecoder() {
			return new NimbusJwtDecoder(new MockJWTProcessor());
		}

		private static class MockJWTProcessor extends DefaultJWTProcessor<SecurityContext> {
			@Override
			public JWTClaimsSet process(SignedJWT signedJwt, SecurityContext context) {
				try {
					return signedJwt.getJWTClaimsSet();
				} catch (ParseException e) {
					throw new IllegalArgumentException(e);
				}
			}
		}
	}
}

Issues

  • The iss claim is not valid

This is caused by the wrong address in the issuer-uri where I put localhost and it wanted 192.168.x.x. You see iss in the Jwt Debugger which is what let me to fix this.

Add Oauth2 Security Opaque Tokens

Application Properties

For the Opaque tokens we do not have a user in them so we need to provide enough information to validate in the application properties. We also need to provide the end point to hit

spring:
  security:
    oauth2:
      resourceserver:
        opaque-token:
          introspection-uri: http://localhost:9999/auth/realms/one/protocol/openid-connect/token/introspect
          client-id: app
          client-secret: bfbd9f62-02ce-4638-a370-80d45514bd0a
logging:
  level:
    root: debug

Add Dependencies

               <dependency>
                       <groupId>org.springframework.boot</groupId>
                       <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
               </dependency>

               <dependency>
                       <groupId>com.nimbusds</groupId>
                       <artifactId>oauth2-oidc-sdk</artifactId>
                       <version>6.0</version>
               </dependency>

Configure Spring Security WebSecurityConfigurerAdapter

http configure

We need to set the scope and to tell the resource server to authenticate. Restrict the requests to be authenticated and add the opaque oauth2ResourceServer customizer. The
OpaqueTokenIntrospector is a Bean that which decodes String tokens into validated instances of OAuth2AuthenticatedPrincipal. For me this approach to framework is quite poor if you are looking at it for the first time.

@Configuration
@EnableWebSecurity(debug = true)
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Override
protected void configure(HttpSecurity http) throws Exception {
		http
		.authorizeRequests(a -> a
		.mvcMatchers("/resolutions").hasAuthority("SCOPE_resolution:read")
		.mvcMatchers("/resolution/{id}/share").hasAuthority("resolution:share")
		.anyRequest().authenticated())
		.oauth2ResourceServer(o -> o.opaqueToken());
	}

@Bean
public OpaqueTokenIntrospector tokenIntrospector(
	OAuth2ResourceServerProperties properties, UserRepository users) {

                // Get Properties
		String url = properties.getOpaquetoken().getIntrospectionUri();
		String clientId = properties.getOpaquetoken().getClientId();
		String clientSecret = properties.getOpaquetoken().getClientSecret();

		OpaqueTokenIntrospector delegate = 
                       new NimbusOpaqueTokenIntrospector
                         (url, 
                          clientId,
                          clientSecret);

			return new ResolutionOpaqueTokenIntrospector(delegate, users);
       }
}

Translating the OAuth2AuthenticatedPrincipal

Bridge User

This basically translates the properties from the OAuth2AuthenticatedPrincipal

	private static class BridgeUser extends User implements OAuth2AuthenticatedPrincipal {
		private final OAuth2AuthenticatedPrincipal delegate;

		public BridgeUser(User user, OAuth2AuthenticatedPrincipal delegate) {
			super(user);
			this.delegate = delegate;
		}

		@Override
		@Nullable
		public <A> A getAttribute(String name) {
			return delegate.getAttribute(name);
		}

		@Override
		public Map<String, Object> getAttributes() {
			return delegate.getAttributes();
		}

		@Override
		public Collection<? extends GrantedAuthority> getAuthorities() {
			return delegate.getAuthorities();
		}

		@Override
		public String getName() {
			return delegate.getName();
		}
	}

Introspector Implementation

This is the class responsible for translating the OAuth2AuthenticatedPrincipal

public class ResolutionOpaqueTokenIntrospector implements OpaqueTokenIntrospector {
	private final OpaqueTokenIntrospector delegate;
	private final UserRepository users;

	public ResolutionOpaqueTokenIntrospector(
			OpaqueTokenIntrospector delegate, UserRepository users) {
		this.delegate = delegate;
		this.users = users;
	}

	@Override
	public OAuth2AuthenticatedPrincipal introspect(String token) {
		OAuth2AuthenticatedPrincipal principal = this.delegate.introspect(token);

		String name = principal.getName();
		Map<String, Object> attributes =
				new HashMap<>(principal.getAttributes());
		Collection<GrantedAuthority> authorities =
				new ArrayList<>(principal.getAuthorities());

		UUID userId = UUID.fromString(principal.getAttribute("user_id"));
		attributes.put("user_id", userId);

		User user = users.findById(userId)
				.orElseThrow(() -> new UsernameNotFoundException("no user"));
		if ("premium".equals(user.getSubscription())) {
			if (authorities.stream().map(GrantedAuthority::getAuthority)
					.anyMatch(authority -> "SCOPE_resolution:write".equals(authority))) {
				authorities.add(new SimpleGrantedAuthority("resolution:share"));
			}
		}

		OAuth2AuthenticatedPrincipal delegate =
				new DefaultOAuth2AuthenticatedPrincipal(name, attributes, authorities);

		return new BridgeUser(user, delegate);
	}

	private static class BridgeUser extends User implements OAuth2AuthenticatedPrincipal {
		private final OAuth2AuthenticatedPrincipal delegate;

		public BridgeUser(User user, OAuth2AuthenticatedPrincipal delegate) {
			super(user);
			this.delegate = delegate;
		}

		@Override
		@Nullable
		public <A> A getAttribute(String name) {
			return delegate.getAttribute(name);
		}

		@Override
		public Map<String, Object> getAttributes() {
			return delegate.getAttributes();
		}

		@Override
		public Collection<? extends GrantedAuthority> getAuthorities() {
			return delegate.getAuthorities();
		}

		@Override
		public String getName() {
			return delegate.getName();
		}
	}
}

Multi Tenant REST API

The AuthenticationManagerResolver allows us to treat users differently at runtime.

  • AuthenticationManagerResolver
    • Typically resolves an authentication manager from the HTTP request
    • The source is a generic type
    • Headers, paths, and request attributes are all possible sources of information

In Usage I saw it

  • allow the application to startup without the resources. Good for two authentication servers
  • you can use JaVer to have the authentication manager be memoized

JwtIsssuerAuthenticationManagerResolver

As the name suggests it

  • Takes a white list of trusted issuers
  • Extracts the issuer claim and retrieves the corresponding JWT-babase AthenticationManager
  • Assumes similar defaults to Spring Boot like using Alogrithm RS256

CORS and REST API

Gosh Spring allows you to place an annotation on and endpoint to bypass CORS. I wonder how often this has be used.

@CrossOrigin
@GetMapping("/resolutions")
public List<Resolution> find(@CurrentUserId UUID userId) {

}
...