赞
踩
Spring Security
已经成为java
后台权限校验的第一选择.今天就通过读代码的方式带大家深入了解一下Security,本文主要是基于开源项目spring-boot-3-jwt-security来讲解Spring Security + JWT(Json Web Token).实现用户鉴权,以及权限校验.
所有代码基于jdk17+
构建.现在让我们开始吧!
Springboot 3.0
Spring Security
Json Web Token(JWT)
BCrypt
Maven
postgresql
数据库来存储用户信息以及Token
(为啥不用Redis?这个先挖个坑),可以按照自己的想法替换成mysql
数据库jpa
,对于一些简单的sql可以根据方法名自动映射,还是很方便的.没使用过的也没关系.不影响阅读今天的文章,后续可以根据自己的实际需求替换成mybatis-lpus
等<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.0.5</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.alibou</groupId> <artifactId>security</artifactId> <version>0.0.1-SNAPSHOT</version> <name>security</name> <description>Demo project for Spring Boot</description> <properties> <java.version>17</java.version> </properties> <dependencies> <!-- jpa --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!-- spring security 安全框架 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!-- web 依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 数据库 --> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency> <!-- lombok --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- JWT --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> </dependency> <!-- doc 这个不需要的可以去掉 --> <dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.1.0</version> </dependency> <!-- 校验 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <excludes> <exclude> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </exclude> </excludes> </configuration> </plugin> </plugins> </build>
Security
依赖后,启动项目会生成一个随机的密码,当我们要访问资源的时候需要使用这个密码登录后才能使用.这会影响我们很多功能的正常使用,比如万恶的swagger
.下面我们来详细了解如何配置我们需要鉴权的路径,以及需要放行的路径@Configuration @EnableWebSecurity @RequiredArgsConstructor @EnableMethodSecurity public class SecurityConfiguration { private final JwtAuthenticationFilter jwtAuthFilter; private final AuthenticationProvider authenticationProvider; private final LogoutHandler logoutHandler; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf() .disable() //关闭csrf(跨域) .authorizeHttpRequests() //配置需要放行的路径 .requestMatchers( "/api/v1/auth/**", "/v2/api-docs", "/v3/api-docs", "/v3/api-docs/**", "/swagger-resources", "/swagger-resources/**", "/configuration/ui", "/configuration/security", "/swagger-ui/**", "/webjars/**", "/swagger-ui.html" ) .permitAll() //放行上述的所有路径 /* * 权限校验(需要登录的用户有指定的权限才可以) * requestMatchers: 指定需要拦截的路径 * hasAnyAuthority: 指定需要的权限 */ .requestMatchers("/api/v1/management/**").hasAnyRole(ADMIN.name(), MANAGER.name()) .requestMatchers(GET, "/api/v1/management/**").hasAnyAuthority(ADMIN_READ.name(), MANAGER_READ.name()) .requestMatchers(POST, "/api/v1/management/**").hasAnyAuthority(ADMIN_CREATE.name(), MANAGER_CREATE.name()) .requestMatchers(PUT, "/api/v1/management/**").hasAnyAuthority(ADMIN_UPDATE.name(), MANAGER_UPDATE.name()) .requestMatchers(DELETE, "/api/v1/management/**").hasAnyAuthority(ADMIN_DELETE.name(), MANAGER_DELETE.name()) .anyRequest() .authenticated() //设置所有的请求都需要验证 .and() .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) //使用无状态Session .and() .authenticationProvider(authenticationProvider) //添加jwt过滤器 .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) //设置logout(当调用这个接口的时候, 会调用logoutHandler的logout方法) .logout() .logoutUrl("/api/v1/auth/logout") .addLogoutHandler(logoutHandler) .logoutSuccessHandler((request, response,authentication) -> SecurityContextHolder.clearContext()) ; return http.build(); } }
jwtAuthFilter
logoutHandler
中的方法上面说到了权限和token
校验,我们先来了解一下登录的逻辑是什么样的.在security
中需要一个UserDetails
类来定义用户账户的行为.这个是用户鉴权的关键.主要有账户,密码,权限,用户状态等等.在下面代码中有详细的注释
@Data @Builder @NoArgsConstructor @AllArgsConstructor @Entity @Table(name = "_user") public class User implements UserDetails { @Id @GeneratedValue private Integer id; //主键ID private String firstname; //名字 private String lastname; //姓氏 private String email; //邮箱 private String password; //密码 /** * 角色枚举 */ @Enumerated(EnumType.STRING) private Role role; /** * 用户关联的Token * 这里面使用了jpa的一对多映射 */ @OneToMany(mappedBy = "user") private List<Token> tokens; /** * 获取用户的权限 * 这里是根据角色枚举的权限来获取的(静态的而非从数据库动态读取) * @return 用户权限列表 */ @Override public Collection<? extends GrantedAuthority> getAuthorities() { return role.getAuthorities(); } /** * 获取用户密码 * 主要是用来指定你的password字段 * @return 用户密码 */ @Override public String getPassword() { return password; } /** * 获取用户账号 * 这里使用email做为账号 * @return 用户账号 */ @Override public String getUsername() { return email; } /** * 账号是否未过期,下面的这个几个方法都是用来指定账号的状态的,因为该项目是一个Demo,所以这里全部返回true * @return true 未过期 */ @Override public boolean isAccountNonExpired() { return true; } /** * 账号是否未锁定 * @return true 未锁定 */ @Override public boolean isAccountNonLocked() { return true; } /** * 密码是否未过期 * @return true 未过期 */ @Override public boolean isCredentialsNonExpired() { return true; } /** * 账号是否激活 * @return true 已激活 */ @Override public boolean isEnabled() { return true; } }
在了解用户实体之后,我们来看一下是怎么来进行登录配置的.如何使用securty
来帮我们管理用户密码的校验.下面我们来看一下security
的整体配置
@Configuration @RequiredArgsConstructor public class ApplicationConfig { /** * 访问用户数据表 */ private final UserRepository repository; /** * 获取用户详情Bean * 根据email查询是否存在用户,如果不存在throw用户未找到异常 */ @Bean public UserDetailsService userDetailsService() { //调用repository的findByEmail方法,来获取用户信息,如果存在则返回,如果不存在则抛出异常 return username -> repository.findByEmail(username) //这里使用的Option的orElseThrow方法,如果存在则返回,如果不存在则抛出异常 .orElseThrow(() -> new UsernameNotFoundException("User not found")); } /** * 身份验证Bean * 传入获取用户信息的bean & 密码加密器 * 可以回看一下SecurityConfiguration中 AuthenticationProvider的配置,使用的就是这里注入到容器中的Bean * 这个bean 主要是用于用户登录时的身份验证,当我们登录的时候security会帮我们调用这个bean的authenticate方法 */ @Bean public AuthenticationProvider authenticationProvider() { DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); //设置获取用户信息的bean authProvider.setUserDetailsService(userDetailsService()); //设置密码加密器 authProvider.setPasswordEncoder(passwordEncoder()); return authProvider; } /** * 身份验证管理器 */ @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } /** * 密码加密器 * 主要是用来指定数据库中存储密码的加密方式,保证密码非明文存储 * 当security需要进行密码校验时,会把请求传进来的密码进行加密,然后和数据库中的密码进行比对 */ @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
上述代码主要做了两件事:
passwordEncoder
现在大家可能会存在一个疑问,security
怎么知道User
实体中那个字段是我的账户,那个字段是我的密码?
不知道大家是否记得UserDetails
类,也就是我们的User
类.其中有两个方法getPassword
& getUsername
.这两个方法返回的就是账号和密码.User
类中的还有几个其他的方法,可以根据我们实际的业务需求来对账号进行禁用
等操作.
token
的生成主要是使用工具包来实现,在本项目中Token中主要存储用户信息
& 用户权限
,下面我们先看一下token
工具包的代码.主要包括为: 生成token
,从token
中获取信息,以及验证token
@Service public class JwtService { /** * 加密盐值 */ @Value("${application.security.jwt.secret-key}") private String secretKey; /** * Token失效时间 */ @Value("${application.security.jwt.expiration}") private long jwtExpiration; /** * Token刷新时间 */ @Value("${application.security.jwt.refresh-token.expiration}") private long refreshExpiration; /** * 从Token中获取Username * @param token Token * @return String */ public String extractUsername(String token) { return extractClaim(token, Claims::getSubject); } /** * 从Token中回去数据,根据传入不同的Function返回不同的数据 * eg: String extractUsername(String token) */ public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) { final Claims claims = extractAllClaims(token); return claimsResolver.apply(claims); } /** * 生成Token无额外信息 */ public String generateToken(UserDetails userDetails) { return generateToken(new HashMap<>(), userDetails); } /** * 生成Token,有额外信息 * @param extraClaims 额外的数据 * @param userDetails 用户信息 * @return String */ public String generateToken( Map<String, Object> extraClaims, UserDetails userDetails ) { return buildToken(extraClaims, userDetails, jwtExpiration); } /** * 生成刷新用的Token * @param userDetails 用户信息 * @return String */ public String generateRefreshToken( UserDetails userDetails ) { return buildToken(new HashMap<>(), userDetails, refreshExpiration); } /** * 构建Token方法 * @param extraClaims 额外信息 * @param userDetails //用户信息 * @param expiration //失效时间 * @return String */ private String buildToken( Map<String, Object> extraClaims, UserDetails userDetails, long expiration ) { return Jwts .builder() .setClaims(extraClaims) //body .setSubject(userDetails.getUsername()) //主题数据 .setIssuedAt(new Date(System.currentTimeMillis())) //设置发布时间 .setExpiration(new Date(System.currentTimeMillis() + expiration)) //设置过期时间 .signWith(getSignInKey(), SignatureAlgorithm.HS256) //设置摘要算法 .compact(); } /** * 验证Token是否有效 * @param token Token * @param userDetails 用户信息 * @return boolean */ public boolean isTokenValid(String token, UserDetails userDetails) { final String username = extractUsername(token); return (username.equals(userDetails.getUsername())) && !isTokenExpired(token); } /** * 判断Token是否过去 */ private boolean isTokenExpired(String token) { return extractExpiration(token).before(new Date()); } /** * 从Token中获取失效时间 */ private Date extractExpiration(String token) { //通用方法,传入一个Function,返回一个T return extractClaim(token, Claims::getExpiration); } /** * 从Token中获取所有数据 */ private Claims extractAllClaims(String token) { return Jwts .parserBuilder() .setSigningKey(getSignInKey()) .build() .parseClaimsJws(token) .getBody(); } /** * 获取签名Key * Token 加密解密使用 */ private Key getSignInKey() { byte[] keyBytes = Decoders.BASE64.decode(secretKey); return Keys.hmacShaKeyFor(keyBytes); } }
token
的生成已经看过了,下面该进入最关键的环节了.用户注册
& 用户登录
passwordEncoder
进行加密).用户信息保存成功后,会根据用户信息创建一个鉴权token
和一个refreshToken
UsernamePasswordAuthenticationToken
对象.然后通过authenticationManager
的authenticate
方法进行校验,如果出现错误会根据错误的不同抛出不同的异常.在实际开发中可以通过捕获的异常类型不同来创建响应提示.@RestController @RequestMapping("/api/v1/auth") @RequiredArgsConstructor public class AuthenticationController { private final AuthenticationService service; /** * 注册方法 * @param request 请求体 * @return ResponseEntity */ @PostMapping("/register") public ResponseEntity<AuthenticationResponse> register( @RequestBody RegisterRequest request ) { return ResponseEntity.ok(service.register(request)); } /** * 鉴权(登录方法) * @param request 请求体 * @return ResponseEntity */ @PostMapping("/authenticate") public ResponseEntity<AuthenticationResponse> authenticate( @RequestBody AuthenticationRequest request ) { return ResponseEntity.ok(service.authenticate(request)); } /** * 刷新token * @param request 请求体 * @param response 响应体 * @throws IOException 异常 */ @PostMapping("/refresh-token") public void refreshToken( HttpServletRequest request, HttpServletResponse response ) throws IOException { service.refreshToken(request, response); } }
可以看出来controller
中的方法都是对service
方法的调用,我们现在看一下service
中的代码
@Service @RequiredArgsConstructor public class AuthenticationService { private final UserRepository repository; //访问user数据库 private final TokenRepository tokenRepository; //访问token数据库 private final PasswordEncoder passwordEncoder; //密码加密器 private final JwtService jwtService; //JWT 相关方法 private final AuthenticationManager authenticationManager; //Spring Security 认证管理器 /** * 注册方法 * @param request 请求体 * @return AuthenticationResponse(自己封装的响应结构) */ public AuthenticationResponse register(RegisterRequest request) { //构建用户信息 var user = User.builder() .firstname(request.getFirstname()) .lastname(request.getLastname()) .email(request.getEmail()) .password(passwordEncoder.encode(request.getPassword())) .role(request.getRole()) .build(); //将用户信息保存到数据库 var savedUser = repository.save(user); //通过JWT方法生成Token var jwtToken = jwtService.generateToken(user); //生成RefreshToken(刷新Token使用) var refreshToken = jwtService.generateRefreshToken(user); //将Token保存到数据库 saveUserToken(savedUser, jwtToken); //返回响应体 return AuthenticationResponse.builder() .accessToken(jwtToken) .refreshToken(refreshToken) .build(); } /** * 鉴权(登录)方法 * @param request 请求体 * @return AuthenticationResponse(自己封装的响应结构) */ public AuthenticationResponse authenticate(AuthenticationRequest request) { //通过Spring Security 认证管理器进行认证 //如果认证失败会抛出异常 eg:BadCredentialsException 密码错误 UsernameNotFoundException 用户不存在 authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( request.getEmail(), request.getPassword() ) ); //通过邮箱查询用户信息,当前项目email就是账号 var user = repository.findByEmail(request.getEmail()) .orElseThrow(); //通过JWT方法生成Token var jwtToken = jwtService.generateToken(user); //生成RefreshToken(刷新Token使用) var refreshToken = jwtService.generateRefreshToken(user); //将之前所有的Token变成失效状态 revokeAllUserTokens(user); //保存新的Token到数据库 saveUserToken(user, jwtToken); //封装响应体 return AuthenticationResponse.builder() .accessToken(jwtToken) .refreshToken(refreshToken) .build(); } /** * 保存用户Token方法 * 构建Token实体后保存到数据库 * @param user 用户信息 * @param jwtToken Token */ private void saveUserToken(User user, String jwtToken) { var token = Token.builder() .user(user) .token(jwtToken) .tokenType(TokenType.BEARER) .expired(false) .revoked(false) .build(); tokenRepository.save(token); } /** * 将用户所有Token变成失效状态 * @param user 用户信息 */ private void revokeAllUserTokens(User user) { //获取用户所有有效的token var validUserTokens = tokenRepository.findAllValidTokenByUser(user.getId()); if (validUserTokens.isEmpty()){ return; } //如果存在还为失效的token,将token置为失效 validUserTokens.forEach(token -> { token.setExpired(true); token.setRevoked(true); }); tokenRepository.saveAll(validUserTokens); } /** * 刷新token方法 * @param request 请求体 * @param response 响应体 * @throws IOException 抛出IO异常 */ public void refreshToken( HttpServletRequest request, HttpServletResponse response ) throws IOException { //从请求头中获取中获取鉴权信息 AUTHORIZATION final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); final String refreshToken; final String userEmail; //如果鉴权信息为空或者不是以Bearer 开头的,直接返回 if (authHeader == null ||!authHeader.startsWith("Bearer ")) { return; } //从鉴权信息中获取RefreshToken refreshToken = authHeader.substring(7); //从RefreshToken中获取用户信息 userEmail = jwtService.extractUsername(refreshToken); if (userEmail != null) { //根据用户信息查询用户,如果用户不存在抛出异常 var user = this.repository.findByEmail(userEmail) .orElseThrow(); //验证Token是否有效 if (jwtService.isTokenValid(refreshToken, user)) { //生成新的Token var accessToken = jwtService.generateToken(user); revokeAllUserTokens(user); saveUserToken(user, accessToken); //生成新的Token和RefreshToken并通过响应体返回 var authResponse = AuthenticationResponse.builder() .accessToken(accessToken) .refreshToken(refreshToken) .build(); new ObjectMapper().writeValue(response.getOutputStream(), authResponse); } } } }
上述代码主要说明了,注册
& 登录
后返回token
的流程,当前项目中由于token
& refreshToken
有效期较长所以选择了将token
保存到数据库(个人观点!!!).可以根据自己业务的实际需求来决定是否需要保存到redis
请求过滤主要是在每次请求的时候动态解析token
来获取用户信息
以及权限
,来保证请求资源的安全性.防止越权访问等.
@Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtService jwtService; private final UserDetailsService userDetailsService; private final TokenRepository tokenRepository; @Override protected void doFilterInternal( @NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain ) throws ServletException, IOException { //判断请求是否为登录请求,如果是登录请求则不进行处理 if (request.getServletPath().contains("/api/v1/auth")) { filterChain.doFilter(request, response); return; } //从请求头中获取鉴权authHeader final String authHeader = request.getHeader("Authorization"); final String jwt; final String userEmail; //如果不存在Token或者Token不已Bearer开头,则不进行处理 if (authHeader == null ||!authHeader.startsWith("Bearer ")) { filterChain.doFilter(request, response); return; } //从authHeader中截取出Token信息 jwt = authHeader.substring(7); //从Token中获取userEmail(账户) userEmail = jwtService.extractUsername(jwt); //SecurityContextHolder 中的 Authentication 为空时,才进行处理 if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) { //获取用户信息 UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail); //从数据库中查询Token并判断Token状态是否正常 var isTokenValid = tokenRepository.findByToken(jwt) .map(t -> !t.isExpired() && !t.isRevoked()) .orElse(false); //如果Token有效,并且Token状态正常,将用户信息存储到SecurityContextHolder if (jwtService.isTokenValid(jwt, userDetails) && isTokenValid) { UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( userDetails, //用户信息 null, userDetails.getAuthorities() //用户的权限 ); authToken.setDetails( new WebAuthenticationDetailsSource().buildDetails(request) //访问信息 ); //将用户信息以及权限保存到 SecurityContextHolder的上下文中,方便后续使用 //eg: 获取当前用户id,获取当前用户权限等等 SecurityContextHolder.getContext().setAuthentication(authToken); } } filterChain.doFilter(request, response); } }
上述代码主要逻辑为: 从请求头中获取到token
.验证token
的有效性并解析token
中的信息存储到SecurityContextHolder
上下文中,方便后续的使用.
登录
以及token
的校验已经说过了,现在就差一个退出登录了.大家是否还记得我们之前配置过一个退出登录
的请求路径: /api/v1/auth/logout
.当我们请求请求这个路径的时候,security
会帮我们找到对应的LogoutHandler
,然后调用logout
方法实现退出登录.
@Service @RequiredArgsConstructor public class LogoutService implements LogoutHandler { private final TokenRepository tokenRepository; @Override public void logout( HttpServletRequest request, HttpServletResponse response, Authentication authentication ) { //从请求头中获取鉴权信息 final String authHeader = request.getHeader("Authorization"); final String jwt; if (authHeader == null ||!authHeader.startsWith("Bearer ")) { return; } //接续出token jwt = authHeader.substring(7); //从数据库中查询出token信息 var storedToken = tokenRepository.findByToken(jwt) .orElse(null); if (storedToken != null) { //设置token过期 storedToken.setExpired(true); storedToken.setRevoked(true); tokenRepository.save(storedToken); //清除SecurityContextHolder上下文 SecurityContextHolder.clearContext(); } } }
security
帮我们做了很多的事情,我们只需要把token
置为失效状态,然后清除掉SecurityContextHolder
上下文,就解决了全部的问题
下面通过几个例子,来讲解两种不同的鉴权配置方式
@RestController @RequestMapping("/api/v1/admin") @PreAuthorize("hasRole('ADMIN')") //用户需要ADMIN角色才能访问 public class AdminController { @GetMapping @PreAuthorize("hasAuthority('admin:read')") //用户需要admin:read权限才能访问 public String get() { return "GET:: admin controller"; } @PostMapping @PreAuthorize("hasAuthority('admin:create')") //用户需要admin:create权限才能访问 @Hidden public String post() { return "POST:: admin controller"; } @PutMapping @PreAuthorize("hasAuthority('admin:update')") @Hidden public String put() { return "PUT:: admin controller"; } @DeleteMapping @PreAuthorize("hasAuthority('admin:delete')") @Hidden public String delete() { return "DELETE:: admin controller"; } }
下面贴出SecurityConfiguration
配置类的部分代码
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。