본문 바로가기

Backend/Spring Boot

[Spring Boot] 스프링부트 + jwt 인증 구현하기 / Token 발급받기

 

안드로이드 네이티브 앱의 API 서버에 인증기능을 추가하려 한다.

 

기존에는 클라이언트에서 google 로그인 후, 요청 헤더값에 포함한 이메일로 사용자를 구분하는 데에 그쳤다.

분명 앱 내에서는 로그인을 해야만 API를 이용할 수 있었지만, 사실상 이메일만 알고 있다면 외부에서 얼마든지 모든 DB를 조회할 수 있던 것이다 ㄴ(ㅇ0ㅇ)ㄱ

 

물론 당시 생각으로도 웹뷰를 통한 세션 로그인이나 Oauth2 등 인증에 대해 가볍게 생각은 했었지만,

결국 완성하지 못해 이렇게 뒤늦게 공부를 하게 되었다..🥲

 

 

시작하기 전에,

당시엔 몰랐지만 구글링을 좀 하다보니 알게 된 사실이 있는데

네이티브앱과 서버가 통신하는 구조일때 Token인증이 가장 강력하고 쉽게 사용할 수 있다는 것이다.

Token과 항상 같이 언급되는 Session의 경우, 쿠키나 IP변경 등의 이유로 네이티브 앱에서 사용이 어렵다고 한다.

 

따라서 <네이티브앱 - 서버> 인증이 필요한데 뚜렷한 다른 대안이 없다면 고민하지 말고 Token을 먼저 공부 해보도록 하자.

 


 

생성해야 하는 파일과 패키지 구조는 다음과 같다.

 

 

이제 총 8개의 과정을 거치며 토큰을 생성해보자

1. 의존성 추가

2. SecurityConfig 설정파일 생성

3. TokenProvider 클래스 생성

4. Jwt 커스텀 필터 생성

5. CustomUserDetailService 클래스 생성

6. 도메인 생성

7. API 구현

8. h2 데이터베이스 설정

 

 

 

 

1. 의존성 추가

plugins {
   id 'org.springframework.boot' version '2.6.11'
   ...
}

...

dependencies {
   implementation 'org.springframework.boot:spring-boot-starter-security'
   implementation 'io.jsonwebtoken:jjwt:0.9.1'
   runtimeOnly 'com.h2database:h2'
   ...
}

사실 스프링부트 2.7.3 버전으로 진행하려 했지만 WebSecurityConfigurerAdapter 인터페이스 지원이 중단 되어 자료가 많지 않아 버전을 낮춰서 진행했다. 참고로 자바 버전은 11.

 

 

2. SecurityConfig 설정파일 생성

@RequiredArgsConstructor
@EnableWebSecurity  //Spring Security 설정 활성화
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtTokenProvider jwtTokenProvider;

    //암호화에 필요한 PasswordEncoder Bean 등록
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //h2 콘솔 사용
                .csrf().disable().headers().frameOptions().disable()
                .and()

                //세션 사용 안함
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()

                //URL 관리
                .authorizeRequests()
                .antMatchers("/join", "/login", "/h2-console/**").permitAll()
                .anyRequest().authenticated()
                .and()

                // JwtAuthenticationFilter를 먼저 적용
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
    }
}

Bean 등록과 http 설정들을 관리하는 클래스이다. 

스프링부트 2.7 이후로는 WebSecurityConfigurerAdapter 로 취소선이 생긴다...

 

 

3. TokenProvider 클래스 생성

@RequiredArgsConstructor
@Component
public class JwtTokenProvider {

    private String secretKey = "webfirewood";

    private long tokenValidTime = 30 * 60 * 1000L;     // 토큰 유효시간 30분

    private final UserDetailsService userDetailsService;

    // 객체 초기화, secretKey를 Base64로 인코딩
    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    // 토큰 생성
    public String createToken(String userPk, List<String> roles) {  // userPK = email
        Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위
        claims.put("roles", roles); // 정보는 key / value 쌍으로 저장
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims) // 정보 저장
                .setIssuedAt(now) // 토큰 발행 시간 정보
                .setExpiration(new Date(now.getTime() + tokenValidTime)) // 토큰 유효시각 설정
                .signWith(SignatureAlgorithm.HS256, secretKey)  // 암호화 알고리즘과, secret 값
                .compact();
    }

    // 인증 정보 조회
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    // 토큰에서 회원 정보 추출
    public String getUserPk(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

    // 토큰 유효성, 만료일자 확인
    public boolean validateToken(String jwtToken) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }

    // Request의 Header에서 token 값 가져오기
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("X-AUTH-TOKEN");
    }
}

토큰생성, 검증 등의 함수들을 구현해놓은 클래스이다. JWT에서 가장 핵심이 되는 클래스이다.

 

 

4. Jwt 커스텀 필터 생성

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // 헤더에서 토큰 받아오기
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);

        // 토큰이 유효하다면
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 토큰으로부터 유저 정보를 받아
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            // SecurityContext 에 객체 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        // 다음 Filter 실행
        chain.doFilter(request, response);
    }
}

http 요청이 들어오면 가장 먼저 거치게 될 필터이다. 

 

 

5. CustomUserDetailService 클래스 생성

@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return memberRepository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
    }
}

토큰의 인증정보를 조회할때 사용

 

 

6. 도메인 생성

 6-1. Member 객체 생성

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

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

    @Column(length = 100, nullable = false, unique = true)
    private String email;

    @Column(length = 300, nullable = false)
    private String password;

    @ElementCollection(fetch = FetchType.EAGER) //roles 컬렉션
    @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 email;
    }

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

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

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

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

 

 6-2. Repository 생성

public interface MemberRepository extends JpaRepository<Member, Long> {

    Optional<Member> findByEmail(String email);

}

 

 

7. API 구현

 7-1. cotroller 생성

@RequiredArgsConstructor
@RestController
public class MemberController {

    private final MemberService memberService;

    // 회원가입 API
    @PostMapping("/join")
    public Long join(@Valid @RequestBody MemberDto memberDto) {
        return memberService.join(memberDto);
    }

    // 로그인 API
    @PostMapping("/login")
    public String login(@RequestBody MemberDto memberDto) {
       return memberService.login(memberDto);
    }
}

 

 7-2. Service 생성

@RequiredArgsConstructor
@Service
public class MemberService {

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

    @Transactional
    public Long join(MemberDto memberDto){
        Member member = Member.builder()
                .email(memberDto.getEmail())
                .password(passwordEncoder.encode(memberDto.getPassword()))  //비밀번호 인코딩
                .roles(Collections.singletonList("ROLE_USER"))         //roles는 최초 USER로 설정
                .build();

        return memberRepository.save(member).getId();
    }

    @Transactional
    public String login(MemberDto memberDto){
        Member member = memberRepository.findByEmail(memberDto.getEmail())
                .orElseThrow(() -> new IllegalArgumentException("가입되지 않은 E-MAIL 입니다."));
        if (!passwordEncoder.matches(memberDto.getPassword(), member.getPassword())) {
            throw new IllegalArgumentException("잘못된 비밀번호입니다.");
        }
        // 로그인에 성공하면 email, roles 로 토큰 생성 후 반환
        return jwtTokenProvider.createToken(member.getUsername(), member.getRoles());
    }
}

 

 7-3. dto 생성

@Getter
public class MemberDto {

    @NotNull
    @Size(min = 3, max = 100)
    private String email;

    @NotNull
    @Size(min = 8, max = 300)
    private String password;

}

 

 

8. h2 데이터베이스 설정

application.yml

spring:
  h2:
    console:
      enabled: true
  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
    defer-datasource-initialization: true

 

이제 완성이다!!!

postman과 h2로 테스트를 진행 해보자ㅎㅎ

 

우선 테이블 2개가 생성된 것을 확인할 수 있다.

 

 

회원가입을 진행하고

 

 

로그인 API를 보내면 토큰을 발급받은 것을 확인할 수 있다!!!

 

 

이제 클라이언트는 응답받은 토큰을 저장하고 요청 헤더에 포함해 인증을 받으면 된다.

간단한 API만 추가하고 테스트 하면 될 것 같긴 하지만

그 과정은 다음 포스팅에서 다룰 수 있도록 긍정적으로 검토해 봐야게따(난 토큰을 받아보고 싶었을 뿐이야...)

 

 

 

참고로 지금 진행한 Jwt는 정말 간단하고 간단한 최소한의 구현만 되어있는 상태라 다른 블로그들도 같이 참고 하면 도움이 될 것 같다. 

또 Jwt가 면접관님도 질문하고 싶어할 정도로 깊이가 깊은 내용인데, 사실 아직 이해 못한 부분이 많다.

Jwt 뿐만 아니라 스프링부트, 패키지 구성, 주석, 어노테이션 등등 모든 부분에 대해 잘못되거나 아쉬운 부분이 있다면 댓글 정말 감사하겠습니다..

 

 

 

 

참고

 

SPRING SECURITY + JWT 회원가입, 로그인 기능 구현

이전에 서블릿 보안과 관련된 포스트(링크)를 작성했던 적이 있습니다. 서블릿 기반의 웹 애플리케이션에서 인증과 인가 과정을 간단하게 설명했습니다. 스프링에서는 마찬가지로 이런 인증과

webfirewood.tistory.com

 

로그인 방식에 대해 알아보자

이 글에서는 두가지 로그인 방식에 대해 비교하며 정리하는 방식으로 글을 작성하겠다. 먼저 필자는 최근 모바일 앱 개발에 벡엔드를 맡게 되었다. 학교 친구들끼리 하는 사이드 프로젝트이다..

velog.io

 

Spring boot를 활용한 JWT 구현

JSON 객체를 사용해서 토큰 자체에 정보들을 저장하고 있는 Web Token이라고 정의할 수 있다.JWT는 Header, Payload, Signature 3개의 부분으로 구성되어져 있다.Header : Signature를 해싱하기 위한 알고리즘 정

velog.io