0

I am working on an API service that is meant to do the following:

  1. Allow users to sign in via Google.
  2. Create the user in the database based on the information retrieved.
  3. Provide the user with a JWT token to be used for authentication so that requests are uniquely identified with said user.
  4. Allow the user to be able to use the obtained token to perform API requests against my service.
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}

I am unsure how can I go about this and what exactly do I need. So far I have the following

Main Application class:

@SpringBootApplication
@EnableWebSecurity
@Configuration
class ApiServiceApplication {

    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http.authorizeHttpRequests {
            it.antMatchers("/", "/login", "/error", "/webjars/**").permitAll().anyRequest().authenticated()
        }
            .logout {
                it.logoutSuccessUrl("/").permitAll()
            }
            .exceptionHandling {
                it.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
            }
            .oauth2Login { oauth2Login ->
                oauth2Login.loginPage("/login")
                oauth2Login.defaultSuccessUrl("/user", true)
            }
            .oauth2Client { oauth2Client -> }
            .csrf {
                it.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            }
        return http.build()
    }
}

fun main(args: Array<String>) {
    runApplication<ApiServiceApplication>(*args)
}

User Service class for saving the user to the DB

@RestController
class UserService : OidcUserService() {

    @Autowired
    lateinit var userRepository: UserRepository

    @Autowired
    lateinit var loginRepository: LoginRepository

    private val oauth2UserService = DefaultOAuth2UserService()

    @GetMapping("/login")
    fun authenticate(): RedirectView {
        return RedirectView("/oauth2/authorization/google")
    }

    override fun loadUser(userRequest: OidcUserRequest?): OidcUser {
        val loadedUser = oauth2UserService.loadUser(userRequest)
        val username = loadedUser.attributes["email"] as String
        var user = userRepository.findByUsername(username)
        if (user == null) {
            user = OauthUser()
            user.username = username
        }

        loadedUser.attributes.forEach { loadedAttribute ->
            val userAttribute = user.oauthAttributes.find { loadedAttribute.key == it.attributeKey && it.active }
            val newAttribute = OauthAttribute(loadedAttribute.key, loadedAttribute.value?.toString())
            if(userAttribute == null){
                user.oauthAttributes.add(newAttribute)
            }
            else if(userAttribute.attributeValue != loadedAttribute.value?.toString()){
                userAttribute.active = false
                user.oauthAttributes.add(newAttribute)
            }
        }
        user.oauthAuthorities = loadedUser.authorities.map { OauthAuthority(it.authority) }.toMutableList()
        user.oauthToken = OauthToken(
            userRequest?.accessToken?.tokenValue!!,
            Date.from(userRequest.accessToken.issuedAt),
            Date.from(userRequest.accessToken.expiresAt)
        )
        userRepository.save(user)
        val login = Login(user)
        loginRepository.save(login)
        return user
    }
}

I am not providing the data classes and corresponding repositories because what's above works fine - upon accessing the /login endpoint, the user is redirected to Google where after authentication the user is saved in the database along with the corresponding information.

My main issue is that I am not really sure how to go about authenticating each request. I've tried to provide an authentication Bearer in Postman that is the same as the one obtained from Google in the loadUser method, but I'm getting back 401 unauthorized codes. When I access the server through the browser and I authenticate I can access all the endpoints just fine, but I'm guessing that it's just my session that is authenticated.

bosowski
  • 124
  • 6

2 Answers2

0

You are trying to configure a resource-server (REST API serving resources) as a UI client (application consuming resources). That won't work.

You should not implement oauth2 login and logout on resource-server, this are UI client concerns and should be removed from your Java conf. An exception is if your application also serves UI with Thymeleaf, JSF or other server-side rendered UI, in which case you should create a second "client" security filter-chain bean and move login & logout there as described there: Use Keycloak Spring Adapter with Spring Boot 3).

Unless you are in the "exception" above (UI client) or use a REST client auto-configured by spring-boot (WebClient, @FeignClient, RestTemplate) to consume resources from other resource-servers, remove all spring.security.oauth2.client properties from your yaml file and spring-boot-starter-oauth2-client from your dependencies.

Details for configuring resource-servers in the answer linked above (applies to any OIDC authorization-server, not just Keycloak) or the tutorials of this repo of mine.

ch4mp
  • 6,622
  • 6
  • 29
  • 49
-1

I have managed to achieve what I wanted by doing the following:

Adding a resource server definition to my spring.security.oauth2 configuration:

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
      resourceserver:
        jwt:
          issuer-uri: https://accounts.google.com
          jwk-set-uri: https://www.googleapis.com/oauth2/v3/certs

Adding the OAuth2ResourceServerConfigurer and specifying the default JwtConfigurer via .oauth2ResourceServer().jwt(), and specifying the authorization matches for the path I want to be secured by JWT. I've also split the filter chains, thanks to the comment from ch4mp, so that only /api endpoint is secured via JWT:

@Bean
@Order(HIGHEST_PRECEDENCE)
fun apiFilterChain(http: HttpSecurity): SecurityFilterChain {
    http.antMatcher("/api/**").authorizeRequests { authorize ->
        authorize.antMatchers("/api/**").authenticated()
    }.exceptionHandling {
        it.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
    }
        .csrf().disable()
        .oauth2ResourceServer().jwt()
    return http.build()
}

@Bean
fun uiFilterChain(http: HttpSecurity): SecurityFilterChain {
    http.authorizeRequests { authorize ->
        authorize.antMatchers("/", "/login", "/error", "/webjars/**").permitAll().anyRequest()
            .authenticated()
    }.logout {
        it.logoutSuccessUrl("/").permitAll()
    }.exceptionHandling {
        it.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
    }.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
        .and()
        .oauth2Login { oauth2Login ->
            oauth2Login.loginPage("/login")
            oauth2Login.defaultSuccessUrl("/", true)
        }.oauth2Client()
    return http.build()
}

Now, in the method mapped to the path I can do some more specific authentication logic:

@GetMapping("/api/securedByJWT")
fun getResponse(@AuthenticationPrincipal jwt: Jwt): ResponseEntity<String> {
    val email = jwt.claims["email"] as String
    val oauthUser = userRepository.findByUsername(email)
    if(oauthUser == null){
        return ResponseEntity("User not registered.", UNAUTHORIZED)
    }
    return ResponseEntity("Hello world!", HttpStatus.OK)
}
bosowski
  • 124
  • 6
  • 1
    login and logout are **client** concerns and should be removed from your **resource-server** security filter chain. Also you might be missing CORS configuration. On a resource-server, you might also disable sessions (and CSRF protection) – ch4mp Feb 06 '23 at 16:26
  • Yes, I want to serve both API endpoints and Web Pages. Thank you for providing the links in your answer and shedding some light on multiple filter configuration. I've updated my original answer to include a configuration for the two different filters. – bosowski Feb 07 '23 at 01:07