사용자가 앱의 다양한 기능을 이용하기 위해서는 사용자 인증과 권한부여 기능이 만들어져야 합니다.
두 단어를 번역하면 Authorization은 인가, Authentication은 인증입니다. 영어로는 비슷한 단어같지만, 한국어로 보면 전혀 다른 의미임을 알 수 있습니다.
Authorization은 사용자에게 권한을 부여하는 것입니다. 예를 들어 사용자에게 관리자의 권한을 준다면 관리자로써 할 수 있는 페이지 관리나 사원관리 등에 접근할 수 있고, 일반 사용자의 권한을 준다면 조회 등의 기능만 가질 것입니다.
Authentication은 사용자가 누군지 인증을 받는 것입니다. 토큰 방식의 경우 서버가 서명한 토큰을 클라이언트가 서버로 전송하게 되면 해당 사용자가 누구인지 확인할 수 있습니다.
사용자 인증은 보통 2가지 방식 중 하나를 택하여 처리합니다. 바로 Session인증 방식과 Token인증 방식입니다.
Session방식은 클라이언트가 한번 인증을 받았을 때 가지는 세션 정보를 서버의 세션 스토리지에 저장하고, 클라이언트의 Cookie로 Session ID를 전송해주는 방식입니다. 서버에서 클라이언트의 정보를 가지고 있기 때문에 Stateful한 방식입니다.
하지만 사용자가 많은 경우에 세션 방식은 저장소의 용량을 많이 차지한다는 문제가 있습니다. 또한 서버를 확장하고자 하는 경우 세션 불일치가 발생할 수도 있기 때문에 Stateful한 Session방식 인증보다는 토큰 방식이 더 권장됩니다.
Token방식은 사용자에게 서버가 서명한 토큰을 제공하고, 인증이 필요한 순간에 클라이언트가 토큰을 서버에 제공하면 서버가 토큰을 해독하여 사용자가 인증된 사용자인지 확인하는 방식입니다. Session방식과 달리 Stateless한데, 서버에 토큰을 따로 저장하지 않고 클라이언트만 소유하고 있기 때문입니다.
JWT는 JSON Web Token의 약어로, 토큰 인증 방식에서 많이 사용하는 토큰입니다. 하나의 JWT 토큰을 해독해보면 Header-Payload-Signature
의 3가지로 구성되어 있습니다. (참고: JWT 공식문서)
이중에서 가장 중요한 부분은 Payload
입니다. Payload안에 여러 Claim을 넣을 수 있는데, 이를테면 사용자 ID, 토큰 만료시간 등의 정보를 Payload에 넣는다면, 사용자가 가지고 있는 토큰을 해독했을 때 해당 정보를 서버가 확인할 수 있습니다.
Claim에 들어갈 수 있는 여러 Claim에 대한 정보는 아래 링크에서 확인할 수 있습니다.
https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-token-claims
Token방식은 얼핏보면 장점만 있어보이지만, 치명적인 보안 문제가 있습니다. 해커가 클라이언트의 토큰을 탈취해서 정상적인 사용자처럼 행세할 수 있다는 문제입니다. 이를 해결하기 위해 나온 해결책이 Access Token과 Refresh Token 2가지를 이용하는 방식입니다.
Access Token은 클라이언트만 가지고있는 토큰입니다. 대신, 토큰의 유효기간을 매우 짧게 설정해서(보통 30분) 이 토큰이 탈취당하더라도 유효기간 이후로는 사용할 수 없도록 합니다. 정상적인 사용자는 Access 토큰이 만료되었다면 Refresh 토큰을 통해 재발급을 받는 절차를 진행합니다.
Refresh Token은 재발급 용 토큰으로 서버와 클라이언트 모두가 가지고 있습니다. Access 토큰에 대해 재발급을 원할 시 클라이언트는 Access 토큰과 Refresh 토큰을 함께 보내고, 서버는 클라이언트가 보낸 Refresh 토큰이 자신이 가지고 있는 Refresh 토큰과 일치하는 지 확인 후, 일치한다면 새로 Access Token을 발급하여 클라이언트로 보내줍니다.
출처: 그랩의 블로그(원출처는 찾을 수 없었음)
보안 관련 설정을 진행하기 위한 이론은 이정도로 마무리하고 이제는 구현을 진행해보겠습니다. 아래의 구현 상당부분은 뱀귤 블로그에서 참고하였습니다.
22년 2월부터 WebSecurityConfigurorAdaptor
가 Deprecated되었습니다.(참고) 때문에 22년 이전의 대부분의 블로그들은 SecurityConfig
구현 시 WebSecurityConfigurorAdaptor
를 상속받았는데 현재는 불가능하므로 이를 이용하지 않는 방향으로 개발을 진행했습니다.
기존 방식을 수정하는 방법은 아래 링크에서 자세히 설명해주고 있습니다.
https://backendstory.com/spring-security-how-to-replace-websecurityconfigureradapter/
추가적으로 antMatchers
도 Deprecated가 되어 더이상 IntelliJ에서 자동완성을 해주지 않는데 requestMatchers
가 동일한 기능을 하므로 이것으로 대체하면 됩니다. (참고)
먼저 진행하던 프로젝트에 JJWT 라이브러리를 추가합니다.
//dependencies 내부에 추가
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5',
'io.jsonwebtoken:jjwt-jackson:0.11.5'
💡 이전부터 프로젝트를 따라오고 계신분들이라면 properties 파일을 모두 삭제하고, 하나의 yml파일로 변경해주시길 바랍니다.
또한 .gitIgnore에서 추가해두었던 properties를 아래와 같이 yml로 변경해주시길 바랍니다.### application.yml ### *.yml
그리고 JWT토큰을 이용하기 위해서는 비밀키를 가지고 있어야 합니다. 이 비밀키는 MySQL 패스워드처럼 노출되어선 안되는 정보이기 때문에 별도의 profile을 생성해줍니다.
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
username: dms_admin
url: jdbc:mysql://localhost:3306/dms
password: ****
jpa:
hibernate:
ddl-auto: update
show-sql: 'true'
//jwt Profile 추가
jwt:
secret: ****
먼저 Access Token과 Refresh Token을 담을 TokenDTO
를 생성합니다.
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TokenDTO {
private String tokenType;
private String accessToken;
private String refreshToken;
private Duration duration;
}
grantType
: HTTP Header의 Authorization에 타입이 명시되어 오는데, JWT와 같은 경우에는 Bearer
로 담겨서 옵니다. (참고) 이를 전달하기 위한 변수입니다.이제 토큰을 생성할 클래스인 JwtTokenProvider를 생성합니다.
@Component
@Log4j2
public class JwtTokenProvider {
private final Key encodedKey;
private static final String BEARER_TYPE = "Bearer";
private static final Long accessTokenValidationTime = 30 * 60 * 1000L; //30분
private static final Long refreshTokenValidationTime = 7 * 24 * 60 * 60 * 1000L; //7일
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Base64.getDecoder().decode(secretKey);
this.encodedKey = Keys.hmacShaKeyFor(keyBytes);
}
/**
* accessToken과 refreshToken을 생성함
* @param subject
* @return TokenDTO
* subject는 Form Login방식의 경우 userId, Social Login방식의 경우 email
*/
public TokenDTO createTokenDTO(String subject, List<Role> roles) {
//권한을 하나의 String으로 합침
String authority = roles.stream().map(Role::getType).collect(Collectors.joining(","));
//토큰 생성시간
Instant now = Instant.from(OffsetDateTime.now());
//accessToken 만료시간
Instant refreshTokenExpirationDate = now.plusMillis(refreshTokenValidationTime);
//accessToken 생성
String accessToken = Jwts.builder()
.setSubject(subject)
.claim("roles", authority)
.setExpiration(Date.from(now.plusMillis(accessTokenValidationTime)))
.signWith(encodedKey)
.compact();
//refreshToken 생성
String refreshToken = Jwts.builder()
.setExpiration(Date.from(now.plusMillis(refreshTokenValidationTime)))
.signWith(encodedKey)
.compact();
//TokenDTO에 두 토큰을 담아서 반환
return TokenDTO.builder()
.tokenType(BEARER_TYPE)
.accessToken(accessToken)
.refreshToken(refreshToken)
.duration(Duration.ofMillis(refreshTokenValidationTime))
.build();
}
/**
* UsernamePasswordAuthenticationToken으로 보내 인증된 유저인지 확인
* @param accessToken
* @return Authentication
* @throws ExpiredJwtException
*/
public Authentication getAuthentication(String accessToken) throws ExpiredJwtException {
Claims claims = Jwts.parserBuilder().setSigningKey(encodedKey).build().parseClaimsJws(accessToken).getBody();
if(claims.get("roles") == null) {
throw new RuntimeException("권한정보가 없는 토큰입니다.");
}
Collection<? extends GrantedAuthority> roles = Arrays.stream(claims.get("roles").toString().split(",")).map(SimpleGrantedAuthority::new).collect(Collectors.toList());
UserDetails user = new User(claims.getSubject(), "", roles);
return new UsernamePasswordAuthenticationToken(user, "", roles);
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(encodedKey).build().parseClaimsJws(token);
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;
}
}
JwtTokenProvider
는 크게 3가지 기능을 가지고 있습니다.
createTokenDTO
: 토큰 생성 메서드, Access Token과 Refresh Token을 생성한 뒤, TokenDTO에 담아 반환합니다.
getAuthentication
: 사용자 인증 메서드
validateToken
: 토큰 유효성 검사
Date 클래스는 JAVA8이후 사용을 권장하지 않는 클래스지만, jjwt는 expiration등의 시간을 Date로 받습니다. 따라서 Instant 타입으로 시간을 생성한 후, Date.of로 캐스팅하여 저장하는 방식을 택했습니다.
https://velog.io/@lsb156/Instant-vs-LocalDateTime
https://www.daleseo.com/java8-instant/
Token생성기를 만들었으므로, 다음으로는 커스텀 필터를 생성하겠습니다. 생성한 필터는 이후 Configuration
클래스에서 UsernamePasswordAuthenticationFilter이전에 필터 기능을 하도록 삽입할 예정입니다.
커스텀 Filter는 OncePerRequestFilter를 상속받고 있습니다. 다음 블로그에서 자세히 설명해주고 있습니다.
@Component
@RequiredArgsConstructor
@Log4j2
public class JwtRequestFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private static final String BEARER_PREFIX = "Bearer";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String jwt = resolveToken(request);
if(StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {
Authentication authentication = jwtTokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String token = request.getHeader("Authorization");
if(StringUtils.hasText(token) && token.startsWith(BEARER_PREFIX)) {
return token.substring(7); //"Bearer "를 뺀 값, 즉 토큰 값
}
return null;
}
}
doFilterInternal
: SecurityContext에 Access Token으로부터 뽑아온 인증 정보를 저장합니다. SecurityContext는 어디서든 접근 가능한데, 정상적으로 Filter를 통과하여 Controller
에 도착한다면, SecurityContext내부에 Member의 username이 있다는 것이 보장됩니다.
resolveToken
: 앞서 언급했던 대로 Header에서 Authorization부분을 추출할 때 Type이 Bearer인지 확인 후, 일치한다면 JWT부분만 추출하여 doFilter
에 제공합니다.
다음으로는 Security Configuration 클래스를 생성해야 합니다. 그 전에, filter를 거치면서 받을 인증, 인가 예외에 대해 예외 처리 Handler부터 생성하겠습니다.
@Component
@Log4j2
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
log.warn("access denied in handler");
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
접근권한이 없는 사용자가 접근 시 보내지는 Handler입니다.
@Component
@Log4j2
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
인증되지 않은 사용자가 접근 시 보내지는 Handler입니다.
이제 SecurityConfig를 구현할 차례입니다. Configuration
클래스에서는 어떤 API가 인증 및 접근권한이 필요한지, Filter를 어떻게 진행할지, 패스워드 인코딩은 어떻게 할지 등 보안 관련 설정들을 입력하는 곳입니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@Log4j2
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
private final CustomOAuth2UserService oAuth2UserService;
private static final String[] URL_TO_PERMIT = {
"/member/login",
"/member/signup",
"/v3/api-docs/**",
"/swagger-ui/**",
"/auth/**"
};
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable() //csrf설정 끔
.sessionManagement() //세션은 stateless방식
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.exceptionHandling() //예외처리
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and() //jwt를 사용하는 STATELESS방식이므로 session 사용하지 않는다고 명시
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and() //인증 진행할 uri설정
.authorizeHttpRequests()
.requestMatchers(URL_TO_PERMIT).permitAll()
.anyRequest().authenticated();
http
.oauth2Login()
.successHandler(oAuth2SuccessHandler)
.userInfoEndpoint().userService(oAuth2UserService);
http //jwt필터를 usernamepassword인증 전에 실행
.addFilterBefore(new JwtRequestFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
log.info("securityConfig");
return http.build();
}
}
💡 Spring Boot의 Chaining방식에 대해서는 공식문서에서 자세히 다루고 있습니다.
이 자료는 공식문서가 아닌, 별도의 자료인데, AuthenticationManager
를 통해 Filter가 통과가능한지 판단한 후 Authentication
객체가 반환되는 모습을 확인할 수 있습니다. Authentication
객체가 Filter의 입력과 출력으로 흘러가면서 중간에 인증이나 권한 관련 예외가 발생하면 필터에서 걸러지게 됩니다.
💡 위의 Filter의 조건에 csrf설정을 비활성화 시켰는데, 이는 세션방식을 이용하지 않고, 토큰 방식을 이용하고 있기 때문에 csrf를 통한 인증이 불필요하기 때문입니다.
마지막으로 Filter를 통과한 이후 API에서 Username을 가져올 수 있도록 SecurityUtil클래스를 만들겠습니다.
@Log4j2
public class SecurityUtil {
private SecurityUtil() {}
public static String getCurrentUsername() {
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
log.info("SecurityUtil: " + authentication);
if (authentication == null || authentication.getName() == null) {
throw new RuntimeException("인증 정보가 없습니다.");
}
log.info(authentication.getName());
return authentication.getName();
}
}
위에서 Chain이 진행되는 과정을 간략히 설명하긴 하였지만, 내용이 매우 복잡하여 이를 이후 글에서 추가적으로 작성하였습니다. 이후 글은 Login
기능이 구현된 이후를 기준으로 작성한 내용이기 때문에 참고만 하시면 좋을 것 같습니다.
추가적인 참고자료
https://hudi.blog/refresh-token/
https://jangjjolkit.tistory.com/m/26
https://indepth.dev/posts/1382/localstorage-vs-cookies
https://devlog-wjdrbs96.tistory.com/434
https://velog.io/@sun1203/Spring-Boot-Security-Jwt-Token-Refresh-Token
https://aroundlena.tistory.com/106
https://jessic2.com/27
https://velog.io/@jkijki12/Spirng-Security-Jwt-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0