SpringBoot学习小结之权限控制Shiro

前言

Shiro是 Apache下一个功能强大、灵活的轻量级java开源安全框架,可用来实现用户身份验证、权限授权、session管理和密码加密等功能。下图是Shiro的重点

Shiro_feature

主要概念

  • Authentication: 身份验证,有时也会简称登录
  • Authorization: 授权,即权限验证,验证已登录用户拥有的权限
  • Session Management: 会话管理,shiro自定义了一套session, 即使在非web环境中也能使用session
  • Cryptography: 加密,通过加密算法加密数据,使用户数据更安全

支持功能

  • Web Support: web支持,Shiro能够很容易集成到web环境中
  • Caching: 缓存,主要用于用户,授权等信息的获取。缓存用户,权限信息,减少访问数据库次数,增加访问速度
  • Concurrency: 并发,支持多线程并发权限,即一个线程中开启另一个线程,能够传播权限过去
  • Testing: 测试,测试支持可以帮助编写单元和集成测试,并确保代码如预期一样安全
  • Run As: 允许用户假定另一个用户的身份(如果允许的话)的功能,有时在管理场景中很有用
  • Remember Me: 记住我,很常见功能,用户在登录成功后退出,一段时间内再次访问(使用同一个浏览器)无需再登录

身份验证和授权功能具有可插拔性和灵活性,方便开发人员自定义自己的角色权限规则

shiro和Spring Security对比

ShiroSpring Security
API简单,易于理解和阅读较复杂
依赖环境不依赖于Spring,可以在Java SE和Java EE环境下使用Spring下子项目,依赖Spring,除了Spring其他地方不能用
JSP提供了一套标签库能够在Jsp中使用

一、初步使用

1.1 Pom依赖

	<parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.4.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
	</parent>
    <properties>
        <java.version>1.8</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
            <version>2.1.4.RELEASE</version>
        </dependency>

        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring-boot-web-starter</artifactId>
            <version>1.9.0</version>
        </dependency>

        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>30.0-jre</version>
        </dependency>

        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.19.2</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.47</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

1.2 实体类

@Builder
@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    
    private String username;
    
    private String password;
    
    private String salt;
    
    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
    private LocalDate birthdate;
    
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private LocalDateTime lastLoginTime;

    @Builder.Default
    @ToString.Exclude
    @EqualsAndHashCode.Exclude
    @JsonIgnore
    @ManyToMany
    @JoinTable(name = "user_role",
            joinColumns = {@JoinColumn(name = "user_id", referencedColumnName = "id")},
            inverseJoinColumns = {@JoinColumn(name = "role_id", referencedColumnName = "id")})
    private Set<Role> roles = Sets.newHashSet();
}
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Role {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    private String name;
    
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private LocalDateTime createTime;

    @Builder.Default
    @ToString.Exclude
    @EqualsAndHashCode.Exclude
    @JsonIgnore
    @ManyToMany(mappedBy = "roles")
    private Set<User> users = Sets.newHashSet();

    @Builder.Default
    @ToString.Exclude
    @EqualsAndHashCode.Exclude
    @ManyToMany
    @JoinTable(name = "role_permission",
            joinColumns = {@JoinColumn(name = "role_id", referencedColumnName = "id")},
            inverseJoinColumns = {@JoinColumn(name = "permission_id", referencedColumnName = "id")})
    private Set<Permission> permissions = Sets.newHashSet();

}
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Permission {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    
    private String name;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private LocalDateTime createTime;

    @Builder.Default
    @ToString.Exclude
    @JsonIgnore
    @ManyToMany(mappedBy = "permissions")
    private Set<Role> roles = Sets.newHashSet();
}

1.3 Dao

public interface UserDao extends JpaRepository<User, Integer> {
    User findByUsername(String username);

    @Query("select u from User u join  u.roles r where r.id = :roleId")
    List<User> findUsersByRoleId(@Param("roleId")Integer roleId);

    @Query("select distinct p.name from Permission p left join p.roles r join  r.users u where u.username = :username")
    Set<String> findPermission(@Param("username")String username);
}
public interface RoleDao extends JpaRepository<Role, Integer> {
	Role findRoleByName(String name);
}

public interface PermissionDao extends JpaRepository<Permission, Integer> {
}

1.4 Controller

@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserDao userDao;

    private static final Logger logger = LoggerFactory.getLogger(UserController.class);

    @RequiresPermissions("role:list")
    @GetMapping("/findAllRole")
    public Set<Role> findAllRole(@LoginUser User user){

        logger.info("{}", user.getUsername());
        Optional<User> userOption = userDao.findById(user.getId());
        return userOption.map(User::getRoles).orElse(Sets.newHashSet());
    }
    
    @RequiresPermissions("user:list")
    @GetMapping("/list")
    public ResponseEntity userList() {
        return ResponseEntity.ok(userDao.findAll());
    }

    @PostMapping("/login")
    public ResponseEntity login(@RequestBody User user){

        logger.info("user:{}", user);
        Subject subject = SecurityUtils.getSubject();
        UsernamePasswordToken token = new UsernamePasswordToken(user.getUsername(), user.getPassword());

        try {
            subject.login(token);
        } catch ( UnknownAccountException  uae) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("用户名错误");
        } catch (IncorrectCredentialsException ice) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("密码错误");
        }

        Session session = SecurityUtils.getSubject().getSession();
        session.setAttribute("loginUser", subject.getPrincipal());

        return ResponseEntity.ok().body("登录成功");
    }

    @GetMapping("/unauth")
    public ResponseEntity unauth() {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("未登录");
    }

}

1.5 配置

  1. web配置

    @Configuration
    public class WebConfig extends WebMvcConfigurationSupport {
    
        @Bean
        public LoginUserArgumentResolver loginUserArgumentResolver() {
            return new LoginUserArgumentResolver();
        }
    
        @Bean
        public RequestJsonArgumentResolver jsonPathArgumentResolver() {
            return new RequestJsonArgumentResolver();
        }
    
        @Override
        public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
            argumentResolvers.add(loginUserArgumentResolver());
            argumentResolvers.add(jsonPathArgumentResolver());
            super.addArgumentResolvers(argumentResolvers);
        }
    
    	// 解决RestController只返回字符串中文乱码问题
        @Override
        public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
            List<StringHttpMessageConverter> stringHttpMessageConverters = converters.stream()
                    .filter(converter -> converter.getClass().equals(StringHttpMessageConverter.class))
                    .map(converter -> (StringHttpMessageConverter) converter)
                    .collect(Collectors.toList());
    
            if (stringHttpMessageConverters.isEmpty()) {
                converters.add(new StringHttpMessageConverter(StandardCharsets.UTF_8));
            } else {
                stringHttpMessageConverters.forEach(converter -> converter.setDefaultCharset(StandardCharsets.UTF_8));
            }
        }
    }
    
  2. Shiro配置

    @Configuration
    public class ShiroConfig {
    
        @Bean
        public HashedCredentialsMatcher hashedCredentialsMatcher() {
            HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
            hashedCredentialsMatcher.setHashAlgorithmName(Md5Hash.ALGORITHM_NAME);
            hashedCredentialsMatcher.setHashIterations(1024);
            return hashedCredentialsMatcher;
        }
    
        @Bean
        public Realm authorizer() {
            LoginRealm loginRealm = new LoginRealm();
            loginRealm.setCredentialsMatcher(hashedCredentialsMatcher());
            return loginRealm;
        }
    
        @Bean
        public SessionsSecurityManager securityManager(Realm authorizer){
            return new DefaultWebSecurityManager(authorizer);
        }
    
        @Bean
        public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
            ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
            shiroFilterFactoryBean.setSecurityManager(securityManager);
            //用户未认证登录的页面
            shiroFilterFactoryBean.setLoginUrl("/user/login");
            //用户认证成功登录的页面
            shiroFilterFactoryBean.setSuccessUrl("/user/findAllRole");
            //用户没有该权限的展示页面
            shiroFilterFactoryBean.setUnauthorizedUrl("/user/unauth");
    
            Map<String, String> map = Maps.newHashMap();
    
            map.put("/user/login", "anon");
    
            map.put("/user/**","authc");
    
            shiroFilterFactoryBean.setFilterChainDefinitionMap(map);
            return shiroFilterFactoryBean;
        }
    
        // shiro 注解支持
        @Bean
        public DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator() {
            DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
            advisorAutoProxyCreator.setProxyTargetClass(true);
            return advisorAutoProxyCreator;
        }
    
    }
    
  3. 全局异常处理

    @RestControllerAdvice
    public class GlobalExceptionHandler {
    
        @ExceptionHandler(AuthorizationException.class)
        public ResponseEntity authorizationException() {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("没有权限");
        }
    }
    

1.6 Realm

@Component
public class LoginRealm extends AuthorizingRealm {

    @Autowired
    private UserDao userDao;

    private static final Logger logger = LoggerFactory.getLogger(LoginRealm.class);

    // 鉴权
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        logger.info("开始鉴权:{}", principals);
        SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
        User sessionUser = (User) principals.getPrimaryPrincipal();
        Set<String> collect = userDao.findPermission(sessionUser.getUsername());
        authorizationInfo.setStringPermissions(collect);
        return authorizationInfo;
    }

    // 登录验证
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;

        String username = token.getUsername();

        User user = userDao.findByUsername(username);

        if (user == null) {
            throw new UnknownAccountException();
        }
        String password = user.getPassword();
        String salt = user.getSalt();

        // new Md5Hash(password, salt, 1024);

        return new SimpleAuthenticationInfo(user, password, ByteSource.Util.bytes(salt), getName());

    }
}

1.7 自定义注解LoginUser

实现在Controller层参数注入已登录用户

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterType().isAssignableFrom(User.class) &&
                parameter.hasParameterAnnotation(LoginUser.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return SecurityUtils.getSubject().getPrincipal();
    }
}

1.8 自定义注解RequestJson

用于请求时,除了RequestBody外,可以额外接收json数据

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestJson {
}
public class RequestJsonArgumentResolver implements HandlerMethodArgumentResolver {


    private ObjectMapper objectMapper = new ObjectMapper();


    @Override
    public boolean supportsParameter(MethodParameter parameter) {

        return parameter.hasParameterAnnotation(RequestJson.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {


        HttpServletRequest servletRequest = webRequest.getNativeRequest(HttpServletRequest.class);

        assert servletRequest != null;
        String body = (String)servletRequest.getAttribute("request_body");
        JsonNode jsonNode = objectMapper.readTree(body);
        JsonNode value = jsonNode.findValue(parameter.getParameterName());

        return objectMapper.convertValue(value, parameter.getParameterType());

    }
}

由于RequestBody处理json数据会读取流,而流只能被读取一次,所以需要过滤器复制一下body数据

@Component
@WebFilter(urlPatterns = "/*")
public class CachingRequestBodyFilter implements Filter {
    private static final Logger logger = LoggerFactory.getLogger(CachingRequestBodyFilter.class);

    @Override
    public void init(FilterConfig filterConfig) {
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {
        String contentType = request.getContentType();
        if (!StringUtils.isEmpty(contentType) && contentType.startsWith(MediaType.APPLICATION_JSON_VALUE)) {
            CachingRequestBodyWrapper requestWrapper = new CachingRequestBodyWrapper((HttpServletRequest) request);

            String body = new String(requestWrapper.getBody(), requestWrapper.getCharacterEncoding());

            request.setAttribute("request_body", body);

            chain.doFilter(requestWrapper, response);
        } else {
            chain.doFilter(request, response);
        }

    }

    @Override
    public void destroy() {
    }
}
public class CachingRequestBodyWrapper extends HttpServletRequestWrapper {

    private byte[] body;

    private BufferedReader reader;

    private ServletInputStream inputStream;

    public CachingRequestBodyWrapper(HttpServletRequest request) throws IOException{
        super(request);
        loadBody(request);
    }

    private void loadBody(HttpServletRequest request) throws IOException{
        body = IOUtils.toByteArray(request.getInputStream());
        inputStream = new RequestCachingInputStream(body);
    }

    public byte[] getBody() {
        return body;
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        if (inputStream != null) {
            return inputStream;
        }
        return super.getInputStream();
    }

    @Override
    public BufferedReader getReader() throws IOException {
        if (reader == null) {
            reader = new BufferedReader(new InputStreamReader(inputStream, getCharacterEncoding()));
        }
        return reader;
    }

    private static class RequestCachingInputStream extends ServletInputStream {

        private final ByteArrayInputStream inputStream;

        public RequestCachingInputStream(byte[] bytes) {
            inputStream = new ByteArrayInputStream(bytes);
        }

        @Override
        public int read() {
            return inputStream.read();
        }

        @Override
        public boolean isFinished() {
            return inputStream.available() == 0;
        }

        @Override
        public boolean isReady() {
            return true;
        }

        @Override
        public void setReadListener(ReadListener readlistener) {
        }
    }
}

1.9 测试

@SpringBootTest
//@Transactional
class DemoShiroApplicationTests {

    private static final Logger logger = LoggerFactory.getLogger(DemoShiroApplicationTests.class);

    @Autowired
    private UserDao userDao;

    @Autowired
    private RoleDao roleDao;

    @Autowired
    private PermissionDao permissionDao;

    @Autowired
    AbstractShiroFilter shiroFilter;

    @Autowired
    private WebApplicationContext webApplicationContext;

    @Autowired
    private DefaultSecurityManager securityManager;
    private MockMvc mockMvc;

    @BeforeEach
    void setUp() {
        Permission p1 = Permission.builder().name("user:list").createTime(LocalDateTime.now()).build();
        Permission p2 = Permission.builder().name("user:add").createTime(LocalDateTime.now()).build();
        Permission p3 = Permission.builder().name("role:list").createTime(LocalDateTime.now()).build();

        permissionDao.saveAll(Sets.newHashSet(p1, p2, p3));

        Role r1 = Role.builder().name("管理员").createTime(LocalDateTime.now()).build();
        Role r2 = Role.builder().name("前端组").createTime(LocalDateTime.now()).build();
        Role r3 = Role.builder().name("后端组").createTime(LocalDateTime.now()).build();



        r1.setPermissions(Sets.newHashSet(p1, p2, p3));
        r2.setPermissions(Sets.newHashSet(p1));
        r3.setPermissions(Sets.newHashSet(p1, p2));

        roleDao.saveAll(Sets.newHashSet(r1, r2, r3));


        User u1 = User.builder().username("test").salt("1v3v").password(new Md5Hash("testtest", "1v3v", 1024).toString())
                .birthdate(LocalDate.of(1999, 2, 2)).lastLoginTime(LocalDateTime.now()).valid(true).build();

        User u2 = User.builder().username("admin").salt("qwerty").password(new Md5Hash("adminadmin", "qwerty", 1024).toString())
                .birthdate(LocalDate.of(1998, 1, 1)).lastLoginTime(LocalDateTime.now()).valid(true).build();

        u1.setRoles(Sets.newHashSet(r1, r2));
        u2.setRoles(Sets.newHashSet(r3));
        userDao.saveAll(Sets.newHashSet(u1, u2));

        mockMvc = MockMvcBuilders
                .webAppContextSetup(webApplicationContext)
            	// mockmvc不会经过过滤器拦截器,需要手动添加
                .addFilters(new CachingRequestBodyFilter(), new KickoutSessionControlFilter(), shiroFilter)
                .build();

        SecurityUtils.setSecurityManager(securityManager);
    }

    @Test
    void testUserDao() {
        Set<String> test = userDao.findPermission("admin");
        assertThat(test, is(equalTo(Sets.newHashSet("user:list", "user:add"))));
        logger.info("{}", test);
    }

    @Test
    void testRoleDao() {

        Role role = roleDao.findRoleByName("前端组");
        List<User> users = userDao.findUsersByRoleId(role.getId());

        logger.info("{}", users);
        users.forEach(u -> u.getRoles().remove(role));
        userDao.saveAll(users);

        roleDao.delete(role);
        List<Role> roles = roleDao.findAll();

        assertThat(roles, is(hasSize(2)));
    }

    @Test
    void testShiroLogin() throws Exception {
        String loginSuccess = mockMvc.perform(MockMvcRequestBuilders.post("/user/login")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\n" +
                        "    \"username\": \"test\",\n" +
                        "    \"password\": \"testtest\",\n" +
                        "    \"rememberMe\": false\n" +
                        "}"))
                .andExpect(status().isOk())
//                .andDo(print())
                .andReturn()
                .getResponse()
                .getContentAsString();

        assertThat(loginSuccess, is(equalTo("登录成功")));
    }

}

二、额外功能

2.1 记住我

开启RememberMe功能需要修改几个地方

  1. User类实现序列化

    public class User implements Serializable {
        ...
    }
    
  2. Shiro配置需要加上以下

    @Bean
    public Cookie sessionCookieTemplate() {
        SimpleCookie cookie = new SimpleCookie();
        cookie.setMaxAge(7 * 24 * 60 * 60);
        return cookie;
    }
    
    @Bean
    public CookieRememberMeManager rememberMeManager(Cookie sessionCookieTemplate) {
        CookieRememberMeManager cookieRememberMeManager = new CookieRememberMeManager();
        cookieRememberMeManager.setCookie(sessionCookieTemplate);
        // rememberMe cookie加密的密钥
        // 这个密钥一定要修改为自己的,2016年曾曝出一个反序列化漏洞,就是因为Shiro 1.2.4之前这个秘钥是固定的
        cookieRememberMeManager.setCipherKey(Base64.decode("4AvVh12f32vb3ebLUsEVE21prsdag=="));
        return cookieRememberMeManager;
    }
    
  3. UserController

    login增加一个rememberMe 参数,UsernamePasswordToken也要加上

    public ResponseEntity login(@RequestBody User user, @RequestJson Boolean rememberMe){
    	...
            UsernamePasswordToken token = new UsernamePasswordToken(user.getUsername(), user.getPassword(), rememberMe);
        ...
    }
    

2.2 缓存

由于每次鉴权都会走数据库访问,人数多的话会造成数据库压力,所以需要缓存

开启缓存功能需要在配置文件中修改Realm,添加一个Bean CacheManager

@Bean
public Realm authorizer() {
    LoginRealm loginRealm = new LoginRealm();
    loginRealm.setCredentialsMatcher(hashedCredentialsMatcher());

    loginRealm.setCacheManager(shiroCacheManager());
    loginRealm.setAuthorizationCachingEnabled(true);
    loginRealm.setAuthorizationCacheName("userAuthorizationCache");
    loginRealm.setAuthenticationCachingEnabled(true);
    loginRealm.setAuthenticationCacheName("userAuthenticationCache");

    return loginRealm;
}

@Bean
public CacheManager shiroCacheManager() {
    return new MemoryConstrainedCacheManager();
}

当修改某个用户权限时,这时就应该清空缓存,可以考虑使用aop来实现这个功能

2.3 登录次数控制

登录次数限制这种场景很常见,使用Shiro可以通过密码比较器来实现

  1. User表增加字段private Boolean valid, 用来控制用户账号锁定场景

  2. Reaml 也要增加一个用户被锁定的情况

    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
            UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
    
            String username = token.getUsername();
    
            User user = userDao.findByUsername(username);
    
            if (user == null) {
                throw new UnknownAccountException();
            }
    		// 新增用户被锁定的情况
            if (!user.getValid()) {
                throw new LockedAccountException();
            }
    
            String password = user.getPassword();
            String salt = user.getSalt();
        
            return new SimpleAuthenticationInfo(user, password, ByteSource.Util.bytes(salt), getName());
    
        }
    
  3. 密码比较器

    @Component
    public class RetryLimitHashedCredentialsMatcher extends HashedCredentialsMatcher {
    
        private static final Logger logger = LoggerFactory.getLogger(RetryLimitHashedCredentialsMatcher.class);
    
    
        @Autowired
        private UserDao userDao;
    
        private Cache<String, AtomicInteger> passwordRetryCache;
        
        private static final Integer DEFAULT_RETRY_NUM = 3;
        
        private Integer retryLimitNum = DEFAULT_RETRY_NUM;
    
        public RetryLimitHashedCredentialsMatcher(CacheManager cacheManager) {
            passwordRetryCache = cacheManager.getCache("passwordRetryCache");
        }
        public void setRetryLimitNum(Integer num) {
            this.retryLimitNum = num;
        }
    
        @Override
        public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
            // 获取登录用户的用户名
            String username = (String)token.getPrincipal();
            // 获取用户登录次数
            AtomicInteger retryCount = passwordRetryCache.get(username);
    
            if (retryCount == null) {
                // 如果用户没有登陆过,登陆次数加1 并放入缓存
                retryCount = new AtomicInteger(0);
                passwordRetryCache.put(username, retryCount);
            }
            if (retryCount.incrementAndGet() > retryLimitNum) {
                // 如果用户登陆失败次数大于3次 抛出锁定用户异常  并修改数据库字段
                User user = userDao.findByUsername(username);
                if (user != null){
                    // 修改数据库的状态字段为锁定
                    user.setValid(false);
                    userDao.save(user);
                    logger.info("锁定用户" + user.getUsername());
                }
    
                // 抛出用户锁定异常
                throw new LockedAccountException();
            }
            // 判断用户账号和密码是否正确
            boolean matches = super.doCredentialsMatch(token, info);
            if (matches) {
                // 如果正确,从缓存中将用户登录计数 清除
                passwordRetryCache.remove(username);
            }
            return matches;
        }
    }
    
  4. 修改shiro配置

    @Bean
    public HashedCredentialsMatcher hashedCredentialsMatcher() {
        RetryLimitHashedCredentialsMatcher retryLimitHashedCredentialsMatcher = new RetryLimitHashedCredentialsMatcher(shiroCacheManager());
        retryLimitHashedCredentialsMatcher.setHashAlgorithmName(Md5Hash.ALGORITHM_NAME);
        retryLimitHashedCredentialsMatcher.setHashIterations(1024);
        retryLimitHashedCredentialsMatcher.setRetryLimitNum(5);
        return retryLimitHashedCredentialsMatcher;
    }
    
  5. 增加一个全局异常处理

    @ExceptionHandler(LockedAccountException.class)
    public ResponseEntity LockedAccountException() {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("账户已被锁定,请联系管理员");
    }
    

2.4 登录人数控制

这个场景也很常见,允许一个账户同时在线几个人。shiro可以使用过滤器来实现这个功能

  1. 过滤器KickoutSessionControlFilter

    @Setter
    public class KickoutSessionControlFilter extends AccessControlFilter {
    
        private static final Logger logger = LoggerFactory.getLogger(KickoutSessionControlFilter.class);
    
        // 踢出后到的地址
        private String kickoutUrl;
    
        // 踢出之前登录的或者之后登录的用户, 默认踢出之前登录的用户
        private boolean kickoutAfter = false;
    
        // 同一个帐号最大会话数 默认1
        private int maxSession = 1;
    
        private SessionManager sessionManager;
    
        private Cache<String, Deque<Serializable>> cache;
    
        public void setCacheManager(CacheManager cacheManager) {
            this.cache = cacheManager.getCache("shiro-kickout-session");
        }
    
        @Override
        protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
            return false;
        }
    
        @Override
        protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
            Subject subject = getSubject(request, response);
    
            if (!subject.isAuthenticated() && !subject.isRemembered()) {
                // 如果没有登录,直接进行之后的流程
                return true;
            }
            Session session = subject.getSession();
            String username = subject.getPrincipal().toString();
            Serializable sessionId = session.getId();
    
            // 初始化用户的队列放到缓存里
            Deque<Serializable> deque = cache.get(username);
            if (deque == null) {
                deque = new LinkedList<Serializable>();
                cache.put(username, deque);
            }
    
            // 如果队列里没有此sessionId,且用户没有被踢出;放入队列
            if (!deque.contains(sessionId) && session.getAttribute("kickout") == null) {
                deque.push(sessionId);
                // 将用户的sessionId队列缓存
                cache.put(username, deque);
            }
    
            // 如果队列里的sessionId数超出最大会话数,开始踢人
            while (deque.size() > maxSession) {
                Serializable kickoutSessionId = null;
                // 如果踢出后者
                if (kickoutAfter) {
                    kickoutSessionId = deque.removeFirst();
                } else {
                    // 否则踢出前者
                    kickoutSessionId = deque.removeLast();
                }
                
                try {
                    Session kickoutSession = sessionManager.getSession(new WebSessionKey(kickoutSessionId, request, response));
                    if (kickoutSession != null) {
                        // 设置会话的 kickout 属性表示踢出了
                        kickoutSession.setAttribute("kickout", true);
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
    
            // 如果被踢出了,直接退出,重定向到踢出后的地址
            if (session.getAttribute("kickout") != null) {
                // 会话被踢出了
                try {
                    subject.logout();
                } catch (Exception e) {
                }
                // 这里还可以根据是否ajax请求自定义返回数据
                WebUtils.issueRedirect(request, response, kickoutUrl);
    
                return false;
            }
            return true;
        }
    }
    
  2. 配置文件修改

    @Bean
    public KickoutSessionControlFilter kickoutSessionControlFilter(SessionManager sessionManager){
        KickoutSessionControlFilter kickoutSessionControlFilter = new KickoutSessionControlFilter();
        // 用于根据会话ID,获取会话进行踢出操作的;
        kickoutSessionControlFilter.setSessionManager(sessionManager);
        // 使用cacheManager获取相应的cache来缓存用户登录的会话;用于保存用户—会话之间的关系的;
        kickoutSessionControlFilter.setCacheManager(shiroCacheManager());
        // 是否踢出后来登录的,默认是false;即后者登录的用户踢出前者登录的用户;
        kickoutSessionControlFilter.setKickoutAfter(false);
        // 同一个用户最大的会话数,默认1;比如2的意思是同一个用户允许最多同时两个人登录;
        kickoutSessionControlFilter.setMaxSession(1);
        // 被踢出后重定向到的地址
        kickoutSessionControlFilter.setKickoutUrl("/");
        return kickoutSessionControlFilter;
    }
    
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager, KickoutSessionControlFilter kickoutSessionControlFilter) {
        
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
    	// 增加kickout定义
        LinkedHashMap<String, Filter> filtersMap = new LinkedHashMap<>();
        filtersMap.put("kickout", kickoutSessionControlFilter);
        shiroFilterFactoryBean.setFilters(filtersMap);
    
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        //用户未认证登录的页面
        shiroFilterFactoryBean.setLoginUrl("/user/login");
        //用户认证成功登录的页面
        shiroFilterFactoryBean.setSuccessUrl("/user/findAllRole");
        //用户没有该权限的展示页面
        shiroFilterFactoryBean.setUnauthorizedUrl("/user/unauth");
    
        Map<String, String> map = Maps.newHashMap();
    
        map.put("/user/login", "anon");
        
    	//增加一个kickout过滤器
        map.put("/user/**","kickout, user");
    
        shiroFilterFactoryBean.setFilterChainDefinitionMap(map);
        return shiroFilterFactoryBean;
    }
    

三、源码分析

3.1 login分析

下图是从源码绘制login的流程,看一下shiro是如何实现对用户名和密码验证的登录过程。

从UserController开始,通过Subject调用,经过一系列类,调用到AuthenticatingRealm这个类,这个是LoginRealm继承的类,从源码可以看到会尝试从缓存中取,没有的话会从自定义的LoginRealm中得到info(在LoginRealm会从数据库得到用户账号和密码,盐), 接着会执行assertCredentialsMatch这个方法,开始对用户名密码进行加密验证

UserController DelegatingSubject ModularRealmAuthenticator AuthenticatingRealm LoginRealm RetryLimitHashedCredentialsMatcher login subject.login login(token) doSingleRealmAuthentication getAuthenticationInfo getAuthenticationInfo doGetAuthenticationInfo assertCredentialsMatch doCredentialsMatch UserController DelegatingSubject ModularRealmAuthenticator AuthenticatingRealm LoginRealm RetryLimitHashedCredentialsMatcher login分析

3.2 filter

Shiro是通过过滤器实现用户权限,自带以下过滤器

Filter NameClass含义
anonorg.apache.shiro.web.filter.authc.AnonymousFilter允许立即访问
authcorg.apache.shiro.web.filter.authc.FormAuthenticationFilter要求用户身份验证
authcBasicorg.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter要求用户http basic认证
authcBearerorg.apache.shiro.web.filter.authc.BearerHttpAuthenticationFilter要求用户http bear认证(jwt)
invalidRequestorg.apache.shiro.web.filter.InvalidRequestFilter非法请求过滤,路径包含分号,反斜杠,非ASCII字符即为非法请求
logoutorg.apache.shiro.web.filter.authc.LogoutFilter当前用户退出
noSessionCreationorg.apache.shiro.web.filter.session.NoSessionCreationFilter在请求期间禁用创建新会话
permsorg.apache.shiro.web.filter.authz.PermissionsAuthorizationFilter要求用户有对应权限
portorg.apache.shiro.web.filter.authz.PortFilter要求请求位于特定端口
restorg.apache.shiro.web.filter.authz.HttpMethodPermissionFilter将请求方法GET, POST, PUT, DELETE转化为’create’, edit’, 'delete’等更友好动词映射到请求路径已配置权限上
rolesorg.apache.shiro.web.filter.authz.RolesAuthorizationFilter要求用户有对应角色
sslorg.apache.shiro.web.filter.authz.SslFilterhttps ssl过滤
userorg.apache.shiro.web.filter.authc.UserFilter已验证或通过记住我功能的用户可通过

四、错误及解决方法

  1. 权限始终未进入鉴权方法

    解决方法:经过debug发现,是我的LoginRealm继承的Realm错了,我继承的是AuthenticatingRealm,实际应该是AuthorizingRealm,这两个太像了

  2. 中文乱码

    使用RestController时,只返回字符串的话,不会转为json格式,而是会返回文本,而StringHttpMessageConverter默认使用的编码是ISO_8859_1,所以需要改下编码

    解决方法:见 1.5配置

  3. 测试时发生异常java.lang.IllegalArgumentException: SessionContext must be an HTTP compatible implementation

    经测试发现需要加上shiro的filter

    解决方法:见 1.9测试

参考

  1. https://shiro.apache.org/spring-boot.html
  2. https://shiro.apache.org/documentation.html
  3. 跟我学shiro
  4. [SpringBoot]请求返回字符串中文乱码的解决探讨
  5. springboot + shiro 实现限制用户登录次数
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

aabond

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值