20230710

아홍·2023년 7월 10일

2023.07

목록 보기
5/12

Spring Security : Spring MVC 기반 애플리케이션의 인증과 인가 기능을 지원하는 보안 프레임워크
적용하기 위해서는 의존성을 추가해줘야한다.

implementation 'org.springframework.boot:spring-boot-starter-security'

Principal주체 : 애플리케이션에서 작업을 수행하는 유저, 디바이스, 시스템 등등. 일반적으로 인증 프로세스가 성공적으로 수행된 사용자의 계정 정보를 말한다

Authentication인증 : 애플리케이션을 사용하는 사용자가 본인이 맞음을 증명하는 절차. 인증을 위해서는 Credential(신원증명정보. 사용자를 식별하기 위한 정보)가 필요하다

Authorization(인가, 권한부여) : Authentication이 수행된 사용자에게 하나 이상의 authority권한을 부여해 특정 리소스에 접근할 수 있게 허가하는 과정

Access Control접근 제어 : 사용자가 애플리케이션의 리소스에 접근하는 행위를 제어하는 것


Spring Security를 추가해주는 것만으로도 제공해주는 디폴트 로그인 페이지, 로그인 정보가 있다.
디폴트 로그인 정보의 username은 user,
password는 로그에 출력된다.

Using generated security password: 39666a24-34e7-46cb-a34a-02d9130b4106

This generated password is for development use only. Your security configuration must be updated before running your application in production.

2023-07-10 11:06:28.291  INFO 9156 --- [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure any request with [org.springframework.security.web.session.DisableEncodeUrlFilter@26d02dc6, org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@24d7c365, org.springframework.security.web.context.SecurityContextPersistenceFilter@147892be, org.springframework.security.web.header.HeaderWriterFilter@3e7fc07e, org.springframework.security.web.csrf.CsrfFilter@64a8851a, org.springframework.security.web.authentication.logout.LogoutFilter@402fdef1, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@261b27db, org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@dc24732, org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@26b150cd, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@48f95f96, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@3533d790, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@23e1f610, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@62ff3028, org.springframework.security.web.session.SessionManagementFilter@1a61f634, org.springframework.security.web.access.ExceptionTranslationFilter@28d37d43, org.springframework.security.web.access.intercept.FilterSecurityInterceptor@68908be7]

디폴트 로그인 정보를 설정할 수 있다.
UserDetailsManager 객체를 빈으로 등록하면 해당 빈의 인증 정보가 넘어올 경우, 인증 프로세스를 성공적으로 통과한다. 다만, 이런 방법은 테스트 환경에서만 사용해야한다.

@Configuration
public class SecurityConfiguration {

    @Bean
    public UserDetailsManager userDetailsService() {
        UserDetails userDetails =
                User.withDefaultPasswordEncoder()
                        .username("example@example.com")
                        .password("1234")
                        .roles("USER")
                        .build();

        return new InMemoryUserDetailsManager(userDetails);
    }
}

커스텀 로그인 페이지 지정하기

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        //파라미터가 HttpSecurity이고, 반환값이 SecurityFilterChain인 메서드를 정의하면
        //HTTP 보안 설정을 구성할 수 있다.

        http
                .csrf().disable() //CSRF 공격에 대한 설정을 비활성화
                .formLogin() //인증 방법을 폼 로그인 방식으로 지정
                .loginPage("/auths/login-form") //Spring Security가 제공하는 디폴트가 아닌, 지정해둔 로그인 페이지를 사용하도록 설정
                .loginProcessingUrl("/process_login") //로그인 인증 요청을 수행할 요청 URL 지정
                .failureUrl("/auths/login-form?error") //로그인 인증에 실패할 경우 리다이렉트 지정
                .and()
                .authorizeHttpRequests() //클라이언트의 요청이 온다면 접근 권한을 확인하겠다고 정의
                .anyRequest() // 모든 요청에 대해
                .permitAll(); //접근을 허용한다

        return http.build();
    }

Role을 통해 URI접근 권한 부여하기

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .csrf().disable()
                .formLogin()
                .loginPage("/auths/login-form")
                .loginProcessingUrl("/process_login")
                .failureUrl("/auths/login-form?error")
                .and()
                .exceptionHandling().accessDeniedPage("/auths/access-denied") //권한이 없는 사용자가 접근을 시도한다면 403에러를 발생시킨다
                .and()
                .authorizeHttpRequests(authorize -> authorize //request URI에 접근 권한을 부여한다
                        .antMatchers("/orders/**").hasRole("ADMIN") //ADMIN만 /orders/와 그 하위에 접근할 수 있다
                        .antMatchers("/members/my-page").hasRole("USER") //USER만 my-page에 접근 가능하다
                        .antMatchers("/**").permitAll() //앞서 설정한 경로 외의 모든 URL은 모두 접근이 가능하다.
                );

        return http.build();
    }

로그인, 로그아웃 표시를 위한 header.html 수정

<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5"> <!--사용자의 인증 정보나 권한 정보를 이용해 로직을 처리하기 위해 sec 태그를 사용하기 위한 XML 네임 스페이스-->
<body>
<div align="right" th:fragment="header">
    <a href="/members/register" class="text-decoration-none">회원가입</a> |
    <span sec:authorize="isAuthenticated()"> <!-- 현재 페이지에 접근한 사용자가 인증에 성공한 사용자인지 체크 -->
                <span sec:authorize="hasRole('USER')">  <!-- Role이 USER인 사용자에게만 표하도록 설정 -->
                    <a href="/members/my-page" class="text-decoration-none">마이페이지</a> |
                </span>
                <a href="/logout" class="text-decoration-none">로그아웃</a>  <!-- 인증에 성공한 사용자라면 로그인한 사용자이므로 로그아웃 메뉴 표시 -->
                <span th:text="${#authentication.name}">홍길동</span><!-- 로그인한 사용자의 name 표시 -->
            </span>

    <span sec:authorize="!isAuthenticated()"> <!-- 로그인한 사용자가 아니라면 로그인 메뉴 표시 -->
                <a href="/auths/login-form" class="text-decoration-none">로그인</a>
            </span>
</div>
</body>
</html>

sec 태그를 사용하려면 build.gradle에 의존성을 추가해줘야한다.

implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'

로그아웃을 위한 추가 설정

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .csrf().disable()
                .formLogin()
                .loginPage("/auths/login-form")
                .loginProcessingUrl("/process_login")
                .failureUrl("/auths/login-form?error")
                .and()
                .logout() //로그아웃 추가 설정을 위한 메서드. LogoutConfigurer를 리턴한다
                .logoutUrl("/logout") //로그아웃을 위한 request URL 지정
                .logoutSuccessUrl("/") //로그아웃 후 리다이렉트할 URL 지정
                .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();
    }

회원가입을 통해 인메모리에 유저 등록
1. PasswordEncoder Bean 등록
2. MemberService Bean 등록을 위한 JavaConfiguration 구성
3. InMemoryMemberService 클래스 구현

  1. PasswordEncoder는 패스워드 암호화 기능을 제공하는 컴포넌트
    회원가입 폼을 통해 전달받은 패스워드는 등록 전에 암호화되어야 한다. Spring Security에서 지원하는 암호화 알고리즘은 bcrypt.
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
  1. MemberService Bean 등록을 위한 JavaConfiguration 구성
    일단 MemberService 인터페이스를 만들어준 뒤,
public interface MemberService {
    Member createMember(Member member);
}

DB에 User를 등록하기 위한 DBMemberService 클래스를 일단 만들어준다.

@Transactional
public class DBMemberService implements MemberService {
    public Member createMember(Member member) {
         return null;
    }
}

JavaConfiguration을 구성해준다.
JavaConfiguration에서는 MemberService의 구현 클래스인 InMemoryMemberService를 빈으로 등록한다.
이 때, User를 등록해야하니 UserDetailsManager가,
등록 전에 패스워드를 암호화해야하니 PasswordEncoder가 필요하므로 DI해준다.

@Configuration
public class JavaConfiguration {
    @Bean
    public MemberService inMemoryMemberService(UserDetailsManager userDetailsManager, 
                                               PasswordEncoder passwordEncoder) {
        return new InMemoryMemberService(userDetailsManager, passwordEncoder);
    }
}
  1. InMemoryMemberService 클래스 구현
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;
    }

    @Override
    public Member createMember(Member member) {
        List<GrantedAuthority> authorities = createAuthorities(Member.MemberRole.ROLE_USER.name());
        //유저를 등록하기 위해서는 유저의 권한을 지정해줘야한다. 유저의 권한목록을 생성해주고 있다.

        String encryptedPassword = passwordEncoder.encode(member.getPassword()); //패스워드 암호화

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

        userDetailsManager.createUser(userDetails);

        return member;
    }

    private List<GrantedAuthority> createAuthorities(String... roles) {
        return Arrays.stream(roles)
                .map(role -> new SimpleGrantedAuthority(role))
                .collect(Collectors.toList());
    }
}

+Spring Security에서는 SimpleGrantedAuthority를 이용해 권한을 지정할 때, ROLE_{권한 이름} 으로 지정해줘야한다. 그렇지 않으면 권한 매핑이 이루어지지 않는다.


위 방법은 인메모리에 등록하는 방법.
DB에 등록하기 위해서 사용할 수 있는 방법 중 하나가 Custom UserDetailsService

  1. SecurityConfiguration의 설정 변경 및 추가
  2. JavaConfiguration의 Bean 등록 변경
  3. DBMemberService 구현
  4. Custom UserDetailsService 구현
  1. SecurityConfiguration의 설정 변경 및 추가
    frameOptions() : <frame>이나 <iframe>, <object> 태그에서 페이지를 렌더링 할지의 여부를 결정한다. 여기서는 smaeOrigin()을 붙여서 동일 출처로부터 오는 리퀘스트만 렌더링을 허용했다. 디폴트는 DENY. 이는 Clickjacking공격을 막기 위함이다.
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .headers().frameOptions().sameOrigin() // 추가된 부분.
                .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();
    }
  1. JavaConfiguration의 Bean 등록 변경
    기존의 인메모리서비스에서 DBMemberService로 변경했다.
@Configuration
public class JavaConfiguration {
    @Bean
    public MemberService dbMemberService(MemberRepository memberRepository, PasswordEncoder passwordEncoder) {
        return new DBMemberService(memberRepository, passwordEncoder);
    }
}
  1. DBMemberService 구현
@Transactional
public class DBMemberService implements MemberService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

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

    @Override
    public Member createMember(Member member) {

        verifyExistsEmail(member.getEmail());

        String encryptedPassword = passwordEncoder.encode(member.getPassword());
        member.setPassword(encryptedPassword);

        Member savedMember = memberRepository.save(member);

        System.out.println("# Create Member in DB");
        return savedMember;
    }
}
  1. Custom UserDetailsService 구현
    UserDetailsService는 User 정보를 로드하는 인터페이스.
@Component
//UserDetailsService 를 구현한다
public class HelloUserDetailsServiceV1 implements UserDetailsService {
    private final MemberRepository memberRepository;
    private final HelloAuthorityUtils authorityUtils;

    public HelloUserDetailsServiceV1(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));

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

        //DB에서 조회한 User 객체를 리턴하면 Spring Security가 이 정보를 이용해서 인증 절차를 수행한다
        return new User(findMember.getEmail(), findMember.getPassword(), authorities);
    }
}
@Component
public class HelloAuthorityUtils {
    //application.yml에 추가한 프로퍼티를 가져오는 표현식
    //@Value는 lombok의 value가 아니다... org.springframework.beans.factory.annotation.Value 이다.
    @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");

    public List<GrantedAuthority> createAuthorities(String email) {
        //관리자라면, 관리자 권한을 부여하고 아니라면 유저 권한 부여
        if (email.equals(adminMailAddress)) {
            return ADMIN_ROLES;
        }
        return USER_ROLES;
    }
}

application.yml의 프로퍼티에서 관리자 이메일을 가져오므로 application.yml에 추가해준다.

mail:
  address:
    admin: admin@example.com

0개의 댓글