#前置きと環境
Spring-Bootを使って簡単なログイン機能を実装しました。
似たようなことはPHPで一回やってるし余裕っしょーって思ったらめちゃくちゃ苦戦しまくったので備忘録としてまとめておきます。
全てを網羅して書くと力尽きてしまうので、詰まった部分を中心にざっくりと書いていこうと思います。
ここ間違ってるよ!などありましたらやさしく教えて頂けると喜びます(╹◡╹)
###環境
- Spring-Boot(2.0.4)
- MySQL(ユーザ情報、セッション情報格納用)
- Spring-Security(認証・認可処理に利用)
#処理の流れ
今回の処理の流れは以下のようになります。
画面は「hello.html」、「login.html」が存在し、hello.htmlはログインしないと見られない画面とします。
①認可処理
hello.htmlへログインなしにアクセスしようとするとlogin.htmlへ遷移
②認証処理
フォームへ入力された「ユーザ名」、「パスワード」の値をDBに格納された値と比較。合致するものが存在した場合は認証成功。
存在しない場合はlogin.htmlへ遷移し、エラーメッセージを表示。
③セッション管理
認証に成功した場合、セッション情報をDBへ格納します。必要に応じてセッション情報からユーザ名を取得できるようにします。
④セッション破棄
ログアウトボタンが押されると、セッション情報を破棄し、再度認証が必要な状態となります。
これらの処理を図にまとめると以下のようになります。
これらを一気に実装すると頭がパンクしてしまうので、今回は3ステップに分けて実装を行いました。
- ステップ1: 特定のユーザ名・パスワードを入れたら通るよ!
- ステップ2: DBに格納されているユーザ名・パスワードの組だけ通すよ!
- ステップ3: ログイン情報をセッションに乗せて誰がログインしてるか管理するよ!
以下では各ステップについて実装手順を見ていきたいと思います。
##依存関係
その前にSpring-Bootでプロジェクトを作るときの依存関係を記述しておきます。
詳しいあれこれはpom.xmlをば。
#ステップ1. 「user」、「password」入れたら通るよ!
まずは土台作りから始めていきます。このステップで作るのは、以下のものとなります。
- hello.html(認証済みユーザのみアクセスできる画面)
- login.html(ログイン画面)
- WebSecurityConfigクラス(認証・認可についての設定を記述したクラスファイル)
- MvcConfigクラス(URLとビュー名の対応づけを行うためのクラスファイル)
まずは全てのベースとなる設定ファイルから見ていきます。が、その前に...
##コンフィグファイル難しそう問題
コンフィグファイルをいじくり回すのってなんか難しそう...っていうことで最初はSpring-Securityをとっつきづらいものだと思っていました。
しかし、Spring-SecurityではJavaクラスの形で設定を記述することができるので、一回書いてしまえばなんとなく感覚が掴めるような気がします。
設定を管理しているクラスのプロパティに値(システム用の設定)を足し込んでいく感じで設定できるのは、ゲームとかで音量とか画質とか弄るあれこれになんとなく近い感覚がしたので直感的に理解しやすいんじゃないかなーと思います。
MvcConfigは特に分かりやすいのでコンフィグを使うのは初めてという場合にはそちらから触ってみるのもよいかもしれないです。
コンフィグファイルを最初見たときに普段書いてるコードと見た目が全然違ってうっ...ってなってしまったので実際にコードを見ていく前に一応書いておきます。
package login.app.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import login.app.service.UserDetailsServiceImpl;
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsServiceImpl userDetailsService;
//フォームの値と比較するDBから取得したパスワードは暗号化されているのでフォームの値も暗号化するために利用
@Bean
public BCryptPasswordEncoder passwordEncoder() {
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
return bCryptPasswordEncoder;
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers(
"/images/**",
"/css/**",
"/javascript/**"
);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login") //ログインページはコントローラを経由しないのでViewNameとの紐付けが必要
.loginProcessingUrl("/sign_in") //フォームのSubmitURL、このURLへリクエストが送られると認証処理が実行される
.usernameParameter("username") //リクエストパラメータのname属性を明示
.passwordParameter("password")
.successForwardUrl("/hello")
.failureUrl("/login?error")
.permitAll()
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
.permitAll();
}
@Autowired
public void configure(AuthenticationManagerBuilder auth) throws Exception{
auth
.inMemoryAuthentication()
.withUser("user").password("{noop}password").roles("USER");
}
}
基本的な部分は公式さんが丁寧にまとめてくれているので、何言ってるかわかんねーよってなったらそちらを参照された方がよいかと思います。
それでは各処理について見ていきたいと思います。
###アノテーション
@Configurationアノテーションは、このクラスは設定ファイルだからちゃんと読んでね!ってSpringさんに伝えるためのものです。それに付随している@EnableWebSecurityアノテーションは、Spring-Securityのあれこれを使うためのものとなっています。
###継承クラス
このクラスは「WebSecurityConfigurerAdapter」クラスを継承しており、設定で使うメソッドをいくつかオーバーライドして使っていく形となります。
今回は、configureメソッドをオーバーライドして認証・認可処理の設定を行っていきます。
###configureメソッド(WebSecurity)
ここでは静的ファイル(image,css,javascript)を利用する際のリクエストまで弾いてしまわないための設定を行っています。
###configureメソッド(HttpSecurity)
認証・認可の処理の中でhttpリクエスト関連の部分についての設定を記述するためのメソッドです。
ドットがいっぱい並んでいて一見難しそうに見えますがやっていることは個別の設定をセットしているだけなので、チュートリアルとか漁っていればあーなるほどそういことねってなります、きっと。
ここでは何を設定しているか、ということをざっくりとリスト化しておきます。
- アプリ内の画面は基本的に認証しないと見られない
- ログイン画面は「/login」に存在(後述)
- 認証処理は「/sign_in」へリクエストが送られると実行される
- フォームの入力値のname属性は「username」「password」とする
- ログインに成功したら「/hello」へ遷移
- ログイン失敗/ログアウト時にはlogin画面へgetパラメータを渡して遷移
###configureメソッド(AuthenticationManagerBuilder)
ここでは、inMemoryAuthentication()でインメモリで認証を行うことを明示し、認証に利用するユーザ名、パスワードを直接セットしています。いまどきのアプリでDBを使わないということはまずないかと思いますので、認証処理についてはDBを利用する部分で改めて見ていきたいと思います。
####Autowiredの有無について
チュートリアルとかを見ているとconfigureGlobalというメソッド名だったり@Autowiredアノテーションがついていたり、というのをよく見かけるかと思います。@Autowiredといえばフィールドに用いているのをよく見かけるかと思いますが、メソッドにつけることもできます。
今回のコンフィグクラスについては、@Autowiredアノテーションのついていないメソッドについては名前をconfigureに、ついているメソッドは任意の名前を設定することができる、というようになっています。参考
次にViewNameに関する設定ファイルについて見ていきます。
package login.app.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class MvcConfig implements WebMvcConfigurer {
/**
* 「/login」というURLからlogin.htmlを呼び出す
*/
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/login").setViewName("login");
}
}
通常、Spring-Bootではコントローラが存在し、@RequesMappingアノテーションでリクエストを拾い、何らかの処理をした後、画面(html)用のビュー名を返し、画面を表示する、ということをやっています。
しかし、ログイン処理についてはSpring-Security先生がよろしくやってくれていますので、LoginControllerとかそんな感じのものは実装しなくても問題はないかと思います。ただ、ビュー名とURLの対応づけだけは別でやっておく必要があるので、今回の設定ファイルに記述します。
ここでは、「/login」というURLでリクエストが来たら「login」ってビュー名(login.html)で表示してねって処理を記述しています。もっといい感じの記述方法があったら教えて頂けるととても嬉しいです。
hello.htmlはただ挨拶しているだけなので省略しますが、login.htmlについては少しハマった部分があるので書いておきます。
<form th:action="@{/sign_in}" method="post">
<input type="text" name="username">user_name
<br/>
<input type="password" name="password">password
<br/>
<input type="submit" value="Login">
</form>
ここでの注意点は、name属性の値は設定ファイルに記述したものに合わせる、ということと、フォームの送信先をth:action属性で指定することです。
ただ、「/sign_in」と書くとそんなURLはありませんって怒られてしまいます。
これは、デフォルトでSpring-SecurityのCSRF対策が有効になっていることが原因となっています。この状態で何も考えずにacrtionに素のURLを渡すと、CSRF対策用のトークンが乗せられていないため怒られます。
これを解消するには色々と方法がありますが、今回はThymeleafで「th:action=@{URL}」と設定してあげることでトークンを生成してURLに乗せてくれるようになっています。参考
また、送信先のURLはデフォルトでは長ったらしい感じのURLが認証処理を行うURLとなっていますが、設定ファイルで明示してあげるのがよいかと思います。
ここまでの実装で実行してあげるとログイン画面が表示されるので、「user」「password」と入れてあげるとhello.htmlへ遷移できるようになります。実行結果を毎度キャプチャするのを忘れてしまったので実行画面はステップ3でのみ表示しますがご容赦くださいませ。
#ステップ2.DBでユーザが見つかったら通すよ!
ステップ1でログインしたユーザだけがページを見られるようにする機能を実装できました。しかし、みんなで共通のユーザ名・パスワードを使っていては「誰がログインしたか」を識別することはできません。そもそもセキュリティががばがば過ぎですね。
今回のステップでは、DBに登録されているユーザのみがログインできる機能を追加します。必要となるものは以下となります。
- LoginUser(エンティティ)
- LoginUserDao(DBとのアクセスメソッドを実装したクラス)
- UserDetailsServiceImpl(認証処理を行うときに呼び出されるメソッドを定義したクラス)
ここではログイン機能を作ることが目的なので、EntityやEntityManagerの使い方については割愛しますが、この辺についてもいずれまとめたいと思っています。
ということで、UserDetailsServiceImplクラスについて見ていきます。
package login.app.service;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import login.app.dao.LoginUserDao;
import login.app.entity.LoginUser;
@Service
public class UserDetailsServiceImpl implements UserDetailsService{
//DBからユーザ情報を検索するメソッドを実装したクラス
@Autowired
private LoginUserDao userDao;
@Override
public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
LoginUser user = userDao.findUser(userName);
if (user == null) {
throw new UsernameNotFoundException("User" + userName + "was not found in the database");
}
//権限のリスト
//AdminやUserなどが存在するが、今回は利用しないのでUSERのみを仮で設定
//権限を利用する場合は、DB上で権限テーブル、ユーザ権限テーブルを作成し管理が必要
List<GrantedAuthority> grantList = new ArrayList<GrantedAuthority>();
GrantedAuthority authority = new SimpleGrantedAuthority("USER");
grantList.add(authority);
//rawDataのパスワードは渡すことができないので、暗号化
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
//UserDetailsはインタフェースなのでUserクラスのコンストラクタで生成したユーザオブジェクトをキャスト
UserDetails userDetails = (UserDetails)new User(user.getUserName(), encoder.encode(user.getPassword()),grantList);
return userDetails;
}
}
認証処理を行うための情報を取得しているメソッドloadUserByUsernameの処理の流れは以下のようになります。
- Daoクラス内のメソッドでユーザ名からDBを検索
- 見つかったユーザ情報はEntityクラスへ格納
- 権限情報を設定(ここではダミーデータを設定)
- パスワードを暗号化
- UserDetailsオブジェクトを生成し、認証処理へ渡す
それぞれについて見ていきたいと思います。
###権限情報
Spring-Securityでは認証情報に権限(Admin,Userなど)も渡すことが必須となっています。
が、ここではUserさえいれば良いので、SimpleGrantedAuthorityから仮で権限情報を生成し、GrantedAuthorityのリストへぶち込んでおきます。権限も設定したい場合は権限テーブルやユーザ権限テーブルをDBに作ったりエンティティを増やしたりといった処理が必要になってきますが、今回はとりあえずログインできればOKなので割愛します。
###暗号化
Spring-Securityではパスワードは原則暗号化したものを利用することとなっています。暗号化については全く知識がないので有名どころをとりあえず使ってます、という感じですが、実際のアプリを作るときはそれぞれの手法についての知識、DBへユーザ情報を格納する段階でパスワードを暗号化する、といったことが必要になるかと思います。
###UserDetailsオブジェクト
Spring-Securityではユーザ情報をユーザのEntityで渡すのではなく、UserDetailsというユーザ名、暗号化されたパスワード、権限情報によって作られたUserDetails型のオブジェクトを認証処理に渡します。
UserクラスはUserDetailsインタフェースの実装クラスなのでそのまま戻り値として利用することもできますが、メソッドの型に合わせてキャストしてから返すのがよいかと思います。
続いて、認証情報がインメモリからDBへと変更されたので、設定ファイルの変更された部分について見ていきたいと思います。
public void configure(AuthenticationManagerBuilder auth) throws Exception{
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
ここでは、認証にuserDetailsServiceを用いることを指定し、更に引数に先ほど作成した実装クラスを渡してあげると、DaoAuthenticationProviderというクラスが呼ばれ、認証の中で実装したloadUserByUsernameメソッドを呼んでくれるようになります。
passwordEncoderにはDBのものと同じ暗号化方式を指定し、フォームの入力値とDBのパスワードを比較し認証処理を行うために利用します。
ここまでの実装でもう一度login.htmlでフォームを送信すると、DBの検索を行い、DB内に存在するユーザのみを認証に通すようにしてくれます。やったね。
#ステップ3. セッションで誰がログインしてるか見るよ!
最後にセッション情報の管理を行い、以下の機能を実現します。
- セッションの有効期間中は継続してログイン可能(ブラウザを閉じてもログインした記録は残る)
- セッションの情報から「誰がログインしているか」の情報を取得
- ログアウトボタンからセッション情報を破棄する
セッション管理の方法は色々とありますが、今回はMySQL上にぶち込みます。
といっても、やることとしては、用意されたセッションテーブル生成クエリを実行し、application.propertiesと依存関係をちょろちょろっと弄るだけです。中身の処理はSpring-Securityさんがよろしくやってくれます。
セッション管理はNoSQLでRedisを使ったりとかセッションIDをどうしているのかとかとかここには書ききれないことがあるので、今回はログイン機能を作る上でのざっくりとした利用方法を書くのみに留め、細かい内容についてはまた別で見ていきたいと思います。
###セッション格納テーブルの作成・設定
作成自体は上記のリンクで自分が使っているSQLごとのCREATE文を実行してあげればOKです。
アプリの中でのセッションがどのような動きをしているのかは、このテーブルの中身を逐次SELECTすると見ることができるかと思います。その辺はちゃんと知っておく必要があるのでもう少し整理してから書きたいと思います。
続いてテーブルを利用するための設定ですが、これはapplication.propertiesとpom.xmlにちょろっと書き足すだけでOKです。ありがたや。
spring.session.store-type=jdbc
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-jdbc</artifactId>
</dependency>
pom.xmlについてはプロジェクト生成の段階での依存関係の一覧にはおそらく無いと思いますので、手で追加してあげる必要があります。これが分からなくてけっこう詰まりました。
###セッション情報の読み取り
hello.htmlで「こんにちは、ユーザ名さん!」といった感じで表示するためには、今誰がログインしているか、という情報が必要です。これは、ログインに成功した段階で遷移するHelloControllerのinitメソッドで実装してあげます。
@RequestMapping("/hello")
private String init(Model model) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
//Principalからログインユーザの情報を取得
String userName = auth.getName();
model.addAttribute("userName", userName);
return "hello";
}
ここでポイントとなるのは、SecurityContextHolderです。これは現在のログインユーザの情報を格納したものなのですが、実態はHttpSessionです。
つまり、Spring-Security上で定義されたセッション情報に入っているユーザ情報をキーにセッション情報を格納したDBを参照し、対応したユーザのログイン状態を設定する、ということやっています。ややこしいですね。この辺はきちんと整理してまた別の記事でまとめます(o_o)
本来のアプリなら取得したユーザ名からユーザID辺りをDBから拾ってユーザ個別の情報を更に読み出して...ということをやっていくのですが、今回はリクエストスコープにユーザ名をぽんと乗せるだけに留めておきます。
この状態でhello.htmlへ遷移することで、画面にログインユーザの名前を表示することができるようになります。
###ログアウト
ログアウト時にはセッション情報を破棄したりだとか色々やることはありますが、Spring-Security先生の「/logout」へリクエストを投げてくれればよろしくやってくれます。自分の手でログアウト処理を実装することもできますが、CSRFのあれこれとかも上手くやってくれるみたいなので、任せておくのが無難かと思います。
しかし、処理の中で何が必要で、セッションのDB情報はどのように変わるか、ということは押さえておく必要があるかと思います。その辺は整理して(以下略)
一点だけ注意点がございまして、hello.html上で「/logout」へ遷移するときは必ず「POST」メソッドで遷移しないといけないみたいです。CSRFのあれこれをよろしくやるためのものみたいですが理解がふわふわなのでセキュリティのお勉強をもう少し頑張りたいです。
#実行結果
これで一通りの機能を実装できましたやったー。
最後に一連の処理の実行結果を乗せておきたいと思います。
今回の一連の実装のソースコードはGitHubにあげておきます。sqlフォルダに今回のあれこれをコマンド一発でやるためのsqlファイルも置いてありますのでよろしければ。