# Spring Security ###### tags: `Spring` `Security` `login` `Annotation` `email` ## ER-Model ![](https://i.imgur.com/ynE3bgh.png) ## 項目的目錄結構 * src/main/ * java/com/xxx/ * controller:控制層 * dto:數據傳輸物件 * entity:持久層 * [event](https://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/context/ApplicationEvent.html):事件 * [listener](https://www.edureka.co/blog/action-listener-in-java/):監聽器 * repository:數據庫 * service:服務 * validator:自訂義標籤 * resources * skeleton:document * templates:view ## 環境變數 ``` DATASOURCE_HOST={資料庫主機} DATASOURCE_PORT={資料庫埠號} DATASOURCE_CATALOG={資料庫名稱} DATASOURCE_USERNAME={資料庫帳號} DATASOURCE_PASSWORD={資料庫密碼} DATASOURCE_URL=postgresql://localhost:5432/{資料庫名稱}?password={密碼}&sslmode=prefered&user={使用者名稱} DEVTOOLS_RESTART_ENABLED=true PORT=8080 SPRING_PROFILES_ACTIVE=d MAIL_USER={寄件者帳號} MAIL_PASSWORD={寄件者密碼} FACEBOOK_APP_ID={FACEBOOK應用程式編號} FACEBOOK_APP_SECRET={FACEBOOK金鑰} ``` > 若使用 Gmail 或 G Suite 寄送電子郵件可能會寄送失敗,解決方法:https://support.google.com/accounts/answer/6010255 ## 配置 Spring Security ### 先搞一個繼承 [WebSecurityConfigurerAdapter](https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/config/annotation/web/configuration/WebSecurityConfigurerAdapter.html) 的 class ``` @Configuration @EnableWebSecurity public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter { } ``` ### 此 class 須 [@Override](https://docs.oracle.com/javase/8/docs/api/java/lang/Override.html) 以下幾個 methods #### protected void [configure](https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/config/annotation/web/configuration/WebSecurityConfigurerAdapter.html#configure-org.springframework.security.config.annotation.web.builders.HttpSecurity-)(HttpSecurity http) throws java.lang.Exception ``` @Override protected void configure(HttpSecurity httpSecurity) throws Exception { httpSecurity. authorizeRequests(). antMatchers("/criteria","/registration/", "/registration/confirm", "/registration/resendToken", "/talent/forgetPassword", "/signin/**", "/signup/**").permitAll(). // 允許所有人請求 //antMatchers().hasAuthority("CHANGE_PASSWORD_PRIVILEGE"). //anyRequest().hasAnyRole("ADMIN", "USER"). // 其它全部的路徑都得經過使用者驗證後才可以存取 and(). formLogin(). // 使用 Form Login 登入驗證 loginPage("/login.aspx"). // 自定義登入頁面 permitAll(). // 允許所有人請求 //.loginProcessingUrl("/login") // 對應自定義登入頁面的 action URI //defaultSuccessUrl("/"). // 登入成功後導向的URI and(). logout(). // 如開啟CSRF功能, 會將 logout 預設為 POST, 在此設定使用任何 HTTP 方法請求(不建議) //logoutRequestMatcher(new AntPathRequestMatcher("/logout")). logoutUrl("/logout.aspx"). // 登出的 URL //logoutSuccessUrl("/login"). // 登出後的跳轉地址(預設值原為 /login?logout) permitAll(). invalidateHttpSession(true). // 登出時是否 invalidate HttpSession deleteCookies("JSESSIONID"). // 登出同時清除 cookies and(). rememberMe(). rememberMeParameter("remember"). //from的忘記我欄位name key("uniqueAndSecret"). //組成 cookie 的加密字串 rememberMeCookieName("remember"). tokenValiditySeconds(86400).//設定有效時間 ,預設是兩周,這裡設置為1天 and(). sessionManagement(). sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED).//設定Session策略 invalidSessionUrl("/login.aspx?session").//Session失效,重定向到可配置的URL //典型session修復攻擊的保護-Session fixation sessionFixation(). migrateSession(). and(). csrf().disable(); // 關閉 CSRF 防護 // 預設開啟 CSRF 功能, 需設定 csrfTokenRepository() 以存取 CsrfToken 進行驗證 //csrfTokenRepository(new HttpSessionCsrfTokenRepository()); } ``` 2. AuthenticationManagerBuilder (身份驗證管理) ### 登入邏輯 [使用者登入資訊](https://docs.spring.io/spring-security/site/docs/3.2.x/apidocs/org/springframework/security/core/userdetails/User.html) ``` // service/MyUserDetailsService.java /** * 實作 UserDetailsService 進行會員登入邏輯判斷 */ @Service @Transactional public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private TalentRepository talentRepository; public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { Talent talent = talentRepository.findByEmail(email); if (talent == null) { throw new UsernameNotFoundException("No user found with username: " + email); } boolean enabled = true; //啟用 /* 加入使用者登入資訊 */ return new org.springframework.security.core.userdetails.User(talent.getEmail().toLowerCase(), talent.getShadow(), enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, getAuthorities(talent.getRole())); } private static List<GrantedAuthority> getAuthorities(String role) { List<GrantedAuthority> authorities = new ArrayList<>(); authorities.add(new SimpleGrantedAuthority(role)); return authorities; } } ``` ### protected void [configure](https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/config/annotation/web/configuration/WebSecurityConfigurerAdapter.html#configure-org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder-)(AuthenticationManagerBuilder auth) throws java.lang.Exception ``` /** * 新增使用者 */ @Override protected void configure(final AuthenticationManagerBuilder auth) throws Exception { // 手動建立使用者 auth.inMemoryAuthentication() .withUser("powerfish0813@gmail.com") //設定使用者帳號 .password(new BCryptPasswordEncoder().encode("123"))//設定密碼 .roles("ADMIN");//設定權限(可以同時給多個身分,用逗號隔開) // 導入登入邏輯 auth.userDetailsService(userDetailsService); } ``` 3. WebSecurity(WEB安全) ## 自訂義 Annotation ### 標籤 1. [@Target](https://docs.oracle.com/javase/7/docs/api/java/lang/annotation/Target.html):限定 Annotation 使用對象 * TYPE:適用 class, interface, enum * FIELD:適用 field * ANNOTATION_TYPE:適用 Annotation 型態 2. [@Retention](https://docs.oracle.com/javase/7/docs/api/java/lang/annotation/Retention.html):告知編譯器如何處理 Annotation * RUNTIME: Annotation 會被 JVM 所保留 4. [@Constraint](https://docs.oracle.com/javaee/7/api/javax/validation/Constraint.html):決定對象可以使用 @Valid 驗證 * validatedBy:指定 Validator (驗證器)的 class 6. [@Documented](https://docs.oracle.com/javase/7/docs/api/java/lang/annotation/Documented.html):表明這個 Annotation 可以被 Java Doc 紀錄 7. 實作密碼與再輸入密碼判別是否相同的 Annotation ### 定義標籤 @PasswordMatches ``` //validator/PasswordMatches.java @Target({TYPE, ANNOTATION_TYPE}) @Retention(RUNTIME) @Constraint(validatedBy = PasswordMatchesImpl.class) @Documented public @interface PasswordMatches { String message() default "Passwords don't match";//設定驗證失敗回傳訊息 Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; } ``` ### 定義驗證邏輯 ``` //validator/PasswordMatchesImpl.java public class PasswordMatchesImpl implements ConstraintValidator<PasswordMatches, Object> { /* 初始化 */ @Override public void initialize(PasswordMatches constraintAnnotation) { } /* 驗證邏輯 */ @Override public boolean isValid(Object obj, ConstraintValidatorContext context) { RegistrationDto registration = (RegistrationDto) obj; return registration.getPassword().equals(registration.getMatchingPassword()); } } ``` ### 套用 @PasswordMatches ``` // dto/RegistrationDto.java /* 當初設定可套用類型TYPE, ANNOTATION_TYPE 故只有 class, interface, enum, annotation 可以套用此標籤 */ @PasswordMatches public class RegistrationDto { @NotNull @NotEmpty private String nickName; @NotNull @NotEmpty private String password; private String matchingPassword; @Pattern(regexp = "[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", message = "Invalid email") @NotNull @NotEmpty private String email; // standard getters and setters } ``` ## 登入功能 ### 登入邏輯 [使用者登入資訊](https://docs.spring.io/spring-security/site/docs/3.2.x/apidocs/org/springframework/security/core/userdetails/User.html) ``` /** * 認證(authentication)商業邏輯 * src/main/java/com/security/demo/service/UserDetailsServiceImpl.java */ @Service @Transactional public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private SomeoneRepository someoneRepository; @Autowired private LoginAttemptService loginAttemptService; @Autowired private HttpServletRequest request; /** * * @param email * @return * @throws UsernameNotFoundException */ @Override public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { String ip = getClientIP(); if (loginAttemptService.isBlocked(ip)) { throw new RuntimeException("blocked"); } Someone someone = someoneRepository.findOneByEmail(email); if (someone == null) { throw new UsernameNotFoundException("No user found with username: " + email); } boolean verfied = someone.isVerified(); //boolean accountNonExpired = true; //boolean credentialsNonExpired = true; //boolean accountNonLocked = true; /* 加入使用者登入資訊 */ return new org.springframework.security.core.userdetails.User( someone.getEmail().toLowerCase(), someone.getShadow(), verfied, true, // accountNonExpired true, // credentialsNonExpired true, // accountNonLocked getAuthorities(someone.getRole()) ); } /** * * @param role * @return */ private static List<GrantedAuthority> getAuthorities(String role) { List<GrantedAuthority> grantedAuthorities = new ArrayList<>(); grantedAuthorities.add(new SimpleGrantedAuthority(role)); return grantedAuthorities; } private final String getClientIP() { final String header = request.getHeader("X-Forwarded-For"); if (header == null || header.isEmpty()) { return request.getRemoteAddr(); } return header.split(",")[0]; } } ``` ### 將登入邏輯加入 AuthenticationManagerBuilder(身份驗證管理) ``` // java/WebSecurityConfigurer.java @Override @Configuration @EnableWebSecurity public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter { /* 登入邏輯 service/MyUserDetailsService.java */ @Autowired private MyUserDetailsService userDetailsService; protected void configure(final AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService); } } ``` ## 註冊並寄送驗證信 ### ``` // controller/RegistrationController.java /** * 建立會員 * * @param registrationDto 註冊DTO * @param result 驗證是否正確 * @return */ private Talent createUserAccount(RegistrationDto registrationDto, BindingResult result) { Talent registered = null; try { registered = iUserService.registerNewUserAccount(registrationDto); } catch (Exception e) { return null; } return registered; } /** * 取得註冊DTO * * @param nickName 暱稱 * @param email 信箱 * @param password 密碼 * @param matchingPassword 再輸入密碼 * @return */ public RegistrationDto testModeelAttribute(@RequestParam("nickName") String nickName, @RequestParam("email") String email, @RequestParam("password") String password, @RequestParam("matchingPassword") String matchingPassword) { RegistrationDto registrationDto = new RegistrationDto(); registrationDto.setNickName(nickName); registrationDto.setEmail(email); registrationDto.setPassword(password); registrationDto.setPassword(matchingPassword); return registrationDto; } /** * 註冊會員 * * @param registrationDto 會員資料驗證 * @param result 驗證是否正確 * @param request * @param errors * @return * @throws Exception */ @PostMapping("/") public ModelAndView registerUserAccount(@ModelAttribute("testModeelAttribute") @Valid RegistrationDto registrationDto, BindingResult result, WebRequest request, Errors errors) throws Exception { Talent talent = new Talent(); /* 註冊DTO驗證 */ if (!result.hasErrors()) { /* 建立會員 */ talent = createUserAccount(registrationDto, result); } if (talent == null) { result.rejectValue("email", "message.regError"); } if (result.hasErrors()) { Document document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse("classpath:/skeleton/index.xml"); ModelAndView modelAndView = new ModelAndView("registrations"); modelAndView.getModelMap().addAttribute(new DOMSource(document)); return modelAndView; } try { /* 執行註冊事件-寄送驗證信 */ String appUrl = request.getContextPath(); applicationEventPublisher.publishEvent(new OnRegistrationCompleteEvent(talent, request.getLocale(), appUrl)); } catch (Exception me) { System.out.print(me); Document document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse("classpath:/skeleton/index.xml"); ModelAndView modelAndView = new ModelAndView("registrations111"); modelAndView.getModelMap().addAttribute(new DOMSource(document)); return modelAndView; } return index(); } ``` ### 設定註冊事件 ``` // event/OnRegistrationCompleteEvent.java /* * 需要繼承 ApplicationEvent */ public class OnRegistrationCompleteEvent extends ApplicationEvent { private static final long serialVersionUID = -6411093423884992233L; private String appUrl;//驗證路徑 private Locale locale;//地區 private Talent talent;//會員資料 public OnRegistrationCompleteEvent(Talent talent, Locale locale, String appUrl) { super(talent); this.talent = talent; this.locale = locale; this.appUrl = appUrl; } // standard getters and setters } ``` ### 註冊監聽器 ``` // listener/Listener /* * 需要實作ApplicationListener<註冊事件> */ @Component public class RegistrationListenerImpl implements ApplicationListener<OnRegistrationCompleteEvent> { @Autowired private IUserService iUserService; @Autowired private MessageSource messageSource; @Autowired private JavaMailSender javaMailSender; /* 當註冊事件被觸發的時候會觸發此方法 */ @Override public void onApplicationEvent(OnRegistrationCompleteEvent event) { this.confirmRegistration(event); } private void confirmRegistration(OnRegistrationCompleteEvent event) { Talent talent = event.getTalent(); UUID token = UUID.randomUUID(); /* 產生token */ iUserService.createVerificationToken(talent, token); String recipientAddress = talent.getEmail(); String subject = "Registration Confirmation"; String confirmationUrl = event.getAppUrl() + "/registration/confirm?token=" + token.toString(); /* 發送E-mail */ SimpleMailMessage email = new SimpleMailMessage(); email.setTo(recipientAddress); email.setSubject(subject); email.setText("來認證喔:" + "http://localhost:8080" + confirmationUrl); javaMailSender.send(email); } } ``` ### 會員認證信驗證 ``` // controller/RegistrationController.java @GetMapping("/confirm") public ModelAndView confirmRegistration(WebRequest request, @RequestParam("token") UUID token) throws Exception { Locale locale = request.getLocale(); /* 驗證Token是否正確 */ VerificationToken verificationToken = iUserService.getVerificationToken(token); if (verificationToken == null) { Document document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse("classpath:/skeleton/index.xml"); ModelAndView modelAndView = new ModelAndView("registrations"); modelAndView.getModelMap().addAttribute(new DOMSource(document)); return modelAndView; } Talent talent = verificationToken.getTalent(); Calendar cal = Calendar.getInstance(); if ((verificationToken.getExpiryDate().getTime() - cal.getTime().getTime()) <= 0) { Document document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse("classpath:/skeleton/index.xml"); ModelAndView modelAndView = new ModelAndView("registrations111"); modelAndView.getModelMap().addAttribute(new DOMSource(document)); return modelAndView; } talent.setEnabled(true); iUserService.saveRegisteredUser(talent); return index(); } ``` ### 信驗證過期重新寄送 ``` @GetMapping("/resendToken") public ModelAndView resendRegistration(WebRequest request, @RequestParam("token") UUID token) throws Exception { VerificationToken verificationToken = iUserService.resetVerificationToken(token); /* 觸發寄送驗證信事件 */ try { String appUrl = request.getContextPath(); applicationEventPublisher.publishEvent(new OnRegistrationCompleteEvent(verificationToken.getTalent(), request.getLocale(), appUrl)); } catch (Exception me) { return index(); } return new ModelAndView("redirect:/"); } ``` ## Github Repository >https://github.com/pkmogy/SpringSecurityDemo [前往 Spring Security Acl](https://hackmd.io/@LeeLo/HJD8s_NUH) [前往 Spring Boot Redis](https://hackmd.io/@LeeLo/HJBTC6Xb8) [前往 Spring Security防止暴力破解身份驗證](https://hackmd.io/@LeeLo/HJyI3yEWL) [前往 Internationalization](https://hackmd.io/@LeeLo/By-z7tSZL)