s00jin 님의 블로그

6. [로그인] JWT 인증 인가를 이용한 로그인 구현하기 | Spring Boot / JWT 본문

프로젝트/하고 싶은거 다해보는 내 사이트

6. [로그인] JWT 인증 인가를 이용한 로그인 구현하기 | Spring Boot / JWT

s00jin 2025. 8. 17. 01:06

JWT란

인증에 필요한 정보들을 암호화 시킨 JSON 토큰이다.


JWT 구조

JWT는 Header, Payload, Signature가 점으로 구분되어 이루어져 있다.

AAAAA.BBBBB.CCCCC

 

A ← Header

B ← Payload

C ← Signature

Header

헤더에는 일반적으로 토큰 유형서명(해싱) 알고리즘이 들어간다.

  • typ : 토큰 타입 지정
  • alg : 서명 알고리즘 지정 (보통, HS256, RSA 사용 + Signature에서 사용)
{
	"typ": "JWT",
	"alg" : "HS256"
}

Payload

페이로드에는 클레임으로 구성되어 있는데, 등록된 클래임과 개인 클래임이 있다.

클레임(claim) 이란?

  • 토큰에 담을 정보의 한 조각
  • key-value 형태
  • 여러 개의 클레임을 담을 수 있음

 

클레임 종류

  • 등록된 클레임
    • 이미 정해진 클래임
    • 필수가 아닌 선택
  • 공개된 클레임
  • 비공개 클레임
    • 정보 공유를 위해 사용자가 지정한 정보를 담는다
{
	// 등록된 클레임
	"exp": "1234032343",
	
	// 비공개 클레임
	"userName": "One",
	"isAdmin": false
}

Signature

헤더에서 정의한 알고리즘 방식을 사용해서 헤더 + 페이로드 + 비밀키를 합친 것을 암호화 한 것이다.

Base64Url(Header) + . + Base64Url(PayLoad) + SecretKey 를 알고리즘을 사용하여 암호화

 


JWT 인증 방식

 

구현할 때 지정해준 비밀키를 사용해서 서버가 받아온 토큰디코딩한다.

이때, Header와 Payload비밀키가 없어도 누구나 정보를 알아낼 수 있다. 하지만 Signature은 비밀키를 알아야만 구할 수 있다.

  1. 사용자가 로그인을 요청함
  2. 요청한 로그인 정보를 가지고 사용자를 확인함
  3. 사용자가 맞다면 토큰을 발급함
  4. 발급한 토큰을 서버에 보내줌
  5. 사용자가 서버에 요청 시, 헤더에 발급 받은 토큰을 붙여 요청을 보냄
  6. 토큰 검증
  7. 문제 없다면 응답 반환

 

구현 코드

라이브러리 설치

// JWT Token 사용 라이브러리
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0..11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

JwtTokenUtil

JWT 사용시 필용한 기능들을 정리해 둔 클래스

package org.mySite.user.jwt;

import java.util.Date;

public class JwtTokenUtil {

    // JWT token 발급 (Claim에 loginId 넣기)
    public static String createToken(String loginId, String key, long expireTimeMs) {
        // Claim = Jwt Token에 들어갈 정보
        // Claim에 loginId를 넣어줌으로써 나중에 loginId를 꺼낼 수 있음
        Claims claims = Jwts.claims();
        claims.put("loginId", loginId);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expireTimeMs))
                .signWith(SignatureAlgorithm.HS256, key)
                .compact();
    }

    // Claims에서 loginId 꺼내기
    public static String getLoginId(String token, String secretKey) {
        return extractClaims(token, secretKey).get("loginId").toString();
    }

    // 발급된 Token이 만료 시간이 지났는지 체크
    public static boolean isExpired(String token, String secretKey){
        Date expiredDate = extractClaims(token, secretKey).getExpiration();
        // 만료 날짜가 지금보다 이전인지 체크
        return expiredDate.before(new Date());
    }

    // SecretKey를 사용해 Token Parsing
    private static Claims extractClaims(String token, String secretKey) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
    }
}

SecurityConfig

package org.mySite.user.SpringSecurity;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig{
     //spring Security 로그인에서 사용 + JWT
     @Bean
     public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
     }

     //spring security 사용
    @Bean
    protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(auth -> auth
                        // 인증, 인가 필요한 url 지정
                        .requestMatchers("/security-login/info").authenticated()    //authenticated 해당 url에 진입하기 위해 authentication(인증, 로그인)이 필요함
                        .requestMatchers("/security-login/admin/**").hasAuthority(UserRole.ADMIN.name())    // 해당 url 집입하기 위해서 Authorization(인가, ex)권한이 ADMIN인 유저만 집입가능)이 필요함
                        .anyRequest().permitAll()   // 그외의 모든 url / authentication, authorization 필요 없이 통과
                )
                .formLogin(form -> form
                        // form login 방식 적용
                        .usernameParameter("loginId")   // 로그인할 때 사용되는 id를 적어줌(여기서는 loginId로 로그인 하기 때문에 따로 적어줌. userName으로 로그인 한다면 적어주지 않아도 됨)
                        .passwordParameter("password")  // 로그인할 때 사용되는 password를 적어줌
                        .loginPage("/security-login/login") // 로그인 페이지 url
                        .defaultSuccessUrl("/security-login", true)   // 로그인 성공 시 이동할 url
                        .failureUrl("/security-login/login")    // 로그인 실패 시 이동할 url

                )
                .logout(logout -> logout
                        // 로그아웃에 대한 정보
                        .logoutUrl("/security-login/logout")
                        .invalidateHttpSession(true)
                        .deleteCookies("JSESSIONID")
                );
        return http.build();
    }

    //---------------------------

    // JWT 로그인에서 사용
    private final @Lazy JwtTokenFilter jwtTokenFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
         httpSecurity
                .httpBasic(Customizer.withDefaults()) // Http Basic 인증 비활성화
                .csrf(csrf -> csrf.disable())   // csrf 보호 비활성화(jwt는 상태가 없기 때문)
                .sessionManagement(session ->
                        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 사용 안함 + 필수 설정
                .addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class)    // JWT 필터 추가
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/jwt-login/info").authenticated() // 인증 필요
                        .requestMatchers("jwt-login/admin/**").hasAuthority(UserRole.ADMIN.name()) // ADMIN 권한 필요
                        .anyRequest().permitAll() // 그 외 요청은 모두 허용
                );
         return httpSecurity.build();
    }
}

JwtTokenFilter

package org.mySite.user.jwt;

@Component
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter { // OncePerRequestFilter : 매번 들어갈 때 마다 체크해주는 필터

    private final UserDetailsService userDetailsService;

    @Value("${jwt.secret}")
    private String secretKey;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

        // Header의 Authorization 값이 비어있으면 => Jwt Token을 전송하지 않음 => 로그인 하지 않음
        if(authorizationHeader == null) {
            filterChain.doFilter(request, response);
            return;
        }

        // Header의 Authorization 값이 'Bearer '로 시작하지 않으면 => 잘못된 토큰
        if(!authorizationHeader.startsWith("Bearer ")){
            filterChain.doFilter(request, response);
            return;
        }

        // 전송받은 값에서 'Bearer ' 뒷부분(Jwt Token) 추출
        String token = authorizationHeader.split(" ")[1];

        // 전송받은 Jwt Token이 만료되었으면 => 다음 필터 진행(인증 X)
        if(JwtTokenUtil.isExpired(token, secretKey)) {
            filterChain.doFilter(request, response);
            return;
        }

        // Jwt Token에서 loginId 추출
        String loginId = JwtTokenUtil.getLoginId(token, secretKey);

        // 추출한 loginId로 User 찾아오기
        UserDetails userDetails = userDetailsService.loadUserByUsername(loginId);

        // loginUser 정보로 UsernamePasswordAuthenticationToken 발급
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                userDetails.getUsername(), null, userDetails.getAuthorities()
        );
        authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

        // 권한 부여
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        filterChain.doFilter(request, response);
    }
}

JwtLoginApiController

package org.mySite.user.jwt;

@RestController
@RequiredArgsConstructor
@RequestMapping("/jwt-login")
public class JwtLoginApiController {

    private final UserService userService;

    @PostMapping("/join")
    public String join(@RequestBody JoinRequest joinRequest) {

        // loginId 중복 체크
        if(userService.checkLoginId(joinRequest.getLoginId())) {
            return "로그인 아이디가 중복됩니다.";
        }

        // 닉네임 중복 체크
        if(userService.checkNickname(joinRequest.getNickname())) {
            return "닉네임이 중복됩니다.";
        }

        // password + passwordCheck 확임
        if(!joinRequest.getPassword().equals(joinRequest.getPasswordCheck())) {
            return "비밀번호가 일치하지 않습니다.";
        }

        //userService.join2(joinRequest);
        userService.join(joinRequest);
        return "회원가입 성공";
    }

    @PostMapping("/login")
    public String login(@RequestBody LoginRequest loginRequest) {

        User user = userService.login(loginRequest);

        // 로그인 실패 시 에러 반환
        if(user == null){
            return "로그인 아이디 또는 비밀번호가 틀렸습니다.";
        }

        // 로그인 성공 시 Jwt Token 발급
        String secretKey = "dGhpcy1pcy1hLXZlcnktc2VjdXJlLXNlY3JldC1rZXktMTIzNDU2Nzg5MCE=";
        long expireTimeMs = 1000 * 60 * 60;     // 60분

        String jwtToken = JwtTokenUtil.createToken(user.getLoginId(), secretKey, expireTimeMs);

        return jwtToken;
    }

    @GetMapping("/info")
    public String userInfo(Authentication auth) {
        User loginUser = userService.getLoginUserByLoginId(auth.getName());

        return String.format("loginId: %s\\n nickname: %s\\n role: %s", loginUser.getLoginId(), loginUser.getNickname(), loginUser.getRole().name());

    }

    @GetMapping("/admin")
    public String adminPage() {
        return "관리자 페이지 접근 성공";
    }
}

결과 확인

POSTMAN을 사용하여 회원가입 → 로그인 → 토큰 발급됨 → 헤더에 토큰 추가 후 info 등 확인