Spring Security
스프링 시큐리티는 간단하게 인증과 인가, 즉 로그인할수있는 기능을 제공해주는 라이브러리이다.
스프링 시큐리티를 이용해 만들어져있는기능으로 로그인, 권한에 따른 접근 을 간편하게 구현할수있다.
스프링 시큐리티의 동작에대해 잘 설명되어있는 글이다. 여기에 더해서 jwt토큰을 더해 클라이언트에서 사용할 로그인 기능을 구현할 생각이다.
JWT(json web token)
jwt토큰을 이용하면 필요한 정보를 암호화해서 보관하고 가져와서 사용할수있다.
build.gradle
dependencies {
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'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}
jpa , security , swagger , jwt 를 사용하기위한 라이브러리들을 불러와서 사용한다.
SecurityConfig
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// CORS(Cross-Origin Resource Sharing) 설정
.cors((cors) -> cors.configurationSource(new CorsConfigurationSource() {
@Override
public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowedOrigins(Collections.singletonList("*")); // 모든 출처 허용
corsConfiguration.setAllowedMethods(Collections.singletonList("*")); // 모든 HTTP 메서드 허용
corsConfiguration.setAllowCredentials(true); // 자격 증명 허용
corsConfiguration.setAllowedHeaders(Arrays.asList("Authorization", "Cache-Control", "Content-Type")); // 허용된 헤더
corsConfiguration.setExposedHeaders(Arrays.asList("Authorization", "Cache-Control", "Content-Type")); // 노출된 헤더
return corsConfiguration;
}
}))
// CSRF 토큰 비활성화
.csrf((auth) -> auth.disable())
// 폼 로그인 비활성화
.formLogin((auth) -> auth.disable())
// HTTP 기본 인증 비활성화
.httpBasic((auth) -> auth.disable())
// 권한 설정
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/login", "/", "/join").permitAll() // 로그인, 홈, 회원가입 페이지 접근 허용
.requestMatchers("/imgs/**").permitAll() // 이미지 파일 접근 허용
.requestMatchers("/admin/**","/swagger-ui/**").hasRole("ADMIN") // 관리자 권한 필요
.requestMatchers("/user/**").hasRole("USER") // 사용자 권한 필요
.anyRequest().authenticated() // 나머지 요청은 인증 필요
)
// 세션 관리 설정
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 무상태 세션 정책
)
// JWT 필터 추가
.addFilterBefore(new JwtFilter(jwtUtil), LoginFilter.class)
// 로그인 필터 추가
.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil),
UsernamePasswordAuthenticationFilter.class)
;
return http.build();
}
jwt 인증방식의 로그인을 사용하기위해서 httpBasic ,sessionManagement 는 비활성화 해주어야한다.
기존 http인증방식이아닌 jwt로 인증을 해주어야하기 때문이다.
CustomUserDetails
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
private final UserEntity userEntity;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(
new GrantedAuthority() {
@Override
public String getAuthority() {
return String.valueOf(userEntity.getRole());
}
}
);
return collection;
}
@Override
public String getPassword() {
return userEntity.getPassword();
}
@Override
public String getUsername() {
return userEntity.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
CustomUserDetailService
@Service
@RequiredArgsConstructor
public static class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserEntity byEmail = userRepository.findByEmail(username).orElseThrow(IllegalArgumentException::new);
return new CustomUserDetails(byEmail);
}
}
사용자가 로그인폼에 아이디와 비밀번호를 입력하고 로그인을하게되면
- UsernamePasswordAuthenticationToken 객체가 생성
- AuthenticationManager가 이 토큰을 받아 인증을 시도
- AuthenticationManager는 내부적으로 AuthenticationProvider를 호출하여 인증 로직을 수행
- AuthenticationProvider는 CustomUserDetailsService의 loadUserByUsername 메서드를 호출하여 사용자 정보를 조회
- loadUserByUsername메서드 에서는 사용자 이메일로 UserEntity를 찾아 CustomUserDetails 객체를 생성합니다.
- CustomUserDetails 객체에는 사용자의 이메일, 비밀번호, 권한 등의 정보가 포함
- AuthenticationProvider는 CustomUserDetails 객체에서 가져온 비밀번호와 사용자가 입력한 비밀번호를 비교하여 인증 여부를 판단 인증이 성공하면 인증된 Authentication객체를 반환. 실패하면 Exception을 던진다.
이제 인증을 한후 인증정보를 jwt토큰으로 만들어 클라이언트에 보내주어야한다.
JwtUtil
@Component
public class JwtUtil {
private SecretKey secretKey;
private String EMAIL = "email";
private String ROLE = "role";
public JwtUtil(@Value("${spring.jwt.secret}")String secret) {
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), SIG.HS256.key().build().getAlgorithm());
}
public String getUsername(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get(EMAIL, String.class);
}
public String getRole(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get(ROLE, String.class);
}
public Boolean isExpired(String token) {
JwtParser parser = Jwts.parser().verifyWith(secretKey).build();
Jws<Claims> claimsJws = parser.parseSignedClaims(token);
Claims payload = claimsJws.getPayload();
Date expiration = payload.getExpiration();
boolean before = expiration.before(new Date());
return before;
}
public String createJwt(String username, String role, Long expiredMs) {
long now = System.currentTimeMillis();
Date expiration = new Date(now + expiredMs);
return Jwts.builder()
.claim(EMAIL, username)
.claim(ROLE, role)
.issuedAt(new Date(now))
.expiration(expiration)
.signWith(secretKey)
.compact();
}
}
jwt 토큰을 만들고 읽을 수있는 util 클래스를 우선생성해준다.
LoginFilter - 로그인 시 동작할 필터
@RequiredArgsConstructor
@Slf4j
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtUtil jwtUtil;
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
// 1. 클라이언트가 보낸 username과 password를 추출합니다.
String userName = obtainUsername(request);
String password = obtainPassword(request);
// 2. 추출한 username과 password로 UsernamePasswordAuthenticationToken을 생성합니다.
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userName, password, null);
// 3. AuthenticationManager에게 인증을 요청합니다.
return authenticationManager.authenticate(authenticationToken);
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
// 4. 인증이 성공하면 CustomUserDetails 객체를 가져옵니다.
CustomUserDetails customUserDetails = (CustomUserDetails) authResult.getPrincipal();
// 5. CustomUserDetails에서 username과 role을 추출합니다.
String username = customUserDetails.getUsername();
String role = getRole(customUserDetails);
// 6. JwtUtil을 사용하여 JWT 토큰을 생성합니다.
String token = jwtUtil.createJwt(username, role, 60 * 60 * 1000L);
// 7. 생성한 JWT 토큰을 응답 헤더에 추가합니다.
response.addHeader("Authorization", "Bearer " + token);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) throws IOException, ServletException {
// 8. 인증이 실패하면 응답 상태 코드를 401(Unauthorized)로 설정합니다.
response.setStatus(401);
}
private String getRole(CustomUserDetails customUserDetails) {
// 9. CustomUserDetails에서 사용자의 권한(role)을 추출합니다.
return customUserDetails.getAuthorities().iterator().next().getAuthority();
}
}
로그인 할경우 해당 필터를 통과해 attemptAuthentication메서드로 인증을 시도하고 인증을 성공하는경우
successfulAuthentication메서드를 실행하고 jwt토큰을 발급해서 응답헤더에 토큰을 붙여서 응답을 보내준다.
실패하는경우에는 unsuccessfulAuthentication을 실행시키고 응답에 401에러를 보내주게된다.
이제 이렇게하면 로그인을 하고 응답으로 jwt토큰을 받아서 클라이언트에 저장할수있다.
이제 다시 서버에서 jwt토큰을 읽어서 해당 유저의 인증된 정보를 받아서 보관하는 필터를 만들어줘야한다.
JwtFilter
@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 1. 요청 헤더에서 Authorization 헤더를 가져옵니다.
String authorization = request.getHeader("Authorization");
// 2. Authorization 헤더가 없거나 "Bearer" 로 시작하지 않으면 토큰이 없는 것으로 간주하고 다음 필터로 넘어갑니다.
if (authorization == null || !authorization.startsWith("Bearer")) {
log.error("token null");
filterChain.doFilter(request, response);
return;
}
// 3. Authorization 헤더에서 실제 토큰 값을 추출합니다.
String token = authorization.split(" ")[1];
// 4. 토큰의 만료 시간을 확인합니다. 만료된 경우 다음 필터로 넘어갑니다.
if (jwtUtil.isExpired(token)) {
log.error("token expired");
filterChain.doFilter(request, response);
return;
}
// 5. 토큰이 유효한 경우 setAuthentication 메서드를 호출하여 인증 정보를 SecurityContextHolder에 설정합니다.
setAuthentication(token);
// 6. 다음 필터로 요청을 전달합니다.
filterChain.doFilter(request, response);
}
private void setAuthentication(String token) {
// 7. 토큰에서 사용자 이름과 역할 정보를 추출합니다.
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
// 8. 추출한 정보로 UserEntity 객체를 생성합니다.
UserEntity user = UserEntity.builder()
.email(username)
.password("")
.role(UserRole.valueOf(role))
.build();
// 9. UserEntity 객체를 이용하여 CustomUserDetails 객체를 생성합니다.
CustomUserDetails customUserDetails = new CustomUserDetails(user);
// 10. CustomUserDetails 객체와 권한 정보를 이용하여 UsernamePasswordAuthenticationToken 객체를 생성합니다.
Authentication authenticationToken = new UsernamePasswordAuthenticationToken(
customUserDetails, null, customUserDetails.getAuthorities());
// 11. 생성한 인증 토큰을 SecurityContextHolder에 설정합니다.
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
서버에 api요청을 하는경우 이 필터를 통과해서 토큰이 있는경우 setAuthentication으로 SecurityContextHolder에 인증된 값을 세팅해준다. 이러면 컨트롤러에서도 인증된 유저의 정보를 사용할수도있고 인증되지않은 유저, 토큰을 가지지않은 유저가 api에 접근할때에도 통과시키지않을수있다.
보통 클라이언트와 서버를 나눠서 사용할때는 jwt토큰방식을 사용하고
서버에서 프론트까지 모두 만드는경우에는 세션방식을 많이 이용한다고 한다.
jwt토큰방식은 stateless하다는것이 장점이라고하는데 토큰을 보내주고난다음에는 서버에서는 토큰을 보관하고있지않고 요청이 올때마다 토큰을 읽어서 인증하기때문에 서버에 부담이 적다고 한다.
공부를 더 해서 잘 이해하게되면 나중에 이 글도 버전업으로 더 잘써보겠다.
'공부 임시 저장소' 카테고리의 다른 글
도메인 레코드란? DNS Record (1) | 2024.10.13 |
---|---|
도메인이란? DNS (1) | 2024.10.13 |
스프링부트 - aws s3파일저장 (0) | 2024.06.25 |
스프링부트 - 파일 저장 (0) | 2024.06.24 |
채팅앱 - 채팅 미전송 메시지 처리 (0) | 2024.06.24 |