고딩왕 코범석

Spring Security + JWT + Redis로 로그인 구현하기 (1) 설정과 회원가입 본문

Language & Framework/Spring

Spring Security + JWT + Redis로 로그인 구현하기 (1) 설정과 회원가입

고딩왕 코범석 2021. 11. 23. 20:19
반응형

안녕하세요! 이번 시간에는 Spring Security와 Redis를 활용하여 JWT 기반 로그인 기능을 구현해보겠습니다. 저는 예전에 Spring Security로 JWT만 주는 부분까지 구현한 적이 있습니다. 하지만 Redis를 활용하여 Refresh 토큰을 주는 기능과 로그아웃을 구현하지 못했습니다.


이번에 소마에서 저와 같이 프로젝트한 백엔드 동료가 Redis를 활용해 refresh, logout까지 구현을 해서 시큐리티 복습을 겸해 포스팅을 진행하겠습니다.

보다 자세한 코드는 제 깃허브 를 참조해주세요!


우선, 이번 포스팅에서 개발할 기능은 다음과 같습니다.

  • 설정과 기본 프로젝트 셋팅
  • 회원가입

설정과 기본 프로젝트 셋팅

build.gradle

plugins {
    id 'org.springframework.boot' version '2.5.7'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

group = 'tutorial.redis'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
    implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

test {
    useJUnitPlatform()
}

application.yml

spring:
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password:
  jpa:
    database-platform: org.hibernate.dialect.H2Dialect
    hibernate:
      ddl-auto: create-drop
    properties:
      hibernate:
        format_sql: true
        show_sql: true
  h2:
    console:
      enabled: true
  redis:
    port: 6379
    host: localhost
jwt:
  secret: testtesttesttesttesttesttesttest
logging:
  level:
    com.tutorial: debug

SecurityCofig

시큐리티 설정을 저는 다음과 같이 작성했습니다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtEntryPoint jwtEntryPoint; // 1
    private final JwtAuthenticationFilter jwtAuthenticationFilter; // 1
    private final CustomUserDetailService customUserDetailService; // 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("/h2-console/**", "/favicon.ico");
    }

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

                .and()
                .csrf().disable()
                .authorizeRequests() // 5
                .antMatchers("/", "/join/**", "/login", "/health").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(customUserDetailService).passwordEncoder(passwordEncoder());
    }
}

주석에 표시한 번호대로 설명드리겠습니다.


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

JwtEntryPoint

@Slf4j
@Component
public class JwtEntryPoint implements AuthenticationEntryPoint {

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

JwtAuthenticationFilter

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

    private final JwtTokenUtil jwtTokenUtil;
    private final CustomUserDetailService customUserDetailService;
    private final LogoutAccessTokenRedisRepository logoutAccessTokenRedisRepository;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String accessToken = getToken(request);
        if (accessToken != null) {
            checkLogout(accessToken);
            String username = jwtTokenUtil.getUsername(accessToken);
            if (username != null) {
                UserDetails userDetails = customUserDetailService.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) {
        if (logoutAccessTokenRedisRepository.existsById(accessToken)) {
            throw new IllegalArgumentException("이미 로그아웃된 회원입니다.");
        }
    }

    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);
    }
}

다른 예제들도 참조했는데, Try Catch문도 섞여있고 가독성이 좋지 않아서 제 나름대로 리팩토링을 해보았습니다. doFilterInternal 메서드의 기능을 순차적으로 설명드리면

  1. getToken 메서드로 헤더에서 JWT를 'Bearer '를 제외하여 가져옵니다. 만약, JWT를 프론트에서 주지 않았을 경우 null로 그대로 반환합니다.
  2. 해당 토큰이 null이 아닐 경우는 이 토큰이 로그아웃된 토큰인지 검증합니다. 이 경우는 2편에서 상세하게 보겠습니다.
  3. JwtTokenUtil에 선언된 메서드로 토큰에서 username을 가져옵니다.
  4. username이 null이 아닌 경우는 앞에서 만든 CustomUserDetailService에서 UserDetails객체를 가져옵니다.
  5. 이 토큰에서 추출한 username과 userDetailService에서 가져온 username이 맞는지 검증하고, 토큰의 유효성 검사를 진행합니다.
  6. 검증 과정에 예외가 발생하지 않았다면, 해당 유저의 정보를 SecurityContext에 넣어줍니다.

CustomUserDetailService

@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    @Cacheable(value = CacheKey.USER, key = "#username", unless = "#result == null")
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = memberRepository.findByUsernameWithAuthority(username).orElseThrow(() -> new NoSuchElementException("없는 회원입니다."));
        return CustomUserDetails.of(member);
    }
}

위에 보이는 @Cacheable은 토큰을 줄 때 마다 데이터베이스를 거치는 것을 줄이기 위해 설정해두었습니다. 해당 내용은 2편에서 자세하게 설명드리겠습니다.

CustomUserDetails

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CustomUserDetails implements UserDetails {

    private String username;
    private String password;
    @Builder.Default
    private List<String> roles = new ArrayList<>();

    public static UserDetails of(Member member) {
        return CustomUserDetails.builder()
                .username(member.getUsername())
                .password(member.getPassword())
                .roles(member.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;
    }
}

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

JwtTokenUtil

@Slf4j
@Component
public class JwtTokenUtil {

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

    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 Key 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에서 설정한 secretkey로 서명 후 HS256 알고리즘으로 암호화합니다.
  2. 토큰 추출 메서드입니다. 서명했을 때의 secretkey로 서명하고 토큰을 만들때 username, 발급날짜, 만료기간을 넣었던 payload를 가져옵니다.

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;
}

JwtHeaderUtilEnums

@Getter @AllArgsConstructor
public enum JwtHeaderUtilEnums {

    GRANT_TYPE("JWT 타입 / Bearer ", "Bearer ");

    private String description;
    private String value;
}

회원가입

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

Controller

@RestController
@RequiredArgsConstructor
public class Api {

    private final MemberService memberService;
    private final JwtTokenUtil jwtTokenUtil;

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

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

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

JoinDto

@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class JoinDto {

    private String email;
    private String password;
    private String nickname;
}

Service

@Service
@RequiredArgsConstructor
@Transactional
public class MemberService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;
    private final RefreshTokenRedisRepository refreshTokenRedisRepository;
    private final LogoutAccessTokenRedisRepository logoutAccessTokenRedisRepository;
    private final JwtTokenUtil jwtTokenUtil;

    public void join(JoinDto joinDto) {
        joinDto.setPassword(passwordEncoder.encode(joinDto.getPassword()));
        memberRepository.save(Member.ofUser(joinDto));
    }

    public void joinAdmin(JoinDto joinDto) {
        joinDto.setPassword(passwordEncoder.encode(joinDto.getPassword()));
        memberRepository.save(Member.ofAdmin(joinDto));
    }
}

passwordEncoder를 통해 비밀번호를 암호화하여 member를 저장합니다. 또한 권한 처리를 위해 Member 객체 생성시 일반 유저와 어드민을 구분하여 생성합니다.

Member

@Entity
@Getter
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor(access = PROTECTED)
@Builder
public class Member {

    @Id @GeneratedValue(strategy = IDENTITY) @Column(name = "MEMBER_ID")
    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 = "member", cascade = ALL, orphanRemoval = true)
    @Builder.Default
    private Set<Authority> authorities = new HashSet<>();

    public static Member ofUser(JoinDto joinDto) {
        Member member = Member.builder()
                .username(UUID.randomUUID().toString())
                .email(joinDto.getEmail())
                .password(joinDto.getPassword())
                .nickname(joinDto.getNickname())
                .build();
        member.addAuthority(Authority.ofUser(member));
        return member;
    }

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

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

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

Authority

@Entity
@Getter
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor(access = PROTECTED)
@Builder
public class Authority implements GrantedAuthority {

    @Id @GeneratedValue(strategy = IDENTITY) @Column(name = "AUTHORITY_ID")
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

    private String role;

    public static Authority ofUser(Member member) {
        return Authority.builder()
                .role("ROLE_USER")
                .member(member)
                .build();
    }

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

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

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


이제, 회원가입을 실행해보겠습니다.


image


image


데이터베이스에도 저장이 된 것을 확인할 수 있습니다.


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

반응형