1 写在之前
本博客主要使用Spring Boot 整合Spring Security + JWT实现权限管理,利用JWT工具生成token,返回给登录接口。在访问其他接口时,采用Bearer Token的方式携带登录时获取的token进行验证,token验证通过,到达ctrl层的对应接口,验证失败,返回401错误。登录与验证的流程如下。
登录流程
接口验证流程
接下来介绍最核心的继承WebSecurityConfigurerAdapter类的配置类SysSecurityConfig,然后我会根据登录流程,接口验证流程,登出流程依次讲解本系统部分代码,整个系统代码请到github获取。
2.系统总体概览
2.1 代码层次结构
上图展示了本项目的全部核心代码类, 除了经典的ctrl, service, model, dao层以外,项目还加入一些自定义的异常(PermissionDeniedException),封装的返回结果(BaseResponse<T>),自定义返回的状态码(ResultCode)以及各种handler处理器。
1.2 代码依赖配置
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.1.RELEASE</version>
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<org.springframework.boot.version>2.3.1.RELEASE</org.springframework.boot.version>
<org.apache.common.lang3.version>3.12.0</org.apache.common.lang3.version>
<org.apache.common.beanutils.version>1.9.4</org.apache.common.beanutils.version>
<com.baomidou.mybatis-plus.version>3.4.3</com.baomidou.mybatis-plus.version>
<mysql.connector.java.version>8.0.12</mysql.connector.java.version>
<jjwt-version>0.9.0</jjwt-version>
<com.alibaba.fastjson.version>1.2.76</com.alibaba.fastjson.version>
<!--Lombok-->
<lombok.version>1.18.10</lombok.version>
<commons-io.version>2.6</commons-io.version>
<javadoc.version>3.0.0</javadoc.version>
<maven-release-plugin.version>2.5.3</maven-release-plugin.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${org.springframework.boot.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>${org.apache.common.lang3.version}</version>
</dependency>
<dependency>
<groupId>commons-beanutils</groupId>
<artifactId>commons-beanutils</artifactId>
<version>${org.apache.common.beanutils.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${com.baomidou.mybatis-plus.version}</version>
</dependency>
<!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.connector.java.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>${jjwt-version}</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>${com.alibaba.fastjson.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>commons-beanutils</groupId>
<artifactId>commons-beanutils</artifactId>
</dependency>
//关于数据库的连接以及持久层框架本项目实际并没有用到
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
//jwt工具库
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
</dependency>
</dependencies>
2.系统最核心配置代码讲解
2.1 Spring Security 权限控制核心配置类
/**
* @program: authority-management-sys
* @author: zgr
* @create: 2021-07-25 20:52
**/
@Configuration
@EnableWebSecurity
public class SysSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private final MyAuthenticationEntryPoint unauthorizedHandler;
@Autowired
private final AccessDeniedHandler accessDeniedHandler;
@Autowired
private final AuthenticationTokenFilter authenticationTokenFilter;
@Autowired
private final SysLogoutHandler sysLogoutHandler;
@Autowired
private final SysLogoutSuccessHandler sysLogoutSuccessHandler;
@Autowired
private SysUserService sysUserService;
@Autowired
public SysSecurityConfig(MyAuthenticationEntryPoint unauthorizedHandler,
@Qualifier("RestAuthenticationAccessDeniedHandler") AccessDeniedHandler accessDeniedHandler, AuthenticationTokenFilter authenticationTokenFilter, SysLogoutHandler sysLogoutHandler, SysLogoutSuccessHandler sysLogoutSuccessHandler) {
this.unauthorizedHandler = unauthorizedHandler;
this.accessDeniedHandler = accessDeniedHandler;
this.authenticationTokenFilter = authenticationTokenFilter;
this.sysLogoutHandler = sysLogoutHandler;
this.sysLogoutSuccessHandler = sysLogoutSuccessHandler;
}
/**
* 解决 无法直接注入 AuthenticationManager
*
* @return
* @throws Exception
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
/**
* 这里是对认证管理器的添加配置,添加自定义的用户查询服务
*
* @param auth
* @throws Exception
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(sysUserService).passwordEncoder(new BCryptPasswordEncoder());
;
}
/**
* 配置不需要安全验证的接口地址
*
* @param web
*/
@Override
public void configure(WebSecurity web) {
//配置允许匿名访问的接口,比如swagger地址,系统文档地址
web.ignoring().antMatchers("/success/logout-page");
}
/**
* 安全请求配置,这里配置的是security的部分,这里配置全部通过,安全拦截在资源服务的配置文件中配置,
* 要不然访问未验证的接口将重定向到登录页面,前后端分离的情况下这样并不友好,无权访问接口返回相关错误信息即可
*
* @param http
* @return void
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.logout().addLogoutHandler(sysLogoutHandler).logoutSuccessHandler(sysLogoutSuccessHandler)
.and()
// 由于使用的是JWT,我们这里不需要csrf
.csrf().disable()
// 权限不足处理类
.exceptionHandling().accessDeniedHandler(accessDeniedHandler).and()
// 认证失败处理类
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeRequests()
// 对于登录login要允许匿名访问
.antMatchers(HttpMethod.POST, "/login").permitAll()
// 访问接口的测试 需要拥有admin权限,实际环境中,可以配置在filter中进行权限的验证
.antMatchers("/test/page").hasAuthority("admin")
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
// 禁用缓存
http.headers().cacheControl();
http.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
}
2.2 jwt工具类
**
* @program: authority-management-sys
* @author: zgr
* @create: 2021-07-25 21:06
**/
@Component
@Slf4j
public class JwtTokenUtil {
private Map<String, String> tokenMap = new ConcurrentHashMap<>(32);
private Date generateExpirationDate(Long expiration) {
return new Date(System.currentTimeMillis() + expiration);
}
/**
* 生成令牌
*
* @param userDetail 用户
* @return 令牌
*/
public String generateAccessToken(SysUserDetails userDetail) {
Map<String, Object> claims = generateClaims(userDetail);
return generateAccessToken(userDetail.getUsername(), claims);
}
public boolean checkToken(String userName, String token) {
/*1.token对比,是否存在
*2.token是否过期,过期应该重新登录
**/
Long expirationTime = getExpirationTime(token);
return userName != null
&& tokenMap.containsKey(userName)
&& tokenMap.get(userName).equals(token)
&& expirationTime != null
&& expirationTime > System.currentTimeMillis();
}
public void putToken(String userName, String token) {
tokenMap.put(userName, token);
}
public void deleteToken(String userName) {
tokenMap.remove(userName);
}
/**
* 生成token
*
* @param subject 用户名
* @param claims
* @return
*/
private String generateAccessToken(String subject, Map<String, Object> claims) {
return generateToken(subject, claims);
}
/**
* 根据token 获取用户信息
*
* @param token
* @return
*/
public SysUserDetails getUserDetails(String token) {
SysUserDetails userDetail;
try {
final Claims claims = getClaims(token);
Integer userId = Integer.parseInt(claims.get(Constant.CLAIM_KEY_USER_ID).toString());
String username = getUsername(token);
String roleName = claims.get(Constant.CLAIM_KEY_AUTHORITIES).toString();
Role role = Role.builder().name(roleName).build();
userDetail = new SysUserDetails(userId, username, null, role);
log.info("user details {}", userDetail.toString());
} catch (Exception e) {
log.error("获取用户详情出错", e);
userDetail = null;
}
return userDetail;
}
/**
* 生成token
*
* @param subject 用户名
* @param claims 声明
* @return
*/
private String generateToken(String subject, Map<String, Object> claims) {
return Jwts.builder()
.setClaims(claims)
// sub(Subject):代表这个JWT的主体,即它的所有人,这个是一个json格式的字符串,可以存放什么userid,roldid之类的,作为什么用户的唯一标志。
.setSubject(subject)
// 设置jti(JWT ID):是JWT的唯一标识,根据业务需要,这个可以设置为一个不重复的值,主要用来作为一次性token,从而回避重放攻击。
.setId(UUID.randomUUID().toString())
// iat: jwt的签发时间
.setIssuedAt(new Date())
//有效时长
.setExpiration(generateExpirationDate(Constant.ACCESS_TOKEN_EXPIRATION))
//压缩格式
.compressWith(CompressionCodecs.DEFLATE)
//secret在实际使用中可以做成配置项
.signWith(Constant.SIGNATURE_ALGORITHM, Constant.JWT_SECRET)
.compact();
}
/**
* 根据token 获取用户名
*
* @param token
* @return
*/
public String getUsername(String token) {
String username;
try {
final Claims claims = getClaims(token);
username = claims.getSubject();
} catch (Exception e) {
log.error("获取用户名出错", e);
username = null;
}
return username;
}
public Long getExpirationTime(String token) {
Long expirationTime;
try {
final Claims claims = getClaims(token);
expirationTime = claims.getExpiration().getTime();
} catch (Exception e) {
expirationTime = null;
}
return expirationTime;
}
/**
* 根据token 获取用户ID
*
* @param token
* @return
*/
private Integer getUserId(String token) {
Integer userId;
try {
final Claims claims = getClaims(token);
userId = Integer.parseInt((String) claims.get(Constant.CLAIM_KEY_USER_ID));
} catch (Exception e) {
userId = null;
}
return userId;
}
/***
* 解析token 信息
* @param token
* @return
*/
private Claims getClaims(String token) {
Claims claims;
try {
claims = Jwts.parser()
.setSigningKey(Constant.JWT_SECRET)
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
claims = null;
}
return claims;
}
private Map<String, Object> generateClaims(SysUserDetails userDetail) {
Map<String, Object> userDetails = new HashMap<>(16);
userDetails.put(Constant.CLAIM_KEY_USER_ID, userDetail.getId());
userDetails.put(Constant.CLAIM_KEY_AUTHORITIES, authoritiesToArray(userDetail.getAuthorities()).get(0));
return userDetails;
}
private List authoritiesToArray(Collection<? extends GrantedAuthority> authorities) {
List<String> list = new ArrayList<>();
for (GrantedAuthority ga : authorities) {
list.add(ga.getAuthority());
}
return list;
}
}
3.登录流程代码详解
登录流程文字描述:开启登录--->进入AuthenticationTokenFilter(直接跳过)--->进入ctrl层的LoginController中的login方法--->进入service逻辑层,依据用户名查找用户,组装用户详情,生成对应token--->接口返回token。ctrl层的没有任何逻辑,所以本节只展示比较重要的AuthenticationTokenFilter以及逻辑层的代码。
3.1 过滤器AuthenticationTokenFilter
此处说一下个人理解,AuthenticationTokenFilter是所有的访问接口的请求都会经过的,一般的接口,filter过滤通过,不能生成对应的Authentication放入SecurityContextHolder.getContext().setAuthentication()中,且在2系统最核心配置代码讲解中没有允许其匿名访问,那么接口就会返回权限不足的信息。
/**
* @program: authority-management-sys
* @author: zgr
* @create: 2021-07-26 12:02
**/
@Slf4j
@Component
public class AuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
if (!request.getRequestURL().toString().contains(Constant.LOGIN_URL)) {
//取出token
String token = request.getHeader(Constant.TOKEN_HEADER);
if (StringUtils.isNotEmpty(token) && token.startsWith(Constant.TOKEN_STARTER)) {
token = token.substring(Constant.TOKEN_STARTER.length()).trim();
} else {
token = null;
}
//关于token形式验证通过,验证token的内容
String username = jwtTokenUtil.getUsername(token);
if (username != null && jwtTokenUtil.checkToken(username, token) && SecurityContextHolder.getContext().getAuthentication() == null) {
log.info("{} access the {} API", username, request.getRequestURL());
SysUserDetails userDetails = jwtTokenUtil.getUserDetails(token);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
log.info(String.format("Authenticated userDetail %s, setting security context", username));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
chain.doFilter(request, response);
}
}
3.2 service查找用户生成token
/**
* @program: authority-management-sys
* @author: zgr
* @create: 2021-07-26 10:20
**/
@Service
@Slf4j
public class LoginServiceImpl implements LoginService {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Override
public String loginSys(LoginUserEntity loginUser) {
final Authentication authentication = authenticate(loginUser.getUsername(), loginUser.getPassword());
//存储认证信息
SecurityContextHolder.getContext().setAuthentication(authentication);
//生成token
log.info("{} 登录,生成token", loginUser.getUsername());
final SysUserDetails userDetail = (SysUserDetails) authentication.getPrincipal();
final String token = jwtTokenUtil.generateAccessToken(userDetail);
//存储token
jwtTokenUtil.putToken(loginUser.getUsername(), token);
return token;
}
private Authentication authenticate(String username, String password) {
try {
// 该方法会去调用userDetailsService.loadUserByUsername()去验证用户名和密码,
// 如果正确,则存储该用户名密码到security 的 context中
return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
} catch (DisabledException | BadCredentialsException e) {
throw new PermissionDeniedException("用户名或密码错误,请重新登录");
}
}
}
3.3 依据用户名查找用户,生成对应UserDetails
@Service
@Slf4j
public class SysUserServiceImpl implements SysUserService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("根据用户名{}查询用户信息", username);
//此处实际可以从数据库中查找对应的用户名
User user;
if (username.equals("admin")) {
user = User.builder()
.id(1)
.username("admin")
.password("$2a$10$blrIf6.vDYUAGbq.8fk2heScZYVgMl8lFAUWvPi1aZ9aiCar3pALe")
.test("this is test")
.build();
}else {
throw new UsernameNotFoundException(username + "不存在");
}
Role role = Role.builder()
.id(1)
.name("admin")
.build();
//这里权限列表,这个为方便直接下(实际开发中查询用户时连表查询出权限)
return new SysUserDetails(user.getId(), user.getUsername(), user.getPassword(), role);
}
}
3.4 登录结果展示
3.4.1 正确登录结果展示
3.4.2 用户名错误登录结果展示
3.4.3 密码错误登录结果展示
4.接口权限验证
访问接口--->进入AuthenticationTokenFilter,1.验证token的有效性,2.从token中取出信息组装成Authentication放入SecurityContextHolder.getContext().setAuthentication()中,验证通过--->访问对应ctrl层中的接口。
4.1 ctrl层中的接口
@GetMapping("/test/page")
public BaseResponse<String> testPage() {
return BaseResponse.success("test page");
}
4.2 验证结果展示
4.2.1 正确验证结果展示
4.2.2 不带token验证结果展示
4.2.3 token错误验证结果展示
5 退出系统
通过Spring Security 自带的LogoutFilter来执行,通过自定义的继承LogoutHandler类SysLogoutHandler处理登出逻辑,通过自定义的继承LogoutSuccessHandler类SysLogoutSuccessHandler实现登出成功后的逻辑。
5.1继承LogoutHandler类SysLogoutHandler
/**
* @program: authority-management-sys
* @author: zgr
* @create: 2021-07-29 10:46
**/
@Configuration
@Slf4j
public class SysLogoutHandler implements LogoutHandler {
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
//是用户登出具体逻辑的实现,可以记录用户下线的时间,ip,以下为删除token的逻辑
String token = request.getHeader(Constant.TOKEN_HEADER).substring(Constant.TOKEN_STARTER.length());
String username = jwtTokenUtil.getUsername(token);
if (username == null) {
throw new PermissionDeniedException(ResultCode.UN_AUTHORIZED);
}
jwtTokenUtil.deleteToken(username);
}
}
5.2 继承LogoutSuccessHandler类SysLogoutSuccessHandler
/**
* @program: authority-management-sys
* @author: zgr
* @create: 2021-07-29 11:08
**/
@Configuration
@Slf4j
public class SysLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
//登出成功的执行的逻辑
ResultUtil.writeResponse(httpServletResponse, ResultCode.SUCCESS, ResultCode.SUCCESS.getMsg());
}
}
5.3 登出结果展示
登录系统获取token--->访问验证接口--->退出系统--->再次访问验证接口
5.3.1 登录系统获取token结果展示
5.3.2 访问验证接口结果展示
5.3.3 登出系统结果展示
5.3.4 再次访问验证接口展示
6 写在最后
以上只是对系统关键代码的一个展示,有很多引用的工具类代码,最关键的token的生成验证以及从token中获取用户信息没有展示,想要代码可以找到最后的GitHub地址进行代码的clone。
代码目前还是实验阶段,离生产应用还有一段距离,比如用户查找没有与当下流行的基于角色的权限管理系统(RBAC)结合起来。在token的存储上,后期可以放在redis中进行缓存,微服务系统在进行token权限验证的时候直接访问redis做验证。
本文是对自己学习经验的一个总结,总觉得能写出来的东西才是自己掌握的东西,有很多不对或遗漏之处请指出,不尽感激。
7 引用
1.Spring Security(一)--Architecture Overview
4.Spring Security(四)--核心过滤器源码分析
5.Spring Security(五)--动手实现一个IP_Login
6.Spring Boot整合实战Spring Security JWT权限鉴权系统
8 本文对应的GitHub地址
GitHub - airhonor/authority-management-sys: 基于spring-security和jwt实现基于角色的权限管理系统