GoogleOAuthController.java

package com.hwhub.backend.presentation.rest.auth;

import com.hwhub.backend.application.service.UserService;
import com.hwhub.backend.application.service.oauth.GoogleOAuthService;
import com.hwhub.backend.application.service.oauth.GoogleOAuthUserLoginOrCreateService;
import com.hwhub.backend.domain.enums.OAuthFlow;
import com.hwhub.backend.domain.model.UserModel;
import com.hwhub.backend.domain.oauth.google.GoogleUserInfo;
import com.hwhub.backend.presentation.rest.auth.dto.GoogleMobileLoginRequest;
import com.hwhub.backend.presentation.rest.auth.dto.LoginResponse;
import com.hwhub.backend.presentation.rest.auth.dto.LoginUserDto;
import com.hwhub.backend.presentation.rest.common.EmailAlreadyUsedForLocalAccountException;
import com.hwhub.backend.presentation.rest.common.OAuthEmailNotVerifiedException;
import com.hwhub.backend.security.JwtProvider;
import com.hwhub.backend.security.oauth.OAuthStateSigner;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/oauth/google")
public class GoogleOAuthController {

  private final GoogleOAuthService googleOAuthService;
  private final GoogleOAuthUserLoginOrCreateService loginOrCreateService;
  private final UserService userService;
  private final JwtProvider jwtProvider;
  private final OAuthStateSigner stateSigner;
  private final GoogleOAuthLinkHelper linkHelper;

  /**
   * モバイル用 Google 認証: Flutter が取得した idToken を検証し HwHub JWT を返す。
   *
   * <p>フロー: Flutter(google_sign_in) → idToken → このエンドポイント → Google tokeninfo 検証 → loginOrCreate →
   * accessToken + refreshToken
   */
  @Operation(security = {})
  @PostMapping("/mobile")
  public ResponseEntity<LoginResponse> mobileLogin(
      @Valid @RequestBody GoogleMobileLoginRequest request) {

    GoogleUserInfo info = googleOAuthService.verifyIdToken(request.idToken());

    if (Boolean.FALSE.equals(info.getEmailVerified())) {
      throw new OAuthEmailNotVerifiedException();
    }

    UserModel user = loginOrCreateService.loginOrCreate(info);
    String accessToken = jwtProvider.generateToken(user.getUserId(), user.getDisplayName());
    String refreshToken = jwtProvider.generateRefreshToken(user.getUserId());
    return ResponseEntity.ok(
        new LoginResponse(accessToken, refreshToken, LoginUserDto.fromModel(user)));
  }

  /** OAuth開始:Googleにリダイレクト - state を生成して Cookie に保存 - 302でGoogleへ */
  @GetMapping("/start")
  public ResponseEntity<Void> start(HttpServletResponse response) {
    String state = linkHelper.generateStateForLogin();
    linkHelper.setStateCookie(response, state);
    String url = googleOAuthService.buildAuthorizationUrl(state);
    return ResponseEntity.status(302).header(HttpHeaders.LOCATION, url).build();
  }

  /** コールバック:code + state 受領 - state 検証(Cookieと一致 & 署名OK & TTL内) - code -> token - userinfo 取得 - */
  @GetMapping("/callback")
  public ResponseEntity<Void> callback(
      @RequestParam(value = "code", required = false) String code,
      @RequestParam(value = "state", required = false) String state,
      @CookieValue(value = GoogleOAuthLinkHelper.STATE_COOKIE_NAME, required = false)
          String stateCookie,
      @RequestParam(value = "error", required = false) String error,
      HttpServletResponse response) {

    linkHelper.clearStateCookie(response);

    if (error != null) return linkHelper.redirectToLoginFailure("googleCanceled");
    if (code == null) return linkHelper.redirectToLoginFailure("missingCode");
    if (!linkHelper.isValidState(state, stateCookie)) {
      return linkHelper.redirectToLoginFailure("stateInvalid");
    }

    String purpose = stateSigner.extractPurpose(state);

    if (OAuthFlow.LINK.getCode().equals(purpose)) {
      Long userId = Long.parseLong(stateSigner.extractSubject(state));
      userService.linkGoogleAccount(userId, code);

      UserModel user = userService.getProfile(userId);
      String jwt = jwtProvider.generateToken(userId, user.getDisplayName());

      return linkHelper.redirectToLinkSuccess("googleLinked", jwt);
    }

    var token = googleOAuthService.exchangeCodeForToken(code);
    GoogleUserInfo info = googleOAuthService.fetchUserInfo(token.getAccessToken());

    try {
      var user = loginOrCreateService.loginOrCreate(info);
      String jwt = jwtProvider.generateToken(user.getUserId(), user.getDisplayName());
      return linkHelper.redirectToLoginSuccess("googleLoginSuccess", jwt);
    } catch (EmailAlreadyUsedForLocalAccountException ex) {
      return linkHelper.redirectToLoginFailure("emailAlreadyUsed");
    }
  }
}