Spring Security
인증, 인가, 권한, 로그인 등의 기능을 제공해주는 Spring Security를 학습하고 적용해보자.
1. Spring Security 개념
스프링 시큐리티는 스프링 기반의 애플리케이션의 보안(인증과 권한, 인가 등)을 담당하는 스프링 하위 프레임워크이다.
- 보안과 관련해서 체계적으로 많은 옵션을 제공
- 일일이 보안 관련 로직을 작성하지 않아도 됨
1. Spring Security 용어
- 접근 주체 (Principal)
- 보호된 리소스에 접근하는 유저
- 인증 (Authentication)
- 현재 유저가 누구인지 확인한다. ex) Form Login
- 애플리케이션의 작업을 수행할 수 있는 주체임을 확인하는 과정
- 인가 (Authorization)
- 현재 유저가 리소스에 접근할 수 있는 권한이 있는지 검사하는 과정
- 인증(Authentication) 이후 진행됨
- 권한
- 인증된 주체가 애플리케이션의 리소스를 이용할 수 있는지를 결정하는 요소
2. Spring Security 구조
인증(Authentication)
Spring Security의 인증 방식 중 전통적인 세션-쿠키 방식으로 인증을 진행한다.
→ JWT (Json Web Token)은 spring-security-oauth2를 사용함
위의 그림에서 번호에 해당되는 인증 과정의 흐름을 간략히 설명하면 다음과 같다.
- Form 기반 로그인 사용자 인증 요청
- AuthenticationFilter가 사용자가 보낸 ID와 PW를 인터셉트하고, 이를 기반으로 Authentication Token(UsernamePasswordAuthenticationToken)을 생성.
- AuthenticationManager는 Authentication Token을 전달받음
- AuthenticationManager의 구현체인 AuthenticationProvider에서 실제 인증 처리 진행
- PasswordEncoder을 통한 패스워드 암호화
- UserDetailsService 객체에게 사용자 ID를 넘겨주고, DB에서 사용자 정보(ID, 암호화된 PW, 권한 등)를 UserDetails객체로 전달받음.
- AuthenticationProvider는 UserDetails 객체를 전달 받고, 실제 사용자의 입력정보와 UserDetails에 담긴 정보를 가지고 인증을 시도한다.
8~11. 인증이 완료되면 Authentication 객체를 SecurityContextHolder에 저장
12. 유저에게 session ID와 함께 응답
13. 이후 요청에서 요청 쿠키에서 JSESSIONID을 검증 후 인증 처리
위와 같은 인증 과정을 거쳐 접근 주체에 대한 인증을 수행하고, 인가와 권한에 대한 설정을 할 수 있게 된다.
2. Spring Security 프로젝트 구성
1. 의존성 추가
- Spring Boot & Gradle 기반
implementation 'org.springframework.boot:spring-boot-starter-security'
2. Project에 Security Config 추가
3. WebSecurityConfigurerAdapter 클래스를 상속받아 SecurityConfig를 구성한다.
@EnableWebSecurity
@Configuration
@AllArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private AdminService adminService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 인증 절차를 거치지 않는 목록 정의 ( Security Rule을 무시 )
* 항상 인가시킬 리소스를 명시
* resource/static 디렉터리 파일, Swagger API 인가 시킴
*/
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/css/**", "/js/**", "/static/**",
"/v2/api-docs", "/configuration/ui", "/swagger-resoureces",
"/configuration/security", "/swagger-ui.html", "/webjars/**", "/swagger/**");
}
/**
* Spring Security Rules
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors()
.and() // Session 전략 설정
.csrf().disable() // axios post 요청시 csrf token이 없으면 요청이 거절당한다. 일시적으로 사용안함으로 바꿈.
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.NEVER)
.and() // 페이지 권한 설정
.authorizeRequests()
.antMatchers("/member/licenseInfo", "/admin/login", "/admin/signIn", "/license/**").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and() // 로그인 설정
.formLogin()
.loginPage("/admin/login")
.permitAll()
.and() // 로그아웃 설정
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/admin/logout"))
.logoutSuccessUrl("/admin/login")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
.and() // 에러 핸들링
.exceptionHandling().accessDeniedPage("/admin/error")
.and() // 기본 auth를 사용
.httpBasic();
}
/**
* AuthenticationManager 생성
* Security Login에서 사용 (Swagger)
* 인증을 위해서 UserDetailsService를 통해 필요 정보를 가져온다.
* adminService에서 UserDetailsService를 implements하여 loaduserbyUsername() 메서드 구현
* 비밀번호 암호화
*/
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(adminService).passwordEncoder(passwordEncoder());
}
/**
* AuthenticationManager Bean 등록
* Custom Login에서 사용
* UsernamePasswordAuthenticationToken을 파라미터로 인증을 수행
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
3. Security를 이용한 Login 구현
3-1. Custom Login
먼저 Front를 구성하여 Login Page를 구현해야 한다. Vue.js를 이용해 Front를 구성하였고 간단한 Login Page를 만들었다. 이제 설정한 Security Rule에 따라 로그인 기능을 구현할 수 있다.
1. 아래와 같은 Login Page에서 ID, PW를 입력받는다.
2. LOGIN 버튼 클릭 시 axios를 통해 Controller Mapping
Controller
/**
* 로그인
* @param loginDto - 로그인에 필요한 정보를 담고있는 Dto
* @param session - 인증된 유저일 때 session 정보를 저장
* @return - tokenId 발급 -> header를 통해 전달됨
*/
@PostMapping("/signIn")
@ApiOperation(value = "로그인", notes = "Spring Security를 통한 로그인 API")
public ResponseEntity<?> signIn(LoginDto loginDto, @ApiIgnore HttpSession session) {
String username = loginDto.getUsername();
String password = loginDto.getPassword();
SignedAdmin admin = adminService.findByAdminId(username);
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(username, password);
Authentication authentication = authenticationManager.authenticate(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
SecurityContextHolder.getContext());
admin.setTokenId(RequestContextHolder.currentRequestAttributes().getSessionId());
return ResponseEntity.ok(admin);
}
- Post로 넘어온 ID, PW를 이용해 UsernamePasswordAuthenticationToken을 생성
- SecurityConfig에서 Bean으로 등록한 authenticationManager를 주입받아 인증을 수행
- 만약 DB의 유저 정보와 입력받은 정보가 다르다면 오류 발생 → Exception 처리
- SecurityContextHolder → getContext 메서드를 통해 context를 가져온 후, 인증 정보를 set → SecurityContext에 인증 값 설정됨
- session에 SecurityContext 값을 넣음
- UserDetails의 구현체인 admin 객체에 현재 sessionId 값을 TokenId에 set 하고 리턴→ UserDetails을 Custom 하게 구현한 SignedAdmin 객체에 TokenId를 따로 만들어주었다.
- → TokenId는 추후 Front단(Vue.js)에서 Navigation Guards 구현에도 사용됨
AdminService
public SignedAdmin findByAdminId(String adminId) {
Admin admin = adminRepository.findByAdminId(adminId).orElseThrow(
() -> new IllegalArgumentException("아이디 혹은 비밀번호를 확인해주세요."));
return new SignedAdmin(admin.getAdminId(), admin.getPassword(),
admin.getName(), authorities(admin.getRole()));
}
private Collection<? extends GrantedAuthority> authorities(AdminRole role) {
return Collections.singleton(new SimpleGrantedAuthority("ROLE_" + role.toString()));
}
LoginDto
@Getter // Lombok
@Setter // Lombok
public class LoginDto {
private String username;
private String password;
}
SignedAdmin
@Getter
@Setter
@RequiredArgsConstructor
public class SignedAdmin implements UserDetails {
private String adminId;
@JsonIgnore
private String password;
private String name;
private Collection<? extends GrantedAuthority> role;
private String tokenId;
public SignedAdmin(String adminId, String password, String name,
Collection<? extends GrantedAuthority> role) {
this.adminId = adminId;
this.password = password;
this.name = name;
this.role = role;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return role;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return adminId;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
3-2. Security Login
Swagger API 문서 같은 경우에도 보안이 필요하다. 따라서 Swagger에도 Security를 적용하여 인증 절차를 거친 유저에게만 접속 권한을 준다.
- Spring Security는 기본적으로 Login Form을 제공하고 있다.
- 기본 Login Form 이용하여 Swagger에 인증 절차를 구현한다.
1. 기본 Login Form
2. 기본 인증 절차
SecurityConfig
/**
* AuthenticationManager 생성
* 기본 Security Login에서 사용 (Swagger)
* 인증을 위해서 UserDetailsService를 통해 필요 정보를 가져온다.
* adminService에서 UserDetailsService를 implements하여 loaduserbyUsername() 메서드 구현
* 비밀번호 암호화
*/
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(adminService).passwordEncoder(passwordEncoder());
}
- AuthenticationManager를 생성
- 입력된 ID, PW가 AuthenticationManager로 전달됨
- 인증 처리를 위해서 UserDetailsService를 통해 정보를 가져옴
AdminService
@Override
public UserDetails loadUserByUsername(String adminId) throws UsernameNotFoundException {
Admin admin = adminRepository.findByAdminId(adminId)
.orElseThrow(() -> new UsernameNotFoundException(adminId));
return new SignedAdmin(admin.getAdminId(), admin.getPassword(), admin.getName(),
authorities(admin.getRole()));
}
private Collection<? extends GrantedAuthority> authorities(AdminRole role) {
return Collections.singleton(new SimpleGrantedAuthority("ROLE_" + role.toString()));
}
AdminService에서 UserDetailsService를 implements 하여, loadUserByusername() 메서드를 구현. 인증에 필요한 정보를 AuthenticationManager로 전달한다.