Notice
Recent Posts
Recent Comments
Link
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
Tags
more
Archives
Today
Total
관리 메뉴

이지은님의 블로그

250304 - Java Spring JWT 로그인 인증방식 구현하기 본문

TIL

250304 - Java Spring JWT 로그인 인증방식 구현하기

queenriwon3 2025. 3. 4. 23:35

 오늘 배운 

jwt를 구현하는 방법에 대해 TIL을 작성해보고자 한다.

Jwt 사용하여 회원가입, 로그인, 로그아웃, 리플레시 토큰 발급을 구현해보자.

 

 

<<목차>>

1. JwtUtil

    1) 각 필드 소개

    2) 생성자

    3) Access Token 생성

    4) 토큰에서 문자열 빼기

    5) 토큰에서 사용자 정보 가져오기

2.AuthController

    1) 회원가입 & 로그인

    2) 로그아웃

    3) 액세스 토큰 발급

3. JwtFilter

4. AdminInterceptor

 

 


 

1. JwtUtil

먼저 다음과 같은 util 클래스가 있어야한다. (리프레시 토큰에 사용자 정보를 넣는 경우)

@Slf4j(topic = "JwtUtil")
@Component
public class JwtUtil {

    private static final String BEARER_PREFIX = "Bearer ";
    private static final long ACCESS_TOKEN_TIME = 60 * 60 * 1000L;      // access 토큰 시간
    private static final long REFRESH_TOKEN_TIME = 7 * 24 * 60 * 60 * 1000L;    // refresh 토큰 시간

    @Value("${jwt.secret.key}")
    private String secretKey;
    private Key key;
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    @PostConstruct
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secretKey);
        key = Keys.hmacShaKeyFor(bytes);
    }

    //Access Token 생성
    public String createAccessToken(Long userId, String email, UserRole userRole) {
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(String.valueOf(userId))
                        .claim("email", email)
                        .claim("userRole", userRole)
                        .setExpiration(new Date(date.getTime() + ACCESS_TOKEN_TIME)) //만료시간
                        .setIssuedAt(date) // 발급일
                        .signWith(key, signatureAlgorithm) // 암호화 알고리즘
                        .compact();
    }

    //Refresh Token 생성 - 간단한 유저 정보만 저장(Access재발금용이라 그냥 유저 판단만 할 수 있을 정도만 필요하다)
    public String createRefreshToken(Long userId) {
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(String.valueOf(userId))
                        .setExpiration(new Date(date.getTime() + REFRESH_TOKEN_TIME))
                        .setIssuedAt(date) // 발급일
                        .signWith(key, signatureAlgorithm) // 암호화 알고리즘
                        .compact();
    }

    public String substringToken(String tokenValue) {
        if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
            return tokenValue.substring(7);
        } // BEARER_PREFIX를 지우기 위함
        throw new ServerException("Not Found Token");
    }

    // 검증 + 추출
    public Claims extractClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }
}

 

 

1) 필드 소개

private static final String BEARER_PREFIX = "Bearer ";
private static final long ACCESS_TOKEN_TIME = 60 * 60 * 1000L;      // access 토큰 시간
private static final long REFRESH_TOKEN_TIME = 7 * 24 * 60 * 60 * 1000L;    // refresh 토큰 시간

@Value("${jwt.secret.key}")
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

BEARER_PREFIX은 토큰 앞에 붙여줄 문자열 (7자), ACCESS_TOKEN_TIME는 Access Token의 만료기간(1시간), REFRESH_TOKEN_TIME는 Refresh Token의 만료기간(1주일)을 정의한다.

secretKey 비밀키, Key 암호화된키, signatureAlgorithm 서명알고리즘을 뜻한다.(HS256, enum 형식)

 

 

2) 생성자

@PostConstruct
public void init() {
    byte[] bytes = Base64.getDecoder().decode(secretKey);
    key = Keys.hmacShaKeyFor(bytes);
}

@PostConstruct: 스프링 Bean이 생성된 후 자동으로 실행.

Base64.getDecoder().decode(secretKey);

Base64에서 secretKey를 디코딩해서 bytes에 담는다.

Keys.hmacShaKeyFor(bytes);

서명을 위한 키를 생성한다.(jwt 생성 검증에 사용)

 

 

3) Access Token 생성

//Access Token 생성
public String createAccessToken(Long userId, String email, UserRole userRole) {
    Date date = new Date();

    return BEARER_PREFIX +
            Jwts.builder()
                    .setSubject(String.valueOf(userId))
                    .claim("email", email)
                    .claim("userRole", userRole)
                    .setExpiration(new Date(date.getTime() + ACCESS_TOKEN_TIME)) //만료시간
                    .setIssuedAt(date) // 발급일
                    .signWith(key, signatureAlgorithm) // 암호화 알고리즘
                    .compact();
}

.setSubject()으로 문자열화 한 userId를 subject로 저장

.claim(): key-value 형태로 유저 정보 저장 (보통 유저 id, password외 저장)

.setExpiration() 만료시간을 정의하는데 현재시간에 엑세스토큰 시간을 더함

.setIssuedAt() 현재 시간으로 발급일을 저장

.signWith(): 비밀키와 암호화 알고리즘을 함께 저장(signature)

.compact(): builder()를 합치는 메서드

 

 

4) 토큰에서 문자열 빼기

public String substringToken(String tokenValue) {
    if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
        return tokenValue.substring(7);
    }
    throw new ServerException("Not Found Token");
}

"Bearer “가 포함되어 있다면 토큰에서 삭제시키기 위해 사용한다. 만약 없을 경우 예외를 발생시킨다.

 

 

5) 토큰에서 사용자 정보 가져오기

public Claims extractClaims(String token) {
    return Jwts.parserBuilder()
            .setSigningKey(key)
            .build()
            .parseClaimsJws(token)
            .getBody();
}

Claims 형태로 사용자 정보를 가져올 수 있다.(subject로 id, email, UserRole)

 

 

 

2.AuthController

@RestController
@RequiredArgsConstructor
@RequestMapping("/auths")
public class AuthController {

    private final AuthService authService;

    // 회원가입
    @PostMapping("/signup")
    public TokenResponse signup(@Valid @RequestBody SignupRequest signupRequest) {
        return authService.signup(signupRequest);
    }

    // 로그인
    @PostMapping("/login")
    public TokenResponse login(@Valid @RequestBody SigninRequest signinRequest) {
        return authService.login(signinRequest);
    }
   
    // 로그아웃
    @PostMapping("/logout")
    public void logout(@Auth AuthUser authUser) {
        authService.logout(authUser);
    }

    // 리프레시 토큰 재발급
    @PostMapping("/refresh")
    public TokenResponse reissueAccessToken(@RequestBody RefreshTokenRequest request) {
        return authService.reissueAccessToken(request);
    }
}

 

 

 

1) 회원가입 & 로그인

// Controller
@PostMapping("/signup")
public TokenResponse signup(@Valid @RequestBody SignupRequest signupRequest) {
    return authService.signup(signupRequest);
}

회원가입을 하면 로그인 상태를 만들기 위해,

아이디, 비밀번호 등을 입력받고 회원가입을 한뒤 Access Token Refresh Token 반환한다.

 

 

// Service
@Transactional
public TokenResponse signup(SignupRequest signupRequest) {

    if (userRepository.existsByEmail(signupRequest.getEmail())) {
        throw new InvalidRequestException("이미 존재하는 이메일입니다.");
    }

    String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());

    UserRole userRole = UserRole.of(signupRequest.getUserRole());

    User user = new User(
            signupRequest.getEmail(),
            encodedPassword,
            userRole
    );
    userRepository.save(user);

    // 토큰발행
    String accessToken = tokenService.createAccessToken(user);
    String refreshToken = tokenService.createRefreshToken(user);

    return new TokenResponse(accessToken, refreshToken);
}

유저 저장을 한후 토큰을 발행한다.

 

// TokenService
public String createAccessToken(User user) {
    return jwtUtil.createAccessToken(user.getId(), user.getEmail(), user.getUserRole());
}

public String createRefreshToken(User user) {
    // RefreshToken 저장 (DB 또는 Redis)
    RefreshToken refreshToken = refreshTokenRepository.save(new RefreshToken(user.getId()));
    return refreshToken.getToken();
}

AccessToken은 헤더+사용자정보+시그니처 구조의 토큰을,

RefreshToken은 UUID랜덤값의 토큰을 생성하여 Refresh는 DB에 저장한다.

 

 

2) 로그아웃

// Controller
@PostMapping("/logout")
public void logout(@Auth AuthUser authUser) {
    authService.logout(authUser);
}

로그아웃은 refreshToken 만료시키는 방법을 사용한다. 이때 만료의 경우 DB에서 enum값을 INVAILD 설정시키면 된다.(비활성화)

 

// Service
@Transactional
public void logout(AuthUser authUser) {
    tokenService.revokeRefreshToken(authUser.getId());
}

RefreshToken 만료하는 메서드를 작성한다. 이때 유저 정보가 필요하다.

 

// TokenService
public void revokeRefreshToken(Long userId) {
    RefreshToken refreshToken = refreshTokenRepository.findById(userId).orElseThrow(
            () -> new InvalidRequestException("해당 유저의 Token이 존재하지 않음."));
    refreshToken.updateStatus(INVALIDATED);
}

userId를 받아서 토큰을 DB에서 받아와서 상태를 비활성화로 만든다.

 

 

3) 액세스 토큰 발급

// Controller
@PostMapping("/refresh")
public TokenResponse reissueAccessToken(@RequestBody RefreshTokenRequest request) {
    return authService.reissueAccessToken(request);
}

리프레시 토큰을 요청값으로 넣고 새로 발급한 엑세스와 리플레시 토큰을 반환해준다.(refresh token rotation 방식)

 

// Service
@Transactional
public TokenResponse reissueAccessToken(@RequestBody RefreshTokenRequest request) {
    User user = tokenService.reissueToken(request);

    String accessToken = tokenService.createAccessToken(user);
    String refreshToken = tokenService.createRefreshToken(user);

    return new TokenResponse(accessToken,refreshToken);
}

입력받은 리플래시 토큰이 유효한지 확인하고 유저정보를 반환한다.

, 새로 발급한 엑세스와 리플레시 토큰을 응답으로 반환한다.

 

// TokenService
public User reissueToken(RefreshTokenRequest request) {
    String token = request.getRefreshToken();
    
    RefreshToken refreshToken = refreshTokenRepository.findByToken(token).orElseThrow(
            () -> new InvalidRequestException("유저 찾을 수 없음"));
    
    if (INVALIDATED == refreshToken.getStatus()) {
        throw new InvalidRequestException("유효기간이 지난 refresh 토큰입니다. 다시 로그인 해주세요");
    }

    return userService.findUserByIdOrElseThrow(refreshToken.getUserId());
}

리플래시 토큰을 검색해서 유효값을 검색, 이후 유저 정보를 반환한다.

 

 

3. JwtFilter

@Slf4j
@RequiredArgsConstructor
public class JwtFilter implements Filter {

    private final JwtUtil jwtUtil;

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        Filter.super.init(filterConfig);
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        String url = httpRequest.getRequestURI();

        if (url.startsWith("/auth")) {
            chain.doFilter(request, response);
            return;
        }

        // 로그인 검증
        String bearerJwt = httpRequest.getHeader("Authorization");

        if (bearerJwt == null) {
            // 토큰이 없는 경우 400을 반환합니다.
            httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다.");
            return;
        }

        String jwt = jwtUtil.substringToken(bearerJwt); //베어러 제거

        try {
            // JWT 유효성 검사와 claims 추출
            Claims claims = jwtUtil.extractClaims(jwt);
            if (claims == null) {
                httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다.");
                return;
            }

            // 검증만하고 jwt에 담아줌
            httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject()));
            httpRequest.setAttribute("email", claims.get("email"));
            httpRequest.setAttribute("userRole", claims.get("userRole"));


            chain.doFilter(request, response);
        } catch (SecurityException | MalformedJwtException e) {
            log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.error("Expired JWT token, 만료된 JWT token 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다.");
        } catch (Exception e) {
            log.error("Invalid JWT token, 유효하지 않는 JWT 토큰 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "유효하지 않는 JWT 토큰입니다.");
        }
    }

    @Override
    public void destroy() {
        Filter.super.destroy();
    }
}

특정 컨트롤러에서 비로그인 사용자를 필터링한다.

비로그인자 판단의 경우 AccessToken 유무와 유효성을 판단한다.

 

 

// 로그인 검증
String bearerJwt = httpRequest.getHeader("Authorization");

if (bearerJwt == null) {
    httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다.");
    return;
    }

String jwt = jwtUtil.substringToken(bearerJwt); //베어러 제거

try {
    // JWT 유효성 검사와 claims 추출
    Claims claims = jwtUtil.extractClaims(jwt);
    if (claims == null) {
        httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다.");
        return;
    }

    httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject()));
    httpRequest.setAttribute("email", claims.get("email"));
    httpRequest.setAttribute("userRole", claims.get("userRole"));

 

로그인 유저를 확인하기 위해 header에서 "Authorization”값을 얻고, bearer문자열을 제거하여, 사용자 정보를 추출한다. 사용자 정보는 httpRequest에 저장하여 argument resolver에서 처리한다.

 

 

 

4. AdminInterceptor(선택)

@Slf4j
public class AdminAuthInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        UserRole userRole = UserRole.of((String) request.getAttribute("userRole"));

        log.info(String.valueOf(userRole));

        if(UserRole.ADMIN != userRole) {
            log.warn("관리자가 아닌 사용자 접근");
            throw new AuthException("관리자가 아닌 사용자 접근");
        }
        return true;
    }
}

request에서 사용자 정보를 얻어와 권한을 판단하여 접근가능 권한이 아닐시 예외를 발생시킨다.