Integrated thinking

ShiroFilter blocks all requests, and Shrio determines which requests need to be authenticated and authorized, and which do not.

If the request accesses public resources of the system, authentication and authorization are not required and ShiroFilter permits the request directly.

If the request accesses restricted resources of the system, authentication is required for the first access. After the authentication is successful, subsequent access is authorized. ShiroFilter relies on the SecurityManager for authentication and authorization, and the SecurityManager relies on the Realm for authentication and authorization.

Public resources do not require authentication or authorization and can be accessed by any user. Similar to login page, registration page.

Restricted resources are resources that can be accessed only after being authenticated and granted permissions. It’s like the system home page, the user home page.

If Shiro is integrating with Spring Cloud, integrate the operations into the Spring Cloud Gateway or Zuul.

Integrate Shiro for authentication

  1. Introduce dependencies in POM.xml

    <dependency>
        <groupId>org.apache.shiro</groupId>
        <artifactId>shiro-spring-boot-starter</artifactId>
        <version>1.5.3</version>
    </dependency>
    Copy the code
  2. Create the factory utility class

    @Component
    public class ApplicationContextUtils implements ApplicationContextAware {
    
        private static ApplicationContext context;
    
        // The factory is the method's argument, which is received when Spring Boot starts
        @Override
        public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
            context = applicationContext;
        }
    
        // Get the specified object in the factory based on the Bean name
        public static Object getBean(String beanName) {
            returnapplicationContext.getBean(beanName); }}Copy the code

    Realm is not hosted by Spring, so you can’t inject Service objects automatically, so you need to create a utility class to get the factory before creating a Realm.

  3. Build the Shiro package and build the Realms package under shiro

  4. Build custom realms in realms packs

    public class UserRealm extends AuthorizingRealm {
    
        @Override
        protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
            return null;
        }
    
        // The authentication operation
        @Override
        protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
            // Get the incoming identity information from the front-end
            String username = (String) token.getPrincipal();
    
            // Get the userService from the factory
            UserSerivce userSerivce = (UserSerivce) ApplicationContextUtils.getBean("userService");
            // Get User from DB based on identity information
            User user = userSerivce.getUserByUsername(username);
    
            // Get the encrypted password and Salt, Shiro automatically authenticate
            if(user ! =null) {
                return new SimpleAuthenticationInfo(username, user.getPassword(), ByteSource.Util.bytes(user.getSalt()), this.getName());
            }
    
            return null; }}Copy the code

    The default Spring factory-managed Bean names are all lowercase and can be specified, for example @service (“userService”).

  5. Create Shiro configuration classes

    @Configuration
    public class ShiroConfig {
    
        / / create ShiroFilter
        @Bean
        public ShiroFilterFactoryBean getShiroFilterFactoryBean(DefaultWebSecurityManager defaultWebSecurityManager) {
            ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
    
            // Inject the SecurityManager into the ShiroFilter
            shiroFilterFactoryBean.setSecurityManager(defaultWebSecurityManager);
    
            // Set the default authentication path. If the authentication fails, this interface is called as a public resource
            shiroFilterFactoryBean.setLoginUrl("/user/login");
    
            // Configure public and restricted resources
            Map<String, String> map = new HashMap<>();
            // Anon is a filter that indicates that the resource is a public resource and needs to be set to authC
            map.put("/user/register"."anon");
            map.put("/user/login"."anon");
            // Authc is a filter that indicates that all resources are restricted except public resources and default authentication paths
            map.put("/ * *"."authc");
    
            shiroFilterFactoryBean.setFilterChainDefinitionMap(map);
    
            return shiroFilterFactoryBean;
        }
    
        // Create a SecurityManager with Web features
        @Bean
        public DefaultWebSecurityManager getDefaultWebSecurityManager(@Qualifier("getRealm") Realm realm) {
            DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
    
            Inject the SecurityManager with a Realm
            defaultWebSecurityManager.setRealm(realm);
    
            return defaultWebSecurityManager;
        }
    
        // Create custom realms
        @Bean
        public Realm getRealm(a) {
            UserRealm userRealm = new UserRealm();
    
            // Set the Hash credential verifier to complete password encryption verification
            HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
            // Set the encryption algorithm MD5
            hashedCredentialsMatcher.setHashAlgorithmName("md5");
            // Set the hash count to 1024
            hashedCredentialsMatcher.setHashIterations(1024);
    
            // Inject credentials to verify the matcher
            userRealm.setCredentialsMatcher(hashedCredentialsMatcher);
    
            returnuserRealm; }}Copy the code

    When Spring injected a custom Realm into SecurityManager, there were already multiple realms in the factory, including system realms and custom realms from Shiro, so it was not known who was injected into SecurityManager. We need to specify the Realm created by the getRealm method below, and the name of the Bean created by the getRealm method is the method name getRealm by default, so we need to put getRealm in @Qualifier to specify the Bean injection.

  6. Design database

    You need to add a salt field to the base of the user table.

  7. Create a utility class that randomly generates Salt

    public class SaltUtils {
    
        /** * Randomly generates Salt * of fixed length@paramThe length of the n *@return Salt
         */
        public static String getSalt(int n) {
            char[] chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789~! @ # $% ^ & * () _ + | {} :., < >? /".toCharArray();
            
            StringBuilder stringBuilder = new StringBuilder();
    
            for (int i = 0; i < n; i++) {
                char c = chars[new Random().nextInt(chars.length)];
                stringBuilder.append(c);
            }
    
            returnstringBuilder.toString(); }}Copy the code
  8. Create a Controller

    By default, DB, MP, and Service are configured and written.

    The following code is written in Controller for the convenience of demonstration, which needs to be extracted into Service during actual development.

    @RestController
    @RequestMapping("/user")
    public class UserContoller {
    
        @Autowired
        private UserService userService;
    
        @PostMapping("register")
        public Response register(@RequestBody UserRegisterDto userRegisterDto) {
            try {
                // Generate an 8-bit Salt
                String salt = SaltUtils.getSalt(8);
                MD5 + Hash + Salt Encrypts the password
                Md5Hash md5Hash = new Md5Hash(userRegisterDto.getPassword(), salt, 1024);
                / / register
                userService.register(userRegisterDto.getUsername(), md5Hash.toHex(), salt);
                // Registration succeeded
                return Response.ok().message("Registration successful");
            } catch (Exception e) {
                return Response.error(ResponseEnum.UNIFIED_ERROR).message("Registration failed"); }}@PostMapping("login")
        public Response login(@RequestBody UserLoginDto userLoginDto) {
            Subject subject = SecurityUtils.getSubject();
    
            try {
                // Shiro automatically authenticate login
                subject.login(new UsernamePasswordToken(userLoginDto.getUsername(), userLoginDto.getPassword()));
                // The authentication succeeded
                return Response.ok().message("Login successful");
            } catch (UnknownAccountException e) {
                return Response.error(ResponseEnum.UNKNOWN_ACCOUNT_ERROR);
            } catch (IncorrectCredentialsException e) {
                returnResponse.error(ResponseEnum.INCORRECT_CREDENTIALS_ERROR); }}@GetMapping("logout")
        public Response login(a) {
            Subject subject = SecurityUtils.getSubject();
    
            subject.logout();
    
            return Response.ok().message("Exit successful"); }}Copy the code

    In a Web environment, as long as the Shiro configuration class is configured with the SecurityManager, Spring hosts it without creating it separately in the Controller.

Shiro filter

Shiro provides multiple default filters that you can use to configure and control permissions for specific urls.

There are two common ones: anon and AuthC.

Filter abbreviation The filter function
anon AnonymousFilter Specifies that the URL can be accessed anonymously without authentication or authorization
authc FormAuthenticationFilter Username, password, rememberMe and other parameters will be obtained from the request by default. If you fail to log in, you will switch to the authentication path configured by setLoginUrl. We could also use this filter as the default login logic, but we usually write the login logic ourselves on the controller because we can customize the information returned by errors.
authcBasic BasicHttpAuthenticationFilter Basic login is required to specify the URL
logout LogoutFilter Logout filter, configure the specified URL can realize the exit function, very convenient
noSessionCreation NoSessionCreationFilter Disabling session creation
perms PermissionsAuthorizationFilter You need to specify permission to access
port PortFilter You need to specify a port for access
rest HttpMethodPermissionFilter Convert the HTTP request method into the corresponding verb to construct a permission string, this feeling is not meaningful, interested in looking at the source code comments
roles RolesAuthorizationFilter You need to specify a role to access
ssl SslFilter An HTTPS request is required for access
user UserFilter You need to be logged in or “Remember me” to access

Integrate Shiro to implement authorization

As mentioned earlier, Shiro provides three ways of authorization. In a system with a separate front and back end, we mainly use annotated authorization. The back-end is only responsible for the write interface to transmit user permission information, and the front-end is responsible for the specific display of the foreground.

1. @ RequiresRoles annotation

This annotation is marked on the interface method to indicate that only the specified role can access the interface.

@GetMapping
@RequiresRoles("admin")
public Response findAll(a) {... }Copy the code

You can also set multiple roles to access the interface only when you have all specified roles.

@GetMapping
@RequiresRoles("admin")
public Response findAll(a) {... }Copy the code

2. @ RequiresPermissions annotation

This annotation is marked on the interface method to indicate that the interface can be accessed only with specified access permission.

@GetMapping
@RequiresPermissions("user:*:*")
public Response findAll(a) {... }Copy the code

You can also set multiple access permissions to access the interface only when you have all specified access permissions.

@GetMapping
@RequiresPermissions(value = {"user:*:*", "product:*:*"})
public Response findAll(a) {... }Copy the code

3. Authorize persistent data

In a real project, the permission data needs to be retrieved in DB, so we need to design the role table and the permission table.

In general, the design is as follows: User < — (* *) — > role, role < — (* *) — > permission, permission < — (1 1) — > resource

  1. Designing user tables

  2. Designing the role sheet

    Table structure:

    Data cases:

    id role
    1418430206598709249 admin
    1418430206598709250 user
  3. Design Permission table

    Table structure:

    Permission indicates the permission identifier, and URL indicates the URL corresponding to the permission identifier.

    Data cases:

    id permission url
    1418430206598709251 user:*:*
    1418430206598709252 user:find:1418430206598709252
  4. Design the user-role table

    Table structure:

  5. Design the role-permission table

    Table structure:

4. Authorization process

  1. Build beans for roles and permissions

    @Data
    public class Role implements Serializable {
        
        private String id;
        
        private String role;
        
    }
    Copy the code
    @Data
    public class Permission implements Serializable {
    
        private String id;
    
        private String permission;
    
        private String url;
    
    }
    Copy the code

    All beans must be serialized because the Bean will be stored in Redis later.

  2. Add a collection of roles in the User class

    @Data
    public class User implements Serializable {
    
        private String id;
    
        private String username;
    
        private String password;
    
        private String salt;
    
        private List<String> roles;
    
    }
    Copy the code
  3. Add a collection of permissions to the Role class

    @Data
    public class Role implements Serializable {
        
        private String id;
        
        private String role;
        
        List<Permission> permissions;
        
    }
    Copy the code
  4. An interface is provided in UserMapper and UserService to query the set of roles of a single user through username

    Specific use logic implementation using MP or Mybatis different, you can choose.

    List<Role> getRolesByUsername(String username);
    Copy the code
  5. An interface is provided in UserMapper and UserService to query the permission set of a single role by role_id

    List<Permission> getPermissionsByRoleId(String roleId);
    Copy the code

    Specific use logic implementation using MP or Mybatis different, you can choose.

  6. Methods for integrating authorization in Realm

    public class UserRealm extends AuthorizingRealm {
    
        // Authorize the operation
        @Override
        protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
            // Get the identity information
            String username = (String) principals.getPrimaryPrincipal();
    
            // Fetch UserService from the factory
            UserService userService = (UserService) ApplicationContextUtils.getBean("userService");
    
            // Inject roles and permissions for this role
            List<Role> roles = userService.getRolesByUsername(username);
            if(roles ! =null) {
                SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
                roles.forEach(role -> {
                    // Inject roles
                    simpleAuthorizationInfo.addRole(role.getRole());
    
                    // Get permission set
                    List<Permission> permissions = userService.getPermissionsByRoleId(role.getId());
                    // You can also use this method to determine whether the collection is not empty
                    if(! CollectionUtils.isEmpty(permissions)) { permissions.forEach(permission -> {// Inject permissionsimpleAuthorizationInfo.addStringPermission(permission.getPermission()); }); }});return simpleAuthorizationInfo;
            }
            
            return null;
        }
    
        @Override
        protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
            // Get the incoming identity information from the front-end
            String username = (String) token.getPrincipal();
    
            / / get userService
            UserSerivce userSerivce = (UserSerivce) ApplicationContextUtils.getBean("userService");
            // Get User from DB based on identity information
            User user = userSerivce.getUserByUsername(username);
    
            // Get the encrypted password and Salt, Shiro automatically authenticate
            if(user ! =null) {
                return new SimpleAuthenticationInfo(username, user.getPassword(), ByteSource.Util.bytes(user.getSalt()), this.getName());
            }
    
            return null; }}Copy the code
  7. Add roles to the Controller interface

    @GetMapping("info")
    @RequiresRoles(value = {"admin", "user"})
    public Response info(a) {... }Copy the code
  8. Add permission to the Controller interface

    @GetMapping("info")
    @RequiresPermissions(value = {"user:find:*", "admin:*:*"})
    public Response info(a) {... }Copy the code

Redis cache integration

In real front-end development, we use annotations heavily to control permissions. Each time an authentication or authorization operation is performed, Shiro queries the DB for identity or permission information. It is known that identity information and authority information is not often changed, and very complex. If many users operate the system at the same time, Shiro needs to query the identity or permission in the DB for each operation, which undoubtedly increases the pressure on the database and consumes a lot of computing resources.

To avoid these problems, we add caches when designing identities and permissions.

The so-called cache means that if the system has authenticated or authorized the user once, it will cache the identity information or permission information of the user. When the user is authenticated or authorized again, Shiro will directly obtain the identity information and permission information of the user from the cache.

1. Implementation process

Shiro provides CacheManager as the CacheManager. The implementation process is as follows

2. Implementation

Shiro’s default cache is EhCache, which can only be cached locally. If the application server goes down, the cached data is lost. In actual production practice, Redis is generally used to realize distributed cache, and the cached data is independent from the application server to improve data security.

In this article, Shiro and EhCache integration will not be described, directly integrate Redis.

  1. Introduce dependencies in POM.xml

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    Copy the code
  2. Configure Redis in application.yml

    Spring:
      .
      # Redis configuration
      redis:
        port: 6379
        host: localhost
        database: 0
    Copy the code
  3. Create a cache package in the Shiro package

  4. Create the Redis cache manager in the cache package

    public class RedisCacheManager implements CacheManager {
    
        // This method is called each time the cache is executed, injecting s automatically
        The s parameter is the name of the authentication cache or authorization cache set in ShiroConfig
        @Override
        public <K, V> Cache<K, V> getCache(String s) throws CacheException {
            // Automatically go to RedisCahce to find the implementation
            return newRedisCache<K, V>(s); }}Copy the code

    Shiro provides a global CacheManager interface called CacheManager. To implement a custom CacheManager, you must enable the custom CacheManager to implement the CacheManager interface.

  5. Create the Reids cache in the cache package

    public class RedisCache<K.V> implements Cache<K.V> {
    
        // Authentication cache or authorization cache name name
        private String cacheName;
    
        public RedisCache(a) {}public RedisCache(String cacheName) {
            this.cacheName = cacheName;
        }
    
        // Get the RedisTemplate instance
        private RedisTemplate getRedisTemplate(a) {
            // Retrieve the RedisTemplate instance from the factory
            RedisTemplate redisTemplate = (RedisTemplate) ApplicationContextUtils.getBean("redisTemplate");
            // Set the serialization rule for Key to a string
            redisTemplate.setKeySerializer(new StringRedisSerializer());
            // Set the serialization rule for field in the Hash to a string
            redisTemplate.setHashKeySerializer(new StringRedisSerializer());
    
            return redisTemplate;
        }
    
        // Get the cache
        @Override
        public V get(K k) throws CacheException {
            return (V) getRedisTemplate().opsForHash().get(this.cacheName, k.toString())
        }
    
        // Cache
        @Override
        public V put(K k, V v) throws CacheException {
            getRedisTemplate().opsForHash().put(this.cacheName, k.toString(), v);
    
            return null;
        }
    
        // Delete the cache
        @Override
        public V remove(K k) throws CacheException {
            return (V) getRedisTemplate().opsForHash().delete(this.cacheName, k.toString());;
        }
    
        // Clear all caches
        @Override
        public void clear(a) throws CacheException {
            getRedisTemplate().delete(this.cacheName);
        }
    
        // Number of caches
        @Override
        public int size(a) {
            return getRedisTemplate().opsForHash().size(this.cacheName).intValue();
        }
    
        // Get all keys
        @Override
        public Set<K> keys(a) {
            return getRedisTemplate().opsForHash().keys(this.cacheName);
        }
    
        // Get all values
        @Override
        public Collection<V> values(a) {
            return getRedisTemplate().opsForHash().values(this.cacheName); }}Copy the code

    CacheManager implements Cache<K, V>, so you need to create a RedisCache to implement custom Cache. RedisCache also implements the Cache interface.

    Redis is used to implement Shiro’s integration with Redis. Shiro decides when to call the RedisCache interface.

    Redis uses a Hash data structure to manage Shiro’s identity and permissions. Key corresponds to cacheName, field corresponds to K, and value corresponds to V.

  6. Configure the cache manager in ShiroConfig

    @Configuration
    public class ShiroConfig {
    
        / / create ShiroFilter
        @Bean
        public ShiroFilterFactoryBean getShiroFilterFactoryBean(DefaultWebSecurityManager defaultWebSecurityManager) {... }// Create a SecurityManager with Web features
        @Bean
        public DefaultWebSecurityManager getDefaultWebSecurityManager(@Qualifier("getRealm") Realm realm) {... }// Create custom realms
        @Bean
        public Realm getRealm(a) {...// Inject the cache manager
            userRealm.setCacheManager(new RedisCacheManager());
            // Enable global caching
            userRealm.setCachingEnabled(true);
            // enable the authentication cache and name it (the real authentication cacheName is cacheName)
            userRealm.setAuthenticationCachingEnabled(true);
            userRealm.setAuthenticationCacheName("authenticationCache");
            // Enable authorization cache and name it (real authorization cache is the full package name +cacheName)
            userRealm.setAuthorizationCachingEnabled(true);
            userRealm.setAuthorizationCacheName("authorizationCache");
    
            returnuserRealm; }}Copy the code
  7. Serialize and deserialize Salt

    In the configuration above, the Salt is stored directly by ByteSource and is not serialized.

    // Get the encrypted password and Salt, Shiro automatically authenticate
    if(user ! =null) {
        return new SimpleAuthenticationInfo(username, user.getPassword(), ByteSource.Util.bytes(user.getSalt()), this.getName());
    }
    Copy the code

    During Shiro authentication, Salt is also cached along with Username and Password. Username and Password are serialized and deserialized by String, and Salt (ByteSource) also needs to be serialized and deserialized.

    Create a salt package in the Shiro package, and create a ByteSource that can be serialized and deserialized by Redis

    public class MyByteSource implements ByteSource.Serializable {
    
        private byte[] bytes;
        private String cachedHex;
        private String cachedBase64;
    
        public MyByteSource(a) {}public MyByteSource(byte[] bytes) {
            this.bytes = bytes;
        }
    
        public MyByteSource(char[] chars) {
            this.bytes = CodecSupport.toBytes(chars);
        }
    
        public MyByteSource(String string) {
            this.bytes = CodecSupport.toBytes(string);
        }
    
        public MyByteSource(ByteSource source) {
            this.bytes = source.getBytes();
        }
    
        public MyByteSource(File file) {
            this.bytes = (new MyByteSource.BytesHelper()).getBytes(file);
        }
    
        public MyByteSource(InputStream stream) {
            this.bytes = (new MyByteSource.BytesHelper()).getBytes(stream);
        }
    
        public static boolean isCompatible(Object o) {
            return o instanceof byte[] || o instanceof char[] || o instanceof String || o instanceof ByteSource || o instanceof File || o instanceof InputStream;
        }
    
        @Override
        public byte[] getBytes() {
            return this.bytes;
        }
    
        @Override
        public boolean isEmpty(a) {
            return this.bytes == null || this.bytes.length == 0;
        }
    
        @Override
        public String toHex(a) {
            if (this.cachedHex == null) {
                this.cachedHex = Hex.encodeToString(this.getBytes());
            }
    
            return this.cachedHex;
        }
    
        @Override
        public String toBase64(a) {
            if (this.cachedBase64 == null) {
                this.cachedBase64 = Base64.encodeToString(this.getBytes());
            }
    
            return this.cachedBase64;
        }
    
        @Override
        public String toString(a) {
            return this.toBase64();
        }
    
        @Override
        public int hashCode(a) {
            return this.bytes ! =null && this.bytes.length ! =0 ? Arrays.hashCode(this.bytes) : 0;
        }
    
        @Override
        public boolean equals(Object o) {
            if (o == this) {
                return true;
            } else if (o instanceof ByteSource) {
                ByteSource bs = (ByteSource)o;
                return Arrays.equals(this.getBytes(), bs.getBytes());
            } else {
                return false; }}private static final class BytesHelper extends CodecSupport {
            private BytesHelper(a) {}public byte[] getBytes(File file) {
                return this.toBytes(file);
            }
    
            public byte[] getBytes(InputStream stream) {
                return this.toBytes(stream); }}}Copy the code

    Note that MyByteSource cannot inherit from SimpleByteSource, because SimpleByteSource has no no-argument construction and can only be serialized, not deserialized, because when Salt is deserialized by Redis, A parameterless construct of MyByteSource needs to be called, so MyByteSource can only implement ByteSource.

    Change the ByteSource used for authentication

    // Get the encrypted password and Salt, Shiro automatically authenticate
    if(user ! =null) {
        return new SimpleAuthenticationInfo(username, user.getPassword(), new MyByteSource(user.getSalt()), this.getName());
    }
    Copy the code