2019-2-25

一、《Spring Security开发安全的REST服务》视频笔记---part2:Spring Security部分

Spring Security核心功能:认证(你是谁)、授权(你能干什么)、攻击防护(防止伪造身份)

内容:Spring Security基本原理、实现用户名+密码认证、实现手机号+短信认证

 1、Spring Security开发基于表单的认证

WebSecurityConfigurerAdapter是web安全应用的一个适配器,弄成表单登录配置以下即可(默认账号是user,密码随机生成):

@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter{
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		
		http.formLogin()
			.and()
			.authorizeRequests()
			.anyRequest()
			.authenticated();
		
	}
	
}

  若是还是想用回最原始的弹出框输入账号密码登录,则这样写:

http.httpBasic()
			.and()
			.authorizeRequests()
			.anyRequest()
			.authenticated();

  

Spring Security基本原理(对应视频4-2):

上面的第一个filter对应于配置中的http.formLogin(),第二个filter对应于配置中的http.httpBasic()(注意:如果不配置,则这些filter不会生效;除了绿色以外,其它颜色的过滤器无法控制,即一定会在过滤器链上),最后一个Interceptor是请求要经过的最后一个过滤器(用来做最终判断的),对应于配置中每个and()后面的内容,若不符合这些内容的要求,则靠响应过程中的第二个filter(即从右边开始数的第二个)来捕获异常,并根据是否有异常来决定是否引导用户回到前面的配置中登录(比如前面配了用户名密码登录,则引导回那里登录)。

2、自定义用户认证逻辑

有3个步骤:处理用户信息获取逻辑(比如从数据库获取)[UserDetailsService]、处理用户校验逻辑(比如用户有无被冻结)[UserDetails]、处理密码加密解密[PasswordEncoder]

(1)用户信息获取逻辑被SpringSecurity封装在一个接口中:UserDetailsService

public interface UserDetailsService {
    UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException;
}

  

该接口只有一个loadUserByUsername方法,是根据用户名获取用户信息并将之封装到UserDetails接口的实现类中,SpringSecurity就拿着这个用户信息去做处理和校验,通过的话就放在session中;不通过就返回异常并有相应的错误提示。

下面用自己定义的类去实现UserDetailsService接口:

@Component
public class MyUserDetailsService implements UserDetailsService{
    
        logger...

        @override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{

                logger.info("登录用户名:"+username);
                return new User(username, "123456", AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
        }      

}    

  上面的User类是SpringSecurity提供的实现了UserDetails接口的类,该类的构造函数中第3个参数是权限集合,这里调用的方法是将逗号分隔的字符串转换成权限的方法(这里的权限会与上面配置类中需要的权限校验做对比,看看校验能否通过)。

(2)处理用户校验逻辑(主要有这些校验:密码是否正确、用户是否被冻结、密码是否过期)

校验用户的逻辑其实就放在UserDetails接口里的4个返回布尔值的方法:

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();

    String getPassword();

    String getUsername();

    boolean isAccountNonExpired();

    boolean isAccountNonLocked();

    boolean isCredentialsNonExpired();

    boolean isEnabled();
}

  isAccountNonExpired返回true代表账号没有过期,isAccountNonLocked返回true代表账号没有被锁定(通常与业务中的用户冻结划等号), isCredentialsNonExpiredfanhtrue代表密码没有过期,isEnabled代表账号是否可用(通常与业务中的用户逻辑删除即假删除画等号)。

 这里可以修改上面的User调用的构造函数:

return new User(username, "123456", true, true , true, true, AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));

  或者可以自己定义一个与实际用户实体业务结合的User类去实现UserDetails接口。

(3)处理密码加密解密

找到PasswordEncoder类(是crypto包下的类),

public interface PasswordEncoder {
    String encode(CharSequence var1);

    boolean matches(CharSequence var1, String var2);
}

  

encode方法用来将密码加密,matches方法用来判断用户提交上来的密码是否和用户的密码匹配。

配置一个实现了PasswordEncoder接口的BCryptPasswordEncoder类,也可以自定义实现。

@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter{
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		
		http.formLogin()
			.and()
			.authorizeRequests()
			.anyRequest()
			.authenticated();
		
	}

        @Bean
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
	
}

  这时候刚刚那个User就需要提供加密后的密码参数:

return new User(username, passwordEncoder.encode("123456"), true, true , true, true, AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));

  

 3、个性化用户认证流程

有3个步骤:自定义登录页面、自定义登录成功处理、自定义登录失败处理

(1)自定义登录页面(在刚刚上面那个配置类补充一个loginPage方法调用),并在项目resources目录下再新建一个resources文件夹,里面放一个imooc-signIn.html

        @Override
	protected void configure(HttpSecurity http) throws Exception {
		
		http.formLogin()
                        .loginPage("/imooc-signIn.html")
			.and()
			.authorizeRequests()
			.anyRequest()
			.authenticated();
		
	}

  但如果只是这样配置,进入这个登录页面会报错:重定向次数过多。这是因为现在的配置是:所有页面,包括这个登录页面,都需要身份认证,所以都要跳转到登录页面,即登录页面本身也需要身份认证也就需要再次跳转到自身。

所以代码改成如下:

@Override
	protected void configure(HttpSecurity http) throws Exception {
		
		http.formLogin()
                        .loginPage("/imooc-signIn.html")
			.and()
			.authorizeRequests()
                        .antMatchers("/imooc-signIn.html").permitAll()
			.anyRequest()
			.authenticated();
		
	}

  imooc-signIn.html页面如下:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>登录</title>
</head>
<body>
	<h2>标准登录页面</h2>
	<h3>表单登录</h3>
	<form action="/authentication/form" method="post">
		<table>
			<tr>
				<td>用户名:</td> 
				<td><input type="text" name="username"></td>
			</tr>
			<tr>
				<td>密码:</td>
				<td><input type="password" name="password"></td>
			</tr>
			<tr>
				<td>图形验证码:</td>
				<td>
					<input type="text" name="imageCode">
					<img src="/code/image?width=200">
				</td>
			</tr>
			<tr>
				<td colspan='2'><input name="remember-me" type="checkbox" value="true" />记住我</td>
			</tr>
			<tr>
				<td colspan="2"><button type="submit">登录</button></td>
			</tr>
		</table>
	</form>
	
	<h3>短信登录</h3>
	<form action="/authentication/mobile" method="post">
		<table>
			<tr>
				<td>手机号:</td>
				<td><input type="text" name="mobile" value="13012345678"></td>
			</tr>
			<tr>
				<td>短信验证码:</td>
				<td>
					<input type="text" name="smsCode">
					<a href="/code/sms?mobile=13012345678">发送验证码</a>
				</td>
			</tr>
			<tr>
				<td colspan="2"><button type="submit">登录</button></td>
			</tr>
		</table>
	</form>
	<br>
	<h3>社交登录</h3>
	<a href="/qqLogin/callback.do">QQ登录</a>
	    
	<a href="/qqLogin/weixin">微信登录</a>
</body>
</html>

  注意:该登录页面的登录请求是提交到/authentication/form,而不是SpringSecurity提供的UsernamePasswordAuthenticationFilter类指定的/login路径,那么现在需要UsernamePasswordAuthenticationFilter类去处理这个新的路径,所以需要配置:

@Override
	protected void configure(HttpSecurity http) throws Exception {
		
		http.formLogin()
                        .loginPage("/imooc-signIn.html")
                        .loginProcessingUrl("/authentication/form")
			.and()
			.authorizeRequests()
                        .antMatchers("/imooc-signIn.html").permitAll()
			.anyRequest()
			.authenticated();
		
	}

  按照这个配置,登录后会报另一个错误:Invalid CSRF Token 'null' was found on request parameter '_csrf' or header 'X-CSRF-TOKEN'

因为SpringSecurity在默认情况下会提供一个跨站请求伪造的一个防护,可以暂时禁止这个配置:

@Override
	protected void configure(HttpSecurity http) throws Exception {
		
		http.formLogin()
                        .loginPage("/imooc-signIn.html")
                        .loginProcessingUrl("/authentication/form")
			.and()
			.authorizeRequests()
                        .antMatchers("/imooc-signIn.html").permitAll()
			.anyRequest()
			.authenticated()
                        .and()
                        .csrf().disable();
		
	}

  这样配置之后,登录逻辑可以走通,但是有2个不合理的地方:

①登录失败返回的是一个html页面而不是一个json字符串

②现在的配置是统一使用同一个登录页面,应该改成有自定义的登录页面就使用自定义的,没有的话才使用这个登录页面。

先看第一个地方怎么改:

目前的逻辑是当需要身份认证时跳到一个登录页面,应改成跳到一个自定义的Controller方法上:

Controller自定义方法如下:

@RestController
public class BrowserSecurityController {

	private Logger logger = LoggerFactory.getLogger(getClass());

	private RequestCache requestCache = new HttpSessionRequestCache();

	private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

	@Autowired
	private SecurityProperties securityProperties;

	@Autowired
	private ProviderSignInUtils providerSignInUtils;

	/**
	 * 当需要身份认证时,跳转到这里
	 */
	@RequestMapping(SecurityConstants.DEFAULT_UNAUTHENTICATION_URL)
	@ResponseStatus(code = HttpStatus.UNAUTHORIZED)
	public SimpleResponse requireAuthentication(HttpServletRequest request, HttpServletResponse response)
			throws IOException {

		SavedRequest savedRequest = requestCache.getRequest(request, response);

		if (savedRequest != null) {
			String targetUrl = savedRequest.getRedirectUrl();
			logger.info("引发跳转的请求是:" + targetUrl);
			if (StringUtils.endsWithIgnoreCase(targetUrl, ".html")) {
				redirectStrategy.sendRedirect(request, response, securityProperties.getBrowser().getLoginPage());
			}
		}

		return new SimpleResponse("访问的服务需要身份认证,请引导用户到登录页");
	}

}

  

配置这些类:

让这些类生效:

@Configuration
@EnableConfigurationProperties(SecurityProperties.class)
public class SecurityCoreConfig {

}

  配置如下:

  

@Override
	protected void configure(HttpSecurity http) throws Exception {
		
		http.formLogin()
                        .loginPage("/imooc-signIn.html")
                        .loginProcessingUrl("/authentication/form")
			.and()
			.authorizeRequests()
                        .antMatchers("/authentication/require", securityProperties.getBrowser().getLoginPage()).permitAll()
			.anyRequest()
			.authenticated()
                        .and()
                        .csrf().disable();
		
	}

  

(2)自定义登录成功处理

关键接口:AuthenticationSuccessHandler

自定义自己的ImoocAuthenticationSuccessHandler类实现AuthenticationSuccessHandler接口(或者也可以继承SavedRequestAwareAuthenticationSuccessHandler类---这是SpringSecurity默认的登录成功处理器)

然后在配置类BrowserSecurityConfig中注入上面的自定义类:

@Override
	protected void configure(HttpSecurity http) throws Exception {
		
		http.formLogin()
                        .loginPage("/imooc-signIn.html")
                        .loginProcessingUrl("/authentication/form")
                        .successHandler(imoocAuthenticationSuccessHandler)
			.and()
			.authorizeRequests()
                        .antMatchers("/authentication/require", securityProperties.getBrowser().getLoginPage()).permitAll()
			.anyRequest()
			.authenticated()
                        .and()
                        .csrf().disable();
		
	}

  

(3)自定义登录失败处理

关键接口:AuthenticationFailureHandler

自定义自己的ImoocAuthenticationFailureHandler类实现AuthenticationFailureHandler接口(或者也可以继承SimpleUrlAuthenticationFailureHandler类)

然后在配置类BrowserSecurityConfig中注入上面的自定义类:

@Override
	protected void configure(HttpSecurity http) throws Exception {
		
		http.formLogin()
                        .loginPage("/imooc-signIn.html")
                        .loginProcessingUrl("/authentication/form")
                        .successHandler(imoocAuthenticationSuccessHandler)
                        .failureHandler(imoocAuthenticationFailureHandler)
			.and()
			.authorizeRequests()
                        .antMatchers("/authentication/require", securityProperties.getBrowser().getLoginPage()).permitAll()
			.anyRequest()
			.authenticated()
                        .and()
                        .csrf().disable();
		
	}

  

4、认证流程源码级详解(把前面的内容可以联系起来理解)[对应视频4-6]

有3块内容:认证处理流程说明、认证结果如何在多个请求之间共享、获取认证用户信息

(1)认证处理流程说明

认证流程中核心的类:

(2)认证结果如何在多个请求之间共享

该SecurityContextPersistenceFilter是过滤器链第一个过滤器,请求时检查session,如果有SecurityContext就放到线程中;响应时检查线程,如果有SecurityContext就拿出来放到session中。这样不同的请求,就可以从同一个session拿到相同的认证信息。

(3)获取认证用户信息

        @GetMapping("/me")
	public Object getCurrentUser() {
		return SecurityContextHolder.getContext().getAuthentication();
	}

或者更简便的写法:

        @GetMapping("/me")
	public Object getCurrentUser(Authentication authentication) {
		return authentication;
	}

  但是上面的写法是返回全部的authentication信息

如果只想知道authentication信息里的principal信息部分,这样写:

        @GetMapping("/me")
	public Object getCurrentUser(@AuthenticationPrincipal UserDetails user) {
		return user;
	}

  

5、实现图形验证码功能

有2步:开发生成图形验证码接口、在认证流程中加入图形验证码校验、重构代码

(1)生成图形验证码:根据随机数生成图片、将随机数存到session中、将生成的图片写到接口的响应中

验证码:

public class ValidateCode {
	
	private String code;
	
	private LocalDateTime expireTime;
	
	public ValidateCode(String code, int expireIn){
		this.code = code;
		this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
	}
	
	public ValidateCode(String code, LocalDateTime expireTime){
		this.code = code;
		this.expireTime = expireTime;
	}
	
	public boolean isExpried() {
		return LocalDateTime.now().isAfter(expireTime);
	}

	public String getCode() {
		return code;
	}

	public void setCode(String code) {
		this.code = code;
	}

	public LocalDateTime getExpireTime() {
		return expireTime;
	}

	public void setExpireTime(LocalDateTime expireTime) {
		this.expireTime = expireTime;
	}
	
}

  图片验证码:

public class ImageCode extends ValidateCode {
	
	private BufferedImage image; 
	
	public ImageCode(BufferedImage image, String code, int expireIn){
		super(code, expireIn);
		this.image = image;
	}
	
	public ImageCode(BufferedImage image, String code, LocalDateTime expireTime){
		super(code, expireTime);
		this.image = image;
	}
	
	public BufferedImage getImage() {
		return image;
	}

	public void setImage(BufferedImage image) {
		this.image = image;
	}

}

  控制器方法:

@RestController
public class ValidateCodeController {

	@Autowired
	private ValidateCodeProcessorHolder validateCodeProcessorHolder;

	/**
	 * 创建验证码,根据验证码类型不同,调用不同的 {@link ValidateCodeProcessor}接口实现
	 * 
	 * @param request
	 * @param response
	 * @param type
	 * @throws Exception
	 */
	@GetMapping(SecurityConstants.DEFAULT_VALIDATE_CODE_URL_PREFIX + "/{type}")
	public void createCode(HttpServletRequest request, HttpServletResponse response, @PathVariable String type)
			throws Exception {
		validateCodeProcessorHolder.findValidateCodeProcessor(type).create(new ServletWebRequest(request, response));
	}

}

  检查验证码的filter:

@Component("validateCodeFilter")
public class ValidateCodeFilter extends OncePerRequestFilter implements InitializingBean {

	/**
	 * 验证码校验失败处理器
	 */
	@Autowired
	private AuthenticationFailureHandler authenticationFailureHandler;
	/**
	 * 系统配置信息
	 */
	@Autowired
	private SecurityProperties securityProperties;
	/**
	 * 系统中的校验码处理器
	 */
	@Autowired
	private ValidateCodeProcessorHolder validateCodeProcessorHolder;
	/**
	 * 存放所有需要校验验证码的url
	 */
	private Map<String, ValidateCodeType> urlMap = new HashMap<>();
	/**
	 * 验证请求url与配置的url是否匹配的工具类
	 */
	private AntPathMatcher pathMatcher = new AntPathMatcher();

	/**
	 * 初始化要拦截的url配置信息
	 */
	@Override
	public void afterPropertiesSet() throws ServletException {
		super.afterPropertiesSet();

		urlMap.put(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_FORM, ValidateCodeType.IMAGE);
		addUrlToMap(securityProperties.getCode().getImage().getUrl(), ValidateCodeType.IMAGE);

		urlMap.put(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_MOBILE, ValidateCodeType.SMS);
		addUrlToMap(securityProperties.getCode().getSms().getUrl(), ValidateCodeType.SMS);
	}

	/**
	 * 讲系统中配置的需要校验验证码的URL根据校验的类型放入map
	 * 
	 * @param urlString
	 * @param type
	 */
	protected void addUrlToMap(String urlString, ValidateCodeType type) {
		if (StringUtils.isNotBlank(urlString)) {
			String[] urls = StringUtils.splitByWholeSeparatorPreserveAllTokens(urlString, ",");
			for (String url : urls) {
				urlMap.put(url, type);
			}
		}
	}

	/*
	 * (non-Javadoc)
	 * 
	 * @see
	 * org.springframework.web.filter.OncePerRequestFilter#doFilterInternal(
	 * javax.servlet.http.HttpServletRequest,
	 * javax.servlet.http.HttpServletResponse, javax.servlet.FilterChain)
	 */
	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws ServletException, IOException {

		ValidateCodeType type = getValidateCodeType(request);
		if (type != null) {
			logger.info("校验请求(" + request.getRequestURI() + ")中的验证码,验证码类型" + type);
			try {
				validateCodeProcessorHolder.findValidateCodeProcessor(type)
						.validate(new ServletWebRequest(request, response));
				logger.info("验证码校验通过");
			} catch (ValidateCodeException exception) {
				authenticationFailureHandler.onAuthenticationFailure(request, response, exception);
				return;
			}
		}

		chain.doFilter(request, response);

	}

	/**
	 * 获取校验码的类型,如果当前请求不需要校验,则返回null
	 * 
	 * @param request
	 * @return
	 */
	private ValidateCodeType getValidateCodeType(HttpServletRequest request) {
		ValidateCodeType result = null;
		if (!StringUtils.equalsIgnoreCase(request.getMethod(), "get")) {
			Set<String> urls = urlMap.keySet();
			for (String url : urls) {
				if (pathMatcher.match(url, request.getRequestURI())) {
					result = urlMap.get(url);
				}
			}
		}
		return result;
	}

}  

新配置:

        @Override
	protected void configure(HttpSecurity http) throws Exception {
		
                ValidCodeFilter validCodeFilter = new ValidCodeFilter();
                validCodeFilter.setAuthenticationFailureHandler(imoocAuthenticationFailureHandler);

		http.addFilterBefore(validCodeFilter, UsernamePasswordAuthenticationFilter.class)
                        .formLogin()
                        .loginPage("/imooc-signIn.html")
                        .loginProcessingUrl("/authentication/form")
                        .successHandler(imoocAuthenticationSuccessHandler)
                        .failureHandler(imoocAuthenticationFailureHandler)
			.and()
			.authorizeRequests()
                        .antMatchers("/authentication/require", securityProperties.getBrowser().getLoginPage()).permitAll()
			.anyRequest()
			.authenticated()
                        .and()
                        .csrf().disable();
		
	}

  

6、重构图形验证码接口(视频4-8)

重构内容:验证码基本参数可配置、验证码拦截接口可配置、验证码的生成逻辑可配置

7、添加“记住我”功能

“记住我”功能基本原理、“记住我”功能具体实现、“记住我”功能SpringSecurity源码解析

配置:

8、实现短信验证码登录(视频4-10 ~ 4-13)

开发短信验证码接口、校验短信验证码并登录、重构代码

 登录页面:

<h3>短信登录</h3>
	<form action="/authentication/mobile" method="post">
		<table>
			<tr>
				<td>手机号:</td>
				<td><input type="text" name="mobile" value="13012345678"></td>
			</tr>
			<tr>
				<td>短信验证码:</td>
				<td>
					<input type="text" name="smsCode">
					<a href="/code/sms?mobile=13012345678">发送验证码</a>
				</td>
			</tr>
			<tr>
				<td colspan="2"><button type="submit">登录</button></td>
			</tr>
		</table>
	</form>

  短信验证码生成接口略。

短信登录开发需要写一个SmsAuthenticationFilter和SmsAuthenticationProvider

关键类:

原文地址:https://www.cnblogs.com/z-y-x/p/10428055.html