# Spring Security
###### tags: `Spring` `Security` `login` `Annotation` `email`
## ER-Model

## 項目的目錄結構
* 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)