spring securityを試す

猫好きリーマン

こんにちわ、猫好きリーマンのほげPGです。

コテツ(飼い猫)が旅立って、もうすぐ一年になります。最近、子どもが『コテツの音が聞こえる』と言っていて…ちゃんと覚えていてくれるのは嬉しいです。

今回は以下の構成を試します。

springboot 2.7 + spring security 5 + mybatis + logback

springboot が2なのはtomcat9向けの構成としているためです。

JavaEEとJakartaEEの境目なのでspringbootを3にするとtomcat9では動かなくなります。

1 全体

1 リクエスト

1.1 spring-securityの設定に基づきLoginControllerの/loginへ

1.2 テンプレートauth.htmlからhtmlを返却

2 username, passwordを送信

2.1 LoginService.loadUserByUsername()が呼ばれる

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(requests -> requests
                        .antMatchers("/static/**", "/login*", "/logout*","/hoge").permitAll()
                        .anyRequest().authenticated())
                .formLogin(formLogin -> formLogin
                        .usernameParameter("username")
                        .passwordParameter("password")
                        .loginPage("/login")
                        .successForwardUrl("/login-success")
                        .failureForwardUrl("/login-failure")
                        .permitAll())
                .logout(logout -> logout
                        .deleteCookies()
                        .invalidateHttpSession(true)
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/login")
                        .permitAll())
                .exceptionHandling(handling -> handling
                        .accessDeniedPage("/error/403"));
        return http.build();
    }

2.2 UserRepositoryのgetを呼ぶ

2.3 UserMapperを通してusersテーブルからデータ取得

2.4 spring-securityでパスワードチェックを行い、不一致ならng、一致ならokへ

2.5 認証OK時は1のリクエストにリダイレクト

2詳細

spring-security定義

WebSecurityConfig.java

認証関連リクエスト処理

LoginController.java

@Controller
@Slf4j
public class LoginController {

    @Data
    public static class LoginForm {
        private String username;
        private String password;
    }

    @ModelAttribute("loginForm")
    public LoginForm loginForm() {
        return new LoginForm();
    }

    @GetMapping("/login")
    public String login(Model model) {
        log.debug("called.");
        return "login";
    }

    @PostMapping("/login-failure")
    public String loginFailure(
            HttpSession session,
            HttpServletRequest request,
            Model model,
            @ModelAttribute("loginForm") LoginForm form,
            BindingResult result) {
        log.debug("called. {}", form);
        AuthenticationException authExcp = (AuthenticationException) request.getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
        log.debug("cause: " + authExcp);
        if (authExcp instanceof InternalAuthenticationServiceException iase) {
            result.rejectValue("username", "system.error", new Object[] {iase}, null);
        } else {
            result.rejectValue("username", "auth.failed", new Object[] {authExcp.getMessage()}, null);
        }
        return "login";
    }

    private RequestCache requestCache = new HttpSessionRequestCache();

    @PostMapping("/login-success")
    public String loginSuccess(
            HttpServletRequest request,
@Service
@Slf4j
@RequiredArgsConstructor
public class LoginService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.debug("called. {}", username);
        User user = userRepository.findByName(username);
        if (user == null) {
            throw new UsernameNotFoundException(username);
        }
        return LoginUser.create(user);
    }
}

ログインユーザの取得

LoginService.java

ユーザ情報の取得

UserRepository.java

@Repository
@Slf4j
@RequiredArgsConstructor
public class UserRepository {

    private final UsersMapper usersMapper;

    @Transactional(readOnly = true)
    public User findByName(String name) {
        List<User> userList = usersMapper.select(name);
        if (userList.isEmpty()) {
            return null;
        } else {
            return userList.get(0);
        }
    }
}

@Mapper
public interface UsersMapper {
    List<User> select(String name);
}

sqlマッパー

UsersMapper.java

Sql定義

UsersMapper.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="jp.co.ois.hoge.springsecurity.mapper.UsersMapper">

    <select id="select" resultType="jp.co.ois.hoge.springsecurity.model.User">
        SELECT
            id, name, password, role
        FROM
            users
        <where>            
        	<if test="name != null">
                AND name = #{name}
            </if>
        </where>
        ORDER BY
            id DESC
    </select>
</mapper>

@Data
public class User {
	private int id;
	private String name;
	private String password;
	private String role;
}

User.java

LoginUser.java

@ToString
public class LoginUser implements UserDetails, CredentialsContainer {
    private String password;
    private final String username;
    private final Collection<? extends GrantedAuthority> authorities;
    @Getter
    private final int id;

    public LoginUser(String username, String password, Collection<? extends GrantedAuthority> authorities, int id) {
        this.username = username;
        this.password = password;
        this.authorities = authorities;
        this.id = id;
    }

    @Override
    public void eraseCredentials() {
        this.password = null;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    public static LoginUser create(User user) {
        List<GrantedAuthority> authorities = new ArrayList<>(1);
        authorities.add(new SimpleGrantedAuthority("ROLE_" + user.getRole()));
        return new LoginUser(user.getName(), user.getPassword(), authorities, user.getId());
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;

ログイン画面

login.html

<!DOCTYPE html>
<!--/* ログイン */-->
<html xmlns:th="https://www.thymeleaf.org"
      xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity6"
      lang="ja">
<head>
    <meta th:replace="~{common::meta_header}"/>
</head>
<body>
    <header th:replace="~{common::header}"/>

    <main class="mx-lg-4">
        <section id="content" class="lh-1">
            <div class="col-md-4">
            <h3>ログイン</h3>
            <form method="POST" th:action="@{/login}" th:object="${loginForm}" >
                <div class="col-12  mt-3">
                    <label for="username" th:text="#{auth.username}" class="form-label">ユーザ名</label>
                    <input type="text" th:field="*{username}" class="form-control" required>
                    <span th:if="${#fields.hasErrors('username')}" class="text-danger" role="alert">
                        <strong th:errors="*{username}">エラーユーザ名</strong>
                    </span>
                </div>
                <div class="col-12 mt-3">
                    <label for="password" th:text="#{auth.password}" class="form-label">パスワード</label>
                    <input type="password" th:field="*{password}" class="form-control" required>
                    <span th:if="${#fields.hasErrors('password')}" class="text-danger" role="alert">
                        <strong th:errors="*{password}">エラーパスワード</strong>
                    </span>
                </div>
                <button th:text="#{auth.login}" class=" w-100 btn btn-primary btn-lg mt-4" type="submit">ログイン</button>
            </form>
            </div>
        </section>
    </main>

    <footer th:replace="~{common::footer}"/>
</body>
</html>

共通ヘッダーなど

common.html

<!DOCTYPE html>
<!--/* 共通フラグメント定義 */-->
<html xmlns:th="https://www.thymeleaf.org"
      xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity5"
      lang="ja">
<head>
<!-- 共通メタ -->
<th:block th:fragment="meta_header">
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="csrf-token" th:content="${_csrf.token}"/>
    <title th:text="#{app.name}">タイトル</title>
    <link rel="stylesheet" th:href="@{/static/css/bootstrap.min.css}">
    <script th:src="@{/static/js/bootstrap.bundle.min.js}"></script>
    <script th:src="@{/static/js/jquery-3.7.1.min.js}"></script>
</th:block>
</head>
<body>
    <!-- 共通ヘッダー -->
    <header class="bd-header bg-dark py-3 d-flex align-items-stretch border-bottom border-dark">
      <div class="container-fluid d-flex align-items-center">
        <h1 class="d-flex align-items-center fs-4 text-white mb-0">
          <a class="logo" th:href="@{/}"><img th:src="@{/static/images/ois_logo.jpg}" height="30px"></a>
          <div th:text="#{app.name}">hoge-springsecurity</div>
        </h1>
        <form id="logout-form" th:action="@{/logout}" method="post" class="ms-auto link-light">
          <a id="logout" th:text="#{auth.logout}">Logout</a>
        </form>
      </div>
      <script>
      $(window).on("load", function() {
        $("#logout").on('click', function(e) {
            $('#logout-form').submit();
        });
      });
      </script>
    </header>

    <main>
    <div class="container">
    </div>
    </main>

@Controller
@Slf4j
public class HogeController {
    @GetMapping("/")
    public String home() {
        log.debug("called.");
        return "redirect:/top";
    }

    @GetMapping("/top")
    public String top(@AuthenticationPrincipal LoginUser user) {
        log.debug("called. {}", user);
        return "top";
    }
}

HogeController.java

top.java

<!DOCTYPE html>
<!--/* トップ */-->
<html xmlns:th="https://www.thymeleaf.org"
      xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity6"
      lang="ja">
<head>
    <meta th:replace="~{common::meta_header}"/>
</head>
<body>
    <header th:replace="~{common::header}"/>

    <main class="mx-lg-4">
        <section id="content" class="lh-1">
            <div class="col-md-4">
            <h3>トップ</h3>
            </div>
        </section>
    </main>

    <footer th:replace="~{common::footer}"/>
</body>
</html>

DB定義

-- データベース設定
USE hoge_db;

CREATE TABLE `users` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(100) NOT NULL,
  `password` varchar(255) DEFAULT NULL,
  `role` varchar(100) NOT NULL,
  `created` timestamp NOT NULL DEFAULT current_timestamp(),
  `updated` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
  PRIMARY KEY (`id`),
  UNIQUE KEY `users_name_unique` (`name`)
) comment 'users' ;

-- 初期データ
INSERT INTO users ( name ,password ,role ,created ,updated ) VALUES ( 'ois' ,'{bcrypt}$2a$10$L9EzxwQQXT3vKNeGUGXLOeW30QmcbFiwNu7RnvAXdCc.rojQRSYbu' ,'ADMIN' ,now() ,now() );

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>jp.co.ois</groupId>
    <artifactId>hoge-springsecurity</artifactId>
    <packaging>war</packaging>
    <version>1.0.0-SNAPSHOT</version>
    <name>hoge-springsecurity</name>
    <url>http://maven.apache.org</url>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.18</version>
    </parent>

    <properties>
        <java.version>17</java.version>
        <tomcat.version>9.0.108</tomcat.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-core</artifactId>
        </dependency>
        <dependency>

3動作確認

起動させた後、以下URLを開く

http://localhost:9080/hoge-springsecurity

ユーザ名:ois、パスワード:yokohama を入力し、リターンキー押下

プロジェクト一式を添付しておきます。

猫好きリーマン
hoge-springsecurity.zip