赞
踩
Spring Security
已经成为java
后台权限校验的第一选择.今天就通过读代码的方式带大家深入了解一下Security,本文主要是基于开源项目spring-boot-3-jwt-security[1]来讲解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
等
本文使用了Lombok来生成固定的模版代码
- <parent>
- <groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-starter-parent</artifactId>
- <version>3.0.5</version>
- <relativePath/>
- </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>
-
- <dependency>
- <groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-starter-data-jpa</artifactId>
- </dependency>
-
- <dependency>
- <groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-starter-security</artifactId>
- </dependency>
-
- <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>
-
- <dependency>
- <groupId>org.projectlombok</groupId>
- <artifactId>lombok</artifactId>
- <optional>true</optional>
- </dependency>
-
- <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>
-
-
- <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()
- .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)
- .and()
- .authenticationProvider(authenticationProvider)
-
- .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
-
- .logout()
- .logoutUrl("/api/v1/auth/logout")
- .addLogoutHandler(logoutHandler)
- .logoutSuccessHandler((request, response,authentication) -> SecurityContextHolder.clearContext())
- ;
-
- return http.build();
- }
- }
-
放行不需要鉴权的路径(注册&登录,swagger)
配置访问特定的接口用户需要的权限.(比如想要删除用户必须要有删除用户的权限)
添加前置过滤器,用来从Token中判断用户是否合法和获取用户权限: jwtAuthFilter
配置退出登录的Handler,以及监听的路径.当访问这个路径的时候会自动调用logoutHandler
中的方法
上面说到了权限和token
校验,我们先来了解一下登录的逻辑是什么样的.在security
中需要一个UserDetails
类来定义用户账户的行为.这个是用户鉴权的关键.主要有账户,密码,权限,用户状态等等.在下面代码中有详细的注释
- @Data
- @Builder
- @NoArgsConstructor
- @AllArgsConstructor
- @Entity
- @Table(name = "_user")
- public class User implements UserDetails {
-
- @Id
- @GeneratedValue
- private Integer 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() {
-
- return username -> repository.findByEmail(username)
-
- .orElseThrow(() -> new UsernameNotFoundException("User not found"));
- }
-
-
- * 身份验证Bean
- * 传入获取用户信息的bean & 密码加密器
- * 可以回看一下SecurityConfiguration中 AuthenticationProvider的配置,使用的就是这里注入到容器中的Bean
- * 这个bean 主要是用于用户登录时的身份验证,当我们登录的时候security会帮我们调用这个bean的authenticate方法
- */
- @Bean
- public AuthenticationProvider authenticationProvider() {
- DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
-
- 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)
- .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) {
-
- 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;
- private final TokenRepository tokenRepository;
- private final PasswordEncoder passwordEncoder;
- private final JwtService jwtService;
- private final AuthenticationManager authenticationManager;
-
-
- * 注册方法
- * @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);
-
- var jwtToken = jwtService.generateToken(user);
-
- var refreshToken = jwtService.generateRefreshToken(user);
-
- saveUserToken(savedUser, jwtToken);
-
- return AuthenticationResponse.builder()
- .accessToken(jwtToken)
- .refreshToken(refreshToken)
- .build();
- }
-
-
- * 鉴权(登录)方法
- * @param request 请求体
- * @return AuthenticationResponse(自己封装的响应结构)
- */
- public AuthenticationResponse authenticate(AuthenticationRequest request) {
-
-
- authenticationManager.authenticate(
- new UsernamePasswordAuthenticationToken(
- request.getEmail(),
- request.getPassword()
- )
- );
-
- var user = repository.findByEmail(request.getEmail())
- .orElseThrow();
-
- var jwtToken = jwtService.generateToken(user);
-
- var refreshToken = jwtService.generateRefreshToken(user);
-
- revokeAllUserTokens(user);
-
- 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) {
-
- var validUserTokens = tokenRepository.findAllValidTokenByUser(user.getId());
- if (validUserTokens.isEmpty()){
- return;
- }
-
- 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 {
-
- final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
- final String refreshToken;
- final String userEmail;
-
- if (authHeader == null ||!authHeader.startsWith("Bearer ")) {
- return;
- }
-
- refreshToken = authHeader.substring(7);
-
- userEmail = jwtService.extractUsername(refreshToken);
- if (userEmail != null) {
-
- var user = this.repository.findByEmail(userEmail)
- .orElseThrow();
-
-
- if (jwtService.isTokenValid(refreshToken, user)) {
-
- var accessToken = jwtService.generateToken(user);
- revokeAllUserTokens(user);
- saveUserToken(user, accessToken);
-
- 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;
- }
-
- final String authHeader = request.getHeader("Authorization");
- final String jwt;
- final String userEmail;
-
-
- if (authHeader == null ||!authHeader.startsWith("Bearer ")) {
- filterChain.doFilter(request, response);
- return;
- }
-
- jwt = authHeader.substring(7);
-
- userEmail = jwtService.extractUsername(jwt);
-
- if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
-
- UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail);
-
-
- var isTokenValid = tokenRepository.findByToken(jwt)
- .map(t -> !t.isExpired() && !t.isRevoked())
- .orElse(false);
-
-
- if (jwtService.isTokenValid(jwt, userDetails) && isTokenValid) {
- UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
- userDetails,
- null,
- userDetails.getAuthorities()
- );
- authToken.setDetails(
- new WebAuthenticationDetailsSource().buildDetails(request)
- );
-
-
- 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;
- }
-
- jwt = authHeader.substring(7);
-
- var storedToken = tokenRepository.findByToken(jwt)
- .orElse(null);
- if (storedToken != null) {
-
- storedToken.setExpired(true);
- storedToken.setRevoked(true);
- tokenRepository.save(storedToken);
-
- SecurityContextHolder.clearContext();
- }
- }
- }
-
security
帮我们做了很多的事情,我们只需要把token
置为失效状态,然后清除掉SecurityContextHolder
上下文,就解决了全部的问题
下面通过几个例子,来讲解两种不同的鉴权配置方式
- @RestController
- @RequestMapping("/api/v1/admin")
- @PreAuthorize("hasRole('ADMIN')")
- public class AdminController {
-
- @GetMapping
- @PreAuthorize("hasAuthority('admin:read')")
- public String get() {
- return "GET:: admin controller";
- }
- @PostMapping
- @PreAuthorize("hasAuthority('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
配置类的部分代码
[1]
https://github.com/ali-bouali/spring-boot-3-jwt-security: https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Fali-bouali%2Fspring-boot-3-jwt-security
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。