基于springboot+SpringSecurity+kaptcha的用户登陆 密码三次输入错误锁定,登陆成功则授权。
项目是前后端分离的,前端是vue.js 后端是springboot
1、kaptcha组件用来用户登陆时的一个验证码生成。
2、SpringSecurity安全框架,使用SpringSecurity拦截登陆请求 进行认证和授权,因为是前后端分离的不用做像jsp重定向处理,只用做对应接口授权。
3、登陆业务逻辑是支持账户登陆和邮箱登陆,用户登陆是允许三次输入错误密码,超过三次密码错误,则账号锁定,需要等待指定时间才解锁。
一、首先基于MySql数据库表的设计
CREATE TABLE `user_info` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(20) DEFAULT NULL COMMENT '用户名',
`password` varchar(500) DEFAULT '' COMMENT '密码',
`nick_name` varchar(20) DEFAULT NULL COMMENT '用户昵称',
`sex` int(11) DEFAULT '0' COMMENT '性别(默认 0:未知 1:女 2:男)',
`role` varchar(20) DEFAULT '' COMMENT '角色',
`allow_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '允许登陆时间',
`email` varchar(255) DEFAULT NULL COMMENT '邮箱',
`error_num` int(11) DEFAULT NULL COMMENT '登录错误次数',
`status` tinyint(4) DEFAULT '1' COMMENT '账户状态(默认1:可用 0:锁定)',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=18 DEFAULT CHARSET=utf8;
二、用户实体类的设计,实现SpringSecurity提供的UserDetails接口,需要覆盖接口对应的方法,
分别是:getAuthorities()->获取权限,isAccountNonExpired()->账户是否过期,isAccountNonLocked()->账户是否锁定,isCredentialsNonExpired()->授权是否过期,isEnabled()->是否可用。
下面省略了get和set方法。
public class UserVO implements UserDetails, Serializable {
private static final long serialVersionUID = -962358173215433342L;
private String id; // 用户ID
private String username; //用户账户
private String email; // 邮箱
private String password; // 用户密码
private Integer roleId; // 用户角色ID
private String roleName; // 用户角色名称
private String kaptcha; //验证码
private Integer errorNum; // 用户登陆错误次数
private Date allowTime; // 用户允许登陆时间
HashMap<String,HashMap<String,Boolean>> authoritiesMap; //当前用户具备权限
private List<? extends GrantedAuthority> authorities; //用户权限
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
//此处省略成员变量的get和set方法
}
三、控制层Controller,一个登陆接口和一个生成验证码接口,CHECK_CODE是定义的session的值,用于存放验证码,校验验证码正确后再执行service层的登陆逻辑。
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService userService;
@Autowired
DefaultKaptcha defaultKaptcha;
/**
* 用户登陆
*
* @param vo
* @return
*/
@PostMapping("/login")
public ResultVO userLogin(@RequestBody UserVO vo, HttpServletRequest request) {
String s = request.getSession().getAttribute(ConstantVal.CHECK_CODE).toString();
if (StringUtils.isEmpty(vo.getKaptcha()) || !s.equals(vo.getKaptcha())) { //验证码为空或者错误
return ResultVO.error("验证码有误");
}
return userService.userLogin(vo, request);
}
/**
* 生成验证码
*
* @param httpServletRequest
* @param httpServletResponse
* @throws Exception
*/
@RequestMapping("/defaultKaptcha")
public void defaultKaptcha(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws Exception {
byte[] captchaChallengeAsJpeg = null;
ByteArrayOutputStream jpegOutputStream = new ByteArrayOutputStream();
try {
// 生产验证码字符串并保存到session中
String createText = defaultKaptcha.createText();
httpServletRequest.getSession().setAttribute(ConstantVal.CHECK_CODE, createText);
BufferedImage challenge = defaultKaptcha.createImage(createText);
ImageIO.write(challenge, "jpg", jpegOutputStream);
} catch (IllegalArgumentException e) {
httpServletResponse.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
captchaChallengeAsJpeg = jpegOutputStream.toByteArray();
httpServletResponse.setHeader("Cache-Control", "no-store");
httpServletResponse.setHeader("Pragma", "no-cache");
httpServletResponse.setDateHeader("Expires", 0);
httpServletResponse.setContentType("image/jpeg");
ServletOutputStream responseOutputStream = httpServletResponse.getOutputStream();
responseOutputStream.write(captchaChallengeAsJpeg);
responseOutputStream.flush();
responseOutputStream.close();
}
}
四、service层的设计,主要的登录逻辑这这部分。首先校验账号密码,基于既可以账号登陆又可以邮箱登陆,账号和邮箱是不同字段,先基于账号查账号字段,不存在则基于账号查邮箱字段。
下面是登陆验证的主要逻辑,然后有两个关键字段,errorNum错误次数,allowTime用户允许登陆的时间,
1、首先用户允许登陆时间为空或者当前时间大于允许登陆时间,则做进一步判断,否则返回账号锁定,还没到允许登录的时间。
2、如果允许登陆做进一步校验,这里密码采用加密加盐判断校验,如果密码不正确,则判断错误次数是否到达3次,用errorNum这个字段记录,如果超过3次,allowTime更新到定义登陆的时候,即锁定。如果错误次数没有超过3次,那么再进行判断是否是否距离上次输入错误密码间隔的时间是2分钟之内的。如果间隔时间2分钟内,则错误次数errorNum再次加1。
3、如果账号密码正确,则进行授权加载对应的授权规则返回给前端。
public ResultVO userLogin(UserVO vo, HttpServletRequest request) {
if (StringUtil.isBlank(vo.getUsername()) || StringUtil.isBlank(vo.getPassword())) {
return ResultVO.error("账户或密码为空");
}
UserVO queryVo = userMapper.queryUserByUserName(vo);
if (queryVo == null) {
queryVo = userMapper.loadUserByUserEmail(vo.getUsername());
if (queryVo == null) {
return ResultVO.error("账户不存在");
}
}
Date allowTime = queryVo.getAllowTime(); //允许登陆时间
Date currentTime = new Date(); //当前时间
if (allowTime == null || currentTime.getTime() > allowTime.getTime()) { //如果当前登录时间大于允许登录时间
UserVO user = new UserVO();
user.setUsername(queryVo.getUsername());
if (!MD5Util.getSaltverifyMD5(vo.getPassword(), queryVo.getPassword())) { //如果账号密码错误
int errorNum = queryVo.getErrorNum() == null ? 0 : queryVo.getErrorNum(); //登录错误次数
long allowTimes = queryVo.getAllowTime() == null ? 0 : queryVo.getAllowTime().getTime();
if (errorNum < 2) { //错误的次数
int result;
if ((currentTime.getTime() - allowTimes) <= 120000) { //每次输入错误密码间隔时间在2分钟内 (如果上次登录错误时间距离相差小于定义的时间(毫秒))
user.setErrorNum(errorNum + 1);
user.setAllowTime(new Date());
result = userMapper.updateUser(user);
} else {
user.setErrorNum(1);
user.setAllowTime(new Date());
result = userMapper.updateUser(user);
}
if (result == 1) {
return ResultVO.error("账号密码错误");
}
} else {
Date dateAfterAllowTime = new Date(currentTime.getTime() + 600000); //锁定10分钟后才可登陆 允许时间加上定义的登陆时间(毫秒)
user.setErrorNum(0); //错误次数清零
user.setAllowTime(dateAfterAllowTime);
int result = userMapper.updateUser(user);
if (result == 1) {
return ResultVO.error("用户账号密码输入三次失败,被锁定十分钟");
}
}
} else { //账号密码正确
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(queryVo.getUsername(), queryVo.getPassword());
try {
//使用SpringSecurity拦截登陆请求 进行认证和授权
Authentication authenticate = myAuthenticationManager.authenticate(usernamePasswordAuthenticationToken);
SecurityContextHolder.getContext().setAuthentication(authenticate);
//使用redis session共享
HttpSession session = request.getSession();
session.setAttribute("SPRING_SECURITY_CONTEXT", SecurityContextHolder.getContext());
} catch (Exception e) {
e.printStackTrace();
return ResultVO.error("登陆异常");
}
user.setErrorNum(0);
int result = userMapper.updateUser(user);
if (result == 1) {
HashMap<String, HashMap<String, Boolean>> userAuthroityList = getUserAuthroityList(queryVo); //这块的话是取出当前用户对那些接口操作有权限,这块可以根据不同需求做。
queryVo.setAuthoritiesMap(userAuthroityList);
return ResultVO.success(queryVo);
}
}
} else {
return ResultVO.error("账号锁定,还没到允许登录的时间");
}
return ResultVO.error("登陆未成功");
}
五、DAO层的设计,DAO数据交互层也很简单,查询用户信息和更新用户信息而已。
1、UserVO queryUserByUserName(UserVO vo); //通过用户账户查询用户信息
2、int updateUser(UserVO vo); // 更新用户
<select id="queryUserByUserName" resultType="com.game.manager.security.domain.UserVO">
SELECT
username,
password,
role,
status,
allow_at as allowTime,
error_num as errorNum
FROM
user_info
WHERE
username = #{username}
</select>
<update id="updateUser" parameterType="com.game.manager.security.domain.UserVO">
UPDATE
user_info
<set>
<if test="errorNum != null">
error_num = #{errorNum},
</if>
<if test="allowTime != null">
allow_at = #{allowTime,jdbcType=TIMESTAMP},
</if>
</set>
WHERE
username = #{username}
</update>
六、SpringSecurity认证和授权部分,这块可以根据每个人的需要授权的接口进行设计。这里的话是实现SpringSecurity提供的UserDetailsService接口,
实现自定义获取用户过程,覆盖UserDetailsService的loadUserByUsername方法,通过username来获取user信息。然后获取到自己定义好的权限列表然后存储到SpringSecurity提供的GrantedAuthority里并设置给user进行授权。
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
//认证账号
UserVO user = userMapper.loadUserByUsername(s);
if(user == null){
user = userMapper.loadUserByUserEmail(s);
if(user == null) {
throw new UsernameNotFoundException("账号不存在");
}
}
//对用户的角色进行授权
List<RoleMenuVO> roleMenuVOS = userMapper.queryUserAuthroityList(user);
List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
if(roleMenuVOS.size() == 1 && roleMenuVOS.get(0) == null){
return user;
}
for(RoleMenuVO roleMenu : roleMenuVOS){
if(!StringUtil.isBlank(roleMenu.getUrl())) {
GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(roleMenu.getUrl());
//此处将权限信息添加到 GrantedAuthority 对象中,在后面进行全权限验证时会使用GrantedAuthority 对象。
grantedAuthorities.add(grantedAuthority);
}
}
user.setAuthorities(grantedAuthorities);
return user;
}
}
七、config配置,配置部分有两块,第一块的话是Kaptcha验证码组件的配置,第二块的话是SpringSecurity的配置。
1、kaptcha配置也简单,对象装配后,配置里面的属性,验证码的字体调色模糊度等,属性有很多,大家可以去官网查阅,这里不一一介绍。
@Configuration
public class KaptchaConfig {
@Bean
public DefaultKaptcha defaultKaptcha(){
com.google.code.kaptcha.impl.DefaultKaptcha defaultKaptcha = new com.google.code.kaptcha.impl.DefaultKaptcha();
Properties properties = new Properties();
properties.setProperty("kaptcha.border", "no"); // 图片边框
properties.setProperty("kaptcha.border.color", "105,179,90"); // 边框颜色
properties.setProperty("kaptcha.textproducer.font.color", "red"); // 字体颜色
properties.setProperty("kaptcha.image.width", "110"); // 图片宽
properties.setProperty("kaptcha.image.height", "40"); // 图片高
properties.setProperty("kaptcha.textproducer.font.size", "30"); // 字体大小
properties.setProperty("kaptcha.session.key", "code"); // session key
properties.setProperty("kaptcha.textproducer.char.length", "4"); // 验证码长度
properties.setProperty("kaptcha.textproducer.font.names", "宋体,楷体,微软雅黑"); // 字体
properties.setProperty("kaptcha.obscurificator.impl", "com.google.code.kaptcha.impl.ShadowGimpy");
properties.setProperty("kaptcha.noise.impl", "com.google.code.kaptcha.impl.DefaultNoise");
Config config = new Config(properties);
defaultKaptcha.setConfig(config);
return defaultKaptcha;
}
}
2、SpringSecurity的配置,首先通过@EnableWebSecurity注解开启Spring Security的功能,然后继承WebSecurityConfigurerAdapter类,在configure方法里做拦截配置,
这里的只对登录的接口和验证码接口放行不做拦截,其他接口都需要登录授权。因为SpringSecurity在不授权情况下返回403,这里是因为需求所以定义返回401状态码。
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception{
auth.userDetailsService(customUserDetailsService())
.passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
//验证码和登陆不进行拦截
.antMatchers("/user/defaultKaptcha").permitAll()
.antMatchers("/user/login").permitAll()
.anyRequest()
.authenticated() //其他的需要登陆后才能访问
.and()
.cors()
.and()
.csrf().disable()// 取消跨站请求伪造防护
.exceptionHandling().authenticationEntryPoint((httpServletRequest, httpServletResponse, e) -> { //认证失败指定编码401
httpServletResponse.setStatus(401);
httpServletResponse.setCharacterEncoding("UTF-8");
httpServletResponse.setContentType("application/json; charset=utf-8");
PrintWriter printWriter = httpServletResponse.getWriter();
String body = "{\"status\":\"failure\",\"msg\":" + HttpStatus.UNAUTHORIZED + "}";
printWriter.write(body);
printWriter.flush();
});
}
/**
* 自定义UserDetailsService,授权
* @return
*/
@Bean
public CustomUserDetailsService customUserDetailsService(){
return new CustomUserDetailsService();
}
/**
* AuthenticationManager
* @return
* @throws Exception
*/
@Bean(name = BeanIds.AUTHENTICATION_MANAGER)
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
八、里面引入的pom.xml的jar包
<!--kaptcha验证码生成器-->
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
<!--SpringSecurity安全框架-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>