이번에는 로그인에 대해 정리해보려고 한다.
아직 완벽하게 끝난것은 아니지만(Refresh Token관련한 부분) 먼저 구현한 부분에 대해 정리하려고 한다.

동작 원리

  1. 클라이언트에서 loginId 와 password로 로그인을 시도한다.
  2. 서버에서 DB에 loginId/password가 일치하는 유저가 있는지 검사한다.
  3. 일치하는 유저가 있으면 token을 발급해준다.
  4. 이후 클라이언트는 헤더에 토큰을 포함하여 서버로 요청을 보낸다.

사용한 라이브러리

implementation 'org.springframework.boot:spring-boot-starter-security'

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

Member.java

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;

    private String username;

    private String email;

    private String loginId;

    private String password;

    private String field; //유저 활동 지역

    @Enumerated(EnumType.STRING)
    private MemberType role;

    @OneToMany(mappedBy = "postWriter")
    private List<Post> posts = new ArrayList<>();
    @Embedded //생성일 수정일 삭제일
    private DateTime dateTime;

    @OneToMany(mappedBy = "member")
    private List<Diary> diaries = new ArrayList<>();

    @OneToMany(mappedBy = "commentWriter")
    private List<Comment> comments = new ArrayList<>();

    @Builder
    public Member(String username, String email, String loginId, String password, String field, MemberType role, DateTime dateTime) {
        this.username = username;
        this.email = email;
        this.loginId = loginId;
        this.password = password;
        this.field = field;
        this.role = role;
        this.dateTime = dateTime;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
        authorities.add(new SimpleGrantedAuthority(this.role.toString()));
        return authorities;
    }

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

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

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

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

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

UserDetails 인터페이스를 구현한 클래스는 스프링 시큐리티에서 사용자 정보로 인식된다.
스프링 시큐리티는 Member클래스를 이용해 인증 작업을 실시한다.

UserDetails을 구현하면 오버라이드 해야되는 메소드이다.

MemberService.java

@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class MemberService implements UserDetailsService {

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

    public ResponseResult signIn(String loginId, String password){

		// 1. Login ID/PW 를 기반으로 Authentication 객체 생성
        // 이때 authentication 는 인증 여부를 확인하는 authenticated 값이 false
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginId, password);
        // 2. 실제 검증 (사용자 비밀번호 체크)이 이루어지는 부분
        // authenticate 매서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드가 실행
        Authentication authentication = null;
        try {
            authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
        } catch (Exception e) {
            if (e instanceof BadCredentialsException){
                throw new BadCredentialsException("비밀번호가 틀렸습니다. 다시 시도해주세요.");
            } else if (e instanceof InternalAuthenticationServiceException){
                throw new InternalAuthenticationServiceException("아이디가 틀렸습니다. 다시 시도해주세요.");
            }
        }

        if (authentication == null){
            throw new AuthenticationException("로그인 실패입니다 다시 시도해주세요.");
        } else {
            // 3. 인증 정보를 기반으로 JWT 토큰 생성
            TokenInfo tokenInfo = jwtTokenProvider.generateToken(authentication);
            log.info(authentication.getName()+" 로그인");
            return new ResponseResult(HttpStatus.OK.value(), tokenInfo);
        }
    }

    @Override
    public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException {
        Optional<Member> findMember = memberRepository.findByLoginId(loginId);
        return findMember.map(this::createUserDetails).orElseGet(() -> createUserDetails(null));
    }

    // 해당하는 User 의 데이터가 존재한다면 UserDetails 객체로 만들어서 리턴
    private UserDetails createUserDetails(Member member) {
        return User.builder()
                .username(member.getUsername())
                .password(member.getPassword())
                .roles(member.getRole().toString())
                .build();
    }
}

UserDetailsService를 구현한 MemberService 클래스를 만들었다.
UserDetailsService를 구현하면 loadUserByUsername를 오버라이드 해야된다.
loadUserByUsername 메서드는 DB에서 유저 정보를 가져오는 역할을 한다.
사용자에게 입력받은 아이디와 비밀번호를 통해 UsernamePasswordAuthenticationToken 객체를 생성한다.
authenticate메서드를 통해 인증 과정이 이루어진다.
authenticate 메서드가 호출되면 loadUserByUsername 메서드가 실행되고 Member 정보가 있으면 UserDetails 객체로 만들어서 리턴한다.
실패하면 예외가 발생했는데 아이디가 틀렸을 때랑 비밀번호가 틀렸을 때 발생하는 예외가 달라 catch문 안에서 각각 처리해 주었다.
authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); 이 과정에서 예외가 발생하지 않았다면 만들어진 authentication객체를 바탕으로 JWT토큰을 생성한다.

JwtTokenProvider.java

@Slf4j
@Component
public class JwtTokenProvider {

    private final Key key;

    public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    // 유저 정보를 가지고 AccessToken, RefreshToken 을 생성하는 메서드
    public TokenInfo generateToken(Authentication authentication) {
        // 권한 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();
        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + 30*60*1000);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim("auth", authorities)
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + 30*60*1000))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

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

    // JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

        if (claims.get("auth") == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        // 클레임에서 권한 정보 가져오기
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get("auth").toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // UserDetails 객체를 만들어서 Authentication 리턴
        UserDetails principal = new User(claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    // 토큰 정보를 검증하는 메서드
    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;
    }

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

Key에는 생성자를 통해 application.yml파일에서 설정해둔 값이 들어간다.

사진에서는 짧게 나왔지만 토큰 암호화 복호화에 쓰이는 secretKey로서 token을 생성할 때 HS512알고리즘을 사용하기 때문에 64byte 이상으로 설정해야된다.
generateToken 메서드는 입력받은 Authentication 객체를 바탕으로 토큰을 생성한다.
accessToken의 유효시간은 탈취됐을 때의 위험성 때문에 짧게 설정한다.(30 X 60 X 1000 = 30분)
accessToken에는 setSubject으로 토큰 제목과 claim으로 정보를 넣어준다 claim은 key value쌍으로 이루어진다.


getAuthentication, validateToken 메서드는 이후 작성할 JwtAuthenticationFilter 클래스에서 호출해 사용한다.
validateToken으로 토큰이 유효한지 판별하고 유효한 토큰이라면 getAuthentication 메서드를 사용하여 권한 정보를 추출하고 Authentication 객체로 리턴한다.
getAuthentication 메서드 안에 SimpleGrantedAuthority클래스는 권한 객체를 생성하는 클래스다.
생성자로 권한 문자열("USER" or "ADMIN")을 넣어주면 권한 객체가 생성된다.

JwtAuthenticationFilter.java

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        // 1. Request Header 에서 JWT 토큰 추출
        String token = resolveToken((HttpServletRequest) request);

        // 2. validateToken 으로 토큰 유효성 검사
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }

    // Request Header 에서 토큰 정보 추출
    public static String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

SecurityContextHolder란 Authentication를 담고 있는 Holder라고 정의를 할 수 있다.
SecurityContextHolder.getContext().setAuthentication(authentication); 이 코드를 통해 인증된 결과를 SecurityContext에 저장한다.
https://ohtaeg.tistory.com/8 글을 참고하면 이해하는데 더 도움이 될 것 같다.
저번 글에서 설명했던것 처럼 이 필터를 UsernamePasswordAuthenticationFilter 보다 먼저 사용되게 필터 체인에 등록해준다.

@Slf4j
@RestControllerAdvice(assignableTypes = MemberController.class)
public class ExceptionController {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(BadCredentialsException.class)
    public ExceptionResponse signInHandle(BadCredentialsException exception){

        log.error(exception.getMessage());
        return new ExceptionResponse(HttpStatus.BAD_REQUEST.value(), exception.getMessage());
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(InternalAuthenticationServiceException.class)
    public ExceptionResponse signInHandle(InternalAuthenticationServiceException exception){

        log.error(exception.getMessage());
        return new ExceptionResponse(HttpStatus.BAD_REQUEST.value(), exception.getMessage());
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(AuthenticationException.class)
    public ExceptionResponse signInHandle(AuthenticationException exception){

        log.error(exception.getMessage());
        return new ExceptionResponse(HttpStatus.BAD_REQUEST.value(), exception.getMessage());
    }
}

로그인 과정에서 발생할 수 있는 예외를 처리하기 위해 @RestControllerAdvice, @ExceptionHandler를 사용했다.
@RestControllerAdvice는 대상으로 지정한 컨트롤러에 @ExceptionHandler기능을 부여한다.
@ExceptionHandler는 해당 컨트롤러에서 발생하는 처리하고 싶은 예외를 설정해주면 된다.
지정한 예외와 그 자식 예외까지 처리할 수 있다.

📚참고자료

https://gksdudrb922.tistory.com/217
https://to-dy.tistory.com/86
https://velog.io/@gmtmoney2357/스프링-시큐리티-Authentication-SecurityContext
https://zgundam.tistory.com/49
https://ohtaeg.tistory.com/9
https://ohtaeg.tistory.com/8

profile
iOS 개발자 지망생 https://github.com/10000DOO

0개의 댓글