私は、STOMP/SockJS WebSocketを含むSpring Boot(1.3.0.BUILD-SNAPSHOT)を使用してRESTful Webアプリケーションをセットアップしていますが、これはiOSアプリとWebブラウザーから使用する予定です。 JSON Web Tokens (JWT)を使用してRESTリクエストとWebSocketインターフェイスを保護しますが、後者に問題があります。
アプリはSpring Securityで保護されています:-
@Configuration
@EnableWebSecurity
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
public WebSecurityConfiguration() {
super(true);
}
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("steve").password("steve").roles("USER");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.exceptionHandling().and()
.anonymous().and()
.servletApi().and()
.headers().cacheControl().and().and()
// Relax CSRF on the WebSocket due to needing direct access from apps
.csrf().ignoringAntMatchers("/ws/**").and()
.authorizeRequests()
//allow anonymous resource requests
.antMatchers("/", "/index.html").permitAll()
.antMatchers("/resources/**").permitAll()
//allow anonymous POSTs to JWT
.antMatchers(HttpMethod.POST, "/rest/jwt/token").permitAll()
// Allow anonymous access to websocket
.antMatchers("/ws/**").permitAll()
//all other request need to be authenticated
.anyRequest().hasRole("USER").and()
// Custom authentication on requests to /rest/jwt/token
.addFilterBefore(new JWTLoginFilter("/rest/jwt/token", authenticationManagerBean()), UsernamePasswordAuthenticationFilter.class)
// Custom JWT based authentication
.addFilterBefore(new JWTTokenFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
WebSocket構成は標準です:
@Configuration
@EnableScheduling
@EnableWebSocketMessageBroker
public class WebSocketConfiguration extends AbstractWebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").withSockJS();
}
}
WebSocketを保護するためにAbstractSecurityWebSocketMessageBrokerConfigurer
のサブクラスもあります:-
@Configuration
public class WebSocketSecurityConfiguration extends AbstractSecurityWebSocketMessageBrokerConfigurer {
@Override
protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) {
messages.anyMessage().hasRole("USER");
}
@Override
protected boolean sameOriginDisabled() {
// We need to access this directly from apps, so can't do cross-site checks
return true;
}
}
また、いくつかの@RestController
注釈付きクラスがさまざまな機能を処理し、JWTTokenFilter
クラスに登録されたWebSecurityConfiguration
を介して正常に保護されます。
ただし、WebSocketをJWTで保護することはできません。ブラウザで SockJS 1.1. および STOMP 1.7.1 を使用していますが、トークンを渡す方法がわかりません。それは 表示される SockJSは、最初の/info
および/またはハンドシェイク要求でパラメーターを送信することを許可しません。
Spring Security for WebSocketsドキュメントの状態 は、AbstractSecurityWebSocketMessageBrokerConfigurer
が以下を保証することです:
着信CONNECTメッセージには、同一生成元ポリシーを実施するための有効なCSRFトークンが必要です
これは、STOMP CONNECTメッセージを受信した時点で、最初のハンドシェイクを保護せずに認証を呼び出す必要があることを暗示しているようです。残念ながら、これを実装することに関する情報を見つけることができないようです。さらに、このアプローチでは、WebSocket接続を開き、STOMP CONNECTを送信しない不正クライアントを切断するための追加ロジックが必要になります。
Springが(非常に)初めてなので、Spring Sessionsがこれに適合するかどうか、またはどのように適合するかについてもわかりません。ドキュメントは非常に詳細ですが、さまざまなコンポーネントがどのように組み合わされ、相互に作用するかについてのニースでシンプルな(別名バカ)ガイドには表示されません。
できればハンドシェイクの時点で、JSON Web Tokenを提供してSockJS WebSocketを保護するにはどうすればよいですか(それも可能ですか)。
クエリ文字列のサポートがSockJSクライアントに追加されたようです。 https://github.com/sockjs/sockjs-client/issues/72 を参照してください。
UPDATE 2016-12-13:以下で参照されている問題は修正済みとマークされているため、Spring 4.3.5以降では以下のハックは不要になりました。 https://github.com/spring-projects/spring-framework/blob/master/src/docs/asciidoc/web/websocket.adoc#token-authentication を参照してください。
現在(2016年9月)、これは、Spring WebSocketサポートの多く(すべて?)を書いた@ rossen-stoyanchevが回答したクエリパラメーターを介して、Springでサポートされていません。 HTTPリファラーの潜在的な漏洩とサーバーログへのトークンの格納のため、クエリパラメーターアプローチが好きではありません。さらに、セキュリティの影響が気にならない場合は、このアプローチが真のWebSocket接続で機能することを発見したことに注意してください。but他のメカニズムへのフォールバックでSockJSを使用している場合、フォールバックに対してdetermineUser
メソッドが呼び出されることはありません。 Spring 4.xトークンベースのWebSocket SockJSフォールバック認証 を参照してください。
トークンベースのWebSocket認証のサポートを改善するために、Springの問題を作成しました: https://jira.spring.io/browse/SPR-1469
それまでの間、テストでうまく機能するハックを見つけました。組み込みのSpring接続レベルのSpring認証機構をバイパスします。代わりに、クライアント側のStompヘッダーで送信することにより、メッセージレベルで認証トークンを設定します(これにより、通常のHTTP XHR呼び出しで既に行っていることをうまく反映します)。例:
stompClient.connect({'X-Authorization': 'token'}, ...);
stompClient.subscribe(..., {'X-Authorization': 'token'});
stompClient.send("/wherever", {'X-Authorization': 'token'}, ...);
サーバー側で、ChannelInterceptor
を使用してStompメッセージからトークンを取得します
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.setInterceptors(new ChannelInterceptorAdapter() {
Message<*> preSend(Message<*> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
List tokenList = accessor.getNativeHeader("X-Authorization");
String token = null;
if(tokenList == null || tokenList.size < 1) {
return message;
} else {
token = tokenList.get(0);
if(token == null) {
return message;
}
}
// validate and convert to a Principal based on your own requirements e.g.
// authenticationManager.authenticate(JwtAuthentication(token))
Principal yourAuth = [...];
accessor.setUser(yourAuth);
// not documented anywhere but necessary otherwise NPE in StompSubProtocolHandler!
accessor.setLeaveMutable(true);
return MessageBuilder.createMessage(message.payload, accessor.messageHeaders)
}
})
これは簡単で、85%の方法で取得できますが、このアプローチは特定のユーザーへのメッセージ送信をサポートしていません。これは、ユーザーをセッションに関連付けるSpringの機構がChannelInterceptor
の結果の影響を受けないためです。 Spring WebSocketは、認証がメッセージ層ではなくトランスポート層で行われることを前提としているため、メッセージレベルの認証を無視します。
とにかくこれを機能させるためのハックは、DefaultSimpUserRegistry
およびDefaultUserDestinationResolver
のインスタンスを作成し、それらを環境に公開し、インターセプターを使用して、Spring自体が実行しているかのようにそれらを更新することです。つまり、次のようなものです。
@Configuration
@EnableWebSocketMessageBroker
@Order(HIGHEST_PRECEDENCE + 50)
class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer() {
private DefaultSimpUserRegistry userRegistry = new DefaultSimpUserRegistry();
private DefaultUserDestinationResolver resolver = new DefaultUserDestinationResolver(userRegistry);
@Bean
@Primary
public SimpUserRegistry userRegistry() {
return userRegistry;
}
@Bean
@Primary
public UserDestinationResolver userDestinationResolver() {
return resolver;
}
@Override
public configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/queue", "/topic");
}
@Override
public registerStompEndpoints(StompEndpointRegistry registry) {
registry
.addEndpoint("/stomp")
.withSockJS()
.setWebSocketEnabled(false)
.setSessionCookieNeeded(false);
}
@Override public configureClientInboundChannel(ChannelRegistration registration) {
registration.setInterceptors(new ChannelInterceptorAdapter() {
Message<*> preSend(Message<*> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
List tokenList = accessor.getNativeHeader("X-Authorization");
accessor.removeNativeHeader("X-Authorization");
String token = null;
if(tokenList != null && tokenList.size > 0) {
token = tokenList.get(0);
}
// validate and convert to a Principal based on your own requirements e.g.
// authenticationManager.authenticate(JwtAuthentication(token))
Principal yourAuth = token == null ? null : [...];
if (accessor.messageType == SimpMessageType.CONNECT) {
userRegistry.onApplicationEvent(SessionConnectedEvent(this, message, yourAuth));
} else if (accessor.messageType == SimpMessageType.SUBSCRIBE) {
userRegistry.onApplicationEvent(SessionSubscribeEvent(this, message, yourAuth));
} else if (accessor.messageType == SimpMessageType.UNSUBSCRIBE) {
userRegistry.onApplicationEvent(SessionUnsubscribeEvent(this, message, yourAuth));
} else if (accessor.messageType == SimpMessageType.DISCONNECT) {
userRegistry.onApplicationEvent(SessionDisconnectEvent(this, message, accessor.sessionId, CloseStatus.NORMAL));
}
accessor.setUser(yourAuth);
// not documented anywhere but necessary otherwise NPE in StompSubProtocolHandler!
accessor.setLeaveMutable(true);
return MessageBuilder.createMessage(message.payload, accessor.messageHeaders);
}
})
}
}
これで、Springは認証を完全に認識します。つまり、Principal
を必要とするコントローラーメソッドに挿入し、Spring Security 4.xのコンテキストに公開し、メッセージを送信するためのWebSocketセッションにユーザーを関連付けます。特定のユーザー/セッション。
最後に、Spring Security 4.xメッセージングサポートを使用する場合、AbstractWebSocketMessageBrokerConfigurer
の@Order
をSpring SecurityのAbstractSecurityWebSocketMessageBrokerConfigurer
(Ordered.HIGHEST_PRECEDENCE + 50
が機能するよりも高い値に設定してください。 、上記のように)。このようにして、Spring Securityがチェックを実行してセキュリティコンテキストを設定する前に、インターセプターがPrincipal
を設定します。
上記のコードのこの行は、多くの人を混乱させているようです。
// validate and convert to a Principal based on your own requirements e.g.
// authenticationManager.authenticate(JwtAuthentication(token))
Principal yourAuth = [...];
これはStomp固有のものではないため、質問の範囲外です。しかし、Springでの認証トークンの使用に関連しているため、少し詳しく説明します。トークンベースの認証を使用する場合、必要なPrincipal
は通常、Spring SecurityのJwtAuthentication
クラスを拡張するカスタムAbstractAuthenticationToken
クラスになります。 AbstractAuthenticationToken
は、Authentication
インターフェイスを拡張するPrincipal
インターフェイスを実装し、Spring Securityとトークンを統合するためのほとんどの機構を含みます。
したがって、Kotlinコードでは(これをJavaに変換する時間や傾向がありません)、JwtAuthentication
はAbstractAuthenticationToken
の単純なラッパーになります。
import my.model.UserEntity
import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.core.GrantedAuthority
class JwtAuthentication(
val token: String,
// UserEntity is your application's model for your user
val user: UserEntity? = null,
authorities: Collection<GrantedAuthority>? = null) : AbstractAuthenticationToken(authorities) {
override fun getCredentials(): Any? = token
override fun getName(): String? = user?.id
override fun getPrincipal(): Any? = user
}
今、あなたはそれに対処する方法を知っているAuthenticationManager
が必要です。これはKotlinでも次のようになります。
@Component
class CustomTokenAuthenticationManager @Inject constructor(
val tokenHandler: TokenHandler,
val authService: AuthService) : AuthenticationManager {
val log = logger()
override fun authenticate(authentication: Authentication?): Authentication? {
return when(authentication) {
// for login via username/password e.g. crash Shell
is UsernamePasswordAuthenticationToken -> {
findUser(authentication).let {
//checkUser(it)
authentication.withGrantedAuthorities(it).also { setAuthenticated(true) }
}
}
// for token-based auth
is JwtAuthentication -> {
findUser(authentication).let {
val tokenTypeClaim = tokenHandler.parseToken(authentication.token)[CLAIM_TOKEN_TYPE]
when(tokenTypeClaim) {
TOKEN_TYPE_ACCESS -> {
//checkUser(it)
authentication.withGrantedAuthorities(it).also { setAuthenticated(true) }
}
TOKEN_TYPE_REFRESH -> {
//checkUser(it)
JwtAuthentication(authentication.token, it, listOf(SimpleGrantedAuthority(Authorities.REFRESH_TOKEN)))
}
else -> throw IllegalArgumentException("Unexpected token type claim $tokenTypeClaim.")
}
}
}
else -> null
}
}
private fun findUser(authentication: JwtAuthentication): UserEntity =
authService.login(authentication.token) ?:
throw BadCredentialsException("No user associated with token or token revoked.")
private fun findUser(authentication: UsernamePasswordAuthenticationToken): UserEntity =
authService.login(authentication.principal.toString(), authentication.credentials.toString()) ?:
throw BadCredentialsException("Invalid login.")
@Suppress("unused", "UNUSED_PARAMETER")
private fun checkUser(user: UserEntity) {
// TODO add these and lock account on x attempts
//if(!user.enabled) throw DisabledException("User is disabled.")
//if(user.accountLocked) throw LockedException("User account is locked.")
}
fun JwtAuthentication.withGrantedAuthorities(user: UserEntity): JwtAuthentication {
return JwtAuthentication(token, user, authoritiesOf(user))
}
fun UsernamePasswordAuthenticationToken.withGrantedAuthorities(user: UserEntity): UsernamePasswordAuthenticationToken {
return UsernamePasswordAuthenticationToken(principal, credentials, authoritiesOf(user))
}
private fun authoritiesOf(user: UserEntity) = user.authorities.map(::SimpleGrantedAuthority)
}
挿入されたTokenHandler
は、JWTトークンの解析を抽象化しますが、 jjwt のような一般的なJWTトークンライブラリを使用する必要があります。挿入されたAuthService
は、トークンのクレームに基づいて実際にUserEntity
を作成する抽象化であり、ユーザーデータベースまたは他のバックエンドシステムと通信できます。
さて、最初の行に戻ると、次のようになります。authenticationManager
は、Springによってアダプターに挿入されたAuthenticationManager
であり、CustomTokenAuthenticationManager
のインスタンスです上記:
Principal yourAuth = token == null ? null : authenticationManager.authenticate(new JwtAuthentication(token));
このプリンシパルは、上記のようにメッセージに添付されます。 HTH!
最新のSockJS 1.0.3では、クエリURLを接続URLの一部として渡すことができます。したがって、JWTトークンを送信してセッションを認証できます。
var socket = new SockJS('http://localhost/ws?token=AAA');
var stompClient = Stomp.over(socket);
stompClient.connect({}, function(frame) {
stompClient.subscribe('/topic/echo', function(data) {
// topic handler
});
}
}, function(err) {
// connection error
});
これで、websocketに関連するすべてのリクエストにパラメーター「?token = AAA」が含まれます
http:// localhost/ws/info?token = AAA&t = 144648250684
http:// localhost/ws/515/z45wjz24/websocket?token = AAA
次に、Springを使用して、提供されたトークンを使用してセッションを識別するフィルターを設定できます。