본문 바로가기
프로젝트/PongGame

Spring Security, JWT, 인증, 인가

by 히포파타마스 2022. 4. 18.

Spring Security, JWT, 인증, 인가

Spring Security를 베이스로 JWT를 사용해서 해당 프로젝트의 인증과 인가를 구현한다.

이와 관련돼서 생성된 클래스는 다음과 같다.

 

● SecurityConfig : Spring Security관련 설정

● UserAccount : Spring Security에서 인증 요소(principal)로 사용되는 객체. Userdetails를 상속받고 Account의 정보를 갖는다.

● PrincipalDetailService : 인증 시, DB에서 Account를 찾고 UserAccount로 반환하는 loadUserByUsername 메서드를 갖는다.

● JwtAutienticationFilter : jwt를 사용해서 인증 처리

● JwtAutiorizationFilter : jwt를 사용해서 인가 처리

 

 

 

 

1. SecurityConfig

해당 프로젝트에서 Spring Security가 사용할 정책, 필터, 인가 권한 등을 설정한다.

 

[SecurityConfig 전체 코드]

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final AccountRepository accountRepository;
    private final JwtProcessor jwtProcessor;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .formLogin().disable()
                .httpBasic().disable();

        http
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        http
                .addFilter(corsFilter())
                .addFilter(new JwtAuthenticationFilter(authenticationManager(), jwtProcessor))
                .addFilter(new JwtAuthorizationFilter(authenticationManager(), accountRepository, jwtProcessor));

        http
                .authorizeRequests()
                .mvcMatchers("/home", "/login").permitAll() //** 홈페이지, login
                .anyRequest().hasAuthority("ROLE_USER");
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web
                .ignoring()
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }

    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.addAllowedOriginPattern("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        source.registerCorsConfiguration("/**", config);
        return new CorsFilter(source);
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

 

 

 

■ 기본 설정

[SecurityConfig 기본 설정]

http
        .csrf().disable()
        .formLogin().disable()
        .httpBasic().disable();

http
        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

● csrf.disable() : API를 작성하는데 프런트가 정해져있지 않기 때문에 csrf설정은 우선 꺼놓는다.

● formLogin.disable() : formLogin 대신 Jwt를 사용하기 때문에 disable로 설정 

● httpBasic.disable() : httpBasic 방식 대신 Jwt를 사용하기 때문에 disable로 설정

● SessionCreationPolicy.STATELESS : Jwt를 사용하기 때문에 session을 stateless로 설정한다. stateless로 설정 시 Spring Security는 세션을 사용하지 않는다.

 

 

 

■ 추가 필터

[추가 필터]

http
        .addFilter(corsFilter())
        .addFilter(new JwtAuthenticationFilter(authenticationManager(), jwtProcessor))
        .addFilter(new JwtAuthorizationFilter(authenticationManager(), accountRepository, jwtProcessor));

 

 

□ corsFilter

[corsFilter]

@Bean
public CorsFilter corsFilter() {
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowCredentials(true);
    config.addAllowedOriginPattern("*");
    config.addAllowedHeader("*");
    config.addAllowedMethod("*");
    source.registerCorsConfiguration("/**", config);
    return new CorsFilter(source);
}

cors관련 설정을 포함한 필터.

기본적으로 서버 또는 지정된 특정 도메인의 요청만 허용하지만 프런트가 정해져있지 않기 때문에 모든 도메인을 허용하는 방식으로 설정.

● setAllowCredentials : 내 서버가 응답을 할 때 json을 자바스크립트에서 처리할수 있게 할지를 설정

● addAllowedOriginPattern : 허용할 도메인 목록

● addAllowedHeader : 허용할 헤더 목록

● addAllowedMethod : 허용할 메서드(GET, PUT, 등) 목록

● source.registerCorsConfiguration : 지정한 url에 config 적용

 

 

□ JwtAuthenticationFilter

Jwt를 사용한 인증을 구현한 필터

 

 

□ JwtAuthorizationFilter

Jwt를 사용한 인가를 구현한 필터

 

 

 

■ 인가

[인가 관련 코드]

http
            .authorizeRequests()
            .mvcMatchers("/home", "/login").permitAll() //** 홈페이지, 로그인
            .anyRequest().hasAuthority("ROLE_USER");

● authorizationRequest : 요청에 따른 인가 설정

○ 기본적으로 모든 uri은 ROLE_USER의 권한만 허용

○ 홈페이지와 로그인, 스웨거 관련 uri은 모두 허용

○ configure(WebSecurity web) : HttpSecurity에서 설정하지 않은 정적리소스와 HTML 등에 관한 권한을 설정한다.

web.ignoring().requestMathers(PathRequest.toStaticResources().atCommonLocations())

static 리소스의 자원을 시큐리티에서 제외(시큐리티에서 걸러지지 않고 접근 가능)

 

 

 

■ authenticationManagerBean

[authenticationManagerBean]

@Override
public void configure(WebSecurity web) throws Exception {
    web
            .ignoring()
            .requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}

WebSecurityConfigurerAdepter를 상속받은 SecurityConfigure 외에서 AuthenticationManager를 사용하려면 authenticationManagerBean()을 오버라이드 해서 @Bean으로 직접 등록해야 한다.

 

 

 

 

2. UserDetails, UserDetailsService 

Spring Security에서 인증, 인가를 할 때 사용되는 Principal과 관련 서비스 클래스를 만든다.

 

 

 

■ UserAccount

Principal은 인증, 인가시 검증되는 객체이기 때문에 본 프로젝트에서 사용되는 회원의 정보인 Account를 갖고 있어야 한다.

 

[UserAccount]

@Getter
public class UserAccount implements UserDetails {

    private Account account;

    public UserAccount(Account account) {
        this.account = account;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        String roleName = account.getRole().getRoleName();
        authorities.add(() -> roleName);
        return authorities;
    }

    @Override
    public String getPassword() {
        return account.getPassword();
    }

    @Override
    public String getUsername() {
        return account.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

● UserDetails를 상속받는다.

● 회원 계정 엔티티인 Account를 필드로 갖는다.

● getAuthorities() : account의 Role에 저장된 권한 정보를 authorities에 담고 반환한다.

● isAccountNonExpired() : 계정이 만료되지 않았는지를 리턴(true => 만료되지 않음을 의미)

● isAccountNonLocked() : 계정이 잠겨있는지를 리턴(true => 잠겨있지 않음을 의미)

● isCredentialNonExpired() : 계정의 패스워드가 만료되어있는지 를 리턴(true => 만료되지 않음을 의미)

● isEnabled() : 계정이 사용 가능한지를 리턴

 

 

 

■ PrincipalDetailService

[PrincipalDetailService]

@Service
@RequiredArgsConstructor
public class PrincipalDetailService implements UserDetailsService {

    private final AccountRepository accountRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Account account = accountRepository.findByUsername(username)
                .orElseThrow(() -> new NonExistResourceException("해당 username을 갖는 Account를 찾을 수 없습니다."));
        return new UserAccount(account);
    }
}

● UserDetailsService를 상속받는다.

 

 

□ loadUserByUsername(String username)

[loadUserByUsername(String username)]

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    Account account = accountRepository.findByUsername(username)
            .orElseThrow(() -> new NonExistResourceException("해당 username을 갖는 Account를 찾을 수 없습니다."));
    return new UserAccount(account);
}

○ Spring Security에서 AutenticationManager가 authenticate()를 통해서 인증을 할 때, 지정된 repository에서 인증 대상 객체를 찾아서  Principal 형태로 반환

 

 

[AccountRepository]

public interface AccountRepository extends JpaRepository<Account, Long> {

    Optional<Account> findByUsername(String username);
}

● 스프링 데이터 JPA를 사용해서 Repository를 생성한다.

● findByUsername : username(= id)으로 Account를 찾아서 반환한다.

 

 

 

 

3. Jwt

Jwt를 생성하고 디코딩하는 클래스와 Jwt를 사용한 인증, 인가 필터를 구현한 클래스를 만든다.

 

 

 

■ JwtProcessor

[JwtProcessor]

@Component
public class JwtProcessor {

    public String createAuthJwtToken(UserAccount userAccount) {
        return JWT.create()
                .withSubject(userAccount.getUsername())
                .withExpiresAt(new Date(System.currentTimeMillis() + JwtProperties.EXPIRATION_TIME))
                .withClaim("id", userAccount.getAccount().getId())
                .withClaim("username", userAccount.getAccount().getUsername())
                .sign(Algorithm.HMAC512(JwtProperties.SECRET));
    }

    public String decodeJwtToken(String jwtToken, String secretKey, String claim) {
        return JWT.require(Algorithm.HMAC512(secretKey)).build()
                .verify(jwtToken)
                .getClaim(claim)
                .asString();
    }

    public String extractBearer(String jwtHeader) {
        int pos = jwtHeader.lastIndexOf(" ");
        return jwtHeader.substring(pos + 1);
    }
}

□ creatAuthJwtToken

[creatAuthJwtToken]

public String createAuthJwtToken(UserAccount userAccount) {
    return JWT.create()
            .withSubject(userAccount.getUsername())
            .withExpiresAt(new Date(System.currentTimeMillis() + JwtProperties.EXPIRATION_TIME))
            .withClaim("id", userAccount.getAccount().getId())
            .withClaim("username", userAccount.getAccount().getUsername())
            .sign(Algorithm.HMAC512(JwtProperties.SECRET));
}

● userAccount를 받아서 JwtToken을 생성하고 반환.

● Account의 id(엔티티의 id)와 username(로그인 시 id로 사용됨)을  HMAC512 알고리즘으로 암호화한다.

● 만료시간은 현재 시간으로부터 JwtProperties(Jwt관련 설정 정보를 모아놓은 클래스)에 정의된 EXPIRATION_TIME까지로 설정

※ 만료시간은 밀리 세컨드로 설정됨

 

 

□ decodeJwtToken

[decodeJwtToken]

public String decodeJwtToken(String jwtToken, String secretKey, String claim) {
    return JWT.require(Algorithm.HMAC512(secretKey)).build()
            .verify(jwtToken)
            .getClaim(claim)
            .asString();
}

● JwtToken을 받으면 secretKey를 사용해서 지정된 claim을 반환한다.

 

 

□ extractBearer

[extractBearer]

public String extractBearer(String jwtHeader) {
    int pos = jwtHeader.lastIndexOf(" ");
    return jwtHeader.substring(pos + 1);
}

● Authentication 해더의 Jwt Token은 앞에 "Bearer "가 붙기 때문에 "Bearer "를 제거하고 뒤의 순수한 Jwt Token만을 추출한다.

 

 

 

■ JwtProperties

[JwtProperties]

public interface JwtProperties {
    String SECRET = (JWT 암호화시 사용할 SecretKey);
    int EXPIRATION_TIME = 60000 * 60;
    String TOKEN_PREFIX = "Bearer";
    String HEADER_STRING = "Authorization";
}

● JWT와 관련된 설정 수치들을 지정한 인터페이스

● JWT 암호화 시 사용되는 SecretKey의 값이 있기 때문에 gitIgnore 설정

 

 

 

■ JwtAuthenticationFilter

[JwtAuthenticationFilter]

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final AuthenticationManager authenticationManager;
    private final JwtProcessor jwtProcessor;

    @SneakyThrows
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        ObjectMapper objectMapper = new ObjectMapper();
        Account account = objectMapper.readValue(request.getInputStream(), Account.class);

        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(account.getUsername(), account.getPassword());

        Authentication authentication = authenticationManager.authenticate(authenticationToken);
        return authentication;
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
                                            Authentication authResult) throws IOException, ServletException {
        UserAccount userAccount = (UserAccount) authResult.getPrincipal();

        String jwtToken = jwtProcessor.createAuthJwtToken(userAccount);

        response.addHeader(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX + " " + jwtToken);
    }
}

● JWT로 인증을 하기 위한 클래스

● Spring Security 로그인 시 인증을 담당하는 UsernamePasswordAuthenticationFilter를 상속받는다.

※ Spring Bean으로 등록하지 않는 이유는 해당 클래스가 AuthenticationManager를 의존성 주입받는데 해당 클래스를 사용하는 SecurityConfig에서 AuthenticationManager를 빈으로 등록하기 때문에 순환 참조가 발생하기 때문이다.

 

 

□ attemptAuthentication

[attemptAuthentication]

@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
        throws AuthenticationException {
    ObjectMapper objectMapper = new ObjectMapper();
    Account account = objectMapper.readValue(request.getInputStream(), Account.class);

    UsernamePasswordAuthenticationToken authenticationToken =
            new UsernamePasswordAuthenticationToken(account.getUsername(), account.getPassword());

    Authentication authentication = authenticationManager.authenticate(authenticationToken);
    return authentication;
}

● 로그인 시 인증을 위해 실행되는 메서드

● 오버라이드 해서 Json으로 들어오는 id와 password로 인증을 하도록 변경한다.

● 반환 값은 인증된 Authentication 객체이다.

 

 

□ successfulAuthentication

[successfulAuthentication]

@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
                                        Authentication authResult) throws IOException, ServletException {
    UserAccount userAccount = (UserAccount) authResult.getPrincipal();

    String jwtToken = jwtProcessor.createAuthJwtToken(userAccount);

    response.addHeader(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX + " " + jwtToken);
}

● 인증에 성공할 시 실행되는 메서드

● 인증에 성공할 시 인증된 Account의 정보를 통해 JWT Token을 만들고 헤더(Authentication 헤더)에 포함시킨다. 

 

 

 

■ JwtAuthorizationFilter

[JwtAuthorizationFilter]

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

    private final AccountRepository accountRepository;
    private final JwtProcessor jwtProcessor;

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager, AccountRepository accountRepository,
                                  JwtProcessor jwtProcessor) {
        super(authenticationManager);
        this.accountRepository = accountRepository;
        this.jwtProcessor = jwtProcessor;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        String jwtHeader = request.getHeader(JwtProperties.HEADER_STRING);

        if (jwtHeader == null || !jwtHeader.startsWith(JwtProperties.TOKEN_PREFIX)) {
            chain.doFilter(request, response);
            return;
        }

        String jwtToken = jwtProcessor.extractBearer(jwtHeader);
        String username = jwtProcessor.decodeJwtToken(jwtToken, JwtProperties.SECRET, "username");

        if (username != null) {
            Account account = accountRepository.findByUsername(username)
                    .orElseThrow(() -> new NonExistResourceException("해당 username을 갖는 Account를 찾을 수 없습니다."));

            UserAccount userAccount = new UserAccount(account);
            Authentication authentication = new UsernamePasswordAuthenticationToken(userAccount, null,
                    userAccount.getAuthorities());

            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }
}

● JWT로 인가를 하기 위한 클래스

● 헤더를 통한 인증 시 적용되는 BasicAuthenticationFilter를 상속받는다.

● BasicAuthenticationFilter는 AuthenticationManager를 사용하기 때문에 super를 사용해서 주입해준다.

 

 

□ doFilterInternal

[doFilterInternal]

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
        throws IOException, ServletException {
    String jwtHeader = request.getHeader(JwtProperties.HEADER_STRING);

    if (jwtHeader == null || !jwtHeader.startsWith(JwtProperties.TOKEN_PREFIX)) {
        chain.doFilter(request, response);
        return;
    }

    String jwtToken = jwtProcessor.extractBearer(jwtHeader);
    String username = jwtProcessor.decodeJwtToken(jwtToken, JwtProperties.SECRET, "username");

    if (username != null) {
        Account account = accountRepository.findByUsername(username)
                .orElseThrow(() -> new NonExistResourceException("해당 username을 갖는 Account를 찾을 수 없습니다."));

        UserAccount userAccount = new UserAccount(account);
        Authentication authentication = new UsernamePasswordAuthenticationToken(userAccount, null,
                userAccount.getAuthorities());

        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
    chain.doFilter(request, response);
}

● 필터 적용 시 실행되는 메서드.

● 헤더에 담겨있는 JWT Token을 디코딩해서 얻은 username값이 올바른지 판단하고 username으로 DB에서 Account를 찾아온다.

● 찾아진 Account로 만든 Authentication 객체를 SecurityContextHolder에 넣어서 인가를 처리한다.

'프로젝트 > PongGame' 카테고리의 다른 글

LoginAccountIdArgumentResolver  (0) 2022.04.28
FileProcessor  (0) 2022.04.28
Account 생성  (0) 2022.04.14
build.gradle, application.yml  (0) 2022.04.13
PongGame  (0) 2022.04.10

댓글