spring security 实现登录验证码及记住我
spring security 验证码登录:
在现在主流的网站登录页上,我们经常可以看到登陆的时候是通过账号密码登录,那么时常会看到需要我们输入一个图片验证码里面的值。或者通过手机验证码进行短信登陆,进行获取验证码进行登录。而这两种登陆方式都用到了验证码,前者是图片验证码,后者是短信验证码。在spring security 中使用验证码来验证登录,其核心还是拦截器链,当请求获取验证码的时候,将该验证码放入request中,当用户收到后进行输入。然后取出request里面的值进行比对。或者将这个东西存在别的比较安全的地方都是可以的。然后添加自己的校验拦截器。进行响应的逻辑处理,即可达到验证码登录的效果。
为了让读者能更好的理解这一套处理逻辑,下图展示了这个功能的类图:
在这里我们需要导入以下依赖,commons没用到的可以删掉:
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-config</artifactId>
<version>1.1.4.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-core</artifactId>
<version>1.1.4.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-security</artifactId>
<version>1.1.4.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-web</artifactId>
<version>1.1.4.RELEASE</version>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.6</version>
</dependency>
<dependency>
<groupId>commons-beanutils</groupId>
<artifactId>commons-beanutils</artifactId>
<version>1.8.3</version>
</dependency>
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.1</version>
</dependency>
1.验证码生成器 ValidateCodeGenerator,主要逻辑是用于生成验证码:
public interface ValidateCodeGenerator {
/**
* 生成验证码
*/
ValidateCode generate(ServletWebRequest request);
}
这里需要定义我们验证码的值及过期时间:
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);
}
// 省略get/set
}
上述两种类型的验证码中,图片验证码需要向浏览器发送图片流,所以这里还需要定义一个图片验证码类:
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;
}
// 省略get/set
}
图片验证码生成,样式可以自己调整,不行的话就百度:
public class ImageCodeGenerator implements ValidateCodeGenerator {
@Override
public ImageCode generate(ServletWebRequest request) {
int width = ServletRequestUtils.getIntParameter(request.getRequest(), "width",60);
int height = ServletRequestUtils.getIntParameter(request.getRequest(), "height",20);
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics g = image.getGraphics();
Random random = new Random();
g.setColor(getRandColor(200, 250));
g.fillRect(0, 0, width, height);
g.setFont(new Font("Times New Roman", Font.ITALIC, 20));
g.setColor(getRandColor(160, 200));
for (int i = 0; i < 155; i++) {
int x = random.nextInt(width);
int y = random.nextInt(height);
int xl = random.nextInt(12);
int yl = random.nextInt(12);
g.drawLine(x, y, x + xl, y + yl);
}
String sRand = "";
for (int i = 0; i < 4; i++) {
String rand = String.valueOf(random.nextInt(10));
sRand += rand;
g.setColor(new Color(20 + random.nextInt(110), 20 + random.nextInt(110), 20 + random.nextInt(110)));
g.drawString(rand, 13 * i + 6, 16);
}
g.dispose();
return new ImageCode(image, sRand, 60);
}
/**
* 生成随机背景条纹
*/
private Color getRandColor(int fc, int bc) {
Random random = new Random();
if (fc > 255) {
fc = 255;
}
if (bc > 255) {
bc = 255;
}
int r = fc + random.nextInt(bc - fc);
int g = fc + random.nextInt(bc - fc);
int b = fc + random.nextInt(bc - fc);
return new Color(r, g, b);
}
}
短信验证码,但是测试环境我们就随机生成一个六位数的密码:
@Component("smsValidateCodeGenerator")
public class SmsCodeGenerator implements ValidateCodeGenerator {
@Override
public ValidateCode generate(ServletWebRequest request) {
String code = RandomStringUtils.randomNumeric(6);
return new ValidateCode(code, 60);
}
}
2.验证码处理器 ValidateCodeProcessor ,这里主要是创建验证码及校验验证码功能,还有一个是需要往 request 里面存放我们的验证码,所以这里定义一个前缀。
public interface ValidateCodeProcessor {
/**
* 验证码放入session时的前缀
*/
String SESSION_KEY_PREFIX = "SESSION_KEY_FOR_CODE_";
/**
* 创建校验码*/
void create(ServletWebRequest request) throws Exception;
/**
* 校验验证码*/
void validate(ServletWebRequest servletWebRequest);
}
抽象的验证码处理器:
public abstract class AbstractValidateCodeProcessor<C extends ValidateCode> implements ValidateCodeProcessor {
/**
* 操作session的工具类
*/
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
/**
* 收集系统中所有的 {@link ValidateCodeGenerator} 接口的实现。
*/
@Autowired
private Map<String, ValidateCodeGenerator> validateCodeGenerators;
/*
* (non-Javadoc)
*/
@Override
public void create(ServletWebRequest request) throws Exception {
C validateCode = generate(request);
save(request, validateCode);
send(request, validateCode);
}
/**
* 生成校验码
*
*/
@SuppressWarnings("unchecked")
private C generate(ServletWebRequest request) {
String type = getValidateCodeType(request).toString().toLowerCase();
String generatorName = type + ValidateCodeGenerator.class.getSimpleName();
ValidateCodeGenerator validateCodeGenerator = validateCodeGenerators.get(generatorName);
if (validateCodeGenerator == null) {
throw new ValidateCodeException("验证码生成器" + generatorName + "不存在");
}
return (C) validateCodeGenerator.generate(request);
}
/**
* 保存校验码
*
* @param request
* @param validateCode
*/
private void save(ServletWebRequest request, C validateCode) {
sessionStrategy.setAttribute(request, getSessionKey(request), validateCode);
}
/**
* 构建验证码放入session时的key
*
* @param request
* @return
*/
private String getSessionKey(ServletWebRequest request) {
return SESSION_KEY_PREFIX + getValidateCodeType(request).toString().toUpperCase();
}
/**
* 发送校验码,由子类实现
*
* @param request
* @param validateCode
* @throws Exception
*/
protected abstract void send(ServletWebRequest request, C validateCode) throws Exception;
/**
* 根据请求的url获取校验码的类型
*
* @param request
* @return
*/
private ValidateCodeType getValidateCodeType(ServletWebRequest request) {
String type = StringUtils.substringBefore(getClass().getSimpleName(), "CodeProcessor");
return ValidateCodeType.valueOf(type.toUpperCase());
}
@SuppressWarnings("unchecked")
@Override
public void validate(ServletWebRequest request) {
ValidateCodeType processorType = getValidateCodeType(request);
String sessionKey = getSessionKey(request);
C codeInSession = (C) sessionStrategy.getAttribute(request, sessionKey);
String codeInRequest;
try {
codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(),
processorType.getParamNameOnValidate());
} catch (ServletRequestBindingException e) {
throw new ValidateCodeException("获取验证码的值失败");
}
if (StringUtils.isBlank(codeInRequest)) {
throw new ValidateCodeException(processorType + "验证码的值不能为空");
}
if (codeInSession == null) {
throw new ValidateCodeException(processorType + "验证码不存在");
}
if (codeInSession.isExpried()) {
sessionStrategy.removeAttribute(request, sessionKey);
throw new ValidateCodeException(processorType + "验证码已过期");
}
if (!StringUtils.equals(codeInSession.getCode(), codeInRequest)) {
throw new ValidateCodeException(processorType + "验证码不匹配");
}
sessionStrategy.removeAttribute(request, sessionKey);
}
}
子类实现,图片验证码处理器:
@Component("imageValidateCodeProcessor")
public class ImageCodeProcessor extends AbstractValidateCodeProcessor<ImageCode> {
/**
* 发送图形验证码,将其写到响应中
*/
@Override
protected void send(ServletWebRequest request, ImageCode imageCode) throws Exception {
ImageIO.write(imageCode.getImage(), "JPEG", request.getResponse().getOutputStream());
}
}
短信验证码处理器,这里主要需要调用发送短信的api进行短信发送,这里就直接通过打印一行日志来表示:
@Component("smsValidateCodeProcessor")
public class SmsCodeProcessor extends AbstractValidateCodeProcessor<ValidateCode> {
/**
* 短信验证码发送器
*/
@Autowired
private SmsCodeSender smsCodeSender;
@Override
protected void send(ServletWebRequest request, ValidateCode validateCode) throws Exception {
String paramName = "mobile";
String mobile = ServletRequestUtils.getRequiredStringParameter(request.getRequest(), paramName);
smsCodeSender.send(mobile, validateCode.getCode());
}
}
public interface SmsCodeSender {
void send(String mobile, String code);
}
public class DefaultSmsCodeSender implements SmsCodeSender {
@Override
public void send(String mobile, String code) {
System.out.println("向手机"+mobile+"发送短信验证码"+code);
}
}
那么到现在为止,验证码的生成及处理已经完成。
3.向spring容器注入相关类,当然也可以直接标在类上
@Configuration
public class ValidateCodeBeanConfig {
@Bean
@ConditionalOnMissingBean(name = "imageValidateCodeGenerator")
public ValidateCodeGenerator imageValidateCodeGenerator() {
ImageCodeGenerator codeGenerator = new ImageCodeGenerator();
return codeGenerator;
}
@Bean
@ConditionalOnMissingBean(SmsCodeSender.class)
public SmsCodeSender smsCodeSender() {
return new DefaultSmsCodeSender();
}
}
4.验证码处理器控制类 ValidateCodeProcessorHolder
@Component
public class ValidateCodeProcessorHolder {
@Autowired
private Map<String, ValidateCodeProcessor> validateCodeProcessors;
public ValidateCodeProcessor findValidateCodeProcessor(ValidateCodeType type) {
return findValidateCodeProcessor(type.toString().toLowerCase());
}
public ValidateCodeProcessor findValidateCodeProcessor(String type) {
String name = type.toLowerCase() + ValidateCodeProcessor.class.getSimpleName();
ValidateCodeProcessor processor = validateCodeProcessors.get(name);
if (processor == null) {
throw new ValidateCodeException("验证码处理器" + name + "不存在");
}
return processor;
}
}
5.接下去就是需要我们的拦截器登场了
@Component("validateCodeFilter")
public class ValidateCodeFilter extends OncePerRequestFilter implements InitializingBean {
/**
* 验证码校验失败处理器
*/
@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;
/**
* 系统中的校验码处理器
*/
@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("/authentication/form", ValidateCodeType.IMAGE);
addUrlToMap("/user/*", ValidateCodeType.IMAGE);
urlMap.put("/authentication/mobile", ValidateCodeType.SMS);
addUrlToMap("/user/*", ValidateCodeType.SMS);
}
/**
* 系统中配置的需要校验验证码的URL根据校验的类型放入map*/
protected void addUrlToMap(String urlString, ValidateCodeType type) {
if (StringUtils.isNotBlank(urlString)) {
String[] urls = StringUtils.splitByWholeSeparatorPreserveAllTokens(urlString, ",");
for (String url : urls) {
urlMap.put(url, type);
}
}
}
@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
*/
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;
}
}
这里我们通过枚举来定义处理器类型,同时需要实现我们的异常类:
public enum ValidateCodeType {
/**
* 短信验证码
*/
SMS {
@Override
public String getParamNameOnValidate() {
return "smsCode";
}
},
/**
* 图片验证码
*/
IMAGE {
@Override
public String getParamNameOnValidate() {
return "imageCode";
}
};
/**
* 校验时从请求中获取的参数的名字
* @return
*/
public abstract String getParamNameOnValidate();
}
public class ValidateCodeException extends AuthenticationException {
private static final long serialVersionUID = -7285211528095468156L;
public ValidateCodeException(String msg) {
super(msg);
}
}
6.接下去就是需要把这个拦截器加入到 spring security的拦截器链中,就是在配置类中将我们的拦截器注入,然后通过调用http.addFilterBefore添加到某个拦截器前面,就可以了:
http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
7.提供一个接口进行验证码获取:
@RestController
public class ValidateCodeController {
@Autowired
private ValidateCodeProcessorHolder validateCodeProcessorHolder;
/**
* 创建验证码,根据验证码类型不同,调用不同的*/
@GetMapping("/code/{type}")
public void createCode(HttpServletRequest request, HttpServletResponse response, @PathVariable String type)
throws Exception {
validateCodeProcessorHolder.findValidateCodeProcessor(type).create(new ServletWebRequest(request, response));
}
}
经过以上的步骤我们就能获取到验证码了,可以看到接口上有个 type属性。这个属性用于区分时图片验证码还是短信验证码,是因为这两个处理器在注入容器的时候命名来决定的,所以type可以为 image 、sms 类型。小伙伴本也可以进行代码重构,将其中的一些配置通过配置类的方式注入。
spring security 记住我:
在Security 中要实现记住我功能很简单,先来看代码:
@Configuration
@EnableWebSecurity// 开启Security
@EnableGlobalMethodSecurity(prePostEnabled = true)//开启Spring方法级安全
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// Secutiry 处理链
// SecurityContextPersistenceFilter
// --> UsernamePasswordAuthenticationFilter
// --> BasicAuthenticationFilter
// --> ExceptionTranslationFilter
// --> FilterSecurityInterceptor
@Autowired
private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
@Autowired
private MyAuthenticationProvider myAuthenticationProvider;
@Autowired
private MyAuthenctiationFailureHandler myAuthenctiationFailureHandler;
@Autowired
private MyUserDetailService myUserDetailService;
@Autowired
private ValidateCodeFilter validateCodeFilter;
@Autowired
private DataSource dataSource;
//密码加密
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// 自定义认证配置
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(myAuthenticationProvider);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
//关闭Security功能
// http.csrf().disable()
// .authorizeRequests()
// .anyRequest().permitAll()
// .and().logout().permitAll();
http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeRequests()
.antMatchers("/wuzz/test4","/code/*").permitAll() //不需要保护的资源,可以多个
.antMatchers("/wuzz/**").authenticated()// 需要认证得资源,可以多个
.and()
.formLogin().loginPage("http://localhost:8080/#/login")//自定义登陆地址
.loginProcessingUrl("/authentication/form") //登录处理地址
.successHandler(myAuthenticationSuccessHandler) // 登陆成功处理器
.failureHandler(myAuthenctiationFailureHandler) // 登陆失败处理器
.permitAll()
.and()
.userDetailsService(myUserDetailService)//设置userDetailsService,处理用户信息
.rememberMe()//实现记住我功能 RememberMeAuthenticationFilter
.tokenRepository(persistentTokenRepository())
.tokenValiditySeconds(3600)
;
http.headers().cacheControl(); //禁用缓存
http.csrf().disable(); //禁用csrf校验
}
//忽略的uri
// @Override
// public void configure(WebSecurity web) throws Exception {
// web.ignoring()
// .antMatchers( "/api/**", "/resources/**", "/static/**", "/public/**", "/webui/**", "/h2-console/**"
// , "/configuration/**", "/swagger-resources/**", "/api-docs", "/api-docs/**", "/v2/api-docs/**"
// , "/**/*.css", "/**/*.js","/**/*.ftl", "/**/*.png ", "/**/*.jpg", "/**/*.gif ", "/**/*.svg", "/**/*.ico", "/**/*.ttf", "/**/*.woff");
// }
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
//启动的时候是否创建该表,这个表格是保存用户登录信息的
// tokenRepository.setCreateTableOnStartup(true);
return tokenRepository;
}
}
上述代码中新增注入两个类DataSource、PersistentTokenRepository(通过jdbcTemplate实现),因为记住我功能是将token信息存入到数据库,实现类由两种,一种基于内存,另一种基于数据库,后者比较有保障。所以这里使用JDBC,当然需要在application.properties 加入以下配置:
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://192.168.1.101:3306/study?useUnicode=true&characterEncoding=utf-8
spring.datasource.username=root
spring.datasource.password=123456
然后配置启用记住我功能:
.rememberMe()//实现记住我功能 RememberMeAuthenticationFilter
.tokenRepository(persistentTokenRepository())
.tokenValiditySeconds(3600) // 过期时间
就这样就配置好了记住我功能,然后需要我们在前端请求登录的时候加上参数 remember-me,这个参数是固定的。在 AbstractRememberMeServices 类中声明:
public static final String SPRING_SECURITY_REMEMBER_ME_COOKIE_KEY = "remember-me";
当我们登陆的时候这个值传的是 true,那么就会进行进驻我操作。
记住我操作的源码可以阅读 RememberMeAuthenticationFilter,流程很简单。多过几遍就清晰了。