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.