대부분의 서비스는 해당 서비스의 사용자라는 인증을 요구한다.
우리는 로그인이라는 인증절차를 거치게 되며, 인증을 마치면 서비스에 일정부분 접근할 수 있는 인가 권한을 갖게 된다.
Spring에서는 이런 복잡한 인증/인가 처리 환경을 Spring Security라는 프레임워크에 위임하여 간단하게 구성할 수 있는데, Spring Security에서 인증/인가, 보안과 관련된 많은 옵션을 제공해주기 때문에, 개발자는 이 옵션을 환경에 맞게 설정하기만 하면 된다.
스프링부트 웹 어플리케이션에 시큐리티를 연동했을 때 생기는 일
@Controller
public class FormController {
/**
* 메인 페이지 (인증없이 접근 가능)
*/
@GetMapping("/")
public String index() {
return "/index";
}
/**
* 관리자 페이지 (인증필요, ADMIN 권한만 접근 가능)
*/
@GetMapping("/admin")
public String admin() {
return "/admin";
}
/**
* 회원 페이지 (인증필요, USER 이상의 권한 접근 가능)
*/
@GetMapping("/user")
public String user() {
return "/user";
}
}
위와 같이 3개의 페이지로 구성된 애플리케이션이 있다.
메인화면인 index
를 제외한 나머지 페이지는 인증/인가 처리가 필요한 상황이다.
하지만 현재는 아무런 처리를 하지 않은 상태기 때문에, 바램과 달리 아무나 접근 할 수 있으며, 우리는 이를 스프링 시큐리티를 통해 해결하고자 한다.
먼저 스프링 시큐리티 의존성을 추가한다.
org.springframework.boot:spring-boot-starter-security
의존성만 추가하고 인덱스를 호출한 결과
만든적 없는 경로(/login)로 이동.. 그리고 처음보는 로그인 폼...
의존성만 추가했는데 스프링 시큐리티가 활성화 되었다.
(인증이 필요한 경우 시큐리티는 요청을 가로채서 로그인 화면으로 튕겨낸다.)
로그인 폼의 경우 시큐리티가 제공하는 기능 중 하나인, 자체 폼-로그인이 활성화 된 것으로, 별도의 설정을 통해 로그인 폼과 경로를 직접 만들어 사용할 수도 있다.
디폴트 유저계정 [id:'user', pwd: 로그에 임시 패스워드 출력]
디폴트 계정을 통해 로그인을 하면, 모든 페이지에 정상적으로 접근이 가능하다.
(/logout
을 통해 로그아웃도 가능하다.)
시큐리티 연동으로 인증 기능이 활성화 된 것은 확인했다.
하지만 현재 모든 페이지가 인증을 거쳐야 하는 상태로, 아래의 문제점을 해결해야 한다.
index
의 경우 인증 없이 누구나 접근 가능해야 한다.user, admin
의 경우 인증 뿐만 아니라 권한으로 접근을 통제해야 한다.시큐리티는 인증/인가 그리고 보안에 관한 다양한 옵션을 제공한다.
이 옵션들을 아래와 같이 config 클래스를 작성해 사용할 수 있다.
/**
* @EnableWebSecurity + WebSecurityConfigurerAdapter
* ==> 웹과 관련된 시큐리티 설정을 할 수 있다.
*/
@Configuration
@EnableWebSecurity
public class SecurityConrig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// HttpSecurity를 통해 HTTP 요청에 대한 웹 기반 보안을 구성할 수 있다.
http
.authorizeRequests() //HttpServletRequest에 따라 접근을 제한
.antMatchers("/").permitAll() // 해당 요청은 인증이 필요 없다.
.antMatchers("/admin").hasRole("ADMIN") // 해당 요청은 ADMIN 권한만 접근하도록...
.antMatchers("/user").hasRole("USER") // 해당 요청은 USER 권한만 접근하도록...
.anyRequest().authenticated(); // 이외의 모든 요청은 인증을 거친다.
http
.formLogin() // 폼 기반 로그인 사용
.and()
.httpBasic(); // Http 기반 로그인 사용
}
}
이제 index
는 인증없이 접근 할 수 있으며, 나머지 페이지는 특정 권한만 접근할 수 있게 되었다.
하지만 아직 처리해야 할 문제점은 남아있는데...
admin, user
페이지에 대한 인가 테스트가 불가능하다.시큐리티를 DB의 회원 풀과 연동하여 로그인/회원가입 처리
보통의 환경에서는 DAO(또는 Repository)에 쿼리메서드를 작성하고, 이를 Service에서 비즈니스에 맞게 호출하는 방식으로 CRUD를 처리한다.
스프링 시큐리티 또한 DAO와 연동하여 인증을 할 수 있도록 'Service 인터페이스'를 제공하는데, 우리는 이 인터페이스가 제공하는 메서드에 DAO_쿼리메서드를 기반으로 한 인증 로직을 구현하기만 하면 된다.
[❗️ 편의를 위해 'JPA'와 'H2(In-Memory DB)'를 통해 회원 풀을 구축합니다.]
1. 회원 스키마 생성
username, password, role
@Entity
@SequenceGenerator(
name = "seq_member_id",
sequenceName = "MEMBER_ID_SEQ",
initialValue = 1000000001,
allocationSize = 1
)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE
, generator = "seq_member_id")
private Long member_id;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String role;
...
2. 'username'으로 회원정보를 가져오는 DAO_쿼리메서드 작성
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByUsername(String username);
}
선행작업이 모두 끝났다면, 본격적으로 시큐리티와 DAO를 연동해서 DB를 통한 인증을 구현해본다.
@Service
@RequiredArgsConstructor
public class MemberService implements UserDetailsService {
final MemberRepository memberRepository;
/**
* 인증/인가 DB 회원 풀로 연동
* @param username
* @return UserDetails
* @throws UsernameNotFoundException
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 1. DAO_findByUsername 쿼리메서드로 유저정보 가져오기
Member member = memberRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Member Not Found : " + username));
// 2. (1)의 유저정보를 UserDetails 타입으로 리턴
return User.builder()
.username(member.getUsername())
.password(member.getPassword())
.roles(member.getRole())
.build();
}
}
UserDetailsService
는 DAO와 연동하여 인증을 할 수 있도록 시큐리티에서 제공하는 인터페이스로, loadUserByUsername(String username)
메소드를 제공한다.
결국 이 메서드의 매개인 'username'으로 유저를 가져오는 쿼리메서드를 호출하여, 유저정보를 UserDetails
타입으로 리턴해주면 끝!
시큐리티는 DB에서 유저정보를 가져와 인증을 하게 된다.
리턴타입인 UserDetails
는 스프링 시큐리티에서 유저정보를 담는 인터페이스로, 애플리케이션의 유저정보를 시큐리티가 이해할 수 있는 타입으로 변환한다고 생각하면 된다.
(물론 UserDetails를 커스텀해서 더 많은 유저정보/기능을 담을 수도 있고, 별도 설정을 통해 username 대신 email과 같은 항목으로 인증-키를 바꿀수도 있다.)
이제 DB_유저정보를 기반으로 인증을 하는 환경이 구축됐다.
인증/인가 테스트를 하려면 회원 데이터가 필요하니, 아래와 같이 간소한 등록기능을 구현해 회원을 등록해본다.
/*
* MemberController
*/
@GetMapping("/member/{username}/{password}/{role}")
public Member insertMember(@ModelAttribute Member member) {
log.info(" *** Insert User is {}", member.toString());
return memberService.insertMember(member);
}
----------------------------
/*
* MemberService
*/
public Member insertMember(Member member) {
return memberRepository.save(member);
}
----------------------------
/*
* SecurityConfig
*/
// 회원등록(/member/..) 인증에서 제외
.antMatchers("/", "/member/**").permitAll()
성공적으로 회원데이터가 등록됐다면, 로그인을 통해 인증/인가를 테스트 해본다.
[/user
호출 -> 로그인(인증) -> 유저페이지에 접근되는 지 확인(인가)]
‼️ 만약 아래와 같은 오류가 발생한다면?? (스프링 시큐리티 5↑)
스프링 시큐리티5 부터는 패스워드-인코더가 변경됐기 때문에, 따로 관련 설정을 하거나, 아래와 같이 비밀번호 앞에 인코딩 포맷을 명시해야 한다.
우선 아래와 같이 비밀번호를 넣고 회원가입을 해본다.
{인코더}비밀번호 ==> 예) {noop}u123
('noop'은 암호화 없이 평문을 사용하겠다는 뜻)
위의 계정으로 로그인을 하게되면 'USER'권한을 가지고 있기 때문에 /user
페이지에도 접근할 수 있다.
('ADMIN' 계정도 위와 같은 방식으로 등록과 로그인을 해본다.)
이제 DB 회원 풀을 통한 인증/인가 처리가 가능해졌다.
하지만 시큐리티5의 경우 매번 위와 같은 방식으로 패스워드를 가공할 순 없기에, 따로 설정을 해주려 한다.
이전 버젼까지는 'noop(평문)'이 기본전략이었다가, 시큐리티5부터 'bcrypt'로 바뀌었다.
하지만 기본전략이 바뀌면서 아래의 문제를 해결해야 했다.
이런 문제를 모두 해결할 수 있도록 {알고리즘}패스워드
방식의 포맷을 채용하게 됐다.
다른 포맷을 사용하게 될 경우 위와 같은 포맷을 사용하면 된다.
하지만 'bcrypt'를 사용하는 경우 포맷작업 없이 자동으로 암호화되도록 설정할 수 있다.
1. 패스워드-인코더 구현 및 빈으로 등록
public class SecurityConrig extends WebSecurityConfigurerAdapter {
/**
* 패스워드-인코더 구현 + 빈으로 등록
*/
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
...
2. 패스워드-인코더를 통해 평문 패스워드 암호화 하기
// 1. Entity or DTO에 패스워드 암호화 메서드 추가
public class Member {
private String password;
...
/**
* 패스워드 암호화 메서드
*/
public void passwordEncode(PasswordEncoder passwordEncoder) {
this.password = passwordEncoder.encode(this.password);
}
----------------------------------
// 2. 암호화 메서드를 통해 패스워드 암호화
public class MemberService implements UserDetailsService {
final PasswordEncoder passwordEncoder;
...
public Member insertMember(Member member) {
member.passwordEncode(passwordEncoder); // DB작업 전 패스워드 암호화
// 암호화 처리 상태를 확인하기 위한 로그
log.info("======== Insert User Info {}", member.toString());
return memberRepository.save(member);
}
설정을 마쳤다면 {알고리즘}비밀번호
포맷이 아닌, 비밀번호만 기입하여 회원을 등록해본다.
인코딩 설정이 잘 됐다면, 따로 포맷을 명시하지 않았어도, 기본전략(bcrypt)으로 암호화 될 것이며, 로그인 시 아까와 같은 에러도 발생하지 않는다.
로그인 시 입력한 패스워드=u123, 실제 DB에 저장된 패스워드={bcrypt}$2a$10$XLxqjEEeoR3ATCt4AqFTzOD30.RBTDmWjK9QVChga2jyOf0BcjFiu
테스트 코드를 작성하여 인증/인가를 테스트 하는 방법
security-test 의존성만 추가하면 시큐리티의 기능들을 테스트를 해볼 수 있다.
org.springframework.security:spring-security-test
(scope=test)
/**
* 메인 페이지 (인증없이 접근 가능)
*/
@GetMapping("/")
public String index() {return "/index";}
/**
* 관리자 페이지 (인증필요, ADMIN 권한만 접근 가능)
*/
@GetMapping("/admin")
public String admin() {return "/admin";}
/**
* 회원 페이지 (인증필요, USER 이상의 권한 접근 가능)
*/
@GetMapping("/user")
public String user() {return "/user";}
위에서 해당 페이지들을 통해 시큐리티의 인증/인가 기능을 확인해봤다.
여태까진 서비스를 구동해서 결과를 확인해왔지만, 이번에는 테스트코드를 작성하여 인증/인가를 테스트 해본다.
테스트 방법은 일반적인 JUnit 테스트와 동일하다.
가령 MockMvc를 통해 특정 페이지의 요청을 테스트 한다고 하면, 여기에 인증된(혹은 미인증된) 임시계정을 만들어주면 된다.
Mock 계정 어노테이션
@WithAnonymousUser
: 미인증 유저
@WithMockUser(username = "testUser", roles = "USER")
: 인증된 유저
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class FormControllerTest {
@Autowired
MockMvc mockMvc;
/**
* index 페이지에 미인증 유저도 접근이 가능해야된다.
*/
@Test
@WithAnonymousUser
public void index_anonymous() throws Exception {
mockMvc.perform(get("/"))
.andExpect(status().isOk())
.andDo(print());
}
/**
* admin 페이지에 ADMIN 권한이 아니면 열람 불가하다.
*/
@Test
@WithMockUser(username = "user1", roles = "USER")
public void admin_another_role() throws Exception {
mockMvc.perform(get("/admin"))
.andExpect(status().isForbidden())
.andDo(print());
}
/**
* admin 페이지에 ADMIN 권한은 접근할 수 있다.
*/
@Test
@WithMockUser(username = "admin1", roles = "ADMIN")
public void admin_success() throws Exception {
mockMvc.perform(get("/admin"))
.andExpect(status().isOk())
.andDo(print());
}
...
시큐리티 테스트는 MockUser 뿐만 아니라, 폼-로그인 테스트도 지원한다.
MockUser는 가짜인증계정을 통해 인증 이후의 상황을 테스트 하기 위한 기능이라면, 폼-로그인 테스트는 폼-로그인 인증 그 자체를 검증하기 위한 기능이다.
mockMvc.perform(formLogin().user(username).password(password)) .andExpect(authenticated());
/**
* 폼-로그인 테스트
*/
@Test
@Transactional
public void form_login() throws Exception {
String username = "user";
String password = "u123";
String role = "USER";
// 로그인 테스트 전 회원 등록
Member member = memberService.insertMember(Member.builder()
.username(username)
.password(password)
.role(role)
.build());
// 해당 유저를 폼-로그인을 통해 로그인 했을 때 잘 되는지?
mockMvc.perform(formLogin().user(username).password(password))
.andExpect(authenticated());
}