Develop/Java & Spring

[Spring Boot] 게시판 프로젝트 v2.0 - JWT를 이용한 회원가입 및 로그인 구현 #4 Util

jjh0119 2025. 3. 26. 13:24

JWT에 대한 이론은 저번 글에서 충분히 알아봤고 이제부터 본격적인 구현에 들어가보자


우선 이번엔 Board 때와 다르게 util 패키지부터 시작해볼텐데 JWT를 실직적으로 발급하는 로직에 관한 클래스들이 대부분 여기 모여있기 때문에 처음으로 짚고 넘어가야 이해가 쉬울 것이라 생각했다.


com.security_board.security.jwt.util.JwtRule

package com.security_board.security.jwt.util;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Getter
public enum JwtRule {
	JWT_ISSUE_HEADER("Set-Cookie"),
	JWT_RESOLVE_HEADER("Cookie"),
	ACCESS_PREFIX("access"),
	REFRESH_PREFIX("refresh");
	
	private final String value;
}

com.security_board.security.jwt.util.TokenStatus

package com.security_board.security.jwt.util

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Getter
public enum TokenStatus {
	AUTHENTICATED,
	EXPIRED,
	INVALID
}

JWT를 생성하는 데 필요한 상수 클래스


com.security_board.security.jwt.util.JwtGenerator

package com.security_board.security.jwt.util;

import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

import org.springframework.stereotype.Component;

import com.security_board.security.member.domain.Member;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

@Component
public class JwtGenerator {
	public String generateAccessToken(final Key ACCESS_SECRET, final long ACCESS_EXPIRATION, Member member) {
		Long now = System.currentTimeMillis();
		
		return Jwts.builder()
				.setHeader(createHeader())
				.setClaims(createClaims(member))
				.setSubject(member.getEmail())
				.setExpiration(new Date(now + ACCESS_EXPIRATION))
				.signWith(ACCESS_SECRET, SignatureAlgorithm.HS256)
				.compact();
	}
	
	public String generateRefreshToken(final Key REFRESH_SECRET, final long REFRESH_EXPIRATION, Member member) {
		Long now = System.currentTimeMillis();
		
		return Jwts.builder()
				.setHeader(createHeader())
				.setSubject(member.getEmail())
				.setExpiration(new Date(now + REFRESH_EXPIRATION))
				.signWith(REFRESH_SECRET, SignatureAlgorithm.HS256)
				.compact();
	}

	public Map<String, Object> createHeader() {
		Map<String, Object> header = new HashMap<>();
		header.put("typ", "JWT");
		header.put("alg", "HS512");
		return header;
	}
	
	private Map<String, Object> createClaims(Member member){
		Map<String, Object> claims = new HashMap<>();
		claims.put("email", member.getEmail());
		claims.put("role", member.getRole());
		return claims;
	}
}

JWT를 생성하는 클래스.
Header와 Claim에 들어가야할 정보를 받아서 JWT를 build하고 이를 반환하는 메서드를 갖고 있다.

Access Token에는 사용자의 정보를 담아야 하기 때문에 claim에 사용자를 식별할 수 있도록 email과 사용자의 권한을 의미하는 role을 함께 담는다.

Refresh Token은 인가를 위한 수단이 아니고, Access Token을 재발급 받기 위한 수단이므로 claim에 별다른 값을 넣어주지 않고 생성한다.


com.security_board.security.jwt.util.JwtUtil

비즈니스 로직은 아니지만 비즈니스 로직과 관련되고 JWT를 다루면서 필요한 여러가지 메서드들을 구현해놓은 클래스

package com.security_board.security.jwt.util;

import java.security.Key;
import java.util.Arrays;
import java.util.Base64;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.nimbusds.jose.util.StandardCharset;

import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.Cookie;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class JwtUtil {
	public TokenStatus getTokenStatus(String token, Key secretKey) {
		try {
			Jwts.parserBuilder()
					.setSigningKey(secretKey)
					.build()
					.parseClaimsJws(token);
			return TokenStatus.AUTHENTICATED;
		} catch (ExpiredJwtException | IllegalArgumentException e) {
			log.error(e.getMessage());
			return TokenStatus.EXPIRED;
		} catch(JwtException e) {
			throw new RuntimeException("Invalid JWT");
		}
	}
	
	public String resolveTokenFromCookie(Cookie[] cookies, JwtRule tokenPrefix) {
	    return Arrays.stream(cookies)
	            .filter(cookie -> cookie.getName().equals(tokenPrefix.getValue()))
	            .findFirst()
	            .map(Cookie::getValue)
	            .orElse("");
	}

	public Key getSigningKey(String secretKey) {
		String encodedKey = encodeToBase64(secretKey);
		return Keys.hmacShaKeyFor(encodedKey.getBytes(StandardCharset.UTF_8));
	}
	
	public String encodeToBase64(String secretKey) {
		return Base64.getEncoder().encodeToString(secretKey.getBytes());
	}
	
	public Cookie resetToken(JwtRule tokenPrefix) {
		Cookie cookie = new Cookie(tokenPrefix.getValue(), null);
		cookie.setMaxAge(0);
		cookie.setPath("/");
		return cookie;
	}
}

getTokenStatus()

	public TokenStatus getTokenStatus(String token, Key secretKey) {
		try {
			Jwts.parserBuilder()
					.setSigningKey(secretKey)
					.build()
					.parseClaimsJws(token);
			return TokenStatus.AUTHENTICATED;
		} catch (ExpiredJwtException | IllegalArgumentException e) {
			log.error(e.getMessage());
			return TokenStatus.EXPIRED;
		} catch(JwtException e) {
			throw new RuntimeException("Invalid JWT");
		}
	}

검사하고자 하는 token과 secretkey를 전달받아, 해당 토큰의 유효 기간이 지나지 않았고 유효한지 여부를 파악한다.

토큰의 시크릿 키, 구조가 유효하고 유효 기간이 지나지 않았다면 정상적으로 AUTHENTICATED 상태를 반환하게 되고,
시크릿 키와 구조는 유효하나 유효 기간이 지났다면 EXPIRED를 반환한다.

이 과정에서 토큰 자체가 유효하지 않다면 Exception이 발생한다.

resolveTokenFromCookie()

	public String resolveTokenFromCookie(Cookie[] cookies, JwtRule tokenPrefix) {
	    return Arrays.stream(cookies)
	            .filter(cookie -> cookie.getName().equals(tokenPrefix.getValue()))
	            .findFirst()
	            .map(Cookie::getValue)
	            .orElse("");
	}

Cookie에서 원하는 토큰을 찾는 역할.
쿠키에 담긴 토큰 중 tokenPrefix와 일치하는 토큰을 찾아 반환하는 메서드.

getSigningKey()

	public Key getSigningKey(String secretKey) {
		String encodedKey = encodeToBase64(secretKey);
		return Keys.hmacShaKeyFor(encodedKey.getBytes(StandardCharset.UTF_8));
	}

String으로 된 문자열을 인코딩과 Keys 클래스의 정적 메서드를 사용하여 Key객체로 만들어 반환한다.

resetToken()

	public Cookie resetToken(JwtRule tokenPrefix) {
		Cookie cookie = new Cookie(tokenPrefix.getValue(), null);
		cookie.setMaxAge(0);
		cookie.setPath("/");
		return cookie;
	}

tokenPrefix와 같은 이름의 새로운 토큰을 담은 쿠키를 반환한다. 이 쿠키는 생성 즉시 파기되도록 setMaxAge()를 0으로 설정해 실적으로 쿠키에 담긴 토큰을 삭제하는 것과 같은 효과를 낸다. 후에 로그아웃을 위해 사용되는 메서드이다.


com.security_board.security.jwt.util.UserPrincipal

package com.security_board.security.jwt.util;

import java.util.Collection;
import java.util.Collections;
import java.util.Map;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;

import com.security_board.security.member.domain.Member;

import lombok.Getter;


@Getter
public class UserPrincipal implements UserDetails, OAuth2User {

	private Member member;
    private String nameAttributeKey; // for OAuth
    private Map<String, Object> attributes; // for OAuth
    private Collection<? extends GrantedAuthority> authorities;

	//Form Login
    public UserPrincipal(Member member) {
        this.member = member;
        this.authorities = Collections.singletonList(new SimpleGrantedAuthority(member.getRole().getKey()));
    }
	//OAuth2 Login
    public UserPrincipal(Member member, Map<String, Object> attributes, String nameAttributeKey) {
        this.member = member;
        this.authorities = Collections.singletonList(new SimpleGrantedAuthority(member.getRole().getKey()));
        this.attributes = attributes;
        this.nameAttributeKey = nameAttributeKey;
    }

    @Override
    public String getName() {
        return member.getEmail();
    }

    @Override
    public String getPassword() {
        return null;
    }

    @Override
    public String getUsername() {
        return String.valueOf(member.getId());
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

Spring Security에서는 SecurityContextHolder에 Authentication 객체를 넣어 관리한다.

이렇게 넣은 Authentication 객체는 SecurityContextHolder.getContext().getAuthentication()을 통해 해당 객체를 다시 받아올 수도 있다.

Authentication 인터페이스는 getPrincipal() 메서드를 통해 인증과 관련된 정보들이 담긴 객체를 받아올 수 있으며 UserDetails 타입의 객체가 여기에 해당한다.

UserDetails은 SpringSecurity에서 기본으로 제공하는 인터페이스로서, 인증과 관련된 사용자 정보를 추상화한 인터페이스이다.

UserPrincipal은 이 UserDetails을 구현한 클래스로 Authentication 객체를 만들고 SecurityContextHolder에 들어가는 객체가 된다.

public class UserPrincipal implements UserDetails, OAuth2User {

	private Member member;
    private String nameAttributeKey; // for OAuth
    private Map<String, Object> attributes; // for OAuth
    private Collection<? extends GrantedAuthority> authorities;

후에 OAuth2를 이용한 간편 로그인도 기능도 추가할 계획이기 때문에 추가로 OAuth2User도 구현했다.

	//Form Login
   public UserPrincipal(Member member) {
        this.member = member;
        this.authorities = Collections.singletonList(new SimpleGrantedAuthority(member.getRole().getKey()));
    }
	//OAuth2 Login
    public UserPrincipal(Member member, Map<String, Object> attributes, String nameAttributeKey) {
        this.member = member;
        this.authorities = Collections.singletonList(new SimpleGrantedAuthority(member.getRole().getKey()));
        this.attributes = attributes;
        this.nameAttributeKey = nameAttributeKey;
    }

2개의 생성자가 있는데 Form Login 즉 일반 로그인에는 Member 객체와 authorities가 들어간다.
authorities는 Member 객체의 권한 role을 가져와 설정한다.

	//OAuth2User
    @Override
    public String getName() {
        return member.getEmail();
    }

    @Override
    public String getPassword() {
        return null;
    }
    @Override
    public String getUsername() {
        return String.valueOf(member.getId());
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

UserDetails와 OAuth2User 구현하면 재정의해야 하는 메서드들이다.
원래는 getAuthorities()도 있지만 @Getter 어노테이션을 달아주며 자동으로 구현되었다.