해당 게시글은 인프런 Spring Boot JWT Tutorial을 보고 코드를 저에 맞게 변형하였습니다.
맨 마지막에 예제 프로젝트가 있으니 디렉토리를 참고하며 봐주시기 바랍니다
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
, security-test
, spring validation
, jwt
의존성을 기입합니다.
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-validation'
annotationProcessor 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
spring:
h2:
console:
enabled: true
path: /h2
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:test;mode=mysql
username: sa
password:
jpa:
show-sql: true
generate-ddl: true
hibernate:
ddl-auto: create-drop
properties:
hibernate:
format_sql: true
defer-datasource-initialization: true
jwt:
header: Authorization
secret: rutyweorituwyerotiuweyrtoiuweyrtoweiurtywoeighdfsojkghsdfgsdofiguwyertouw | base64
YWprbGdoc2Rma2xnanNkaGZnbGprc2RmZ2hsc2
access-token-validity-in-seconds: 600 # 10 min
jwt.secret
은 랜덤의 문자열을 base64로 인코딩한 값을 사용하였습니다. 일정 길이 이상 되어야합니다. 짧으면 exception을 발생시키니 충분히 길게 설정합니다.
echo gfdfgdflkgjdlfkgjsasdjkhaskdjahsdkjahsd | base64
access-token-validity-in-seconds
은 우리가 발급할 액세스토큰의 유효기간을 지정합니다.
@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 USER (USER_ID, USERNAME, PASSWORD, NICKNAME, ACTIVATED) VALUES (1, 'admin', '$2a$08$lDnHPz7eUkSi6ao14Twuau08mzhWrL4kyZGGU5xfiGALO/Vxd5DOi', 'admin', 1);
INSERT INTO USER (USER_ID, USERNAME, PASSWORD, NICKNAME, ACTIVATED) VALUES (2, 'user', '$2a$08$UkVvwpULis18S19S5pZFn.YHPZt3oaqHZnDwqbCW9pft6uFtkXKDC', 'user', 1);
INSERT INTO AUTHORITY (AUTHORITY_NAME) values ('ROLE_USER');
INSERT INTO AUTHORITY (AUTHORITY_NAME) values ('ROLE_ADMIN');
INSERT INTO USER_AUTHORITY (USER_ID, AUTHORITY_NAME) values (1, 'ROLE_MEMBER');
INSERT INTO USER_AUTHORITY (USER_ID, AUTHORITY_NAME) values (1, 'ROLE_ADMIN');
INSERT INTO USER_AUTHORITY (USER_ID, AUTHORITY_NAME) values (2, 'ROLE_USER');
@Data
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
private String header;
private String secret;
private Long accessTokenValidityInSeconds;
}
application.yml
에 기입한 정보를 객체로 매핑하여 사용하기 위해 선언합니다.
// 토큰 생성, 검증
@Slf4j
public sealed class TokenProvider permits AccessTokenProvider {
protected static final String AUTHORITIES_KEY = "auth";
protected final String secret;
protected final long tokenValidityInMilliseconds;
protected Key key;
public TokenProvider(String secret, long tokenValidityInSeconds) {
this.secret = secret;
this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
//시크릿 값을 decode해서 키 변수에 할당
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
// 토큰을 받아 클레임을 만들고 권한정보를 빼서 시큐리티 유저객체를 만들어 Authentication 객체 반환
public Authentication getAuthentication(String token) {
Claims claims = Jwts
.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// 디비를 거치지 않고 토큰에서 값을 꺼내 바로 시큐리티 유저 객체를 만들어 Authentication을 만들어 반환하기에 유저네임, 권한 외 정보는 알 수 없다.
User principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
// 토큰 유효성 검사
public boolean validateToken(String token) {
try {
Claims claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
log.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
}
TokenProvider
은 문자열 토큰을 검증하거나 문자열 토큰으로부터 스프링 시큐리티 Authentication
객체를 생성하는 역할을 수행합니다.
sealed class
로 구성하여 액세스토큰프로바이더만 상속받을 수 있도록 합니다.(추후 RefreshTokenProvider를 생성하기 위해)
public final class AccessTokenProvider extends TokenProvider {
public AccessTokenProvider(String secret, long tokenValidityInSeconds) {
super(secret, tokenValidityInSeconds);
}
// 토큰 생성
public String createToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date validity = new Date(now + this.tokenValidityInMilliseconds);
return Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
}
AccessTokenProvider는 TokenProvider
를 상속받으며 Authentication
객체로부터 토큰을 생성하는 역할을 수행합니다.
@Configuration
@EnableConfigurationProperties(JwtProperties.class)
public class JwtConfig {
// 액세스 토큰 발급용, 리프레시 토큰 발급용은 각각 별도의 키와 유효기간을 갖는다.
@Bean(name = "accessTokenProvider")
public TokenProvider accessTokenProvider(JwtProperties jwtProperties) {
return new AccessTokenProvider(jwtProperties.getSecret(), jwtProperties.getAccessTokenValidityInSeconds());
}
}
JwtConfig
는 JWT 설정파일로 AccessTokenProvider
에 의존성을 주입하고 빈을 생성하는 역할을 수행합니다.
@Component
public class CustomJwtFilter extends GenericFilterBean {
private static final Logger logger = LoggerFactory.getLogger(CustomJwtFilter.class);
public static final String AUTHORIZATION_HEADER = "Authorization";
private final AccessTokenProvider accessTokenProvider;
public CustomJwtFilter(AccessTokenProvider accessTokenProvider) {
this.accessTokenProvider = accessTokenProvider;
}
// 실제 필터링 로직은 doFilter 안에 들어가게 된다. GenericFilterBean을 받아 구현
// Dofilter는 토큰의 인증정보를 SecurityContext 안에 저장하는 역할 수행
// 현재는 jwtFilter 통과 시 loadUserByUsername을 호출하여 디비를 거치지 않으므로 시큐리티 컨텍스트에는 엔티티 정보를 온전히 가지지 않는다
// 즉 loadUserByUsername을 호출하는 인증 API를 제외하고는 유저네임, 권한만 가지고 있으므로 Account 정보가 필요하다면 디비에서 꺼내와야함
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String jwt = resolveToken(httpServletRequest);
String requestURI = httpServletRequest.getRequestURI();
// 유효성 검증
if (StringUtils.hasText(jwt) && accessTokenProvider.validateToken(jwt)) {
// 토큰에서 유저네임, 권한을 뽑아 스프링 시큐리티 유저를 만들어 Authentication 반환
Authentication authentication = accessTokenProvider.getAuthentication(jwt);
// 해당 스프링 시큐리티 유저를 시큐리티 건텍스트에 저장, 즉 디비를 거치지 않음
SecurityContextHolder.getContext().setAuthentication(authentication);
logger.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
} else {
logger.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
}
filterChain.doFilter(servletRequest, servletResponse);
}
// 헤더에서 토큰 정보를 꺼내온다.
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
실질적으로 액세스토큰을 검증하는 역할을 수행하는 GenericFilterBean
을 상속받아 CustomJwtFilter
를 작성합니다.
우리가 눈여겨보아야할 곳은 doFilter
메서드 영역입니다.
해당 코드는 필터 통과 시 토큰의 유효성을 검증하고, 토큰에서 식별자인 username
과 해당 토큰에 부여된 권한을 뽑아 스프링 시큐리티 Authentication
객체를 생성하고 시큐리티 컨텍스트
에 저장합니다.
이 말은 토큰 검증을 하며 데이터베이스에 사용자가 존재하는지 조회하지 않는다는 것입니다. doFilter
을 통과시켜 시큐리티 컨텍스트에 저장된 authentication는 유저이름과 권한정보만 담고 있으므로 해당 사용자가 실제로 존재하는 지, 혹은 해당 사용자에 대한 다른 정보를 얻고싶다면 별도로 조회해야합니다.
@Configuration
public class CorsFilterConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}
CorsFilter
를 먼저 작성합니다.
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();
}
}
@Configuration
@EnableWebSecurity // 기본적인 웹보안을 활성화하겠다
@EnableMethodSecurity // @PreAuthorize 어노테이션 사용을 위해 선언
@RequiredArgsConstructor
public class SecurityConfig {
private final CorsFilter corsFilter;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity, CustomJwtFilter customJwtFilter) throws Exception {
httpSecurity
.csrf(AbstractHttpConfigurer::disable) // token을 사용하는 방식이기 때문에 csrf를 disable
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(exceptionConfig ->
exceptionConfig.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
)
.headers(headerConfig -> headerConfig.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
.sessionManagement(httpSecuritySessionManagementConfigurer ->
httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(registry ->
registry.requestMatchers("/h2/**").permitAll()
.requestMatchers("/favicon.ico").permitAll()
.requestMatchers("/error").permitAll()
)
.authorizeHttpRequests(registry -> registry // actuator, rest docs 경로, 실무에서는 상황에 따라 적절한 접근제어 필요
.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 인증 해야함
.addFilterBefore(customJwtFilter, UsernamePasswordAuthenticationFilter.class);
return httpSecurity.build();
}
}
@EnableMethodSecurity
는 @PreAuthorize
어노테이션 사용을 위해 선언합니다.
@EnableWebSecurity
는 기본적인 웹보안을 활성화하겠다는 어노테이션입니다.
나머지는 주석문을 참고해주시기 바랍니다.
public class AccountAdapter extends User {
private Account account;
public AccountAdapter(Account account) {
super(account.getUsername(), account.getPassword(), authorities(account.getAuthorities()));
this.account = account;
}
public Account getAccount() {
return this.account;
}
private static List<GrantedAuthority> authorities(Set<Authority> authorities) {
return authorities.stream()
.map(authority -> new SimpleGrantedAuthority(authority.getAuthorityName()))
.collect(Collectors.toList());
}
}
뒤에서 구현할 인증 API 호출 시 그 과정에서 loadUserByUsername
를 호출해 디비에서 사용자 정보를 꺼내오게될 것입니다.
인증 API 호출 시 엔티티 유저를 반환하여 사용하고 싶어 어댑터 패턴 사용합니다.
인증API 호출 과정에서authentication.getPrincipal()
로 AccountAdapter
객체를 꺼내올 수 있습니다. -> JwtFilter
을 통해 시큐리티 컨텍스트에 저장된 Authentication
객체에서는 기본 User
객체만 꺼내집니다.
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final AccountRepository accountRepository;
public CustomUserDetailsService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
@Override
@Transactional
public UserDetails loadUserByUsername(final String username) {
Account account = accountRepository.findOneWithAuthoritiesByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(username + " -> 데이터베이스에서 찾을 수 없습니다."));
if(!account.isActivated()) throw new RuntimeException(account.getUsername() + " -> 활성화되어 있지 않습니다.");
return new AccountAdapter(account);
}
}
인증 API 호출 시에만 그 과정에서 재정의한 loadUserByUsername
를 호출하여 디비에서 유저정보와 권한정보를 가져옵니다.
내가 만든 커스텀 AccountAdapter
는 org.springframework.security.core.userdetails.User
를 상속받았으므로 이걸 반환해도 됩니다.
@Builder
public record RequestLogin(
@NotNull
@Size(min = 3, max = 50)
String username,
@NotNull
@Size(min = 5, max = 100)
String password
) {
}
@Builder
public record ResponseLogin(
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 AuthenticationManagerBuilder authenticationManagerBuilder;
private final AccountRepository accountRepository;
public AccountService(AccessTokenProvider accessTokenProvider, AuthenticationManagerBuilder authenticationManagerBuilder, AccountRepository accountRepository) {
this.accessTokenProvider = accessTokenProvider;
this.authenticationManagerBuilder = authenticationManagerBuilder;
this.accountRepository = accountRepository;
}
// username 과 패스워드로 사용자를 인증하여 액세스토큰을 반환한다.
public ResponseLogin authenticate(String username, String password) {
// 받아온 유저네임과 패스워드를 이용해 UsernamePasswordAuthenticationToken 객체 생성
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(username, password);
// authenticationToken 객체를 통해 Authentication 객체 생성
// 이 과정에서 CustomUserDetailsService 에서 우리가 재정의한 loadUserByUsername 메서드 호출
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// 인증 정보를 기준으로 jwt access 토큰 생성
String accessToken = accessTokenProvider.createToken(authentication);
return ResponseLogin.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<ResponseLogin> authorize(@Valid @RequestBody RequestLogin loginDto) {
ResponseLogin token = accountService.authenticate(loginDto.username(), loginDto.password());
// response header 에도 넣고 응답 객체에도 넣는다.
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(CustomJwtFilter.AUTHORIZATION_HEADER, "Bearer " + token.accessToken());
return new ResponseEntity<>(token, httpHeaders, HttpStatus.OK);
}
}
인증 API를 직접 호출할 때를 제외하고 JwtFilter를 통과할 때 토큰에서 정보를 받아 스프링 시큐리티에 인증 정보를 저장할 때는 loadUserByUsername
를 호출하지 않기에 실제로 디비에 사용자가 존재하는지 별도로 Account
를 디비에서 조회해주어야 합니다.
이를 위해 편리하게 스프링 시큐리티에서 username
을 꺼내주는 유틸을 작성하려고 합니다.
security context
에 저장된 Authentication
객체를 이용해 username을 리턴해주는 유틸을 작성합니다.
security context
에 authentication
객체가 저장되는 시점은 토큰 검증을 수행하는 JwtFilter
의 doFilter
영역입니다.
@Component
public class SecurityUtil {
private static final Logger logger = LoggerFactory.getLogger(SecurityUtil.class);
private SecurityUtil() {
}
public Optional<String> getCurrentUsername() {
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
logger.debug("Security Context에 인증 정보가 없습니다.");
return Optional.empty();
}
String username = null;
if (authentication.getPrincipal() instanceof UserDetails) {
UserDetails springSecurityUser = (UserDetails) authentication.getPrincipal();
username = springSecurityUser.getUsername();
} else if (authentication.getPrincipal() instanceof String) {
username = (String) authentication.getPrincipal();
}
return Optional.ofNullable(username);
}
}
USER_ROLE
을 부여하여 Account
를 생성하는 유저 등록 API를 구현하겠습니다.
@Builder
public record RequestUserRegister(
@NotNull
@Size(min = 3, max = 50)
String username,
@NotNull
@Size(min = 5, max = 100)
String password,
@NotNull
@Size(min = 5, max = 100)
String nickname
) {
}
@Builder
public record ResponseUserRegister(
String username,
String password,
String nickname,
Long tokenWeight,
Set<String> authoritySet
) {
public static ResponseUserRegister of(Account account) {
if(account == null) return null;
return ResponseUserRegister.builder()
.username(account.getUsername())
.password(account.getPassword())
.nickname(account.getNickname())
.tokenWeight(account.getTokenWeight())
.authoritySet(account.getAuthorities().stream()
.map(authority -> authority.getAuthorityName())
.collect(Collectors.toSet()))
.build();
}
}
예시이므로 비밀번호까지 전부 응답으로 한번 보내보겠습니다.
@Service
@RequiredArgsConstructor
public class AccountService {
private final AccountRepository accountRepository;
private final PasswordEncoder passwordEncoder;
......
....
@Transactional
@Override
public ResponseUserRegister registerMember(RequestUserRegister registerMemberDto) {
Optional<Account> accountOptional = accountRepository.findOneWithAuthoritiesByUsername(
registerMemberDto.username());
if (accountOptional.isPresent()) {
throw new ApplicationException(CommonErrorCode.CONFLICT, "이미 가입되어있는 유저");
}
// 이 유저는 권한이 ROLE_MEMBER
// 이건 부팅 시 data.sql에서 INSERT로 디비에 반영한다. 즉 디비에 존재하는 값이여야함
Authority authority = Authority.builder()
.authorityName("ROLE_MEMBER")
.build();
Account user = Account.builder()
.username(registerMemberDto.username())
.password(passwordEncoder.encode(registerMemberDto.password()))
.nickname(registerMemberDto.nickname())
.authorities(Collections.singleton(authority))
.activated(true)
.build();
// DB에 저장하고 그걸 DTO로 변환해서 반환, 예제라서 비번까지 다 보낸다. 원랜 당연히 보내면 안댐
return ResponseUserRegister.of(accountRepository.save(user));
}
}
ROLE_USER
는 Authority
테이블에 존재하는 값이여야 합니다.
현재는 data.sql
에서 애플리케이션 부팅 시 insert 해줍니다.
AccountService
를 가져다 사용하기 위해 파사드 패턴을 사용합니다.
파사드 패턴에 대해 이해가 부족하신 분은 UserService
라고 명칭해도 됩니다.
@Service
@RequiredArgsConstructor
public class UserFacadeService {
private final AccountService accountService;
@Override
public ResponseUserFacadeInformation signup(RequestUserFacadeRegister registerDto) {
ResponseUserFacadeInformation response = accountService.registerMember(RequestAccount.RegisterMember.builder()
.nickname(registerDto.nickname())
.username(registerDto.username())
.password(registerDto.password())
.build());
return ResponseUserFacadeInformation.builder()
.authoritySet(response.authoritySet())
.nickname(response.nickname())
.tokenWeight(response.tokenWeight())
.password(response.password())
.username(response.username())
.build();
}
}
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class UserController {
private final UserFacadeService userFacadeService;
// user 등록 API
@PostMapping("/user/signup")
public ResponseEntity<ResponseUserFacadeRegister> signup(
@Valid @RequestBody RequestUserFacadeRegister registerDto
) {
ResponseUserFacadeRegister userInfo = userFacadeService.signup(registerDto);
return ResponseEntity.ok(ResponseUserRegister);
}
}
HelloController
를 작성하여 인증과 권한에 따른 인가가 잘 작동하는지 테스트해봅니다. 먼저 AccountService
에 기능을 몇 개 추가하겠습니다.
// AccountService
@Transactional(readOnly = true)
@Override
public ResponseAccountInformation getAccountWithAuthorities(String username) {
Account account = accountRepository.findOneWithAuthoritiesByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(username + "-> 찾을 수 없습니다"));
return ResponseAccountInformation.of(account);
}
// 현재 시큐리티 컨텍스트에 저장된 username에 해당하는 정보를 가져온다.
@Transactional(readOnly = true)
@Override
public ResponseAccountInformation getMyAccountWithAuthorities() {
Account account = securityUtil.getCurrentUsername()
.flatMap(accountRepository::findOneWithAuthoritiesByUsername)
.orElseThrow(() -> new UsernameNotFoundException("security context로부터 찾을 수 없습니다"));
return ResponseAccountInformation.of(account);
}
위의 메서드는 username
을 받아 해당 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}
// @AuthenticationPrincipal를 통해 JwtFilter에서 토큰을 검증하며 등록한 시큐리티 유저 객체를 꺼내올 수 있다.
// JwtFilter는 디비 조회를 하지 않기에 유저네임, 권한만 알 수 있음
// Account 엔티티에 대한 정보를 알고 싶으면 당연 디비 조회를 별도로 해야함
@GetMapping("/user")
@PreAuthorize("hasAnyRole('USER','ADMIN')") // USER, ADMIN 권한 둘 다 호출 허용
public ResponseEntity<ResponseUserInfo> getMyUserInfo(@AuthenticationPrincipal User user) {
System.out.println(user.getUsername() + " " + user.getAuthorities());
return ResponseEntity.ok(accountService.getMyUserWithAuthorities());
}
@GetMapping("/user/{username}")
@PreAuthorize("hasAnyRole('ADMIN')") // ADMIN 권한만 호출 가능
public ResponseEntity<ResponseUserInfo> getUserInfo(@PathVariable String username) {
return ResponseEntity.ok(accountService.getUserWithAuthorities(username));
}
}
https://github.com/suhongkim98/spring-security-jwt-ssongplate
위 깃허브를 통해 예제를 확인할 수 있습니다.
블로그에서는 액세스토큰만 설명드렸지만 예제에는 리프레시토큰 예제도 포함되어 있으며 계속 리팩토링되고 있는 레포지토리입니다.
잘 봤습니다. 다만 궁금한 점이 있어서 댓글 남겨요!
제가 알기로 필터에서는 사용자의 인가처리보다는 잘못된 요청이나 비정상적인 사용자 접근을 처리해주는게 맞다고 생각했습니다. 하지만 코드를 보다보니, 필터에서 인가처리를 해주는 것 같아서 궁금해서 여쭤봅니다.
제가 스프링 시큐리티를 아직 사용해보지 않았기 때문에 그럴수도 있어서 혹시 스프링 시큐리티를 사용하면 필터에서 처리를 해줘야하는 건가요? 아니라면 인터셉터가 아닌 필터에서 인가 처리를 하신 이유가 있을까요?