どうも、小野です。今回は、バックエンドがAPIのみ、フロントエンドがSPAの構成におけるOAuth認証とリクエスト時の認証チェックについてです。個人的にSpringBootとNuxtを利用することが多いのですが、この構成でのOAuth認証の実現方法を中々見出せずにいました。SprignBootにはOAuth認証を行う仕組みがSpringSecurityに実装されているのですが、フロントエンドとの連携が複雑化して断念しました。そこで、FirebaseのAuthentizcationサービスを使ってGoogle認証について調査したところ、スマートな方法が発見できたのでご紹介します。
構成

技術スタック
| 項目 | バージョン |
|---|---|
| Java | 17 |
| SpringBoot | 2.16 |
| NuxtJS(CompositionAPI利用) | 2 |
| Node | 16.13.0 |
Firebaseのセットアップ
プロジェクト作成



Authentication設定
プロバイダの追加




承認済みドメインの追加(任意)
今回はローカルでの確認のみなので、特に作業は必要ありませんが、以下のリストに記載されているドメインからGoogle認証を行う場合は追加が必要になります。

マイアプリ登録



サービスアカウント
バックエンドで利用するFirebase Admin SDK初期化時の認証ファイルをダウンロードします。

フロントエンド
今回は、Nuxt2+CompositionAPIを使って実装していきます。
まず、プロジェクトを作成します。
$ npx create-nuxt-app frontend Need to install the following packages: create-nuxt-app Ok to proceed? (y) y create-nuxt-app v3.7.1 ✨ Generating Nuxt.js project in frontend ? Project name: frontend ? Programming language: TypeScript ? Package manager: Npm ? UI framework: (Use arrow keys) ? UI framework: None ? Nuxt.js modules: (Press <space> to select, <a> to toggle all, <i> to invert selection) ? Linting tools: (Press <space> to select, <a> to toggle all, <i> to invert selection) ? Testing framework: None ? Rendering mode: Universal (SSR / SSG) ? Deployment target: Static (Static/Jamstack hosting) ? Development tools: (Press <space> to select, <a> to toggle all, <i> to invert selection) ? What is your GitHub username? frevo-works-ono ? Version control system: Git
CompositionAPIを利用するための設定は今回は割愛します。以下のサイトを参考に、セットアップしてください。
Firebaseのセットアップ
依存モジュールをインストールします。
npm i firebase
Firebaseの初期化情報を環境変数から取得するようにします。 .envに下記の内容を記載します。
FIREBASE_API_KEY=<Firebase API Key>
nuxt.config.js
export default {
// 〜省略〜
publicRuntimeConfig: {
firebaseApiKey: process.env.FIREBASE_API_KEY, // 追記
},
})
Firebaseの機能をラップするプラグインを作成します。
import { initializeApp } from 'firebase/app'
import { getAuth, GoogleAuthProvider, signInWithPopup, User } from 'firebase/auth'
import type { Plugin } from '@nuxt/types'
const firebase: Plugin = ({ $config }, inject) => {
const firebaseConfig = {
apiKey: $config.firebaseApiKey,
authDomain: `xxxxxxxxxx.firebaseapp.com`,
projectId: xxxxxxxxxx,
storageBucket: `xxxxxxxxxx.appspot.com`,
messagingSenderId: xxxxxxxxxxxxx,
appId: xxxxxxxxxxxxxxxxx,
}
// Firebase初期化
initializeApp(firebaseConfig)
// ログイン処理
const login = () => {
return new Promise<User | false>((resolve, reject) => {
signInWithPopup(
getAuth(),
new GoogleAuthProvider()
).then(user => {
if (user.user) {
resolve(user.user)
} else {
resolve(false)
}
})
})
}
// ユーザ取得
const getUser = () => {
return new Promise<User | false>((resolve, reject) => {
getAuth().onAuthStateChanged((user) => {
if (user) {
resolve(user)
} else {
resolve(false)
}
})
})
}
inject('firebase', { login, getUser })
}
export default firebase
ログインページの作成
pages/signin.vueを作成します。
<template>
<button type="button" @click="login">Google認証</button>
</template>
<script lang="ts">
import { defineComponent, useContext } from '@nuxtjs/composition-api'
export default defineComponent({
setup() {
const { app, $firebase } = useContext()
const login = async () => {
const user = await $firebase.login()
if (!user) {
// 認証エラー
return
}
app.router?.replace('/')
}
return {
login
}
}
})
</script>
ログイン後ページの作成
pages/index.vueを作成します。
<template>
<div>{{ message }} {{ name }}</div>
</template>
<script lang="ts">
import {
defineComponent,
ref,
useContext,
useFetch,
} from "@nuxtjs/composition-api";
export default defineComponent({
setup() {
const { app, $firebase } = useContext();
let name = ref("");
let message = ref("");
useFetch(async () => {
// ログイン済みユーザの取得
const user = await $firebase.getUser();
if (!user) {
// 未ログインのため、ログイン画面に遷移
app.router?.replace("/signin");
return;
}
const token = await user.getIdToken();
const res = await fetch("http://localhost:8080/api/hello", {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 403) {
app.router?.replace("/signin");
return;
}
name.value = user.displayName!;
message.value = await res.text();
});
return {
name,
message,
};
},
});
</script>
以上で、フロントエンド側の実装は終わりです。
バックエンド
Spring Initializrでプロジェクトを作成します。
今回利用するライブラリは以下の通り。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
他にクライアントでGoogle認証で発行されたトークンを検証するために、Firebase Admin SDKを追加します。
<dependency>
<groupId>com.google.firebase</groupId>
<artifactId>firebase-admin</artifactId>
<version>8.1.0</version>
</dependency>
Firebase Admin SDKを利用するには、最初に初期化処理が必要になります。 初期化処理には、Firebaseのサービスアカウントでダウンロードした秘密鍵(jsonファイル)を利用する必要があるのですが、Githubなどにはアップするべきではありません。でも、デプロイの際には必要になります。では、どうするか。以下のコードのように、各値を環境変数から取得するようにし、jsonファイルを作成すればいいのです。
@Component
public class FirebaseAuthClient {
private static final String FIREBASE_CREDENTIALS_PATH = "credential.json";
public FirebaseAuthClient() {
try {
FileWriter fw = new FileWriter(FIREBASE_CREDENTIALS_PATH);
try (PrintWriter pw = new PrintWriter(new BufferedWriter(fw))) {
// 環境変数から取得して設定する
Map<String, String> credentials = new HashMap<>();
credentials.put("type", "service_account");
credentials.put("project_id", System.getenv("FIREBASE_PROJECT_ID"));
credentials.put("private_key_id", System.getenv("FIREBASE_PRIVATE_KEY_ID"));
credentials.put("private_key", System.getenv("FIREBASE_PRIVATE_KEY"));
credentials.put("client_email", System.getenv("FIREBASE_CLIENT_EMAIL"));
credentials.put("client_id", System.getenv("FIREBASE_CLIENT_ID"));
credentials.put("auth_uri", "https://accounts.google.com/o/oauth2/auth");
credentials.put("token_uri", "https://oauth2.googleapis.com/token");
credentials.put("auth_provider_x509_cert_url", "https://www.googleapis.com/oauth2/v1/certs");
credentials.put("client_x509_cert_url",
"https://www.googleapis.com/robot/v1/metadata/x509/" + System.getenv("FIREBASE_CLIENT_EMAIL"));
// MapをJson形式の文字列に変換
String str = new ObjectMapper().writeValueAsString(credentials);
// ファイルに書き込み
pw.println(str);
}
FileInputStream serviceAccount = new FileInputStream(FIREBASE_CREDENTIALS_PATH);
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(serviceAccount)).build();
// 初期化
FirebaseApp.initializeApp(options);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public FirebaseToken verify(String token) throws FirebaseAuthException {
return FirebaseAuth.getInstance().verifyIdToken(token);
}
@PreDestroy
public void destroy() {
FirebaseApp.getInstance().delete();
}
}
トークンを取得するフィルターの作成
クライアントからはAuthorizationヘッダのBearerトークンで渡されるので、そこからトークンを取得します。
public class AuthFilter extends AbstractPreAuthenticatedProcessingFilter {
private static final String TOKEN_PREFIX = "Bearer ";
@Override
protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
return "";
}
@Override
protected Object getPreAuthenticatedCredentials(HttpServletRequest request) {
final var token = request.getHeader(HttpHeaders.AUTHORIZATION);
if (Objects.isNull(token) || !token.startsWith(TOKEN_PREFIX)) {
// 先頭が「Bearer 」で開始されていない場合
return "";
}
// 「Bearer 」以降の文字列を返却
return token.substring(TOKEN_PREFIX.length());
}
}
トークンの検証を行うサービスの作成
public class AuthService implements AuthenticationUserDetailsService<PreAuthenticatedAuthenticationToken> {
private FirebaseAuthClient firebaseAuthClient;
private UserRepository userRepository;
public AuthService(FirebaseAuthClient firebaseAuthClient, UserRepository userRepository) {
this.firebaseAuthClient = firebaseAuthClient;
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserDetails(PreAuthenticatedAuthenticationToken token) throws UsernameNotFoundException {
// AuthFilterから渡されたトークンを取得
final var credential = token.getCredentials().toString();
// 空の場合は認証エラーとする
if (credential.isEmpty()) {
throw new BadCredentialsException("トークンが空です");
}
try {
// トークンの検証
FirebaseToken firebaseToken = firebaseAuthClient.verify(credential);
return userRepository.findByEmail(firebaseToken.getEmail())
.map(user -> new User(user.getEmail(), "",
AuthorityUtils.createAuthorityList(user.getRole())))
.orElseThrow(() -> new UsernameNotFoundException("該当するユーザが存在しません"));
} catch (FirebaseException e) {
throw new BadCredentialsException("トークンの検証エラー",e);
}
}
}
SpringSecurityの設定
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private UserRepository userRepository;
private FirebaseAuthClient firebaseAuthClient;
public SecurityConfig(UserRepository userRepository, FirebaseAuthClient firebaseAuthClient) {
this.userRepository = userRepository;
this.firebaseAuthClient = firebaseAuthClient;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest()
.authenticated();
http.addFilter(preAuthenticatedProcessingFilter());
// CSRF無効
http.csrf().disable();
// CORS対応(フロントエンドとオリジンが異なるため必要)
http.cors().configurationSource(getCorsConfigurationSource());
// セッション管理無効
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
private CorsConfigurationSource getCorsConfigurationSource() {
var corsConfiguration = new CorsConfiguration();
// 全てのメソッドを許可
corsConfiguration.addAllowedMethod(CorsConfiguration.ALL);
// 全てのヘッダを許可
corsConfiguration.addAllowedHeader(CorsConfiguration.ALL);
// localhost:3002のみ許可
corsConfiguration.addAllowedOrigin("http://localhost:3002");
corsConfiguration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource corsSource = new UrlBasedCorsConfigurationSource();
corsSource.registerCorsConfiguration("/**", corsConfiguration);
return corsSource;
}
// フィルター登録
@Bean
public PreAuthenticatedAuthenticationProvider preAuthenticatedAuthenticationProvider() {
var preAuthenticatedAuthenticationProvider = new PreAuthenticatedAuthenticationProvider();
preAuthenticatedAuthenticationProvider
.setPreAuthenticatedUserDetailsService(new AuthService(firebaseAuthClient, userRepository));
preAuthenticatedAuthenticationProvider.setUserDetailsChecker(new AccountStatusUserDetailsChecker());
return preAuthenticatedAuthenticationProvider;
}
// 作成したフィルター
private AbstractPreAuthenticatedProcessingFilter preAuthenticatedProcessingFilter() throws Exception {
var preAuthenticatedProcessingFilter = new AuthFilter();
preAuthenticatedProcessingFilter.setAuthenticationManager(authenticationManager());
return preAuthenticatedProcessingFilter;
}
API作成
サンプルのAPIを作成します。
@RestController
@RequestMapping("api/hello")
public class HelloController {
@GetMapping
public String hello() {
return "Hello";
}
}
動作確認
フロントエンド、バックエンドそれぞれ起動します。
起動したら、http://localhost:3000にアクセスします。(ポートはデフォルトを利用しています)
未認証なため、以下のようなログイン画面に遷移されます。

Google認証ボタンをクリックし、毎度おなじみのOAuth認証を行います。
認証に成功すると、画面に「Hello <認証したGoogleアカウント名>」が表示されます。

おわりに
OAuthにFirebaseなどの認証サービスを利用することで、比較的簡単に認証処理を実現できました。 OAuthのサービスプロバイダごとに認証APIが変わることがありますが、Firebaseなどを利用することでAPI変更など意識しなくて済むので、よりビジネスロジックの実装に集中できると思います。今回はFirebaseを利用しましたが、他にAuth0やAmazon Cognitoなどのサービスがあるので、興味がある方はぜひ使ってみてください。
今回利用したソースはGithubにあげたので参考にしてみてください。