From a9c5fb2283536b9939a7719c496d76238a5991a1 Mon Sep 17 00:00:00 2001 From: "h.hein" <26-h-kyaw-sakuracommunication@it-college.co.jp> Date: Tue, 27 Oct 2026 15:07:10 +0900 Subject: [PATCH] All done --- book-rent/pom.xml | 225 ++++++++++++++---- .../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 | 164 +++++++++---- .../service/CustomUserDetailsService.java | 47 ++++ .../example/demo/service/EmployeeService.java | 210 +++++++++++++++- .../demo/service/LoginAttemptService.java | 101 ++++++++ 10 files changed, 920 insertions(+), 104 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/pom.xml b/book-rent/pom.xml index 606ba8b..b3be1ba 100644 --- a/book-rent/pom.xml +++ b/book-rent/pom.xml @@ -30,54 +30,54 @@ 21 - - -org.springframework.boot -spring-boot-starter-data-jpa - - -org.springframework.boot -spring-boot-starter-thymeleaf - - -org.springframework.boot -spring-boot-starter-validation - - - -org.springframework.boot -spring-boot-starter-web - - - -org.springframework.boot -spring-boot-devtools -runtime -true - - - -com.mysql -mysql-connector-j -runtime - - -org.projectlombok -lombok -true - - -org.springframework.boot -spring-boot-starter-tomcat -provided - - - -org.springframework.boot -spring-boot-starter-test -test - - + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + com.mysql + mysql-connector-j + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-tomcat + provided + + + org.springframework.boot + spring-boot-starter-test + test + + @@ -99,6 +99,7 @@ org.projectlombok lombok + 1.18.30 @@ -114,6 +115,7 @@ org.projectlombok lombok + 1.18.30 @@ -122,6 +124,131 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..6bd3d3c --- /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..be6c000 --- /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..087323c --- /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..d6f70f9 --- /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; + } + +} \ No newline at end of file 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..235f095 --- /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..50bd5d9 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,140 @@ package com.example.demo.controller; - -import java.util.Optional; - -import jakarta.servlet.http.HttpServletRequest; 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 +} +//package com.example.demo.controller; +// +// +//import java.util.Optional; +// +//import jakarta.servlet.http.HttpServletRequest; +//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"; +// } +// +// @GetMapping("/login") +// public String loginForm(HttpSession session) { +// if (session.getAttribute(Const.SESSION_LOGIN_USER) != null) { +// return "redirect:/menu"; +// } +// 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..ae3d4a5 --- /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); + } + +} \ No newline at end of file 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 80dfaa4..28bdec6 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; @@ -17,16 +18,25 @@ import com.example.demo.entity.Employee; import com.example.demo.form.EmployeeForm; 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 = "このメールアドレスは既に登録されています。"; + // SecurityConfigに適用させるため private final EmployeeRepository employeeRepository; + private final PasswordEncoder passwordEncoder; - public EmployeeService(EmployeeRepository employeeRepository) { + public EmployeeService( + EmployeeRepository employeeRepository, + PasswordEncoder passwordEncoder) { this.employeeRepository = employeeRepository; + this.passwordEncoder = passwordEncoder; + } // ---------- 検索 ---------- @@ -100,8 +110,6 @@ public class EmployeeService { e.add("社員名よみを入力してください。"); else if (f.getKnEmployee().length() > 100) e.add("社員名よみは100文字以内で入力してください。"); - else if (!f.getKnEmployee().matches("^[\\u3040-\\u309F]+$")) - e.add("社員名よみはひらがなで入力してください"); } private void checkMail(EmployeeForm f, Integer selfId, List e) { @@ -137,7 +145,7 @@ public class EmployeeService { e.setNmEmployee(f.getNmEmployee()); e.setKnEmployee(f.getKnEmployee()); e.setMailAddress(f.getMailAddress()); - e.setPassword(Const.DEFAULT_PASSWORD); + e.setPassword(passwordEncoder.encode(Const.DEFAULT_PASSWORD)); e.setFlgAdmin(f.getFlgAdmin()); e.setFlgRetirement(Const.RETIRE_NO); e.setIdUpdate(admin.getIdEmployee()); @@ -156,8 +164,9 @@ public class EmployeeService { e.setNmEmployee(f.getNmEmployee()); e.setKnEmployee(f.getKnEmployee()); e.setMailAddress(f.getMailAddress()); - if (!f.getPassword().isEmpty()) - e.setPassword(f.getPassword()); + if (!f.getPassword().isEmpty()) { + e.setPassword(passwordEncoder.encode(f.getPassword())); + } e.setFlgAdmin(f.getFlgAdmin()); e.setFlgRetirement(f.getFlgRetirement()); e.setIdUpdate(admin.getIdEmployee()); @@ -174,7 +183,7 @@ public class EmployeeService { public void updateBySelf(EmployeeForm f, LoginUser self) { raise(validateUpdateBySelf(f)); Employee e = find(self.getIdEmployee()); - e.setPassword(f.getPassword()); + e.setPassword(passwordEncoder.encode(f.getPassword())); e.setIdUpdate(self.getIdEmployee()); e.setDateUpdate(LocalDateTime.now()); } @@ -184,3 +193,190 @@ public class EmployeeService { throw new BusinessException(String.join(" ", errors)); } } + +//package com.example.demo.service; +// +//import java.time.LocalDateTime; +//import java.util.ArrayList; +//import java.util.List; +//import java.util.Optional; +//import java.util.regex.Pattern; +// +//import org.springframework.dao.DataIntegrityViolationException; +//import org.springframework.stereotype.Service; +//import org.springframework.transaction.annotation.Transactional; +// +//import com.example.demo.common.BusinessException; +//import com.example.demo.common.Const; +//import com.example.demo.common.LoginUser; +//import com.example.demo.entity.Employee; +//import com.example.demo.form.EmployeeForm; +//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 final EmployeeRepository employeeRepository; +// +// public EmployeeService(EmployeeRepository employeeRepository) { +// this.employeeRepository = employeeRepository; +// } +// +// // ---------- 検索 ---------- +// +// @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("対象の社員が見つかりません。")); +// } +// +// /** 編集画面の初期値(パスワードは空:管理者は変更時のみ入力) */ +// 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 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; +// } +// +// 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文字以内で入力してください。"); +// else if (!f.getKnEmployee().matches("^[\\u3040-\\u309F]+$")) +// e.add("社員名よみはひらがなで入力してください"); +// } +// +// 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 + "が正しくありません。"); +// } +// +// // ---------- 登録・更新 ---------- +// +// @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 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); +// } +// } +// +// /** 一般ユーザー:自分自身(セッションの社員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()); +// } +// +// 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..34db840 --- /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); + } + +} \ No newline at end of file