[TIL] 20241008 Spring Security

ekkkyo_j·2024년 10월 8일

[내배캠 Java 6기] TIL

목록 보기
29/38

1. 인증/인가

인증(Authentication)

: 로그인

인가(Authorization)

: 권한

2. HTTP

stateless & connectionless
: http 요청들은 서로 독립적이다
=> 따라서, 다른 요청들이 어떤 정보를 갖고 있는지, 어떤 행위를 했는지 알 수 없다.

💡 HTTP의 이러한 statelss 특징을 극복하기 위해 JWT 데이터 덩어리를 들고 다니는 것이다.

그렇다면 로그아웃은?
토큰을 삭제하면, 서버에 요청을 보내지 않아도 로그아웃 됨.
근본적으로 서버에서 할 수 있는 방법은 없다.

3. JWT 특징

Stateless

상태가 없다. 누가? 서버가

서버가 무상태라는 것이다.

상태 == 데이터(ex)유저정보)

JWT 안에 모든 정보가 들어있기 때문에, 서버는 아무것도 몰라도 된다.
== 상태를 가지고 있지 않다.

서버는 무하한 확장이 가능하다.

어떤 코드가 맞을까?

1번

    @Transactional
    public ManagerSaveResponse saveManager(AuthUser authUser, long todoId, ManagerSaveRequest managerSaveRequest) {
        // 일정을 만든 유저
        User user = User.fromAuthUser(authUser);
        Todo todo = todoRepository.findById(todoId)
                .orElseThrow(() -> new InvalidRequestException("Todo not found"));

        if (todo.getUser() == null || !ObjectUtils.nullSafeEquals(user.getId(), todo.getUser().getId())) {
            throw new InvalidRequestException("담당자를 등록하려고 하는 유저가 유효하지 않거나, 일정을 만든 유저가 아닙니다.");
        }

        User managerUser = userRepository.findById(managerSaveRequest.getManagerUserId())
                .orElseThrow(() -> new InvalidRequestException("등록하려고 하는 담당자 유저가 존재하지 않습니다."));

        if (ObjectUtils.nullSafeEquals(user.getId(), managerUser.getId())) {
            throw new InvalidRequestException("일정 작성자는 본인을 담당자로 등록할 수 없습니다.");
        }

        Manager newManagerUser = new Manager(managerUser, todo);
        Manager savedManagerUser = managerRepository.save(newManagerUser);

        return new ManagerSaveResponse(
                savedManagerUser.getId(),
                new UserResponse(managerUser.getId(), managerUser.getEmail())
        );
    }

2번

    @Transactional
    public ManagerSaveResponse saveManager(AuthUser authUser, long todoId, ManagerSaveRequest managerSaveRequest) {
        // 일정을 만든 유저
        User user = User.findById(authUser.getId())
                  .orElseThrow(() -> new InvalidRequestException("User not found"));
        Todo todo = todoRepository.findById(todoId)
                .orElseThrow(() -> new InvalidRequestException("Todo not found"));

        if (todo.getUser() == null || !ObjectUtils.nullSafeEquals(user.getId(), todo.getUser().getId())) {
            throw new InvalidRequestException("담당자를 등록하려고 하는 유저가 유효하지 않거나, 일정을 만든 유저가 아닙니다.");
        }

        User managerUser = userRepository.findById(managerSaveRequest.getManagerUserId())
                .orElseThrow(() -> new InvalidRequestException("등록하려고 하는 담당자 유저가 존재하지 않습니다."));

        if (ObjectUtils.nullSafeEquals(user.getId(), managerUser.getId())) {
            throw new InvalidRequestException("일정 작성자는 본인을 담당자로 등록할 수 없습니다.");
        }

        Manager newManagerUser = new Manager(managerUser, todo);
        Manager savedManagerUser = managerRepository.save(newManagerUser);

        return new ManagerSaveResponse(
                savedManagerUser.getId(),
                new UserResponse(managerUser.getId(), managerUser.getEmail())
        );
    }

어떤 코드가 더 좋은 코드일까요?

이 상황에서는 1번이 맞습니다.

JWT로 기껏 Stateless로 구현을 해놓고, id를 직접 참조하고 있습니다.

서버는 유저의 정보를 몰라도 되기 때문에,

User user = User.fromAuthUser(authUser);

로 유저를 찾아봅시당!

여기서는 JWT에서만 꺼내서 확인하면 안됩니다.

@RestController
@RequiredArgsConstructor
public class UserAdminController {

    private final UserAdminService userAdminService;

    @PatchMapping("/admin/users/{userId}")
    public void changeUserRole(@PathVariable long userId, @RequestBody UserRoleChangeRequest userRoleChangeRequest) {
        userAdminService.changeUserRole(userId, userRoleChangeRequest);
    }
}

왜그럴까요?

A,B 유저가 있습니다. 둘다 ADMIN 권한을 갖고 있습니다.

A와 B는 모두 로그인 상태입니다. => JWT 갖고 있음.
B가 A의 권한을 USER 권한으로 바꾸었습니다.
그런데, A는 아직 로그아웃을 하지 않았기 때문에, ADMIN 권한을 포함한 JWT를 갖고 있습니다.
따라서, 추가적으로 A의 userRole을 DB에서 확인하지 않으면, A는 ADMIN전용 API를 사용할 수 있습니다.

4. Spring Security

JWT가 폭넓게 사용된 시점은 2015년
Spring Security의 개발이 시작된 시점은 2003년이다.

따라서, 어쩔수 없이 기존의 세션 방식과 JWT가 결합된 Spring Security 사용방법이 퍼지게 된다.

코드를 봐봅시다.

UserDetailsServiceImpl

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    public UserDetailsServiceImpl(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
		
		@Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("Not Found " + username));

        return new UserDetailsImpl(user);
    }
}

JWT Filter

필터 내에서 위의 UserDetailsServiceImpl을 주입 받고 있다.

@Slf4j(topic = "JWT 검증 및 인가")
public class JwtAuthorizationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final UserDetailsServiceImpl userDetailsService;

    public JwtAuthorizationFilter(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService) {
        this.jwtUtil = jwtUtil;
        this.userDetailsService = userDetailsService;
    }

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

        String tokenValue = jwtUtil.getTokenFromRequest(req);

        if (StringUtils.hasText(tokenValue)) {
            // JWT 토큰 substring
            tokenValue = jwtUtil.substringToken(tokenValue);
            log.info(tokenValue);

            if (!jwtUtil.validateToken(tokenValue)) {
                log.error("Token Error");
                return;
            }

            Claims info = jwtUtil.getUserInfoFromToken(tokenValue);

            try {
                 setAuthentication(info.getSubject());
            } catch (Exception e) {
                log.error(e.getMessage());
                return;
            }
        }

        filterChain.doFilter(req, res);
    }

    // 인증 처리
    public void setAuthentication(String username) {
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        Authentication authentication = createAuthentication(username);
        context.setAuthentication(authentication);

        SecurityContextHolder.setContext(context);
    }

    // 인증 객체 생성
    private Authentication createAuthentication(String username) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
    }
}

인증 객체를 생성하면서 직접 DB에 접근(loadUserByUsername으로)한다.

JWT에 유저의 정보가 존재하는데 DB에 직접 접근한다?

그럼 JWT가 왜 필요할까??

따라서, JWT 토큰을 온전히 사용하는 코드를 아래에서 설명하겠다.

Spring Security 사용한 코드

JwtSecurityFilter.java

package org.example.expert.config;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.expert.domain.common.dto.AuthUser;
import org.example.expert.domain.user.enums.UserRole;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtSecurityFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(
            HttpServletRequest httpRequest,
            @NonNull HttpServletResponse httpResponse,
            @NonNull FilterChain chain
    ) throws ServletException, IOException {

        String requestURI = httpRequest.getRequestURI();

        // /auth 경로는 필터링하지 않음
        if (requestURI.startsWith("/auth")) {
            chain.doFilter(httpRequest, httpResponse);
            return;
        }

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

        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            String jwt = jwtUtil.substringToken(authorizationHeader);
            try {
                Claims claims = jwtUtil.extractClaims(jwt);
                Long userId = Long.valueOf(claims.getSubject());
                String email = claims.get("email", String.class);

                UserRole userRole = UserRole.of(claims.get("userRole", String.class));
                log.info("Role :" + userRole);
                String nickname = claims.get("nickname", String.class);

                if (SecurityContextHolder.getContext().getAuthentication() == null) {
                    AuthUser authUser = new AuthUser(userId, email, userRole, nickname);
                    log.info("Role :" + authUser.getAuthorities());

                    JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(authUser);
                    authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpRequest));
                    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
                }

                // 관리자 권한이 필요한 경로 검증
                if (requestURI.startsWith("/admin") && !UserRole.ROLE_ADMIN.equals(userRole)) {
                    log.warn("Unauthorized admin access attempt by user: {}", email);
                    httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN, "관리자 권한이 필요합니다.");
                    return;
                }

            } catch (SecurityException | MalformedJwtException e) {
                log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e);
                httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다.");
            } catch (ExpiredJwtException e) {
                log.error("Expired JWT token, 만료된 JWT token 입니다.", e);
                httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다.");
            } catch (UnsupportedJwtException e) {
                log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e);
                httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다.");
            } catch (Exception e) {
                log.error("Internal server error", e);
                httpResponse.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
            }
        }
        chain.doFilter(httpRequest, httpResponse);
    }
}

JwtAuthenticationToken.java

package org.example.expert.config;


import org.example.expert.domain.common.dto.AuthUser;
import org.springframework.security.authentication.AbstractAuthenticationToken;

public class JwtAuthenticationToken extends AbstractAuthenticationToken {

    private final AuthUser authUser;

    public JwtAuthenticationToken(AuthUser authUser) {
        super(authUser.getAuthorities());
        this.authUser = authUser;
        setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return null;
    }

    @Override
    public Object getPrincipal() {
        return authUser;
    }
}

SecurityConfig.java

package org.example.expert.config;

import lombok.RequiredArgsConstructor;
import org.example.expert.domain.user.enums.UserRole;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter;

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
public class SecurityConfig {

    private final JwtSecurityFilter jwtSecurityFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // SessionManagementFilter, SecurityContextPersistenceFilter
                )
                .addFilterBefore(jwtSecurityFilter, SecurityContextHolderAwareRequestFilter.class)
                .formLogin(AbstractHttpConfigurer::disable) // UsernamePasswordAuthenticationFilter, DefaultLoginPageGeneratingFilter 비활성화
                .anonymous(AbstractHttpConfigurer::disable) // AnonymousAuthenticationFilter 비활성화
                .httpBasic(AbstractHttpConfigurer::disable) // BasicAuthenticationFilter 비활성화
                .logout(AbstractHttpConfigurer::disable) // LogoutFilter 비활성화
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/auth/signin", "/auth/signup").permitAll()
                        .requestMatchers("/test").hasAuthority(UserRole.Authority.ADMIN)
                        .anyRequest().authenticated()
                )
                .build();
    }
}

일단, spring security를 쓰고 싶은 경우 이 세 코드를 적고,
build.gradle dependencies에

    implementation 'org.springframework.boot:spring-boot-starter-security'

를 추가해주면된다.

😭 하면서 직면한 에러

  1. AuthUser의 Role null 값이래

일단, 코드가 너무 복잡해서 모두 이해하지는 못하였다. 추가해야하는, userRole enum도 있었고,

UserRole.java

package org.example.expert.domain.user.enums;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;

@Getter
@RequiredArgsConstructor
public enum UserRole {

    ROLE_USER(Authority.USER),
    ROLE_ADMIN(Authority.ADMIN);

    private final String userRole;

    public static UserRole of(String role) {
        return Arrays.stream(UserRole.values())
                .filter(r -> r.name().equalsIgnoreCase(role))
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("유효하지 않은 UserRole"));
    }

    public static class Authority {
        public static final String USER = "ROLE_USER";
        public static final String ADMIN = "ROLE_ADMIN";
    }
}

회원가입/로그인이 돼서 토큰도 잘 나오는데, 할일을 생성하거나 다른 API에 접근할 때 403 에러가 발생하면서, AuthUser의 userRole이 null이라고 nullpointexception exception이 발생하였다. log로 jwt에서 role도 찍어보고 AuthUser에 저장하고 난 후 role도 찍어봤는데 다 잘 나왔다.

그런데, 계속 이 에러가 발생해서 튜터님께 여쭤봤는데, 필터가 여러번 돌면서 null값이 들어가는 것이라고 하셨다.

하지만, JwtSecurityFilter는 OncePerRequestFilter를 상속받기 때문에 필터를 한번만 거친다. 그래서

             if (SecurityContextHolder.getContext().getAuthentication() == null) {
                    AuthUser authUser = new AuthUser(userId, email, userRole, nickname);
                    log.info("Role :" + authUser.getAuthorities());

                    JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(authUser);
                    authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpRequest));
                    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
                }

이 if문 안에 return;을 해주었는데, 200 ok가 떴다.

성공!

인줄 알았으나, 200 ok 코드만 뜨고, DB에 저장이 안되는 것이다.

  1. 200 오케이잖아 왜 DB에 저장이 안되니

9시 지나서 팀원분이랑 전에 같이 팀했던 분들과 얘기하다가 내가 이 문제를 직면하고 말이 없어지니, 같이 보자고 하셨다.. 천사들,,

알고보니 Spring Security를 사용하려면, 이 AuthUser를 사용할 때 앞에 @Auth를 사용하는 것이 아니라 @AuthenticationPrincipal을 해주어야한다. 예시로,

    @Transactional
    @PostMapping("/todos")
    public ResponseEntity<TodoSaveResponse> saveTodo(
            @AuthenticationPrincipal AuthUser authUser,
            @Valid @RequestBody TodoSaveRequest todoSaveRequest
    ) {
        return ResponseEntity.ok(todoService.saveTodo(authUser, todoSaveRequest));
    }

했는데, 또 200 ok뜨고 DB에 저장이 안되는 것이다.

그래서 위에서 해줬던 return;을 지워줬더니 잘됐다!

profile
게으르지만 성실히 공부하기^_^

0개의 댓글