Authentication with?OpenID

From parent article

Motivation

We want to provide users with the option to sign into their account using OpenID, specifically through Google Login. The motivation behind this is to offer a convenient alternative to remembering or signing in with a password. If a user forgets their password, they can easily sign in using their Google account. Additionally, if the user is already signed into their Google account on their browser, signing into our application becomes a one-click process.

What is?OpenID?

OAuth 2.0 is an authorization protocol that allows our application to access a user’s resources through a third party without exposing their credentials.

?OpenID Connect (OIDC) is an authentication protocol that builds on OAuth 2.0; it helps us identify the user by returning user-specific information from the third party.

In this case, when a user wants to sign into our application without providing their credentials, we redirect them to Google Login. Upon successful authentication, Google returns the user’s email, which serves as a unique identifier that we have stored in our database. This allows us to authenticate the user securely without needing their credentials.

OpenID Flow

  1. Authentication Request: The application directs the user to Google’s authorization endpoint, requesting authorization and including the openid scope.
  2. User Authentication: Google authenticates the user and asks for consent to the requested scopes.
  3. Authorization Response: If the user consents, Google redirects the user back to the application with an authorization code.
  4. Token Exchange: The application exchanges the authorization code for an ID token (and optionally an access token and refresh token) by making a request to Google’s token endpoint.
  5. ID Token Verification: The application verifies the ID token’s signature, ensuring it was issued by the trusted Google and that it has not been tampered with.
  6. UserInfo Request (Optional): The application can retrieve the user’s email by querying the UserInfo endpoint with the access token.
  7. Authenticated Session: The application creates an access token and refresh token, then sends these tokens to the user.

Setting Up?OpenID

  1. Go to the Google Cloud Console: Navigate to Google Cloud Console .

2. Create OAuth consent screen, on the left sidebar go to OAuth Consent Screen. Fill in: - App name - User support email - Upload App logo - Add App domain name - Add Authorized Domain - Developer contact information email

3. Navigate to APIs & Services > Credentials. Create Credentials

4. Go to https://dashboard.render.com/ Add the following environment variables - GOOGLE_CLIENT_ID: - GOOGLE_CLIENT_SECRET: - GOOGLE_REDIRECT_BASE_URI:

Setting Up the Application

Add the following dependency to pom.xml .

<!-- Spring Security OAuth2 Client -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-client</artifactId>
    </dependency>
    <dependency>
        <groupId>com.nimbusds</groupId>
        <artifactId>nimbus-jose-jwt</artifactId>
        <version>9.37.2</version> <!-- Replace with the fixed version -->
    </dependency?        

CustomOAuth2SuccessHandler.java

public class CustomOAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    @Value("${OAUTH2_REDIRECT_BASE_URI}")
    private String baseRedirectUri;
    private String redirectUri;
    private String failureRedirectUri;
    @Autowired
    UserService userService;
    @Autowired
    JwtUtils jwtUtils;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        this.redirectUri = baseRedirectUri + "/oauth2/success";
        this.failureRedirectUri = baseRedirectUri + "/oauth2/failure";
        handle(request, response, authentication);
        super.clearAuthenticationAttributes(request);
    }

    /**
     * We are close to end of the OAuth2 flow, after the Client redirected the user to the OAuth2 provider,
     * after the user sends its credential/consent to the OAuth2 provider, the OAuth2 provider redirects the user back to the Client.
     * providing the authorization code/token to the Client to retrieve the users' information.
     * This method handles the successfull retrieval of the user's information from the OAuth2 provider.
     * Where we validate that the user's email matches the email in the database, and using the user's information
     * we generate the JWT access/refresh token, in which we redirect to the /oauth2/success endpoint otherwise
     * we redirect to the /oauth2/failure endpoint.
     */
    @Override
    protected void handle(HttpServletRequest request, HttpServletResponse response, Authentication oauthAuthentication) throws IOException {
        String targetUrl = redirectUri.isEmpty() ?
                determineTargetUrl(request, response, oauthAuthentication) : redirectUri;
        OAuth2AuthenticationToken oauth2Authentication = (OAuth2AuthenticationToken) oauthAuthentication;
        try {
            if (!Boolean.TRUE.equals(oauth2Authentication.getPrincipal().getAttribute("email_verified"))) {
                targetUrl = UriComponentsBuilder.fromUriString(failureRedirectUri)
                        .queryParam("error", "Email not verified")
                        .build().toUriString();
                response.setContentType(CONTENT_TYPE);
                getRedirectStrategy().sendRedirect(request, response, targetUrl);
                return;
            }
            String email = oauth2Authentication.getPrincipal().getAttribute("email");

            Optional<User> optionalUser = userService.getByEmail(email);
            if(optionalUser.isEmpty()) {
                String failureUrl = UriComponentsBuilder.fromUriString(failureRedirectUri)
                        .queryParam("error", "User not found with email: " + email)
                        .build().toUriString();
                response.setContentType("application/json");
                getRedirectStrategy().sendRedirect(request, response, failureUrl);
                return;
            }

            User user = optionalUser.get();

            UsernameEmailPasswordAuthenticationToken authentication = new UsernameEmailPasswordAuthenticationToken(user.getId(), user.getUsername(), user.getAuthorities());

            String accessToken = jwtUtils.generateToken(authentication, TokenType.ACCESS_TOKEN);
            String refreshToken = jwtUtils.generateToken(authentication, TokenType.REFRESH_TOKEN);

            userService.updateTokens(authentication.getId(), accessToken, refreshToken);

            // Set refresh token in HTTP-only cookie
            Cookie refreshTokenCookie = getRefreshTokenCookie(refreshToken);
            response.addCookie(refreshTokenCookie);

            targetUrl = UriComponentsBuilder.fromUriString(targetUrl)
                    .queryParam("accessToken", accessToken)
                    .build().toUriString();

            response.setContentType(CONTENT_TYPE);
            getRedirectStrategy().sendRedirect(request, response, targetUrl);
        } catch (AuthenticationException e) {
            targetUrl = UriComponentsBuilder.fromUriString(failureRedirectUri)
                    .queryParam("error", e.getMessage())
                    .build().toUriString();
            response.setContentType(CONTENT_TYPE);
            getRedirectStrategy().sendRedirect(request, response, targetUrl);
        }
    }

    private Cookie getRefreshTokenCookie(String refreshToken) {
        Cookie refreshTokenCookie = new Cookie("refresh-token", refreshToken);
        refreshTokenCookie.setHttpOnly(true); // prevents JavaScript from accessing the cookie
        refreshTokenCookie.setSecure(true); // should be set to true in production
        refreshTokenCookie.setPath("/");
        refreshTokenCookie.setMaxAge((int) JwtUtils.REFRESH_TOKEN_EXPIRATION_TIME.toSeconds()); // Evicts the cookie from browser when the token expires
        return refreshTokenCookie;
    }
}        

Upon successful authentication, it will create an access and refresh token, redirect the user to the “/oauth/success” endpoint where the tokens are returned to the user. If the authentication fails, redirect user to “/oauth2/failure”.

Now update SecurityConfig.java

org.springframework.security.oauth2.core.AuthorizationGrantType.AUTHORIZATION_CODE;
import static org.springframework.security.oauth2.core.ClientAuthenticationMethod.CLIENT_SECRET_BASIC;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    public static final String[] WHITE_LIST_URL = {
          "/public/**",
          "/swagger-ui/**",
          "/swagger-resources/**",
          "/v3/api-docs/**",
          "/login/oauth2/**"
   };

  @Bean
  public ClientRegistrationRepository clientRegistrationRepository(
          @Value("${GOOGLE_CLIENT_ID}") String googleClientId,
          @Value("${GOOGLE_CLIENT_SECRET}") String googleClientSecret,
          @Value("${OAUTH2_REDIRECT_BASE_URI}") String baseRedirectUri) {
    ClientRegistration clientRegistration = ClientRegistration.withRegistrationId("google")
            .clientId(googleClientId)
            .clientSecret(googleClientSecret)
            .clientAuthenticationMethod(CLIENT_SECRET_BASIC)
            .authorizationGrantType(AUTHORIZATION_CODE)
            .redirectUri(baseRedirectUri + "/login/oauth2/code/google")
            .scope("openid", "profile", "email")
            .authorizationUri("https://accounts.google.com/o/oauth2/auth")
            .tokenUri("https://oauth2.googleapis.com/token")
            .userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo")
            .jwkSetUri("https://www.googleapis.com/oauth2/v3/certs")
            .userNameAttributeName("sub")
            .clientName("Google")
            .build();
    return new InMemoryClientRegistrationRepository(clientRegistration);
  }

 @Bean
  public CustomOAuth2SuccessHandler customAuthenticationSuccessHandler() {
    return new CustomOAuth2SuccessHandler();
  }

  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http,
                                                 JwtAuthenticationFilter jwtAuthenticationFilter,
                                                 UsernameEmailPasswordAuthenticationFilter usernameEmailPasswordAuthenticationFilter,
                                                 ClientRegistrationRepository clientRegistrationRepository,
                                                 CustomOAuth2SuccessHandler customAuthenticationSuccessHandler) throws Exception {
    http
            .csrf(csrf -> csrf.disable())  // Disable CSRF protection
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers(WHITE_LIST_URL).permitAll()  // Whitelist signup endpoint
                    .anyRequest().authenticated()  // All other endpoints require authentication
            )
            /**
             * Because we are using JWT(self contain) tokens, we can make the api calls stateless, which means
             * we don't need to store the session in the server. But for OAuth2 flows, because we need to make multiple
             * calls between the client(our application), user, and the google authorization server, to exchange user
             * credentials for oauth2 tokens, and exchange the tokens for user information to authenticate the user.
             * We need to use sessions stored on the client to keep track of the keys and personal information that
             * is propagated during the sequence of api calls.
             * So for the sequence of api calls for OAuth2 authentication we need the REST calls to be stateful, while
             * for the rest of the api calls we can make them stateless.
             */
            .sessionManagement(session -> session
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)  // Default to stateless
                    .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // Stateful for OAuth2 flows
                    .sessionFixation().none()  // No session fixation protection
            )
            .oauth2Login(oauth2 ->
                    oauth2.clientRegistrationRepository(clientRegistrationRepository)// Ensure OAuth2 login is configured
                            .successHandler(customAuthenticationSuccessHandler))
            .authenticationProvider(authenticationProvider())  // Register custom authentication provider
            .addFilterAt(usernameEmailPasswordAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            .addFilterAfter(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    return http.build();
  }
}        

We want our application to be stateless for most endpoints, but maintain a session for the OAuth 2.0 endpoints because the token exchange and retrieval of user information are done through URL redirections. If we don’t maintain a session where the user information is temporarily stored on the server, we would lose crucial data during the redirections, preventing us from properly authenticate the user.


Let’s create OAuthController.java, where the /oauth2/success and /oauth2/failure endpoints handle the return of the JWT to the user at the end of the OAuth2 flow. The /oauth2/success endpoint is responsible for returning the JWT upon successful authentication.

@RestController
public class OAuthController {

    @GetMapping("/oauth2/success")
    public ResponseEntity<?> success(@RequestParam String accessToken, HttpServletResponse response) {
        /**
         * Since the api calls is stateful during the OAuth2 flow, and this endpoint marks the end of the OAuth2 flow,
         * we remove the JSESSIONID cookie to revert back to stateless api calls.
         */
        response.addCookie(removeJSessionIdCookie());
        try {
            return ResponseEntity.ok(new AccessTokenResponse(accessToken, ACCESS_TOKEN_EXPIRATION_TIME.toSeconds()));
        } catch(Exception e) {
            return ResponseEntity.badRequest().body("Error: " + e.getMessage());
        }
    }
   
     @GetMapping("/oauth2/failure")
    public ResponseEntity<?> failure(@RequestParam String error, HttpServletResponse response) {
        /**
         * Since the api calls is stateful during the OAuth2 flow, and this endpoint marks the end of the OAuth2 flow,
         * we remove the JSESSIONID cookie to revert back to stateless api calls.
         */
        response.addCookie(removeJSessionIdCookie());
        return ResponseEntity.badRequest().body(error);
    }

    private Cookie removeJSessionIdCookie() {
        Cookie jsessionidCookie = new Cookie("JSESSIONID", null);
        jsessionidCookie.setPath("/");
        jsessionidCookie.setMaxAge(0);
        return jsessionidCookie;
    }
}          

Since the success and failure endpoints mark the end of the OAuth 2.0 flow, we want to revert the application to a stateless configuration. State is managed through the JSESSIONID cookie during the OAuth 2.0 process, so after completion, we ensure the application remains stateless by removing the stored JSESSIONID cookie from the client side.

Signing in with?Google

To sign in with Google, go to the following URL

https://<application url>/oauth2/authorization/google        

It will return the JWT access token in the response body and the refresh token in the httpOnly cookie named refresh-token.


要查看或添加评论,请登录

社区洞察

其他会员也浏览了