개발

[Spring Boot] AccessToken, RefreshToken을 이용한 로그인 구현

changha. 2023. 7. 22. 15:58

이번에 팀 프로젝트 로그인 구현 방식으로 JWT를 적용해봤습니다 

다른 완성도 높은 방식들도 많지만 아직 완벽히 이해하진 못해서 스스로 이해 가능할 정도 수준으로 구현 했습니다😅


package구조

- 개인 패키지 이름

    - config

    - controller

    - dto

    - entity

    - repository

    - service 

    - jwt 

 

 Build.Gradle

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

group = 'com.donggram'
version = '0.0.1-SNAPSHOT'

java {
	sourceCompatibility = '17'
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
	maven {
		url 'https://repo.clojars.org'
		name 'Clojars'
	}
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	runtimeOnly 'com.h2database:h2'
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
	implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
	implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'

}


tasks.named('test') {
	useJUnitPlatform()
}

위의 'io.jsonwebtoken:~~~' dependency들은 꼭 넣어줘야 나중에 에러때문에 안 막힙니다

 

config/SecurityConfig.class

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .httpBasic().disable()
                .csrf().disable()
                .headers().frameOptions().disable().and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/h2-console/**").permitAll()
                .antMatchers("/api/login").permitAll()
                .antMatchers("/api/join").permitAll()
//                .anyRequest().authenticated()
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, refreshTokenRepository),
                        UsernamePasswordAuthenticationFilter.class)
                .build();
    }

}

JWT방식으로 하면 세션을 사용하지 않으므로 STATELESS로 설정 해놓습니다.

headers().frameOptions().disable() : h2 DB 접근을 위해 설정 합니다. 

 

entity/RefreshToken.class

@Getter
@Entity
@NoArgsConstructor
public class RefreshToken {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank
    private String refreshToken;

    @NotBlank
    private String studentId;

    public RefreshToken(String token, String studentId) {
        this.refreshToken = token;
        this.studentId = studentId;
    }

    public RefreshToken updateToken(String token) {
        this.refreshToken = token;
        return this;
    }


}

jwt/JwtTokenProvider.class

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    private String secretKey = "myprojectsecretmyprojectsecretmyprojectsecret";
    // 토큰 유효시간 30분
    private long tokenValidTime =  30 * 60 * 1000L;
    // refreshToken 유효시간 30일
    private long refreshTokenValidTime = 30 * 24 * 60 * 60 * 1000L;

    private final UserDetailsService userDetailsService;
    private final RefreshTokenRepository refreshTokenRepository;



    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    public TokenInfo generateToken(String userPk, List<String> roles){
        Claims claims = Jwts.claims().setSubject(userPk);
        claims.put("roles", roles);
        Date now = new Date();

        String accessToken = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + tokenValidTime) )
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now.getTime() + refreshTokenValidTime))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        return TokenInfo.builder()
                .grantType("Bearer")
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }

    public void setHeaderAccessToken(HttpServletResponse response, String accessToken) {
        response.setHeader("Access_Token", accessToken);
    }
    public void setHeaderRefreshToken(HttpServletResponse response, String refreshToken) {
        response.setHeader("Refresh_Token", refreshToken);
    }


    public Authentication getAuthentication(String accessToken){
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(accessToken));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    public String getUserPk(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

    public String getHeaderToken(HttpServletRequest request, String type) {
        return type.equals("Access") ? request.getHeader("Access_Token") : request.getHeader("Refresh_Token");
    }

    public boolean validateToken(String jwtToken) {
        try {
            Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken).getBody();
            Date expiration = claims.getExpiration();
            log.info("expiration : " + expiration);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    public boolean refreshTokenValidation(String token){

        if(!validateToken(token)) return false;

        Optional<RefreshToken> refreshToken = refreshTokenRepository.findByRefreshToken(token);

        return refreshToken.isPresent() && token.equals(refreshToken.get().getRefreshToken());

    }

}

∙메서드 설명

 

    - generateToken(Stirng userPk, List<String> roles) : 로그인 시 유저의 학번, 역할 파라미터로 받은 이후 토큰 생성      - setHeaderAccessToken() : Header에 accessToken 설정  

    - setHeaderRefreshToken() : Header에 refreshToken 설정  

    - getAuthentication(String accessToken) : accessToken으로 사용자의 인증정보 가져옴 

    - getUserPk() : 토큰을 복호화하여 학번 추출함 

    - getHeaderToken() : access, refresh 중 어떤 헤더를 가져올 것인지 

    - validateToken() : 해당 토큰이 유효한지 판단 

    - refreshTokenValidation() : 받아온 토큰이 DB에 있는 토큰과 일치하는지 판단 

 

 

jwt/JwtAuthenticationFilter.class

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtTokenProvider jwtTokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        String accessToken = jwtTokenProvider.getHeaderToken((HttpServletRequest) request, "Access");
        String refreshToken = jwtTokenProvider.getHeaderToken((HttpServletRequest) request, "Refresh");

        if(accessToken != null){

            //access 유효한 경우
            if (jwtTokenProvider.validateToken(accessToken)){
                Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);
                SecurityContextHolder.getContext().setAuthentication(authentication);
                log.info("accessToken 유효");
            }

            else if(refreshToken != null){

                boolean isRefreshToken = jwtTokenProvider.refreshTokenValidation(refreshToken);
                // access 만료 && refresh 유효
                if(isRefreshToken){

                    Optional<RefreshToken> refreshToken1 = refreshTokenRepository.findByRefreshToken(refreshToken);

                    String loginId = refreshToken1.get().getStudentId();
                    TokenInfo tokenInfo = jwtTokenProvider.generateToken(loginId, Collections.singletonList("ROLE_USER"));
                    String newAccessToken = tokenInfo.getAccessToken();

                    jwtTokenProvider.setHeaderAccessToken((HttpServletResponse) response, newAccessToken);

                    Authentication authentication = jwtTokenProvider.getAuthentication(newAccessToken);
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                    log.info("accessToken만료, refreshToken 발급함");

                }

                // access , refresh 둘다 만료
                else{
                    jwtExceptionHandler((HttpServletResponse) response, "RefreshToken Expired", HttpStatus.BAD_REQUEST);
                    log.info("accessToken,refreshToken 둘 다 만료");
                    return;
                }

            }
        }
        chain.doFilter(request, response);
    }

    public void jwtExceptionHandler(HttpServletResponse response, String msg, HttpStatus status) {
        response.setStatus(status.value());
        response.setContentType("application/json");
        try {
            String json = new ObjectMapper().writeValueAsString(new GlobalResDto(msg, status.value()));
            response.getWriter().write(json);
        } catch (Exception e) {
            log.error(e.getMessage());
        }
    }
}

 

GenericFilterBean은 Spring Security에서 제공하는 필터를 구현하기 위한 추상 클래스 입니다. 이 클래스를 상속받아 커스텀 필터를 만들 수 있습니다. 커스텀 필터를 이용해 AccessToken,RefreshToken 검증 로직을 추가했습니다. 

 

고려해야 할 경우가 3가지 있습니다.

1. AccessToken이 유효한 경우 : 해당 사용자를 인증 

2. AccessToken 만료 && RefreshToken 유효 : RefreshToken 유효성 검사 후 새로운 AccessToken 생성 

3. AccessToken && RefreshToken 모두 만료 : 오류 응답을 보냄 

 

service/CustomUserDetailService.class

@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {

    private final MemberRepository memberRepository;


    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        return memberRepository.findByStudentId(username)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
    }
}

JwtTokenProvider의 getAuthentication에서 쓰입니다. 

사용자의 정보를 데이터베이스에서 가져와 Spring Security에서 사용할 수 있는 UserDetails로 반환 합니다.

 

service/MemberService.class

@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;
    private final RefreshTokenRepository refreshTokenRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtTokenProvider jwtTokenProvider;

    public ResponseDto join(SignUpDto signUpDto) throws Exception{
        if(memberRepository.findByStudentId(signUpDto.getStudentId()).isPresent()){
            throw new Exception("이미 가입된 학번 입니다.");
        }
        if(!signUpDto.getPassword().equals(signUpDto.getCheckPassword())){
            throw new Exception("비밀번호가 일치 하지 않습니다.");
        }

        Member member = Member.builder()
                .studentId(signUpDto.getStudentId())
                .password(signUpDto.getPassword())
                .name(signUpDto.getName())
                .college1(signUpDto.getCollege1())
                .college2(signUpDto.getCollege2())
                .major1(signUpDto.getMajor1())
                .major2(signUpDto.getMajor2())
                .profile_image("NULL")
                .roles(Collections.singletonList("ROLE_USER"))
                .build();
        member.encodePassword(passwordEncoder);
        memberRepository.save(member);


        return ResponseDto.builder()
                .status(200)
                .responseMessage("회원가입 성공")
                .data("NULL")
                .build();
    }

    public ResponseDto login(SignInDto signInDto) {
        Member member = memberRepository.findByStudentId(signInDto.getStudentId())
                .orElseThrow(() -> new IllegalArgumentException("가입되지 않은 학번입니다."));


        TokenInfo tokenInfo = jwtTokenProvider.generateToken(signInDto.getStudentId(), member.getRoles());

        //RefreshToken 있는지 확인
        Optional<RefreshToken> refreshToken = refreshTokenRepository.findByStudentId(signInDto.getStudentId());

        //있으면 refreshToken 업데이트
        //없으면 새로 만들고 저장
        if(refreshToken.isPresent()){
            refreshTokenRepository.save(refreshToken.get().updateToken(tokenInfo.getRefreshToken()));
        }else {
            RefreshToken newToken = new RefreshToken(tokenInfo.getRefreshToken(), signInDto.getStudentId());
            refreshTokenRepository.save(newToken);
        }

        return ResponseDto.builder()
                .status(200)
                .responseMessage("로그인 성공")
                .data(tokenInfo)
                .build();
    }
}

회원가입과 로그인 로직입니다. 

 

dto/SignInDto.class

@Data
@Builder
@AllArgsConstructor
public class SignInDto {

    @NotBlank(message = "학번을 입력해주세요. ")
    private String studentId;

    @NotBlank(message = "비밀번호를 입력해주세요.")
    private String password;
}

dto/SignUpDto.class

@Data
@Builder
@AllArgsConstructor
public class SignUpDto {

    @NotBlank(message = "학번을 입력해주세요.")
    private String studentId;

    @NotBlank(message = "이름을 입력해주세요.")
    private String name;

    @NotBlank(message = "비밀번호를 입력해주세요.")
    private String password;

    private String checkPassword;

    @NotBlank(message = "단과대를 선택해주세요.")
    private String college1;

    private String college2;

    @NotBlank(message = "전공을 선택해주세요.")
    private String major1;

    private String major2;

    private String role;

}

dto/ResponseDto.class

@Getter
@RequiredArgsConstructor
@Builder
public class ResponseDto {
    private final int status;
    private final String responseMessage;
    private final Object data;
}

controller/MemberController.class

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class MemberController {

    private final MemberService memberService;

    @PostMapping("/join")
    public ResponseEntity<?> join(@RequestBody SignUpDto signUpDto) throws Exception {

        ResponseDto responseDto = memberService.join(signUpDto);

        return ResponseEntity.ok(responseDto);

    }

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody SignInDto signInDto) throws Exception {

        ResponseDto responseDto = memberService.login(signInDto);

        return ResponseEntity.ok(responseDto);
    }
}

 

entity/Member.class

@Getter
@AllArgsConstructor
@Builder
@Entity
@NoArgsConstructor
public class Member extends BaseTimeEntity implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @Column(nullable = false)
    private String studentId;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String college1;

    @Column
    private String college2;

    @Column(nullable = false)
    String major1;

    @Column
    String major2;

    @Column
    String profile_image;

    @ElementCollection(fetch = FetchType.EAGER)
    @Builder.Default
    private List<String> roles = new ArrayList<>();
    
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.roles.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

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

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }


    public void encodePassword(PasswordEncoder passwordEncoder){
        this.password = passwordEncoder.encode(password);
    }

}

 

repository/MemberRepository.interface

public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByStudentId(String student_id);
    Optional<Member> findByName(String username);
}

repository/RefreshTokenRepository.interface

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByStudentId(String studentId);
    Optional<RefreshToken> findByRefreshToken(String token);
}

 

 


PostMan 테스트 결과