day-course day33

2rlokr·2025년 4월 17일

dev-course

목록 보기
33/43
post-thumbnail

오늘 배운 것

🌐 OAuth 2.0 인증 방식 실습

1. Naver, Kakao 추가

네이버와 카카오는 구글, 깃허브, 페이스북과 달리 자동으로 설정되어 있지 않는 부분들이 많아 제공자인 네이버에 대한 정보와, 인증 방식, redirect-url 등을 직접 설정해줘야 한다.

security:
  oauth2:
    client :
      provider : 
        naver : 
          authorization-uri: https://nid.naver.com/oauth2.0/authorize
          token-uri: https://nid.naver.com/oauth2.0/token
          user-info-uri: https://openapi.naver.com/v1/nid/me
          user-name-attribute: response
        kakao :
       	  authorization-uri: https://kauth.kakao.com/oauth/authorize 
          token-uri: https://kauth.kakao.com/oauth/token
          user-info-uri: https://kapi.kakao.com/v2/user/me
          user-name-attribute: id
      registration :
        naver : 
          client-name : Naver
          client-id: {클라이언트 아이디}
          client-secret: {클라이언트 비밀키}
          redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
          authorization-grant-type: authorization_code
          client-authentication-method: client_secret_post
          scope :
          	- name
            - nickname
            - email
        kakao :
       	  client-name: Kakao
          client-id: {클라이언트 아이디}
          client-secret: {클라이언트 비밀키}
          redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
          authorization-grant-type: authorization_code
          client-authentication-method: client_secret_post
          scope :
            - profile_nickname
            - profile_image

provider

  • authorization-uri : Authorization code(권한부여 승인코드)를 받기 위한 주소
  • token-uri : AccessToken을 받기 위한 주소
  • user-info-uri : 사용자 정보를 받기 위한 주소
  • user-name-attribute : 요청 정보를 어디에 담아서 리턴하는지 지정해준다. 네이버는 response 에 사용자 정보를 반환해주기 때문에 response를 넣어준다. 카카오는 id에 정보를 담아 주기 때문에, id로 넣어준다.
  • scope : scope를 넣어줄 때도, 각 제공자의 개발자 센터에서 주요 스코프 이름을 확인한 후, 맞게 넣어줘야 한다.

2. MemberDetailsFactory 구현

이전에는 구글 하나만 이용했기 때문에 별 문제가 없었다. 하지만, 이제 카카오, 네이버, 구글 이렇게 다양한 서버에서 사용자의 정보를 받아오는데, 그 정보가 저장되어 있는 형태가 다 동일하지 않다. 네이버는 OAuth2Userattributesresponse 안에, 카카오는 properties 안에 저장되어 있다. 그렇기 때문에 Resource Server에 따라 다르게 처리해줄 필요가 있다.

1. If/Else if문 사용

간단하게, loadUser 메서드에서 if/else 문으로 서버를 구분해주어 다르게 처리해줄 수 있다.

2. 팩토리 패턴 사용

public class MemberDetailsFactory {

    public static MemberDetails memberDetails(String provider, OAuth2User oAuth2User) {

        Map<String, Object> attributes = oAuth2User.getAttributes();

        switch (provider.toUpperCase().trim()) {
            case "KAKAO" -> {
                Map<String, String> properties = (Map<String, String>) attributes.get("properties");
                return MemberDetails.builder()
                        .name(attributes.get("nickname").toString())
                        .email(properties.get("id") + "@kakao.com")
                        .attributes(attributes)
                        .build();
            }
            case "GOOGLE" -> {
                return MemberDetails.builder()
                        .name(attributes.get("name").toString())
                        .email(attributes.get("email").toString())
                        .attributes(attributes)
                        .build();
            }
            case "NAVER" -> {
                Map<String, String> properties = (Map<String, String>) attributes.get("response");
                return MemberDetails.builder()
                        .name(properties.get("name"))
//                        .email(properties.get("email"))
                        .email(properties.get("id") + "@naver.com")
                        .attributes(attributes)
                        .build();

            }
            default -> {
                throw new IllegalArgumentException("지원하지 않는 제공자 : " + provider);
            }
        }
    }
}

static 메서드
memberDetails()메서드는 MemberDetails 객체를 만들어주는 유틸리성 메서드이다. 이럴 땐 굳이 객체를 생성할 필요 없이, 클래스 차원에서 메서드를 바로 만들어줄 수 있도록 static으로 만들어야 한다. 또, static으로 만들어주지 않으면 new MemberDetailsFactory() 처럼 매번 인스턴스 객체를 만들어낼 수 있기 때문에 static 으로 만들어준다.

메서드 설명

  • provider 에 따라 MemberDetails를 빌더 방식으로 생성해서 반환해주도록 한다.
  • 카카오일 경우
    • properties 안에 nickname, profile_image 등이 담겨있기 때문에, attributes에서 properties를 또 get해줘야 한다.
  • 네이버일 경우
    • response 안에 name, email 등이 담겨있기 때문에, attributes에서 response를 또 get해줘야 한다.

3. MemberService 리팩토링

이제 제공자에 맞게 MemberDetails를 반환해주는 MemberDetailsFactory를 구현해줬기 때문에 MemberService 내부 로직도 바꿔줘야 한다.

@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

	OAuth2User oAuth2User = super.loadUser(userRequest); 
    String provider = userRequest.getClientRegistration().getRegistrationId().toUpperCase();

	MemberDetails memberDetails = MemberDetailsFactory.memberDetails(provider, oAuth2User);

	Optional<Member> memberOptional = memberRepository.findByEmail(memberDetails.getEmail());

	Member member = memberOptional.orElseGet(
    	() -> {
        	Member saved = Member.builder()
            	.name(memberDetails.getName())
                .email(memberDetails.getEmail())
                .provider(provider)
                .build();
            return memberRepository.save(saved);
        }
	);

	if (member.getProvider().equals(provider)) {
    	return memberDetails.setRole(member.getRole());
    } else {
    	throw new RuntimeException();
    }
}
  • oAuthUser (사용자 정보), provider (제공자, Resource Server) 를 받은 후, MemberDetailsFactory를 호출하여 MemberDetails를 받아온다.
  • 하지만, 아직 이 MemberDetails는 권한 정보를 가지고 있지 않다.
  • DB에서 findByEmail로 회원 가입 정보를 불러오고, 없으면 회원 가입을 시켜준다.
  • 이제 이 MemberDetails에 권한 정보만 설정해주면 된다.
    memberDetails.setRole(member.getRole())
  • ⚠️ 하지만, 다음과 같은 상황이 있을 수 있다.
    구글 이메일도 Naver 이메일로 가입해두고, 카카오를 가입할 때도, Naver 이메일로 설정해둔 경우!
    그럴 경우, 다른 사이트로 로그인은 했지만, 이메일이 같은 경우가 발생한다. 즉, email이 unique하다는 설정을 깰 수 있다.

✅ 그렇기 때문에, 해당 이메일으로 가입한 Member (DB에 저장된)의 provider와 현재 로그인을 시도한 계정의텍스트 provider를 비교한다. 즉, 이미 저장된 사람은 이전에 카카오로 로그인 했지만 이메일은 네이버인 사람, 그치만 이번엔 네이버로 네이버 이메일을 가지고 로그인을 시도한다.

예외 발생 시키기 !

🧱 JWT 인증 방식

1. 의존성 주입

implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
  • jwt/io -> Libraries 에서 원하는 jwt를 선택하고, Github에서 Installation에 원하는 방식을 찾아 넣어준다.

2. SecurityConfig 설정

이번에는 프론트를 사용하여 데이터를 줄 것이기 때문에, http를 사용하지 않을 것이다.

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
	return http
    	.httpBasic(httpB -> httpB.disable()) //
        .csrf(csrf -> csrf.disable())
        .cors( cors -> cors.disable())
        .formLogin(form -> form.disable())
        .sessionManagement(
        	session -> session.sessionCreationPolicy(
            	SessionCreationPolicy.STATELESS
            )
        )
  • httpBasic() : Spring Security에서 제공하는 HTTP Basic 인증 방식을 설정하는 메서드이다. 이번 프로젝트에서는 프론트를 분리할 것이기 때문에 꺼주는 것이다.
  • cors( cors -> cors.disable()) : 이제 프론트와 통신할 것이기 때문에, 출처가 다르더라도 허용해줘야 한다.
  • formLogin(form-> form.disable()) : 이제 폼 방식 로그인을 사용하지 않을 것이기 때문에 설정을 꺼준다.
  • sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) : Spring Security의 세션 관리 전략을 설정하는 것이다. STATELESS로 두어 세션을 사용하지 않겠다고 하는 것이다.

📝 ❓세션을 왜 안 쓸까?
기본적으로 Spring Security는 세션 기반 인증이다. 즉, 사용자가 로그인하면 서버가 세션을 생성해서 인증 정보를 거기 저장하고, 요청마다 세션 ID를 통해 인증된 사용자인지 확인한다.

하지만, JWT를 사용할 때는, 인증 정보를 세션에 저장하는 것이 아니라, 토큰 자체에 인증 정보를 들고 다닌다.

STATELESS로 두면, 서버가 세션을 생성하지 않고, 세션에 인증 정보를 저장하지 않으며, 매 요청마다 토큰 기반으로 인증을 처리한다.


오늘 궁금했던 것 ❓

Q1. JWT의 구조 중에 Signature 부분이 이해가 안된다. 다시 정리하기 !

  1. Header : 해싱 알고리즘과 토큰의 타입을 가지고 있다.
  • 여기서 해싱 알고리즘은 서명(Signature) 및 토큰 검증에 사용한다.
  • 헤더를 암호화하는 것이 아니라, 서명을 해싱하기 위한 알고리즘이다.
  1. Payload : 토큰에서 사용할 정보의 조각들인 클레임(Claim)이 담겨있다.
  • 등록된 클레임 (Registered Claim), 공개 클레임 (Public Claim), 비공개 클레임 (Private Claim) 으로 나누어지며, key-value 형태로 존재한다.
  1. Signature : 토큰을 인코딩하거나 유효성 검증을 할 때 사용하는 고유한 암호화 코드이다.
  • 헤더와 페이로드, 그리고 비밀 키를 기반으로 생성되며 해당 토큰이 변조되지 않았음을 확인하기 위한 매커니즘이다.

✍️ 서명 생성 과정

  1. 헤더(Header)와 페이로드(Payload)의 값을 각각 Base64로 인코딩한다.
  2. 인코딩한 값을 비밀 키를 이용해 헤더(Header)에서 정의한 알고리즘으로 해싱한다.
  • 비밀키를 이용한 해싱 = HMAC 해싱
  • HMAC : 해시 함수 + 비밀 키를 같이 사용해서, 누가 해싱했는지도 인증할 수 있게 만든 해시값
    (나만 아는 비밀 키이니까)
  1. 해싱한 값을 다시 Base64로 인코딩하여 생성한다.
예를 들어 HMAC-SHA256 알고리즘을 사용한다고 가정할 때 : 

secret = "mySecretKey"

1. Header + Payload 인코딩:
   BASE64(header) + "." + BASE64(payload)

2. 서명 생성 (Signature):
   HMAC-SHA256(
       base64UrlEncode(header) + "." + base64UrlEncode(payload),
       secret
   )

3. 이 결과값을 다시 base64로 인코딩해서 마지막에 붙인다!

정리
누군가 Payload를 조작해도, 서버는 비밀 키가 없으면 정상적인 Signature를 만들 수 없다. 즉, JWT 위조를 막아주는 결정적인 보안장치가 Signature이다.


느낀 점

와우.. 오늘 뭔가 샤라웃을 받은 것 같아서 너무 기분이 좋았다 하하 .. 좋은 말씀을 너무 많이 해주셔서 너무 감사했다. 그치만 뭔가 티내고 싶지 않아서 이 꽉 깨물고 입꼬리를 조절했다 .. ㅎ 알아봐주셔서 너무 너무 감사합니다..! 근데 그만큼 진짜 머리에 남는 것 + 실력도 많이 늘어야 하는데.. 하하 더 파이팅해야겠다 !! 🔥🔥

오늘은 정말 수업을 따라가기 수월했던 것 같다. 다행이다. 근데 이제 시작중인 JWT는 또 해봐야 알겠지만,, 무섭네 ㅎㅎㅎ 그래서 내일 또 JWT한테 맞을까봐 좀 헷갈리는 부분은 정리했다. 내일도 살려다오.

내일 벌써 금요일이잖아?!!? 너무 행복하군 ㅎㅎㅎ 내일도 파이팅하쟈 !!

0개의 댓글