SpringBoot3 Security 설정 이해해보기 (w.ProblemDetail)

최준호·2023년 5월 31일
6

Appling

목록 보기
2/12
post-thumbnail

📗 SrpingBoot3 Security를 왜??

SpringBoot3로 넘어오면서 2.x까지 사용하던 WebSecurityConfigurerAdapter가 삭제되었다. 그에 따라 모든 설정하는 방법이 변경되었는데 해당 부분을 확인해보려고 한다.

📄 잘쓰던 WebSecurityConfigurerAdapter 왜 빼냐;;

기존에 SpringSecurity 구성을 WebSecurityConfigurerAdapter를 extends 후 override하여 구현했다.

하지만 이렇게 구현하다보니 해당 부분에 사용되는 구현체들이 어떻게 구현되고 있는지 또 명시적으로 보기가 힘들었다. 마치 set처럼 override하여 구현하면 자동으로 설정값이 들어가는거 처럼 구현이 되어 있었기 때문에!

하지만 이제는 해당 구현체를 직접 구현한 후 @Bean으로 등록하여 사용하는 방식으로 변경되었다.

(Spring 공식 문서) Spring Security without the WebSecurityConfigurerAdapter

📄 Spring3 Security 설정

import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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;

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
    private final TokenProvider tokenProvider;
    private final CustomEntryPoint entryPoint;
    private final CustomAccessDeniedHandler accessDeniedHandler;


    private static final String[] DEFAULT_LIST = {
            "/docs.html"
    };

    private static final String[] WHITE_LIST = {
            "/api/auth/**"
    };

    private static final String[] SELLER_LIST = {
            "/api/v2/**"
    };


    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
        http
                .csrf(c -> c.disable())
                .cors(c -> c.disable())
                .headers(c -> c.frameOptions(f -> f.disable()).disable())
                .authorizeHttpRequests(auth -> {
                    try{
                        auth
                                .requestMatchers(WHITE_LIST).permitAll()
                                .requestMatchers(DEFAULT_LIST).permitAll()
                                .requestMatchers(PathRequest.toH2Console()).permitAll()
                                .requestMatchers(SELLER_LIST).hasRole("SELLER")
                                .anyRequest().authenticated()
                        ;
                    }catch (Exception e){
                        e.printStackTrace();
                    }
                }).exceptionHandling(c ->
                        c.authenticationEntryPoint(entryPoint).accessDeniedHandler(accessDeniedHandler)
                ).sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .apply(new JwtSecurityConfig(tokenProvider))
        ;
        return http.build();
    }

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

일단 내가 적용한 설정은 다음과 같다. 하나씩 뜯어보자!

✅ SecurityFilterChain

기존에는 WebSecurityConfigurerAdapter를 extend하여 configure를 override하여 설정을 구현하였다. 하지만 SpringBoot3 에서부터는 SecurityFilterChain을 @Bean으로 등록하여야 한다.

✅ csrf, cors

api로 사용할 것이기 때문에 csrf(html 해킹 공격)는 받을 수가 없다... 그래서 따로 필요가 없기 때문에 disabled() 처리 해주어야 하는데 기존 방식은 deprecated되었지만 그래도 아직 사용은 가능한데... 소스자체에 빨간색이 자꾸 뜨고 warn 표시가 자꾸 뜬다.

👉 csrf

csrf().disabled()

에서

csrf(c -> c.disabled())

로 변경되었다. 이제 이 부분만 이해하면 쉬워지는데 기존에 체이닝하여 설정이 적용되던 방법을 Customizer라는 객체에 직접 disabled() 처리해주는 방식으로 변경되었다.

👉 headers

cors().disable();

에서

cors(c -> c.disable())

👉 headers

headers().frameOptions().disable();

에서

headers(c -> c.frameOptions(f -> f.disable()).disable())

h2-console을 사용하기 위해 설정된 값이다.

✅ authorizeRequests

url 요청을 권한에 따라 분리할 수 있는데

http.authorizeRequests().antMatchers("/**").permitAll()
			.and()
			.logout()
			.logoutSuccessUrl("/")
			.and()
			.oauth2Login()
			.successHandler(oauth2SuccessHandler)
			.userInfoEndpoint()
			.userService(oauth2UserService);

에서

.authorizeHttpRequests(auth -> auth
			.requestMatchers(WHITE_LIST).permitAll()               
			.requestMatchers(DEFAULT_LIST).permitAll()
			.requestMatchers(PathRequest.toH2Console()).permitAll()
			.requestMatchers(SELLER_LIST).hasRole("SELLER")
			.anyRequest().authenticated()
)

해당 부분도 구현 방법만 달라졌지 거의 동일하다.

✅ ExceptionHandling

그리고 401(Unauthorized), 403(Forbidden)에 대한 Exception Handling을 위해 CustomEntryPoint CustomAccessDeniedHandler 두개의 클래스를 구현했다.

CustomEntryPoint

import com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.HttpStatus;
import com.juno.appling.domain.dto.ErrorDto;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;
import java.io.PrintWriter;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

@Component
public class CustomEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        List<ErrorDto> errors = new ArrayList<>();
        errors.add(ErrorDto.builder().point("ACCESS TOKEN / REFRESH TOKEN").detail("please check request token").build());

        ProblemDetail pb = ProblemDetail.forStatusAndDetail(HttpStatusCode.valueOf(HttpStatus.SC_FORBIDDEN), "FORBIDDEN");
        pb.setType(URI.create("/docs.html"));
        pb.setProperty("errors", errors);
        pb.setInstance(URI.create(request.getRequestURI()));
        ObjectMapper objectMapper = new ObjectMapper();

        PrintWriter writer = response.getWriter();
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        writer.write(objectMapper.writeValueAsString(pb));
    }
}

CustomAccessDeniedHandler

import com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.HttpStatus;
import com.juno.appling.domain.dto.ErrorDto;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;
import java.io.PrintWriter;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        List<ErrorDto> errors = new ArrayList<>();
        errors.add(ErrorDto.builder().point("UNAUTHORIZED").detail("unauthorized token").build());

        ProblemDetail pb = ProblemDetail.forStatusAndDetail(HttpStatusCode.valueOf(HttpStatus.SC_UNAUTHORIZED), "UNAUTHORIZED");
        pb.setType(URI.create("/docs.html"));
        pb.setProperty("errors", errors);
        pb.setInstance(URI.create(request.getRequestURI()));
        ObjectMapper objectMapper = new ObjectMapper();

        PrintWriter writer = response.getWriter();
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        writer.write(objectMapper.writeValueAsString(pb));
    }
}

위 두 에러 핸들링은 SpringBoot3에서 제공하는 ProblemDetail을 사용해보았다. 원래는 에러를 내리기 위해서 따로 Dto를 작성하여 사용하고 있었는데 rfc 7807 규격을 따라 작성된다고 하여 이번 기회에 적용해보았다.

세계적으로 에러 핸들링 규격을 명시해놓은 것이다. 해당 부분을 사용하면 따로 에러 Dto에 대해 고민하지 않아도 되서 좋았다.
단점으로는 세계적 규격이지만 세계적으로 잘 사용을 안한다고 한다...
그래도 개인적으로 사용해보니 괜찮다는 느낌을 많이 받았다! set을 사용하는건 좀 그랬지만...ㅎ;;

✅ apply

마지막에 apply({Filter})를 넣어서 api 방식으로 jwt 토큰을 사용하여 토큰을 반환하도록 만들었다.

해당 부분도 살펴보면

JwtSecurityConfig

import lombok.RequiredArgsConstructor;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    private final TokenProvider tokenProvider;

    @Override
    public void configure(HttpSecurity builder) throws Exception {
        AuthFilter authFilter = new AuthFilter(tokenProvider);
        builder.addFilterBefore(authFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

SecurityConfigurerAdapter를 상속하고 configure()를 override하여 내가 만든 AuthFilter를 UsernamePasswordAuthenticationFilter로써 작동할 수 있도록 추가해준다.


AuthFilter

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

import static org.springframework.http.HttpHeaders.AUTHORIZATION;

@Slf4j
@RequiredArgsConstructor
public class AuthFilter extends OncePerRequestFilter {
    private final TokenProvider tokenProvider;
    private final String BEARER_PREFIX = "Bearer ";

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        log.debug("인증처리 시작");
        String jwtToken = resolveToken(request);
        log.debug("토큰 권한 Security 저장");

        // 2. validateToken 으로 토큰 유효성 검사
        // 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장
        if (StringUtils.hasText(jwtToken) && tokenProvider.validateToken(jwtToken)) {
            Authentication authentication = tokenProvider.getAuthentication(jwtToken);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    // Request Header 에서 토큰 정보를 꺼내오기
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(BEARER_PREFIX.length());
        }
        return null;
    }
}

OncePerRequestFilter를 상속하여 doFilterInternal()을 override하였고


TokenProvider

import com.juno.appling.domain.vo.member.LoginVo;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;

@Slf4j
@Component
public class TokenProvider {
    private static final String AUTHORITIES_KEY = "auth";
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30;            // 30분
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;  // 7일

    private final Key key;

    public TokenProvider(@Value("${jwt.secret}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    public LoginVo generateTokenDto(Authentication authentication) {
        // 권한들 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = createAccessToken(authentication.getName(), authorities, accessTokenExpiresIn);

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

        return LoginVo.builder()
                .type("Bearer ")
                .accessToken(accessToken)
                .accessTokenExpired(accessTokenExpiresIn.getTime())
                .refreshToken(refreshToken)
                .build();
    }

    public String createAccessToken(String sub, String authorities, Date accessTokenExpiresIn) {
        String accessToken = Jwts.builder()
                .setSubject(sub)       // payload "sub": "name"
                .claim(AUTHORITIES_KEY, authorities)        // payload "auth": "ROLE_USER"
                .setExpiration(accessTokenExpiresIn)        // payload "exp": 1516239022 (예시)
                .signWith(key, SignatureAlgorithm.HS512)    // header "alg": "HS512"
                .compact();
        return accessToken;
    }

    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

        if (claims.get(AUTHORITIES_KEY) == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        // 클레임에서 권한 정보 가져오기
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // UserDetails 객체를 만들어서 Authentication 리턴
        UserDetails principal = new User(claims.getSubject(), "", authorities);

        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.warn("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.warn("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.warn("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.warn("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }

    public Claims parseClaims(String token) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }

    public Long getMemberId(String token) {
        Claims claims = parseClaims(token);
        return Long.parseLong(claims.get("sub").toString());
    }
}

AuthFilter에서 사용하는 TokenProvider 내부로직을 통해 Jwt를 생성하고 ㅇ효성 체크를 진행한다.

TokenProvider의 유효성 체크가 종료되면 해당 토큰은 SecurityContextHolder에 등록하여 인증된 토큰으로써 Security에서 작동할 수 있도록 진행된다.

📄 로그인 구현

위에 내용은 Security에서 토큰을 생성하고 해당 토큰을 인증하는 방법에 대해 알아보았다. 그럼 로그인을 해야 토큰이 생성될텐데 로그인은 어떻게 처리했는지 확인해보자!

✅ AuthController

import com.juno.appling.domain.dto.Api;
import com.juno.appling.domain.dto.member.JoinDto;
import com.juno.appling.domain.dto.member.LoginDto;
import com.juno.appling.domain.vo.member.JoinVo;
import com.juno.appling.domain.vo.member.LoginVo;
import com.juno.appling.service.member.MemberAuthService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import static com.juno.appling.domain.enums.ResultCode.POST;
import static com.juno.appling.domain.enums.ResultCode.SUCCESS;

@RestController
@RequestMapping("${api-prefix}/auth")
@RequiredArgsConstructor
public class AuthController {
    private final MemberAuthService memberAuthService;

    @PostMapping("/join")
    public ResponseEntity<Api<JoinVo>> join(@RequestBody @Validated JoinDto joinDto, BindingResult bindingResult){
        return ResponseEntity.status(HttpStatus.CREATED).body(Api.<JoinVo>builder()
                .code(POST.CODE)
                .message(POST.MESSAGE)
                .data(memberAuthService.join(joinDto))
                .build());
    }

    @PostMapping("/login")
    public ResponseEntity<Api<LoginVo>> login(@RequestBody @Validated LoginDto loginDto, BindingResult bindingResult){
        return ResponseEntity.ok(
                Api.<LoginVo>builder()
                        .code(SUCCESS.CODE)
                        .message(SUCCESS.MESSAGE)
                        .data(memberAuthService.login(loginDto))
                        .build()
        );
    }

    @GetMapping("/refresh/{refresh_token}")
    public ResponseEntity<Api<LoginVo>> refresh(@PathVariable(value = "refresh_token") String refreshToken){
        return ResponseEntity.ok(
                Api.<LoginVo>builder()
                        .code(SUCCESS.CODE)
                        .message(SUCCESS.MESSAGE)
                        .data(memberAuthService.refresh(refreshToken))
                        .build()
        );
    }
}

나는 다음과 같이 Controller에 /api/auth/login을 추가하였다.

그 외 부분은 혹시라도 로그인 관련 api를 작성중이신 분들에게 큰 도움은 아니지만 기본적인 구조를 알수 있는데 도움이 되길 바라며 그냥 다 넣었다.

login을 위한 MemberAuthService.login()을 살펴보자

✅ MemberAuthService.login()

import com.juno.appling.common.security.TokenProvider;
import com.juno.appling.domain.dto.member.JoinDto;
import com.juno.appling.domain.dto.member.LoginDto;
import com.juno.appling.domain.entity.member.Member;
import com.juno.appling.domain.vo.member.JoinVo;
import com.juno.appling.domain.vo.member.LoginVo;
import com.juno.appling.repository.member.MemberRepository;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Date;
import java.util.Optional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberAuthService {
    private final MemberRepository memberRepository;
    private final BCryptPasswordEncoder passwordEncoder;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final TokenProvider tokenProvider;
    private final RedisTemplate<String, Object> redisTemplate;

    private static final String AUTHORITIES_KEY = "auth";
    private static final String TYPE = "Bearer ";
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30;            // 30분

    @Transactional
    public JoinVo join(JoinDto joinDto){
        joinDto.passwordEncoder(passwordEncoder);
        Optional<Member> findMember = memberRepository.findByEmail(joinDto.getEmail());
        if(findMember.isPresent()){
            throw new IllegalArgumentException("이미 존재하는 회원입니다.");
        }

        Member saveMember = memberRepository.save(Member.of(joinDto));
        return JoinVo.builder()
                .email(saveMember.getEmail())
                .name(saveMember.getName())
                .nickname(saveMember.getNickname())
                .build();
    }

    public LoginVo login(LoginDto loginDto) {
        // id, pw 기반으로 UsernamePasswordAuthenticationToken 객체 생
        UsernamePasswordAuthenticationToken authenticationToken = loginDto.toAuthentication();

        // security에 구현한 AuthService가 실행됨
        Authentication authenticate = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

        LoginVo loginVo = tokenProvider.generateTokenDto(authenticate);

        // token redis 저장
        String accessToken = loginVo.getAccessToken();
        String refreshToken = loginVo.getRefreshToken();
        Claims claims = tokenProvider.parseClaims(refreshToken);
        long refreshTokenExpired = Long.parseLong(claims.get("exp").toString());

        ValueOperations<String, Object> ops = redisTemplate.opsForValue();
        ops.set(refreshToken, accessToken);
        redisTemplate.expireAt(refreshToken, new Date(refreshTokenExpired*1000L));

        return loginVo;
    }

    public LoginVo refresh(String refreshToken) {
        if(!tokenProvider.validateToken(refreshToken)){
            throw new IllegalArgumentException("유효하지 않은 토큰입니다.");
        }

        ValueOperations<String, Object> ops = redisTemplate.opsForValue();
        String originAccessToken = ops.get(refreshToken).toString();
        Claims claims = tokenProvider.parseClaims(originAccessToken);

        String sub = claims.get("sub").toString();
        long now = (new Date()).getTime();
        Date accessTokenExpired = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = tokenProvider.createAccessToken(sub, claims.get(AUTHORITIES_KEY).toString(), accessTokenExpired);

        Authentication authentication = tokenProvider.getAuthentication(accessToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);

        return LoginVo.builder()
                .type(TYPE)
                .accessToken(accessToken)
                .accessTokenExpired(accessTokenExpired.getTime())
                .refreshToken(refreshToken)
                .build();
    }
}

로그인 로직만 살펴보자

전달받은 LoginDto 값을 먼저 받아와야한다.

import jakarta.validation.constraints.NotNull;
import lombok.*;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class LoginDto {
    @NotNull(message = "email 비어있을 수 없습니다.")
    private String email;
    @NotNull(message = "password 비어있을 수 없습니다.")
    private String password;

    public UsernamePasswordAuthenticationToken toAuthentication(){
        return new UsernamePasswordAuthenticationToken(email, password);
    }
}

LoginDto에서 다음과 같이 이메일과 비밀번호를 전달받고 해당 부분으로 UsernamePasswordAuthenticationToken 객체로 다시 생성한다.

그러면 해당 부분을

Authentication authenticate = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

authenticate()를 실행하면 Security에 우리가 등록한 AuthService가 작동한다.


AuthService

import com.juno.appling.domain.entity.member.Member;
import com.juno.appling.domain.enums.member.Role;
import com.juno.appling.repository.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.User;
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.*;


@Service
@Slf4j
@RequiredArgsConstructor
public class AuthService implements UserDetailsService {
    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.debug("회원 인증 처리");
        Member member = memberRepository.findByEmail(username).orElseThrow(() ->
                new UsernameNotFoundException("유효하지 않은 회원입니다.")
        );

        Role role = member.getRole();
        Set<String> roleSet = new HashSet<>();
        roleSet.add("USER");
        roleSet.add(role.ROLE);

        String[] roles = Arrays.copyOf(roleSet.toArray(), roleSet.size(), String[].class);

        return User.builder()
                .username(String.valueOf(member.getId()))
                .password(member.getPassword())
                .roles(roles)
                .build();
    }
}

AuthSErvice의 경우 회원에 대한 인증처리를 진행하는 부분으로 UserDetailsService를 상속하고 loadUserByUsername()를 overried하여 구현한다.

해당 부분에서 주요 역할은 회원의 정보를 DB에서 찾아와 권한값을 지정해주면 Security 내부적으로 해당 비밀번호를 체크하고 권한을 체크해주는 로직을 실행한다.

해당 부분이 궁금하면 Security 내부적 로직을 더 자세하게 설명해둔 글을 찾아서 보시길 바랍니다!
아니면 직접 디버깅하여 로직을 타고 들어가보면 확인이 가능합니다~!


AuthService에서 인증이 완료되어 정상 인증 회원이라면

LoginVo loginVo = tokenProvider.generateTokenDto(authenticate);

해당 로직을 통해 AccessToken과 RefreshToken 값을 반환 받아온다. 그 후 refresh 토큰은 redis에 등록하여 해당 access token에 저장되어 있는 정보로 토큰을 재발급할때 재사용할 수 있도록 하였다.

👏 마치며...

SpringBoot2에서도 항상 프로젝트를 시작하며 가장 먼저 세팅하는것이 Security였는데 그때마다 힘들었고 너무 복잡했다 ㅜㅜ (할때마다 까먹음) 그래서 SpringBoot3로 넘어오기 제일 힘들었던 것이 Security 설정을 버전에 맞게 다시 이해하는게 가장큰 벽이였는데

해당글이 조금이나마 다른 분들이 SpringBoot3로 넘어감에 있어 Security가 장벽이 아니길 바라며 작성해보았다.

혹여 모든 소스가 필요하신 분은 Appling-api github에 내가 작업하고 있는 프로젝트를 참고해주시면 될거 같다.

계속 소스가 변경될 순 있지만!...

항상 시작이 어렵기 결국 해낸다는 것을 또 한번 느꼈다!...

profile
코딩을 깔끔하게 하고 싶어하는 초보 개발자 (편하게 글을 쓰기위해 반말체를 사용하고 있습니다! 양해 부탁드려요!) 현재 KakaoVX 근무중입니다!

3개의 댓글

comment-user-thumbnail
2024년 3월 1일

안녕하세요 ! 올려주신 코드 설명이 너무 잘 되어 있어서 보고 있는데 혹 apply 부분에서 오류는 안 나시나요 ?
jwtsecurityconfig를 filter로 인식하지 못해서 해당 부분이 오류가 나는 거 같은데 준호님께서는 따로 오류가 나지 않는지 싶어 여쭤봅니다 ..

2개의 답글