Spring Security 기본 구조 및 암호화 기술

jungseo·2023년 7월 11일
0

Spring

목록 보기
21/23
post-thumbnail

Spring Security

  • 다양한 유형(폼 로그인 인증, 토큰 기반 인증, OAuth 2 기반 인증, LDAP 인증)의 사용자 인증 기능 적용
  • 애플리케이션 사용자의 역할(Role)에 따른 권한 레벨 적용
  • 애플리케이션에서 제공하는 리소스에 대한 접근 제어
  • 민감한 정보에 대한 데이터 암호화
  • 특정 보안 요구 사항을 만족시키기 위한 코드의 커스터마이징이 용이하고 유연한 확장이 가능

보안 영역 주요 개념

  • Principal (주체)
    • 애플리케이션에서 작업을 수행할 수 있는 사용자, 디바이스 또는 시스템 등
    • 일반적으로 인증 프로세스가 성공적으로 수행된 사용자의 계정 정보
  • Authentication (인증)
    • 애플리케이션 사용자가 본인이 맞음을 증명하는 절차
  • Authorization (인가)
    • Authentication이 정상적으로 수행된 사용자에게 하나 이상의 권한(authority)을 부여해 특정 리소스에 접근을 허가하는 과정
    • Authentication 이후 수행되며 일반적으로 역할(Role)의 형태로 부여
  • Credential (신원 증명 정보)
    • Authentication을 위해 필요한 사용자를 식별하기 위한 정보
    • 특정 사이트의 로그인 시 사용하는 패스워드
  • Access Control (접근 제어)
    • 사용자가 어플리케이션의 리소스에 접근하는 행위를 제어하는 것

1. 인메모리 유저를 통한 인증

  • Spring Security의 기본 구조와 동작 방식을 이해를 위해 SSR 방식으로 구현
    • Thymeleaf 템플릿 엔진 사용하여 HTML 뷰 구성

1) Spring Security 적용

  • 의존 라이브러리 추가
implementation 'org.springframework.boot:spring-boot-starter-security'
  • 라이브러리 추가된 상태에서 애플리케이션 실행 후 http://localhost:8080 접속 시 화면
  • Spring Security가 자동구성을 통해 내부적으로 제공해 주는 디폴트 로그인 페이지
    • Username : user
    • Password : 프로그램 실행 시 로그로 출력

2) 인증을 위한 AuthController

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/auths")
public class AuthController {
    @GetMapping("/login-form")
    public String loginForm() {
        return "login";
    }

    @GetMapping("/access-denied")
    public String accessDenied() {
        return "access-denied";
    }

    @PostMapping("/login")
    public String login() {
        System.out.println("Login successfully!");
        return "home";
    }
}

3) Spring Security Configuration(InMemory)

  • SecurityConfiguration 클래스에 Spring Security에서 지원하는 인증과 권한 부여 설정
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfiguration_v2 {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // (1)
        http
                .csrf().disable()
                .formLogin()
                .loginPage("/auths/login-form")
                .loginProcessingUrl("/process_login")
                .failureUrl("/auths/login-form?error")
                .and()
                .logout()
                .logoutUrl("/logout")
                .logoutSuccessUrl("/")
                .and()
                .exceptionHandling().accessDeniedPage("/auths/access-denied")
                .and()
                .authorizeHttpRequests(authorize -> authorize // (2)
                        .antMatchers("/orders/**").hasRole("ADMIN")
                        .antMatchers("/members/my-page").hasRole("USER")
                        .antMatchers("/**").permitAll()
                );
        return http.build();
    }
    
    @Bean
    public InMemoryUserDetailsManager userDetailsService() { // (3)
        UserDetails user =
                User.withDefaultPasswordEncoder()
                        .username("kevin@gmail.com")
                        .password("1111")
                        .roles("USER")
                        .build();

        UserDetails admin =
                User.withDefaultPasswordEncoder()
                        .username("admin@gmail.com")
                        .password("2222")
                        .roles("ADMIN")
                        .build();

        return new InMemoryUserDetailsManager(user, admin);
    }

    @Bean
    public PasswordEncoder passwordEncoder() { // (4)
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}
  • (1) HttpSecurity를 파라미터로 가지고, SecurityFilterChain을 리턴하는 형태의 메서드를 정의하면 HTTP 보안 설정을 구성 가능
  • (2) authorizeHttpRequests(): 람다 표현식을 통해 request URI에 대한 접근 권한을 부여
    • antMatchers(): ant 빌드 툴에서 사용되는 Path Pattern을 이용해서 매치되는 URL을 표현
      • .antMatchers("/orders/**").hasRole("ADMIN")은 ADMIN Role을 부여받은 사용자만 /orders로 시작하는 모든 URL에 접근할 수 있다
      • **은 모든 하위 URL을 포함
      • 더 구체적인 URL 경로부터 접근 권한을 부여한 다음 덜 구체적인 URL 경로에 접근 권한을 부여
  • (3) 사용자의 계정 정보를 메모리상에 지정
    • UserDetails를 관리하는 UserDetailsManager 인터페이스
      • 현재 메모리상에서 UserDetails를 관리하므로 InMemoryUserDetailsManager라는 구현체를 사용
      • UserDetailsManager 객체를 Bean으로 등록하면 Spring에서는 해당 Bean이 가지고 있는 사용자의 인증 정보가 클라이언트의 요청으로 넘어올 경우 정상적인 인증 프로세스를 수행
    • UserDetails 인터페이스는 인증된 사용자의 핵심 정보를 포함
    • UserDetails 구현체인 User 클래스를 이용해서 사용자의 인증 정보를 생성
  • (4) PasswordEncoder는 Spring Security에서 제공하는 패스워드 암호화 기능을 제공하는 컴포넌트
    • 디폴트 암호화 알고리즘은 bcrypt
    • 먼저 생성되는 DelegatingPasswordEncoder가 실질적으로 PasswordEncoder 구현 객체를 생성

4) login.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="layouts/common-layout">
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
        <title>Hello Spring Security Coffee Shop</title>
    </head>
    <body>
        <div class="container" layout:fragment="content">
            <form action="/process_login" method="post">
                <!-- (1) 로그인 실패에 대한 메시지 표시 -->
                <div class="row alert alert-danger center" role="alert" th:if="${param.error != null}">
                    <div>로그인 인증에 실패했습니다.</div>
                </div>
                <div class="row">
                    <div class="col-xs-2">
                        <input type="email" name="username"  class="form-control" placeholder="Email" />
                    </div>
                </div>
                <div class="row" style="margin-top: 20px">
                    <div class="col-xs-2">
                        <input type="password" name="password"  class="form-control" placeholder="Password" />
                    </div>
                </div>

                <button class="btn btn-outline-secondary" style="margin-top: 20px">로그인</button>
            </form>
            <div style="margin-top: 20px">
                <a href="/members/register">회원가입</a>
            </div>
        </div>
    </body>
</html>

5) header.html

<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5"> <!-- (1) -->
    <body>
        <div align="right" th:fragment="header">
            <a href="/members/register" class="text-decoration-none">회원가입</a> |
            <span sec:authorize="isAuthenticated()"> <!-- (2) -->
                <span sec:authorize="hasRole('USER')">  <!-- (3) -->
                    <a href="/members/my-page" class="text-decoration-none">마이페이지</a> |
                </span>
                <a href="/logout" class="text-decoration-none">로그아웃</a>  <!-- (4) -->
                <span th:text="${#authentication.name}">홍길동</span><!-- (5) -->
            </span>

            <span sec:authorize="!isAuthenticated()"> <!-- (6) -->
                <a href="/auths/login-form" class="text-decoration-none">로그인</a> |
            </span>
        </div>
    </body>
</html>
  • (1) 타임리프 기반의 HTML 템플릿에서 사용자의 인증 정보나 권한 정보를 이용해 어떤 로직을 처리하기 위해서는 먼저 sec 태그를 사용하기 위한 XML 네임스페이스를 지정
    • implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5' 의존 라이브러리 추가
  • (2) sec:authorize="isAuthenticated()": 현재 페이지에 접근한 사용자가 인증에 성공한 사용자인지를 체크
    • isAuthenticated()의 값이 true이면 태그 하위에 포함된 콘텐츠를 화면에 표시
  • (3) sec:authorize="hasRole('USER')": USER Role을 가진 사용자에게만 표시되도록 함
  • (4) 만약 (2)가 true라면 [로그인] 대신에 [로그아웃] 표시
    • href="/logout"에서 “/logout” URL은 SecutiryConfiguration 클래스에서 설정한 값과 같아야 함
  • (5) th:text="${#authentication.name}": 로그인한 username 표시
  • (6) sec:authorize="!isAuthenticated()": 로그인한 사용자가 아니라면 [로그인] 버튼 표시

6. JavaConfiguration 구성

import com.codestates.member.InMemoryMemberService;
import com.codestates.member.MemberService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.UserDetailsManager;

@Configuration
public class JavaConfiguration {
    @Bean
    public MemberService inMemoryMemberService(UserDetailsManager userDetailsManager, 
                                               PasswordEncoder passwordEncoder) {
        return new InMemoryMemberService(userDetailsManager, passwordEncoder);
    }
}
  • MemberService 인터페이스의 구현 클래스인 InMemoryMemberService를 Spring Bean으로 등록
  • 데이터베이스 연동 없이 메모리에 Spring Security의 User를 등록해야 하므로 UserDetailsManager 객체가 필요
  • User 등록 시, 패스워드를 암호화한 후에 등록해야 하므로 Spring Security에서 제공하는 PasswordEncoder 객체가 필요

7. InMemory User 등록을 위한 MemberService 클래스

  • 인메모리에서 데이터베이스 연결로 바꾸기 위해 createMember() 메서드만 선언해둔 MemberService 인터페이스 사용
  • 메모리에 Spring Security User를 등록해 주는 InMemoryMemberService 클래스
import com.codestates.auth.utils.AuthorityUtils;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.UserDetailsManager;

import java.util.List;

public class InMemoryMemberService implements MemberService {
    private final UserDetailsManager userDetailsManager;
    private final PasswordEncoder passwordEncoder;

    public InMemoryMemberService(UserDetailsManager userDetailsManager, PasswordEncoder passwordEncoder) {
        this.userDetailsManager = userDetailsManager;
        this.passwordEncoder = passwordEncoder;
    }

    public Member createMember(Member member) {
        // (1)
        List<GrantedAuthority> authorities = createAuthorities(Member.MemberRole.ROLE_USER.name());

        // (2)
        String encryptedPassword = passwordEncoder.encode(member.getPassword());

        // (3)
        UserDetails userDetails = new User(member.getEmail(), encryptedPassword, authorities);

        // (4)
        userDetailsManager.createUser(userDetails);

        return member;
    }

    private List<GrantedAuthority> createAuthorities(String... roles) {
        // (1-1)
        return Arrays.stream(roles)
                .map(role -> new SimpleGrantedAuthority(role))
                .collect(Collectors.toList());
    }
}
  • (1) User의 권한 목록을 List< GrantedAuthority>로 생성
    • Member 클래스에 MemberRole이라는 enum이 정의, ROLE_USER와 ROLE_ADMIN이라는 enum 타입이 정의
      • Spring Security에서 SimpleGrantedAuthority를 사용해 Role 베이스 형태의 권한을 지정할 때 ‘ROLE_’ + 권한 명 형태로 지정해 주어야 함
    • Stream API를 이용해 생성자 파라미터로 해당 User의 Role을 전달하면서 SimpleGrantedAuthority 객체를 생성한 후, List< SimpleGrantedAuthority> 형태로 리턴
  • (2) PasswordEncoder를 이용해 등록할 User의 패스워드를 암호화
    • 패스워드를 암호화하지 않고 User를 등록한다면 User 등록은 되지만 로그인 인증 시 에러 발생
  • (3) Spring Security User로 등록하기 위해 UserDetails를 생성
    • Spring Security에서 User 정보는 UserDetails로 관리함
  • (4) User를 등록

2. 데이터베이스 연동을 통한 인증

  • Custom UserDetailsService를 이용하여 인증 처리
  • 인메모리 인증을 데이터베이스 기반으로 수정
  • Member 엔티티 클래스를 이용해서 회원의 인증 정보를 포함한 회원 정보를 데이터베이스 테이블에서 관리

1) SecurityConfiguration의 설정 변경 및 추가

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.provisioning.UserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfiguration {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .headers().frameOptions().sameOrigin() // (1)
            .and()
            .csrf().disable()
            .formLogin()
            .loginPage("/auths/login-form")
            .loginProcessingUrl("/process_login")
            .failureUrl("/auths/login-form?error")
            .and()
            .logout()
            .logoutUrl("/logout")
            .logoutSuccessUrl("/")
            .and()
            .exceptionHandling().accessDeniedPage("/auths/access-denied")
            .and()
            .authorizeHttpRequests(authorize -> authorize
                    .antMatchers("/orders/**").hasRole("ADMIN")
                    .antMatchers("/members/my-page").hasRole("USER")
                    .antMatchers("⁄**").permitAll()
            );
        return http.build();
    }
    
// 애플리케이션 실행시 사용자 정보를 지정해주던 코드 삭제

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}
  • (1) 웹 브라우저에서 H2 웹 콘솔을 정상적으로 사용하기 위한 설정
    • frameOptions(): HTML 태그 중에서 < frame>이나 < iframe>, < object> 태그에서 페이지를 렌더링 할지의 여부를 결정하는 기능
    • .frameOptions().sameOrigin(): 동일 출처로부터 들어오는 request만 페이지 렌더링을 허용

2) JavaConfiguration의 Bean 등록 변경

@Configuration
public class JavaConfiguration {
    @Bean
    public MemberService dbMemberService(MemberRepository memberRepository,
                                         PasswordEncoder passwordEncoder) {
        return new DBMemberService(memberRepository, passwordEncoder); (1)
    }
}
  • (1) 내부에서 데이터를 데이터베이스에 저장하고, 패스워드를 암호화하기 위해 MemberRepository와 PasswordEncoder 객체를 DI

3) DBMemberService 구현

import com.codestates.exception.BusinessLogicException;
import com.codestates.exception.ExceptionCode;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

@Transactional
public class DBMemberService implements MemberService {
    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    // (1)
    public DBMemberService(MemberRepository memberRepository,
                             PasswordEncoder passwordEncoder) {
        this.memberRepository = memberRepository;
        this.passwordEncoder = passwordEncoder;
    }

    public Member createMember(Member member) {
        verifyExistsEmail(member.getEmail()); // 중복 이메일 검증 메서드
        String encryptedPassword = passwordEncoder.encode(member.getPassword());  // (2)
        member.setPassword(encryptedPassword);    // (3)

        Member savedMember = memberRepository.save(member);

        System.out.println("# Create Member in DB");
        return savedMember;
    }
    
    ...
    ...
}
  • (1) MemberRepository와 PasswordEncoder Bean 객체 DI
  • (2) PasswordEncoder를 이용해 패스워드를 암호화
  • (3) 암호화된 패스워드를 password 필드에 다시 할당

4) Custom UserDetailsService 구현

  • 데이터베이스에서 조회한 User의 인증 정보를 기반으로 인증을 처리
  • UserDetailsService: Spring Security에서 제공하는 컴포넌트 중 하나로 User 정보를 로드(load)하는 핵심 인터페이스
    • InMemory User를 등록하는 데 사용했던 InMemoryUserDetailsManager는 UserDetailsManager 인터페이스의 구현체
    • UserDetailsManager는 UserDetailsService를 상속하는 확장 인터페이스
import com.codestates.auth.utils.HelloAuthorityUtils;
import com.codestates.exception.BusinessLogicException;
import com.codestates.exception.ExceptionCode;
import com.codestates.member.Member;
import com.codestates.member.MemberRepository;
import org.springframework.security.core.GrantedAuthority;
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.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;

import java.util.Collection;
import java.util.Optional;

@Component
public class HelloUserDetailsServiceV1 implements UserDetailsService {   // (1)
    private final MemberRepository memberRepository;
    private final HelloAuthorityUtils authorityUtils;

    // (2)
    public HelloUserDetailsServiceV1(MemberRepository memberRepository, HelloAuthorityUtils authorityUtils) {
        this.memberRepository = memberRepository;
        this.authorityUtils = authorityUtils;
    }

    // (3)
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<Member> optionalMember = memberRepository.findByEmail(username);
        Member findMember = optionalMember.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));

        // (4)
        Collection<? extends GrantedAuthority> authorities = authorityUtils.createAuthorities(findMember.getEmail());

        // (5)   
        return new User(findMember.getEmail(), findMember.getPassword(), authorities);
    }
}
  • (1) UserDetailsService 인터페이스를 구현
  • (2) 데이터베이스에서 User를 조회하고, 조회한 User의 권한(Role) 정보를 생성하기 위해 MemberRepository와 HelloAuthorityUtils 클래스를 DI
  • (3) UserDetailsService 인터페이스의 추상메서드 구현
  • (4) HelloAuthorityUtils를 이용해 데이터베이스에서 조회한 회원의 이메일 정보를 이용해 Role 기반의 권한 정보(GrantedAuthority) 컬렉션을 생성
  • (5) 데이터베이스에서 조회한 인증 정보와 (4)에서 생성한 권한 정보를 UserDetails 인터페이스의 구현체인 User 클래스의 객체를 통해 Spring Security에 제공
    • 데이터베이스에서 조회한 User 클래스의 객체를 리턴하면 Spring Security가 이 정보를 이용해 인증 절차를 수행

    • 데이터베이스에서 User의 인증 정보만 Spring Security에 넘겨주고, 인증 처리는 Spring Security가 대신 수행

      UserDetails :
      UserDetailsService에 의해 로드(load)되어 인증을 위해 사용되는 핵심 User 정보를 표현하는 인터페이스
      구현체는 Spring Security에서 보안 정보 제공을 목적으로 직접 사용되지는 않고, Authentication 객체로 캡슐화되어 제공

5) HelloAuthorityUtils

  • User 권한을 생성하기 위해 사용
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.stereotype.Component;
import java.util.List;

@Component
public class HelloAuthorityUtils {
    // (1)
    @Value("${mail.address.admin}")
    private String adminMailAddress;

    // (2)
    private final List<GrantedAuthority> ADMIN_ROLES = AuthorityUtils.createAuthorityList("ROLE_ADMIN", "ROLE_USER");

    // (3)
    private final List<GrantedAuthority> USER_ROLES = AuthorityUtils.createAuthorityList("ROLE_USER");
    
    public List<GrantedAuthority> createAuthorities(String email) {
        // (4)
        if (email.equals(adminMailAddress)) {
            return ADMIN_ROLES;
        }
        return USER_ROLES;
    }
}
  • (1) application.yml에 추가한 프로퍼티를 가져오는 표현식
    • @Value("${프로퍼티 경로}")의 표현식 형태로 작성하면 application.yml에 정의되어 있는 프로퍼티의 값을 클래스 내에서 사용 가능
mail:
  address:
    admin: admin@gmail.com
  • (2) AuthorityUtils 클래스를 이용해서 관리자용 권한 목록을 List< GrantedAuthority> 객체로 생성 (일반 유저 권한까지 포함)
  • (3) AuthorityUtils 클래스를 이용해서 일반 사용 권한 목록을 List< GrantedAuthority> 객체로 생성
  • (4) 파라미터로 전달받은 이메일 주소가 application.yml 파일에서 가져온 관리자용 이메일 주소와 동일하다면 관리자용 권한인 List< GrantedAuthority> ADMIN_ROLES를 리턴

6) Custom UserDetails 구현

  • HelloUserDetailsService 클래스에 Custom UserDetails 클래스를 구현해 리팩토링
import com.codestates.auth.utils.HelloAuthorityUtils;
import com.codestates.exception.BusinessLogicException;
import com.codestates.exception.ExceptionCode;
import com.codestates.member.Member;
import com.codestates.member.MemberRepository;
import org.springframework.security.core.GrantedAuthority;
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.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;

import java.util.Collection;
import java.util.Optional;

@Component
public class HelloUserDetailsServiceV2 implements UserDetailsService {
    private final MemberRepository memberRepository;
    private final HelloAuthorityUtils authorityUtils;

    public HelloUserDetailsServiceV2(MemberRepository memberRepository, HelloAuthorityUtils authorityUtils) {
        this.memberRepository = memberRepository;
        this.authorityUtils = authorityUtils;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<Member> optionalMember = memberRepository.findByEmail(username);
        Member findMember = optionalMember.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));

        return new HelloUserDetails(findMember);  // (1) 개선된 부분
    }

    // (2) HelloUserDetails 클래스 추가
    private final class HelloUserDetails extends Member implements UserDetails { // (2-1)
        // (2-2)
        HelloUserDetails(Member member) {
            setMemberId(member.getMemberId());
            setFullName(member.getFullName());
            setEmail(member.getEmail());
            setPassword(member.getPassword());
        }

        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            return authorityUtils.createAuthorities(this.getEmail());  // (2-3) 리팩토링 포인트
        }

        // (2-4)
        @Override
        public String getUsername() {
            return getEmail();
        }

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

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

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

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

}
  • (1) loadUserByUsername() 메서드의 리턴 값으로
    new User(findMember.getEmail(), findMember.getPassword(), authorities); 에서
    new HelloUserDetails(findMember); 로 수정
  • (2) UserDetails 인터페이스를 구현하고 Member 엔티티 클래스를 상속하는 HelloUserDetails 클래스
    • (2-3) User의 권한 정보를 생성하는 코드를 HelloUserDetails 내부로 옮김
    • 데이터베이스에서 조회한 회원 정보를 Spring Security의 User 정보로 변환하는 과정과 User의 권한 정보를 생성하는 과정을 캡슐화
    • (2-4) Spring Security가 인식할 수 있는 username에 Member 클래스의 email을 할당

8) DB에서 User Role 관리

import com.codestates.audit.Auditable;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import javax.persistence.*;
import java.security.Principal;
import java.util.ArrayList;
import java.util.List;

@NoArgsConstructor
@Getter
@Setter
@Entity
public class Member extends Auditable implements Principal{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long memberId;

    @Column(length = 100, nullable = false)
    private String fullName;

    @Column(nullable = false, updatable = false, unique = true)
    private String email;

    @Column(length = 100, nullable = false)
    private String password;

    @Enumerated(value = EnumType.STRING)
    @Column(length = 20, nullable = false)
    private MemberStatus memberStatus = MemberStatus.MEMBER_ACTIVE;

    // (1) User의 권한 정보 테이블과 매핑되는 정보
    @ElementCollection(fetch = FetchType.EAGER)
    private List<String> roles = new ArrayList<>();

					...
                    
    public enum MemberRole {
        ROLE_USER,
        ROLE_ADMIN
    }
}
  • (1) Member 엔티티 클래스와 User 권한 정보 매핑
    • 애플리케이션을 실행시 매핑된 MEMBER_ROLES 테이블 생성

9) 회원 가입 시, User의 권한 정보(Role)를 데이터베이스에 저장

DBMemberService

@Transactional
public class DBMemberService implements MemberService {
    ...
    ...
  
    private final HelloAuthorityUtils authorityUtils;

    ...
    ...

    public Member createMember(Member member) {
        verifyExistsEmail(member.getEmail());
        String encryptedPassword = passwordEncoder.encode(member.getPassword());
        member.setPassword(encryptedPassword);

        // (1) Role을 DB에 저장
        List<String> roles = authorityUtils.createRoles(member.getEmail());
        member.setRoles(roles);

        Member savedMember = memberRepository.save(member);

        return savedMember;
    }

    ...
    ...
}
  • (1) 회원의 권한 정보 생성 후 member 객체에 전달

HelloAuthorityUtils 클래스에 createRoles() 메서드 추가

@Component
public class HelloAuthorityUtils {
    @Value("${mail.address.admin}")
    private String adminMailAddress;

    ...
    ...

    private final List<String> ADMIN_ROLES_STRING = List.of("ADMIN", "USER");
    private final List<String> USER_ROLES_STRING = List.of("USER");

    ...
    ...

    // (1) DB 저장용
    public List<String> createRoles(String email) {
        if (email.equals(adminMailAddress)) {
            return ADMIN_ROLES_STRING;
        }
        return USER_ROLES_STRING;
    }
}

10) 로그인 인증 시, User의 권한 정보를 데이터베이스에서 조회

개선된 HelloUserDetailsService(V3)

@Component
public class HelloUserDetailsServiceV3 implements UserDetailsService {
    private final MemberRepository memberRepository;
    private final HelloAuthorityUtils authorityUtils;

    public HelloUserDetailsServiceV3(MemberRepository memberRepository, HelloAuthorityUtils authorityUtils) {
        this.memberRepository = memberRepository;
        this.authorityUtils = authorityUtils;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<Member> optionalMember = memberRepository.findByEmail(username);
        Member findMember = optionalMember.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));

        return new HelloUserDetails(findMember);
    }

    private final class HelloUserDetails extends Member implements UserDetails {
        HelloUserDetails(Member member) {
            setMemberId(member.getMemberId());
            setFullName(member.getFullName());
            setEmail(member.getEmail());
            setPassword(member.getPassword());
            setRoles(member.getRoles());        // (1)
        }

        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            // (2) DB에 저장된 Role 정보로 User 권한 목록 생성
            return authorityUtils.createAuthorities(this.getRoles());
        }

        ...
        ...
    }

}
  • (1) HelloUserDetails가 상속하고 있는 Member에 데이터베이스에서 조회한 List< String> roles를 전달
  • (2) Member에 전달한 Role 정보를 authorityUtils.createAuthorities() 메서드의 파라미터로 전달해서 권한 목록(List< GrantedAuthority>)을 생성
    • 데이터베이스에서 Role 정보를 가지고 오지 않았을 때
      authorityUtils.createAuthorities(this.getEmail());

createAuthorities() 메서드가 추가된 HelloAuthorityUtils 클래스

@Component
public class HelloAuthorityUtils {
    @Value("${mail.address.admin}")
    private String adminMailAddress;

    private final List<GrantedAuthority> ADMIN_ROLES = AuthorityUtils.createAuthorityList("ROLE_ADMIN", "ROLE_USER");
    private final List<GrantedAuthority> USER_ROLES = AuthorityUtils.createAuthorityList("ROLE_USER");
    private final List<String> ADMIN_ROLES_STRING = List.of("ADMIN", "USER");
    private final List<String> USER_ROLES_STRING = List.of("USER");

    // 메모리 상의 Role을 기반으로 권한 정보 생성.
    public List<GrantedAuthority> createAuthorities(String email) {
        if (email.equals(adminMailAddress)) {
            return ADMIN_ROLES;
        }
        return USER_ROLES;
    }

    // (1) DB에 저장된 Role을 기반으로 권한 정보 생성
    public List<GrantedAuthority> createAuthorities(List<String> roles) {
       List<GrantedAuthority> authorities = roles.stream()
               .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) // (2)
               .collect(Collectors.toList());
       return authorities;
    }

    ...
    ...
}
  • (1) 기존에는 application.yml 파일의 mail.address.admin 프로퍼티에 정의된 관리자용 이메일 주소를 기준으로 관리자 Role을 추가하는 방식에서 데이터베이스에서 가지고 온 Role 목록을 그대로 이용해서 권한 목록을 생성하는 방식으로 수정
  • (2) SimpleGrantedAuthority 객체를 생성할 때 생성자 파라미터로 넘겨주는 값이 “ USER" 또는 “ADMIN"으로 넘겨주면 안 되고 “ROLE_USER" 또는 “ROLE_ADMIN" 형태로 넘겨주어야 함

11) Custom AuthenticationProvider 사용

  • 기존 작성한 Custom UserDetailsService를 사용해 로그인 인증을 처리하는 방식은 Spring Security가 내부적으로 인증을 대신 처리해 주는 방식
  • Custom AuthenticationProvider를 이용해 직접 로그인 인증을 처리 가능
  • AuthenticationProvider
    • Spring Security에서 클라이언트로부터 전달받은 인증 정보를 바탕으로 인증된 사용자인지에 대한 인증 처리를 수행하는 컴포넌트

HelloUserAuthenticationProvider

import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

import java.util.Collection;
import java.util.Optional;

@Component
public class HelloUserAuthenticationProvider implements AuthenticationProvider {   // (1)
    private final HelloUserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;

    public HelloUserAuthenticationProvider(HelloUserDetailsService userDetailsService, 
                                           PasswordEncoder passwordEncoder) {
        this.userDetailsService = userDetailsService;
        this.passwordEncoder = passwordEncoder;
    }


    // (3)
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        UsernamePasswordAuthenticationToken authToken = (UsernamePasswordAuthenticationToken) authentication;  // (3-1)

        // (3-2)
        String username = authToken.getName();
        Optional.ofNullable(username).orElseThrow(() -> new UsernameNotFoundException("Invalid User name or User Password"));

        // (3-3)
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);

        String password = userDetails.getPassword();
        verifyCredentials(authToken.getCredentials(), password);    // (3-4)

        Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();  // (3-5)

        // (3-6)
        return UsernamePasswordAuthenticationToken.authenticated(username, password, authorities);
    }

    // (2) HelloUserAuthenticationProvider가 Username/Password 방식의 인증을 지원한다는 것을 Spring Security에 알려준다.
    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.equals(authentication);
    }

    private void verifyCredentials(Object credentials, String password) {
        if (!passwordEncoder.matches((String)credentials, password)) {
            throw new BadCredentialsException("Invalid User name or User Password");
        }
    }
}
  • (1) AuthenticationProvider 인터페이스의 구현 클래스로 정의

    • AuthenticationProvider를 구현한 구현 클래스가 Spring Bean으로 등록되어 있다면 해당 AuthenticationProvider를 이용해서 인증을 진행
  • (2) supports(Class<?> authentication)

    • 구현하는 Custom AuthenticationProvider가 Username/Password 방식의 인증을 지원한다는 것을 Spring Security에 알려주는 역할
    • supports() 메서드의 리턴값이 true일 경우, Spring Security는 해당 AuthenticationProvider의 authenticate() 메서드를 호출해서 인증을 진행
  • (3) authenticate(Authentication authentication)에서 우리가 직접 작성한 인증 처리 로직을 이용해 사용자의 인증 여부를 결정
    - (3-1) authentication을 캐스팅하여 UsernamePasswordAuthenticationToken로 변환
    - (3-2) 사용자의 Username을 얻은 후, 존재하는지 체크
    - (3-3) Username이 존재한다면 userDetailsService를 이용해 데이터베이스에서 해당 사용자를 조회
    - (3-4) 로그인 정보에 포함된 패스워드(authToken.getCredentials())와 데이터베이스에 저장된 사용자의 패스워드 정보가 일치하는지를 검증
    - (3-5) 전 단계에서 검증 과정을 통과했다면 로그인 인증에 성공한 사용자이므로 해당 사용자의 권한을 생성
    - (3-6) 인증된 사용자의 인증 정보를 리턴값으로 전달
    - 전달된 인증 정보는 내부적으로 Spring Security에서 관리

    HelloUserDetailsService를 이용해 인증을 처리할 경우 인증 실패 시,
    Spring Security 내부에서 인증 실패에 대한 전용 Exception인 AuthenticationException을 throw 하게 되고
    SecurityConfiguration에서 설정한 .failureUrl("/auths/login-form?error") 을 통해 로그인 폼으로 리다이렉트 하면서
    “로그인 인증에 실패했습니다.”라는 인증 실패 메시지를 표시함
    하지만 Custom AuthenticationProvider를 이용할 경우에는 회원가입 전 인증 실패 시 “Whitelebel Error Page”가 표시

    이유 : MemberService에서 등록된 회원 정보가 없으면, BusinessLogicException을 throw 하는데 BusinessLogicException이 Cusotm AuthenticationProvider를 거쳐
    그대로 Spring Security 내부 영역으로 throw 되기 때문
    Spring Security에서는 인증 실패 시, AuthenticationException이 throw 되지 않으면 Exception에 대한 별도의 처리를 하지 않고, 서블릿 컨테이너인 톰캣 쪽으로 이 처리를 넘김

    서블릿 컨테이너 영역에서 해당 Exception에 대해 “/error” URL로 포워딩하는데 “/error” URL로 포워딩되었을 때 보여줄 뷰 페이지를 별도로 구성하지 않았기 때문에 디폴트 페이지인 “Whitelebel Error Page”를 브라우저에 표시

  • Cusotm AuthenticationProvider에서 Exception이 발생할 경우, 이 Exception을 catch 해서 AuthenticationException으로 rethrow 하여 해결

HelloUserAuthenticationProvider 개선

@Component
public class HelloUserAuthenticationProvider implements AuthenticationProvider {
    private final HelloUserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;

    public HelloUserAuthenticationProvider(HelloUserDetailsService userDetailsService,
                                           PasswordEncoder passwordEncoder) {
        this.userDetailsService = userDetailsService;
        this.passwordEncoder = passwordEncoder;
    }

    // AuthenticationException을 rethrow 하는 개선 코드
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        UsernamePasswordAuthenticationToken authToken = (UsernamePasswordAuthenticationToken) authentication;

        String username = authToken.getName();
        Optional.ofNullable(username).orElseThrow(() -> new UsernameNotFoundException("Invalid User name or User Password"));
        try {
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            String password = userDetails.getPassword();
            verifyCredentials(authToken.getCredentials(), password);

            Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();
            return UsernamePasswordAuthenticationToken.authenticated(username, password, authorities);
        } catch (Exception ex) {
            throw new UsernameNotFoundException(ex.getMessage()); // (1) AuthenticationException으로 다시 throw 한다.
        }
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.equals(authentication);
    }

    private void verifyCredentials(Object credentials, String password) {
        if (!passwordEncoder.matches((String)credentials, password)) {
            throw new BadCredentialsException("Invalid User name or User Password");
        }
    }
}

3. DelegatingPasswordEncoder

  • Spring Security에서 지원하는 PasswordEncoder 구현 객체를 생성해 주는 컴포넌트
  • 애플리케이션에서 사용할 PasswordEncoder를 결정하고, 결정된 PasswordEncoder로 사용자가 입력한 패스워드를 단방향으로 암호화
  • DelegatingPasswordEncoder를 사용해 다양한 방식의 암호화 알고리즘을 적용할 수 있는데, 암호화 알고리즘을 특별히 지정하지 않는다면 Spring Security에서 권장하는 암호화 알고리즘을 사용하여 패스워드를 암호화

1) PasswordEncoder 생성

PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
  • PasswordEncoderFactories 클래스를 이용하면 기본적으로 Spring Security에서 권장하는 PasswordEncoder를 사용 가능
  • PasswordEncoderFactories.createDelegatingPasswordEncoder();를 통해 DelegatingPasswordEncoder의 객체를 생성하고, 내부적으로 DelegatingPasswordEncoder가 다시 적절한 PasswordEncoder 객체를 생성

2) Custom DelegatingPasswordEncoder 생성

  • DelegatingPasswordEncoder로 직접 PasswordEncoder를 지정해서 Custom DelegatingPasswordEncoder를 사용 가능
String idForEncode = "bcrypt";
Map encoders = new HashMap<>();
encoders.put(idForEncode, new BCryptPasswordEncoder());
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("sha256", new StandardPasswordEncoder());

PasswordEncoder passwordEncoder = new DelegatingPasswordEncoder(idForEncode, encoders);
  • Map encoders에 원하는 유형의 PasswordEncoder를 추가해서 DelegatingPasswordEncoder의 생성자로 넘겨주면 디폴트로 지정(idForEncode)한 PasswordEncoder를 사용 가능

3) 암호화된 Password Format

  • {id}encodedPassword의 형식
  • Custom DelegatingPasswordEncoder에서 지원하는 단방향 암호화 알고리즘 유형에 따른 암호화된 패스워드
    • BCryptPasswordEncoder
      • {bcrypt}2a$10dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
    • Pbkdf2PasswordEncoder
      • {pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc
    • SCryptPasswordEncoder
      • {scrypt}e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc=
    • StandardPasswordEncoder
      • {sha256}97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0

4) 암호화 기술

1. Plain Text 저장

  • 암호화되지 않은 텍스트 그 자체

2. Hash 알고리즘

  • 단방향 암호화를 위한 핵심 알고리즘
  • 데이터베이스에 암호화되어 저장되는 패스워드 자체는 사용자가 입력한 패스워드와 비교해 올바른 패스워드를 입력했는지 검증하는 용도이기 때문에 다시 복호화될 필요가 없음
  • Work Factor를 추가한 Hash 알고리즘
    • Work Factor는 공격자가 해시된 메시지를 알아내는 데 더 느리게 더 비용이 많이 들게 해주는 특정 요소를 의미
    • PBKDF2나 bcrypt의 경우 Work Factor로 솔트와 키 스트레칭을 기본적으로 사용하지만 내부적으로 훨씬 복잡한 알고리즘을 이용해서 공격자의 공격을 느리게 만듦
    • scrypt는 다이제스트 생성 시, 메모리 오버헤드를 갖도록 설계되어 무차별 대입 공격(Brute Force Attack)을 시도하기 위해 병렬화 처리가 매우 어려움

3. MD5(Message Digest 5)

  • 초창기에 사용하던 MD2, MD4 해시 알고리즘의 결함을 보완한 알고리즘
  • 방향 알고리즘인데도 불구하고 복호화가 된 사례가 종종 발견되어 지금은 거의 사용하지 않는 알고리즘

    다이제스트(Digest) :
    원본 메시지를 암호화한 메시지를 의미

4. SHA(Secure Hash Algorithm)

  • MD5의 결함을 보완하기 위해서 나온 대표적인 해시 알고리즘
  • 해시된 문자열을 만들어내기 위해 비트 회전 연산이 추가된 방식
  • 해시된 문자열의 비트 값을 회전하면서 반복적으로 해시 처리

    Rainbow Attack:
    사용자가 패스워드로 사용할만한 문자열들을 미리 목록(Rainbow Table)으로 만들어 놓고, 이 목록에 있는 문자열을 동일한 알고리즘으로 암호화한 후, 탈취한 암호화된 문자열과 서로 비교하는 작업을 통해 패스워드의 원본 문자열을 구함

Rainbow Attack에 대한 대응책

  • SHA 알고리즘처럼 해시된 다이제스트를 또 해시하고, 또 해시된 다이제스트를 반복적으로 해시하는 것 -> 키 스트레칭
  • 솔트(Salt)를 이용

0개의 댓글