동작 원리
구현
- 인증: 로그인
- 인가: JWT를 통한 경로별 접근 권한
- 회원가입
- 프론트엔드 없이 API 응답 방식으로 진행할 예정
JWT 인증 방식 시큐리티 동작원리
- 회원가입: 내부 로직은 세션 방식과 JWT 차이 없음

- 로그인(인증): 로그인 요청을 받은 후 세션 방식은 세션이 유저 정보를 저장하지만, JWT 방식은 토큰을 생성하여 응답

- 경로접근(인가): JWT Filter를 통해 요청의 헤더에서 JWT를 찾아 검증 후 일시적으로 요청에 대한 세션 생성(생성된 세션은 요청이 끝나면 소멸)

프로젝트 생성 및 의존성 추가
JWT 의존성 추가
- 대부분 0.11.5버전을 사용하지만 최신 버전은 0.12.3(버전 별로 구현 방법이 다르니 주의)
- 0.12.3으로 구현 예정
dependencies {
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}
기본 Controller 생성
@RestController
public class AdminController {
@GetMapping("/admin")
public String adminP() {
return "Admin Controller";
}
}
@RestController
public class MainController {
@GetMapping("/")
public String mainP() {
return "Main Controller";
}
}
SecurityConfig
기본 요소
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf((csrf) -> csrf.disable());
http
.formLogin((auth) -> auth.disable());
http
.httpBasic((auth) -> auth.disable());
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/", "/join", "/login").permitAll()
.requestMatchers("/admin").hasRole("ADMIN")
.anyRequest().authenticated());
http
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
DB연결 및 Entity 작성
application.yml
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://아이피:3306/데이터베이스?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true
username: 아이디
password: 비밀번호
jpa:
hibernate:
ddl-auto: none
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: debug
org.hibernate.type: trace
User 엔티티 작성
@Entity
@Getter
@Setter
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password;
private String role;
}
UserRepository 작성
public interface UserRepository extends JpaRepository<User, Long> {
}
회원가입 로직 구현
JoinDto
@Getter
@Setter
public class JoinDto {
private String username;
private String password;
}
JoinController
- 일단은 회원가입 여부 상관없이 ok 리턴하는 걸로 작성했음
@RestController
public class JoinController {
private final JoinService joinService;
public JoinController(JoinService joinService) {
this.joinService = joinService;
}
@PostMapping("/join")
public String joinProcess(JoinDto joinDto) {
joinService.joinProcess(joinDto);
return "ok";
}
}
JoinService
@Service
public class JoinService {
private final UserRepository userRepository;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
public JoinService(UserRepository userRepository, BCryptPasswordEncoder bCryptPasswordEncoder) {
this.userRepository = userRepository;
this.bCryptPasswordEncoder = bCryptPasswordEncoder;
}
public void joinProcess(JoinDto joinDto) {
String username = joinDto.getUsername();
String password = joinDto.getPassword();
boolean isExist = userRepository.existsByUsername(username);
if (isExist) {
return;
}
User user = new User();
user.setUsername(username);
user.setPassword(bCryptPasswordEncoder.encode(password));
user.setRole("ROLE_ADMIN");
userRepository.save(user);
}
}
UserRepository
public interface UserRepository extends JpaRepository<User, Long> {
boolean existsByUsername(String username);
}
로그인 필터 구현
스프링 시큐리티 필터 동작 원리
- 스프링 시큐리티는 클라이언트 요청이 여러개의 필터를 거쳐 DispatcherServlet(Controller)로 향하는 중간 필터에서 요청을 가로챈 후 검증(인증/인가)을 진행
- 클라이언트 -> 서블릿 필터 -> 서블릿(컨트롤러)
- 서블릿 컨테이너는 톰캣이라고 생각하면 됨

- DelegatingFilterProxy: 서블릿 컨테이너에 존재하는 필터 체인에 DelegatingFilterProxy를 등록한 뒤 모든 요청을 가로챔(서블릿 컨테이너와 스프링 IOC 컨테이너의 연결 다리 느낌)

- 서블릿 필터 체인의 DelegatingFilter → Security 필터 체인 (내부 처리 후) → 서블릿 필터 체인의 DelegatingFilter
- 가로챈 요청은 Security 필터 체인에서 처리 후 상황에 따른 거부, 리다이렉션, 서블릿으로 요청 전달

- SecurityFilterChain의 필터 목록과 순서

- 클라이언트에서 username, password가 넘어오면 SecurityFilter를 통과할 때 UsernamePasswordAuthentication 필터에서 회원 검증을 진행
- 회원 검증의 경우 UsernamePasswordAuthenticationFilter가 호출한 AuthenticationManager를 통해 진행하며 DB에서 조회한 데이터를 UserDetailsService를 통해 받음
- JWT 방식은 formLogin 방식을 disable()처리 했기 때문에 기본적으로 활성화된 UsernamePasswordAuthenticationFilter는 동작하지 않음
- 필터를 커스텀해서 등록해야함
커스텀 UsernamePasswordAuthentication 필터 작성
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
public LoginFilter(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
String username = obtainUsername(request);
String password = obtainPassword(request);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null);
return authenticationManager.authenticate(authToken);
}
@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 설정
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final AuthenticationConfiguration authenticationConfiguration;
public SecurityConfig(AuthenticationConfiguration authenticationConfiguration) {
this.authenticationConfiguration = authenticationConfiguration;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf((csrf) -> csrf.disable());
http
.formLogin((auth) -> auth.disable());
http
.httpBasic((auth) -> auth.disable());
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/", "/join", "/login").permitAll()
.requestMatchers("/admin").hasRole("ADMIN")
.anyRequest().authenticated());
http
.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration)),
UsernamePasswordAuthenticationFilter.class);
http
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
DB기반 로그인 검증 로직
UserRepository
public interface UserRepository extends JpaRepository<User, Long> {
boolean existsByUsername(String username);
User findByUsername(String username);
}
UserDetailsService 커스텀 구현
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username);
if(user != null) {
return new CustomUserDetails(user);
}
return null;
}
}
UserDetails 커스텀 구현
public class CustomUserDetails implements UserDetails {
private final User user;
public CustomUserDetails(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return user.getRole();
}
});
return collection;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
JWT 발급 및 검증 클래스
JWT 발급 및 검증
- 로그인 -> 로그인 성공 -> JWT 발급
- 접근 -> JWT 검증
- JWTUtil이라는 클래스를 통해 JWT 발급과 검증을 진행하는 메소드 작성
JWT 생성 원리
JWT 공식 홈페이지

- JWT는 Header, Payload, Signature로 구성
- Header
- Payload
- Signature
- 암호화 알고리즘((BASE64(Header) + BASE64(Payload)) + 암호화키)
- JWT는 단순 BASE64 방식으로 인코딩 하기 때문에 외부에서 쉽게 디코딩 가능
- 외부에서 열람해도 되는 정보만 담을 것
- 토큰 자체의 발급처를 확인하기 위해서 사용
JWT 암호화 방식
- 양방향
- 대칭키: 이 프로젝트는 양방향 대칭키 사용(HS256)
- 비대칭키
- 단방향
암호화 키 저장
- 암호화 키는 하드코딩 방식으로 내부에 탑재하는 것을 지양, 변수 파일에 저장할 것
- application.yml
spring:
jwt:
secret: dlscksdlrkcjdmadmfhwlsgodgksmstmvmfldwpdlejqmfdbxlvmfhwprxmdlqslek
JwtUtil
- 토큰 payload에 저장할 정보
- JwtUtil 구현 메소드
- JwtUtil 생성자
- username 확인 메소드
- role 확인 메소드
- 만료일 확인 메소드
- JwtUtil 0.12.3
@Component
public class JwtUtil {
private SecretKey secretKey;
public JwtUtil(@Value("${spring.jwt.secret}") String secret) {
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}
public String getUsername(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class);
}
public String getRole(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
}
public Boolean isExpired(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
}
public String createJwt(String username, String role, Long expiredMs) {
return Jwts.builder()
.claim("username", username)
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(secretKey)
.compact();
}
}
@Component
public class JWTUtil {
private Key key;
public JWTUtil(@Value("${spring.jwt.secret}")String secret) {
byte[] byteSecretKey = Decoders.BASE64.decode(secret);
key = Keys.hmacShaKeyFor(byteSecretKey);
}
public String getUsername(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().get("username", String.class);
}
public String getRole(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().get("role", String.class);
}
public Boolean isExpired(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getExpiration().before(new Date());
}
public String createJwt(String username, String role, Long expiredMs) {
Claims claims = Jwts.claims();
claims.put("username", username);
claims.put("role", role);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
}
로그인 성공 JWT 발급
JwtUtil 주입
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtUtil jwtUtil;
public LoginFilter(AuthenticationManager authenticationManager, JwtUtil jwtUtil) {
this.authenticationManager = authenticationManager;
this.jwtUtil = jwtUtil;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
String username = obtainUsername(request);
String password = obtainPassword(request);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null);
return authenticationManager.authenticate(authToken);
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
String username = customUserDetails.getUsername();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority grantedAuthority = iterator.next();
String role = grantedAuthority.getAuthority();
String token = jwtUtil.createJwt(username, role, 60*60*10L);
response.addHeader("Authorization", "Bearer " + token);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
response.setStatus(401);
}
}
- SecurityConfig에서 Filter에 JwtUtil 주입
@Component
public class JwtUtil {
private SecretKey secretKey;
public JwtUtil(@Value("${spring.jwt.secret}") String secret) {
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}
public String getUsername(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class);
}
public String getRole(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
}
public Boolean isExpired(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
}
public String createJwt(String username, String role, Long expiredMs) {
return Jwts.builder()
.claim("username", username)
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(secretKey)
.compact();
}
}
LoginFilter - successfulAuthentication()
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
String username = customUserDetails.getUsername();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority grantedAuthority = iterator.next();
String role = grantedAuthority.getAuthority();
String token = jwtUtil.createJwt(username, role, 60*60*10L);
response.addHeader("Authorization", "Bearer " + token);
}
- HTTP 인증 방식은 RFC 7235 정의에 따라 아래 인증 헤더 형태를 가짐
Authorization: 타입 인증토큰
//예시
Authorization: Bearer 인증토큰string
LoginFilter - unsuccessfulAuthentication()
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JWTUtil jwtUtil;
public LoginFilter(AuthenticationManager authenticationManager, JWTUtil jwtUtil) {
this.authenticationManager = authenticationManager;
this.jwtUtil = jwtUtil;
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
response.setStatus(401);
}
}
JWT 검증 필터
JwtFilter 역할
- 스프링 시큐리티에 담긴 JWT를 검증하기 위함
- Authorization 키에 JWT가 존재하는 경우 JWT를 검증하고 강제로 SecurityContextHolder에 세션을 생성(이때 세션은 STATELESS 방식으로 관리되어 해당 요청이 끝나면 소멸)
JwtFilter 구현
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
public JwtFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String authorization = request.getHeader("Authorization");
if (authorization == null || !authorization.startsWith("Bearer ")) {
System.out.println("token null");
filterChain.doFilter(request, response);
return;
}
String token = authorization.split(" ")[1];
if (jwtUtil.isExpired(token)) {
System.out.println("token expired");
filterChain.doFilter(request, response);
return;
}
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
User user = new User();
user.setUsername(username);
user.setPassword("temppassword");
user.setRole(role);
CustomUserDetails customUserDetails = new CustomUserDetails(user);
Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
}
SecurityConfig에 등록
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf((csrf) -> csrf.disable());
http
.formLogin((auth) -> auth.disable());
http
.httpBasic((auth) -> auth.disable());
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/", "/join", "/login").permitAll()
.requestMatchers("/admin").hasRole("ADMIN")
.anyRequest().authenticated());
http
.addFilterAfter(new JwtFilter(jwtUtil), LoginFilter.class)
.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil),
UsernamePasswordAuthenticationFilter.class);
http
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
세션 정보
MainController
- 여기서 세션 정보 확인 해볼거임
- 일시적인 세션이 생성되기 때문에 정보를 가져올 수 있다는 것을 알기 위함
@RestController
public class MainController {
@GetMapping("/")
public String mainP() {
String username = SecurityContextHolder.getContext().getAuthentication().getName();
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority auth = iterator.next();
String role = auth.getAuthority();
return "Main Controller : " + username + " " + role;
}
}
CORS 설정
CORS란?
- CORS(Cross-Origin Resource Sharing)
- 원래 도메인과 다른 도메인에서 요청된 웹페이지 자원에 대해 사용을 허가하는 메커니즘
CORS 설정
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors((cors) -> cors
.configurationSource(new CorsConfigurationSource() {
@Override
public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000"));
configuration.setAllowedMethods(Collections.singletonList("*"));
configuration.setAllowCredentials(true);
configuration.setAllowedHeaders(Collections.singletonList("*"));
configuration.setMaxAge(3600L);
configuration.setExposedHeaders(Collections.singletonList("Authorization"));
return configuration;
}
}));
return http.build();
}
}
@Configuration
public class CorsMvcConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry
.addMapping("/**")
.allowedOrigins("http://localhost:3000");
}
}
참조링크
개발자 유미 - 스프링 시큐리티 JWT