Spring Security + JWT 회원가입/로그인 로직 이해하기

·2023년 6월 2일
0

이 글은 '뱀귤'님의 Spring Security+JWT 예제 코드 블로그를 보고 개인 공부 목적으로 작성한 글입니다. 이 글에 나오는 모든 코드와 로직은 제것이 아님을 밝힙니다! 전반적인 로직을 확인하려면 계속 보시는 것을 추천드리고 코드가 궁금하시다면 뱀귤님의 블로그를 추천드립니다~!! 저는 깃허브에서 코드를 받아와 디버깅하며 공부했습니다 :)

JWT 입문 개념 짚고 가기


1. 도메인 Member

🥝 Entity 엔티티

  • Member, MemberResponseDto 두 가지가 있다

🥥 Member

회원 정보 멤버변수

  • id(GeneratedValue): 식별 id
  • email: 사용자 이메일
  • password: 사용자 비밀번호
  • authority(Enum): 권한
    • Authority Enum에는 ROLE_USER ROLE_ADMIN 사용자, 관리자 두 권한이 정의되어 있다

메서드

  • Builder 생성자: 이메일, 패스워드, 권한

🥥 RefreshToken

DB에 저장할 RefreshToken(↔ AccessToken)

멤버변수

  • key: 키
  • value: 값
  • Builder 생성자: 키, 값

메서드

  • updateValue(토큰): 매개변수로 전달 받은 token으로 갱신


🥝 Repository(DAO) 레퍼지토리

🥥 MemberRepository

DB에 있는 회원정보 조회

  • Optional<Member> findByEmail(이메일): 이메일로 회원 정보 조회
  • boolean existsByEmail(이메일): 중복 가입 방지를 위해 해당 이메일로 회원 등록 유무 조회


🥝 Service 서비스

🥥 MemberService

식별 id, 이메일로 회원 정보 조회
반환형: MemberResponseDto

  • MemberResponseDto findMemberInfoById(회원 식별 id): 식별 id로 회원 찾기 (Long 타입)
  • MemberResponseDto findMemberInfoByEmail(이메일): 이메일로 회원 찾기

🥥 MemberResponseDto

회원 정보 요청 시 보내줄 응답

  • email: 회원 이메일
  • public static MemberResponseDto of(Member 엔티티): Member을 DTO로 만들어주기


🥝 RestController 컨트롤러

🥥 MemberController

GET 요청 받았을때 회원 정보 응답 전송
"api/member"로 RequestMapping

  • @GetMapping("/me")
    public ResponseEntity<MemberResponseDto> findMemberInfoById(): 내 정보 가져옴

  • @GetMapping("/{email}")
    public ResponseEntity<MemberResponseDto> findMemberInfoByEmail(url로 받은 이메일): Get으로 받은 email로 회원 정보 응답 전송

✅ 다른 Controller에서 API 요청이 들어오면 필터에서 Access Token을 복호화 해서 유저 정보를 꺼내 SecurityContext라는 곳에 저장한다
SecurityContext에 저장된 유저 정보는 전역이라서 어디서든 꺼낼 수 있다
SecurityUtil.getCurrentMemberId()로 꺼낼 수 있다

🥥 SecurityUtil

회원 정보 조회하는 요청을 했을때 실행됨 "/api/member/me"
/*뱀귤님의 설명인데 이해가 잘 되지 않지만 필요한듯 하여ㅜㅜ*/
//SecurityContext에 회원 정보가 저장되는 시점
//요청이 왔을때 JwtFilter의 doFilter에서 저장

  • Long getCurrentMemberId(): SpringContext에서 회원 식별 id(이메일 아님)만 반환
    • Principal를 상속 받은 Authentication(인증) 클래스 객체에 SecurityContext가 값을 넣어준다
    • Principal 멤버 변수인 username에 id가 들어있다
    • 만약 인증 정보가 없어서 authentication 객체가 null이라면 에러 반환
    • 인증 정보가 있다면 authentication.getName()으로 id를 반환해준다

🥝 application.yml

생략

2. JWT와 Security

🥝 JWT 설정

🥥 TokenProvider

멤버변수

  • AUTHORITIES_KEY = "auth": 인가 키
  • BEARER_TYPE = "Bearer": 인증 타입으로 JWT 토큰을 사용한다는 뜻
  • ACCESS_TOKEN_EXPIRE_TIME = 30분: Access Token 유효 시간
  • REFRESH_TOKEN_EXPIRE_TIME = 7일: Refresh Token 유효 시간
  • key: 토큰 만드는 과정 중에서 암호화할때 사용될 비밀 키

메서드

  • TokenProvider(비밀키): 생성자
    • application.yml에 정의해놓은 jwt.secret값으로 암호화 키값 생성
    • @Value("${jwt.secret}") String secretKey로 가져오면 된다
  jwt:
  secret: c3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXR1dG9yaWFsLWppd29vbi1zcHJpbmctYm9vdC1zZWN1cml0eS1qd3QtdHV0b3JpYWwK
  • TokenDto generateTokenDto(Authentication authentication) : Authentication으로 회원 정보를 받아 Access&Refresh Token 생성

    • Access Token

      • 현재 시간+토큰 유효 시간 => 토큰이 만료될 시간 지정
      • Jwts.builder()로 토큰 생성
        • setSubject(회원 식별 id)
        • claim(권한)
        • setExpiration(토큰이 만료될 시간)
        • signWith(비밀키, 암호화 알고리즘 이름)
    • Refresh Token

      • Jwts.builder()로 토큰 생성
      • setExpiration(토큰이 만료될 시간)
      • signWith(비밀키, 암호화 알고리즘 이름)
    • 리턴(TokenDto)

      • grantType(전달자) 승인타입
      • accessToken(Access 토큰 객체)
      • accessTokenExpiresIn(Access 토큰 만료될 시간)
      • refreshToken(Refresh 토큰 객체)

    • Authentication getAuthentication(Access 토큰) : 토큰을 복호화해서 정보를 꺼낸다

      • accessToken에서 클레임을 가져온다. => 권한이 있는지 체크해준다
      • 클레임의 권한 정보를 List로 저장한다
      • UserDetails 객체를 만들어서 클레임의 회원 식별 id, 권한 정보를 저장한다
      • UsernamePasswordAuthenticationTokene 객체로 return하는데 Spring Security를 사용하기 위한 절차로 받아들이면 된다

    • Claims parseClaims(Access 토큰) : 토큰에서 클레임을 가져와 반환
      • 만료된 토큰이어도 가져올 수 있게 따로 분리했다
      • Claims로 반환
        • setSigningKey(비밀키)
        • parseClaimsJws(Access 토큰)

    • boolean validateToken(토큰) : 토큰 정보 검증
      • Jwts 클래스로 비밀키를 전달하고 토큰으로 클레임을 만들 수 있다면 true 반환
      • 아니라면 상황에 맞춰 Exception을 발생시킨다 (Jwts 모듈이 알아서 해준다)



🥥 JwtFilter

요청 분석할때 사용할 상수들

  • AUTHORIZATION_HEADER = "Authorization": 헤더에 Authorization있는지 확인할때
  • BEARER_PREFIX = "Bearer ": 인증 타입이 Bearer Token인지 확인할때
    • 요청이 들어올때 Authorization, Bearer Token이 들어온다. 이것은 개발자가 지정해주는 것이 아니라 이미 http에 정해져 있는 것이다

메서드

  • String resolveToken(요청): 요청 헤더에서 토큰 정보를 꺼내온다
    • getHeader() 메서드로 토큰 정보를 꺼내온다
    • StringUtils.hasText()로 토큰 null 체크를 하고
    • Bearer Token이면 "Token" 문자열을 반환한다

  • void doFilterInternal(요청, 응답, 필터체인) : 토큰 인증 정보를 SecurityContext에 저장한다
    • 실제 필터링 로직 수행
      1. resolveToken()로 토큰 정보를 꺼내온다
      1. TokenProvide 클래스의 validateToken() 메서드로 유효성 검사
      • 리마인딩: 비밀키와 토큰으로 클레임을 만들 수 있으면 true 반환하는 메서드
    • 2-1. TokenProvider의 getAuthentication()로 Authentication 얻어오고
      • 리마인딩: 토큰을 복호화하여 토큰에 있는 정보 꺼내는 메서드
    • 2-2. SpringContext에 Authentication 넣기
      1. doFilter()로 필터링

🥝 Spring Security 설정

🥥 JwtSecurityConfig

설명

  • TokenProvider, JwtFilter를 SecurityConfig에 적용할때 사용
  • 추상 클래스인 SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity>의 구현체
  • http.addFilterBefore()로 직접 만든 JwtFilter를 Spring Security Filter 앞에 추가한다

메서드

  • void configure(HttpSecurity http): TokenProvider를 주입 받아서 JwtFilter를 통해 Security 로직에 필터 등록



⚠️ 여기서부터 나오는 에러 처리 클래스는 밑에 나올 SecurityConfig의 filterChain에서 사용될 예정이다 ⚠️

🥥 JwtAuthenticationEntryPoint 인증 정보 없을때 에러

인증 정보 없을때, 유효한 자격증명을 제공하지 않고 접근하려 할때 => 401 에러 반환
(Entry Point: 접근, 입구 지점)

메서드

  • void commence(요청, 응답, AuthenticationException): 매개변수로 에러를 받고, 응답에 HttpServletResponse.SC_UNAUTHORIZED 에러를 추가한다



🥥 JwtAccessDeniedHandler 접근 권한 에러

인증 정보는 있지만 접근 권한은 없을 때 403 에러 반환

메서드

  • void handle(요청, 응답, AccessDeniedException)



🥥 SecurityConfig 스프링 시큐리티 설정

직접 만들었던 클래스를 사용한다! 리마인딩해보자

  • TokenProvider: 토큰 생성 & 토큰에서 정보 꺼내기
  • JwtAuthenticationEntryPoint: 인증 정보 없을때 오류
  • JwtAccessDeniedHandler: 접근 권한 없을때 오류

메서드

  • PasswordEncoder passwordEncoder(): 비밀번호 암호화
  • WebSecurityCustomizer webSecurityCustomizer(): DB 관련 API 모두 무시(h2 DB라서)
  • SecurityFilterChain filterChain(HttpSecurity http): 스프링 시큐리티 필터 체인 실행

맨마지막 메서드인 필터체인 메서드를 더 자세하게 알아보자!
1. CSRF(사이트 간 요청 위조 공격) 설정 Disable

http.csrf().disable()
  1. 직접 만든 클래스로 exception handling
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint) //클래스 변수로 가져옴
.accessDeniedHandler(jwtAccessDeniedHandler) 
  1. h2-console을 위한 설정 추가 (아직 이해 잘 안 됨)
.and()
.headers() //응답 헤더
.frameOptions() //커스텀 허용
.sameOrigin() //같은 곳에서 오는 요청들을 허락
  1. 세션 stateless로 설정
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)

참고: stateless는 값이 변하지 않는 상태, statefull은 값이 변할 수 있는 상태를 말한다. 토큰은 값이 변하지 않지만 세션은 들어있는 값이 변한다는 것을 떠올리면 된다.

  1. 로그인, 회원가입 permitAll 설정
.and()
.authorizeRequests() //인가 요청
.antMatchers("/auth/**").permitAll() // "/auth/**" api 요청들은 권한 상관없이 모두 허용
.anyRequest().authenticated()   // 나머지 API 는 전부 인증 필요
  1. JwtSecurityConfig 클래스 적용
.and()
.apply(new JwtSecurityConfig(tokenProvider));

리마인딩: 직접 만든 JwtFilter를 Security Fillter 앞에 추가하는 클래스이다



🥥 SecurityUtill 유저 정보 제공

SecurityContext에서 유저 정보를 꺼낸다

메서드

  • Long getCurrentMemberId(): 현재 context에 들어있는 회원 id 꺼내기



🥝 Refresh 토큰 저장소

🥥 RefreshToken Entity 엔티티

보통 토큰이 만료될때 Redis로 자동 삭제 처리를 하는데 RDB로 구현할 때에는 배치(일괄) 작업으로 만료된 토큰들을 삭제해주어야 한다.
이때 생성, 수정 시간 컬럼이 필요하다

  • key: 회원 고유 id 값
  • value: Refresh Token
  • 근데 왜 생성/수정 시간 컬럼 추가를 안 하셨지?

🥥 RefreshTokenRepository 레퍼지토리

  • findByKey(): 회원 ID 값으로 토큰을 가져온다



3. 사용자 인증 과정

드디어!!!!!ㅠㅠ JWT 관련 설정이 끝이 났다..
이제 로그인 요청이 들어왔을 때 인증 처리 후 JWT 토큰을 발급하는 과정을 살펴보자

  • AuthController
  • AuthService
  • CustomUserDetailsService

🥝 Controller 컨트롤러

🥥 MemberRequestDto

  • 이메일
  • 패스워드
  • toMember(비밀번호 Encoder): 이 DTO를 Member Entity로 만들어준다
  • toAuthentication(): 이메일과 패스워드로 UsernamePasswordAuthenticationToken을 만들어준다

🥥 TokenRequestDto

  • Access Token
  • Refresh Token

🥥 AuthController

  • 회원가입, 로그인, 재발급을 처리한다
    • 참고: 재발급은 아직 회원이 로그아웃하지 않았는데 Access 토큰 유효 기간이 만료되었을때 다시 토큰을 발급해주는 것이다.
  • /auth/** 요청은 모두 허용했기 때문에 토큰 검증 로직은 타지 않는다
  • MemberRequestDto: 로그인 form
  • TokenRequestDto: AccessToken, RefreshToken

1. 회원가입

회원가입 form을 받아 authService의 signup() 메서드에 보내준다

2. 로그인

로그인 form을 받아 authService의 login() 메서드에 보내준다

3. 재발급

Access&Refresh Token이 있는 DTO를 받아 authService의 reissue() 메서드에 보내준다



🥝 Service 서비스

🥥 AuthService

1. signup() 회원가입

회원가입 form을 받는다

  • 이미 있는 회원인지 확인
  • DTO의 toMember() 메서드로 비밀번호 암호화해서 저장
  • 생성된 Member를 DTO로 변환해서 응답
    *참고: @Transactional 어노테이션
    모든 작업들이 성공적으로 완료되어야 결과를 적용하고,
    오류가 발생했다면 모든 것을 되돌려준다
    DB의 Transaction 개념이다~!!

2. login() 로그인

로그인 form을 받는다

로직 순서
1. 아직 인증 받지 못한 ID와 PW로 UsernamePasswordAuthenticationToken(토큰)을 생성한다

  1. AuthenticationManager의 authenticate() 메서드로 검증을 받는다
    2-1. Authentication 객체에 회원 정보를 넣는다
    2-2. 회원 식별 id가 이때 이메일, 비밀번호와 함께 들어간다

  2. 인증된 정보로 JWT 토큰을 생성한다
    3-1. TokenProvider의 generateTokenDto() 메서드
    3-2. Access Token, Refresh Token 모두 새로 생성한다

  3. Refresh Token을 DB에 저장한다

  4. 권한, Access 토큰, Refresh 토큰, Access 토큰 만료 시간을 반환한다
    5-1. TokenDto를 반환한다


3. reissues() 토큰 재발급

DTO로 Access Token, Refresh Token을 받는다

로직 순서
1. Refresh Token이 만료되었는지 검증한다
1-1. 유효하다면 다음 단계!

  1. Access Token에서 회원 식별 id를 가져온다
    2-1. TokenProvider의 getAuthentication() 메서드 - 여기서 토큰 복호화해줌

  2. 2번에서 가져온 회원 id로 저장소의 Refresh Token 값을 가져온다

  3. DTO로 받은 Refresh Token과 저장소에 있던 Refresh Token이 일치하는지 검사한다
    4-1. 일치한다면 다음 단계!

  4. Access 토큰, Refresh 토큰 모두 새로 생성해준다

  5. Refresh 토큰의 재사용을 막기 위해 저장소의 Refresh Token을 업데이트해준다.

  6. 권한, Access 토큰, Refresh 토큰, Access 토큰 만료 시간을 반환한다
    7-1. TokenDto를 반환한다



🥥 CustomUserDetailsService

UserDetails와 Authentication의 비밀번호를 비교하고 검증한다

메서드

  • UserDetails createUserDetails(Member 엔티티): Member Entity를 UserDetails로 만든다
    • User 객체를 만들어서 반환하면 된다
  • UserDetails loadUserByUsername(이메일): 이메일 받아서 UserDetails 반환
    • email로 회원을 찾고 createUserDetails()로 UserDetails 객체로 만들어준다



회고

지금 스프링 부트로 쇼핑몰 프로젝트를 구현하고 있는데 Spring Security를 써봐야겠다 싶어서 구글링하게 되었다. 그러던중 괜찮은 예제 코드를 찾게 되었는데 알고보니 Spring Security에서 한 단계 더 나아간.. JWT+Spring Security였다ㅋㅋㅋㅋㅋㅋ (나는 Spring Security와 초면이었는데 말이다!!!!!) 그치만 JWT 공부하는게 너무 재밌었어서 끝까지 재밌게 끝냈다~~

내가 직접 다 구현했으면 정말 좋았겠지만 프로젝트 마감기한 때문에ㅠㅠㅠ 아쉽게 다른 분의 로직으로 개발을 진행해야겠다.. 그래서 최대한 많이 이해하려고 많이 노력했당......ㅜㅜ 내가 이해하려고 정리한 글이라서 다른 분들께 도움이 될지는 모르겠지만....ㅎㅎㅎㅎㅎㅎㅎㅎ 그래도 짱 수고많았다! 이 글 보고있는 분들도 모두모두 화이팅!!!

피드백이나 틀린 내용 있다면 언제든지 댓글 남겨주세요

profile
기록하고 싶은 내용들을 주로 올리고 있습니다

0개의 댓글