Home JWT 적용
Post
Cancel

JWT 적용

요약

<before>
사진1
사진2

<after>
사진3

기존에는 Controller 에서 form 데이터를 받고 DB 에 회원정보를 조회하여 세션연결을 하는 방식이었다.
이번 개선을 통해 세션 방식에서 JWT 방식으로 변경하고 Spring Security 와 Filter 를 사용하는 방식으로 변경한다.

목차

  • Spring Security 세팅
  • Login Filter 작성 part 1
  • JWT 발급 작성
  • Login Filter 작성 part 2
  • JWT 인증 작성

개요

기존의 로그인 방식은 세션 로그인 방식이며 DB 에 패스워드를 암호화 하지 않은 상태로 저장한다.
세션로그인을 JWT 방식으로 변경하고 패스워드 암호화도 같이 적용한다.

Spring security 및 filter 를 사용해 JWT 를 구현한다.

Spring Security 및 JWT 라이브러리 추가

1
2
3
4
5
6
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'

Spring Config 작성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {

        return configuration.getAuthenticationManager();
    }
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

기본적으로 로그인(Athentication) 할 때 필요한 Bean 두 개를 작성해준다.
Login Filter 에서 사용할 AuthenticationManager 와 비밀번호를 암호화할 때 사용할 BCryptPasswordEncoder 를 등록해줬다.

Security Filter Chain 작성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // 1. csrf 비활성화
        http.csrf(auth -> auth.disable());

        // 2. form 로그인 비활성화
        http.formLogin((auth) -> auth.disable());

        // 3. http basic 인증 방식 disable
        http.httpBasic((auth) -> auth.disable());

        // 4. 요청 경로별 인가 작업
        // login, 회원가입, 아이디 찾기, 비밀번호 찾기는 인증없이 통과할 수 있도록 설정한다.
        http.authorizeHttpRequests((auth) -> auth
                .requestMatchers( "/", "/login", "/register", "findId", "findPw").permitAll()
                .requestMatchers("/admin").hasRole("ADMIN")
                .anyRequest().authenticated());

        // 5. session 을 stateless 로 설정
        http.sessionManagement((session) -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        
        return http.build();
    }

Servlet Filter Chain 에 등록할 Security Filter Chain 을 작성해 Bean 으로 등록한다.
기본적인 세팅만 한다.

회원 정보 Entity 수정

기존 Entity

member_idlogin_idpasswordemailnamequestionanswer
       

수정 후 Entity

member_idlogin_idpasswordemailnamequestionanswerroleaccount_statusaccount_expirepassword_expireaccount_disable_reason
            

기존 member Entity 에 role, account_status, account_expire, password_expire, account_disable_reason 를 추가한다.

  • role : admin, user
  • account_status : active, locked, disabled
  • account_expire : 계정 만료 일자. 오래 접속 안하면 휴면 계정 처리
  • password_expire : 비밀번호 만료 일자. 3개월 이상 변경하지 않으면 로그인 불가
  • account_disable_reason : 계정이 active 상태가 아닌 사유 작성 (이메일 인증을 하지 않았다, 휴면계정 등)

별도의 Entity 로 작성해야 싶었지만 기존의 Entity 에 포함시키기로 했다.

  • 별도의 Entity 로 작성한다면 어떤 기준으로 나눌지 애매했다.
  • email, name, question, answer 을 제외한 속성들은 로그인 인증시 반드시 같이 사용되기 때문에 Entity 를 나눴다가 다시 JOIN 할 이유가 없다 생각했다.

회원가입 로직 수정

1
2
3
4
5
6
7
8
9
10
11
// Before (코드 수정 전)
public boolean signUp(Member member) {
        boolean isExist = memberRepository.existsByLoginIdOrEmail(member.getLoginId(), member.getEmail());
        if(isExist) {
            System.out.println("exist");

            return false;
        }
        memberRepository.save(member);
        return true;
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// After (코드 수정 후)
public boolean signUp(Member member) {
        boolean isExist = memberRepository.existsByLoginIdOrEmail(member.getLoginId(), member.getEmail());
        if(isExist) {
            System.out.println("exist");

            return false;
        }
        member.setRole("USER");
        member.setAccountStatus("active");
        member.setAccountExpire(LocalDateTime.now().plusYears(3));
        member.setPasswordExpire(LocalDateTime.now().plusMonths(3));
        memberRepository.save(member);
        return true;
    }

수정한 Entity 에 맞게 회원가입 로직을 수정했다.(role, status(active), expire).

Login Filter 작성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
    private final AuthenticationManager authenticationManager;

    public LoginFilter(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        // request 에서 username 과 password 를 꺼내 AuthenticationManager 에게 넘겨준다.
        // Authentication Manager 는 적절한 Provider 를 선택하여 인증을 하도록 넘겨준다.
        // Provider 는 받은 값을 UserDetailsService 의 loadUserByUsername 의 리턴값과 비교한다.
        String username = obtainUsername(request);
        String password = obtainPassword(request);

        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null);

        return authenticationManager.authenticate(authToken);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        // authenticationManager.authenticate(authToken); 인증에 성공하면 호출된다.
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        // authenticationManager.authenticate(authToken); 인증에 실패하면 호출된다.
    }
}

Login Filter 의 기본적인 뼈대를 구성했다.
사용자가 입력한 username 과 password 를 Authentication Provider 에게 넘기는 로직까지 작성했다.

Login Filter 등록

1
2
3
4
5
6
7
8
9
    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // 생략

        // 6. Filter 등록
        http.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration)), UsernamePasswordAuthenticationFilter.class);
        
        return http.build();
    }

원래 UsernamePasswordAuthenticationFilter 가 있던 자리에 내가 만든 Login Filter 를 등록한다.

UserDetailsService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    private final MemberRepository memberRepository;

    public UserDetailsServiceImpl(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<Member> byLoginId = memberRepository.findByLoginId(username);

        // 조회된 member 가 있다.
        if(byLoginId.isPresent()) {
            Member member = byLoginId.get();
            return new MemberDetails(member);
        }

        // 조회된 member 가 없다.
        return null;
    }
}

Login Filter 의 authenticationManager.authenticate(authToken); 가 수행되면 Provider 로 값이 전달된다.
Provider 는 DB 에 저장된 값과 비교하기 위해 UserDetailsService 의 loadUserByUsername() 를 호출한다.
따라서 loadUserByUsername() 를 구현해준다.

UserDetails

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
public class MemberDetails implements UserDetails {
    private final Member member;

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

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();

        collection.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return member.getRole();
            }
        });

        return collection;
    }

    public Long getMemberId() {
        return member.getMemberId();
    }

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

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

    @Override
    public boolean isAccountNonExpired() {
        // true : 만료되지 않음
        // false : 만료됨
        LocalDateTime today = LocalDateTime.now();
        LocalDateTime accountExpire = member.getAccountExpire();
        if(today.isBefore(accountExpire))
            return true;
        return false;
    }

    @Override
    public boolean isAccountNonLocked() {
        // true : lock 아님
        // false : locked
        String accountStatus = member.getAccountStatus();
        if(accountStatus.equals("locked"))
            return false;

        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        // true : 만료되지 않음
        // false : 만료됨
        LocalDateTime today = LocalDateTime.now();
        LocalDateTime passwordExpire = member.getPasswordExpire();

        if(today.isBefore(passwordExpire))
            return true;

        return false;
    }

    @Override
    public boolean isEnabled() {
        // true : 활성화 상태
        // false : 비활성화 상태
        String accountStatus = member.getAccountStatus();
        if(accountStatus.equals("active"))
            return true;
        else if(accountStatus.equals("disable"))
            return false;

        return false;
    }
}

UserDetailsService 의 loadUserByUsername() return type 은 UserDetails 이므로 member 를 바로 provider 에게 전달할 수 없다.
member 를 UserDetails 로 만들어주는 DTO 를 만든다.

이제 Provider 가 client 가 보내준 값과 DB 에 보관된 값을 비교할 수 있게 되었다.
인증에 성공하면 successfulAuthentication() 가 호출된다.

참고로 getMemberId() 는 @Override 가 안붙은걸 알 수 있다.
이건 내가 필요해서 직접 정의한 메서드기 때문이다.

successfulAuthentication

1
2
3
4
5
6
7
8
9
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
    // 생략

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        // 인증에 성공하면 호출된다.
        // JWT 를 발급해 return 한다.
    }
}

로그인 시도 -> Login Filter -> DB 에 저장된 값과 비교 하는 로직을 만들었다.
다음 차례는 성공 또는 실패시 수행할 동작을 작성한다.

successfulAuthentication() 를 구현하려고 보니 JWT 발급하는걸 만들지 않았다.
JWT 를 발급하는 로직을 작성하고 다시 와야한다.

JWT 발급 로직

1
2
3
4
5
6
7
8
dependencies {
	// 생략

	// jwt
	implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
	implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
	implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}
1
2
3
# application.properties 파일

spring.jwt.secret=${SPRING_JWT_SECRET}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@Component
public class JWTUtil {
    private SecretKey secretKey;

    public JWTUtil(@Value("${spring.jwt.secret}") String secret) {
        this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
    }

    public String getMemberId(String token) {
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("memberId", String.class);
    }

    /**
     * jwt token 검증 후 username 을 꺼낸다.
     * @param token
     * @return
     */
    public String getUsername(String token) {
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class);
    }

    /**
     * jwt token 을 검증 후 role 을 꺼낸다.
     * @param token
     * @return
     */
    public String getRole(String token) {
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
    }

    /**
     * jwt token 을 검증 후 만료가 됐는지 확인한다.
     * @param token
     * @return
     */
    public Boolean isExpired(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
    }

    /**
     * JWT token 생성한다.
     * @param username
     * @param role
     * @param expiredMs
     * @return
     */
    public String createJwt(Long memberId, String username, String role, Long expiredMs) {

        return Jwts.builder()
                .claim("memberId", String.valueOf(memberId))
                .claim("username", username)
                .claim("role", role)
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + expiredMs))
                .signWith(secretKey)
                .compact();
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
    // 생략

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
        // 인증에 성공하면 호출된다.
        // JWT 를 발급해주자.

        // authentication.getPrincipal() 은 user details 을 반환한다. Type 은 Object 형태로 반환된다.
        MemberDetails memberDetails = (MemberDetails) authentication.getPrincipal();

        Long memberId = memberDetails.getMemberId();
        String username = memberDetails.getUsername();

        Collection<? extends GrantedAuthority> authorities = memberDetails.getAuthorities();
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator(); // 굳이 Iterator 에 보관하지 않고 authorities.iterator().next() 로 축약가능.
        GrantedAuthority auth = iterator.next(); // collection 의 첫번째 값이 auth 에 보관된다.
        String role = auth.getAuthority();

        String token = jwtUtil.createJwt(memberId, username, role, 60*60*10L*100); // 3600초

//        response.addHeader("Authorization", "Bearer " + token);
        Cookie cookie = new Cookie("Authorization", token);
        cookie.setMaxAge(3600);
        cookie.setHttpOnly(true);
        cookie.setPath("/");
        response.addCookie(cookie);
    }
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        // 인증에 실패하면 호출된다.
        System.out.println("로그인 실패"); // 안해도 됨
        response.setStatus(401);
    }
}

build.gradle 에 jwt 라이브러리를 추가한다.
application.properties 에 spring.jwt.secret=${SPRING_JWT_SECRET} 를 추가한다. 환경변수가 아니라 값을 직접 넣어도 된다.

successfulAuthentication() 메서드 에 JWT 를 생성 후 response 헤더에 추가해주는 로직을 작성했다.
refresh token 을 구현하지 않아서 페이지 이동시 JWT 가 사라진다.
그래서 Cookie 에 보관하여 60분동안 JWT 가 유지되도록했다.

JWT 인증 로직

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class JWTFilter extends OncePerRequestFilter {
    private final JWTUtil jwtUtil;

    public JWTFilter(JWTUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 1. JWT 를 검증한다.
        // 1-1. request 헤더에서 JWT 를 꺼낸다.
        // 1-2. JWT 형식이 맞는지 검사한다.
        // 1-3. 유효한 JWT 인지 검증한다.
//        String authorization = request.getHeader("Authorization");
        // 여기부터
        String authorization = null;
        if (request.getCookies() == null) {
            filterChain.doFilter(request,response);
            return;
        }
        for (Cookie cookie : request.getCookies()) {
            if(cookie.getName().equals("Authorization")) {
                authorization = "Bearer " + cookie.getValue();
            }
        } // 여기까지는 Cookie 에 저장된 JWT 를 꺼내기 위한 방법이다.

        if(authorization  == null || !authorization.startsWith("Bearer")) {
            System.out.println("Token 이 없거나 잘못된 형식입니다. : " + authorization);
            filterChain.doFilter(request,response);
            return ;
        }
        String token = authorization.split(" ")[1];
        try {
            jwtUtil.isExpired(token);

        } catch (ExpiredJwtException e) {
            System.out.println("Token 이 만료되었습니다.");
            filterChain.doFilter(request,response);
            return;
        }
        String memberId = jwtUtil.getMemberId(token);
        String username = jwtUtil.getUsername(token);
        String role = jwtUtil.getRole(token);

        // 2. 인증 정보를 보관한다.
        Member member = new Member();
        member.setMemberId(Long.parseLong(memberId));
        member.setLoginId(username);
        member.setPassword("temp");
        member.setRole(role);

        MemberDetails memberDetails = new MemberDetails(member);
        Authentication authToken = new UsernamePasswordAuthenticationToken(memberDetails, null, memberDetails.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authToken);

        filterChain.doFilter(request, response);
    }
}
1
2
3
4
5
        // 6. Filter 등록
        http.addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);

        http.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), UsernamePasswordAuthenticationFilter.class);
        return http.build();

발급받은 JWT 로 서버에 요청을 보낼때, JWT 를 검증하는 로직을 작성한다.
기존의 로직을 수정하지 않고 Cookie 에서 꺼내는 부분만 추가하다 보니까 “Bearer” 를 붙이는 과정이 생겨버렸다.
일단 그냥 넘어가자. 일단 임시로 해놓고 나중에 바꿀거다..

작성 후 SecurityConfig 에 Filter 를 등록해준다.

마무리 단계

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Before

// 로그인 인증
    @PostMapping("/authentication")
    @ResponseBody
    public Map<String, String> authentication(@RequestBody LoginForm form, HttpSession session) {
        Map<String, String> response = new HashMap<>();
        System.out.println(form.toString());
        Member member = new Member();
        member.setLoginId(form.getLoginId());
        member.setPassword(form.getPassword());
        member = memberService.athentication(member);

        if (member != null) {
            // 로그인 성공. 메인 페이지로 이동
            session.setAttribute("memberId", member.getMemberId());
            session.setMaxInactiveInterval(1800); // 30분간 세션 유지
//            response.put("memberId", member.getMemberId().toString());
            response.put("result", "success");
            response.put("message", "/chatting_room_list");
        }
        else {
            // 로그인 실패. 로그인 페이지로 이동
            response.put("result", "fail");
            response.put("message", "로그인 실패\n 아이디 또는 비밀번호를 확인해주세요" );
        }
        return response;
    }
    @GetMapping("/chatting_room_list")
    public String chattingRoomList(HttpSession session) {
        if(session.getAttribute("memberId") == null) {
            // 로그인하지 않고 주소창에 /chatting_room_list 를 입력하고 넘어오면
            // 로그인 창으로 돌려보낸다.
            return "login";
        }
        return "chatting_room_list";
    }
1
2
3
4
5
6
7
8
// After
// authentication 메서드는 더 이상 필요없음. 삭제

@GetMapping("/chatting_room_list")
    public String chattingRoomList() {
        // 로그인 여부 검증은 JWT Filter 에서 이미 끝냈다.
        return "chatting_room_list";
    }

이제 다시 로그인부터 천천히 로직을 따라가면서 기존에 Session 로그인 방식들을 JWT 방식으로 코드를 수정하면 된다.

1
2
3
4
5
6
7
8
// 4. 요청 경로별 인가 작업
        // login, 회원가입, 아이디 찾기, 비밀번호 찾기는 인증없이 통과할 수 있도록 설정한다.
        http.authorizeHttpRequests((auth) -> auth
                .requestMatchers( "/", "/login", "/register", "/findId", "/findPw").permitAll()
                .requestMatchers( "login_page", "/register/check-duplicate", "/chatting_room_list").permitAll()
                .requestMatchers("/admin").hasRole("ADMIN")
//                .anyRequest().permitAll()); // 나머지 모든 경로에 대해 JWT 인증을 하지 않는다.
                .anyRequest().authenticated()); // 나머지 모든 경로에 대해 JWT 인증을 수행한다.

로그인 로직을 따라가면서 jwt 인증이 필요없는 경로는 permitAll() 로 설정해주는걸 잊지말자.

This post is licensed under CC BY 4.0 by the author.

Servlet 과 JSP 로 회원 관리 예제 만들고 비교

Youtube 자동 극장모드 해제