在web中,我们使用spring-security基于http进行请求认证。在cordova中,由于请求实际上是访问手机本地的资源,因此,其资源的url,不是 http://ip:port/res
,而是 file://res
。
它带来的问题大概有如下问题:
1. 从手机app端h5页面访问后台url资源时的跨域访问问题。
问题原因:
从手机app端主要访问本地路径上的h5页面,页面域名是 file://;而后台的url资源,其域名要么是 http://ip:port/appName,要么是类似 http://www.fredworks.cn/appName。因此构成了跨域。
一般而言,会因为cors规范导致请求被拒绝,结果得到403异常。
解决方案:
h5页面的跨域访问问题,需要后台服务器资源启用cors服务,并允许来自cordova的请求访问。
spring-mvc,是通过如下代码启用cors控制的:
/**
* 各模块安全配置类的基类。
* @author wangqiang
* 2018年8月26日 上午12:53:40
*/
public abstract class ModuleSecurityConfig extends WebSecurityConfigurerAdapter {
...
/**
* 启用cors控制
* 2018年8月26日 下午11:07:37 wangqiang添加此方法
* @param http
* @throws Exception
*/
private void configure(HttpSecurity http) throws Exception {
http.cors()
.and()
...
}
}
上述代码的目的,是为了确保启用了cors服务。
你不启用cors配置,甚至通过类似这样的配置 http.cors().disable()
显式的关闭cors服务,并不会让cors不工作。你关闭的,其实只是服务器端对cors的授权控制。
而现代的浏览器,基本都已经实现了对cors规范的默认支持。无论服务器端是否有开启cors授权,浏览器都会向服务器询问cors授权结果。关闭服务器端的cors控制,只会导致浏览器向服务器发出跨域检查请求时,服务器无法对此进行授权,从而导致请求无法通过cors控制而得到403异常。
自定义CORS配置内容
SpringMvc通过CorsFilter来完成CORS控制。SpringMvc创建CorsFilter时,会自动寻找一个名字为 “corsConfigurationSource“,类型为 CorsConfigurationSource 的 Bean 作为配置参数来完成初始化。因此,我们只要自定义一个CorsConfigurationSource,就可以完成对cors的配置。
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedHeaders(Arrays.asList("*"));
config.setAllowedMethods(Arrays.asList("GET", "POST", "DELETE", "PUT", "OPTIONS"));
config.setAllowedOrigins(Arrays.asList("http://localhost:8100", "file://"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
上述代码中, .allowedOrigins
方法支持两个特定的域:
- "http://localhost:8100":是为了允许使用 ionic serve 进行浏览器端模拟测试阶段的请求不要被跨域控制拒绝。
- "file://":是为了允许ionic的手机包中的页面资源请求服务器资源时不要被跨域控制拒绝。
2. 会话保持问题。
问题的原因:
来自cordova的请求和来自web的请求不同,来自cordova的请求不会携带cookie信息,从而导致不会携带会话ID,丢失认证信息。
当访问需要认证的资源时,会因为没有会话ID,从而被spring-security的安全控制拦截和拒绝,最终得到403异常。
解决方案:
第一步,要在服务器端的cors控制中,允许跨域请求携带认证信息。
否则spring-mvc将不会处理http请求头信息中携带的会话ID,从而导致请求找不到匹配的会话:
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedHeaders(Arrays.asList("*"));
config.setAllowedMethods(Arrays.asList("GET", "POST", "DELETE", "PUT", "OPTIONS"));
config.setAllowedOrigins(Arrays.asList("http://localhost:8100", "file://"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
上述代码中,
.allowCredentials(true)
就是用来告诉spring-mvc要处理跨域请求中携带的会话ID信息。
第二步,服务器端登陆通过后,要返回会话ID给前端
cordova使用webview来渲染web页面,和正常的浏览器不一样,它不支持cookie,无法像正常的浏览器一样,在cookie中自动存储会话ID。因此,服务器端需要在登陆通过后,将会话ID作为请求返回数据的一部分来返回给cordova端的h5页面。
/**
* 登录成功后,不要跳转到新页面,而是返回json数据,以满足手机端使用spring-security的目的。
* @author wangqiang
* 2018年8月26日 上午12:41:05
*/
@Service(SecurityModuleBeanNames.SecurityModuleAuthenticationSuccessHandler)
public class JsonReturningAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
String username = request.getParameter("username");
AuthenInfo authenInfo = GsonUtils.fromJson(AuthenInfo.class, username);
switch (authenInfo.getLoginType()) {
case MobileUserLoginType.Value: {//手机app端用户登录,需要将会话ID作为数据返回
SingleResult<AuthenResultDto> retObj = new SingleResult<>();
retObj.setSuccess(true);
AuthenResultDto data = new AuthenResultDto();
retObj.setData(data);
String sessionId = request.getSession().getId();
data.setSessionId(sessionId);
ISecurityUser user = (ISecurityUser) authentication.getPrincipal();
data.setName(user.getUsername());
data.setMobile(user.getMobile());
response.getWriter().write(retObj.toString());
response.getWriter().flush();
break;
}
case WapUserLoginType.Value: {//手机wap端用户登录,重定向到登录前原页面或主页。
//设置手机wap的默认登录后页面为wap端主页
this.setDefaultTargetUrl("");
super.onAuthenticationSuccess(request, response, authentication);
break;
}
default: {
super.onAuthenticationSuccess(request, response, authentication);
}
}
}
}
以上代码,是在登陆成功处理器中,判断是否是手机端登陆。如果是,则覆盖默认的行为(跳转到登陆前url,或配置中指定的特定页面),不跳转到特定页面(cordova的页面是无法在服务器端进行跳转的,它根本就不在服务器上,而是在手机app本地),而是通过json格式返回数据结构,其中就包含有登陆后的会话ID。
由于会话ID,一般在cookie中使用变量名 JSESSIONID 来记录,因此我们也使用该变量名,只是做了java风格的命名规范改造。
第三步,需要在cordova的h5页面端接收并存储登陆后的会话ID
cordova使用webview来渲染web页面,和正常的浏览器不一样,它不支持cookie,无法像正常的浏览器一样,在cookie中自动存储会话ID。因此,h5页面需要将接收到的会话ID保存在本地。
比如如下的angular代码:
/**
* 客户登录功能
*/
login() {
let me = this;
// 密码登录
const url = '/security/authen/login';
let username = {
loginType: 1,
authenKey: me.mobileNo,
password: me.password
};
let params = new HttpParams()
.set('username', JSON.stringify(username))
.set('password', me.password);
me.http.post<SingleResult<UserInfo>>(url, params).subscribe(
rejObj => {
if (rejObj.success) {
UserInfo.currentUser = rejObj.data;
me.navCtrl.navigateForward(this.targetUrl);
} else {
me.errorMsg = rejObj.message;
}
},
error => {
console.error(error.message, error);
me.errorMsg = error.message;
}
)
}
这段代码的重点,在于通过
UserInfo.currentUser = rejObj.data;
将返回的数据存储起来了。其中,UserInfo.currentUser
是一个存储当前登陆用户信息的和后端约定好的数据结构,其中就有会话ID。
你可以采用自己的存储数据的方法,比如存在全局变量中,或存储在手机端的sqlite数据库中,或其他适当的方式。
第四步,设置angular的http请求基础方法,在http header中设置会话参数 JSEESIONID,并启用认证
我用的是angular8,因此代码大致如下:
/**
* 预处理options对象,主要是在headers中添加会话ID的cookie的属性。
* @param options 待处理的options对象
*/
private processOptions(options?: {
headers?: HttpHeaders ;
observe: 'body';
params?: HttpParams;
reportProgress?: boolean;
responseType?: 'json';
withCredentials?: boolean;
}): any {
if (options) {
let httpHeaders = options.headers || new HttpHeaders();
if (UserInfo.currentUser && UserInfo.currentUser.sessionId) {
options.headers = httpHeaders.set('Cookie', 'JSESSIONID=' + UserInfo.currentUser.sessionId);
}
options.withCredentials = true;
} else {
let httpHeaders = new HttpHeaders();
if (UserInfo.currentUser && UserInfo.currentUser.sessionId) {
httpHeaders = httpHeaders.set('Cookie', 'JSESSIONID=' + UserInfo.currentUser.sessionId);
}
options = {
headers: httpHeaders,
observe: 'body',
params: null,
reportProgress: false,
responseType: 'json',
withCredentials: true
};
}
return options;
}
/**
* 构建一个Get请求,它发送请求前,先预处理请求参数,增加认证会话信息。
* @return an `Observable` of the `HttpResponse` for the request, with a body type of `T`.
*/
get<T>(url: string, options?: {
headers?: HttpHeaders;
observe: 'body';
params?: HttpParams;
reportProgress?: boolean;
responseType?: 'json';
withCredentials?: boolean;
}): Observable<T> {
options = this.processOptions(options);
return this.http.get<T>(environment.ctx + url, options);
}
- 在
processOptions
方法中,检查全局变量中存储的已认证用户信息,并设置到请求的cookie中。- 设置请求的
withCredentials: true
,确保认证信息会被传递过去。