96일차 - JPA (JWT 토큰, 로그인 인증 인가, 세션과 토큰 차이점, 토큰에 저장한 데이터 컨트롤러에서 사용)

Yohan·2024년 7월 15일
0

코딩기록

목록 보기
138/156
post-custom-banner

회원가입이 마무리 되지 않았을 때 처리

  • EventUserService
  • 기존 인증코드를 삭제하고 인증코드 재발송
    // 이메일 중복확인 처리
    public boolean checkEmailDuplicate(String email) {

        boolean exists = eventUserRepository.existsByEmail(email);
        log.info("Checking email {} is duplicate : {}", email, exists);

        // 중복인데 회원가입이 마무리되지 않은 회원은 중복이 아니라고 판단
        if (exists && notFinish(email)) {
            return false;
        }

        // 일련의 후속 처리 (데이터베이스 처리, 이메일 보내는 것...)
        if (!exists) processSignUp(email);

        return exists;
    }

    private boolean notFinish(String email) {
        EventUser eventUser = eventUserRepository.findByEmail(email).orElseThrow();

        if (!eventUser.isEmailVerified() || eventUser.getPassword() == null) {
            // 기존 인증코드가 있는 경우 삭제
            EmailVerification ev = emailVerificationRepository
                    .findByEventUser(eventUser)
                    .orElse(null);
            if (ev != null) emailVerificationRepository.delete(ev);

            // 인증코드 재발송
            generateAndSendCode(email, eventUser);
            return true;
        }

        return false;
    }

로그인 검증

  • EventUserController
    @PostMapping("/sign-in")
    public ResponseEntity<?> singIn(@RequestBody LoginRequestDto dto) {

        try {
            eventUserService.authenticate(dto);
            return ResponseEntity.ok().body("login success");
        } catch (LoginFailException e) {
            // service에서 예외발생 (로그인 실패)
            String errorMessage = e.getMessage();
            return ResponseEntity.status(422).body(errorMessage);
        }
    }
  • EventUserService
// 회원 인증처리
    public void authenticate(final LoginRequestDto dto) {

        // 이메일을 통해 회원정보 조회
        EventUser eventUser = eventUserRepository
                .findByEmail(dto.getEmail())
                .orElseThrow(
                        () -> new LoginFailException("가입된 회원이 아닙니다.")
                );
        // 이메일 인증을 안했거나 패스워드를 설정하지 않은 회원
        if (!eventUser.isEmailVerified() || eventUser.getPassword() == null) {
            throw new LoginFailException("회원가입이 중단된 회원입니다. 다시 가입해주세요.");
        }

        // 패스워드 검증
        String inputPassword = dto.getPassword();
        String encodedPassword = eventUser.getPassword();

        if (!encoder.matches(inputPassword, encodedPassword)) {
            throw new RuntimeException("비밀번호가 틀렸습니다.");
        }

        // 로그인 성공
        // 인증정보를 어떻게 관리할 것인가?

    }

세션과 토큰 차이점

세션 (Stateful)

  • 로그인한다 -> 로그인한 상태라는 것을 서버(또는 DB)에 저장
    서버와 클라이언트가 연결된 상태로(Stateful), 다른 요청들을 처리
    -> 서버가 클라이언트와 연결된 상태를 유지해야함 -> 사용자가 많아지면 버거움

토큰 (Stateless)

  • 로그인을 할 때 서버가 클라이언트에 토큰을 줌
    -> 클라이언트는 매 요청시마다 토큰을 같이 보내줌
    -> 서버는 연결상태(로그인여부?)를 확인할 필요없이 토큰만 있으면 작업을 처리 (Stateless)

JWT 토큰

  • JWT(JSON web token)는 웹에서 정보를 안전하게 전송하는 방법 중 하나
  • JWT는 header, payload, signature로 이뤄져있음

토큰 생성

  • TokenProvider
    • 추가 클레임은 가장 먼저 설정
package com.study.event.api.auth;

@Component
@Slf4j
// 토큰을 생성하여 발급하고, 서명 위조를 검사하는 객체
public class TokenProvider {

    // 서명에 사용할 512비트의 랜덤 문자열 비밀키. 
    // 테스트 클래스에서 발급 받은 정보로 yml에서 설정함.
    @Value("${jwt.secret}")
    private String SECRET_KEY;


    /**
     * JWT를 생성하는 메서드
     * @param eventUser - 토큰에 포함될 로그인한 유저의 정보
     * @return - 생성된 JWT의 암호화된 문자열
     */
    public String createToken(EventUser eventUser) {

        /*
            토큰의 형태
            {
                "iss": "뽀로로월드",
                "exp": "2024-07-18",
                "iat": "2024-07-15",
                ...
                "email": "로그인한 사람 이메일",
                "role": "ADMIN"
                ...
                ===
                서명
            }
         */

        // 토큰에 들어갈 커스텀 데이터 (추가 클레임)
        Map<String, Object> claims = new HashMap<>();
        claims.put("email", eventUser.getEmail());
        claims.put("role", eventUser.getRole().toString());

        return Jwts.builder()
                // token에 들어갈 서명
                .signWith(
                        Keys.hmacShaKeyFor(SECRET_KEY.getBytes())
                        , SignatureAlgorithm.HS512
                )
                // payload에 들어갈 클레임 설정
                .setClaims(claims) // 추가 클레임은 항상 가장 먼저 설정
                .setIssuer("메롱메롱") // 발급자 정보
                .setIssuedAt(new Date()) // 발급 시간
                .setExpiration(Date.from(
                        Instant.now().plus(1, ChronoUnit.DAYS)
                )) // 토큰 만료 시간
                .setSubject(eventUser.getId()) // 토큰을 식별할수 있는 유일한 값
                .compact();
    }
}

회원 인증 처리

  • EventUserService
    • 로그인 성공한 인증 정보 관리 어떻게 할거야? (세션 or 토큰 or 쿠키)
      -> 토큰, 인증정보를 클라이언트에게 전송
    // 회원 인증 처리 (login)
    public LoginResponseDto authenticate(final LoginRequestDto dto) {

        // 이메일을 통해 회원정보 조회
        EventUser eventUser = eventUserRepository.findByEmail(dto.getEmail())
                .orElseThrow(
                        () -> new LoginFailException("가입된 회원이 아닙니다.")
                );

        // 이메일 인증을 안했거나 패스워드를 설정하지 않은 회원
        if (!eventUser.isEmailVerified() || eventUser.getPassword() == null) {
            throw new LoginFailException("회원가입이 중단된 회원입니다. 다시 가입해주세요.");
        }

        // 패스워드 검증
        String inputPassword = dto.getPassword(); // 방금 입력받은 pw
        String encodedPassword = eventUser.getPassword();  // DB에 저장된 암호화된 pw

        if (!encoder.matches(inputPassword, encodedPassword)) { // 일치하지 않으면~
            throw new LoginFailException("비밀번호가 틀렸습니다.");
        }

        // 로그인 성공
        // 인증 정보를 어떻게 관리할 것인가? (핵심) 세션 or 토큰 or 쿠키
        // 인증정보(이메일, 닉네임, 프사, 토큰정보)를 클라이언트에게 전송

        // 토큰 생성
        String token = tokenProvider.createToken(eventUser);

        return LoginResponseDto.builder()
                .email(dto.getEmail())
                .role(eventUser.getRole().toString())
                .token(token)
                .build();
    }
  • LoginRequestDto
    • 회원가입시 입력받았던 정보들을 클라이언트 -> 서버로
package com.study.event.api.event.dto.request;

@Getter
@ToString
@EqualsAndHashCode
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class LoginRequestDto {

    private String email;
    private String password;

    // 자동로그인 여부 ...
}
  • LoginResponseDto
    • 로그인 정보가 맞다면(로그인 성공) 토큰 생성해서 LoginResponseDto에 성공 정보를 담아 클라이언트에 반환
package com.study.event.api.event.dto.response;

import lombok.*;

@Getter
@ToString
@EqualsAndHashCode
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class LoginResponseDto {

    private String email;
    private String role; // 권한
    private String token; // 인증 토큰 (json 토큰은 문자열로 만들어져있다)

}
  • EventUserController
    @PostMapping("/sign-in") // login은 password가 날라오기 때문에 post
    public ResponseEntity<?> signIn(@RequestBody LoginRequestDto dto) {

        try {
            eventUserService.authenticate(dto); // authenticate에 throw가 3개나 있다
            return ResponseEntity.ok().body("login success");

        } catch (LoginFailException e) {
            // 서비스에서 예외발생 (로그인 실패)
            String errorMessage = e.getMessage();
            return ResponseEntity.status(422).body(errorMessage);
        }
    }
  • SecurityConfig
    • 세션 인증 사용안하는 설정 해야함
    • 로그인 없이 이용가능한 부분, 로그인이 있어야 이용가능한 부분 인가 설정
package com.study.event.api.config;

// 스프링 시큐리티 설정 파일
// 인터셉터, 필터 처리
// 세션인증, 토큰인증
// 권한처리
// OAuth2 - SNS 로그인
@EnableWebSecurity
public class SecurityConfig {

    // 비밀번호 암호화 객체 컨테이너에 등록 (스프링에게 주입받는 설정)
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // 시큐리티 설정 (스프링 부트 2.7버전 이전 인터페이스를 통해 오버라이딩)
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .cors()
                .and()
                .csrf().disable() // 필터설정 off
                .httpBasic().disable() // 베이직 인증 off
                .formLogin().disable() // 로그인창 off
                // 세선 인증은 더 이상 사용하지 않음
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 상태관리를 세션으로 안한다
                .and()
                // 여기까지는 시큐리티에서 기본제공하는 기능 다 off
                .authorizeRequests() // 요청 별로 인가 설정

                // 아래의 URL요청은 로그인 없이 모두 허용
                .antMatchers("/", "/auth/**").permitAll()

                // 나머지 요청은 전부 인증(로그인) 후 진행해라
                .anyRequest().authenticated() // 인가 설정 on
                ;
        return http.build();
    }

}

  • 이제 모든 요청에서 토큰검사가 선행되어야 함!
    -> 컨트롤러의 모든 요청에서 검사하는 로직을 작성해도 되지만, JwtAuthfilter를 라는 객체를 생성하여 controller 보다 먼저 요청을 받는 객체를 만들어 토큰 검사를 진행 (interceptor보다 더 광범위한 개념)
    -> filter를 작성해서 securityConfig에서 기존의 filterChain에 등록(연결) 시켜야한다.
  • JwtAuthFilter
package com.study.event.api.auth.filter;


// 클라이언트가 요청에 포함한 토큰정보를 검사하는 필터
@Component
@Slf4j
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private final TokenProvider tokenProvider; // 위조검사할때 얘한테 시킴

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        try {
            // 요청 메세지에서 토큰을 파싱
            // 토큰정보는 요청헤더에 포함되어 전송됨
            String token = parseBearerToken(request);

                log.info("토큰 위조 검사 필터 작동");
            if (token != null) {
                // 토큰 위조 검사
                tokenProvider.validateAndGetTokenInfo(token);
            }

        } catch (Exception e) {
            log.warn("토큰이 위조되었습니다.");
            e.printStackTrace();
        }

        // 필터체인에 내가 만든 커스텀필터를 실행하도록 명령
        // 필터체인: 필터는 여러개임. 우리가 체인에 걸어놓은 필터를
        // 실행 명령
        filterChain.doFilter(request, response); // 등록x 실행명령o

    }

    private String parseBearerToken(HttpServletRequest request) {

        /*
            1. 요청 헤더에서 토큰을 가져오기

            -- request header
            {
                'Authorization' : "Bearer slkdgnlskegnwlekfwe",
                'Content-type' : "application/json"
            }
         */
        String bearerToken = request.getHeader("Authorization");

        // 토큰에 붙어있는 Bearer라는 문자열을 제거
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
            return bearerToken.substring(7);
        }

        return null;
    }
}

토큰에 저장한 데이터 컨트롤러에서 사용

@GetMapping("/page/{pageNo}")
    public ResponseEntity<?> getList(
            // 토큰파싱 결과로 로그인에 성공한 회원의 PK
            @AuthenticationPrincipal String userId,
            @RequestParam(required = false) String sort,
            @PathVariable int pageNo) throws InterruptedException {

        log.info("token user id : {}", userId);
  • JwtAuthFilter
    • 여기 있는 userId에는 회원의 pk가 들어있음
                AbstractAuthenticationToken auth
                        = new UsernamePasswordAuthenticationToken(
                        userId, // 인증 완료 후 컨트롤러에서 사용할 정보
                        null, // 인증된 사용자의 패스워드 - 보통 null로 둠
                        new ArrayList<>()  // 인가정보(권한) 리스트
                );
  • 토큰에 저장한 userId 사용하기 위해 userId 추가
profile
백엔드 개발자
post-custom-banner

0개의 댓글