Spring Stateless Security系列的第二部分是关于以无状态方式探索身份验证的方法。 如果您错过了CSRF的第一部分,可以在这里找到。
因此,在谈论身份验证时,其全部内容就是让客户端以可验证的方式向服务器标识自己。 通常,这始于服务器向客户端提供挑战,例如要求填写用户名/密码的请求。 今天,我想着重介绍在通过此类初始(手动)挑战后会发生什么情况,以及如何处理其他HTTP请求的自动重新身份验证。
常用方法
基于会话Cookie
我们可能最了解的最常见方法是使用服务器生成的JSESSIONID cookie形式的秘密令牌(会话密钥)。 这些天的初始设置几乎没有用,也许会让您忘记,您有一个选择要放在这里。 即使没有进一步使用此“会话密钥”来存储“会话中”的任何其他状态,该密钥本身实际上也是状态 。 即,如果没有这些密钥的共享和持久存储,则成功的身份验证将无法在服务器重新启动或请求负载平衡到另一台服务器后继续存在。
OAuth2 / API密钥
每当谈论REST API和安全性时; 提到了OAuth2和其他类型的API密钥。 基本上,它们涉及在HTTP授权标头中发送自定义令牌/密钥。 如果使用得当,两种方法都可以避免客户端使用标头来处理Cookie。 这解决了CSRF漏洞和其他Cookie相关问题。 但是,他们无法解决的一件事是服务器需要检查显示的身份验证密钥,几乎需要一些持久且可维护的共享存储来将密钥链接到用户/授权。
无状态方法
1. HTTP基础认证
处理认证的最古老,最粗糙的方式。 只需让用户随每个请求发送其用户名/密码。 这听起来似乎很可怕,但是考虑到上述任何方法也都通过网络发送秘密密钥,这实际上并不是那么安全。 主要是用户体验和灵活性,这使得其他方法成为更好的选择。
2.服务器签名的令牌
以无状态方式处理请求中的状态的一个巧妙小技巧是让服务器对其“签名”。 然后可以在每个请求之间在客户端/服务器之间来回传输该请求,并确保它不会受到限制。 这样,任何用户标识数据都可以以纯文本形式共享,并为其添加特殊的签名哈希。 考虑到已签名,服务器可以简单地验证签名哈希是否仍与接收到的内容匹配,而无需保持任何服务器端状态。
可以用于此目的的通用标准是JSON Web令牌 (JWT),该标准仍在起草中。 对于本博客文章,我想摆脱困境,跳过完全的合规性以及使用附带的库的尖叫声。 从中挑选我们真正需要的东西。 (省略了标头/变量哈希算法和url-safe base64编码)
实作
如前所述,我们将使用Spring Security和Spring Boot将自己的实现整合在一起。 没有任何库或精美的API会混淆令牌级别上真正发生的事情。 令牌在伪代码中看起来像这样:
content = toJSON(user_details)
token = BASE64(content) + "." + BASE64(HMAC(content))
令牌中的点用作分隔符,因此每个部分都可以分别标识和解码,因为点字符不是任何base64编码字符串的一部分。 HMAC代表基于哈希的消息身份验证代码,它基本上是使用预定义密钥从任何数据中生成的哈希。
在实际的Java中,令牌的生成与伪代码非常相似:
创建令牌
public String createTokenForUser(User user) {byte[] userBytes = toJSON(user);byte[] hash = createHmac(userBytes);final StringBuilder sb = new StringBuilder(170);sb.append(toBase64(userBytes));sb.append(SEPARATOR);sb.append(toBase64(hash));return sb.toString();
}
JSON中使用的相关User属性是id,username,expires和role ,但可以是您真正想要的任何东西。 我标记了杰克逊JSON序列化期间将忽略的User对象的“ password”属性,因此它不会成为令牌的一部分:
忽略密码
@JsonIgnore
public String getPassword() {return password;
}
对于现实世界的场景,您可能只想为此使用专用对象。
通过一些输入验证来防止/捕获由于对令牌进行调整而导致的解析错误,令牌的解码会稍微复杂一些:
解码令牌
public User parseUserFromToken(String token) {final String[] parts = token.split(SEPARATOR_SPLITTER);if (parts.length == 2 && parts[0].length() > 0 && parts[1].length() > 0) {try {final byte[] userBytes = fromBase64(parts[0]);final byte[] hash = fromBase64(parts[1]);boolean validHash = Arrays.equals(createHmac(userBytes), hash);if (validHash) {final User user = fromJSON(userBytes);if (new Date().getTime() < user.getExpires()) {return user;}}} catch (IllegalArgumentException e) {//log tampering attempt here}}return null;
}
它本质上验证提供的哈希值是否与内容的新计算哈希值相同。 因为createHmac方法在内部使用未公开的秘密密钥来计算哈希,所以没有客户端能够调整内容并提供与服务器生成的哈希相同的哈希。 仅在通过此测试后,提供的数据才会被解释为表示User对象的JSON。
放大Hmac部分,让我们看一下所涉及的Java。 首先,必须使用一个私钥对其进行初始化,这是TokenHandler的构造函数的一部分:
HMAC初始化
...
private static final String HMAC_ALGO = "HmacSHA256";private final Mac hmac;public TokenHandler(byte[] secretKey) {try {hmac = Mac.getInstance(HMAC_ALGO);hmac.init(new SecretKeySpec(secretKey, HMAC_ALGO));} catch (NoSuchAlgorithmException | InvalidKeyException e) {throw new IllegalStateException("failed to initialize HMAC: " + e.getMessage(), e);}
}
...
初始化后,可以使用一个方法调用(重新)使用它! (doFinal的JavaDoc读取“处理给定的字节数组并完成MAC操作。对该方法的调用会将这个Mac对象重置为先前通过调用init(Key)或init(Key,AlgorithmParameterSpec进行初始化)时所处的状态。 …”)
createHmac
// synchronized to guard internal hmac object
private synchronized byte[] createHmac(byte[] content) {return hmac.doFinal(content);
}
我在这里使用了一些粗略的同步,以防止在Spring Singleton Service中使用时发生冲突。 实际的方法非常快(〜0.01ms),因此除非您每台服务器每秒要发送10k +请求,否则它不会造成任何问题。
说到服务,让我们一路攀升到完全可运行的基于令牌的身份验证服务:
令牌认证服务
@Service
public class TokenAuthenticationService {private static final String AUTH_HEADER_NAME = "X-AUTH-TOKEN";private static final long TEN_DAYS = 1000 * 60 * 60 * 24 * 10;private final TokenHandler tokenHandler;@Autowiredpublic TokenAuthenticationService(@Value("${token.secret}") String secret) {tokenHandler = new TokenHandler(DatatypeConverter.parseBase64Binary(secret));}public void addAuthentication(HttpServletResponse response, UserAuthentication authentication) {final User user = authentication.getDetails();user.setExpires(System.currentTimeMillis() + TEN_DAYS);response.addHeader(AUTH_HEADER_NAME, tokenHandler.createTokenForUser(user));}public Authentication getAuthentication(HttpServletRequest request) {final String token = request.getHeader(AUTH_HEADER_NAME);if (token != null) {final User user = tokenHandler.parseUserFromToken(token);if (user != null) {return new UserAuthentication(user);}}return null;}
}
很简单,初始化一个私有TokenHandler来完成繁重的工作。 它提供了添加和读取自定义HTTP令牌标头的方法。 如您所见,它不使用任何(数据库驱动的)UserDetailsService查找用户详细信息。 通过令牌提供了让Spring Security处理进一步的授权检查所需的所有详细信息。
最后,我们现在可以将所有这些插件插入到Spring Security中,在Security配置中添加两个自定义过滤器:
StatelessAuthenticationSecurityConfig内部的安全配置
...
@Override
protected void configure(HttpSecurity http) throws Exception {http...// custom JSON based authentication by POST of // {"username":"<name>","password":"<password>"} // which sets the token header upon authentication.addFilterBefore(new StatelessLoginFilter("/api/login", ...), UsernamePasswordAuthenticationFilter.class)// custom Token based authentication based on // the header previously given to the client.addFilterBefore(new StatelessAuthenticationFilter(...), UsernamePasswordAuthenticationFilter.class);
}
...
StatelessLoginFilter在成功认证后添加令牌:
StatelessLoginFilter
...
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,FilterChain chain, Authentication authentication) throws IOException, ServletException {// Lookup the complete User object from the database and create an Authentication for itfinal User authenticatedUser = userDetailsService.loadUserByUsername(authentication.getName());final UserAuthentication userAuthentication = new UserAuthentication(authenticatedUser);// Add the custom token as HTTP header to the responsetokenAuthenticationService.addAuthentication(response, userAuthentication);// Add the authentication to the Security contextSecurityContextHolder.getContext().setAuthentication(userAuthentication);
}
...
StatelessAuthenticationFilter仅根据标头设置身份验证:
StatelessAuthenticationFilter
...
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {SecurityContextHolder.getContext().setAuthentication(tokenAuthenticationService.getAuthentication((HttpServletRequest) req));chain.doFilter(req, res); // always continue
}
...
请注意,与大多数与Spring Security相关的过滤器不同,无论身份验证成功如何,我都选择继续沿过滤器链向下移动。 我想支持触发Spring的AnonymousAuthenticationFilter以支持匿名身份验证。 这里最大的区别是过滤器未配置为映射到任何专门用于身份验证的URL,因此不提供标头并不是真正的问题。
客户端实施
客户端实现同样非常简单。 再次,我将其保持为最低限度,以防止在AngularJS详细信息中丢失身份验证位。 如果您正在寻找一个更完整地与路由集成的AngularJS JWT示例,则应在此处查看 。 我从中借用了一些拦截器逻辑。
登录只需存储令牌(在localStorage中 ):
登录
$scope.login = function () {var credentials = { username: $scope.username, password: $scope.password };$http.post('/api/login', credentials).success(function (result, status, headers) {$scope.authenticated = true;TokenStorage.store(headers('X-AUTH-TOKEN'));});
};
注销甚至更简单(无需调用服务器):
登出
$scope.logout = function () {// Just clear the local storageTokenStorage.clear(); $scope.authenticated = false;
};
要检查用户是否“已经登录”,ng-init =“ init()”可以很好地工作:
在里面
$scope.init = function () {$http.get('/api/users/current').success(function (user) {if(user.username !== 'anonymousUser'){$scope.authenticated = true;$scope.username = user.username;}});
};
我选择使用匿名可访问的端点来防止触发401/403。 您也可以解码令牌本身并检查到期时间,并相信本地客户端时间足够准确。
最后,为了使添加标头的过程自动化,就像上一个博客条目中那样,一个简单的拦截器很好地做到了:
令牌验证拦截器
factory('TokenAuthInterceptor', function($q, TokenStorage) {return {request: function(config) {var authToken = TokenStorage.retrieve();if (authToken) {config.headers['X-AUTH-TOKEN'] = authToken;}return config;},responseError: function(error) {if (error.status === 401 || error.status === 403) {TokenStorage.clear();}return $q.reject(error);}};
}).config(function($httpProvider) {$httpProvider.interceptors.push('TokenAuthInterceptor');
});
假设客户端不会允许调用需要更高特权的区域,它还会照顾到在收到HTTP 401或403之后自动清除令牌的情况。
令牌存储
TokenStorage只是对localStorage的包装服务,我不会打扰您。 将令牌放入localStorage可以防止脚本像保存cookie一样在保存脚本的脚本源之外读取脚本。 但是,由于令牌不是实际的Cookie,因此无法指示任何浏览器将其自动添加到请求中。 这是至关重要的,因为它可以完全防止任何形式的CSRF攻击。 因此,您不必实施我以前的博客中提到的任何(无状态)CSRF保护。
- 您可以在github上找到一个完整的工作示例,其中包含一些不错的功能。
确保已安装gradle 2.0,并使用“ gradle build”和“ gradle run”简单地运行它。 如果要像Eclipse一样在IDE中使用它,请使用“ gradle eclipse”,只需从IDE内导入并运行它即可(无需服务器)。
翻译自: https://www.javacodegeeks.com/2014/10/stateless-spring-security-part-2-stateless-authentication.html