본문 바로가기

Back-End/Spring

SpringSecurity + JWT를 활용한 로그인

이번 시간에는 단일 Access 토큰을 이용하여 전체 시스템이 어떤 식으로 회원가입부터 로그인, JWT 발급 및 이후 요청마다 토큰 검증을 수행하는 전체 흐름을 하나의 실제 코드를 통해 설명합니다.


글의 중심 클래스

  • AuthController: 회원가입과 로그인 엔드포인트

  • AuthService: 회원가입 / 로그인 비즈니스 로직

  • LoginFilter: 스프링 시큐리티 필터(UsernamePasswordAuthenticationFilter 확장)

  • JwtFilter: JWT 유효성 검증을 담당하는 커스텀 필터

  • SecurityConfig: Security 설정 (필터 체인, 인증/인가 정책, 세션 정책 등)

  • CustomUserDetails, CustomUserDetailService: 사용자 정보를 스프링 시큐리티 표준 형태로 제공

  • User 엔티티 ,UserRepository: 실제 DB 컬럼과 연결되는 사용자 엔티티 및 JPA 저장소

코드를 하나씩 살펴보며 흐름 살펴보기

1. 회원가입 흐름

1) AuthController의 /register 매핑

@PostMapping("/register")
public ResponseEntity<String> register(@RequestBody RegisterRequest registerRequest) {
    authService.register(registerRequest);
    return ResponseEntity.ok("성공적으로 가입됨!");
}
  • 클라이언트가 POST /register로 JSON 형태로 요청 값(name, email, password)을 전송하면 RegisterDto로 매핑됩니다.
  • authService.register(registerRequest)를 호출해 회원가입을 진행합니다.

2) AuthService의 register() 메서드

public void register(RegisterRequest registerRequest) {
    boolean isExist = userRepository.existsByEmail(registerRequest.getEmail());
    if (isExist) {
        throw new IllegalStateException("이미 가입된 유저입니다.");
    }

    String encodedPassword = bCryptPasswordEncoder.encode(registerRequest.getPassword());
    User user = User.registerUser(registerRequest, encodedPassword);
    userRepository.save(user);
}
  1. 먼저 DB에 동일 이메일이 이미 존재하는지 확인
  2. 중복이 없다면 비밀번호를 BCryptPasswordEncoder로 해시(암호화) 한 뒤
  3. User.registerUser() 정적 팩토리 메서드에 넘겨 User엔티티 객체를 생성합니다.
  4. 마지막에 생성한 user 객체를 save 하여 DB에 사용자 정보를 저장합니다.

결과적으로 회원가입이 성공하면 "성곡적으로 가입됨!" 이라는 문자열을 반환합니다.


2. 로그인 흐름

1) 코드를 보면 AuthController에 /login 메서드가 있습니다.

@PostMapping("/login")
public ResponseEntity<String> login(@RequestBody LoginRequest loginRequest) {
    authService.login(loginRequest);
    return ResponseEntity.ok("로그인이 성공되었음");
}

하지만 실제로는 스프링 시큐리티 필터 체인에서 설정된 LoginFilter가 /login 요청을 먼저 가로챕니다. 즉 전형적인 시큐리티 구조에서는 컨트롤러의 /login 메서드는 거치지 않고 LoginFilter.attemptAuthentication()가 곧바로 동작할 수 있다고 합니다.

  • 사용자가 /login으로 인증 정보를 보내면 LoginFilter.attemptAuthentication()가 동작해 아이디/비밀번호를 검증하고 성공하면 JWT를 발급하는 구조입니다.
  • 컨트롤러 단의 /login은 사실상 호출되지 않아도 필터에서 처리가 가능하므로 "필터단에서 모든 로그인 로직을 처리한다면 컨트롤러 단의 /login이 꼬 필요한지는 상황에 따라 달리질 수 있다"라고 생각됩니다.

2) LoginFilter 클래스

LoginFilterUsernamePasswordAuthenticationFilter를 상속 받아서 커스텀 인증 로직을 구현한 필터입니다. 보통 시큐리티에서 사용자 인증을 담당하는 가장 앞쪽(UsernamePasswordAuthenticationFilter)에서 동작하게 됩니다.

(1) attemptAuthentication() - 로그인 요청 받기

@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    private final ObjectMapper objectMapper = new ObjectMapper();
    private final JwtUtil jwtUtil;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        try {
            Map<String, String> requestBody = objectMapper.readValue(request.getInputStream(), Map.class);
            String email = requestBody.get("email");
            String password = requestBody.get("password");

            // 시큐리티가 인증을 식별하기 위한 Token 객체 생성
            UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(email, password, null);

            //AuthenticationManager에게 검증을 위해 토큰을 전달
            return authenticationManager.authenticate(authToken);
            
        } catch (IOException e) {
            throw new RuntimeException("요청 포맷 오류");
        }
    }
    1. /login 요청의 바디(JSON)을 objectMapper로 파싱하여 email, password를 꺼냅니다.
    2. 이를 UsernamePasswordAuthenticationToken 객체에 담아

3. authenticationManager.authenticate()에 넘겨서 인증을 진행합니다.

    AuthenticationManager 내부적으로는 UserDetailService.loadUserByUsername()를 호출하여 DB에서 해당 사용자 정보를 가져오고 비밀번호가 맞는지 BCryptPasswordEncoder.mathes() 등으로 검증합니다. 이 과정을 통과하면 인증 성공, 통과하지 못하면 예외가 발생합니다.

2) successfulAuthentication - 인증 성공 후

@Override
protected void successfulAuthentication(
    HttpServletRequest request, HttpServletResponse response,
    FilterChain chain, Authentication authentication
) throws IOException, ServletException {
    
    CustomUserDetails customUserDetails 
        = (CustomUserDetails) authentication.getPrincipal();

    String username = customUserDetails.getUsername();
    String email = customUserDetails.getEmail();

    // 권한 목록 중 첫 번째 권한(ROLE_...) 추출
    Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
    String role = authorities.iterator().next().getAuthority();

    // JWT 발급
    String token = jwtUtil.generateToken(username, role, email);
    // 응답 헤더에 JWT 실어서 반환
    response.addHeader("Authorization", token);
}
  1. 인증이 성공했으므로 스프링 시큐리티 측에서 Authentication 객체를 만들고 그 안에 Principal로 CustomUserDetails를 저장해둡니다.
  2. customUserDetails에는 DB에서 가져온 사용자 정보(User엔티티의 name, email, password, role 등)가 들어 있습니다.
  3. JWT 발급을 위해 jwtUtil.generateToken()에 username, role, email을 넣어 줍니다.
  4. 완성된 JWT를 Authorization 헤더에 담아 반환하면 클라이언트는 응답을 받은 뒤 헤더에서 토큰을 꺼내어 이후 요청에 활용할 수 있습니다.

3) unsuccessfulAuthentication() - 인증 실패시

@Override
protected void unsuccessfulAuthentication(
    HttpServletRequest request, 
    HttpServletResponse response, 
    AuthenticationException failed
) throws IOException, ServletException {
    response.setStatus(401); // Unauthorized
}
  • 비밀번호나 계정정보가 틀리면 예외가 발생하고 여기서 401 상태 코드를 내려줍니다.

3. CustomUserDetailService와 CustomUserDetails의 역할

1) CustomUserDetailService(UserDetailsService 구현)

@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        User userEmail = userRepository.findByEmail(email);
        if (userEmail != null) {
            return new CustomUserDetails(userEmail);
        }
        return null;
    }
}
  • 스프링 시큐리티 내부적으로 인증을 진행할 때 "사용자 정보가 필요해!" 라고 하면 UserDetailsService를 호출합니다.
  • 저는 커스텀 구현체로 CustomUserDetailService를 만들고 loadUserByUsername()에서 DB 조회를 수행했습니다.
  • 결과가 있으면 CustomUserDetails에 넣어서 반환해주고 없으면 null 또는 글로벌 예외를 던지면 됩니다.

2) CustomUserDetails(UserDetails 인터페이스 구현)

public class CustomUserDetails implements UserDetails {

    private final User user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // 예: ROLE_USER, ROLE_ADMIN
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add(() -> "ROLE_" + user.getRole());
        return collection;
    }
    
    // getPassword(), getUsername(), isAccountNonExpired() 등등...
}
  • 스프링 시큐리티는 인증된 사용자 정보를 표준화해서 다루기 위해 UserDetails 인터페이스를 사용합니다.
  • 여기서 User 엔티티를 필드로 갖고 이쏘 그 안에 들어있는 비밀번호, 이름, 권한 등을 꺼내어 표준 메서드로 제공합니다.
  • getAuthorities()에서 "ROLE_" + DB에 저장된 Role 이름을 붙여서 스프링이 인가를 제대로 처리할 수 있도록 합니다.

중요! : 비밀번호 검증 로직은 여기서 하지 않습니다. UserDetails는 단순히 "데이터를 가지고 있는 객체"입니다. 실제로 입력된 비밀번호 == DB의 비밀번호가 맞는지 체크하는 것은 AuthenticationManager -> PasswordEncoder가 담당합니다.


4. JWT 검증 흐름

1) JwtFilter(OncePerRequestFilter)

로그인 성공으로 JWT를 발급받은 이후 클라이언트는 모든 API 요청에 Authorization: Bearer <JWT 토큰> 헤더를 실어서 보냅니다. 서버는 이 토큰이 유효한지 확인해야 하며 그 과정을 담당하는 것이 JwtFilter 입니다.

@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {
    private final JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(
        HttpServletRequest request, 
        HttpServletResponse response, 
        FilterChain filterChain
    ) throws ServletException, IOException {

        String authorizationHeader = request.getHeader("Authorization");

        // 1) 헤더 검사
        if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
            log.info("JWT 토큰이 필요 합니다.");
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "JWT 토큰이 필요 합니다.");
            filterChain.doFilter(request, response);
            return;
        }

        // 2) "Bearer " 부분 제거
        String token = authorizationHeader.substring(7);

        // 3) JWT 유효성 검증
        if (!jwtUtil.validateToken(token)) {
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            response.getWriter().write("{\"error\": \"Unauthorized\"}");
            filterChain.doFilter(request, response);
            return;
        }

        // 4) 토큰 파싱 & 스프링 시큐리티 인증 객체 생성
        String username = jwtUtil.extractUsername(token);
        String role = jwtUtil.extractRoles(token);
        String email = jwtUtil.extractEmail(token);

        // User 엔티티를 임시 생성 (암호는 임시)
        User user = new User(username, email, "temppassword", role);
        CustomUserDetails customUserDetails = new CustomUserDetails(user);

        // 인증 객체(UsernamePasswordAuthenticationToken)를 만들어서 SecurityContext에 저장
        UsernamePasswordAuthenticationToken authToken = new 
            UsernamePasswordAuthenticationToken(
                customUserDetails, null, customUserDetails.getAuthorities()
            );
        SecurityContextHolder.getContext().setAuthentication(authToken);

        // 5) 다음 필터로 진행
        filterChain.doFilter(request, response);
    }
}
  1. 헤더 확인 : Authorization 헤더가 비어 있거나 Bearer 로 시작하지 않으면 인증 불가. 401 반환.
  2. 토큰 파싱 : 문자열 "Bearer "를 떼고 실제 JWT 부분만 추출합니다.
  3. jwtUtil.validateToken(): 서명(Signature), 만료 시간(Expiration) 등을 검증합니다.
  4. 토큰에서 정보 추출: username, role, email 등을 파싱해, CustomUserDetails를 만듭니다.
  5. 시큐리티 컨텍스트 : 스프링에서 인증이 통과되었다는 사실을 공유하려면, SecurityContextHolder에 Authentication 객체를 저장해야 합니다.
  6. 이후 컨트롤러나 다른 비즈니스 로직에서는 SecurityContextHolder.getContext().getAuthentication()을 통해 인증된 사용자 정보를 꺼내 활용할 수 있습니다.

결과적으로 로그인 이후 요청마다 JwtFilter가 토큰을 검사하기 때문에, 세션 없이도(Stateless) 매 요청마다 사용자 인증을 안전하게 확인할 수 있습니다.


5. SecurityConfig로 보는 전체 필터 체인

SecurityConfig 클래스를 통해 어떻게 이 필터들이 순서대로 동작하는지 한눈에 알 수 있습니다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final AuthenticationConfiguration authenticationConfiguration;
    private final JwtUtil jwtUtil;

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

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

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{

        // 1) 기본 설정들: CSRF, formLogin, httpBasic off
        http.csrf(csrf -> csrf.disable());
        http.formLogin(form -> form.disable());
        http.httpBasic(basic -> basic.disable());

        // 2) 요청 권한 부여
        http.authorizeHttpRequests(auth -> auth
            .requestMatchers("/", "/login", "/register").permitAll()
            .requestMatchers("/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated()
        );

        // 3) 필터 등록
        // 3-1) JWT 검증 필터를 LoginFilter보다 먼저 실행
        http.addFilterBefore(new JwtFilter(jwtUtil), LoginFilter.class);

        // 3-2) 우리가 만든 커스텀 로그인 필터를 UsernamePasswordAuthenticationFilter 자리에서 실행
        http.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil),
                UsernamePasswordAuthenticationFilter.class);

        // 4) 세션을 사용하지 않음 (JWT)
        http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return http.build();
    }
}
  1. CSRF, FormLogin, HttpBasic 비활성화 : API + Jwt 조합이라 사용하지 않는 기능들이라 비활성화 했습니다.
  2. 인가 설정 :  "/", "/login", "/register"는 누구나 접근 가능, /admin/"는 ADMIN 권한만 허용, 나머지는 인증 필요
  3.  addFilterBefore(new JwtFilter(jwtUtil), LoginFilter.class);
    •  모든 요청에 대해 JWT 유효성을 먼저 확인합니다.(회원가입과 로그인은 필요 없으니 Filter단에서 넘어가게 함)
  4. 필터 설정
    • addFilterAt(new LoginFilter(...), UsernamePasswordAuthenticationFilter.class)
      • 원래 시큐리티가 제공하는 UsernamePasswordAuthenticationFilter 위치에 커스텀 LoginFilter를 끼워 넣습니다.
    • addFilterAt(new LoginFilter(...), UsernamePasswordAuthenticationFilter.class)
      • 결과적으로 /login 요청 시, LoginFilter에서 사용자 인증 후 JWT를 발급해 줍니다.
  5. 세션 Stateless: 로그인 -> 세션 생성이 아니라 로그인 -> JWT 발급 방식을 사용하므로 세션이 필요 없어서 사용하지 않았습니다.

6. 전체 흐름 정리

1) 회원가입

  1.  클라이언트가 POST /register 요청 (JSON 바디: {name, email, password}).
  2. AuthController.register()AuthService.register() → DB 저장.
  3. 결과로 “성공적으로 가입됨!” 문자열 응답.

2) 로그인 

  1. 클라이언트가 POST /login 요청(JSON 바디: {email, password})
  2. LoginFilter가 이 요청을 가로챔
    1. attemptAuthentication() -> authenticationManager.authenticate() -> UserDetailService.loadUserByUSername() -> DB조회 & 비밀번호 대조
    2. 서옹 시 successfulAuthentication() -> JWT 생성 -> Authorization 헤더에 Bearer <토큰> 담아 응답
  3. 클라이언트는 이후로 발급 받은 JWT를 저장합니다.

3) 인증이 필요한 나머지 API 호출

  1. 클라이언트가 EX. GET /todo 같은 엔드포인트로 요청할 때 HTTP 헤더에 Authorization: Bearer <JWT>를 포함합니다.
  2. 서버 쪽에서 JwtFilter가 매번 요청을 가로채 Authorization 헤더 파싱 -> ㅓㅁ증 -> SecurityContextHolder에 인증 객체 저장
  3. 컨트롤러나 서비스 로직은 SecurityContext에 인증된 사용자 정보가 있는지를 확인해 정상적으로 처리 가능합니다.

7. 시뮬레이션

이해하기 더 쉽게 (이름 : 박병천, password : 1234, 이메일 : genius1788@naver.com, 권한 : ADMIN 사용자)를 대상으로 회원가입 → 로그인 → (JWT 발급) → 어드민 페이지 접근 과정을 구체적인 요청/응답 데이터와 함께 살펴보는 시뮬레이션입니다.

1) 회원가입

(1) 클라이언트 -> 서버 : /register 요청

요청 경로
POST /register
Content-Type: application/json

요청 JSON 바디
{
  "name": "박병천",
  "email": "genius1788@naver.com",
  "password": "1234"
}
  1.  

여기서는 실제로 권한을 직접 입력하지 않았고 서버 측에서 기본값 USER나 ADMIN을 셋팅하는 방식으로 가정했습니다.

(2)서버 내부 : 회원가입 로직 흐름
1. AuthController.register -> AuthService.register를 통해
2. DB 저장

id      name      email                   password(BCrypt)                role
------------------------------------------------------------------------------------
1       박병천    genius1788@naver.com    $2a$10$... (BCrypt된 "1234")    ADMIN


(3) 결과 응답

HTTP/1.1 200 OK
"성공적으로 가입됨!"

여기까지 박병천(ADMIN 계정)이 회원가입 되었습니다.


2) 로그인(Login) & JWT 발급

(1) 클라이언트 -> 서버 : /login 요청

이 시나리오에서는 스프링 시큐리티에서 LoginFilter(UsernamePasswordAuthenticationFilter)가 /login 경로를 가로채 처리하도록 되어 있습니다.

요청 경로
POST /login
Content-Type: application/json

요청 JSON 바디
{
  "email": "genius1788@naver.com",
  "password": "1234"
}


(2) 서버 내부 : LoginFilter 동작

@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
        throws AuthenticationException {

    // 1) JSON 파싱
    Map<String, String> requestBody = objectMapper.readValue(request.getInputStream(), Map.class);
    String email = requestBody.get("email");     // "genius1788@naver.com"
    String password = requestBody.get("password"); // "1234"

    // 2) 스프링 시큐리티에서 인증을 표현하는 토큰 생성
    UsernamePasswordAuthenticationToken authToken 
        = new UsernamePasswordAuthenticationToken(email, password, null);

    // 3) AuthenticationManager에게 검증 위임 (여기서 DB 조회와 비밀번호 대조 진행)
    return authenticationManager.authenticate(authToken);
}
  • email과 password를 읽어 토큰(authToken)을 만든 뒤 AuthenticationManager에게 넘김니다.

(2.1) 내부 인증 매니저/서비스

  • AuthenticationManager내부적으로 CustomUserDetailService.loadUserByUsername()를 호출 → UserRepository.findByEmail("genius1788@naver.com") → User 엔티티 조회.
  • 비밀번호 1234와 DB의 BCrypt 해시가 BCryptPasswordEncoder.matches()로 비교되어 일치하면 인증 성공.

(3) 서버 내부 : 인증 성공(successfulAuthentication()) -> JWT 생성

@Override
protected void successfulAuthentication(
    HttpServletRequest request,
    HttpServletResponse response,
    FilterChain chain,
    Authentication authentication
) throws IOException, ServletException {
    // 1) 인증 성공하면 스프링 시큐리티가 Authentication 객체를 만들어 둠
    CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();

    // 2) userDetails에서 데이터 꺼내기
    String username = userDetails.getUsername(); // DB의 user.getName(), 여기서는 "박병천"
    String email = userDetails.getEmail();       // "genius1788@naver.com"
    String role = userDetails.getAuthorities().iterator().next().getAuthority(); 
    // -> "ROLE_ADMIN"

    // 3) JWT 생성
    String token = jwtUtil.generateToken(username, role, email);
    // 예: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

    // 4) 응답 헤더에 토큰 실어서 반환
    response.addHeader("Authorization", token);
}

(4) 클라이언트가 받게 될 응답

HTTP/1.1 200 OK
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

3) 어드민 페이지 접근(ADMIN 페이지)

이제 박병천(ADMIN 계정)이 로그인 후 특정 어드민 전용 API(예: GET /admin/info)에 접근하려는 상황

(1) 클라이언트 -> 서버 : /admin/info 요청

클라이언트가 아래처럼 JWT를 헤더에 첨부해서 요청합니다.

GET /admin/info
Host: localhost:8080
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiLsnbjs...
  • 이 토큰은 이전 로그인 응답에서 받았던 Authorization 헤더 값을 그대로 전달한 것입니다.

(2) 서버 내부 : JwtFilter에서 토큰 검증

  • 정상 토큰이면 ADMIN 권한을 포함한 Authentication 객체가 시큐리티 컨텍스트에 세팅되므로 이후의 컨트롤러나 서비스 로직은 현재 로그인 사용자 = 박병천, 권한 = ADMIN 이라는 사실을 인식할 수 있습니다.

(3) SecurityConfig에서 권한 체크

http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/", "/login", "/register").permitAll()
    .requestMatchers("/admin/**").hasRole("ADMIN")
    .anyRequest().authenticated());
  • /admin/** 경로는 hasRole("ADMIN")로 제한했으므로 토큰에 ROLE_ADMIN 권한이 있어야 접근이 ㅏ능합니다.


(4) AdminController

@RestController
@RequestMapping("/admin")
public class AdminController {

    @GetMapping("/info")
    public String getAdminInfo() {
        // ADMIN 권한이 있는 사용자만 접근 가능
        return "관리자 페이지에 오신 것을 환영합니다!";
    }
}


(5) 응답

HTTP/1.1 200 OK
"관리자 페이지에 오신 것을 환영합니다!"

8. 컨트롤러에서 인증된 사용자 정보를 얻는 법과 권한 체크 하는 법

1) 인증된 사용자 정보를 얻는 법

  • 컨트롤러나 서비스 단에서 사용자 정보를 꺼내오는 법
    스프링 시큐리티는 필터에서 토큰 검증이 끝나면
UsernamePasswordAuthenticationToken authToken
    = new UsernamePasswordAuthenticationToken(
          customUserDetails, null, customUserDetails.getAuthorities()
      );
SecurityContextHolder.getContext().setAuthentication(authToken);

이와 같이 인증된 객체(Authentication)를 SecurityContext에 보관합니다.
이후 아무 컨트롤러(또는 서비스)에서든 아래 방식으로 현재 인증 객체(principal)를 꺼낼 수 있습니다.

Authentication auth = SecurityContextHolder.getContext().getAuthentication();
CustomUserDetails userDetails = (CustomUserDetails) auth.getPrincipal();
  • userDetails 안에 getUsername(), getEmail(), getAuthorities() 등 모두 들어 있습니다.

그럼 Ex. 컨트롤러 유저 정보를 얻는 메서드 안에서 인증된 유저 정보를 얻어와보자!

@GetMapping("/api/userinfo")
public String getUserInfo() {
    // 1) 인증 객체 꺼내기
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();

    // 2) CustomUserDetails로 캐스팅
    CustomUserDetails userDetails = (CustomUserDetails) auth.getPrincipal();

    // 3) 필요한 정보 사용
    String username = userDetails.getUsername();
    String role = userDetails.getAuthorities().iterator().next().getAuthority();
    // ...
    return "Welcome " + username + ", role = " + role;
}

그럼 궁금증이 생길 수 있습니다.

Q1 : "아니 그럼 메서드마다 저런식으로 반복 코드를 계속 써줘야하는것인가? 너무 귀찮은데?"
A1 :
스프링 시큐리티가 제공하는 @AuthenticationPrincipal 애노테이션을 쓰면 컨트롤러 메서드 파라미터로 현재 인증 객체(Principal)를 바로 주입 받을 수 있습니다. 하지만 스프링 시큐리티에 종속적인 애노테이션

Q2 : " 공통적으로 사용할 수 있게 필드에 둘까?"
A2 : 필드로 SecurityContextHolder를 두면 안됩니다! 컨트롤러는 싱클톤으로 만들어지기 때문에 멀시트레드 환경에서 ContextHolder의 필드 값이 어떻게 바뀔지 모릅니다!!

2)권한 체크 하는 법

컨트롤러를 만들다 보면 우리가 SecurityConfig에서 URI 별로 권한 체크를 해줬지만 공통 컨트롤러에서 특정 권한만 있는 유저만 특정 메서드에 실행 가능하게 설정 하고싶을 수 있습니다.

먼저 SecurityConfig에서 메서드 수준의 보안 기능 활성화를 위해 애노테이션 추가

@Configuration
@EnableWebSecurity
@EnableMethodSecurity   // ← 메서드 수준의 보안 기능 활성화
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // 기존 Security 설정...
        return http.build();
    }
}
@RestController
@RequestMapping("/api")
public class SampleController {

    // 아무 권한 없이도 접근 가능
    @GetMapping("/public")
    public String publicApi() {
        return "누구든 접근 가능";
    }

    // USER 권한이 있어야만 접근 가능 (예: ROLE_USER)
    @GetMapping("/user-page")
    @PreAuthorize("hasRole('USER')")
    public String userApi() {
        return "USER 권한이 있는 사용자만 접근 가능";
    }

    // ADMIN 권한이 있어야만 접근 가능 (예: ROLE_ADMIN)
    @GetMapping("/admin-page")
    @PreAuthorize("hasRole('ADMIN')")
    public String adminApi() {
        return "ADMIN 권한이 있는 사용자만 접근 가능";
    }
}
  • @PreAuthorize("hasRole('ADMIN')") -> 현재 인증된 사용자의 권한 목록에 ROLE_ADMIN이 포함되어 있어야만 실행됩니다.
  • @PreAuthorize는 표현식을 지원하기 때문에 hasAnyRole('USER', 'ADMIN'), #username == authentication.name 같은 복잡한 조건도 가능합니다.

9. 독자들이 글을 읽으면서 궁금해 할 수도 있겠다 하는 것들

이 파트에서는 제가 문서와 영상을 참조하며 구현하면서 궁금해 했던 것들을 정리해보겠습니다. 아실 분들도 있을테지만 모르시는 분들을 위해 작성해봅니다.


1) AuthenticationManager가 인터페이스에 등록된 UserDetailsService의 구현부를 찾아서 loadUserByUnsername()을 호출해 DB에서 사용자 조회를 하고 조회된 User엔티티를 UserDetails로 감싸서 반환 하고 Jwtfilter 클래스에도 넘어온 토큰 정보를 추출하고 User 엔티티를 새로 만들고 또 UserDetails에 넣는게 있던데.. 뭐지..? 왜 이렇게 중복되는 것처럼 보이는 로직이 나오는거지?? 생각할 수 있습니다.

이게 사실 로그인 단계JWT 필터를 통한 인증 유지 단계가 섞여서 혼동이 생길 수 있습니다. 사실 이 둘은 서로 다른 흐름입니다!!
1.1) 로그인 흐름

1.1.1) AuthenticationManager가 로그인 처리

  • 사용자가 /login에 아이디/비번을 입력하여 요청을 보내면 LoginFilter(UsernamePasswordAuthenticationFilter)가 이를 가로챕니다.
  • 내부적으로 AuthenticationManager -> UserDetailsService.loadUserByUsername() 를 호출해 DB에서 사용자 조회 -> 조회된 User 엔티티를 CustomUserDetails 인자에 넣고 반환 -> 스프링 시큐리티가 반환된 CustomUserDetails를 이용해 비밀번호 검증을 수행
  • 인증에 성공하면 LoginFilter.successfulAuthentication()에서 JWT를 발급해 클라이언트에게 반환.
  • 결론적으로 여기서의 UserDetails는 (로그인 전용) 이 사용자가 정말 존재하고, 비밀번호가 맞는지를 확인하는 과정입니다.

1.2) JWT 필터 흐름(로그인 이후)

1.2.1) 각 요청마다 토큰 검증

  • 로그인이 이미 완료된 사용자는 JWT를 헤더에 달고 요청을 보냅니다.
  • 서버 측에서 JwtFilter.doFilterInternal()이 모든 요청을 가로채 토큰이 유효한지 검사
  • 그리고 UsernamePasswordAuthenticationToken에 담아 SecurityContextHolder에 세팅("로그인된 사용자" 라고 시큐리티에 알려줌, 로그인 이후 매 요청마다 일어남)
  • 결론적으로 토큰에 담긴 정보만 확인 후 임시로 User 객체를 만든 다음 다시 CustomUserDetails에 감싸서 인증 객체를 구성합니다. 
  • 그래서 로그인에서 이미 인증된 사용자JWT로부터 사용자 이름, 권한 정보를 추출 후 별도의 DB 확인 없이 토큰 안의 정보만으로 new User(...) -> new CustomUSerDetails(...)  이로써 시큐리티 컨텍스트에 인증 세팅하고 컨텍스트를 통해 컨트롤러/ 서비스 접근을 하는 것이다.

2) 아니 글 읽는데 인증 객체 Authentication 이랑 인증 객체 Principal은 뭐야 헷갈리게..

https://1000-e.tistory.com/103?category=1208407 확인!


10. 구현 중 트러블 슈팅이 났던 상황

//CustomUserDetails 클래스

@Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return "ROLE_" + user.getRole();
            }
        });
        return collection;
    }

권한을 얻는 과정에서 return user.getRole()를 했는데 처음에는 403 예외가 계속 나왔습니다. 계속 왜 그런가 싶었는데
스프링 시큐리티에서는 Role은 "ROLE_" 접두어로 구분하는 것이 기본 정책 입니다.

  • hasRole("ADMIN") > 실제 내부적으로는 hasAuthority("ROLE_ADMIN") 검사
  • hasRole("USER") > 실제 내부적으로는 hasAuthority("ROLE_USER") 검사

그래서 제 코드에서는 "ROLE_" 없이 return "ADMIN"이라고 하면 시큐리티는 그걸 단순 권한이라고만 인시하고 역할로는 보지 않습니다. 즉 hasAuthority("ADMIN")로 체크하면 통과할 수 있지만 hasRole("ADMIN")로 체크하면 통과하지 못하고 에러가 발생합니다.


앞으로 더 추가될 것

현재는 Access 단일 토큰만 사용했는데 이것은 위험하기 때문에 다음에는 AccessToken과 RefreshToken을 사용하여 Refresh 토큰은 Redis에 저장하는 식으로 진행하며 로그아웃 기능까지 구현해 볼 생각입니다!