[12.27] 내일배움캠프[Spring] TIL-40

박상훈·2022년 12월 27일
0

내일배움캠프[TIL]

목록 보기
40/72

[12.27] 내일배움캠프[Spring] TIL-40

1. Spring Security를 통한 로그인 구현

오늘 한 부분과 생각한 흐름

1) User Role을 추가해서 회원가입 진행 -> 서버가 갖고있는 Admin Token값 입력해서 가입시 ADMIN
2) H2 DB에 회원의 정보가 저장된다.( 여기서 비밀번호는 passwordEncoder.encode()에 의해 암호화 )
3) 로그인 시도 -> ( 로그인 창은 Spring Security에서 PermitAll()해주었기 때문에 인증/인가가 필요 없는 상태 )
-> 사용자가 입력한 아이디 검증 -> 아이디가 있다면 그 유저를 User 에 담음 -> 입력한 평문의 password를 passwordEncoder.matches(DB에서 가져온 암호화 비밀번호,User.getPassword())를 통해 비밀번호 검증을 진행
4) 검증된 회원일 경우 ResponseHeaderJWT토큰 파싱 후 로그인 완료 후 인증 된 작업 창 도출
5) 로그인 완료 후 화면 ( "/shop")인데 이 URI는 현재 인증 인가가 필요한 부분임
6) 따라서 우리가 다른 우리가 Spring Sercurity를 거치게 하는 Config 빈 진입
7) 해당 빈에는 다른 Filter를 거치기전에 우리가 커스텀한 JwtAuthFilter를 거치게 설정되어 있기 때문에 그 곳의 로직을 수행하여 인증/인가 작업을 거치고 잘 되었다면 해당 페이지("/shop")을 띄움
.and().addFilterBefore(new JwtAuthFilter(jwtUtil),UsernamePasswordAuthenticationFilter.class);

생각한 흐름을 정리하기 전 어려움

  • 1) Cilient의 URL 요청 -> FilterChain -> DispatcherServlet -> Controller 순이라면서 어떻게 로그인을 진행할 때는 JWT토큰이 없는 상태인데 FilterChain을 거칠 때 Exception이 발생하지 않을까?
    -> 회원기입/로그인을 진행할 때는 인증/인가가 필요하지 않아야한다. 권한이 없는 사람이 회원가입과 로그인을 통해 자격을 얻는 것 이기 때문이다.
    -> 따라서 해당 URI에 대해서 PermitAll()을 해주기 때문에 인증/인가 없이 토큰에 대한 검증없이 진행할 수 있는 것이다.
  • 빈으로 등록한 WebSecurityConfig파일의 권한 설정 부분 코드
http.authorizeRequests().requestMatchers("/api/user/**").permitAll()
                .requestMatchers("/api/search").permitAll()
                .requestMatchers("/api/shop").permitAll()
                .anyRequest().authenticated()
                .and().addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); // JWT쓰겠다
  • 코드를 보면 api/shop의 권한도 필요없는거 아니야? -> 사실 그렇다 로그인 되지 않는 사용자는 보여줄 것도 없기 때문 -> 로그인 한 사용자의 폴더 정보등을 가져올 때 인증/인가가 필요한 것..!
    -> 그래서 로그아웃하면 로그인 창이 뜨는게 아니라 메인 shop의 페이지가 뜨는것 .. !
  • 2) Spring Security만 있어도 인증/인가를 할 수 있는거 아니야? JWT는 또 왜 섞어서 쓰는건가
    -> 기존의 Spring Security의 인증/인가 방식은 일단 Session의 방식인데, 그 방식이 아닌 JWT의 인증 토큰을 발행하여, 거기에 있는 Subject를 Authentication에 넣어 SecurityContext에 넣어주고 , 그 SecurityContext를 가장 큰 객체인 SpringSecurityContextHolder에 넣어 인증 객체를 완성시킨다.
  • Authentication객체는 UserDetails로 저장되는데, UserDetailsUserDetailsService에 의해 DB검증을 한번더 거친 후 발급된다.
  • JwtAuthFilter
package com.sparta.myselectshop.jwt;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.myselectshop.dto.SecurityExceptionDto;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Slf4j
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

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

        String token = jwtUtil.resolveToken(request);

        if(token != null) {
            //만약 여기서 분기처리를 해주지 않으면, 인증객체가 필요없는 로그인이나 회원가입 부분에서 Exception이 터짐
            if(!jwtUtil.validateToken(token)){
                jwtExceptionHandler(response, "Token Error", HttpStatus.UNAUTHORIZED.value());
                return;
            }
            Claims info = jwtUtil.getUserInfoFromToken(token);
            setAuthentication(info.getSubject());
        }
        filterChain.doFilter(request,response);
    }

    public void setAuthentication(String username) {
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        //여기가 UserDetails를 만들어 주는 부분!
        Authentication authentication = jwtUtil.createAuthentication(username);
        context.setAuthentication(authentication);

        SecurityContextHolder.setContext(context);
    }

    public void jwtExceptionHandler(HttpServletResponse response, String msg, int statusCode) {
        response.setStatus(statusCode);
        response.setContentType("application/json");
        try {
            String json = new ObjectMapper().writeValueAsString(new SecurityExceptionDto(statusCode, msg)); //토큰 오류를 Json타입으로 반환하기 위한 작업
            response.getWriter().write(json);
        } catch (Exception e) {
            log.error(e.getMessage());
        }
    }

}
  • 이해를 돕기 위한 그림 첨부

2. KaKao Login구현

진행 흐름

1) 로그인 요청 -> 인증코드 요청 후 Callback URI 지정

Controller 에서 인증 토큰 생성하는 부분

@GetMapping("/kakao/callback")
    public String kakaoLogin(@RequestParam String code, HttpServletResponse response) throws JsonProcessingException {인증 코드로 인증 토큰 요청하는 로직}

Service 에서 인증코드를 바탕으로 인증 토큰 요청하는 부분과 인증 토큰을 바탕으로 User 정보 가져오는 부분

package com.sparta.myselectshop.service;


import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.myselectshop.dto.KakaoUserInfoDto;
import com.sparta.myselectshop.entity.User;
import com.sparta.myselectshop.entity.UserRoleEnum;
import com.sparta.myselectshop.jwt.JwtUtil;
import com.sparta.myselectshop.repository.UserRepository;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

import java.util.UUID;

@Slf4j
@Service
@RequiredArgsConstructor
public class KakaoService {
    private final PasswordEncoder passwordEncoder;
    private final UserRepository userRepository;
    private final JwtUtil jwtUtil;

    public String kakaoLogin(String code, HttpServletResponse response) throws JsonProcessingException {
        // 1. "인가 코드"로 "액세스 토큰" 요청
        String accessToken = getToken(code);

        // 2. 토큰으로 카카오 API 호출 : "액세스 토큰"으로 "카카오 사용자 정보" 가져오기
        KakaoUserInfoDto kakaoUserInfo = getKakaoUserInfo(accessToken);

        // 3. 필요시에 회원가입
        User kakaoUser = registerKakaoUserIfNeeded(kakaoUserInfo);

        // 4. JWT 토큰 반환
        String createToken =  jwtUtil.createToken(kakaoUser.getUsername(), kakaoUser.getRole());
//        response.addHeader(JwtUtil.AUTHORIZATION_HEADER, createToken);
        //반환 헤더에 토큰을 넣어주는 방벙도 있지만 Controller에서 보면 서버에서 직접 쿠키를 만들어 주는 방법도 있다는 것을 보여주기 위함.

        return createToken;
    }

    // 1. "인가 코드"로 "액세스 토큰" 요청
    private String getToken(String code) throws JsonProcessingException {
        // HTTP Header 생성
        HttpHeaders headers = new HttpHeaders();
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        // HTTP Body 생성
        // Map은 원래 순서를 보장해주지 않지만 LinkedMutiValueMap<>은 순서를 보장해줌(입력순서 보장)
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("grant_type", "authorization_code");
        body.add("client_id", "f8011d6f1066019ec11cad39afb94e65");
        body.add("redirect_uri", "http://localhost:8080/api/user/kakao/callback");
        body.add("code", code);

        // HTTP 요청 보내기
        HttpEntity<MultiValueMap<String, String>> kakaoTokenRequest =
                new HttpEntity<>(body, headers);
        RestTemplate rt = new RestTemplate();
        ResponseEntity<String> response = rt.exchange(
                "https://kauth.kakao.com/oauth/token",
                HttpMethod.POST,
                kakaoTokenRequest,
                String.class
        );

        // HTTP 응답 (JSON) -> 액세스 토큰 파싱
        String responseBody = response.getBody();
        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode jsonNode = objectMapper.readTree(responseBody);
        return jsonNode.get("access_token").asText();
    }

    // 2. 토큰으로 카카오 API 호출 : "액세스 토큰"으로 "카카오 사용자 정보" 가져오기
    private KakaoUserInfoDto getKakaoUserInfo(String accessToken) throws JsonProcessingException {
        // HTTP Header 생성
        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "Bearer " + accessToken);
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        // HTTP 요청 보내기
        HttpEntity<MultiValueMap<String, String>> kakaoUserInfoRequest = new HttpEntity<>(headers);
        RestTemplate rt = new RestTemplate();
        ResponseEntity<String> response = rt.exchange(
                "https://kapi.kakao.com/v2/user/me",
                HttpMethod.POST,
                kakaoUserInfoRequest,
                String.class
        );

        String responseBody = response.getBody();
        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode jsonNode = objectMapper.readTree(responseBody);
        Long id = jsonNode.get("id").asLong();
        String nickname = jsonNode.get("properties")
                .get("nickname").asText();
        String email = jsonNode.get("kakao_account")
                .get("email").asText();

        log.info("카카오 사용자 정보: " + id + ", " + nickname + ", " + email);
        return new KakaoUserInfoDto(id, nickname, email);
    }

    // 3. 필요시에 회원가입
    private User registerKakaoUserIfNeeded(KakaoUserInfoDto kakaoUserInfo) {
        // DB 에 중복된 Kakao Id 가 있는지 확인
        Long kakaoId = kakaoUserInfo.getId();
        User kakaoUser = userRepository.findByKakaoId(kakaoId)
                .orElse(null);
        if (kakaoUser == null) {
            // 카카오 사용자 email 동일한 email 가진 회원이 있는지 확인
            String kakaoEmail = kakaoUserInfo.getEmail();
            User sameEmailUser = userRepository.findByEmail(kakaoEmail).orElse(null);
            if (sameEmailUser != null) {
                kakaoUser = sameEmailUser;
                // 기존 회원정보에 카카오 Id 추가
                kakaoUser = kakaoUser.kakaoIdUpdate(kakaoId);
            } else {
                // 신규 회원가입
                // password: random UUID
                String password = UUID.randomUUID().toString();
                String encodedPassword = passwordEncoder.encode(password);

                // email: kakao email
                String email = kakaoUserInfo.getEmail();

                kakaoUser = new User(kakaoUserInfo.getNicknmae(), kakaoId, encodedPassword, email, UserRoleEnum.USER);
            }

            userRepository.save(kakaoUser);
        }
        return kakaoUser;
    }
}
  • 근데 여기서 Email중복 체크 왜 할까? -> 기본 회원 가입과 구분하기 위해서가 아닐까..?

3. Java - CodingTest

Level - 0

하샤드 수

  • 문제 설명
    1) 양의 정수 x가 하샤드 수이려면 x의 자릿수의 합으로 x가 나누어져야 합니다. 예를 들어 18의 자릿수 합은 1+8=9이고, 18은 9로 나누어 떨어지므로 18은 하샤드 수입니다. 자연수 x를 입력받아 x가 하샤드 수인지 아닌지 검사하는 함수, solution을 완성해주세요.
  • 입출력 예시
arrreturn
10true
11false
12true
13false
class Solution {
    public boolean solution(int x) {
        String res = String.valueOf(x);

        char[] resArr = res.toCharArray();
        int total = 0;

        for (char component : resArr) {

            total += Integer.parseInt(String.valueOf(component));

        }

        if (x % total == 0) {
            return true;
        }
        return false;
    }
}
profile
기록하는 습관

0개의 댓글