Spring REST API Quick Start: Difference between revisions
(7 intermediate revisions by the same user not shown) | |||
Line 142: | Line 142: | ||
===Add Dependencies=== | ===Add Dependencies=== | ||
<syntaxhighlight lang="xml"> | <syntaxhighlight lang="xml"> | ||
<dependency> | |||
<groupId>org.springframework.security</groupId> | |||
<artifactId>spring-security-test</artifactId> | |||
</dependency> | |||
<dependency> | <dependency> | ||
<groupId>org.springframework.boot</groupId> | <groupId>org.springframework.boot</groupId> | ||
Line 154: | Line 158: | ||
<artifactId>commons-lang3</artifactId> | <artifactId>commons-lang3</artifactId> | ||
</dependency> | </dependency> | ||
<dependency> | |||
<groupId>org.springframework.boot</groupId> | |||
<artifactId>spring-boot-starter-web</artifactId> | |||
</dependency> | |||
</syntaxhighlight> | </syntaxhighlight> | ||
===Define location of Keycloak Server=== | ===Define location of Keycloak Server=== | ||
Add some debug for good measure | Add some debug for good measure | ||
Line 508: | Line 519: | ||
<syntaxhighlight lang="xml"> | <syntaxhighlight lang="xml"> | ||
<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> | |||
</syntaxhighlight> | </syntaxhighlight> | ||
==Configure Spring Security WebSecurityConfigurerAdapter== | ==Configure Spring Security WebSecurityConfigurerAdapter== | ||
===http configure=== | ===http configure=== | ||
Line 563: | Line 576: | ||
====Bridge User==== | ====Bridge User==== | ||
This basically translates the properties from the OAuth2AuthenticatedPrincipal | This basically translates the properties from the OAuth2AuthenticatedPrincipal | ||
< | <syntaxhighlight lang="java"> | ||
private static class BridgeUser extends User implements OAuth2AuthenticatedPrincipal { | private static class BridgeUser extends User implements OAuth2AuthenticatedPrincipal { | ||
private final OAuth2AuthenticatedPrincipal delegate; | private final OAuth2AuthenticatedPrincipal delegate; | ||
Line 667: | Line 680: | ||
} | } | ||
</syntaxhighlight> | |||
=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. | |||
<syntaxhighlight lang="java"> | |||
@CrossOrigin | |||
@GetMapping("/resolutions") | |||
public List<Resolution> find(@CurrentUserId UUID userId) { | |||
} | |||
... | |||
</syntaxhighlight> | </syntaxhighlight> |
Latest revision as of 07:35, 12 April 2021
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.
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) {
}
...