Spring 3버전 Security+JWT 구현 (2)

code_able·2024년 6월 9일
0
post-custom-banner

환경

java 17
Spring 3.3.0
Postgresql 15.5

User table (flyway V1__init.sql)

CREATE SEQUENCE users_seq START WITH 1;
COMMENT ON SEQUENCE users_seq IS 'users 테이블에 대한 유일한 ID를 생성하기 위한 시퀀스';

CREATE TABLE users (
    id BIGINT DEFAULT nextval('users_seq') PRIMARY KEY,
    username VARCHAR(50) NOT NULL,
    password VARCHAR(255) NOT NULL,
    email VARCHAR(100) ,
    phone VARCHAR(20),
    authority VARCHAR(5),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP
)
;
COMMENT ON TABLE users IS '사용자 정보를 저장하는 테이블';
COMMENT ON COLUMN users.id IS '사용자를 식별하는 고유값';
COMMENT ON COLUMN users.username IS '사용자의 계정 이름';
COMMENT ON COLUMN users.password IS '사용자릐 계정 암호';
COMMENT ON COLUMN users.email IS '사용자의 이메일';
COMMENT ON COLUMN users.phone IS '사용자의 전화번호';
COMMENT ON COLUMN users.authority IS '사용자의 권한';
COMMENT ON COLUMN users.created_at IS '생성일시';
COMMENT ON COLUMN users.updated_at IS '변경일시';
COMMENT ON COLUMN users.deleted_at IS '삭제일시';

build.gradle

	// Spring security
	implementation 'org.springframework.boot:spring-boot-starter-security'
	testImplementation 'org.springframework.security:spring-security-test'

	// JWT
	implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
	runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
	runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'

application.yml

jwt:
  secret: [secret key]
  expirationTime: [expiration time]

SecurityConfig.java

비밀번호 인코더 빈 생성

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

RESTFUL API에서 불필요하여 CSRF를 비활성화

http.csrf(AbstractHttpConfigurer::disable);

예외 처리 구성

http.exceptionHandling(exception ->
        exception.authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler));

세션을 STATELESS로 설정

http.sessionManagement(sessionManagement ->
        sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

JWT 필터 추가

http.addFilterBefore(new JwtFilter(tokenProvider, customUserDetailsService), UsernamePasswordAuthenticationFilter.class);

인증 없이 접근할 수 있는 엔드포인트 설정

return web -> web.ignoring().requestMatchers(
        "/swagger-ui/**",
        "/signUp",
        "/signIn");

전체 코드

import com.boilerplate.jwt.*;
import com.boilerplate.service.CustomUserDetailsService;
import lombok.RequiredArgsConstructor;
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.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;


@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {
    private final TokenProvider tokenProvider;

    private final CustomUserDetailsService customUserDetailsService;

    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

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

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .exceptionHandling(exception ->
                        exception.authenticationEntryPoint(jwtAuthenticationEntryPoint)
                                .accessDeniedHandler(jwtAccessDeniedHandler))
                .sessionManagement(sessionManagement ->
                        sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                ).authorizeHttpRequests(authorizeRequests ->
                        authorizeRequests
                                .anyRequest().authenticated()
                )
                .addFilterBefore(new JwtFilter(tokenProvider, customUserDetailsService), UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public WebSecurityCustomizer configure() {
        return web -> web.ignoring().requestMatchers(
                "/swagger-ui/**",
                "/signUp",
                "/signIn"
        );
    }
}

JWT 토큰 발급 구현

import com.boilerplate.entitiy.Users;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.time.ZonedDateTime;
import java.util.Date;
import java.util.Collection;
import java.util.List;
import java.util.ArrayList;

@Component
public class TokenProvider implements InitializingBean {
    private static final Logger logger = LoggerFactory.getLogger(TokenProvider.class);

    private final String secret;

    private final Long expirationTime;

    private Key key;

    public TokenProvider(@Value("${jwt.secret}") String secret, @Value("${jwt.expirationTime}") long expirationTime) {
        this.secret = secret;
        this.expirationTime = expirationTime;
    }

    @Override
    public void afterPropertiesSet() {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    public String createToken(Users uesrs) {
        Claims claims = Jwts.claims();
        claims.put("username", uesrs.getUsername());
        claims.put("email", uesrs.getEmail());
        claims.put("phone", uesrs.getPhone());
        claims.put("authority", uesrs.getAuthority());

        ZonedDateTime now = ZonedDateTime.now();
        ZonedDateTime tokenValidity = now.plusSeconds(this.expirationTime);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(Date.from(now.toInstant()))
                .setExpiration(Date.from(tokenValidity.toInstant()))
                .signWith(this.key, SignatureAlgorithm.HS256)
                .compact();
    }

    public Collection<? extends GrantedAuthority> getAuthentication(String token) {
        Claims claims = getAllClaims(token);

        List<String> authorites = new ArrayList<>();
        authorites.add("ROLE_"+ claims.get("authority").toString());

        return authorites.stream()
                .map(SimpleGrantedAuthority::new)
                .toList();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            logger.info("Invalid JWT Token");
        } catch (ExpiredJwtException e) {
            logger.info("Expired JWT Token");
        } catch (UnsupportedJwtException e) {
            logger.info("Unsupported JWT Token");
        } catch (IllegalArgumentException e) {
            logger.info("JWT claims string is empty");
        }
        return false;
    }

    public Claims getAllClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(this.secret).build()
                .parseClaimsJws(token)
                .getBody();
    }

}

JWT 필터 구현

import com.boilerplate.service.CustomUserDetailsService;
import com.querydsl.core.util.StringUtils;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.filter.GenericFilterBean;

import java.io.IOException;
import java.util.Arrays;
import java.util.Optional;

@RequiredArgsConstructor
public class JwtFilter extends GenericFilterBean {
    private static final Logger log = LoggerFactory.getLogger(JwtFilter.class);

    private final TokenProvider tokenProvider;

    private final CustomUserDetailsService customUserDetailsService;


    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;

        String jwt = resolveToken(httpServletRequest);
        String requestURI = httpServletRequest.getRequestURI();

        String username = String.valueOf(tokenProvider.getAllClaims(jwt).get("username"));
        UserDetails userDetails = customUserDetailsService.loadUserByUsername(username);

        if (!StringUtils.isNullOrEmpty(jwt) && tokenProvider.validateToken(jwt)) {
            UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
                    new UsernamePasswordAuthenticationToken(userDetails, null, tokenProvider.getAuthentication(jwt));
            SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
            log.debug("save {} in security config / uri : {}", usernamePasswordAuthenticationToken.getName(), requestURI);
        } else {
            log.debug("JWT is empty or null / uri : {}", requestURI);
        }
        filterChain.doFilter(servletRequest, servletResponse);
    }

    private String resolveToken(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        Optional<Cookie> authorizationCookie = Arrays.stream(cookies)
                .filter(cookie -> "Autentication".equals(cookie.getName()))
                .findFirst();
        String bearerToken = authorizationCookie.map(Cookie::getValue).orElse(null);
        if (!StringUtils.isNullOrEmpty(bearerToken)) {
            return bearerToken;
        }
        return null;
    }
}

CustomUserDetails.java 구현

import com.boilerplate.dto.SignInRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.Collections;

@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {

    private final transient SignInRequest signInRequest;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.emptyList();
    }

    @Override
    public String getPassword() {
        return null;
    }

    @Override
    public String getUsername() {
        return null;
    }
}

CustomUserDetailsService.java 구현

import com.boilerplate.dto.SignInRequest;
import com.boilerplate.entitiy.Users;
import com.boilerplate.jwt.CustomUserDetails;
import com.boilerplate.repository.CustomUserRepository;
import lombok.RequiredArgsConstructor;
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;

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final CustomUserRepository customUserRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Users users = customUserRepository.findOneWithAuthoritiesByUsername(username);

        SignInRequest signInRequest = SignInRequest.builder()
                .username(users.getUsername())
                .password(users.getPassword())
                .build();
        return new CustomUserDetails(signInRequest);
    }
}

권한에러 예외 핸들러 구현

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}

인증에러 예외 구현

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}

User Entity

import com.boilerplate.enums.UserAuthority;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;

@Entity
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Table(name="users")
@SequenceGenerator(
        name = "users_seq",
        sequenceName = "users_seq",
        allocationSize = 1
)
@Getter
public class Users {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "users_seq")
    private Long id;

    @Column(name = "username", length = 50)
    private String username;

    @Column(name = "password", length = 255)
    private String password;

    @Column(name = "email", length = 100)
    private String email;

    @Column(name = "phone", length = 100)
    private String phone;

    @Column(name="authority")
    @Enumerated(EnumType.STRING)
    private UserAuthority authority;

    @Column(name = "created_at")
    private LocalDateTime createdAt;

    @Column(name = "updated_at")
    private LocalDateTime updatedAt;

    @Column(name = "deleted_at")
    private LocalDateTime deletedAt;

    @Builder
    public Users(String username, String password, String email, String phone, LocalDateTime createdAt) {
        this.username = username;
        this.password = password;
        this.email = email;
        this.phone = phone;
        this.createdAt = createdAt;

    }
}

CustomUserRepository.java

import com.boilerplate.entitiy.QUsers;
import com.boilerplate.entitiy.Users;
import com.boilerplate.repository.CustomUserRepository;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Repository;

@Repository
@AllArgsConstructor
public class CustomUserRepositoryImpl implements CustomUserRepository {
    private final JPAQueryFactory queryFactory;

    @Override
    public boolean existsByUsername(String username) {
        QUsers user = QUsers.users;
        return queryFactory
                .select(user.username)
                .from(user)
                .where(user.username.eq(username)
                        .and(user.deletedAt.isNull()))
                .fetchFirst() != null;
    }

    @Override
    public Users findOneWithAuthoritiesByUsername(String username) {
        QUsers user = QUsers.users;
        return queryFactory
                    .select(user)
                    .from(user)
                    .where(user.username.eq(username)
                            .and(user.deletedAt.isNull()))
                    .fetchOne();
    }
}

AuthService.java

import com.boilerplate.dto.SignInRequest;
import com.boilerplate.dto.SignUpRequest;
import com.boilerplate.entitiy.Users;
import com.boilerplate.jwt.TokenProvider;
import com.boilerplate.repository.CustomUserRepository;
import com.boilerplate.repository.UserRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;

@Service
@RequiredArgsConstructor
public class AuthService {

    private final UserRepository userRepository;

    private final CustomUserRepository customUserRepository;

    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    private final TokenProvider tokenProvider;

    public boolean registerUSer(SignUpRequest signUpRequest) {
        String username = signUpRequest.getUsername();
        boolean isExists = this.customUserRepository.existsByUsername(username);
        if (isExists) {
            return false;
        }

        Users user = Users.builder()
                .username(signUpRequest.getUsername())
                .password(bCryptPasswordEncoder.encode(signUpRequest.getPassword()))
                .email(signUpRequest.getEmail())
                .phone(signUpRequest.getPhone())
                .createdAt(LocalDateTime.now())
                .build();
        this.userRepository.save(user);
        return true;
    }

    @Transactional
    public String authenticateUser(SignInRequest signInRequest) throws UsernameNotFoundException{
        Users users = this.customUserRepository.findOneWithAuthoritiesByUsername(signInRequest.getUsername());
        if (bCryptPasswordEncoder.matches(signInRequest.getPassword(), users.getPassword())) {
            return tokenProvider.createToken(users);
        }
        throw new UsernameNotFoundException(signInRequest.getUsername() + " is not found.");
    }
}

AuthController.java

import com.boilerplate.dto.SignInRequest;
import com.boilerplate.dto.SignUpRequest;
import com.boilerplate.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import com.boilerplate.response.MessageResponse;

@Tag(name = "Sign up", description = "회원가입 API")
@RestController
@ResponseBody
@RequiredArgsConstructor
public class AuthController {

    @Value("${jwt.expirationTime}")
    private int expirationTime;

    @Value("${cookie.SameSite}")
    private String sameSite;

    private final AuthService authService;

    @Operation(summary = "로그인", description = "로그인")
    @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ApiResponse.class)))
    @ApiResponse(responseCode = "401", description = "Unauthorized", content = @Content(schema = @Schema(implementation = ApiResponse.class)))
    @PostMapping(path = "signIn")
    public ResponseEntity<MessageResponse> authenticateUser(@RequestBody SignInRequest signInDto, HttpServletResponse response) {
        String accessToekn = authService.authenticateUser(signInDto);
        Cookie cookie = new Cookie("Autentication",  accessToekn);
        cookie.setHttpOnly(true);
        cookie.setSecure(true);
        cookie.setMaxAge(expirationTime);
        cookie.setAttribute("SameSite", this.sameSite);
        response.addCookie(cookie);
        return ResponseEntity.status(HttpStatus.OK).body(new MessageResponse("success", 200));
    }
}
profile
할수 있다! code able
post-custom-banner

0개의 댓글