<Spring Security> DB 연동 인증 처리

실습 환경

build.gradle는 다음과 같다.

plugins {
	id 'org.springframework.boot' version '2.6.4'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'
	id 'java'
}

group = 'me.ramos'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
	implementation 'org.modelmapper:modelmapper:2.3.0'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	runtimeOnly 'mysql:mysql-connector-java'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
}

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

PasswordEncoder

  • 비밀번호를 안전하게 암호화 하도록 제공하는 기능이다.
  • Spring Security 5.0 이전에는 기본 PasswordEncoder가 평문을 지원하는 NoOpPasswordEncoder(현재는 Deprecated 됨) 였다.
  • 생성
    • PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder()
    • 여러 개의 PasswordEncoder 유형을 선언한 뒤, 상황에 맞게 선택해서 사용할 수 있도록 지원하는 Encoder이다.
  • 암호화 포맷: {id}encodedPassword
    • 기본 포맷은 Bcrypt: {bcrypt}$2a$10$dXJ3SW6G7P50IGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
    • 알고리즘 종류: bcrypt, noop, pbkdf2, scrypt, sha256
  • 인터페이스
    • encode(password): 패스워드 암호화
    • matches(rawPassword, encodedPassword): 패스워드 비교

DB 연동 인증 처리

inMemoryAuthentication()이 아닌 실제 DB를 통해 계정 연동을 만들고자 한다.
현재는 Form 방식을 기준으로 작성했지만, JWT나 OAuth2 방식도 기본 베이스는 이와 같다.

CustomUserDetailsService

회원 가입시 유저 정보를 UserDetails 타입으로 만들어서 반환해주기 위해 CustomUserDetailsService를 구현한다. UserDetailsService를 implements 받아 loadUserByUsername()을 오버라이딩해서 User 엔티티(Account)와 적절하게 매칭시켜줘야 한다.

package me.ramos.securitystudy.security.service;

import lombok.RequiredArgsConstructor;
import me.ramos.securitystudy.domain.Account;
import me.ramos.securitystudy.repository.UserRepository;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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.Service;

import java.util.ArrayList;
import java.util.List;

@Service("userDetailsService")
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

		// DB에서 Account 객체 조회
        Account account = userRepository.findByUsername(username);

        if (account == null) {
            throw new UsernameNotFoundException("UsernameNotFoundException");
        }
		
        // 권한 정보 등록
        List<GrantedAuthority> roles = new ArrayList<>();
        roles.add(new SimpleGrantedAuthority(account.getRole()));

		// AccountContext 생성자로 UserDetails 타입 생성
        AccountContext accountContext = new AccountContext(account, roles);

        return accountContext;
    }
}

AccountContext

CustomUserDetailsService는 최종적으로 UserDetails 타입으로 반환해야 한다. 따라서 AccountContext를 만들어서 UserDetails 타입으로 객체를 만들어주는 구현체를 만들어야 한다.
Spring Security의 User 클래스를 상속받아 생성자를 구현한다.

package me.ramos.securitystudy.security.service;

import me.ramos.securitystudy.domain.Account;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;

import java.util.Collection;

public class AccountContext extends User {

    private final Account account;

    public AccountContext(Account account, Collection<? extends GrantedAuthority> authorities) {
        super(account.getUsername(), account.getPassword(), authorities);
        this.account = account;
    }

    public Account getAccount() {
        return account;
    }
}

SecurityConfig 설정

앞서 구현한 CustomUserDetailsServiceSecurityConfig 설정 클래스에 등록해서 사용해야 한다.

package me.ramos.securitystudy.security.configs;

import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final UserDetailsService userDetailsService;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/", "/users", "user/login/**").permitAll()
                .antMatchers("/mypage").hasRole("USER")
                .antMatchers("/messages").hasRole("MANAGER")
                .antMatchers("/config").hasRole("ADMIN")
                .anyRequest().authenticated()

                .and()
                .formLogin();
    }
}

📌 SecurityConfig에서 DI를 받을 때의 의문사항?
CustomUserDetailsService상단에 @Service("userDetailsService") 애노테이션을 붙여두었다. 이후 SecurityConfig의 코드를 보면 UserDetailsService를 주입받는 방식이 CustomUserDetailsService라는 이름이 아닌 UserDetailsService라는 이름으로 되어있다.

일반적으로 @Service("userDetailsService")와 같이 value 값을 지정하지 않는다면 해당 클래스명으로 빈이 생성된다. 따라서 CustomUserDetailsService라는 이름으로 빈이 등록될 것이다.
여기서 UserDetailsService 타입으로 생성된 빈이 여러 개라면, 타입 중복으로 인한 오류가 발생한다. 다만 위 예제 코드에선 해당 이름으로 생성된 빈이 단 하나뿐이라서 DI를 하는 과정에서 오류가 발생하지 않는다.

AuthenticationProvider

우선 CustomAuthenticationProvider를 다음과 같이 생성한다.

package me.ramos.securitystudy.security.provider;

import me.ramos.securitystudy.security.service.AccountContext;
import org.springframework.beans.factory.annotation.Autowired;
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.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;

public class CustomAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        String username = authentication.getName();
        String password = (String) authentication.getCredentials();

        AccountContext accountContext = (AccountContext) userDetailsService.loadUserByUsername(username);

        if (!passwordEncoder.matches(password, accountContext.getAccount().getPassword())) {
            throw new BadCredentialsException("BadCredentialsException");
        }

        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(accountContext.getAccount(), null, accountContext.getAuthorities());

        return authenticationToken;
    }

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

먼저, UsernamePasswordAuthenticationTokenAuthentication 인터페이스를 구현한 인증 객체이다. Flow가 쉽게 이해가 되지 않는데, 가장 핵심은 Authentication 객체로부터 인증에 필요한 정보(username, password 등)를 받아오고, userDetailsService 인터페이스를 구현한 객체(CustomUserDetailsService)로 부터 DB에 저장된 유저 정보를 받아온 후, password를 비교하고 인증이 완료되면 인증이 완료된 Authentication 객체를 리턴해주는 것이다.

이후 SecurityConfig를 다음과 같이 수정하자.

package me.ramos.securitystudy.security.configs;

import lombok.RequiredArgsConstructor;
import me.ramos.securitystudy.security.provider.CustomAuthenticationProvider;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
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.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(authenticationProvider());
    }

    @Bean
    public AuthenticationProvider authenticationProvider() {
        return new CustomAuthenticationProvider();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/", "/users", "user/login/**").permitAll()
                .antMatchers("/mypage").hasRole("USER")
                .antMatchers("/messages").hasRole("MANAGER")
                .antMatchers("/config").hasRole("ADMIN")
                .anyRequest().authenticated()

                .and()
                .formLogin();
    }
}

References

좋은 웹페이지 즐겨찾기