본문 바로가기
웹 개발/Back End

스프링 시큐리티 개념

by L3m0n S0ju 2024. 5. 19.

 

스프링 시큐리티 스터디를 시작하게 되었는데 다음주 발표라서 급하게 만듭니다.

 

단어, 그림 위주로 넣고 발표할 때 자세히 설명할거임 -> 사유: 꽤바쁨

 

 

 

 

 


1. 교재


 

 

 

2. 스프링 시큐리티란?

스프링 시큐리티는 Spring 기반의 애플리케이션에서 보안 기능을 제공합니다.

 

인증 : 사용자의 신원 확인

인가: 사용자의 권한 확인

 

추가적으로 CSRF, XSS, 세션 변조 등 여러가지 웹해킹 공격을 방지해주는 기능을 제공합니다.


 

 

 

3. 왜 스프링 시큐리티를 사용하는가?

 

그냥 JWT 토큰만 사용하는거랑 무슨 차이가 있을까?

 

1. 직접 설정하기 귀찮다.

-> JWT 코드

@Slf4j
@Service
@RequiredArgsConstructor
public class JwtService {

    public static final String ACCESS_TOKEN = "accessToken";
    private final MemberDataRepository memberDataRepository;
    @Value("${jwt.key}")
    private String KEY;

    public String getJwtKey() {
        return KEY;
    }

    public String createAccessToken(Member findMember) {
        Key key = Keys.hmacShaKeyFor(java.util.Base64.getDecoder().decode((KEY)));
        Date now = new Date();
        Date expiration = new Date(now.getTime() + Duration.ofDays(1).toMillis());
        String jws = Jwts.builder()
                .setSubject(findMember.getUid())
                .setIssuedAt(now)
                .setExpiration(expiration)
                .signWith(key)
                .compact();
        return jws;
    }

    public boolean authenticateToken(HttpServletRequest request) {
        String accessToken = request.getHeader("accessToken");
        if (accessToken == null || accessToken.equals("")) {
            throw new JwtTokenNullException();
        }
        byte[] decodeKey = Base64.decodeBase64(getJwtKey());

        // claims 생성
        Jws<Claims> claims = Jwts.parserBuilder()
                .setSigningKey(decodeKey)
                .build()
                .parseClaimsJws(accessToken);

        Date now = new Date();
        if (!now.before(claims.getBody().getExpiration())) {
            throw new UnauthorizedException();
        }

        // 사용자 존재여부 검사
        Optional<Member> findMember = memberDataRepository.findByUid(claims.getBody().getSubject());
        if (!findMember.isEmpty()) {
            return true;
        }
        return false;
    }

    /**
     * 현재 로그인한 사용자 정보가 필요할 때
     */
    public Member findMemberByToken(String accessToken) {
        if (accessToken == null || accessToken.equals("")) {
            throw new UnauthorizedException();
        }

        byte[] decodeKey = Base64.decodeBase64(KEY);
        Jws<Claims> claims = Jwts.parserBuilder()
                .setSigningKey(decodeKey)
                .build()
                .parseClaimsJws(accessToken);

        Optional<Member> findMember = memberDataRepository.findByUid(claims.getBody().getSubject());
        if (findMember.isEmpty()) {
            throw new MemberNonExistException();
        }
        return findMember.get();
    }
}

 

위코드처럼 기존의 JWT로만 로그인을 구현할 때는 직접 토큰을 만드는 함수, 인증하는 함수, 인증 정보와 일치하는 사용자를 가져오는 함수를 따로 만들어줘야 합니다. 하지만 스프링 시큐리티를 사용하면 이런 함수들이 기본적으로 내장되어 있어 따로 만들어 주지 않아도 됩니다.

 

 

 

 

2. 추가적인 보안 기능들을 쓸 수 있다.

 CSRF, XSS, 세션 변조 등 여러가지 웹해킹 공격을 방지해주는 기능을 제공합니다. 이런 기능들을 따로 만들 필요없이 필요하면 가져다 쓰면 됩니다.

 

 

 

 

3. 테스트 할 때 편하다.

 

기존 테스트 코드

 

글쓰기 테스트

    @Test
    @DisplayName("글쓰기")
    void postWrite() throws Exception {
        // given
        Member member = utility.mockSignup("test01");
        String jwt = utility.mockJwt(member);

        PostWriteRequestDto request = new PostWriteRequestDto();
        request.setTitle("test title");
        request.setContent("test content");
        String json = objectMapper.writeValueAsString(request);

        // expected
        mockMvc.perform(post("/auth/post")
                        .contentType(APPLICATION_JSON)
                        .accept(APPLICATION_JSON)
                        .header("accessToken", jwt)
                        .content(json)).andDo(print())
                .andExpect(status().isOk())
                .andDo(document("post-write", requestFields(
                                fieldWithPath("title").description("제목"),
                                fieldWithPath("content").description("내용")),
                        responseFields(fieldWithPath("postId").description("글 번호"))));
    }

 

 

아이디 생성 유틸리티

    public Member mockSignup(String uid) {
        Member member = Member.builder()
                .uid(uid)
                .pwd("test123!")
                .name("james")
                .build();
        return memberDataRepository.save(member);
    }
    
    
    public String mockJwt(Member member) {
        Date now = new Date();
        Date expiration = new Date(now.getTime() + Duration.ofMinutes(30).toMillis()); // 만료기간 30분
        Key key = Keys.hmacShaKeyFor(Base64.getDecoder().decode((KEY)));

        return Jwts.builder()
                .setSubject(member.getUid())
                .setIssuedAt(now) // 발급시간(iat)
                .setExpiration(expiration) // 만료시간(exp)
                .signWith(key) // 사용자 uid
                .compact();
    }

 

jwt로 테스트를 할 때는 테스트에 사용할 사용자를 생성하는 함수 그리고 해당 사용자의 jwt를 생성하는 함수를 따로 만들어서 테스트를 할 때 마다 앞에 넣어줘서 중복되는 코드가 많은 것을 볼 수 있습니다. 

 

 

 

 

 

 

스프링 시큐리티 적용 후

    @Test
    @WithMockUser(username = "test01", roles = "ADMIN")
    public void admin_admin() throws Exception {
        mockMvc.perform(get("/admin"))
                .andDo(print())
                .andExpect(status().isOk());
    }

 

스프링 시큐리티를 적용 하면 @WithMockUser 어노테이션을 통해 로그인할 사용자를 바로 지정할 수 있어서 로그인 후에 동작할 테스트 코드를 쉽게 만들 수 있습니다.

 

 

 


 

4. 스프링 시큐리티 아키텍쳐

 

4-1 AuthenticationManager

 

스프링 시큐리티에서 가장 기본이 되는 AuthenticationManager는 사용자 정보를 인증하는 함수입니다. SecurityContextHoder 안에 SecurityContext안에 Authentication안에 사용자 정보인 Principal를 저장하는 구조입니다.

Authentication 안에는 아래와 같이 3가지 요소가 존재합니다.

 

 

Principal : 사용자 정보

Credentials: 인증 정보 -> ex) 패스워드

GrantAuthorities : 인가 정보 -> ex) USER, ADMIN 권한

 

 

로그인 후 사용자 정보를 가져올 때?

Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();

 

 

 

어떻게?

ThreadLocal

-> 같은 쓰레드 내에서 데이터 공유 가능

-> 사용자들이 많을 텐데 어떻게 해당 인증정보들을 구별할 수 있느냐 물어본다면 -> 한 사용자는 같은 쓰레드를 사용하므로 인증 정보를 공유할 수 있습니다. 만약 비동기로 동작하게 하고 싶으면 중간에서 쓰레드를 연결해주는 필터가 있습니다.

-> WebAsyncManagerIntegrationFilter라는 필터가 중간에서 쓰레드를 연결해주는 역할을 하고 필터의 가장 첫번째에 위치합니다.

 


 

 

 

 

4-2 스프링 시큐리티에서 사용되는 필터

 

 

WebAsyncManagerIntegrationFilter

-> 첫번째 필터

-> 중간에서 쓰레드를 연결해주는 역할

-> 비동기로 동작하는  경우 사용자 데이터를 공유할 수 있도록 도와주는 필터입니다.

-> 비동기로 동작하는 경우에는 같은 함수 안에서 여러개의 쓰레드를 사용하는 경우가 있는데 그러면 SecurityContextHolder를 공유할 수 없습니다. 여기에 설정을 하나 추가해주면 다른 쓰레드 같은 경우에도 SecurityContextHolder를 가져올 수 있습니다.

 

 

 

SecurityContextPersistenceFilter

-> 필터의 두번째에 위치합니다.

-> Http session 캐시에서 인증 값이 있는지 확인하는 필터

-> 사용자가 요청을 하면 해당 jwt 값이 Http session 캐시의 인증 값과 일치하는 지 확인하고 없으면 로그인 페이지로 이동하게끔 합니다.

 

 

HeaderWriterFilter

-> 세번째 필터

-> 응답 헤더에 시큐리티 관련 헤더를 추가해주는 필터

-> XContentTypeOptionalHeaderWriter: 마임 타입 스니팅 방어.

-> XXssProtectionHeaderWriter: 브라우저에 내장된 XSS 필터 적용.

-> CacheControllHeadersWriter: 캐시 히스토리 취약점 방어.

-> HstsHeaderWriter: HTTPS로만 소통하도록 강제.

-> XFrameOptionsHeaderWriter: clickjacking 방어.

 

 

CsrfFilter

-> 4번째 필터

-> CSRF 방지 토큰을 통해 CSRF 공격을 방지해줍니다.

-> JWT 토큰이 있는데 이게 왜 필요해??

 

내가 이해한 내용

-> JWT 토큰은 사용자가 로컬 스토리지나 세션 쿠키에 저장되어 있는데 이것 또한 Csrf 공격의 대상이 될 수 있음

-> CSRF토큰은 로컬 스토리지나 세션 쿠키에 저장되는게 아니라 화면의 form 자체에 숨겨져 있음 

-> ex) 아래 코드와 같이 코드에 적혀서 같이 넘어오기 때문에 공격자 웹사이트가 Csrf 값은 알 수 없음

 

<body>
  <div>
    <form action="/login>
      ...
      로그인 폼 내용
      ...
      <input name="_csrf" type="hidden" value="1b2abc344-78aa-29b9-1287df37fff">
    </form>
  </div>
</body>

 

 

 

UsernamePasswordAuthenticationFilter

-> 폼 인증을 처리하는 시큐리티 필터

-> 사용자가 로그인을 할 때 사용되는 필터입니다.

 

인증정보가 넘어올때 → Principal + Credentials

인증 후 → Principal + GrantAuthorities 를 AuthenticationManager을 통해 저장

 

인증이 완료되면 사용자의 인증 정보는 삭제하고 사용자 정보와 인가 정보를 다음 필터로 넘깁니다.

 

 

FilterSecurityInterceptor

-> 마지막 필터

-> 넘어온 인가 정보 확인하는 필터

-> 인가하는 여러가지 정책이 존재

 

 


4-3 스프링 시큐리티 전체적인 동작 과정

 

AuthenticationManager을 구현한 ProviderManager에는 여러가지 AuthenticationProvider가 있는데 그중에서 스프링 시큐리티는 기본적으로 DaoAuthenticationProvider를 사용합니다. UserDetailsService는 사용자가 커스터마이징 할 수 있는 부분으로 DaoAuthenticationProvider와 같이 연동하여 동작합니다.

 

AccessDecisionManager의 AccessDecisionVoter에는 1개만 통과하면 넘어가는 정책, 다수결을 넘어가면 통과하는 정책, 모두 통과해야 넘어가는 정책이 있는데 스프링 시큐리티에서는 기본적으로 인가 정보는 1개만 통과하면 넘어가는 정책을 사용합니다.

 

댓글