JWT(Json Web Token) - 3

수정이·2022년 8월 22일
0

security-jwt

목록 보기
7/7
post-thumbnail

로그인 로직 만들기

시큐리티를 이용해서 로그인 로직을 만들어 보겠다.
로그인 로직을 만드는 이유는 시큐리티를 통해 권한관리를 해주기 위해서다.
(권한 -> 역할(ROLE_USER같은거))

시큐리티에서 로그인을 하기 위해서는 UserDetails를 구현한 PrincipalDetails를 만들었다.
그 부분하고 똑같다. 코드만 보여주고 추가 설명은 생략하겠다.

public class PrincipalDetails implements UserDetails {

    private User user;

    public PrincipalDetails(User user) {
        this.user = user;
    }

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

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

    @Override
    public String getUsername() {
        return user.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;
    }
}
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        System.out.println("PrincipalDetailsService.loadUserByUsername");
        User userEntity = userRepository.findByUsername(username);
        return new PrincipalDetails(userEntity);
    }
}

Spring Security만 사용했을 때는 /login 요청이 오면 PrincipalDetailsServiceloadUserByUsername이 실행되서 회원가입이 된 사용자인지 판별 후에 PrincipalDetails 타입의 객체를 Authentication타입의 객체에 넣고, Security Session에 저장하였다.

하지만 JWT를 사용하기 위해 formLogin()을 미사용으로 설정했다.
formLogin()을 사용해야 UsernamePasswordAuthenticationFilter가 실행되서 로그인 처리를 해준다.

그래서 UsernamePasswordAuthenticationFilter을 상속받은 필터를 만들어서 대신 로그인을 진행시켜 줄 것이다.

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        return null;
    }
}

attemptAuthentication 메소드는 /login요청을 하면 로그인을 위해 실행되는 메소드 이다.
attemptAuthentication 메소드안에 다음과 같이 코드를 추가할 것이다.

1. 사용자의 username, password를 받는다.
2. username, password가 정상인지 로그인 시도를 한다. authenticationManager로 로그인 시도를 하면
PrincipalDetailsService의 loadUserByUsername 메소드가 실행된다.
3. 반환받은 PrincipalDetails를 시큐리티 세션에 담는다.(Authentication에 넣은 후에)
(세션에 담지 않으면 권한 관리가 안된다.)
4. JWT 토큰을 만들어서 응답해준다.

이런 식으로 코드를 짜서 로그인을 진행시켜 줄 것이다.

SecurityConfigJwtAuthenticationFilter를 추가 시켜 준다.

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

    private final CorsFilter corsFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.addFilterBefore(new MyFilter3(), BasicAuthenticationFilter.class);
        http.csrf().disable();
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                (생략)
                .addFilter(new JwtAuthenticationFilter(authenticationManager()))
                (생략)
                .anyRequest().permitAll();
    }
}

authenticationManager가 로그인을 도와주기 때문에 매니저를 사용하기 위해서WebSecurityConfigurerAdapter를 상속 받았다.
그리고 JwtAuthenticationFilter에 파라미터로 넣어주었다.

회원가입 컨트롤러

formLogin 없이 회원가입을 하는 방법은 다음과 같다.
RestApiController를 만들어준다.

@RestController
@RequestMapping("api/v1")
@RequiredArgsConstructor
public class RestApiController {
	
	private final UserRepository userRepository;
	private final BCryptPasswordEncoder bCryptPasswordEncoder;

	@GetMapping("home")
	public String home() {
		return "<h1>home</h1>";
	}
	
	@PostMapping("join")
	public String join(@RequestBody User user) {
		user.setPassword(bCryptPasswordEncoder.encode(user.getPassword()));
		user.setRoles("ROLE_USER");
		userRepository.save(user);
		return "회원가입완료";
	}
}

home 메소드는 모든 사람이 접근 가능한 메소드이다.

JWT 토큰 만들기

이제 본격적으로 로그인한 사람이 회원가입을 한 사용자인지 아닌지 판별하고, 회원가입을 한 사용자면 JWT를 만들어서 사용자에게 리턴해주는 코드를 만들 것이다.

그 전에 코드 중복을 피하기 위해 다음과 같이 클래스를 만들어 준다.

public interface JwtProperties {
	String SECRET = "jwt"; // 우리 서버만 알고 있는 비밀값
	int EXPIRATION_TIME = 864000000; // 10일 (1/1000초)
	String TOKEN_PREFIX = "Bearer ";
	String HEADER_STRING = "Authorization";
}

JwtAuthenticationFilter는 다음과 같다.

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter{

	private final AuthenticationManager authenticationManager;

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

		ObjectMapper om = new ObjectMapper();
		LoginRequestDto loginRequestDto = null;
		try {
			loginRequestDto = om.readValue(request.getInputStream(), LoginRequestDto.class);
		} catch (Exception e) {
			e.printStackTrace();
		}
	
		UsernamePasswordAuthenticationToken authenticationToken = 
				new UsernamePasswordAuthenticationToken(
						loginRequestDto.getUsername(), 
						loginRequestDto.getPassword());
	
		Authentication authentication = 
				authenticationManager.authenticate(authenticationToken);

		return authentication;
	}

	@Override
	protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
			Authentication authResult) throws IOException, ServletException {
		
		PrincipalDetails principalDetailis = (PrincipalDetails) authResult.getPrincipal();
		
		String jwtToken = JWT.create()
				.withSubject(principalDetailis.getUsername())
				.withExpiresAt(new Date(System.currentTimeMillis()+JwtProperties.EXPIRATION_TIME))
				.withClaim("id", principalDetailis.getUser().getId())
				.withClaim("username", principalDetailis.getUser().getUsername())
				.sign(Algorithm.HMAC512(JwtProperties.SECRET));
		
		response.addHeader(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX+jwtToken);
	}
	
}

"/login" 요청이 오면 attemptAuthentication 메소드가 실행된다. 클라이언트 서버에서 request의 body안에 json 형태로 아이디와 비밀번호를 보낸다.
그러면 우리는 그 정보를 받아야한다. 그러기위해서 ObjectMapper를 사용하였다.

ObjectMapper를 사용해서 loginRequestDto에 아이디와 비밀번호를 저장한다.
그리고 그 정보를 가지고 UsernamePasswordAuthenticationToken를 통해서
Security Session에 들어갈 수 있는 Authentication에 들어갈 토큰을 만들어준다. 여기서 토큰은 JWT와 달리 아이디와 비밀번호가 들어있는 토큰이다.

그리고 토큰을 통해 authenticationManager.authenticate(authenticationToken)을 호출하면 "인증 Provider"가 UserDetailsService의 "loadUserByUsername"을 토큰에 들어있는 아이디를 통해 호출하고, UserDetails를 리턴받는다.

리턴받은 UserDetails와 토큰에 들어있는 패스워드를 비교해서 동일하면 Authentication 객체를 만들어서 필터체인으로 리턴해준다.

JWT 만들어 주기

맨 밑에 있는 successfulAuthentication 메소드는 인증이 성공하면 실행되는 메소드이다. 이 메소드에서 JWT를 만들어 준다.

withSubject : 토큰의 제목을 정해주는 것인데 아무렇게나 정해도 된다.

withExpiresAt : 토큰의 만료시간을 정해준다. 발행됐을 때부터 30분간 유효하다. 나중에 만료되면 로그인을 다시 해줘야한다.

withClaim : JWT의 Payload 부분에 들어가는 정보이다.

sign : HMAC512 알고리즘으로 암호화 해준다.


JWT로 인증 해주기

이제 클라이언트 서버에서 요청이 왔을 때, JWT가 있는지 없는지 판단해서 인증(or권한)이 필요한 URL이면 JWT를 검사해서 있으면 통과시키고 없으면 막아버리는 코드를 만들 것이다.

시큐리티는 여러 Filter를 가지고 있는데 그 필터 중에서 BasicAuthenticationFilter라는 필터가 있다. 이 필터는 모든 요청이 거치게 되는 필터인데 이 필터를 통해서 인증 처리를 해줄 것이다.

JwtAuthorizationFilter

public class JwtAuthorizationFilter extends BasicAuthenticationFilter{
	
	private UserRepository userRepository;
	
	public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserRepository userRepository) {
		super(authenticationManager);
		this.userRepository = userRepository;
	}
	
	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws IOException, ServletException {
            
		String header = request.getHeader(JwtProperties.HEADER_STRING);
		if(header == null || !header.startsWith(JwtProperties.TOKEN_PREFIX)) {
			chain.doFilter(request, response);
                        return;
		}
        
		String token = request.getHeader(JwtProperties.HEADER_STRING)
				.replace(JwtProperties.TOKEN_PREFIX, "");
                
		String username = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET)).build().verify(token)
				.getClaim("username").asString();
		
		if(username != null) {	
			User user = userRepository.findByUsername(username);
			
			PrincipalDetails principalDetails = new PrincipalDetails(user);
			Authentication authentication =
					new UsernamePasswordAuthenticationToken(
							principalDetails, //나중에 컨트롤러에서 DI해서 쓸 때 사용하기 편함.
							null, // 패스워드는 모르니까 null 처리, 어차피 지금 인증하는게 아니니까!!
							principalDetails.getAuthorities());
			
			// 강제로 시큐리티의 세션에 접근하여 값 저장
			SecurityContextHolder.getContext().setAuthentication(authentication);
		}
	
		chain.doFilter(request, response);
	}
}

UserRepository를 주입받은 이유는 JWT안에 들어있는 "username"이 회원가입됐는지 확인하기 위해서다.

먼저 "Authorizaion"이라는 헤더가 있는지 판단한다. 있으면 그 헤더의 값에서 "Baerer"이라는 문자열을 지워주고 복호화해서 "username"을 빼온다.

그리고 "username"이 있으면 강제로 Authentication 객체를 만들고 시큐리티 세션에 값을 저장한다.

이제 이것을 테스트하는 컨트롤러는 다음과 같다.

RestApiController

@RestController
@RequestMapping("api/v1")
@RequiredArgsConstructor
public class RestApiController {
	
	private final UserRepository userRepository;
	private final BCryptPasswordEncoder bCryptPasswordEncoder;
	
	// 모든 사람이 접근 가능
	@GetMapping("home")
	public String home() {
		return "<h1>home</h1>";
	}
	
	// Tip : JWT를 사용하면 UserDetailsService를 호출하지 않기 때문에 @AuthenticationPrincipal 사용 불가능.
	// 왜냐하면 @AuthenticationPrincipal은 UserDetailsService에서 리턴될 때 만들어지기 때문이다.
	
	// 유저 혹은 매니저 혹은 어드민이 접근 가능
	@GetMapping("user")
	public String user(Authentication authentication) {
		PrincipalDetails principal = (PrincipalDetails) authentication.getPrincipal();
		System.out.println("principal : "+principal.getUser().getId());
		System.out.println("principal : "+principal.getUser().getUsername());
		System.out.println("principal : "+principal.getUser().getPassword());
		
		return "<h1>user</h1>";
	}
	
	// 매니저 혹은 어드민이 접근 가능
	@GetMapping("manager/reports")
	public String reports() {
		return "<h1>reports</h1>";
	}
	
	// 어드민이 접근 가능
	@GetMapping("admin/users")
	public List<User> users(){
		return userRepository.findAll();
	}
	
	@PostMapping("join")
	public String join(@RequestBody User user) {
		user.setPassword(bCryptPasswordEncoder.encode(user.getPassword()));
		user.setRoles("ROLE_USER");
		userRepository.save(user);
		return "회원가입완료";
	}
	
}

추가로 SecurityConfig는 다음과 같다.

@Configuration
@EnableWebSecurity // 시큐리티 활성화 -> 기본 스프링 필터체인에 등록
public class SecurityConfig extends WebSecurityConfigurerAdapter{	
	
	@Autowired
	private UserRepository userRepository;
	
	@Autowired
	private CorsConfig corsConfig;
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http
				.addFilter(corsConfig.corsFilter())
				.csrf().disable()
				.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
			.and()
				.formLogin().disable()
				.httpBasic().disable()
				
				.addFilter(new JwtAuthenticationFilter(authenticationManager()))
				.addFilter(new JwtAuthorizationFilter(authenticationManager(), userRepository))
				.authorizeRequests()
				.antMatchers("/api/v1/user/**")
				.access("hasRole('ROLE_USER') or hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
				.antMatchers("/api/v1/manager/**")
					.access("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
				.antMatchers("/api/v1/admin/**")
					.access("hasRole('ROLE_ADMIN')")
				.anyRequest().permitAll();
	}
}

이상으로 Security + JWT를 마치겠다.


참고

스프링부트 시큐리티 & JWT 강의

0개의 댓글