스프링부트 + 스프링 시큐리티 + mybatis 회원 로그인 구현하기

김겨울·2023년 3월 13일
0

프로젝트를 진행하면서 회원가입과 로그인, 소셜로그인 부분을 내가 맡게 되었다

AOP 구현 방법을 배웠기 때문에 로그인 체크를 AOP로 구현할까도 했지만

마지막 프로젝트이니만큼 여유가 있을 때 새로운 공부를 해보고 싶어서 시큐리티로 구현하는 것을 목표로 하였다

여러 블로그들과 stack overflow를 참고해가며 구현했지만, 시큐리티의 전체 볼륨에 비하면 겉핥기 정도로 완성한 것 같다.

하지만 프로젝트에 써먹을만큼은 구현에 성공해서 그동안 공부했던 것들을 정리해보고자 한다

백문이 불여일견, 이론만 써놓는 것보다 실제 구현한 코드를 먼저 Overview 해보는 것이 더 도움이 된다고 생각한다
시큐리티의 이론과 기본 구조를 설명한 블로그는 정말 많기 때문에 간략하게 정리만 하고 실제 코드 구현으로 넘어가보도록 한다

여기서는 기본 제공되는 인증방식이나 ORM이 아닌 Mybatis를 이용해서 시큐리티를 커스터마이징 해본다


1. 스프링 시큐리티의 기본 구조

1. 로그인 요청이 들어오면 AuthenticationFilter가 낚아챈다

2. 요청에 들어있는 username과 password를 기반으로 인증토큰(AuthenticationToken)을 준비한다

3. 인증 토큰을 AuthenticationManager에게 넘긴다

4. Manager는 AuthenticationProvider를 지정하여 토큰을 넘긴다

5. 토큰은 여기서 대기하고 있고 Provider는 UserDetailService에게 username을 넘겨 일치하는 유저 정보를 불러오게끔 일을 시킨다

6. UserDetailService(스프링에서의 Service와 비슷한 역할)는 DAO를 통해 DB에 접근하여 username과 일치하는 정보를 불러온다.
이 정보를 UserDetails를 구현한 DTO에 담는다

7. DTO를 Provider에게 반환하고 Provider는 가지고 있는 토큰의 정보와 DTO의 정보가 일치하는지 검사한다

8. 일치한다면 UsernamePasswordAuthenticationToken을 만들어 UserDetails의 사용자 정보를 담아 Manager에게 반환한다

9. Manager는 반환받은 토큰을 필터에게 반납하고

10. 필터는 이를 SecurityContextHolder(Session)에 담는다. 

이상이 간략한 시큐리티의 작동 구조이다.


2. 시큐리티 커스터마이징을 위한 준비물

위의 구조는 프레임워크가 기본으로 제공하는 객체들로 이루어진 과정이다
Mybatis를 이용하기 위해서는 각 객체들을 직접 커스터마이징하여 구현하여야 한다

우선 직접 구현해야하는 객체들의 목록이다

AuthenticationProvider
-DB에 들어있는 유저 정보의 password는 암호화되어있기 때문에, 암호화 객체를 이용하여 password를 비교하려면 토큰이 머물고 있는 Provider를 구현하여야 한다

UserDetailService
-이 단계에서 DAO와 연결되기 때문에 Mybatis를 통해 DB에 접근하려면 이 객체를 구현해야 한다

UserDetails
-시큐리티에서 필요한 데이터뿐만 아니라 웹 서비스를 위해 가지고 있어야할 로그인한 유저 정보도 따로 담기 위해 이 객체를 구현한다

이외에
-기본적으로 Mybatis 연동에 필요한 Mapper, DTO, DAO, Service 등을 기본적으로 준비해야한다
-또한 유저 종류별 권한값의 상수들을 모아둔 Enum 객체도 필요하다

구현해야할 구조와 객체가 정해졌으니 구현에 들어가보자


3. 구현

필자는 Back-end 최심부에서 Front-end 방향으로의 역방향 개발을 선호하는 편이다

따라서 위에 구현해야하는 객체들을 역방향으로 준비해보자

주제가 스프링부트가 아니기 때문에 mybatis의 설정 같은건 건너뛰겠다

1) DTO, Mapper, DAO, Service

공부하면서 지겹도록 구현한 객체라, 별다른 설명 없이 빠르게 적고 넘어가려한다.

테이블의 구조는 다음과 같다

Member DTO
스크린샷에는 빠졌지만 유저의 권한을 명시해둘 Role 컬럼이나 테이블도 준비해야한다

@Data
public class Member {
    private int memberIdx;
    private String memberName;
    private String memberId;
    private String memberPass;
    private String localAddress;
    private String detailAddress;
    private String email;
    private int phone;
    private String regdate;

    private Role role;
}
    

유저권한 Enum

//유저의 권한 종류만을 담당할 ENUM 객체
public enum Role {
    ROLE_USER, ROLE_SHOP, ROLE_ADMIN
}

Mapper

//Mapper, DAO의 역할을 동시에 담당하기 위해 클래스로 구현
@Mapper
public interface MemberMapper {

    @Select("select * from member where member_id = #{memberId}")
    public Member selectById(String memberId);

    @Insert("insert into member(member_name, member_id, member_pass, local_address, detail_address, email, phone, role) values(#{memberName}, #{memberId}, #{memberPass},#{localAddress},#{detailAddress},#{email},#{phone},#{role})")
    public void insert(Member member);
}

MemberService

@Service
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService{
  
  	private Logger log = LoggerFactory.getLogger(getClass());
    private final MemberMapper memberMapper;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;
   
    @Override
    public Member findById(String memberId) {
        return memberMapper.selectById(memberId);
    }
	
    //실습을 위해 유저 등록하는 부분도 구현
    @Override
    public void regist(Member member) {
        member.setRole(Role.ROLE_USER); //유저의 권한, 기본적으로 USER로 설정해준다
        String encodedPass = bCryptPasswordEncoder.encode(member.getMemberPass()); //비밀번호를 암호화
        member.setMemberPass(encodedPass);
        log.info("암호화된 비밀번호 : "+member.getMemberPass());
        memberMapper.insert(member);
    }
}

여기에 Controller와 View를 준비하여 회원을 등록해두었다


2) UserDetails, UserDetailsService, AuthenticationProvider

이제부터 핵심이다. 먼저 유저가 클라이언트에서 입력한 정보와 비교하기 위해 DB에서 불러온 정보를 담을 UserDetails의 구현체를 만든다

/*
* 사용자 정보에 관한 처리를 담당할 VO
* DTO에 직접 UserDetails를 구현하여 사용할 수도 있다
* */
@RequiredArgsConstructor
@Getter
@ToString
public class CustomUserDetail implements UserDetails {

    private final Member member; //조회한 회원의 정보를 담은 DTO가 생성자에서 주입된다

    //가입된 회원의 인증정보를 불러와 리턴
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        ArrayList<GrantedAuthority> roleList = new ArrayList<GrantedAuthority>();
        roleList.add(new SimpleGrantedAuthority(member.getRole().toString()));
        return roleList;
    }

    //패스워드 비교를 위해 패스워드 리턴
    @Override
    public String getPassword() {
        return member.getMemberPass();
    }

    //이름 비교를 위해 리턴
    @Override
    public String getUsername() {
        return member.getMemberId();
    }

    /*
    * 아래의 정보들은 DB에 테이블을 따로 두어 관리할 수 있지만
    * 현재 어플리케이션에서는 사용하지 않을 기능들이기 때문에 전부 true를 리턴한다
    * */
    @Override
    public boolean isAccountNonExpired() { // 계정이 만료되었는지 여부를 리턴한다
        return true;
    }

    @Override
    public boolean isAccountNonLocked() { // 계정이 잠겼는지 여부를 리턴한다
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() { // 계정 보안정보가 만료되었는지를 리턴한다
        return true;
    }

    @Override
    public boolean isEnabled() { //계정의 비활성화 여부를 리턴한다
        return true;
    }
}

다음으로 이 객체에 정보를 담아 Provider에게 리턴해줄 UserDetailsService 객체를 구현해보자

/*
* Model의 Service의 역할을 담당하는 객체
* 시큐리티에서는 UserDetailsService를 구현해서 사용한다
* */
@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService { //클래스명은 자기 취향대로
    
    private final MemberMapper memberMapper; //매퍼를 주입받아 CRUD에 이용한다
    
    @Override
    public UserDetails loadUserByUsername(String memberId) throws UsernameNotFoundException {
        Member member = memberMapper.selectById(memberId); //DB에서 유저가 입력한 ID와 일치하는 정보를 불러온다
        if(member==null){ //없는 회원일 경우 예외처리
            throw new UsernameNotFoundException("userID" + memberId + " not found");
        }
        
		//일치하는 유저가 있는 경우 정보를 Provider에 반환한다
        return new CustomUserDetail(member);
    }
}

이러한 과정을 통해 Provider에 일치하는 유저의 정보(비밀번호를 포함하여)가 넘어오고,
Provider는 이를 토큰에 들어있는 유저정보(로그인시 입력한 정보)와 비교한다
Provider를 만들어보자

/*
사용자 인증 과정에서 암호화된 패스워드를 비교하기 위해 provider를 커스터마이징 하기 위한 객체
*/
@Component
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {
   
   private final UserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;
   
   @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        
        //로그인을 시도한 정보를 받아온다
        String memberId = authentication.getName();
        String password = (String)authentication.getCredentials();

        //DB에서 로그인 정보와 일치하는 사용자 정보를 찾아 DTO에 담아 비교
        UserDetails userDetails = (CustomUserDetail) userDetailsService.loadUserByUsername(memberId);
        
        //시큐리티에서 지원하는 암호화 객체를 통해 입력한 비밀번호와 DB의 비밀번호가 일치하는지 판단
        if (!passwordEncoder.matches(password, userDetails.getPassword())){
        	//일치하지 않는 경우 예외처리한다
            throw new BadCredentialsException("사용자 정보가 일치하지 않습니다");
        }
        //일치하는 사용자가 있을 경우 인증 토큰을 발급한다
        return new UsernamePasswordAuthenticationToken(userDetails,password,userDetails.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        //provider의 동작 여부를 결정한다. false가 리턴되면 authenticate 메서드는 호출되지 않는다
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }
}

3) 시큐리티 설정

여기까지가 시큐리티를 커스터마이징하여 Mybatis를 이용한 로그인을 구현한 객체들이다
구현체들을 살펴보면 코드의 양이 많거나 매우 복잡하지 않다
1번의 시큐리티의 구조와 비교해가면서 구현하다보면 점차 윤곽이 잡힐 것이다

마지막으로 시큐리티의 설정 부분이다

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return web -> web.ignoring()
                // 정적 자원에 대해서는 시큐리티 적용을 막는다
                .requestMatchers("/resources/**");
    }
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests(authorize -> authorize //URI별 접근권한을 매핑한다
                        .requestMatchers("/regist",  "/member/regist", "/user", "/", "/rest/member/**").permitAll()
                        .requestMatchers("/user_access", "/manager", "/list").authenticated()
                        .requestMatchers("/**").hasRole("ADMIN")
                        .anyRequest().permitAll())
                .formLogin(form -> form //로그인을 커스터마이징 하기 위한 설정
                        .loginPage("/login")
                        .loginProcessingUrl("/login_proc")
                        .usernameParameter("member_id")
                        .passwordParameter("member_pass")
                        .defaultSuccessUrl("/userinfo")
                        .failureUrl("/access_denied")
                        .permitAll())
                .logout(logout -> logout //로그아웃 설정
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/list"))
                .csrf().disable()
                .build();
    }
}

마치며

프로젝트 때문에 이것저것 자료 참고해가며 처음으로 구현해 본 코드이고, 아직 숙달되지 못했기에 주먹구구식 코드일 것이라고 생각한다
하지만 필자처럼 처음 시큐리티를 공부하는 사람들에게는 기본적인 윤곽을 잡는데엔 문제가 없을 것이라고 생각한다

이번엔 부트 프로젝트에 적용할 코드를 작성했지만, 레거시로 진행되는 프로젝트도 있어서 다음 글은 이 구조를 레거시와 xml을 이용한 설정을 통해 다시 구현해보는 것이 목표이고,

그 다음에는 여기에 API를 이용한 소셜로그인과 JWT 토큰을 커스터마이징하는 과정까지 공부할 예정이다

To Be Continued...

profile
겨울을 좋아하는, 법대 나온 개발자 지망생입니다

0개의 댓글