[SpringBoot+React] Redis+JWT 활용한 로그인 회원인증 기능 구현 (1)

Kyunghwan Ko·2023년 1월 23일
0

Spring Boot

목록 보기
2/2

JWT란?

Json Web Token

Redis란?

폴더구조

src/com.example.chat
├─config
│  ├─auth
│  └─jwt
├─controller
├─domain
├─dto
├─repository
└─service

설정 & 프로젝트 세팅

dependency

  • spring data JPA
  • lombok
  • spring-data-redis
  • mysql-driver
  • spring web
dependencies {
	...
	implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

} 

Alt + insert를 눌러서 add dependency를 통해 위와 같이 필요한 dependencey들을 추가할 수 있습니다.

application-db.yml

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/chat?serverTimezone=UTC&characterEncoding=UTF-8
    username: root
    password:
  redis:
    port: 6379
    host: localhost
jwt:
  secret: ee.ff
  • application.yml 파일의 경우 .gitignore 적용없이 올릴 것이기 때문에 민감한 정보의 경우엔 별도의 설정파일로 빼서 관리하는 것이 좋기 때문에 아래와 같이 별도로 작성해줍니다.
  • DB로는 MySQL을 사용할 것이기 때문에 필요한 설정을 해줍니다.
  • redis도 사용할 것이기 때문에 설정 정보도 입력해줍니다.
  • JWT를 암호화할 때 사용할 secret key값을 설정해줍니다.(들여쓰기 없이 제일 밖에 써주시면 됩니다!)

application.yml

spring:
  profiles:
    include:
      - db
  jpa:
    database: mysql
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
        show_sql: true

logging:
  level:
    com.example: debug
  • profiles/inclide 로 외부 설정파일 정보에 접근하도록 함
  • ddl-auto의 경우 맨 처음에 main함수 실행할 때만 create설정으로 두조 추후 개발 진행할 경우 update, 운영 및 배포단계에서는 validate 옵션으로 진행하는 것이 좋습니다
  • 애플리케이션 실행하면서 찍히는 로그 정보에서 어느 레벨까지 볼 것인지 설정할 수 있는데 일반적으로 debug 또는 info 모드를 선택하고 더 상세한 정보를 보고 싶다면 trace 레벨로 설정해도 됩니다!
    자세한 내용은 [Web] Logging Level이란? 블로그를 참고해보시기 바랍니다.

config/SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final JwtEntryPoint jwtEntryPoint; // 1
    private final JwtAuthenticationFilter  jwtAuthenticationFilter; // 1
    private final PrincipalDetailsService principalDetailsService; // 2

    @Bean
    public AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManagerBean();
    }

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

    @Override
    public void configure(WebSecurity web) { // 4
        web.ignoring().antMatchers("/favicon.ico");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .cors()

                .and()
                .csrf().disable()
                .authorizeRequests() // 5
                .antMatchers("/", "/join/**", "/login", "/chat").permitAll()
                .anyRequest().hasRole("USER")

                .and()
                .exceptionHandling()
                .authenticationEntryPoint(jwtEntryPoint)

                .and()
                .logout().disable() // 6
                .sessionManagement().sessionCreationPolicy(STATELESS)

                .and() // 7
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
        ;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(principalDetailsService).passwordEncoder(passwordEncoder());
    }
}
  1. Spring Security는 각종 권한 인증 등 보안과 관련된 것들을 체크하기 위해 여러 필터들이 존재합니다. 저는 JWT 기반으로 구현해야 하기 때문에 JwtAuthenticationFilter 라는 이름의 클래스를 구현했고, 만약 시큐리티 필터 과정 중 에러가 발생할 경우는 JwtEntryPoint에서 처리하도록 구현했습니다.
  2. Spring Security에서는 UserDetailsService 라는 유저의 정보를 가져오기 위한 클래스를 제공합니다. JWT 기반으로 구현해야하기 때문에 따로 커스터마이징 하였습니다.
  3. 비밀번호 암호화 클래스입니다. 사용자가 회원 가입시 입력한 비밀번호를 BCrypt strong hashing function을 통해 암호화하며, 단방향입니다.
  4. 진행하는 동안 시큐리티를 설정하고 나면 h2 데이터베이스 콘솔에 접속할 수 없습니다. 따라서 h2 관련 url은 ignore 해주었습니다.
  5. antMatchers("/", "/join/**", "/login", "/chat").permitAll() 메서드를 통해 명시된 url은 권한에 제한 없이 요청할 수 있습니다.
  6. JWT 기반으로 로그인 / 로그아웃을 처리할 것이기 때문에 logout은 disable 해주었고, Spring Security는 기본 로그인 / 로그아웃 시 세션을 통해 유저 정보들을 저장합니다. 하지만 Redis를 사용할 것이기 때문에 상태를 저장하지 않는 STATELESS로 설정했습니다.
  7. 앞에서 만들었던 JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 필터를 추가하겠다는 의미입니다.

config/jwt/JwtEntryPoint

@Slf4j
@Component
public class JwtEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        log.error("Unauthorized error: {}", authException.getMessage());
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Error: Unauthorized");
    }
}

config/jwt/JwtAuthenticationFilter

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtTokenUtil jwtTokenUtil;
    private final PrincipalDetailsService principalDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        String accessToken = getToken(request);
        if (accessToken != null) {
            checkLogout(accessToken);
            String username = jwtTokenUtil.getUsername(accessToken);
            if (username != null) {
                UserDetails userDetails = principalDetailsService.loadUserByUsername(username);
                equalsUsernameFromTokenAndUserDetails(userDetails.getUsername(), username);
                validateAccessToken(accessToken, userDetails);
                processSecurity(request, userDetails);
            }
        }
        filterChain.doFilter(request, response);
    }

    private String getToken(HttpServletRequest request) {
        String headerAuth = request.getHeader("Authorization");
        if (StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) {
            return headerAuth.substring(7);
        }
        return null;
    }

    private void checkLogout(String accessToken) {
        log.info("JwtAuthenticationFilter/checkLogout(): 로그이아웃 검증 로직 추가 필요");
    }

    private void equalsUsernameFromTokenAndUserDetails(String userDetailsUsername, String tokenUsername) {
        if (!userDetailsUsername.equals(tokenUsername)) {
            throw new IllegalArgumentException("username이 토큰과 맞지 않습니다.");
        }
    }

    private void validateAccessToken(String accessToken, UserDetails userDetails) {
        if (!jwtTokenUtil.validateToken(accessToken, userDetails)) {
            throw new IllegalArgumentException("토큰 검증 실패");
        }
    }

    private void processSecurity(HttpServletRequest request, UserDetails userDetails) {
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(userDetails,null, userDetails.getAuthorities());
        usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
    }
}
  1. getToken 메서드로 헤더에서 JWT를 'Bearer '를 제외하여 가져옵니다. 만약, JWT를 프론트에서 주지 않았을 경우 null로 그대로 반환합니다.
  2. 해당 토큰이 null이 아닐 경우는 이 토큰이 로그아웃된 토큰인지 검증합니다. 이 경우는 2편에서 상세하게 보겠습니다.
  3. JwtTokenUtil에 선언된 메서드로 토큰에서 username을 가져옵니다.
  4. username이 null이 아닌 경우는 앞에서 만든 PrincipalDetailService에서 UserDetails객체를 가져옵니다.
  5. 이 토큰에서 추출한 username과 principalDetailService에서 가져온 username이 맞는지 검증하고, 토큰의 유효성 검사를 진행합니다.
  6. 검증 과정에 예외가 발생하지 않았다면, 해당 유저의 정보를 SecurityContext에 넣어줍니다.

com.example.ChatApplication

@EnableCaching // 추가!
@SpringBootApplication
public class ChatApplication {

	public static void main(String[] args) {
		SpringApplication.run(ChatApplication.class, args);
	}

}

Spring의 캐싱기능(한번 접근한 것을 다시 접근할 때 빠르게 접근할 수 있음)을 사용하기 위해서 main 메서드가 있는 클래스에
@EnableCaching 어노테이션을 추가함

config/auth/PrincipalDetailService

@Service
@RequiredArgsConstructor
@Slf4j
public class PrincipalDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    @Override
    @Cacheable(key = "#username", unless = "#result == null")
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<User> findUser = userRepository.findByUsername(username);
        log.info("PrincipalDetailsService/loadUserByUsername: username에 해당하는 user없을 경우 예외처리 필요");
        return PrincipalDetails.of(findUser.get());
    }
}

@Cacheable 어노테이션은 토큰을 부여할 때 마다 DB를 거치는 cost를 줄이기 위해 설정해둔 것입니다. 자세한 내용은 2편에서 설명드리겠습니다.

config/auth/PrincipalDetails

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PrincipalDetails implements UserDetails {
    private String username;
    private String password;
    @Builder.Default
    private List<String> roles = new ArrayList<>();

    public static UserDetails of(User user) {
        return PrincipalDetails.builder()
                .username(user.getUsername())
                .password(user.getPassword())
                .roles(user.getRoles())
                .build();
    }

    @Override
    @JsonIgnore
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return roles.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(toList());
    }

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

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

    @Override
    @JsonIgnore
    public boolean isAccountNonExpired() {
        return false;
    }

    @Override
    @JsonIgnore
    public boolean isAccountNonLocked() {
        return false;
    }

    @Override
    @JsonIgnore
    public boolean isCredentialsNonExpired() {
        return false;
    }

    @Override
    @JsonIgnore
    public boolean isEnabled() {
        return false;
    }
}
  • 코드 참고해서 진행하실 때 SimpleGrantedAuthority가 import 잘 되지 않는다면 import org.springframework.security.core.authority.SimpleGrantedAuthority; 로 SimpleGrantedAuthority를 import 해오면 됩니다.

  • PrincipalDetails 클래스를 따로 만든 이유는 Redis에 캐싱할 때, 기본적인 UserDetails로 저장할 경우 역직렬화가 되지 않는 이슈를 확인했습니다. 인증과 권한체크를 위한 정보들을 필드에 설정하였고, 저장할 때 관련이 없는 나머지 메서드들은 @JsonIgnore로 처리했습니다.

config/jwt/JwtTokenUtil

@Slf4j
@Component
public class JwtTokenUtil {
    @Value("${jwt.secret}") 
    private String SECRET_KEY; // application-db.yml에서 설정한 ee.ff 값이 할당됨

    public Claims extractAllClaims(String token) { // 2
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey(SECRET_KEY))
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    public String getUsername(String token) {
        return extractAllClaims(token).get("username", String.class);
    }

    private SecretKey getSigningKey(String secretKey) {
        byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    public Boolean isTokenExpired(String token) {
        Date expiration = extractAllClaims(token).getExpiration();
        return expiration.before(new Date());
    }

    public String generateAccessToken(String username) {
        return doGenerateToken(username, ACCESS_TOKEN_EXPIRATION_TIME.getValue());
    }

    public String generateRefreshToken(String username) {
        return doGenerateToken(username, REFRESH_TOKEN_EXPIRATION_TIME.getValue());
    }

    private String doGenerateToken(String username, long expireTime) { // 1
        Claims claims = Jwts.claims();
        claims.put("username", username);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expireTime))
                .signWith(getSigningKey(SECRET_KEY), SignatureAlgorithm.HS256)
                .compact();
    }

    public Boolean validateToken(String token, UserDetails userDetails) {
        String username = getUsername(token);
        return username.equals(userDetails.getUsername())
                && !isTokenExpired(token);
    }

    public long getRemainMilliSeconds(String token) {
        Date expiration = extractAllClaims(token).getExpiration();
        Date now = new Date();
        return expiration.getTime() - now.getTime();
    }
}
  1. 토큰 생성 메서드 입니다. 먼저 JWT는 header.payload.signature로 구성되어 있습니다. username, 발급날짜, 만료기간을 payload에 넣고 앞에 yml에서 설정한 secret-key로 서명 후 HS256 알고리즘으로 암호화합니다.(외부에서 token이 갈취되어도 secret-key를 모르면 해독 불가능)
  2. 토큰 추출 메서드입니다. 서명했을 때의 secret-key로 서명하고 토큰을 만들때 username, 발급날짜, 만료기간을 넣었던 payload를 가져옵니다.

config/jwt/JwtExpirationEnums

@Getter @AllArgsConstructor
public enum JwtExpirationEnums {
    ACCESS_TOKEN_EXPIRATION_TIME("JWT 만료 시간 / 30분", 1000L * 60 * 30),
    REFRESH_TOKEN_EXPIRATION_TIME("Refresh 토큰 만료 시간 / 7일", 1000L * 60 * 60 * 24 * 7),
    REISSUE_EXPIRATION_TIME("Refresh 토큰 만료 시간 / 3일", 1000L * 60 * 60 * 24 * 3);

    private String description;
    private Long value;
}

config/jwt/JwtHeaderUtilEnums

class가 아니라 enum 타입으로 작성했습니다

@Getter @AllArgsConstructor
public enum JwtHeaderUtilEnums {
    GRANT_TYPE("JWT 타입 / Bearer ", "Bearer ");

    private String description;
    private String value;
}

회원가입

회원가입 기능을 구현해보겠습니다.

controller/UserApiController

@RestController
@RequiredArgsConstructor
public class UserApiController {
    private final UserService userService;
    private final JwtTokenUtil jwtTokenUtil;

    @GetMapping("/chat")
    public String chat() {
        return "OK";
    }

    @PostMapping("/join")
    public String join(@RequestBody UserJoinDto joinDto) {
        userService.join(joinDto);
        return "회원가입 완료";
    }

    @PostMapping("/join/admin")
    public String joinAdmin(@RequestBody UserJoinDto joinDto) {
        userService.joinAdmin(joinDto);
        return "어드민 회원 가입 완료";
    }
}

dto/UserJoinDto

@Data
@Builder
public class UserJoinDto {
    private String email;
    private String password;
    private String nickname;
}

service/UserService

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public void join(UserJoinDto joinDto) {
        joinDto.setPassword(passwordEncoder.encode(joinDto.getPassword()));
        userRepository.save(User.of(joinDto));
    }

    public void joinAdmin(UserJoinDto joinDto) {
        joinDto.setPassword(passwordEncoder.encode(joinDto.getPassword()));
        userRepository.save(User.ofAdmin(joinDto));
    }
}

사용자가 입력한 비밀번호를 그대로 DB에 저장하면 안되기 때문에 passwordEncoder를 통해 비밀번호를 암호화하여 user를 저장합니다. 또한 권한 처리를 위해 User 객체 생성시 일반 유저와 Admin을 구분하여 생성합니다.

domain/User

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
public class User {
    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    @Column(unique = true)
    private String username;

    @Column(unique = true)
    private String email;

    private String password;

    @Column(unique = true)
    private String nickname;

    @OneToMany(mappedBy = "user", cascade = ALL, orphanRemoval = true)
    @Builder.Default
    private Set<Authority> authorities = new HashSet<>();

    public static User of(UserJoinDto joinDto) {
        User user = User.builder()
                .username(UUID.randomUUID().toString())
                .email(joinDto.getEmail())
                .password(joinDto.getPassword())
                .nickname(joinDto.getNickname())
                .build();
        user.addAuthority(Authority.of(user));
        return user;
    }

    public static User ofAdmin(UserJoinDto joinDto) {
        User user = User.builder()
                .username(UUID.randomUUID().toString())
                .email(joinDto.getEmail())
                .password(joinDto.getPassword())
                .nickname(joinDto.getNickname())
                .build();
        user.addAuthority(Authority.ofAdmin(user));
        return user;
    }

    private void addAuthority(Authority authority) {
        authorities.add(authority);
    }

    public List<String> getRoles() {
        return authorities.stream()
                .map(Authority::getRole)
                .collect(toList());
    }
}

domain/Authority

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
public class Authority implements GrantedAuthority {
    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    private String role;

    public static Authority of(User user) {
        return Authority.builder()
                .role("ROLE_USER")
                .user(user)
                .build();
    }

    public static Authority ofAdmin(User user) {
        return Authority.builder()
                .role("ROLE_ADMIN")
                .user(user)
                .build();
    }

    @Override
    public String getAuthority() {
        return role;
    }
}

회원과 권한의 관계는 1:N 관계이며, Spring Security가 권한을 체크할 때 GrantedAuthority 타입으로 체크하기 때문에 구현체로 만들었습니다.

결과


회원을 생성하기 위해 Body에 정보를 넣어서 요청하는 API이기 때문에 POST요청으로 보내어서
아래와 같이 새로운 사용자가 생성된 것을 볼 수 있습니다.

이상으로 시큐리티와 JWT 설정 및 회원가입 기능까지 마무리했습니다. 다음 편에서 Redis 기반으로 로그인, 로그아웃, 토큰 재발급, 로그아웃, 간단한 회원 정보 조회까지 해보겠습니다!

profile
부족한 부분을 인지하는 것부터가 배움의 시작이다.

1개의 댓글

comment-user-thumbnail
2024년 1월 2일

안녕하세요 해당 포스팅의 깃허브 주소가 있을까요?

답글 달기