JWT는 RFC7519 웹 표준으로 JSON 객체를 이용해 데이터를 주고받을 수 있도록한 웹 토큰입니다.
JWT는 header
, payload
, signature
로 구성되어 있으며 header
는 signature
를 해싱하기 위한 알고리즘 정보가 담겨있고 payload
는 실제로 사용될 데이터들이 담겨 있습니다.
signature
는 토큰의 유효성 검증을 위한 문자열로 이 문자열을 통해 이 토큰이 유효한 토큰인지 검증 가능합니다.
JWT는 인터페이스이고 그 구현체인 JWS
, JWE
가 존재합니다. 해당 게시글에서는 JWS
를 응용하며 JWT에 대한 자세한 게시글은 다음에 작성하도록 하겠습니다.
먼저 자바21로 개발환경을 구성해주셔야 합니다!
윈도우/맥 터미널에 java --version
을 입력했을 때 자바 21이 출력되도록 환경구성을 해주세요.
또한 인텔리제이는 최소 23.3 버전으로 업데이트 해주셔야 합니다.
그다음 spring initializer에서 스프링 프로젝트를 생성합니다.
의존성은 별도로 추가할 예정이오니 아무것도 추가 안하셔도 됩니다.
build.gradle에 spring WEB
, JPA
, H2
, lombok
,security
, oauth2-resource-server
, security-test
, spring validation
의존성을 기입합니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
annotationProcessor 'org.projectlombok:lombok'
implementation 'org.flywaydb:flyway-core:9.16.0'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'
}
oauth2-resource-server
는 클라이언트 및 인가서버와의 통신을 담당하는 리소스 서버의 기능을 필터 기반으로 구현한 모듈로 클라이언트 리소스 접근 제한, 토큰 검증을 위한 인가서버와의 통신 등의 구현이 가능합니다.
JWT 토큰을 생성하고 이를 검증하는데 있어 많은 추상화를 해주므로, 이를 간편하게 사용하기 위해 해당 의존성을 스프링 시큐리티에 이어 추가로 주입해줍니다.
spring:
h2:
console:
enabled: true
path: /h2
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:test;mode=mysql
username: sa
password:
flyway:
enabled: true
baseline-on-migrate: true
locations: classpath:db/migration/{vendor},classpath:db/seed/local # when you want to give test seed, add location test seed too
jpa:
show-sql: true
generate-ddl: false
hibernate:
ddl-auto: validate
properties:
hibernate:
format_sql: true
jwt:
access-private-key: classpath:secret/accessKey
access-public-key: classpath:secret/accessKey.pub
access-token-validity-in-seconds: 600
access-token-validity-in-seconds
은 우리가 발급할 액세스토큰의 유효기간을 지정합니다.
jwt.access-private-key와 jwt.access-public-key에는 아래에서 설명할 JWT 암복호화에 사용할 키의 경로를 지정합니다.
맥이나 리눅스 계열 OS를 사용하신다면 openssl 명령어를 이용해 비대칭키를 생성할 수 있습니다.
openssl genrsa -out accessKey 2048
openssl rsa in accessKey -out accessKey.pub -pubout
생성 후 resources/secret 하위 디렉토리에 위치시킵니다.
@Entity
@Table(name = "authority")
@Getter
@NoArgsConstructor
public class Authority {
@Id
@Column(name = "authority_name", length = 50)
private String authorityName;
@Builder
public Authority(String authorityName) {
this.authorityName = authorityName;
}
}
@Entity
@Table(name = "account")
@Getter
@NoArgsConstructor
public class Account {
@Id
@Column(name = "account_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "username", length = 50, unique = true)
private String username;
@Column(name = "password", length = 100)
private String password;
@Column(name = "nickname", length = 50)
private String nickname;
@Column(name = "activated")
private boolean activated;
@ManyToMany
@JoinTable( // JoinTable은 테이블과 테이블 사이에 별도의 조인 테이블을 만들어 양 테이블간의 연관관계를 설정 하는 방법
name = "account_authority",
joinColumns = {@JoinColumn(name = "account_id", referencedColumnName = "account_id")},
inverseJoinColumns = {@JoinColumn(name = "authority_name", referencedColumnName = "authority_name")})
private Set<Authority> authorities;
@Builder
public Account(String username, String password, String nickname, Set<Authority> authorities, boolean activated) {
this.username = username;
this.password = password;
this.nickname = nickname;
this.authorities = authorities;
this.activated = activated;
this.tokenWeight = 1L; // 초기 가중치는 1
}
}
Authority
는 인가에 사용되는 권한들을 DB로 관리하고자 생성한 엔티티입니다.
Account
는 인증에 사용되는 계정 엔티티입니다.
현재 embedded H2
를 사용하며 JPA
의 create-drop
설정에 따라 스프링 부트 애플리케이션이 실행될 때마다 데이터베이스에 있는 데이터들은 전부 날라갈 것입니다.
해당 게시글은 예제이므로 초기 데이터를 넣어주기 위해 resources/data.sql
을 작성합니다. 실제로 개발환경에서 초기 권한데이터를 넣어줄 땐 flyway
를 사용해보아요.
INSERT INTO ACCOUNT (ACCOUNT_ID, USERNAME, PASSWORD, NICKNAME, ACTIVATED) VALUES (1, 'admin', '$2a$08$lDnHPz7eUkSi6ao14Twuau08mzhWrL4kyZGGU5xfiGALO/Vxd5DOi', 'admin', 1);
INSERT INTO ACCOUNT (ACCOUNT_ID, USERNAME, PASSWORD, NICKNAME, ACTIVATED) VALUES (2, 'user', '$2a$08$UkVvwpULis18S19S5pZFn.YHPZt3oaqHZnDwqbCW9pft6uFtkXKDC', 'user', 1);
INSERT INTO AUTHORITY (AUTHORITY_NAME) values ('ROLE_MEMBER');
INSERT INTO AUTHORITY (AUTHORITY_NAME) values ('ROLE_ADMIN');
INSERT INTO ACCOUNT_AUTHORITY (ACCOUNT_ID, AUTHORITY_NAME) values (1, 'ROLE_MEMBER');
INSERT INTO ACCOUNT_AUTHORITY (ACCOUNT_ID, AUTHORITY_NAME) values (1, 'ROLE_ADMIN');
INSERT INTO ACCOUNT_AUTHORITY (ACCOUNT_ID, AUTHORITY_NAME) values (2, 'ROLE_MEMBER');
@Data
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
private RSAPublicKey accessPublicKey;
private RSAPrivateKey accessPrivateKey;
private Long accessTokenValidityInSeconds;
}
application.yml
에 기입한 정보를 객체로 매핑하여 사용하기 위해 선언합니다.
@Configuration
@RequiredArgsConstructor
@EnableConfigurationProperties(JwtProperties.class)
public class JwtConfig {
@Bean(name = "accessJwtDecoder")
public JwtDecoder accessJwtDecoder(JwtProperties jwtProperties) {
return NimbusJwtDecoder.withPublicKey(jwtProperties.getAccessPublicKey()).build();
}
@Bean(name = "accessJwtEncoder")
public JwtEncoder accessJwtEncoder(JwtProperties jwtProperties) {
JWK jwk = new RSAKey.Builder(jwtProperties.getAccessPublicKey())
.privateKey(jwtProperties.getAccessPrivateKey())
.build();
JWKSource<SecurityContext> jwks = new ImmutableJWKSet<>(new JWKSet(jwk));
return new NimbusJwtEncoder(jwks);
}
@Bean(name = "accessTokenProvider")
public AccessTokenProvider accessTokenProvider(JwtEncoder accessJwtEncoder,
JwtProperties jwtProperties) {
return new AccessTokenProvider(accessJwtEncoder, jwtProperties.getAccessTokenValidityInSeconds());
}
}
JwtConfig
는 JWT 설정파일로 AccessTokenProvider
에 의존성을 주입하고 빈을 생성하는 역할을 수행합니다.
JWT 토큰 인코딩, 디코딩의 책임을 가지는 JwtDecoder
빈, JwtEnCoder
빈도 이쪽 설정파일에서 생성합니다.
public final class AccessTokenProvider {
private final JwtEncoder jwtEncoder;
private final long tokenValidityInSeconds;
private static final String AUTHORITIES_KEY = "scp"; // spring security 기본값 (scope)
public AccessTokenProvider(JwtEncoder jwtEncoder, long tokenValidityInSeconds) {
this.jwtEncoder = jwtEncoder;
this.tokenValidityInSeconds = tokenValidityInSeconds;
}
// 토큰 생성
public String createToken(String username, Set<String> authorities) {
String strAuthorities = String.join(" ", authorities);
Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("self")
.issuedAt(now)
.expiresAt(now.plusSeconds(this.tokenValidityInSeconds))
.subject(username)
.claim(AUTHORITIES_KEY, strAuthorities)
.build();
return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
}
AccessTokenProvider는 TokenProvider
를 상속받으며 아이디, 비밀번호를 이용해 토큰을 생성하는 역할을 수행합니다.
AuthenticationEntryPoint
는 인증 실패 시 동작하도록 시큐리티 설정파일 작성 시 지정할 예정입니다. 상속받아 구현합니다.
// 인증 실패 시
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
// 유효한 자격증명을 제공하지 않고 접근하려 할때 401(인증 실패)
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
AccessDeniedHandler
는 권한 체크 후 인가 실패 시 동작하도록 시큐리티 설정파일에 설정할 예정입니다.
AccessDeniedHandler
를 상속받아 구현합니다.
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
//필요한 권한이 없이 접근하려 할때 403
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
@Configuration
public class PasswordEncoderConfig {
// BCryptPasswordEncoder 라는 패스워드 인코더 사용
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
유저의 비밀번호는 비밀번호가 저장된 DB를 열어볼 수 있는 개발자조차 몰라야 합니다. 이를 위해 유저의 비밀번호는 암호화하여 DB에 저장합니다.
비밀번호 암호화, 검증을 위해 PasswordEncoder 빈을 생성합니다.
@Configuration
@EnableWebSecurity // 기본적인 웹보안을 활성화하겠다
@EnableMethodSecurity // @PreAuthorize 어노테이션 사용을 위해 선언
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.csrf(AbstractHttpConfigurer::disable) // token을 사용하는 방식이기 때문에 csrf를 disable
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.sessionManagement(httpSecuritySessionManagementConfigurer ->
httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.cors(httpSecurityCorsConfigurer ->
httpSecurityCorsConfigurer.configurationSource(corsConfigurationSource())
)
.exceptionHandling(exceptionConfig ->
exceptionConfig.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
)
.headers(headerConfig -> headerConfig.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
.authorizeHttpRequests(registry ->
registry.requestMatchers("/h2/**").permitAll()
.requestMatchers("/favicon.ico").permitAll()
.requestMatchers("/error").permitAll()
)
.authorizeHttpRequests(registry -> // actuator, swagger 경로, 실무에서는 상황에 따라 적절한 접근제어 필요
registry.requestMatchers("/actuator/**").permitAll()
.requestMatchers("/docs/**").permitAll()
)
.authorizeHttpRequests(registry -> // api path
registry.requestMatchers("/api/hello").permitAll()
.requestMatchers("/api/v1/accounts/token").permitAll() // login
.requestMatchers("/api/v1/members").permitAll()
)
.authorizeHttpRequests(registry -> registry.anyRequest().authenticated()) // 나머지 경로는 jwt 인증 해야함
.oauth2ResourceServer(httpSecurityOAuth2ResourceServerConfigurer ->
httpSecurityOAuth2ResourceServerConfigurer.jwt(jwtConfigurer ->
jwtConfigurer.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);
return httpSecurity.build();
}
private JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(new CustomJwtGrantedAuthoritiesConverter());
return converter;
}
private CorsConfigurationSource corsConfigurationSource() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOriginPattern("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
@EnableMethodSecurity
는 @PreAuthorize
어노테이션 사용을 위해 선언합니다.
@EnableWebSecurity
는 기본적인 웹보안을 활성화하겠다는 어노테이션입니다.
header에서 Authorization
필드의 Bearer 토큰을 꺼내와 Authentication
객체를 만들어어 주는 부분은 oauth2-resource-server
의존성을 추가하고 oauth2ResourceServer
를 지정해줌으로써 많은 추상화가 이루어졌습니다.
JwtAuthenticationConverter
는 Jwt를 인증으로 변환하는 역할을 담당합니다.
우리는 인증 인가 후 만들어진 Authentication
객체를 통해 유저의 인증, 인가 정보를 가져와 사용할 수 있습니다.
혹시 토큰에서 authorities 정보를 꺼내와 객체를 구성하는 로직을 커스텀하고 싶을 수 있습니다. CustomJwtGrantedAuthoritiesConverter
클래스를 구성하고 JwtAuthenticationConverter
에서 이를 지정합니다.
// 커스텀이 필요하면 수정
public class CustomJwtGrantedAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
@Override
public Collection<GrantedAuthority> convert(Jwt source) {
JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
return converter.convert(source);
}
}
현재는 단순 예제이므로 기본적인 JwtGrantedAuthoritiesConverter
객체를 생성해 conveter로 지정하고 JwtAuthenticationConverter
를 반환했습니다.
앞으로 커스텀이 필요한 경우 해당 클래스를 수정해줍니다.
@Builder
public record TokenRequestDto(
@NotNull
@Size(min = 3, max = 50)
String username,
@NotNull
@Size(min = 5, max = 100)
String password
) {
}
@Builder
public record TokenResponseDto(
String accessToken,
) {
}
public interface AccountRepository extends JpaRepository<Account, Long> {
@EntityGraph(attributePaths = "authorities") // 엔티티그래프 통해 EAGER로 가져온다.
Optional<Account> findOneWithAuthoritiesByUsername(String username); // user를 기준으로 유저를 조회할 때 권한정보도 가져온다.
}
public interface AuthorityRepository extends JpaRepository<Authority, String> {
}
@Service
public class AccountService {
private final AccessTokenProvider accessTokenProvider;
private final RefreshTokenProvider refreshTokenProvider;
private final AccountRepository accountRepository;
private final PasswordEncoder passwordEncoder;
// username 과 패스워드로 사용자를 인증하여 액세스토큰과 리프레시 토큰을 반환한다.
@Override
public TokenResponseDto authenticate(TokenRequestDto tokenRequestDto) {
Account account = accountRepository.findOneWithAuthoritiesByUsername(tokenRequestDto.username())
.orElseThrow(() -> new RuntimeException());
if (!passwordEncoder.matches(tokenRequestDto.password(), account.getPassword())) {
throw new RuntimeException();
}
if(!account.isActivated()) {
throw new RuntimeException();
}
Set<String> authorities = account.getAuthorities().stream()
.map(Authority::getAuthorityName).collect(Collectors.toSet());
String accessToken = accessTokenProvider.createToken(account.getUsername(), authorities);
return TokenResponseDto.builder()
.accessToken(accessToken)
.build();
}
}
@RestController
@RequestMapping("/api")
public class AccountController {
private final AccountService accountService;
// 생성자주입
public AuthController(AccountService accountService) {
this.accountService = accountService;
}
@PostMapping("/authenticate") // Account 인증 API
public ResponseEntity<TokenResponseDto> authorize(@Valid @RequestBody TokenRequestDto requestDto) {
TokenResponseDto token = accountService.authenticate(requestDto);
// response header 에도 넣고 응답 객체에도 넣는다.
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add("Authorization", "Bearer " + token.accessToken());
return new ResponseEntity<>(token, httpHeaders, HttpStatus.OK);
}
}
ROLE_MEMBER
을 부여하여 Account
를 생성하는 유저 등록 API를 구현하겠습니다.
@Builder
public record RegisterMemberRequestDto(
@NotNull
@Size(min = 3, max = 50)
String username,
@NotNull
@Size(min = 5, max = 100)
String password,
@NotNull
@Size(min = 5, max = 100)
String nickname
) {
}
@Service
@RequiredArgsConstructor
public class AccountService {
private final AccountRepository accountRepository;
private final PasswordEncoder passwordEncoder;
......
....
@Transactional
@Override
public void registerMember(RegisterMemberRequestDto requestDto) {
Optional<Account> accountOptional = accountRepository.findOneWithAuthoritiesByUsername(requestDto.username());
if (accountOptional.isPresent()) {
throw new RuntimeExceptionn("이미 가입되어있는 유저");
}
// 이 유저는 권한이 MEMBER
// 이건 부팅 시 data.sql에서 INSERT로 디비에 반영한다. 즉 디비에 존재하는 값이여야함
Authority authority = Authority.builder()
.authorityName("ROLE_MEMBER")
.build();
Account user = Account.builder()
.username(requestDto.username())
.password(passwordEncoder.encode(requestDto.password()))
.nickname(requestDto.nickname())
.authorities(Collections.singleton(authority))
.activated(true)
.build();
accountRepository.save(user);
}
ROLE_MEMBER
는 Authority
테이블에 존재하는 값이여야 합니다.
현재는 data.sql
에서 애플리케이션 부팅 시 insert 해줍니다.
AccountService
를 가져다 사용하기 위해 파사드 패턴을 사용합니다.
@Builder
public record RegisterMemberFacadeRequestDto(
@NotNull
@Size(min = 3, max = 50)
String username,
@NotNull
@Size(min = 5, max = 100)
String password,
@NotNull
@Size(min = 5, max = 100)
String nickname
) {
}
@Service
@RequiredArgsConstructor
public class MemberFacadeService {
private final AccountService accountService;
@Override
public void signup(RegisterMemberFacadeRequestDto requestDto) {
accountService.registerMember(RegisterMemberRequestDto.builder()
.nickname(requestDto.nickname())
.username(requestDto.username())
.password(requestDto.password())
.build());
}
}
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class MemberController {
private final MemberFacadeService memberFacadeService;
// user 등록 API
@PostMapping("/members")
public ResponseEntity<Void> signup(
@Valid @RequestBody RegisterMemberFacadeRequestDto requestDto
) {
memberFacadeService.signup(requestDto);
return ResponseEntity
.noContent()
.build();
}
}
HelloController
를 작성하여 인증과 권한에 따른 인가가 잘 작동하는지 테스트해봅니다. 먼저 AccountService
에 기능을 몇 개 추가하겠습니다.
// AccountService
@Override
@Transactional(readOnly = true)
public AccountInfoResponseDto getAccountWithAuthorities(String username) {
Account account = accountRepository.findOneWithAuthoritiesByUsername(username)
.orElseThrow(() -> new RuntimeException());
return AccountInfoResponseDto.of(account);
}
위의 메서드는 username
을 받아 해당 Account
의 정보를 반환합니다.
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class HelloController {
private final AccountService accountService;
@GetMapping("/hello")
public ResponseEntity<String> hello() {
return ResponseEntity.ok("hello");
}
// redirect test
@PostMapping("/test-redirect")
public void testRedirect(HttpServletResponse response) throws IOException {
response.sendRedirect("/api/user");
}
// 인가 테스트
// Authorization: Bearer {AccessToken}
@GetMapping("/user")
@PreAuthorize("hasAuthority('SCOPE_ROLE_MEMBER') or hasAuthority('SCOPE_ROLE_ADMIN')")
@SecurityRequirement(name = "bearer-key")
public ResponseEntity<AccountInfoResponseDto> getMyUserInfo(Authentication authentication) {
log.info(authentication.getName());
log.info(authentication.getAuthorities().toString());
return ResponseEntity.ok(accountService.getAccountWithAuthorities(authentication.getName()));
}
@GetMapping("/user/{username}")
@PreAuthorize("hasAuthority('SCOPE_ROLE_ADMIN')") // ADMIN 권한만 호출 가능
@SecurityRequirement(name = "bearer-key")
public ResponseEntity<AccountInfoResponseDto> getUserInfo(@PathVariable(name = "username") String username) {
return ResponseEntity.ok(accountService.getAccountWithAuthorities(username));
}
}
https://github.com/suhongkim98/spring-security-jwt-ssongplate
위 깃허브를 통해 예제를 확인할 수 있습니다.
블로그에서는 액세스토큰만 설명드렸지만 예제에는 리프레시토큰 예제도 포함되어 있으며 계속 리팩토링되고 있는 레포지토리입니다.
잘 봤습니다. 다만 궁금한 점이 있어서 댓글 남겨요!
제가 알기로 필터에서는 사용자의 인가처리보다는 잘못된 요청이나 비정상적인 사용자 접근을 처리해주는게 맞다고 생각했습니다. 하지만 코드를 보다보니, 필터에서 인가처리를 해주는 것 같아서 궁금해서 여쭤봅니다.
제가 스프링 시큐리티를 아직 사용해보지 않았기 때문에 그럴수도 있어서 혹시 스프링 시큐리티를 사용하면 필터에서 처리를 해줘야하는 건가요? 아니라면 인터셉터가 아닌 필터에서 인가 처리를 하신 이유가 있을까요?