> ## Documentation Index
> Fetch the complete documentation index at: https://blog.nvim.me/llms.txt
> Use this file to discover all available pages before exploring further.

# Auth API에 JWT와 Security를 연결해요

> 1편의 domain과 application에 JDBC, JWT, SecurityFilterChain과 controller를 연결해요. 통합 test와 실제 HTTP 요청으로 검증해요.

> domain port만으로는 HTTP 요청이 움직이지 않아요. JDBC와 Spring Security가 바깥 구현을 맡아요.

이 글은 Auth API 실습의 2편이에요. 1편의 domain port, `IdentityFacade`, token 설정에 JDBC adapter를 붙여요. 이어서 JWT 발급, 두 `SecurityFilterChain`, controller와 통합 test를 차례대로 추가해요.

<Card title="1편: domain과 application 준비하기" icon="arrow-left" href="/spring-boot/source-backed-auth-api">
  프로젝트 생성부터 1편 checkpoint까지 아직 끝내지 않았다면 먼저 진행해요.
</Card>

<Tabs sync={false}>
  <Tab title="1편에서 이어가기">
    `auth-api` 디렉터리에서 아래 명령이 성공해야 해요.

    ```bash theme={null}
    ./gradlew test \
      --tests me.nvim.blog.auth.identity.domain.UserAccountTests \
      --tests me.nvim.blog.auth.identity.infrastructure.config.RsaKeyConfigTests
    ```
  </Tab>

  <Tab title="완성본부터 확인하기">
    직접 만들지 않고 결과부터 확인하려면 별도 디렉터리에 저장소를 clone하고 검증된 tag로 이동해요.

    ```bash theme={null}
    git clone https://github.com/kmj8843/aha-spring-boot-auth-api.git
    cd aha-spring-boot-auth-api
    git switch --detach auth-api-first-commit
    ./gradlew test
    ```

    이 경로를 선택했다면 아래 파일은 이미 있으므로 구조와 설명을 읽은 뒤 14절의 실제 요청으로 이동해도 돼요.
  </Tab>
</Tabs>

<Note title="마지막 HTTP 검증에 필요한 도구">
  14절까지 진행하려면 `curl`과 `jq`가 필요해요. 지금 `curl --version`과 `jq --version`을 실행해 두면 서버를 띄운 뒤 도구 설치 때문에 흐름이 끊기지 않아요.
</Note>

<Warning title="HTTP 예제는 localhost 실습 전용이에요">
  `curl` 예제의 `http://localhost:8080`은 한 컴퓨터 안에서 흐름을 관찰하기 위한 주소예요. Basic credential, access token, refresh token은 민감한 값이에요. localhost 밖에서는 반드시 TLS로 보호해야 해요.
</Warning>

## 8. domain port를 JDBC adapter로 구현해요

여기까지 안쪽 코드는 “회원을 저장해 달라”, “활성 refresh token을 찾아 달라”고 요청만 했어요. 이 절에서는 준비된 설정과 schema를 사용해 그 요청을 실제 DB 작업으로 바꿔요.

`UserAccountRepository`와 `RefreshTokenRepository`의 실제 DB 구현을 만들어요. Spring Data가 interface를 대신 구현하게 하지 않고 `JdbcClient`로 SQL을 눈에 보이게 적어요.

```bash theme={null}
mkdir -p src/main/java/me/nvim/blog/auth/identity/infrastructure/jdbc
```

<Tree>
  <Tree.Folder name="src/main/java/me/nvim/blog/auth/identity/infrastructure/jdbc" defaultOpen>
    <Tree.File name="JdbcInstantReader.java" />

    <Tree.File name="JdbcRefreshTokenRepository.java" />

    <Tree.File name="JdbcUserAccountRepository.java" />
  </Tree.Folder>
</Tree>

회원 adapter는 계정과 role row를 함께 저장하고, unique email 위반을 `EMAIL_ALREADY_USED` 업무 오류로 바꿔요. 아래 세 파일을 모두 만든 뒤 refresh token의 조회와 폐기 조건을 살펴볼게요.

여기서 자세히 볼 쪽은 refresh token이에요. 원문에 application secret을 섞어 HMAC-SHA-256을 계산하고, DB에는 16진수 hash만 저장해요. 활성 token 조회와 폐기의 핵심 SQL은 다음 두 조건을 공유해요.

<AccordionGroup>
  <Accordion title="JdbcInstantReader.java">
    ```java title="src/main/java/me/nvim/blog/auth/identity/infrastructure/jdbc/JdbcInstantReader.java" lines theme={null}
    package me.nvim.blog.auth.identity.infrastructure.jdbc;

    import java.sql.ResultSet;
    import java.sql.SQLException;
    import java.sql.Timestamp;
    import java.time.Instant;
    import java.time.OffsetDateTime;

    final class JdbcInstantReader {

        private JdbcInstantReader() {
        }

        static Instant readInstant(ResultSet resultSet, String column) throws SQLException {
            Object value = resultSet.getObject(column);
            if (value instanceof OffsetDateTime offsetDateTime) {
                return offsetDateTime.toInstant();
            }
            if (value instanceof Timestamp timestamp) {
                return timestamp.toInstant();
            }
            return resultSet.getTimestamp(column).toInstant();
        }
    }
    ```
  </Accordion>

  <Accordion title="JdbcRefreshTokenRepository.java">
    ```java title="src/main/java/me/nvim/blog/auth/identity/infrastructure/jdbc/JdbcRefreshTokenRepository.java" lines theme={null}
    package me.nvim.blog.auth.identity.infrastructure.jdbc;

    import static me.nvim.blog.auth.identity.infrastructure.jdbc.JdbcInstantReader.readInstant;

    import java.nio.charset.StandardCharsets;
    import java.security.InvalidKeyException;
    import java.security.NoSuchAlgorithmException;
    import java.sql.ResultSet;
    import java.sql.SQLException;
    import java.time.Instant;
    import java.time.OffsetDateTime;
    import java.time.ZoneOffset;
    import java.util.HexFormat;
    import java.util.Optional;
    import java.util.UUID;

    import javax.crypto.Mac;
    import javax.crypto.spec.SecretKeySpec;

    import org.springframework.jdbc.core.simple.JdbcClient;
    import org.springframework.stereotype.Repository;

    import me.nvim.blog.auth.identity.domain.RefreshToken;
    import me.nvim.blog.auth.identity.domain.RefreshTokenRepository;
    import me.nvim.blog.auth.identity.infrastructure.config.TokenProperties;

    @Repository
    class JdbcRefreshTokenRepository implements RefreshTokenRepository {

        private static final String HMAC_ALGORITHM = "HmacSHA256";

        private final JdbcClient jdbcClient;
        private final SecretKeySpec refreshTokenHmacKey;

        JdbcRefreshTokenRepository(JdbcClient jdbcClient, TokenProperties tokenProperties) {
            this.jdbcClient = jdbcClient;
            this.refreshTokenHmacKey = new SecretKeySpec(
                    tokenProperties.refreshTokenHmacSecret().getBytes(StandardCharsets.UTF_8),
                    HMAC_ALGORITHM);
        }

        @Override
        public void save(UUID userId, String rawToken, Instant expiresAt) {
            this.jdbcClient.sql("""
                    insert into refresh_token (id, user_id, token_hash, created_at, expires_at, revoked_at)
                    values (:id, :userId, :tokenHash, :createdAt, :expiresAt, null)
                    """)
                    .param("id", UUID.randomUUID())
                    .param("userId", userId)
                    .param("tokenHash", hash(rawToken))
                    .param("createdAt", OffsetDateTime.now(ZoneOffset.UTC))
                    .param("expiresAt", OffsetDateTime.ofInstant(expiresAt, ZoneOffset.UTC))
                    .update();
        }

        @Override
        public Optional<RefreshToken> findActive(String rawToken, Instant now) {
            return this.jdbcClient.sql("""
                    select id, user_id, expires_at
                    from refresh_token
                    where token_hash = :tokenHash
                      and revoked_at is null
                      and expires_at > :now
                    """)
                    .param("tokenHash", hash(rawToken))
                    .param("now", OffsetDateTime.ofInstant(now, ZoneOffset.UTC))
                    .query(this::mapRefreshToken)
                    .optional();
        }

        @Override
        public boolean revokeIfActive(UUID tokenId, Instant revokedAt) {
            int updated = this.jdbcClient.sql("""
                    update refresh_token
                    set revoked_at = :revokedAt
                    where id = :id
                      and revoked_at is null
                      and expires_at > :revokedAt
                    """)
                    .param("id", tokenId)
                    .param("revokedAt", OffsetDateTime.ofInstant(revokedAt, ZoneOffset.UTC))
                    .update();
            return updated == 1;
        }

        private String hash(String rawToken) {
            try {
                Mac mac = Mac.getInstance(HMAC_ALGORITHM);
                mac.init(this.refreshTokenHmacKey);
                return HexFormat.of().formatHex(mac.doFinal(rawToken.getBytes(StandardCharsets.UTF_8)));
            } catch (InvalidKeyException | NoSuchAlgorithmException ex) {
                throw new IllegalStateException("HMAC-SHA-256 is not available", ex);
            }
        }

        private RefreshToken mapRefreshToken(ResultSet resultSet, int rowNumber) throws SQLException {
            return new RefreshToken(
                    resultSet.getObject("id", UUID.class),
                    resultSet.getObject("user_id", UUID.class),
                    readInstant(resultSet, "expires_at"));
        }
    }
    ```
  </Accordion>

  <Accordion title="JdbcUserAccountRepository.java">
    ```java title="src/main/java/me/nvim/blog/auth/identity/infrastructure/jdbc/JdbcUserAccountRepository.java" lines theme={null}
    package me.nvim.blog.auth.identity.infrastructure.jdbc;

    import static me.nvim.blog.auth.identity.infrastructure.jdbc.JdbcInstantReader.readInstant;

    import java.sql.ResultSet;
    import java.sql.SQLException;
    import java.time.OffsetDateTime;
    import java.time.ZoneOffset;
    import java.util.List;
    import java.util.Optional;
    import java.util.UUID;

    import org.springframework.dao.DuplicateKeyException;
    import org.springframework.jdbc.core.simple.JdbcClient;
    import org.springframework.stereotype.Repository;

    import me.nvim.blog.auth.common.exception.BusinessException;
    import me.nvim.blog.auth.common.exception.ErrorCode;
    import me.nvim.blog.auth.identity.domain.UserAccount;
    import me.nvim.blog.auth.identity.domain.UserAccountRepository;

    @Repository
    class JdbcUserAccountRepository implements UserAccountRepository {

        private final JdbcClient jdbcClient;

        JdbcUserAccountRepository(JdbcClient jdbcClient) {
            this.jdbcClient = jdbcClient;
        }

        @Override
        public UserAccount save(UserAccount userAccount) {
            try {
                this.jdbcClient.sql("""
                        insert into app_user (id, email, password_hash, display_name, enabled, created_at)
                        values (:id, :email, :passwordHash, :displayName, :enabled, :createdAt)
                        """)
                        .param("id", userAccount.id())
                        .param("email", userAccount.email())
                        .param("passwordHash", userAccount.passwordHash())
                        .param("displayName", userAccount.displayName())
                        .param("enabled", userAccount.enabled())
                        .param("createdAt", OffsetDateTime.ofInstant(userAccount.createdAt(), ZoneOffset.UTC))
                        .update();
            } catch (DuplicateKeyException ex) {
                throw new BusinessException(
                        ErrorCode.EMAIL_ALREADY_USED,
                        "Email is already registered: " + userAccount.email(),
                        ex);
            }

            for (String role : userAccount.roles()) {
                this.jdbcClient.sql("""
                        insert into app_user_role (user_id, role_name)
                        values (:userId, :roleName)
                        """)
                        .param("userId", userAccount.id())
                        .param("roleName", role)
                        .update();
            }

            return findById(userAccount.id()).orElseThrow();
        }

        @Override
        public Optional<UserAccount> findByEmail(String email) {
            return this.jdbcClient.sql("""
                    select id, email, password_hash, display_name, enabled, created_at
                    from app_user
                    where email = :email
                    """)
                    .param("email", email)
                    .query(this::mapUser)
                    .optional()
                    .map(this::withRoles);
        }

        @Override
        public Optional<UserAccount> findById(UUID id) {
            return this.jdbcClient.sql("""
                    select id, email, password_hash, display_name, enabled, created_at
                    from app_user
                    where id = :id
                    """)
                    .param("id", id)
                    .query(this::mapUser)
                    .optional()
                    .map(this::withRoles);
        }

        @Override
        public List<UserAccount> findAll() {
            return this.jdbcClient.sql("""
                    select id, email, password_hash, display_name, enabled, created_at
                    from app_user
                    order by created_at asc, email asc
                    """)
                    .query(this::mapUser)
                    .list()
                    .stream()
                    .map(this::withRoles)
                    .toList();
        }

        private UserAccount withRoles(UserAccount userAccount) {
            List<String> roles = this.jdbcClient.sql("""
                    select role_name
                    from app_user_role
                    where user_id = :userId
                    order by role_name asc
                    """)
                    .param("userId", userAccount.id())
                    .query(String.class)
                    .list();

            return new UserAccount(
                    userAccount.id(),
                    userAccount.email(),
                    userAccount.passwordHash(),
                    userAccount.displayName(),
                    userAccount.enabled(),
                    userAccount.createdAt(),
                    roles);
        }

        private UserAccount mapUser(ResultSet resultSet, int rowNumber) throws SQLException {
            return new UserAccount(
                    resultSet.getObject("id", UUID.class),
                    resultSet.getString("email"),
                    resultSet.getString("password_hash"),
                    resultSet.getString("display_name"),
                    resultSet.getBoolean("enabled"),
                    readInstant(resultSet, "created_at"),
                    List.of());
        }
    }
    ```
  </Accordion>
</AccordionGroup>

`findActive`로 읽은 뒤에도 `revokeIfActive`의 update 결과를 다시 확인하는 이유는 동시 요청 때문이에요. 같은 token을 두 요청이 함께 읽더라도 `updated == 1`을 얻은 한 요청만 새 token으로 진행할 수 있어요. 저장과 조회가 모두 같은 `hash(rawToken)`을 호출하므로 DB에는 원문이 남지 않아요.

<Check title="JDBC adapter가 compile돼요">
  ```bash theme={null}
  ./gradlew classes
  ```

  이 명령은 아직 애플리케이션을 띄우지 않아요. JDBC class와 앞에서 만든 port의 method 모양이 일치하는지만 먼저 확인해요.
</Check>

## 9. password와 token 발급 adapter를 연결해요

DB adapter가 저장 port를 채웠다면, 아직 비어 있는 기술 경계는 password hash와 인증이에요. domain은 password를 어떤 algorithm으로 hash하는지, JWT를 어떤 library로 서명하는지 몰라야 하죠. 이번에는 그 port들을 Spring Security 구현과 연결할게요.

domain port와 Spring Security 사이를 잇는 네 class를 만들어요.

```bash theme={null}
mkdir -p src/main/java/me/nvim/blog/auth/identity/infrastructure/security
```

<Tree>
  <Tree.Folder name="src/main/java/me/nvim/blog/auth/identity/infrastructure/security" defaultOpen>
    <Tree.File name="DatabaseUserDetailsService.java" />

    <Tree.File name="JwtTokenIssuer.java" />

    <Tree.File name="SpringPasswordHasher.java" />

    <Tree.File name="UserPrincipal.java" />
  </Tree.Folder>
</Tree>

Basic 쪽 세 class는 번역 역할이 분명해요. `UserPrincipal`은 `UserAccount`를 `UserDetails`로 바꾸고 role 문자열을 `GrantedAuthority`로 옮겨요. `DatabaseUserDetailsService`는 email을 소문자로 정규화해 계정을 찾고, `SpringPasswordHasher`는 domain의 `PasswordHasher`를 `PasswordEncoder`로 구현해요. 네 파일은 모두 필요하며, `JwtTokenIssuer`가 실제 token 모양을 결정해요.

실제로 token 모양을 결정하는 `JwtTokenIssuer`는 본문에서 볼게요.

JWT adapter는 RSA private key로 access token을 서명하고 32 byte 무작위 refresh token을 만들어요. JWT에는 `iss`, `sub`, `aud`, `iat`, `exp`와 우리 claim인 `email`, `roles`가 들어가요.

<AccordionGroup>
  <Accordion title="UserPrincipal.java">
    ```java title="src/main/java/me/nvim/blog/auth/identity/infrastructure/security/UserPrincipal.java" lines theme={null}
    package me.nvim.blog.auth.identity.infrastructure.security;

    import java.util.Collection;

    import org.springframework.security.core.GrantedAuthority;
    import org.springframework.security.core.authority.SimpleGrantedAuthority;
    import org.springframework.security.core.userdetails.UserDetails;

    import me.nvim.blog.auth.identity.domain.UserAccount;

    final class UserPrincipal implements UserDetails {

        private final UserAccount userAccount;
        private final Collection<GrantedAuthority> authorities;

        UserPrincipal(UserAccount userAccount) {
            this.userAccount = userAccount;
            this.authorities = userAccount.roles().stream()
                    .map(SimpleGrantedAuthority::new)
                    .map(GrantedAuthority.class::cast)
                    .toList();
        }

        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            return this.authorities;
        }

        @Override
        public String getPassword() {
            return this.userAccount.passwordHash();
        }

        @Override
        public String getUsername() {
            return this.userAccount.email();
        }

        @Override
        public boolean isEnabled() {
            return this.userAccount.enabled();
        }
    }
    ```
  </Accordion>

  <Accordion title="DatabaseUserDetailsService.java">
    ```java title="src/main/java/me/nvim/blog/auth/identity/infrastructure/security/DatabaseUserDetailsService.java" lines theme={null}
    package me.nvim.blog.auth.identity.infrastructure.security;

    import java.util.Locale;

    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 me.nvim.blog.auth.identity.domain.UserAccountRepository;

    @Service
    class DatabaseUserDetailsService implements UserDetailsService {

        private final UserAccountRepository userAccountRepository;

        DatabaseUserDetailsService(UserAccountRepository userAccountRepository) {
            this.userAccountRepository = userAccountRepository;
        }

        @Override
        public UserDetails loadUserByUsername(String username) {
            return this.userAccountRepository.findByEmail(username.toLowerCase(Locale.ROOT))
                    .map(UserPrincipal::new)
                    .orElseThrow(() -> new UsernameNotFoundException(username));
        }
    }
    ```
  </Accordion>

  <Accordion title="SpringPasswordHasher.java">
    ```java title="src/main/java/me/nvim/blog/auth/identity/infrastructure/security/SpringPasswordHasher.java" lines theme={null}
    package me.nvim.blog.auth.identity.infrastructure.security;

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

    import me.nvim.blog.auth.identity.domain.PasswordHasher;

    @Component
    class SpringPasswordHasher implements PasswordHasher {

        private final PasswordEncoder passwordEncoder;

        SpringPasswordHasher(PasswordEncoder passwordEncoder) {
            this.passwordEncoder = passwordEncoder;
        }

        @Override
        public String hash(String rawPassword) {
            return this.passwordEncoder.encode(rawPassword);
        }
    }
    ```
  </Accordion>

  <Accordion title="JwtTokenIssuer.java">
    ```java title="src/main/java/me/nvim/blog/auth/identity/infrastructure/security/JwtTokenIssuer.java" lines theme={null}
    package me.nvim.blog.auth.identity.infrastructure.security;

    import java.security.SecureRandom;
    import java.time.Instant;
    import java.util.Base64;
    import java.util.List;

    import org.springframework.security.oauth2.jwt.JwtClaimsSet;
    import org.springframework.security.oauth2.jwt.JwtEncoder;
    import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
    import org.springframework.stereotype.Component;

    import me.nvim.blog.auth.identity.domain.IssuedToken;
    import me.nvim.blog.auth.identity.domain.TokenIssuer;
    import me.nvim.blog.auth.identity.domain.UserAccount;
    import me.nvim.blog.auth.identity.infrastructure.config.TokenProperties;

    @Component
    class JwtTokenIssuer implements TokenIssuer {

        private static final int REFRESH_TOKEN_BYTES = 32;

        private final JwtEncoder jwtEncoder;
        private final TokenProperties tokenProperties;
        private final SecureRandom secureRandom = new SecureRandom();

        JwtTokenIssuer(JwtEncoder jwtEncoder, TokenProperties tokenProperties) {
            this.jwtEncoder = jwtEncoder;
            this.tokenProperties = tokenProperties;
        }

        @Override
        public IssuedToken issue(UserAccount userAccount, Instant issuedAt) {
            Instant accessTokenExpiresAt = issuedAt.plus(this.tokenProperties.accessTokenTtl());
            Instant refreshTokenExpiresAt = issuedAt.plus(this.tokenProperties.refreshTokenTtl());
            return new IssuedToken(
                    createAccessToken(userAccount, issuedAt, accessTokenExpiresAt),
                    accessTokenExpiresAt,
                    createRefreshToken(),
                    refreshTokenExpiresAt);
        }

        private String createAccessToken(UserAccount userAccount, Instant issuedAt, Instant expiresAt) {
            JwtClaimsSet claims = JwtClaimsSet.builder()
                    .issuer(this.tokenProperties.issuer())
                    .subject(userAccount.id().toString())
                    .audience(List.of(this.tokenProperties.audience()))
                    .issuedAt(issuedAt)
                    .expiresAt(expiresAt)
                    .claim("email", userAccount.email())
                    .claim("roles", userAccount.roles())
                    .build();
            return this.jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
        }

        private String createRefreshToken() {
            byte[] bytes = new byte[REFRESH_TOKEN_BYTES];
            this.secureRandom.nextBytes(bytes);
            return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
        }
    }
    ```
  </Accordion>
</AccordionGroup>

access token은 서명된 JWT라서 resource server가 자체 검증할 수 있어요. refresh token은 의미 없는 opaque random 문자열이고, 우리 DB와 application만 상태를 판단해요.

<Check title="Security adapter가 compile돼요">
  ```bash theme={null}
  ./gradlew classes
  ```
</Check>

BODYPENDING
