[수정사항]
스프링 시큐리티 6.1부터 SecurityFilterChain의 일부 설정들을 메소드체이닝으로 설정하는 방식이 Deprcated 되고 7부터 삭제될 예정이어서 람다를 사용한 방식으로 코드 수정
목차
시작하기 전에
글을 작성한 계기
예전에 REST API만 제공하는 백엔드 프로젝트를 진행하는 과정에서 API 사용 권한을 제한하기 위해 스프링 시큐리티와 JWT를 사용한 적이 있다. 이 때는 스프링 시큐리티와 JWT에 대한 지식이 전무해서 해당 부분은 다른 분이 작성해주신 것을 사용했다.
프로젝트 개발이 종료되고 관련 내용을 알아두는게 좋을 것 같아서 이리저리 관련 내용을 뒤적거리면서 해당 내용을 적용한 예제 프로젝트를 진행했다. 이 과정에서 진행한 과정들을 정리해보았다.
다만, 스프링 시큐리티와 JWT에 대해 깊게 이해하고 있는 것은 아니기 때문에 이론적인 내용은 부족하거나 틀린 내용이 있을 수 있다.
예제 프로젝트의 목표
- 스프링 시큐리티를 통해 비밀번호를 암호화하여 DB에 저장 및 DB에 저장된 사용자의 계정과 비밀번호로 로그인
- JWT를 사용하여 로그인한 사용자에게 토큰 발급
- 인가된 토큰의 권한에 따라 API 접근 권한 제어
- 로그인 된 사용자의 정보를 토대로 동작하는 API 작성
- Swagger UI를 통해 권한에 따른 API 호출 여부 테스트
완성된 프로젝트 Github : Kotlin, Java
프로젝트 기본 구성 둘러보기
밑바탕 프로젝트
아래 첨부파일은 각각 코틀린과 자바로 작성된 스프링 시큐리티와 JWT가 적용되지 않은 상태의 밑바탕 프로젝트입니다. 원하시는 언어에 맞는 파일을 다운로드 하셔서 압축을 해제하고 IDE로 로드하여 사용하시면 됩니다. 또는 위 Github에서 base 브랜치를 사용하실 수도 있습니다.(둘 모두 내용은 동일합니다.)
기본 환경
- Kotlin 1.8 / Java 19
- Spring Boot 3.0.6
- Springdoc 2.1.0
- Spring Security
- Spring Data JPA
- H2 DB
- Gradle 7.6.1
- Lombok(Java 한정)
이 글의 목표는 API 접근 권한을 부여하는 것이기 때문에 별도의 화면을 구성하지 않고 Swagger UI를 통해 API 호출을 테스트합니다. 또한 기본적으로 아무런 설정을 해놓지 않은 스프링 시큐리티가 포함되어 있습니다.
최초 프로젝트 구성
- common : API 상태와 멤버타입을 나타내기 위한 Enum
- controller : 관리자용 API, 일반 사용자용 API, 회원가입 및 로그인용 API가 작성된 컨트롤러
- dto : 회원가입, 로그인 등 API의 요청과 응답을 위한 DTO
- entity : DB와 매핑되어 있는 회원 엔티티
- handler : 예외가 발생하면 예외의 종류에 따른 응답을 반환하는 핸들러
- repository : Spring Data JPA 사용을 위한 레포지토리
- service : 각 컨트롤러의 API들이 사용하는 비즈니스 로직이 작성된 서비스 계층
- SwaggerConfig : 스웨거 기본 설정
- RepositoryExtensions(Kotlin) : 레포지토리 확장 함수가 작성된 유틸 파일
- AdminInitializer : 앱 실행 시 기본 관리자 계정을 추가하는 ApplicationRunner 구현체
- 그 외 서비스 계층의 테스트 코드
프로젝트를 실행했을 때
최초로 프로젝트를 실행하면 자동으로 생성된 패스워드와 함께 프로덕션 환경에서 애플리케이션을 실행하기 전에 시큐리티 설정을 업데이트해야 한다는 안내 문구가 콘솔에 출력된다.
localhost:8080에 접속해보면 아래와 같은 기본 로그인 화면이 나타난다.
Username에는 user를, Password에는 콘솔에 출력된 패스워드를 입력하고 로그인하면 그제서야 스웨거 화면으로 넘어가는 것을 확인할 수 있다.
이 상태에서는 아무 설정도 하지 않았기 때문에 임시 로그인화면만 넘어가면 모든 API를 마음껏 사용할 수 있다.
스프링 시큐리티 기본 설정
아무 설정도 되지 않은 기본 상태에서는 서버의 어디로 접속하더라도 위의 임시 로그인창에서 로그인을 진행해야 한다. 그런데 이 프로젝트에서는 권한이 없는 사용자가 API를 사용하지 못하도록 막기만 하면 되기 때문에 스웨거 문서에 접속할 때는 굳이 로그인을 할 필요가 없다. 따라서 이 부분에 대한 예외 처리가 필요하다.
우선 security
패키지를 만들고 아래 코드와 같이 스프링 시큐리티 기본 설정을 작성한다.
Kotlin
@Configuration
class SecurityConfig {
@Bean
fun filterChain(http: HttpSecurity) = http
.csrf { it.disable() }
.headers { it.frameOptions { frameOptions -> frameOptions.sameOrigin() } } // H2 콘솔 사용을 위한 설정
.authorizeHttpRequests {
it.requestMatchers("/", "/swagger-ui/**", "/v3/**").permitAll() // requestMatchers의 인자로 전달된 url은 모두에게 허용
.requestMatchers(PathRequest.toH2Console()).permitAll() // H2 콘솔 접속은 모두에게 허용
.anyRequest().authenticated() // 그 외의 모든 요청은 인증 필요
}
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } // 세션을 사용하지 않으므로 STATELESS 설정
.build()!!
}
Java
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(CsrfConfigurer<HttpSecurity>::disable)
.headers(headers -> headers.frameOptions(FrameOptionsConfig::sameOrigin)) // H2 콘솔 사용을 위한 설정
.authorizeHttpRequests(requests ->
requests.requestMatchers("/", "/swagger-ui/**", "/v3/**").permitAll() // requestMatchers의 인자로 전달된 url은 모두에게 허용
.requestMatchers(PathRequest.toH2Console()).permitAll() // H2 콘솔 접속은 모두에게 허용
.anyRequest().authenticated() // 그 외의 모든 요청은 인증 필요
)
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
) // 세션을 사용하지 않으므로 STATELESS 설정
.build();
}
}
이 프로젝트는 쿠키와 세션을 사용하지 않기 때문에 세션 생성 정책을 STATELESS로 설정하고, CSRF도 비활성화 한다.
이제 다시 애플리케이션을 실행하고 기본 url로 이동하면 로그인 페이지 없이 스웨거 문서로 이동하는 것을 확인할 수 있다.
그런데 어떤 API를 실행해봐도 403 에러만 떨어진다. 심지어 로그인과 회원 가입조차!
로그인과 회원 가입도 허용 목록에 추가
로그인과 회원 가입은 아무 권한이 없는 상태에도 가능해야 한다. 허용할 url 목록에 로그인과 회원 가입 url을 추가해준다.
Kotlin
@Configuration
class SecurityConfig {
private val allowedUrls = arrayOf("/", "/swagger-ui/**", "/v3/**", "/sign-up", "/sign-in") // sign-up, sign-in 추가
@Bean
fun filterChain(http: HttpSecurity) = http
.csrf { it.disable() }
.headers { it.frameOptions { frameOptions -> frameOptions.sameOrigin() } }
.authorizeHttpRequests {
it.requestMatchers(*allowedUrls).permitAll() // 허용할 url 목록을 배열로 분리했다
.requestMatchers(PathRequest.toH2Console()).permitAll()
.anyRequest().authenticated()
}
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.build()!!
}
Java
@Configuration
public class SecurityConfig {
private final String[] allowedUrls = {"/", "/swagger-ui/**", "/v3/**", "/sign-up", "/sign-in"}; // sign-up, sign-in 추가
@Bean
public SecurityFilterChain filterChain(Http httpSecurity) throws Exception {
return http
.csrf(CsrfConfigurer<HttpSecurity>::disable)
.headers(headers -> headers.frameOptions(FrameOptionsConfig::sameOrigin))
.authorizeHttpRequests(requests ->
requests.requestMatchers(allowedUrls).permitAll() // 허용할 url 목록을 배열로 분리했다
.requestMatchers(PathRequest.toH2Console()).permitAll()
.anyRequest().authenticated()
)
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.build();
}
}
허용할 url 목록이 많아져서 requestMatchers()
의 인자로 넘겨주던 url들을 배열로 분리했다. sign-up과 sign-in은 각각 회원가입, 로그인 API의 url이다.
이제 정상적으로 로그인과 회원 가입 API가 동작하는 것을 볼 수 있다!
로그인 API를 사용할 수 있게 되었으니 스프링 시큐리티에서 기본적으로 생성해주는 임시 비밀번호는 더 이상 필요 없다. application.yml에 다음 내용을 추가하면 콘솔에 임시 비밀번호가 생성되지 않는다.
spring:
...
security:
user:
password: 1
사실 이 설정은 스프링 시큐리티 기본 비밀번호를 '1'로 설정하겠다는 뜻이고 이로 인해 임시 비밀번호가 생성되지 않는 것이지만, 어차피 스프링 시큐리티 기본 계정을 사용할 일이 없기 때문에 상관 없다.
비밀번호는 DB에 암호화해서 저장해야 하는거 아닌가?
회원 가입 API를 호출하고 DB를 확인해보면 가입한 정보가 저장되긴 했는데 비밀번호가 RequestBody로 넘긴 값 그대로 저장된 것을 확인할 수 있다.
DB에 비밀번호가 그대로 노출되면 보안상 큰 문제가 발생할 수 있다. 비밀번호를 암호화하여 저장해보자.
암호화 모듈 설정
스프링 시큐리티를 통해 암호화를 진행하려면 스프링 시큐리티 설정에서 PasswordEncoder를 구현한 클래스를 빈으로 추가해야 한다. 특별히 커스텀 암호화 모듈을 사용할 것이 아니라면 스프링 시큐리티에서 기본으로 제공하는 강력한 암호화 모듈인 BCryptPasswordEncoder를 사용하면 된다. 이 예제에서는 BCryptPasswordEncoder를 사용할 것이다.
SecurityConfig에 다음과 같이 빈을 등록한다.
Kotlin
@Configuration
class SecurityConfig {
...
@Bean
fun passwordEncoder() = BCryptPasswordEncoder()
}
Java
@Configuration
public class SecurityConfig {
...
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
회원가입과 로그인, 회원정보 수정에 암호화를 적용하자
DB에 비밀번호를 암호화해서 저장하려면 엔티티 객체에 비밀번호가 암호화되어 있어야 한다. 이를 위해 Member의 정적 팩토리 메소드와 update()
를 다음과 같이 수정한다.
Kotlin
@Entity
class Member(
...
) {
companion object {
fun from(request: SignUpRequest, encoder: PasswordEncoder) = Member( // 파라미터에 PasswordEncoder 추가
account = request.account,
password = encoder.encode(request.password), // 수정
name = request.name,
age = request.age
)
}
fun update(newMember: MemberUpdateRequest, encoder: PasswordEncoder) { // 파라미터에 PasswordEncoder 추가
this.password = newMember.newPassword
?.takeIf { it.isNotBlank() }
?.let { encoder.encode(it) } // 추가
?: this.password
this.name = newMember.name
this.age = newMember.age
}
}
Java
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Getter
@Entity
public class Member {
...
public static Member from(SignUpRequest request, PasswordEncoder encoder) { // 파라미터에 PasswordEncoder 추가
return Member.builder()
.account(request.account())
.password(encoder.encode(request.password())) // 수정
.name(request.name())
.age(request.age())
.type(MemberType.USER)
.createdAt(LocalDateTime.now())
.build();
}
public void update(MemberUpdateRequest newMember, PasswordEncoder encoder) { // 파라미터에 PasswordEncoder 추가
this.password = newMember.newPassword() == null || newMember.newPassword().isBlank()
? this.password : encoder.encode(newMember.newPassword()); // 수정
this.name = newMember.name();
this.age = newMember.age();
}
}
엔티티의 비밀번호를 암호화하기 위해 Setter를 사용하거나 별도의 메소드를 사용할 수도 있다. 하지만 메소드의 파라미터에 PasswordEncoder를 추가하여 객체를 생성하면 비밀번호가 반드시 암호화되기 때문에 예제 코드에서는 메소드에서 비밀번호 암호화를 진행했다.(이 부분은 편하신 방법을 사용하시면 됩니다.)
SignService와 MemberService에도 비밀번호 암호화를 위해 관련 코드를 추가한다. 테스트 코드 역시 암호화 관련 코드를 추가해야 하지만 여기서는 따로 언급하지 않을 것이다.
Kotlin
@Service
class SignService(
private val memberRepository: MemberRepository,
private val encoder: PasswordEncoder // 추가
) {
@Transactional
fun registMember(request: SignUpRequest) = SignUpResponse.from(
memberRepository.flushOrThrow(IllegalArgumentException("이미 사용중인 아이디입니다.")) {
save(Member.from(request, encoder)) // 회원가입 정보를 암호화하도록 수정
}
)
@Transactional
fun signIn(request: SignInRequest): SignInResponse {
val member = memberRepository.findByAccount(request.account)
?.takeIf { encoder.matches(request.password, it.password) } // 암호화된 비밀번호와 비교하도록 수정
?: throw IllegalArgumentException("아이디 또는 비밀번호가 일치하지 않습니다.")
return SignInResponse(member.name, member.type)
}
}
@Service
class MemberService(
private val memberRepository: MemberRepository,
private val encoder: PasswordEncoder // 추가
) {
...
@Transactional
fun updateMember(id: UUID, request: MemberUpdateRequest): MemberUpdateResponse {
val member = memberRepository.findByIdOrNull(id)?.takeIf { encoder.matches(request.password, it.password) } // 암호화된 비밀번호와 비교하도록 수정
?: throw IllegalArgumentException("아이디 또는 비밀번호가 일치하지 않습니다.")
member.update(request, encoder) // 새 비밀번호를 암호화하도록 수정
return MemberUpdateResponse.of(true, member)
}
}
Java
@RequiredArgsConstructor
@Service
public class SignService {
private final MemberRepository memberRepository;
private final PasswordEncoder encoder; // 추가
@Transactional
public SignUpResponse registMember(SignUpRequest request) {
Member member = memberRepository.save(Member.from(request, encoder)); // 회원가입 정보를 암호화하도록 수정
try {
memberRepository.flush();
} catch (DataIntegrityViolationException e) {
throw new IllegalArgumentException("이미 사용중인 아이디입니다.");
}
return SignUpResponse.from(member);
}
@Transactional(readOnly = true)
public SignInResponse signIn(SignInRequest request) {
Member member = memberRepository.findByAccount(request.account())
.filter(it -> encoder.matches(request.password(), it.getPassword())) // 암호화된 비밀번호와 비교하도록 수정
.orElseThrow(() -> new IllegalArgumentException("아이디 또는 비밀번호가 일치하지 않습니다."));
return new SignInResponse(member.getName(), member.getType());
}
}
@RequiredArgsConstructor
@Service
public class MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder encoder; // 추가
...
@Transactional
public MemberUpdateResponse updateMember(UUID id, MemberUpdateRequest request) {
return memberRepository.findById(id)
.filter(member -> encoder.matches(request.password(), member.getPassword())) // 암호화된 비밀번호와 비교하도록 수정
.map(member -> {
member.update(request, encoder); // 새 비밀번호를 암호화하도록 수정
return MemberUpdateResponse.of(true, member);
})
.orElseThrow(() -> new NoSuchElementException("아이디 또는 비밀번호가 일치하지 않습니다."));
}
}
SignService에서는 SecurityConfig에서 설정한 PasswordEncoder 빈을 주입받아서 사용한다. 만약 다른 암호화 모듈을 사용하게 된다면 SecurityConfig의 PasswordEncoder 빈만 다른 암호화 모듈로 교체하면 된다.
여기까지 진행하면 다시 회원가입을 하고 DB를 보면 비밀번호가 암호화되어 저장된 것을 확인할 수 있다.
위에서 콜라곰과 사이다곰은 똑같이 1234를 비밀번호로 추가했는데도 비밀번호가 다르게 저장된 것을 확인할 수 있다. 이로 인해 equals()
로는 비밀번호가 일치하는지 확인할 수 없으므로 PasswordEncoder의 matches()
를 사용하여 비교해야 한다.
추가적으로 관리자 계정은 AdminInitializer에서 앱 실행시 자동으로 추가되고 있는데, 이 정보는 암호화가 되어있지 않아서 로그인이 되지 않는다. 아래와 같이 AdminInitializer에서 관리자 비밀번호도 암호화를 적용한다.
Kotlin
@Component
class AdminInitializer(
private val memberRepository: MemberRepository,
private val encoder: PasswordEncoder // 추가
) : ApplicationRunner {
override fun run(args: ApplicationArguments?) {
memberRepository.save(Member("admin", encoder.encode("admin"), "관리자", type = MemberType.ADMIN)) // 수정
}
}
Java
@RequiredArgsConstructor
@Component
public class AdminInitializer implements ApplicationRunner {
private final MemberRepository memberRepository;
private final PasswordEncoder encoder; // 추가
@Override
public void run(ApplicationArguments args) {
memberRepository.save(Member.builder()
.account("admin")
.password(encoder.encode("admin")) // 수정
.name("관리자")
.type(MemberType.ADMIN)
.createdAt(LocalDateTime.now())
.build());
}
}
이제 다시 애플리케이션을 실행해서 DB를 확인해보면 관리자 계정의 비밀번호도 정상적으로 암호화 된 것을 확인할 수 있고, 관리자 계정으로 로그인을 할 수도 있다.
로그인하면 권한을 부여받아서 API를 사용하고 싶다!
현재 로그인 API를 호출해도 DB의 사용자 정보와 비교하여 로그인 되었다고만 할 뿐 권한이 부여되지 않은 상태이다. 그렇기 때문에 로그인 API를 사용해서 로그인 했어도 권한이 없어서 다른 API는 여전히 403 에러가 뜬다. 이 예제에서는 JWT를 사용하여 Bearer 토큰으로 사용 권한을 확인할 것이므로 JWT 관련 설정을 먼저 해보자.
JWT 의존성 라이브러리 추가
JWT를 사용하려면 관련 라이브러리를 추가해야 한다. JWT 공식 홈페이지에서 다양한 종류의 JWT 라이브러리를 확인할 수 있다. 이 글에서는 자바 진영에서 가장 많이 사용되는 JJWT를 사용한다.
Gradle 또는 Maven에 아래 코드를 추가하여 프로젝트에 의존성 라이브러리를 추가한다.
Gradle
dependencies {
...
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
...
}
Maven
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
로그인 API를 호출하면 토큰 발급
권한을 부여받으려면 로그인 API를 호출하여 로그인에 성공했을 때 토큰을 발급받아야 한다. JWT의 핵심적인 부분은 토큰의 내용을 암호화하기 위한 비밀키인데, 이러한 비밀키는 코드에 작성하면 공유 레포지토리에 그대로 공개되어 버리는 등의 문제가 발생할 수 있다. 따라서 별도의 설정 파일에 분리하여 관리하는 것이 여러모로 좋다.
/main/resources에 아래 내용의 jwt.yml
파일을 추가한다.
secret-key: NiOeyFbN1Gqo10bPgUyTFsRMkJpGLXSvGP04eFqj5B30r5TcrtlSXfQ7TndvYjNvfkEKLqILn0j1SmKODO1Yw3JpBBgI3nVPEahqxeY8qbPSFGyzyEVxnl4AQcrnVneI
expiration-hours: 3
issuer: colabear754
각 key가 의미하는 것은 다음과 같다.
- secret-key : JWT의 서명에 사용할 비밀키. 이 예제에서는 서명부의 암호화 알고리즘으로 HS512를 사용할 것이기 때문에 길이가 512비트(64바이트) 이상인 비밀키를 사용해야 한다.
- expiration-hours : 발급된 토큰의 만료시간. 토큰이 발급되고 설정된 시간이 지나면 해당 토큰은 무효화된다.
- issuer : 토큰발급자.
security
패키지에 아래와 같이 토큰을 생성해줄 클래스를 작성한다.
Kotlin
@PropertySource("classpath:jwt.yml")
@Service
class TokenProvider(
@Value("\${secret-key}")
private val secretKey: String,
@Value("\${expiration-hours}")
private val expirationHours: Long,
@Value("\${issuer}")
private val issuer: String
) {
fun createToken(userSpecification: String) = Jwts.builder()
.signWith(SecretKeySpec(secretKey.toByteArray(), SignatureAlgorithm.HS512.jcaName)) // HS512 알고리즘을 사용하여 secretKey를 이용해 서명
.setSubject(userSpecification) // JWT 토큰 제목
.setIssuer(issuer) // JWT 토큰 발급자
.setIssuedAt(Timestamp.valueOf(LocalDateTime.now())) // JWT 토큰 발급 시간
.setExpiration(Date.from(Instant.now().plus(expirationHours, ChronoUnit.HOURS))) // JWT 토큰의 만료시간 설정
.compact()!! // JWT 토큰 생성
}
Java
@PropertySource("classpath:jwt.yml")
@Service
public class TokenProvider {
private final String secretKey;
private final long expirationHours;
private final String issuer;
public TokenProvider(
@Value("${secret-key}") String secretKey,
@Value("${expiration-hours}") long expirationHours,
@Value("${issuer}") String issuer
) {
this.secretKey = secretKey;
this.expirationHours = expirationHours;
this.issuer = issuer;
}
public String createToken(String userSpecification) {
return Jwts.builder()
.signWith(new SecretKeySpec(secretKey.getBytes(), SignatureAlgorithm.HS512.getJcaName())) // HS512 알고리즘을 사용하여 secretKey를 이용해 서명
.setSubject(userSpecification) // JWT 토큰 제목
.setIssuer(issuer) // JWT 토큰 발급자
.setIssuedAt(Timestamp.valueOf(LocalDateTime.now())) // JWT 토큰 발급 시간
.setExpiration(Date.from(Instant.now().plus(expirationHours, ChronoUnit.HOURS))) // JWT 토큰 만료 시간
.compact(); // JWT 토큰 생성
}
}
이제 로그인하면 토큰을 발급받을 수 있도록 SignInResponse와 SignService의 signIn()
메소드에 다음 내용을 추가한다.
Kotlin
data class SignInResponse(
@Schema(description = "회원 이름", example = "콜라곰")
val name: String?,
@Schema(description = "회원 유형", example = "USER")
val type: MemberType,
val token: String // 추가
)
@Service
class SignService(
private val memberRepository: MemberRepository,
private val encoder: PasswordEncoder,
private val tokenProvider: TokenProvider // 추가
) {
...
@Transactional
fun signIn(request: SignInRequest): SignInResponse {
val member = memberRepository.findByAccount(request.account)
?.takeIf { encoder.matches(request.password, it.password) }
?: throw IllegalArgumentException("아이디 또는 비밀번호가 일치하지 않습니다.")
val token = tokenProvider.createToken("${member.id}:${member.type}") // 토큰 생성
return SignInResponse(member.name, member.type, token) // 생성자에 토큰 추가
}
}
Java
public record SignInResponse(
@Schema(description = "회원 이름", example = "콜라곰")
String name,
@Schema(description = "회원 유형", example = "USER")
MemberType type,
String token // 추가
) {
}
@RequiredArgsConstructor
@Service
public class SignService {
private final MemberRepository memberRepository;
private final PasswordEncoder encoder;
private final TokenProvider tokenProvider; // 추가
...
@Transactional(readOnly = true)
public SignInResponse signIn(SignInRequest request) {
Member member = memberRepository.findByAccount(request.account())
.filter(it -> encoder.matches(request.password(), it.getPassword()))
.orElseThrow(() -> new IllegalArgumentException("아이디 또는 비밀번호가 일치하지 않습니다."));
String token = tokenProvider.createToken(String.format("%s:%s", member.getId(), member.getType())); // 토큰 생성
return new SignInResponse(member.getName(), member.getType(), token); // 생성자에 토큰 추가
}
}
이렇게 설정을 하고 다시 애플리케이션을 실행하여 로그인 API를 호출해보면 정상적으로 토큰이 발급되는 것을 확인할 수 있다.
발급받은 토큰을 스웨거에서 쓰고 싶다!
현재 기본 스웨거 화면에서는 토큰을 발급받아도 사용할 수가 없다. 토큰을 헤더에 추가해서 사용하기 위해 SwaggerConfig의 OpenAPI 빈을 아래와 같이 수정한다.
Kotlin
private const val SECURITY_SCHEME_NAME = "authorization" // 추가
@Configuration
class SwaggerConfig {
@Bean
fun swaggerApi(): OpenAPI = OpenAPI()
.components(Components()
// 여기부터 추가 부분
.addSecuritySchemes(SECURITY_SCHEME_NAME, SecurityScheme()
.name(SECURITY_SCHEME_NAME)
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")))
.addSecurityItem(SecurityRequirement().addList(SECURITY_SCHEME_NAME))
// 여기까지
.info(Info()
.title("스프링시큐리티 + JWT 예제")
.description("스프링시큐리티와 JWT를 이용한 사용자 인증 예제입니다.")
.version("1.0.0"))
}
Java
@Configuration
public class SwaggerConfig {
private static final String SECURITY_SCHEME_NAME = "authorization"; // 추가
@Bean
public OpenAPI swaggerApi() {
return new OpenAPI()
.components(new Components()
// 여기부터 추가 부분
.addSecuritySchemes(SECURITY_SCHEME_NAME, new SecurityScheme()
.name(SECURITY_SCHEME_NAME)
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")))
.addSecurityItem(new SecurityRequirement().addList(SECURITY_SCHEME_NAME))
// 여기까지
.info(new Info()
.title("스프링시큐리티 + JWT 예제")
.description("스프링시큐리티와 JWT를 이용한 사용자 인증 예제입니다.")
.version("1.0.0"));
}
}
addSecuritySchemes()
는 인증 정보 입력을 위한 버튼을, addSecurityItem()
은 시큐리티 요구 사항을 스웨거에 추가한다. SECURITY_SCHEME_NAME은 시큐리티 스키마의 이름을 뜻하기 때문에 원하는 이름을 사용할 수 있다.
API를 호출을 위해 HTTP 요청을 보낼 때 Authorization 헤더에 JWT 기반의 Bearer 토큰을 사용할 것이기 때문에 시큐리티 스키마는 위와 같이 설정하면 된다.
시큐리티 요구사항은 메소드마다 어노테이션을 사용하여 걸어줄 수도 있지만 몹시 번거로워지니까 스웨거 설정에서 전역으로 걸어준다.
이렇게 하면 위와 같이 Authorize 버튼과 자물쇠 아이콘이 추가된 것을 볼 수 있다. Authorize 버튼을 클릭하면 토큰 값을 입력할 수 있는 창이 나타나게 되고, 여기서 토큰값을 입력하고 API를 실행해보면 Authorization 헤더에 토큰값이 추가되어 API가 호출되는 것을 볼 수 있다.
근데 아직도 API 실행은 안되는데?
토큰을 발급받긴 했지만 토큰에는 로그인한 사용자에 대한 정보만 있을 뿐, 아직 토큰에 담긴 정보로 사용자를 구분할 수 없어서 API를 실행할 수 없다. 토큰에 담긴 정보를 토대로 사용자 권한을 구분해보자.
먼저 헤더로 받은 토큰에 담긴 정보를 다시 얻어오기 위해 TokenProvider에 다음 메소드를 추가한다.
Kotlin
@PropertySource("classpath:jwt.yml")
@Service
class TokenProvider(
...
) {
...
fun validateTokenAndGetSubject(token: String): String? = Jwts.parserBuilder()
.setSigningKey(secretKey.toByteArray())
.build()
.parseClaimsJws(token)
.body
.subject
}
Java
@PropertySource("classpath:jwt.yml")
@Service
public class TokenProvider {
...
public String validateTokenAndGetSubject(String token) {
return Jwts.parserBuilder()
.setSigningKey(secretKey.getBytes())
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
}
validateTokenAndGetSubject()
는 비밀키를 토대로 createToken()
에서 토큰에 담은 Subject를 복호화하여 문자열 형태로 반환하는 메소드이다. 그리고 이 Subject에는 SignService의 singIn()
에서 토큰을 생성할 때 인자로 넘긴 "{회원ID}:{회원타입}"이 담겨있다.
이제 본격적으로 토큰에 담긴 정보로 사용자를 필터링해줄 필터를 작성해야 한다. security
패키지에 OncePerRequestFilter를 상속받는 필터 클래스를 작성한다.
Kotlin
@Order(0)
@Component
class JwtAuthenticationFilter(private val tokenProvider: TokenProvider) : OncePerRequestFilter() {
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) {
val token = parseBearerToken(request)
val user = parseUserSpecification(token)
UsernamePasswordAuthenticationToken.authenticated(user, token, user.authorities)
.apply { details = WebAuthenticationDetails(request) }
.also { SecurityContextHolder.getContext().authentication = it }
filterChain.doFilter(request, response)
}
private fun parseBearerToken(request: HttpServletRequest) = request.getHeader(HttpHeaders.AUTHORIZATION)
.takeIf { it?.startsWith("Bearer ", true) ?: false }?.substring(7)
private fun parseUserSpecification(token: String?) = (
token?.takeIf { it.length >= 10 }
?.let { tokenProvider.validateTokenAndGetSubject(it) }
?: "anonymous:anonymous"
).split(":")
.let { User(it[0], "", listOf(SimpleGrantedAuthority(it[1]))) }
}
Java
@Order(0)
@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = parseBearerToken(request);
User user = parseUserSpecification(token);
AbstractAuthenticationToken authenticated = UsernamePasswordAuthenticationToken.authenticated(user, token, user.getAuthorities());
authenticated.setDetails(new WebAuthenticationDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticated);
filterChain.doFilter(request, response);
}
private String parseBearerToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(HttpHeaders.AUTHORIZATION))
.filter(token -> token.substring(0, 7).equalsIgnoreCase("Bearer "))
.map(token -> token.substring(7))
.orElse(null);
}
private User parseUserSpecification(String token) {
String[] split = Optional.ofNullable(token)
.filter(subject -> subject.length() >= 10)
.map(tokenProvider::validateTokenAndGetSubject)
.orElse("anonymous:anonymous")
.split(":");
return new User(split[0], "", List.of(new SimpleGrantedAuthority(split[1])));
}
}
@Order
를 통해int
범위 내에서 의존성 주입 우선순위를 설정한다(수치가 낮을수록 높다.). 이 때 우선순위를 너무 높이면(= 값이 너무 작으면) 유효한 토큰이라도 인증이 안되고, 우선순위가 너무 낮으면(= 값이 너무 크면) 토큰이 없어도 통과되기 때문에 적당한 값으로 설정한다.parseBearerToken()
은 HTTP 요청의 헤더에서 Authorization값을 찾아서 Bearer로 시작하는지 확인하고 접두어를 제외한 토큰값으로 파싱한다. 헤더에 Authorization이 존재하지 않거나 접두어가 Bearer가 아니면 null을 반환한다.parseUserSpecification()
은 토큰값을 토대로 토큰에 담긴 회원ID와 회원타입을 토대로 스프링 시큐리티에서 사용할 User 객체를 반환한다. 이 때 파싱된 토큰이 null이 아니면서 길이가 너무 짧지 않을 때만 토큰을 복호화하고, 그 외에는 별도로 익명임을 뜻하는 User 객체를 생성한다. 비밀번호는 로그인 API를 호출할 때 이미 확인을 했기 때문에, User 객체를 생성할 때는 사용하지 않으므로 빈 문자열을 넘긴다.- 마지막으로 오버라이드한
doFilterInternal()
에서 인증 정보를 설정한다. HTTP 요청 헤더의 토큰을 기반으로 생성한 User 객체를 토대로 스프링 시큐리티에서 사용할 UsernamePasswordAuthenticationToken 객체를 생성한다. 이후 스프링 시큐리티 컨텍스트의 인증 정보를 새로 생성한 인증 토큰으로 설정하고 다음 필터로 넘어간다.- 인증 토큰의
details
에는 요청을 날린 클라이언트 또는 프록시의 IP 주소와 세션 ID를 저장하는데, 이 부분은 필요하지 않다면 삭제해도 된다.
- 인증 토큰의
SecurityConfig의 SecurityFilterChain 설정에 새로 작성한 JwtAuthenticationFilter를 추가하면 모든 설정이 마무리된다.
Kotlin
@EnableMethodSecurity // 추가
@Configuration
class SecurityConfig(private val jwtAuthenticationFilter: JwtAuthenticationFilter) { // JwtAuthenticationFilter 주입
private val allowedUrls = arrayOf("/", "/swagger-ui/**", "/v3/**", "/sign-up", "/sign-in")
@Bean
fun filterChain(http: HttpSecurity) = http
.csrf { it.disable() }
.headers { it.frameOptions { frameOptions -> frameOptions.sameOrigin() } }
.authorizeHttpRequests {
it.requestMatchers(*allowedUrls).permitAll()
.requestMatchers(PathRequest.toH2Console()).permitAll()
.anyRequest().authenticated()
}
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.addFilterBefore(jwtAuthenticationFilter, BasicAuthenticationFilter::class.java) // 추가
.build()!!
...
}
Java
@EnableMethodSecurity // 추가
@RequiredArgsConstructor
@Configuration
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter; // JwtAuthenticationFilter 주입
private final String[] allowedUrls = {"/", "/swagger-ui/**", "/v3/**"};
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(CsrfConfigurer<HttpSecurity>::disable)
.headers(headers -> headers.frameOptions(FrameOptionsConfig::sameOrigin))
.authorizeHttpRequests(requests ->
requests.requestMatchers(allowedUrls).permitAll()
.requestMatchers(PathRequest.toH2Console()).permitAll()
.anyRequest().authenticated()
)
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.addFilterBefore(jwtAuthenticationFilter, BasicAuthenticationFilter.class) // 추가
.build();
}
...
}
SecurityConfig에 @EnableMethodSecurity
를 추가하여 메소드 시큐리티를 활성화한다. 그리고 이전 단계에서 작성한 JwtAuthenticationFilter를 주입받아서 BasicAuthenticationFilter 앞에 추가한다.
이 프로젝트에서는 스프링 시큐리티 공식 문서에서 언급하는 필터 순서가 ... → BearerTokenAuthenticationFilter → BasicAuthenticationFilter → ... 이기 때문에 BasicAuthenticationFilter 앞에 JwtAuthenticationFilter를 추가하였습니다.
여기까지 완료했다면 이제 로그인 API에서 발급받은 토큰을 사용하여 메소드를 정상적으로 실행할 수 있게 된다!
토큰이 없어도 메소드가 실행되는데?
사실 위 단계에서 헤더의 토큰을 토대로 인증 정보를 설정하긴 했지만 API의 호출에 제한을 걸지 않았기 때문에 모든 URL에 접근이 가능해진 상태이다. 심지어 SecurityConfig의 authorizeHttpRequests()
에서 anyRequest().authenticated()
만 남기고 모두 지워도 스웨거 문서에 접속되고 모든 API를 호출할 수 있다. 따라서 각자 권한에 맞는 API만 호출할 수 있게 권한을 제한해야 한다.
AdminController와 MemberController의 메소드 호출 제한
권한에 따라 메소드의 호출을 제한하고 싶다면 컨트롤러나 메소드 위에 @PreAuthorize
를 붙여서 손쉽게 제한할 수 있다. 컨트롤러의 위에 붙인다면 해당 컨트롤러의 모든 메소드에 적용되고, 메소드 위에 붙인다면 해당 메소드에만 적용된다. 만약 컨트롤러와 메소드 모두에 붙인다면 메소드에 붙은 권한 검증이 우선시 되고, 해당 컨트롤러 내에서 @PreAuthorize
가 붙지 않은 모든 메소드에는 컨트롤러에 붙은 권한 검증이 적용된다.
이 예제에서는 간단하게 컨트롤러에 붙이는 것으로 적용할 것이다. AdminController와 MemberController의 위에 아래와 같이 @PreAuthorize
를 추가해준다.
Kotlin
@Tag(name = "관리자용 API")
@PreAuthorize("hasAuthority('ADMIN')") // 추가
@RestController
@RequestMapping("/admin")
class AdminController(private val adminService: AdminService) {
...
}
@Tag(name = "로그인 후 사용할 수 있는 API")
@PreAuthorize("hasAuthority('USER')") // 추가
@RestController
@RequestMapping("/member")
class MemberController(
...
) {
...
}
Java
@Tag(name = "관리자용 API")
@RequiredArgsConstructor
@PreAuthorize("hasAuthority('ADMIN')") // 추가
@RestController
@RequestMapping("/admin")
public class AdminController {
...
}
@Tag(name = "로그인 후 사용할 수 있는 API")
@RequiredArgsConstructor
@PreAuthorize("hasAuthority('USER')") // 추가
@RestController
@RequestMapping("/member")
public class MemberController {
...
}
그런데 @PreAuthorize
의 value
로 문자열 리터럴을 넘겨주는 것이 굉장히 거슬릴 수 있다. 자칫 잘못해서 오타가 발생하더라도 컴파일 시점에 잡아낼 수 없고 런타임 에러가 발생하기 때문이다. 이를 방지하기 위해 아래와 같이 커스텀 어노테이션을 만들어서 사용할 수도 있다.
Kotlin
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@PreAuthorize("hasAuthority('ADMIN')")
annotation class AdminAuthorize
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@PreAuthorize("hasAuthority('USER')")
annotation class UserAuthorize
Java
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasAuthority('ADMIN')")
public @interface AdminAuthorize {}
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasAuthority('USER')")
public @interface UserAuthorize {}
커스텀 어노테이션을 작성했으니 이제 각 컨트롤러나 메소드에 붙일 @PreAuthorize("hasAutority('ADMIN')")
은 @AdminAuthorize
로, @PreAuthorize("hasAutority('USER')")
는 @UserAuthorize
로 바꿔서 사용하면 된다.
이렇게 하면 각자의 권한에 맞게 관리자용 API는 관리자 계정만, 회원용 API는 사용자 계정만 사용할 수 있게 된다.
회원용 API에 문제가 있다!
현재 회원용 API는 상대방의 ID를 알고있다면 회원으로 로그인 하기만 해도 상대방의 계정을 마음껏 조회하고 수정하고 삭제할 수 있다. 이를 방지하기 위해 현재 로그인된 사용자의 정보를 가져와서 메소드를 실행하도록 수정해야 한다.
MemberController에 있는 메소드들의 파라미터 변경
MemberController의 메소드들을 모두 다음과 같이 수정한다.
Kotlin
@Tag(name = "로그인 후 사용할 수 있는 API")
@UserAuthorize
@RestController
@RequestMapping("/member")
class MemberController(private val memberService: MemberService) {
@Operation(summary = "회원 정보 조회")
@GetMapping
fun getMemberInfo(@AuthenticationPrincipal user: User) =
ApiResponse.success(memberService.getMemberInfo(UUID.fromString(user.username)))
@Operation(summary = "회원 탈퇴")
@DeleteMapping
fun deleteMember(@AuthenticationPrincipal user: User) =
ApiResponse.success(memberService.deleteMember(UUID.fromString(user.username)))
@Operation(summary = "회원 정보 수정")
@PutMapping
fun updateMember(@AuthenticationPrincipal user: User, @RequestBody request: MemberUpdateRequest) =
ApiResponse.success(memberService.updateMember(UUID.fromString(user.username), request))
}
Java
@Tag(name = "로그인 후 사용할 수 있는 API")
@RequiredArgsConstructor
@UserAuthorize
@RestController
@RequestMapping("/member")
public class MemberController {
private final MemberService memberService;
@Operation(summary = "회원 정보 조회")
@GetMapping
public ApiResponse getMemberInfo(@AuthenticationPrincipal User user) {
return ApiResponse.success(memberService.getMemberInfo(UUID.fromString(user.getUsername())));
}
@Operation(summary = "회원 탈퇴")
@DeleteMapping
public ApiResponse deleteMember(@AuthenticationPrincipal User user) {
return ApiResponse.success(memberService.deleteMember(UUID.fromString(user.getUsername())));
}
@Operation(summary = "회원 정보 수정")
@PutMapping
public ApiResponse updateMember(@AuthenticationPrincipal User user, @RequestBody MemberUpdateRequest request) {
return ApiResponse.success(memberService.updateMember(UUID.fromString(user.getUsername()), request));
}
}
기존에 문자열 형태로 받던 파라미터를 스프링 시큐리티의 User 객체로 변경했고, MemberService의 메소드를 호출할 때 넘겨주던 ID 인자도 User 객체의 username
을 넘겨주도록 변경되었다. 이 User 객체는 앞서 JwtAuthenticationFilter에서 토큰을 토대로 생성하여 시큐리티 컨텍스트에 인증 정보로 설정한 그 User 객체이다.
이제 다시 애플리케이션을 실행하여 회원 계정으로 로그인하여 회원 API를 호출해보면 기존에 파라미터로 요청하던 ID가 사라지고, 그냥 호출만 해도 로그인한 계정의 정보를 이용하여 API가 동작하는 것을 볼 수 있다.
스프링 시큐리티 + JWT 설정 완료!
최종 프로젝트 구성
완성된 프로젝트 Github : Kotlin, Java
■ : 변경, ■ : 신규
- common : API 상태와 멤버타입을 나타내기 위한 Enum
- controller : 관리자용 API, 일반 사용자용 API, 회원가입 및 로그인용 API가 작성된 컨트롤러 → 권한 부여 및 로그인한 사용자의 정보를 사용하도록 수정
- dto : 회원가입, 로그인 등 API의 요청과 응답을 위한 DTO
- SingInResponse : 응답값에 토큰 추가
- entity : DB와 매핑되어 있는 회원 엔티티 → 암호화 관련 내용 추가
- handler : 예외가 발생하면 예외의 종류에 따른 응답을 반환하는 핸들러
- repository : Spring Data JPA 사용을 위한 레포지토리
- security : 스프링 시큐리티와 JWT에 관련된 신규 내용
- AdminAuthorize, UserAuthorize : 권한에 따른 접근 제한을 위한 커스텀 어노테이션
- JwtAuthenticationFilter : JWT를 통해 권한을 부여하는 필터
- SecurityConfig : 스프링 시큐리티 기본 설정
- TokenProvider : JWT 생성 및 복호화
- service : 각 컨트롤러의 API들이 사용하는 비즈니스 로직이 작성된 서비스 계층 → 암호화 관련 내용, 토큰 생성 내용 추가
- SwaggerConfig : 스웨거 기본 설정 → JWT 사용을 위한 내용 추가
- RepositoryExtensions(Kotlin) : 레포지토리 확장 함수가 작성된 유틸 파일
- AdminInitializer : 앱 실행 시 기본 관리자 계정을 추가하는 ApplicationRunner 구현체 → 암호화 관련 내용 추가
- jwt.yml : JWT 비밀키, 만료시간, 토큰 발행자에 대한 설정
마치며
사실 이렇게 스프링 시큐리티 + JWT를 적용하는 과정을 정리하긴 했지만 스프링 시큐리티에 대한 지식은 많이 부족한 편이다. 그렇다보니 이정도로 장문의 글을 써도 되나 싶은 생각이 들긴 하지만 그래도 밑바닥부터 차근차근 스프링 시큐리티 + JWT를 적용하고 그 과정을 정리하기 위해 관련 정보를 찾아보면서 알게된 지식은 상당히 도움이 되었던 것 같다. 본문의 내용 외에도 리프레시 토큰이나 시큐리티 예외 처리 등에 대해서 공부하면 더 좋을 것 같기도 하고...
코틀린과 자바 양쪽을 모두 다루다보니 글이 엄청나게 길어져버렸는데, 그래도 스프링은 양쪽 진영 모두에서 사용되는 기술이다보니 두 경우 모두 정리하는 것이 좋다고 생각했다.
참조 링크
관련글
'Development > Spring & Spring Boot' 카테고리의 다른 글
[Spring] WebClient로 외부 API를 호출하여 받은 JSON을 객체로 역직렬화할 때 대문자 필드가 바인딩되지 않는 문제 (0) | 2023.06.29 |
---|---|
[Spring Security] Spring Security 예외를 @ControllerAdvice와 @ExceptionHandler를 사용하여 전역으로 처리해보자 (4) | 2023.05.24 |
[WebClient] Mono에 담긴 List를 하나로 합치기 (0) | 2023.05.14 |
[Spring Boot] 스프링부트 실행 시 나타나는 배너를 바꿔보자 (0) | 2023.04.14 |
[WebClient] WebClient를 사용하여 외부API 호출 후 처리할 비즈니스 로직을 비동기로 처리해보기 (0) | 2023.04.12 |
댓글