Account 도메인, Role enum 클래스 추가
@Entity
@Getter @Setter @EqualsAndHashCode(of = "id")
@Builder @NoArgsConstructor @AllArgsConstructor
public class Account {
@Id @GeneratedValue
private Integer id;
private String email;
private String password;
@ElementCollection(fetch = FetchType.EAGER)
@Enumerated(EnumType.STRING)
private Set<AccountRole> roles;
}
public enum AccountRole {
ADMIN, USER
}
Event 클래스 연관관계 코드 추가
@ManyToOne
private Account manager;
웹 시큐리티, 메서드 시큐리티 중 웹 시큐리티, OAUTH2 인증
oauth2 maven 추가
<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
<version>2.6.6</version>
</dependency>
Acoount Service, Repository 추가
@Service
public class AccountService implements UserDetailsService {
@Autowired
AccountRepository accountRepository;
@Override
public UserDetails loadUserByUsername(String username) {
Account account = accountRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException(username));
return new User(account.getEmail(), account.getPassword(), authorities(account.getRoles()));
}
private Collection<? extends GrantedAuthority> authorities(Set<AccountRole> roles) {
return roles.stream().map(e -> new SimpleGrantedAuthority("ROLE_" + e.name())).collect(Collectors.toSet());
}
}
public interface AccountRepository extends JpaRepository<Account, Integer> {
Optional<Account> findByEmail(String username);
}
테스트 클래스 추가
@SpringBootTest
@ActiveProfiles("test")
class AccountServiceTest {
@Autowired
AccountService accountService;
@Autowired
AccountRepository accountRepository;
@Test
void findByUsername() {
//Given
String username = "dcun@rest.api";
String password = "dcun";
Account account = Account.builder()
.email(username)
.password(password)
.roles(Set.of(AccountRole.ADMIN, AccountRole.USER))
.build();
this.accountRepository.save(account);
//When
UserDetailsService userDetailsService = this.accountService;
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
//Then
assertThat(userDetails.getPassword()).isEqualTo(password);
}
}
강의 영상 junit 4 기준 테스트 코드
아래 코드에서 중요한건 ExpectedException 필드의 접근제한자,
@Test 코드안에 //Expected, //When 의 순서가 변경되면 테스트에 통과하지 못한다
객체 이름 그대로 예외를 예상하는 것이기 때문에 예외에 대한 정보를 미리 설정하고
When 이 진행되어야 한다
@SpringBootTest
@ActiveProfiles("test")
class AccountServiceTest {
@Rule
public ExpectedException expectedException = ExpectedException.none();
@Autowired
AccountService accountService;
@Autowired
AccountRepository accountRepository;
@Test
void expectedExceptionFindByUsername() {
//Expected
String username = "random@email.com";
expectedException.expect(UsernameNotFoundException.class);
expectedException.expectMessage(Matchers.containsString(username));
//When
accountService.loadUserByUsername(username);
}
}
junit 5 기준 테스트 코드
junit 4 에서 사용했던 주석, 예상예외 객체 코드 -> assertThrows 로 대체
상속 계층의 예외들까지 테스트를 통과하므로 유의해야 한다 참조
@Test
void expectedExceptionFindByUsername() {
String username = "dcun@rest.rest";
UsernameNotFoundException exceptionWasExpected = assertThrows(UsernameNotFoundException.class, () -> {
this.accountService.loadUserByUsername(username);
}, "UsernameNotFoundException was expected");
assertThat(exceptionWasExpected.getMessage()).isEqualTo(username);
}
DelegatingPasswordEncoder 빈 등록할 때 이미 빈으로 등록했던 ModelMapper 를 같이
Config 클래스에 분리 작업
createDelegatingPasswordEncoder 메서드 안에 내용을 확인하면 prefix 로 인코딩 명이
들어가니 실제 패스워드 인코딩에 어떤 작업을 행한지 확인 가능
@Configuration
public class AppConfig {
@Bean
public ModelMapper modelMapper() {
return new ModelMapper();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
기본적인 시큐리티 설정
PathRequest.toStaticResources().atCommonLocations() 정적 자원에 주로 사용하는 경로들이
들어가 있으며 개별적으로 설정하지 않고 한번에 ignoring 가능하다
아래 코드는 다른 클래스에서 사용하기 위해 authenticationManager 를 빈으로 등록하고
빈으로 등록할 때 내가 작성한 service, encoder 를 authenticationManager 에 설정하였다
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
AccountService accountService;
@Autowired
PasswordEncoder passwordEncoder;
@Bean
public TokenStore tokenStore() {
return new InMemoryTokenStore();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(accountService)
.passwordEncoder(passwordEncoder);
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().mvcMatchers("/docs/index.html");
//정적 리소스에 대한 시큐리티 무시
web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.mvcMatchers("/docs/index.html").anonymous()
.requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
}
AppConfig 클래스에 ruuner 추가
어플리케이션이 실행될 때 runner 를 통해서 acoount 를 생성하고 로그인 페이지에 입력 후
시큐리티 인증 및 진입하도록 설정
@Bean
public ApplicationRunner applicationRunner() {
return new ApplicationRunner() {
@Autowired
AccountService accountService;
@Override
public void run(ApplicationArguments args) throws Exception {
Account account = Account.builder()
.email("dcun@email.com")
.password("dcun")
.build();
accountService.saveAccount(account);
}
};
}
HttpSecurity 인증 설정
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.anonymous()
.and()
.formLogin()
.and()
.authorizeRequests()
.mvcMatchers(HttpMethod.GET, "/api/**").authenticated()
.anyRequest().authenticated();
}
AccountService account 등록 시 패스워드 인코딩 작업 코드 추가
@Autowired
PasswordEncoder passwordEncoder;
public Account saveAccount(Account account) {
account.setPassword(this.passwordEncoder.encode(account.getPassword()));
return this.accountRepository.save(account);
}
테스트
assertThat(passwordEncoder.matches(password, userDetails.getPassword())).isTrue();
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
AccountService accountService;
@Autowired
PasswordEncoder passwordEncoder;
@Bean
public TokenStore tokenStore() {
return new InMemoryTokenStore();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(accountService)
.passwordEncoder(passwordEncoder);
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().mvcMatchers("/docs/index.html");
web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
}
oauth2 토큰 테스트
client : oauth2 서버 정보
class AuthServerConfigTest extends BaseControllerTest {
@Autowired
AccountService accountService;
@Test
@DisplayName("인증 토큰을 발급 받는 테스트")
void authTokenIsNull() throws Exception {
//Given
String username = "other@email.com";
String password = "other";
Account account = Account.builder()
.email(username)
.password(password)
.roles(Set.of(AccountRole.ADMIN, AccountRole.USER))
.build();
this.accountService.saveAccount(account);
//When
String clientId = "myApp";
String clientSecret = "pass";
this.mockMvc.perform(post("/oauth/token")
.with(httpBasic(clientId, clientSecret))
.param("username", username)
.param("password", password)
.param("grant_type", "password"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("access_token").exists());
}
}
테스트 response body
bearer : 전달자, 토큰 타입
{
"access_token":"KVkl-mVhuKQKcb7Y-hxPZBfhTCg",
"token_type":"bearer",
"refresh_token":"qm7zoWvLRpDltkTWbUDt6fAosVY",
"expires_in":599,
"scope":"read write"
}
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.resourceId("event");
}
@Override
public void configure(HttpSecurity http) throws Exception {
http
.anonymous()
.and()
.authorizeRequests()
.mvcMatchers(HttpMethod.GET, "/api/**")
.anonymous()
.anyRequest()
.authenticated()
.and()
.exceptionHandling()
.accessDeniedHandler(new OAuth2AccessDeniedHandler());
}
}
테스트
perform 에 대한 응답에서 인증 토큰만 리턴하도록 코드 추가
private String getBearerAccessToken() throws Exception {
return "Bearer " + getAccessToken();
}
private String getAccessToken() throws Exception {
//Given
String username = "other@email.com";
String password = "other";
Account account = Account.builder()
.email(username)
.password(password)
.roles(Set.of(AccountRole.ADMIN, AccountRole.USER))
.build();
this.accountService.saveAccount(account);
//When
String clientId = "myApp";
String clientSecret = "pass";
ResultActions perform = this.mockMvc.perform(post("/oauth/token")
.with(httpBasic(clientId, clientSecret))
.param("username", username)
.param("password", password)
.param("grant_type", "password"));
var responseBody = perform.andReturn().getResponse().getContentAsString();
Jackson2JsonParser jackson2JsonParser = new Jackson2JsonParser();
return jackson2JsonParser.parseMap(responseBody).get("access_token").toString();
}
perform - GET 을 제외한 요청에 인증 토큰 전달
.header(HttpHeaders.AUTHORIZATION, getBearerAccessToken())
이슈
모든 테스트를 한번에 진행할 때 유니크하지 않은 값을 인서트 하며 단 건 조회 시 여러개가 조회되며 에러 발생
jnit 4 : @Before, junit 5 : @BeforeEach
@BeforeEach
public void initEach() {
eventRepository.deleteAll();
accountRepository.deleteAll();
}
application-properties 자동 완성 : processor 추가, build project
테스트 클래스에 하드 코딩들 빈 주입 받아서 적용
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
@Component
@ConfigurationProperties(prefix = "my-app")
@Getter @Setter
public class AppProperties {
@NotEmpty
private String adminUsername;
@NotEmpty
private String adminPassword;
@NotEmpty
private String userUsername;
@NotEmpty
private String userPassword;
@NotEmpty
private String clientId;
@NotEmpty
private String clientSecret;
}
특별한 내용 없으며 포스트맨 사용으로 테스트 진행
SecurityContextHolder 에서 사용자 정보 조회
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
User principal = (User) authentication.getPrincipal();
@AuthenticationPrincipal 주석으로 사용자 정보 조회
expression 에 삼항연산자는 anonymousUser 인 경우 객체를 리턴하지 않고
문자열이 반환되기 때문에 null 로 리턴하기 위함
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : account")
public @interface CurrentUser {}
사용자 정보를 스프링 시큐리티 User 타입으로 반환하기 위한 adapter
public class AccountAdapter extends User {
private Account account;
public AccountAdapter(Account account) {
super(account.getEmail(), account.getPassword(), authorities(account.getRoles()));
this.account = account;
}
public static Collection<? extends GrantedAuthority> authorities(Set<AccountRole> roles) {
return roles.stream().map(e -> new SimpleGrantedAuthority("ROLE_" + e.name())).collect(Collectors.toSet());
}
public Account getAccount() {
return account;
}
}
documents 에 관리자에 대한 계정 정보 노출 제거
public class AccountSerializer extends JsonSerializer<Account> {
@Override
public void serialize(Account account, JsonGenerator gen, SerializerProvider serializers) throws IOException {
gen.writeStartObject();
gen.writeNumberField("id", account.getId());
gen.writeEndObject();
}
}