스프링 부트에서는 공식적으로 JWT 인증 방식을 스프링 시큐리티와 함께 구현하는 것을 권장하고 있다.
스프링 시큐리티란?
스프링 시큐리티는 스프링 프레임워크 기반으로 제작한 프로젝트의 인증과 권한 기능을 담당하는 프레임워크다.
어플리케이션의 로그인, 회원가입 등의 기능을 자체적으로 처리하는 기능이 내장되어 있기 때문에
이런 식으로 스프링 시큐리티 없이 로그인 로직을 직접 작성하였다면,
if (param.get("USER_PW").toString().equals(member.get("USER_PW").toString())) {
system.out.println("로그인 성공!");
return "main";
}
스프링 시큐리티 사용 시 이렇게 한줄 코드로 로그인 처리를 할 수 있다.
Member member = loginService.loadUserByUsername(param.get("USER_ID"));
템플릿 엔진을 사용한다면 로그인이 완료된 후에 어떤 뷰로 이동할지도 Config 파일에서 설정이 가능하다.
덧붙여 로그인한 사용자만 특정 API를 이용 할 수 있게
기존 스프링의 인터셉터를 직접 작성하였다면,
String requestUrl = request.getRequestURL().toString();
if(requestUrl.contains("/gis")) {
if("ATH0003".equals(session.getAttribute("USER_AUTH"))){
return true;
} else {
printwriter.print("<script>alert('접근 권한이 없습니다.');location.href='/replotting/main';</script>");
printwriter.flush();
printwriter.close();
return false;
}
이렇게 한 줄 코드로 인터셉터 기능을 구현할 수 있다.
request.requestMatchers("/gis").hasRole("ATH0003") // ATH0003 권한이 있는 사용자만 허용
이게 스프링 시큐리티의 매력이다.
일단 JWT를 중점으로 작업하기 위해 스프링 시큐리티를 최소화하여 구현해보자.
스프링에서 구현해야 하는 JWT의 동작 원리는 다음과 같다.
- 로그인 요청이 들어오면 로그인 성공 시 JWT 토큰을 클라이언트에게 발급
- 클라이언트가 토큰을 발급 받고 인증이 필요한 API에 접속 시 해당 토큰이 유효한지 검증
간단하게 위의 2가지 과정으로 인증이 이루어지는데 해당 과정을 구현하기 위해서 코드를 작성해야 할 파일이 3개가 있다.
- JWT 토큰을 생성해주는 토큰 생성 클래스 (JwtProvider.java)
- 인증이 필요한 API 접속 시 토큰을 검증하고 인증을 처리하는 필터 클래스 (JwtAuthenticationFilter.java)
- 해당 토큰 검증 필터를 적용 시킬 수 있게 설정하는 스프링 시큐리티 설정 클래스(WebSecurityConfig.java)
해당 정보를 바탕으로 구현해보자.
1. 라이브러리 추가
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
- 해당 라이브러리들을 설치한다.
2. application.properties 파일에 보안 키 설정
jwt.secret = 7b64445f014cec357c8b8e7e175320997c4f2ed468f5fe7cee1d9a5cf231f6bd
- JWT 토큰을 생성하고 검증할 때 application.properties 안에 있는 시크릿 키를 인증하여 생성과 검증이 이루어진다.
- 해당 키는 토큰 발행 및 검증을 하기 전에 서버 내의 1차적인 보안을 위한 키라고 생각하면 된다.
- 해당 키는 base64 랜덤 값 32자리로 이루어져 있으며, 해당 키를 생성하기 위해서는 리눅스 터미널에서 openssl rand -hex 64 명령어를 입력한다. (Git Bash에서도 가능)
3. TokenInfoDTO 작성
@Builder
@Data
@Getter
@AllArgsConstructor
public class TokenInfo { // 클라이언트에 토큰을 보내기 위한 DTO
private String grantType; // 토큰 타입
private String accessToken; // 접근 토큰
private String refreshToken; // 접큰 토큰이 만료 될 시 재발급 받기 위한 토큰
}
- 로그인에 성공 시 토큰을 발급한 후 클라이언트에게 보낼 토큰 정보 DTO다.
- 변수 grantType 에는 보통 “Bearer”라는 단어가 들어가는데 이는 해당 토큰이 JWT 토큰이라는 것을 나타내는 식별자다.
- JwtTokenProvider 클래스에서 토큰을 생성하고 해당 DTO를 만들 것이다.
4. JwtTokenProvider 작성
@Slf4j
@Component
public class JwtTokenProvider { // 토큰을 생성하고 검증한다.
private final Key key;
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
// application.properties에서 secret key를 가져온다.
// 토큰 발행 및 검증을 하기 전에 서버 내의 1차적인 보안을 위한 키임.
byte[] keyBytes = Base64.getDecoder().decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public TokenInfo generateToken(Member member) { // 토큰을 생성한다.
Calendar cal1 = Calendar.getInstance();
cal1.add(Calendar.HOUR_OF_DAY, 2);
Date accessTokenExpiration = new Date(cal1.getTimeInMillis()); // 현재부터 2시간 뒤의 날짜 및 시간
Calendar cal2 = Calendar.getInstance();
cal2.add(Calendar.DATE, 14);
Date refreshTokenExpiration = new Date(cal2.getTimeInMillis()); // 현재부터 2주 뒤의 날짜 및 시간
// Access Token 생성
String accessToken = Jwts.builder()
.setHeader(createHeader())
.setClaims(createClaims(member, "access"))
.setSubject(member.getId()) // 페이로드의 sub 설정, 이 토큰의 주인이 누구인지?
.signWith(key, SignatureAlgorithm.HS256) // 서명 부분에 진행할 암호화 알고리즘 설정
.setExpiration(accessTokenExpiration) // 엑세스 토큰의 만료 기한 설정
.compact();
String refreshToken = Jwts.builder()
.setHeader(createHeader())
.setClaims(createClaims(member, "refresh"))
.setSubject(member.getId()) // 페이로드의 sub 설정, 이 토큰의 주인이 누구인지?
.signWith(key, SignatureAlgorithm.HS256) // 서명 부분에 진행할 암호화 알고리즘 설정
.setExpiration(refreshTokenExpiration) // 리프레쉬 토큰의 만료 기한 설정
.compact();
return TokenInfo.builder() // 위에서 생성한 토큰을 포함하여 토큰 정보 반환
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
public boolean validateToken(String token, HttpServletResponse response) throws IOException { // 토큰을 검증한다.
try {
Claims claims = parseClaims(token); // 토큰을 검증함과 동시에 복호화하여 클레임을 가져온다.
return true;
} catch (SecurityException | MalformedJwtException e) { // 위에서 검증함과 동시에 예외처리를 한다.
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
log.error("토큰의 기한이 만료되었습니다.");
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty", e);
}
return false;
}
private Claims parseClaims(String accessToken) { // 토큰을 복호화하여 클레임 부분을 반환한다.
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(accessToken) // 실제 토큰 검증이 일어나는 부분
.getBody();
}
private Map<String, Object> createHeader() { // 토큰의 헤더 부분을 만든다.
Map<String, Object> header = new HashMap<>();
header.put("typ", "JWT");
header.put("alg", "HS256");
return header;
}
private Map<String, Object> createClaims(Member member, String classification) { // 토큰의 클레임을 만든다.
Map<String, Object> claims = new HashMap<>();
claims.put("id", member.getId());
claims.put("nm", member.getName());
claims.put("seq", member.getSeq());
claims.put("typ", classification);
return claims;
}
public String getUserIdfromToken(String token) { // 토큰에서 유저의 아이디를 반환한다.
Claims claim = parseClaims(token);
return (String) claim.get("id");
}
}
- generateToken(): 엑세스 토큰과 리프레쉬 토큰을 만들어서 빌더패턴으로 정의된 TokenInfo DTO에 담는다. 토큰 내부의 헤더, 페이로드, 클레임, 서명 부분을 직접 setHeader() , setClaims() , signWith() 메소드들을 통해서 설정하고 setExpiration() 메소드를 통해 만료기한을 설정한다. signWith() 메소드는 시크릿키를 통해 인증이 필요해서 첫 번째 파라미터로 넘겨준다.
- validateToken(): 토큰을 검증하는 메소드다. 클라이언트가 인증이 필요한 API 호출 시 바로 작동하는 필터 클래스에서 호출하기 위해 JwtTokenProvider 클래스에 정의했다. 해당 코드를 보면 클레임을 복호화 하는 parseClaims() 메소드를 호출하고 있는데 이 메소드 안에 실질적으로 토큰이 유효한지 검증하는 코드가 담겨있기 때문이다. 해당 메소드 호출 후 예외가 발생하지 않는다면 유효한 토큰이고, 문제가 있는 토큰이라면 각 문제에 따라 다른 예외들을 발생시킨다.
- parseClaims(): 토큰의 클레임 부분을 복호화하는 코드이다. 해당 코드 내에서 .setSigningKey(key) 를 통해 시크릿 키 인증을 하고, .parseClaimsJws(accessToken) 를 통해 클레임 복호화 및 토큰 검증을 실행한다.
- createHeader(): 토큰의 헤더 부분을 만든다. 헤더 값은 typ: JWT, alg: HS256 고정이다.
- createClaims(): 토큰의 클레임 부분을 만든다. 클레임에는 해당 토큰의 사용자 정보가 들어간다. 토큰이 탈취 당할 시를 대비해 회원의 간략한 정보만 넣는다. 여기서 넣어준 사용자 정보는 클라이언트 쪽에서 토큰을 복호화 하여 사용할 수 있다.
5. 로그인 로직 작성
public TokenInfo login(String username, String password) {
Member member = memberMapper.getMember(username);
log.info("찾은 아이디: {}", member.getId());
log.info("DB 암호화 비밀번호: {}", member.getPw());
if (!passwordEncoder.matches(password, member.getPw())) { // 요청 비밀번호와 DB 안의 비밀번호와 일치하는지 확인
throw new PasswordNotMatchException(); // 일치하지 않으면 예외처리로 인해 중단
}
TokenInfo tokenInfo = jwtTokenProvider.generateToken(member); // 로그인에 성공한 회원에 대해 토큰 생성
return tokenInfo;
}
- 토큰을 발급 받기 전 아이디와 비밀번호 유효성 검증을 해야 하지 않겠는가 로그인 서비스 클래스에 해당 로직을 손수 짜준다.
- 원래는 스프링 시큐리티의 기능을 이용하여 구현해야 한다.
6. JwtAuthenticationFilter 작성
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private AuthenticationManager authenticationManager;
@Override
public void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws IOException, ServletException {
// 1. Request Header에서 JWT 토큰 추출
String token = resolveToken(request);
log.info("필터에 들어온 토큰: {}", token);
// 2. vaidateToken으로 토큰 검증
try {
if(token != null && jwtTokenProvider.validateToken(token, response)) {
// 토큰이 유효할 경우 토큰에서 클레임 부분에 유저 아이디를 가지고 와서 SecurityContext에 저장
String userId = jwtTokenProvider.getUserIdfromToken(token);
log.info("토큰의 주인: {}", userId);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userId, null, null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
log.info("해당 사용자 인증여부: {}", auth.isAuthenticated());
}
} catch (ExpiredJwtException e) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "토큰 만료");
}
filterChain.doFilter(request, response);
}
// Request Header에서 토큰 정보 추출
private String resolveToken(HttpServletRequest request) {
String token = request.getHeader("Authorization");
if(StringUtils.hasText(token) && token.startsWith("Bearer")) {
return token.substring(7);
}
return null;
}
}
- 스프링 시큐리티는 인증을 처리하기 위한 필터가 여러가지 있는데 그 중 OncePerRequestFilter를 상속 받아 Jwt 필터로 커스텀한다.
- doFilterInternel() 안의 코드들이 필터로 인식되어 처리 된다.
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userId, null, null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
- resolveToken(HttpServletRequest request) 메소드를 통해 요청 헤더에서 토큰 값을 뽑아온다.
- jwtTokenProvider.validateToken() 을 통해 문제가 없는 토큰인지 검증이 완료되면 new UsernamePasswordAuthenticationToken(userId, null, null) 을 통해 jwtTokenProvider.getUserIdfromToken(token) 메소드로 뽑아온 유저 아이디를 인증이 완료된 인증 객체로 만들어준다.
- ※ 스프링 시큐리티에는 인증 객체라는 것이 존재한다. Authentication이라는 객체인데 이 객체에는 유저의 아이디 등을 포함한 유저의 정보가 들어있다. UsernamePasswordAuthenticationToken 객체는 이를 상속받은 객체이다.
- 인증객체로 만들어준 UsernamePasswordAuthenticationToken을 SecurityContextHolder.getContext().setAuthentication(authenticationToken) 을 통해 시큐리티 컨텍스트에 넣어준다. SecurityContext는 Authentication 객체의 집이다. 여기에 들어간 인증객체는 스프링 시큐리티가 인증이 된 사용자라고 자동으로 인식을 한다.
7. 스프링 시큐리티 설정 파일 작성
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig{
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
// 스프링 시큐리티 접근 보안 설정
return httpSecurity
.csrf(AbstractHttpConfigurer::disable)
.cors(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(request -> {
// 로그인 요청에 대해서는 모두 접근 허용
request.requestMatchers("/member/login").permitAll()
.requestMatchers("/api").permitAll()
// 이 밖에 모든 요청들은 인증을 필요로 한다.
.anyRequest().authenticated();
}).addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class).build();
// 요청 사용자에게 토큰이 있는지 먼저 검사하기 위해 JWT 필터를 먼저 거치도록 설정
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
- 스프링 시큐리티 설정 파일에는 일반 스프링 설정 파일과 다르게 @EnableWebSecurity 라는 어노테이션을 붙여야 한다. 동시에 웹 인증 및 보안에 대해 스프링 시큐리티가 모든 것을 관여하게 된다.
- SecurityFilterChain 이라는 객체를 빈으로 등록해줌으로서 웹의 접근 권한을 설정하고 해당 접근 권한이 있는지 판단하기 위한 필터를 등록해준다.
- request.requestMatchers("/member/login").permitAll() 은 “/member/login” 매핑에 대해 인증 여부든 상관 없이 모두 접근을 허용한다는 것이다. 로그인은 어떤 사람이든 해야 하지 않겠는가
- .anyRequest().authenticated() 은 앞서 설정해준 매핑들을 제외한 모든 요청들은 인증이 되어야 한다. 로그인을 진행하고 필터 클래스에서 인증이 완료된 Authentication 객체가 시큐리티 컨텍스트 안에 들어가야 접근할 수 있다.
- .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class).build() 해당 코드는 우리가 커스텀 한 JWT 필터를 UsernamePasswordAuthenticationFilter 전에 배치한다는 뜻이다. 보통 이 필터가 가장 먼저 실행되기 때문에 JWT 필터를 먼저 실행하게 하는 것이다.
'웹개발 > Java, Spring' 카테고리의 다른 글
[Spring Boot] Thymeleaf HTML 템플릿 수정 시 브라우저 반영 설정 (0) | 2024.06.26 |
---|---|
[Spring Boot] MyBatis + PostgreSQL 연동 (2) | 2024.06.25 |
[Spring] PropertyPlaceholderConfigurer 클래스를 이용한 프로필 설정 파일 불러오기 (0) | 2023.08.20 |
[Java] 웹 내 TIF 이미지 → JPG 이미지로 변환하여 출력하는 방법 (0) | 2023.07.30 |
[Spring] ResponseEntity를 이용한 HTTP 통신 (0) | 2023.07.16 |