Try   HackMD

Spring Security

tags: Spring Security login Annotation email

ER-Model

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

項目的目錄結構

  • src/main/
    • java/com/xxx/
      • controller:控制層
      • dto:數據傳輸物件
      • entity:持久層
      • event:事件
      • listener:監聽器
      • 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 的 class

@Configuration
@EnableWebSecurity
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
}

此 class 須 @Override 以下幾個 methods

protected void configure(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());
	}
  1. AuthenticationManagerBuilder (身份驗證管理)

登入邏輯

使用者登入資訊

// 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(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);
    }
  1. WebSecurity(WEB安全)

自訂義 Annotation

標籤

  1. @Target:限定 Annotation 使用對象
    • TYPE:適用 class, interface, enum
    • FIELD:適用 field
    • ANNOTATION_TYPE:適用 Annotation 型態
  2. @Retention:告知編譯器如何處理 Annotation
    • RUNTIME: Annotation 會被 JVM 所保留
  3. @Constraint:決定對象可以使用 @Valid 驗證
    • validatedBy:指定 Validator (驗證器)的 class
  4. @Documented:表明這個 Annotation 可以被 Java Doc 紀錄
  5. 實作密碼與再輸入密碼判別是否相同的 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
	
}

登入功能

登入邏輯

使用者登入資訊

/**
 * 認證(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
前往 Spring Boot Redis
前往 Spring Security防止暴力破解身份驗證
前往 Internationalization