본문 바로가기

개발/스프링부트

스프링 시큐리티로 회원가입 로그인 구현하기

안녕하세요.

오늘은 스프링 시큐리티를 사용해서 기본적인 회원가입과 로그인 기능을 구현해보도록 하겠습니다.


JWT 객체

우선 가장 기본적인 클라이언트에게 반환할 JWT 객체를 먼저 구현하도록 하겠습니다.

public class JwtToken {
   private String accessToken;
   private String refreshToken;
}

기본적인 엑세스 토큰과 더불어 엑세스 토큰 만료시 재발급 받을 수 있도록 리프레쉬 토큰도 넣어서 로그인 시 클라이언트에게 반환해 주기 위해 해당 객체를 만들었습니다.


JwtTokenProvider

이번에는 스프링 내에서 Jwt 토큰 생성, 증명 등 관련 작업을 수행하는 객체를 만들도록 하겠습니다.

public class JwtTokenProvider {
   private final Key key;

   private final int ACCESS_TOKEN_EXPIRED_TIME = 60 * 30 * 1000;
   private final int REFRESH_TOKEN_EXPIRED_TIME = 60 * 60 * 1000 * 24;

   public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
      byte[] secretByteKey = DatatypeConverter.parseBase64Binary(secretKey);
      key = Keys.hmacShaKeyFor(secretByteKey);
   }

   public JwtToken generateToken(Authentication authentication) {
      String authorities = authentication.getAuthorities().stream()
         .map(GrantedAuthority::getAuthority)
         .collect(Collectors.joining(","));

      String accessToken = Jwts.builder()
         .setSubject(authentication.getName())
         .claim("auth", authorities)
         .setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRED_TIME))
         .signWith(key, SignatureAlgorithm.HS256)
         .compact();

      String refreshToken = Jwts.builder()
         .setExpiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRED_TIME))
         .signWith(key, SignatureAlgorithm.HS256)
         .compact();

      return JwtToken.builder()
         .accessToken(accessToken)
         .refreshToken(refreshToken)
         .build();
   }

   public Authentication getAuthentication(String accessToken) {
      Claims claims = parseClaims(accessToken);

      if (claims.get("auth") == null) {
         throw new RuntimeException("권한 정보 X");
      }

      Collection<? extends GrantedAuthority> authorities = Arrays.stream(claims.get("auth").toString().split(","))
         .map(SimpleGrantedAuthority::new)
         .collect(Collectors.toList());

      UserDetails principal = new User(claims.getSubject(), "", authorities);
      return new UsernamePasswordAuthenticationToken(principal, "", authorities);
   }

   private Claims parseClaims(String accessToken) {
      try {
         return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
      } catch(ExpiredJwtException e) {
         return e.getClaims();
      }
   }

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

      return false;
   }
}

우선 jwt토큰을 생성해서 발급해주기 위해서는 secret key가 필요합니다. JWT는 공개 키로 데이터를 암호화하고 비밀 키로 서명을 하여 access token을 발급하게 됩니다. 자세한 내용은 이 곳을 확인해보시면 좋을 것 같습니다 :)

그래서 일단 secret key 는 랜덤하게 생성해 주셔도 되지만 저는

그냥 위 명령어를 통해 생성한 값을 key로 사용하기로 했습니다!

그리고 위와 같이 application.yml 내에 해당 정보를 넣어주었습니다.

이제 method 하나씩 살펴보도록 하겠습니다.

public JwtToken generateToken(Authentication authentication) {
   String authorities = authentication.getAuthorities().stream()
      .map(GrantedAuthority::getAuthority)
      .collect(Collectors.joining(","));

   String accessToken = Jwts.builder()
      .setSubject(authentication.getName())
      .claim("auth", authorities)
      .setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRED_TIME))
      .signWith(key, SignatureAlgorithm.HS256)
      .compact();

   String refreshToken = Jwts.builder()
      .setExpiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRED_TIME))
      .signWith(key, SignatureAlgorithm.HS256)
      .compact();

   return JwtToken.builder()
      .accessToken(accessToken)
      .refreshToken(refreshToken)
      .build();
}

이번 함수는 이름에서 유추할 수 있듯이 토큰을 생성하는 함수입니다. 유저의 권한을 토큰에 포함시켜서 생성해주는데요, 보시면 Jwts를 사용하여 토큰을 생성했습니다.

implementation 'io.jsonwebtoken:jjwt-api:0.11.5'

해당 라이브러리는 위 의존성을 추가해주시면 사용할 수 있습니다. 그리고 처음 만들어주었던 JwtToken객체에 토큰들을 집어넣고 반환하는 것을 볼 수 있습니다.

getAuthentication 함수는 Jwt필터를 만들 때 알아보도록 하겠습니다.

마지막으로 validateToken 함수는 token이 유효한지 확인해주는 함수입니다.


JwtAuthenticationFilter

public class JwtAuthenticationFilter extends OncePerRequestFilter {

   private final JwtTokenProvider jwtTokenProvider;
   @Override
   protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
      FilterChain filterChain) throws ServletException, IOException {
      String token = resolveToken(request);

      if (token != null && jwtTokenProvider.validateToken(token)) {
         Authentication authentication = jwtTokenProvider.getAuthentication(token);
         SecurityContextHolder.getContext().setAuthentication(authentication);
      }
      filterChain.doFilter(request, response);
   }

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

이 코드는 JWT토큰을 request에 포함했을 때 인증을 해주기 위해 구현된 필터입니다. 사실 오늘은 회원가입, 로그인만 구현할 것이기 때문에 사용되지 않는 필터이긴 한데요. 후에 게시판 기능도 구현할 것이기 때문에 만들어줬습니다.

OncePerRequestFilter를 상속함으로 한 Http요청당 한 번만 실행되는 것을 보장합니다.

메소드를 확인해보면 특별한 것은 없고 유효한 토큰인지 확인하고 Security Context에 인증 객체를 저장하는 과정임을 알 수 있습니다.

UserDetailService

public class MemberUserDetailService implements UserDetailsService {

   private final MemberRepository memberRepository;

   @Override
   @Transactional(readOnly = true)
   public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
      return memberRepository.findByEmail(username)
         .map(this::createUserDetails)
         .orElseThrow(() -> new RuntimeException());
   }

   public UserDetails createUserDetails(Member member) {
      SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(member.getRole().toString());
      return new User(member.getEmail(), member.getPassword(), Collections.singletonList(simpleGrantedAuthority));
   }
}

다음은 UserDetailsService입니다.

직접 구현한 Member객체를 사용할 것이기 때문에 UserDetailsService도 구현해주었습니다. username을 받아서 해당 username과 같은 Member를 찾아서 반환해주는 loadUserByUsername을 오버라이딩 하였습니다. 이제 스프링 시큐리티에서 저 함수를 통해 찾은 멤버로 아이디와 비밀번호의 일치 여부를 통해 유효한 로그인 요청인지를 판별할 것입니다 :)

마찬가지로 UserDetails도 직접 상속해서 구현해줄 수 있지만 복잡한 부분이 없기 때문에 같이 구현해주었습니다. 사용자 정보를 조회해서 반환해줍니다.

SecurityConfig

public class SecurityConfig {
   private final JwtTokenProvider jwtTokenProvider;

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

   @Bean
   public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
      return http
         .csrf().disable()
         .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
         .and()
         .formLogin().disable()
         .httpBasic().disable()
         .authorizeRequests()
         .antMatchers("/api/members").permitAll()
         .and()
         .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
         .build();
   }
}

스프링 시큐리티 설정입니다. 해당 설정과 동일하게 해주셔야 정상적으로 jwt필터를 등록할 수 있고, 세션 방식을 사용하지 않을 수 있습니다.

API 구현

`public class MemberController {
   private final MemberService memberService;

   @PostMapping("/signup")
   public ResponseEntity<SignUpResponse> signUp(@RequestBody SignUpRequest request) {
      return new ResponseEntity<>(memberService.signUp(request), HttpStatus.OK);
   }

   @PostMapping("/signin")
   public ResponseEntity<JwtToken> signIn(@RequestBody SignInRequest request) {
      return new ResponseEntity<>(memberService.signIn(request), HttpStatus.OK);
   }
}`

`public class MemberService {
   private final MemberRepository memberRepository;
   private final AuthenticationManagerBuilder authenticationManagerBuilder;
   private final JwtTokenProvider jwtTokenProvider;
   private final BCryptPasswordEncoder bCryptPasswordEncoder;

   public SignUpResponse signUp(SignUpRequest request) {
      Member member = new Member(request, bCryptPasswordEncoder);
      return new SignUpResponse(memberRepository.save(member));
   }

   public JwtToken signIn(SignInRequest request) {
      UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
         request.getEmail(), request.getPassword());

      Authentication authenticate = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
      return jwtTokenProvider.generateToken(authenticate);
   }
}`

컨트롤러와 서비스단입니다. 간단하게 회원가입, 로그인 API만 작성을 했습니다.

회원가입은 간단하게 이메일, 비밀번호 받아서 바로 member 객체 저장하도록 했습니다. 이 때 주의할점은 패스워드를 암호화해서 저장하지 않으면 저장할 때는 문제가 없지만 후에 로그인 시도를 할 때 시큐리티에서 오류를 내기 때문에 꼭 암호화해서 저장해주셔야 합니다.

로그인도 비슷하게 이메일, 비밀번호 받아서 시큐리티에서 인증이 된다면 구현해놓은 JwtTokenProvider에서 토큰을 생성해서 반환해주게 됩니다.

구현 결과

회원가입

회원가입 정상적으로 진행됩니다.

로그인

 

로그인도 마찬가지로 성공한다면 엑세스토큰과 리프레쉬 토큰을 정상적으로 잘 발급해주는 것을 확인할 수 있습니다.

403

이렇게 비밀번호가 틀리면 오류를 내줍니다 :)


마치며

지금까지 스프링 시큐리티와 JWT 토큰 방식을 사용해서 회원가입, 로그인 기능을 구현해봤습니다. 사실 처음 구현해보는 거라 너무 오래걸렸네요. 다음 시간에는 게시판 구현해보도록 하겠습니다 :)