1. JWT 로그인 환경 설정

  • 이전에 작성한 token 프로젝트에 로그인 관련 코드를 추가한다

 1) 로그인

  • oauth 패키지를 만들고 PrincipalDetails 클래스를 생성한다
    - UserDetails 인터페이스를 implements 한다
    - Member 객체와 생성자를 추가한다
package com.json.token.oauth;

import com.json.token.model.Member;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;

@Data
public class PrincipalDetails implements UserDetails {

    private Member member;

    public PrincipalDetails(Member member) {
        this.member = member;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        member.getRoleList().forEach(n -> {
            authorities.add(() -> n);
        });
        return authorities;
    }

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

    @Override
    public String getUsername() {
        return member.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;
    }
}

 

  • repository 패키지를 만들고 인터페이스 MemberRepository 클래스를 생성한다
    - username을 기준으로 검색할 수 있도록 findByUsername()메서드를 생성한다
package com.json.token.repository;

import com.json.token.model.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    public Member findByUsername(String member);
}

 

  • oauth 패키지에 PrincipalDetailsService 클래스를 생성한다
package com.json.token.oauth;

import com.json.token.model.Member;
import com.json.token.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member memberEntity = memberRepository.findByUsername(username);
        return new PrincipalDetails(memberEntity);
    }
}

 

  • Jwt로 로그인 처리를 하기 위해 filter 패키지에 JwtAuthenticationFilter 클래스를 생성한다
package com.json.token.filter;


import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        System.out.println("login 시도");
        return super.attemptAuthentication(request, response);
    }
}

 

  • JwtAuthenticationFilter 클래스를 실행하기 위해 SecurityConfig.java를 수정한다
    - 이전에는 .addFilter(new JwtAuthenticationFilter(authenticationManager())) 메서드를 통해 쉽게 처리할 수 있었다
    - 하지만 WebSecurityConfigureAdapter가 deprecated되면서 내부에 클래스를 만들어주거나 별도의 처리가 필요하다
    - CustomDsl 내부 클래스를 만들어 .addFilter(new JwtAuthenticationFilter(authenticationManager())) 처리를 통해 해당 필터를 적용한다
	//주석처리  http.addFilterBefore(new FirstFilter(), BasicAuthenticationFilter.class); // 추가
    
    ......
    
    			.and()
                .formLogin().disable()
                .httpBasic().disable()
                .apply(new CustomDsl()) // 추가
                .and()  //추가
                .authorizeRequests()
                
                ......
                
                     return http.build();
    }

	//추가 코드
    public class CustomDsl extends AbstractHttpConfigurer<CustomDsl, HttpSecurity> {

        @Override
        public void configure(HttpSecurity builder) throws Exception {
            AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
            builder
                    .addFilter(corsFilter)
                    .addFilter(new JwtAuthenticationFilter(authenticationManager));
        }
    }

      

 http.addFilterBefore(new FirstFilter(), BasicAuthenticationFilter.class);
 
 필히 주석처리 한다
 안할 경우 postman에서 결과가 나오지 않는다

 

 2) 회원가입

  • 회원가입 로직 처리를 위해 RestApiController 클래스에 코드를 추가한다
@RestController
@RequiredArgsConstructor // 추가
public class RestApiController {

	// 추가
    private final MemberRepository memberRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

		...

	// 추가
    @PostMapping("/join")
    public String join(@RequestBody Member member) {
        member.setPassword(bCryptPasswordEncoder.encode(member.getPassword()));
        member.setRoles("ROLE_USER");
        memberRepository.save(member);
        return "회원 가입 완료";
    }
}

 

  •  BCryptPasswordEncoder 빈 등록을 TokenApplication 클래스에 한다
    - 코드를 작성하면 자동으로 두개의 클래스로 분리되어진다
    - BCryptPasswordEncoder 순환 참조를 막기 위해 JwtApplication에 @Bean으로 등록한다
package com.json.token;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@SpringBootApplication
public class JwtApplication {

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

    public static void main(String[] args) {
        SpringApplication.run(JwtApplication.class, args);
    }
}

 

  • 로그인을 진행하기 위하여 postman에서 POST를 통해 회원가입 요청을 보낸다

 

 3) 회원가입 후 로그인 시도

  • 실행 후 postman으로 body에 username, password를 포함하여 /login post 요청을 합니다.

  • 지금은 username과 password 처리를 하지 않았기 때문에 오류가 발생한다
  • intelliJ 로그를 확인해보면 login 시도(jwAuthenticationFilter가 정상적으로 적용) 후에 오류가 발생하는 것을 확인할 수 있다

  • 이를 해결하기 위해 jwtAuthenticationFilter에서 정상적인 로그인 시도를 하고 JWT 토큰을 만들어서 응답해주는 작업을 진행을 해 본다

 

 4) 사용자 정보 확인

  • request 객체에 사용자 정보가 담겨져 있는데 확인해 보도록 한다
    - JwtAuthenticationFilter 클래스의 코드를 추가한다
    - request에 담긴 정보를 가져와서 출력하는 코드를 추가한다
 try {
            BufferedReader br = request.getReader();

            String input = null;
            while((input = br.readLine()) != null) {
                System.out.println(input);
            }
        } catch (IOException e) {
            e.printStackTrace();;
        }

  • 재실행 후 postman으로 /login POST 요청을 하면 login 시도 문구 다음에 입력된 값을 확인할 수 있다
    - 아래에 오류가 뜨는 것은 정상이다

  • postman으로 /login POST 요청을 할 때 Body에 사용자 정보를 raw, JSON 형태로 보내본다
    - 콘솔에 출력되는형태가 변경되었음을 확인할 수 있다

 

 5) 로그인 처리

  • JwtAuthenticationFilter 클래스의 attemptAuthentication 메서드 코드를 수정한다
    -  기존의 코드를 아래의 코드로 대체한다
    - PrincipalDetailsService의 loadUserByUsername() 메서드가 실행된 후 정상 작동된다면 authentication이 return된다
    - 로그인이 정상적으로 된다면 authentication 객체를 session에 저장한다
    - attemptAuthentication 메서드가 정상적으로 작동하게 되면 successfulAuthentication 메서드를 실행한다
      : 해당 메서드에서 JWT 토큰을 만들어서 요청한 사용자에게 JWT 토큰을 응답으로 돌려준다
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        try {
            ObjectMapper om = new ObjectMapper();
            Member member = om.readValue(request.getInputStream(), Member.class);

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

            Authentication authentication = authenticationManager.authenticate(authenticationToken);

            PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
            return authentication;
        } catch (IOException e) {
            e.printStackTrace();;
        }
        return null;
    }

      - 재실행 후 postman에서 login을 요청해도 정상 작동하지 않는다

 

 6) JWT 토큰 생성 후 전달

  • JwtAuthenticationFilter 클래스의 successfulAuthentication 메서드를 오버라이드 한다
@Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {

        System.out.println("successfulAuthentication");
        PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();

        String jwtToken = JWT.create()
                .withSubject("cos jwt token")
                .withExpiresAt(new Date(System.currentTimeMillis() + (60 * 1000 * 10)))
                .withClaim("id", principalDetails.getMember().getId())
                .withClaim("username", principalDetails.getMember().getUsername())
                .sign(Algorithm.HMAC512("cos_jwt_token"));
        response.addHeader("Authorization", "Bearer " + jwtToken);
    }

  • 재실행 후 postman에서 join으로 먼저 등록하고 login을 POST로 요청한다
    - join 후 login 시도 시 Body에는 어떤 결과도 반영되지 않는다
    - 하지만, Headers에 값을 확인해보면 Authorization - Bearer 값이 들어와 있는 것을 확인할 수 있다
    - 로그인을 성공 했을 때 JWT 토큰을 생성한다
    - 클라이언트에 JWT 토큰을 응답을 통해 보낸다
    - 요청할 때마다 JWT 토큰을 가지고 요청하게 되고 서버는 JWT 토큰이 유효한지 필터를 통해 판단하기만 하면 된다

 

2. JWT 인증권한 테스트

  • Security filter 에서 권한 및 인증이 필요한 주소를 요청 시 BasicAuthenticationFilter를 반드시 진행하게 되어있다
  • 권한이나 인증이 필요하지 않을 경우 BasicAuthenticationFilter는 적용되지 않는다

 1) 인증 권한 테스트

  • 인증 권한이 필요한 url에 접속할 때 특정 필터가 적용되도록 테스트 해 본다
  • filter 패키지에 JwtAuthorizationFilter 클래스를 생성한다
    - doFilterInternal 메서드는 인증이나 권한이 필요한 주소 요청이 있을 때마다 해당 필터를 통하게 되어 있다
package com.json.token.filter;

import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        System.out.println("인증이나 권한이 필요한 주소 요청 됨.");
        super.doFilterInternal(request, response, chain);
    }
}
  • SecurityConfig 클래스에 해당 필터를 적용한다
    - 필터가 적용되면 /api/v1/user , /api/v1/admin 등 접속 권한이 필요한 url에는 모두 로그가 출력되는 것을 확인할 수 있다
 //코드 추가
                    .addFilter(new JwtAuthorizationFilter(authenticationManager));

 

 2) Token을 통한 인증 처리

  • JwtAuthorizationFilter 클래스에 코드를 추가 및 수정한다
    - memberRepository가 필요하여 생성자에 추가했으므로 SecurityConfig에도 수정이 필요하다
    - 요청에 Authorization header 값을 가져와서 토큰을 가지고 있는지 체크한다
    - 토큰이 있더라도 verify() 메서드를 통해 username이 있는지 확인하여 서비스에 등록된 유저인지 확인한다
    - username이 정상적으로 확인된다면 UsernamePasswordAuthenticationToken을 통해 authentication을 설정한다
package com.json.token.filter;

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.json.token.model.Member;
import com.json.token.oauth.PrincipalDetails;
import com.json.token.repository.MemberRepository;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

    //코드 추가
    private MemberRepository memberRepository;

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager, /*코드 추가*/MemberRepository memberRepository) {
        super(authenticationManager);

        //코드 추가
        this.memberRepository = memberRepository;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        System.out.println("인증이나 권한이 필요한 주소 요청 됨.");

        //코드 추가
        String jwtHeader = request.getHeader("Authorization");

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

        String jwtToken = jwtHeader.replace("Bearer ", "");

        String username = JWT.require(Algorithm.HMAC512("cos_jwt_token")).build().verify(jwtToken).getClaim("username").asString();

        if (username != null) {
            Member memberEntity = memberRepository.findByUsername(username);

            PrincipalDetails principalDetails = new PrincipalDetails(memberEntity);
            Authentication authentication = new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authentication);

            chain.doFilter(request, response);
        }

        super.doFilterInternal(request, response, chain);
    }
}

 

  • SecurityConfig 클래스를 수정한다
    - JwtAuthorizationFilter를 수정한 후 실행 시 에러가 발생하는 부분을 수정한다
private final MemberRepository memberRepository;
.......
.addFilter(new JwtAuthorizationFilter(authenticationManager, memberRepository)); // 수정

 

 3) 로그인 및 권한 테스트

  • 권한을 확인하기 위해 RestApiController 클래스에 url을 추가한다
		// 추가
    @GetMapping("/api/v1/user")
        public String user() {
            return "user";
        }
		// 추가
    @GetMapping("/api/v1/admin")
        public String admin() {
            return "admin";
        }

  • Postman에 post 요청으로 http://localhost:8080/join에 회원가입을 요청한다

  • 회원가입 정보를 /login에 post 요청으로 로그인을 시도한다
    - Body의 내용은 공백이 정상이다
    - Header에 Authorization 값이 생성되었으며 그 값을 복사한다

  • postman에서 http://localhost:8080/api/v1/user/ 등 권한이 필요한 url에 Header에 복사한 Bearer token 값을 넣고 Get 요청을 보낸다
    - 'user'라는 결과를 응답한다
    - 다른 권한 url인 admin, manager 등에서는 403 (접근 권한 없음)이 출력된다

 

+ Recent posts