1. 계기

이번에 새로운 프로젝트를 시작하게 되었는데 로그인에 대한 구현이 필요해서 다음과 같이 Spring Security를 사용해서 JWT 방식으로 로그인을 구현해보려고한다.


2. JWT란?

JWT는 JSON Web Token으로 Header, Payload, Signature등으로 나눈 정보를 Base64 URL-safe Encode을 통해서 인코딩해 직렬화 한 방식이다.
사실, 다른 블로그에서 설명을 잘해놨기 때문에 이 정도만 알고 있으면 좋을것 같다.
참고자료


3. 인증방식

인증 방식은 우선 AccessTokenRefreshToken을 사용해서 구현할 예정이다. 그래서 다음 형식을 지닌다.
사실 이 부분은 다른 블로그에도 잘 설명이 되어있기 때문에 참고링크를 통해서 확인해보면 좋을것 같다.

1. 계기

이번에 새로운 프로젝트를 시작하게 되었는데 로그인에 대한 구현이 필요해서 다음과 같이 Spring Security를 사용해서 JWT 방식으로 로그인을 구현해보려고한다.


2. JWT란?

JWT는 JSON Web Token으로 Header, Payload, Signature등으로 나눈 정보를 Base64 URL-safe Encode을 통해서 인코딩해 직렬화 한 방식이다.
사실, 다른 블로그에서 설명을 잘해놨기 때문에 이 정도만 알고 있으면 좋을것 같다.
참고자료


3. 인증방식

인증 방식은 우선 AccessTokenRefreshToken을 사용해서 구현할 예정이다. 그래서 다음 형식을 지닌다.
사실 이 부분은 다른 블로그에도 잘 설명이 되어있기 때문에 참고링크를 통해서 확인해보면 좋을것 같다.


다음과 같은 로직으로 작동하게 만드려고한다. 여기서 세부적으로 설명해야할 부분들을 설명하도록 하겠다.

3-1. Refresh Token의 사용이유

이번 프로젝트에서 고민했던 것이 Refresh 토큰을 사용하는 부분에서 고민이 많았다.

여러가지 자료들을 참고했지만, 살짝씩 다른 말들뿐이었다. 그래서 소신대로 해보자라고 생각하고 다음처럼 구성을 했다.

우선 설명하기 전에 Refresh Token왜 생겨났냐?에 대한 이야기를 해보려고한다. 우선 각 토큰에는 만료시간이라는 것이 존재해, 이 만료시간이 끝난다면, 토큰은 더 이상 사용하지 못한다는 점이 존재한다.

그래서, 보안을 위해서 보통 Access Token의 만료시간을 짧게 주고 자주 재발급 받는 형식을 사용을한다. 하지만 여기에서 문제점이 생긴다. Access Token만 존재한다면 재발급 받는 방법은, 다시 로그인을 하는 방법 뿐이다.

사용자들은 자주 로그인 하는것을 싫어하기 때문에 UX적인 측면을 고려하지 못할 수도 있다. 그렇다고, 만료시간을 길게 잡는다면 만료시간이 길어져서 공격자가 토큰을 탈취하게 된다면, 공격자인지 사용자인지 서버는 모르게되고 사용자의 정보는 탈취당하게 된다.

그래서 Refresh Token이 등장하게 되었다. Refresh Token과 Access Token을 같이 로그인을 할때 발급을 하는 방식을 사용하려고 한다.

이런 Refresh Token을 관리하는 방식도 여러가지가 있다 크게는 다음과 같이 운영하는것 같다.

  1. Local Storage에 프론트가 저장을 한다.
  2. Cookie를 통해서 Refresh Token을 저장한다 (HttpOnly 속성을 넣어서)
  3. 서버가 Refresh Token을 관리한다.

결론적으로

  • Access Token의 만료시간이 길면, 탈취당하고 공격자에 의해 이용당할 수 있음
  • Access Token의 만료시간이 짧으면, UX 측면에서 사용자는 짧은 시간주기로 계속해서 재로그인을 해야함

3-2 Front에서 Local storage에 저장해서 사용하는 방식

XSS 공격등으로, 탈취당할 문제점이 존재합니다. 하지만, 제일 편한 방식입니다.
또한, LocalStorage는 자바스크립트로도 접근이 가능합니다.

window.localStorage.getItem();

3-3 Front에서 Cookie

HttpOnlySecure이나 이런 정보를 넣어 LocalStorage 보다는 안전하게 저장을 할 수 있습니다.
하지만, 이것도 CSRF공격등으로 탈취당할 수 있습니다.

3-4 Server에서 저장하는 방식

Session 또는 DB에 저장할 수 있습니다. 하지만, 이 방식은 Server에 무리를 일으킨다는 단점이 존재합니다. 그래서 현재는 Redis같은 In-Memory DB 방식을 사용해서 사용을 합니다.

4. 프로젝트에 결정한 방식

프로젝트에서는 간단하게 Cookie방식을 통한 방법을 고려하기로 했습니다.
당연히 Security는 안전하게 할 수록 좋지만, 어떤 방식이던지 취약점은 존재하기 때문입니다.
100% 막을 수 있는 보안방법은 없습니다. 또한, 각 방식마다 장단점이 존재하기 때문에 결론적으로는 CookieHttpOnlySecure Flag를 추가해서 배포하기로 마음먹었습니다.


5. 아니 그래서 HttpOnly랑 Secure가 뭔데?

계속해서 HttpOnlySecure를 넣어서 안전하게 사용할것이다라고 하는데 그래서 이게 뭔지에 대한 설명이 적은것 같다.

우선, HttpOnly는 자바스크립트로 쿠키를 조회하는 것을 막는 옵션이라고 보면 좋다.
그리고, Secure는 HTTPS로 통신하는 경우에만 쿠키를 서버로 전송하는 옵션이라고 보면 좋다.

자세한 사항이 궁금하면 해당 링크를 참고하면 좋을것 같다.

6. Refresh Token에 대해서 검증하고 동작하는 로직 결정

내가 가장 고민했던 방식에 대해서 설명하려고 한다.

다른 블로그나 참고자료에서는 이 부분에 대해서 설명한 곳이 별로 없거니와, 사람마다 차이점이 존재하기 때문에 그래서 소신대로 라는 말을 붙여서 제목에 의미를 더했다.

검증 방법은 다음과 같은 종류를 생각했다.

첫 번째로 Access Token에 문제가 있는 경우는 다음과 같은 경우가 있다고 판단하였습니다.

  1. Authorized 헤더에 Access Token 값이 없거나 헤더에 대한 정보가 없는 경우
  2. Access Token이 유효하지 않은 토큰인 경우
  3. Access Token의 시간이 만료된 경우

마지막으로는 Refresh Token에 대해서 문제가 있는 상황은 다음과 같은 경우를 생각했습니다.

  1. Access Token이 만료되지 않았을 때 Refresh Token을 사용해 재발급 받는 경우는 해당 공격자가 탈취를 한경우라고 판단하여 두 토큰을 모두 만료시키는 방식으로 하였습니다.
  2. Refresh Token이 Cookie에 존재하지 않는경우
  3. Refresh Token으로 DB에 유저정보를 검색하는데, 이 토큰을 가지고 있는 사용자가 없는 경우
  4. Refresh Token의 유효시간이 만료된 경우
  5. Refresh Token이 유효하지 않은 토큰인 경우

다음과 같은 상황을 검증하려고 합니다.


7. 마지막으로

이제 다음 부터는 직접 Spring Security를 써서 어떻게 구축을 했는지를 확인하려고 합니다.
다음과 같은 로직으로 작동하게 만드려고한다. 여기서 세부적으로 설명해야할 부분들을 설명하도록 하겠다.

3-1. Refresh Token의 사용이유

이번 프로젝트에서 고민했던 것이 Refresh 토큰을 사용하는 부분에서 고민이 많았다.

여러가지 자료들을 참고했지만, 살짝씩 다른 말들뿐이었다. 그래서 소신대로 해보자라고 생각하고 다음처럼 구성을 했다.

우선 설명하기 전에 Refresh Token왜 생겨났냐?에 대한 이야기를 해보려고한다. 우선 각 토큰에는 만료시간이라는 것이 존재해, 이 만료시간이 끝난다면, 토큰은 더 이상 사용하지 못한다는 점이 존재한다.

그래서, 보안을 위해서 보통 Access Token의 만료시간을 짧게 주고 자주 재발급 받는 형식을 사용을한다. 하지만 여기에서 문제점이 생긴다. Access Token만 존재한다면 재발급 받는 방법은, 다시 로그인을 하는 방법 뿐이다.

사용자들은 자주 로그인 하는것을 싫어하기 때문에 UX적인 측면을 고려하지 못할 수도 있다. 그렇다고, 만료시간을 길게 잡는다면 만료시간이 길어져서 공격자가 토큰을 탈취하게 된다면, 공격자인지 사용자인지 서버는 모르게되고 사용자의 정보는 탈취당하게 된다.

그래서 Refresh Token이 등장하게 되었다. Refresh Token과 Access Token을 같이 로그인을 할때 발급을 하는 방식을 사용하려고 한다.

이런 Refresh Token을 관리하는 방식도 여러가지가 있다 크게는 다음과 같이 운영하는것 같다.

  1. Local Storage에 프론트가 저장을 한다.
  2. Cookie를 통해서 Refresh Token을 저장한다 (HttpOnly 속성을 넣어서)
  3. 서버가 Refresh Token을 관리한다.

결론적으로

  • Access Token의 만료시간이 길면, 탈취당하고 공격자에 의해 이용당할 수 있음
  • Access Token의 만료시간이 짧으면, UX 측면에서 사용자는 짧은 시간주기로 계속해서 재로그인을 해야함

3-2 Front에서 Local storage에 저장해서 사용하는 방식

XSS 공격등으로, 탈취당할 문제점이 존재합니다. 하지만, 제일 편한 방식입니다.
또한, LocalStorage는 자바스크립트로도 접근이 가능합니다.

window.localStorage.getItem();

3-3 Front에서 Cookie

HttpOnlySecure이나 이런 정보를 넣어 LocalStorage 보다는 안전하게 저장을 할 수 있습니다.
하지만, 이것도 CSRF공격등으로 탈취당할 수 있습니다.

3-4 Server에서 저장하는 방식

Session 또는 DB에 저장할 수 있습니다. 하지만, 이 방식은 Server에 무리를 일으킨다는 단점이 존재합니다. 그래서 현재는 Redis같은 In-Memory DB 방식을 사용해서 사용을 합니다.

4. 프로젝트에 결정한 방식

프로젝트에서는 간단하게 Cookie방식을 통한 방법을 고려하기로 했습니다.
당연히 Security는 안전하게 할 수록 좋지만, 어떤 방식이던지 취약점은 존재하기 때문입니다.
100% 막을 수 있는 보안방법은 없습니다. 또한, 각 방식마다 장단점이 존재하기 때문에 결론적으로는 CookieHttpOnlySecure Flag를 추가해서 배포하기로 마음먹었습니다.


5. 아니 그래서 HttpOnly랑 Secure가 뭔데?

계속해서 HttpOnlySecure를 넣어서 안전하게 사용할것이다라고 하는데 그래서 이게 뭔지에 대한 설명이 적은것 같다.

우선, HttpOnly는 자바스크립트로 쿠키를 조회하는 것을 막는 옵션이라고 보면 좋다.
그리고, Secure는 HTTPS로 통신하는 경우에만 쿠키를 서버로 전송하는 옵션이라고 보면 좋다.

자세한 사항이 궁금하면 해당 링크를 참고하면 좋을것 같다.

6. Refresh Token에 대해서 검증하고 동작하는 로직 결정

내가 가장 고민했던 방식에 대해서 설명하려고 한다.

다른 블로그나 참고자료에서는 이 부분에 대해서 설명한 곳이 별로 없거니와, 사람마다 차이점이 존재하기 때문에 그래서 소신대로 라는 말을 붙여서 제목에 의미를 더했다.

검증 방법은 다음과 같은 종류를 생각했다.

첫 번째로 Access Token에 문제가 있는 경우는 다음과 같은 경우가 있다고 판단하였습니다.

  1. Authorized 헤더에 Access Token 값이 없거나 헤더에 대한 정보가 없는 경우
  2. Access Token이 유효하지 않은 토큰인 경우
  3. Access Token의 시간이 만료된 경우

마지막으로는 Refresh Token에 대해서 문제가 있는 상황은 다음과 같은 경우를 생각했습니다.

  1. Access Token이 만료되지 않았을 때 Refresh Token을 사용해 재발급 받는 경우는 해당 공격자가 탈취를 한경우라고 판단하여 두 토큰을 모두 만료시키는 방식으로 하였습니다.
  2. Refresh Token이 Cookie에 존재하지 않는경우
  3. Refresh Token으로 DB에 유저정보를 검색하는데, 이 토큰을 가지고 있는 사용자가 없는 경우
  4. Refresh Token의 유효시간이 만료된 경우
  5. Refresh Token이 유효하지 않은 토큰인 경우

다음과 같은 상황을 검증하려고 합니다.


7. 마지막으로

이제 다음 부터는 직접 Spring Security를 써서 어떻게 구축을 했는지를 확인하려고 합니다.

'Spring > 스프링 시큐리티' 카테고리의 다른 글

스프링 시큐리티 인 액션 (2)  (1) 2023.01.31
스프링 시큐리티 인 액션 (1)  (0) 2023.01.31

스프링 시큐리티의 인증 구현

  • 우리는 사용자 관리를 위해서 UserDetailsServiceUserDetailsManager 인터페이스를 이용합니다.
  • UserDetailsService
    • 사용자의 이름으로 사용자를 검색하는 역할을 함
  • UserDetailsManager
    • 대부분의 애플리케이션에 필요한 사용자 추가, 수정, 삭제 작업을 함

→ 이렇게 인터페이스를 분리해서 유연성을 향상시켰음

사용자 기술하기

UserDetails 인터페이스

  • UserDetails 인터페이스는 다음과 같은 메서드로 이루어져있다. 우리는 이를 구현할 예정이다.
    • getPassword() : 사용자의 패스워드를 반환한다.
    • getUsername() : 사용자의 이름을 반환한다.
    • getAuthorities() : 사용자에게 부여된 권한의 그룹을 반환한다.
    • 나머지들은 사용자가 애플리케이션의 리소스에 접근할 수 있도록 권한을 부여하는 메서드이다.
      • 계정 만료
      • 계정 잠금
      • 자격 증명 만료
      • 계정 비활성화
      • 우리는 이들을 이용해서 true 를 반환하게 재정의 하여, 사용자에 대한 제한을 둘 수 있습니다.
        • 기능을 구현할 필요가 없다면 모두 true 를 반환하게 하면 됩니다.

GrantedAuthority 인터페이스

  • 스프링 시큐리티에서는 GrantedAthority 를 통해서 인터페이스로 권한을 나타냅니다.

  • 권한을 만들기 위해서는 해당 이용 권리의 이름을 찾으면 됩니다.
  • 우리는 부여한 이름을 바탕으로 권한 부여 규칙을 작성합니다.
  • 보통은 람다식을 통해서 이러한 권한을 만듭니다 혹은 SimpleGrantedAuthority 클래스를 이용합니다.
    • 이런 람다식을 사용할때는 @FunctionalInterface 어노테이션으로 인터페이스가 함수형인것을 지정하는것이 좋습니다.
GrantedAuthority g1 = () -> "READ";
GrantedAuthority g2 = new SimpleGrantedAuthority("READ");

각각의 구현을 작성

  • 첫 번째로는 UserDetails 에 대한 구현을 작성해보도록 하겠습니다.
package com.example.example1;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;

public class DummyUser implements UserDetails {
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(() -> "READ");
    }

    @Override
    public String getPassword() {
        return "12345";
    }

    @Override
    public String getUsername() {
        return "haechan";
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}
  • getAuthorities() 에서는 람다식을 통해 READ 를 반환하도록 만듭니다.
  • getUsername()getPassword() 에는 원하는 값을 반환하게 만듭니다.
    • 하지만, 실질적인 User는 다음과 같이 생성자를 통해서 값을 받고 반환하도록 만들어야합니다.

  • 나머지는 모두 true 를 반환하도록 합니다.
  • 이러한 것을 통해서 UserDetail 인스턴스를 생성할 수 있습니다.
User.UserBuilder builder1 = User.withUsername("haechan");
        UserDetails u1 = builder1.password("12345")
                .authorities("read", "write")
                .passwordEncoder(p -> encode(p))
                .accountExpired(false)
                .disabled(true)
                .build();
  • 우리는 이러한 User 를 Entity를 통해서 저장한다고 한다. 그러면 어떻게 해야할까?
    • Entity에 UserDetails 를 구현하는 방법이있다. → 이는 잘못된 사례다 Entity에 여러가지 관계도 추가되오 인터페이스의 메서드도 구현해야하기 떄문에 많이 복잡해진다.
    • 깔끔하게 책임을 가지는것은 Entity를 생성하고 UserDetails 를 구현하는 새로운 클래스에 맴버변수로 엔티티를 가지는것이 가장 좋은 코드라고 볼 수 있다.
    • ublic class DummyUser implements UserDetails { private final User user; public DummyUser(User user) { this.user = user; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return List.of(() -> user.getAuthority()); } @Override public String getPassword() { return this.user.getPassword(); } @Override public String getUsername() { return this.user.getUsername(); } // 나머지 값들 }

스프링 시큐리티가 사용자를 관리하는 방법 지정

  • 스프링 시큐리티는 새 사용자를 추가하거나, 사용자를 관리 할때, UserDetailsService 라는 특정 구성 요소로 인증 프로세스가 사용자의 관리를 위임합니다. 그래서 우리는 이를 구현하는 다양한 방법을 사용할 것 입니다.
  • 그중 JdbcUserDetailsManager 를 이용해서 예제 프로젝트를 작성할 것이다.

UserDetailsService 인터페이스

  • 인증구현은 loadUserByUsername 메서드를 호출해서 주어진 사용자 이름을 가진 사용자의 세부정보를 얻을 수 있다.
    • 만약, 존재하지 않는다면 UsernameNotFoundException 이 발생하게 된다.
    • 이는 RuntimeException 이다.

 

UserDetailsService의 계약 구현

  • 이제 UserDatailsServiceInMememoryDetailsManager 를 사용해서 구현해보도록 하겠습니다.
package com.example.example1;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;

public class User implements UserDetails {
    private final String username;
    private final String password;
    private final String authority;

    public User(String username, String password, String authority) {
        this.username = username;
        this.password = password;
        this.authority = authority;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(() -> authority);
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @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 를 정의를 하고 이제 인터페이스를 구현해봅시다.
package com.example.example1;

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

import java.util.List;

public class InMemoryUserDetailsService implements UserDetailsService {

    private final List<UserDetails> users;

    public InMemoryUserDetailsService(List<UserDetails> users) {
        this.users = users;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return users.stream()
                .filter(user -> user.getUsername().equals(username))
                .findFirst()
                .orElseThrow(
                        () -> new UsernameNotFoundException("유저를 찾을 수 없습니다.")
                );
    }
}
  • 이제 구성 클래스를 빈으로 추가하고 사용자를 등록해보도록 하겠습니다
package com.example.example1;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

import java.util.List;

@Configuration
public class ProjectConfig {

    @Bean
    public UserDetailsService userDetailsService() {
        UserDetails user = new User("haechan", "12345", "read");
        List<UserDetails> users = List.of(user);
        return new InMemoryUserDetailsService(users);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
}
  • 이후 실행을 해보면 제대로 나오는 것을 알 수 있다.

UserDetailsManager 인터페이스 구현

  • 이제 UserDetailsManager 라는 유저의 수정, 삭제, 추가 등을 관리하는 인터페이스를 구현하는 방법을 살펴보겠습니다.

  • 우리가 위에서 사용한 InMemoryUserDetailsManagerUserDetailsManager 를 구현한 것 입니다. 이제 우리는 JdbcUserDetailsManager를 이용하도록 하겠습니다.

JdbcUserDetailsManager

  • 이는 SQL 데이터베이스에 저장된 사용자를 관리하고, JDBC를 이용해서 데이터베이스에 직접 연결을합니다.

  • 우선 테이블가 데이터를 넣습니다.
    • 외래키 관련은 테스트를 위함으로 뺴서 작성을 하였습니다.
create table if not exists users(
    id int not null auto_increment,
    username varchar(45) not null,
    password varchar(45) not null,
    enabled int not null,
    primary key(id)
);

create table if not exists authorities(
    id int not null auto_increment,
    username varchar(45) not null,
    authority varchar(45) not null,
    primary key (id)
);

insert ignore into authorities values(NULL, 'haechan', 'write');
insert ignore into users values(NULL, 'haechan', '12345', '1');
  • 이후 사용할 의존성을 추가해준다.
<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.15</version>
            <scope>runtime</scope>
        </dependency>
    </dependencies>
  • 의존성을 설치했으면, db정보를 넣는다.
spring.datasource.username=root
spring.datasource.url=jdbc:mysql://localhost/security?useUnicode=true&serverTimezone=Asia/Seoul
spring.datasource.password=root
spring.sql.init.mode=always
  • mysql 8.0 이상부터는 serverTimezone 설정을 깜박하면, 에러가생긴다.
  • 이제 JdbcUserDetailsManager 를 등록한다.
package com.example.example1;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.provisioning.UserDetailsManager;

import javax.sql.DataSource;
import java.util.List;

@Configuration
public class ProjectConfig {

    @Bean
    public UserDetailsService userDetailsService(DataSource dataSource) {
        return new JdbcUserDetailsManager(dataSource);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
}

  • 이후 실행하면 제대로 된 값이 나오는 것을 알 수 있다.
  • 우리는 이에대한, query 값도 변경이 가능하다. 사실 실생활에서는 username으로 판단할 수도 있지만, email로 판단할수도 있고 여러가지 경우가 존재한다.
@Bean
    public UserDetailsService userDetailsService(DataSource dataSource) {

        String userByUsernameQuery = "SELECT * from users where username = ?";
        String authsByUserQuery = "SELECT * from authorities where username = ?";

        JdbcUserDetailsManager userDetailsManager = new JdbcUserDetailsManager(dataSource);
        userDetailsManager.setUsersByUsernameQuery(userByUsernameQuery);
        userDetailsManager.setAuthoritiesByUsernameQuery(authsByUserQuery);

        return userDetailsManager;
    }
  • 다음처럼, 명시적으로 setter 등을 통해서 query를 변경할 수 있다.

Reference

  • 스프링 시큐리티 인 액션 - 로렌티우 스필카

스프링 시큐리티 공부

사실 프로젝트를 계속하면서 시큐리티를 해야지 해야지 했는데… 제대로 하질 못했던것 같아서 이번에 제대로 하려고 정리하려한다.

그래서 스프링 시큐리티 인 액션 이라는 책의 예제를 바탕으로 까먹지 않도록 계속해서 핵심 내용만 정리하려고 한다.

책을 다 때기까지 화이팅이다!!!

cURL

  • 우리는 HTTP 테스트를 postman등으로 하는 경우도 있지만, 리눅스 혹은 mac 환경이라면 curl 을 통해서도 확인을 할 수 있다.
  • 예제에서 현재는 HelloController 에 다음과 같이 코드를 짠경우입니다.
    • 의존성으로는, Spring-web과 Spring-security를 넣었습니다.
package com.example.example1;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

    @GetMapping("/hello")
    public String hello() {
        return "Hello!";
    }
}
  • 이후 실행하고 확인을 하려고한다면 다음과 같은 명령어로 확인이 가능합니다.
    • curl -v http://localhost:8080/hello

  • 확인을 해보면 401 이 반환이 된것을 알 수 있습니다. 이는 Unauthorized 입니다.
  • 이후 스프링에서 나온 값과 함게 -u attribute를 통해서 접근을 한다면 인증이 됩니다.

  • -u 를 사용한다면, <username>:<password> 문자열을 Base64로 인코딩을 하고 Basic이 붙은 Authorization 헤더의 값으로 보냅니다.

스프링 시큐리티의 인증 프로세스

  • 우리는 다음 그림을 통해서 다음과 같은 사실을 알 수 있습니다.
    • 인증 필터는 인증 요청을 인증 관리자에게 위임하고 응답을 바탕으로 보안 컨텍스트를 구성합니다/
    • 인증 관리자는 인증 공급자를 이용해 인증을 처리합니다
    • 인증 공급자는 인증 논리를 구현합니다
    • 인증 공급자는 사용자 관리 책임을 구현하는 사용자 세부 정보 서비스를 인증 논리에 이용합니다.
    • 인증 공급자는 암호 관리를 구현하는 암호 인코더를 인증 논리에 이용합니다.
    • 보안 컨텍스트는 인증 프로세스 후 인증 데이터를 유지합니다.

→ 우리는 위오아 같은 예시에서 자동으로 구성되는 UserDetailService , PasswordEncoder 라는 빈이 생깁니다.

PasswordEncoder

  • 이러한 passwordEncoder 는 다음과 같은 일을 합니다.
    • 암호를 인코딩합니다.
    • 암호가 기존 인코딩과 일치하는지를 확인합니다.
  • 이때 AuthenticationProvider 는 인증 논리를 정의하고 사용자와 암호의 관리를 위임합니다.
    • 이는 UserDetailsService, PsswordEncoder 에 제공된 기본 구현을 이용해서 구현이 되어있습니다.

기본 구성 재정의

  • 이제 기본적으로 구성되어있던, UserDetailServicePasswordEncoder 에 대해서 재정의를 해서 새로운 방법으로 구성하는 방법을 공부할것 입니다.

UserDetailsService 재정의

  • 이를 재정의 하기 위해서 우선 InMemoryUserDetailManager을 이용해서 구현을 해봅니다.
package com.example.example1;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class ProjectConfig {
    @Bean
    public UserDetailsService userDetailsService() {
        var userDetailsService = new InMemoryUserDetailsManager();

        var user = User.withUsername("haechan")
                .password("12345")
                .authorities("read") // 권한 목록
                .build();

        userDetailsService.createUser(user);

        return userDetailsService;
    }
}
  • 우리는 새로운 haechan이라는 이름을 가진 유저를 read 권한을 가지도록 생성을 했습니다.
    • 이후 createUser 를 통해서 유저를 생성합니다.
    • 이대로만 실행하면 PasswordEncoder 가 비어있으므로, 이도 재정의 해야합니다
     

@Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
  • 이때 사용하는 NoOpPasswordEncoder 는 그냥 암호화하지 않고 기본적인 텍스트로 문자열비교만하는것입니다.

  • 이제 값이 제대로 나오게 됩니다.

엔드포인트 권한 부여로 구성 재정의

  • 앞에서는 user를 생성하고 password를 encoding을 해서 인증을 통과하는 방법을 배웠습니다.
  • 이번에는 엔드포인트 권한을 통해서 어떤 엔드포인트에 접근할 때 인증을 허용하는 방법을 배워보겠습니다.
  • 우리는 이를 통해 WebSecurityConfigureAdapter 클래스를 확장해서 configure(HttpSecurity http) 메서드를 재정의해 권한을 허용시킨다
    • 하지만 현재는 @Deprecated 되어있는 것을 알 수 있다.

  • 이를 해결하기 위해서는 이제, SecurityFilterChain을 Bean으로 등록해서 사용을 하도록 변경이 되었습니다.
@Configuration
public class ProjectConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
        http
                .httpBasic(Customizer.withDefaults())
                .authorizeRequests((authz) -> authz
                        .anyRequest().permitAll());
        return http.build();
    }
}

 

  • 이후 실행을 하면, permitAll() 을 해놓았기 때문에 어떤 요청에도 다음과 같이 자격증명없이 모든 엔드포인트를 접근할 수 있는 것을 볼 수 있습니다.

다른 방법으로 구성을 설정하는 법

  • 우리는 위에서 UserDetailsServicePsswordEncoder를 재정의 했었는데, 이에 대해서 ㄱ동일한 구성을 수행하는 다른 방법도 존재합니다.
  • 두 객체를 빈으로 정의하지 않고 원래는 WebSecurityConfigurerAdapter 클래스를 상속받아 configure(AuthenticationManagerBuilder 로 설정이 가능합니다.
    • 하지만, 현재는 Deprecate 되어있는 상황이라 다음과 같은 코드를 다믕ㅁ으로 변경을 합니다.
/*** 수정 전 **/
package com.example.example1;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter{
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
       var userDetailsService = new InMemoryUserDetailsManager(); // 사용자를 메모리에 저장하기 위한 방식

        var user = User.withUsername("haechan")
                .password("12345")
                .authorities("read")
                .build();

        userDetailsService.createUser(user); // 사용자를 추가함

        auth.userDetailsService(userDetailsService) // configure 메서드에서 UserdetailsService와 PasswordEncoder를 설정
                .passwordEncoder(NoOpPasswordEncoder.getInstance());
    }

}
/** 수정 후 **/
package com.example.example1;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class ProjectConfig {
    @Bean
    public InMemoryUserDetailsManager userDetailsService() {
        var user = User.withUsername("haechan")
                .password("{noop}12345")
                .authorities("read")
                .build();

        return new InMemoryUserDetailsManager(user);
    }
}
  • 다음 링크를 참고하면, 변경된 사항이 존재한다.
    • 비밀번호는 {noop} 을 넣어서 작성하면 NoOpPasswordEncoder로 사용이 가능합니다.
/** 최종 코드 **/ 
package com.example.example1;

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.core.userdetails.User;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class ProjectConfig {
    @Bean
    public InMemoryUserDetailsManager userDetailsService() {
        var user = User.withUsername("haechan")
                .password("{noop}12345")
                .authorities("read")
                .build();

        return new InMemoryUserDetailsManager(user);
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests((authz) -> authz.anyRequest().authenticated())
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }
}
  • 우리는 현재 인-메모리 방식으로 사용자를 구성하였지만, 보통은 데이터베이스에 저장하거나 다른 시스템을 가져와야합니다.
    • 결론적으로 이 방법은 권장하지 않습니다

AuthenticationProvider 구현 재정의

  • 이번은 이 그림에서 인증 관리자를 재정의해서 이번에는 구현해보려고합니다.
  • 우리가 재정의할 AuthenticationProvider 는 인증 논리를 구현하며, AuthenticationManager 에서 요청을 받고 사용자를 찾는 것을 UserDetailsService 에서 암호검증을 PasswordEncoder에 위임을합니다.
package com.example.example1;

import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;

import java.util.Arrays;

@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = authentication.getName();
        String password = String.valueOf(authentication.getCredentials());

        if ("haechan".equals(username) && "12345".equals(password)) {
            return new UsernamePasswordAuthenticationToken(username, password, Arrays.asList());
        }
        throw new AuthenticationCredentialsNotFoundException("authentication에서 오류가 발생했습니다");
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class
                .isAssignableFrom(authentication);
    }
} 
  • 우리는 다음과 같이, “haechan”, “12345”를 username과 password로 받으면, 새로운 UsernamePasswordAuthenticationToken을 생성하고 반환을 합니다.
  • 이후 커스텀하는 Configuration은 다음과 같이 작성을 합니다. (스프링 시큐리티 5부터 변경되었습니다.)
package com.example.example1;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;

@Configuration
@EnableWebSecurity
public class ProjectConfig {

    @Autowired
    private CustomAuthenticationProvider authenticationProvider;

    @Bean
    public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
        AuthenticationManagerBuilder authenticationManagerBuilder = 
                http.getSharedObject(AuthenticationManagerBuilder.class);

        authenticationManagerBuilder.authenticationProvider(authenticationProvider);
        return authenticationManagerBuilder.build();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests((authz) -> authz.anyRequest().authenticated())
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }
}
  • 이 또한, 제대로 되는 것을 볼 수 있습니다.

프로젝트에 여러 구성 클래스 이용

  • 우리는 지금까지 하나의 구성 클래스만 사용을 했지만, 구성 클래스도 책임을 분리하는 것이 좋습니다.
  • 이제 UserManagementConfigWebAuthorizationConfig 로 나누어서 환경을 구성할 예정입니다
    • UserManagementConfigUserDetailsServicePasswordEncoder 의 Bean만 포함을 합니다.
    • 이는, WebSecurityConfigureAdapter를 확장할 수 있기 때문에 두 객체를 빈으로 구성을 했습니다.
      • (현재는 WebSecurityConfigureAdapater 를 상속받지 않습니다. Security 5부터)
package com.example.example1;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class UserManagementConfig {
    @Bean
    public InMemoryUserDetailsManager userDetailsManager() {
        var user = User.withUsername("haechan")
                .password("{noop}12345")
                .authorities("read")
                .build();
        return new InMemoryUserDetailsManager(user);
    }
}
package com.example.example1;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
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.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class WebAuthorizationConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests((authz) -> authz.anyRequest().authenticated())
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }
}

Reference

  • 스프링 시큐리티 인 액션 - 로렌티우 스필카

+ Recent posts