搭建环境
我的是一个自己搭建的微服务项目,大佬们搭建可能会有点不一样,但是所使用的方法都是一样的,下面会具体讲解方法,这是列出我项目中的依赖版本
SpringBoot 2.3.12.RELEASE
SpringSecurity 2.3.12.RELEASE
jjwt-api 0.11.2
jjwt-impl 0.11.2
jjwt-jackson 0.11.2
还有一些我也不确定的依赖也列出来
数据库搭建(五张表)
表都可以自定义字段设计
此次项目中五张表还是存在一些瑕疵,但是不影响使用
瑕疵就是我们的权限会有很多,那么假设给一个用户分配很多权限的时候就会浪费很多时间去点击,所以我们可以多增加一张权限分组表以及权限分组表与权限表的中间表,这样就是用户与角色关联,角色与权限分组表关联,权限分组表与权限相关联,这样给角色分配权限的时候就可以少点击,以及页面展示时候会更好看。但是本个项目中没有权限分组表,因为起初的想法是通过注解来进行权限控制,但是五张表也是可以的只是给用户分配权限的时候需要点击多次
用户表
CREATE TABLE `p_user` ( `id` int(10) NULL DEFAULT NULL COMMENT '用户id', `username` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, `password` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, `sex` int(1) NULL DEFAULT NULL COMMENT '性别(0-女,1-男)', `birthday` date NULL DEFAULT NULL COMMENT '出生日期', `region` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, `salt` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, `phone` char(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '手机号', `email` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, `introduction` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, `status` int(1) NULL DEFAULT NULL COMMENT '是否冻结', )
角色表
CREATE TABLE `p_role` ( `id` int(10) NOT NULL AUTO_INCREMENT COMMENT '角色id', `name` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, `introduction` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, `create_time` date NULL DEFAULT NULL COMMENT '创建时间', );
权限表
CREATE TABLE `p_power` ( `id` int(10) NOT NULL AUTO_INCREMENT, `name` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, `url` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '路径', `parent_id` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL, `is_show` int(10) NULL DEFAULT NULL COMMENT '是否需要展示(0-不展示 1- 展示)', );
用户-角色表
CREATE TABLE `p_user_role` ( `id` int(10) NOT NULL AUTO_INCREMENT COMMENT '中间表id', `uid` int(10) NULL DEFAULT NULL COMMENT '用户id', `rid` int(10) NULL DEFAULT NULL COMMENT '角色id', PRIMARY KEY (`id`) USING BTREE );
角色-权限表
CREATE TABLE `p_role_power` ( `id` int(10) NOT NULL AUTO_INCREMENT COMMENT '中间表id', `rid` int(10) NULL DEFAULT NULL COMMENT '角色id', `pid` int(10) NULL DEFAULT NULL COMMENT '权限id', );
创建对应的工具类
这里的工具类是使用黑马中SpringSecurity的工具类,如果我的不行可以去看看黑马的工具类
PayLoad实体
@Data public class Payload <T>{ private String id; private T userInfo; private Date expiration; }
JwtUtils
加解密工具类
package com.sicnu.common.utils; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import org.joda.time.DateTime; import java.security.PrivateKey; import java.security.PublicKey; import java.util.Base64; import java.util.UUID; public class JwtUtils { /** * 私钥加密token * * @param userInfo 载荷中的数据 * @param privateKey 私钥 * @param expire 过期时间,单位分钟 * @return JWT */ public static String generateTokenExpireInMinutes(Object userInfo, PrivateKey privateKey, int expire) { return Jwts.builder() .claim(Constant.JWT_PAYLOAD_USER_KEY, JsonUtils.toString(userInfo)) .setId(createJTI()) .setExpiration(DateTime.now().plusMinutes(expire).toDate()) .signWith(privateKey, SignatureAlgorithm.RS256) .compact(); } /** * 私钥加密token * * @param userInfo 载荷中的数据 * @param privateKey 私钥 * @param expire 过期时间,单位秒 * @return JWT */ public static String generateTokenExpireInSeconds(Object userInfo, PrivateKey privateKey, int expire) { return Jwts.builder() .claim(Constant.JWT_PAYLOAD_USER_KEY, JsonUtils.toString(userInfo)) .setId(createJTI()) .setExpiration(DateTime.now().plusSeconds(expire).toDate()) .signWith(privateKey, SignatureAlgorithm.RS256) .compact(); } /** * 公钥解析token * * @param token 用户请求中的token * @param publicKey 公钥 * @return Jws<Claims> */ private static Jws<Claims> parserToken(String token, PublicKey publicKey) { // return Jwts.parserBuilder().build().setSigningKey(publicKey).parseClaimsJws(token); return Jwts.parser().setSigningKey(publicKey).parseClaimsJws(token); } private static String createJTI() { return new String(Base64.getEncoder().encode(UUID.randomUUID().toString().getBytes())); } /** * 获取token中的用户信息 * * @param token 用户请求中的令牌 * @param publicKey 公钥 * @return 用户信息 */ public static <T> Payload<T> getInfoFromToken(String token, PublicKey publicKey, Class<T> userType) { Jws<Claims> claimsJws = parserToken(token, publicKey); Claims body = claimsJws.getBody(); Payload<T> claims = new Payload<>(); claims.setId(body.getId()); claims.setUserInfo(JsonUtils.toBean(body.get(Constant.JWT_PAYLOAD_USER_KEY).toString(), userType)); claims.setExpiration(body.getExpiration()); return claims; } /** * 获取token中的载荷信息 * * @param token 用户请求中的令牌 * @param publicKey 公钥 * @return 用户信息 */ public static <T> Payload<T> getInfoFromToken(String token, PublicKey publicKey) { Jws<Claims> claimsJws = parserToken(token, publicKey); Claims body = claimsJws.getBody(); Payload<T> claims = new Payload<>(); claims.setId(body.getId()); claims.setExpiration(body.getExpiration()); return claims; } }
RsaUtils
生成公钥私钥以及通过路径获取公钥与私钥
注意:
使用generateKey生成公钥与私钥
package com.sicnu.common.utils; import org.apache.commons.io.IOUtils; import org.springframework.core.io.ClassPathResource; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.security.*; import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; import java.util.Base64; /** * @ClassName: RsaUtils * @Description: TODO * @Author: 热爱生活の李 * @Date: 2022/3/30 13:46 */ public class RsaUtils { private static final int DEFAULT_KEY_SIZE = 2048; /* * @MethodName: getPublicKey * @Description: 获取公钥 * @Author: 热爱生活の李 */ public static PublicKey getPublicKey(String filename) throws Exception { byte[] bytes = readFile(filename); return getPublicKey(bytes); } /* * @MethodName: getPrivateKey * @Description:获取私钥 * @Author: 热爱生活の李 */ public static PrivateKey getPrivateKey(String filename) throws Exception { byte[] bytes = readFile(filename); return getPrivateKey(bytes); } private static PublicKey getPublicKey(byte[] bytes) throws Exception { bytes = Base64.getDecoder().decode(bytes); X509EncodedKeySpec spec = new X509EncodedKeySpec(bytes); KeyFactory factory = KeyFactory.getInstance("RSA"); return factory.generatePublic(spec); } private static PrivateKey getPrivateKey(byte[] bytes) throws NoSuchAlgorithmException, InvalidKeySpecException { bytes = Base64.getDecoder().decode(bytes); PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(bytes); KeyFactory factory = KeyFactory.getInstance("RSA"); return factory.generatePrivate(spec); } public static void generateKey(String publicKeyFilename, String privateKeyFilename, String secret, int keySize) throws Exception { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); SecureRandom secureRandom = new SecureRandom(secret.getBytes()); keyPairGenerator.initialize(Math.max(keySize, DEFAULT_KEY_SIZE), secureRandom); KeyPair keyPair = keyPairGenerator.genKeyPair(); byte[] publicKeyBytes = keyPair.getPublic().getEncoded(); publicKeyBytes = Base64.getEncoder().encode(publicKeyBytes); writeFile(publicKeyFilename, publicKeyBytes); byte[] privateKeyBytes = keyPair.getPrivate().getEncoded(); privateKeyBytes = Base64.getEncoder().encode(privateKeyBytes); writeFile(privateKeyFilename, privateKeyBytes); } private static byte[] readFile(String fileName) throws Exception { ClassPathResource classPathResource = new ClassPathResource(fileName); InputStream in =classPathResource.getInputStream(); byte[] bytes = IOUtils.toByteArray(in); return bytes; } private static void writeFile(String destPath, byte[] bytes) throws IOException { File dest = new File(destPath); if (!dest.exists()) { dest.createNewFile(); } Files.write(dest.toPath(), bytes); } }
RsaKeyProperties
将本地的公钥与私钥读取进服务器
注意:
配置的路径是resources路径下的文件
@Data @Configuration public class RsaKeyProperties { @Value("${ky.key.pubKeyPath}") private String pubKeyPath; @Value("${ky.key.priKeyPath}") private String priKeyPath; private PublicKey publicKey; private PrivateKey privateKey; @PostConstruct public void loadKey() throws Exception { publicKey = RsaUtils.getPublicKey(pubKeyPath); privateKey = RsaUtils.getPrivateKey(priKeyPath); } }
创建对应实体以及重写方法
创建实体
我的实体是通过renren-generator的一个项目来自动生成的,这里大佬们可以自己手动写也可以使用renren-generator
重写方法
同理先重写UserDetailsService中的loadUserByUsername方法
注意1
: 这里查询出用户的时候也要查询出对应的角色
注意2
: 官方写角色使用的是SimpleGrantedAuthority,但是我用的就是我们数据库中定义的实体Role,因为SimpleGrantedAuthority这个我最开始使用的时候可以序列化成功,但是不能序列化回来,所以直接使用数据库中角色对应的实体
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 如果为空直接返回null if(username== null ||username.isEmpty()) {return null;} UserEntity user = null; try { // 这里是我调用的我远程查数据库的方法,如果你是本地的话就使用本地方法就行 user = personFeignService.getUserByName(username); }catch (Exception e){ // 远程调用出错 e.printStackTrace(); throw new KyException(CodeEnume.FERIGN_PERSON_SERVICE_EXCEPTION); } if(user == null) {return null;} // 获取角色 // List<SimpleGrantedAuthority> authorities = new ArrayList<>(); // 获取角色 List<RoleEntity> roles = user.getRoles(); // roles.stream().forEach(r->authorities.add(new SimpleGrantedAuthority(r.getName()))); // noop 代表密码没有加密 return new User(username,"{noop}"+user.getPassword(),roles); }
远程查用户的那个方法
因为前端可能传手机号也可能传邮箱登录,所有有两种条件
public UserEntity getUserByPhoneOrEmail(String loginAcct) { QueryWrapper<UserEntity> wrapper = new QueryWrapper<>(); // 判断是那种格式账号登录(手机号 / 邮箱) if(CheckEmailOrPhone.checkMobileNumber(loginAcct)){ wrapper.eq("phone", loginAcct); }else if(CheckEmailOrPhone.checkEmail(loginAcct)){ wrapper.eq("email",loginAcct); }else{ throw new KyException(CodeEnume.USER_ACCOUNT_TYPE_ERROR); } UserEntity user = baseMapper.selectOne(wrapper); // 没有这个用户 if(user == null){ throw new KyException(CodeEnume.USER_NULL); } //该用户被冻结 if(user.getStatus().intValue() == 1){ throw new KyException(CodeEnume.USER_NOT_ACTIVE); } //查询出这个用户的角色 List<UserRoleEntity> userRoleEntities = userRoleService.list(new QueryWrapper<UserRoleEntity>().eq("uid", user.getId())); if(userRoleEntities == null || userRoleEntities.isEmpty()){ throw new KyException(CodeEnume.USER_NO_ROLE); } HashSet<Integer> roleIds = new HashSet<>(); userRoleEntities.stream().forEach(u->roleIds.add(u.getRid())); List<RoleEntity> roles = roleService.listByIds(roleIds); user.setRoles(roles); return user; }
自己编写Filter
首先Filter与Interceptor的区别佬们可以去理解一下
编写登录认证过滤器
编写过滤器实现其中的两个方法
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter { // 认证管理器 private AuthenticationManager authenticationManager; // 这是一个配置类可以获取公钥与私钥路径的 private RsaKeyProperties prop; public TokenLoginFilter(AuthenticationManager authenticationManager, RsaKeyProperties prop) { this.authenticationManager = authenticationManager; this.prop = prop; } }
接收前端来的参数,因为我这个是前后端分离的项目所以重写这个方法
这个方法执行后SpringSecrity就会去执行loadByUsername方法然后将数据库密码与传的密码做比较,感兴趣的大佬可以debug试一下
/** * 接收到并解析前端数据 * @param request * @param res * @return * @throws AuthenticationException */ @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse res) throws AuthenticationException { try { UserEntity user = new ObjectMapper().readValue(request.getInputStream(), UserEntity.class); String username = user.getUsername(); String password = user.getPassword(); // 解密 (这里不用管,因为我这个是前后端数据加密的内容) username= AesEncryptUtils.decrypt(username,"abcdef0123456789"); password=AesEncryptUtils.decrypt(password,"abcdef0123456789"); username = username.substring(1,username.length()-1); password = password.substring(1,password.length()-1); //然后把用户名与密码封装成一个Authentication对象 Authentication authenticate = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, MD5Utils.MD5(password))); return authenticate; }catch (Exception e){ // 这里是可能出现异常,因为这是在Filter中如果直接抛出异常,那么ControllerAdvice是捕获不到的,所有可以这样,但是下面会讲一种自定义SpringSecurity的异常 res.setContentType("application/json;charset=utf-8"); res.setStatus(HttpServletResponse.SC_UNAUTHORIZED); PrintWriter out = null; try { out = res.getWriter(); } catch (IOException ex) { ex.printStackTrace(); } Map<String, Object> map = new HashMap<String, Object>(); map.put("code", HttpServletResponse.SC_UNAUTHORIZED); map.put("message", "账号或密码错误!"); try { out.write(new ObjectMapper().writeValueAsString(map)); } catch (JsonProcessingException ex) { ex.printStackTrace(); } out.flush(); out.close(); throw new KyException(CodeEnume.USER_AUTH_TOKEN_FILTER_ERROR); } }
重写这个Filter里面认证成功的方法
默认是存在Session中的,但是我是微服务,所以我是生成token来实现
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse res, FilterChain chain, Authentication authResult) throws IOException, ServletException { /** * 得到当前认证的用户对象 */ UserEntity user = new UserEntity(); user.setUsername(authResult.getName()); user.setRoles((List<RoleEntity>) authResult.getAuthorities()); //构建Token String token = JwtUtils.generateTokenExpireInMinutes(user, prop.getPrivateKey(), 24 * 60); res.addHeader(Constant.AUTH_TOKEN_HEADER_NAME,Constant.AUTH_TOKEN_HEADER_RESOURCE_START+token); try { //登录成功時,返回json格式进行提示 res.setContentType("application/json;charset=utf-8"); res.setStatus(HttpServletResponse.SC_OK); PrintWriter out = res.getWriter(); Map<String, Object> map = new HashMap<String, Object>(); map.put("code", HttpServletResponse.SC_OK); map.put("message", "登陆成功!"); map.put("token",Constant.AUTH_TOKEN_HEADER_RESOURCE_START+token); out.write(new ObjectMapper().writeValueAsString(map)); out.flush(); out.close(); } catch (Exception e1) { e1.printStackTrace(); } }
编写Token检验的Filter
获取token然后进行校验,如果成功就放到SecurityContextHolder.getContext()中
public class TokenVerifyFilter extends BasicAuthenticationFilter { private RsaKeyProperties prop; public TokenVerifyFilter(AuthenticationManager authenticationManager, RsaKeyProperties prop) { super(authenticationManager); this.prop = prop; } /** * 过滤请求 * @param request * @param response * @param chain * @throws IOException * @throws ServletException */ @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { /** * 保证了有些时候没有token但是session里面有 */ HttpSession session = request.getSession(); UserEntity u = (UserEntity)session.getAttribute(Constant.AUTH_SESSION_USER_NAME); List<SimpleGrantedAuthority> roleNames = new ArrayList<>(); UsernamePasswordAuthenticationToken authenticationToken = null; if(u != null){ List<RoleEntity> roles = u.getRoles(); roles.stream().forEach(role->roleNames.add(new SimpleGrantedAuthority(role.getName()))); authenticationToken = new UsernamePasswordAuthenticationToken(u,null,roleNames); SecurityContextHolder.getContext().setAuthentication(authenticationToken); chain.doFilter(request,response); return; } String token = request.getHeader(Constant.AUTH_TOKEN_HEADER_NAME); // 没有登录 if(token == null || !token.startsWith(Constant.AUTH_TOKEN_HEADER_RESOURCE_START)){ chain.doFilter(request,response); return; } //获取信息 Payload<UserEntity> payload = JwtUtils.getInfoFromToken(token.replace(Constant.AUTH_TOKEN_HEADER_RESOURCE_START, ""), prop.getPublicKey(), UserEntity.class); UserEntity user = payload.getUserInfo(); System.out.println(user.getRoles()); user.getRoles().stream().forEach(role->roleNames.add(new SimpleGrantedAuthority(role.getName()))); if(user != null){ authenticationToken = new UsernamePasswordAuthenticationToken(user, null, roleNames); } SecurityContextHolder.getContext().setAuthentication(authenticationToken); chain.doFilter(request,response); } }
如果使用注解的方式话就可以将这两个Filter注册进去就行了但是我们没有使用注解,所以还需要写两个Filter,一个是FilterInvocationSecurityMetadataSource,一个是AccessDecisionManager
FilterInvocationSecurityMetadataSource
更具Url来获取这个接口所需要的角色
@Component public class MySecurityMetadataSource implements FilterInvocationSecurityMetadataSource { @Autowired private PersonFeignService personFeignService; @Override public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException { FilterInvocation fi = (FilterInvocation) o; // 获取Request HttpServletRequest request = ((FilterInvocation) o).getRequest(); //获取url String requestURI = request.getRequestURI(); List<RoleEntity> roles = null; try { roles = personFeignService.getRolesByUrl(requestURI); }catch (Exception e){ throw new KyException(CodeEnume.FERIGN_PERSON_SERVICE_EXCEPTION); } if(roles == null) {return null;} Set<ConfigAttribute> set = new HashSet<>(); roles.stream().forEach(role->set.add(new SecurityConfig(role.getName()))); return set; } /** * 返回所有定义的权限资源 * @return */ @Override public Collection<ConfigAttribute> getAllConfigAttributes() { return null; } /** * 返回类对象是否支持校验 * @param aClass * @return */ @Override public boolean supports(Class<?> aClass) { return FilterInvocation.class.isAssignableFrom(aClass); } }
编写SpringSecurity的配置类
为了注入上面那些自定义Filter
@Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(securedEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private UserDetailsService userDetailsService; @Autowired private RsaKeyProperties prop; @Autowired private CustomAccessDecisionManager customAccessDecisionManager; @Autowired private MySecurityMetadataSource mySecurityMetadataSource; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService); } @Override protected void configure(HttpSecurity http) throws Exception { http .cors().and().csrf().disable() .authorizeRequests() .antMatchers("/auth/user/test2").permitAll() .anyRequest().authenticated() .withObjectPostProcessor( new ObjectPostProcessor<FilterSecurityInterceptor>() { @Override public <O extends FilterSecurityInterceptor> O postProcess(O o) { o.setSecurityMetadataSource(mySecurityMetadataSource); o.setAccessDecisionManager(customAccessDecisionManager); return o; } } ); http .formLogin() .loginProcessingUrl("/auth/user/login") .permitAll() .and() .addFilter(new TokenLoginFilter(authenticationManager(),prop)) .addFilter(new TokenVerifyFilter(authenticationManager(),prop)) .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); } }
异常处理
前面的处理不是抛异常而是由response返回
所以自定义两个异常类,一个是认证的exception,一个是决策的exception
/** * 自定义异常(拒绝策略) * * @author 热爱生活の李 * @version 1.0 * @since 2022/11/1 13:18 */ public class MyAccessDeniedException extends AccessDeniedException { public MyAccessDeniedException(String msg) { super(msg); } }
/** * 自定义异常(认证) * * @author 热爱生活の李 * @version 1.0 * @since 2022/11/1 13:18 */ public class MyAuthenticationException extends AuthenticationException { public MyAuthenticationException(String msg, Throwable t) { super(msg, t); } }
然后为两个异常配置对应的处理器
@Component public class MyAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { response.setContentType("application/json;charset=utf-8"); response.setStatus(HttpServletResponse.SC_FORBIDDEN); PrintWriter out = response.getWriter(); Map<String, Object> map = new HashMap<String, Object>(); map.put("code", HttpServletResponse.SC_FORBIDDEN); map.put("message", accessDeniedException.getMessage()); out.write(new ObjectMapper().writeValueAsString(map)); out.flush(); out.close(); } }
@Component public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { response.setContentType("application/json;charset=utf-8"); response.setStatus(HttpServletResponse.SC_FORBIDDEN); PrintWriter out = response.getWriter(); Map<String, Object> map = new HashMap<String, Object>(); map.put("code", HttpServletResponse.SC_FORBIDDEN); map.put("message", authException.getMessage()); out.write(new ObjectMapper().writeValueAsString(map)); out.flush(); out.close(); } }
然后编写到SpringSecurity的配置类中
http .exceptionHandling() .authenticationEntryPoint(myAuthenticationEntryPoint) .accessDeniedHandler(myAccessDeniedHandler);
写在最后
希望对大家有帮助,如果存在问题可以联系我