세션과 쿠키란 ❓
쿠키와 세션을 사용하는 이유
HTTP 프로토콜 환경은 "connectionless, stateless" 한 특성을 가지기 때문에 서버는 클라이언트가 누구인지 매번 확인해야 한다.
connectionless 특성이란 ?
클라이언트가 요청을 한 후 응답을 받으면 그 연결을 끊어 버리는 특징이다.
HTTP는 먼저 클라이언트가 request를 서버에 보내면, 서버는 클라이언트에게 요청에 맞는 response를 보내고 접속을 끊는 특성이 있다.
stateless 특성이란 ?
통신이 끝나면 상태를 유지하지 않는 특징이다. 즉, 연결을 끊는 순간 클라이언트와 서버의 통신이 끝나며 상태 정보는 유지하지 않는 특성이 있다.
이 2가지 특성을 예로 들면, 쇼핑몰에서 옷을 구매하려고 로그인을 했지만, 페이지를 한번 이동할 때마다 계속 로그인을 해야되는 일이 발생하는 것이다.
따라서 쿠키와 세션은 이러한 HTTP 프로토콜의 특성이자 단점을 해결하기 위해 사용하게 되었다.
쿠키(Cookie)
쿠키는 클라이언트(브라우저) 로컬에 저장되는 키와 값이 들어있는 작은 데이터 파일로, 사용자 인증이 유효한 시간을 명시할 수 있으며, 유효 시간이 정해지면 브라우저가 종료되어도 인증이 유지된다는 특징이 있다.
쿠키는 클라이언트의 상태 정보를 로컬에 저장했다가 참조하는데, 클라이언트에는 300개까지 쿠키저장이 가능하며, 하나의 도메인당 20개의 값만 가질 수 있다고 한다.
쿠키는 사용자가 따로 요청하지 않아도 브라우저가 Request시에 Request Header를 넣어서 자동으로 서버에 전송한다. 따라서 보통 사용자가 웹브라우저를 처음 접속하면 그때 자동으로 사용자에게 쿠키를 생성해서 저장시켜 준다.
✍ 쿠키의 동작 방식
1) 클라이언트가 페이지를 요청
2) 서버에서 쿠키를 생성
3) HTTP 헤더에 쿠키를 포함 시켜 응답
4) 브라우저가 종료되어도 쿠키 만료 기간이 있다면 클라이언트에서 보관
5) 같은 요청을 할 경우 HTTP 헤더에 쿠키를 함께 보냄
6) 서버에서 쿠키를 읽어 이전 상태 정보를 변경 할 필요가 있을 때 쿠키를 업데이트 하여
변경된 쿠키를 HTTP 헤더에 포함시켜 응답
세션(Session)
세션은 쿠키를 기반으로 하고 있지만, 사용자 정보 파일을 브라우저에 저장하는 쿠키와 달리 세션은 서버 측에서 관리한다.
서버에서는 클라이언트를 구분하기 위해 세션 ID를 부여하며 웹 브라우저가 서버에 접속해서 브라우저를 종료할 때까지 인증상태를 유지한다. 또한, 접속 시간에 제한을 두어 일정 시간 응답이 없다면 정보가 유지되지 않게 설정이 가능 하다.
사용자에 대한 정보를 서버에 두기 때문에 쿠키보다 보안에 좋지만, 사용자가 많아질수록 서버 메모리를 많이 차지하게 된다는 단점이 있다. 즉, 동접자 수가 많은 웹 사이트인 경우 서버에 과부하를 주게 되므로 성능 저하의 요인이 된다.
✍ 세션의 동작 방식
1) 클라이언트가 서버에 접속 시 세션 ID를 발급 받음
2) 클라이언트는 세션 ID에 대해 쿠키를 사용해서 저장하고 가지고 있음
3) 클라리언트는 서버에 요청할 때, 이 쿠키의 세션 ID를 같이 서버에 전달해서 요청
4) 서버는 세션 ID를 전달 받아서 별다른 작업없이 세션 ID로 세션에 있는 클라언트
정보를 가져와서 사용
5) 클라이언트 정보를 가지고 서버 요청을 처리하여 클라이언트에게 응답
세션과 쿠키를 보안적으로 생각해보면, 클라이언트의 쿠키를 가로챌 수 만 있다면,
그 클라이언트의 계정으로 서버로 접속하는 것이 가능해진다. 세션에서는 사용자에게 부여한 쿠키를 통해 인증을 시켜주기 때문에, 쿠키만 세션에 저장된 것과 동일하면 접속하려는 사용자가 원래 쿠키를 부여한 사용자가 아니여도 접속을 허용시켜준다. 이것이 세션 하이재킹 공격이다. 따라서 개발자는 쿠키 관리가 중요하다고 한다.
또 다른 문제로 서버에 메모리가 부족해지면 부하분산을 위해 서버를 한 대 더 늘리게 되는데 1번 사용자의 정보가 1번 서버에 저장되어 있다고 할때, 서버가 늘어나있으면 1번 사용자가 접속을 다시 시도할때, 1번 서버의 메모리가 꽉차서 2번 서버로 요청이 가면 2번 서버에는 1번 사용자의 정보가 없디 때문에 로그인이 되지 않게 된다. 따라서 세션 서버를 클러스터링으로 구성하여 서버를 서로 동기화하여 처리토록 해준다.
하지만 이렇게 하면, 똑같은 데이터가 세션 두 곳에 모두 저장되기 때문에 세션에 메모리 낭비가 되는 문제가 발생한다. 만약, 서버를 더이상 늘릴 수 있는 환경, 여건이 없다면 어떻게 할 수 있을것인가? 이때는 DB나 캐시에 저장을 해서 사용할 수 있겠지만, 이것도 DB에 대한 접근이 많아져 서버 동작이 느려질 수 있다.
따라서 이러한 것을 해결하기 위해 세션에 클라이언트의 정보를 저장하는 것이 아닌 클라이언트에게 각자 본인의 정보를 저장토록 하는 방식을 최근들어 사용하게 되었다는데, 그것이 토큰 기반의 인증 방식이다.
토큰 기반 인증 방식은 클라이언트에게만 정보를 저장하게 하는 방식으로, 클라이언트가 로그인할 때 서버가 클라이언트에게 토큰을 발급하고, 클라이언트는 발급 받은 토큰으로 인증을 받게 되는 방식이다.
✍ 토큰 기반 인증 동작 방식
1) 사용자가 아이디와 비밀번호로 로그인 한다.
2) 서버에서 해당 정보를 검증한다.
3) 정보가 정확하면, 서버에서 사용자에게 토큰을 발급한다.
4) 클라이언트는 전달받은 토큰을 저장해두고, 서버에 요청을 할 때마다 해당 토큰을
서버에 함께 전달한다. 이때 토큰은 HTTP 요청 헤더에 포함시킨다.
5) 서버는 토큰을 검증하고, 요청에 응답한다.
JWT (Java Web Token) 란❓
JWT 토큰은 토큰 기반 인증 시스템에서 가장 많이 사용되는 토큰 중 하나이다. JWT 토큰은 JSON 포맷을 이용하여 사용자에 대한 속성을 저장하는 Claim 기반의 웹 토큰이다. JWT는 토큰 자체를 정보로 사용하는 Self-Contained 방식으로 정보를 안전하게 전달한다.
보통 사용하는 방식은 클라이언트에게 토큰을 2개 주는데, 한개는 접근할 때 주는 토큰(Access Token)이고 다른 하나는 토큰을 갱신할 때 주는 토큰(Refresh Token) 이다. 따라서, 접근 토큰은 유지 시간을 짧게 주고, 갱신 토큰으로 만료되면 갱신을 시키는 방식이다.
여기서 갱신 토큰은 JWT 토큰이 아니다. 이것은 서버에 저장해 놓는 것인데, 서버에 저장한다는 것 자체가 세션 기반 인증과 크게 차이가 없다는 것을 느껴서 토큰 기반 인증을 사용하다가 세션 기반 인증으로 돌아가기도 한다고 한다.
하지만, 세션 기반 인증과 토큰 기반 인증 중 모가 더 좋다 나쁘다 할것은 없다. 결국은 운영하는 서버의 성격에 따라 달라질 것이다.
Spring Security 란 ❓
스프링 시큐리티는 스프링 기반의 어플리케이션의 보안(인증과 권한)을 담당하는 프레임워크이다. 만약 스프링 시큐리티가 없다면, 자체적으로 세션을 체크하고 Redirect 등 다양한 처리를 해야하지만 스프링 시큐리티는 보안과 관련해서 체계적으로 많은 옵션들을 지원해주기 때문에 간단한 코딩만으로 구현이 가능하다.
스프링 시큐리티는 인증과 권한에 대한 부분을 Filter 흐름에 따라 처리하는데 이 Filter는 디스패처 서블릿으로 가기 전에 적용되므로 가장 먼저 URL 요청을 받게 된다.
Filter란, HTTP 요청과 응답을 변경할 수 있는 재사용 가능한 코드로, 클라이언트가 서버로 요청을 하게 되면 가장 먼저 Servlet Filter를 거치게 된다. 이 필터를 모두 통과해야지만 디스패처 서블릿으로 요청이 전달된다.
디스패처 서블릿을 통과하고 나서 컨트롤러 까지는 Interceptor와 AOP가 있는데, 이것은 나중에 배우면 다시 정리하겠다.
스프링 시큐리티에서 제공하는 인증, 인가를 위한 이러한 필터들의 모음을 Security Filter Chain 이라고 한다. Filter Chain에는 아래와 같은 Filter들이 있다.
(1) SecurityContextPersistenceFilter
SecurityContextRepository에서 SecurityContext를 로드하고 저장하는 일을 담당
(2) LogoutFilter
로그아웃 URL로 지정된 가상 URL에 대한 요청을 감시하고 매칭되는 요청이 있으면
사용자를 로그아웃시킴
(3) UsernamePasswordAuthenticationFilter
사용자명과 비밀번호로 이뤄진 폼기반 인증에 사용하는 가상 URL 요청을 감시하고
요청이 있으면 사용자의 인증을 진행함
(4) DefaultLoginPageGeneratingFilter
폼기반 또는 OpenID 기반 인증에 사용하는 가상 URL에 대한 요청을 감시하고
로그인 폼 기능을 수행하는데 필요한 HTML을 생성함
(5) BasicAuthenticationFilter
HTTP 기본 인증 헤더를 감시하고 이를 처리함
(6) RequestCacheAwareFilter
로그인 성공 이후 인증 요청에 의해 가로채어진 사용자의 원래 요청을 재구성하는데
사용됨
(7) SecurityContextHolderAwareRequestFilter
HttpServletRequest를 HttpServletRequestWrapper를 상속하는 하위 클래스
(SecurityContextHolderAwareRequestWrapper)로 감싸서 필터 체인상 하단에 위치한
요청 프로세서에 추가 컨텍스트를 제공함
(8) AnonymousAuthenticationFilter
필터가 호출되는 시점까지 사용자가 아직 인증을 받지 못했다면 요청 관련 인증
토큰에서 사용자가 익명 사용자로 나타나게 됨
(9) SessionManagementFilter
인증된 주체를 바탕으로 세션 트래킹을 처리해 단일 주체와 관련한 모든 세션들이
트래킹되도록 도움
(10) ExceptionTranslationFilter
보호된 요청을 처리하는 동안 발생할 수 있는 기대한 예외의 기본 라우팅과 위임을
처리함
(11) FilterSecurityInterceptor
권한부여와 관련한 결정을 AccessDecisionManager에게 위임해 권한부여 결정 및
접근 제어 결정을 쉽게 만들어 줌
1. 사용자가 로그인을 시도(요청)한다.
2. AuthenticationFilter (UsernamePasswordAuthenticationFilter)에서 전달 받은
Username과 password를 가지고 UsernameAuthenticationToken 을 생성하여 발급한다.
3. 발급받은 인증 객체를 인증매니저 (AuthenticationManger : 인증 담당 역할)에 전달한다.
4. 인증매니저는 받아온 인증 객체를 AuthenticationProvider에 전달한다. 이 때 이
AuthenticationProvider는 유효성 검증을 하기 위해 전달받은 인증 객체의
정보를 UserDetailsService에 전달한다.
5. UserDetailsService는 전달받은 사용자 정보를 통해 실제 저장 계층(DB)을 확인하여
알맞은 사용자를 찾고, 이를 기반으로 UserDetails를 구현한 객체를 반환한다.
6. 반환한 UserDetails 객체를 AuthenticationProvider 에 전달한다.
7. AuthenticationProvider 는 전달받은 UserDetails 를 인증하여, 인증에 성공하면
AuthenticationManager 에게 권한을 담은 검증된 인증 객체를 전달한다.
8. AuthenticationManager 는 검증된 인증 객체를 AuthenticationFilter 에 전달한다.
9. AuthenticationFilter는 인증 정보를 담고 있는 인증 객체(Authentication)를
LoginSuccessHandler로 전송하고, SecurityContextHoler 의 SecurityContext 에
저장한다.
➡ 만약, 로그인한 사용자를 가져오려면❓
Entity명 entity변수명 =
SecurityContextHolder.getContext().getAuthentication().getPrincipal();
➡ 이게 정말 신기한게, 위의 코드대로 가져오면 로그인한 사용자의 정보를 세션에서 알아서
찾아서 불러와준다. 나는 어떤 id를 사용하는 사용자인지 이러한 데이터를 알려주지 않아도
불러와준다는게 너무 신기했다. 왜냐면 메서드 안에 어떠한 곳에도 몇번 사용자를
가져오라는 내용이 없기 때문이다.
10. 로그인 인증이 완료된다.
💻 스프링 시큐리티 실습하기 ( 세션 기반 인증을 통한 로그인 구현 )
스프링 프로젝트를 spring initializer 를 통해 실행시킨다.
pom.xml 파일에 필요한 라이브러리들을 추가해준다. 자바 버전은 11, 스프링 부트 버전은 2.7.13으로 수정 ( 수업 환경 ) 해줬다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
spring:
datasource:
url: jdbc:mysql://77.77.77.110/security
username: test02
password: qwer1234
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
properties:
hibernate:
format_sql: true
show-sql: true
logging:
level:
org.springframework.security: DEBUG
웹 서버를 실행 시킨 뒤 localhost:8080 으로 접속해보면, 아래와 같은 로그인 화면이 자동으로 뜨는 것을 볼 수 있다. 이것은 스프링 시큐리티에서 알아서 만들어서 제공해주는 화면이다.
이제 회원가입 및 로그인 기능을 구현해보겠다. 회원가입 및 로그인 기능을 구현하기 위해서 먼저 Member 엔티티부터 생성한다.
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Member implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
String username;
String password;
String authority;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
기존에 작성했던 엔티티와 다른점이 있다면 UserDetails
라는 사용자의 정보를 담는 인터페이스를 구현하는 점이다. 오버라이드 된 메서드들은 UserDetails
인터페이스를 구현하기 위해 기본으로 생성해야되는 메서드들이다.
1) getUsername() : String 타입 / 계정의 고유한 값을 리턴
2) getPassword() : String 타입 / 계정의 비밀번호를 리턴
3) getAuthorities() : 계정의 권한 목록을 리턴
4) isAccountNonExpired() : boolean 타입 / 계정의 만료 여부 리턴 true ( 만료 안됨 )
5) isAccountNonLocked() : boolean 타입 / 계정의 잠김 여부 리턴 true ( 잠기지 않음 )
6) isCredentialsNonExpired() : boolean 타입 / 비밀번호 만료 여부 리턴 true (만료 안됨)
7) isEnabled() : boolean 타입 / 계정의 활성화 여부 리턴 true ( 활성화 됨 )
public interface MemberRepository extends JpaRepository<Member, Integer> {
// 사용자 정보를 찾을때 username 으로 찾기 위해 메서드 설정
Optional<Member> findByUsername(String username);
}
@Configuration
public class SecurityConfig{
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) {
try {
// 설정을 하나씩 추가해준다.
// csrf().disable() : 크로스 사이트 요청 변조 공격을 방어토록 하는 토큰 발급 기능을 끄는 것
// Why? 프론트와 백엔드 서버를 나눴다고 생각하고 수업을 진행하기 때문에
http.csrf().disable()
// HTTP 요청에 대한 인가 설정을 구성하기 위한 것
.authorizeHttpRequests()
.antMatchers("/login", "/member/signup").permitAll() // 로그인(인증)을 하지 않아도 갈 수 있는 URL 설정
.antMatchers("/member/mypage").hasRole("USER") // 로그인을 하여 "USER라는 권한을 가진 사람" 만 갈 수 있는 URL 설정
.anyRequest().authenticated() // 나머지 모든 기능들은 인증받은 사용자만 갈 수 있도록 설정
.and()
.formLogin().loginProcessingUrl("/member/login") // 로그인을 어디서 실시할지 URL 설정
.defaultSuccessUrl("/member/mypage"); // 로그인 성공 시 자동으로 이동할 URL 설정
return http.build();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
@Service
public class MemberService implements UserDetailsService {
private MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
public void signup(String username, String password) {
if(!memberRepository.findByUsername(username).isPresent()) {
memberRepository.save(Member.builder()
.username(username)
.password(password)
.authority("ROLE_USER")
.build());
}
}
// 사용자가 입력한 username을 DB에서 찾는 역할 수행
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Member> result = memberRepository.findByUsername(username);
Member member = null;
if(result.isPresent()) {
member = result.get();
}
return member; // 입력한 username이 있으면 Member 객체를 UserDetails 로 반환
}
}
@RestController
@RequestMapping("/member")
public class MemberController {
private MemberService memberService;
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
@RequestMapping(method = RequestMethod.POST, value = "/signup")
public ResponseEntity signup (String username, String password) {
memberService.signup(username, password);
return ResponseEntity.ok().body("ok");
}
@RequestMapping(method = RequestMethod.GET, value = "/mypage")
public ResponseEntity mypage() {
return ResponseEntity.ok().body("마이 페이지입니다.");
}
}
여기서 test01
유저로 로그인을 시도해보겠다.
그러면 위와 같이 Error Page가 뜨는데 상태코드를 보면 500이고, 인텔리제이로 가보면 위와 같이 에러 메시지가 뜬 것을 볼것이다.
이것은 스프링 시큐리티에서 회원가입을 할 때 자동으로 비밀번호를 암호화해서 저장하기 때문에, 로그인 시도하려는 비밀번호를 입력해도 실제로 저장된 비밀번호와 달라서 비밀번호를 찾을 수 없다고 에러가 뜬것이다.
따라서, 회원가입 할 때부터 스프링 시큐리티가 쓰는 암호화 방식을 사용해서 저장을 해야만 한다.
10. 암호화 방식 적용하기
1) PasswordEncoderConfig 클래스 생성 ✅
@Configuration
public class PasswordEncoderConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
2) Member Service 클래스 수정하기 ✅
- Password Encoder 객체 생성 및 의존성 주입
- password DB 저장 시 암호화 한 패스워드로 저장토록 설정
@Service
public class MemberService implements UserDetailsService {
private MemberRepository memberRepository;
private PasswordEncoder passwordEncoder; // ✅ 추가 부분
public MemberService(MemberRepository memberRepository, PasswordEncoder passwordEncoder) {
this.memberRepository = memberRepository;
this.passwordEncoder = passwordEncoder; // ✅ 추가 부분
}
public void signup(String username, String password) {
if(!memberRepository.findByUsername(username).isPresent()) {
memberRepository.save(Member.builder()
.username(username)
.password(passwordEncoder.encode(password)) // ✅ 추가 부분
.authority("ROLE_USER")
.build());
}
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Member> result = memberRepository.findByUsername(username);
Member member = null;
if(result.isPresent()) {
member = result.get();
}
return member;
}
}
3) Member 엔티티 클래스 수정 ✅
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Member implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
String username;
String password;
String authority;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// ✅ 추가 부분 ( 부여받은 권한을 반환토록 설정 )
return Collections.singleton((GrantedAuthority) () -> authority);
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
/member/mypage
로 가도록 하여 "마이 페이지입니다."
가 출력되도록 설정해줬는데, username : test03 으로 로그인을 다시 시도했더니 아래와 같이 로그인에 성공하여 설정한대로 출력되는 것을 확인할 수 있었다. @Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Member> result = memberRepository.findByUsername(username);
Member member = null;
if(result.isPresent()) {
member = result.get();
return member;
}
}
AuthenticationProvider
인터페이스에 있는 DaoAuthenticationProvider
클래스로 가보면 패스워드를 검증 하는 코드를 아래와 같이 확인할 수 있다. protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (authentication.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
} else {
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
}
오늘의 느낀점 👀
오늘 드디어 로그인 기능을 구현해봤는데, 내일 마지막으로 반환하는 형태를 JSON 형태로 반환하는것을 배울 예정이다. 그러면 세션 기반 인증의 로그인 방식은 다 배운것이라고 하셨다.
다시 한번 느끼지만 스프링 부트 자체에는 많은 기능들이 편리하게 사용될 수 있도록 구현되어 있다는 것을 느꼇다. 하지만 결국 이 기능들을 사용함에 있어서 최소한 어떤 역할을 하는지 이해하고 사용하는 것이 중요하다고 생각한다.
어제와 같이 알고나면 정말 간단한 오류도 몇시간씩 걸려서 해결한거에는 그만큼 내가 이 스프링 부트의 편리한 기능들을 고민없이 사용하고 있다는 점이 아닐까 싶다.