はじめに
こんにちは。
Spring Security 5.7~+SPAの記事が少なすぎて血反吐を吐きながら実装をしているのですが、
みなさんどうやって実装を進めていらっしゃるんでしょうか…?
Java触って4年目だしポートフォリオでも作るか~とおさらい感覚で手を出したのに、
ここまで泥沼にハマるとは夢にも思いませんでした。
全体的にVue.js(SPA)初心者/Spring初心者の視点で書いていますので、至らない点が多々ある点についてはご容赦ください。
やりたかったこと
・Spring Securityを使う
・Vue.jsでSPAを使う
・Spring Security+Vuex+Axiosによるログイン(認証)処理の実装
・ログイン処理時にCSRF対策を有効化
作成したVue3のログイン処理(Vuex+Axios)で認証のコンテキストをPOSTで送信し、
それをSpringで受け取って認証を通すという流れを目指していました。
問題
Vueがやや難しいSpring Securityが難しいSpring Security+SPAが輪をかけて難しい- Spring Security+SPAでログイン前にCSRF対策を有効にするとAccess Deniedになる
天下のSpringと今アツいVue(SPA)のこと、初学者のための記事がそこらじゅうに転がっていると信じて取り掛かりましたが、その二つを組み合わせた場合には話が違ったようです。
早速ですが、結論からいきます。
結論
Cookieを使わない
理由は追ってご説明しますが、まずVue.js(SPA)で採用できるCSRF対策からご説明します。
Spring Bootにおいては、@EnableWebSecurity
を明示したうえでformタグにth:action=〜
を付与するだけで自動でCSRFトークンがPOSTされるため、実装時に意識することはほぼありません。
しかし、SPAでAjax等を利用する場合はひと工夫が必要です。
Spring+SPAの構成でも、検索すればいくつかの記事はヒットしますが、概ねCSRF対策については以下のような実装になっていました。
不採用とした実装例1: Cookie採用+ログイン時はCSRF対策しない
CookieでCSRFトークンを保持する実装例です。
.withHttpOnlyFalse()
を使用することにより、JavaScriptからもCookieを扱えるようになる…ようですが、そのぶんセキュリティ面では少し難があるとのこと。
この場合、Axios側でwithCredentialsを有効にすることで自動でCSRFトークンが付与されて利便性が高まります。
もっとも、この例ではログイン時にCSRF対策を無効としているため、今回の目的には沿いません。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
// 前後は省略
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 前後は省略
http.csrf()
// 認証を行なうパスではCSRF対策を無効にする。
.ignoringAntMatchers("/api/login")
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
}
}
不採用とした実装例2: CookieCSRFトークンの完全な適用
実装例1の完全版です。
こちらを採用すれば、ログインのリクエストも含めてCSRF対策が有効になります。
しかし、こちらを採用するとCSRFトークンがリクエストのヘッダに付与されるにもかかわらず何故かアクセスが通りません。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
// 前後は省略
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 前後は省略
http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
}
}
不採用とした実装例番外編: ノーガード戦法
CSRF対策を有効にしない方法です。
※これを「対策」と言っていいのかは怪しいので、番外編としておきます
@Configuration
@EnableWebSecurity
public class SecurityConfig {
// 前後は省略
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 前後は省略
http.csrf().disable();
}
}
何というか、そもそも使わなければ認証は通りますよね。
(もちろん、#authorizeHttpRequests()
で適切に認証から除外するパスを設定する前提で)
ただ、今回の目的からはズレるので採用はできません。
Cookieトークンの何が問題なのか
いろいろ調べてみたところ、どうやら実装例2を採用した場合は
サーバー側で保持しているCSRFトークンの文字列とCookieに付与されるCSRFトークンが全く同一の文字列になってしまうようです。
これの何が問題かというと、サーバー側でCSRFトークンの照合を行なう際、PostされたCSRFトークンをBase64でデコードしているためです。
public final class XorCsrfTokenRequestAttributeHandler extends CsrfTokenRequestAttributeHandler {
// 前後は省略
private static String getTokenValue(String actualToken, String token) {
byte[] actualBytes;
try {
// ここでPOSTされたトークン(HTTPヘッダのX-CSRF-TOKENの値かパラメータの_csrfの値)がデコードされている
actualBytes = Base64.getUrlDecoder().decode(actualToken);
}
catch (Exception ex) {
return null;
}
byte[] tokenBytes = Utf8.encode(token);
int tokenSize = tokenBytes.length;
// actualTokenとtokenが同一の文字列である場合、この分岐がtrueになり、
// CSRFトークンを付与していないのと同じことになる
if (actualBytes.length < tokenSize) {
return null;
}
// 前後は省略
}
XorCsrfTokenRequestAttributeHandlerを使わないように旧バージョンのフィルターで差し替えるという対策を紹介している記事もありましたが、それだとせっかくSpring Security 6を採用した意味が薄くなってしまいます。
…では、どうすればよいのでしょうか?
解決策
CSRFトークンをログイン処理時にコントローラーから取得し、それをヘッダに設定してPostするという方法で上記問題を回避できました。
@RestController
@RequestMapping("/api")
public class ApiController {
// CSRFトークンを返すためだけのパス
@PostMapping("/csrf")
public CsrfToken csrf(CsrfToken token) {
return token;
}
}
※詳細は公式をご確認ください。
※上の説明にあるとおり、CSRFトークンを外部ドメインに晒してしまってはこのややこしい実装をしている意味がなくなってしまいますから、CORSの設定も同時に有効にする必要がありますね。
SecurityFilterChain
の内容は以下のようになります。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// 前後の処理(ハンドラーや認証の本体等)は割愛
// ★CSRFトークンを返すパスはCSRF対策から除外
http.csrf(csrf -> csrf.ignoringRequestMatchers("/api/csrf"))
// ★CORSを有効にする
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeHttpRequests(authz ->
// ルートとSPAの起点になるindex.htmlは認証不要とする
authz.requestMatchers("/").permitAll()
.requestMatchers("/index.html").permitAll()
// ★ログインを受けるパスとCSRFを返すパスを除外
.requestMatchers(HttpMethod.POST, "/api/login").permitAll()
.requestMatchers(HttpMethod.POST, "/api/csrf").permitAll()
// リソース系のパスを除外
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.anyRequest().authenticated())
// 以下省略
}
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.addAllowedOrigin("http://localhost:8080");
corsConfiguration.addAllowedMethod(CorsConfiguration.ALL);
corsConfiguration.addAllowedHeader(CorsConfiguration.ALL);
corsConfiguration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource corsSource = new UrlBasedCorsConfigurationSource();
corsSource.registerCorsConfiguration("/**", corsConfiguration);
return corsSource;
}
}
続いて、JS(Axios)の実装です。
class MyAuth {
headers = {
'Content-type': 'application/json;charset=utf-8'
};
config = {
method: null,
url: 'http://localhost:8080/api/login',
headers : null,
data: null
};
constructor(){
this.config.headers = this.headers;
}
createConfig(){
return Object.assign({}, this.config);
}
async tryLogin(authInfo) {
const config = this.createConfig();
config.method = 'post';
// まず、CSRFトークンを取得するためのリクエストを送信
return Axios.post("/api/csrf").then(res => {
// CsrfTokenのフィールドがそのまま入ってくるため、
// CsrfTokenのheaderNameとtokenの値が対応するようにヘッダに設定する
config.headers[res.data.headerName] = res.data.token;
config.data = authInfo;
// ヘッダにCSRFトークン情報を加えて次のログインのリクエストを送信
return Axios.request(config)
.then(res => res);
}).catch(err => { throw err; });
}
}
以上により、無事認証のリクエストが通るようになりました。
もし困っている方がいたら、こちらが参考になれば幸いです。
(本当にこんなやり方しかないんですかねぇ…さすがにもっとスマートなやり方がありそうな。知っている方がいたら、コメント等でご教示いただけると大変助かります)