관리 메뉴

제뉴어리의 모든것

[Section 4] [Spring Security] Spring Security 기본 본문

코드스테이츠/정리 블로깅

[Section 4] [Spring Security] Spring Security 기본

제뉴어리맨 2022. 9. 23. 23:54

Spring Security란?

Spring Security의 기능은 여러가지가 있겠지만, 

주된 사용 목적은

접속한 유저가 해당 사이트의 가입된 유저인가를 "인증" 하는 부분과

그리고 그 유저에게 해당하는 권한만큼의 "권한 부여" 해 주기 위함이다.

쉽게 말해, 로그인 기능과 로그인한 유저의 권한에 따라 특정 페이지 접근 권한 같은 리소스 접근 제어를하기 위함이다.

그런 기능들은 보통 어떤 사이트나 필요한 기능이지만, 복잡한 로직을 요구하기 때문에

잘 만들어진, 그리고 Spring에서 만든 (지원하는) Spring Security 를 사용하는 것이다.

 

포스트의 순서는

spring security 의 정의 와 기능 그리고 사용되는 용어와 같이 사전 정의 내용과

실제 spring security를 사용하기 위한 방법을 알아보고

마지막으로 사용한 방법들이 어떤 원리로 작동되는지 알아보겠다.

 

참고로 해당 포스트는 공부용이기 때문에 Spring Security 이외의 내용도 기억해두기 위해 적혀있는 경우도 있다.

 

  • Spring Security 기능
    1. 다양한 유형(폼 로그인 인증, 토큰 기반 인증, OAuth 2 기반 인증, LDAP 인증)의 사용자 인증 기능 적용
    2. 애플리케이션 사용자의 역할(Role)에 따른 권한 레벨 적용
    3. 애플리케이션에서 제공하는 리소스에 대한 접근 제어
    4. 민감한 정보에 대한 데이터 암호화
    5. SSL 적용
    6. 일반적으로 알려진 웹 보안 공격 차단


이외에도 추가적인 기능들이 존재한다.

 

 

 

Spring Security 에서 사용하는 용어

  • Principal(주체)
    인증을 요청하거나, 인증이 된 
    다른 사용자와 구별되는 인증과 관련된 대상. (인증의 대상)
    예를 들어, 
    username (id), password 와 같은 로그인 환경에서는 id가 될 수 있다.

  • Authentication(인증)
    내가 나임을 상대에게 증명하여 인증을 받는것을 말한다.
    임의의 유저가 서버에게 로그인 요청시 서버에서 로그인 요청한 유저가 정말 그 유저가 맞는지 
    확인하기 위해 Credential (신원 증명 정보) 를 확인한다.
    그리고 Credential이 서버에서 가지고 있는 해당 유저의 Credential과 일치한다면 해당 유저가 맞음을 인증하는것이다.
    Credential은 id, password 환경의 로그인에서 password에 해당한다.

  • Authorization(인가 또는 권한 부여)
    인증이 정상적으로 이루어진 유저에게 하나 이상의 권한을 주어 리소스에 접근 범위를 열어주는것이다.
    왜 하나이상이냐면,
    관리자의 접근 범위는 관리자만 접근할 수 있는 범위 + 사용자가 접근할 수 있는 범위까지 포함하는 이러한 경우도 있기 때문에 1개 이상인 것이다.

 

 

 

Spring Security 기본적인 적용 방법

Spring Security가 적용된 모습을 보다 잘 표현하기 위해

Thymeleaf 라는 기술이 사용되었다.

감안하여 보자.

 

  • build.gradle
plugins {
	id 'org.springframework.boot' version '2.7.2'
	id 'io.spring.dependency-management' version '1.0.12.RELEASE'
	id 'java'
}

group = 'com.codestates'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.1.0'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	implementation 'org.mapstruct:mapstruct:1.5.2.Final'
	annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.2.Final'
	implementation 'com.google.code.gson:gson'


	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
}

tasks.named('test') {
	useJUnitPlatform()
}

 

위 부분에서 dependencies { } 부분만 보면 된다.

그 중에서도 3,4줄의 공백이 있는 아래의 3개 부분만 염두하면 된다.

implementation 'org.springframework.boot:spring-boot-starter-security'  // Spring Security 의존성
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' // Thymeleaf 의존성
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'  // Thymeleaf에서 spring security를 사용하기 위한 의존성

사실 나는 Thymeleaf 안 쓰겠다 하면 Spring Security 의존성만 넣어도 된다.

하지만 그렇게 할 경우 앞으로 기술한 내용들은 알아서 본인의 프로젝트에 맞게 적용을 해야 한다.

 

 

페이지 접근 권한 설정

  • SecurityFilterChain (필터체인) 을 빈으로 등록해주는 SecurityConfiguration (Configuration) 클래스
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.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfiguration {
    @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")   // (1)
            .and()
            .authorizeHttpRequests(authorize -> authorize                  // (2)
                    .antMatchers("/orders/**").hasRole("ADMIN")        // (2-1)
                    .antMatchers("/members/my-page").hasRole("USER")   // (2-2)
                    .antMatchers("/**").permitAll()                    // (2-3)
            );
        return http.build();
    }

}

위와 같이 Bean을 모아서 등록해주는 FactoryBean 역할을 하는 SecurityConfiguration이라는 클래스를 만들고. (@Configuration 애노테이션으로 인해 가능)

그 안에 SecurityFilterChain 이라는 클래스를 Bean으로 등록하는 메소드가 있다.

 

HttpSecurity 를 이용하여 보안에 대한 설정을 해주고 build() 메소드로 인해 최종적으로 SecurityFilterChain 가 빈으로 등록되면 해당 내용들이 필터로 만들어지면서, 클라이언트로부터 들어오는 request에 대해 필터링을 하게 된다.

 

즉, SecurityFilterChain 를 세팅하여 빈 등록하면 인증과 접근제어 같은 기능을 활성화 해준다.

 

가입 처리

  • 회원가입 HTML 페이지 

아래는 가입창의 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>
        <hr />
        <div class="container" layout:fragment="content">
            <!-- 회원 가입 폼 -->
            <form action="/members/register" method="post">     <!-- 1 -->
                <div class="row">
                    <div class="col-xs-2">
                        <input type="text" name="fullName" class="form-control" placeholder="User Name"/>
                    </div>
                </div>
                <div class="row" style="margin-top: 20px">
                    <div class="col-xs-2">
                        <input type="email" name="email" 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>
    </body>
</html>

 

사실 이 페이지에서는 Spring Security가 쓰인곳은 없다.

Thymeleaf만 사용 됬을 뿐이다.

중요한 점은 1번 주석 부분인데,

회원가입 form에서 button이 눌리면 

"/members/register" 의 경로로 회원가입 정보를 전송하는 것이다.

 

  • Controller 부분
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

@Controller
@RequestMapping("/members")
public class MemberController {
    private final MemberService memberService;
    private final MemberMapper mapper;

    public MemberController(MemberService memberService, MemberMapper mapper) {
        this.memberService = memberService;
        this.mapper = mapper;
    }

    @GetMapping("/register")
    public String registerMemberForm() {
        return "member-register";
    }

    @PostMapping("/register")
    public String registerMember(@Valid MemberDto.Post requestBody, Model model) {
        Member member = mapper.memberPostToMember(requestBody);
        Member createdMember = memberService.createMember(member);

        MemberDto.Response response = mapper.memberToMemberResponse(createdMember);
        model.addAttribute("member",response); //View로 데이터 전송

        System.out.println("Member Registration Successfully");
        return "login";
    }

}

Contorller 부분도 Spring Security가 쓰인 곳은 없다.

 

  • Service 부분
import com.codestates.auth.CustomAuthorityUtils;
import com.codestates.exception.BusinessLogicException;
import com.codestates.exception.ExceptionCode;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;

@RequiredArgsConstructor
@Transactional
public class DBMemberService implements MemberService {

    //멤버를 저장하기 위한 레포지토리
    private final MemberRepository memberRepository;
    
    //비밀번호를 해싱하여 저장하기 위한 인코더
    private final PasswordEncoder passwordEncoder;
    
    //멤버의 권한을 설정하기 위한 사용자정의 유틸 클래스
    private final CustomAuthorityUtils customAuthorityUtils;


    @Override
    public Member createMember(Member member) {

        verifyExistsMember(member.getEmail());
        
        //사용자가 입력하여 보내온 비밀번호를 해싱
        String encodedPwd = passwordEncoder.encode(member.getPassword());
        
        //해싱한 비밀번호로 엔티티에 다시 세팅
        member.setPassword(encodedPwd);

        //권한 설정
        List<String> authority = customAuthorityUtils.createAuthority(member.getEmail());
        member.setRoles(authority);

        //멤버 저장
        Member savedMember = memberRepository.save(member);

        return savedMember;
    }

    //존재하는 멤버인지 검증, 유니크해야하는 이메일이 존재하다면 가입 불가 멤버로써 에러
    private void verifyExistsMember(String email) {

        memberRepository.findByEmail(email).ifPresent(optionalMember -> {
            throw new BusinessLogicException(ExceptionCode.MEMBER_EXISTS); });
    }

    //멤버의 로그인 요청시, 요청과 함께 들어온 Principal (ID) 로 검색하여 해당 하는 멤버 정보를 가져오는 메소드
    public Member findMember(String username) {

        Optional<Member> optionalMember = memberRepository.findByEmail(username);

        Member findMember = optionalMember.orElseThrow(() -> {
            throw new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND);
        });

        return findMember;
    }
}

Spring Security가 쓰인 곳은 없다.

  • repository
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByEmail(String email);
}

Spring Security가 쓰인 곳은 없다.

Member를 저장하는 save 메소드는 JpaRepository 내부에 이미 존재하므로 보이지 않는다.

 

 

인증 부분

  • CustomUserAuthenticationProvider (AuthenticationProvider 의 사용자 정의 구현 클래스)
import com.codestates.member.Member;
import com.codestates.member.MemberService;
import lombok.RequiredArgsConstructor;
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;

/** 접속 요청 유저가 가입된 해당 유저가 정말 맞는지 검증하는 클래스 **/
@RequiredArgsConstructor
@Component
public class CustomUserAuthenticationProvider implements AuthenticationProvider {

    //DB에 저장되어 있는 Credential (해싱된) 과 요청시 들어 온 Credential (해싱 안 된) 비교를 위한 인코더,
    private final PasswordEncoder passwordEncoder;

    //멤버의 처리를 해주는 Service 객체, AuthenticationProvider 을 구현한 (현 클래스) CustomUserAuthenticationProvider 클래스로 인해
    //UserDetailsService 의 역할을 대체한다.
    private final MemberService memberService;

    //권한에 대한 처리를 담당하는 Util 객체
    private final CustomAuthorityUtils authorityUtils;

    //아직 인증되지 않은 Authentication 이 넘어 오고, 해당 메소드에서 인증과정을 거친 후
    //인증된 Authentication 을 리턴
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        //아직 인증 되지 않은 Authentication 을 UsernamePasswordAuthenticationToken 로 형변환
        UsernamePasswordAuthenticationToken authToken = (UsernamePasswordAuthenticationToken) authentication;

        //로그인 요청이 들어 온 Principal 정보를 가져오기 위해 username 을 구함
        String username = authToken.getName();

        Optional.ofNullable(username).orElseThrow(()-> new UsernameNotFoundException("Invalid Username or Password"));

        //Service 객체에서 멤버 정보 가져 옴
        Member findMember = memberService.findMember(username);

        //DB에 저장되어 있는 Credential
        String password = findMember.getPassword(); // DB에 저장되어 있는 크레덴셜

        //현재 로그인 요청 정보의 크레덴셜과 DB상 의 Credential 매치해봄
        verifyCredentials(authToken.getCredentials(), password);

        //해당 구간까지 왔다는것은 Credential 검증을 통과 한 상태 이므로
        //DB상에 권한 정보를 Spring Security에서 사용할 수 있는 권한정보 형태로 변환
        Collection<? extends GrantedAuthority> authorities = authorityUtils.getAuthority(findMember.getRoles());

        //최종적으로 인증이 된 Authentication 구현체를 반환
        return new UsernamePasswordAuthenticationToken(username, password, authorities);

    }

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


    //현재 구현하는 사용자정의 AuthenticationProvider(CustomUserAuthenticationProvider)가
    //Username/Password 방식의 인증을 지원한다는 것을 Spring Security에게 알려주는 역할을 함.
    //해당 메소드를 정확히 구현해주지 않으면 인증과정이 진행되지 않음.
    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.equals(authentication);
    }
}

검증을 하는 매우 중요한 클래스이다.

검증을 실질적으로 담당하는 인터페이스는 AuthenticationProvider 이다.

AuthenticationProvider 인터페이스를 구현하여 구현한 클래스를 @Component를 사용하여

빈으로 등록했기 때문에,  authenticate() 메소드에서 검증 부분을 직접 작성할 수 있다.

 

그리고 중요한 점은 authenticate() 메소드에 인자로 들어오는 

Authentication 은 아직 인증이 안된것이고,

리턴되는 Authentication은 인증이 완료된것이다.

 

인자로 들어 온 Authentication에는 사용자가 입력한 평문의 Password가 있으므로,

해싱 되어 저장된 Password와 비교하기 위해 PasswordEncoder를 사용한다.

동일한 평문이 들어왔다면 해싱하였을때 DB에 저장된 동일 Password로 암호화 될것이므로 유효성 검사가 가능하다.

 

그리고 supports () 메소드로 현재 인증 방식은 UsernamePassword 방식임을 Spring Security에 알려주어야 내부적으로 정상 동작하게 된다.

 

그리고 주의깊게 볼점은

AuthenticationProvider 인터페이스를 구현한 해당 클래스로 인해

인증 부분에서 유저의 정보를 읽어 오는 핵심 인터페이스인 UserDetailsService 가 사용되지 않는다는것이다.

 

왜냐하면 우리가 해당 클래스를 구현하여 재정의 하지 않았다면

스프링 내부적으로 AuthenticationProvider 역할을 하는 

DaoAuthenticationProvider (AbstractUserDetailsAuthenticationProvider의 하위 클래스),

AbstractUserDetailsAuthenticationProvider  가 있는데,

해당 클래스 안에 UserDetailsService가 존재하고 사용된다.

그런데 AuthenticationProvider 를 구현하여 빈으로 등록해버렸으므로,

DaoAuthenticationProvider 와

AbstractUserDetailsAuthenticationProvider 가 쓰이지 않게 됬고

당연히 UserDetailsService 도 사용되지 않는것이다.

그리고 그 역할을 멤버필드로 선언 되어 있는 MemberService가 대체한다.

 

 

 

추가적으로 사용되는 클래스

  • CustomAuthorityUtils
    가입 멤버에게 권한을 주고, 가입된 멤버의 권한을 Spring Security가 알 수있는 권한의 형태로 변환하여 주는 클래스
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;


/** 사용자 권한에 대한 처리를 담당하는 클래스 **/
@Component
public class CustomAuthorityUtils {

    @Value("${mail.address.admin}")
    private String adminEmail;

    private final List<String> ADMIN_ROLE = new ArrayList<>(List.of("ADMIN", "USER"));
    private final List<String> USER_ROLE = new ArrayList<>(List.of("USER"));


    /** 최초 가입자에게 권한을 만들어 주는 메소드 **/
    public List<String> createAuthority(String email) {

        if (email.equals(adminEmail)) {
            return ADMIN_ROLE;
        }

        return USER_ROLE;
    }

    /** DB 상의 사용자 권한 정보를 Spring Security가 인식하는 권한 정보로 변환 하여 리턴해주는 메소드 **/
    public List<GrantedAuthority> getAuthority(List<String> authority) {

        //“ADMIN"으로 넘겨주면 안되고 “ROLE_USER" 또는 “ROLE_ADMIN" 형태로 넘겨주어야 한다!! 즉, ROLE_ 이 꼭 들어가야 한다
        return authority.stream().map(str-> new SimpleGrantedAuthority("ROLE_" + str)).collect(Collectors.toList());

    }
}

 

 

  • Member 클래스
    사용자 정보가 저장되는 Entity 이다
import com.codestates.audit.Auditable;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

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


@NoArgsConstructor
@Getter
@Setter
@Entity
public class Member extends Auditable {
    @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;

    @ElementCollection(fetch = FetchType.EAGER)  // (1)
    private List<String> roles = new ArrayList<>();

    public Member(String email) {
        this.email = email;
    }

    public Member(String email, String fullName, String password) {
        this.email = email;
        this.fullName = fullName;
        this.password = password;
    }

    public enum MemberStatus {
        MEMBER_ACTIVE("활동중"),
        MEMBER_SLEEP("휴면 상태"),
        MEMBER_QUIT("탈퇴 상태");

        @Getter
        private String status;

        MemberStatus(String status) {
           this.status = status;
        }
    }
}

여기서 눈여겨 볼점은 (1) 이다

@ElementCollection 
을 사용하여 컬랙션 타입의 필드를 DB상에 테이블로 생성하여 준다.

DB상에는 현재 필드가 속해 있는 Entity와의 연관관계를 맺어서 하나의 테이블로써 만들어 준다.

당연히 해당 테이블의 FK는 Member 엔티티의 ID가 된다.

 

 

 

 

깃 주소 : https://github.com/JanuaryKim/spring_security_template_practice/tree/master/src/main/java/com/codestates/auth