以下は 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での例)
チャレンジを取得(alice のチャレンジを要求)
curl -s -X POST http://localhost:8080/auth/challenge -H "Content-Type: application/json" \ -d '{"username":"alice"}' | jq# 返却例:# {"nonce":"BASE64_NONCE","expiresAt":1710000000000}
クライアント側で 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 -c や node の1行で HMAC を作るほうが簡単です。
検証リクエストを送る
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-..."}
セキュリティ上の注意点(重要)
TLS(HTTPS)を必ず使う — チャレンジやレスポンスは平文で盗聴されると危険。
shared secret の管理 — secretKey は平文保存してはいけない。KMS(Vault, AWS KMS等)や安全なストアを用いる。
nonce は短時間で使い捨て — 再利用やリプレイを防ぐ。サーバ側は必ず一回削除すること(上の実装では remove)。
レート制限 — ユーザーごとのリクエスト頻度を制限し総当たり攻撃を抑制する。
鍵導出 — ユーザーパスワードから直接 secretKey を使う場合は PBKDF2/Argon2 等で安全に導出し、かつサーバにそのままパスワードを保存しない。
タイミング攻撃対策 — 比較は定数時間で行う(上で constantTimeEquals を使用)。
サーバ発行トークン — 認証成功後は短寿命のJWT等を発行してセッション管理するのが一般的。
補足(実運用での拡張案)
ユーザーごとに 非対称鍵(公開鍵)方式 を使う(公開鍵をサーバに置き、クライアントは秘密鍵で署名する)と鍵配布の問題が解決しやすい。
OAuth/OpenID Connect と組み合わせるか、既存の認証基盤(LDAP, OIDC)を使うのが現場では一般的。
HSM/KMSでキー管理、監査証跡、ローテーションを行う。
この記事へのコメント