引言
前端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 queryWrapper = new QueryWrapper<>();
queryWrapper.eq("login_name", username);
// 通过自己的dao获取数据库信息
MAccount mAccount = dao.selectOne(queryWrapper);
Optional.ofNullable(mAccount).orElseThrow(() -> new BadCredentialsException("LOGIN_FAILURE"));
ArrayList 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 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
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">
*
*
GET
POST
PUT
DELETE
TRACE
OPTIONS
class="jp.co.saftec.siled.auth.UnauthorizedEntryPoint">
class="jp.co.saftec.siled.auth.MyAuthenticationFailureHandler">
复制代码web.xml
(省略无关部分)
contextConfigLocation
/WEB-INF/spring/root-context.xml,
/WEB-INF/spring/spring-security.xml
springSecurityFilterChain
org.springframework.web.filter.DelegatingFilterProxy
springSecurityFilterChain
/*
复制代码
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的源码,理解了源码配置才能有理有据。