SpringBoot3.2.6 Security 로그인 인증 & 인가

suhwani·2024년 5월 29일
0
post-thumbnail

SpringBoot security 를 사용하려고 했는데, 버전 3.x 가 없어서 제가 직접 찾아서 작성했는데, 다른 분들과 공유하려고 합니다!! 참고로 Spring Security 는 버전마다 코드가 훅훅 바뀌어서 버전이 중요해요!!

1. Version

SpringBoot 3.2.6
Java 17
spring-boot-starter-security 3.2.6

아래 사이트에서 자신과 맞는 버전 찾기!!
SpringBoot → Learn → 버전별 Reference Doc → 하단 Dependency Version

2. 로그인 방식

[ JWT 방식 채용 ]
저희는 기존 계획에 없었는데… 급하게 시연용을 위해서 만들게 돼서…
진짜 로그인 용도로 만들었다기 보다는 인증을 한 사용자와 인증을 하지 않은 사용자를 나누기 위함으로
만들었습니다!! 개인정보가 들어있는 프로젝트라서 저희팀원(= 관리자)가 아닌 사람은 제한된 페이지만 갈 수 있도록 했습니다.

일단 JWT 방식으로 로그인을 구현했고, 실제 로그인을 개인정보 넣고, 회원가입하고 이런 용도가 아니라 우리 팀원만 접속할 수 있도록 만들기 위해서 특정 문자열을 넣은 ID 로 회원가입을 하고, 로그인을 완료하면 토큰을 발급한 후 헤더에 토큰을 넣고 다음 api 요청을 보내야합니다. 이 때 토큰 claim 내에 loginID 를 넣고, API 요청이 오면 Filter 에서 토큰을 검사합니다. loginID 를 해석하고, DB 에 정보가 있다면 인증된 사용자로 요청을 허가합니다.

3. 코드 설명 시작!

build.gradle

implementation 'org.springframework.boot:spring-boot-starter-security' // login
implementation 'io.jsonwebtoken:jjwt:0.9.1' // JWT 
implementation 'com.sun.xml.bind:jaxb-impl:4.0.1' // com.sun.xml.bind
implementation 'com.sun.xml.bind:jaxb-core:4.0.1' // com.sun.xml.bind
implementation 'javax.xml.bind:jaxb-api:2.4.0-b180830.0359' // javax.xml.bind

JwtTokenFilter.java
아래 필터는 모든 reuqest 마다 반응합니다!!
주의점
1. request headers 에 토큰이 있다면 무조건!! 해당 필터를 거쳐갑니다!
2. filterChain.doFilter 는 필터 작업을 종료하고 다음 작업으로 넘긴다는 의미입니다.
3. filter 는 controller, service 단에 도착하기 전에 거쳐가는 곳으로, @Autowire 나 @Value는 사용할 수 없고, 따로 넣어줘야합니다. 아래처럼 생성자를 통해서

// OncePerRequestFilter : 매번 들어갈 때 마다 체크 해주는 필터
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {
    
    private final JwtTokenService jwtTokenService;
    private final UserService userService;
    private final String secretKey;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

        // Header의 Authorization 값이 비어있으면 => Jwt Token을 전송하지 않음 => 로그인 하지 않음
        if(authorizationHeader == null) {
            filterChain.doFilter(request, response);
            return;
        }

        // Header의 Authorization 값이 'Bearer '로 시작하지 않으면 => 잘못된 토큰
        if(!authorizationHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        // 전송받은 값에서 'Bearer ' 뒷부분(Jwt Token) 추출
        String token = authorizationHeader.split(" ")[1];

        // 전송받은 Jwt Token이 만료되었으면 => 다음 필터 진행(인증 X)        
        if (jwtTokenService.isExpired(token, secretKey)) {
            filterChain.doFilter(request, response);
            return;
        }

        // Jwt Token에서 loginId 추출
        String loginId = jwtTokenService.getLoginId(token, secretKey);

        // 추출한 loginId로 User 찾아오기
        UserEntity loginUser = userService.getLoginUserByLoginId(loginId);

        // loginUser 정보로 UsernamePasswordAuthenticationToken 발급
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                loginUser.getLoginId(), null, List.of(new SimpleGrantedAuthority(loginUser.getRole().name())));
        authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

        // 권한 부여
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        filterChain.doFilter(request, response);
    }
}

BCryptConfig.java
주의점
1. 비밀번호 암호화를 위해서

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

SecurityConfig.java
주의점
1. 아래 문법이 이전 버전과 많이 달라졌습니다! 해당 글은 springboot 3.2.6 버전을 기준으로 합니다
2. requestMatchers 를 이용해 내가 원하는 api path 와 해당 경로의 특정 method 를 지정하고, 원하는 권한에 대하여 인가를 할 수 있습니다!
3. permitAll() 은 모두 허용, authenticated() 는 인증된 사람, hasAuthority() 는 특정 권한에 대해서!

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final JwtTokenService jwtTokenService;
    private final UserService userService;

    @Value("${jwt.secretKey}")
    private String secretKey;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .httpBasic(http -> http.disable())
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilterBefore(new JwtTokenFilter(jwtTokenService, userService, secretKey), UsernamePasswordAuthenticationFilter.class)
                .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/api/guardian/**").permitAll()
                    .requestMatchers(HttpMethod.GET, "/api/missing-people").permitAll()
                    .requestMatchers("/api/user/info/**").authenticated()
                    .requestMatchers("/api/user/**").permitAll()
                    .anyRequest().hasAuthority(UserRole.ADMIN.name())
                )
                .build();
    }
}

JwtLoginApiController.java
주의점
1. DTO 는 상황마다 다르고, 너무 개별적인 부분이라 여러분이 직접 작성하셔야 돼요!

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/user")
public class JwtLoginApiController {
    @Value("${jwt.secretKey}")
    private String secretKey;
    @Value("${jwt.expireTime}")
    private long expireTime;

    @Autowired
    private UserService userService;
    @Autowired
    private JwtTokenService jwtTokenService;

    @PostMapping("/join")
    public ResponseEntity<?> join(@RequestBody JoinRequestDto joinRequestDto) {
        
        // loginId 중복 체크
        userService.checkLoginIdDuplicate(joinRequestDto.getLoginId());

        // 회원가입 
        userService.join(joinRequestDto);
        return ResponseEntity.ok().body(new SuccessResponse("Join Success"));
    }

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequestDto loginRequestDto) {
        // 로그인
        UserEntity user = userService.login(loginRequestDto);

        // Jwt Token 발급
        String jwtToken = jwtTokenService.createToken(user.getLoginId(), secretKey, expireTime);

        LoginResponseDto loginResponseDto = new LoginResponseDto(user, jwtToken);
        return ResponseEntity.ok().body(new SuccessResponse(loginResponseDto));
    }

    @GetMapping("/info")
    public ResponseEntity<?> userInfo(Authentication auth) {
        UserEntity loginUser = userService.getLoginUserByLoginId(auth.getName());

        return ResponseEntity.ok().body(new SuccessResponse(String.format("loginId : %s, role : %s",
        loginUser.getLoginId(), loginUser.getRole().name())));
    }
}

JwtTokenService
주의점
1. 보통 jwtUtil 같은 이름으로 사용을 합니다! 우리는 아니지만…?
2. claim 내에 원하는 정보를 수정해서 사용해주세요!

@Service
public class JwtTokenService {

    // JWT Token 발급
    public static String createToken(String loginId, String secretKey, long expireTime) {
        try {
            // Claim = Jwt Token에 들어갈 정보
            // Claim에 loginId를 넣어 줌으로써 나중에 loginId를 꺼낼 수 있음
            Claims claims = Jwts.claims();
            claims.put("loginId", loginId);

            return Jwts.builder()
                    .setClaims(claims)
                    .setIssuedAt(new Date(System.currentTimeMillis()))
                    .setExpiration(new Date(System.currentTimeMillis() + expireTime))
                    .signWith(SignatureAlgorithm.HS256, secretKey)
                    .compact();
        } catch (Exception e) {
            throw new CustomException(ErrorCode.JWT_CREATE_ERROR);
        }
    }

    // Claims에서 loginId 꺼내기
    public static String getLoginId(String token, String secretKey) {
        return extractClaims(token, secretKey).get("loginId").toString();
    }

    // 발급된 Token이 만료 시간이 지났는지 체크
    public static boolean isExpired(String token, String secretKey) {
        Date expiredDate = extractClaims(token, secretKey).getExpiration();

        // Token의 만료 날짜가 지금보다 이전인지 check
        return expiredDate.before(new Date());
    }

    // SecretKey를 사용해 Token Parsing
    private static Claims extractClaims(String token, String secretKey) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
    }
}

UserRepository.java
주의점
1. Optional return 을 하기 때문에, 알아서 수정해서 쓰셔도 됩니다!

public interface UserRepository extends JpaRepository<UserEntity, Long>{
    boolean existsByLoginId(String loginId);
    Optional<UserEntity> findByLoginId(String loginId);
}

ErrorCode.java ← 이건 저희팀 에러 처리 방식입니다. 굳이 안쓰셔도 됩니다

// Login Error
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "User not found by login id."),
USER_NOT_MATCH_PASSWORD(HttpStatus.UNAUTHORIZED, "Invalid password"),
JWT_CREATE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Can not create JWT token"),
USER_NOT_ADMIN(HttpStatus.UNAUTHORIZED, "User not Admin, Please contact probee team"),
DUPLICATE_USER_LOGIN_ID(HttpStatus.CONFLICT, "Duplicate user login id.");
profile
Backend-Developer

0개의 댓글