LoginSignup
34
51

More than 3 years have passed since last update.

Spring Security with Spring Boot 2.1でシンプルなデモサイトを作成する

Last updated at Posted at 2019-10-10

概要

以前に『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

参考

作成するデモサイトの主な機能

  • アカウント登録・削除機能
  • アカウント登録内容の変更機能
  • メールアドレス/パスワードでログイン
  • ログアウト
  • ロールによるアクセス制御
  • セッションによる多重ログイン制御
  • 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と関連する特別な実装はありません。

エンティティ

認証・認可情報を管理するユーザーテーブルに対応するエンティティクラスの実装は以下のとおりです。

User
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;
  }

}
UserProfile
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();
  }

}

リポジトリ

メールアドレスで検索するメソッドを追加します。

UserRepository
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抽象クラスを継承します。主なコンフィグレーションはAuthenticationManagerWebSecurityHttpSecurityの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が使用するユーザー情報クラスをUserDetailsCredentialsContainerインタフェースを実装して作成します。
equalshashCodeメソッドのオーバーライドも適切に行う必要があります。このメソッドを適切に実装していないとセッション管理の多重ログインチェックが機能しません。

SimpleLoginUser
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)を生成します。

SimpleUserDetailsService
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

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の主要なコンフィグレーションを行います。コード(設定)量が多くなったので個別に記載しましたがアウトラインは下記のようになっています。

HttpSecurity
@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>

access_denied.png

サインイン

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()の代わりに、それぞれAuthenticationSuccessHandlerAuthenticationFailureHandlerを実装してカスタマイズすることができます。

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>

signin_form.png

認証失敗時のメッセージ表示

認証に失敗した場合、サインインページへ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>

signin_form_error.png

アカウントがロックされていた場合のメッセージ表示

userテーブルのlock_flagに1を立てた状態でサインインしようとすると図のメッセージが表示されます。

lock.png

なお、これらのフラグが検証されるのはサインインのときでログイン中のアカウントのフラグを立てても影響を受けません。

サインアウト

サインアウトはサインアウトページ(/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>

signout_form.png

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属性が付きます。

xsrf-token.png

以下のコードでクッキー名を変えることができます。

CookieCsrfTokenRepository customCsrfTokenRepository() {
  CookieCsrfTokenRepository cookieCsrfTokenRepository = new CookieCsrfTokenRepository();
  cookieCsrfTokenRepository.setCookieName("NEW-TOKEN-NAME");
  return cookieCsrfTokenRepository;
}

Remember-Me

サインインフォームでRemember-Meにチェックしてサインインすると、REMEMBERMEという名前のクッキーが発行されます。このクッキーがある状態でデモサイトにアクセスすると匿名(Anonymous)であれば自動的に認証されます。

なお、Spring Securityの認証状態にはauthenticatedfullyAuthenticatedがあり、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>

signup_form.png

登録処理

アカウントサービスクラスの実装です。コンストラクタでデータベースアクセス用のリポジトリ、パスワードのハッシュ化を行う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>

delete_form.png

削除処理

アカウントサービスクラスでは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を利用してみました。

No.1 トップ
1_top.png

トップページにはデバッグ情報を出力しています。特にトップページにアクセスしているユーザーどの認証状態にあるか確認できるよう以下のコードで状態を表示しています。

<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>

No.2 メニュー
2_menu.png

No.3 サインアップ
3_signup.png

No.4 サインイン
4_signin.png

No.5 サインアウト
5_signout.png

No.6 パスワード変更
6_password_change.png

No.7 ロール変更
7_role_change.png

No.8 プロフィール変更
8_profile_change.png

No.9 アカウント削除
9_delete.png

34
51
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
34
51