나를 기록하다
article thumbnail
반응형

Spring Security + JWT

사이드 프로젝트를 진행하면서 Spring Security와 JWT를 적용하여 회원 기능을 완성하는 역할을 맡게 되었다.

JWT는 프로젝트에 적용시켜본 경험이 있으나 Spring Security는 처음이었고 Spring Security와 JWT를 함께 적용시켜보는 것 또한 처음이었기에 JWT부터 Spring Security까지 적용시켜가는 과정을 차근차근 기록해보려 한다. 시작하겠다.

 

JWT(Json Web Token)

인증 방식

토큰 인증 방식(JWT)

  • 동작 원리
    1. 사용자가 로그인하면 서버는 JWT와 같은 토큰을 생성하여 클라이언트에 전달
    2. 클라이언트는 이후의 모든 요청에 이 토큰을 HTTP 헤더에 포함시켜 서버로 전송
    3. 서버는 토큰을 검증하여 사용자 정보를 확인하고 인증 여부 판단
  • 장점
    1. 서버는 무상태(stateless) 방식으로 작동 → 수평 확장 용이
    2. 클라이언트 측에서 인증 정보 유지 → 서버 부담 줄어듦
    3. API 서버 간의 인증을 쉽게 처리 가능 → MSA(마이크로서비스 아키텍처)에 적합
  • 단점
    1. 토큰이 클라이언트 측에 저장 → 토큰 유출 시 보안 문제 발생
    2. 토큰 무효화가 어려움 → 토큰 만료 전 로그아웃 처리 복잡
    3. 토큰의 크기가 커질 수 있음 → 네트워크 트래픽 부담 우려

세션 인증 방식

  • 동작 원리
    1. 사용자가 로그인하면 서버는 사용자 정보를 세션 저장소에 저장하고, 세션 ID를 생성하여 클라이언트에 쿠키로 전송
    2. 클라이언트는 이후의 모든 요청에 세션 ID를 포함한 쿠키를 서버로 전송
    3. 서버는 세션 ID를 기반으로 세션 저장소에서 사용자 정보를 조회하여 인증 여부를 판단
  • 장점
    1. 서버가 세션을 관리 → 세션 만료나 로그아웃 시 즉시 세션 종료 가능
    2. 서버 측에서 세션 데이터를 직접 관리 → 보안성 높음
  • 단점
    1. 서버 메모리에 세션 데이터를 저장 → 사용자 수 많아지면 서버 부담 증가
    2. 서버 간 상태 정보를 공유 → 수평 확장 어려움
    3. 클라이언트가 서버에 의존 → 서버 부하 증가

 

JWT

  • Json Web Token, 웹에서 토큰 인증 방식으로 많이 사용되는 토큰의 종류
  • JSON으로 만들어진 인증 관련 정보를 인코딩한 토큰
  • 토큰에 대한 기본 정보, 인증에 대한 서버 측에 전달할 정보, 검증되었음을 증명하는 Signature를 포함

 

JWT의 구조

jwt.io에서 확인할 수 있는 jwt 구조

JWT 토큰은 base64로 인코딩하여 아래와 같은 문자열 형식을 사용한다.

(Header)xxxxxxxxx.(Payload)xxxxxxxxx.(Signature)xxxxxxxxx

Header

  • alg: 인코딩에 사용되는 해싱 알고리즘 종류
  • typ: 토큰의 타입, JWT

Payload

  • 토큰에 담을 정보가 들어감
  • JSON 쌍으로 구성
  • 등록된 클레임: 토큰의 기본 정보를 담기 위한 이름이 정해져 있는 정보
    • iss: 토큰 발급자
    • sub: 토큰 제목
    • aud: 토큰 대상자
    • exp: 토큰의 만료 시간, 해당 시간이 끝난 이후에는 사용 불가
    • iat: 토큰이 발급된 시간
    • nbf: 토큰의 활성화 시간, 해당 시간이 지나기 전에는 사용 불가
  • 공개 클레임
    • 원하는 대로 정의한 클레임
    • 충돌이 방지된 이름을 가지고 있어야 함
  • 비공개 클레임
    • 서비스에서 공유하기 위한 정보를 담은 클레임
    • 이름이 중복되어 충돌 가능
    • 인증에서의 사용자 ID 등이 이곳에 담김

Signature

  • 유효성을 검증하기 위한 부분
  • 헤더의 인코딩 값과 페이로드의 인코딩 값을 합친 후 토큰을 생성할 때 사용된 Secret Key를 통해 암호화하여  생성
  클라이언트 서버
인증
1 로그인 요청  
2   DB에서 ID와 비밀번호 대조 후 일치여부 확인
3   일치 시 암호화된 토큰 생성
4   응답으로 토큰 반환
5 클라이언트에 토큰 저장  
인가
1 API 요청 시 헤더에 토큰을 포함시켜 요청  
2   토큰을 복호화하여 유효성 검증
3   검증이 완료되었다면 API 로직 처리 후 응답
4 응답을 받음  

 

 

인증

  1. 클라이언트는 ID와 비밀번호를 포함하여 서버에 로그인 요청을 보냄
  2. 서버는 ID와 비밀번호를 DB의 회원 정보와 대조 후 인증 여부 판단
  3. 일치 시 Secret Key를 이용하여 암호화된 토큰을 생성
    토큰에는 인증정보, 서명 문자열, 사용자의 일부 정보(비밀번호 같은 민감정보는 제외)를 포함
    해당 토큰에는 만료시간 설정
  4. 클라이언트는 Cookie나 Storage, Application 내부 등에 반환받은 토큰 저장

인가

  1. 클라이언트는 API 요청 시 HTTP 헤더에 인증 과정을 거친 후 발급받은 토큰을 포함시켜 요청
  2. 서버는 헤더에 포함된 토큰을 Secret Key를 통해 Signature 부분의 유효성 검증
  3. 유효한 토큰이라면 인가에 성공했으며 API 요청 처리 및 응답
  4. 클라이언트는 API의 응답을 받음

Access Token & Refresh Token

JWT 토큰의 단점, 해결방안

단점

  • Payload 부분은 단순히 base64 형식으로 인코딩한 수준 → 안의 데이터 볼 수 있음 → 민감한 데이터 넣지 않음
  • 이미 발급된 토큰에 대해서는 사용자의 인증과 인가 처리를 무효화하기 어려움 → 토큰 탈취 사용될 우려

해결방안

  • 위의 문제점을 극복하기 위해 토큰 만료 시간을 짧게 주어 탈취 당하여 사용되기 이전에 무효화시키는 방법 사용
  • 이를 위해 도입된 개념이 Access Token & Refresh Token

 

JWT Access / Refresh Token 인증 플로우

  • 토큰 만료시간을 짧게 주면 다시 인증과정을 거쳐 재발급을 받아야 함
  • 따라서 다시 로그인과 같은 인증 과정을 거치지 않고 Refresh Token을 통해서 인증 과정을 대신함
  • Access Token: 평상시 API 인가에 사용되는 토큰
  • Refresh Token: Access Token이 만료되었을 때 헤더에 포함시켜서 Access Token 재발급 요청을 하는 데 사용
  • 보통 Access Token은 시간을 2 ~ 5분 내외로 짧게 주는 반면 Refresh Token은 2주 정도로 길게 주는 편
  클라이언트 서버
인증
1 로그인 요청  
2   DB에서 ID와 비밀번호 대조 후 일치여부 확인
3   일치 시 암호화된 토큰 생성
4   응답으로 Access Token / Refresh Token 반환
5 클라이언트에 토큰 저장  
인가
1 API 요청 시 헤더에 Access Token을 포함시켜 요청  
2   토큰을 복호화하여 유효성 검증
3   유효성 확인 중 Access Token이 만료가 되지 않음
4 응답을 받음 검증 완료 시 API 로직 처리 후 응답
재발급
1 API 요청 시 헤더에 Access Token을 포함시켜 요청  
2   토큰 유효성 검증
3   유효성 확인 중 Access Token이 만료됨
4   응답으로 Access Token이 만료되었음을 알림
5 Refresh Token을 헤더에 포함시켜 Access Token 재발급 요청  
6   Refresh Token의 유효성 검증
7   새로운 Access Token을 응답으로 반환하여 발급
8 새로운 Access Token 저장  

 

인증

  • 앞의 인증 플로우와 다른 점은 인증 완료 시 Access Token과 함께 Refresh Token을 발급

인가와 토큰 재발급

  • 인가 중 Access Token이 만료되었을 때 클라이언트에 만료되었음을 응답으로 보냄
  • 클라이언트는 만료되었음을 확인하고 Refresh Token을 헤더에 실어서 Access Token의 재발급 요청
  • 서버는 마찬가지로 Refresh Token의 유효성을 검증한 후 Access Token을 재발급
  • 재발급된 Access Token으로 API 요청

 


보통 Refresh Token은 Access Token보다 출처 확인 등의 엄격한 검증 과정을 거침
Refresh Token을 사용하면 Access Token만 사용할 때보다 안전
하지만 HTTP 요청이 많아진다는 단점 존재
서비스에 따라서 어떤 방식을 사용할 지 장단점을 비교해서 사용하자

Spring Security

API에 권한 기능이 없으면 아무나 회원 정보를 조회하고 수정할 수 있다. 따라서 이를 막기 위해 인증된 유저만 API를 사용할 수 있도록 해야하는데, 이때 사용할 수 있는 해결책 중 하나가 Spring Security이다.

스프링 프레임워크에서는 인증 및 권한 부여로 리소스 사용을 컨트롤 할 수 있는 Spring Security를 제공한다.

이 프레임워크를 사용하면, 보안 처리를 자체적으로 구현하지 않아도 쉽게 필요한 기능을 구현할 수 있다.

Spring Security의 구조

Spring Security는 스프링의 DispatcherServlet 앞단에 Filter 형태로 위치한다.

Dispatcher로 넘어가기 전에 Filter가 요청을 가로채서 클라이언트의 리소스 접근 권한을 확인하고, 없는 경우에는 인증 요청 화면으로 자동 리다이렉트한다.

 

Spring Security Filter

Spring Security Filter

그림에서 클라이언트가 리소스에 대한 접근 권한이 없을 때 처리를 담당하는 필터는 UsernamePasswordAuthenticationFilter다.

인증 권한이 없을 때 오류를 JSON으로 내려주기 위해 해당 필터가 실행되기 전 처리가 필요하다.

 

API 인증 및 권한 부여를 위한 작업 순서

  1. 회원 가입 & 로그인 API 구현
  2. 리소스 접근 가능한 ROLE_USER 권한을 가입 회원에게 부여
  3. Spring Security 설정에서 ROLE_USER 권한을 가지면 접근 가능하도록 세팅
  4. 권한이 있는 회원이 로그인 성공하면 리소스 접근 가능한 JWT 토큰 발급
  5. 해당 회원은 권한이 필요한 API 접근 시 JWT 보안 토큰을 사용

위와 같이 접근 제한이 필요한 API에는 보안 토큰을 통해서 이 유저가 권한이 있는지 여부를 Spring Security를 통해 체크하고 리소스를 요청할 수 있도록 구성할 수 있다.


인증 및 인가를 구현할 때 사용되는 개념

Filter 체인

  • Spring Security는 다양한 Filter들의 체인으로 구성
  • 이 Filter 체인은 Request를 가로챈 후 일련의 절차를 처리
  • UsernamePasswordAuthenticationFilter는 사용자가 제출한 인증정보 처리

UsernamePasswordAuthenticationToken 생성

  • UsernamePasswordAuthenticationFilter는 UsernamePasswordAuthenticationToken을 생성하여 AuthenticationManager에게 전달
  • 이 토큰에는 사용자가 제출한 인증 정보가 포함되어 있음

AuthenticationManager

  • AuthenticationManager는 실제로 인증을 수행
  • 여러 AuthenticationProvider들을 이용

AuthenticationProvider

  • 각각의 Provider들은 특정 유형의 인증을 처리
  • Ex. DaoAuthenticationProvider는 사용자 정보를 DB에서 가져와 인증을 수행

PasswordEncoder

  • 인증과 인가에서 사용될 패스워드의 인코딩 방식을 지정

UserDetailsService

  • AuthenticationProvider는 UserDetailsService를 사용하여 사용자 정보를 가져옴
  • UserDetailsService는 사용자의 아이디를 받아 loadByUsername을 호출하여 해당 사용자의 UserDetails를 반환

UserDetails

  • UserDetails에는 사용자의 아이디, 비밀번호, 권한 등이 포함

Authentication 객체 생성

  • 인증에 성공하면, AuthenticationProvider는 Authentication 객체를 생성하여 AuthenticationManager에게 반환
  • Authentication 객체에는 사용자의 세부 정보와 권한이 포함

SecurityContextHolder

  • 현재 실행 중인 스레드에 대한 SecurityContext를 제공

SecurityContext

  • 현재 사용자의 Authentication이 저장되어 있음
  • 애플리케이션은 SecurityContextHolder를 통해 현재 사용자의 권한을 확인하고 인가 결정을 함

Spring Security + JWT 적용 과정

의존성 추가

우선 build.gradle을 작성하여 의존성을 추가하였다.

plugins {
	id 'java'
	id 'org.springframework.boot' version '3.3.0'
	id 'io.spring.dependency-management' version '1.1.5'
}

group = 'com.sptp'
version = '0.0.1-SNAPSHOT'

java {
	toolchain {
		languageVersion = JavaLanguageVersion.of(17)
	}
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-data-redis'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-web'

	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	runtimeOnly 'com.mysql:mysql-connector-j'

	//JWT
	implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
	implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
	implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'

	//ModelMapper
	implementation group: 'org.modelmapper', name: 'modelmapper', version: '2.4.2'
	//validation
	implementation group: 'jakarta.validation', name: 'jakarta.validation-api', version: '3.1.0'

	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
	useJUnitPlatform()
}

 

application.yml에 jwt 설정 정보 저장

jwt:
  expiration_time: 86400000
  secret: RElBUllfQU5EX0NPTU1VTklUWV9QUk9KRUNUX0RBV05BUllfQURNSU5fU0VDUkVUX0tFWQ==

secret key 또는 database 관련 정보는 가급적이면 보안을 위해 .env 파일에 작성하고 .gitignore을 이용하여 로컬 저장소에서만 관리하는 것을 권장한다. 만약 이를 어길 시 아래와 같은 경고 메일을 받아볼 수 있다.

generic high entropy secret

Entity

Member(Entity)

package com.sptp.dawnary.member.domain;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Table(name = "MEMBER")
@Setter
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Member {

	@Id
	@GeneratedValue
	@Column(name = "member_id")
	private Long id;

	@Column(name = "email", length = 50, updatable = false, unique = true)
	private String email;

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

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

	@Column(name = "image_path")
	private String imagePath;

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

	public void updatePassword(String password) {
		this.password = password;
	}

	public void updateName(String name) {
		this.name = name;
	}

	public void updateImagePath(String imagePath) {
		this.imagePath = imagePath;
	}
}

DTO

MemberDto

package com.sptp.dawnary.member.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class MemberDto {

	private Long id;
	private String email;
	private String password;
	private String name;
}

 

MemberRequestDto

dto에서 jakarta.validation을 이용하여 유효성 검증을 실행

package com.sptp.dawnary.member.dto;

import com.sptp.dawnary.member.domain.RoleType;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class MemberRequestDto {

	@NotBlank(message = "이메일은 필수 입력 값입니다.")
	@Email(message = "이메일 형식에 맞지 않습니다.")
	private String email;

	@NotBlank(message = "비밀번호는 필수 입력 값입니다.")
	// @Pattern(regexp = "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=!])(?=\\S+$).{8,20}$")
	private String password;

	@NotBlank(message = "닉네임은 필수 입력 값입니다.")
	private String name;

	private RoleType role;
}

 

LoginRequestDto

package com.sptp.dawnary.member.dto;

import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequestDto {

	@NotNull(message = "이메일 입력은 필수입니다.")
	private String email;

	@NotNull(message = "패스워드 입력은 필수입니다.")
	private String password;
}

 

 

CustomUserInfoDto

로직 내부에서 인증 유저 정보를 저장해 둘 dto

package com.sptp.dawnary.member.dto;

import com.sptp.dawnary.member.domain.RoleType;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class CustomUserInfoDto extends MemberDto {

	private Long memberId;
	private String email;
	private String password;
	private String name;
	private RoleType role;
}

Util

JwtUtil

JWT를 생성하고 검증하는 기능

  • key
    • JWT 서명에 사용할 키
    • secretKey를 Base64로 디코딩하여 HMAC SHA 알고리즘을 사용한 키로 변환
  • accessTokenExpTime: 액세스토큰의 만료시간
  • createAccessToken
    • 주어진 CustomUserInfoDto 객체를 바탕으로 JWT 액세스 토큰을 생성 후 반환
  • createToken: JWT 토큰을 생성하는 내부 메서드
    1. Claims 객체를 생성하고 사용자 정보를 클레임에 추가
    2. 현재 시간을 기준으로 토큰 발행 시간과 만료 시간을 설정
    3. JWT 빌더를 사용하여 클레임, 발행 시간, 만료 시간을 설정하고, 서명 알고리즘과 키를 지정하여 토큰을 생성
  • getUserId: 주어진 JWT 토큰에서 사용자 ID 추출
    1. parseClaims 메서드를 사용하여 토큰의 클레임 파싱
    2. 클레임에서 memberId 값을 추출하여 반환
  • isValidToken(String Token): 주어진 JWT 토큰의 유효성 검증
    1. 토큰을 파싱하여 유효성 검증
    2. 파싱 도중 예외 발생 시 유효하지 않은 토큰으로 간주하고 false 반환
    3. 각 예외 상황에 대한 로그 기록
  • parseClaims: 주어진 JWT 토큰에서 클레임 추출
    1. 토큰을 파싱하여 클레임 추출
    2. 토큰이 만료되었을 경우 'ExpiredJwtException 예외에서 클레임을 추출하여 반환
package com.sptp.dawnary.security.util;

import java.security.Key;
import java.time.ZonedDateTime;
import java.util.Date;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import com.sptp.dawnary.member.dto.CustomUserInfoDto;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
public class JwtUtil {

	private final Key key;
	private final long accessTokenExpTime;

	public JwtUtil(
		@Value("${jwt.secret}") final String secretKey,
		@Value("${jwt.expiration_time}") final long accessTokenExpTime)
	{
		byte[] keyBytes = Decoders.BASE64.decode(secretKey);
		this.key = Keys.hmacShaKeyFor(keyBytes);
		this.accessTokenExpTime = accessTokenExpTime;
	}

	/**
	 * Access Token 생성
	 *
	 * @param member
	 * @return Access Token String
	 */
	public String createAccessToken(CustomUserInfoDto member) {
		return createToken(member, accessTokenExpTime);
	}

	/**
	 * JWT 생성
	 * @ param member
	 * @param expireTime
	 * @return JWT String
	 */
	private String createToken(CustomUserInfoDto member, long expireTime) {
		Claims claims = Jwts.claims();
		claims.put("memberId", member.getId());
		claims.put("email", member.getEmail());
		claims.put("name", member.getName());
		claims.put("role", member.getRole()); //USER, ADMIN

		ZonedDateTime now = ZonedDateTime.now();
		ZonedDateTime tokenValidity = now.plusSeconds(expireTime);

		return Jwts.builder()
			.setClaims(claims)
			.setIssuedAt(Date.from(now.toInstant()))
			.setExpiration(Date.from(tokenValidity.toInstant()))
			.signWith(key, SignatureAlgorithm.HS256)
			.compact();
	}

	/**
	 * Token에서 User ID 추출
	 *
	 * @param token
	 * @return User ID
	 */
	public Long getUserId(String token) {
		return parseClaims(token).get("memberId", Long.class);
	}

	/**
	 * JWT 검증
	 * @param token
	 * @return IsValidate
	 */
	public boolean isValidToken(String token) {
		try {
			Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
			return true;
		} catch (SecurityException | MalformedJwtException e) {
			log.info("Invalid JWT", e);
		} catch (ExpiredJwtException e) {
			log.info("Expired JWT", e);
		} catch (UnsupportedJwtException e) {
			log.info("Unsupported JWT", e);
		} catch (IllegalArgumentException e) {
			log.info("JWT claims string is empty", e);
		}
		return false;
	}

	/**
	 * JWT Claims 추출
	 * @param accessToken
	 * @return JWT Claims
	 */
	public Claims parseClaims(String accessToken) {
		try {
			return Jwts.parserBuilder()
				.setSigningKey(key)
				.build()
				.parseClaimsJws(accessToken)
				.getBody();
		} catch (ExpiredJwtException e) {
			return e.getClaims();
		}
	}
}

사용자 정의 핸들러

CustomAccessDeniedHandler

Spring Security에서 권한이 부족하여 접근이 거부될 때 호출되는 핸들러

  • ObjectMapper: JSON의 직렬화/역직렬화를 담당하는 Jackson 라이브러리의 객체
  • handle 메서드: 매개변수로 클라이언트의 요청 및 응답, 접근 거부 예외 객체를 받음
    1. 접근 거부가 발생하면 로그 기록
    2. ErrorResponseDto 객체를 생성하여 HTTP 상태코드, 예외 메세지, 현재 시간을 포함
    3. 객체를 JSON 형식으로 직렬화하여 응답 본문에 작성하고 응답 상태 코드를 403 FORBIDDEN으로  설정

 

CustomAuthenticationEntryPoint

Spring Security에서 인증되지 않은 사용자가 보호된 리소스에 접근할 때 호출되는 핸들러

  • comment 메서드: 클라이언트의 요청 및 응답, 인증 예외 객체를 받음
    1. 인증되지 않은 접근 시 로그 기록
    2. ErrorResponseDto 객체를 생성하여 HTTP 상태코드, 예외 메세지, 현재 시간을 포함
    3. 객체를 JSON 형식으로 직렬화하여 응답 본문에 작성하고 응답 상태 코드를 401 UNAUTHORIZED으로  설정
package com.sptp.dawnary.security.handler;

import java.io.IOException;
import java.time.LocalDateTime;

import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sptp.dawnary.exception.dto.ErrorResponseDto;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j(topic = "FORBIDDEN_EXCEPTION_HANDLER")
@AllArgsConstructor
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

	private final ObjectMapper objectMapper;

	@Override
	public void handle(final HttpServletRequest request, final HttpServletResponse response,
		final AccessDeniedException accessDeniedException) throws IOException, ServletException {
		log.error("No Authorities", accessDeniedException);

		ErrorResponseDto errorResponseDto = new ErrorResponseDto(HttpStatus.FORBIDDEN.value(),
			accessDeniedException.getMessage(),
			LocalDateTime.now());

		String responseBody = objectMapper.writeValueAsString(errorResponseDto);
		response.setContentType(MediaType.APPLICATION_JSON_VALUE);
		response.setStatus(HttpStatus.UNAUTHORIZED.value());
		response.setCharacterEncoding("UTF-8");
		response.getWriter().write(responseBody);
	}
}

CustomUserDetails, CustomUserDetailsService

CustomUserDetails

Spring Security의 UserDetails 인터페이스를 구현하여 사용자 정보를 Security Context에서 사용할 수 있도록 제공

CustomUserInfoDto 객체를 기반으로 사용자 인증 및 권한 정보를 설정

  • getAuthorities: 사용자 권한 목록을 반환
    1. 사용자의 역할(Role)을 기반으로 SimpleGrantedAuthority 객체를 생성
    2. member.getRole()를 이용해 역할을 가져오고 "ROLE_" 접두사와 결합하여 SimpleGrantedAuthority로 변환
    3. 최종적으로 권한 목록을 반환

 

CustomUserDetailsService

Spring Security에서 사용자 인증 정보를 로드하는 서비스

database에서 사용자 정보를 조회하여 'UserDetails' 객체로 변환

  • loadUserByUsername
    1. 사용자 ID를 이용해 database에서 Member 객체 조회
    2. 사용자가 존재하지 않으면 UsernameNotFoundException 예외 발생
    3. Member 객체를 CustomUserInfoDto 객체로 변환 후 CustomUserDetails 객체로 감싸서 반환
package com.sptp.dawnary.security.details;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

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

import com.sptp.dawnary.member.dto.CustomUserInfoDto;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {

	private final CustomUserInfoDto member;

	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		List<String> roles = new ArrayList<>();
		roles.add("ROLE_" + member.getRole().toString());

		return roles.stream()
			.map(SimpleGrantedAuthority::new)
			.toList();
	}

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

	@Override
	public String getUsername() {
		return member.getMemberId().toString();
	}

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

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

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

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

 


Filter

JwtAuthFilter

Spring Security의 필터로, 요청이 들어올 때마다 JWT의 유효성을 검사하고, 유효한 경우 사용자 정보를 SecurityContext에 설정

  • doFilterInternal
    1. Authorization 헤더 추출: HTTP 요청 헤더에서 Authorization 헤더 값을 추출
    2. JWT 존재 여부 확인: Authorization 헤더가 존재하고 "Bearer "로 시작하는지 확인
      1. 존재하면 JWT 토큰 추출(String token = authorizationHeader.substring(7))
    3. JWT 유효성 검증: jwtUtils 객체를 사용하여 토큰의 유효성 검증
      1. 유효하면 jwtUtil을 통해 사용자 ID 추출
    4. UserDetails 로드: CustomUserDetailsService를 사용하여 사용자 ID로 UserDetails 객체 로드
    5. Security Context 설정
      1. UserDetails 객체가 null이 아닌 경우 UsernamePasswordAuthenticationToken 객체를 생성하여 사용자 인증 정보 설정
      2. 현재 요청의 SecurityContext에 인증 정보 설정
    6. 다음 필터로 요청을 전달
package com.sptp.dawnary.security.filter;

import java.io.IOException;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.filter.OncePerRequestFilter;

import com.sptp.dawnary.security.service.CustomUserDetailsService;
import com.sptp.dawnary.security.util.JwtUtil;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

	private final CustomUserDetailsService customUserDetailsService;
	private final JwtUtil jwtUtil;

	/**
	 * JWT 검증 필터 수행
	 */
	@Override
	protected void doFilterInternal(final HttpServletRequest request,
		final HttpServletResponse response,
		final FilterChain filterChain) throws ServletException, IOException {
		String authorizationHeader = request.getHeader("Authorization");

		//JWT 헤더가 있을 경우
		if(authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
			String token = authorizationHeader.substring(7);
			//JWT 유효성 검증
			if (jwtUtil.isValidToken(token)) {
				Long userId = jwtUtil.getUserId(token);

				//유저와 토큰 일치 시 userDetails 생성
				UserDetails userDetails = customUserDetailsService.loadUserByUsername(
					userId.toString());

				if (userDetails != null) {
					//UserDetails, Password, Role -> 접근 권한 인증 Token 생성
					UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
						userDetails, null, userDetails.getAuthorities());

					//현재 Request의 Security Context에 접근 권한 설정
					SecurityContextHolder.getContext()
						.setAuthentication(usernamePasswordAuthenticationToken);
				}
			}
		}

		filterChain.doFilter(request, response); //다음 필터로 넘김
	}
}

Config

ModelMapperConfig

package com.sptp.dawnary.security.config;

import org.modelmapper.ModelMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ModelMapperConfig {

	@Bean
	public ModelMapper modelMapper() {
		return new ModelMapper();
	}
}

 

 

PasswordEncoderConfig

package com.sptp.dawnary.security.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@Configuration
public class PasswordEncoderConfig {

	@Bean
	public BCryptPasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
}

 

SecurityConfig

Spring Security의 설정을 담당

애플리케이션의 보안 정책, 필터 체인, 예외 처리 등을 구성

  • AUTH_WHITELIST: 인증 없이 접근 가능한 엔드포인트 목록(로그인, 회원가입, 스웨거 UI 등)
  • filterChain 메서드
    1. CSRF, CORS 설정: CSRF 보호 비활성화, CORS 설정을 기본값으로 설정
    2. 세션 관리: 세션을 사용하지 않도록 설정. JWT를 사용하기 위함
    3. FormLogin, BasicHttp 비활성화
    4. JwtAuthFilter 추가: UsernamePasswordAuthenticationFilter 앞에 JwtAuthFilter를 추가하여 JWT를 통한 인증 처리
    5. 예외 처리 핸들러 설정: 인증 실패 및 접근 거부 예외를 처리하는 핸들러 설정
    6. 권한 규칙 작성
      1. 화이트 리스트에 있는 경로는 누구나 접근할 수 있도록 허용
      2. 나머지 모든 경로는 @PreAuthorize 등의 메서드 수준 보안을 사용하여 접근을 제어
package com.sptp.dawnary.security.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import com.sptp.dawnary.security.entrypoint.CustomAuthenticationEntryPoint;
import com.sptp.dawnary.security.filter.JwtAuthFilter;
import com.sptp.dawnary.security.handler.CustomAccessDeniedHandler;
import com.sptp.dawnary.security.service.CustomUserDetailsService;
import com.sptp.dawnary.security.util.JwtUtil;

import lombok.AllArgsConstructor;

@Configuration
@EnableWebSecurity
//메서드 수준에서의 보안 처리 활성화
//@Secure, @PreAuthorize 어노테이션 사용 가능
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
@AllArgsConstructor
public class SecurityConfig {

	private final CustomUserDetailsService customUserDetailsService;
	private final JwtUtil jwtUtil;
	private final CustomAccessDeniedHandler accessDeniedHandler;
	private final CustomAuthenticationEntryPoint authenticationEntryPoint;

	private static final String[] AUTH_WHITELIST = {"/member/login", "/member/signup",
		"/swagger-ui/**", "/api-docs", "swagger-ui-custom.html"};

	@Bean
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		//CSRF, CORS
		http.csrf(AbstractHttpConfigurer::disable);
		http.cors((Customizer.withDefaults()));

		//세션 관리 상태 없음으로 구성, Spring Security가 세션 생성 or 사용 x
		http.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(
			SessionCreationPolicy.STATELESS));

		//FormLogin, BasicHttp 비활성화
		http.formLogin(AbstractHttpConfigurer::disable);
		http.httpBasic(AbstractHttpConfigurer::disable);

		//JwtAuthFilter를 UsernamePasswordAuthenticationFilter 앞에 추가
		http.addFilterBefore(new JwtAuthFilter(customUserDetailsService, jwtUtil),
			UsernamePasswordAuthenticationFilter.class);

		http.exceptionHandling((exceptionHandling) -> exceptionHandling.authenticationEntryPoint(
			authenticationEntryPoint).accessDeniedHandler(accessDeniedHandler));

		//권한 규칙 작성
		http.authorizeHttpRequests(authorize -> authorize
			.requestMatchers(AUTH_WHITELIST).permitAll()
			//@PreAuthorization 사용 -> 모든 경로에 대한 인증처리는 Pass
			.anyRequest().permitAll()
		);

		return http.build();
	}
}

 


MemberRepository

package com.sptp.dawnary.member.repository;

import java.util.List;
import java.util.Optional;

import org.springframework.stereotype.Repository;

import com.sptp.dawnary.member.domain.Member;

import jakarta.persistence.EntityManager;
import jakarta.persistence.NoResultException;
import jakarta.persistence.TypedQuery;
import lombok.RequiredArgsConstructor;

@Repository
@RequiredArgsConstructor
public class MemberRepository {

	private final EntityManager em;

	public void create(Member member) {
		em.persist(member);
	}

	public Member findMemberById(Long id) {
		return em.find(Member.class, id);
	}

	public Optional<Member> findMemberByEmail(String email) {
		TypedQuery<Member> typedQuery = em.createQuery(
			"select m from Member m where m.email = :email", Member.class);
		typedQuery.setParameter("email", email);
		try {
			Member member = typedQuery.getSingleResult();
			return Optional.ofNullable(member);
		} catch (NoResultException e) {
			return Optional.empty();
		}
	}

	public List<Member> findAll() {
		return em.createQuery("select m from Member m", Member.class)
			.getResultList();
	}

	public List<Member> findByName(String name) {
		return em.createQuery("select m from Member m where m.name = :name", Member.class)
			.setParameter("name", name)
			.getResultList();
	}
}

Service

MemberService

  • login
    1. dto에서 email, password 확인
    2. database에서 email을 이용하여 member를 조회 후 Optional<Member> 객체에 저장
    3. member가 비어있다면 UsernameNotFoundException 던짐
    4. 암호화된 password를 디코딩한 값과 입력한 패스워드 값이 다르면 BadCredentialException 던짐
    5. 3,4번을 통과한 경우 ModelMapper를 이용하여 Optional<Member> 형태의 객체를 CustomUserInfo으로 변환
    6. jwtUtil.creatAccessToken(info)를 이용하여 Access Token을 발급 후 반환
  • signup
    1. database에서 email을 이용하여 member를 조회 후 Optional<Member> 객체에 저장
    2. 저장된 객체(validMember)가 존재한다면(이미 해당 이메일이 database에 등록되어 있다면) ValidateMemberException 던짐
    3. 2번을 통과한 경우 member.updatePassword에 encoder.encode(member.getPassword())를 통해 비밀번호를 해시 처리한 값을 member의 password로 저장
    4. repository에 create 메서드를 이용하여 member를 database에 저장
    5. member의 id를 반환
package com.sptp.dawnary.member.service;

import java.util.Optional;

import org.modelmapper.ModelMapper;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.sptp.dawnary.exception.ValidateMemberException;
import com.sptp.dawnary.member.domain.Member;
import com.sptp.dawnary.member.dto.CustomUserInfoDto;
import com.sptp.dawnary.member.dto.LoginRequestDto;
import com.sptp.dawnary.member.repository.MemberRepository;
import com.sptp.dawnary.security.util.JwtUtil;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {

	private final JwtUtil jwtUtil;
	private final MemberRepository memberRepository;
	private final PasswordEncoder encoder;
	private final ModelMapper modelMapper;

	@Transactional
	public String login(LoginRequestDto dto) {
		String email = dto.getEmail();
		String password = dto.getPassword();
		Optional<Member> member = memberRepository.findMemberByEmail(email);
		if (member.isEmpty()) {
			throw new UsernameNotFoundException("이메일이 존재하지 않습니다.");
		}

		//암호화된 password를 디코딩한 값과 입력한 패스워드 값이 다르면 null 반환
		if (!encoder.matches(password, member.get().getPassword())) {
			throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");
		}

		CustomUserInfoDto info = modelMapper.map(member, CustomUserInfoDto.class);
		return jwtUtil.createAccessToken(info);
	}

	@Transactional
	public Long signup(Member member) {
		Optional<Member> validMember = memberRepository.findMemberByEmail(member.getEmail());

		if (validMember.isPresent()) {
			throw new ValidateMemberException("this member email is already exist. " + member.getEmail());
		}
		//비밀번호 해시 처리
		member.updatePassword(encoder.encode(member.getPassword()));
		memberRepository.create(member);
		return member.getId();
	}
}

Controller

MemberController

dto를 이용해 필요한 정보들만 받은 후, ModelMapper를 이용하여 Member의 형태로 변환한다.

"login"은 memberService.login(request)로 암호화된 토큰을 반환한다.

"signup"은 memberService.signup(entity)로 db에 저장된 id를 반환한다.

package com.sptp.dawnary.member.controller;

import org.modelmapper.ModelMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.sptp.dawnary.member.domain.Member;
import com.sptp.dawnary.member.dto.LoginRequestDto;
import com.sptp.dawnary.member.dto.MemberRequestDto;
import com.sptp.dawnary.member.service.MemberService;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
public class MemberController {

	private final MemberService memberService;
	private final ModelMapper modelMapper;

	@PostMapping("login")
	public ResponseEntity<String> getMemberProfile(
		@Valid @RequestBody LoginRequestDto request
	) {
		String token = memberService.login(request);
		return ResponseEntity.status(HttpStatus.OK).body(token);
	}

	@PostMapping("signup")
	public ResponseEntity<Long> signup(@Valid @RequestBody MemberRequestDto member) {
		Member entity = modelMapper.map(member, Member.class);
		Long id = memberService.signup(entity);
		return ResponseEntity.status(HttpStatus.OK).body(id);
	}
}

테스트

위의 모든 코드를 마쳤고, POSTMAN을 이용하여 테스트를 진행해봤다.

회원가입

http://localhost:8080/member/signup으로 JSON 형태의 데이터를 POST 방식으로 넘겨주었다.

id인 1을 반환하는 것을 알 수 있다.

database에도 제대로 저장이 되었는지 확인하자.

database에도 제대로 값이 저장되었고, 암호화된 password도 정상적으로 저장이 된 것을 확인할 수 있다.

 

로그인

http://localhost:8080/member/login으로 JSON 형태의 데이터를 POST 방식으로 넘겨주었다.

생성된 JWT 토큰을 올바르게 반환하는 것을 확인할 수 있다.

이로써 Spring Security + JWT를 활용한 회원가입 및 로그인 RESTful API 구현 및 테스트를 완료하였다.

구글링을 통해 자료를 찾으면 오래된 자료가 많다. 특히 옛날 코드는 deprecated된 것들이 대부분이기에 공부하는 데 많은 어려움이 있었다. 이번 구현을 통해 Security와 JWT에 대한 이해를 할 수 있었고, 현재는 기본 코드이지만 발전시켜서 계층형 RoleType에 대한 인가도 적용시켜 보고자 한다.

반응형
profile

나를 기록하다

@prao

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!

profile on loading

Loading...