目录
一 作用
spring security作为spring家族中的一员,它的主要作用有两个,分别是认证和授权。
我们以前在实现登录功能的时候,前端会传来用户名和密码,然后我们根据前端传来的数据从用户表中的数据进行比较,从而实现用户登录。
而springSecurity的功能也有登录认证,并且它在登录认证成功后,会生成一个认证对象,当登录成功后再发送其他请求,就会根据这个认证对象来判断当前用户是否已经登录。
二 流程及源码分析
在使用springSecurity之前,你还需要导入它的依赖坐标,只要导入了依赖,它就会自动生效。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
① 用户输入用户名和密码以及验证码访问登录接口:
登录其实也是一个请求,所以也会被springsecurity拦截,但是因为你没有登录,就没有他的认证对象,它就不会让你访问这个接口,所以开始之前还需要在spring security的配置类中放行登录请求。(配置类的代码最后会统一给)
放行登录亲请求:
② 调用逻辑业务service层,完成验证。
package com.fs.system.service.ipml; import cn.hutool.core.convert.Convert; import com.fs.common.constant.CacheConstants; import com.fs.common.constant.UserConstants; import com.fs.common.core.pojo.SysUser; import com.fs.common.core.vo.LoginUser; import com.fs.common.enums.UserStatus; import com.fs.common.exception.ServiceException; import com.fs.common.exception.user.BlackListException; import com.fs.common.exception.user.CaptchaException; import com.fs.common.exception.user.UserNotExistsException; import com.fs.common.exception.user.UserPasswordNotMatchException; import com.fs.common.util.DateUtils; import com.fs.common.util.RedisCache; import com.fs.common.util.ip.IpUtils; import com.fs.system.security.UserDetailsServiceImpl; import com.fs.system.service.ISysConfigService; import com.fs.system.service.ISysLoginService; import com.fs.system.service.ISysUserService; import com.mysql.cj.Constants; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; import java.util.Objects; @Service public class ISysLoginServiceImpl implements ISysLoginService { @Autowired private ISysUserService sysUserService ; //进行用户信息校验,包括查询该用户是否存在 @Autowired private SysPasswordService passwordService ; //密码校验,包括密码输入的正确性已经输入密码的次数 @Autowired private ISysConfigService sysConfigService ; //判断验证码功能有没有开启 @Autowired private RedisCache redisCache ; //用于操作缓存中的数据 @Autowired private TokenService tokenService ; //对token的操作 @Autowired private UserDetailsServiceImpl userDetailsService ; //用来获取LoginUser对象 @Autowired private AuthenticationManager authenticationManager ; //用于获取认证 /** 登录验证 */ @Override public String login(String username, String password, String code, String uuid) { // 1.验证码验证码 validateCaptcha(username , code , uuid); System.out.println("验证码校验完成..."); // 2.参数校验 loginCheck(username , password) ; 3.根据用户名查询用户 // SysUser sysUser = sysUserService.selectUserByUserName(username); // 检验密码 // passwordService.validate(sysUser , password); //通过Security帮我们进行处理,不需要自己校验了 // 创建token // //先创建一个LoginUser对象,因为返回的的是LoginUser对象 // LoginUser loginUser = new LoginUser(); // loginUser.setUserId(sysUser.getUserId()); // loginUser.setUser(sysUser); // 返回一个认证对象 Authentication authentication = null; try{ //创建一个Authentication认证对象, 属性: authenticated =false, 没有被认证 UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password); //返回一个认证对象: 被认证认证对象 authentication = authenticationManager.authenticate(authRequest); //这个认证对象就是一个登录的认证器 }catch (Exception e){ throw new ServiceException("用户不存在或者是密码错误"); } //调用tokenService创建token //里面已经给LoginUser赋值token了,不过这里面的token是一个uuid,redis存的是loginUser,LoginUser里面的token又是一个uuid? String token = tokenService.createToken((LoginUser) userDetailsService.loadUserByUsername(username)); // 修改登陆时间和登录ip LoginUser loginUser= (LoginUser) authentication.getPrincipal(); recordLoginInfo(loginUser.getUserId()); // 返回token给前端(token里面存放着登录用户的信息,) System.out.println("token:=========>"+token); return token; } /** 验证码验证码 */ @Override public void validateCaptcha(String username, String code, String uuid) { /** * code是前端传来的验证码答案,uuid是验证码缓存的key */ // 先判断有没有开启验证码功能 boolean captchaEnabled = sysConfigService.selectCaptchaEnabled(); if (!captchaEnabled) throw new ServiceException("验证码功能没有开启"); // 然后根据key去查询缓存中的value,判断value和输入的答案是否正确 String value = redisCache.getCacheObject(CacheConstants.CAPTCHA_CODE_KEY + uuid); // 再将从缓存中拿到的value和code比对 if (!value.equals(code)){ throw new CaptchaException(); } } /** 记录登录信息 : */ @Override public void recordLoginInfo(Long userId) { SysUser sysUser = new SysUser(); sysUser.setUserId(userId); sysUser.setLoginIp(IpUtils.getIpAddr()); sysUser.setLoginDate(DateUtils.getNowDate()); sysUserService.updateUserProfile(sysUser); } /** 登录前置校验(对请求参数的校验) */ private void loginCheck(String username , String password){ // 非空校验 if (Objects.isNull(username)||Objects.isNull(password)){ throw new UserNotExistsException() ; } // 长度校验 // 密码如果不在指定范围内 错误 if (password.length() < UserConstants.PASSWORD_MIN_LENGTH || password.length() > UserConstants.PASSWORD_MAX_LENGTH){ throw new UserPasswordNotMatchException(); } //用户名如果不在指定范围内 if (username.length()<UserConstants.USERNAME_MIN_LENGTH||username.length()>UserConstants.USERNAME_MAX_LENGTH){ throw new UserPasswordNotMatchException() ; } // IP黑名单校验 //todo 这个地方调用selectConfigByKey这个方法,但是这个数据库里面根本没有记录黑名单的列 String blackStr = sysConfigService.selectConfigByKey("sys.login.blackIPList"); if (IpUtils.isMatchedIp(blackStr, IpUtils.getIpAddr())) { throw new BlackListException(); } } }
③ 获取认证对象
我们需要调用spring security中的方法来获取一个认证对象,它会经过一系列的认证,只有最后用户的信息认证成功后才会生成一个认证对象,这说明用户认证成功,否则则说明这个用户认证失败。
④ 源码分析
那么我们就从这里作为入口,去看看springsecurity的源码是如何实现用户的账号信息以及密码校验的。
1.进入anthenticate方法后,我们发现这个是一个接口的抽象方法:
2.既然是接口,那我们就找到它的默认实现类ProviderManager,并且找到这个实现的方法:
3.那么我们继续看这个方法,这个方法的核心就是调用登录认证器的authenticate()方法:
4.我们现在知道这里主要就是调用了登录认证器的authenticate方法,那么我们就进这个方法看看里面实现了什么。最后发现这个方法也是一个接口的抽象方法:
5.然后我们找到它的实现类AbstractUserDetailsAuthenticationProvider,并且找到对应的方法:
6.我们找到实现方法authenticate后,发现它主要进行两步操作,先从缓存中获取到这个用户,如果是null,那么就调用etrieveUser()方法,而第一次登录里面肯什么都没有,所以是null,那么主要就是调用etrieveUser()方法:
7.那么不用多说,直接进入这个方法。但是发现它是一个抽象方法,那么我们肯定就是找到它的实现方法,去看里面的实现逻辑:
8.找到它的实现方法,这就已经到尾了,不过我们还记得一开时我们需要返回的值就是一个用户信息对象,所以这里也是找到了我们心心念念的 loadUserByUsername(username)方法:
9.那么我们肯定就进入这个方法去看看,发现它是接口 UserDetailsService的方法。
10.既然如此,我们只需要实现这个接口重写loadUserByUsername方法,然后从数据路根据用户名查询到用户信息,然后封装成一个loginUser对象,并且对这个用户的基本信息进行一个校验,没问题后就可以返回了,到这里根据用户名查找用户这个验证已经完成了。(不过需要注意,因为这个方法返回的是一个UserDetails,所以loginUser对象需要实现或者继承它才能作为该对象返回loginUser对象)
package com.fs.system.security; import cn.hutool.core.util.ObjectUtil; import com.fs.common.core.pojo.SysUser; import com.fs.common.core.vo.LoginUser; import com.fs.common.enums.UserStatus; import com.fs.common.exception.ServiceException; import com.fs.common.exception.user.UserNotExistsException; import com.fs.system.mapper.SysUserMapper; import com.fs.system.service.ipml.TokenService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import java.util.Collections; import java.util.Objects; @Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private SysUserMapper userMapper ; @Autowired private TokenService tokenService ; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { System.out.println("username:"+username); // 从数据库查询用户信息 SysUser sysUser = userMapper.selectUserByUserName(username); System.out.println("查询到用户信息:"+sysUser); //验证用户是否存在 if (Objects.isNull(sysUser)){ System.out.println("用户不存在"); throw new UserNotExistsException() ; } // 是否禁用 if (sysUser.getStatus().equals(UserStatus.DISABLE)){ throw new ServiceException("用户已封禁") ; } // 是否删除 if (sysUser.getDelFlag()== UserStatus.DELETED.getCode()){ throw new ServiceException("账号已经被删除"); } // 创建LoginUser对象 LoginUser loginUser = creatLoginUser(sysUser); // 创建token tokenService.createToken(loginUser); return loginUser ; } public LoginUser creatLoginUser(SysUser sysUser){ //先创建一个LoginUser对象,因为返回的的是LoginUser对象 LoginUser loginUser = new LoginUser(); loginUser.setUserId(sysUser.getUserId()); loginUser.setUser(sysUser); System.out.println("成功查询user对象并且返回:"+loginUser); return loginUser ; } }
上面只是完成了用户名查到用户的功能,既然已经查询到用户的信息了,说明也就知道了用户的真实密码,那么我接下来就需要把用户输入的密码和真实密码做对比,所以我们继续回到DaoAuthenticationProvider类中。
在这里的 createSuccessAuthentication()方法中,我们成功看见了密码比对的方法:
我们直接找到源头,发现这个加密方法是一个PasswordEncoder接口的抽象方法,里面主要两个方法,分别用于对前端输入的密码加密,还有把加密后的密码和用户信息中的密码进行比对。
所以,我们要实现自定义的加密以及对密码进行比对,我们只需要实现这个接口,同时完成这个两个方法的实现就可以了:
package com.fs.system.security; import cn.hutool.core.convert.Convert; import com.fs.common.util.sign.PasswordUtils; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; /** * @author suke * @version 1.0 * @title MyPasswordEncoder * @description 自定义的密码编码 * @create 2024/7/25 10:58 */ @Component public class MyPasswordEncoder implements PasswordEncoder { @Override public String encode(CharSequence rawPassword) { return PasswordUtils.generate(Convert.toStr(rawPassword)); } @Override public boolean matches(CharSequence rawPassword, String encodedPassword) { return PasswordUtils.verify(Convert.toStr(rawPassword),encodedPassword); } }
PasswordUtils是我们用于密码的加密和比对的一个工具类,我们使用的是md5加密,工具类代码如下:package com.fs.common.util.sign; import java.security.MessageDigest; import java.util.Random; /** * MD5加盐加密 */ public class PasswordUtils { /** * 生成含有 随机盐的密码 */ public static String generate(String password) { Random r = new Random(); StringBuilder sb = new StringBuilder(16); sb.append(r.nextInt(99999999)).append(r.nextInt(99999999)); int len = sb.length(); if (len < 16) { //不够16位,前面补0 for (int i = 0; i < 16 - len; i++) { sb.append("0"); } } String salt = sb.toString(); password = md5Hex(password + salt); //32位的16进制 char[] cs = new char[48]; for (int i = 0; i < 48; i += 3) { cs[i] = password.charAt(i / 3 * 2); char c = salt.charAt(i / 3); cs[i + 1] = c; cs[i + 2] = password.charAt(i / 3 * 2 + 1); } return new String(cs); } /** * 校验密码是否正确 */ public static boolean verify(String password, String md5) { char[] cs1 = new char[32]; char[] cs2 = new char[16]; for (int i = 0; i < 48; i += 3) { cs1[i / 3 * 2] = md5.charAt(i); cs1[i / 3 * 2 + 1] = md5.charAt(i + 2); cs2[i / 3] = md5.charAt(i + 1); } String salt = new String(cs2); return md5Hex(password + salt).equals(new String(cs1)); } /** * 获取十六进制字符串形式的MD5摘要 */ public static String md5Hex(String src) { try { return Md5Utils.hash(src); } catch (Exception e) { return null; } } public static void main(String[] args) { System.out.println(generate("123456")); //System.out.println(verify("123456", "02dd65660724d38816641b3a98409fa2080401b489c9ff42")); } }
最后在这里,这个校验过程就完成了,最后会成功创建一个认证对象。
随后,我们只需要创建一个token,返回给前端就欧克了。
这里附上token创建的代码:
package com.fs.system.service.ipml; import java.util.HashMap; import java.util.Map; import java.util.Objects; import java.util.concurrent.TimeUnit; import javax.servlet.http.HttpServletRequest; import cn.hutool.core.lang.UUID; import cn.hutool.core.util.StrUtil; import cn.hutool.http.useragent.UserAgent; import cn.hutool.http.useragent.UserAgentUtil; import com.baomidou.mybatisplus.core.toolkit.StringUtils; import com.fs.common.constant.CacheConstants; import com.fs.common.constant.Constants; import com.fs.common.core.vo.LoginUser; import com.fs.common.util.RedisCache; import com.fs.common.util.ServletUtils; import com.fs.common.util.ip.AddressUtils; import com.fs.common.util.ip.IpUtils; import com.fs.system.config.TokenProperties; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.stereotype.Service; /** * token验证处理 */ @Service public class TokenService { @Autowired private TokenProperties tokenProperties; protected static final long MILLIS_SECOND = 1000; protected static final long MILLIS_MINUTE = 60 * MILLIS_SECOND; private static final Long MILLIS_MINUTE_TEN = 20 * 60 * 1000L; @Autowired private RedisCache redisCache; /** * 获取用户身份信息 * * @return 用户信息 */ public LoginUser getLoginUser(HttpServletRequest request){ // 获取请求携带的令牌 String token = getToken(request); if (StringUtils.isNotEmpty(token)) { try { Claims claims = parseToken(token); // 解析对应的权限以及用户信息 String uuid = (String) claims.get(Constants.LOGIN_USER_KEY); String userKey = getTokenKey(uuid); LoginUser user = redisCache.getCacheObject(userKey); return user; } catch (Exception e) { } } return null; } /** * 设置用户身份信息 */ public void setLoginUser(LoginUser loginUser){ if (Objects.nonNull(loginUser) && StrUtil.isNotEmpty(loginUser.getToken())) { refreshToken(loginUser); } } /** * 删除用户身份信息 */ public void delLoginUser(String token){ if (StringUtils.isNotEmpty(token)){ String userKey = getTokenKey(token); redisCache.deleteObject(userKey); } } /** * 创建令牌 * * @param loginUser 用户信息 * @return 令牌 */ public String createToken(LoginUser loginUser){ String token = UUID.randomUUID(false).toString(true); loginUser.setToken(token); setUserAgent(loginUser); refreshToken(loginUser); Map<String, Object> claims = new HashMap<>(); claims.put(Constants.LOGIN_USER_KEY, token); return createToken(claims); } /** * 验证令牌有效期,相差不足20分钟,自动刷新缓存 * * @param loginUser * @return 令牌 */ public void verifyToken(LoginUser loginUser){ long expireTime = loginUser.getExpireTime(); long currentTime = System.currentTimeMillis(); if (expireTime - currentTime <= MILLIS_MINUTE_TEN) { refreshToken(loginUser); } } /** * 刷新令牌有效期 * * @param loginUser 登录信息 */ public void refreshToken(LoginUser loginUser){ loginUser.setLoginTime(System.currentTimeMillis()); loginUser.setExpireTime(loginUser.getLoginTime() + tokenProperties.getExpireTime() * MILLIS_MINUTE); // 根据uuid将loginUser缓存 String userKey = getTokenKey(loginUser.getToken()); redisCache.setCacheObject(userKey, loginUser, tokenProperties.getExpireTime(), TimeUnit.MINUTES); } /** * 设置用户代理信息 * * @param loginUser 登录信息 */ public void setUserAgent(LoginUser loginUser){ UserAgent userAgent = UserAgentUtil.parse(ServletUtils.getRequest().getHeader("User-Agent")); String ip = IpUtils.getIpAddr(); loginUser.setIpaddr(ip); loginUser.setLoginLocation(AddressUtils.getRealAddressByIP(ip)); loginUser.setBrowser(userAgent.getBrowser().getName()); loginUser.setOs(userAgent.getOs().getName()); } /** * 从数据声明生成令牌 * * @param claims 数据声明 * @return 令牌 */ private String createToken(Map<String, Object> claims){ String token = Jwts.builder() .setClaims(claims) .signWith(SignatureAlgorithm.HS512, tokenProperties.getSecret()).compact(); return token; } /** * 从令牌中获取数据声明 * * @param token 令牌 * @return 数据声明 */ private Claims parseToken(String token){ return Jwts.parser() .setSigningKey(tokenProperties.getSecret()) .parseClaimsJws(token) .getBody(); } /** * 从令牌中获取用户名 * * @param token 令牌 * @return 用户名 */ public String getUsernameFromToken(String token){ Claims claims = parseToken(token); return claims.getSubject(); } /** * 获取请求token * * @param request * @return token */ private String getToken(HttpServletRequest request){ String token = request.getHeader(tokenProperties.getHeader()); if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX)){ token = token.replace(Constants.TOKEN_PREFIX, ""); } return token; } private String getTokenKey(String uuid){ return CacheConstants.LOGIN_TOKEN_KEY + uuid; } }
最后再附上spring security配置类的完成代码:
package com.fs.system.config; import com.fs.common.util.RedisCache; import com.fs.system.security.JwtAuthenticationTokenFilter; import com.fs.system.security.MyDaoAuthenticationProvider; import com.fs.system.security.MyPasswordEncoder; import com.fs.system.security.TokenAuthenticationEntryPoint; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.BeanIds; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.WebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.filter.CorsFilter; /** * @author suke * @version 1.0 * @title SpringSecurityConfiguration * @description springSecurity的自定义配置类 * @create 2024/7/25 10:14 */ @Configuration public class SpringSecurityConfiguration extends WebSecurityConfigurerAdapter { @Autowired // @Qualifier("userDetailsServiceImpl") private UserDetailsService userDetailsService; @Autowired private RedisCache redisCache ; @Autowired private SysPasswordProperties passwordProperties ; @Autowired private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter ; @Autowired private CorsFilter corsFilter; @Autowired private TokenAuthenticationEntryPoint tokenAuthenticationEntryPoint; // @Autowired // private LogoutSuccessHandlerImpl logoutSuccessHandler; //对密码进行加密 密码校验 @Bean public PasswordEncoder passwordEncoder(){ return new MyPasswordEncoder() ; } //修改用户名,密码 @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { System.out.println("进入配置类..."); //使用MyDaoAuthenticationProvider auth.authenticationProvider(new MyDaoAuthenticationProvider(userDetailsService,redisCache,passwordEncoder(),passwordProperties)); } 修改用户名,密码 //@Override //protected void configure(AuthenticationManagerBuilder auth) throws Exception { // //使用MyDaoAuthenticationProvider // auth.authenticationProvider(new MyDaoAuthenticationProvider()); //} @Bean(name = BeanIds.AUTHENTICATION_MANAGER) @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override protected void configure(HttpSecurity http) throws Exception { http // CSRF禁用,因为不使用session .csrf().disable() // 禁用HTTP响应标头 .headers().cacheControl().disable().and() // 基于token,所以不需要session .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() // 过滤请求 .authorizeRequests() // 对于登录login 注册register 验证码captchaImage 允许匿名访问 .antMatchers("/login","/getRouters", "/captchaImage").permitAll() // 静态资源,可匿名访问 .antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll() .antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll() // 除上面外的所有请求全部需要鉴权认证 .anyRequest().authenticated() .and() .headers().frameOptions().disable(); //添加过滤器 http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); //添加跨域过滤器 http.addFilterBefore(corsFilter,JwtAuthenticationTokenFilter.class); //配置认证失败的入口 http.exceptionHandling().authenticationEntryPoint(tokenAuthenticationEntryPoint); // // //配置注销 // http.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler); } }