종식당
현대오토에버 모빌리티 SW 스쿨 웹/앱 스프링 시큐리티 본문
이번 수업에서는 스프링 시큐리티 JWT를 이용해서 로그인을 구현하는 것을 배웠다. JWT는 처음 써보기도 했도 조금 어려운 내용이지만 정리하면서 공부해보려 한다.
JWT로그인
HTTP요청이 들어오면 AuthenticationFilter를 통해 username과 password를 추출하고 UsernamePasswordAuthenticationToken에 username과 password를 전달한다. 객체가 생성되면 AuthenticationManger에 전달하여 인증을 시도한다.
AuthenticationManger에서는 username을 userDetailsService에 보내고 해당 username으로 DB에서 user를 찾는다.
user를 찾고 나면 AuthenticationManager에서 JWT를 생성한다.여기에는 사용자의 정보가 담겨있다.
생성된 JWT는 클라이언트에 응답으로 전송하며 이 토큰을 저장하고 이후에 헤더에 포함시켜 서버에 전달한다.
스프링 시큐리티 필터 동작 원리
스프링 시큐리티는 클라이언트의 요청이 여러개의 필터를 거쳐 DispatcherServlet(Controller)으로 향하는 중간 필터에서 요청을 가로챈 후 검증(인증/인가)을 진행한다.
Form 로그인 방식에서 UsernamePasswordAuthenticationFilter
Form 로그인 방식에서는 클라이언트단이 username과 password를 전송한 뒤 Security 필터를 통과하는데 UsernamePasswordAuthentication 필터에서 회원 검증을 진행을 시작한다.
(회원 검증의 경우 UsernamePasswordAuthenticationFilter가 호출한 AuthenticationManager를 통해 진행하며 DB에서 조회한 데이터를 UserDetailsService를 통해 받음)
우리의 JWT 프로젝트는 SecurityConfig에서 formLogin 방식을 disable 했기 때문에 기본적으로 활성화 되어 있는 해당 필터는 동작하지 않는다.
따라서 로그인을 진행하기 위해서 필터를 커스텀하여 등록해야 한다.
파일 구조
build.gradle
implementation 'com.google.code.gson:gson:2.10.1'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
Member
package com.sample.spring.domain;
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = "memberRoleList")
public class Member {
@Id
private String email;
private String pw;
private String nickname;
private boolean social;
@ElementCollection(fetch = FetchType.LAZY) // MemberRole db가 생성됨
@Builder.Default
private List<MemberRole> memberRoleList = new ArrayList<>();
public void addRole(MemberRole memberRole) {
memberRoleList.add(memberRole);
}
public void clearRoles() {
memberRoleList.clear();
}
public void changePw(String pw) {
this.pw = pw;
}
public void changeNickname(String nickname) {
this.nickname = nickname;
}
public void changeSocial(boolean social) {
this.social = social;
}
}
@ToString(exclude = "memberRoleList")
위 어노테이션을 통해 Lombok이 자동으로 toString()메서드를 생성해서 클래스의 모든 필드를 포함한 문자열을 반환한다. 하지만 exclude = "memberRoleList" 를 통해서 memberRoleList필드는 toString()메서드에서 제외된다.
@ElementCollection(fetch = FetchType.LAZY) // MemberRole db가 생성됨
@Builder.Default
private List<MemberRole> memberRoleList = new ArrayList<>();
이를 통해 memberRoleList는 별도의 테이블에 저장되며 이는 기본 키가 필요없는 엔티티의 컬렉션을 나타낸다.
fetch = FetchType.LAZY 를 통해 필드의 데이터를 필요할 때 로드한다. 즉, memberRoleList에 접근할 때 db에서 가져온다.
MemberRole
package com.sample.spring.domain;
public enum MemberRole {
USER, MEMBER, ADMIN
}
위 클래스는 Member의 역할을 나타내기 위해 enum(열거형) 타입을 사용하였다.
그리고 USER, MEMBER, ADMIN 이 3가지의 역할을 상수로 정의 하였다.
MemberDto
package com.sample.spring.dto;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
public class MemberDto extends User {
public String email, pw, nickname;
private boolean social;
private List<String> roleNames = new ArrayList<>();
public MemberDto(String email, String pw, String nickname, boolean social, List<String> roleNames) {
super(email, pw,
roleNames.stream().map(str -> new SimpleGrantedAuthority("ROLE_" + str)).collect(Collectors.toList()));
this.email = email;
this.pw = pw;
this.nickname = nickname;
this.social = social;
this.roleNames = roleNames;
}
public Map<String, Object> getClaims() {
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("email", email);
dataMap.put("pw", pw);
dataMap.put("nickname", nickname);
dataMap.put("social", social);
dataMap.put("roleNames", roleNames);
return dataMap;
}
}
먼저 생성자 부분이다.
public MemberDto(String email, String pw, String nickname, boolean social, List<String> roleNames) {
super(email, pw,
roleNames.stream().map(str -> new SimpleGrantedAuthority("ROLE_" + str)).collect(Collectors.toList()));
this.email = email;
this.pw = pw;
this.nickname = nickname;
this.social = social;
this.roleNames = roleNames;
}
MemberDto는 User클래스의 생성자를 super()를 통해서 호출하는데 여기서 User클래스의 첫번 째 인자는 username으로 이를 email로 초기화 시켜줬다. 그래서 이번 프로젝트에서는 email값을 username을 생각하면 된다.
그리고 roleNames리스트를 stream으로 바꾸고 map을 통해 각각의 이름 앞에 ROLE_을 붙여주었다.
public Map<String, Object> getClaims() {
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("email", email);
dataMap.put("pw", pw);
dataMap.put("nickname", nickname);
dataMap.put("social", social);
dataMap.put("roleNames", roleNames);
return dataMap;
}
getClaims()메서드에서는 사용자의 정보를 바탕으로 Map형태로 반환한다.
이 DTO에서는 Spring Security의 User클래스를 상속하여 필요한 로직을 구현한다는 점을 알고 넘어가자😉 😉 😉
MemberRepository
package com.sample.spring.repository;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import com.sample.spring.domain.Member;
public interface MemberRepository extends JpaRepository<Member, String> {
@EntityGraph(attributePaths = ("memberRoleList"))
@Query("select m from Member m where m.email = :email")
Member getWithRole(@Param("email") String email);
}
MemberRepository는 일단 JpaRepository를 상속받아 기본적으로 CRUD기능을 처리한다.
@EntityGraph(attributePaths = ("memberRoleList")) 를 통해서 기존에 Lazy로 설정되었던 memberRoleList를 member와 함께 조회할 수 있도록 한다.
그리고 JPQL을 이용해서 @Param("email") 를 통해 파라미터로 받은 이메일을 가지고 m.email = :email 를 통해서 일치하는 Member엔티티를 조회한다.
따라서 이메일을 기준으로 Member 엔티티를 조회하고, 해당 Member와 관련된 memberRoleList를 즉시 로딩하여 반환한다.🐶🐶🐶
CustomSecurityConfig
package com.sample.spring.config;
import java.util.Arrays;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import com.sample.spring.security.APILoginFailHandler;
import com.sample.spring.security.APILoginSuccessHandler;
import com.sample.spring.security.filter.JWTCheckFilter;
import com.sample.spring.security.handler.CustomAccessDeniedHandler;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
@Configuration
@Log4j2
@RequiredArgsConstructor
@EnableMethodSecurity
public class CustomSecurityConfig {
@Bean
public SecurityFilterChain filterchain(HttpSecurity http) throws Exception {
log.info("################### security config ######################");
http.cors(httpSecurityCorsConfigurer -> {
httpSecurityCorsConfigurer.configurationSource(CorsConfigurationSource());
});
http.csrf(config -> config.disable());
http.sessionManagement(sessionConfig -> sessionConfig.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); // 세션비활성화
http.formLogin(config -> {
config.loginPage("/api/member/login");
config.successHandler(new APILoginSuccessHandler()); // token 발행, 200ok정보출력
config.failureHandler(new APILoginFailHandler()); // 200ok 회원자료무
});
http.addFilterBefore(new JWTCheckFilter(), UsernamePasswordAuthenticationFilter.class); // 토큰 인증 여부확인
http.exceptionHandling(config -> {
config.accessDeniedHandler(new CustomAccessDeniedHandler());
});
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public CorsConfigurationSource CorsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("HEAD", "GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("Authorization", "Cache-Control", "Content-Type"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
먼저 다른 어노테이션은 넘어가고 @EnableMethodSecurity를 보면 이는 메소드 수준에서 보안 설정을 활성화한다. 특정 메소드에 보안 규칙을 적용할 수 있다.
@Bean
public SecurityFilterChain filterchain(HttpSecurity http) throws Exception {
이 메소드가 바로 HTTP보안 설정을 정의하는 메소드이며, Spring Security의 SecurityFilterChain을 정의하는 메소드이다.
http.cors(httpSecurityCorsConfigurer -> {
httpSecurityCorsConfigurer.configurationSource(CorsConfigurationSource());
});
cors()를 통해서 Cross Origin설정을 활성화하며 CorsConfigurationSource를 통해 출처, 메소드, 헤더 등을 설정한다.
http.csrf(config -> config.disable());
JWT인증방식에서는 세션을 사용하지 않기 때문에 csrf()를 통해 CSRF보호를 비활성화한다.
http.sessionManagement(sessionConfig -> sessionConfig.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
세션을 사용하지 않기 때문에 STSTELESS를 통해 세션을 사용하지 않도록 설정했다.
http.formLogin(config -> {
config.loginPage("/api/member/login");
config.successHandler(new APILoginSuccessHandler()); // 로그인 성공 시 처리
config.failureHandler(new APILoginFailHandler()); // 로그인 실패 시 처리
});
formLogin()을 통해서 사용자 로그인 폼 설정을 정의했다.
먼저 loginPage("/api/member/login")를 통해서 사용자에게 보여줄 로그인 페이지 url을 설정했다.
그 후 로그인 성공, 실패시 처리하는 Handler를 호출하여 정의하였다.
그럼 APILoginSuccessHandler와 APILoginFailHandler에 대해 알아보겠다.
APILoginSuccessHandler
package com.sample.spring.security;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Map;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import com.google.gson.Gson;
import com.sample.spring.dto.MemberDto;
import com.sample.spring.util.JWTUtil;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;
@Log4j2
public class APILoginSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
log.info("-------------------------");
log.info("-----authentication------");
log.info("-------------------------");
MemberDto memberDto = (MemberDto) authentication.getPrincipal();
Map<String, Object> claims = memberDto.getClaims();
String accessToken = JWTUtil.generateToken(claims, 10);
String refreshToken = JWTUtil.generateToken(claims, 60 * 24);
claims.put("accessToken", accessToken);
claims.put("refreshToken", refreshToken);
Gson gson = new Gson();
String jsonStr = gson.toJson(claims);
response.setContentType("application/json;charset=utf-8");
PrintWriter printWriter = response.getWriter();
printWriter.print(jsonStr);
printWriter.close();
}
}
Spring Security의 인증 성공 후 처리하는 핸들러 인터페이스 AuthenticationSuccessHandler를 implement하며 사용자가 성공적으로 로그인하면 호출된다.
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
- HttpServletRequest request: 클라이언트의 요청 정보를 담고 있는 객체
- HttpServletResponse response: 서버가 클라이언트에 응답을 보낼 때 사용되는 객체
- Authentication authentication: 인증 정보를 담고 있으며, 여기에는 사용자 정보와 권한 등이 포함됨
MemberDto memberDto = (MemberDto) authentication.getPrincipal();
authentication.getPrincipal() 를 호출하여 사용자의 정보를 가져오고 이를 MemberDto로 캐스팅한다.
Map<String, Object> claims = memberDto.getClaims();
MemberDto의 getClaims()를 호출하여 사용자의 정보를 Map형태로 가져와서 이를 claims에 저장한다.
다음은 MemberDto의 getClaims()메소드이다.
public Map<String, Object> getClaims() {
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("email", email);
dataMap.put("pw", pw);
dataMap.put("nickname", nickname);
dataMap.put("social", social);
dataMap.put("roleNames", roleNames);
return dataMap;
}
String accessToken = JWTUtil.generateToken(claims, 10);
String refreshToken = JWTUtil.generateToken(claims, 60 * 24);
이제 여기서 JETUtil의 generateToken()을 사용해서 accesToken과 refreshToken을 생성한다.
JWTUtil
package com.sample.spring.util;
import java.time.ZonedDateTime;
import java.util.Date;
import java.util.Map;
import javax.crypto.SecretKey;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.InvalidClaimException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.security.Keys;
import lombok.extern.log4j.Log4j2;
@Log4j2
public class JWTUtil {
private static String key = "1234567890123456789012345678901234567890";
public static String generateToken(Map<String, Object> valueMap, int min) {
SecretKey key = null;
try {
key = Keys.hmacShaKeyFor(JWTUtil.key.getBytes("UTF-8"));
} catch (Exception e) {
throw new RuntimeException(e.getMessage());
}
String jwtStr = Jwts.builder().setHeader(Map.of("typ", "JWT")).setClaims(valueMap)
.setIssuedAt(Date.from(ZonedDateTime.now().toInstant()))
.setExpiration(Date.from(ZonedDateTime.now().plusMinutes(min).toInstant())).signWith(key).compact();
return jwtStr;
}
public static Map<String, Object> validateToken(String token) {
Map<String, Object> claim = null;
try {
SecretKey key = Keys.hmacShaKeyFor(JWTUtil.key.getBytes("UTF-8"));
claim = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token) // 파싱 및 검증, 실패 시 에러
.getBody();
} catch (MalformedJwtException malformedJwtException) {
throw new CustomJWTException("MalFormed"); // 잘못된 형식의 JWT가 전달된 경우
} catch (ExpiredJwtException expiredJwtException) {
throw new CustomJWTException("Expired"); // 만료된 JWT가 전달된 경우
} catch (InvalidClaimException invalidClaimException) {
throw new CustomJWTException("Invalid"); // JWT의 클레임이 유효하지 않은 경우
} catch (JwtException jwtException) {
throw new CustomJWTException("JWTError"); // 기타 JWT 관련 오류 발생 시
} catch (Exception e) {
throw new CustomJWTException("Error");// 그 외의 예외 발생 시
}
return claim;
}
}
이 클래스는 기본적으로 JWT를 생성하고 검증하는 클래스이다.
private static String key = "1234567890123456789012345678901234567890";
JWT의 서명에 사용할 비밀 키(Secret Key)이다. 이 키는 토큰을 생성하고 검증할 때 사용되며 HMAC(SHA 알고리즘) 기반의 서명에 사용되는 키로, 이 값은 중요한 보안 요소이기 때문에 외부에 노출되지 않도록 주의해야 한다.
이 키가 노출되는 순간 보안은 끝이다.😂😂😂
public static String generateToken(Map<String, Object> valueMap, int min)
JWT의 claims에 포함될 사용자의 정보가 valueMap이며 두번째 인자는 토큰의 유효기간이 들어간다.
SecretKey key = Keys.hmacShaKeyFor(JWTUtil.key.getBytes("UTF-8"));
Keys.hmacShaKeyFor메소드를 통해서 JWTUtil.key에서 사용할 SecretKey를 생성한다.
String jwtStr = Jwts.builder()
.setHeader(Map.of("type", "JWT"))
.setClaims(valueMap)
.setIssuedAt(Date.from(ZonedDateTime.now().toInstant()))
.setExpiration(Date.from(ZonedDateTime.now().plusMinutes(min).toInstant()))
.signWith(key)
.compact();
Jwts.builder()를 사용해 JWT를 생성한다.
- setHeader: JWT의 헤더 부분에 정보를 추가하고 여기서는 "type"라는 속성에 "JWT"라는 값을 설정하여 JWT 타입을 명시한다.
- setClaims: 클레임(payload)에 전달받은 사용자 데이터를 설정한다.
- setIssuedAt: 토큰이 발행된 시간을 설정한다.
- setExpiration: 토큰의 만료 시간을 설정하고 min 매개변수에 따라 만료 시간이 결정된다.
- signWith: 위에서 생성한 SecretKey를 사용하여 JWT를 서명한다.
- compact(): 최종적으로 JWT 문자열을 생성한다.
claim = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
Jwts.parserBuilder()를 사용해 토큰을 파싱하고 검증한다. 서명 키를 설정하고 parseClaimsJws(token)로 JWT를 파싱하여 클레임 부분을 추출한다.
검증에 실패하면 예외가 발생하는데 예외처리에 대해 알아보겠다.
catch (MalformedJwtException malformedJwtException) {
throw new CustomJWTException("MalFormed"); // 잘못된 형식의 JWT가 전달된 경우
} catch (ExpiredJwtException expiredJwtException) {
throw new CustomJWTException("Expired"); // 만료된 JWT가 전달된 경우
} catch (InvalidClaimException invalidClaimException) {
throw new CustomJWTException("Invalid"); // JWT의 클레임이 유효하지 않은 경우
} catch (JwtException jwtException) {
throw new CustomJWTException("JWTError"); // 기타 JWT 관련 오류 발생 시
} catch (Exception e) {
throw new CustomJWTException("Error");// 그 외의 예외 발생 시
}
- MalformedJwtException: 잘못된 형식의 JWT가 전달된 경우 발생
- ExpiredJwtException: JWT가 만료되었을 때 발생
- InvalidClaimException: JWT의 클레임이 유효하지 않은 경우 발생
- JwtException: 기타 JWT 관련 오류가 발생한 경우 처리
그럼 다시 config파일로 돌아와 남은 코드를 살펴보겠다🙄🙄🙄
String accessToken = JWTUtil.generateToken(claims, 10);
String refreshToken = JWTUtil.generateToken(claims, 60 * 24);
accessToken과 refreshToken에 모두 첫번째인자로 사용자의 정보를 담고 두번째인자로는 유효시간을 담았다.
claims.put("accessToken", accessToken);
claims.put("refreshToken", refreshToken);
생성된 token들을 클라이언트에 함께 전달하기 위해서 모두 claims에 추가해준다.
Gson gson = new Gson();
String jsonStr = gson.toJson(claims);
response.setContentType("application/json;charset=utf-8");
GSON라이브러리를 사용해서 Map형식의 claims객체를 JSON형시으로 반환한다.
그 후 응답의 콘텐츠 타입을 JSON으로 설정하고, UTF-8 인코딩을 지정하여 한글과 같은 문자를 지원하게 한다.
PrintWriter printWriter = response.getWriter();
printWriter.print(jsonStr);
printWriter.close();
PrintWriter 객체를 사용하여 응답 본문에 변환된 JSON 문자열(jsonStr)을 작성하고, 클라이언트에게 전송한다.
APILoginSuccessHandler
package com.sample.spring.security;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Map;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import com.google.gson.Gson;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;
@Log4j2
public class APILoginFailHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
log.info("-------------------------");
log.info("-------login fail--------");
log.info("-------------------------" + exception);
Gson gson = new Gson();
String jsonStr = gson.toJson(Map.of("error", "ERROR_LOGIN"));
response.setContentType("application/json");
PrintWriter printWriter = response.getWriter();
printWriter.print(jsonStr);
printWriter.close();
}
}
여기서는 에러 메세지를 gson라이브러리를 이용해서 JSON형태로 만들어 클라이언트에게 전송한다.
다시 config파일로 돌아와 코드를 살펴보겠다.
http.addFilterBefore(new JWTCheckFilter(), UsernamePasswordAuthenticationFilter.class); // 토큰 인증 여부확인
http.exceptionHandling(config -> {
config.accessDeniedHandler(new CustomAccessDeniedHandler());
});
addFilterBefore를 통해서 UsernamePasswordAuthenticationFilter앞에 JWTCheckFilter필터를 위치시켜주었다.
그리고 토큰 인증이 확인이 안되면 예외처리를 진행했다.
JWTCheckFilter
package com.sample.spring.security.filter;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.List;
import java.util.Map;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import com.google.gson.Gson;
import com.sample.spring.dto.MemberDto;
import com.sample.spring.util.JWTUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;
@Log4j2
public class JWTCheckFilter extends OncePerRequestFilter {
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String path = request.getRequestURI();
log.info("###############// check url :" + path);
if (path.startsWith("/api/member/")) {
return true;
}
// if(path.startsWith("/test")) {
// return true;
// }
return false;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
log.info("###############dododoFilter#####################");
log.info("###############dododoFilter#####################");
log.info("###############dododoFilter#####################");
String authHeaderStr = request.getHeader("Authorization");
try {
String accessToken = authHeaderStr.substring(7);
Map<String, Object> claims = JWTUtil.validateToken(accessToken);
log.info("##############jwt claims : " + claims);
String email = (String) claims.get("email");
String pw = (String) claims.get("pw");
String nickname = (String) claims.get("nickname");
Boolean social = (boolean) claims.get("social");
List<String> roleNames = (List<String>) claims.get("roleNames");
MemberDto memberDto = new MemberDto(email, pw, nickname, social.booleanValue(), roleNames);
log.info(memberDto);
log.info(memberDto.getAuthorities());
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(memberDto,
pw, memberDto.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
} catch (Exception e) {
log.info("JWT check error");
Gson gson = new Gson();
String jsonStr = gson.toJson(Map.of("error", "ERROR_ACCESS_TOKEN"));
response.setContentType("application/json;charset=utf-8");
PrintWriter printWriter = response.getWriter();
printWriter.print(jsonStr);
printWriter.close();
}
}
}
요청에 포함된 JWT(Access Token)를 확인하고, 유효하면 인증 정보를 설정하는 역할을 한다.
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String path = request.getRequestURI();
log.info("###############// check url :" + path);
if (path.startsWith("/api/member/")) {
return true;
}
return false;
}
이 메서드는 /api/member/로 시작하는 요청에 대해 JWT 검증 필터를 건너뛰도록 설정하고 있으며 로그인이 필요하지 않은 페이지에서 이를 사용할 수 있다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
log.info("###############dododoFilter#####################");
String authHeaderStr = request.getHeader("Authorization");
authHeaderStr는 Authorization 헤더에 포함된 JWT 토큰을 가져오는 부분이다.
String accessToken = authHeaderStr.substring(7);
Map<String, Object> claims = JWTUtil.validateToken(accessToken);
log.info("##############jwt claims : " + claims);
Authorization 헤더에서 "Bearer " 이후의 실제 JWT 토큰을 추출한다.
JWTUtil.validateToken(accessToken)을 사용해 토큰을 검증하고, 토큰이 유효하면 그 안에 포함된 클레임(사용자 정보)을 추출한다.
String email = (String) claims.get("email");
String pw = (String) claims.get("pw");
String nickname = (String) claims.get("nickname");
Boolean social = (boolean) claims.get("social");
List<String> roleNames = (List<String>) claims.get("roleNames");
MemberDto memberDto = new MemberDto(email, pw, nickname, social.booleanValue(), roleNames);
JWT클레임에서 사용자의 정보를 추출해 MemberDto객체를 만들어준다.
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(memberDto, pw, memberDto.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
UsernamePasswordAuthenticationToken을 사용해 인증 토큰을 생성하고, SecurityContextHolder에 설정하여 현재 요청이 인증된 사용자로 처리될 수 있도록 한다.
filterChain.doFilter(request, response);
JWT 검증에 성공하면 필터 체인을 계속 진행하여 다음 필터나 컨트롤러로 요청을 전달한다.
} catch (Exception e) {
log.info("JWT check error");
Gson gson = new Gson();
String jsonStr = gson.toJson(Map.of("error", "ERROR_ACCESS_TOKEN"));
response.setContentType("application/json;charset=utf-8");
PrintWriter printWriter = response.getWriter();
printWriter.print(jsonStr);
printWriter.close();
}
에러 메세지를 gson을 통해 JSON형태로 만들어 클라이언트에게 전송하는 예외처리 부분이다.
다시 config파일로 넘어가보겠다.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
BCryptPasswordEncoder를 Bean으로 등록해 비밀번호를 안전하게 저장하고 검증 시 동일한 방식으로 비교할 수 있다.
@Bean
public CorsConfigurationSource CorsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("HEAD", "GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("Authorization", "Cache-Control", "Content-Type"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
이 부분은 CORS설정을 하는 부분이다.
- 모든 출처에서 요청을 허용 (setAllowedOrigins(Arrays.asList("*"))).
- 허용되는 HTTP 메소드 목록 설정 (HEAD, GET, POST, PUT, DELETE, OPTIONS).
- 허용되는 헤더 목록 설정 (Authorization, Cache-Control, Content-Type).
- 자격 증명(Cookie 등)을 허용 (setAllowCredentials(true)).
- 특정 URL 패턴에 이 규칙을 적용 (/**)
HomeController
package com.sample.spring.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HomeController {
@PreAuthorize("hasAnyRole('ROLE_USER')")
@GetMapping("/test")
public String testPage() {
return "test";
}
}
@PreAuthorize("hasAnyRole('ROLE_USER')")
위 어노테이션을 통해 ROLE_USER의 역할을 가지고 있을 때만 접근가능하게 할 수있다.
권한이 확인되면 testPage() 메서드가 실행되고, "test" 문자열이 클라이언트에게 반환된다.
DBeaver
member테이블은 다음과 같다.
Postman
postman을 통해 해당 주소로 body값을 포함해 post요청을 보냈다.
그럼 요청보낸 Body애서 accessToken을 확인할 수 있다. 그리고 USER, MEMBER, ADMIN역할을 모두 가지고 있으므로 /test로 접근했을 때 성공적으로 페이지를 반환하고 test문자가 출력될 것이다.
/test경로를 통해 GET 요청을 보내는데 Authorization에서 Auth Type을 Bearer Token으로 설정하고 Token에 Access Token을 넣어주고 요청을 보낸 결과이다.
인증에 성공해 test를 출력하는 모습을 확인할 수 있다.
JWT.IO
JWT.IO
JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.
jwt.io
Encoded부분에 AccessToken을 넣으면
payload에서 데이터를 확인할 수 있다.
verify signature의 빈칸에 JWTUtil에서 설정한 key값을 넣어주면 왼쪽 아래에 Verified가 나오는 것을 확인할 수 있다.
- 마무리
이전에 HTTP세션을 가지고 로그인을 구현해본 적이 있었는데 이때도 JWT를 사용해서 보안성을 높이고 싶었는데 너무 어렵고 잘 안되서 HTTP세션을 이용했던 기억이 있다.
처음에 수업을 들으면서는 너무 생소한 내용도 많고 어려워서 집중도 잘 못하고 그랬지만 Spring Security 동작과정을 보고 코드를 천천히 따라가보니 훨씬 처음보다 많이 알게 되었다. 앞으로 프로젝트를 진행하면서 사용하는 일이 많을 것 같은데 추후에 지금 이 정리하면서 공부했던 과정이 도움이 되었으면 좋겠다😊
'현대오토에버 모빌리티 sw 스쿨' 카테고리의 다른 글
현대오토에버 모빌리티 SW 스쿨 웹/앱 스프링부트 파일 업로드 2 (3) | 2024.10.16 |
---|---|
현대오토에버 모빌리티 SW 스쿨 웹/앱 스프링부트 파일 업로드 (1) | 2024.10.15 |
현대오토에버 모빌리티 SW 스쿨 웹/앱 스프링부트 어노테이션 (1) | 2024.10.14 |
현대오토에버 모빌리티 SW 스쿨 웹/앱 데이터베이스 JDBC & MyBatis (2) | 2024.10.14 |
현대오토에버 모빌리티 SW 스쿨 웹/앱 SQL JOIN (2) | 2024.10.14 |