JWT 인증 서버 구현하기
JWT Authentication Server with Spring
JWT(Json Web Token)는 클라이언트와 서버 간의 인증 정보를 안전하게 교환하는 데 유용하며, 다양한 디바이스 간의 세션 관리를 단순화해 현대 애플리케이션에서 널리 활용됩니다. 하지만 토큰 기반 인증 방식은 탈취에 의해 보안에 취약할 수 있으므로 이를 보완하는 다양한 전략이 필요합니다. 유효기간이 짧은 액세스 토큰과 유효기간이 긴 리프레시 토큰을 사용하는 전략을 통해 JWT 인증 서버를 구현하면, 보안을 강화하면서도 사용자 편의성을 높일 수 있습니다.
JWT Authentication Workflow
인증 서버의 역할은 사용자의 자격 증명을 확인하고 서비스 이용에 필요한 액세스 토큰(Access Token) 및 리프레시 토큰(Refresh Token)을 발급하는 것입니다.
액세스 토큰과 리프레시 토큰은 JWT 인증 워크플로우의 핵심 요소입니다.
- 액세스 토큰(Access Token)
- 액세스 토큰은 사용자의 인증 및 권한 정보를 포함하는 짧은 유효기간을 가진 토큰입니다.
- 일반적으로 보호된 리소스에 접근할 때마다 클라이언트는 이 토큰을 API 서버로 전송합니다.
- 액세스 토큰은 보통 몇 분에서 몇 시간의 유효기간을 가지며, 만료되면 더 이상 유효하지 않습니다.
- 이 토큰은 HTTP Authorization 헤더에
Bearer ${access_token}
형식으로 포함되어 전송됩니다. - 액세스 토큰은 서버에서 사용자의 신원을 빠르고 효율적으로 확인할 수 있게 합니다.
- 리프레시 토큰(Refresh Token)
- 리프레시 토큰은 새로운 액세스 토큰을 발급받기 위해 사용되는 토큰으로, 상대적으로 긴 유효기간을 가집니다.
- 액세스 토큰이 만료되었을 때, 클라이언트는 리프레시 토큰을 사용하여 새로운 액세스 토큰을 요청할 수 있습니다.
- 리프레시 토큰의 유효기간은 몇 시간에서 며칠 또는 몇 주에 이르기까지 다양할 수 있습니다.
- 이 토큰은 클라이언트 측에 안전하게 저장되어야 하며, 서버는 리프레시 토큰의 유효성을 검증하여 새로운 액세스 토큰을 발급합니다.
- 리프레시 토큰을 사용하면 사용자가 다시 로그인하지 않고도 지속적으로 서비스를 이용할 수 있습니다.
이 두 가지 토큰을 사용하면 보안성을 유지하면서도 사용자 경험을 향상시킬 수 있습니다. 액세스 토큰이 만료되면 리프레시 토큰을 사용해 새 토큰을 발급받아 사용자가 계속해서 서비스를 이용할 수 있게 됩니다.
JWT 기반의 인증 워크플로우는 다음과 같은 주요 단계로 구성됩니다:
- 사용자 로그인 요청
- 클라이언트는 이메일 및 패스워드 등의 사용자 자격 증명을 포함하여 로그인 요청을 인증 서버로 전송합니다.
- 이 요청은 일반적으로 POST 메서드를 사용하며, HTTPS를 통해 안전하게 전달됩니다.
- 클라이언트는 이 요청을 통해 사용자 인증을 시도하며, 자격 증명이 유효할 경우 액세스 및 리프레시 토큰을 받을 수 있습니다.
- 자격 증명 검증
- 인증 서버는 데이터베이스에서 사용자 정보를 조회하고, 전송된 자격 증명과 일치하는지 확인합니다.
- 서버는 입력된 패스워드를 저장된 해시된 패스워드와 비교하여 일치 여부를 확인합니다.
- 사용자가 존재하지 않거나 패스워드가 일치하지 않으면, 서버는 적절한 에러 메시지와 함께 인증 실패를 응답합니다.
- 액세스 토큰 및 리프레시 토큰 발급
- 자격 증명이 유효하면 서버는 JWT 형식의 액세스 토큰과 리프레시 토큰을 생성하여 클라이언트에 반환합니다.
- 액세스 토큰은 사용자의 인증 및 권한 정보를 포함하며, 짧은 유효기간을 가집니다.
- 리프레시 토큰은 새로운 액세스 토큰을 발급받기 위해 사용되며, 상대적으로 긴 유효기간을 가집니다.
- 서버는 이 토큰들을 안전하게 생성하기 위해 비밀 키를 사용하며, 토큰의 만료 시간을 설정합니다.
- 토큰은 JSON 형식으로 인코딩되며, 클라이언트는 이 토큰들을 적절한 저장소에 보관합니다.
- 액세스 토큰 활용
- 클라이언트는 보호된 리소스에 액세스할 때마다 액세스 토큰을 함께 전송합니다.
- 일반적으로 이 토큰은 HTTP Authorization 헤더에
Bearer ${access_token}
형식으로 포함됩니다. - API 서버는 요청을 받을 때 액세스 토큰의 유효성을 검증하고, 토큰이 만료되었는지, 변조되지 않았는지 확인합니다.
- 검증이 성공하면, 서버는 토큰에 포함된 정보를 기반으로 사용자의 권한을 확인하고 요청을 처리합니다.
- 만약 토큰이 유효하지 않거나 만료되었다면, 해당 서버는 적절한 HTTP 상태 코드를 반환합니다.
- 액세스 토큰 재발급
- 액세스 토큰이 만료된 경우, 클라이언트는 저장된 리프레시 토큰을 사용하여 새로운 액세스 토큰 발급을 인증 서버로 요청합니다.
- 인증 서버는 리프레시 토큰의 유효성을 확인하고, 유효한 토큰이라면 새로운 액세스 토큰과, 필요시 새로운 리프레시 토큰을 발급합니다.
- 이 과정에서 서버는 리프레시 토큰이 유효하지 않거나 만료된 경우, 클라이언트에게 재로그인을 요구할 수 있습니다.
- 새로운 토큰을 발급한 후, 클라이언트는 이를 사용하여 보호된 리소스에 계속 접근할 수 있습니다.
전반적인 인증 프로세스에 대해 자세히 살펴보았습니다. 이제 이러한 개념을 바탕으로 실제로 인증 서버를 구현하는 단계로 넘어가 보겠습니다.
Prerequisites
먼저, 새로운 Spring Boot 프로젝트를 생성합니다. 프로젝트의 기본 구성은 다음과 같습니다:
dependencies {
// Querydsl
implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
Spring Initializr에서 지원하지 않는 Querydsl 연동 설정은 Querydsl 적용하기에서 자세히 다루고 있습니다.
- Java 21
- Spring Boot 3.3.2
- Gradle 8
- MySQL 8
- Dependencies
- Spring Web
- Spring Security
- Spring Data JPA
- MySQL Driver
- Querydsl
- Validation
- Lombok
그리고 사용자와 리프레시 토큰 데이터를 저장하는 테이블을 준비합니다. 디바이스마다 별도의 로그인 세션을 관리하기 위해 사용자와 리프레시 토큰 테이블 간의 관계를 일대다로 설정합니다. 다음은 이를 위한 SQL 스크립트입니다:
-- Create the users table
create table users
(
id bigint unsigned auto_increment primary key,
username varchar(254) not null,
password varchar(100) not null,
role varchar(20) default 'ROLE_GUEST' not null,
status varchar(20) default 'ACTIVATED' not null,
created_at datetime(6) default current_timestamp(6) not null
);
-- Create the refresh_tokens table
create table refresh_tokens
(
id bigint unsigned auto_increment primary key,
user_id bigint unsigned not null,
device_id varchar(50) not null,
token varchar(68) not null,
expires_at datetime(6) not null,
created_at datetime(6) default current_timestamp(6) not null,
constraint token unique (token),
constraint fk_refresh_tokens_users foreign key (user_id) references users (id)
);
users
테이블은 각 사용자에 대한 정보를 저장하며, refresh_tokens
테이블은 각 디바이스에 대한 리프레시 토큰을 저장합니다. 사용자와 디바이스 간의 일대다 관계를 설정하여 사용자가 여러 디바이스에서 로그인할 수 있도록 구상하였습니다.
API 구현을 테스트하기 위한 샘플 계정도 하나 준비합니다:
insert into users (id, username, password, role, status, created_at)
values (1, 'jynn@catsriding.com', '{bcrypt}$2a$10$2qPO8x7SfCYnrDL/Y9ANUuSI0W.BsLG4Okr8/i2J7TjVXfVtjukZy', 'ROLE_USER', 'ACTIVATED', '2024-08-03 19:27:31.000000');
- 아이디:
jynn@catsriding.com
- 패스워드:
catsriding
Configuring JWT Dependencies
JWT 라이브러리는 클라이언트와 서버 간의 인증 정보를 안전하게 교환하기 위한 중요한 도구입니다. Java 진영에서는 JJWT와 Java JWT 두 가지 주요 JWT 라이브러리가 널리 사용됩니다. 이 두 라이브러리는 표현 방식에 약간의 차이가 있을 뿐, 사용법이 거의 동일하므로 개인의 선호에 따라 선택할 수 있습니다. 개인적으로는 Java JWT가 더 깔끔하게 코드를 작성할 수 있어 선호하는 편입니다.
Java JWT 라이브러리를 사용하기 위해서는 build.gradle
파일에 다음과 같이 의존성을 추가합니다:
dependencies {
+ // jwt
+ implementation 'com.auth0:java-jwt:4.4.0'
// Querydsl
implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
JWT 토큰을 발행하기 위해서는 서명에 사용되는 암호화 키(Secret Key)와 토큰의 만료 시간을 설정해야 합니다. 이러한 값들은 서버 실행 환경에 따라 외부에서 설정할 수 있도록 속성 파일로 관리합니다. 이를 위해 application.yml
파일에 다음과 같은 속성을 추가합니다:
auth:
token:
jwt:
issuer: catsriding.com
secret-key: catsridingSecretKey
expiresAccessToken: 300_000 # 5 mins
expiresRefreshToken: 604_800_000 # 7 days
- 암호화 키(Secret Key): JWT 서명에 사용되는 비밀 키입니다.
- 토큰 유효기한:
- 액세스 토큰의 유효기한은 5분으로 설정되었습니다.
- 리프레시 토큰의 유효기한은 7일로 설정되었습니다.
이제, 이러한 속성을 전용 객체에 바인딩하여 소스 코드에서 활용할 수 있도록 구성합니다:
@Getter
@AllArgsConstructor
@ConfigurationProperties(prefix = TokenProperties.PREFIX)
public class TokenProperties {
public static final String PREFIX = "auth.token.jwt";
private String issuer;
private String secretKey;
private long expiresAccessToken;
private long expiresRefreshToken;
}
이렇게 하면 설정된 속성을 손쉽게 가져와 JWT 토큰 발행에 활용할 수 있습니다.
Implementing User Authentication API
이제 사용자의 로그인 요청을 처리하고, JWT 기반의 액세스 토큰과 리프레시 토큰을 발급하는 API를 구현합니다. 이 과정은 사용자 인증 정보를 검증하고, 인증이 성공하면 토큰을 생성하여 클라이언트에 반환하는 일련의 절차로 구성됩니다:
- 클라이언트가
POST /login
엔드포인트로 로그인 요청을 보냅니다. LoginRequest
객체가 요청 본문에서 생성되고, 기본적인 입력 폼 유효성 검사가 수행됩니다.- 사용자 인증 정보 및 상태를 검증하고 통과하는 경우 JWT 토큰을 발행합니다.
LoginResponse
객체에 토큰 정보를 담아 클라이언트로 반환합니다.
Handling Login Request
먼저, 사용자의 로그인 요청을 처리하는 클래스들을 살펴보겠습니다.
@Slf4j
@Getter
@Builder
@RequiredArgsConstructor
public class LoginRequest {
@NotBlank
@Pattern(regexp = "^[a-zA-Z0-9._%+-]{1,64}@[a-zA-Z0-9.-]{1,255}\\.[a-zA-Z]{2,}$")
@Size(max = 254)
private final String username;
@NotBlank
@Size(max = 100)
private final String password;
@NotBlank
@Size(max = 50)
private final String deviceId;
public LoginCommand toCommand() {
return LoginCommand.builder()
.username(username)
.password(password)
.deviceId(deviceId)
.build();
}
}
username
: 사용자 계정의 로그인 아이디입니다. 여기서는 이메일 형식을 기반으로 하고 있습니다.password
: 사용자 계정의 패스워드입니다.deviceId
: 디바이스 마다 별도의 인증 정보를 관리하기 위한 값입니다. 이는 하드웨어 정보 또는 클라이언트 사이드에서 임의로 생성한 값이 될 수 있습니다.toCommand()
: 요청 처리에 필요한 모델로 전환합니다.
사용자 로그인 요청이 들어오면, 이를 처리하는 비즈니스 로직이 실행되고, 토큰이 발급됩니다. 이 토큰 정보는 TokenContext
객체에 담겨 컨트롤러로 전달됩니다. 컨트롤러에서는 이 값을 활용하여 적절한 응답 페이로드를 작성합니다:
@Slf4j
@Getter
@Builder
@RequiredArgsConstructor
public class LoginResponse {
private final String tokenType;
private final String accessToken;
private final LocalDateTime accessTokenExpiresAt;
private final String refreshToken;
private final LocalDateTime refreshTokenExpiresAt;
public static LoginResponse from(TokenContext tokenContext) {
return LoginResponse.builder()
.tokenType(tokenContext.getTokenType())
.accessToken(tokenContext.getAccessToken())
.accessTokenExpiresAt(tokenContext.getAccessTokenExpiresAt())
.refreshToken(tokenContext.getRefreshToken())
.refreshTokenExpiresAt(tokenContext.getRefreshTokenExpiresAt())
.build();
}
}
다음은, 로그인 요청을 처리하는 컨트롤러를 구현합니다. POST /login
엔드포인트로 들어오는 요청을 처리하고, 로그인 요청을 받아 LoginUseCase
를 통해 실제 로그인 처리를 수행합니다. 성공적으로 로그인되면, TokenContext
객체를 LoginResponse
로 변환하여 클라이언트에 응답합니다:
@Slf4j
@RequiredArgsConstructor
@RestApiAdapter
public class LoginApiAdapter {
private final LoginUseCase loginUseCase;
@PostMapping("/login")
public ResponseEntity<?> loginApi(@Valid @RequestBody LoginRequest request) {
TokenContext tokenContext = loginUseCase.login(request.toCommand());
LoginResponse response = LoginResponse.from(tokenContext);
return ResponseEntity
.ok(RestApiResponse.compose(SUCCESS, response));
}
}
이러한 방식으로 로그인 요청을 처리하고, JWT 토큰을 발급하여 클라이언트에 전달할 수 있습니다.
Processing User Login
이제 요청 처리 세부 사항을 구현해보도록 하겠습니다. 이는 다음과 같은 과정을 거칩니다:
@Slf4j
@RequiredArgsConstructor
@UseCase
public class UserManagementService implements LoginUseCase {
private final LoadUserPort loadUserPort;
private final VerifyEncryptedDataPort verifyEncryptedDataPort;
private final PersistRefreshTokenPort persistRefreshTokenPort;
private final GenerateTokenPort generateTokenPort;
private final EncryptTokenPort encryptTokenPort;
@Override
public TokenContext login(LoginCommand command) {
User user = loadUserPort.loadForLogin(command.getUsername());
verifyPassword(user, command);
user.checkActivated();
TokenContext tokenContext = generateTokenPort.generate(user, command.getDeviceId());
persistRefreshToken(user, tokenContext);
return tokenContext;
}
}
- 데이터베이스에 저장된 사용자의 데이터를 로드합니다.
- 사용자의 자격 증명을 검증합니다.
- 액세스 및 리프레시 토큰을 생성합니다.
- 리프레시 토큰 정보를 데이터베이스에 저장합니다.
- 생성한 액세스 및 리프레시 토큰을 객체에 담아 반환합니다.
Fetching Uesr Data
사용자가 입력한 데이터를 기반으로 사용자 자격 증명을 하기 위해서는 데이터베이스에 저장된 사용자 데이터를 로드해야 합니다.
@Override
public TokenContext login(LoginCommand command) {
User user = loadUserPort.loadForLogin(command.getUsername());
verifyPassword(user, command);
user.checkActivated();
TokenContext tokenContext = generateTokenPort.generate(user, command.getDeviceId());
persistRefreshToken(user, tokenContext);
return tokenContext;
}
사용자가 입력한 아이디를 기반으로 계정 정보를 조회합니다. 사용자가 잘못 입력한 경우 데이터 조회 결과가 없을 수 있기 때문에 데이터베이스 조회 결과는 Optional<T>
로 감싸서 반환합니다:
@Slf4j
@RequiredArgsConstructor
public class UserJpaRepositoryImpl implements UserJpaRepositoryExtension {
private final JPAQueryFactory queryFactory;
@Override
public Optional<UserEntity> fetchByUsername(String username) {
return Optional.ofNullable(
queryFactory
.select(userEntity)
.from(userEntity)
.where(userEntity.username.eq(username))
.fetchOne());
}
}
조회된 결과를 처리하는 과정에서, 데이터가 존재하지 않는 경우에는 적절한 예외 처리를 해야 합니다. 데이터가 존재하는 경우에는 이를 도메인 객체로 변환하여 반환합니다:
@Slf4j
@RequiredArgsConstructor
@PersistenceAdapter
public class UserPersistenceAdapter implements LoadUserPort {
private final UserJpaRepository userJpaRepository;
@Override
@Transactional(readOnly = true)
public User loadForLogin(String username) {
UserEntity userEntity = userJpaRepository.fetchByUsername(username)
.orElseThrow(() -> {
log.warn("fetchUserByUsername: Does not found user - username={}", username);
return new UserNotFoundException("Does not found user by username");
});
return userEntity.toDomain();
}
}
Verifying Account Specifications
사용자 데이터 조회에 성공한 경우, 다음 단계는 계정의 상태와 패스워드가 일치하는지 여부를 확인하는 것입니다. 이 과정은 사용자 인증의 핵심 단계로, 사용자의 자격 증명을 통해 계정의 유효성을 확인합니다.
@Override
public TokenContext login(LoginCommand command) {
User user = loadUserPort.loadForLogin(command.getUsername());
verifyPassword(user, command);
user.checkActivated();
TokenContext tokenContext = generateTokenPort.generate(user, command.getDeviceId());
persistRefreshToken(user, tokenContext);
return tokenContext;
}
패스워드 검증은 Spring Security에서 제공하는 BCrypt 방식을 사용합니다. BCrypt는 강력한 해시 함수를 사용하여 패스워드를 암호화하고 검증할 수 있습니다. 이를 구현하는 코드는 다음과 같습니다:
@Slf4j
@RequiredArgsConstructor
@SecurityAdapter
public class EncryptionAdapter implements VerifyEncryptedDataPort {
private final PasswordEncoder encoder;
@Override
public boolean verify(String raw, String encrypted) {
return encoder.matches(raw, encrypted);
}
}
패스워드가 일치하지 않는 경우, 로그를 남기고 예외를 발생시켜 인증 프로세스를 종료합니다:
private void verifyPassword(User user, LoginCommand command) {
if (!verifyEncryptedDataPort.verify(command.getPassword(), user.getPassword())) {
log.warn("verifyPassword: Does not match password - username={}", user.getUsername());
throw new LoginFailedException("Does not match password.");
}
}
사용자가 입력한 패스워드가 일치한다면, 다음 단계는 계정의 상태를 확인하는 것입니다. 실제 서비스에서는 다양한 검증 단계를 거칠 수 있지만, 여기서는 간단하게 계정이 활성화되었는지 여부만 확인합니다. 이를 위해 계정 상태를 검증하는 명세 클래스를 정의합니다:
@Slf4j
@RequiredArgsConstructor
public class UserActivatedSpec extends AbstractSpecification<User> {
@Override
public boolean isSatisfiedBy(User user) {
return user != null && isUserActivated(user);
}
private static boolean isUserActivated(User user) {
return user.getStatus().equals(UserStatus.ACTIVATED);
}
}
추가한 명세를 User
도메인 엔티티에서 활용하여 계정의 상태를 검증합니다.
@Slf4j
@Getter
@Builder
@RequiredArgsConstructor
public class User {
...
public void checkActivated() {
UserActivatedSpec userActivatedSpec = new UserActivatedSpec();
if (!userActivatedSpec.isSatisfiedBy(this)) {
log.warn("checkUserActivated: User is not activated - username={}, status={}", username, status);
throw new UserNotActivatedException("User is not activated.");
}
}
}
사용자의 계정 상태와 패스워드를 성공적으로 검증했습니다. 모든 검증 과정을 통과한 경우, 해당 계정을 유효한 것으로 판단하고 다음 단계로 넘어가 JWT 토큰을 생성하겠습니다.
Issuing Access and Refresh Tokens
JWT는 사용자의 ID 카드와 같은 역할을 하며, 사용자를 식별할 수 있는 정보 등이 담겨 있습니다. JWT 페이로드는 누구나 해석이 가능하기 때문에 토큰에 많은 정보를 담지 않도록 주의해야 합니다.
@Override
public TokenContext login(LoginCommand command) {
User user = loadUserPort.loadForLogin(command.getUsername());
verifyPassword(user, command);
user.checkActivated();
TokenContext tokenContext = generateTokenPort.generate(user, command.getDeviceId());
persistRefreshToken(user, tokenContext);
return tokenContext;
}
Java JWT 라이브러리는 JWT
클래스를 통해 JWT 토큰을 생성하고 서명할 수 있는 다양한 기능을 제공합니다. JWT.create()
메서드를 활용하여 새로운 사용자를 식별할 수 있는 최소한의 정보를 담은 액세스 토큰을 생성합니다:
private final TokenProperties props;
private String generateAccessToken(User user, Instant issuedAt, Instant expiresAt) {
return JWT.create()
.withClaim("id", user.getUserId().getId())
.withClaim("username", user.getUsername())
.withClaim("roles", user.getRole().name())
.withIssuer(props.getIssuer())
.withIssuedAt(issuedAt)
.withExpiresAt(expiresAt)
.sign(algorithm(props.getSecretKey()));
}
private Algorithm algorithm(String secretKey) {
return Algorithm.HMAC256(secretKey);
}
private Instant issuedAt(long currentTimeMillis) {
return Instant.ofEpochMilli(currentTimeMillis);
}
private Instant expiresAt(long currentTimeMillis, long expires) {
return Instant.ofEpochMilli(currentTimeMillis + expires);
}
JWT
: Java JWT 라이브러리의 주요 클래스입니다.create()
: 새로운 JWT 빌더를 생성합니다.withClaim()
: 토큰에 사용자 정보를 추가합니다.withIssuer()
: 토큰 발급자를 설정합니다.withIssuedAt()
: 토큰 발급 시간을 설정합니다.withExpiresAt()
: 토큰 만료 시간을 설정합니다.sign()
: 주어진 알고리즘을 사용하여 토큰을 서명합니다. 토큰의 무결성을 보장하며, 토큰이 발급자에 의해 변경되지 않았음을 확인할 수 있도록 합니다.
algorithm()
: 암호화 키를 사용하여HMAC256
알고리즘을 생성합니다.issuedAt()
:JWT
기능은 시간을Date
또는Instant
타입만 허용하기 때문에, 이를 위해 현재 시간을Instant
객체로 반환합니다.expiresAt()
: 현재 시간에 속성에 추가한 만료 시간을 더하여Instant
객체로 반환합니다.
리프레시 토큰은 액세스 토큰을 재발급 하기 위한 하나의 식별자 역할로 많은 정보가 담길 필요는 없으며 상대적으로 긴 유효기한을 가집니다:
private String generateRefreshToken(User user, Instant issuedAt, Instant expiresAt) {
return JWT.create()
.withClaim("id", user.getUserId().getId())
.withIssuer(props.getIssuer())
.withIssuedAt(issuedAt)
.withExpiresAt(expiresAt)
.sign(algorithm(props.getSecretKey()));
}
다음으로, 생성된 토큰 정보를 관리하기 위한 TokenContext
클래스를 정의합니다. 이 클래스는 생성된 JWT 토큰의 종류, 디바이스 ID, 액세스 토큰과 리프레시 토큰, 그리고 이들의 만료 시간과 발급 시간을 포함하고 있습니다.
@Slf4j
@Getter
public class TokenContext {
private final String tokenType;
private final String deviceId;
private final String accessToken;
private final LocalDateTime accessTokenExpiresAt;
private final String refreshToken;
private final LocalDateTime refreshTokenExpiresAt;
private final LocalDateTime issuedAt;
public TokenContext(
String deviceId,
String accessToken,
Instant accessTokenExpiresAt,
String refreshToken,
Instant refreshTokenExpiresAt,
Instant issuedAt) {
this.tokenType = "bearer";
this.deviceId = deviceId;
this.accessToken = accessToken;
this.accessTokenExpiresAt = convert(accessTokenExpiresAt);
this.refreshToken = refreshToken;
this.refreshTokenExpiresAt = convert(refreshTokenExpiresAt);
this.issuedAt = convert(issuedAt);
}
private static LocalDateTime convert(Instant instant) {
return LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
}
}
TokenContext
클래스는 JWT 토큰 발급과 관련된 모든 중요한 정보를 담고 있으며, convert()
메서드를 통해 Instant
객체를 LocalDateTime
으로 변환합니다. 이는 JWT 라이브러리가 Instant
타입으로 시간을 다루는데, 이를 더 인간 친화적인 LocalDateTime
으로 변환하여 일관된 시간 표현을 제공하기 위해 작성하였습니다.
액세스 및 리프레시 토큰을 생성하고 관련 정보를 TokenContext
객체에 담아 반환하는 프로세스는 다음과 같습니다:
@Slf4j
@RequiredArgsConstructor
@SecurityAdapter
public class TokenManagementAdapter implements GenerateTokenPort {
private final TokenProperties props;
@Override
public TokenContext generate(User user, String deviceId) {
long currentTimeMillis = System.currentTimeMillis();
Instant issuedAt = issuedAt(currentTimeMillis);
Instant accessTokenExpires = expiresAt(currentTimeMillis, props.getExpiresAccessToken());
Instant refreshTokenExpires = expiresAt(currentTimeMillis, props.getExpiresRefreshToken());
String accessToken = generateAccessToken(user, issuedAt, accessTokenExpires);
String refreshToken = generateRefreshToken(user, issuedAt, refreshTokenExpires);
log.info("generate: Successfully created token = userId={}, deviceId={}, issuedAt={}, refreshTokenExpires={}",
user.getUserId().getId(),
deviceId,
issuedAt,
refreshTokenExpires);
return new TokenContext(
deviceId,
accessToken,
accessTokenExpires,
refreshToken,
refreshTokenExpires,
issuedAt);
}
private String generateAccessToken(User user, Instant issuedAt, Instant expiresAt) {
return JWT.create()
.withClaim("id", user.getUserId().getId())
.withClaim("username", user.getUsername())
.withClaim("roles", user.getRole().name())
.withIssuer(props.getIssuer())
.withIssuedAt(issuedAt)
.withExpiresAt(expiresAt)
.sign(algorithm(props.getSecretKey()));
}
private String generateRefreshToken(User user, Instant issuedAt, Instant expiresAt) {
return JWT.create()
.withClaim("id", user.getUserId().getId())
.withIssuer(props.getIssuer())
.withIssuedAt(issuedAt)
.withExpiresAt(expiresAt)
.sign(algorithm(props.getSecretKey()));
}
private Algorithm algorithm(String secretKey) {
return Algorithm.HMAC256(secretKey);
}
private Instant issuedAt(long currentTimeMillis) {
return Instant.ofEpochMilli(currentTimeMillis);
}
private Instant expiresAt(long currentTimeMillis, long expires) {
return Instant.ofEpochMilli(currentTimeMillis + expires);
}
}
Persisting Refresh Token
이제 마지막으로 리프레시 토큰을 데이터베이스에 저장하는 처리만 남았습니다. 리프레시 토큰을 안전하게 저장하고 관리하는 것은 매우 중요합니다. 이를 통해 액세스 토큰의 만료 시점 이후에도 사용자가 원활하게 서비스를 이용할 수 있도록 보장할 수 있습니다.
@Override
public TokenContext login(LoginCommand command) {
User user = loadUserPort.loadForLogin(command.getUsername());
verifyPassword(user, command);
user.checkActivated();
TokenContext tokenContext = generateTokenPort.generate(user, command.getDeviceId());
persistRefreshToken(user, tokenContext);
return tokenContext;
}
여기서는 JWT 토큰 발행과 리프레시 전략에 집중하기 위해 RDBMS를 사용하여 토큰을 영속화하지만, Redis와 같은 메모리 DB를 사용하여 더 효율적으로 관리할 수도 있습니다. 그러나 이러한 접근 방식은 추가적인 작업이 필요할 수 있습니다. 예를 들어, Redis 환경을 구축하거나, 메모리에 저장된 결과를 주기적으로 RDBMS에 영속화하는 작업이 필요할 수 있습니다.
리프레시 토큰은 액세스 토큰 재발급 시 패스워드와 같이 일치 여부만 확인하면 되며 보안 강화를 위해 토큰 원본을 그대로 저장하기보다는 해싱하여 저장합니다. 해싱된 토큰을 저장하면, 설령 데이터베이스가 해킹 되더라도 원본 값을 알 수 없기 때문에 안전합니다. 해싱 처리는 Spring Security의 PasswordEncoder
를 활용하여 Bcrypt 방식으로 처리합니다.
TokenContext
와 해싱된 리프레시 토큰 값을 활용하여 데이터베이스에 새로운 레코드를 저장합니다. 이 과정에서 해당 사용자의 계정 정보를 다시 확인하여 토큰 발급 상태를 한 번 더 검증합니다.
@Slf4j
@RequiredArgsConstructor
@PersistenceAdapter
public class RefreshTokenPersistenceAdapter implements PersistRefreshTokenPort {
private final UserJpaRepository userJpaRepository;
private final RefreshTokenJpaRepository refreshTokenJpaRepository;
@Override
@Transactional
public RefreshToken persist(RefreshToken refreshToken) {
UserEntity userEntity = loadUserEntity(refreshToken.getUser().getUserId());
RefreshTokenEntity refreshTokenEntity = RefreshTokenEntity.from(refreshToken, userEntity);
refreshTokenEntity = refreshTokenJpaRepository.save(refreshTokenEntity);
log.info("persist: Successfully persisted new refresh token: userId={}, refreshTokenId={}",
userEntity.getId(),
refreshTokenEntity.getId());
return refreshTokenEntity.toDomain();
}
private UserEntity loadUserEntity(UserId userId) {
return userJpaRepository.fetchActivatedUserById(userId)
.orElseThrow(() -> {
log.warn("persist: Does not found activated userEntity - userId={}", userId.getId());
return new UserNotFoundException("Does not found user by userId");
});
}
}
리프레시 토큰 레코드 추가가 완료되면, JWT 토큰 정보를 반환하여 컨트롤러에서 응답 데이터 작성에 활용할 수 있도록 합니다. 이를 통해 사용자는 액세스 토큰과 리프레시 토큰을 받아 이후 요청에서 사용할 수 있습니다.
Testing User Authentication API
샘플 계정을 활용하여 로그인 API를 호출해 정상적으로 동작하는지 확인합니다. 계정 정보와 함께 임의로 생성한 디바이스 ID를 포함하여 요청을 전송합니다:
POST /login HTTP/1.1
Content-Type: application/json
Host: localhost:8080
Connection: close
User-Agent: RapidAPI/4.2.5 (Macintosh; OS X/14.5.0) GCDHTTPRequest
Content-Length: 104
{"username":"jynn@catsriding.com","password":"catsriding","deviceId":"k2sSHKV4q6n1bGY0gsuovxpoDFgEwmuK"}
로그인 요청이 성공적으로 처리되면, 서버는 사용자 자격 증명을 확인하고, 액세스 및 리프레시 토큰 정보가 담긴 응답을 반환합니다. 다음은 성공적인 응답 예시입니다:
{
"code": 200,
"phrase": "OK",
"payload": {
"tokenType": "bearer",
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJqeW5uQGNhdHNyaWRpbmcuY29tIiwicm9sZXMiOiJST0xFX1VTRVIiLCJpc3MiOiJjYXRzcmlkaW5nLmNvbSIsImlhdCI6MTcyMjc4ODE3OCwiZXhwIjoxNzIyNzg4NDc4fQ.QMBoIgVQ4KwAcQa4_0uHRBuBbp1kg6tCoYDXEtDJFvo",
"accessTokenExpiresAt": "2024-08-05T01:21:18.561",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiaXNzIjoiY2F0c3JpZGluZy5jb20iLCJpYXQiOjE3MjI3ODgxNzgsImV4cCI6MTcyMzM5Mjk3OH0.2voa2BtO13h-IbVCs44qdoL-Z0uin24X5DpwWwpfG3Q",
"refreshTokenExpiresAt": "2024-08-12T01:16:18.561"
}
}
응답을 통해 반환된 액세스 토큰과 리프레시 토큰은 클라이언트에서 적절한 저장소에 보관됩니다. 보통 클라이언트는 브라우저의 로컬 스토리지 또는 세션 스토리지, 모바일 앱의 안전한 저장소 등에 토큰을 저장합니다. 이후, 권한이 필요한 리소스에 접근할 때 아래와 같이 액세스 토큰을 요청 헤더에 담아 전송합니다:
GET /me HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJqeW5uQGNhdHNyaWRpbmcuY29tIiwicm9sZXMiOiJST0xFX1VTRVIiLCJpc3MiOiJjYXRzcmlkaW5nLmNvbSIsImlhdCI6MTcyMjc4ODE3OCwiZXhwIjoxNzIyNzg4NDc4fQ.QMBoIgVQ4KwAcQa4_0uHRBuBbp1kg6tCoYDXEtDJFvo
Host: localhost:8080
Connection: close
User-Agent: RapidAPI/4.2.5 (Macintosh; OS X/14.5.0) GCDHTTPRequest
API 서버는 이 액세스 토큰의 유효성을 검증한 후, 해당 사용자가 요청한 리소스에 접근할 권한이 있는지 확인합니다. 유효한 토큰이라면, 요청한 리소스에 접근할 수 있도록 허용합니다.
액세스 토큰이 만료되었거나 만료 시간이 임박한 경우, 클라이언트는 저장된 리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받아야 합니다. 이를 위해 클라이언트는 리프레시 토큰을 사용하여 인증 서버에 액세스 토큰 재발급 요청을 전송합니다. 다음은 바로 이 리프레시 토큰을 사용한 액세스 토큰 재발급 기능을 구현해 보겠습니다.
Implemeting Access Token Renew API
이제 리프레시 토큰을 사용하여 만료된 액세스 토큰을 재발급하는 API를 구현합니다. 이 과정은 리프레시 토큰을 검증하고, 새로운 액세스 토큰과 리프레시 토큰을 발급하여 클라이언트에 반환하는 절차로 구성됩니다:
- 클라이언트가
POST /renew
엔드포인트로 액세스 토큰 재발급 요청을 보냅니다. TokenRenewRequest
객체가 요청 본문에서 생성되고, 기본적인 입력 폼 유효성 검사가 수행됩니다.- 리프레시 토큰을 검증하고, 유효한 경우 새로운 JWT 토큰을 발급합니다.
TokenRenewResponse
객체에 새로운 토큰 정보를 담아 클라이언트에 반환합니다.
Handling Access Token Renew Request
먼저, 액세스 토큰 재발급 요청을 처리하는 클래스들을 살펴보겠습니다. 특정 리소스에 접근하기 전에, 프론트엔드는 액세스 토큰의 만료 시간을 확인한 후 필요하다면 내부적으로 자동으로 보관하고 있던 리프레시 토큰과 디바이스 ID를 사용해 액세스 토큰 재발급 요청을 수행할 것입니다:
@Slf4j
@Getter
@Builder
@RequiredArgsConstructor
public class TokenRenewRequest {
@NotBlank
private final String refreshToken;
@NotBlank
@Size(max = 50)
private final String deviceId;
public RenewAccessTokenCommand toCommand() {
return RenewAccessTokenCommand.builder()
.refreshToken(refreshToken)
.deviceId(deviceId)
.build();
}
}
refreshToken
: 클라이언트가 제공한 리프레시 토큰입니다.deviceId
: 디바이스마다 별도의 인증 정보를 관리하기 위한 값입니다.toCommand()
: 요청 처리에 필요한 모델로 전환합니다.
리프레시 토큰이 유효하다면, 로그인 API와 마찬가지로 요청 처리 후 새로운 토큰이 담긴 TokenContext
객체가 전달됩니다. 이를 통해 응답 데이터를 작성합니다:
@Slf4j
@Getter
@Builder
@RequiredArgsConstructor
public class TokenRenewResponse {
private final String tokenType;
private final String accessToken;
private final LocalDateTime accessTokenExpiresAt;
private final String refreshToken;
private final LocalDateTime refreshTokenExpiresAt;
public static TokenRenewResponse from(TokenContext tokenContext) {
return TokenRenewResponse.builder()
.tokenType(tokenContext.getTokenType())
.accessToken(tokenContext.getAccessToken())
.accessTokenExpiresAt(tokenContext.getAccessTokenExpiresAt())
.refreshToken(tokenContext.getRefreshToken())
.refreshTokenExpiresAt(tokenContext.getRefreshTokenExpiresAt())
.build();
}
}
이들 모델을 활용하여 액세스 토큰 재발급 요청을 처리하는 컨트롤러를 구현합니다. POST /renew
엔드포인트로 들어오는 요청을 처리하고, RenewAccessTokenUseCase
를 통해 실제 재발급 처리를 수행합니다. 성공적으로 재발급되면, TokenContext
객체를 TokenRenewResponse
로 변환하여 클라이언트에 응답합니다:
@Slf4j
@RequiredArgsConstructor
@RestApiAdapter
public class TokenRenewApiAdapter {
private final RenewAccessTokenUseCase renewAccessTokenUseCase;
@PostMapping("/renew")
public ResponseEntity<?> renew(@Valid @RequestBody TokenRenewRequest request) {
TokenContext tokenContext = renewAccessTokenUseCase.renew(request.toCommand());
TokenRenewResponse response = TokenRenewResponse.from(tokenContext);
return ResponseEntity
.ok(RestApiResponse.compose(SUCCESS, response));
}
}
Processing Renew Access Token
이제 액세스 토큰 재발급 요청 처리 세부 사항을 구현해보도록 하겠습니다. 이는 다음과 같은 과정을 거칩니다:
- 리프레시 토큰을 디코딩하여 사용자 ID를 추출합니다.
- 데이터베이스에서 해당 사용자와 디바이스 ID에 맞는 리프레시 토큰을 조회합니다.
- 사용자의 계정 및 리프레시 토큰이 유효한지 검증합니다.
- 새로운 액세스 및 리프레시 토큰을 생성합니다.
- 새로 생성된 리프레시 토큰 정보를 데이터베이스에 저장합니다.
- 생성된 토큰 정보를 클라이언트에 반환합니다.
@Slf4j
@RequiredArgsConstructor
@UseCase
public class UserManagementService implements RenewAccessTokenUseCase {
private final LoadRefreshTokenPort loadRefreshTokenPort;
private final VerifyEncryptedDataPort verifyEncryptedDataPort;
private final GenerateTokenPort generateTokenPort;
private final DecodeTokenPort decodeTokenPort;
private final PersistRefreshTokenPort persistRefreshTokenPort;
private final EncryptTokenPort encryptTokenPort;
...
@Override
public TokenContext renew(RenewAccessTokenCommand command) {
UserId userId = decodeTokenPort.claimUserId(command.getRefreshToken());
RefreshToken refreshToken = loadRefreshTokenPort.loadForRenewAccessToken(userId, command.getDeviceId());
User user = refreshToken.getUser();
verifyRefreshToken(refreshToken, command);
user.checkActivated();
refreshToken.checkNotExpired();
TokenContext tokenContext = generateTokenPort.generate(user, command.getDeviceId());
persistRefreshToken(user, tokenContext);
return tokenContext;
}
}
Fetching Refresh Token Data
리프레시 토큰 데이터를 조회하는 과정은 다음과 같습니다. 먼저, 리프레시 토큰을 디코딩하여 사용자 ID를 추출합니다:
@Slf4j
@RequiredArgsConstructor
@SecurityAdapter
public class TokenManagementAdapter implements GenerateTokenPort, DecodeTokenPort {
...
@Override
public UserId claimUserId(String refreshToken) {
try {
Long id = Optional.ofNullable(claim(refreshToken, "id").asLong())
.orElseThrow(() -> new InvalidTokenException("Invalid refresh token"));
return UserId.withId(id);
} catch (TokenExpiredException e) {
log.warn("claimUserId: Token has expired.", e);
throw new InvalidTokenException("Token has expired.");
} catch (AlgorithmMismatchException e) {
log.warn("claimUserId: Token algorithm does not match.", e);
throw new InvalidTokenException("Token algorithm does not match.");
} catch (SignatureVerificationException e) {
log.warn("claimUserId: Token signature verification failed.", e);
throw new InvalidTokenException("Token signature verification failed.");
} catch (JWTVerificationException e) {
log.warn("claimUserId: JWT verification failed.", e);
throw new InvalidTokenException("Token has tampered.");
}
}
private Claim claim(String refreshToken, String key) {
return JWT.require(algorithm(props.getSecretKey())).build()
.verify(refreshToken)
.getClaims()
.get(key);
}
}
JWT.require()
: 리프레시 토큰을 검증합니다.JWT.verify()
:DecodedJWT
객체를 반환하여 토큰의 유효성을 검증합니다.DecodedJWT
는 JWT의 해독된 정보를 담고 있으며, 토큰의 헤더, 페이로드, 서명 등을 포함한 모든 클레임에 접근할 수 있습니다.DecodedJWT.getClaims()
:DecodedJWT
에서 페이로드의 클레임 내역을Map<String, Claim>
형식으로 반환합니다. 여기서 특정 키로Claim
을 획득할 수 있습니다.Claim
: JWT의 클레임을 나타내는 객체로, 토큰에 포함된 각종 정보를 담고 있습니다.asLong()
,asBoolean()
,asList()
등 다양한 메서드를 통해 클레임 값을 적절한 타입으로 변환할 수 있습니다.
이렇게 추출한 사용자 ID를 활용하여 데이터베이스에서 사용자 ID와 디바이스 ID에 맞는 리프레시 토큰을 조회합니다. 해당 조건에 부합하는 여러 개의 레코드가 존재할 수 있으며, 이 경우 가장 최신의 토큰을 기준으로 처리합니다:
@Slf4j
@RequiredArgsConstructor
public class RefreshTokenJpaRepositoryImpl implements RefreshTokenJpaRepositoryExtension {
private final JPAQueryFactory queryFactory;
@Override
public Optional<RefreshTokenEntity> fetchWithUserByUserIdAndDeviceId(UserId userId, String deviceId) {
return Optional.ofNullable(
queryFactory
.select(refreshTokenEntity)
.from(refreshTokenEntity)
.innerJoin(refreshTokenEntity.user, userEntity).fetchJoin()
.where(
userEntity.id.eq(userId.getId()),
refreshTokenEntity.deviceId.eq(deviceId)
)
.orderBy(
refreshTokenEntity.id.desc()
)
.fetchFirst());
}
}
데이터 조회 결과가 없을 수 있기 때문에 적절한 예외 처리가 필요합니다. 데이터가 조회된다면 도메인 객체로 변환하여 반환합니다:
@Slf4j
@RequiredArgsConstructor
@PersistenceAdapter
public class RefreshTokenPersistenceAdapter implements PersistRefreshTokenPort, LoadRefreshTokenPort {
@Override
@Transactional(readOnly = true)
public RefreshToken loadForRenewAccessToken(UserId userId, String deviceId) {
return loadRefreshTokenEntity(userId, deviceId).toDomain();
}
private RefreshTokenEntity loadRefreshTokenEntity(UserId userId, String deviceId) {
return refreshTokenJpaRepository.fetchWithUserByUserIdAndDeviceId(userId, deviceId)
.orElseThrow(() -> {
log.warn("loadRefreshTokenEntity: Does not found refreshTokenEntity - userId={}, deviceId={}",
userId,
deviceId);
return new RefreshTokenNotFoundException("Does not found refresh token by userId and deviceId");
});
}
}
Verifying Token Specifications
데이터베이스에서 조회한 데이터를 기반으로 리프레시 토큰의 유효성을 확인하고 만료 여부를 검증합니다. 먼저, 저장된 리프레시 토큰과 요청된 리프레시 토큰이 일치하는지 확인합니다. 이를 위해 PasswordEncoder
를 사용하여 해싱된 데이터와의 일치 여부를 검증합니다:
private void verifyRefreshToken(RefreshToken refreshToken, RenewAccessTokenCommand command) {
if (!verifyEncryptedDataPort.verify(command.getRefreshToken(), refreshToken.getToken())) {
log.warn("verifyRefreshToken: Does not match refresh token - refreshTokenId={}, deviceId={}",
refreshToken.getRefreshTokenId().getId(),
refreshToken.getDeviceId());
throw new LoginFailedException("Does not match refresh token.");
}
}
또한, 리프레시 토큰의 만료 여부를 확인하여 만료되지 않은 경우에만 새로운 토큰을 발급합니다. 리프레시 토큰이 만료된 경우에는 적절한 예외를 발생시켜 프론트엔드에 다시 로그인하도록 안내할 수 있습니다:
@Slf4j
@Getter
@Builder
@RequiredArgsConstructor
public class RefreshToken {
...
public void checkNotExpired() {
TokenNotExpiredSpec tokenNotExpiredSpec = new TokenNotExpiredSpec();
if (!tokenNotExpiredSpec.isSatisfiedBy(this)) {
log.warn("checkNotExpired: Token has expired - refreshTokenId={}, expiresAt={}",
refreshTokenId.getId(),
expiresAt);
throw new TokenHasExpiredException("Token has expired.");
}
}
}
Reissuing and Persisting Tokens
리프레시 토큰이 유효하고 만료되지 않았다면, 새로운 액세스 및 리프레시 토큰을 생성하여 데이터베이스에 저장하고 클라이언트에 반환합니다. 이 과정은 로그인 요청 처리와 동일한 프로세스를 따르므로, 기존에 구현한 함수를 재활용할 수 있습니다:
@Override
public TokenContext renew(RenewAccessTokenCommand command) {
UserId userId = decodeTokenPort.claimUserId(command.getRefreshToken());
RefreshToken refreshToken = loadRefreshTokenPort.loadForRenewAccessToken(userId, command.getDeviceId());
User user = refreshToken.getUser();
verifyRefreshToken(refreshToken, command);
user.checkActivated();
refreshToken.checkNotExpired();
TokenContext tokenContext = generateTokenPort.generate(user, command.getDeviceId());
persistRefreshToken(user, tokenContext);
return tokenContext;
}
Testing Access Token Renew API
샘플 계정을 활용하여 액세스 토큰 재발급 API를 호출해 정상적으로 동작하는지 확인합니다. 로그인 API에 사용한 디바이스 ID와 응답의 리프레시 토큰을 활용하여 요청을 전송합니다:
POST /renew HTTP/1.1
Content-Type: application/json
Host: localhost:8080
Connection: close
User-Agent: RapidAPI/4.2.5 (Macintosh; OS X/14.5.0) GCDHTTPRequest
Content-Length: 233
{"refreshToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiaXNzIjoiY2F0c3JpZGluZy5jb20iLCJpYXQiOjE3MjI3ODgxNzgsImV4cCI6MTcyMzM5Mjk3OH0.2voa2BtO13h-IbVCs44qdoL-Z0uin24X5DpwWwpfG3Q","deviceId":"k2sSHKV4q6n1bGY0gsuovxpoDFgEwmuK"}
서버는 리프레시 토큰을 검증하고, 새로운 액세스 및 리프레시 토큰 정보를 담은 응답을 반환합니다:
{
"code": 200,
"phrase": "OK",
"payload": {
"tokenType": "bearer",
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJqeW5uQGNhdHNyaWRpbmcuY29tIiwicm9sZXMiOiJST0xFX1VTRVIiLCJpc3MiOiJjYXRzcmlkaW5nLmNvbSIsImlhdCI6MTcyMjc4ODE5NiwiZXhwIjoxNzIyNzg4NDk2fQ.XfT6f9y_LAW0_LFohZ_ndKLWkQxgGY95cGHB_gcU0yY",
"accessTokenExpiresAt": "2024-08-05T01:21:36.073",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiaXNzIjoiY2F0c3JpZGluZy5jb20iLCJpYXQiOjE3MjI3ODgxOTYsImV4cCI6MTcyMzM5Mjk5Nn0.AHQeztUh5c3ZMmUgh5iRgOSNV9UyE4yTOwO71cUsjyA",
"refreshTokenExpiresAt": "2024-08-12T01:16:36.073"
}
}
Privacy as a Prerequisite
여기까지 JWT 기반의 인증 서버를 구현하였습니다. 예외 처리를 별도로 다루지 않았지만, 실제 서비스에서는 각 상황에 맞는 적절한 예외 처리가 필요합니다.
토큰 인증 방식은 탈취로 인한 취약점을 가지고 있기 때문에, 인증 서버는 토큰 요청이 새로운 IP 주소나 디바이스에서 발생할 경우, 이메일이나 인증 코드를 통한 사용자의 본인 확인 등의 추가적인 보안 조치가 필요할 수 있습니다. 이를 통해 잠재적인 보안 위협을 사전에 차단하고, 사용자 계정의 안전성을 더욱 강화할 수 있습니다. 이러한 다층 보안 전략은 JWT 기반 인증 시스템의 신뢰성과 보안을 한층 더 높일 수 있습니다.
이 글에서는 전체 코드의 일부만 포함되어 있습니다. 🐙 GitHub Repository에서 해당 프로젝트의 전체 코드를 확인할 수 있습니다.
- Spring
- Security