From 72bd137e8dbf5429af4d1132110629dda1e8c8c4 Mon Sep 17 00:00:00 2001 From: t-eguchi <26-t-eguchi-tmn@it-college.co.jp> Date: Tue, 27 Oct 2026 10:59:00 +0900 Subject: [PATCH] =?UTF-8?q?=E3=83=AD=E3=82=B0=E3=82=A4=E3=83=B3=E3=83=AD?= =?UTF-8?q?=E3=83=83=E3=82=AF=E6=A9=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/demo/common/LoginUser.java | 59 ++-- .../config/CompatiblePasswordEncoder.java | 38 +++ .../CustomAuthenticationFailureHandler.java | 59 ++++ .../CustomAuthenticationSuccessHandler.java | 48 +++ .../demo/config/CustomUserDetails.java | 74 +++++ .../example/demo/config/SecurityConfig.java | 58 ++++ .../demo/controller/LoginController.java | 101 ++++--- .../service/CustomUserDetailsService.java | 47 +++ .../example/demo/service/EmployeeService.java | 273 ++++++++++-------- .../demo/service/LoginAttemptService.java | 101 +++++++ 10 files changed, 662 insertions(+), 196 deletions(-) create mode 100644 book-rent/src/main/java/com/example/demo/config/CompatiblePasswordEncoder.java create mode 100644 book-rent/src/main/java/com/example/demo/config/CustomAuthenticationFailureHandler.java create mode 100644 book-rent/src/main/java/com/example/demo/config/CustomAuthenticationSuccessHandler.java create mode 100644 book-rent/src/main/java/com/example/demo/config/CustomUserDetails.java create mode 100644 book-rent/src/main/java/com/example/demo/config/SecurityConfig.java create mode 100644 book-rent/src/main/java/com/example/demo/service/CustomUserDetailsService.java create mode 100644 book-rent/src/main/java/com/example/demo/service/LoginAttemptService.java diff --git a/book-rent/src/main/java/com/example/demo/common/LoginUser.java b/book-rent/src/main/java/com/example/demo/common/LoginUser.java index 9d47f8c..f8c00ad 100644 --- a/book-rent/src/main/java/com/example/demo/common/LoginUser.java +++ b/book-rent/src/main/java/com/example/demo/common/LoginUser.java @@ -1,31 +1,48 @@ package com.example.demo.common; - - import java.io.Serializable; /** セッションに保持するログイン情報(社員ID・社員名・社員名よみ・メールアドレス・管理者フラグ) */ public class LoginUser implements Serializable { - private static final long serialVersionUID = 1L; + private static final long serialVersionUID = 1L; - private final Integer idEmployee; - private final String nmEmployee; - private final String knEmployee; - private final String mailAddress; - private final String flgAdmin; + private final Integer idEmployee; + private final String nmEmployee; + private final String knEmployee; + private final String mailAddress; + private final String flgAdmin; - public LoginUser(Integer idEmployee, String nmEmployee, String knEmployee, String mailAddress, String flgAdmin) { - this.idEmployee = idEmployee; - this.nmEmployee = nmEmployee; - this.knEmployee = knEmployee; - this.mailAddress = mailAddress; - this.flgAdmin = flgAdmin; - } + public LoginUser(Integer idEmployee, String nmEmployee, String knEmployee, String mailAddress, String flgAdmin) { + this.idEmployee = idEmployee; + this.nmEmployee = nmEmployee; + this.knEmployee = knEmployee; + this.mailAddress = mailAddress; + this.flgAdmin = flgAdmin; + + } + + public Integer getIdEmployee() { + return idEmployee; + } + + public String getNmEmployee() { + return nmEmployee; + } + + public String getKnEmployee() { + return knEmployee; + } + + public String getMailAddress() { + return mailAddress; + } + + public String getFlgAdmin() { + return flgAdmin; + } + + public boolean isAdmin() { + return Const.ADMIN_YES.equals(flgAdmin); + } - public Integer getIdEmployee() { return idEmployee; } - public String getNmEmployee() { return nmEmployee; } - public String getKnEmployee() { return knEmployee; } - public String getMailAddress() { return mailAddress; } - public String getFlgAdmin() { return flgAdmin; } - public boolean isAdmin() { return Const.ADMIN_YES.equals(flgAdmin); } } diff --git a/book-rent/src/main/java/com/example/demo/config/CompatiblePasswordEncoder.java b/book-rent/src/main/java/com/example/demo/config/CompatiblePasswordEncoder.java new file mode 100644 index 0000000..cb30a48 --- /dev/null +++ b/book-rent/src/main/java/com/example/demo/config/CompatiblePasswordEncoder.java @@ -0,0 +1,38 @@ +package com.example.demo.config; + +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +// データベースに保存されている平文パスワードと、BCryptでハッシュ化されたパスワードの両方を照合できるようにするクラス +public class CompatiblePasswordEncoder implements PasswordEncoder { + + // パスワードを安全にハッシュ化するためのクラスを生成 + private final BCryptPasswordEncoder bcrypt = new BCryptPasswordEncoder(); + + // パスワードのハッシュ化 + @Override + public String encode(CharSequence rawPassword) { + return bcrypt.encode(rawPassword); + } + + @Override + public boolean matches( + CharSequence rawPassword, + String encodedPassword) { + + // 保存済みパスワードが存在するか確認 + if (encodedPassword == null) { + return false; + } + + // 既存の平文パスワードに対応 + if (!encodedPassword.startsWith("$2a$") + && !encodedPassword.startsWith("$2b$") + && !encodedPassword.startsWith("$2y$")) { + return rawPassword.toString().equals(encodedPassword); + } + + // BCrypt形式のパスワードに対応 + return bcrypt.matches(rawPassword, encodedPassword); + } +} \ No newline at end of file diff --git a/book-rent/src/main/java/com/example/demo/config/CustomAuthenticationFailureHandler.java b/book-rent/src/main/java/com/example/demo/config/CustomAuthenticationFailureHandler.java new file mode 100644 index 0000000..512143c --- /dev/null +++ b/book-rent/src/main/java/com/example/demo/config/CustomAuthenticationFailureHandler.java @@ -0,0 +1,59 @@ +package com.example.demo.config; + +import java.io.IOException; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import com.example.demo.service.LoginAttemptService; + +// ログイン失敗時の処理 +@Component +public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { + + private LoginAttemptService loginAttemptService; + + public CustomAuthenticationFailureHandler(LoginAttemptService loginAttemptService) { + this.loginAttemptService = loginAttemptService; + } + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + + // 画面から入力されたメールアドレスを取得 + String mailAddress = request.getParameter("mailAddress"); + + if (mailAddress != null && !mailAddress.isBlank()) { + String mail = mailAddress.trim(); + + if (loginAttemptService.isBlocked(mail)) { + getRedirectStrategy().sendRedirect( + request, response, "/login?error=locked"); + return; + } + + // 失敗回数を1増やす + loginAttemptService.loginFailed(mail); + + // すでにロック中なら失敗回数を増やさない + if (loginAttemptService.isBlocked(mail)) { + getRedirectStrategy().sendRedirect( + request, response, "/login?error=locked"); + return; + } + + // あと何回失敗されるとロックされるか取得 + int remaining = loginAttemptService.getRemainingAttempts(mail); + + getRedirectStrategy().sendRedirect( + request, response, "/login?error=failed&remaining=" + remaining); + } + + } +} diff --git a/book-rent/src/main/java/com/example/demo/config/CustomAuthenticationSuccessHandler.java b/book-rent/src/main/java/com/example/demo/config/CustomAuthenticationSuccessHandler.java new file mode 100644 index 0000000..b3adb27 --- /dev/null +++ b/book-rent/src/main/java/com/example/demo/config/CustomAuthenticationSuccessHandler.java @@ -0,0 +1,48 @@ +package com.example.demo.config; + +import java.io.IOException; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import com.example.demo.common.Const; +import com.example.demo.service.LoginAttemptService; + +@Component +public class CustomAuthenticationSuccessHandler + extends SimpleUrlAuthenticationSuccessHandler { + + private final LoginAttemptService loginAttemptService; + + public CustomAuthenticationSuccessHandler( + LoginAttemptService loginAttemptService) { + this.loginAttemptService = loginAttemptService; + setDefaultTargetUrl("/menu"); + setAlwaysUseDefaultTargetUrl(true); + } + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) + throws IOException, ServletException { + + if (authentication.getPrincipal() instanceof CustomUserDetails userDetails) { + String mail = userDetails.getUsername(); + + loginAttemptService.loginSucceeded(mail); + + request.getSession(true).setAttribute( + Const.SESSION_LOGIN_USER, + userDetails.getLoginUser()); + } + + super.onAuthenticationSuccess(request, response, authentication); + } +} \ No newline at end of file diff --git a/book-rent/src/main/java/com/example/demo/config/CustomUserDetails.java b/book-rent/src/main/java/com/example/demo/config/CustomUserDetails.java new file mode 100644 index 0000000..852491f --- /dev/null +++ b/book-rent/src/main/java/com/example/demo/config/CustomUserDetails.java @@ -0,0 +1,74 @@ +package com.example.demo.config; + +import java.util.Collection; +import java.util.Collections; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import com.example.demo.common.LoginUser; +import com.example.demo.service.LoginAttemptService; + +// システムの社員情報をSpring Securityが扱えるクラスに変更 +public class CustomUserDetails implements UserDetails { + + private final LoginUser loginUser; + private final String password; // DBから取得した暗号化パスワードを保持 + private final LoginAttemptService loginAttemptService; + + public CustomUserDetails(LoginUser loginUser, String password, LoginAttemptService loginAttemptService) { + this.loginUser = loginUser; + this.password = password; + this.loginAttemptService = loginAttemptService; + } + + @Override + public String getPassword() { + return this.password; + } + + @Override + public String getUsername() { + return loginUser.getMailAddress(); + } + + // アカウントのロック状態の確認 + @Override + public boolean isAccountNonLocked() { + return !loginAttemptService.isBlocked(getUsername()); + } + + + // 管理者判定メソッド + @Override + public Collection getAuthorities() { + String role = loginUser.isAdmin() + ? "ROLE_ADMIN" + : "ROLE_USER"; + + return Collections.singletonList( // 要素が一つだけの変更できないリストを作るメソッド + new SimpleGrantedAuthority(role)); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + + // ログイン成功後にコントローラー等でLoginUserを取り出すためのゲッター + public LoginUser getLoginUser() { + return loginUser; + } + +} diff --git a/book-rent/src/main/java/com/example/demo/config/SecurityConfig.java b/book-rent/src/main/java/com/example/demo/config/SecurityConfig.java new file mode 100644 index 0000000..3b7db6e --- /dev/null +++ b/book-rent/src/main/java/com/example/demo/config/SecurityConfig.java @@ -0,0 +1,58 @@ +package com.example.demo.config; + +import org.springframework.boot.security.autoconfigure.web.servlet.PathRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final CustomAuthenticationFailureHandler failureHandler; + private final CustomAuthenticationSuccessHandler successHandler; + + public SecurityConfig( + CustomAuthenticationFailureHandler failureHandler, + CustomAuthenticationSuccessHandler successHandler) { + this.failureHandler = failureHandler; + this.successHandler = successHandler; + } + + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity http) throws Exception { + + http + .authorizeHttpRequests(authorize -> authorize + .requestMatchers( + PathRequest.toStaticResources().atCommonLocations()) + .permitAll() + .requestMatchers( + "/", "/index", "/error", "/login") + .permitAll() + .anyRequest().authenticated()) + .formLogin(login -> login + .loginPage("/login") + .loginProcessingUrl("/login") + .usernameParameter("mailAddress") + .passwordParameter("password") + .failureHandler(failureHandler) + .successHandler(successHandler) + .permitAll()) + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/login?logout") + .permitAll()); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new CompatiblePasswordEncoder(); + } +} \ No newline at end of file diff --git a/book-rent/src/main/java/com/example/demo/controller/LoginController.java b/book-rent/src/main/java/com/example/demo/controller/LoginController.java index 0423fa5..87b6498 100644 --- a/book-rent/src/main/java/com/example/demo/controller/LoginController.java +++ b/book-rent/src/main/java/com/example/demo/controller/LoginController.java @@ -1,72 +1,69 @@ -package com.example.demo.controller; - - -import java.util.Optional; -import jakarta.servlet.http.HttpServletRequest; +package com.example.demo.controller; + import jakarta.servlet.http.HttpSession; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import com.example.demo.common.Const; -import com.example.demo.common.LoginUser; -import com.example.demo.service.AuthService; - -/** AUTH-01:ログイン/ログアウト(login.html) */ + @Controller public class LoginController { - private final AuthService authService; - - public LoginController(AuthService authService) { - this.authService = authService; - } - + @GetMapping("/") public String root() { - return "redirect:/menu"; + return "redirect:/login"; } - + @GetMapping("/login") - public String loginForm(HttpSession session) { + public String loginForm( + HttpSession session, + @RequestParam(name = "error", required = false) String error, + @RequestParam(name = "logout", required = false) String logout, + @RequestParam(name = "remaining", required = false) Integer remaining, + Model model) { + + // すでにログインしている場合はメニュー画面へ if (session.getAttribute(Const.SESSION_LOGIN_USER) != null) { return "redirect:/menu"; } + + // ログイン失敗時のメッセージ + if ("locked".equals(error)) { + model.addAttribute( + "errorMessage", + "ログインに5回連続で失敗したため、10分間アカウントをロックしました。" + ); + + } else if ("failed".equals(error) && remaining != null) { + + if (remaining == 1) { + model.addAttribute( + "errorMessage", + "メールアドレスまたはパスワードが間違っています。あと1回間違えると、10分間ログインできなくなります。" + ); + } else { + model.addAttribute( + "errorMessage", + "メールアドレスまたはパスワードが間違っています。あと" + + remaining + + "回間違えると、10分間ログインできなくなります。" + ); + } + + } else if (error != null) { + model.addAttribute( + "errorMessage", + "メールアドレスまたはパスワードが間違っています。" + ); + + } else if (logout != null) { + model.addAttribute("message", "ログアウトしました。"); + } + return "login"; } - - @PostMapping("/login") - public String login(@RequestParam(defaultValue = "") String mailAddress, - @RequestParam(defaultValue = "") String password, - HttpServletRequest request, Model model) { - String mail = mailAddress.trim(); - model.addAttribute("mailAddress", mail); // 入力値を保持(パスワードは保持しない) - - if (mail.isEmpty() || password.isEmpty()) { - model.addAttribute("errorMessage", "メールアドレスとパスワードを入力してください。"); - return "login"; - } - Optional user = authService.authenticate(mail, password); - if (user.isEmpty()) { - // どちらが誤りかは分からない文言にする - model.addAttribute("errorMessage", "メールアドレスまたはパスワードが正しくありません。"); - return "login"; - } - // セッション固定化攻撃対策:古いセッションを破棄して新規発行 - HttpSession old = request.getSession(false); - if (old != null) { - old.invalidate(); - } - request.getSession(true).setAttribute(Const.SESSION_LOGIN_USER, user.get()); - return "redirect:/menu"; - } - - @PostMapping("/logout") - public String logout(HttpSession session) { - session.invalidate(); - return "redirect:/login"; - } -} \ No newline at end of file +} diff --git a/book-rent/src/main/java/com/example/demo/service/CustomUserDetailsService.java b/book-rent/src/main/java/com/example/demo/service/CustomUserDetailsService.java new file mode 100644 index 0000000..ff3e5a7 --- /dev/null +++ b/book-rent/src/main/java/com/example/demo/service/CustomUserDetailsService.java @@ -0,0 +1,47 @@ +package com.example.demo.service; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import com.example.demo.common.Const; +import com.example.demo.common.LoginUser; +import com.example.demo.config.CustomUserDetails; +import com.example.demo.entity.Employee; +import com.example.demo.repository.EmployeeRepository; + +//Spring Securityから依頼を受けて、ログインする社員情報をDBから取得 +@Service +public class CustomUserDetailsService implements UserDetailsService { + + private final EmployeeRepository employeeRepository; + private final LoginAttemptService loginAttemptService; + + public CustomUserDetailsService( + EmployeeRepository employeeRepository, + LoginAttemptService loginAttemptService) { + this.employeeRepository = employeeRepository; + this.loginAttemptService = loginAttemptService; + } + + @Override + public UserDetails loadUserByUsername(String maillAddress) throws UsernameNotFoundException { + + Employee employee = employeeRepository.findByMailAddressAndFlgRetirement(maillAddress.trim(), Const.RETIRE_NO) + .orElseThrow(() -> new UsernameNotFoundException("認証に失敗しました。")); + // ↑社員情報が見つからない場合は例外処理 + + // 取得したEmployeeをLoginUserに変換する + LoginUser loginUser = new LoginUser( + employee.getIdEmployee(), + employee.getNmEmployee(), + employee.getKnEmployee(), + employee.getMailAddress(), + employee.getFlgAdmin()); + + // Spring Security用の形式に変換 + return new CustomUserDetails(loginUser, employee.getPassword(), loginAttemptService); + } + +} diff --git a/book-rent/src/main/java/com/example/demo/service/EmployeeService.java b/book-rent/src/main/java/com/example/demo/service/EmployeeService.java index 0587add..ff1b4b1 100644 --- a/book-rent/src/main/java/com/example/demo/service/EmployeeService.java +++ b/book-rent/src/main/java/com/example/demo/service/EmployeeService.java @@ -7,6 +7,7 @@ import java.util.Optional; import java.util.regex.Pattern; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,145 +21,171 @@ import com.example.demo.repository.EmployeeRepository; /** EMP-01:社員登録、EMP-02:社員編集 */ @Service public class EmployeeService { - private static final Pattern MAIL = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); - private static final String MSG_DUP = "このメールアドレスは既に登録されています。"; + private static final Pattern MAIL = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); + private static final String MSG_DUP = "このメールアドレスは既に登録されています。"; - private final EmployeeRepository employeeRepository; + // SecurityConfigに適用させるため + private final EmployeeRepository employeeRepository; + private final PasswordEncoder passwordEncoder; - public EmployeeService(EmployeeRepository employeeRepository) { - this.employeeRepository = employeeRepository; - } + public EmployeeService( + EmployeeRepository employeeRepository, + PasswordEncoder passwordEncoder) { + this.employeeRepository = employeeRepository; + this.passwordEncoder = passwordEncoder; + } - // ---------- 検索 ---------- + // ---------- 検索 ---------- - @Transactional(readOnly = true) - public Optional findByMail(String mail) { - return employeeRepository.findByMailAddress(mail == null ? "" : mail.trim()); - } + @Transactional(readOnly = true) + public Optional findByMail(String mail) { + return employeeRepository.findByMailAddress(mail == null ? "" : mail.trim()); + } - @Transactional(readOnly = true) - public Employee find(Integer id) { - return employeeRepository.findById(id) - .orElseThrow(() -> new BusinessException("対象の社員が見つかりません。")); - } + @Transactional(readOnly = true) + public Employee find(Integer id) { + return employeeRepository.findById(id) + .orElseThrow(() -> new BusinessException("対象の社員が見つかりません。")); + } - /** 編集画面の初期値(パスワードは空:管理者は変更時のみ入力) */ - public EmployeeForm toForm(Employee e) { - EmployeeForm f = new EmployeeForm(); - f.setIdEmployee(e.getIdEmployee()); - f.setNmEmployee(e.getNmEmployee()); - f.setKnEmployee(e.getKnEmployee()); - f.setMailAddress(e.getMailAddress()); - f.setFlgAdmin(e.getFlgAdmin()); - f.setFlgRetirement(e.getFlgRetirement()); - return f; - } + /** 編集画面の初期値(パスワードは空:管理者は変更時のみ入力) */ + public EmployeeForm toForm(Employee e) { + EmployeeForm f = new EmployeeForm(); + f.setIdEmployee(e.getIdEmployee()); + f.setNmEmployee(e.getNmEmployee()); + f.setKnEmployee(e.getKnEmployee()); + f.setMailAddress(e.getMailAddress()); + f.setFlgAdmin(e.getFlgAdmin()); + f.setFlgRetirement(e.getFlgRetirement()); + return f; + } - // ---------- 入力チェック ---------- + // ---------- 入力チェック ---------- - /** 社員登録:社員名・よみ・メール・管理者フラグ。パスワードは初期値で登録する。 */ - public List validateRegist(EmployeeForm f) { - f.trim(); - List e = new ArrayList<>(); - checkBasic(f, e); - checkMail(f, null, e); - checkFlag(f.getFlgAdmin(), "管理者フラグ", e); - return e; - } + /** 社員登録:社員名・よみ・メール・管理者フラグ。パスワードは初期値で登録する。 */ + public List validateRegist(EmployeeForm f) { + f.trim(); + List e = new ArrayList<>(); + checkBasic(f, e); + checkMail(f, null, e); + checkFlag(f.getFlgAdmin(), "管理者フラグ", e); + return e; + } - /** 管理者による編集:全項目。パスワードは空なら変更しない。 */ - public List validateUpdateByAdmin(EmployeeForm f) { - f.trim(); - List e = new ArrayList<>(); - checkBasic(f, e); - checkMail(f, f.getIdEmployee(), e); - checkFlag(f.getFlgAdmin(), "管理者フラグ", e); - checkFlag(f.getFlgRetirement(), "退職フラグ", e); - if (f.getPassword().length() > 256) e.add("パスワードは256文字以内で入力してください。"); - return e; - } + /** 管理者による編集:全項目。パスワードは空なら変更しない。 */ + public List validateUpdateByAdmin(EmployeeForm f) { + f.trim(); + List e = new ArrayList<>(); + checkBasic(f, e); + checkMail(f, f.getIdEmployee(), e); + checkFlag(f.getFlgAdmin(), "管理者フラグ", e); + checkFlag(f.getFlgRetirement(), "退職フラグ", e); + if (f.getPassword().length() > 256) + e.add("パスワードは256文字以内で入力してください。"); + return e; + } - /** 一般ユーザーによる編集:パスワードのみ(必須)。 */ - public List validateUpdateBySelf(EmployeeForm f) { - f.trim(); - List e = new ArrayList<>(); - if (f.getPassword().isEmpty()) e.add("パスワードを入力してください。"); - else if (f.getPassword().length() > 256) e.add("パスワードは256文字以内で入力してください。"); - return e; - } + /** 一般ユーザーによる編集:パスワードのみ(必須)。 */ + public List validateUpdateBySelf(EmployeeForm f) { + f.trim(); + List e = new ArrayList<>(); + if (f.getPassword().isEmpty()) + e.add("パスワードを入力してください。"); + else if (f.getPassword().length() > 256) + e.add("パスワードは256文字以内で入力してください。"); + return e; + } - private void checkBasic(EmployeeForm f, List e) { - if (f.getNmEmployee().isEmpty()) e.add("社員名を入力してください。"); - else if (f.getNmEmployee().length() > 50) e.add("社員名は50文字以内で入力してください。"); - // ※社員名よみの文字種(ひらがなのみ等)は設計で確定する。現状は文字数のみチェック - if (f.getKnEmployee().isEmpty()) e.add("社員名よみを入力してください。"); - else if (f.getKnEmployee().length() > 100) e.add("社員名よみは100文字以内で入力してください。"); - } + private void checkBasic(EmployeeForm f, List e) { + if (f.getNmEmployee().isEmpty()) + e.add("社員名を入力してください。"); + else if (f.getNmEmployee().length() > 50) + e.add("社員名は50文字以内で入力してください。"); + // ※社員名よみの文字種(ひらがなのみ等)は設計で確定する。現状は文字数のみチェック + if (f.getKnEmployee().isEmpty()) + e.add("社員名よみを入力してください。"); + else if (f.getKnEmployee().length() > 100) + e.add("社員名よみは100文字以内で入力してください。"); + } - private void checkMail(EmployeeForm f, Integer selfId, List e) { - String m = f.getMailAddress(); - if (m.isEmpty()) { e.add("メールアドレスを入力してください。"); return; } - if (m.length() > 256) { e.add("メールアドレスは256文字以内で入力してください。"); return; } - if (!MAIL.matcher(m).matches()) { e.add("メールアドレスの形式が正しくありません。"); return; } - Optional dup = employeeRepository.findByMailAddress(m); - if (dup.isPresent() && !dup.get().getIdEmployee().equals(selfId)) e.add(MSG_DUP); - } + private void checkMail(EmployeeForm f, Integer selfId, List e) { + String m = f.getMailAddress(); + if (m.isEmpty()) { + e.add("メールアドレスを入力してください。"); + return; + } + if (m.length() > 256) { + e.add("メールアドレスは256文字以内で入力してください。"); + return; + } + if (!MAIL.matcher(m).matches()) { + e.add("メールアドレスの形式が正しくありません。"); + return; + } + Optional dup = employeeRepository.findByMailAddress(m); + if (dup.isPresent() && !dup.get().getIdEmployee().equals(selfId)) + e.add(MSG_DUP); + } - private static void checkFlag(String v, String label, List e) { - if (!"0".equals(v) && !"1".equals(v)) e.add(label + "が正しくありません。"); - } + private static void checkFlag(String v, String label, List e) { + if (!"0".equals(v) && !"1".equals(v)) + e.add(label + "が正しくありません。"); + } - // ---------- 登録・更新 ---------- + // ---------- 登録・更新 ---------- - @Transactional - public Integer register(EmployeeForm f, LoginUser admin) { - raise(validateRegist(f)); - Employee e = new Employee(); - e.setNmEmployee(f.getNmEmployee()); - e.setKnEmployee(f.getKnEmployee()); - e.setMailAddress(f.getMailAddress()); - e.setPassword(Const.DEFAULT_PASSWORD); - e.setFlgAdmin(f.getFlgAdmin()); - e.setFlgRetirement(Const.RETIRE_NO); - e.setIdUpdate(admin.getIdEmployee()); - e.setDateUpdate(LocalDateTime.now()); - try { - return employeeRepository.saveAndFlush(e).getIdEmployee(); - } catch (DataIntegrityViolationException ex) { - throw new BusinessException(MSG_DUP); // 同時登録によるUNIQUE制約違反 - } - } + @Transactional + public Integer register(EmployeeForm f, LoginUser admin) { + raise(validateRegist(f)); + Employee e = new Employee(); + e.setNmEmployee(f.getNmEmployee()); + e.setKnEmployee(f.getKnEmployee()); + e.setMailAddress(f.getMailAddress()); + e.setPassword(passwordEncoder.encode(Const.DEFAULT_PASSWORD)); + e.setFlgAdmin(f.getFlgAdmin()); + e.setFlgRetirement(Const.RETIRE_NO); + e.setIdUpdate(admin.getIdEmployee()); + e.setDateUpdate(LocalDateTime.now()); + try { + return employeeRepository.saveAndFlush(e).getIdEmployee(); + } catch (DataIntegrityViolationException ex) { + throw new BusinessException(MSG_DUP); // 同時登録によるUNIQUE制約違反 + } + } - @Transactional - public void updateByAdmin(EmployeeForm f, LoginUser admin) { - raise(validateUpdateByAdmin(f)); - Employee e = find(f.getIdEmployee()); - e.setNmEmployee(f.getNmEmployee()); - e.setKnEmployee(f.getKnEmployee()); - e.setMailAddress(f.getMailAddress()); - if (!f.getPassword().isEmpty()) e.setPassword(f.getPassword()); - e.setFlgAdmin(f.getFlgAdmin()); - e.setFlgRetirement(f.getFlgRetirement()); - e.setIdUpdate(admin.getIdEmployee()); - e.setDateUpdate(LocalDateTime.now()); - try { - employeeRepository.saveAndFlush(e); - } catch (DataIntegrityViolationException ex) { - throw new BusinessException(MSG_DUP); - } - } + @Transactional + public void updateByAdmin(EmployeeForm f, LoginUser admin) { + raise(validateUpdateByAdmin(f)); + Employee e = find(f.getIdEmployee()); + e.setNmEmployee(f.getNmEmployee()); + e.setKnEmployee(f.getKnEmployee()); + e.setMailAddress(f.getMailAddress()); + if (!f.getPassword().isEmpty()) { + e.setPassword(passwordEncoder.encode(f.getPassword())); + } + e.setFlgAdmin(f.getFlgAdmin()); + e.setFlgRetirement(f.getFlgRetirement()); + e.setIdUpdate(admin.getIdEmployee()); + e.setDateUpdate(LocalDateTime.now()); + try { + employeeRepository.saveAndFlush(e); + } catch (DataIntegrityViolationException ex) { + throw new BusinessException(MSG_DUP); + } + } - /** 一般ユーザー:自分自身(セッションの社員ID)のパスワードのみ更新する。 */ - @Transactional - public void updateBySelf(EmployeeForm f, LoginUser self) { - raise(validateUpdateBySelf(f)); - Employee e = find(self.getIdEmployee()); - e.setPassword(f.getPassword()); - e.setIdUpdate(self.getIdEmployee()); - e.setDateUpdate(LocalDateTime.now()); - } + /** 一般ユーザー:自分自身(セッションの社員ID)のパスワードのみ更新する。 */ + @Transactional + public void updateBySelf(EmployeeForm f, LoginUser self) { + raise(validateUpdateBySelf(f)); + Employee e = find(self.getIdEmployee()); + e.setPassword(passwordEncoder.encode(f.getPassword())); + e.setIdUpdate(self.getIdEmployee()); + e.setDateUpdate(LocalDateTime.now()); + } - private static void raise(List errors) { - if (!errors.isEmpty()) throw new BusinessException(String.join(" ", errors)); - } + private static void raise(List errors) { + if (!errors.isEmpty()) + throw new BusinessException(String.join(" ", errors)); + } } diff --git a/book-rent/src/main/java/com/example/demo/service/LoginAttemptService.java b/book-rent/src/main/java/com/example/demo/service/LoginAttemptService.java new file mode 100644 index 0000000..3da38af --- /dev/null +++ b/book-rent/src/main/java/com/example/demo/service/LoginAttemptService.java @@ -0,0 +1,101 @@ +package com.example.demo.service; + +import java.time.LocalDateTime; +import java.util.Locale; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.springframework.stereotype.Service; + +// 失敗回数とロック時間を管理 +@Service +public class LoginAttemptService { + + // 最大失敗回数 + private static final int MAX_ATTEMPTS = 5; + // ロック時間 + private static final int LOCK_MINUTES = 10; + + // 失敗回数を保存 + private final ConcurrentMap attempts = new ConcurrentHashMap<>(); + + public static class AttemptState { + // 何回失敗したか + private final int failedAttempts; + // いつロックを解除するか + private final LocalDateTime lockedUntil; + + private AttemptState(int failedAttempts, LocalDateTime lockedUntil) { + this.failedAttempts = failedAttempts; + this.lockedUntil = lockedUntil; + } + } + + private String normalize(String mailAddress) { + return mailAddress.trim().toLowerCase(Locale.ROOT); + } + + // ログイン失敗時 + public void loginFailed(String mailAddress) { + String mail = normalize(mailAddress); + LocalDateTime now = LocalDateTime.now(); + AttemptState current = attempts.get(mail); + + // ロック中なら何もしない + if (current != null && current.lockedUntil != null + && now.isBefore(current.lockedUntil)) { + return; + } + + // 失敗回数を数える + int count = (current == null || current.lockedUntil != null) + ? 1 + : current.failedAttempts + 1; + + // 5回失敗したら10分間ロック + LocalDateTime lockTime = count >= MAX_ATTEMPTS + ? now.plusMinutes(LOCK_MINUTES) + : null; + + // 失敗回数とロック時間を保存 + attempts.put(mail, new AttemptState(count, lockTime)); + } + + // 現在ロック中かどうか判定 + public boolean isBlocked(String mailAddress) { + String mail = normalize(mailAddress); + AttemptState current = attempts.get(mail); + + if (current == null) { + return false; + } + if (current.lockedUntil == null) { + return false; + } + + // 10分経過したらロックを解除 + if (!LocalDateTime.now().isBefore(current.lockedUntil)) { + attempts.remove(mail); + return false; + } + + return true; + + } + + // ログイン成功時に、過去の失敗回数をきれいにリセットする + public void loginSucceeded(String mailAddress) { + attempts.remove(normalize(mailAddress)); + } + + public int getFailedAttempts(String mailAddress) { + AttemptState state = attempts.get(normalize(mailAddress)); + return state == null ? 0 : state.failedAttempts; + } + + public int getRemainingAttempts(String mailAddress) { + int failed = getFailedAttempts(mailAddress); + return Math.max(0, MAX_ATTEMPTS - failed); + } + +}