SpringBoot 3.2.1 + SpringSecurity 3.2.1 + JWT 实现登录鉴权

前言

文档已验证, 有问题请留言. 欢迎各位大佬斧正.

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 为系统中的用户信息字段.
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值