해당 포스트는 유튜브 '개발자 유미' 님의 스프링 시큐리티 JWT 강의 영상을 바탕으로 작성하였습니다.
https://www.youtube.com/playlist?list=PLJkjrxxiBSFCcOjy0AAVGNtIa08VLk1EJ
스프링 시큐리티 JWT
JWT 방식 인증을 위한 스프링 시큐리티 구현 방법 (Spring Security JWT)
www.youtube.com
스프링 시큐리티 프레임워크를 사용하여 사용자의 인증/인가를 수행하는 방법에는 2가지가 있다.
1. Session 방식
사용자의 정보를 서버의 세션에 저장하고, 클라이언트에 세션 ID를 반환하는 방식이다. 인증된 사용자 정보를 모두 서버에서 관리하기 때문에 STATEFUL 한 방식으로 동작하며 사용자의 요청이 들어올 때 마다 클라이언트가 함께 첨부한 세션 ID를 바탕으로 사용자 정보를 조회하여 인증,인가를 수행한다. 폼 로그인, HTTP Basic 로그인 방식 등에서 적합하다. 장점으로는 세션 관리가 서버에서 이루어지기 때문에 클라이언트 측의 부담이 적고, 단점으로는 서버의 부하가 증가할 수 있다는 점이다.
2. JWT 방식
RESTful API 서버에서 주로 채택하는 방식이다. 사용자가 로그인, 또는 회원가입을 하면 사용자의 정보를 담고있는 JWT(JSON Web Token)을 사용자에게 반환한다. 사용자는 이후 모든 요청마다 JWT를 첨부하게 되고 서버는 이 JWT를 복호화하여 사용자 정보를 추출함으로서 인증, 인가 절차를 수행한다. 이 방식은 STATELESS한 방식으로, 서버가 세션 상태를 유지할 필요가 없으므로 확장성이 높고, 특히 분산 시스템에서 유리하다. JWT는 클라이언트 측에서 관리되기 때문에 서버의 부하를 줄일 수 있지만, 토큰 탈취 등의 보안 문제에 취약할 수 있다.
이전에 작성한 Spring Security (1),(2) 게시글에서 MVC 서버에서 스프링 시큐리티 Session 방식을 사용하여 사용자 인증,인가 를 수행해보았다. 이번 게시글에서는 REST API 서버에서 JWT를 사용한 사용자 인증,인가 방식에 대해 알아보자.
앞선 게시글과 중복되는 설명의 경우 작성하지 않을 예정이며, 스프링 시큐리티에 대해 이해하고 있어야 JWT 방식에 대해 이해하기 편하므로 먼저 앞선 게시글들을 읽어보는 것을 추천한다.
https://76codefactory.tistory.com/70
[SpringBoot] Spring Securtiy (1)
해당 포스트는 유튜브 '개발자 유미' 님의 스프링 시큐리티 강의 영상을 바탕으로 작성하였습니다.https://www.youtube.com/playlist?list=PLJkjrxxiBSFCKD9TRKDYn7IE96K2u3C3U 스프링 시큐리티스프링 시큐리티 : Spri
76codefactory.tistory.com
https://76codefactory.tistory.com/71
[SpringBoot] Spring Securtiy (2)
해당 포스트는 유튜브 '개발자 유미' 님의 스프링 시큐리티 강의 영상을 바탕으로 작성하였습니다.https://www.youtube.com/playlist?list=PLJkjrxxiBSFCKD9TRKDYn7IE96K2u3C3U 스프링 시큐리티스프링 시큐리티 : Spri
76codefactory.tistory.com
1. 프로젝트 생성 및 JWT 의존성 추가
버전
- Spring Boot 3.3.0
- Security 6.1.5
- Spring Data JPA - MySQL
- IntellJ Ultimate
필수 의존성
- Spring Web
- Lombok
- Spring Security
- Spring Data JPA
- MySQL Driver
프로젝트를 생성하고 데이터베이스 관련 설정을 뒤로 미루기 위해 아래와 같이 Spring Data JPA와 MySQL Driver에 대한 의존성을 임시로 주석처리한다.
추가로 JWT를 사용하기 위해 jsonwebtoken 의존성을 주입한다.
dependencies {
// implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
// runtimeOnly 'com.mysql:mysql-connector-j'
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'
// JWT 필수 의존성 설치 0.12.3
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}
2. Config 파일 작성
스프링 시큐리티 프레임워크를 커스터마이징 하기 위해 구성 클래스를 작성한다.
구성 클래스임을 알리기 위해 @Configuration 을, 스프링 시큐리티 구성 클래스임을 알리기 위해 @EnableWebSecurity 를 작성한다.
SecurityConfig.java
@Configuration
@EnableWebSecurity // 시큐리티를 위한 구성파일임을 알림
public class SecurityConfig {
// 사용자 비밀번호를 관리하기 위한 암호화 클래스
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
// csrf disable
http
.csrf(auth -> auth.disable()); // JWT 방식은 세션을 Stateless로 관리하기 때문에 csrf 공격에 비교적 안전하다.
// JWT 방식을 사용할 것이므로 이외의 방식은 모두 비활성화 한다.
// Form 로그인 방식 disable
http
.formLogin(auth -> auth.disable());
// http basic 인증 방식 disable
http
.httpBasic(auth -> auth.disable());
// 경로별 인가 설정
http
.authorizeHttpRequests(auth -> auth
// login, root, join 경로의 요청에 대해서는 모두 허용
.requestMatchers("/login", "/", "/join").permitAll()
// admin 경로 요청에 대해서는 ADMIN 권한을 가진 경우에만 허용
.requestMatchers("/admin").hasRole("ADMIN")
// 이외의 요청에 대해서는 인증된 사용자만 허용
.anyRequest().authenticated()
);
// 가장 중요
// JWT 방식에서는 세션을 Stateless 상태로 관리하게 됨(서버 세션에 저장하지 않음)
// 따라서 세션을 STATELESS 상태로 설정 해주어야 한다.
http
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
JWT 방식은 세션 방식과 달리 사용자 정보를 서버 세션에 저장하지 않는다. 또한 RESTful API 서버는 STATELESS 한 방식으로 동작하기 때문에, CSRF 공격에 비교적 안전하다. 따라서 스프링 프레임워크에 기본적으로 활성화 되어있는 CSRF 설정을 비활성화 해준다.
또한 폼 로그인 방식이 아닌 API 요청을 통해 인증,인가를 수행할 것이기 때문에 formLogin 설정과 httpsBasic 로그인 설정을 비활성화 해준다. 이를 통해 사용자의 요청을 오롯이 API 요청으로 관리할 수 있게된다. authorizeHttpRequest의 requestMAtchers 함수들을 사용하여 각 요청 경로별 인가 설정을 작성하여주었으며, 마지막으로 가장 중요한 설정으로 sessionManagement를 STATELESS 상태로 설정해주어야 한다. 앞서 설명하였듯이 사용자 정보를 서버 세션을 통해 저장하지 않기 때문이다.
3. Controller 작성
인증과 인가에 따른 변화를 확인하기 위한 테스트 Controller를 작성한다.
AdminController.java
package com.example.new_jwt_practice.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class AdminController {
@GetMapping("/admin")
public String adminP() {
return "This is Admin Controller";
}
}
MainController.java
package com.example.new_jwt_practice.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MainController {
@GetMapping("/")
public String mainP() {
return "This is Main Controller";
}
}
모두 GetMapping으로 작성하였으며, SecurityConfig 구성 클래스에 의하여 /admin 요청은 ADMIN 권한을 가진 경우에만 호출이 가능하게 된다.
서버를 실행시켰을 때, 스프링 시큐리티 로그인 폼이 보여진다면 여기까지 문제 없이 잘 된 것이다.
4. DB 연결 및 Entity 작성
데이터베이스 연결과 사용자 정보를 저장하기 위한 엔티티를 작성해보자.
데이터베이스 사용을 위해 주석 처리해두었던 의존성을 다시 해제한다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
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'
// JWT 필수 의존성 설치 0.12.3
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}
이어서 MySQL에서 데이터베이스를 생성하고 Database 연결을 위해 application.properties를 수정한다.
spring.application.name=new-jwt-practice
# DATABASE Connection
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/jwt-practice
spring.datasource.username=root
spring.datasource.password=1234
# JPA
spring.jpa.hibernate.ddl-auto=none
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.springframework.security=DEBUG
회원 정보를 저장하기 위한 Member 엔티티를 작성한다.
Member.class
@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "MEMBER")
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password;
// 사용자 권한
private String role;
}
5. 회원가입 로직 구현
회원 가입 로직은 Session 방식과 같은 방식으로 진행한다.
회원가입시 사용자가 사용할 username 과 password를 전달받기 위해 dto를 작성한다.
JoinDTO.java
import lombok.Data;
@Data
public class JoinDTO {
private String username;
private String password;
}
회원가입을 위한 API를 작성한다.
MemberRepository.java
package com.example.new_jwt_practice.repository;
import com.example.new_jwt_practice.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MemberRepository extends JpaRepository<Member,Long> {
Boolean existsMemberByUsername(String username);
}
JoinService.java
package com.example.new_jwt_practice.service;
import com.example.new_jwt_practice.dto.JoinDTO;
import com.example.new_jwt_practice.entity.Member;
import com.example.new_jwt_practice.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class JoinService {
private final MemberRepository memberRepository;
private final BCryptPasswordEncoder passwordEncoder;
public ResponseEntity<?> join(JoinDTO joinDTO) {
// 동일 username 사용자 생성 방지
if (memberRepository.existsMemberByUsername(joinDTO.getUsername())) {
System.out.println("이미 존재하는 회원");
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("이미 존재하는 회원입니다.");
}
// 새로운 회원 생성
Member member = Member.builder()
.username(joinDTO.getUsername())
// 비밀번호 암호화 해서 저장
.password(passwordEncoder.encode(joinDTO.getPassword()))
.role("ROLE_ADMIN") // 사용자 권한 설정 접두사 ROLE 작성 필요
.build();
System.out.println("회원 생성 완료");
memberRepository.save(member);
return ResponseEntity.ok("회원가입 성공");
}
}
JoinController.java
package com.example.new_jwt_practice.controller;
import com.example.new_jwt_practice.dto.JoinDTO;
import com.example.new_jwt_practice.service.JoinService;
import lombok.RequiredArgsConstructor;
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.RestController;
@RestController
@RequiredArgsConstructor
public class JoinController {
private final JoinService joinService;
@PostMapping("/join")
public ResponseEntity<?> joinProcess(@RequestBody JoinDTO joinDTO) {
// 회원 가입 진행
return joinService.join(joinDTO);
}
}
POSTMAN 을 사용하여 테스트해보면 회원가입에 성공하는 것을 확인할 수 있다.
6. 로그인 필터 구현
일반적으로 사용자가 api 요청을 보내게되면 Tomcat의 Servlet Filter들을 거친 후, DispatcherServlet을 거쳐 적절한 Controller 계층으로 보내지게 된다.
스프링 시큐리티는 여러 필터들 중에 Delegating Filter Proxy 라는 하나의 필터를 생성하여 모든 요청을 가로채어 Security Filter Chain의 모든 필터들을 먼저 거치게 된다. (이는 사용자의 인증,인가를 수행 한 후 권한에 따라 API 호출을 수행하기 위함이다.)
-> 톰캣의 서블릿 필터와 Security Filter는 서로 다른 것이다.
가로채어진 요청은 Security Filter Chain에서 여러 Security Filter들을 거쳐 상황에 따른 거부, 리다이렉션, 서블릿으로 요청 전달을 진행한다.
스프링 시큐리티 프레임워크는 다양한 필터들을 제공하며 이 필터들 중 UsernamePasswordAuthenticationFilter, DigestAuthenticationFilter,BasicAuthenticationFilter,등을 순서대로 거쳐 인증, 인가를 진행한다.
Form 로그인 방식에서는 프론트엔드에서 username과 password를 전송한 뒤 Security 필터를 통과하는데 UsernamePasswordAuthenticationFilter에서 회원 검증 진행을 시작한다.
회원검증(Authentication)의 경우 UsernamePasswordAuthenticationFilte가 호출한 AuthenticationManager를 통해 진행되며 사용자가 전송한 username과 password을 바탕으로 DB에서 조회한 데이터를 UserDetailsService를 통해 전달받게 된다.
(이때 필요하다면 개발자가 UserDetailsService를 구체화하여 사용자가 전송한 username과 password를 바탕으로 조회된 엔티티로 UserDetails 객체를 생성하여 반환한다. - loadUserByUsername)
현재 진행중인 JWT 프로젝트에서는 formLogin 방식을 disable 해두었으므로 UsernamePasswordAuthenticationFilter는 동작하지 않게 된다. 따라서 로그인을 진행하기 위해 해당 필터를 커스텀하여 등록해야 한다.
6.1. 로그인 요청 받기 : 커스텀 UsernamePasswordAuthentication 필터 작성
사용자의 로그인 요청을 받기 위한 UsernamePassowrdAuthentication 필터를 커스텀하여 작성한다.
코드에 대한 설명은 코드블럭의 주석을 참고하길 바란다.
package com.example.new_jwt_practice.jwt;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.io.IOException;
// 폼 로그인 방식의 경우 스프링 시큐리티 필터들 중 UsernamePasswordAuthenticationFilter에 의해 수행된다.
// 해당 필터는 AuthenticationManager를 사용하여 사용자의 인증절차를 수행하는데
// 이때 UserDetailsService가 제공하는 UserDetails를 사용한다.
// 해당 프로젝트의 경우 폼 로그인 방식을 disable하였으므로
// 사용자의 로그인 요청을 수행하기 위한 UsernamePasswordAuthenticationFilter를 커스텀하여 로그인 요청을 수행한다.
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager; // 인증 절차를 수행할 때 사용
@Override
public Authentication attemptAuthentication(
HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
// 클라이언트 요청에서 username, passowrd 추출
String username = obtainUsername(request);
String password = obtainPassword(request);
// 스프링 시큐리티에서 username과 password를 검증하기 위해서는 토큰에 담아야한다.
// username, password, authorities
UsernamePasswordAuthenticationToken authToken
= new UsernamePasswordAuthenticationToken(username, password,null);
// 토큰 정보를 바탕으로 검증 수행 및 결과 반환
// 검증은 AuthenticationManager가 UserDetails를 바탕으로 수행한다.
// DB의 정보를 바탕으로 UserDetailsService에 의해 UserDetails가 생성되면 AuthenticationManager가 이를 토대로 검증한다.
Authentication authentication = authenticationManager.authenticate(authToken);
return authentication;
}
// 로그인 성공시 실행
// 로그인 성공시 사용자 정보를 담고 있는 JWT 토큰을 발급 해준다.
@Override
protected void successfulAuthentication(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain, Authentication authResult) throws IOException, ServletException {
}
// 로그인 실패시 실행
@Override
protected void unsuccessfulAuthentication(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException failed) throws IOException, ServletException {
}
}
시큐리티 필터를 작성하였으니 스프링 시큐리티 프레임워크에 우리가 작성한 필터를 사용하도록 알려주어야 한다.
SecurityConfig에 우리가 작성한 필터를 등록한다.
package com.example.new_jwt_practice.config;
import com.example.new_jwt_practice.jwt.LoginFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity // 시큐리티를 위한 구성파일임을 알림
@RequiredArgsConstructor
public class SecurityConfig {
// AuthenticationManager의 AuthenticationConfiguration에서 사용하기 위함
private final AuthenticationConfiguration authenticationConfiguration;
// 사용자 비밀번호를 관리하기 위한 암호화 클래스
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// 커스텀한 UsernamePasswordAuthentication Filter에서 사용하기 위해 Authentication 빈 등록
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
// csrf disable
http
.csrf(auth -> auth.disable()); // JWT 방식은 세션을 Stateless로 관리하기 때문에 csrf 공격에 비교적 안전하다.
// JWT 방식을 사용할 것이므로 이외의 방식은 모두 비활성화 한다.
// Form 로그인 방식 disable
http
.formLogin(auth -> auth.disable());
// http basic 인증 방식 disable
http
.httpBasic(auth -> auth.disable());
// 경로별 인가 설정
http
.authorizeHttpRequests(auth -> auth
// login, root, join 경로의 요청에 대해서는 모두 허용
.requestMatchers("/login", "/", "/join").permitAll()
// admin 경로 요청에 대해서는 ADMIN 권한을 가진 경우에만 허용
.requestMatchers("/admin").hasRole("ADMIN")
// 이외의 요청에 대해서는 인증된 사용자만 허용
.anyRequest().authenticated()
);
// 커스텀한 UsernamePasswordAuthenticationFilter를 사용하도록 알림
// addFilterAt : 해당 필터를 대체한다.(그 자리를 대체한다.)
// addFilterAt의 2번째 인자는 어떤 위치를 대체할것인지 해당 필터를 작성한다.(이 경우 Username.. Filter)
http
.addFilterAt(new LoginFilter
(authenticationManager(authenticationConfiguration)),
UsernamePasswordAuthenticationFilter.class);
// 가장 중요
// JWT 방식에서는 세션을 Stateless 상태로 관리하게 됨(서버 세션에 저장하지 않음)
// 따라서 세션을 STATELESS 상태로 설정 해주어야 한다.
http
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
addFilterAt() 함수를 사용하여 특정 필터를 내가 작성한 커스텀 필터 코드로 대체할 수 있다.
앞서 작성한 LoginFilter는 AuthenticationManager를 전달받아야하므로 @Bean 어노테이션을 사용하여 AutehnticationManager를 스프링 빈으로 등록한다.
// 커스텀한 UsernamePasswordAuthentication Filter에서 사용하기 위해 Authentication 빈 등록
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
해당 구성 함수는 AutehnticationConfiguration으로부터 getAuthenticationManager를 호출함으로서 AuthenticationManager를 생성하므로 AuthenticationConfiguration을 주입받도록 한다.
그리하여 최종적으로 addFilterAt() 함수에 우리가 작성한 LoginFilter를 아래와 같이 등록할 수 있게 된다.
addFilterAt()의 두번째 인자로 UsernamePasswordAuthenticationFilter.class를 작성하여 해당 필터를 대체할 것임을 알린다.
http.addFilterAt(new LoginFilter
(authenticationManager(authenticationConfiguration)),
UsernamePasswordAuthenticationFilter.class);
6.3. 데이터베이스 기반 로그인 검증 로직
앞서서 UsernamePasswordAuthenticationFilter는 AuthenticaitonManager를 사용하여 검증을 수행하고, AuthenticationManager는 UserDetails를 사용하여 사용자 정보를 확인한다고 설명하였다.
UserDetails는 UserDetailsService에서 데이터베이스로부터 정보를 조회하여 생성된다.
기본적으로 UserDetailsService는 스프링 시큐리티 프레임에서 사용하는 User 엔티티를 사용하여 UserDetails를 생성하도록 구현되어있는데, 우리는 우리가 직접 작성한 Member 엔티티를 대상으로 위와 같은 과정을 수행하길 바란다.
따라서 우리는 UserDetailsService를 커스텀하여 구현할 필요가 있다.
package com.example.new_jwt_practice.jwt.service;
import com.example.new_jwt_practice.entity.Member;
import com.example.new_jwt_practice.jwt.dto.CustomUserDetails;
import com.example.new_jwt_practice.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.Optional;
// username, password를 바탕으로 회원 엔티티를 조회하고, 조회결과를 바탕으로 UserDetails를 생성하여 반환
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Member> optionalMember = memberRepository.findByUsername(username);
if (optionalMember.isEmpty()) {
return null;
}
Member member = optionalMember.get();
// UserDetails를 구체화하는 클래스를 생성하여 반환
return new CustomUserDetails(member);
}
}
조회된 member 엔티티 정보를 바탕으로 UserDetails를 생성해주어야 하므로, UserDetails를 구체화하는 CustomUserDetails 도 작성한다.
package com.example.new_jwt_practice.jwt.dto;
import com.example.new_jwt_practice.entity.Member;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.ArrayList;
import java.util.Collection;
public class CustomUserDetails implements UserDetails {
private Member member;
public CustomUserDetails(Member member) {
this.member = member;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return member.getRole();
}
});
return collection;
}
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public String getUsername() {
return member.getUsername();
}
}
마지막으로 로그인이 성공했을 경우와 실패했을 경우를 살펴보기 위해 LoginFilter의 successfulAuthentication()과 unsuccessfulAuthentication()에 아래와 같이 작성한다.
@Override
protected void successfulAuthentication(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain, Authentication authResult) throws IOException, ServletException {
System.out.println("success!");
}
// 로그인 실패시 실행
@Override
protected void unsuccessfulAuthentication(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException failed) throws IOException, ServletException {
System.out.println("fail!");
}
POSTMAN을 통해 테스트를 진행해보면 아래와 같이 success가 뜨는것을 확인 할 수 있다.
위 과정을 진행한 뒤 몇가지 의문점이 발생하여 자료를 찾아보았다.
1. 우리는 분명 /login 과 관련된 Controller를 작성한 적이 없다. 그렇다면 어떻게 /login 엔드포인트를 사용하여 사용자 검증이 이루어지는가?
/login 경로에 대한 요청이 정상적으로 처리되는 이유는 UsernamePasswordAuthenticationFilter 때문이다.
스프링 시큐리티는 기본적으로 UsernamePasswordAuthenticationFilter를 사용하여 /login 경로에서 로그인 요청을 처리한다.
이 필터가 폼 데이터의 username과 password를 추출하고, AuthenticationManager를 사용하여 인증 절차를 수행합니다. 따라서 별도의 컨트롤러를 작성하지 않아도 /login 경로로 로그인 요청을 보낼 수 있게된다.
앞서 설명하였듯 사용자 요청이 전달되면 여러가지 필터들을 거친 뒤 DispatcherServlet에 의해 적잘한 Controller로 매칭된다. 하지만 위 요청의 경우 Filter단에서 수행되는 로직이기 때문에 DispatcherServlet에 의한 매핑전에 수행된다. 따라서 /login은 별도의 Controller 코드 없이 동작할 수 있게 된다.
2. CustomUserDetailService와 LoginFIlter 코드를 살펴보면 사용자의 비밀번호가 데이터베이스에 저장된 비밀번호와 일치하는지 확인하는 로직이 존재하지 않는다. 어떻게 비밀번호 검증을 수행하는 걸까?
사용자가 로그인을 요청하게 되면 사용자 정보는 CustomUserDetailsService에 의해 UserDetails를 구체화한 CustomUserDetails 객체로 반환된다. 이 객체는 데이터베이스에 저장된 username과 암호화되어 저장된 password를 보유하고 있다.
사용자의 로그인 요청을 처리하는 필터인 UsernamePasswordAuthenticationFilter는 전달받은 사용자 이름을 바탕으로 검증을 수행하며 AuthenticationManager에게 이 일을 위임한다. AuthenticaionManager는 AuthenticationProvider를 통해 실제 인증 작업을 수행하는데 이때 사용자가 전달한 비밀번호를 BcryptEncoder를 통해 암호화하여 해당 해시값과 UserDetails에 저장된 해시값이 일치하는지 확인함으로서 비밀번호가 일치하는지 확인한다.
따라서 별도로 회원의 비밀번호가 일치하는지 확인하는 로직을 작성할 필요가 없다.
3. 회원가입 로직을 작성할 때 사용자 비밀번호를 BcryptEncoder를 사용하여 암호화하여 저장하였다. 하지만 사용자가 로그인할 때 입력하는 값은 암호화되지 않은 값이다. 어떻게 두 값이 같은지 확인할까?
2번에서 설명하였듯이 AuthenticationManger에서 사용자가 입력한 비밀번호를 BcryptEncoder를 사용하여 암호화한뒤 해당 해시값과 UserDetails에 존재하는 사용자 비밀번호 해시값이 일치하는지 확인함으로서 두 값이 일치하는지 확인한다.
이때 암호화에 사용되는 인코더는 passwordEncoder라는 이름으로 등록된 빈을 사용한다. 즉, BcryptEncoder가 아닌 다른 방식으로 비밀번호를 암호화하고 비교하고 싶다면 해당 인코더 클래스를 passwordEncoder 라는 이름의 빈으로 등록하면 된다.
'Backend' 카테고리의 다른 글
[AWS] 네트워크 구축 (0) | 2024.12.08 |
---|---|
[SpringBoot] Spring Securtiy JWT(2) (1) | 2024.06.22 |
[SpringBoot] Spring Securtiy (2) (1) | 2024.06.14 |
[SpringBoot] Spring Securtiy (1) (0) | 2024.06.11 |
[Spring] Spring Initializr Naming Rule (0) | 2024.06.08 |