Spring security前后端分离Json认证 跨域自定义Login Logout filter

3,316 阅读8分钟

引言

前端Vue采用Json登陆,通常是跨域的,而且由于是Resulful无状态的请求,认证后的状态都是靠后台发出的token保持。默认的Spring security认证完成后会进行302重定向,这显然不符合我们的要求。登陆成功后我们想返回用户的相关信息,也是Json,这就需要自定义配置。再加上token的处理。。

好在Spring security全部都可以自定义,只是网上都是Sringboot的配置,这次项目使用的繁琐的SpringMVC的xml配置,摸索了好久终于搞定。

1. 明确默认的Spring sercurity filter chain

自定义Spring sercurity,基本上就是自定义各种filter chain。以下是filter chain的顺序跟别名。具体可以查看官网

这里明确以下我们需要替换的filter。

AliasFilter ClassNamespace Element or Attribute

CHANNEL_FILTER

ChannelProcessingFilter

http/intercept-url@requires-channel

SECURITY_CONTEXT_FILTER

SecurityContextPersistenceFilter

http

CONCURRENT_SESSION_FILTER

ConcurrentSessionFilter

session-management/concurrency-control

HEADERS_FILTER

HeaderWriterFilter

http/headers

CSRF_FILTER

CsrfFilter

http/csrf

LOGOUT_FILTER

LogoutFilter

http/logout

X509_FILTER

X509AuthenticationFilter

http/x509

PRE_AUTH_FILTER

AbstractPreAuthenticatedProcessingFilterSubclasses

N/A

CAS_FILTER

CasAuthenticationFilter

N/A

FORM_LOGIN_FILTER

UsernamePasswordAuthenticationFilter

http/form-login

BASIC_AUTH_FILTER

BasicAuthenticationFilter

http/http-basic

SERVLET_API_SUPPORT_FILTER

SecurityContextHolderAwareRequestFilter

http/@servlet-api-provision

JAAS_API_SUPPORT_FILTER

JaasApiIntegrationFilter

http/@jaas-api-provision

REMEMBER_ME_FILTER

RememberMeAuthenticationFilter

http/remember-me

ANONYMOUS_FILTER

AnonymousAuthenticationFilter

http/anonymous

SESSION_MANAGEMENT_FILTER

SessionManagementFilter

session-management

EXCEPTION_TRANSLATION_FILTER

ExceptionTranslationFilter

http

FILTER_SECURITY_INTERCEPTOR

FilterSecurityInterceptor

http

SWITCH_USER_FILTER

SwitchUserFilter

N/A

2. 自定义FORM_LOGIN_FILTER

首先就是FORM_LOGIN_FILTER,默认使用UsernamePasswordAuthenticationFilter,xml配置为http/form-login。该filter取得用户名跟密码的方法,就是单纯的调用request.getParameter(),这样是无法取得Json形式的用户名跟密码的。

在这里我们创建继承UsernamePasswordAuthenticationFilter的自定义Login filter。

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;


public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {

        ObjectMapper mapper = new ObjectMapper();
        UsernamePasswordAuthenticationToken token;

        try (InputStream is = request.getInputStream()) {
            // 从Json中获取用户名,密码
            MAccount mAccount = mapper.readValue(is, MAccount.class);
            token = new UsernamePasswordAuthenticationToken(mAccount.getLoginName(), mAccount.getLoginPassword());
        } catch (IOException e) {
            throw new BadCredentialsException(MessageUtil.getMessage("MSG_LOGIN_FAILURE"));
        }

        setDetails(request, token);
        return this.getAuthenticationManager().authenticate(token);
    }
}

3. 自定义AuthenticationProvider

取到用户信息之后,真正进行验证的是AuthenticationProvider。这里同样需要自定义,通过一个取数据库信息的UserDetailsService,然后进行验证比对。验证失败则生成一个BadCredentialsException,父类会帮我们处理。

import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Optional;


public class CustomAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

    private UserDetailsService userDetailsService;

    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails,
            UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        String pw = authentication.getCredentials().toString();
        Optional.ofNullable(pw).orElseThrow(() -> new BadCredentialsException("password null"));
        String pwDigest;

        // 对密码加密后验证
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            md.update(pw.getBytes(StandardCharsets.UTF_8));
            pwDigest = StringUtils.byte2Hex(md.digest());
        } catch (NoSuchAlgorithmException e) {
            throw new BadCredentialsException(e.getMessage());
        }
        // 验证密码
        if (!pwDigest.equals(userDetails.getPassword())) {
            throw new BadCredentialsException("password does not match");
        }
    }

    @Override
    protected UserDetails retrieveUser(String username,
            UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {

        return userDetailsService.loadUserByUsername(username);
    }


    public void setUserDetailsService(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }
}

4. 自定义UserDetailsService

刚才提到的UserDetailsService,要重写loadUserByUsername方法,以达到的访问数据库用户信息的目的。

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

import java.util.ArrayList;
import java.util.Optional;

public class CustomUserDetailsService implements UserDetailsService {
    @Autowired
    MAccountDao dao;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        QueryWrapper<MAccount> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("login_name", username);
        
        // 通过自己的dao获取数据库信息
        MAccount mAccount = dao.selectOne(queryWrapper);
        Optional.ofNullable(mAccount).orElseThrow(() -> new BadCredentialsException("LOGIN_FAILURE"));
        ArrayList<SimpleGrantedAuthority> list = new ArrayList<>();
        // 添加权限
        list.add(new SimpleGrantedAuthority(String.valueOf(mAccount.getRoleType())));
        return new User(username, mAccount.getLoginPassword(), list);
    }
}

5. 自定义AuthenticationSuccessHandler跟AuthenticationFailureHandler

开头说了,认证成功后者失败后,不进行重定向跳转,而是返回Json数据,这就需要自定义Handler。

AuthenticationSuccessHandler

认证成功后做两件事,第一件是生成token,之后的请求不再需要用户名密码,每次验证token。第二件是把信息返回给前端。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

import static jp.co.saftec.siled.auth.JWTAuthenticationFilter.HEADER_STRING;


public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    MAccountDao dao;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest req, HttpServletResponse res, Authentication auth) throws IOException {
        // UserDetails取得
        UserDetails userDetails = userDetailsService.loadUserByUsername(auth.getName());
        
        // 生成token
        JwtTokenUtil jwtTokenUtil = new JwtTokenUtil();
        String token = jwtTokenUtil.generateToken(userDetails);

        // 设置跨域
        res.setHeader("Access-Control-Allow-Origin", "*");
        res.setHeader(HEADER_STRING, token);

        // 返回前端Json
        ResponseUtil.makeJsonResponse(res, HttpStatus.OK.value(), dao.selectAccountInfo(auth.getName()));
    }
}

AuthenticationFailureHandler

认证失败比较简单,直接返回403。

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;


public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
    // logger
    private Logger logger = LogManager.getLogger(this.getClass());

    @Override
    public void onAuthenticationFailure(
            HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException{
        
        if (e instanceof BadCredentialsException) {
            logger.warn("loginFailure");
        }

        ErrorResponseDto dto = new ErrorResponseDto();
        dto.setErrorMessage(e.getMessage());
        ResponseUtil.makeJsonResponse(response, HttpStatus.FORBIDDEN.value(),dto);
    }
}

ResponseUtil

用于生成Json的response。

import com.fasterxml.jackson.databind.ObjectMapper;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

public class ResponseUtil {

    public static void makeJsonResponse(HttpServletResponse response, Integer httpStatus, Object JsonObject) throws IOException {
        response.setStatus(httpStatus);
        response.setContentType("application/json; charset=utf-8");

        ObjectMapper objectMapper = new ObjectMapper();
        PrintWriter out = response.getWriter();
        out.write(objectMapper.writeValueAsString(JsonObject));
        out.flush();
        out.close();
    }
}

JwtTokenUtil

token工具类。用于生成,验证token过期等。

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.time.Instant;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;


@Component
public class JwtTokenUtil {

    private static final String CLAIM_KEY_USERNAME = "sub";
    private static final long EXPIRATION_TIME = 432000000;
    private static final String SECRET = "secret";

    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>(16);
        claims.put(CLAIM_KEY_USERNAME, userDetails.getUsername());

        return Jwts.builder()
                .setClaims(claims)
                .setExpiration(new Date(Instant.now().toEpochMilli() + EXPIRATION_TIME))
                .signWith(SignatureAlgorithm.HS512, SECRET)
                .compact();
    }

    public Boolean validateToken(String token, UserDetails userDetails) {
        User user = (User) userDetails;
        String username = getUsernameFromToken(token);

        return (username.equals(user.getUsername()) && !isTokenExpired(token));
    }

    public Boolean isTokenExpired(String token) {
        Date expiration = getExpirationDateFromToken(token);
        return expiration.before(new Date());
    }

    public String getUsernameFromToken(String token) {
        return getClaimsFromToken(token).getSubject();
    }

    public Date getExpirationDateFromToken(String token) {
        return getClaimsFromToken(token).getExpiration();
    }

    private Claims getClaimsFromToken(String token) {
        return Jwts.parser()
                .setSigningKey(SECRET)
                .parseClaimsJws(token)
                .getBody();
    }
}

6. 自定义OncePerRequestFilter

搞定了Login认证后,接下来要解决的事情是登陆成功之后的请求都是通过token验证,而不再发送用户名跟密码,验证token信息是否正确是否过期的则是自定义的OncePerRequestFilter。

import org.apache.commons.lang3.StringUtils;
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.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class JWTAuthenticationFilter extends OncePerRequestFilter {
    /**
     * token header(这个是你跟前端商量好的请求头,不一定是我这个)
     */
    public static final String HEADER_STRING = "X-Session-Token";

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = request.getHeader(HEADER_STRING);

        // token解析
        if(StringUtils.isNotBlank(token)) {
            JwtTokenUtil jwtTokenUtil = new JwtTokenUtil();
            String userName = jwtTokenUtil.getUsernameFromToken(token);

            // token解析成功付与権限
            if (userName != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = userDetailsService.loadUserByUsername(userName);

                // 验证token是否有効期限内
                if (jwtTokenUtil.validateToken(token, userDetails)) {
                    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                            userDetails, null, userDetails.getAuthorities());
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(
                            request));

                    // 権限付与
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        }

        // filterChain继续执行
        filterChain.doFilter(request, response);
    }
}

7. 自定义AuthenticationEntryPoint

匿名访问时候,默认情况下登陆失败会跳转页面,这里就简单的返回401。

import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;


public class UnauthorizedEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        // 401を返す
        ErrorResponseDto dto = new ErrorResponseDto();
        dto.setErrorMessage(authException.getMessage());
        ResponseUtil.makeJsonResponse(response, HttpStatus.UNAUTHORIZED.value(), dto);
    }
}

8. 把以上配置添加到xml

这里就要羡慕使用Springboot的同学了,xml简直就是地狱。

spring-security.xml

<beans:beans xmlns="http://www.springframework.org/schema/security"
    xmlns:beans="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.springframework.org/schema/beans
    http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/security
    https://www.springframework.org/schema/security/spring-security.xsd">
    
    <!-- 跨域设置 -->
    <beans:bean id="corsConfiguration" class="org.springframework.web.cors.CorsConfiguration">

        <beans:property name="allowedOrigins">
            <beans:list>
                 <beans:value>*</beans:value>
            </beans:list>
        </beans:property>

        <beans:property name="allowedHeaders">
        <beans:list>
            <beans:value>*</beans:value>
        </beans:list>
        </beans:property>

        <beans:property name="allowedMethods">
            <beans:list>
                <beans:value>GET</beans:value>
                <beans:value>POST</beans:value>
                <beans:value>PUT</beans:value>
                <beans:value>DELETE</beans:value>
                <beans:value>TRACE</beans:value>
                <beans:value>OPTIONS</beans:value>
            </beans:list>
        </beans:property>

    </beans:bean>

    <beans:bean id="corsSource" class="org.springframework.web.cors.UrlBasedCorsConfigurationSource">
        <beans:property name="corsConfigurations">
            <beans:map>
                <beans:entry key="/**" value-ref="corsConfiguration" />
            </beans:map>
        </beans:property>
    </beans:bean>

    <!-- http -->
    <http entry-point-ref="loginEntryPoint" create-session="stateless">
        <cors configuration-source-ref="corsSource"/>
        <!-- 添加LoginFilter -->
        <custom-filter ref="customLoginFilter" before="FORM_LOGIN_FILTER" />
        <!-- 添加jwtFilter -->
        <custom-filter ref="jwtFilter" before="LOGOUT_FILTER"/>
        <!-- 添加LogoutFilter(需要配置logout可以继续往后看) -->
        <custom-filter ref="logoutFilter" position="LOGOUT_FILTER"/>
        <csrf disabled="true"/>
        <!-- 默认login可以任意访问,但其他都需要认证 -->
        <intercept-url pattern="/login" method="POST" access="permitAll"/>
        <intercept-url pattern="/**" access="isAuthenticated()" />
    </http>
    
    <!-- authenticationManager,用于保持认证信息 -->
    <authentication-manager alias="authenticationManager">
        <authentication-provider ref="customProvider">
        </authentication-provider>
    </authentication-manager>

    <!-- 以下是各种bean -->
    <beans:bean id="jwtFilter" class="jp.co.saftec.siled.auth.JWTAuthenticationFilter"/>
    <beans:bean id="logoutFilter" class="org.springframework.security.web.authentication.logout.LogoutFilter">
        <!-- 配置构造函数 -->
        <beans:constructor-arg index="0">
            <beans:bean id="myLogoutHandler" class="jp.co.saftec.siled.auth.MyLogoutSuccessHandler"/>
        </beans:constructor-arg>
        <beans:constructor-arg index="1">
            <beans:bean id="headerWriterLogoutHandler" class="org.springframework.security.web.authentication.logout.HeaderWriterLogoutHandler">
                <beans:constructor-arg index="0">
                    <beans:bean id="tokenClearHeaderWriter" class="jp.co.saftec.siled.auth.TokenClearHeaderWriter"/>
                </beans:constructor-arg>
            </beans:bean>
        </beans:constructor-arg>
    </beans:bean>

    <beans:bean id="loginEntryPoint"
        class="jp.co.saftec.siled.auth.UnauthorizedEntryPoint">
    </beans:bean>
    
    <beans:bean id="customProvider" class="jp.co.saftec.siled.auth.CustomAuthenticationProvider">
        <beans:property name="userDetailsService" ref="userDetailService"/>
    </beans:bean>

	<beans:bean id="userDetailService" class="jp.co.saftec.siled.auth.CustomUserDetailsService">
	</beans:bean>

    <beans:bean id="customLoginFilter" class="jp.co.saftec.siled.auth.CustomAuthenticationFilter">
        <beans:property name="authenticationManager" ref="authenticationManager" />
        <!-- 配置用户名字段 -->
        <beans:property name="usernameParameter" value="loginName" />
        <!-- 配置密码字段 -->
        <beans:property name="passwordParameter" value="loginPassword" />
        <beans:property name="authenticationSuccessHandler">
            <beans:bean class="jp.co.saftec.siled.auth.MyAuthenticationSuccessHandler">
            </beans:bean>
        </beans:property>
        <beans:property name="authenticationFailureHandler">
            <beans:bean
                class="jp.co.saftec.siled.auth.MyAuthenticationFailureHandler">
            </beans:bean>
        </beans:property>
    </beans:bean>

</beans:beans>

web.xml

(省略无关部分)

	<context-param>
		<param-name>contextConfigLocation</param-name>
		<param-value>/WEB-INF/spring/root-context.xml,
			/WEB-INF/spring/spring-security.xml
		</param-value>
	</context-param>
	
        <filter>
		<filter-name>springSecurityFilterChain</filter-name>
		<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
	</filter>
	<filter-mapping>
		<filter-name>springSecurityFilterChain</filter-name>
		<url-pattern>/*</url-pattern>
	</filter-mapping>

9. 自定义Logout filter

经过九九八十一难,终于把登陆搞定了,如果只需要自定义登陆,下面的就不用看了。以下是自定义Logout的配置,作为bean在上面的xml中已经配置过了,这里贴出java。

MyLogoutSuccessHandler

注销成功,则返回200。

import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;


public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        ResponseUtil.makeJsonResponse(response, HttpStatus.OK.value(),"Logout OK");
    }
}

TokenClearHeaderWriter

注销时前端只发送给后端token,把token清除后就算是注销成功了。这里是回调方法,被xml中注册的LogoutFilter调用。时机是/logout被请求时。

public class TokenClearHeaderWriter implements HeaderWriter {
    @Override
    public void writeHeaders(HttpServletRequest request, HttpServletResponse response) {
        String token = request.getHeader(HEADER_STRING);

        // token清除
        if(StringUtils.isNotBlank(token)) {
            response.setHeader(HEADER_STRING, "");
        }

        // 真正的权限清除
        SecurityContextHolder.getContext().setAuthentication(null);
    }
}

最后

到这里算是终于搞定了自定义Json认证。xml的部分之外是跟springboot没有区别的,但xml是真的很繁琐,错一个地方都无法启动。建议没事的调试以下Spring security的源码,理解了源码配置才能有理有据。