SpringBoot 整合 Spring Security 、JWT 实现认证、权限控制

此文章用到的版本

yaml 复制代码
spring-boot : 2.6.8
java 1.8

引入依赖包(gradle) maven 请自行转换

java 复制代码
dependencies {
    compile group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
}

先说说原理

UsernamePasswordAuthenticationFilter 继承 AbstractAuthenticationProcessingFilter

AbstractAuthenticationProcessingFilter.doFilter() 会执行 抽象方法attemptAuthentication ()

通过观察发现 UsernamePasswordAuthenticationFilter 会拦截 POST /login 的请求

然后通过会通过Http parameter 获取 username 和 password 参数的值执行鉴权认证

java 复制代码
	public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";

	public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";

	private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login",
			"POST");

	private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;

	private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;

	private boolean postOnly = true;

	public UsernamePasswordAuthenticationFilter() {
		super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
	}

	public UsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
		super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
	}

	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
			throws AuthenticationException {
		if (this.postOnly && !request.getMethod().equals("POST")) {
			throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
		}
		String username = obtainUsername(request);
		username = (username != null) ? username : "";
		username = username.trim();
		String password = obtainPassword(request);
		password = (password != null) ? password : "";
		UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
		// Allow subclasses to set the "details" property
		setDetails(request, authRequest);
		return this.getAuthenticationManager().authenticate(authRequest);
	}

	@Nullable
	protected String obtainUsername(HttpServletRequest request) {
		return request.getParameter(this.usernameParameter);
	}

成功会执行 SavedRequestAwareAuthenticationSuccessHandler 重定向到指定url

java 复制代码
public class SavedRequestAwareAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

	protected final Log logger = LogFactory.getLog(this.getClass());

	private RequestCache requestCache = new HttpSessionRequestCache();

	@Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication authentication) throws ServletException, IOException {
		SavedRequest savedRequest = this.requestCache.getRequest(request, response);
		if (savedRequest == null) {
			super.onAuthenticationSuccess(request, response, authentication);
			return;
		}
		String targetUrlParameter = getTargetUrlParameter();
		if (isAlwaysUseDefaultTargetUrl()
				|| (targetUrlParameter != null && StringUtils.hasText(request.getParameter(targetUrlParameter)))) {
			this.requestCache.removeRequest(request, response);
			super.onAuthenticationSuccess(request, response, authentication);
			return;
		}
		clearAuthenticationAttributes(request);
		// Use the DefaultSavedRequest URL
		String targetUrl = savedRequest.getRedirectUrl();
		getRedirectStrategy().sendRedirect(request, response, targetUrl);
	}

	public void setRequestCache(RequestCache requestCache) {
		this.requestCache = requestCache;
	}

}

失败会执行 SimpleUrlAuthenticationFailureHandler

java 复制代码
	@Override
	public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException exception) throws IOException, ServletException {
		if (this.defaultFailureUrl == null) {
			if (this.logger.isTraceEnabled()) {
				this.logger.trace("Sending 401 Unauthorized error since no failure URL is set");
			}
			else {
				this.logger.debug("Sending 401 Unauthorized error");
			}
			response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
			return;
		}
		saveException(request, exception);
		if (this.forwardToDestination) {
			this.logger.debug("Forwarding to " + this.defaultFailureUrl);
			request.getRequestDispatcher(this.defaultFailureUrl).forward(request, response);
		}
		else {
			this.redirectStrategy.sendRedirect(request, response, this.defaultFailureUrl);
		}
	}

这种方式不兼容json方式提交的登录 而且不能返回token 供前端使用 所以我们需要改造此Filter

实现思路:

  1. 拦截Post /login 请求

  2. 获取请求中的body参数 username 以及password

  3. 返回 UsernamePasswordAuthenticationFilter 不携带权限集合

  4. 重写 UserDetailsService 查询数据库

  5. 重写 AuthenticationSuccessHandler 登录成功后返回jwt token令牌

  6. 重写 AuthenticationFailureHandler 失败返回失败原因 例如:密码错误,账户锁定, 账户不存在

定义token常量

java 复制代码
public class SecurityConstants
{
    public static final long EXPIRATION_TIME = 864_000_000; // 10 days
    public static final String TOKEN_PREFIX = "Bearer ";
    public static final String HEADER_STRING = "Authorization";

    private SecurityConstants()
    {
        throw new IllegalStateException("Utility class");
    }
}

实现工具类 JWTUtils 用户生成、解析token

java 复制代码
@Component
public class JwtUtil
{

    /**
     * 签名用的密钥
     */
    private static String signKey = "nacl";


    /**
     * 用户登录成功后生成Jwt
     * 使用Hs256算法
     *
     * @param exp jwt过期时间
     * @param claims 保存在Payload(有效载荷)中的内容
     * @return token字符串
     */
    public static String createJWT(Date exp, Map<String, Object> claims)
    {
        //指定签名的时候使用的签名算法
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

        //生成JWT的时间
        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis);

        //创建一个JwtBuilder,设置jwt的body
        JwtBuilder builder = Jwts.builder()
            //保存在Payload(有效载荷)中的内容
            .setClaims(claims)
            //iat: jwt的签发时间
            .setIssuedAt(now)
            //设置过期时间
            .setExpiration(exp)
            //设置签名使用的签名算法和签名使用的秘钥
            .signWith(signatureAlgorithm, signKey);

        return builder.compact();
    }

    /**
     * 解析token,获取到Payload(有效载荷)中的内容,包括验证签名,判断是否过期
     *
     * @param token
     * @return
     */
    public static Claims parseJWT(String token)
    {
        //得到DefaultJwtParser
        return Jwts.parser()
            //设置签名的秘钥
            .setSigningKey(signKey)
            //设置需要解析的token
            .parseClaimsJws(token).getBody();
    }

}

开始实现身份认证过滤器(JWTAuthenticationFilter) 继承 UsernamePasswordAuthenticationFilter 重写 attemptAuthentication 方法

java 复制代码
public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter
{


    @Override
    public Authentication attemptAuthentication(HttpServletRequest req,
                                                HttpServletResponse res)
    {
        Map<String, String> creds = new HashMap<>();
        try
        {
            creds = new ObjectMapper().readValue(req.getInputStream(), Map.class); // 获取body中的参数
        } catch (IOException e)
        {
            e.printStackTrace();
        }
        return this.getAuthenticationManager().authenticate(
            new UsernamePasswordAuthenticationToken(
                creds.get("username"),
                creds.get("password"),
                new ArrayList<>())
        );
    }
}

重写UserDetailService

返回一个测试用户 用户名:123 密码:123 角色权限: ROLE_ADMIN

java 复制代码
@Service
public class UserDetailsService implements org.springframework.security.core.userdetails.UserDetailsService
{

    @Override
    public UserDetails loadUserByUsername(String username)
    {
        Collection<SimpleGrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN")); // 设定权限
        return new User(
            "123", // username
            new BCryptPasswordEncoder().encode("123") , // password
            true, // enabled -- set to true if the user is enabled
            true, // accountNonExpired -- set to true if the account has not expired
            true, // credentialsNonExpired -- set to true if the credentials have not expired
            true, // accountNonLocked -- set to true if the account is not locked
            authorities // authorities -- the authorities that should be granted to the caller if they presented the
        );
    }
}

重写 AuthenticationSuccessHandler 成功后将生成的 token 放入 response header

java 复制代码
@Component
public class CustomAuthenticateSuccessHandler implements AuthenticationSuccessHandler
{

    @Override
    public void onAuthenticationSuccess(
        HttpServletRequest request,
        HttpServletResponse response,
        Authentication auth) throws IOException, ServletException
    {
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", ((User) auth.getPrincipal()).getUsername());

        String token = JwtUtil.createJWT(
            new Date(System.currentTimeMillis() + SecurityConstants.EXPIRATION_TIME),
            claims
        );

        response.addHeader(SecurityConstants.HEADER_STRING, SecurityConstants.TOKEN_PREFIX + token);
    }
}

重写 AuthenticationFailureHandler 返回账户失败原因

java 复制代码
@Component
public class CustomAuthenticateFailureHandler implements AuthenticationFailureHandler
{
    @Override
    public void onAuthenticationFailure(
        HttpServletRequest request,
        HttpServletResponse response,
        AuthenticationException failed
    ) throws IOException, ServletException
    {
        String returnData = "";
        // 账号过期
        if (failed instanceof AccountExpiredException)
        {
            returnData = "账号过期";
        }
        // 密码错误
        else if (failed instanceof BadCredentialsException)
        {
            returnData = "密码错误";
        }
        // 密码过期
        else if (failed instanceof CredentialsExpiredException)
        {
            returnData = "密码过期";
        }
        // 账号不可用
        else if (failed instanceof DisabledException)
        {
            returnData = "账号不可用";
        }
        //账号锁定
        else if (failed instanceof LockedException)
        {
            returnData = "账号锁定";
        }
        // 用户不存在
        else if (failed instanceof InternalAuthenticationServiceException)
        {
            returnData = "用户不存在";
        }
        // 其他错误
        else
        {
            returnData = "未知异常";
        }

        // 处理编码方式 防止中文乱码
        response.setContentType("text/json;charset=utf-8");
        // 将反馈塞到HttpServletResponse中返回给前台
        response.getWriter().write(returnData);
    }
}

改造BasicAuthenticationFilter基于JWT解析 实现权限认证

认证过滤器 BasicAuthenticationFilter

header里头有Authorization,而且value是以Basic开头的,则走BasicAuthenticationFilter,提取参数构造UsernamePasswordAuthenticationToken进行认证,成功则填充SecurityContextHolder的Authentication

而我们要做的是 header里头有Authorization,而且value是以Bearer开头的, 解析jwt token填充SecurityContextHolder的Authentication

java 复制代码
  @Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		try {
			UsernamePasswordAuthenticationToken authRequest = this.authenticationConverter.convert(request);
			if (authRequest == null) {
				this.logger.trace("Did not process authentication request since failed to find "
						+ "username and password in Basic Authorization header");
				chain.doFilter(request, response);
				return;
			}
			String username = authRequest.getName();
			this.logger.trace(LogMessage.format("Found username '%s' in Basic Authorization header", username));
			if (authenticationIsRequired(username)) {
				Authentication authResult = this.authenticationManager.authenticate(authRequest);
				SecurityContextHolder.getContext().setAuthentication(authResult);
				if (this.logger.isDebugEnabled()) {
					this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
				}
				this.rememberMeServices.loginSuccess(request, response, authResult);
				onSuccessfulAuthentication(request, response, authResult);
			}
		}
		catch (AuthenticationException ex) {
			SecurityContextHolder.clearContext();
			this.logger.debug("Failed to process authentication request", ex);
			this.rememberMeServices.loginFail(request, response);
			onUnsuccessfulAuthentication(request, response, ex);
			if (this.ignoreFailure) {
				chain.doFilter(request, response);
			}
			else {
				this.authenticationEntryPoint.commence(request, response, ex);
			}
			return;
		}

		chain.doFilter(request, response);
	}

  @Override
	public UsernamePasswordAuthenticationToken convert(HttpServletRequest request) {
		String header = request.getHeader(HttpHeaders.AUTHORIZATION);
		if (header == null) {
			return null;
		}
		header = header.trim();
		if (!StringUtils.startsWithIgnoreCase(header, AUTHENTICATION_SCHEME_BASIC)) {
			return null;
		}
		if (header.equalsIgnoreCase(AUTHENTICATION_SCHEME_BASIC)) {
			throw new BadCredentialsException("Empty basic authentication token");
		}
		byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8);
		byte[] decoded = decode(base64Token);
		String token = new String(decoded, getCredentialsCharset(request));
		int delim = token.indexOf(":");
		if (delim == -1) {
			throw new BadCredentialsException("Invalid basic authentication token");
		}
		UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(token.substring(0, delim),
				token.substring(delim + 1));
		result.setDetails(this.authenticationDetailsSource.buildDetails(request));
		return result;
	}

开始实现 继承BasicAuthenticationFilter 并重写 doFilterInternal 方法

getAuthentication 获取 request header中的token 解析成username 并调用Userservice中的loadUserByName方法返回User鉴权信息

装入SecurityContextHolder

java 复制代码
public class JWTAuthorizationFilter extends BasicAuthenticationFilter
{

    public JWTAuthorizationFilter(AuthenticationManager authManager)
    {
        super(authManager);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse res,
                                    FilterChain chain) throws IOException, ServletException
    {
        String header = req.getHeader(SecurityConstants.HEADER_STRING);

        if (header == null || !header.startsWith(SecurityConstants.TOKEN_PREFIX))
        {
            chain.doFilter(req, res);
            return;
        }
        try
        {
            UsernamePasswordAuthenticationToken authentication = getAuthentication(req);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            chain.doFilter(req, res);
        } catch (ExpiredJwtException e)
        {
            res.getWriter().write("token expired");
        } catch (JwtException e)
        {
            res.getWriter().write("token invalid");
        }

    }

    private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request)
    {
        String token = request.getHeader(SecurityConstants.HEADER_STRING);
        if (token != null)
        {
            // parse the token.
            Claims claims = JwtUtil.parseJWT(token.replace(SecurityConstants.TOKEN_PREFIX, ""));

            if (claims != null)
            {
                UserDetailsService userDetailsService = new UserDetailsService();
                User u = (User) userDetailsService.loadUserByUsername((String) claims.get("username"));
                return new UsernamePasswordAuthenticationToken(u.getUsername(), u.getPassword(), u.getAuthorities());
            }
        }
        return null;
    }
}

CustomAccessDeniedHandler 非匿名下的错误拦截器

java 复制代码
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler
{
    @Override
    public void handle(
        HttpServletRequest request,
        HttpServletResponse response,
        AccessDeniedException accessDeniedException) throws IOException, ServletException
    {
        response.setContentType("text/json;charset=utf-8");
        response.getWriter().write("权限错误");
    }
}

CustomAuthenticationEntryPoint 匿名下的错误拦截器

java 复制代码
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint
{
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException
    {
        response.getWriter().write("no login");
    }
}

OK 准备大功告成, 最后设定一下Spring Security的配置

java 复制代码
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter
{

    private final UserDetailsService userDetailsService;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    private final CustomAccessDeniedHandler customAccessDeniedHandler;
    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
    private final CustomAuthenticateSuccessHandler customAuthenticateSuccessHandler;
    private final CustomAuthenticateFailureHandler customAuthenticateFailureHandler;

    public SpringSecurityConfig(
        UserDetailsService userDetailsService,
        BCryptPasswordEncoder bCryptPasswordEncoder,
        CustomAccessDeniedHandler customAccessDeniedHandler,
        CustomAuthenticationEntryPoint customAuthenticationEntryPoint,
        CustomAuthenticateSuccessHandler customAuthenticateSuccessHandler,
        CustomAuthenticateFailureHandler customAuthenticateFailureHandler
    )
    {
        this.userDetailsService = userDetailsService;
        this.bCryptPasswordEncoder = bCryptPasswordEncoder;
        this.customAccessDeniedHandler = customAccessDeniedHandler;
        this.customAuthenticationEntryPoint = customAuthenticationEntryPoint;
        this.customAuthenticateFailureHandler = customAuthenticateFailureHandler;
        this.customAuthenticateSuccessHandler = customAuthenticateSuccessHandler;
    }

    @Override
    public void configure(HttpSecurity http) throws Exception
    {
        http
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and().csrf().disable()
            .authorizeRequests().antMatchers("/sign-up").permitAll()
            .anyRequest().authenticated()
            .and()
            .exceptionHandling()
            .accessDeniedHandler(customAccessDeniedHandler)
            .authenticationEntryPoint(customAuthenticationEntryPoint)
            .and()
            .addFilter(jwtAuthenticationFilter())
            .addFilter(jwtAuthorizationFilter());
    }

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception
    {
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder);
    }

    @Bean
    public JWTAuthenticationFilter jwtAuthenticationFilter() throws Exception
    {
        JWTAuthenticationFilter filter = new JWTAuthenticationFilter();
        filter.setAuthenticationManager(authenticationManager());
        filter.setAuthenticationSuccessHandler(customAuthenticateSuccessHandler);
        filter.setAuthenticationFailureHandler(customAuthenticateFailureHandler);
        return filter;
    }

    @Bean
    public JWTAuthorizationFilter jwtAuthorizationFilter () throws Exception
    {
        return new JWTAuthorizationFilter(authenticationManager());
    }
}

编写测试Controller

java 复制代码
@RestController
public class TestController
{
    @PostMapping("/sign-up")
    public String signUp ()
    {
        return "1111";
    }

    @PostMapping("/admin")
    @PreAuthorize("hasRole('ADMIN')")
    public String admin ()
    {
        return "222";
    }

    @PostMapping("/user")
    @PreAuthorize("hasRole('USER')")
    public String user ()
    {
        return "333";
    }
}

测试 /login 登录

输入错误的账号密码

​编辑

输入正确的账号密码

​编辑

放行请求/sign-up

​编辑

身份鉴权认证

无token

​编辑

错误token

​编辑

正确token 无权限

​编辑

正确token 有权限

​编辑

相关推荐
贫民窟的勇敢爷们2 小时前
SpringBoot整合AOP切面编程实战,实现日志统一记录+接口权限校验
java·spring boot·spring
吾疾唯君医6 小时前
Java SpringBoot集成积木报表实操记录
java·spring boot·spring·导出excel·积木报表·数据文件下载
正儿八经的少年9 小时前
Spring Boot 两种激活配置方式的作用与区别
java·spring boot·后端
疯狂成瘾者10 小时前
Spring Boot 项目中的 SMTP 邮件验证码服务技术解析
java·spring boot·后端
啃臭11 小时前
AOP和反射
java·spring boot
河阿里11 小时前
SpringBoot:Spring Task定时任务完整使用教学
java·spring boot·spring
五阿哥永琪14 小时前
从0开始做一个导出功能,完整流程
spring boot
java1234_小锋15 小时前
SpringBoot可以同时处理多少请求?
java·spring boot·后端
海棠Flower未眠15 小时前
Spring Boot 3 + JPA多模块系统对MySQL和DORIS进行多数据源集成实战(荣耀典藏版)
spring boot·后端·mysql
北风朝向16 小时前
Spring Boot 集成 Open WebUI 实现 AI 流式对话
人工智能·spring boot·状态模式