# 개요

이전 글에서 인증/인가를 구현하기 위해 가장 기본적인 User Entity, 회원가입 API, 로그인 API를 구현했다.

이번 글에서는 본격적으로 Spring Security를 도입한 과정을 작성해보겠다.

선 요약

  1. Spring Security 라이브러리 추가
  2. Spring Security Config 작성
    • formLogin disable
    • csrf disable
    • api 및 사용할 url permitAll으로 등록
    • h2 console을 위한 x-frame-options 설정 

 

# Session vs Token

팀 프로젝트 회의를 하면서 내가 Session 방식 대신에 Token 방식을 사용해보자는 제안을 했다. 물론 어짜피 내가 구현해야하는 것이니 팀원들은 별 의견없이 그러자고 했다. (의견을 좀 내주었으면 좋겠다 싶었는데.. ㅜ)

세션 방식과 토큰 방식의 차이점을 잘 모르는 상태로 토큰 방식을 도입하려고 했다. 단순히 "세션 방식은 구현해봤고 토큰 방식은 한 번도 구현해보지 않았으니까 이번 기회에 토큰 방식을 사용하자!"라는 생각으로 구현하려고 했다.

그냥 구현만하고 배경지식이 없으면 안된다고 생각해서 세션 방식과 토큰 방식의 차이도 공부했다.

 

인증/인가 - Session vs Token

# 개요인증/인가를 구현하는 과정에서 세션 방식과 토큰 방식이 있다는 것을 알게되어 정리한다.내용만 정리하고 별도 코드는 작성하지 않는다.지적, 오타 등 환영합니다.. # 인증? 인가?영어로

jino-dev-diary.tistory.com


 

# Spring Security 추가 및 설정

책과 블로그를 참고하여 Spring Security를 추가하고 Config.java를 추가하여 SecurityFilterChain을 작성했다. 그 과정을 코드와 설명으로 작성해보겠다.

 

## Spring Security 추가

책과 블로그를 참고하여 프로젝트를 할 때는 Spring Security Dependency를 추가하고 바로 Config.java를 추가했는데 좀 더 상세한 흐름을 작성해보겠다.

Spring Security Dependency 추가

더보기

build.gradle

// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-security'

 

추가 후 프로젝트를 실행하면 다음과 같이 못보던 로그도 나오고, localhost:8080으로 접속하면 로그인 창이 나온다.

기본 주소로 접속하면 자동으로 로그인 페이지가 나오며 id에 "user", password에 로그에서 제시한 password를 입력하면 메인페이지로 이동한다.

SignUp API를 구현하면서 BCryptPasswordEncoder 빈 주입을 추가했었는데, 이것 때문에 로그인을 시도하면 "Encoded password does not look like BCrypt"라는 로그가 나오면서 로그인이 되지 않는다. 참고하자

 

Spring Security Config 추가

Spring Security에서 기본적인 로그인 페이지를 제공해주긴 하지만, 나는 프론트엔드와 백엔드를 나누어서 UI/UX와 API서버를 따로 구현했기 때문에 사용하지 않을것이다.

그래서 Spring Security에서 제공해주는 로그인 페이지를 사용하지 않기 위해서 Security Config를 작성했다.

더보기

SecurityConfig.java 추가

package com.example.securityexample.global.config;

// 생략...

@Configuration
@EnableWebSecurity
public class SecurityConfig {

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity.formLogin(AbstractHttpConfigurer::disable);

    return httpSecurity.build();
  }

  @Bean
  public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }
}

Spring Security 설정을 사용하기 위해 @EnableWebSecurity를 붙여준다.

Spring Security 기본 제공하는 formLogin을 사용하지 않기위해 disable설정했다.

PasswordEncoderConfig.java에 있던 PasswordEncoder 설정도 SecuriyConfig.java로 옮겼다.

 

다시 기본 주소인 localhost:8080으로 접속하면

로그인 창이 안뜨는 것을 볼 수 있다.

localhost:8080/login으로도 접속해보면

login에 매핑되는 페이지가 없기 때문에 No static resource login 오류가 난다.

 

 

API 요청 권한 오류 수정

내가 Controller에서 작성했던 Login API를 다시 사용하기 위해 Postman으로 테스트를 했지만 403 Forbidden으로 응답이 왔다.

??????

403 Forbidden 이면 권한 오류라는 건데 갑자기 이런게 왜 나왔나..? 찾아보니 Spring Security를 사용하면 각 API마다 권한 설정을 해주어야 한다고 한다.

더보기

SecurityConfig.java의 filterChain()에 권한 관련 설정 추가

package com.example.securityexample.global.config;

// 생략...
public class SecurityConfig {

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity.formLogin(AbstractHttpConfigurer::disable);

    httpSecurity.authorizeHttpRequests(requests -> requests
//      .anyRequest().permitAll()
        .requestMatchers(HttpMethod.POST, "/api/v1/login", "/api/v1/signup").permitAll()
        .requestMatchers("/").permitAll()
    );

    return httpSecurity.build();
  }
  // 생략...
}

anyRequest().permitAll()을 사용해도 되지만 좀 더 명확하게 작성하기 위해 HTTP 메서드와 url 전부 작성해주었다.

그리고 기본 페이지(localhost:8080)도 접근을 허용하기위해 추가해주었다.

하지만 그래도 403 Forbidden으로 응답이 왔다.

프로젝트할 때는 그냥 무지성으로 filterChain()에 설정 작성 해 주고 잘 되는걸 확인했었는데 글을 작성하며 순차적으로 설정을 추가하니까 안되었고 그 이유도 몰랐다.

그래서 팀 프로젝트를 할 때 작성했던 설정들에 대해 검색해보니 권한 설정을 해주었어도 403 응답이 온 이유는 CSRF 설정을 안해주었기 때문이었다.

 

[Spring Security] CORS, CSRF란?

CORS란? CORS란 “Cross-Origin Resource Sharing”의 약자입니다. CORS는 프로토콜인데, 서로 다른 origin일 시 리소스와 상호 작용하기 위해 클라이언트인 브라우저에서 실행되는 스크립트입니다. 예를 들어

jaykaybaek.tistory.com

요약하자면 Spring Security는 기본적으로 CSRF 보호 설정이 되어있기 때문에 클라이언트에서 요청을 보낼 때 CSRF 토큰을 포함해서 요청을 보내지 않으면 안된다. 하지만 내가 API 테스트할 때는 CSRF 토큰을 포함하지 않았기 때문에 계속 403 Forbidden으로 응답이 왔던 것이었다.

따라서 요청시 CSRF 토큰을 포함시키거나, Security FilterChain에 CSRF 보호 기능을 명시적으로 해제하여 이를 해결할 수 있다. 내가 본 블로그들에서도 그래서 CSRF 기능을 해제했던 것이었다. 그냥 무지성으로 따라했으니 몰랐던거였다. 모지리...

더보기

SecurityConfig.java의 filterChain()에 CSRF 기능 해제 설정 추가

package com.example.securityexample.global.config;

// 생략...

public class SecurityConfig {

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity.formLogin(AbstractHttpConfigurer::disable)
        .csrf(AbstractHttpConfigurer::disable);

    // 생략...
  }
  
  // 생략...
}

성공!

 

 

H2 DB 관련 설정 추가

DB를 직접 조회하기 위해 localhost:8080/h2-console로 접속해보니 접속이 안됐다. (/h2-console인 이유는 application-db.yml에 그렇게 설정했기 때문)

역시 403 에러가 발생...

이전 api url 권한을 permitAll()을 통해 모두 허용한 것처럼 h2-console도 등록해주었다.

더보기

SecurityConfig.java의 filterChain()에 권한 설정 추가

package com.example.securityexample.global.config;

// 생략...
public class SecurityConfig {

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    // 생략...

    httpSecurity.authorizeHttpRequests(requests -> requests
        .requestMatchers(HttpMethod.POST, "/api/v1/login", "/api/v1/signup").permitAll()
        .requestMatchers("/h2-console/**").permitAll()
        .requestMatchers("/").permitAll()
    );

    return httpSecurity.build();
  }
  // 생략...
}

그러고 /h2-console로 접속하여 로그인을 해보았더니 이런 화면이 나왔다

???? 이건 또 뭐여

팀 프로젝트를 할 당시에는 H2가 아닌 Mysql이라서 이런 문제를 전혀 몰랐는데, 이 글을 작성하기 위해 H2를 사용하려고 하니 이런 오류가 발생했다.

관련 검색을 해보니 Spring Security를 사용하면 기본적으로 Header의 X-Frame-Options설정이 되어있어서 iframe나 object 요소를 통해 다른 컨텐츠가 embedded 되는것을 방지한다고 한다. H2 콘솔의 UI는 iframe으로 이루어져 있기 때문에 위와 같이 embedded가 되지 않는 것이었다. 

해결 방법은 X-Frame-Options를 disable하거나 same-origin으로 바꾸는 것이다. 나는 same-origin으로 바꿔주었다.

더보기

SecurityConfig.java의 filterChain()에 Header 설정 추가

package com.example.securityexample.global.config;

// 생략...
public class SecurityConfig {

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity.formLogin(AbstractHttpConfigurer::disable)
        .csrf(AbstractHttpConfigurer::disable)
        .headers(frame -> frame.frameOptions(FrameOptionsConfig::sameOrigin));
        
    // 생략...
    );

    return httpSecurity.build();
  }
  // 생략...
}

 

와! 잘 나온다


 

# 결론

이번 글에서 추가 한 내용은 다음과 같다.

  1. Spring Security 라이브러리 추가
  2. Spring Security Config 작성
    • formLogin disable
    • csrf disable
    • api 및 사용할 url permitAll으로 등록
    • h2 console을 위한 x-frame-options 설정 

다음 글에는 Controller 계층에서 처리하던 Login API를 Spring Security의 Filter에서 처리하도록 바꿔보겠다.


 

# 전체 코드

 

GitHub - JinHoooooou/SecurityExample: Spring Security 적용기

Spring Security 적용기. Contribute to JinHoooooou/SecurityExample development by creating an account on GitHub.

github.com

 

# 개요

인증/인가를 구현하는 과정에서 세션 방식과 토큰 방식이 있다는 것을 알게되어 정리한다.

내용만 정리하고 별도 코드는 작성하지 않는다.

지적, 오타 등 환영합니다..


 

# 인증? 인가?

영어로 하면

  • 인증: Authentication
  • 인가: Authorization 

사실 더 헷갈린다..

내가 이해한 것은 다음과 같다.

  • 인증: 로그인
  • 인가: 사용자의 권한 확인 → 권한에 맞는 응답

예를 들어서 얘기해보자면

  • 어떤 서비스의 회원 리스트를 보고싶은데 일반 회원은 활성화된 회원 리스트만 볼 수 있고, 관리자는 탈퇴하거나 정지된 회원들 리스트도 볼 수 있다고 하자.
    • 인증: 로그인을 통해 내가 이 서비스의 회원이라는 것을 인증받는다.
    • 인가: 내 권한을 확인하여 내가 일반 회원이라면 활성화된 회원 리스트만, 관리자라면 모든 회원 리스트를 볼 수 있도록 인가받는다.

 

# 세션

HTTP는 Stateless 프로토콜이다. 즉, 각 요청이 독립적이며, 서버는 이전 요청의 상태를 기억하지 않는다.

  • 하지만 웹 애플리케이션에서는 클라이언트의 상태를 유지해야 하는 경우가 많다. (예를 들어, 로그인 상태나 장바구니 목록 등)

이러한 Stateless 특성을 보완하기 위해 세션이 도입되었다. 세션은 세션 ID를 사용하여 클라이언트가 서버와의 상호작용에서 상태를 유지하는 방법이다.

 

작동 방식

  1. 클라이언트가 로그인에 성공하면 세션을 생성한다.
  2. 생성된 세션은 서버의 어딘가(메모리, 디스크, 데이터베이스)에 저장되고 클라이언트의 브라우저에 쿠키로 저장한다.
  3. 이후 요청에서 브라우저는 쿠키에 세션 ID를 담아서 서버에 함께 보낸다.
  4. 서버는 이 쿠키의 세션 ID를 확인하여 해당 클라이언트의 상태를 유지하고, 그에 맞는 응답을 보내준다.

 

세션을 사용하면 클라이언트의 상태를 지속적으로 유지할 수 있기 때문에 관리에 용이하다는 장점이 있지만 다음과 같은 단점도 있다.

  • 세션을 메모리, 파일 시스템, 데이터베이스 등에 저장하고 관리하고 있어서 사용자가 많아짐에 따라 부하가 발생할 수 있다.
    • 메모리에서 관리하는 경우 파일 시스템이나 데이터베이스에 비해 빠르지만, 휘발성이라는 단점이 있다.
    • 파일 시스템이나 데이터베이스에서 관리하는 경우 I/O가 발생하므로 메모리에서 관리하는것에 비해 느리다.
  • 서버를 여러 대 두고 운영하는 대규모 서비스의 경우 세션 유지가 어렵다.
    • 사용자 요청이 특정 서버에만 할당되도록 설정하거나, 세션을 중앙에서 관리하도록 설정해야 한다.

이러한 단점을 보완하기 위해 토큰을 통한 인증/인가 방식이 도입되었다.


 

# 토큰 (JWT)

서버 입장에서 세션 방식의 부담을 해결하기 위해 도입된 방식으로 클라이언트의 상태를 따로 저장할 필요가 없다.

 

작동 방식

  1. 로그인에 성공하면 토큰을 생성한다.
  2. 토큰은 서버에 저장하지 않고 클라이언트에 전달된다.
  3. 클라이언트는 이후 요청마다 헤더에 이 토큰을 담아서 보낸다.
  4. 서버는 해당 토큰을 검증하여 해당 클라이언트를 확인하고, 그에 맞는 응답을 보내준다.

위 흐름만 보면 세션을 통한 방법과 작동 방식이 크게 다르지 않은 것 같은데, 토큰을 통한 인증/인가 방식은 어떻게 서버에 토큰을 따로 저장하지 않고 클라이언트가 보낸 토큰을 검증할 수 있을까?

 

토큰 구조

토큰은 다음과 같은 구조를 가지고 있으며 암호화 된 3가지 데이터를 이어붙인 형식이다.

{header}.{payload}.{signature}

  1. Header
    • typ: "JWT" 고정
    • alg: 해싱 알고리즘으로 signature를 만드는데 사용된 알고리즘이 지정된다 (HS256 등..)
  2. Payload
    • 토큰에 담을 정보들이 존재하고, 이를 클레임(claim)이라고 한다.
    • 클레임은 세 가지 유형으로 나뉜다.
      • Registered Claims: JWT 표준에서 정의한 클레임으로 iss(발급자), exp(만료 시간), sub(주제), aud(대상자) 등이 포함된다.
      • Public Claims: 충돌 방지를 위해 IANA(Internet Assigned Numbers Authority) JSON 웹 토큰에 등록되거나 URI로 정의된 클레임이다.
      • Private Claims: 클라이언트와 서버 간에 임의로 정의된 클레임이다.
  3. Signature
    • 토큰의 무결성을 검증하는 데 사용된다.
    • 서명은 다음과 같은 과정으로 생성된다.
      • Header와 Payload를 각각 Base64로 인코딩한다.
      • 인코딩된 Header와 Payload를 '.'으로 연결한다.
      • 이 연결된 문자열을 서버 내의 '비밀 키'를 이용하여 Header에서 정의한 알고리즘으로 해싱한다.
      • 해싱한 결과를 Base64 URL로 인코딩하여 서명을 만든다.

토큰이 위와 같은 구조를 가지고 있기 때문에 서버는 클라이언트에게 발급한 토큰을 따로 기억할 필요 없다. 서버는 가지고 있는 '비밀 키'와 JWT 헤더의 알고리즘을 이용해서 해당 토큰을 검증한다. 만약 Payload가 조작되더라도 Signature가 달라지기 때문에 유효하지 않은 것으로 판별할 수 있다.

Signature 값이 일치하고, 만료 기간이 지나지 않았다면 해당 사용자에게 인가를 해준다.

그럼 토큰 방식이 서버에 부담이 줄어드니까 세션 방식 보다 항상 우월하고 항상 좋을까?


 

# 세션 vs 토큰

 

세션

세션은 서버 내에서 관리되기 때문에 서버가 사용자의 상태를 제어할 수 있다. (Stateful)

  • 안정성: 서버측에서 사용자 상태를 관리하기 때문에 보안상 토큰에 비해 안전하다.
    • 한 기기에서만 로그인이 가능한 서비스를 만드는 경우, PC에서 로그인 한 상태의 사용자가 모바일로 다시 로그인을 하면 기존 세션을 종료함으로써 PC에서 로그아웃할 수 있다.
    • 세션 ID가 탈취되더라도 서버측에서 세션을 제거하거는 등 무효화 처리하면 된다.
  • 서버 부담: 서버가 모든 세션을 관리해야하기 때문에 세션 데이터가 증가하면 서버의 부담이 커진다.
    • 세션 저장소를 효율적으로 관리하기 위해 Redis와 같은 In-Memory 데이터베이스를 사용하여 보완할 수 있다.
  • 확장성: 서버가 확장되면 세션 관리가 어려워 진다.
    • 로드 밸런싱을 통한 스티키 세션이나 중앙 세션 저장소를 사용하여 모든 서버가 동일한 세션 정보를 공유하도록 하여 보완할 수 있다.

 

토큰

토큰은 서버에 저장되지 않기 때문에 사용자의 상태를 제어할 수 없다. (Statelsss)

  • 안정성: 서버에서 토큰 정보를 별도로 관리하지 않기 때문에 보안상 안전하지 않을 수 있다.
    • 토큰이 탈취되더라도 서버 측에서 토큰을 무효화할 방법이 없다.
    • 짧은 만료 시간의 Access Token과 별도로 저장하는 Refresh Token을 도입하여 보완할 수 있다.
  • 서버 부담: 서버가 클라이언트의 정보를 보관하지 않기 때문에 세션에 비해 서버의 부담이 줄어든다.
  • 확장성: 서버 간 상태를 공유할 필요가 없기 때문에 세션에 비해 서버 확장에 용이하다.

# 결론

세션과 토큰 방식은 각각 장단점이 있다. 세션 방식은 서버가 사용자의 상태를 제어할 수 있고 보안상 안전하지만 확장성이 떨어진다. 반면 토큰 방식은 서버 부담이 적고 확장성이 뛰어나지만 상태 제어가 어렵고 보안상 위험이 존재할 수 있다.

따라서 운영하려는 서비스의 특성에 따라 적절한 방식을 선택하는 것이 중요하다고 할 수 있다. 또한 각 방식의 단점을 보완하기 위한 전략을 도입하여 보다 안전하고 효율적인 인증/인가를 구현할 필요가 있다. 

추후 Sticky Session, Refresh Token에 대한 포스팅 할 수 있으면 하기..

'Etc' 카테고리의 다른 글

IntelliJ와 git bash 연동하기  (0) 2022.01.25
내가 배운 팀 프로젝트 협업 1 - 코딩 컨벤션  (0) 2021.10.17

+ Recent posts