본문 바로가기

공부/백엔드

[스프링 부트 핵심 가이드] 13 : 서비스의 인증과 권한 부여 (스프링 시큐리티)

반응형

 

앞서, 보안 용어 정리

  • 인증: 사용자가 누구인지 확인한다. - 로그인
  • 인가: 인증된 사용자가 리소스에 접근할 권한이 있는지 확인한다.
  • 토큰: 인증 시 생성되어 인가에 쓰인다.
  • 접근 주체: 어플리케이션을 사용하는 주체 - 사용자, 시스템, 디바이스

스프링 시큐리티

https://docs.spring.io/spring-security/reference/

: 인증, 인가 등의 보안 기능을 제공하는 스프링 하위 프레임워크로 편리하게 보안 기능을 사용할 수 있다.

 

동작 구조 (Authentication)

  1. 요청이 들어오면 Dispatcher Servlet을 거치기 전 Filter Chain에서 각 Filter를 거친다. (doFilter로 이동)
  2. Filter를 거치다 DelegationgFilterProxy가 실행되면 보안 필터 체인에서 보안 필터들을 실행한다.
    • SecurityFilterChain(HttpSecurity) 빈을 통해 설정할 수 있다. 
  3. 인증 Filter가 request에서 username, password를 추출해서 토큰을 생성 -> (로그인)
  4. 토큰값을 이용해 일치하는 UserDetails 객체를 조회하여 인증 Provider에서 인증을 수행한다. -> (인증)
  5. 인증 성공 시 인증 Filter로 토큰을 전달한다. (UsernamePasswordAuthenticationToken)
  6. 토큰을 SecurityContextHolder의 SecurityContext의 Authentication에 저장한다.

 

JWT

https://jwt.io/introduction/

: JSON 정보를 암호화하여 토큰으로 전송한다.

  • Base64Url로 인코딩된 문자열로만 구성되어 http 어느 요소든 들어갈 수 있다.
  • 디지털 서명이 적용되어 있다.
  • 주로 인가 목적으로 쓰인다.
  • .으로 구분되어 [헤더.내용.서명]으로 구성된다.

 

헤더

: 검증과 관련된 내용

  • alg : 토큰 검증 시 사용하는 해싱 알고리즘 (SHA256, RSA)
  • typ : 토큰의 타입 (JWT)

 

내용

: 토큰에 담을 내용으로 각 속성은 클레임(Claim)

  • Registered Claims : 이미 이름이 정해져 있는 클레임으로 토큰에 대한 기본적인 정보
    • iss(uer), sub(ject), aud(ience), exp(iration), nbf(not before), iat(issued at), jti(jwt id)
  • Public Claims : 키 값을 마음대로 정의하여 속성을 지정
  • Private Claims : Registered, Public이 아닌 클레임

 

서명

: 인코딩 한 헤더, 내용과 비밀키, 알고리즘 속성값을 가져와 생성하며 무결성을 확인한다.

 


 

인증 구현 - UsernamePasswordAuthentication

1. 인증에 사용할 username, password, 권한(string) list가 있는 UserDetails를 구현한 엔티티 클래스 생성

  • getAuthorities() : 부여된 역할(권한)을 List 형태로 리턴
  • getUsername(), getPassword() : 인증에 쓰이는 계정의 아이디, 비밀번호를 리턴
  • isAccountNonExpired() : 계정이 만료되었는지 여부를 리턴
  • isAccountNonLocked() : 계정이 잠겨있는지 여부를 리턴
  • isCredentialsNonExpired() : 비밀번호가 만료됐는지 여부를 리턴
  • isEnabled() : 계정이 활성화 됐는지 여부를 리턴

 

2. UserDetailsRepository에서 사용자 정보를 가져올 UserDetailsService를 구현한 클래스 생성

  • UserDetails loadUserByUsername(String username) : 유니크한 username을 통해 계정의 엔티티를 리턴

 

3. TokenProvider : UserDetails 정보를 이용해 JWT 토큰을 생성, 제공하는 클래스

  • generateToken : username과 key를 이용해 토큰을 생성한다.
// 키와 사용자 정보를 이용해 토큰 생성
String secretKey = Jwts.SIG.HS256.key().build();
String token = Jwts.builder()
                .header()
                .add("roles", roles) // list<String>로 된 권한을 값으로 등록
                .and()
                .subject(username) // subject로 username(unique한 id) 등록
                .issuedAt(new Date()) // 토큰 발행 날짜
                .expiration(new Date(now.getTime()+1000)) // 토큰 만료 날짜
                .signWith(secretKey) // secretKey로 서명
                .compact();

 

  • 생성된 토큰의 유효성을 검증하고 subject(username)를 추출한다.
Claims claims = Jwts.parser().verifyWith(secretKey).build()
                .parseSignedClaims(token).getPayload();
                
if (!claims.getExpiration().before(new Date())) {
	String username = claims.getSubject();
}

 

  • username을 통해 SecurityContextHolder에 저장할 UsernamePasswordAuthenticationToken 생성
UserDetails userDatails = userDetailsService.loadUserByUsername(username);

Authentication auth = new UsernamePasswordAuthenticationToken
						(userDetails, "", userDetails.getAuthorities());

 

 

4. JwtAuthenticationFilter : 요청이 들어오면 헤더의 토큰을 이용해 SecurityContextHolder에 저장하는 필터

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
    
@Override
    protected void doFilterInternal(HttpServletRequest request,
                        		HttpServletResponse response,
                                        FilterChain filterChain) {
    	String token = request.getHeader("Auth-Header");
        if (tokenProvider.getAuthentication(token)) { // TokenProvider 내 토큰을 검증하는 메서드
            	Authentication auth = tokenProvider.getAuthentication(token) // TokenProvider 내 토큰을 얻는 메서드
                SecurityContextHolder.getContext().setAuthentication(auth);
        }
        filterChain.doFilter(request, response);
    }
}
  • OncePerRequestFilter를 상속받은 것은 GenericFilterBean을 바로 상속하는 것과 달리 매 요청마다 한 번만 실행됨

 

5. SecurityFilterChain(HttpSecurity) Bean 등록 : 보안 설정

  • 리소스 접근 권한 설정
  • 인증 실패 시 발생하는 예외 처리
  • 인증 로직 커스터마이징
  • csrf, cors 등 스프링 시큐리티 설정
private final JwtAuthenticationFilter authenticationFilter;
    
@Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .httpBasic(AbstractHttpConfigurer::disable)
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement((sm) ->
                        sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests((auth) -> {
                    auth.requestMatchers(
                    			antMatcher("**all**"),
                                	antMatcher(HttpMethod.GET, "/get/**")).permitAll()
                                .requestMatchers("/user/**").hasRole("USER")
                            	.anyRequest().authenticated();
                })
                .exceptionHandler(e -> {
                	e.authenticationEntryPoint((request, response, exception) -> 
                                  response.sendError(HttpServletResponse.SC_UNAUTHORIZED));
                  	e.accessDeniedHandler((request, response, exception)->
                            	response.sendError(HttpServletResponse.SC_FORBIDDEN));
                })
                .addFilterBefore(authenticationFilter,
                        UsernamePasswordAuthenticationFilter.class)
                .build();
    }
  • httpBasic : 브라우저의 인증 UI를 이용해 아이디, 비밀번호를 확인하는 것으로 jwt 기반 인증에서는 비활성화한다.
  • csrf : 의도치 않은 요청을 보내는 공격을 막기 위해 csrf 토큰을 보내 검증하는데 jwt 토큰을 사용하므로 비활성화한다.
  • sessionManagement : 세션 동작 방식으로 jwt 토큰으로 인증을 처리하며 세션을 사용하지 않으므로 STATELESS
  • authorizeHttpRequests : 요청에 대한 권한 체크
  • exceptionHandling : 시큐리티 필터 내에서 발생하는 예외 처리 방법
    • authenticationEntryPoint : 인증 정보가 없거나 잘못된 정보일 때 발생하는 예외
    • accessDeniedHandler : 인증이 되었지만 접근 권한이 없을 때(인가) 발생하는 예외
  • addFilterBefore(a, b) : b 필터 앞에 a 필터를 추가한다.

 

Custom ExceptionHandling

  • AccessDeniedHandler 인터페이스를 구현해 handle(request, response, exception)을 오버라이딩한다.
  • AuthenticationEntryPoint 인터페이스를 구현해 commence(request, response, exception)을 오버라이딩한다.
private final CustomAccessDeniedHandler accessDenied;
private final CustomAuthenticationEntryPoint authEntryPoint;

// securityFilterChain
.exceptionHandling(e -> {
                    e.authenticationEntryPoint(authEntryPoint);
                    e.accessDeniedHandler(accessDenied);
                })

 


회원가입

: (username, password, roles)+a를 저장

  • password는 PasswordEncoder를 통해 암호화한다.
// Entity
@ElementCollection
@JoinTable(name="생성될 테이블명", joinColumns=@JoinColumn(name="id 컬럼명"))
private List<String> roles;

// Service
private PasswordEncoder passwordEncoder; // 비밀번호 암호화를 위한 클래스

public User signUp(String username, String password, List<String> roles) {
	User user = User.builder()
    		.username(username)
                .password(passwordEncoder.encode(password))
                .roles(roles)
                .build();
    	userRepository.save(user);
}

 

반응형