Skip to content

SpringSecurity가 궁금한 히치하이커를 위한 안내서

Notifications You must be signed in to change notification settings

yeeeeerin/spring-security-for-beginner

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

77 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SpringSecurity가 궁금한 히치하이커를 위한 안내서(제작중)

<초보자도(가) 이해하는 SpringSecurity guide>

스프링시큐리티를 처음 공부하시는 여러분을 위한 초보자 가이드 입니다.



❗[必부록]

step1 - 유저 모델링

Member class

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    @Column(name = "MEMBER_EMAIL")
    private String email;

    @Column(name = "MEMBER_USERNAME")
    private String username;

    @Column(name = "MEMBER_PASSWORD")
    private String password;

    @Column(name = "MEMBER_ROLE")
    @Enumerated(value = EnumType.STRING)
    private MemberRole role;

}

아주 최소한의 정보인 email, username, password, role으로만 Member를 구성하였습니다.

class이름을 User로 하지 않는 것을 권장합니다. org.springframework.security.core.userdetails.User와 같이 spring security에 이미 user가 있음으로 class이름을 User로 하지 않는 것을 권장합니다.

MemberRole

@Getter
public enum  MemberRole {

    ADMIN("ROLE_ADMIN"), USER("ROLE_USER");

    private String roleName;

    MemberRole(String roleName) {
        this.roleName = roleName;
    }

}

기본적으로 adminuser의 권한만 만들어 진행하겠습니다.

Spring Security규정상 role은 기본적으로 'ROLE_'로 시작해야 합니다. 그래야 권한을 인식할 수 있습니다. 'ROLE_'이라는 접두어를 다른 접두어로 변경하고 싶으면 추가적으로 설정이 필요함으로 step1에서는 넘어가도록 하겠습니다.



step2 - 회원가입

우선 데이터베이스에 회원 정보를 넣어주기 위해 repositoryservice를 생성하겠습니다.

MemberRepository

public interface MemberRepository extends JpaRepository<Member, Long> {

    Optional<Member> findByEmail(String email);

}

MemberService

@Service
@RequiredArgsConstructor
@Slf4j
public class MemberService implements UserDetailsService {

    private final MemberRepository memberRepository;

    private final PasswordEncoder passwordEncoder;

    @Transactional
    public Member singUp(Member member){
        log.info(member.getEmail());
        member.setPassword(
                passwordEncoder.encode(member.getPassword())
        );
        member.setRole(MemberRole.USER);

        return memberRepository.save(member);
    }

    //todo 로그인 시 사용
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return null;
    }

}

회원정보를 DB에 넣을 때, 비밀번호를 암호화 하기위해 SecurityConfig파일을 작성 후 PasswordEncoder를 빈으로 설정하겠습니다.

SecurityConfig에서는 비밀번호 암호화 이외에도 여러 security관련 설정을 지원힙니다.

@Configuration
@EnableWebSecurity 
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public PasswordEncoder getPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

}
  • @EnableWebSecurity 에노테이션은 @Configuration 클래스에 WebSecurityConfigurerAdapter를 확장하거나 WebSecurityConfigurer를 정의하여 보안을 활성화 하는 역할을 합니다.

  • @EnableGlobalMethodSecurity(prePostEnabled = true)은 추 후에 @PreAuthorize 를 이용하기 위함입니다.

🔐 @EnableWebSecurity

@Retention(value = java.lang.annotation.RetentionPolicy.RUNTIME)
@Target(value = { java.lang.annotation.ElementType.TYPE })
@Documented 
@Import({ WebSecurityConfiguration.class,
		SpringWebMvcImportSelector.class,
		OAuth2ImportSelector.class })
@EnableGlobalAuthentication
@Configuration
public @interface EnableWebSecurity {
	boolean debug() default false;
}

EnableWebSecurity의 구현을 보면 WebSecurityConfigurationimport되어있을음 알 수 있습니다.

저는 추가적으로 h2 DB에 접근하기 위한 설정을 SecurityConfig에 추가적으로 넣어줬습니다.

HttpSecurityhttp요청에 대해 웹기반 보안기능을 구성할 수 있습니다.

@Override
    protected void configure(HttpSecurity http) throws Exception {

        http
                .headers().frameOptions().disable();
        http
                .csrf().disable();
        http
                .authorizeRequests()
                .antMatchers("/h2-console/**").permitAll();
    }

마지막으로 controller를 작성하겠습니다.

AuthController

@RestController
@RequiredArgsConstructor
public class AuthController {

    private final MemberService memberService;

    @PostMapping("/signUp")
    public String signUp(@RequestBody Member member){
        memberService.singUp(member);
        return "ok";
    }
}



step3 - 로그인

로그인이 성공하면 JWT token을 부여하는 방식으로 진행하겠습니다.

아래는 login 요청이 들어왔을 때의 절차 입니다.

  1. 요청이 들어오면 AbstractAuthenticationProcessingFilter에 들어가게 됩니다.
  2. 그 다음 filterattemptAuthenticationg메소드를 통해 유저의 정보가 담긴 Authentication객체(인증 전)를 AuthenticationManager에 전달합니다.
    • Authentication객체는 UsernamePasswordAuthenticationToken을 통해 만듭니다.
  3. 내부적으로 Spring SecurityProviderManager를 통해 적잘한 AuthenticationProvider를 찾습니다.
  4. AuthenticationProviderauthenticate메소드로 인증을 진행합니다.
  5. 인증에 성공했다면 성공한 Authentication객체(인증 후)를 filter에 다시 반환해 authenticationSuccessHandler를 수행합니다.
  6. authenticationSuccessHandler를 통해 jwt token을 발급하고 response를 채워줍니다.

먼저 filterprovider를 구현하기 전에 몇가지 작업을 해야합니다.

LoginMemberDto

@Data
public class LoginMemberDto {
    String email;
    String password;
}

단순한 emailpassword를 받는 dto입니다.

SecurityMember

public class SecurityMember extends User {


    public SecurityMember(String email, String password, Collection<? extends GrantedAuthority> authorities) {
        super(email, password, authorities);
    }

    public static SecurityMember getMemberDetails(Member member) {
        return new SecurityMember(member.getEmail(),member.getPassword(),parseAuthorities(member.getRole()));
    }

    private static List<SimpleGrantedAuthority> parseAuthorities(MemberRole role) {
        return Arrays.asList(role).stream()
                .map(r -> new SimpleGrantedAuthority(r.getRoleName()))
                .collect(Collectors.toList());
    }
    
    public String getRole(){
            return getAuthorities().stream().findFirst().get().getAuthority();
    }
}

회원정보를 가지고 있는 인증객체인 userdetails를 구현해야합니다.

이미 Member라는 유저 객체가 있는데 UserDetails는 뭔가요?

UserDetails는 인증 객체로서 사용자 정보를 저장합니다. <--는 javadoc에서 발최한 부분으로 더욱 직관적으로 설명하자면 로그인할 때 필요한 UserDetailsServiceloadUserByUsername함수를 보시면 반환값이 UserDetails인 것을 볼 수 있습니다. 이렇듯 springsecurity 에서는 하나의 규격화된 UserDetails인터페이스를 상속 받은 클래스를 사용자로 인식하고 인증합니다.

Userorg.springframework.security.core.userdetails.User으로 User클래스를 보시면 UserDetails가 상속되어 있습니다. UserDetails를 직접 SecurityMember에 상속하여 구현해도 되지만 UserDetails는 interface로 구성되어 있어 모든 함수를 override해야합니다. 그러므로 User를 상속받는 방법으로 진행하겠습니다.

UserDtails를 구성할 때 roleCollection<GrantedAuthority>으로 넘겨줘야합니다. 그래서 parseAuthorities메소드를 만들어 뒀습니다. 저희는 role을 하나만 가지고 있다고 가정하고 파싱하겠습니다.

MemberService

인증을 할 때 UserDetailsServiceloadUserByUsername(String username)DB에서 유저정보를 가져오게 됩니다. 그러므로 UserDetailsService를 상속받은 MemberServiceloadUserByUsername를 구현합니다.

public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
    Member member = memberRepository.
                    findByEmail(email).
                    orElseThrow(() -> new UsernameNotFoundException("Have no registered members"));
            
    return SecurityMember.getMemberDetails(member);
}

JwtSettings

@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "jwt", ignoreInvalidFields = true)
public class JwtSettings {

    private String tokenIssuer;
    private String tokenSigningKey;

}

application.propertiesjwt.으로 시작하는 값들을 가져와 각 변수에 setting해줍니다.

application.properties 설정

jwt.tokenIssuer=yerin
jwt.tokenSigningKey=abcdefg

JwtFactory

@Slf4j
@Component
public class JwtFactory {

    @Autowired
        private JwtSettings jwtSettings;
    
        /*
         * 유저의 권한정보로 토큰을 만듬(claim에는 여러 정보가 올 수 있다.)
         * */
        public String generateToken(SecurityMember securityMember) {
            String token;
    
            token = JWT.create()
                    .withIssuer(jwtSettings.getTokenIssuer())
                    .withClaim("EMAIL", securityMember.getUsername())
                    .withClaim("ROLE",securityMember.getRole())
                    .sign(Algorithm.HMAC256(jwtSettings.getTokenSigningKey()));
    
            log.info("token -- "+token);
    
            return token;
    
        }

}

JWT token생성을 위해 JwtFactory를 만들어줍니다.

드디어 기본적인 작업이 끝났습니다.👏👏

다음으로는 요청이 들어오는 처음단계인 AbstractAuthenticationProcessingFilter를 구현하겠습니다.

providerfiltersuccess,failure handler 사이에서 동작하지만 filter구현에 있어서 마지막으로 provider를 작성하도록 하겠습니다.

BasicLoginProcessingFilter

public class BasicLoginProcessingFilter extends AbstractAuthenticationProcessingFilter {

    @Autowired
    private BasicLoginAuthenticationSuccessHandler successHandler;

    @Autowired
    private BasicLoginAuthenticationFailureHandler failureHandler;

    public BasicLoginProcessingFilter(String defaultFilterProcessesUrl) {
        super(defaultFilterProcessesUrl);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        LoginMemberDto loginMemberDto;
        loginMemberDto = new ObjectMapper().readValue(request.getReader(), LoginMemberDto.class);
        UsernamePasswordAuthenticationToken token =
                new UsernamePasswordAuthenticationToken(loginMemberDto.getEmail(),loginMemberDto.getPassword(), Collections.emptyList());

        return this.getAuthenticationManager().authenticate(token);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        successHandler.onAuthenticationSuccess(request, response, authResult);
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        SecurityContextHolder.clearContext();
        failureHandler.onAuthenticationFailure(request, response, failed);
    }
}

우리는 필터의 생성자의 파라미터로 url을 받습니다. url을 받는 2가지 방법이 있는데 하나는 위의 예제와 같이 String으로 받는 방법이 있고 또하나는 RequestMatcher로 받는 방법입니다.

RequestMatcher로 받는 경우 RequestMatcher interface를 구현하여 RequestMatcher에서 미리 정의한 Request pattern들로 요청을 판별합니다.

요청이 들어왔다면 attemptAuthenticationg메소드를 통해 유저의 정보가 담긴 Authentication객체(인증 전)를 AuthenticationManager에 전달합니다.(인증절차 2번의 내용)

여기서 사용하는 UsernamePasswordAuthenticationToken으로 Authentication객체를 만드는데 UsernamePasswordAuthenticationToken의 어떤생성자를 부르느냐에 따라 인증 전 Authentication를 만드는지 인증 후 Authentication을 만드는지 결정합니다.

BasicLoginAuthenticationSuccessHandler

@Component
public class BasicLoginAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    @Autowired
    private ObjectMapper objectMapper;
    @Autowired
    private JwtFactory jwtFactory;


    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {      
        SecurityMember securityMember = (SecurityMember) authentication.getPrincipal();
        String token = jwtFactory.generateToken(securityMember);
        TokenDto tokenDto = new TokenDto(token);

        makeResponse(response,tokenDto);
    }

    private void makeResponse(HttpServletResponse response, TokenDto tokenDto) throws IOException {
        response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
        response.setStatus(HttpStatus.OK.value());
        response.getWriter().write(objectMapper.writeValueAsString(tokenDto));
    }
}

인증에 성공했다면 AuthenticationSuccessHandler를 통해 토큰값을 주고 맞는 response값을 채워줍니다.

BasicLoginAuthenticationFailureHandler

@Component
public class BasicLoginAuthenticationFailureHandler implements AuthenticationFailureHandler {

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException {
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(exception.getMessage());
    }
}

인증에 실패했다면 AuthenticationFailureHandler를 통해 실패했다는 response값을 채워줍니다.

이제 마지막으로 provider를 만들어 주겠습니다.

public class BasicLoginSecurityProvider implements AuthenticationProvider {

    @Autowired
    private MemberService memberService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String email = (String) authentication.getPrincipal();
        String password = (String) authentication.getCredentials();

        SecurityMember member = (SecurityMember) memberService.loadUserByUsername(email);

        if (!passwordEncoder.matches(password, member.getPassword())) {
            throw new BadCredentialsException("password is incorrect");
        }

        return new UsernamePasswordAuthenticationToken(member, password, member.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

AuthenticationProvider를 상속받으면 authenticatesupports메소드를 구현해야합니다.

  • authenticate에서 userdetailserviceloadUserByUsername(String username)으로부터 유저정보를 가져와 올바른 인증을 하게됩니다.
  • supports는 이 AuthenticationProvider가 표시된 Authentication객체를 지원하는 경우 true를 반환합니다.

이제 정말 마지막으로 SecurityConfig에 등록하면 됩니다.

filter를 등록하기 전에 filter에 관하여 간락하게 설명하겠습니다.

Spring security는 약 10가지의 필터를 순회하여 알맞은 응답값을 찾습니다. 이 10가지 필터는 security에서 기존에 정해놓은 filter들로서 만약 우리가 위의 로그인과같이 filter를 커스텀한다면 spring securityfilterChainProxy에 등록을 시켜주어야합니다.

그 방법으로는 두가지 방법이 있습니다.

  1. 기본 tomcat의 필터에 등록하기
  2. spring sececurity에 등록하기

filter를 등록하기 전에 filter에 관하여 간락하게 설명하겠습니다.

Spring security는 약 10가지의 필터를 순회하여 알맞은 응답값을 찾습니다. 이 10가지 필터는 security에서 기존에 정해놓은 filter들로서 만약 우리가 위의 로그인과같이 filter를 커스텀한다면 spring securityfilterChainProxy에 등록을 시켜주어야합니다.

그 방법으로는 두가지 방법이 있습니다.

  1. 기본 tomcat의 필터에 등록하기
  2. spring sececurity에 등록하기

🔐** FilterChainProxy 中 **

@Override
		public void doFilter(ServletRequest request, ServletResponse response)
				throws IOException, ServletException {
			if (currentPosition == size) {
				if (logger.isDebugEnabled()) {
					logger.debug(UrlUtils.buildRequestUrl(firewalledRequest)
							+ " reached end of additional filter chain; proceeding with original chain");
				}

				// Deactivate path stripping as we exit the security filter chain
				this.firewalledRequest.reset();

                //기존 필터 순회
				originalChain.doFilter(request, response);
			}
			else {
				currentPosition++;

				Filter nextFilter = additionalFilters.get(currentPosition - 1);

				if (logger.isDebugEnabled()) {
					logger.debug(UrlUtils.buildRequestUrl(firewalledRequest)
							+ " at position " + currentPosition + " of " + size
							+ " in additional filter chain; firing Filter: '"
							+ nextFilter.getClass().getSimpleName() + "'");
				}

                //spring security 필터 순회
				nextFilter.doFilter(request, response, this);
			}
		}

위의 코드를 보면 originalChain.doFilter(request, response);nextFilter.doFilter(request, response, this);를 보실 수 있습니다. originalChain.doFilter(request, response);은 기본 tomcat에 등록된 기본적인 filter들이 돌아가고 nextFilter.doFilter(request, response, this);spring security에 사용되는 filter들이 돌아갑니다.

filter가 작동되는 순서는 아주 중요하며 순서가 바뀌었을 시 그 결과값도 바뀔 수 있음으로 filternextFilter에서 돌아가도록 해주어야합니다.

그 방법으로는 configure(HttpSecurity http)addFilterBefore(basicLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class) 를 추가해 주는 것입니다.

위의 코드를 보면 originalChain.doFilter(request, response);nextFilter.doFilter(request, response, this);를 보실 수 있습니다. originalChain.doFilter(request, response);은 기본 tomcat에 등록된 기본적인 filter들이 돌아가고 nextFilter.doFilter(request, response, this);spring security에 사용되는 filter들이 돌아갑니다.

filter가 작동되는 순서는 아주 중요하며 순서가 바뀌었을 시 그 결과값도 바뀔 수 있음으로 filternextFilter에서 돌아가도록 해주어야합니다.

그 방법으로는 configure(HttpSecurity http)addFilterBefore(basicLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class) 를 추가해 주는 것입니다.

SecurityConfig

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    JwtAuthenticationProvider jwtAuthenticationProvider;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }


    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http
                .headers().frameOptions().disable();
        http
                .csrf().disable();
        http
                .authorizeRequests()
                .antMatchers("/h2-console/**").permitAll();
        http
                .addFilterBefore(basicLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(jwtLoginProcessingFilter(),UsernamePasswordAuthenticationFilter.class);

    }

    @Bean
    public BasicLoginSecurityProvider basicLoginSecurityProvider(){
        return new BasicLoginSecurityProvider();
    }

    @Bean
    protected BasicLoginProcessingFilter basicLoginProcessingFilter() throws Exception {
        BasicLoginProcessingFilter filter = new BasicLoginProcessingFilter("/login");
        filter.setAuthenticationManager(super.authenticationManagerBean());
        return filter;
    }

    @Bean
    protected JwtLoginProcessingFilter jwtLoginProcessingFilter() throws Exception{
        FilterSkipPathMatcher matchar = new FilterSkipPathMatcher(Arrays.asList("/login","/signUp"), "/**");
        JwtLoginProcessingFilter filter = new JwtLoginProcessingFilter(matchar);
        filter.setAuthenticationManager(super.authenticationManagerBean());
        return filter;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) {
        auth
                .authenticationProvider(basicLoginSecurityProvider())
                .authenticationProvider(this.jwtAuthenticationProvider);

    }

}

그리고 provider를 주입받고 AuthenticationManagerBuilder를 통해 provider를 등록합니다.

성공했다면 이러한 결과값을

{
  "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ5ZXJpbiIsIkVNQUlMIjoieWVyaW5AeWVyaW4uY29tIn0.G2W_yQ7FQzmT8h6r7rOLHd_IBuW4fGV8SkfYr-6QKtc"
}
Response code: 200; Time: 601ms; Content length: 148 bytes

실패했다면 이러한 결과값을 볼 수 있습니다.

//비밀번호 틀렸을 시
password is incorrect

//회원이 등록되어있지 않았을 시
Have no registered members

Response code: 401; Time: 114ms; Content length: 21 bytes



step4 - 발급받은 jwt으로 로그인

step3에서 발급받은 jwt token으로 인증을 시도해보겠습니다.

절차는 로그인과 비슷함으로 내부적인 동작은 생략한 절차입니다.

  1. 요청이 들어오면 AbstractAuthenticationProcessingFilter에 들어가게 됩니다.
  2. 그 다음 filterattemptAuthenticationg메소드를 통해 header에 있는 token값을 분리해 가져와 Authentication객체(인증 전)에 담고 manager에 전달합니다.
  3. AuthenticationProviderauthenticate메소드로 token에 담겨있는 인증정보를 확인하여 인증을 진행합니다.
  4. 인증에 성공했다면 authenticationSuccessHandler를 통해 SecurityContext를 생성하고 SecurityContextHolder에 보관합니다.

이번 step에도 filter를 구현하기 전에 몇가지 사전 작업을 진행하겠습니다.

FilterSkipMatcher

public class FilterSkipPathMatcher implements RequestMatcher {

    private OrRequestMatcher orRequestMatcher;
    private RequestMatcher requestMatcher;

    public FilterSkipPathMatcher(List<String> pathsToSkip, String processingPath) {

        //건너띌 주소 묶음
        this.orRequestMatcher = new OrRequestMatcher(
                pathsToSkip.stream()
                        .map(AntPathRequestMatcher::new)
                        .collect(Collectors.toList())
        );

        //인증을 진행할 주소
        this.requestMatcher = new AntPathRequestMatcher(processingPath);
    }

    @Override
    public boolean matches(HttpServletRequest request) {
        return !orRequestMatcher.matches(request) && requestMatcher.matches(request);
    }
}

spring security는 모든 요청에 대해 manager에 등록된 모든 필터를 돌게됩니다. 그런데 우리는 jwt token을 이용하여 게시물 정보를 얻는다던가 유저의 프로필 정보를 얻는다던가 하는 여러 api을 사용해야합니다. 유저의 email,password로 로그인을 할 때는 filter"/login"이라는 요청하나만 적용하면 되서 생성자를 통해 string 타입으로 요청을 받고 그 요청에만 filter를 적용할 수 있게 해주었습니다.

그럼 여러 filter의 요청을 "/**"라고 하게되면 /login요청에도 jwt인증 filter가 돌게 되는데 /login요청에는 아직 token이 부여받지 않는 상태라 에러가 나게 됩니다. 이 문제를 해결하기 위해서는 어떻게 해야할까요?

우리는 그 방법으로 step3 filter구현 부분에서 잠깐 설명한 RequestMatcher를 이용할 것입니다. 바로 위의 FilterSkipMatcherRequestMatcher를 이용하여 filter를 거치지 않을 url을 걸러 주는 역할을 합니다.

ReqestMatcher에는 여러 Request pattern들이 있습니다. request pattern 보러가기 그 중 우리가 사용하는 OrRequestMatcher는 여러 요청을 List<String>형식으로 저장할 수 있는 RequestMatcher이며 AntPathRequestMatcher"/books/**"와 같이 ant pattern을 저장할 수 있는 RequestMatcher입니다.

JwtTokenExtractor

@Component
public class JwtTokenExtractor {
    public static final String HEADER_PREFIX = "Bearer ";

    public String extract(final String header) {
        if (StringUtils.isEmpty(header)) {
            throw new AuthenticationServiceException("Authorization header가 없습니다.");
        }

        if (header.length() < HEADER_PREFIX.length()) {
            throw new AuthenticationServiceException("authorization header size가 옳지 않습니다.");
        }

        if (!header.startsWith(HEADER_PREFIX)) {
            throw new AuthenticationServiceException("올바른 header형식이 아닙니다.");
        }

        return header.substring(HEADER_PREFIX.length());
    }
}

jwt tokenheaderAuthorization: Bearer aaa.bbb.ccc이런식으로 담겨옵니다. 우리는 aaa.bbb.ccc이 부분만 가져올 수 있도록하는 JwtTokenExtractor만듭니다. 여기서는 header값이 이상한 값이 들어왔는지 간단한 검사 작업도 진행합니다.

다음으로 filterprovider를 구현하겠습니다.

JwtLoginProcessingFilter

public class JwtLoginProcessingFilter extends AbstractAuthenticationProcessingFilter {

    @Autowired
    private JwtTokenExtractor tokenExtractor;


    public JwtLoginProcessingFilter(RequestMatcher requiresAuthenticationRequestMatcher) {
        super(requiresAuthenticationRequestMatcher);
    }


    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        String tokenPayload = request.getHeader("Authorization");

        UsernamePasswordAuthenticationToken token =
                new UsernamePasswordAuthenticationToken(this.tokenExtractor.extract(tokenPayload),null);

        return super.getAuthenticationManager().authenticate(token);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        //인증에 성공한 경우 해당 사용자에게 권한을 할당
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(authResult);
        //context를 만들고 보관
        SecurityContextHolder.setContext(context);
        //남을 필터들에 대해 다 돌음 (필터를 선택해서 돌수도 있다)
        chain.doFilter(request, response);
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        getFailureHandler().onAuthenticationFailure(request, response, failed);

    }
}

기본적으로 step3과 비슷하지만 각 handler를 따로 구현하지 않았다는 점과 successfulAuthenticationSecurityContext를 생성해준 점이 추가 되었습니다.

JwtAuthenticationProvider

public class JwtAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    private JwtFactory jwtFactory;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String token = (String) authentication.getPrincipal();
        SecurityMember member = jwtFactory.decodeToken(token);
        return new UsernamePasswordAuthenticationToken(member, member.getPassword(), member.getAuthorities());

    }

    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

provider에서 인증은 token을 분석하여 인증후 객체를 만듭니다.

SecurityConfig

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }


    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http
                .headers().frameOptions().disable();
        http
                .csrf().disable();
        http
                .authorizeRequests()
                .antMatchers("/h2-console/**").permitAll();
        http
                .addFilterBefore(basicLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(jwtLoginProcessingFilter(),UsernamePasswordAuthenticationFilter.class);

    }

    @Bean
    public BasicLoginSecurityProvider basicLoginSecurityProvider(){
        return new BasicLoginSecurityProvider();
    }

    @Bean
    public JwtAuthenticationProvider jwtAuthenticationProvider(){
        return new JwtAuthenticationProvider();
    }

    @Bean
    protected BasicLoginProcessingFilter basicLoginProcessingFilter() throws Exception {
        BasicLoginProcessingFilter filter = new BasicLoginProcessingFilter("/login");
        filter.setAuthenticationManager(super.authenticationManagerBean());
        return filter;
    }

    @Bean
    protected JwtLoginProcessingFilter jwtLoginProcessingFilter() throws Exception{
        FilterSkipPathMatcher matchar = new FilterSkipPathMatcher(Arrays.asList("/login","/signUp"), "/**");
        JwtLoginProcessingFilter filter = new JwtLoginProcessingFilter(matchar);
        filter.setAuthenticationManager(super.authenticationManagerBean());
        return filter;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) {
        auth
                .authenticationProvider(basicLoginSecurityProvider())
                .authenticationProvider(jwtAuthenticationProvider());
    }

}

AuthController

@RestController
public class AuthController {

    @Autowired
    private MemberService memberService;

    @PostMapping("/signUp")
    public String signUp(@RequestBody Member member){
        memberService.singUp(member);
        return "ok";
    }

    @GetMapping("/only_user")
    @PreAuthorize("hasRole('ROLE_USER')")
    public String onlyUser(){
        return "hi user";
    }

    @GetMapping("/only_admin")
    @PreAuthorize("hasRole('ROLE_ADMIN')")
    public String onlyAdmin(){
        return "hi admin";
    }
}

###실행결과

GET http://localhost:8080/only_user
Authorization: Bearer aaaa.bbbb.cccc

login해서 받은 토큰값으로 접근을하면

Content-Type: text/plain;charset=UTF-8
Content-Length: 7
Date: Wed, 27 Feb 2019 08:02:58 GMT

hi user

Response code: 200; Time: 91ms; Content length: 7 bytes

와 같은 실행 결과를 받을 수 있습니다.

/only_admin은 따로 실행해보시길바랍니다





❗必부록

모른다면 필수로 봐야하는 부록

step3-참고 JWT란

JWTJson Web Token의 약자로 말 그대로 json으로 제공하는 토큰입니다. 우리는 올바른 정보를 보내온 회원에게 토큰을 부여하고 추가적인 api를 이용할 때 별다른 로그인 없이 토큰을 통해서 권한을 확인할 수 있습니다.

그러면 JWT토큰으로 어떻게 권한을 확인할 수 있을까?

JWT의 기본 구조는

  • Header
  • Payload
  • Signature

이렇게 3 부분으로 나뉩니다. 이 3 부분은 .으로 구분하여 아래와 같은 형식으로 나타납니다.

aaaaaaa.bbbbbbb.zzzzzzz

JWT를 조금 더 살펴보겠습니다.

Header

{
  "alg": "HS256",
  "typ": "JWT"
}

Header에는 암호화 알고리즘(alg)과 토큰의 타입(typ)으로 구성되어있습니다.

Payload

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

Payloadclame으로 구성되어 있습니다. 여기에 유저의 정보를 담습니다. 주의해야할 점은 개인의 민감한 정보를 clame에 담지 않는것 입니다.

JWT토큰은 알고리즘만 알고있다면 해석이 가능함으로 개인정보 유출의 위험이 있습니다.

Signature

SignatureHeader,Payload값을 인코딩하고 secret값으로 해쉬한 암호화 값입니다.

우리가 작성한 코드로 JWT를 어떻게 구성하는지 살펴보겠습니다.

String SECRET = "TheSecret";

token = JWT.create()
​                .withIssuer("yerin")
​                .withClaim("EMAIL", email)
​                .sign(Algorithm.HMAC256(SECRET));
  • SECRETSignature 부분에서 secret값으로 사용됩니다.
  • withIssuerwithClaimPayload에 기록됩니다.

이렇게 구성된 JWT토큰을 디코딩하여 그 정보를 확인하고 인증합니다.

filter에 관하여(작성중)

우리는 지금까지

addFilterBefore를 통해서 필터 등록하기 filter에 @Bean을 붙여 등록하기

@Bean으로 등록했다면 프로젝트가 처음 시작할 때 @Bean검사를 하게되면서 ApplicationFilterChain에 자동 등록되어서 돌아가는데 o.s.security.web.FilterChainProxy에는 등록이 안되어서 로그에 안찍힌거임

About

SpringSecurity가 궁금한 히치하이커를 위한 안내서

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages