VSCode で DOMA3 と Spring Security を用いた API ログイン認証を実装する

VSCode で DOMA3 と Spring Security を用いた API ログイン認証を実装する

2025-05-01

必要な拡張機能をインストールする

code --install-extension humao.rest-client
code --install-extension vmware.vscode-boot-dev-pack
code --install-extension cweijan.vscode-postgresql-client2
code --install-extension yzhang.markdown-all-in-one

Spring Initializer で新規プロジェクトを作成する

以下を選択する

初期化した後のGradle.buildは下記の通り。

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.4.5'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    // Spring Boot dependencies
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'

    // lombok
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    // test
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

Doma3 を導入する

build.gradleにDoma系のライブラリを追記する。 ※詳細はEclipse + Java + Gradle の環境で Doma を動かすを参照してください。

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.4.5'
    id 'io.spring.dependency-management' version '1.1.7'

    id 'com.diffplug.eclipse.apt' version '3.44.0'         // Eclipse のアノテーションプロセッシング関連の設定ファイルを生成するプラグイン
    id 'org.domaframework.doma.compile' version '2.0.0' // Doma を使ったビルドをサポートするプラグイン
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    // Spring Boot dependencies
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'

    // lombok
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    // testing
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'

    // Doma       
    implementation 'org.seasar.doma:doma-core'                  // Doma の核となるライブラリ。ビルド時も実行時も必要。
    implementation'org.seasar.doma:doma-slf4j'                     // Doma のログをSLF4J経由で出力するライブラリ。
    annotationProcessor 'org.seasar.doma:doma-processor:3.6.0'    // Doma のアノテーションプロセッサー。ビルド時のみ必要。

    // logback(SLF4J)
    runtimeOnly 'ch.qos.logback:logback-classic'                // SLF4J の実装ライブラリ。

    // postgresql JDBCドライバ
    runtimeOnly 'org.postgresql:postgresql'                        // データベース接続用のJDBCドライバ。
}

tasks.named('test') {
    useJUnitPlatform()
}

// Eclipse 関連の設定
eclipse {
    // .classpath ファイルに関する設定
    classpath {
        file {
            whenMerged { classpath ->
                // アノテーションプロセッサーで生成したソースコードを保存するフォルダ
                def folder = new org.gradle.plugins.ide.eclipse.model.SourceFolder(".apt_generated", "bin/main")
                // クラスパスに追加
                classpath.entries.add(folder)
                // 存在しなければ作成
                def dir = file(folder.path)
                if (!dir.exists()) {
                    dir.mkdir()
                }
            }
        }
    }
    // .project ファイルに関する設定
    project {
        buildCommand 'org.eclipse.buildship.core.gradleprojectbuilder'
        natures 'org.eclipse.buildship.core.gradleprojectnature'
    }
    // Eclipse から "Refresh Gradle Project" を呼び出した時に実行されるタスク
    synchronizationTasks 'cleanEclipse', 'eclipse'
}

ただし今回は IDE ではなく、VSCodeを利用しているため、このままでは動作しない。

gradle eclipseを実行する

gradle eclipseを実行し、下記のファイルが作成されることを確認する。

その後下記のエラーが出ている場合は、.settings/org.eclipse.buildship.core.prefsを手動で作成してください。

Missing Gradle project configuration file: .settings/org.eclipse.buildship.core.prefs

.settings/org.eclipse.buildship.core.prefsには下記の内容を記入しておいてください。

connection.project.dir=
eclipse.preferences.version=1

ここで一旦gradle buildを実行し、成功するか確認してください。 問題なければ、次へ進みます。

※エラーが出る場合は gradle cleanEclipse eclipse を実行し、設定ファイルを再生成してみてください。

doma code-gen を導入する

Doma CodeGen プラグインを用いてDomaソース一式を作成します。

まずは対象のテーブルを作成します。

CREATE TABLE user_admin (
    user_id VARCHAR(100) PRIMARY KEY,
    user_name VARCHAR(100) NOT NULL,
    email VARCHAR(100) NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

その後、build.gradleに Doma CodeGen の設定を追記します。

// buildscriptを追加
buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath 'org.postgresql:postgresql:42.7.5' // 利用するJDBCドライバを指定する
    }
}


plugins {
    id 'java'
    id 'org.springframework.boot' version '3.4.5'
    id 'io.spring.dependency-management' version '1.1.7'

    id 'com.diffplug.eclipse.apt' version '4.3.0'         
    id 'org.domaframework.doma.compile' version '3.0.1'

    id 'org.domaframework.doma.codegen' version '3.0.0' // doma-codegen プラグインを追加する
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    // Spring Boot dependencies
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'

    // lombok
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    // testing
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'

    // Doma       
    implementation 'org.seasar.doma:doma-core:3.6.0'
    implementation'org.seasar.doma:doma-slf4j:3.6.0'
    implementation 'org.seasar.doma:doma-processor:3.6.0'

    // logback(SLF4J)
    runtimeOnly 'ch.qos.logback:logback-classic'

    // postgresql JDBCドライバ
    runtimeOnly 'org.postgresql:postgresql'
}

tasks.named('test') {
    useJUnitPlatform()
}

eclipse {
    classpath {
        file {
            whenMerged { classpath ->
                def folder = new org.gradle.plugins.ide.eclipse.model.SourceFolder(".apt_generated", "bin/main")
                classpath.entries.add(folder)
                def dir = file(folder.path)
                if (!dir.exists()) {
                    dir.mkdir()
                }
            }
        }
    }
    project {
        buildCommand 'org.eclipse.buildship.core.gradleprojectbuilder'
        natures 'org.eclipse.buildship.core.gradleprojectnature'
    }
    synchronizationTasks 'cleanEclipse', 'eclipse'
}

// SQLファイルを'bin/default/META-INF'にコピーするタスクを定義しておく。
task _copySqls(type: Copy) {
  from 'src/main/resources/META-INF'
  into 'bin/default/META-INF'
}
compileJava.dependsOn _copySqls //compileJavaタスクに依存させておく。

// Doma-codegen タスクを定義する
domaCodeGen {
    // make an arbitrary named block
    dev {
        // JDBC url
        url = 'jdbc:postgresql:postgres'
        // JDBC user
        user = 'postgres'
        // JDBC password
        password = 'postgres'
        // configuration for generated entity source files
        entity {
          packageName = 'com.example.demo.doma'
        }
        // configuration for generated DAO source files
        dao {
          packageName = 'com.example.demo.doma'
        }
    }
}

追記後、gradle domagenDevAllを実行し、各種ソースコードを生成してください。

Doma が動作するまで

VSCodeで操作していると、Domaが安定しない。。。 その場合は下記を確認してみてください。

簡単なAPIを作成する

管理ユーザ作成用APIを作成します。

package com.example.demo.domain.user.admin;

import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.example.demo.domain.user.admin.entity.Admin;


@RestController
@RequestMapping("/api/user/admin")
public class AdminContoroller {

    private final AdminRepository adminRepository;
    private final AdminService adminService;

    AdminContoroller(AdminRepository adminRepository, AdminService adminService) {
        this.adminRepository = adminRepository;
        this.adminService = adminService;
    }
    
    @PostMapping("/create")
    public Admin create(@Validated @RequestBody Admin entity) {
        String encodedPassword = adminService.encodePassword(entity.getPassword());
        adminRepository.create(entity.getUserId(), entity.getUserName(), entity.getEmail(), encodedPassword);
        return entity;
    }
}

package com.example.demo.domain.user.admin;

import org.springframework.stereotype.Repository;

import com.example.demo.doma.dao.UserAdminDao;
import com.example.demo.doma.entity.UserAdmin;

@Repository
public class AdminRepository {

    private final UserAdminDao userAdminDao;

    public AdminRepository(UserAdminDao userAdminDao) {
        this.userAdminDao = userAdminDao;
    }

    public void create(String userId, String userName, String email, String password) {
        
        UserAdmin userAdnmin = new UserAdmin();
        userAdnmin.setUserId(userId);
        userAdnmin.setUserName(userName);
        userAdnmin.setEmail(email);
        userAdnmin.setPasswordHash(password);

        userAdminDao.insert(userAdnmin);
    }

    public UserAdmin findByUserId(String userId) {
        return userAdminDao.selectById(userId);
    }
}

package com.example.demo.domain.user.admin;

import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class AdminService {
    
    private final PasswordEncoder passwordEncoder;
    AdminService(PasswordEncoder passwordEncoder) {
        this.passwordEncoder = passwordEncoder;
    }
    
    public String encodePassword(String password) {
        return passwordEncoder.encode(password);
    }

}
package com.example.demo.doma.entity;

import java.time.LocalDateTime;

import org.seasar.doma.jdbc.entity.EntityListener;
import org.seasar.doma.jdbc.entity.PostDeleteContext;
import org.seasar.doma.jdbc.entity.PostInsertContext;
import org.seasar.doma.jdbc.entity.PostUpdateContext;
import org.seasar.doma.jdbc.entity.PreDeleteContext;
import org.seasar.doma.jdbc.entity.PreInsertContext;
import org.seasar.doma.jdbc.entity.PreUpdateContext;

/**
 * 
 */
public class UserAdminListener implements EntityListener<UserAdmin> {

    @Override
    public void preInsert(UserAdmin entity, PreInsertContext<UserAdmin> context) {
        LocalDateTime now = LocalDateTime.now();    //リスナーでタイムスタンプを発行できるようにしておく。
        entity.setCreatedAt(now);
        entity.setUpdatedAt(now);
    }

    @Override
    public void preUpdate(UserAdmin entity, PreUpdateContext<UserAdmin> context) {
    }

    @Override
    public void preDelete(UserAdmin entity, PreDeleteContext<UserAdmin> context) {
    }

    @Override
    public void postInsert(UserAdmin entity, PostInsertContext<UserAdmin> context) {
    }

    @Override
    public void postUpdate(UserAdmin entity, PostUpdateContext<UserAdmin> context) {
    }

    @Override
    public void postDelete(UserAdmin entity, PostDeleteContext<UserAdmin> context) {
    }
}

REST Client で下記リクエストを送信してみると、無事管理者ユーザを登録できました。

# ユーザー作成APIを呼び出す
POST http://localhost:8080/api/user/admin/create HTTP/1.1
content-type: application/json

{
    "userId": "admin",
    "userName": "管理者",
    "password": "admin123",
    "email": "[email protected]"
}

Spring Security の導入

SecurityConfigを書きます。

package com.example.demo.core.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests((authorize) -> authorize
                        .requestMatchers("/api/auth/**").permitAll()
                        .requestMatchers("/api/user/admin/**").hasRole("ADMIN")
                        .anyRequest().permitAll())
                .csrf((csrf) -> csrf
                        .disable());
        return http.build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    AuthenticationManager authenticationManager(
            UserDetailsService userDetailsService,
            PasswordEncoder passwordEncoder) {
        DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
        authenticationProvider.setUserDetailsService(userDetailsService);
        authenticationProvider.setPasswordEncoder(passwordEncoder);

        return new ProviderManager(authenticationProvider);
    }
}

あとは、userDetailsServiceをDomaを使ってユーザーを取得するように、実装します。

package com.example.demo.domain.auth;

import java.util.Collections;
import java.util.Objects;

import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.example.demo.doma.entity.UserAdmin;
import com.example.demo.domain.user.admin.AdminRepository;

@Service
public class UserDetailServiceImpl implements UserDetailsService {

    private final AdminRepository adminRepository;

    public UserDetailServiceImpl(AdminRepository adminRepository) {
        this.adminRepository = adminRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        UserAdmin user = adminRepository.findByUserId(username);

        if (Objects.isNull(user)) {
            throw new UsernameNotFoundException("User not found");
        } else {
            return new User(user.getUserId(), user.getPasswordHash(), 
                    Collections.singletonList(new SimpleGrantedAuthority("ROLE_ADMIN")));
        }
    }
    
}

最後にログイン用APIを作成します。

package com.example.demo.domain.auth;

import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextHolderStrategy;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthenticationManager authenticationManager;
    private SecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository();
    private final SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
            .getContextHolderStrategy();

    AuthController(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
    }

    @PostMapping("/login")
    public void postMethodName(@RequestBody AuthRequest entity, HttpServletRequest request,
            HttpServletResponse response) {
        UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken.unauthenticated(
                entity.userName, entity.password);
        Authentication authentication = authenticationManager.authenticate(token);
        SecurityContext context = securityContextHolderStrategy.createEmptyContext();
        context.setAuthentication(authentication);
        securityContextHolderStrategy.setContext(context);
        securityContextRepository.saveContext(context, request, response);
    }

    public record AuthRequest(String userName, String password) {
    }

}

ログインせずに、下記リクエストを送信してみます。

# ユーザー作成APIを呼び出す
POST http://localhost:8080/api/user/admin/create HTTP/1.1
content-type: application/json

{
    "userId": "admin2",
    "userName": "管理者2",
    "password": "admin123",
    "email": "[email protected]"
}

今度は、403エラーが返ってきました。

{
    "timestamp":"2025-05-01T12:36:10.513+00:00",
    "status":403,
    "error":"Forbidden",
    "message":"Access Denied",
    "path":"/api/user/admin/create"
}

次に、下記のログイン用APIを呼び出します。 (事前にアカウントは登録しておいてください。)

# ログインAPIを呼び出す
POST http://localhost:8080/api/auth/login HTTP/1.1
content-type: application/json

{
    "userName": "admin",
    "password": "admin123"
}

ログインに成功したら、再度管理者ユーザ作成用APIにリクエストを投げてみると、無事ステータス200が返ってきました。

以上です。 今回作成したデモアプリは公開しているので、気になる人はソースを確認してください。

DOMA3 と Spring Security でログイン認証を実装する

参考文献