文章目录
前言
Shiro是 Apache下一个功能强大、灵活的轻量级java开源安全框架,可用来实现用户身份验证、权限授权、session管理和密码加密等功能。下图是Shiro的重点
主要概念
- Authentication: 身份验证,有时也会简称登录
- Authorization: 授权,即权限验证,验证已登录用户拥有的权限
- Session Management: 会话管理,shiro自定义了一套session, 即使在非web环境中也能使用session
- Cryptography: 加密,通过加密算法加密数据,使用户数据更安全
支持功能
- Web Support: web支持,Shiro能够很容易集成到web环境中
- Caching: 缓存,主要用于用户,授权等信息的获取。缓存用户,权限信息,减少访问数据库次数,增加访问速度
- Concurrency: 并发,支持多线程并发权限,即一个线程中开启另一个线程,能够传播权限过去
- Testing: 测试,测试支持可以帮助编写单元和集成测试,并确保代码如预期一样安全
- Run As: 允许用户假定另一个用户的身份(如果允许的话)的功能,有时在管理场景中很有用
- Remember Me: 记住我,很常见功能,用户在登录成功后退出,一段时间内再次访问(使用同一个浏览器)无需再登录
身份验证和授权功能具有可插拔性和灵活性,方便开发人员自定义自己的角色权限规则
shiro和Spring Security对比
Shiro | Spring 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 配置
-
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)); } } }
-
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; } }
-
全局异常处理
@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功能需要修改几个地方
-
User类实现序列化
public class User implements Serializable { ... }
-
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; }
-
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可以通过密码比较器来实现
-
User表增加字段
private Boolean valid
, 用来控制用户账号锁定场景 -
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()); }
-
密码比较器
@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; } }
-
修改shiro配置
@Bean public HashedCredentialsMatcher hashedCredentialsMatcher() { RetryLimitHashedCredentialsMatcher retryLimitHashedCredentialsMatcher = new RetryLimitHashedCredentialsMatcher(shiroCacheManager()); retryLimitHashedCredentialsMatcher.setHashAlgorithmName(Md5Hash.ALGORITHM_NAME); retryLimitHashedCredentialsMatcher.setHashIterations(1024); retryLimitHashedCredentialsMatcher.setRetryLimitNum(5); return retryLimitHashedCredentialsMatcher; }
-
增加一个全局异常处理
@ExceptionHandler(LockedAccountException.class) public ResponseEntity LockedAccountException() { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("账户已被锁定,请联系管理员"); }
2.4 登录人数控制
这个场景也很常见,允许一个账户同时在线几个人。shiro可以使用过滤器来实现这个功能
-
过滤器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; } }
-
配置文件修改
@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
这个方法,开始对用户名密码进行加密验证
3.2 filter
Shiro是通过过滤器实现用户权限,自带以下过滤器
四、错误及解决方法
-
权限始终未进入鉴权方法
解决方法:经过debug发现,是我的LoginRealm继承的Realm错了,我继承的是
AuthenticatingRealm
,实际应该是AuthorizingRealm
,这两个太像了 -
中文乱码
使用RestController时,只返回字符串的话,不会转为json格式,而是会返回文本,而StringHttpMessageConverter默认使用的编码是ISO_8859_1,所以需要改下编码
解决方法:见 1.5配置
-
测试时发生异常
java.lang.IllegalArgumentException: SessionContext must be an HTTP compatible implementation
经测试发现需要加上shiro的filter
解决方法:见 1.9测试