Spring におけるチャレンジアンドレスポンス認証の実装コードを書いてください

emile-perron-xrVDYZRGdw4-unsplash.jpg

質問

Spring におけるチャレンジアンドレスポンス認証の実装コードを書いてください


解答

以下は Spring Boot(Java) で実装する、簡単な チャレンジ&レスポンス認証 のサンプル実装です。
目的は概念と安全な運用のポイントを示すことで、実運用ではさらに認証情報の管理・TLS・レート制限・ログ監査などを強化してください。

実装の方針(サンプル)

  • クライアントが /auth/challenge にユーザー名を送り、サーバは 一回限りの nonce(チャレンジ) を生成して返す。

  • クライアントは自分の秘密(サーバ側で保持している shared secret またはユーザーごとの鍵)と nonce を使い HMAC-SHA256 を計算して /auth/verify へ送信(=レスポンス)。

  • サーバは保存済みの shared secret で同様に HMAC を計算して比較し、一致すれば認証成功。

  • nonce は使い捨て&有効期限付き、比較はタイミング攻撃対策で定数時間比較を行う。

以下は最小限で動くコード例(Spring Boot 3、Java 17想定)。


主要ファイル構成(例)

  • pom.xml(省略) — Spring Boot Web 依存のみでOK

  • com.example.demo.DemoApplication - 起動クラス

  • com.example.demo.auth.AuthController - 認証用コントローラ

  • com.example.demo.auth.ChallengeService - チャレンジ生成 / 検証ロジック

  • com.example.demo.user.UserService - ユーザー管理(サンプルではインメモリ)

  • com.example.demo.util.HmacUtils - HMAC/比較ユーティリティ

  • com.example.demo.config.SecurityConfig - Spring Security 設定(サンプルは認証無効化してエンドポイント公開)


1) DemoApplication.java

package com.example.demo;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplicationpublic class DemoApplication {  public static void main(String[] args) {    SpringApplication.run(DemoApplication.class, args);  }}

2) DTO:AuthDtos.java

package com.example.demo.auth;public class AuthDtos {  public static class ChallengeRequest {    public String username;  }  public static class ChallengeResponse {    public String nonce; // サーバが返すチャレンジ(Base64)    public long expiresAt; // 有効期限(epoch millis)  }  public static class VerifyRequest {    public String username;    public String response; // クライアントが計算したHMAC(Base64)  }  public static class VerifyResult {    public boolean ok;    public String message;    public String token; // 成功時に発行するトークン(サンプル)  }}

3) AuthController.java

package com.example.demo.auth;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;import org.springframework.beans.factory.annotation.Autowired;@RestController@RequestMapping("/auth")public class AuthController {  private final ChallengeService challengeService;  @Autowired  public AuthController(ChallengeService challengeService) {    this.challengeService = challengeService;  }  @PostMapping("/challenge")  public ResponseEntity<AuthDtos.ChallengeResponse> requestChallenge(@RequestBody AuthDtos.ChallengeRequest req) {    AuthDtos.ChallengeResponse res = challengeService.createChallenge(req.username);    if (res == null) {      return ResponseEntity.badRequest().build();    }    return ResponseEntity.ok(res);  }  @PostMapping("/verify")  public ResponseEntity<AuthDtos.VerifyResult> verify(@RequestBody AuthDtos.VerifyRequest req) {    AuthDtos.VerifyResult result = challengeService.verifyResponse(req.username, req.response);    if (result.ok) return ResponseEntity.ok(result);    return ResponseEntity.status(401).body(result);  }}

4) ChallengeService.java

package com.example.demo.auth;import org.springframework.stereotype.Service;import org.springframework.beans.factory.annotation.Autowired;import com.example.demo.user.UserService;import com.example.demo.user.User;import java.util.Base64;import java.security.SecureRandom;import java.time.Instant;import java.util.concurrent.ConcurrentHashMap;import java.util.Map;@Servicepublic class ChallengeService {  private final UserService userService;  private final SecureRandom random = new SecureRandom();  // nonce格納マップ(username -> challenge record)  private final Map<String, ChallengeRecord> store = new ConcurrentHashMap<>();  // nonce有効期間(ミリ秒)  private static final long NONCE_TTL_MS = 60_000; // 60秒  @Autowired  public ChallengeService(UserService userService) {    this.userService = userService;  }  public AuthDtos.ChallengeResponse createChallenge(String username) {    User u = userService.findByUsername(username);    if (u == null) return null;    byte[] nonce = new byte[32];    random.nextBytes(nonce);    String nonceB64 = Base64.getEncoder().encodeToString(nonce);    long expiresAt = Instant.now().toEpochMilli() + NONCE_TTL_MS;    store.put(username, new ChallengeRecord(nonce, expiresAt));    AuthDtos.ChallengeResponse res = new AuthDtos.ChallengeResponse();    res.nonce = nonceB64;    res.expiresAt = expiresAt;    return res;  }  public AuthDtos.VerifyResult verifyResponse(String username, String responseB64) {    AuthDtos.VerifyResult res = new AuthDtos.VerifyResult();    User u = userService.findByUsername(username);    if (u == null) {      res.ok = false; res.message = "unknown user"; return res;    }    ChallengeRecord rec = store.remove(username); // 一回限りにする -> remove    if (rec == null) {      res.ok = false; res.message = "no challenge"; return res;    }    if (Instant.now().toEpochMilli() > rec.expiresAt) {      res.ok = false; res.message = "challenge expired"; return res;    }    // サーバ側で期待されるHMACを計算(ユーザーの secretKey を使用)    byte[] expected = HmacUtils.hmacSha256(u.getSecretKey().getBytes(), rec.nonce);    byte[] actual;    try {      actual = Base64.getDecoder().decode(responseB64);    } catch (IllegalArgumentException e) {      res.ok = false; res.message = "bad response format"; return res;    }    boolean ok = HmacUtils.constantTimeEquals(expected, actual);    if (!ok) {      res.ok = false; res.message = "authentication failed"; return res;    }    // 成功 → 簡易トークン発行(サンプル)、実運用はJWT等を使用    res.ok = true;    res.message = "ok";    res.token = "SAMPLE-TOKEN-" + username + "-" + Instant.now().toEpochMilli();    return res;  }  private static class ChallengeRecord {    final byte[] nonce;    final long expiresAt;    ChallengeRecord(byte[] nonce, long expiresAt) { this.nonce = nonce; this.expiresAt = expiresAt; }  }}

5) UserService.java(簡易インメモリユーザー)

package com.example.demo.user;import org.springframework.stereotype.Service;import java.util.Map;import java.util.concurrent.ConcurrentHashMap;/** * サンプルの簡易ユーザーサービス。 * 実運用では DB と適切な鍵管理を行うこと。 */@Servicepublic class UserService {  private final Map<String, User> users = new ConcurrentHashMap<>();  public UserService() {    // サンプルユーザーを登録。secretKeyはサンプルの平文だが実際はKMS等で管理すること。    // username: alice, secretKey: supersecretalice    users.put("alice", new User("alice", "supersecretalice"));    // 別のユーザー    users.put("bob", new User("bob", "bob-secret-key"));  }  public User findByUsername(String username) {    return users.get(username);  }}

User クラス:

package com.example.demo.user;public class User {  private final String username;  private final String secretKey;  public User(String username, String secretKey) {    this.username = username;    this.secretKey = secretKey;  }  public String getUsername() { return username; }  public String getSecretKey() { return secretKey; }}

6) HmacUtils.java

package com.example.demo.auth;import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;import java.util.Arrays;public class HmacUtils {  public static byte[] hmacSha256(byte[] key, byte[] data) {    try {      Mac mac = Mac.getInstance("HmacSHA256");      mac.init(new SecretKeySpec(key, "HmacSHA256"));      mac.update(data);      return mac.doFinal();    } catch (Exception e) {      throw new RuntimeException(e);    }  }  // 定数時間比較(タイミング攻撃対策)  public static boolean constantTimeEquals(byte[] a, byte[] b) {    if (a == null || b == null) return false;    if (a.length != b.length) return false;    int diff = 0;    for (int i = 0; i < a.length; i++) {      diff |= a[i] ^ b[i];    }    return diff == 0;  }}

7) SecurityConfig.java(サンプル:全エンドポイントを許可)

package com.example.demo.config;import org.springframework.context.annotation.Configuration;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.web.SecurityFilterChain;import org.springframework.context.annotation.Bean;@Configurationpublic class SecurityConfig {  @Bean  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {    http.csrf().disable(); // APIのサンプルのため無効化(実運用では考慮)    http.authorizeHttpRequests().anyRequest().permitAll();    return http.build();  }}

動作確認(curlでの例)

  1. チャレンジを取得(alice のチャレンジを要求)

curl -s -X POST http://localhost:8080/auth/challenge -H "Content-Type: application/json" \  -d '{"username":"alice"}' | jq# 返却例:# {"nonce":"BASE64_NONCE","expiresAt":1710000000000}
  1. クライアント側で HMAC を計算(OpenSSL を使用した例)

NONCE="BASE64_NONCE"   # 上のレスポンスの nonce をコピー# デコードしてバイナリのnonceを取得して HMAC-SHA256 を計算し、Base64出力する例# Linux/macOSecho -n "$NONCE" | base64 --decode > /tmp/nonce.binecho -n -e "supersecretalice" > /tmp/key.binRESP=$(openssl dgst -sha256 -mac HMAC -macopt hexkey:$(xxd -p /tmp/key.bin) -binary /tmp/nonce.bin | base64)# あるいは簡単に Node/Python などで HMAC を作るのが現実的

注意:上の openssl コマンドは鍵のバイト列取り扱いに応じ調整が必要。実装環境によっては python -cnode の1行で HMAC を作るほうが簡単です。

  1. 検証リクエストを送る

curl -s -X POST http://localhost:8080/auth/verify -H "Content-Type: application/json" \  -d '{"username":"alice", "response":"BASE64_HMAC_FROM_CLIENT"}' | jq# 成功すると {"ok":true,"message":"ok","token":"SAMPLE-TOKEN-..."}

セキュリティ上の注意点(重要)

  1. TLS(HTTPS)を必ず使う — チャレンジやレスポンスは平文で盗聴されると危険。

  2. shared secret の管理secretKey は平文保存してはいけない。KMS(Vault, AWS KMS等)や安全なストアを用いる。

  3. nonce は短時間で使い捨て — 再利用やリプレイを防ぐ。サーバ側は必ず一回削除すること(上の実装では remove)。

  4. レート制限 — ユーザーごとのリクエスト頻度を制限し総当たり攻撃を抑制する。

  5. 鍵導出 — ユーザーパスワードから直接 secretKey を使う場合は PBKDF2/Argon2 等で安全に導出し、かつサーバにそのままパスワードを保存しない。

  6. タイミング攻撃対策 — 比較は定数時間で行う(上で constantTimeEquals を使用)。

  7. サーバ発行トークン — 認証成功後は短寿命のJWT等を発行してセッション管理するのが一般的。


補足(実運用での拡張案)

  • ユーザーごとに 非対称鍵(公開鍵)方式 を使う(公開鍵をサーバに置き、クライアントは秘密鍵で署名する)と鍵配布の問題が解決しやすい。

  • OAuth/OpenID Connect と組み合わせるか、既存の認証基盤(LDAP, OIDC)を使うのが現場では一般的。

  • HSM/KMSでキー管理、監査証跡、ローテーションを行う。

 



あくまでAIが書いたコードなので過信はほどほどに。。


Spring徹底入門 第2版 Spring FrameworkによるJavaアプリケーション開発 [ 株式会社NTTデータ ]

価格:4730円
(2025/9/11 21:13時点)
感想(1件)


Python[完全]入門 [ 松浦健一郎 ]

価格:3190円
(2023/12/2 00:51時点)
感想(1件)


 



この記事へのコメント