#author("2021-07-02T06:05:48+00:00","","") #author("2021-07-02T07:19:18+00:00","","") [[第3回]] * 認証・認可 [#ldb0bb1e] - テキストはパワーポイントを参照のこと - 参考 [[SpringBoot/認証・認可]] ** ToDo管理アプリに認証・認可を実装する [#j079ac41] *** やりたいこと [#e753a6d2] + ToDo管理に''メンバーごとのパスワード認証''をかけたい + これまで管理者が行っていたメンバー登録は,メンバー自身でやるように変更したい -- サインアップ: メンバーが自分自身でサービスに登録すること -- サインイン: メンバーが登録した自身の認証情報でサービスにログインすること + 認可の権限は2種類 -- ''メンバー (MEMBER):'' ToDo画面 (/{mid}/todos) で自分のToDoを管理する. --- 自身のTodDoを閲覧,登録,完了できる.全員のToDo (/{mid}/todos/all) を見ることもできる --- ただし,''他人のToDo画面は見ることができない'' -- ''管理者 (ADMIN):'' 管理者画面(/admin/register)でユーザの管理を行う -- 登録済ユーザの一覧,新規登録,削除ができる ** 認証・認可部の実装 [#p48d9fed] *** 方針 [#va9742c6] - S1: Member, MemberFormにパスワードトロールを追加 - S2: UserDetailsの実装クラスUserDetailsImplを作る - S3: MemverService にUserDetailsServiceを継承させ,loadUserByUsername()を実装する - S4: ToDoAppSecurityConfig を作る *** 準備 [#zf1b5ff4] - build.gradle の dependenciesの中に,下記を追記 implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5' *** S1: Member, MemberFormにパスワードトロールを追加 [#rf4563b2] - entity/Member.java @Data @AllArgsConstructor @NoArgsConstructor @Entity public class Member { @Id String mid; //メンバーID String name; //名前 String password; //パスワード(暗号化済) String role; //ロール } - dto/MemberForm.java @Data public class MemberForm { @Pattern(regexp ="[a-z0-9_\\-]{4,16}") String mid; //メンバーID.英小文字,数字,ハイフン,アンダーバー.4文字以上16文字以下. @NotBlank @Size(min = 1, max = 32) String name; //名前.最大32文字 @NotBlank @Size(min = 8) String password; //パスワード String role = "MEMBER"; //ロール.デフォルトは"MEMBER" public Member toEntity() { Member m = new Member(mid, name, password, role); return m; } } 解説 - パスワード,ロールともに文字列型で定義 - ロールはデフォルトでMEMBERとしている *** S2: UserDetailsの実装クラスUserDetailsImplを作る [#h2b315a8] - dto/UserDetailsImpl.java package jp.ac.kobe_u.cs.itspecialist.todoapp.dto; 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; import jp.ac.kobe_u.cs.itspecialist.todoapp.entity.Member; /** * 認証に必要なUserDetailsの実装クラス.Memberをラップする */ public class UserDetailsImpl implements UserDetails { Member member; Collection<GrantedAuthority> authorities = new ArrayList<>(); /** * コンストラクタ * @param member */ public UserDetailsImpl(Member member) { this.member=member; //メンバーのロールから権限を生成して追加 this.authorities.add(new SimpleGrantedAuthority("ROLE_" + member.getRole())); } public Member getMember() { return member; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; } @Override public String getPassword() { return member.getPassword(); } @Override public String getUsername() { return member.getMid(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } } *** S3: MemverService にUserDetailsServiceを継承させ,loadUserByUsername()を実装する [#e41f4707] - service/MemberService.java package jp.ac.kobe_u.cs.itspecialist.todoapp.service; import java.util.List; 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.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import jp.ac.kobe_u.cs.itspecialist.todoapp.dto.MemberForm; import jp.ac.kobe_u.cs.itspecialist.todoapp.dto.UserDetailsImpl; import jp.ac.kobe_u.cs.itspecialist.todoapp.entity.Member; import jp.ac.kobe_u.cs.itspecialist.todoapp.exception.ToDoAppException; import jp.ac.kobe_u.cs.itspecialist.todoapp.repository.MemberRepository; /** * メンバーのCRUDを行うサービス */ @Service public class MemberService implements UserDetailsService{ @Autowired MemberRepository mRepo; @Autowired BCryptPasswordEncoder encoder; /** * メンバーを作成する (C) * @param form * @return */ public Member createMember(MemberForm form) { //IDの重複チェック String mid = form.getMid(); if (mRepo.existsById(mid)) { throw new ToDoAppException(ToDoAppException.MEMBER_ALREADY_EXISTS, mid + ": Member already exists"); } Member m = form.toEntity(); m.setPassword(encoder.encode(m.getPassword())); //エンコードしてセーブする return mRepo.save(m); } /** * メンバーを取得する (R) * @param mid * @return */ public Member getMember(String mid) { Member m = mRepo.findById(mid).orElseThrow( () -> new ToDoAppException(ToDoAppException.NO_SUCH_MEMBER_EXISTS, mid + ": No such member exists")); return m; } /** * 全メンバーを取得する (R) * @return */ public List<Member> getAllMembers() { return mRepo.findAll(); } public boolean existById(String mid) { return mRepo.existsById(mid); } /** * メンバーを削除する (D) */ public void deleteMember(String mid) { Member m = getMember(mid); mRepo.delete(m); } /* ------------------ ここから追加分 ---------------------------*/ /** * Spring Securityのメソッド.ユーザIDを与えて,ユーザ詳細を生成する */ @Override public UserDetails loadUserByUsername(String mid) throws UsernameNotFoundException { Member m = mRepo.findById(mid).orElseThrow( () -> new UsernameNotFoundException(mid + ": no such user exists") ); return new UserDetailsImpl(m); } /** * 管理者を登録するサービス. */ public Member registerAdmin(String adminPassword) { Member m = new Member(); m.setMid("admin"); m.setName("System Administrator"); m.setPassword(encoder.encode(adminPassword)); m.setRole("ADMIN"); return mRepo.save(m); } } *** S4: ToDoAppSecurityConfig を作る [#v8890f94] *** ToDoアプリのセキュリティ設定方針 [#kf10be3b] 3つのconfigure()でやっている設定 + 静的コンテンツ /img/**, /js/**, /css/** は認証なしでアクセス許可 + HTTPアクセスについて -- /sign_in は認証不要 (ログインの前) -- /sign_up/** は認証不要 (新規アカウントの作成) -- /admin/** はADMINロールが必要 -- それ以外はすべて認証が必要 + ログインの設定 -- フォームログインで行う -- ログイン画面は /sign_in -- ログイン画面のポスト先は, /authenticate -- ログインフォームのユーザIDは,mid -- ログインフォームのパスワードは,password -- ログイン成功したら,/sign_in_success に着地する -- ログイン失敗したら,/sign_in?error に飛ぶ + ログアウト設定 -- ログアウトは /sign_out にPOST -- ログアウト成功したら,/sign_in?sign_out に着地 -- クッキー,セッションともに消去 -- ログアウトは認証なしでアクセス可能 + 認証方法の実装の指定 -- MemberServiceに委任 -- パスワードエンコーダは,ファイル内で生成したBeanを使う *** 具体的な設定クラス [#cd0a8033] - 新規にconfigurationという名前でフォルダを作成 - configuration/ToDoAppSecurityConfig.java import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; 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 jp.ac.kobe_u.cs.itspecialist.todoapp.service.MemberService; @Configuration @EnableWebSecurity //(1) Spring Securityを使うための設定 public class ToDoAppSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired MemberService mService; @Value("${admin.pass}") String adminPass; /** * 静的リソースの認可設定 */ @Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers("/img/**", "/css/**", "/js/**"); } /** * HTTPリクエストに対する認可,および,ログイン・ログアウト設定 */ @Override protected void configure(HttpSecurity http) throws Exception { //認可の設定 http.authorizeRequests() .antMatchers("/sign_in").permitAll() //サインインページは誰でも許可 .antMatchers("/sign_up/**").permitAll() .antMatchers("/admin/**").hasRole("ADMIN") //ユーザ管理は管理者のみ許可 .anyRequest().authenticated(); //それ以外は全て認証必要 //ログインの設定 http.formLogin() // 1. .loginPage("/sign_in") // 2. ログインページ .loginProcessingUrl("/authenticate") // 3. フォームのPOST先URL.認証処理を実行する .usernameParameter("mid") // 4. ユーザ名に該当するリクエストパラメタ .passwordParameter("password") // 5. パスワードに該当するリクエストパラメタ .defaultSuccessUrl("/sign_in_success", true) // 6. 成功時のページ (trueは以前どこにアクセスしてもここに遷移する設定) .failureUrl("/sign_in?error"); // 7. 失敗時のページ //ログアウトの設定 http.logout() //1. .logoutUrl("/sign_out") //2. ログアウトのURL .logoutSuccessUrl("/sign_in?sign_out") //3. ログアウト完了したらこのページへ .deleteCookies("JSESSIONID") //4. クッキー削除 .invalidateHttpSession(true) //5. セッション情報消去 .permitAll(); //6. ログアウトはいつでもアクセスできる } /** * 認証の方法を設定 */ @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(mService).passwordEncoder(passwordEncoder()); //ついでに管理者を登録しておく mService.registerAdmin(adminPass); } /** * アプリで共通のパスワード生成器を作る. * @Beanをつけているので任意の箇所でAutowired可能になる */ @Bean public BCryptPasswordEncoder passwordEncoder() { BCryptPasswordEncoder bcpe = new BCryptPasswordEncoder(); return bcpe; } } - resources/application.properties に追記 admin.pass=s3cret ** 認証・認可に伴う更新 [#if9191e0] *** サインアップ機能のためのコントローラと画面 [#mcf06920] - controller/SignUpController.java package jp.ac.kobe_u.cs.itspecialist.todoapp.controller; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import jp.ac.kobe_u.cs.itspecialist.todoapp.dto.MemberForm; import jp.ac.kobe_u.cs.itspecialist.todoapp.entity.Member; import jp.ac.kobe_u.cs.itspecialist.todoapp.service.MemberService; @Controller @RequestMapping("/sign_up") public class SignUpController { @Autowired MemberService mService; /** * 一般ユーザの登録ページ HTTP-GET /sign_up * * @param model * @return */ @GetMapping("") String showSignUpForm(@ModelAttribute MemberForm form, Model model) { model.addAttribute("MemberForm", form); return "signup"; } /** * ユーザ登録確認ページを表示 HTTP-POST /sign_up/check * * @param form * @param model * @return */ @PostMapping("/check") String checkMemberForm(@Validated @ModelAttribute(name = "MemberForm") MemberForm form, BindingResult bindingResult, Model model) { // 入力チェックに引っかかった場合、ユーザー登録画面に戻る if (bindingResult.hasErrors()) { // GETリクエスト用のメソッドを呼び出して、ユーザー登録画面に戻る return showSignUpForm(form, model); } model.addAttribute("MemberForm", form); return "signup_check"; } /** * ユーザ登録処理 -> 完了ページ HTTP-POST /sign_up/register * * @param form * @param model * @return */ @PostMapping("") String createMember(@ModelAttribute(name = "MemberForm") MemberForm form, Model model) { Member m = mService.createMember(form); model.addAttribute("MemberForm", m); return "signup_complete"; } } - resources/templates/signup.html <!DOCTYPE html> <html lang="ja" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>サインアップ</title> </head> <body> <h1>サインアップ</h1> <h2>メンバー新規登録</h2> <p> メンバーIDと氏名を入力して,「確認する」を押してください.</p> <ul> <li>メンバーIDには,アルファベット小文字,数字,ハイフン,アンダーバーのみ使用できます.4文字以上16文字未満.</li> <li>氏名は最大32文字.半角・全角が使用できます.</li> <li>パスワードは8文字以上でつけてください.</li> </ul> <form role="form" th:action="@{/sign_up/check}" th:object="${MemberForm}" method="post"> <table> <tr> <td><label>メンバーID: </label></td> <td><input type="text" required th:field="*{mid}" /> <span th:if="${#fields.hasErrors('mid')}" th:errors="*{mid}" style="color: red"></span> </td> </tr> <tr> <td><label>氏名: </label></td> <td><input type="text" required th:field="*{name}" /> <span th:if="${#fields.hasErrors('name')}" th:errors="*{name}" style="color: red"></span> </td> </tr> <tr> <td><label>パスワード: </label></td> <td><input type="password" required th:field="*{password}" /> <span th:if="${#fields.hasErrors('password')}" th:errors="*{password}" style="color: red"></span> </td> </tr> </table> <p><input type="submit" value="確認する" /></p> </form> </body> </html> - resources/templates/signup_check.html <!DOCTYPE html> <html lang="ja" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>登録確認</title> </head> <body> <h1>登録確認</h1> <p> 以下の情報でメンバーを登録します.よろしければ登録を押してください. </p> <form role="form" th:action="@{/sign_up}" th:object="${MemberForm}" method="post"> <p> <label>メンバーID: </label> [[*{mid}]] </p> <p> <label>氏名: </label> [[*{name}]] </p> <p> <label>パスワード: </label> [[*{password}]] </p> <p> <input type="button" value="戻る" onclick="history.back()" /> <input type="submit" value="登録する" /> </p> <input type="hidden" th:field="*{mid}" /> <input type="hidden" th:field="*{name}" /> <input type="hidden" th:field="*{password}" /> </form> </body> </html> - resources/templates/signup_complete.html <!DOCTYPE html> <html lang="ja" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>登録完了</title> </head> <body> <h1>登録完了</h1> <p> メンバー登録を完了しました. </p> <div th:object="${MemberForm}"> <p> <label>メンバーID: </label> [[*{mid}]] </p> <p> <label>氏名: </label> [[*{name}]] </p> <p> <a th:href=@{/}>初めに戻る</a> </p> </div> </body> </html> *** ToDpControllerの改訂方針 [#ica16b6d] - ログインしていないと,/sign_in に飛ばされる - ログインが成功すると,/sign_in_success にリダイレクトされるので,そこからさらにリダイレクト -- MEMBER: /{uid}/todos にリダイレクト -- ADMIN: /admin/register にリダイレクト - 各画面のログアウトも変更 -- /sign_out にPOSTリクエストしなければならない *** ToDpControllerと関連画面の改訂 [#zaff7b09] - controller/ToDoController.java import java.util.List; import java.util.Map; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import jp.ac.kobe_u.cs.itspecialist.todoapp.dto.LoginForm; import jp.ac.kobe_u.cs.itspecialist.todoapp.dto.ToDoForm; import jp.ac.kobe_u.cs.itspecialist.todoapp.dto.UserDetailsImpl; import jp.ac.kobe_u.cs.itspecialist.todoapp.entity.Member; import jp.ac.kobe_u.cs.itspecialist.todoapp.entity.ToDo; import jp.ac.kobe_u.cs.itspecialist.todoapp.exception.ToDoAppException; import jp.ac.kobe_u.cs.itspecialist.todoapp.service.MemberService; import jp.ac.kobe_u.cs.itspecialist.todoapp.service.ToDoService; @Controller public class ToDoController { @Autowired MemberService mService; @Autowired ToDoService tService; /** * トップページ */ @GetMapping("/sign_in") String showIndex(@RequestParam Map<String, String> params, @ModelAttribute LoginForm form, Model model) { //パラメータ処理.ログアウト時は?logout, 認証失敗時は?errorが帰ってくる(WebSecurityConfig.java参照) if (params.containsKey("sign_out")) { model.addAttribute("message", "サインアウトしました"); } else if (params.containsKey("error")) { model.addAttribute("message", "サインインに失敗しました"); } //model.addAttribute("loginForm", loginForm); return "signin"; } /** * ログイン処理.midの存在確認をして,ユーザページにリダイレクト */ @GetMapping("/sign_in_success") String login() { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); Member m = ((UserDetailsImpl) auth.getPrincipal()).getMember(); if (m.getRole().equals("ADMIN")) { return "redirect:/admin/register"; } return "redirect:/" + m.getMid() + "/todos"; } /** * ユーザのToDoリストのページ */ @GetMapping("/{mid}/todos") String showToDoList(@PathVariable String mid, @ModelAttribute(name = "ToDoForm") ToDoForm form, Model model) { checkIdentity(mid); Member m = mService.getMember(mid); model.addAttribute("member", m); model.addAttribute("ToDoForm", form); List<ToDo> todos = tService.getToDoList(mid); model.addAttribute("todos", todos); List<ToDo> dones = tService.getDoneList(mid); model.addAttribute("dones", dones); return "list"; } /** * 全員のToDoリストのページ */ @GetMapping("/{mid}/todos/all") String showAllToDoList(@PathVariable String mid, Model model) { checkIdentity(mid); Member m = mService.getMember(mid); model.addAttribute("member", m); List<ToDo> todos = tService.getToDoList(); model.addAttribute("todos", todos); List<ToDo> dones = tService.getDoneList(); model.addAttribute("dones", dones); return "alllist"; } /** * ToDoの作成.作成処理後,ユーザページへリダイレクト */ @PostMapping("/{mid}/todos") String createToDo(@PathVariable String mid, @Validated @ModelAttribute(name = "ToDoForm") ToDoForm form, BindingResult bindingResult, Model model) { checkIdentity(mid); if (bindingResult.hasErrors()) { return showToDoList(mid, form, model); } tService.createToDo(mid, form); return "redirect:/" + mid + "/todos"; } /** * ToDoの完了.完了処理後,ユーザページへリダイレクト */ @GetMapping("/{mid}/todos/{seq}/done") String doneToDo(@PathVariable String mid, @PathVariable Long seq, Model model) { checkIdentity(mid); tService.done(mid, seq); return "redirect:/" + mid + "/todos"; } /** * 認可チェック.与えられたmidがログイン中のmidに等しいかチェックする */ private void checkIdentity(String mid) { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); Member m = ((UserDetailsImpl) auth.getPrincipal()).getMember(); if (!mid.equals(m.getMid())) { throw new ToDoAppException(ToDoAppException.INVALID_TODO_OPERATION, m.getMid() + ": not authorized to access resources of " + mid); } } } - resources/templates/signin.html <!DOCTYPE html> <html lang="ja" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>ToDoアプリケーション</title> </head> <body> <h1>ToDoアプリケーション</h1> <img th:src=@{/img/todo_people.png} width="640px"> <p> サインインしてください.初めての方は,<a th:href="@{/sign_up}">サインアップ</a>してください. </p> <form role="form" th:action="@{/authenticate}" th:object="${loginForm}" method="post"> <p> <label>メンバーID: </label> <input type="text" th:field="*{mid}" /> <span th:if="${#fields.hasErrors('mid')}" th:errors="*{mid}" style="color: red"></span> </p> <p> <label>パスワード: </label> <input type="password" th:field="*{password}" /> <span th:if="${#fields.hasErrors('password')}" th:errors="*{password}" style="color: red"></span> </p> <p> <input type="submit" value="サインイン" /> </p> <p> <span th:if="${message}" th:text="${message}" style="color: red"></span> </p> </form> </body> </html> - resources/templates/list.html -- サインアウトボタンはPOST <!DOCTYPE html> <html lang="ja" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>ToDoList</title> </head> <body> <h1> ようこそ [[${member.name}]] さん!</h1> <p> <a th:href="@{/{mid}/todos/all(mid=${member.mid})}">みんなのToDo</a> <form th:action="@{/sign_out}" method="post"> <input type="submit" value="サインアウト" /> </form> </p> <h2>ToDo</h2> <table border="1"> <tr> <th>#</th> <th>タイトル</th> <th>作成日時</th> <th>コマンド</th> </tr> <tr th:each="todo: ${todos}"> <td>[[${todo.seq}]]</td> <td>[[${todo.title}]]</td> <td>[[${todo.createdAt}]]</td> <td> <a th:href="@{/{mid}/todos/{seq}/done(mid=${member.mid},seq=${todo.seq})}">完了</a> </td> </tr> <tr> <td> * </td> <td colspan="3"> <form role="form" th:action="@{/{mid}/todos(mid=${member.mid})}" th:object="${ToDoForm}" method="post"> <input type="text" required th:field="*{title}" /> <input type="submit" value="新規作成" /> <div th:if="${#fields.hasErrors('title')}" th:errors="*{title}" style="color: red"></div> </form> </td> </tr> </table> <h2>Done</h2> <table border="1"> <tr> <th>#</th> <th>タイトル</th> <th>作成日時</th> <th>完了日時</th> </tr> <tr th:each="done: ${dones}"> <td>[[${done.seq}]]</td> <td>[[${done.title}]]</td> <td>[[${done.createdAt}]]</td> <td>[[${done.doneAt}]]</td> </tr> </table> </body> </html> - resources/templates/alllist.html <!DOCTYPE html> <html lang="ja" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>ToDoList</title> </head> <body> <h1> みんなのToDoリスト</h1> <p> <a th:href="@{/{mid}/todos(mid=${member.mid})}">自分のToDo</a> <form th:action="@{/sign_out}" method="post"> <input type="submit" value="サインアウト" /> </form> </p> <h2>ToDo</h2> <table border="1"> <tr> <th>#</th> <th>タイトル</th> <th>作成者</th> <th>作成日時</th> </tr> <tr th:each="todo: ${todos}"> <td>[[${todo.seq}]]</td> <td>[[${todo.title}]]</td> <td>[[${todo.mid}]]</td> <td>[[${todo.createdAt}]]</td> </tr> </table> <h2>Done</h2> <table border="1"> <tr> <th>#</th> <th>タイトル</th> <th>作成者</th> <th>完了日時</th> </tr> <tr th:each="done: ${dones}"> <td>[[${done.seq}]]</td> <td>[[${done.title}]]</td> <td>[[${done.mid}]]</td> <td>[[${done.doneAt}]]</td> </tr> </table> </body> </html> ** 管理者画面 /admin/register [#p3793983] 同様に変更してみてください! ** 参考 [#z7b7ba69] - [[SpringBoot/Form認証]]