概要
以前に『Spring Security with Spring Boot 2.0で簡単なRest APIを実装する』という記事を作成しましたが、今回画面のあるシンプルなデモサイトを作成しましたので改めて記事を作成しました。
ソースコードはrubytomato/demo-java12-securityにあります。
環境
- Windows 10 Professional 1903
- OpenJDK 12.0.2
- Spring Boot 2.1.9
- Spring Security 5.1.6
- MySQL 8.0.17
参考
- [Spring Security Reference Doc] (https://docs.spring.io/spring-security/site/docs/5.1.6.RELEASE/reference/htmlsingle/)
- [Spring Security API Doc] (https://docs.spring.io/spring-security/site/docs/5.1.6.RELEASE/api/)
作成するデモサイトの主な機能
- アカウント登録・削除機能
- アカウント登録内容の変更機能
- メールアドレス/パスワードでログイン
- ログアウト
- ロールによるアクセス制御
- セッションによる多重ログイン制御
- CSRF対策
- REMEMBER-ME機能で自動ログイン
認証・認可情報の管理
認証・認可情報は下記のユーザーテーブルで管理します。
DROP TABLE IF EXISTS user;
CREATE TABLE user (
id BIGINT AUTO_INCREMENT COMMENT 'ユーザーID',
name VARCHAR(60) NOT NULL COMMENT 'ユーザー名',
email VARCHAR(120) NOT NULL COMMENT 'メールアドレス',
password VARCHAR(255) NOT NULL COMMENT 'パスワード',
roles VARCHAR(120) COMMENT 'ロール',
lock_flag BOOLEAN NOT NULL DEFAULT 0 COMMENT 'ロックフラグ 1:ロック',
disable_flag BOOLEAN NOT NULL DEFAULT 0 COMMENT '無効フラグ 1:無効',
create_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
update_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id)
)
ENGINE = INNODB
DEFAULT CHARSET = UTF8MB4
COMMENT = 'ユーザーテーブル';
ALTER TABLE user ADD CONSTRAINT UNIQUE KEY UKEY_user_email (email);
認証 (Authentication)
サインインはメールアドレスとパスワードを使用するので、その情報を保存するためのメールアドレス(email)、パスワード(password)カラムを用意します。
メールアドレスでアカウントを一意に識別するためメールアドレスカラムにUNIQUE KEYを設定しています。パスワードは平文ではなくSpring Securityのパスワードエンコーダでハッシュ化した値を保存します。
認可 (Authorization)
一部のコンテンツへのアクセス制御はSpring Securityのロールを使用します。ロール(roles)カラムにはアカウントに付与したロール文字列をカンマ区切りで保存します。
また、ロックフラグ(lock_flag)は一時的に凍結する場合、無効フラグ(disable_flag)は永久的に凍結する場合にフラグを立てます。
アカウント削除時はデータを物理削除するため削除フラグは持ちません。
認証・認可以外の属性
また、認証・認可以外の属性はユーザープロフィールテーブルに保存します。この例ではニックネーム(nick_name)、アバター画像(avatar_image)を保存します。アバター画像はバイナリデータをBLOB型のカラムに保存しファイル自体は管理しません。
DROP TABLE IF EXISTS user_profile;
CREATE TABLE user_profile (
id BIGINT AUTO_INCREMENT COMMENT 'ユーザープロファイルID',
user_id BIGINT NOT NULL COMMENT 'ユーザーID',
nick_name VARCHAR(60) COMMENT 'ニックネーム',
avatar_image MEDIUMBLOB COMMENT 'アバターイメージ',
create_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
update_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id)
)
ENGINE = INNODB
DEFAULT CHARSET = UTF8MB4
COMMENT = 'ユーザープロフィールテーブル';
ALTER TABLE user_profile ADD CONSTRAINT FOREIGN KEY FKEY_user_profile_id_user_id (user_id) REFERENCES user (id);
ロール
一部のページはロールによるアクセス制御を行います。
用意するロールは下記の2種類です。1アカウントに複数設定することも全く設定しないこともできます。
ロール | 想定する用途 |
---|---|
ロール無し、ロールが無くても認証は可能 | |
ROLE_USER | 一般ユーザー向けのロール |
ROLE_ADMIN | 特権ユーザー向けのロール |
エンドポイント一覧
- 認証欄が
no
のエンドポイントは匿名(Anonymous)でのアクセスを許可。 - 認証欄が
yes
のエンドポイントは認証されたアカウントのみアクセスを許可。さらにロールが指定されている場合、アクセスするアカウントに当該のロールが付与されている場合にアクセスを許可。 - ロール欄が
-
のエンドポイントは認証されていればアクセスを許可。 - /login、/logoutは、Spring Securityが用意するエンドポイント。
- ページのスクリーンショットはページ末尾の補足欄に掲載。図Noと対応。
エンドポイント | method | 認証 | ロール | 図No | 備考 |
---|---|---|---|---|---|
/ | GET | no | 1 | トップページ 兼 サインインページ | |
/menu | GET | no | 2 | メニューページ | |
/signup | GET | no | 3 | アカウント登録ページ | |
/signup | POST | no | アカウント登録処理の実行、登録後は/へリダイレクト | ||
/signin | GET | no | 4 | サインインページ | |
/login | POST | no | サインイン処理、Spring Securityが用意するエンドポイント、サインイン後は/へリダイレクト | ||
/signout | GET | yes | - | 5 | サインアウトページ |
/logout | POST | yes | - | サインアウト処理、Spring Securityが用意するエンドポイント、サインアウト後は/へリダイレクト | |
/account/change/password | GET | yes | - | 6 | パスワード変更ページ |
/account/change/password | POST | yes | - | パスワード変更処理、変更後は/へリダイレクト | |
/account/change/role | GET | yes | - | 7 | ロール変更ページ |
/account/change/role | POST | yes | - | ロール変更処理、変更後は/へリダイレクト | |
/account/change/profile | GET | yes | - | 8 | プロフィール変更ページ |
/account/change/profile | POST | yes | - | プロフィール変更処理、変更後は/へリダイレクト | |
/account/delete | GET | yes | - | 9 | アカウント削除ページ |
/account/delete | POST | yes | - | アカウント削除処理、削除後は/へリダイレクト | |
/memo | GET | yes | USER, ADMIN | USER or ADMIN Role コンテンツページ | |
/user | GET | yes | USER | USER Role コンテンツページ | |
/admin | GET | yes | ADMIN | ADMIN Role コンテンツページ | |
/error/denied | GET | no | エラーページ、アクセス拒否時 | ||
/error/invalid | GET | no | エラーページ、セッション無効時 | ||
/error/expired | GET | no | エラーページ、セッション期限切れ |
セキュリティ周りの実装
データベースアクセス
データベースアクセスにはJPAを使用します。
データベースアクセス周りではSpring Securityと関連する特別な実装はありません。
エンティティ
認証・認可情報を管理するユーザーテーブルに対応するエンティティクラスの実装は以下のとおりです。
import com.example.demo.auth.UserRolesUtil;
import lombok.Data;
import lombok.ToString;
import javax.persistence.CascadeType;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.OneToOne;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
import javax.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "user")
@Data
@ToString(exclude = {"password"})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", length = 60, nullable = false)
private String name;
@Column(name = "email", length = 120, nullable = false, unique = true)
private String email;
@Column(name = "password", length = 255, nullable = false)
private String password;
@Column(name = "roles", length = 120)
private String roles;
@Column(name = "lock_flag", nullable = false)
private Boolean lockFlag;
@Column(name = "disable_flag", nullable = false)
private Boolean disableFlag;
@Column(name = "create_at", nullable = false)
private LocalDateTime createAt;
@Column(name = "update_at", nullable = false)
private LocalDateTime updateAt;
@OneToOne(mappedBy = "user", cascade = CascadeType.ALL)
private UserProfile userProfile;
public void setUserProfile(UserProfile userProfile) {
this.userProfile = userProfile;
userProfile.setUser(this);
}
public String getAvatarImageBase64Encode() {
return this.userProfile.getAvatarImageBase64Encode();
}
@PrePersist
private void prePersist() {
this.lockFlag = Boolean.FALSE;
this.disableFlag = Boolean.FALSE;
this.createAt = LocalDateTime.now();
this.updateAt = LocalDateTime.now();
}
@PreUpdate
private void preUpdate() {
this.updateAt = LocalDateTime.now();
}
public static User of(String name, String email, String encodedPassword, String[] roles) {
return User.of(name, email, encodedPassword, roles, new UserProfile());
}
public static User of(String name, String email, String encodedPassword, String[] roles,
UserProfile userProfile) {
User user = new User();
user.setName(name);
user.setEmail(email);
user.setPassword(encodedPassword);
String joinedRoles = UserRolesUtil.joining(roles);
user.setRoles(joinedRoles);
user.setUserProfile(userProfile);
return user;
}
}
import lombok.Data;
import lombok.ToString;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.Lob;
import javax.persistence.OneToOne;
import javax.persistence.PostLoad;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
import javax.persistence.Table;
import javax.persistence.Transient;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.Base64;
@Entity
@Table(name = "user_profile")
@Data
@ToString(exclude = {"user", "avatarImage", "avatarImageBase64Encode"})
public class UserProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "nick_name", length = 60)
private String nickName;
@Lob
@Column(name = "avatar_image")
private byte[] avatarImage;
@Column(name = "create_at", nullable = false)
private LocalDateTime createAt;
@Column(name = "update_at", nullable = false)
private LocalDateTime updateAt;
@OneToOne
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Transient
private String avatarImageBase64Encode;
public void setAvatarImage(byte[] avatarImage) {
this.avatarImage = avatarImage;
this.avatarImageBase64Encode = base64Encode();
}
String getAvatarImageBase64Encode() {
return avatarImageBase64Encode == null ? "" : avatarImageBase64Encode;
}
private String base64Encode() {
return new String(Base64.getEncoder().encode(avatarImage), StandardCharsets.US_ASCII);
}
@PostLoad
private void init() {
this.avatarImageBase64Encode = base64Encode();
}
@PrePersist
private void prePersist() {
this.avatarImage = new byte[0];
this.createAt = LocalDateTime.now();
this.updateAt = LocalDateTime.now();
}
@PreUpdate
private void preUpdate() {
this.updateAt = LocalDateTime.now();
}
}
リポジトリ
メールアドレスで検索するメソッドを追加します。
import com.example.demo.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
セキュリティコンフィグレーション
Spring SecurityのコンフィグレーションはWebSecurityConfigurerAdapter
抽象クラスを継承します。主なコンフィグレーションはAuthenticationManager
、WebSecurity
、HttpSecurity
の3つです。
import com.example.demo.auth.SimpleUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.AuthenticationManager;
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.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private SimpleUserDetailsService simpleUserDetailsService;
private PasswordEncoder passwordEncoder;
@Autowired
public void setSimpleUserDetailsService(SimpleUserDetailsService simpleUserDetailsService) {
this.simpleUserDetailsService = simpleUserDetailsService;
}
@Autowired
public void setPasswordEncoder(PasswordEncoder passwordEncoder) {
this.passwordEncoder = passwordEncoder;
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// ...コンフィグレーション 省略 (下記に記載)...
}
@Override
public void configure(WebSecurity web) throws Exception {
// ...コンフィグレーション 省略 (下記に記載)...
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// ...コンフィグレーション 省略 (下記に記載)...
}
}
UserDetails (principal)
Spring Securityが使用するユーザー情報クラスをUserDetails
、CredentialsContainer
インタフェースを実装して作成します。
equals
とhashCode
メソッドのオーバーライドも適切に行う必要があります。このメソッドを適切に実装していないとセッション管理の多重ログインチェックが機能しません。
import com.example.demo.model.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.CredentialsContainer;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.Objects;
import java.util.Set;
@Slf4j
public class SimpleLoginUser implements UserDetails, CredentialsContainer {
private static final long serialVersionUID = -888887602572409628L;
private final String username;
private String password;
private final Set<GrantedAuthority> authorities;
private final boolean accountNonExpired;
private final boolean accountNonLocked;
private final boolean credentialsNonExpired;
private final boolean enabled;
private final User user;
public SimpleLoginUser(User user) {
if ((Objects.isNull(user.getEmail()) || "".equals(user.getEmail())) ||
(Objects.isNull(user.getPassword()) || "".equals(user.getPassword()))) {
throw new IllegalArgumentException(
"Cannot pass null or empty values to constructor");
}
this.username = user.getEmail();
this.password = user.getPassword();
this.authorities = UserRolesUtil.toSet(user.getRoles());
this.accountNonExpired = true;
this.accountNonLocked = !user.getLockFlag();
this.credentialsNonExpired = true;
this.enabled = !user.getDisableFlag();
this.user = user;
}
@Override
public String getUsername() {
return this.username;
}
@Override
public String getPassword() {
return this.password;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public boolean isAccountNonExpired() {
return this.accountNonExpired;
}
@Override
public boolean isAccountNonLocked() {
return this.accountNonLocked;
}
@Override
public boolean isCredentialsNonExpired() {
return this.credentialsNonExpired;
}
@Override
public boolean isEnabled() {
return this.enabled;
}
@Override
public void eraseCredentials() {
this.password = null;
}
public User getUser() {
return user;
}
@Override
public boolean equals(Object rhs) {
if (!(rhs instanceof SimpleLoginUser)) {
return false;
}
return this.username.equals(((SimpleLoginUser)rhs).username);
}
@Override
public int hashCode() {
return this.username.hashCode();
}
}
なお特別な実装を行わないのであれば、リファレンス実装であるorg.springframework.security.core.userdetails.User
を継承する方法が手軽です。
public class SimpleLoginUser extends User {
public SimpleLoginUser(String username, String password, Collection<? extends GrantedAuthority> authorities) {
super(username, password, authorities);
}
public SimpleLoginUser(String username, String password, boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) {
super(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities);
}
}
コントローラのハンドラメソッドで受け取る
認証が必要なエンドポイントのハンドラメソッドでユーザー情報クラスのオブジェクトを受け取ることができます。
受け取る引数に@AuthenticationPrincipal
アノテーションを付与します。
@PostMapping(value = "change/password")
public String changePassword(@AuthenticationPrincipal SimpleLoginUser loggedinUser,
@Validated ChangePasswordForm changePasswordForm, BindingResult result, Model model) {
User user = loggedinUser.getUser();
//...省略...
}
直接Userエンティティを受け取ることもできます。
@AuthenticationPrincipal(expression = "user") User user
テンプレート(Thymeleaf)からアクセスする
getPrincipal()
でユーザー情報クラス(SimpleLoginUser
)にアクセスできます。
${#authentication.getPrincipal()}
or
${#authentication.principal}
Userエンティティクラスにアクセスすることもできます。
${#authentication.getPrincipal().user}
or
${#authentication.principal.user}
UserDetailsService
Spring Securityが認証・認可に必要なユーザー情報(SimpleLoginUser
)を取得する具体的なコードをUserDetailsService
インターフェースを実装して作成します。
オーバーライドしなければならないメソッドはloadUserByUsername()
の1つだけで、この例では、メールアドレスからUserエンティティを検索し、Userエンティティをベースにユーザー情報(SimpleLoginUser)を生成します。
import com.example.demo.repository.UserRepository;
import lombok.extern.slf4j.Slf4j;
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 org.springframework.transaction.annotation.Transactional;
@Service
@Slf4j
public class SimpleUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public SimpleUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Transactional(readOnly = true)
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
log.debug("loadUserByUsername(email):[{}]", email);
return userRepository.findByEmail(email)
.map(SimpleLoginUser::new)
.orElseThrow(() -> new UsernameNotFoundException("User not found by email:[" + email + "]"));
}
}
AuthenticationManager
デフォルトのAuthenticationManagerをオーバーライドしてコンフィグレーションを行います。
AuthenticationManagerは実際の認証処理をAuthenticationProviderへ委譲します。AuthenticationProviderにはいくつかの実装があり、データベースからユーザー情報を取得するプロバイダーの実装がDaoAuthenticationProvider
です。
userDetailsService()
とpasswordEncoder()
がDaoAuthenticationProvider
のコンフィグレーションになります。
userDetailsService()
にUserDetailsService
インターフェースを実装したユーザー情報を取得するクラス(SimpleUserDetailsService
)を設定、passwordEncoder()
にパスワードエンコーダ(デフォルトはBCryptPasswordEncoder
)を設定します。
なお、eraseCredentials(true)
とすると認証後にユーザー情報クラス(SimpleLoginUser
)のパスワードがnullクリアされます。
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.eraseCredentials(true)
// ### DaoAuthenticationConfigurer
.userDetailsService(simpleUserDetailsService)
// ### DaoAuthenticationConfigurer
.passwordEncoder(passwordEncoder);
}
ちなみに、ユーザー情報をメモリにもつ認証の場合は下記のような実装になります。
auth
// ### InMemoryUserDetailsManagerConfigurer
.inMemoryAuthentication()
.withUser("user")
.password(passwordEncoder.encode("passxxx"))
.roles("USER")
.and()
.withUser("admin")
.password(passwordEncoder.encode("passyyy"))
.roles("ADMIN");
※このデモサイトで上記の実装を利用するようにした場合、ユーザー情報はSimpleLoginUserクラスではなくなります。
WebSecurity
@Override
public void configure(WebSecurity web) throws Exception {
// @formatter:off
web
.debug(false)
// ### IgnoredRequestConfigurer
.ignoring()
.antMatchers("/images/**", "/js/**", "/css/**")
;
// @formatter:on
}
HttpSecurity
Spring Securityの主要なコンフィグレーションを行います。コード(設定)量が多くなったので個別に記載しましたがアウトラインは下記のようになっています。
@Override
protected void configure(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeRequests()
//...リクエスト認可のコンフィグレーション 省略 (下記に記載)...
.and()
.exceptionHandling()
//...アクセス拒否のコンフィグレーション 省略 (下記に記載)...
.and()
.formLogin()
//...サインインのコンフィグレーション 省略 (下記に記載)...
.and()
.logout()
//...サインアウトのコンフィグレーション 省略 (下記に記載)...
.and()
.csrf()
//...CSRFのコンフィグレーション 省略 (下記に記載)...
.and()
.rememberMe()
//...Remember-Meのコンフィグレーション 省略 (下記に記載)...
.and()
.sessionManagement()
//...セッション管理のコンフィグレーション 省略 (下記に記載)...
;
// @formatter:on
}
リクエストの認可
// ### ExpressionUrlAuthorizationConfigurer
.authorizeRequests()
.mvcMatchers("/", "/signup", "/menu").permitAll()
.mvcMatchers("/error/**").permitAll()
.mvcMatchers("/memo/**").hasAnyRole("USER", "ADMIN")
.mvcMatchers("/account/**").fullyAuthenticated()
.mvcMatchers("/admin/**").hasRole("ADMIN")
.mvcMatchers("/user/**").hasRole("USER")
.anyRequest().authenticated()
permitAll
匿名(anonymous)を含めてだれでもアクセスを許可されます。
authenticated
認証されたアカウントがアクセスを許可されます。
fullyAuthenticated
自動ログイン(Remember-Me)以外の方法で認証されたアカウントがアクセスを許可されます。
hasRole/hasAnyRole
指定されたロールを持つアカウントがアクセスを許可されます。
アクセス拒否のハンドリング
アクセス拒否された場合の遷移先URLをaccessDeniedPage()
で設定します。
なお匿名の状態で認証が必要なページへアクセスした場合はアクセスを拒否されるのではなく、サインインページへ遷移し認証が成功すればそのページへ移動します。
// ### ExceptionHandlingConfigurer
.exceptionHandling()
// #accessDeniedUrl: the URL to the access denied page (i.e. /errors/401)
.accessDeniedPage("/error/denied")
// #accessDeniedHandler: the {@link AccessDeniedHandler} to be used
//.accessDeniedHandler(accessDeniedHandler)
AccessDeniedHandler
accessDeniedPage()
の代わりにAccessDeniedHandler
を実装してカスタマイズすることができます。
下記のコードはorg.springframework.security.web.access.AccessDeniedHandlerImpl
を参考にしました。
private AccessDeniedHandler accessDeniedHandler = (req, res, accessDeniedException) -> {
if (res.isCommitted()) {
log.debug("Response has already been committed. Unable to redirect to ");
return;
}
req.setAttribute(WebAttributes.ACCESS_DENIED_403, accessDeniedException);
res.setStatus(HttpStatus.FORBIDDEN.value());
RequestDispatcher dispatcher = req.getRequestDispatcher("/error/denied");
dispatcher.forward(req, res);
};
アクセス拒否時のエラーページ
<div class="row justify-content-center" th:if="${SPRING_SECURITY_403_EXCEPTION != null}">
<div class="col">
<div class="alert alert-warning" role="alert">
<h5 class="alert-heading">アクセス拒否</h5>
<p class="mb-0" th:text="${SPRING_SECURITY_403_EXCEPTION.message}">message</p>
</div>
</div>
</div>
サインイン
loginPage()
にはサインインフォームがあるページのURLを設定します。
認証に成功するとdefaultSuccessUrl()
の第2引数にtrue
を設定した場合、常に第1引数のURLへ移動します。false
を設定した場合、認証前に遷移しようとしていたURLへ移動します。
認証に失敗するとfailureUrl()
で設定したURLへ遷移します。
// ### FormLoginConfigurer
.formLogin()
// #loginPage: the login page to redirect to if authentication is required (i.e."/login")
.loginPage("/signin")
// #loginProcessingUrl: the URL to validate username and password
.loginProcessingUrl("/login")
.usernameParameter("email")
.passwordParameter("password")
// #defaultSuccessUrl: the default success url
// #alwaysUse: true if the {@code defaultSuccesUrl} should be used after authentication despite if a protected page had been previously visited
.defaultSuccessUrl("/", false)
// #successHandler: the {@link AuthenticationSuccessHandler}.
//.successHandler(successHandler)
// #authenticationFailureUrl: the URL to send users if authentication fails (i.e."/login?error").
.failureUrl("/signin?error")
// #authenticationFailureHandler: the {@link AuthenticationFailureHandler} to use
//.failureHandler(failureHandler)
.permitAll()
defaultSuccessUrl()
とfailureUrl()
の代わりに、それぞれAuthenticationSuccessHandler
、AuthenticationFailureHandler
を実装してカスタマイズすることができます。
AuthenticationSuccessHandler
private AuthenticationSuccessHandler successHandler = (req, res, auth) -> {
// カスタマイズした処理
};
AuthenticationFailureHandler
private AuthenticationFailureHandler failureHandler = (req, res, exception) -> {
// カスタマイズした処理
};
フォーム
サインインフォームのactionはloginProcessingUrl()
で設定したURLになります。
認証に必要なメールアドレスとパスワードの他に、REMEMBER-ME機能で自動ログインするか選択するチェックボックスを付けています。
<form class="text-center" action="#" th:action="@{/login}" method="post">
<div class="md-form">
<input type="text" id="email" name="email" class="form-control">
<label for="email">E-mail</label>
</div>
<div class="md-form">
<input type="password" id="password" name="password" class="form-control">
<label for="password">Password</label>
</div>
<div class="d-flex justify-content-around">
<div>
<div class="custom-control custom-checkbox">
<input type="checkbox" id="remember-me" name="remember-me" value="on" class="custom-control-input">
<label for="remember-me" class="custom-control-label">Remember me</label>
</div>
</div>
<div>
<p>Not a member? <a href="/app/signup" th:href="@{/signup}">Register</a></p>
</div>
</div>
<button class="btn indigo accent-4 text-white btn-block my-4" type="submit">Sign in</button>
</form>
認証失敗時のメッセージ表示
認証に失敗した場合、サインインページへerror
というパラメータを付けてリダイレクトします(/signin?error
)。
また、Spring Securityによりセッション属性にSPRING_SECURITY_LAST_EXCEPTION
という名前でAuthenticationExceptionクラス(またはこれを継承したクラス、認証情報が正しくない場合にスローされる例外はBadCredentialsException
)のオブジェクトが設定されています。
Thymeleafを利用したテンプレートでは、この2つの情報からメッセージを表示します。
<div class="row justify-content-center" th:if="${param['error'] != null && session['SPRING_SECURITY_LAST_EXCEPTION'] != null}">
<div class="col">
<div class="alert alert-warning" role="alert">
<h5 class="alert-heading">認証に失敗しました</h5>
<p class="mb-1" th:text="${session['SPRING_SECURITY_LAST_EXCEPTION'].message}">message</p>
</div>
</div>
</div>
アカウントがロックされていた場合のメッセージ表示
userテーブルのlock_flagに1を立てた状態でサインインしようとすると図のメッセージが表示されます。
なお、これらのフラグが検証されるのはサインインのときでログイン中のアカウントのフラグを立てても影響を受けません。
サインアウト
サインアウトはサインアウトページ(/signout
)のフォームから行います。これはCSRFを有効にしている場合、ログアウト(/logout
)もPOSTでリクエストしなければならないためです。
サインアウトに成功するとトップページ(/
)へリダイレクトし、且つセッションの付け替えとクッキーの削除を行います。
// ### LogoutConfigurer
.logout()
// #logoutUrl: the URL that will invoke logout.
.logoutUrl("/logout")
// #logoutSuccessUrl: the URL to redirect to after logout occurred
//.logoutSuccessUrl("/")
// #logoutSuccessHandler: the {@link LogoutSuccessHandler} to use after a user
.logoutSuccessHandler(logoutSuccessHandler)
// #invalidateHttpSession: true if the {@link HttpSession} should be invalidated (default), or false otherwise.
.invalidateHttpSession(false)
// #cookieNamesToClear: the names of cookies to be removed on logout success.
.deleteCookies("JSESSIONID", "XSRF-TOKEN")
ちなみにコンフィグレーションでLogoutSuccessHandler()
の代わりに、以下のように実装するとGETでログアウトできるようになります。
//.logoutSuccessHandler(logoutSuccessHandler)
.logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET"))
LogoutSuccessHandler
ログアウト成功後の処理をLogoutSuccessHandler()
で実装します。この例ではセッションIDを付け替えてトップページへリダイレクトします。
private LogoutSuccessHandler logoutSuccessHandler = (req, res, auth) -> {
if (res.isCommitted()) {
log.debug("Response has already been committed. Unable to redirect to ");
return;
}
if (req.isRequestedSessionIdValid()) {
log.debug("requestedSessionIdValid session id:{}", req.getRequestedSessionId());
req.changeSessionId();
}
RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
redirectStrategy.sendRedirect(req, res, "/");
};
今回はハンドラを実装しましたが、コンフィグレーションのlogoutSuccessUrl()
でログアウト後の遷移先URLを指定することもできます。
// #logoutSuccessUrl: the URL to redirect to after logout occurred
.logoutSuccessUrl("/")
フォーム
サインアウトフォームのactionはlogoutUrl()
で設定したURLになります。
<form class="text-center" action="#" th:action="@{/logout}" method="post">
<button class="btn indigo accent-4 text-white btn-block my-4" type="submit">Sign out</button>
</form>
CSRF
CSRFを有効にすると自動的にPOSTメソッドのフォームにCSRFトークンがhiddenで設定されます。
CSRFトークンの保存先はデフォルトではセッションになりますが、下記の実装で保存先をクッキーに変更しています。
// ### CsrfConfigurer
.csrf()
.csrfTokenRepository(new CookieCsrfTokenRepository())
例) パラメータ名は_csrf
です。
<input type="hidden" name="_csrf" value="18331a72-184e-4651-ae0b-e044283a20b3">
例) CSRFトークンのクッキー名はXSRF_TOKEN
です。デフォルトでHttpOnly属性が付きます。
以下のコードでクッキー名を変えることができます。
CookieCsrfTokenRepository customCsrfTokenRepository() {
CookieCsrfTokenRepository cookieCsrfTokenRepository = new CookieCsrfTokenRepository();
cookieCsrfTokenRepository.setCookieName("NEW-TOKEN-NAME");
return cookieCsrfTokenRepository;
}
Remember-Me
サインインフォームでRemember-Meにチェックしてサインインすると、REMEMBERMEという名前のクッキーが発行されます。このクッキーがある状態でデモサイトにアクセスすると匿名(Anonymous)であれば自動的に認証されます。
なお、Spring Securityの認証状態にはauthenticated
とfullyAuthenticated
があり、Remember-Meで自動ログインされた場合はauthenticated
かつRememberMe
として識別され、フォーム認証の場合はauthenticated
かつfullyAuthenticated
として識別されるので、自動ログインとフォーム認証は区別することができます。
// ### RememberMeConfigurer
.rememberMe()
// #alwaysRemember: set to true to always trigger remember me, false to use the remember-me parameter.
.alwaysRemember(false)
// #rememberMeParameter: the HTTP parameter used to indicate to remember the user
.rememberMeParameter("remember-me")
// #useSecureCookie: set to {@code true} to always user secure cookies, {@code false} to disable their use.
.useSecureCookie(true)
// #rememberMeCookieName: the name of cookie which store the token for remember
.rememberMeCookieName("REMEMBERME")
// # Allows specifying how long (in seconds) a token is valid for
.tokenValiditySeconds(daysToSeconds(3))
// #key: the key to identify tokens created for remember me authentication
.key("PgHahck5y6pz7a0Fo#[G)!kt")
authenticatedとfullyAuthenticatedの区別
以下のようなリクエスト認可のコンフィグレーションであれば/account/**
へのアクセスにはフォーム認証が必要となります。Remember-Meによる自動ログインの状態でアクセスしようとするとサインインページへ遷移し、明示的にフォーム認証を行う必要があります。
.authorizeRequests()
.mvcMatchers("/account/**").fullyAuthenticated()
.mvcMatchers("/admin/**").hasRole("ADMIN")
.mvcMatchers("/user/**").hasRole("USER")
.anyRequest().authenticated()
また、あるロールを持ち且つフォーム認証でなければ許可しないという制御は下記のように実装します。
この例では/admin/**
へのアクセスはADMINロールを持ち且つフォーム認証の場合に許可されます。
.mvcMatchers("/admin/**").access("hasRole('ADMIN') and isFullyAuthenticated()")
セッション管理
// ### SessionManagementConfigurer
.sessionManagement()
.sessionFixation()
.changeSessionId()
// #invalidSessionUrl: the URL to redirect to when an invalid session is detected
.invalidSessionUrl("/error/invalid")
// ### ConcurrencyControlConfigurer
// #maximumSessions: the maximum number of sessions for a user
.maximumSessions(1)
// #maxSessionsPreventsLogin: true to have an error at time of authentication, else false (default)
.maxSessionsPreventsLogin(false)
// #expiredUrl: the URL to redirect to
.expiredUrl("/error/expired")
セッション無効時のリダイレクト先
セッション無効時、つまりセッションクッキーが削除またはセッションIDが改ざんされていたり、サーバー側のセッション情報が無い場合の遷移先URLをinvalidSessionUrl()
で設定します。
セッションの有効期限
セッションの有効期限はアプリケーション設定ファイルで設定できます。
下記のように設定すると30分以上操作が無かった場合にセッションは無効となり、それ以降に操作するとinvalidSessionUrl()
で設定したURLへリダイレクトします。
server:
servlet:
session:
timeout: 30m
なおinvalidSessionUrl()
とexpiredUrl()
の両方を設定した場合、invalidSessionUrl()
が優先されるようです。
多重ログインの制御
同一アカウントで複数のログインを制御したい場合maximumSessions()
とmaxSessionsPreventsLogin()
で同時セッション数を制御できます。
maximumSessions()
で同時にログインできるセッション数を設定しmaxSessionsPreventsLogin()
で制御の挙動を設定します。
maxSessionsPreventsLogin | 挙動 |
---|---|
true | 先にログインしているセッションが有効な限り、maximumSessionsを超えてログインはできない |
false (default) | 後からログインしたセッションが有効となり、先にログインしていたセッションのうちmaximumSessionsを超えたセッションが無効になる |
アカウント登録と削除の実装
Spring Securityのコンフィグレーションではアカウント登録と削除は設定できないので、一から実装することになります。
アカウント登録
このデモサイトの実装ではアカウント登録するとすぐにアカウントが作成されます。
本来であれば、いきなり登録は行わず一旦仮登録扱いし仮登録時のメールアドレスへアクティベーションのメールを送信、ユーザーがアクティベーションすることで本登録するという段階を踏む必要があると思います。
フォーム
アカウント登録用のフォームです。
<form class="text-center" action="#" th:action="@{/signup}" th:object="${signupForm}" method="post">
<div class="md-form">
<input id="username" class="form-control" type="text" name="username" th:field="*{username}">
<label for="username">username</label>
<div th:if="${#fields.hasErrors('username')}" th:errors="*{username}" class="text-danger">error</div>
</div>
<div class="md-form">
<input id="email" class="form-control" type="text" name="email" th:field="*{email}">
<label for="email">email</label>
<div th:if="${#fields.hasErrors('email')}" th:errors="*{email}" class="text-danger">error</div>
</div>
<div class="md-form">
<input id="password" class="form-control" type="text" name="password" th:field="*{password}">
<label for="password">password</label>
<div th:if="${#fields.hasErrors('password')}" th:errors="*{password}" class="text-danger">
error
</div>
</div>
<div class="md-form">
<input id="repassword" class="form-control" type="text" name="repassword" th:field="*{repassword}">
<label for="repassword">(re) password</label>
<div th:if="${#fields.hasErrors('repassword')}" th:errors="*{repassword}" class="text-danger">error</div>
</div>
<div class="text-left justify-content-start">
<p>roles</p>
<div class="custom-control custom-checkbox">
<input id="roles_1" class="custom-control-input" type="checkbox" name="roles" value="ROLE_USER" th:field="*{roles}">
<label for="roles_1" class="custom-control-label">ROLE_USER</label>
</div>
<div class="custom-control custom-checkbox">
<input id="roles_2" class="custom-control-input" type="checkbox" name="roles" value="ROLE_ADMIN" th:field="*{roles}">
<label for="roles_2" class="custom-control-label">ROLE_ADMIN</label>
</div>
<div th:if="${#fields.hasErrors('roles')}" th:errors="*{roles}" class="text-danger">error</div>
</div>
<button class="btn indigo accent-4 text-white btn-block my-4" type="submit">Sign up</button>
</form>
登録処理
アカウントサービスクラスの実装です。コンストラクタでデータベースアクセス用のリポジトリ、パスワードのハッシュ化を行うPasswordEncoder、認証を行うAuthenticationManagerをDIします。
@Service
@Slf4j
public class AccountServiceImpl implements AccountService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager;
public AccountServiceImpl(UserRepository userRepository, PasswordEncoder passwordEncoder, AuthenticationManager authenticationManager) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.authenticationManager = authenticationManager;
}
@Transactional
@Override
public void register(String name, String email, String rawPassword, String[] roles) {
log.info("user register name:{}, email:{}, roles:{}", name, email, roles);
String encodedPassword = passwordEncoder.encode(rawPassword);
User storedUser = userRepository.saveAndFlush(User.of(name, email, encodedPassword, roles));
authentication(storedUser, rawPassword);
}
private void authentication(User user, String rawPassword) {
log.info("authenticate user:{}", user);
SimpleLoginUser loginUser = new SimpleLoginUser(user);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, rawPassword, loginUser.getAuthorities());
authenticationManager.authenticate(authenticationToken);
if (authenticationToken.isAuthenticated()) {
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
} else {
throw new RuntimeException("login failure");
}
}
}
パスワードのハッシュ化
パスワードはSpring Securityのパスワードエンコーダでハッシュ化した値を使用します。
String encodedPassword = passwordEncoder.encode(rawPassword);
エンティティの永続化
アカウントデータの永続化はリポジトリを使用します。永続化するUserエンティティはこの後の認証処理で使用します。
User storedUser = userRepository.saveAndFlush(User.of(name, email, encodedPassword, roles));
認証
登録したアカウント情報でプログラマティックに認証します。
永続化するUserエンティティからSpring Securityが使うユーザー情報(SimpleLoginUser
)のオブジェクトを生成します。
さらに、そのユーザー情報オブジェクトから認証トークンを生成します。
UsernamePasswordAuthenticationToken
のコンストラクタの第1引数はprincipal
オブジェクト(つまりユーザー情報)、第2引数はcredentials
オブジェクト(つまりパスワード)、第3引数はauthorities
です。
SimpleLoginUser loginUser = new SimpleLoginUser(user);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser, rawPassword, loginUser.getAuthorities());
生成した認証トークンで認証を行い、成功したらSecurityContextHolder
に認証トークンをセットします。これでアカウント登録と認証が完了し、以降の操作はフォーム認証された状態と同じになります。
authenticationManager.authenticate(authenticationToken);
if (authenticationToken.isAuthenticated()) {
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
} else {
throw new RuntimeException("authenticate failure user:[" + user.toString() + "]");
}
アカウント削除
フォーム
フォームから入力する情報は無いのでサブミットボタンだけのフォームになります。(CSRFトークンは自動的にセットされます。)
<form class="text-center" action="#" th:action="@{/account/delete}" th:object="${deleteForm}" method="post">
<button class="btn indigo accent-4 text-white btn-block my-4" type="submit">Delete</button>
</form>
削除処理
アカウントサービスクラスではUserエンティティを削除するだけの処理で、認証情報の破棄は呼び出し元のコントローラ側で行います。
@Service
@Slf4j
public class AccountServiceImpl implements AccountService {
//...省略...
@Transactional
@Override
public void delete(final User user) {
log.info("delete user:{}", user);
userRepository.findById(user.getId())
.ifPresentOrElse(findUser -> {
userRepository.delete(findUser);
userRepository.flush();
},
() -> {
log.error("user not found:{}", user);
});
}
}
ログアウト処理を行うことで認証情報の破棄(セッション破棄、クッキー削除)を行います。
@PostMapping(value = "delete")
public String delete(@AuthenticationPrincipal SimpleLoginUser user, HttpServletRequest request,
DeleteForm deleteForm) {
log.info("delete form user:{}", user.getUser());
accountService.delete(user.getUser());
try {
request.logout();
} catch (ServletException e) {
throw new RuntimeException("delete and logout failure", e);
}
return "redirect:/";
}
補足
thymeleafのSpring Security拡張
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
namespace
<html lang="ja"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
匿名ユーザーの判定
<p sec:authorize="isAnonymous()">anonymous user</p>
<p sec:authorize="!isAnonymous()">non anonymous user</p>
認証ユーザーの判定
<p sec:authorize="isAuthenticated()">authenticated user</p>
<p sec:authorize="!isAuthenticated()">non authenticated user</p>
ロールを持つユーザーの判定
<p sec:authorize="hasRole('USER')">USER role authenticated user</p>
ユーザー情報の出力
<p sec:authentication="name">name</p>
もしくは
<p sec:authentication="principal.username">username</p>
この場合のprincipalはユーザー情報クラス(SimpleLoginUser
)のインスタンスです。
別の書き方
上記の記述方法のほかに#authorization
、#authentication
というexpression utility objectsを使う方法もあります。
#authorization
認証状態をチェックするユーティリティオブジェクトです。
<p th:if=${#authorization.expression('isAnonymous()')}></p>
#authentication
Spring Securityの認証オブジェクトを表します。
<p th:text="${#authentication.name}">name</p>
ページスクリーンショット
CSSフレームワークにはBootstrap 4.3.1およびMaterial Design for Bootstrap 4.8.10を利用してみました。
トップページにはデバッグ情報を出力しています。特にトップページにアクセスしているユーザーどの認証状態にあるか確認できるよう以下のコードで状態を表示しています。
<tr>
<th scope="row">Anonymous</th>
<th:block th:switch="${#authorization.expression('isAnonymous()')}">
<td th:case="${true}"><span class="badge badge-info">yes</span></td>
<td th:case="${false}">no</td>
</th:block>
</tr>
<tr>
<th>Authenticated</th>
<th:block th:switch="${#authorization.expression('isAuthenticated()')}">
<td th:case="${true}"><span class="badge badge-info">yes</span></td>
<td th:case="${false}">no</td>
</th:block>
</tr>
<tr>
<th>FullyAuthenticated</th>
<th:block th:switch="${#authorization.expression('isFullyAuthenticated()')}">
<td th:case="${true}"><span class="badge badge-info">yes</span></td>
<td th:case="${false}">no</td>
</th:block>
</tr>
<tr>
<th>RememberMe</th>
<th:block th:switch="${#authorization.expression('isRememberMe()')}">
<td th:case="${true}"><span class="badge badge-info">yes</span></td>
<td th:case="${false}">no</td>
</th:block>
</tr>