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
가 평문을 지원하는 NoOpPasswordEncoder
(현재는 Deprecated 됨) 였다.PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder()
{bcrypt}$2a$10$dXJ3SW6G7P50IGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
encode(password)
: 패스워드 암호화matches(rawPassword, encodedPassword)
: 패스워드 비교inMemoryAuthentication()
이 아닌 실제 DB를 통해 계정 연동을 만들고자 한다.
현재는 Form 방식을 기준으로 작성했지만, JWT나 OAuth2 방식도 기본 베이스는 이와 같다.
회원 가입시 유저 정보를 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;
}
}
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;
}
}
앞서 구현한 CustomUserDetailsService
를 SecurityConfig
설정 클래스에 등록해서 사용해야 한다.
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를 하는 과정에서 오류가 발생하지 않는다.
우선 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);
}
}
먼저, UsernamePasswordAuthenticationToken
은 Authentication
인터페이스를 구현한 인증 객체이다. 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();
}
}