springscurity为我们提供了强大的内置功能,但在实际应用场景中依然需要做一定的定制开发和配置。本文尝试通过实战一起了解springscurity的内部世界。
需求场景
混合式开发APP(Hybrid APP)是目前移动互联网主流的前端框架,这样的前端框架对后端接口服务和安全控制有个性化需求,简单整理如下:
- 动静分离,所有接口返回都是json
- 无状态restful接口,没有会话保持
- 手机号做账号,用短信验证码注册登陆
- APP可以自动登陆
- 防止暴力破解和短信炸弹,图片验证码
- 支持公网系统间调用安全认证
针对以上需求,我们需要做以下定制化开发:
- 增加用户代理主键,实现业务系统用户标识与手机号解耦
- 登陆后使用JWT token访问接口
- app原生登陆,跳转webview联合登陆
用户管理
springsecurity为我们提供了完整的用户管理接口和默认实现。UserDetailsManager和UserDetailsService提供了具体的接口约定,实际生产上一般都采用DB作为用户数据持久化方案。所以我们需要关系的核心对象如下:
springsecurity默认的用户主键是用户账号username,在实际生产系统中为了避免用户账号变更对整个系统数据的影响,需要增加代理主键。为此我们需要重写UserDetailsManager和User对象。
重写UserDetailsManager核心代码如下:
@Override
protected List<UserDetails> loadUsersByUsername(String username) {
return getJdbcTemplate().query(this.usersByUsernameQuery,
new String[] { username }, new RowMapper<UserDetails>() {
@Override
public UserDetails mapRow(ResultSet rs, int rowNum)
throws SQLException {
Integer id = rs.getInt(1);
String username = rs.getString(2);
String password = rs.getString(3);
boolean enabled = rs.getBoolean(4);
return new UserAccount(id, username, password,
enabled, AuthorityUtils.NO_AUTHORITIES);
}
});
认证管理
springsecurity认证核心AuthenticationManager默认只有一个实现类ProviderManager,但ProviderManager并不包含真正认证逻辑,而是作为一个代理类调用一组AuthenticationProvider。
认证管理核心对象如下:
短信验证码登陆
ProviderManager通过supports接口根据AuthenticationToken的类型筛选不同的AuthenticationProvider。AbstractUserDetailsAuthenticationProvider默认使用账号密码认证,为了实现短信验证码认证,我们需要重新实现Authentication和AuthenticationProvider。
核心代码如下:
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;
String username = authenticationToken.getPrincipal().toString();
UserDetails user = userDetailsService.loadUserByUsername((String) authenticationToken.getPrincipal());
if (user == null) {
userAccountServiceFacade.register(username);
user = this.getUserDetailsService().loadUserByUsername(username);
}
SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(user, user.getAuthorities());
authenticationResult.setDetails(authenticationToken.getDetails());
return authenticationResult;
}
springsecurity提供了一个抽象的认证过滤器AbstractAuthenticationProcessingFilter,提供认证服务通用的流程控制能力。
短信验证码登陆需要独立的接口和处理逻辑,我们通过重写AbstractAuthenticationProcessingFilter,并集成短信验证码认证。
核心代码如下:
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
// 根据请求参数名,获取请求value
String mobile = obtainMobile(request);
String smsCode = obtainSmsCode(request);
String series = obtainSeries(request);
additionalAuthenticationChecks(mobile,smsCode,series);
// 生成对应的AuthenticationToken
SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile,smsCode);
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
动静分离
在无状态服务中,我们希望用户每次访问受保护资源时,可以不用session或者cookie就可以通过JWT令牌自动认证,所以在登陆成功后要返回access_token。我们可以把用户权限信息封装到令牌中:
private String doGenerateToken(Map<String, Object> claims, UserAccount userDetails) {
final Date createdDate = clock.now();
final Date expirationDate = calculateExpirationDate(createdDate);
Set<String> roles = AuthorityUtils.authorityListToSet(userDetails.getAuthorities());
return Jwts.builder()
.setClaims(claims)
.setId(userDetails.getId().toString())
.setSubject(userDetails.getUsername())
.setIssuedAt(createdDate)
.setExpiration(expirationDate)
.claim(ROLE, roles)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
我们需要定义自己的SuccessHandler:
public class AccessTokenAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
ObjectMapper om = new ObjectMapper();
JwtAccessTokenConverter jwtAccessTokenConverter;
//。。。details
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
UserAccount user = (UserAccount)authentication.getPrincipal();
String accessToken = jwtAccessTokenConverter.generateToken(user);
Map<String,Object> result = new HashMap<>();
//。。。details
om.writeValue(response.getOutputStream(), result);
}
}
APP自动登陆
springsecurity提供了RememberMe(记住密码)的功能。为了保证安全性,可以通过数据库存放校验信息实现记住密码登录。核心类库如下:
混合式开发APP无法统一使用cookie,我们需要重写PersistentTokenBasedRememberMeServices。
@Override
protected String extractRememberMeCookie(HttpServletRequest request) {
return request.getParameter(REFRESH_TOKEN);
}
@Override
protected void setCookie(String[] tokens, int maxAge, HttpServletRequest request,
HttpServletResponse response) {
String refreshToken = encodeCookie(tokens);
request.setAttribute(SUCCESS_LOGIN_REFRESH_TOKEN, refreshToken);
}
退出登陆
退出登陆时需要清空refreshToken.