前言
文档已验证, 有问题请留言. 欢迎各位大佬斧正.
Maven依赖
<!-- spring web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.2.1</version>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>3.2.1</version>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.0</version>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<optional>true</optional>
</dependency>
<!-- commons-lang3 工具包 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.13.0</version>
</dependency>
<!-- hutool 工具包 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.25</version>
</dependency>
TokenUtil
package com.trade.util;
import cn.hutool.core.util.IdUtil;
import com.trade.security.UserDetail;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
/**
* Jwt Util
*
* @author Hollis
* @since 2024-01-08 15:55
*/
@Component
public class JwtTokenUtil {
/**
* Token 盐值\密钥, 请注意修改
*/
private final static String SECRET_KEY = "NoteSecret";
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody();
}
private Boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
public String generateToken(UserDetail userDetail) {
Map<String, Object> claims = new HashMap<>();
claims.put("jwtId", IdUtil.getSnowflakeNextId());
claims.put("memberId", userDetail.getMemberId());
claims.put("memberName", userDetail.getMemberName());
claims.put("tenantId", userDetail.getTenantId());
claims.put("tenantName", userDetail.getTenantName());
claims.put("enterpriseId", userDetail.getEnterpriseId());
claims.put("enterpriseName", userDetail.getEnterpriseName());
claims.put("grantedAuthorities", userDetail.getAuthorities());
return createToken(claims, userDetail.getUsername());
}
/**
* 生成 Token
* 注: 自测使用, Token过期时间写死 '1' 天, 业务中可根据实际情况调整
*
* @param claims Token中的业务数据
* @param subject Token中的用户名
* @return {@link String} Token
* @see #generateToken(UserDetail) 方法使用示例
*/
private String createToken(Map<String, Object> claims, String subject) {
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24))
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.compact();
}
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
}
自定义 Security Exception 实现
JwtAccessDeniedHandler - 访问被拒
package com.trade.security.exception;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
/**
* 访问被拒
*
* @author Hollis
* @since 2024-01-08 16:11
*/
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
//当用户在没有授权的情况下访问受保护的REST资源时,将调用此方法发送403 Forbidden响应
response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage());
}
}
JwtAuthenticationEntryPoint - 登录失败
package com.trade.security.exception;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
/**
* 登录失败
*
* @author Hollis
* @since 2024-01-08 16:11
*/
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
}
}
[重点] SpringSecurityConfig
SrpingSecurity 策略配置类
package com.trade.security.config;
import cn.hutool.core.util.StrUtil;
import com.trade.security.JwtUserDetailsService;
import com.trade.security.exception.JwtAccessDeniedHandler;
import com.trade.security.exception.JwtAuthenticationEntryPoint;
import com.trade.security.filter.JwtRequestFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.core.GrantedAuthorityDefaults;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
/**
* Spring Security Configuration
*
* @author Hollis
* @since 2024-01-08 16:11
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private static final Long CORS_MAX_AGE = 3600L;
private static final List<String> CORS_ALLOWED_HEADERS = List.of("*");
private final JwtRequestFilter jwtRequestFilter;
private final JwtUserDetailsService userDetailsServiceImpl;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final AuthenticationConfiguration authenticationConfiguration;
/**
* Spring Security Filter Chain
*
* @param http http
* @return {@link SecurityFilterChain}
* @throws Exception 异常
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
// 解决跨域
.csrf(AbstractHttpConfigurer::disable)
.cors(corsCustomizer -> corsCustomizer.configurationSource(corsConfigurationSource()))
.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 指定 登录鉴权 处理类
.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class)
// 鉴权白名单, 配置绕过鉴权的接口
.authorizeHttpRequests(authorized -> authorized
.requestMatchers("/member/login").permitAll()
.requestMatchers("/member/register").permitAll()
.requestMatchers(HttpMethod.OPTIONS).permitAll()
.anyRequest().authenticated()
)
// 指定 登录鉴权时 查询用户信息的实现类
.userDetailsService(userDetailsServiceImpl)
// 自定义异常处理
.exceptionHandling(exceptionHandling -> exceptionHandling
.accessDeniedHandler(jwtAccessDeniedHandler)
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
)
.build();
}
/**
* 移除用户角色的 ROLE_ 前缀
*
* @return {@link GrantedAuthorityDefaults}
*/
@Bean
GrantedAuthorityDefaults grantedAuthorityDefaults() {
return new GrantedAuthorityDefaults(StrUtil.EMPTY);
}
/**
* 指定用户密码加密方式
*
* @return {@link PasswordEncoder}
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager() throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
/**
* CORS配置源, 解决跨域
*
* @return {@link CorsConfigurationSource}
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowedHeaders(CORS_ALLOWED_HEADERS);
corsConfiguration.setAllowedMethods(CORS_ALLOWED_HEADERS);
corsConfiguration.setAllowedOrigins(CORS_ALLOWED_HEADERS);
corsConfiguration.setMaxAge(CORS_MAX_AGE);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfiguration);
return source;
}
}
[重点] JwtRequestFilter
登录鉴权处理类
package com.trade.security.filter;
import com.trade.util.JwtTokenUtil;
import com.trade.security.JwtUserDetailsService;
import io.jsonwebtoken.ExpiredJwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* 登录过滤器
*
* @author Hollis
* @since 2024-01-08 16:12
*/
@Log4j2
@Component
public class JwtRequestFilter extends OncePerRequestFilter {
public static final String TOKEN_HEAD = "Bearer ";
private static final String AUTHORIZATION = "Authorization";
@Autowired
private JwtUserDetailsService jwtUserDetailsService;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Override
protected void doFilterInternal(@NotNull HttpServletRequest request,
@NotNull HttpServletResponse response,
@NotNull FilterChain filterChain)
throws ServletException, IOException {
// 从请求头中获取 Token 信息
final String token = request.getHeader(AUTHORIZATION);
// 校验 Token 有效性
if (StringUtils.isBlank(token) || !token.startsWith(TOKEN_HEAD)) {
filterChain.doFilter(request, response);
return;
}
String username = null;
// JWT Token is in the form "Bearer token". Remove Bearer word and get only the Token
String jwtToken = token.replace(TOKEN_HEAD, StringUtils.EMPTY);
try {
username = jwtTokenUtil.extractUsername(jwtToken);
} catch (IllegalArgumentException e) {
log.warn("JwtRequestFilter#doFilterInternal Unable to get JWT Token, jwtToken : {}", jwtToken, e);
} catch (ExpiredJwtException e) {
log.warn("JwtRequestFilter#doFilterInternal JWT Token has expired, jwtToken : {}", jwtToken, e);
}
if (StringUtils.isBlank(username)) {
filterChain.doFilter(request, response);
return;
}
// Once we get the token validate it.
UserDetails userDetails = this.jwtUserDetailsService.loadUserByUsername(username);
// if token is valid configure Spring Security to manually set authentication
if (jwtTokenUtil.validateToken(jwtToken, userDetails)) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
usernamePasswordAuthenticationToken
.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// After setting the Authentication in the context, we specify
// that the current user is authenticated. So it passes the Spring Security Configurations successfully.
SecurityContextHolder
.getContext()
.setAuthentication(usernamePasswordAuthenticationToken);
}
filterChain.doFilter(request, response);
}
}
JwtUserDetailsService
package com.trade.security;
import com.trade.domain.dto.member.MemberDetailDTO;
import com.trade.enums.DataStatusEnum;
import com.trade.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Objects;
/**
* Spring Security UserDetailsService impl
*
* @author Hollis
* @since 2023-09-06 11:21
*/
@Component
public class JwtUserDetailsService implements UserDetailsService {
// 测试项目, 直接写死用户角色.实际业务请动态查询替换
private static final List<String> DEF_MEMBER_ROLE = List.of("ROLE_MEMBER");
@Autowired
private MemberService memberService;
@Override
public UserDetails loadUserByUsername(String username) throws AuthenticationException {
// 查询用户信息
MemberDetailDTO member = memberService.queryMemberDetailByMemberCode(username);
if (Objects.isNull(member)) {
throw new UsernameNotFoundException("未查询到会员信息!");
}
// 校验用户状态
DataStatusEnum memberStatus = member.getDataStatus();
if (DataStatusEnum.INVALID.equals(memberStatus)) {
throw new DisabledException("该账号已被禁用, 请联系管理员处理!");
}
return new UserDetail(member, DEF_MEMBER_ROLE);
}
}
UserDetail
Security中的用户信息, 会随业务变更, 仅供参考
package com.trade.security;
import com.trade.domain.dto.member.MemberDetailDTO;
import com.trade.enums.DataStatusEnum;
import lombok.Data;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Spring Security User
*
* @author Hollis
* @since 2023-09-06 11:33
*/
@Data
public class UserDetail implements UserDetails {
/**
* 会员id
*/
private Long memberId;
/**
* 代码
*/
private String memberCode;
/**
* 名称
*/
private String memberName;
/**
* 密码
*/
private String memberPassword;
/**
* 手机号
*/
private String memberMobilePhone;
/**
* 数据状态(0:有效,1:无效)
*/
private DataStatusEnum dataStatus;
/**
* 租户id
*/
private Long tenantId;
/**
* 租户名称
*/
private String tenantName;
/**
* 企业id
*/
private Long enterpriseId;
/**
* 企业名称
*/
private String enterpriseName;
/**
* 用户角色
*/
private Set<GrantedAuthority> grantedAuthorities;
public UserDetail(MemberDetailDTO member, List<String> permissions) {
this.memberId = member.getMemberId();
this.memberCode = member.getMemberCode();
this.memberName = member.getMemberName();
this.memberPassword = member.getMemberPassword();
this.memberMobilePhone = member.getMemberMobilePhone();
this.dataStatus = member.getDataStatus();
this.tenantId = member.getTenantId();
this.tenantName = member.getTenantName();
this.enterpriseId = member.getEnterpriseId();
this.enterpriseName = member.getEnterpriseName();
if (CollectionUtils.isNotEmpty(permissions)) {
this.grantedAuthorities = permissions
.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toSet());
}
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return grantedAuthorities;
}
@Override
public String getPassword() {
return this.memberPassword;
}
@Override
public String getUsername() {
return this.memberCode;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
备注
- 缺失的 MemberDetailDTO 为系统中的用户信息字段.