最近、現場でログイン機能を実装する機会がありました。
SpringSecurityというフレームワークを使用したのですが、クセが強くてとっつきにくかったので記事にしておこうと思いました!
完全に理解しきれておらず、変な部分もあるかもしれませんがご了承ください。
SpringSecurityとは
アプリケーションにセキュリティ機能を追加するフレームワークです。
主に、認証機能、認可機能を提供します!
ファイル構成
今回動作させたファイル構成になります。
├ src/main/java
└ com.example.demo
└ SecurityConfig.java
├ controller
├ AdminController.java
├ LoginController.java
└ SuccessController.java
├ model
├ CustomUserDetails.java
└ User.java
├ repository
├ LoginRepository.java
├ impl
└ LoginRepositoryImpl.java
└ mapper
└ LoginMapper.java
└ service.impl
└ UserDetailsServiceImpl.java
└ src/main/resource
├ com
└ example
└ demo
└ repository
└ mapper
└ LoginMapper.xml
└ templetes
├ admin.html
├ login.html
└ success.html
SpringSecuritryで最低限必要なクラス
- SecurityConfig.java → WebSecurityConfigurerAdapterを継承したクラス
- CustomUserDetails.java → UserDetailsを実装したクラス
- UserDetailsServiceImpl.java → UserDetailsServiceを実装したクラス
※クラス名は任意です。
SecurityConfig.java
このクラスはログインに関する設定を記述するクラスです。
やっていることは、上から
- UserDetailsServiceImplをインジェクション
- パスワードエンコーダーをbean登録
- cssやjsなどはSpringSecurity制限を対象外とする。
- 認証に関する設定を行う。
- AuthenticationManagerBuilderにUserDetailsServiceImpl、パスワードエンコーダーを登録しインジェクションする。
となります。
package com.example.demo;
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.builders.WebSecurity;
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 com.example.demo.service.impl.UserDetailsServiceImpl;
/**
* セキュリティ設定
*
* @author tk
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsServiceImpl userDetailsServiceImpl;
@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.formLogin().loginPage("/login")// ログイン画面
.loginProcessingUrl("/authenticate")// ログイン処理
.successForwardUrl("/")// 認証成功時の遷移URL
.failureUrl("/login?error")// 認証失敗時の遷移URL
.usernameParameter("username")// ユーザー名の項目名
.passwordParameter("password")// パスワードの項目名
.permitAll();
// ログアウトの処理
http.logout().logoutUrl("/logout")// ログアウト処理
.logoutSuccessUrl("/login")// ログアウト後の処理
.permitAll();
// 権限の設定
http.authorizeRequests().antMatchers("/admin")// 制限をかける処理
.hasRole("ADMIN")// ADMIN権限のみ"/admin"にアクセス可能
.anyRequest()
.authenticated();
}
@Autowired
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsServiceImpl).passwordEncoder(passwordEncoder());
}
}
CustomUserDetails.java
ユーザー情報を格納するクラスです。
認証が成功したユーザーはセッション情報として登録されます。
今回はユーザー名、パスワード、権限をフィールドに持っています。
package com.example.demo.model;
import java.util.ArrayList;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
/**
* ユーザー詳細実装
*
* @author tk
*/
public class CustomUserDetails implements UserDetails {
/** ユーザー名 */
private String username;
/** パスワード */
private String password;
/** 権限リスト */
private Collection<? extends GrantedAuthority> authorityList;
/**
* コンストラクタ
*
* @param username ユーザー名
* @param password パスワード
* @param authority 権限リスト
*/
public CustomUserDetails(String username, String password, String authority) {
this.username = username;
this.password = password;
Collection<GrantedAuthority> authorityList = new ArrayList<>();
authorityList.add(new SimpleGrantedAuthority(authority));
this.authorityList = authorityList;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorityList;
}
@Override
public String getUsername() {
return username;
}
@Override
public String getPassword() {
return password;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
isAccountNonExpired()、isAccountNonLocked()、isCredentialsNonExpired()、isEnabled()は今回使用しない為trueをリターンするようにしています。
仕様に応じて適切に実装して下さい。falseの場合の挙動は下記です。
メソッド | チェック内容 | スローされる例外 |
---|---|---|
isAccountNonExpired | アカウント非有効期限切れ | AccountExpiredException |
isAccountNonLocked | アカウント非ロック済み | LockedException |
isCredentialsNonExpired | パスワード非有効期限切れ | CredentialsExpiredException |
isEnabled | 利用可能 | DisabledException |
UserDetailsServiceImpl.java
このクラスでユーザー情報の取得、設定を行います。
今回は、実際にDBアクセスしてユーザー情報を取得しています。
※DBアクセスの部分は割愛。
package com.example.demo.service.impl;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import com.example.demo.model.CustomUserDetails;
import com.example.demo.model.User;
import com.example.demo.repository.LoginRepository;
/**
* ユーザー詳細サービス実装
*
* @author tk
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private LoginRepository loginRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if (StringUtils.isEmpty(username)) {
// 入力されていない場合は例外をスローする。メッセージは隠蔽される為適当で良い。
throw new UsernameNotFoundException("error!");
}
// ユーザー情報を取得。
User user = loginRepository.getOne(username);
if (user == null) {
// ユーザーが取得出来なかった場合は例外をスローする。メッセージは隠蔽される為適当で良い。
throw new UsernameNotFoundException("error!");
}
// UserDetails実装クラスに取得情報を詰め替えて、返却する。
return new CustomUserDetails(user.getUsername(), user.getPassword(), user.getRole());
}
}
このクラスでは、ユーザー情報が存在しなかった場合にUsernameNotFoundExceptionをスローします。
引数にはStringでメッセージを渡しますが、SpringSecurityさんがBad Credentialsとメッセージを書き換えてしまいます。
これもセキュリティを考慮した仕様なんだなあと思っています。
DB取得に使用しているUserクラスも一応載せておきます。
package com.example.demo.model;
/**
* ユーザー情報モデル
*
* @author tk
*
*/
public class User {
/** ユーザー名 */
private String username;
/** パスワード */
private String password;
/** 権限リスト */
private String authority;
/**
* ユーザー名取得
*
* @return ユーザー名
*/
public String getUsername() {
return username;
}
/**
* ユーザー名設定
*
* @param username ユーザー名
*/
public void setUsername(String username) {
this.username = username;
}
/**
* パスワード取得
*
* @return パスワード
*/
public String getPassword() {
return password;
}
/**
* パスワード設定
*
* @param password パスワード
*/
public void setPassword(String password) {
this.password = password;
}
/**
* 権限リスト取得
*
* @return 権限リスト
*/
public String getRole() {
return authority;
}
/**
* 権限リスト設定
*
* @param role 権限リスト
*/
public void setRole(String role) {
this.authority = role;
}
}
その他のクラス
AdminController.java
package com.example.demo.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class AdminController {
/**
* 管理者ページ遷移処理
*
* @return 管理者画面
*/
@GetMapping("/admin")
public String adminPageTransition() {
return "admin";
}
}
LoginController.java
package com.example.demo.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
/**
* ログインコントローラー
*
* @author tk
*/
@Controller
public class LoginController {
/**
* ログイン処理
*
* @return ログイン画面
*/
@GetMapping("/login")
public String login() {
return "login";
}
}
SuccessController.java
package com.example.demo.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
/**
* サクセスコントローラー
*
* @author tk
*/
@Controller
public class SuccessController {
/**
* 認証成功時の処理
*
* @return サクセス画面
*/
@PostMapping("/")
public String init() {
return "success";
}
}
LoginRepository.java
package com.example.demo.repository;
import com.example.demo.model.User;
/**
* ログインリポジトリ
*
* @author tk
*/
public interface LoginRepository {
/**
* ユーザー情報取得処理
*
* @param username ユーザー名
* @return ユーザー情報
*/
User getOne(String username);
}
LoginRepositoryImpl.java
package com.example.demo.repository.impl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;
import com.example.demo.model.User;
import com.example.demo.repository.LoginRepository;
import com.example.demo.repository.mapper.LoginMapper;
/**
* ログインリポジトリ実装
*
* @author tk
*
*/
@Repository
public class LoginRepositoryImpl implements LoginRepository{
@Autowired
private LoginMapper loginMapper;
@Override
public User getOne(String username) {
return loginMapper.getOne(username);
}
}
LoginMapper.java
package com.example.demo.repository.mapper;
import org.apache.ibatis.annotations.Mapper;
import com.example.demo.model.User;
/**
* ログインマッパー
*
* @author tk
*/
@Mapper
public interface LoginMapper {
/**
* ユーザー情報取得処理
*
* @param username ユーザー名
* @return ユーザー情報
*/
User getOne(String username);
}
LoginMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.repository.mapper.LoginMapper">
<select id="getOne" resultType="com.example.demo.model.User">
select * from user where username = #{username}
</select>
</mapper>
login.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<title>login</title>
</head>
<body>
<div>
<div th:if="${param.error}">エラー: ユーザ名またはパスワードが違います。</div>
<div>
<form th:action="@{/authenticate}" method="post">
<label>ユーザー名</label> <input type="text" name="username" required />
<label>パスワード</label> <input type="password" name="password" required />
<div>
<input type="submit" value="ログイン" />
</div>
</form>
</div>
</div>
</body>
</html>
success.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<title>success</title>
</head>
<body>
<div>
<div>ログイン成功!!!</div>
<a href="/admin">あなたは管理者ページに遷移できますか?</a>
<form role="form" id="logout" th:action="@{/logout}" method="post">
<button type="submit">ログアウト</button>
</form>
</div>
</body>
</html>
admin.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<title>admin</title>
</head>
<body>
<div>
管理者のみ見れます。
</div>
</body>
</html>
動作確認
今回使用するデータです。
username | password | role |
---|---|---|
user1 | password(暗号化して登録されている想定) | ADMIN |
user2 | password(暗号化して登録されている想定) | USER |
まずUserDetailsServiceImplでのエラー処理を確認します。
次に認証チェックエラーの確認です。
パスワードが誤っている(パスワードにaaaaaaaaと入力)
認証チェックはUserDetailsServiceImplからUserDetailsの実装クラスがリターンされた後にSpringSecurityさんが入力パスワードを暗号化して照合しています。
では、実際にログインします。
まずはuser1でログインします。
user1にはADMINというrole値を持たせています。
SpringSecurityの権限の部分の設定を振り返ってみます。
// 権限の設定
http.authorizeRequests().antMatchers("/admin")// 制限をかける処理
.hasRole("ADMIN")// ADMIN権限のみ"/admin"にアクセス可能
.anyRequest()
.authenticated();
今回、管理者のみ行える処理を/adminとしております。
role値がADMINの人だけが/adminにアクセス出来るよっていう設定をしています。
「あなたは管理者ページへ遷移できますか?」を押すと/adminにアクセスします。
押してみましょう。
無事遷移できました!
今度は、USERというrole値を持つuser2でログインしてみましょう。
しっかり権限制御出来ています!