技術スタックの概要
バックエンド:Spring Boot
Spring Bootは、Tomcat、Jetty、Undertowなどのサーブレットコンテナを組み込んでいるため、WARファイルのデプロイを省略し、JARファイルとしてアプリケーションを直接実行できます。最大の特徴は「自動設定」機能であり、クラスパス上の依存関係に基づいてBeanの設定やライブラリのセットアップを自動的に行います。これにより、開発者は複雑なXML設定の手間を大幅に削減でき、ビジネスロジックの実装に集中できます。また、Spring Data、Spring Security、Spring Cloudといったエコシステムとの親和性も高く、マイクロサービスや拡張性の高いアプリケーションを迅速に構築するためのデファクトスタンダードとなっています。
フロントエンド:Vue.jsとUni-app
Vue.jsは、仮想DOM(Virtual DOM)を採用した軽量なJavaScriptフレームワークです。データバインディングがリアクティブに行われるため、データの変更が即座にUIに反映されます。これにより、DOM操作の手間から解放され、データ処理に専念できる開発体験を提供します。また、コンポーネント指向のアーキテクチャにより、UI部品の再利用性と保守性が向上します。本システムでは、Vue.jsの応用であるUni-appを使用することで、単一のコードベースからWebアプリケーションだけでなく、iOSやAndroidなどのモバイルアプリケーションも同時に展開可能なクロスプラットフォーム環境を構築しています。
データ永続化:MyBatis-Plus
MyBatis-Plusは、MyBatisを拡張したORMフレームワークであり、共通のCRUD操作をほとんどコードを書くことなく実装できます。MapperインターフェースにBaseMapperを継承させるだけで、挿入、更新、削除、選択などの基本機能が利用可能になります。さらに、条件構築にラムダ式を使用でき、タイプセーフなクエリ記述が可能です。ページング処理や楽観的ロック、SQLパフォーマンス分析などの実用的な機能も備えており、データアクセス層の開発効率を劇的に向上させます。
システムテスト
テストの目的
システムテストは、リリース前の品質を保証する最終段階です。本システムでは、ブラックボックステスト(黒箱テスト)手法を採用し、ユーザーの視点に立ってシステムの挙動を検証します。主な目的は、要件定義書で定義された機能が正しく動作するか、異常な入力やエッジケースに対してシステムが適切に振る舞うかを確認することです。テストを通じて潜在的なバグを特定し修正を行うことで、システムの信頼性とユーザビリティを向上させます。
機能テスト項目
1. ログイン機能検証
ユーザー認証プロセスの検証を行います。データベースに登録された情報との照合、エラーハンドリング、およびセッション管理の正当性を確認します。
| テストシナリオ(入力データ) | 期待される結果 | 実際の結果 | 判定 |
|---|---|---|---|
| ユーザー名: admin パスワード: 正しいパスワード キャプチャ: 正解 |
システムメイン画面へ遷移 | 正常にログインし、ダッシュボードを表示 | 合格 |
| ユーザー名: admin パスワード: 誤ったパスワード キャプチャ: 正解 |
認証エラーメッセージの表示 | 「パスワードが一致しません」というエラーを表示 | 合格 |
| ユーザー名: admin パスワード: 正しいパスワード キャプチャ: 誤り |
認証コードエラー | 「画像認証コードが正しくありません」と表示 | 合格 |
| ユーザー名: (空欄) パスワード: 任意の文字列 キャプチャ: 正解 |
入力検証エラー | 「ユーザー名を入力してください」と警告表示 | 合格 |
2. ユーザー管理機能検証
管理者によるユーザーアカウントの追加、更新、削除、および検索機能の整合性を確認します。データベース整合性制約とUIのフィードバックの一致に注目します。
| 操作内容 | 期待される結果 | 実際の結果 | 判定 |
|---|---|---|---|
| 新規ユーザー情報を入力して登録 | リストに新規ユーザーが表示される | データベースに保存され、一覧画面に即時反映 | 合格 |
| 既存ユーザーの情報を編集 | 変更内容が保存され表示される | 更新後の情報で画面上のデータが書き換わる | 合格 |
| ユーザーを選択して削除実行 | 確認ダイアログの後、データが削除される | 確認後に該当レコードが一覧から消滅 | 合格 |
| 必須項目(ユーザー名)を空にして登録 | バリデーションエラー | 「ユーザー名は必須です」というメッセージを表示 | 合格 |
| 重複するユーザー名で登録を試行 | 一意性制約エラー | 「このユーザー名は既に使用されています」とエラー表示 | 合格 |
実装コード詳細
認証コントローラの実装
以下は、Spring Bootを使用した認証エンドポイントの実装例です。リファクタリングにより、セキュリティ強化と可読性の向上を図っています。ユーザー情報の検証からトークンの発行までのフローを示しています。
@RestController
@RequestMapping("/api/auth")
public class AuthenticationController {
@Autowired
private AccountService accountService;
@Autowired
private JwtTokenService jwtTokenService;
/**
* ユーザーログイン処理
*/
@PostMapping("/authenticate")
public ApiResponse<LoginResult> authenticateUser(@RequestBody LoginDto loginDto, HttpServletRequest request) {
// ユーザーの存在確認とパスワード検証
SysUser user = accountService.findByUsername(loginDto.getUsername())
.orElseThrow(() -> new BadCredentialsException("認証情報が無効です"));
if (!PasswordEncoder.matches(loginDto.getPassword(), user.getHashedPassword())) {
throw new BadCredentialsException("パスワードが一致しません");
}
// JWTトークンの生成(有効期限1時間)
String accessToken = jwtTokenService.generateToken(user.getId(), user.getUsername(), user.getRole());
// トークン情報のDB永続化
jwtTokenService.saveOrUpdateToken(user, accessToken);
return ApiResponse.success(new LoginResult(accessToken, user.getRole()));
}
}
トークンサービスの実装
トークンの生成および管理ロジックです。`UUID`を使用してトークン文字列を生成し、有効期限を`LocalDateTime`で管理しています。既存のトークンがある場合は更新し、ない場合は新規作成する処理を実装しています。
@Service
public class JwtTokenService {
@Autowired
private AuthTokenRepository authTokenRepository;
public String generateToken(Long userId, String username, String role) {
// ランダムなトークン文字列を生成
String tokenValue = UUID.randomUUID().toString().replace("-", "");
// 有効期限を現在時刻から1時間後に設定
LocalDateTime expiryDate = LocalDateTime.now().plusHours(1);
// 既存トークンの確認と更新または新規登録
AuthToken existingToken = authTokenRepository.findByUserIdAndRole(userId, role);
if (existingToken != null) {
existingToken.setTokenValue(tokenValue);
existingToken.setExpiryDate(expiryDate);
authTokenRepository.updateById(existingToken);
} else {
AuthToken newToken = new AuthToken();
newToken.setUserId(userId);
newToken.setUsername(username);
newToken.setRole(role);
newToken.setTokenValue(tokenValue);
newToken.setExpiryDate(expiryDate);
authTokenRepository.insert(newToken);
}
return tokenValue;
}
}
認証インターセプターの実装
リクエストごとの権限チェックを行うインターセプターです。CORS(Cross-Origin Resource Sharing)のプリフライトリクエストへの対応や、ヘッダーからのトークン抽出、セッション属性への設定を行っています。`@PermitAll`アノテーションが付与されたメソッドは認証をスキップします。
@Component
public class SecurityInterceptor implements HandlerInterceptor {
public static final String AUTH_HEADER = "X-Auth-Token";
@Autowired
private JwtTokenService jwtTokenService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// CORS設定
setCorsHeaders(response);
// プリフライトリクエスト(OPTIONS)は許可
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
response.setStatus(HttpServletResponse.SC_OK);
return false;
}
// アノテーションによる認証スキップチェック
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
if (handlerMethod.getMethodAnnotation(PermitAll.class) != null) {
return true;
}
}
// トークンの取得と検証
String token = request.getHeader(AUTH_HEADER);
if (StringUtils.isEmpty(token)) {
sendErrorResponse(response, 401, "認証トークンが見つかりません");
return false;
}
AuthToken authToken = jwtTokenService.validateToken(token);
if (authToken == null || authToken.getExpiryDate().isBefore(LocalDateTime.now())) {
sendErrorResponse(response, 401, "トークンの有効期限が切れているか、無効です");
return false;
}
// セッション情報の設定
request.getSession().setAttribute("currentUserId", authToken.getUserId());
request.getSession().setAttribute("currentUserRole", authToken.getRole());
request.getSession().setAttribute("currentUsername", authToken.getUsername());
return true;
}
private void setCorsHeaders(HttpServletResponse response) {
response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
response.setHeader("Access-Control-Max-Age", "3600");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Auth-Token, Authorization");
response.setHeader("Access-Control-Allow-Origin", "*");
}
private void sendErrorResponse(HttpServletResponse response, int status, String message) throws IOException {
response.setStatus(status);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":" + status + ",\"message\":\"" + message + "\"}");
}
}
データベース設計
認証トークンテーブル定義
セキュリティ管理の中核となるトークン情報を格納するデータベーススキーマです。ユーザーID、ロール、トークン文字列、および有効期限を管理します。
-- ----------------------------
-- Table structure for auth_tokens
-- ----------------------------
DROP TABLE IF EXISTS `auth_tokens`;
CREATE TABLE `auth_tokens` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主キーID',
`user_id` bigint(20) NOT NULL COMMENT 'ユーザーID',
`username` varchar(100) NOT NULL COMMENT 'アカウント名',
`role` varchar(50) NOT NULL COMMENT 'ロール名',
`token_value` varchar(255) NOT NULL COMMENT 'アクセストークン',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '作成日時',
`expiry_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '有効期限',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_token` (`token_value`),
KEY `idx_user_role` (`user_id`, `role`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='認証トークン管理テーブル';
-- ----------------------------
-- Initial Data (Sample)
-- ----------------------------
INSERT INTO `auth_tokens` (`user_id`, `username`, `role`, `token_value`, `expiry_date`) VALUES
(1, 'admin', 'ADMIN', 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6', DATE_ADD(NOW(), INTERVAL 1 HOUR)),
(23, 'operator01', 'ENGINEER', 'z9y8x7w6v5u4t3s2r1q0p9o8n7m6l5k4', DATE_ADD(NOW(), INTERVAL 1 HOUR)),
(45, 'user_suzuki', 'USER', 'm1n2b3v4c5x6z7l8k9j0h1g2f3d4s5a6', DATE_ADD(NOW(), INTERVAL 1 HOUR));