개발 스펙
- Java(11)
- Amazon Corretto JDK(11)
- Spring Boot(2.5.3)
- Spring Security(boot-starer)
- JWT(0.9.1)
Config
1. Dependency 추가
Spring Security와 JWT의 Dependency 추가
dependencies {
...
// Spring Security
implementation "org.springframework.boot:spring-boot-starter-security"
// Spring Security Test
testImplementation 'org.springframework.security:spring-security-test'
// JWT
implementation 'io.jsonwebtoken:jjwt:0.9.1'
...
}
2. 정적 자원 제공 클래스 생성
정적 자원을 제공하는 클래스를 생성하여 아래와 같이 설정한다.
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private static final String[] CLASSPATH_RESOURCE_LOCATIONS = {
"classpath:/static/",
"classpath:/public/",
"classpath:/",
"classpath:/resources/",
"classpath:/META-INF/resources/",
"classpath:/META-INF/resources/webjars/"
};
@Override
public void addViewControllers(ViewControllerRegistry registry) {
// 경로에 해당하는 url을 forword
// registry.addViewController("/loginPage").setViewName("login");
// 우선순위를 가장 높게 잡는다.
// registry.setOrder(Ordered.HIGHEST_PRECEDENCE);
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 정적 자원의 경로를 허용
registry.addResourceHandler("/**").addResourceLocations(CLASSPATH_RESOURCE_LOCATIONS);
}
}
3. SpringSecurity 설정
스프링 시큐리티의 웹 보안 기능을 초기화 및 설정
WebSecurityConfigurerAdapter를 상속받아 HttpSecurity를 이용해 설정한다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
// JWT 제공 클래스
private final JwtProvider jwtProvider;
// 인증 실패 또는 인증헤더가 전달받지 못했을때 핸들러
private final AuthenticationEntryPoint authenticationEntryPoint;
// 인증 성공 핸들러
private final AuthenticationSuccessHandler authenticationSuccessHandler;
// 인증 실패 핸들러
private final AuthenticationFailureHandler authenticationFailureHandler;
// 인가 실패 핸들러
private final AccessDeniedHandler accessDeniedHandler;
/**
* Security 적용 무시
*/
@Override
public void configure(WebSecurity web) {
web.ignoring()
.requestMatchers(PathRequest.toStaticResources().atCommonLocations())
.mvcMatchers("/docs/**");
}
/**
* 보안 기능 초기화 및 설정
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
final String[] GET_WHITELIST = new String[]{
"/login",
"/user/login-id/**",
"/user/email/**",
"/affiliate"
};
final String[] POST_WHITELIST = new String[]{
"/client-user"
};
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and().exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint) // 인증 실패
.accessDeniedHandler(accessDeniedHandler) // 인가 실패
.and().authorizeRequests()
.antMatchers(HttpMethod.GET, GET_WHITELIST).permitAll() // 해당 GET URL은 모두 허용
.antMatchers(HttpMethod.POST, POST_WHITELIST).permitAll() // 해당 POST URL은 모두 허용
.antMatchers("/client-user/**").hasAnyRole(UserType.CL.getRoll()) // 권한 적용
.anyRequest().authenticated() // 나머지 요청에 대해서는 인증을 요구
.and() // 로그인하는 경우에 대해 설정함
.formLogin().disable() // 로그인 페이지 사용 안함
// .loginPage("/user/loginView") // 로그인 성공 URL을 설정함
// .successForwardUrl("/index") // 로그인 실패 URL을 설정함
// .failureForwardUrl("/index").permitAll()
.addFilterBefore(authenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class);
}
/**
* 사용자 요청 정보로 UserPasswordAuthenticationToken 발급하는 필터
*/
@Bean
public CustomAuthenticationFilter authenticationFilter() throws Exception {
CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter(authenticationManager());
// 필터 URL 설정
customAuthenticationFilter.setFilterProcessesUrl("/login");
// 인증 성공 핸들러
customAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
// 인증 실패 핸들러
customAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler);
// BeanFactory에 의해 모든 property가 설정되고 난 뒤 실행
customAuthenticationFilter.afterPropertiesSet();
return customAuthenticationFilter;
}
/**
* JWT의 인증 및 권한을 확인하는 필터
*/
@Bean
public JwtFilter jwtFilter() {
return new JwtFilter(jwtProvider);
}
}
회원 인증
1. UsernamePasswordAuthenticationFilter 구현
사용자 요청 정보로 UserPasswordAuthenticationToken 발급 후 AuthenticationManager에게 전달하고 AuthenticationProvider의 인증 메서드를 실행하는 UsernamePasswordAuthenticationFilter를 구현
public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
public CustomAuthenticationFilter(AuthenticationManager authenticationManager) {
super.setAuthenticationManager(authenticationManager);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
final UsernamePasswordAuthenticationToken authRequest;
final LoginDTO loginDTO;
try {
// 사용자 요청 정보로 UserPasswordAuthenticationToken 발급
loginDTO = new ObjectMapper().readValue(request.getInputStream(), LoginDTO.class);
authRequest = new UsernamePasswordAuthenticationToken(loginDTO.getLogin(), loginDTO.getPass());
} catch (IOException e) {
throw new NotValidException();
}
setDetails(request, authRequest);
// AuthenticationManager에게 전달 -> AuthenticationProvider의 인증 메서드 실행
return this.getAuthenticationManager().authenticate(authRequest);
}
}
2. AuthenticationProvider 구현
AuthenticationManager 하위에 실제로 인증을 처리할 AuthenticationProvider를 구현
@Component
@RequiredArgsConstructor
public class AuthenticationProviderImpl implements AuthenticationProvider {
private final UserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
/**
* 인증 구현
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// 전달 받은 UsernamePasswordAuthenticationToken
UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
// AuthenticaionFilter에서 생성된 토큰으로부터 아이디와 비밀번호를 추출
String username = token.getName();
String password = (String) token.getCredentials();
// 해당 회원 Database 조회
UserDetailsImpl userDetail = (UserDetailsImpl) userDetailsService.loadUserByUsername(username);
// 비밀번호 확인
if (!passwordEncoder.matches(password, userDetail.getPassword()))
throw new BadCredentialsException(userDetail.getUsername() + "Invalid password");
// 인증 성공 시 UsernamePasswordAuthenticationToken 반환
return new UsernamePasswordAuthenticationToken(userDetail.getUsername(), "", userDetail.getAuthorities());
}
/**
* provider의 동작 여부를 설정
*/
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
3. UserDetailsService 구현
인증 과정 중 실제 Database에 회원을 데이터를 조회하는UserDetailsService를 구현
@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) {
User user = userRepository.findFirstUserByLoginOrderByIdAsc(username).orElseThrow(() -> new NotFoundDataException("User"));
return new UserDetailsImpl(
user.getLogin(),
user.getPass(),
user.getEmail(),
Collections.singleton(new SimpleGrantedAuthority("ROLE_" + user.getType().getRoll()))
);
}
}
4. UserDetails 구현
회원 데이터를 조회하고 해당 정보와 권한을 저장하는 UserDetails를 구현
@AllArgsConstructor
@Getter
@ToString
public class UserDetailsImpl implements UserDetails {
private final String username;
private final String password;
private final String email;
private final Collection<? extends GrantedAuthority> authorities;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return false;
}
@Override
public boolean isAccountNonLocked() {
return false;
}
@Override
public boolean isCredentialsNonExpired() {
return false;
}
@Override
public boolean isEnabled() {
return false;
}
}
5. PasswordEncoder 구현
기본적으로 제공해주는 PasswordEncoder를 구현한 클래스를 사용할 경우 Bean 등록만 하면된다.
(BCryptPasswordEncoder OR DelegatingPasswordEncoder)
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
다른 로직의 비밀번호 인증이 필요할 경우는 PasswordEncoder를 구현해서 사용한다.
@Component
public class PasswordEncoderImpl implements PasswordEncoder {
/**
* 비밀번호 해시
*/
@Override
public String encode(CharSequence rawPassword) {
return passwordEncode((String) rawPassword); // 커스텀 메서드
}
/**
* 비밀번호 확인
*/
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return passwordMatches((String) rawPassword, encodedPassword); // 커스텀 메서드
}
}
6. AuthenticationSuccessHandler 구현
인증 성공 시 핸들링하는 AuthenticationSuccessHandler를 구현
@Component
@RequiredArgsConstructor
public class AuthenticationSuccessHandlerImpl implements AuthenticationSuccessHandler {
private final JwtProvider jwtProvider;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
// 전달받은 인증정보 SecurityContextHolder에 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
// JWT Token 발급
final String token = jwtProvider.generateJwtToken(authentication);
// Response
ApiResponse.token(response, token);
}
}
7. AuthenticationFailureHandler 구현
인증 실패 시 핸들링하는 AuthenticationFailureHandler를 구현
@Component
public class AuthenticationFailureHandlerImpl implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
ApiResponse.error(response, ApiResponseType.UNAUTHORIZED_RESPONSE);
}
}
JWT 인증
1. JwtProvider 생성
JWT를 발급하고 확인하는 클래스 생성
@Component
@RequiredArgsConstructor
public final class JwtProvider {
private final UserDetailsService userDetailsService;
// secret key
@Value("${jwt.secret-key}")
private String secretKey;
// access token 유효시간
private final long accessTokenValidTime = 2 * 60 * 60 * 1000L;
// refresh token 유효시간
private final long refreshTokenValidTime = 2 * 7 * 24 * 60 * 60 * 1000L;
@PostConstruct
private void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
/**
* 토큰에서 Claim 추출
*/
private Claims getClaimsFormToken(String token) {
return Jwts.parser().setSigningKey(DatatypeConverter.parseBase64Binary(secretKey)).parseClaimsJws(token).getBody();
}
/**
* 토큰에서 인증 subject 추출
*/
private String getSubject(String token) {
return getClaimsFormToken(token).getSubject();
}
/**
* 토큰에서 인증 정보 추출
*/
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getSubject(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
/**
* 토큰 발급
*/
public String generateJwtToken(Authentication authentication) {
Claims claims = Jwts.claims().setSubject(String.valueOf(authentication.getPrincipal()));
claims.put("roles", authentication.getAuthorities());
Date now = new Date();
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + accessTokenValidTime))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
/**
* 토큰 검증
*/
public boolean isValidToken(String token) {
try {
Claims claims = getClaimsFormToken(token);
return !claims.getExpiration().before(new Date());
} catch (JwtException | NullPointerException exception) {
return false;
}
}
}
2. JwtFilter 생성
Header를 통해 JWT의 인증 요청이 왔을때 처리하는 Filter 생성
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";
private final JwtProvider jwtProvider;
/**
* 토큰 인증 정보를 현재 쓰레드의 SecurityContext 에 저장하는 역할 수행
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
// Request Header에서 토큰 추출
String jwt = resolveToken(request);
// Token 유효성 검사
if (StringUtils.hasText(jwt) && jwtProvider.isValidToken(jwt)) {
// 토큰으로 인증 정보를 추출
Authentication authentication = jwtProvider.getAuthentication(jwt);
// SecurityContext에 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
/**
* Request Header에서 토큰 추출
*/
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
}
3. AuthenticationEntryPoint 구현
토큰 인증이 실패하거나 인증 헤더를 정상적으로 받지 못했을때 핸들링하는 AuthenticationEntryPoint를 구현
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
ApiResponse.error(response, ApiResponseType.UNAUTHORIZED_RESPONSE);
}
}
4. AccessDeniedHandler 구현
토큰 인가 실패 시 핸들링하는 AccessDeniedHandler를 구현
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
ApiResponse.error(response, ApiResponseType.FORBIDDEN_RESPONSE);
}
}
Flowchart
전체 소스는 github에서 확인하실 수 있습니다.
'Spring' 카테고리의 다른 글
Querydsl 설명 및 예제 (0) | 2021.09.15 |
---|---|
Spring AOP란? (0) | 2021.09.11 |
Spring REST Docs 구축 예시 (0) | 2021.09.10 |
Spring REST Docs란? (0) | 2021.09.10 |
Spring Security란? (0) | 2021.09.10 |