3. JWT 발급, 인증/인가 로직 구현

프라이마리모·2025년 4월 26일

REST API

목록 보기
4/4
post-thumbnail

이 시리즈에서는 JPA 예시 코드를 기반으로, 실제 운영 환경에서 각기 다른 도메인끼리의 통신을 가정한 RESTful API로 디벨롭한다.
* 기본 웹 프로젝트 세팅 완료된 상태에서 시작, 테스트는 POSTMAN 활용

디벨롭 단계

  • Interceptor 생성 및 적용
  • API Key 검증 로직 구현
  • JWT 발급, 인증/인가 로직 구현
    • 서버용 JWT 발급 로직 구현
    • 서버 인터셉터 내 검증 구현
      • JWT의 형식 검증
      • JWT의 서명(Signature) 검증
      • 클레임(예: iss, exp, scope) 검증.
    • 클라이언트 용 인증 로직 구현
  • OAuth 2.0 기반 인증/인가 로직 구현
    • 클라이언트 용 Access Token 발급 로직 구현
    • 서버 인터셉터 내 검증 구현
      • Access Token의 형식 검증
      • 인증 서버에 검증 요청(옵션, 원격 검증)
      • Access Token의 클레임(예: scope, exp) 검증

이전에 작성한 api key 검증 로직의 일부를 수정하여 JWT를 활용하는 로직을 구현한다. 이 파트에서는 spring-security를 활용한다.

서버 측 코드 수정

Gradle 의존성 추가

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'    
    runtimeOnly   'io.jsonwebtoken:jjwt-impl:0.11.5'  
    runtimeOnly   'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

JwtUtil 생성

@Component
public class JwtUtil {
    @Value("${jwt.secret}")
    private String secretKey;
    @Value("${jwt.expiration-ms}")
    private long expirationMs;

    public String generateToken(UserDetails user) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("roles", user.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority).toList());

        return Jwts.builder()
            .setIssuer("my-company")   // issuer 클레임 설정, iss(발급자) 설정
            .setSubject(user.getUsername())
            .claim("roles", user.getAuthorities().stream()
                    .map(GrantedAuthority::getAuthority).toList())
            .claim("scope", "read write")    // scope 클레임 설정, scope(권한 범위) 설정
            .setIssuedAt(now)
            .setExpiration(new Date(now.getTime() + expirationMs))
            .signWith(Keys.hmacShaKeyFor(secretKey.getBytes()),
                      SignatureAlgorithm.HS256)
            .compact();
    }

    public String extractUsername(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(secretKey.getBytes())
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    public boolean validateToken(String token, UserDetails user) {
        Claims claims = Jwts.parserBuilder()
            .setSigningKey(secretKey.getBytes())
            .build()
            .parseClaimsJws(token)
            .getBody();

        boolean subjectOk   = claims.getSubject().equals(user.getUsername());
        boolean notExpired  = claims.getExpiration().after(new Date());
        boolean issuerOk    = "my-company".equals(claims.getIssuer());          // iss(발급자) 검증
        boolean scopeOk     = Arrays.stream(claims.get("scope", String.class)
                                         .split(" "))
                                  .collect(Collectors.toSet())
                                  .containsAll(/* requiredScopes */);         // scope(권한 범위) 검증

        return subjectOk && notExpired && issuerOk && scopeOk;
}

UserDetailsService 구현 클래스 생성

@Service
public class CustomUserDetailsService implements UserDetailsService {
    private final MemberRepository memberRepository; // JPA 리포 사용

    public CustomUserDetailsService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) {
        Member member = memberRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
        return org.springframework.security.core.userdetails.User.builder()
            .username(member.getUsername())
            .password(member.getPassword())
            .roles(member.getRoles().toArray(new String[0]))
            .build();
    }
}

인터셉터 preHandle 수정

@Component
public class AuthenticationInterceptor implements HandlerInterceptor {
	private final JwtUtil jwtUtil;         
    private final UserDetailsService userDetailsService;
    
    public AuthenticationInterceptor(JwtUtil jwtUtil,
                                     UserDetailsService userDetailsService) {
        this.jwtUtil = jwtUtil;
        this.userDetailsService = userDetailsService;
    }

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) throws Exception {
        String header = request.getHeader("Authorization");
        if (header == null || !header.startsWith("Bearer ")) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Unauthorized");
            return false;
        }
        String token = header.substring(7);
        String username = jwtUtil.extractUsername(token);
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails user = userDetailsService.loadUserByUsername(username);
            if (jwtUtil.validateToken(token, user)) {
                UsernamePasswordAuthenticationToken auth =
                  new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(auth);
                return true;
            }
        }
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.getWriter().write("Invalid or expired token");
        return false;
    }
    
    ...
}

클라이언트 측 코드 수정

public class ApiClient {
    private final RestTemplate rest;
    private String jwt; 

    public ApiClient(RestTemplateBuilder b) {
        this.rest = b.build();
    }

    public void login(String user, String pass) {
        AuthRequest req = new AuthRequest(user, pass);
        AuthResponse resp = rest.postForObject("/api/auth/login", req, AuthResponse.class);
        this.jwt = resp.getToken();
    }

    public <T> T get(String url, Class<T> cls) {
        HttpHeaders h = new HttpHeaders();
        h.setBearerAuth(jwt);
        HttpEntity<?> e = new HttpEntity<>(h);
        return rest.exchange(url, GET, e, cls).getBody();
    }
}
profile
개발공부 요약노트

0개의 댓글