原理
由默认用户登录得到SpringSecurity下用户登录的请求流程:
入门示例:使用SpringSecurity提供的登录页面,实现真正的登录功能
第一步:自定义UserDetailsService
@Service public class WegoUserDetailsService implements UserDetailsService { @Resource private MemberMapper memberMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //认证:根据用户名去查询对应的用户信息 LambdaQueryWrapper<Member> queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(Member::getUsername,username); Member member = memberMapper.selectOne(queryWrapper); //如果没有查询到用户抛出异常 if(member == null){ throw GlobalException.builder().code(401).msg("用户名或密码不正确").build(); } // TODO: 授权:查询用户的权限信息 // 将用户信息+权限信息封装成UserDetails对象 LoginMemberDetails loginMemberDetails = new LoginMemberDetails(); loginMemberDetails.setMember(member); return loginMemberDetails; } }第二步:自定义UserDetails:
public class LoginMemberDetails implements UserDetails { @Setter private Member member; @Override public Collection<? extends GrantedAuthority> getAuthorities() { return null; } /** * 登录密码 * @return */ @Override public String getPassword() { return member.getPassword(); } /** * 登录账户 * @return */ @Override public String getUsername() { return member.getUsername(); } /** * 返回对象是否未过期 * @return */ @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }第三步:测试
为了测试,修改待登录用户的密码为:{noop}明文密码,比如:
密码加密存储
默认使用的PasswordEncoder要求数据库中的密码格式为:{id}password。它会根据id去判断密码的加密方式,但是我们一般不会采用这种方式,所以就需要替换PasswordEncoder。 实际项目中推荐使用BCryptPasswordEncoder。只需要把BCryptPasswordEncoder对象注入到Spring容器中,SpringSecurity就会使用该PasswordEncoder进行密码加密。 创建SpringSecurity配置文件,在其中指定加密方式:
@Configuration public class SpringSecurityConfig extends WebSecurityConfigurerAdapter { /** * 密码加密器 * @return */ @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } }将数据库中的密码改成加密后的内容,然后测试就可以了。
用户登录:不再使用默认的用户user+随机字符串登录,使用我们自己数据库中的用户名+密码登录。
SpringSecurity需要对自定义的登录接口放行,让用户可以直接访问到这个接口。 在登录接口中需要通过AuthenticationManager的authenticate()方法进行用户认证,故需要先在SpringSecurity配置文件中把AuthenticationManager注入容器。 认证成功后生成一个jwt,在登录成功后的响应中返回。 为了让用户下回请求时能够通过jwt快速识别出用户,我们将用户信息缓存到Redis中,将用户的id作为key。
第一步:自定义UserDetails类:
@Getter @Setter @ToString public class WegoUserDetails implements UserDetails { private User user; @Override public Collection<? extends GrantedAuthority> getAuthorities() { return null; } @Override public String getPassword() { if(user == null){ return null; } return user.getPassword(); } @Override public String getUsername() { if(user == null){ return null; } return user.getUsername(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }第二步:自定义UserDatailsService
在其中实现根据用户名查找用户,并将用户的信息封装成我们自己的UserDatails对象并返回
@Service public class WegoUserDetailsService implements UserDetailsService { @Resource private UserService userService; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userService.getOne(Wrappers.<User>query().eq("username", username)); //TODO:查询权限信息 WegoUserDetails userDetails = new WegoUserDetails(); userDetails.setUser(user); return userDetails; } }第三步:修改SpringSecurity配置类
在系统注入AuthenticationManager对象并放行登录接口user/login:
@Configuration public class SpringSecurityConfig extends WebSecurityConfigurerAdapter { /** * 认证用:最终调用DetailsService的具体的登录逻辑 * @return * @throws Exception */ @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override protected void configure(HttpSecurity http) throws Exception { http //关闭csrf .csrf().disable() //禁用Session:不通过Sessin获取SecurityContext .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() //登录接口:匿名访问(未登录可以访问,登录的话不就能访问了) .antMatchers("/user/login").anonymous() //放行 //其他的任何请求都需要鉴权认证 .anyRequest().authenticated() ; } }第四步:自定义登录接口:
登录接口实现功能: 1、 将用户名+密码封装成UsernamePasswordAuthenticationToken对象 2、 调用AuthenticationManager的authenticate()方法进行登录判断
@RestController @RequestMapping("/user") public class LoginController { @Resource private AuthenticationManager authenticationManager; @PostMapping("/login") public Result login(@RequestBody User user) { UsernamePasswordAuthenticationToken passwordAuthenticationToken = new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword()); authenticationManager.authenticate(passwordAuthenticationToken); return ResultUtil.success(); } }第五步:在SpringSecurity配置文件中注入加密类的对象:
/** * 密码加密器 * @return */ @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }在测试类中,利用该对象生成密码并保存到数据库中
class FafuRbacApplicationTests { @Test public void fun() { PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); System.out.println(passwordEncoder.encode("1234")); } }第六步:测试
打开postman,在其中请求user/login,注意以post方式+application/json的形式请求。
返回token
第一步:准备JWT工具类:
@Slf4j @Component //@ConfigurationProperties(prefix = "jwt") public class JwtUtil { /** * 携带JWT令牌的HTTP的Header的名称,在实际生产中可读性越差越安全 */ @Getter @Value("${jwt.token}") private String token; /** * 为JWT基础信息加密和解密的密钥 * 在实际生产中通常不直接写在配置文件里面。而是通过应用的启动参数传递,并且需要定期修改。 */ @Value("${jwt.secret}") private String secret; /** * JWT令牌的有效时间,单位秒 * - 默认2周 */ @Value("${jwt.expiration}") private Long expiration; private static String getUUID() { String token = UUID.randomUUID().toString().replaceAll("-", ""); return token; } /** * SecretKey 根据 SECRET 的编码方式解码后得到: * Base64 编码:SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretString)); * Base64URL 编码:SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretString)); * 未编码:SecretKey key = Keys.hmacShaKeyFor(secretString.getBytes(StandardCharsets.UTF_8)); */ private static SecretKey getSecretKey(String secret) { byte[] encodeKey = Decoders.BASE64.decode(secret); return Keys.hmacShaKeyFor(encodeKey); } /** * 用claims生成token * * @param claims 数据声明,用来创建payload的私有声明,subject只是claims的一部分 * @return token 令牌 */ private JwtBuilder getJwtBuilder(Map<String, Object> claims) { SecretKey key = getSecretKey(secret); //SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256); //两种方式等价 // 添加payload声明 JwtBuilder jwtBuilder = Jwts.builder() // 如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的 .setClaims(claims) // 设置jti(JWT ID):是JWT的唯一标识,根据业务需要,这个可以设置为一个不重复的值,主要用来作为一次性token,从而回避重放攻击。 .setId(getUUID()) // 你也可以改用你喜欢的算法,支持的算法详见:https://github.com/jwtk/jjwt#features // SignatureAlgorithm.HS256:指定签名的时候使用的签名算法,也就是header那部分 .signWith(key, SignatureAlgorithm.HS256) // iat: jwt的签发时间 .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + this.expiration * 1000)); return jwtBuilder; } /** * 生成Token令牌 * * @param username 用户名 * @return 令牌Token */ public String generateToken(String username) { Map<String, Object> claims = new HashMap<>(); claims.put("sub", username); claims.put("created", new Date()); return getJwtBuilder(claims).compact(); } public String generateToken(Serializable id) { Map<String, Object> claims = new HashMap<>(); claims.put("sub", id); claims.put("created", new Date()); return getJwtBuilder(claims).compact(); } /** * 从token中获取数据声明claim * * @param token 令牌token * @return 数据声明claim */ public Claims getClaimsFromToken(String token) { try { SecretKey key = getSecretKey(secret); Claims claims = Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token).getBody(); return claims; } catch (ExpiredJwtException | UnsupportedJwtException | MalformedJwtException | IllegalArgumentException e) { log.error("token解析错误", e); throw new IllegalArgumentException("Token invalided."); } } /** * 从token中获取登录用户名 * * @param token 令牌 * @return 用户名 */ public String getSubjectFromToken(String token) { String subject; try { Claims claims = getClaimsFromToken(token); subject = claims.getSubject(); } catch (Exception e) { subject = null; } return subject; } /** * 获取token的过期时间 * * @param token token * @return 过期时间 */ public Date getExpirationFromToken(String token) { return getClaimsFromToken(token).getExpiration(); } public String getUserRole(String token) { return (String) getClaimsFromToken(token).get("role"); } /** * 判断token是否过期 * * @param token 令牌 * @return 是否过期:已过期返回true,未过期返回false */ public Boolean isTokenExpired(String token) { Date expiration = getExpirationFromToken(token); return expiration.before(new Date()); } /** * 验证令牌:判断token是否非法 * * @param token 令牌 * @param username 用户名 * @return 如果token未过期且合法,返回true,否则返回false */ public Boolean validateToken(String token, String username) { //如果已经过期返回false if (isTokenExpired(token)) { return false; } String usernameFromToken = getSubjectFromToken(token); return username.equals(usernameFromToken); } }第二步:修改LoginController
在login()方法中,当用户登录成功时,生成token,并放到返回结果中
@RestController @RequestMapping("/user") public class LoginController { @Resource private JwtUtil jwtUtil; @Resource private AuthenticationManager authenticationManager; @PostMapping("/login") public Result login(@RequestBody User user) { UsernamePasswordAuthenticationToken passwordAuthenticationToken = new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword()); Authentication authenticate = authenticationManager.authenticate(passwordAuthenticationToken); if(authenticate == null){ return ResultUtil.error(401,"error"); } user = ((WegoUserDetails)authenticate.getPrincipal()).getUser(); String token = jwtUtil.generateToken("user:" + user.getId()); return ResultUtil.success().addData("token",token); } }第三步:修改application.yml,其中添加jwt的配置信息:
jwt: # 为JWT基础信息加密和解密的密钥,长度需要大于等于43 # 在实际生产中通常不直接写在配置文件里面。而是通过应用的启动参数传递,并且需要定期修改 secret: oQZSeguYloAPAmKwvKqqnifiQatxMEPNOvtwPsCLasd # JWT令牌的有效时间,单位秒,默认2周 expiration: 1209600 token: Authorization第四步:测试:
token认证过滤器
第一步:自定义过滤器
@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Resource private ByteRedisUtil<WegoUserDetails> wegoUserDetailsByteRedisUtil; @Resource private JwtUtil jwtUtil; @Override protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException { //1、获取用户请求时传递过来的token String token = httpServletRequest.getHeader("Authorization"); if(token == null || token.length() == 0){ //放行到过滤器链中的下一个过滤器过滤 filterChain.doFilter(httpServletRequest,httpServletResponse); return; } //如果用户请求携带有token,解析token Claims claimsFromToken = null; try { claimsFromToken = jwtUtil.getClaimsFromToken(token); } catch (Exception e) { System.out.println("非法的token"); e.printStackTrace(); } //获取用户id String userId = claimsFromToken.getSubject(); //根据用户id,构造出一个Authentication对象,给过滤器链中后面的Filter使用(过滤器链中处理的都是Authentication对象) UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(userId,null,null); SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); //放行到过滤器链中的下一个过滤器过滤 filterChain.doFilter(httpServletRequest,httpServletResponse); } }第二步:在SpringSecurity配置类中配置自定义的过滤器
@Resource private JwtAuthenticationFilter jwtAuthenticationFilter; @Override protected void configure(HttpSecurity http) throws Exception { …… //添加过滤器 http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); }第三步:测试
1、 先登录,获取token 2、 将token配置到header中,请求任意一个资源
完善token认证过滤器
@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Resource private ByteRedisUtil<WegoUserDetails> wegoUserDetailsByteRedisUtil; @Override protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException { //…….. //获取用户id String userId = claimsFromToken.getSubject(); //从Redis中获取缓存的用户数据 WegoUserDetails wegoUserDetails = wegoUserDetailsByteRedisUtil.get("user:" + userId); if(wegoUserDetails == null){ System.out.println("无效的token"); } //根据用户id,构造出一个Authentication对象,给过滤器链中后面的Filter使用(过滤器链中处理的都是Authentication对象) UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(wegoUserDetails,null,null); SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); //放行到过滤器链中的下一个过滤器过滤 filterChain.doFilter(httpServletRequest,httpServletResponse); } }异常处理
原理(套路性内容)
在SpringSecurity中,在认证或者授权的过程中出现的异常会被ExceptionTranslationFilter捕获到,在ExceptionTranslationFilter中会去判断这个异常是认证失败还是授权失败产生的:- 认证过程中出现的异常,会被封装成AuthenticationException,SpringSecurity会调用AuthenticationEntryPoint对象的方法处理这个异常
- 授权过程中出现的异常,会被封装成AccessDeniedException,SpringSecurity会调用AccessDeniedHandler对象的方法处理这个异常 所以,自定义异常处理,只需要自定义AuthenticationEntryPoint和AccessDeniedHanler,然后在SpringSecurity中进行配置即可。
Spring Security 中的异常主要分为两大类:一类是认证异常,另一类是授权相关的异常:
-
AuthenticationException 是在用户认证的时候出现错误时抛出的异常。系统用户不存在、被锁定、凭证失效、密码错误等认证过程中出现的异常都由 AuthenticationException 处理。主要的子类如图:
-
AccessDeniedException 主要是在用户在访问受保护资源时被拒绝而抛出的异常。主要是 CSRF 相关的异常和授权服务异常。主要的子类如图:
状态码:
-
401 未授权状态 HTTP 401 错误 - 未授权(Unauthorized) 一般来说该错误消息表明您首先需要登录(输入有效的用户名和密码)。 如果你刚刚输入这些信息,立刻就看到一个 401 错误,就意味着,无论出于何种原因您的用户名和密码其中之一或两者都无效(输入有误,用户名暂时停用,账户被锁定,凭证失效等) 。总之就是认证失败了。其实正好对应我们上面的 AuthenticationException 。
-
403 被拒绝状态 HTTP 403 错误 - 被禁止(Forbidden) 出现该错误表明您在访问受限资源时没有得到许可。服务器理解了本次请求但是拒绝执行该任务,该请求不该重发给服务器。并且服务器想让客户端知道为什么没有权限访问特定的资源,服务器应该在返回的信息中描述拒绝的理由。一般实践中我们会比较模糊的表明原因。 该错误对应了我们上面的 AccessDeniedException 。
示例
第一步:自定义认证失败处理器
/** * 认证失败处理器 * * @author hc */ @Component public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { response.setStatus(200); response.setContentType("application/json"); response.setCharacterEncoding("utf-8"); String message = authException.getMessage(); if (message == null){ message = "登录失败"; } String json = "{\"code\":\"401\",\"msg\":"+ message +"}"; response.getWriter().write(json); } }第二步:自定义授权失败处理器
/** * 授权失败处理器 * * @author hc */ @Component public class AccessDeniedHandlerImpl implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { response.setStatus(200); response.setContentType("application/json"); response.setCharacterEncoding("utf-8"); String message = accessDeniedException.getMessage(); if (message == null){ message = "您的权限不足"; } String json = "{\"code\":\"403\",\"msg\":"+ message +"}"; response.getWriter().write(json); } }第三步:在SpringSecurity配置文件中配置:
@Resource private AuthenticationEntryPointImpl authenticationEntryPoint; @Resource private AccessDeniedHandlerImpl accessDeniedHandler; @Override protected void configure(HttpSecurity http) throws Exception { …… //处理异常处理器 http.exceptionHandling() .authenticationEntryPoint(authenticationEntryPoint) //认证失败处理器 .accessDeniedHandler(accessDeniedHandler) //授权失败处理器 ; }第四步:测试。
1、 将Redis中的用户删除,然后请求某个资源
2、 错误的用户名or密码登录
退出登录
在控制器中提供一个退出登录的接口,然后获取SecurityContextHolder中的认证信息,最后删除Redis中对应的数据即可。代码实现
@RestController @RequestMapping("/user") public class LogoutController { @Resource private ByteRedisUtil<WegoUserDetails> redisUtil; @GetMapping("/logout") public String logout(){ //获取SecurityContextHolder中的用户id UsernamePasswordAuthenticationToken authentication = (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication(); WegoUserDetails userDatails = (WegoUserDetails) authentication.getPrincipal(); Long userId = userDatails.getUser().getId(); //从Redis缓存中删除指定id的用户 redisUtil.del(GlobalConst.REDIS_USER_KEY+userId); return "{\"code\":200,\"msg\":\"注销成功!\"}"; } }测试
1、 正确用户名和密码登录,发现redis中缓存了数据 2、 访问某个资源,ok 3、 退出登录,发现Redis中缓存的数据没有了 4、 再次访问相同的资源,发现访问不到了:
授权
原理
SpringSecurity使用默认的FilterSecurityInterceptor进行权限校验。FilterSecurityInterceptor会从SecurityContextHolder获取 Authentication,然后获取其中的权限信息,从而知道当前用户是否拥有访问指定资源所需要的权限。 所以我们需要将当前登录用户的权限信息也存入到Authenticaion中。 只需要将用户的“权限+角色”信息存放到Authentication(在过滤器链中处理的对象)中,SpringSecurity就能自动处理权限验证
示例
准备工作
第一步:在RoleMapper.java接口中添加如下根据用户id获取该用户拥有的role的方法:
/** * 查询指定id的用户的role * @param userId * @return 角色名称-角色编码 */ @Select(""" SELECT tb_role.id, tb_role.`code` FROM tb_role INNER JOIN tb_user_role ON tb_role.id = tb_user_role.role_id WHERE tb_user_role.user_id = #{userId} """) List<Role> selectRoleCodeByUserId(@Param("userId") Long userId);第二步:在PermissionMapper.java接口中添加如下根据Role的id获取该Role拥有的Permission的信息:
@Select(""" SELECT tb_permission.`code` FROM tb_role_permission INNER JOIN tb_permission ON tb_role_permission.permission_id = tb_permission.id WHERE tb_role_permission.role_id = #{roleId,jdbcType=BIGINT} """) List<String> selectPermissionCodeByRoleId(@Param("roleId") Long roleId);第三步:在UserServiceImpl类中添加如下根据用户id获取该用户拥有的角色与权限的方法:
@Resource private RoleMapper roleMapper; @Resource private PermissionMapper permissionMapper; @Override public Set<String> getRolePermissionCodeByUserId(Long userId){ Set<String> rolePermissionSet = new HashSet<>(); ////根据用户id查询角色code List<Role> roleList = roleMapper.selectRoleCodeByUserId(userId); for (Role role : roleList) { rolePermissionSet.add("ROLE_"+role.getCode()); // 根据角色id查询权限code List<String> permissionList = permissionMapper.selectPermissionCodeByRoleId(role.getId()); for (String permission : permissionList) { rolePermissionSet.add(permission); } } return rolePermissionSet; }具体实现
第一步:修改UserDetails类,在其中添加如下代码:
//用户角色+权限 private Set<String> list; public WegoUserDetails(User user, Set<String> list) { this.user = user; this.list = list; } private List<SimpleGrantedAuthority> authorities; @Override public Collection<? extends GrantedAuthority> getAuthorities() { if(authorities == null) { authorities = list.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList()); } return authorities; }第二步:修改UserDetailsService类,在其中添加获取用户权限的信息:
@Service public class WegoUserDetailsService implements UserDetailsService { @Resource private UserService userService; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //认证:根据用户名去查询对应的用户信息 //Wrappers.<User>lambdaQuery().eq(User::getUsername,username); LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(User::getUsername, username); User user = userService.getOne(queryWrapper); //如果没有查询到用户抛出异常 if (user == null) { throw GlobalException.builder().code(401).msg("用户名或密码不正确").build(); } //TODO: 授权:查询用户的权限信息 //List<String> set = List.of("add", "get"); Set<String> set = userService.getRolePermissionCodeByUserId(user.getId()); // 将用户信息+权限信息封装成UserDetails对象 WegoUserDetails wegoUserDetails = new WegoUserDetails(user, set); return wegoUserDetails; } }第三步:修改JwtAuthenticationFilter,在其中添加获取用户角色+权限的功能:
//TODO: 权限信息 Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities(); //此处必须使用三个参数的构造方法,其中第三个参数是权限信息 UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, authorities);第四步:测试
- 第一步:开启相关配置:
- 第二步:在方法上添加注解