[GDGoC] JWT를 활용한 로그인 구현

wuuwls·2024년 11월 6일
2

GDGoC

목록 보기
1/2
post-thumbnail

GDG on Campus SKHU의 Server 파트를 위한 강의자료 입니다.

👀 개요

지난 시간 우리는 Session로그인에 대해서 배웠어요
이번시간에는 JWT에 대해서 배워볼 거에요


☘️ JWT란?

JWT는 Json Web Token의 약자로 전사 서명된 URL-safe(URL로 이용할 수 있는 문자로 구성된)의 JSON으로 유저를 인증하고 식별하기 위한 Token 기반 인증이에요

JWT는 JSON 데이터를 Base64 URL-safe Encode를 인코딩하여 직렬화한 것이 포함되어요
따라서 클라이언트가 JWT를 서버로 전송하면 서버는 JWT를 검증하는 과정을 거치게 되며, 검증이 완료되면 요청한 응답을 돌려줘요

Base64 URL-safe Encode는 일반적인 Base64 Encode를 URL에서 오류없이 사용하도록 표현합니다.


⚙️ JWT 인증 흐름

보편적으로 JWT를 사용하는 경우의 인증 흐름을 알아볼게요

  1. 클라이언트 사용자 아이디, 패스워드를 통해 웹 서비스에 인증(Authentication)
  2. 서버는 회원DB에 등록된 사용자인지 확인
  3. 확인된 사용자의 accessToken(JWT) 발급
  4. 서버에서 서명된 JWT를 클라이언트에게 응답(Response)
  5. 클라이언트가 서버에 요청(Request)하며 JWT를 Http Header또는 URL 파라미터로 첨부
  6. 서버에서 클라이언트로부터 받은 JWT를 검증
  7. 검증이 완료되면 URL의 요청에 응답

🌵 인증은 왜 필요할까❔

JWTSession과 같이 인증(Authentication)은 왜 필요한걸까?


⚒️ JWT 구조

1. header

토큰의 타입과 해시 암호화 알고리즘에 대한 정보를 담고 있어요

{
  "alg": "HS256",
  "typ": "JWT"
}  

2. payload

토큰에 담을 정보가 들어있습니다. 여기에 담은 하나의 정보를 클레임이라고 해요
클레임은 등록된 클레임, 공개 클레임, 비공개 클레임이 존재해요 이런게 있구나 하고 넘어갈게요

{
  "sub":"12345", // 등록된 플레임
  "name": "woogym", 
  "iat" : 17889271 
}

클레임이란?

payload에 담긴 정보의 한 조각을 클레임이라고 해요, name/value의 한 쌍으로 이루어져 있어요

3. signature

서명은 [헤더 base64 + 페이로드 base64 + SECRET_KEY]를 사용해서 JWT 백엔드에서 발행해서 클라이언트에게 제공해요, 만약 헤더, 페이로드의 정보가 클라이언트에 의해 변경된 경우 서명이 무효화됩니다

  HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  your-256-bit-secret
)

🙋‍♂️ JWT는 왜 사용할까?

JWT의 장점

  • 데이터의 위조와 변조를 방지해요
  • JWT는 인증에 필요한 정보를 담고 있기 때문에 인증을 위한 별도의 저장소가 필요없어요
  • 세션(Session)과 다르게 서버는 완벽한 무상태(StateLess)를 유지할 수 있어요

JWT의 단점

  • 쿠키/세션과 다르게 토큰의 길이가 길어요 따라서 인증 요청이 많아질수록 네트워크의 부담이 심해져요
  • payload 자체는 암호화가 되지 않아 중요한 정보는 담을 수 없어요
  • 토큰을 탈취 당한다면 대처가 매우 어려워요

Ps.서버에서 가장 기피해야할 것은 DB조회에요
이와 관련해서 JWT는 별도의 DB조회를 필요로 하지 않는 장점을 가지고 있어요
그렇다면 어디에? 클라이언트의 Cookie, LocalStorage에 저장해요 이런게 있구나 하고 넘어가도록해요

Session vs JWT
이세상에 100% 완벽한 보안을 유지하는 소프트웨어, 하드웨어는 존재하지 않아요 그런만큼 session과 jwt 둘 다 보안에 취약한 부분이 있어요
1) XSS - SQL Injection : 토큰이나 세션의 민감한 정보를 탈취
2) CSRF - 세션 라이딩 기법 : 공격자가 의도한 행위를 특정 웹사이트에 요청하게 하는 공격 기법 (사용자는 자신도 모르게 공격을 수행하는 사람이 되어버려요~)


🔐 Spring Security를 활용한 JWT 인증 과정

세션 로그인 시간에 배웠던 Spring Security를 배웠어요, 기본적으로 Spring Security는 세션 & 쿠키 방식의 인증을 제공해요, 그중에서 Spring Security의 filter chain을 활용하여 JWT를 사용한 인증, 인가를 구현할 수 있어요
filter chain 활용의 핵심인 SecurityContextHolder의 개념에 대해서 알아볼게요

(1) SecurityContextHolder

Spring Security의 핵심 구성 요소에요, 현재 실행중인 스레드에 대한 보안 컨텍스트 정보를 저장하고 추출할 수 있는 매커니즘을 제공해요. 보안 컨텍스트에는 현재 인증된 사용자에 대한 인증 객체 및 권한 정보등을 포함하고 있어요

  • 현재 스레드에 대한 보안 컨텍스트 정보를 저장해요, 보안 정보를 검색할 수 있어요

(2) SecurityContext

Spring Security의 중요한 역할을 하는 인터페이스로, 애플리케이션의 보안 관련 정보를 포함하 현재 스레드의 인증상태와 권한 정보등을 저장해요

  • 인증 객체 저장, 접근 제공

(3) Authentication

Spring Security에서 사용자에 대한 인증 정보를 나타내는 역할을 담당하는 인터페이스에요, Authentication 객체는 사용자의 신원 정보 및 인증 상태를 나타내며 권한 및 인가 처리에 필요한 기본 요소를 제공해요

  • Principal : 사용자의 주체 정보를 나타내며, 일반적으로 사용자 이름, ID, 비밀번호, 이메일과 같은 정보들을 포함해요
  • Credentials : 주로 사용자의 비밀번호와 같은 인증 증거를 나타내요
  • Authorities : 현재 사용자에게 부여된 권한의 목록을 나타내요, GrantedAuthority 인터페이스 구현체를 사용해서 각 권한을 나타내요, 보편적으로 ROLE_USER, ROLE_ADMIN과 같이 문자열 형식으로 표현해요

(4) UsernamePasswordAuthenticationToken

Authentication의 구현체인 AbstractAuthenticationToken의 하위 클래스에요
쉽게 생각해서 Spring Security에서 인증을 수행하기 위해 필요한 토큰이라고 생각하면 됩니다
인증 성공 후 인증에 성공한 사용자의 인증 정보가 UsernamePasswordAuthenticationToken에 포함되어 Authentication 객체 형태로 SecurityContext에 저장되는 동작을 담당해요

SpringSecurity는 러닝 커브가 굉장히 높아요 그만큼 방대하다는 뜻이에요, 실무에서 많이 쓰이지 않는 부분이니 사이드 프로젝트를 위한 수준까지만 딱 배우고 써먹어봅시다!


👨‍💻100번 보는 것보다 한 번 해보는 게 좋다

간단한 회원가입 예제를 통해서 실습해볼게요

프로젝트를 생성

의존성 추가

JWT와 JSON 관련 의존성을 build.gradle에 추가하고 적용해줘야해요! (🔄모양 표시를 누르면 된답니다)

 dependencies {   
    ...
    
    implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
    
	...
}

application.yml

spring:
  datasource:
    url: ${DB_JDBC_URL}
    username: ${DB_USER}
    password: ${DB_PASSWORD}
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    database: mysql
    database-platform: org.hibernate.dialect.MySQL8Dialect
    hibernate:
      ddl-auto: create
    show-sql: true
    properties:
      hibernate:
        format_sql: false
        use_sql_comments: true

jwt:
  secret: ${JWT_SECRET}
  access-token-validity-in-milliseconds: ${ACCESS_TOKEN_VALIDITY_IN_MILLISECONDS}
  1. JWT_SECRET은 JWT 서명과 검증에 필요한 원본 값이에요, HMAC 서명 알고리즘에서 JWT의 무결성을 보장하는데 사용되요 JWT_SECRET이 외부에 알려지지 않는 한, JWT가 수정되면 검증 시에 무효화되요
    서명과 검증에 필요한 원본 값은 왜 필요할까요? 뒤에서 알 수 있습니다

JWT_SECRET 생성 방법
macOs : 터미널 접속 -> openssl rand -hex 32 명령어 입력
window : 윈도우는 git bash를 사용해서 openssl rand -hex 32명령어를 입력하셔야합니다. git bash에 자동으로 openssl이 내장되어 있습니다. 다른 방법도 있지만 복잡하고 시간이 오래 걸려서 git bash를 권장합니다.

  1. access-token-validity-in-milliseconds 는 토큰 만료 기간을 설정해요
    기본 시간 단위는 밀리초 입니다. 따라서 이번 시간에는 1800000 = 30분으로 설정할게요

1번과 2번 둘 다 보안을 위해 민감한 정보는 환경변수로 저장할거에요

domain

public enum Role {
    ROLE_USER;
}
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "USER_EMAIL", nullable = false)
    private String email;

    @Column(name = "USER_PASSWORD", nullable = false)
    private String password;

    @Column(name = "USER_PHONE_NUMBER", nullable = false)
    private String phoneNumber;

    @Enumerated(EnumType.STRING)
    @Column(name = "USER_ROLE", nullable = false)
    private Role role;

    @Builder
    public User(String email, String password, String phoneNumber) {
        this.email = email;
        this.password = password;
        this.phoneNumber = phoneNumber;
        this.role = Role.USER;
    }
}

dto

@Getter
@Builder
@AllArgsConstructor
public class TokenDto {
    private String accessToken;
}
@Getter
public class UserSignUpDto {
    private String email;
    private String password;
    private String phoneNumber;
}
@Getter
@Builder
@AllArgsConstructor
public class UserInfoDto {
    private String email;
    private String phoneNumber;
    private String role;
}

jwt

@Component
public class TokenProvider {

    private static final String ROLE_CLAIM = "Role";
    private static final String BEARER = "Bearer ";
    private static final String AUTHORIZATION = "Authorization";

    private final Key key;
    private final long accessTokenValidityTime;

    public TokenProvider(@Value("${jwt.secret}") String secretKey,
                         @Value("${jwt.access-token-validity-in-milliseconds}") long accessTokenValidityTime) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
        this.accessTokenValidityTime = accessTokenValidityTime;
    }

    public String createAccessToken(User user) {
        long nowTime = (new Date().getTime());

        Date accessTokenExpiredTime = new Date(nowTime + accessTokenValidityTime);

        return Jwts.builder()
                .setSubject(user.getId().toString())
                .claim(ROLE_CLAIM, user.getRole().name())
                .setExpiration(accessTokenExpiredTime)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    public Authentication getAuthentication(String accessToken) {
        Claims claims = parseClaims(accessToken);

        if (claims.get(ROLE_CLAIM) == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        // 사용자의 권한 정보를 securityContextHolder에 담아준다
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(ROLE_CLAIM).toString().split(","))
                // 해당 hasRole이 권한 정보를 식별하기 위한 전처리 작업
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                .collect(Collectors.toList());

        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(claims.getSubject(), "", authorities);
        authentication.setDetails(claims);

        return authentication;
    }

    public String resolveToken(HttpServletRequest request) { //토큰 분해/분석
        String bearerToken = request.getHeader(AUTHORIZATION);

        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER)) {
            return bearerToken.substring(7);
        }

        return null;
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (UnsupportedJwtException | ExpiredJwtException | IllegalArgumentException e) {
            return false;
        }
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(accessToken)
                    .getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        } catch (SignatureException e) {
            throw new RuntimeException("토큰 복호화에 실패했습니다.");
        }
    }
}
@RequiredArgsConstructor
public class JwtFilter extends GenericFilterBean {

    private final TokenProvider tokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        String token = tokenProvider.resolveToken((HttpServletRequest) request);

        if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
            Authentication authentication = tokenProvider.getAuthentication(token);

            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        filterChain.doFilter(request, response);
    }
}

config

@Configuration
@RequiredArgsConstructor
public class SecurityConfig{

    private final TokenProvider tokenprovider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .httpBasic(AbstractHttpConfigurer::disable)
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(
                        SessionCreationPolicy.STATELESS))
                .formLogin(AbstractHttpConfigurer::disable)
                .logout(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(authorizeRequests -> authorizeRequests
                        .requestMatchers("/gdg/**").permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(new JwtFilter(tokenprovider), UsernamePasswordAuthenticationFilter.class)
                .build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}
  1. @Configuration어노테이션으로 해당 클래스를 설정 클래스로 인식하게해요, 이는 어플리케이션 컨텍스트에 등록해요
  2. @Bean 어노테이션을 통해서 Spring 컨텍스트에 SecurityFilterChain 빈을 등록해요
  3. httpBasic : http의 기본이 되는 인증을 비활성화해요 (JWT를 사용하기에)
  4. csrf : CSRF 보호를 비활성화해요 - RESTful API와 Stateless(무상태) 인증을 사용하기에 CSRF 방어가 불필요해요
  5. sessionManagement : 세션 상태를 비저장(statless) 설정하여 서버에서 세션을 사용하지 않도록해요 (JWT를 사용해서 인증 상태를 관리하기 때문이에요)
  6. formLogin : JWT를 사용한 인증이므로 별도의 로그인 폼은 필요없어요
  7. authorizeHttpRequests : /gdg/** 경로에 대한 접근은 인증 없이 허용해요, 그 외 경로는 인증된 사용자만 접근할 수 있어요.
  8. addFilterBefore : 들어오는 요청에 대해 JWT 토큰을 검증하고 인증 정보를 설정해요.

service

@Service
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final TokenProvider tokenProvider;

    @Transactional
    public TokenDto signUp(UserSignUpDto signUpDto) {
        User user = userRepository.save(User.builder()
                .email(signUpDto.getEmail())
                .password(passwordEncoder.encode(signUpDto.getPassword()))
                .phoneNumber(signUpDto.getPhoneNumber())
                .build());

        String accessToken = tokenProvider.createAccessToken(user);

        return TokenDto.builder()
                .accessToken(accessToken)
                .build();
    }

    @Transactional(readOnly = true)
    public UserInfoDto findByPrincipal(Principal principal) {
        Long userId = Long.parseLong(principal.getName());

        User user = userRepository.findById(userId)
                .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));

        return UserInfoDto.builder()
                .email(user.getEmail())
                .phoneNumber(user.getPhoneNumber())
                .role(user.getRole().name())
                .build();
    }
}

contoller

@RestController
@RequestMapping("/gdg")
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
public class UserController {

    private final UserService userService;

    @PostMapping("/signUp")
    public ResponseEntity<TokenDto> signUp(@RequestBody UserSignUpDto userSignUpDto) {
        TokenDto response = userService.signUp(userSignUpDto);

        return ResponseEntity.ok(response);
    }

    @GetMapping("/getUser")
    public ResponseEntity<UserInfoDto> getUser(Principal principal) {
        UserInfoDto userInfoDto = userService.findByPrincipal(principal);

        return ResponseEntity.ok(userInfoDto);
    }
}

🔌Postman 실습

  • Bearer 토큰은 위와 같은 방법으로 header에 담을 수 있어요.

여담

JWT <- 사이트는 브라우저 상에서 JWT 토큰을 검증하고 생성 할 수 있게 해주는 디버거 서비스를 제공해요.

참고한 레퍼런스

이미지 출처
https://www.google.com/url?sa=i&url=https%3A%2F%2Fcoming-soon.oopy.io%2Fd9934ee3-8095-4d81-b80b-72d2b1742f37&psig=AOvVaw2vfhM9htQCX6kKSoOGrABA&ust=1731022550619000&source=images&cd=vfe&opi=89978449&ved=0CBcQjhxqFwoTCOC50t3vyIkDFQAAAAAdAAAAABAT
https://tansfil.tistory.com/58
https://mokpo.tistory.com/458

참고 자료
https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html
https://velog.io/@sujin-create/Spring-spring-security%EC%99%80-JWT-%EC%9D%B8%EC%A6%9D-%EC%9D%B8%EA%B0%80-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0
https://velog.io/@qowl880/Spring-Security-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-ContextHolder

profile
신우일신하는 개발자되기

1개의 댓글

comment-user-thumbnail
2024년 11월 11일

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyLrgpzsnbTrj4QiOiLsg53qsIHrs7Tri6Qg7Ja066C17KOgLi4_IOyggOuPhCDsspjsnYzsl5Ag64SI66y0IOyWtOugpOyboOyWtOyalC4uIiwi7IKs6rO8Ijoi7KeI66y47J2EIOyemO2VtOyVvO2VnOuLpOuKlCDslZXrsJXsnYQg7KSAIOqygyDqsJnslYTshJwg66-47JWI7ZW07JqUIiwi7KeI66y4Ijoi7LKY7J2M67aA7YSwIOyniOusuOydhCDsnpjtlZjripQg7IKs656M7J2AIOyXhuuLpOuKlCDqsbAuLiEiLCJHREdvQyI6IuyEnOuyhCDtjIztirgg7ZmU7J207YyFISIsIuqzvOygnCI6IuyYpOuKmOydtOuCmCDrgrTsnbzspJHsnLzroZwg6rO17KeA7ZWg6rKM7JqUIOOFjuOFjiJ9.8OakS1Jg7n_YFu3CRgf3EIORKeGYe72mmyM9ETO1W8Q

답글 달기