1. 概要
SORACOMの認証サービスEndorseの検証をしてみました。今回はSpringBootで作ったAPIサーバで利用するため、SORACOM SIM経由でデバイスがログインAPIにアクセスすると認証処理を自動で行いセッションを払い出す仕組みを検討しました。
一通り動いたのでメモ書きです。
2. Soracom Endorseとは
SORACOM EndorseはSORACOMが提供しているサービスの一つです。SIMの認証を利用してドコモのモバイル網内でSORACOM Airを使っている場合のみJWT(JSON Web Token)形式のトークンを発行します。
SORACOM Endorseが発行するJWTにはSORACOM特有の情報が記載されています。まず、ヘッダ内にkidと言うkeyでJWTにつけられた署名の公開鍵名が記載されています。この公開鍵はSORACOM指定のAmazon S3サーバからPEM形式でダウンロードできます。
また、JWTトークン内にはSIMの情報とユーザ定義の情報を埋め込むこともできます。
さらに、認証サーバアクセス時のtoken読み出しに指定したリクエストパラメータを埋め込むことができます。リクエストパラメータにリダイレクトの設定を行うと、取得したJWTトークンをリクエストパラメータに設定し、指定のURLにリダイレクトさせることも可能です。
今回はこのリダイレクトの機能を使い、作成したログインAPIにアクセスしただけで認証処理が走るようにします。
※ 詳細はSORACOMさんの公式ドキュメントを参照してください。
3. サービスへのアクセスの流れ
- 下記のような流れを検討しました。
- クライアントからログインAPI(/login)にアクセスするとSORACOM Endorseにリダイレクトする。
- SORACOM Endorseのサーバに対してリクエストパラメータ(redirect_url)を指定してアクセスする。
- クライアントはSORACOM EndorseサーバからJWTトークンを取得する。
- SORACOM Endorseサーバからredirect_urlで指定したサーバにリダイレクトされる。取得したJWTトークンはリクエストパラメータsoracom_endorse_tokenで渡される。
- 元のページにリダイレクトされると同時にspring-securityのPreAuthenticationFilterでJWTを検証し、セッションを払い出す。
4. SpringBootの実装
PreAuthentication周りの実装と設定は以下の記事を参考にしました。
Spring Security(Spring Boot)で、PreAuthentication
ここでは差分を紹介します。
- WebSecurityConfigureのconfig Spring Securityの設定は次の通りです。 /loginアクセスをリダイレクトさせるため、ログインなしでアクセスできるようにします。
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/login").permitAll()
.anyRequest().fullyAuthenticated()
.and().authorizeRequests().antMatchers("/").permitAll().anyRequest()
.authenticated()
.and().addFilter(preAuthenticatedProcessingFilter());
}
- AbstractPreAuthenticatedProcessingFilterの実装
リクエストパラメータsoracom_endorse_tokenを取得します。これはみログイン状態のリクエストが来た場合に、実行されます。
public class PreAuthenticationFilterWithJwt extends AbstractPreAuthenticatedProcessingFilter {
@Override
protected Object getPreAuthenticatedCredentials(HttpServletRequest request) {
String jwtToken = request.getParameter("soracom_endorse_token");
if(jwtToken==null){
return "";
}
return jwtToken;
}
@Override
protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
return "";
}
}
- AuthenticationUserDetailsServiceの実装クラス
JWTの検証とJWTに含まれるClaim情報を引き出します。今回はClaimの中からSIMのIMSIを取り出しています。またJWTの検証やパースにはjjwt.jarを利用しています。spring securityのOAuthパッケージにもJWTのライブラリがあるようですが、今回はこちらを利用しました。
public class MyUserDetailService implements AuthenticationUserDetailsService<PreAuthenticatedAuthenticationToken> {
@Override
public UserDetails loadUserDetails(PreAuthenticatedAuthenticationToken token) throws UsernameNotFoundException {
Object credentials = token.getCredentials();
String imsi = "";
SigningKeyResolver resolver = new SoracomSigningKeyResolver();
try{
Claims claims = Jwts.parser().requireSubject("soracom-endorse")
.requireAudience("soracom-endorse-audience")
.setSigningKeyResolver(resolver)
.parseClaimsJws((String) credentials).getBody();
LinkedHashMap<String,String> soracomClaim =
(LinkedHashMap<String, String>) claims.get("soracom-endorse-claim");
imsi = (String)soracomClaim.get("imsi");
} catch (ExpiredJwtException e){
throw new UsernameNotFoundException("JwtToken was expired.");
} catch (MalformedJwtException e){
throw new UsernameNotFoundException("JwtToken was malformed token value.");
} catch (IllegalArgumentException e){
throw new UsernameNotFoundException("JWT String argument cannot be null or empty.");
}
Collection<GrantedAuthority> authorities =new HashSet<GrantedAuthority>() ;
authorities.add(new AdminAuthority());
User user = new User(imsi,"",authorities);
return user;
}
}
- SoracomSigningKeyResolverの実装
JWTの署名を検証する場合に公開鍵を設定する必要があります。jwttの場合、SigningKeyResolverインタフェースを実装したクラスのインスタンスをJwts:setSigningKeyResolverに渡します。
また、SORACOMの署名の公開鍵はpemファイルで用意されています。pemファイルの読み込みについては下記サイトの実装を利用させていただきました。
JavaSE RSA暗号 Java8
public class SoracomSigningKeyResolver implements SigningKeyResolver {
@Value(value = "soracom.certfile")
private final String publicCert = "v1-f2fea060b93f510bfb722f2cd4b3774e-x509.pem";
@Override
public Key resolveSigningKey(JwsHeader jwsHeader, String plaintxt) {
PublicKey pubKey=null;
try {
pubKey = KeyFactory.getInstance("RSA")
.generatePublic(new X509EncodedKeySpec(readKey(publicCert)));
} catch (Exception e) {
e.printStackTrace();
}
return pubKey;
}
@Override
public Key resolveSigningKey(JwsHeader jwsHeader, Claims claim) {
PublicKey pubKey=null;
try {
pubKey = KeyFactory.getInstance("RSA")
.generatePublic(new X509EncodedKeySpec(readKey(publicCert)));
} catch (Exception e){
e.printStackTrace();
}
return pubKey;
}
private static byte[] readKey(final String fileName) throws Exception {
ClassLoader loader = Thread.currentThread().getContextClassLoader();
InputStream keyStream = loader.getResourceAsStream(fileName);
try (BufferedReader br = new BufferedReader(new InputStreamReader(keyStream))) {
String line;
StringBuilder sb = new StringBuilder();
boolean isContents = false;
while ((line = br.readLine()) != null) {
if (line.matches("[-]+BEGIN[ A-Z]+[-]+")) {
isContents = true;
} else if (line.matches("[-]+END[ A-Z]+[-]+")) {
break;
} else if (isContents) {
sb.append(line);
}
}
return Base64.getDecoder().decode(sb.toString());
} catch (FileNotFoundException e) {
throw new Exception("File not found.", e);
} catch (IOException e) {
throw new Exception("can't read the PEM file.", e);
}
}
}
- セッションの払い出し
セッションの払い出しを行います。今回はspring-session-redisを下記の資料を参考に設定しました。
http://www.slideshare.net/makingx/rest-with-spring-boot-jqfk
別途Redisを起動させる必要があります。
@EnableRedisHttpSession
public class HttpSessionConfig {
@Bean
HttpSessionStrategy
httpSessionStrategy(){
return new HeaderHttpSessionStrategy();
}
}
- /loginの作成
/loginにアクセスするとSORACOM Endorseサーバにリダイレクトするようにします。この部分は@Controllerで実装します。またSORACOM Endorse側の設定でSORACOM側からのリダイレクト先(redirect_url)を設定しておく必要があります。
@Controller
public class LoginEndpoint {
@RequestMapping("/login")
String loginRedirect(){
return "redirect:https://endorse.soracom.io/?redirect_url=http://localhost:8080/";
}
}
- API
ログイン後の確認用に以下のような処理を用意しておきます。
ログイン成功後アクセスするとメッセージとともにSIMのIMSIが表示されるはずです。実際にはIMSIと紐づけられたユーザ名や端末名を表示することになるかと思います。また、Endorsからのリダイレクト先である/にアクセスした際にtextを返すようにしています。
@RestController
public class api {
@RequestMapping("/")
String successLogin(){
return "success Login";
}
@RequestMapping("/api/v1/info")
String getInfo(Principal principal){
return "Success authentcation via soracom:" + principal.getName();
}
5. 実験
実験1 SORACOM Airじゃない場合
SORACOM Airじゃない場合の挙動です。
$ curl -v -L http://localhost:8080/login
* Trying ::1...
* Connected to localhost (::1) port 8080 (#0)
> GET /login HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.43.0
> Accept: */*
>
< HTTP/1.1 302 Found
< Server: Apache-Coyote/1.1
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Location: https://endorse.soracom.io/?redirect_url=http://localhost:8080/
< Content-Language: ja-JP
< Content-Length: 0
< Date: Tue, 17 May 2016 15:12:35 GMT
<
* Connection #0 to host localhost left intact
* Issue another request to this URL: 'https://endorse.soracom.io/?redirect_url=http://localhost:8080/'
* Trying 54.250.252.99...
* Connected to endorse.soracom.io (54.250.252.99) port 443 (#1)
* TLS 1.2 connection using TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
* Server certificate: endorse.soracom.io
* Server certificate: Let's Encrypt Authority X1
* Server certificate: DST Root CA X3
> GET /?redirect_url=http://localhost:8080/ HTTP/1.1
> Host: endorse.soracom.io
> User-Agent: curl/7.43.0
> Accept: */*
>
< HTTP/1.1 403 Forbidden
< Content-Type: application/json
< Content-Length: 51
< Date: Tue, 17 May 2016 15:12:37 GMT
< Connection: keep-alive
<
* Connection #1 to host endorse.soracom.io left intact
"Use SORACOM Air to access SORACOM Endorse server.
SORACOMのEndorseサーバにアクセスしていますが、SORACOMの回線経由ではないためトークンが取得できませんでした。
実験2 SORACOM Airの場合
$curl -v -L http://localhost:8080/login
* Trying ::1...
* Connected to localhost (::1) port 8080 (#0)
> GET /login HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.43.0
> Accept: */*
>
< HTTP/1.1 302 Found
< Server: Apache-Coyote/1.1
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Location: https://endorse.soracom.io/?redirect_url=http://localhost:8080/
< Content-Language: ja-JP
< Content-Length: 0
< Date: Tue, 17 May 2016 23:15:00 GMT
<
* Connection #0 to host localhost left intact
* Issue another request to this URL: 'https://endorse.soracom.io/?redirect_url=http://localhost:8080/'
* Trying 54.250.252.99...
* Connected to endorse.soracom.io (54.250.252.99) port 443 (#1)
* TLS 1.2 connection using TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
* Server certificate: endorse.soracom.io
* Server certificate: Let's Encrypt Authority X1
* Server certificate: DST Root CA X3
> GET /?redirect_url=http://localhost:8080/ HTTP/1.1
> Host: endorse.soracom.io
> User-Agent: curl/7.43.0
> Accept: */*
>
< HTTP/1.1 302 Moved Temporarily
< Location: http://localhost:8080/?soracom_endorse_token=eyJr
~~~~~~~~~~~~~~~~~~~~~~~~~ 中略(JWTトークン)~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
< Date: Tue, 17 May 2016 23:15:00 GMT
< Connection: keep-alive
< Transfer-Encoding: chunked
<
* Ignoring the response-body
* Connection #1 to host endorse.soracom.io left intact
* Issue another request to this URL: 'http://localhost:8080/?soracom_endorse_token=eyJr
~~~~~~~~~~~~~~~~~~~~~~~~~ 中略(JWTトークン)~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* Found bundle for host localhost: 0x7f8d92f05cd0
* Re-using existing connection! (#0) with host localhost
* Connected to localhost (::1) port 8080 (#0)
> GET /?soracom_endorse_token=eyJr
~~~~~~~~~~~~~~~~~~~~~~~~~ 中略(JWTトークン)~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.43.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Server: Apache-Coyote/1.1
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< x-auth-token: 168df74f-2bda-405f-8aed-7f6c5c8174a7
< Content-Type: text/plain;charset=UTF-8
< Content-Length: 13
< Date: Tue, 17 May 2016 23:15:00 GMT
<
* Connection #0 to host localhost left intact
success Login
/loginからEndorseサーバにリダイレクト後、JWTを取得して再度、APIにアクセスし、ログインが成功しました。x-auth-tokenも払い出されています。
続いて、取得したx-auth-tokenをヘッダに設定してアクセスします。
$curl -v -H "x-auth-token: 168df74f-2bda-405f-8aed-7f6c5c8174a7"
http://localhost:8080/api/v1/info
* Trying ::1...
* Connected to localhost (::1) port 8080 (#0)
> GET /api/v1/info HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.43.0
> Accept: */*
> x-auth-token: 168df74f-2bda-405f-8aed-7f6c5c8174a7
>
< HTTP/1.1 200 OK
< Server: Apache-Coyote/1.1
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Content-Type: text/plain;charset=UTF-8
< Content-Length: 49
< Date: Tue, 17 May 2016 23:17:24 GMT
<
* Connection #0 to host localhost left intact
Success authentcation via soracom:xxxxxxxxxxxxxxx
アクセスできました。また、認証情報にIMSIを設定していましたので、そちらを取り出すことができます。(最後のxxxxxxxxxxxxxxxは実際にはIMSIが表示されています)
まとめ
SORACOM Endorseを使うとデバイス側にIDやパスワードなど、認証のための情報を持たせずに使うことができると思います。また、利用の可否もSORACOMの管理画面から設定可能なため、遠隔地に置いたデバイスを扱いやすくなるのではないでしょうか。
また、JWTにSIMの情報を含めることができるため、デバイスごとにアクセスできるAPIを分けたりできます。デバイスごとのデータをアプリケーション側で管理保存する場合もデバイスのIDなどを改めて送る必要がないです。
認証からアプリケーションまでEndorseは幅広く使えそうです。
ところでSIMの情報に接続している基地局の位置情報を含めることができればユースケースが広がるのではと思います。