Skip to content

Instantly share code, notes, and snippets.

@51enra
Last active May 8, 2020 06:59
Show Gist options
  • Save 51enra/a2b77b6559436c4058ccd36e06346324 to your computer and use it in GitHub Desktop.
Save 51enra/a2b77b6559436c4058ccd36e06346324 to your computer and use it in GitHub Desktop.

Spring Boot Resource Server Configuration

The following description refers to the current implementation of a secure REST API for the Mayo project. It is NOT the simplest possible implementation of a Spring Boot Resource Server, which requires much less code. For a choice of simpler configuration options, please refer to the official Spring Security documentation, which is anyway an excellent starting point for deeper understanding. The main driver for the additional complexity here is the objective to cover authenticated access to the resources with authentication tokens provided by different entities.

pom.xml

Add the following dependency:

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

application.properties

To validate the JWTs our resource server receives, it needs to get the public key for the token signature from the issuer. The uri where the keys can be found is called jwk-set-uri (a.k.a Jason Web Key set endpoint). It can be configured directly or an issuer-uri can be specified. If the jwk-set-uri is not provided, Spring will retrieve it from a "well known" URI (schema specified in OAUTH2) which can be derived from the issuer-uri. Additionally, the issuer-uri (if provided) will be checked agains the iss claim in the JWT. For Google as Authorization Server, the addresses are as follows (as explained above, providing one would be sufficient):

spring.security.oauth2.resourceserver.jwt.issuer-uri=https://accounts.google.com
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs

Web Security Configuration

Create a new class in the config package:

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
	
	@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuerUri;

	protected void configure(HttpSecurity http) throws Exception {
	        // disable csrf, otherwise client needs to send csrf token in POST messages etc.
		http //
				.csrf().disable() //
				.authorizeRequests(authorize -> authorize.mvcMatchers("/secure/**").authenticated()) //
				.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt) //
				.authorizeRequests().anyRequest().permitAll();
		http //
				.formLogin().loginPage("/login") //
				.failureUrl("/login-error") //
				.and().logout().logoutSuccessUrl("/login");
	}
	}
	
	private OAuth2TokenValidator<Jwt> audienceValidator() {
	    return new AudienceValidator();
	}

	@Bean
	JwtDecoder jwtDecoder() {
	    NimbusJwtDecoder jwtDecoder = (NimbusJwtDecoder)
	        // Define the "well known" URI where the setJwks URI can be obtained (public keys)
	        JwtDecoders.fromIssuerLocation(issuerUri);
            
	    OAuth2TokenValidator<Jwt> audienceValidator = audienceValidator();
	    // Only tokens within the validity time and where iss claim equals issuerUri shall be accepted
	    OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri);
	    OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator);

	    jwtDecoder.setJwtValidator(withAudience);

	    return jwtDecoder;
	}
	
	static class AudienceValidator implements OAuth2TokenValidator<Jwt> {
		
		// Your Client ID goes here!
		String allowedAudience = "#############################################.apps.googleusercontent.com";
	    
	    @Override
	    public OAuth2TokenValidatorResult validate(Jwt jwt) {
	    	
		    OAuth2Error error = new OAuth2Error("unknown_audience",
		    		String.format("Unknown JWT audience claim ", jwt.getAudience()),
		    		null);
	    	
	        if (jwt.getAudience().contains(allowedAudience)) {
	            return OAuth2TokenValidatorResult.success();
	        } else {
	            return OAuth2TokenValidatorResult.failure(error);
	        }
	    }
	}

}

Note: This is a bare minimum security config that protects the REST API and leaves open access to the Swagger doc. The following checks of the ID token will be done: - Signature, -exp (expiry time), -iss (issuer - this check ban be switched off), -aud (audience, i.e. requesting client - this check is explicitly added above). The "not before" time is an optional claim, but will be checked if present.

Main reference: https://docs.spring.io/spring-security/site/docs/current/reference/html5/#oauth2resourceserver

The configuration above is for unencrypted signed jwt tokens sent in the authentication header when the signature algorithm uses an asymmetric key pair and the public key can be retrieved from the jwks set uri (like RSA). For symmetric key algorithms (like HMAC SHA), the key is secret and must be stored in the backend itself. Compared to above, the only thing that changes is the JwtDecoder:

	@Bean
	JwtDecoder jwtDecoder() {

		String secret = "******************************"; //The secret as String - not shown here
		SecretKey key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
		NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withSecretKey(key).build();
		
		// TELEKOM: iss claim needs to be converted to URL for jwt parsing
		MappedJwtClaimSetConverter converter = MappedJwtClaimSetConverter
	            .withDefaults(Collections.singletonMap("iss", iss -> "https://" + iss));

		OAuth2TokenValidator<Jwt> audienceValidator = new AudienceValidator();
		OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri);
		OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator);
		
		jwtDecoder.setClaimSetConverter(converter);
		jwtDecoder.setJwtValidator(withAudience);
		
		return jwtDecoder;
	}

Sources for key preparation:

https://stackoverflow.com/questions/55102937/how-to-create-a-spring-security-key-for-signing-a-jwt-token

https://stackoverflow.com/questions/7124735/hmac-sha256-algorithm-for-signature-calculation

Further help in the Spring Security reference above.

Note that the JwtDecoders.fromIssuerLocation(issuerUri) is gone because we don't fetch the key. The validation of the iss claim with issuerUri is still there!

Demo Controller

@RestController
@RequestMapping("/api/v1")
public class DemoController {

	@GetMapping("idtoken")
	public Object getIdToken(@AuthenticationPrincipal(errorOnInvalidType = true) Jwt jwt) {
		return jwt; // user.getClaim("name");
	}

	@GetMapping("whoami")
	public Object whoAmI(@AuthenticationPrincipal(errorOnInvalidType = true) Jwt jwt) {
		String response = "Your name: " + jwt.getClaim("name") + ", your identifier at " + jwt.getIssuer() + ": "
				+ jwt.getSubject();
		return response;
	}

}

This returns the whole ID token upon GET to http://localhost:8080/api/v1/idtoken and the value of the three claims name, iss and sub upon GET to http://localhost:8080/api/v1/whoami. Note that the GET requests must be sent with a valid ID Token in the authorization header! This can be easily configured e.g. in Postman.

Frontend configuration (Android app)

build.gradle(app)

Added two dependencies which are useful for testing; they're not required for the secure REST interface itself:

    implementation 'com.squareup.retrofit2:converter-scalars:2.8.1'
    implementation 'com.squareup.okhttp3:logging-interceptor:4.5.0'

Secure User Profile API and Repository

Besides the (arbitrary) name, the API looks exactly like the standard User Profile API, only using the /secure/ route.

public interface SecureUserProfileApi {

    @GET("secure/api/v1/profiles/{id}")
    Call<UserProfile> getById(@Path("id") Long id);
}

For the repository, we only adjust the naming. No functional difference:

public class SecureUserProfileRepository {

    private static SecureUserProfileRepository instance;
    private final SecureUserProfileApi userProfileApi;

    private SecureUserProfileRepository() {
        userProfileApi = ApiFactory.getInstance().createApi(SecureUserProfileApi.class);
    }

    public static synchronized SecureUserProfileRepository getInstance() {
        if (instance == null) {
            instance = new SecureUserProfileRepository();
        }
        return instance;
    }

    public LiveData<UserProfile> getById(Long id) {
        final MutableLiveData<UserProfile> userProfile = new MutableLiveData<>();
        Call call = userProfileApi.getById(id);
        call.enqueue(new Callback<UserProfile>() {

            @Override
            public void onResponse(Call<UserProfile> call, Response<UserProfile> response) {
                if (response.isSuccessful()) {
                    Timber.d("User Profile " + response.body());
                    userProfile.setValue(response.body());
                }
            }

            @Override
            public void onFailure(Call<UserProfile> call, Throwable t) {
                Timber.d("User Profile Error:" + t);
                userProfile.setValue(null);
            }
        });
        return userProfile;
    }

}

Demo API and Repository

For demonstration purposes, the Demo API in the backend reads and returns information from the submitted token. The answer is sent as a String, which requires the addition of a ConverterFactory that can deal with Strings in the ApiFactory (see below).

public interface DemoApi {

    @GET("/secure/api/v1/whoami")
    public Call<String> getUser();

}
public class DemoRepository {

    private static DemoRepository instance;
    private final DemoApi demoApi;

    private DemoRepository() {
        demoApi = ApiFactory.getInstance().createApi(DemoApi.class);
    }

    public static synchronized DemoRepository getInstance() {
        if (instance == null) {
            instance = new DemoRepository();
        }
        return instance;
    }

    public LiveData<String> getUser() {
        final MutableLiveData<String> userMutableLiveData = new MutableLiveData<>();
        Call call = demoApi.getUser();
        call.enqueue(new Callback<String>() {
            @Override
            public void onResponse(Call<String> call, Response<String> response) {
                if (response.isSuccessful()) {
                    Timber.d("User " + response.body());
                    userMutableLiveData.setValue(response.body());
                }
            }
            @Override
            public void onFailure(Call<String> call, Throwable t) {
                Timber.d("User Error: " + t);
                userMutableLiveData.setValue(null);
            }
        });
        return userMutableLiveData;
    }
}

ApiFactory

Here we have the essential adjustment for the secure REST interface: The TokenAuthenticator. As mentioned above, the LoggingInterceptor helps for testing and the ScalarsConverterFactory is only required to receive the DemoController response as a plain String. The order of the two Factories is important! The TokenAuthenticator retrieves a valid ID Token (if possible, i.e. the user is properly "logged in") from the Smart Credentials API via the performActionWithFreshToken request.

Note that the Authentication Header will be added to every request if the ApiFactory is built like below. It is also possible to use the Retrofit @Headers (or @Header) annotation to set the header for individual Calls. It turns out that the Spring backend ignores an authentication header if it is present but not required for an unsecured route, so leaving it in every request is no harm.

/**
 * Creates API instances for performing REST calls.
 */
public class ApiFactory {

    private static final String BACKEND_BASE_URL = "http://10.0.2.2:8080";
    private static ApiFactory instance;
    private final Retrofit retrofit;

    private ApiFactory() {
        TokenAuthenticator authenticator = new TokenAuthenticator();
        HttpLoggingInterceptor loggingInterceptor = new HttpLoggingInterceptor();
        loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
        OkHttpClient okHttpClient = new OkHttpClient.Builder()
                .authenticator(authenticator)
                .addInterceptor(loggingInterceptor).build();

        Gson gson = new GsonBuilder()
                .setLenient()
                .create();
        retrofit = new Retrofit.Builder()
                .baseUrl(BACKEND_BASE_URL)
                .addConverterFactory(ScalarsConverterFactory.create())
                .addConverterFactory(GsonConverterFactory.create(gson))
                .client(okHttpClient)
                .build();
    }

    /**
     * @return the only ApiService instance, never null
     */
    public static synchronized ApiFactory getInstance() {
        if (instance == null) {
            instance = new ApiFactory();
        }
        return instance;
    }

    /**
     * @param retrofitApiInterface defines the REST interface, must not be null
     * @param <S>
     * @return API instance for performing REST calls, never null
     */
    public <S> S createApi(Class<S> retrofitApiInterface) {
        return retrofit.create(retrofitApiInterface);
    }

    private static class TokenAuthenticator implements Authenticator {

        String validIdToken;

        @Nullable
        @Override
        public Request authenticate(@Nullable Route route, @NotNull Response response) throws IOException {

            AuthenticationApi authenticationApi = SmartCredentialsAuthenticationFactory.getAuthenticationApi();

            authenticationApi.performActionWithFreshTokens(new OnFreshTokensRetrievedListener() {
                @Override
                public void onRefreshComplete(@Nullable String accessToken, @Nullable String idToken, @Nullable AuthorizationException exception) {
                    if (idToken != null) {
                        JwtConsumer jwtConsumer = new JwtConsumerBuilder()
                                .setSkipAllValidators()
                                .setSkipSignatureVerification().build();
                        try {
                            Timber.d("TokenAuthenticator - refresh success: idToken Expiration time = %s", jwtConsumer.processToClaims(idToken).getExpirationTime());
                        } catch (Exception e) {
                            Timber.e("Invalid JWT of id token! %s", e.getMessage());
                        }
                    } else {
                        Timber.d("TokenAuthenticator - refresh success: idToken equals null!");
                    }
                    validIdToken = idToken;
                }

                @Override
                public void onFailed(AuthenticationError errorDescription) {
                    Timber.d("TokenAuthenticator - refresh failed: idToken not available");
                    validIdToken = "";
                }
            });

            return response.request()
                    .newBuilder()
                    .addHeader("Authorization", "Bearer " + validIdToken)
                    .build();
        }
    }

}

SecureProfileActivity

No difference to using the non-secure API. We only show the method implementing the action for the "Get Profile" button. Note that we didn't implement a ViewModel, as this is only a demo!

public void secureLoadUserProfile(View view) {

        SecureUserProfileRepository repository = SecureUserProfileRepository.getInstance();

        repository.getById(1l).observe(this, new Observer<UserProfile>() {
                    @Override
                    public void onChanged(UserProfile userProfile) {
                        EditText tFirstName = findViewById(R.id.edit_secure_firstname);
                        EditText tLastName = findViewById(R.id.edit_secure_lastname);
                        EditText tPhoneNo = findViewById(R.id.edit_secure_phone);
                        EditText tEmail = findViewById(R.id.edit_secure_mail);
                        EditText tLocation = findViewById(R.id.edit_secure_location);
                        EditText tPreferredRoute = findViewById(R.id.edit_secure_route);
                        TextView tGender = findViewById(R.id.secure_gender);

                        if(userProfile != null) {
                            tFirstName.setText(userProfile.getFirstName());
                            tLastName.setText(userProfile.getLastName());
                            tPhoneNo.setText(userProfile.getPhoneNo());
                            tEmail.setText(userProfile.getEmail());
                            tLocation.setText(userProfile.getLocation());
                            tPreferredRoute.setText(userProfile.getPreferredRoute());
                            tGender.setText(userProfile.getGender());
                        } else {
                            tFirstName.setText("no response");
                            tLastName.setText("no response");
                            tPhoneNo.setText("no response");
                            tEmail.setText("no response");
                            tLocation.setText("no response");
                            tPreferredRoute.setText("no response");
                            tGender.setText("no response");
                        }
                    }
                });
    }

CompletionActivity

Only the code part that retrieves the String info from the Demo API is shown.

DemoRepository repository = DemoRepository.getInstance();
repository.getUser().observe(this, new Observer<String>() {
   @Override
   public void onChanged(String user) {
       Timber.d( "onCreate: " + user);
       String tokenInfo = accessTokenInfo + "\n\n" + user;
       TextView resultTextView = findViewById(R.id.text_view_result);
       resultTextView.setText(tokenInfo);
   }
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment