스프링 시큐리티 3) - JWT 로그인 방식

TreeSick·2022년 4월 20일
3

스프링시큐리티

목록 보기
3/4

스프링부트에서 세션기반 스프링시큐리티를 구현해보겠습니다~!

스프링 시큐리티 기본 예제

이번 예제는 스프링 시큐리티 1) - 세션방식을 토대로 JWT 관련 코드를 추가한 것입니다.

따라서, 추가된 내용만 설명이 들어가기 때문에 꼭, 1편을 보고 와주세요~~~!!

https://velog.io/@rainbowweb/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-%EC%8A%A4%ED%94%84%EB%A7%81%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0-%EC%84%B8%EC%85%98%EB%B0%A9%EC%8B%9D

JWT 기본 개념

깃헙에 있는 정리본을 참고해주세요!

https://github.com/namusik/TIL-SampleProject/blob/main/Spring%20Boot/%EC%8A%A4%ED%94%84%EB%A7%81%20%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0/JWT%20%EA%B0%9C%EB%85%90.md

전체 소스코드

팔로우와 스타 부탁드립니다~~!

https://github.com/namusik/TIL-SampleProject/tree/main/Spring%20Boot/%EC%8A%A4%ED%94%84%EB%A7%81%20%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0

작업환경

IntelliJ
Spring Boot
java 11
gradle

build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
    implementation 'org.springframework.boot:spring-boot-devtools'
    //jwt
    implementation 'io.jsonwebtoken:jjwt:0.9.1'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    runtimeOnly 'mysql:mysql-connector-java'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

핵심적으로는 spring-security와 jjwt 라이브러리를 추가해줘야 합니다.

application.properties

spring.h2.console.enabled=true
spring.datasource.url=jdbc:h2:mem:springminiprojectdb
spring.datasource.username=sa
spring.datasource.password=

#token secret key
jwt.token.key=abcdefghijklm;

회원 정보를 저장할 DB로 간단한 h2를 사용해줍니다.
(대신 서버가 종료되면 DB도 날라감!)

토큰을 만들 때, 사용할 시크릿키를 적어줍니다. 보통은 이제 gigignore에 올려서 노출이 되지 않게 해야합니다.

WebSecurityConfig

@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
@RequiredArgsConstructor
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtTokenProvider jwtTokenProvider;

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

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/h2-console/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //프론트엔드가 별도로 존재하여 rest Api로 구성한다고 가정
        http.httpBasic().disable();

        //csrf 사용안함
        http.csrf().disable();

        //세선사용 x
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        //URL 인증여부 설정.
        http.authorizeRequests()
                .antMatchers( "/user/signup", "/", "/user/login", "/css/**", "/exception/**", "/favicon.ico").permitAll()
                .anyRequest().authenticated();


        //JwtFilter 추가
        http.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

        //JwtAuthentication exception handling
        http.exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint());

        //access Denial handler
        http.exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler());

    }
}

기존의 세션방식의 Config에서 몇가지 변경된 부분들이 있습니다.

http.httpBasic().disable();

스프링시큐리티에서 만들어주는 로그인 페이지를 안쓰기 위해

http.csrf().disable();

JWT 방식을 제대로 쓰려고 하면, 프론트엔드가 분리된 환경을 가정하고 해야합니다. 

그래서 서버는 Restful한 Api형태가 되는데, 이를 위해 사용해줍니다. 

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

JWT 토큰 방식을 사용하면 더이상 세션저장은 필요없으니 해당 기능을 꺼줍니다.

http.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

JWT Token을 위한 Filter를 아래에서 만들어 줄건데, 
이 Filter를 어느위치에서 사용하겠다고 등록을 해주어야 Filter가 작동이 됩니다. 

http.exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint());

토큰 인증과정에서 발생하는 예외를 처리하기 위한 EntryPoint를 등록해주는 코드입니다.

http.exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler());

인가에 실패했을 때(일반 유저가 관리자 페이지로 접속을 시도할 때와 같은), 
예외를 발생시키는 handler를 등록시켜주는 코드입니다. 

JwtAuthenticationFilter

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // 헤더에서 JWT 를 받아옵니다.
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);

        // 유효한 토큰인지 확인합니다. 유효성검사
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 토큰 인증과정을 거친 결과를 authentication이라는 이름으로 저장해줌.
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            // SecurityContext 에 Authentication 객체를 저장합니다.
            // token이 인증된 상태를 유지하도록 context(맥락)을 유지해줌
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        //UsernamePasswordAuthenticationFilter로 이동
        chain.doFilter(request, response);
    }
}

JWT 방식은 세션방식과 다르게 Filter 하나를 추가해줘야합니다.

이제 사용자가 로그인을 했을 때, Request에 가지고 있는 Token을 해석해주는 로직이 필요하기 때문입니다.

이 역할을 하는 것이 JWTAuthenticatonFilter.

세부 비즈니스 로직들은 전부 JwtTokenProvider에 적어줍니다. 일종의 Service 클래스라고 생각하면 됩니다.

  1. 먼저, 사용자의 Request Header에 토큰을 가져옵니다.

  2. 해당 토큰의 유효성검사를 실시해서 유효하면

  3. Authentication 인증객체를 만들고

  4. ContextHolder에 저장해 줍니다.

  5. 해당 Filter 과정이 끝나면 이제 시큐리티에 다음 Filter로 이동하게 되죠.

UserController

    @PostMapping("/user/login")
    @ResponseBody
    public String login(LoginUserDto loginUserDto, HttpServletResponse response) {

        User user = userService.login(loginUserDto);
        String checkEmail = user.getEmail();
        UserRoleEnum role = user.getRole();

        String token = jwtTokenProvider.createToken(checkEmail, role);
        response.setHeader("JWT", token);

        return token;
    }

세션 방식과 다르게 login과정을 직접 구현해줘야 합니다.

왜냐하면 로그인을 요청하면 동시에 JWT 토큰을 만들어서 반환해줘야 하기 때문이죠.

전달받은 아이디와 패스워드를 가지고 실제 DB에 존재하는 유저인지 확인 후, User 객체로 반환을 해줍니다.

그리고 User의 이메일과 권한을 추출해 토큰을 만들어주는 작업을 해줍니다.

그리고 만들어진 토큰을 응답에 커스텀헤더로 만들어서 프론트엔드로 전달해줍니다.

제대로 된 프로젝트라면 프론트엔드에서 응답을 받은 후, 페이지로 넘어가겠지만 여기서는 일단 토큰을 반환해서 화면에 띄워보겠습니다.

JwtTokenProvider

@Component
@RequiredArgsConstructor
@Slf4j
public class JwtTokenProvider {
    private final UserDetailsServiceImpl userDetailsServiceImpl;

    @Value("${jwt.token.key}")
    private String secretKey;

    //토큰 유효시간 설정
    private Long tokenValidTime = 240 * 60 * 1000L;

    //secretkey를 미리 인코딩 해줌.
    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    //JWT 토큰 생성
    public String createToken(String email, UserRoleEnum role) {

        //payload 설정
        //registered claims
        Date now = new Date();
        Claims claims = Jwts.claims()
                .setSubject("access_token") //토큰제목
                .setIssuedAt(now) //발행시간
                .setExpiration(new Date(now.getTime() + tokenValidTime)); // 토큰 만료기한

        //private claims
        claims.put("email", email); // 정보는 key - value 쌍으로 저장.
        claims.put("role", role);

        return Jwts.builder()
                .setHeaderParam("typ", "JWT") //헤더
                .setClaims(claims) // 페이로드
                .signWith(SignatureAlgorithm.HS256, secretKey)  // 서명. 사용할 암호화 알고리즘과 signature 에 들어갈 secretKey 세팅
                .compact();
    }

    //JWT 토큰에서 인증정보 조회
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsServiceImpl.loadUserByUsername(this.getUserPk(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    // 토큰에서 회원 정보 추출
    public String getUserPk(String token) {
        return (String) Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().get("email");
    }

    // Request의 Header에서 token 값을 가져옵니다. "Authorization" : "TOKEN값'
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("JWT");
    }

    // 토큰의 유효성 + 만료일자 확인  // -> 토큰이 expire되지 않았는지 True/False로 반환해줌.
    public boolean validateToken(String jwtToken) {
        try {
            Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken).getBody();

            return !claims.getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }
}

Jwt Token 인증 로직을 구현한 일종의 Service 클래스.

JWT 토큰과 관련된 로직들을 상세 구현해 놓았습니다.

init()

application.properties에 저장했던 시크릿키를 암호화로 사용하기 위해 미리 인코딩해주는 작업입니다. 

createToken()은 이제 로그인 시도 시 호출되는 메서드입니다.

토큰을 만들 때, 핵심적으로 사용자 id, 사용자 권한, 토큰 만료시간이 필요합니다. 

토큰을 만드는 코드는 정해져 있으므로 이처럼 만들면 됩니다. 

그리고 application.properities에 저장했던 secretkey가 여기서 사용됩니다. 

이제 나머지 메서드들은 JWTfilter에서 사용되는 것들입니다.

resolveToken()

로그인 후, 토큰을 발행해주면 프론트에서는 이제 모든 요청마다 헤더에 토큰을 담아서 보내게 됩니다. 

그 토큰을 헤더에서 가져오는 역할을 하는 메서드입니다. 

참고로 프론트와 커스텀헤더의 이름을 서로 정해주시면 됩니다. 여기서는 "JWT"로 하였습니다. 

validateToken()

토큰을 받아오면 이제 유효성 검사를 해줘야 합니다. 

토큰을 만들 때 설정한 만료기간과 지금의 시간을 비교해서 만료됐다면 false를 반환해줍니다. 

그 외에 exception이 일어나도 false를 반환합니다. 

getAuthentication()

토큰이 유효하다면 이제 이 토큰에 저장한 유저 식별값(여기서는 "email"입니다)을 추출해줍니다.

이 식별값을 세션과 동일하게 loadUserByUsername으로 UserDetails 객체에 담아줍니다.

그리고, Authentication 타입으로 반환합니다. 

UserService

    //로그인
    public User login(LoginUserDto loginUserDto) {
        User user = userRepository.findByEmail(loginUserDto.getEmail()).orElseThrow(
                () -> new CustomException(ErrorCode.NO_USER)
        );
        if (!passwordEncoder.matches(loginUserDto.getPassword(), user.getPassword())) {
            throw new CustomException(ErrorCode.NO_USER);
        }
        return user;
    }

Controller의 로그인 과정 로직입니다.

전달받은 이메일을 가지고 DB에서 찾은 후, 비밀번호를 디코딩해서 검사해줍니다.

CustomAuthenticationEntryPoint

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.sendRedirect("/exception/entrypoint");
    }
}

이 EntryPoint의 역할은 Filter에서 토큰 관련 예외가 발생했을 경우, 이를 servlet 단계로 보내줘서

예외를 처리해주기 위함입니다.

예외가 발생했을 때, Controller에 있는 해당 URI로 전달해주게 됩니다.

CustomAccessDeniedHandler

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.sendRedirect("/exception/access");
    }
}

이 deniedHandler는 권한이 없는 사용자가 해당 페이지를 접속할때 발생하는 예외를 서블렛 단계로 보내주기 위해 사용합니다.

역시 해당 URI의 controller로 전달됩니다.

TokenExceptionController

@RestController
public class TokenExceptionController {
    @GetMapping("/exception/entrypoint")
    public void entryPoint() {
        throw new CustomException(ErrorCode.NO_LOGIN);
    }

    @GetMapping("/exception/access")
    public void denied() {
        throw new CustomException(ErrorCode.NO_ADMIN);
    }
}

entrypoint와 deniedhanlder에서 전달받은 예외들을 처리해주는 controller입니다.

저는 CustomException을 만들어서 정해진 형식으로 예외를 반환해주고 있습니다.

이 부분은 세션방식과 동일하게 처리하였습니다.

ErrorCode

@Getter
@AllArgsConstructor
public enum ErrorCode {
    ADMIN_TOKEN(HttpStatus.BAD_REQUEST, "관리자 암호가 일치하지않습니다"),
    SAME_EMAIL(HttpStatus.BAD_REQUEST, "동일한 이메일이 존재합니다."),
    NO_USER(HttpStatus.BAD_REQUEST, "없는 사용자입니다."),
    NO_LOGIN(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다"),
    NO_ADMIN(HttpStatus.FORBIDDEN, "권한이 없는 사용자입니다");

    private HttpStatus httpStatus;
    private String detail;
}

참고로, NO_LOGIN이 entrypoint가 처리하는 예외처리

NO_ADMIN이 deniedHanlder가 처리하는 예외처리 입니다.

나머지

이외에 html과 예외처리 부분은 상단에 있는 깃허브 링크에서 참고해주시면 감사하겠습니다.

실행결과

먼저 회원가입을 진행해주고

로그인을 해주면

이렇게 AccessToken을 반환해줍니다.

여기서부터는 Postman을 켜서 수동으로 직접 토큰을 요청 헤더에 넣어주어야 합니다.

만약 토큰이 유효하다면 로그인이 된것으로 인식해 정상적으로 메인페이지를 내려줄 것입니다.

전달받은 토큰을 헤더에 "JWT"라는 (서로 약속한 이름으로 해줘야합니다.) 이름으로 넣고

메인페이지 URI로 요청을 보내면

이렇게 로그인한 유저의 이름과 권한을 보여주는 메인페이지를 응답으로 받을 수 있습니다.

만약 토큰이 없는 요청을 하거나, 토큰이 변형이 생긴다면

이런 메인페이지가 보여지게 됩니다.

그럼 이제 권한이 없는 일반사용자가 관리자 페이지로 접속을 시도한다면 어떻게 될까요?

원래라면 프론트엔드에서 예외처리 응답을 받은 후, 별도의 페이지로 리다이렉트를 시켜야 할텐데

이렇게 정해진 형식에 맞춰서 어떤 오류가 발생했는지 응답을 해주게 됩니다.

이 부분이 바로 deniedHanlder가 처리하는 부분입니다.


그럼 만약 토큰이 헤더에 없거나, 변형이 가해진 토큰을 가지고 요청을 보내면 어떻게 될까요

401코드를 보내고 로그인이 필요하다는 msg를 보내게 됩니다.

이 부분을 처리하는 것이 entrypoint입니다.

후기

어떻게 보면 가장 최신의 방식인 JWT를 가지고 스프링시큐리티를 구현해보았습니다.

매우 기본적인 로직만 구현이 된 것이고 저도 아직 공부가 필요합니다

다음 편에서는 이제 더 고도화된 Refresh Token을 사용하는 방식으로 찾아오겠습니다. 감사합니다~!

참고

https://webfirewood.tistory.com/115

https://daddyprogrammer.org/post/636/springboot2-springsecurity-authentication-authorization/

https://bcp0109.tistory.com/301

profile
깃헙에 올린 예제 코드의 설명을 적어놓는 블로그

0개의 댓글