개요
아무 생각 없이 인증처리를 안 해놓은 벌로 누군가의 침입이 일어났다.. 😱
어쩐지 람다 함수 절반이 안 돌아서 데이터를 살펴보니 누군가.. 백준에 없는 아이디와 이름을 넣어놨다. 심지어 스터디에도 넣어놨넴... 😵 인증처리를 미리미리 해뒀으면 좋았겠지만, 지금이라도 코알라 임원진끼리만 공유할 수 있는 비밀번호 처리를 해두는 것이 좋겠다 .
Spring Security
Session
Spring Security는 기본적으로 JSESSION_ID라는 이름을 가진 세션 방식의 인증 메카니즘으로 인증을 처리한다. Spring Security를 공부하다 보면 JWT 개념에 대해서도 같이 공부하게 되는데, 토큰(JWT) 방식과, 세션 방식은 엄연히 다른 내용이므로 이에 대해 알고 가야 한다 !
세션에 대한 내용에 대해 알아보자. (이곳 참고).
사용자가 브라우저를 닫아 서버와의 연결을 끝내는 시점까지를 세션이라고 한다. 토큰은 클라이언트 측의 컴퓨터에 정보를 저장하는 반면, 세션은 서비스가 돌아가는 서버 측에 데이터를 저장하고, 세션의 키값만을 클라이언트에 남겨둔다. 브라우저는 필요할 때마다 이 키값을 이용하여 서버에 저장된 데이터를 사용하게 된다.
이 세션은 다른 모든 헤더보다 먼저 생성되며, 클라이언트에 이미 세션 아이디가 존재한다면 기존 세션 변수를 사용하고, 존재하지 않으면 새로운 랜덤 세션 아이디 값을 생성한다.
또한 세션 변수의 사용이 모두 끝났다면, 세션 변수의 등록을 해지할 수도 있고, 세션 자체를 완전히 종료하기 위해 세션 아이디를 삭제할 수도 있다. Session은 브라우저를 닫게되면 소멸된다는 특징이 있다. 따라서 Spring Security에서는 remember-me 토큰 방식을 함께 이용해 세션 유지 기능을 할 수 있다.
Spring Security 기본 WorkFlow
Spring Security를 구현하다보면 deprecated 된 기능들이 너무 많아 혼란이 올 수 있다. 그래도 기본적인 workflow를 이해하면 그 안에서 변화하는 것이 크지 않기 때문에 이해하고 적용하기 어렵지 않은 것 같다.
Spring Security 6 버전 공식문서와 Spring Security 구버전 - 5.2 문서를 참고하여 공부하고 구현하였다.
우선 Spring Security의 핵심은 '인증과 인가를 주축으로, Authentication 객체에 어떤 내용을 담을 것인가'. 그리고 '어떤 기능들을 Filter들이 수행하도록 할 것인가' 이다. 따라서 당연하게 거쳐가는 Filter 의 순서에 대해 이해하는 것도 중요하다.
위에서 말한 Session에는 `Authentication` 객체를 담고 있게 된다. -> 서버에 여러 request들이 들어오면 서블릿 컨테이너들 사이에서 필터들이 이 요청들을 가로채서 인증 처리를 수행한다.
내가 구현한 인증 처리 workflow는 다음과 같다.
-> 클라이언트가 form 인증 방식으로 인증 시도 -> UsernamePasswordAuthenticationFilter 로 인증 처리 -> 인증이 성공하면 SecurityContext 에 Authentication 객체를 저장 -> 인증 처리 완료
Authentication 객체에는 인증성공결과(ADMIN, Authorities) 저장
블로그글이나 예제들보다도 그냥 공식문서를 한 번 따라가면서 읽다보니 대체 이 필터가 뭔지, 인증 객체는 어디에 있고, 어떤 정보를 담고 있는지 이해하기 쉬웠다.
구현
간단히 내 프로젝트와 비슷한 환경을 예제로 만들어 인증 처리를 해보았다.
JSP 파일
build.gradle에 jsp와 관련한 의존성을 추가해두고 attend.jsp, home.jsp, list.jsp 파일에 다음과 같이 h1 태그로 간단히 페이지 구분만 되도록 써두었다. localhost:8081/attend에는 ATTEND라는 글씨가, /home에는 HOME이라는 글씨가, /list에는 LIST라는 글씨가 큼지막하게 보인다.
ViewController
그리고 ViewController 에 해당 페이지들이 표시되도록 라우팅 처리를 해두었다.
package com.example.koalaauth.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
public class ViewController {
@RequestMapping("/")
public String home(){
return "home";
}
@RequestMapping("/attend")
public String attend(){
return "attend";
}
@RequestMapping("/list")
public String list(){
return "list";
}
}
config/SecurityConfig
Spring Security 버전 6부터는 WebSecurityConfigurerAdapter가 Deprecated 되었기 때문에 SecurityFilterChain를 Bean으로 등록해서 사용해야 한다.
package com.example.koalaauth.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Value("${spring.security.user.name}") //application.yml 파일에서 가져온다.
private String username;
@Value("${spring.security.user.password}")
private String password;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.formLogin(Customizer.withDefaults())
.authorizeHttpRequests((authorize)-> authorize
.requestMatchers("/attend").hasRole("ADMIN")
.anyRequest().permitAll() //그 외에 다른 uri는 permitAll
)
.logout((logout)->logout.logoutUrl("/logout")
.deleteCookies("JSESSIONID") //로그아웃 후 JESSIONID 쿠키 삭제
.invalidateHttpSession(true)
.logoutSuccessUrl("/")
);
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails userDetails = User.withDefaultPasswordEncoder()
.username(username)
.password(password)
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(userDetails);
}
}
spring:
security:
user:
name: admin
password: 2222
위 구현 방식에 대해 알아보자.
✔ 암호 저장 형식
스프링 시큐리티 공식문서에는 암호 저장 역사에 대한 친절한(?) 설명과 함께 암호 저장 형식에 대해 설명해주고 있다. 위와 같이 {id} encodedPassword 형식으로 저장되며 bcrypt, scrypt 등 여러 양식이 있지만 나는 기본값인 DefaultPasswordEncoder를 사용하였다. 다른 양식을 사용할 경우 passwordEncoder 를 따로 지정하여 사용해야 한다.
✔ UsernamePasswordAuthenticationToken
Security에서 제공하는 로그인 form 페이지를 이용해 간단히 user과 password를 인증받도록 구현하였다.
.formLogin(Customizer.withDefaults())
DB를 사용한다면 사용자 정의 UserDetailsService 를 custom하여 만들고 Bean으로 등록하는 방식으로 구현할 수도 있지만, 간단히 인메모리에 원하는 값 한 개만 저장해두고 꺼내와 사용하도록 구현하였다.
return new InMemoryUserDetailsManager(userDetails);
username과 password는 application.yml 파일에 하드코딩하여 저장해두었고 이후 외부 환경 변수로 할당해 사용할 예정이다.
✔ ROLE
서버에 들어오는 요청들에 권한을 부여하는 방법에는 여러가지가 있다.
Endpoint를 지정하는 방법, URI에 포함된 특정 정규식에 제한을 주어 권한을 부여하는 방법, HTTP METHOD(GET, POST) 방식에 read/write 권한을 주는 방법, DispatcherType에 따라 권한을 다르게 주는 법 .. 등 다양하고 custom 하게 적용할 수도 있다.
나는 이 중에 Endpoint를 지정하는 방식으로 구현하였다.
.requestMatchers("/attend").hasRole("ADMIN")
.anyRequest().permitAll() //그 외에 다른 uri는 permitAll
"/attend" URI로 접근하려면 ADMIN 권한이 필요하고, 그 외에 다른 Request들은 권한 제한 없이 접근을 허용함을 의미한다.
.permitAll() vs .anonymous()
이 둘에 대한 의미를 잘 구분하는 것이 필요하다.
permitAll()의 경우 인증된 사용자든, 익명 사용자든 모두 접근이 가능하지만 anonymous는 익명 사용자만이 접근이 가능하다.
따라서 나는 나머지 Request들은 모두 다 접근 가능하도록 하기 위해 permitAll()을 썼고, 권한 부여는 구체적인(범위가 좁은) 것부터 적용되므로 가장 하위에 적용해 주었다.
화면단 결과
권한이 부여되지 않은 HOME, LIST 페이지는 자유롭게 접근할 수 있다.
하지만 /attend 페이지에 접근하기 위해서는 로그인이 필요하다.
그러면 login 페이지로 우회하게 되고
admin, 2222로 인증에 성공하면
SavedRequest 전략에 따라 로그인을 시도했던 해당 페이지(/attend) 로 다시 redirect 된다.
결론
미뤄왔던 Spring Security 공부를 드디어 하게 되었다.! 이전에 무작정 SecurityConfig를 짜기 전에 인증 방식을 한 번만 훑어봤으면 그렇게까지 고생하진 않았을 텐데 허무할 정도로 공식문서도 잘 되어있고 SpringSecurity 자체도 짜임새 있게 이루어져 있다고 느꼈다. 아직 구현을 안 해본 사람이라면 Filter 가 뭘까 ? -> 이 질문이라도 한 번 답해볼 수 있게 공부해보길,,
암튼 공부한 개념을 토대로 이후에는 조금 더 계층화된 권한 부여도 과감히 도전해볼 수 있을 것 같다 🙌
'BackEnd > JAVA\SPRING' 카테고리의 다른 글
[S3|AWS] S3와 CloudFront로 이미지 저장소 만들기 (0) | 2024.06.30 |
---|---|
[Java/Spring] 템플릿 콜백 패턴으로 JDBC 템플릿 구현해보기 (0) | 2024.04.02 |
[Java|Spring] Koala 프로젝트 구현 과정 중 이슈 정리 (0) | 2024.02.01 |
[WAS/WS|Docker] Springboot war 파일 외장 tomcat 배포 (2) | 2024.01.10 |
[SPRING] 서비스 추상화 (0) | 2023.10.19 |