Spring MVC的使用技巧(APP驗證 加密/解密Json 敏感詞過濾)
APP服務端的Token驗證
通過攔截器對使用了 @Authorization 註解的方法進行請求攔截,從http header中取出token資訊,驗證其是否合法。非法直接返回401錯誤,合法將token對應的user key存入request中後繼續執行。具體實現程式碼:
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) throws Exception {
//如果不是對映到方法直接通過
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
//從header中得到token
String token = request.getHeader(httpHeaderName);
if (token != null && token.startsWith(httpHeaderPrefix) && token.length() > 0 ) {
token = token.substring(httpHeaderPrefix.length());
//驗證token
String key = manager.getKey(token);
if (key != null) {
//如果token驗證成功,將token對應的使用者id存在request中,便於之後注入
request.setAttribute(REQUEST_CURRENT_KEY, key);
return true;
}
}
//如果驗證token失敗,並且方法註明了Authorization,返回401錯誤
if (method.getAnnotation(Authorization.class) != null) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setCharacterEncoding("gbk");
response.getWriter().write(unauthorizedErrorMessage);
response.getWriter().close();
return false;
}
//為了防止以某種直接在REQUEST_CURRENT_KEY寫入key,將其設為null
request.setAttribute(REQUEST_CURRENT_KEY, null);
return true;
}
通過攔截器後,使用解析器對修飾了 @CurrentUser 的引數進行注入。從request中取出之前存入的user key,得到對應的user物件並注入到引數中。具體實現程式碼:
@Override
public boolean supportsParameter(MethodParameter parameter) {
Class clazz;
try {
clazz = Class.forName(userModelClass);
} catch (ClassNotFoundException e) {
return false;
}
//如果引數型別是User並且有CurrentUser註解則支援
if (parameter.getParameterType().isAssignableFrom(clazz) &&
parameter.hasParameterAnnotation(CurrentUser.class)) {
return true;
}
return false;
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
//取出鑑權時存入的登入使用者Id
Object object = webRequest.getAttribute(AuthorizationInterceptor.REQUEST_CURRENT_KEY, RequestAttributes.SCOPE_REQUEST);
if (object != null) {
String key = String.valueOf(object);
//從資料庫中查詢並返回
Object userModel = userModelRepository.getCurrentUser(key);
if (userModel != null) {
return userModel;
}
//有key但是得不到使用者,丟擲異常
throw new MissingServletRequestPartException(AuthorizationInterceptor.REQUEST_CURRENT_KEY);
}
//沒有key就直接返回null
return null;
}
使用別名接受物件的引數
請求中的引數名和程式碼中定義的引數名不同是很常見的情況,對於這種情況Spring提供了幾種原生的方法:
對於 @RequestParam 可以直接指定value值為別名( @RequestHeader 也是一樣),例如:
public String home(@RequestParam("user_id") long userId) {
return "hello " + userId;
}
對於 @RequestBody ,由於其使使用Jackson將Json轉換為物件,所以可以使用 @JsonProperty 的value指定別名,例如:
public String home(@RequestBody User user) {
return "hello " + user.getUserId();
}
class User {
@JsonProperty("user_id")
private long userId;
}
但是使用物件的屬性接受引數時,就無法直接通過上面的辦法指定別名了,例如:
public String home(User user) {
return "hello " + user.getUserId();
}
這時候需要使用DataBinder手動繫結屬性和別名,我在StackOverFlow上找到的 這篇文章 是個不錯的辦法,這裡就不重複造輪子了。
關閉預設通過請求的字尾名判斷Content-Type
之前接手的專案的開發習慣是使用.html作為請求的字尾名,這在Struts2上是沒有問題的(因為本身Struts2處理Json的幾種方法就都很爛)。但是我接手換成Spring MVC後,使用 @ResponseBody 返回物件時就會報找不到轉換器錯誤。
這是因為Spring MVC預設會將字尾名為.html的請求的Content-Type認為是 text/html ,而 @ResponseBody 返回的Content-Type是 application/json ,沒有任何一種轉換器支援這樣的轉換。所以需要手動將通過後綴名判斷Content-Type的設定關掉,並將預設的Content-Type設定為 application/json :
@Configuration
public class WebMvcConfig extends WebMvcConfigurerAdapter {
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer.favorPathExtension(false).
defaultContentType(MediaType.APPLICATION_JSON);
}
}
更改預設的Json序列化方案
專案中有時候會有自己獨特的Json序列化方案,例如比較常用的使用 0 / 1 替代 false / true ,或是通過 "" 代替 null ,由於 @ResponseBody 預設使用的是 MappingJackson2HttpMessageConverter ,只需要將自己實現的 ObjectMapper 傳入這個轉換器:
public class CustomObjectMapper extends ObjectMapper {
public CustomObjectMapper() {
super();
this.getSerializerProvider().setNullValueSerializer(new JsonSerializer<Object>() {
@Override
public void serialize(Object value, JsonGenerator jgen, SerializerProvider provider) throws IOException {
jgen.writeString("");
}
});
SimpleModule module = new SimpleModule();
module.addSerializer(boolean.class, new JsonSerializer<Boolean>() {
@Override
public void serialize(Boolean value, JsonGenerator jgen, SerializerProvider provider) throws IOException {
jgen.writeNumber(value ? 1 : 0);
}
});
this.registerModule(module);
}
}
自動加密/解密請求中的Json
涉及到 @RequestBody 和 @ResponseBody 的型別轉換問題一般都在 MappingJackson2HttpMessageConverter 中解決,想要自動加密/解密只需要繼承這個類並重寫 readInternal / writeInternal 方法即可:
@Override
protected Object readInternal(Class<?> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
//解密
String json = AESUtil.decrypt(inputMessage.getBody());
JavaType javaType = getJavaType(clazz, null);
//轉換
return this.objectMapper.readValue(json, javaType);
}
@Override
protected void writeInternal(Object object, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
//使用Jackson的ObjectMapper將Java物件轉換成Json String
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(object);
//加密
String result = AESUtil.encrypt(json);
//輸出
outputMessage.getBody().write(result.getBytes());
}
基於註解的敏感詞過濾功能
專案需要對使用者釋出的內容進行過濾,將其中的敏感詞替換為 * 等特殊字元。大部分Web專案在處理這方面需求時都會選擇過濾器( Filter ),在過濾器中將 Request 包上一層 Wrapper ,並重寫其 getParameter 等方法,例如:
public class SafeTextRequestWrapper extends HttpServletRequestWrapper {
public SafeTextRequestWrapper(HttpServletRequest req) {
super(req);
}
@Override
public Map<String, String[]> getParameterMap() {
Map<String, String[]> paramMap = super.getParameterMap();
for (String[] values : paramMap.values()) {
for (int i = 0; i < values.length; i++) {
values[i] = SensitiveUtil.filter(values[i]);
}
}
return paramMap ;
}
@Override
public String getParameter(String name) {
return SensitiveUtil.filter(super.getParameter(name));
}
}
public class SafeTextFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
SafeTextRequestWrapper safeTextRequestWrapper = new SafeTextRequestWrapper((HttpServletRequest) request);
chain.doFilter(safeTextRequestWrapper, response);
}
@Override
public void destroy() {
}
}
但是這樣做會有一些明顯的問題,比如無法控制具體對哪些資訊進行過濾。如果使用者註冊的郵箱或是密碼中也帶有 fuck 之類的敏感詞,那就屬於誤傷了。
所以改用Spring MVC的Formatter進行拓展,只需要在 @RequestParam 的引數上使用 @SensitiveFormat 註解,Spring MVC就會在注入該屬性時自動進行敏感詞過濾。既方便又不會誤傷,實現方法如下:
宣告 @SensitiveFormat 註解:
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SensitiveFormat {
}
建立 SensitiveFormatter 類。實現 Formatter 介面,重寫 parse 方法(將接收到的內容轉換成物件的方法),在該方法中對接收內容進行過濾:
public class SensitiveFormatter implements Formatter<String> {
@Override
public String parse(String text, Locale locale) throws ParseException {
return SensitiveUtil.filter(text);
}
@Override
public String print(String object, Locale locale) {
return object;
}
}
建立 SensitiveFormatAnnotationFormatterFactory 類,實現 AnnotationFormatterFactory 介面,將 @SensitiveFormat 與 SensitiveFormatter 繫結:
public class SensitiveFormatAnnotationFormatterFactory implements AnnotationFormatterFactory<SensitiveFormat> {
@Override
public Set<Class<?>> getFieldTypes() {
Set<Class<?>> fieldTypes = new HashSet<>();
fieldTypes.add(String.class);
return fieldTypes;
}
@Override
public Printer<?> getPrinter(SensitiveFormat annotation, Class<?> fieldType) {
return new SensitiveFormatter();
}
@Override
public Parser<?> getParser(SensitiveFormat annotation, Class<?> fieldType) {
return new SensitiveFormatter();
}
}
最後將 SensitiveFormatAnnotationFormatterFactory 註冊到Spring MVC中:
@Configuration
public class WebMvcConfig extends WebMvcConfigurerAdapter {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addFormatterForFieldAnnotation(new SensitiveFormatAnnotationFormatterFactory());
super.addFormatters(registry);
}
}
記錄請求的返回內容
這裡提供一種比較通用的方法,基於過濾器實現,所以在非Spring MVC的專案也可以使用。
首先匯入 commons-io :
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.4</version>
</dependency>
需要用到這個庫中的 TeeOutputStream ,這個類可以將一個將內容同時輸出到兩個分支的輸出流,將其封裝為 ServletOutputStream :
public class TeeServletOutputStream extends ServletOutputStream {
private final TeeOutputStream teeOutputStream;
public TeeServletOutputStream(OutputStream one, OutputStream two) {
this.teeOutputStream = new TeeOutputStream(one, two);
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setWriteListener(WriteListener listener) {
}
@Override
public void write(int b) throws IOException {
this.teeOutputStream.write(b);
}
@Override
public void flush() throws IOException {
super.flush();
this.teeOutputStream.flush();
}
@Override
public void close() throws IOException {
super.close();
this.teeOutputStream.close();
}
}
然後建立一個過濾器,將原有的 response 的 getOutputStream 方法重寫:
public class LoggingFilter implements Filter {
private static final Logger LOGGER = LoggerFactory.getLogger(LoggingFilter.class);
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
public void doFilter(ServletRequest request, final ServletResponse response, FilterChain chain) throws IOException, ServletException {
final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
HttpServletResponseWrapper responseWrapper = new HttpServletResponseWrapper((HttpServletResponse) response) {
private TeeServletOutputStream teeServletOutputStream;
@Override
public ServletOutputStream getOutputStream() throws IOException {
return new TeeServletOutputStream(super.getOutputStream(), byteArrayOutputStream);
}
};
chain.doFilter(request, responseWrapper);
String responseLog = byteArrayOutputStream.toString();
if (LOGGER.isInfoEnabled() && !StringUtil.isEmpty(responseLog)) {
LOGGER.info(responseLog);
}
}
@Override
public void destroy() {
}
}
將 super.getOutputStream() 和 ByteArrayOutputStream 分別作為兩個分支流,前者會將內容返回給客戶端,後者使用 toString 方法即可獲得輸出內容。