diff --git a/book-rent/pom.xml b/book-rent/pom.xml index 606ba8b..5209f82 100644 --- a/book-rent/pom.xml +++ b/book-rent/pom.xml @@ -30,7 +30,7 @@ 21 - + org.springframework.boot spring-boot-starter-data-jpa @@ -55,7 +55,7 @@ runtime true - + com.mysql mysql-connector-j @@ -77,6 +77,10 @@ spring-boot-starter-test test + +org.springframework.boot +spring-boot-starter-security + @@ -123,5 +127,4 @@ - - + \ No newline at end of file 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..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..7e64080 --- /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); + } + + } +} \ No newline at end of file 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/LoginCheckInterceptor.java b/book-rent/src/main/java/com/example/demo/config/LoginCheckInterceptor.java index ac2a925..107dbfb 100644 --- a/book-rent/src/main/java/com/example/demo/config/LoginCheckInterceptor.java +++ b/book-rent/src/main/java/com/example/demo/config/LoginCheckInterceptor.java @@ -34,4 +34,5 @@ public class LoginCheckInterceptor implements HandlerInterceptor { } return true; } + } 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..6bdc6c4 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,68 @@ 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 diff --git a/book-rent/src/main/java/com/example/demo/entity/Employee.java b/book-rent/src/main/java/com/example/demo/entity/Employee.java index 51cf471..25798ad 100644 --- a/book-rent/src/main/java/com/example/demo/entity/Employee.java +++ b/book-rent/src/main/java/com/example/demo/entity/Employee.java @@ -105,4 +105,5 @@ public class Employee { public void setDateUpdate(LocalDateTime v) { this.dateUpdate = v; } + } diff --git a/book-rent/src/main/java/com/example/demo/repository/EmployeeRepository.java b/book-rent/src/main/java/com/example/demo/repository/EmployeeRepository.java index c0617a7..e417ae2 100644 --- a/book-rent/src/main/java/com/example/demo/repository/EmployeeRepository.java +++ b/book-rent/src/main/java/com/example/demo/repository/EmployeeRepository.java @@ -10,4 +10,5 @@ public interface EmployeeRepository extends JpaRepository { Optional findByMailAddressAndFlgRetirement(String mailAddress, String flgRetirement); Optional findByMailAddress(String mailAddress); + } diff --git a/book-rent/src/main/java/com/example/demo/service/BookAdminService.java b/book-rent/src/main/java/com/example/demo/service/BookAdminService.java index 126fc2b..17f56d1 100644 --- a/book-rent/src/main/java/com/example/demo/service/BookAdminService.java +++ b/book-rent/src/main/java/com/example/demo/service/BookAdminService.java @@ -32,10 +32,8 @@ public class BookAdminService { else if (f.getIsbn().length() > 13) e.add("ISBNは13文字以内で入力してください。"); if (f.getNmBook().isEmpty()) e.add("書籍名を入力してください。"); else if (f.getNmBook().length() > 50) e.add("書籍名は50文字以内で入力してください。"); - if(!f.getKnBook().matches("^[\\u3040-\\u309F]+$")) e.add("書籍名よみはひらがなで入力してください"); - else if (f.getKnBook().length() > 100) e.add("書籍名よみは100文字以内で入力してください。"); + if (f.getKnBook().length() > 100) e.add("書籍名よみは100文字以内で入力してください。"); if (f.getPublisher().length() > 50) e.add("出版社は50文字以内で入力してください。"); - return e; } 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..be96b55 --- /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..c1f56af 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()); } 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 diff --git a/book-rent/src/main/resources/static/css/style.css b/book-rent/src/main/resources/static/css/style.css index 056edc5..bfebba8 100644 --- a/book-rent/src/main/resources/static/css/style.css +++ b/book-rent/src/main/resources/static/css/style.css @@ -1,113 +1,90 @@ -/* ---------------------------------------------------- - Login Layout & Animation Styles (Updated) ----------------------------------------------------- */ -body { - font-family: "Meiryo", "Hiragino Sans", sans-serif; - margin: 0; - color: #222; - background: #f6f7f9; +body +{font-family: + "Meiryo","Hiragino Sans",sans-serif; + margin:0;color:#222; + background:#f6f7f9} +header.top +{display:flex; + justify-content:space-between; + align-items:center; + background:#176a78;color:#fff; + padding:10px 20px } -header.top { - display: flex; - justify-content: space-between; - align-items: center; - background: #176a78; - color: #fff; - padding: 10px 20px; +header.top form +{display:inline; + margin:0} +header.top .user +{margin-right:12px } -header.top form { display: inline; margin: 0; } -header.top .user { margin-right: 12px; } -main { max-width: 960px; margin: 24px auto; padding: 0 16px; } -h1 { font-size: 20px; } -.card { background: #fff; border: 1px solid #dde; border-radius: 6px; padding: 20px; margin-bottom: 16px; } -label { display: block; margin: 10px 0 4px; font-weight: bold; } -input[type=text], input[type=password], input[type=email] { width: 100%; max-width: 360px; padding: 6px; box-sizing: border-box; } -button, .btn { display: inline-block; background: #176a78; color: #fff; border: 0; border-radius: 4px; padding: 7px 16px; cursor: pointer; text-decoration: none; font-size: 14px; } -button.sub, .btn.sub { background: #6b7b80; } -.error { background: #fdecea; color: #b3261e; border: 1px solid #f5c2be; padding: 8px 12px; border-radius: 4px; margin-bottom: 12px; } -.info { background: #e8f4ea; color: #1b5e20; border: 1px solid #bfdcc3; padding: 8px 12px; border-radius: 4px; margin-bottom: 12px; } -table { border-collapse: collapse; width: 100%; background: #fff; } -th, td { border: 1px solid #dde; padding: 6px 8px; text-align: left; font-size: 14px; } -th { background: #eaf4f6; } -.menu a { display: block; margin: 8px 0; } - -/* Flexbox Layout: ဘယ်ဘက် Icon နှင့် ညာဘက် Login အချက်အလက်များ ဘေးချင်းယှဉ်ရန် */ -.login-container-flex { - display: flex; - align-items: flex-start; - gap: 35px; /* Icon နှင့် Form ကြား အကွာအဝေး */ +main +{max-width:960px; + margin:24px auto; + padding:0 16px } - -/* ဘယ်ဘက်ခြမ်း Icon Box Styling */ -.avatar-box { - flex-shrink: 0; - width: 90px; - height: 90px; - background: #eaf4f6; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.4s ease; - cursor: pointer; - margin-top: 5px; +h1 +{font-size:20px } - -.avatar-box:hover { - transform: scale(1.08); - background: #d5edf1; +.card +{background:#fff; + border:1px solid #dde; + border-radius:6px; + padding:20px; + margin-bottom:16px } - -/* ညာဘက်ခြမ်း Form Area ကို အပြည့်အသုံးပြုရန် */ -.form-content-area { - flex-grow: 1; +label +{display:block; + margin:10px 0 4px; + font-weight:bold } - -/* 1. FALSE / ERROR State (တုန်ခါမည်) */ -.card.shake { - animation: shake 0.4s ease-in-out; +input[type=text],input[type=password],input[type=email] +{width:100%; + max-width:360px; + padding:6px; + box-sizing:border-box } - -.avatar-box.error-state { - background: #fdecea !important; - animation: pulseError 0.4s ease-in-out; +button,.btn +{display:inline-block; + background:#176a78; + color:#fff;border:0; + border-radius:4px; + padding:7px 16px; + cursor:pointer; + text-decoration:none; + font-size:14px } - -@keyframes shake { - 0%, 100% { transform: translateX(0); } - 20%, 60% { transform: translateX(-8px); } - 40%, 80% { transform: translateX(8px); } +button.sub,.btn.sub +{background:#6b7b80 } - -@keyframes pulseError { - 0% { transform: scale(1); } - 50% { transform: scale(1.15); } - 100% { transform: scale(1); } +.error +{background:#fdecea;color:#b3261e; + border:1px solid #f5c2be; + padding:8px 12px; + border-radius:4px; + margin-bottom:12px } - -/* 2. TRUE / SUCCESS State (အောင်မြင်မှု Animation) */ -.avatar-box.success-state { - background: #e8f4ea !important; - animation: successFly 0.6s ease-in-out forwards; +.info +{background:#e8f4ea; + color:#1b5e20; + border:1px solid #bfdcc3; + padding:8px 12px; + border-radius:4px; + margin-bottom:12px } - -@keyframes successFly { - 0% { transform: scale(1); } - 40% { transform: scale(1.2) translateY(-8px); } - 100% { transform: scale(0.9) translateY(-20px); opacity: 0.8; } +table +{border-collapse:collapse; + width:100%; + background:#fff +} +th,td +{border:1px solid #dde; + padding:6px 8px; + text-align:left; + font-size:14px +} +th +{background:#eaf4f6 +} +.menu a +{display:block; + margin:8px 0 } - -/* Responsive (ဖုန်းစခရင်သေးများတွင် အောက်သို့ ဆင်းသွားစေရန်) */ -@media (max-width: 600px) { - .login-container-flex { - flex-direction: column; - align-items: center; - text-align: center; - } - .form-content-area { - width: 100%; - } - input[type=text], input[type=password], input[type=email] { - max-width: 100% !important; - } -} \ No newline at end of file diff --git a/book-rent/src/main/resources/templates/login.html b/book-rent/src/main/resources/templates/login.html index 5452d9d..26aaa40 100644 --- a/book-rent/src/main/resources/templates/login.html +++ b/book-rent/src/main/resources/templates/login.html @@ -1,117 +1,123 @@ + - - ログイン - - - - - + + ログイン + + -
-

書籍貸出管理システム

-
- - - - -
-
- - +
+

書籍貸出管理システム

+
+

ログイン

+
+
+ + + + +

+
+
+
+ + - - - - + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + \ No newline at end of file