引言
前端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。
Alias | Filter Class | Namespace Element or Attribute |
---|---|---|
CHANNEL_FILTER |
|
|
SECURITY_CONTEXT_FILTER |
|
|
CONCURRENT_SESSION_FILTER |
|
|
HEADERS_FILTER |
|
|
CSRF_FILTER |
|
|
LOGOUT_FILTER |
|
|
X509_FILTER |
|
|
PRE_AUTH_FILTER |
|
N/A |
CAS_FILTER |
|
N/A |
FORM_LOGIN_FILTER |
|
|
BASIC_AUTH_FILTER |
|
|
SERVLET_API_SUPPORT_FILTER |
|
|
JAAS_API_SUPPORT_FILTER |
|
|
REMEMBER_ME_FILTER |
|
|
ANONYMOUS_FILTER |
|
|
SESSION_MANAGEMENT_FILTER |
|
|
EXCEPTION_TRANSLATION_FILTER |
|
|
FILTER_SECURITY_INTERCEPTOR |
|
|
SWITCH_USER_FILTER |
|
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的源码,理解了源码配置才能有理有据。