JWT를 이용한 회원가입과 로그인

JeongMin·2023년 5월 31일
0

회원가입, 로그인 기능 추가


📄요구 사항

✔ 1. 회원가입 기능

  • 회원가입 시 이메일, 패스워드를 받아서, DB에 이메일, 패스워드, 회원 가입 시간을 저장해야 한다.
  • 유저에 대한 정보가 저장될 때, id(PK, primary key)도 같이 Auto-increment 형식으로 저장돼야 한다.
  • 이메일에 반드시 @가 1개만 포함되어 있어야 한다.
  • 이메일에 공백이 포함될 수 없다.
  • 중복된 이메일이 존재할 수 없다.
  • 패스워드에 공백이 포함될 수 없다.
  • 패스워드는 8자 이상 15자 이하여야 한다.

Email

@Getter
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Email {

    private static final String EMAIL_REGEX = "^[^\\s@]+@[^\\s@]+$";
    private static final Pattern EMAIL_PATTERN = Pattern.compile(EMAIL_REGEX);

    @Column(name = "email")
    private String value;

    public Email(String value) {
        validate(value);
        this.value = value;
    }

    public void validate(String value){

        if (!EMAIL_PATTERN.matcher(value).matches()) {
            throw new InvalidEmailException();
        }
    }
}

Password

@Getter
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Password {

    private static final String PASSWORD_REGEX = "^(?!.*\\s).{8,15}$";
    private static final Pattern PASSWORD_PATTERN = Pattern.compile(PASSWORD_REGEX);

    @Column(name = "password")
    private String value;

    public Password(String value) {
        validate(value);
        this.value = value;
    }

    public void validate(String value) {

        if (!PASSWORD_PATTERN.matcher(value).matches()) {
            throw new InvalidPasswordException();
        }
    }
}

Roles

public enum Roles implements GrantedAuthority{
    ADMIN, USER;

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

Member

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

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "member_id", nullable = false)
    private Long id;

    @Embedded
    private Email email;

    @Embedded
    private Password password;

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime regTime;

    @Enumerated(EnumType.STRING)
    private Roles roles;

    @Builder
    public Member(String email, String password, LocalDateTime regTime) {
        this.email = new Email(email);
        this.password = new Password(password);
        this.regTime = LocalDateTime.now();
        this.roles = Roles.USER;
    }
}
  • 값 타입을 사용해서 이메일과 비밀번호에 대한 검증을 각 타입 안에서 했다.
  • 검증은 정규식을 통해서 검증했다.

MemberRepository

public interface MemberRepository extends JpaRepository<Member, Long> {

    Optional<Member> findByEmailValueAndPasswordValue(String email, String password);

    boolean existsByEmailValue(String email);

    Member findByEmailValue(String email);

}
  • 중복된 이메일이 있는지 확인하기 위해 JPA namedQuery로 추상 메서드를 생성했다.

MemberService

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {

    private final MemberRepository memberRepository;
    private final JwtTokenProvider jwtTokenProvider;

    @Transactional
    public String signUp(MemberRequest memberRequest){

        if(!memberRepository.existsByEmailValue(memberRequest.getEmail())) {
            Member member = Member.builder()
                    .email(memberRequest.getEmail())
                    .password(memberRequest.getPassword())
                    .regTime(memberRequest.getRegTime())
                    .build();

            memberRepository.save(member);
            return "회원가입이 되었습니다!";
        } else {
            throw new DuplicateEmailException();
        }
    }
}
  • Service에서 중복되는 회원이 있는지 검사하고 중복된 회원이 없다면 회원가입을 정상적으로 실행하게 만드는 메서드를 생성한다.

✔ 2. 로그인 기능

  • 로그인 시 이메일, 패스워드 값을 받는다.
  • 로그인에 성공했을 때, JWT를 활용해 Access Token 값을 응답해야 한다.
  • JWT의 payload에는 사용자의 id(PK, primary key)가 반드시 담겨있어야 한다.

gradle

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.security:spring-security-test'
implementation group: 'com.auth0', name: 'java-jwt', version: '4.2.1'
implementation 'io.jsonwebtoken:jjwt:0.9.1'

SecurityConfig

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

    private final JwtTokenProvider jwtTokenProvider;

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

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

        http.csrf().disable();
        //http.httpBasic().disable();
        http.httpBasic().disable()
                .authorizeRequests()
                .antMatchers("/test").authenticated()
                .antMatchers("/admin/**").hasRole("ADMIN")
                .antMatchers("/user/**").hasRole("USER")
                .antMatchers("/**").permitAll()
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                        UsernamePasswordAuthenticationFilter.class); 
       
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);


    }
}
  • JWT와 Spring Security를 사용하기 위해 의존성을 추가하고 Config에 시큐리티 설정을 한다. (WebSecurityConfigurerAdapter를 상속받는 방식은 Deprecated된 방식이기 때문에 수정 예정)

JwtTokenProvider

@RequiredArgsConstructor
@Component
@Slf4j
public class JwtTokenProvider {

    private String secretKey = "myprojectsecret";

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

    private final MemberDetailsService memberDetailsService;

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

    // JWT 토큰 생성
    public LoginResponse createToken(Long userId, String roles) {

        Claims claims = Jwts.claims();
        claims.put("userId", userId);
        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() + tokenValidTime))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        return LoginResponse.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 userDetails = memberDetailsService.loadUserByUsername(getUserPk(accessToken));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

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

    // Request의 Header에서 token 값을 가져옵니다.
    public String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")){
            return bearerToken.substring(7);
        }
        return null;
    }

    // 토큰의 유효성 + 만료일자 확인
    public boolean validateToken(String jwtToken) {
        try {
            Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
        } catch (MalformedJwtException e) {
            log.info("Invalid JWT Token", e);
            throw new JwtException("유효하지 않은 토큰입니다.");
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT Token", e);
            throw new JwtException("기한이 만료된 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT Token", e);
            throw new JwtException("지원하지 않는 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty.", e);
            throw new JwtException("claims 정보가 비어있습니다.");
        } catch (SignatureException e){
            log.info("JWT signature does not match locally computed signature.", e);
            throw new JwtException("JWT 서명이 로컬로 산정된 서명과 일치하지 않습니다.");
        }
        return false;
    }

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

}

MemberDetailsService

@RequiredArgsConstructor
@Service
public class MemberDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = memberRepository.findByEmailValue(username);

        if (member == null) {
            throw new UsernameNotFoundException("해당 이메일을 찾을 수 없습니다.");
        }

        return org.springframework.security.core.userdetails.User
                .withUsername(member.getEmail().getValue())
                .authorities(member.getRoles())
                .accountExpired(false)
                .accountLocked(false)
                .credentialsExpired(false)
                .disabled(false)
                .build();
    }
}

JwtAuthenticationFilter

@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);
            
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);

    }
}
  • JwtTokenProvider 를 통해서 JWT를 Access Token을 생성할 수 있다.

MemberService

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {

    private final MemberRepository memberRepository;
    private final JwtTokenProvider jwtTokenProvider;
    
    public LoginResponse logIn(LoginRequest loginRequest){

        Member member = memberRepository.findByEmailValueAndPasswordValue(loginRequest.getEmail(),
                loginRequest.getPassword()).orElseThrow(InvalidCredentialsException::new);

        return jwtTokenProvider.createToken(member.getId(), member.getRoles().getAuthority());
    }
    
}
  • 요청 DTO를 통해서 아이디와 비밀번호를 받고 아이디와 비밀번호가 DB에 있는지 확인한 후에 리턴값으로 JWTokenProvider에서 생성된 토큰을 응답 DTO에 넣어 반환한다.

✔ 3. 내 정보 조회 기능

  • 사용자가 요청을 보낼 때 Header에 JWT 토큰을 넘기도록 한다.
  • Header에 JWT 토큰이 담겨있지 않다면 에러로 응답한다.
  • Header에 담겨있는 JWT 토큰이 올바르지 않거나 조작되었다면 에러로 응답한다.
  • Header에 담겨있는 JWT 토큰의 만료기간이 지났다면 에러로 응답한다.
  • Haeder에 담겨있는 JWT 토큰이 올바르다면, JWT 토큰의 payload에 담겨있는 사용자의 id(PK, primary key)를 활용해라.
  • 응답값에는 id(PK, primary key), 이메일, 회원 가입 시간이 포함되어야 한다.
  • 응답값에는 패스워드가 포함되면 안 된다.

JwtTokenProvicer

public class JwtTokenProvider {

	.....
    // JWT 토큰 생성
    public LoginResponse createToken(Long userId, String roles) {

        Claims claims = Jwts.claims();
        claims.put("userId", userId);
        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() + tokenValidTime))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        return LoginResponse.builder()
                .grantType("Bearer")
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }
    
    .....
    
}
  • createToken메서드를 통해서 Claims 객체를 생성하고 토큰에 추가 정보를 넣는다. 요구 사항에서 나온 id값과 역할을 payload에 저장하고 accessToken을 생성한다.
// 토큰의 유효성 + 만료일자 확인
    public boolean validateToken(String jwtToken) {
        try {
            Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
        } catch (MalformedJwtException e) {
            log.info("Invalid JWT Token", e);
            throw new JwtException("유효하지 않은 토큰입니다.");
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT Token", e);
            throw new JwtException("기한이 만료된 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT Token", e);
            throw new JwtException("지원하지 않는 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty.", e);
            throw new JwtException("claims 정보가 비어있습니다.");
        } catch (SignatureException e){
            log.info("JWT signature does not match locally computed signature.", e);
            throw new JwtException("JWT 서명이 로컬로 산정된 서명과 일치하지 않습니다.");
        }
        return false;
    }
  • validateToken메서드를 통해서 Header에 JWT 토큰이 담겨있지 않다거나, Header에 담겨있는 JWT 토큰이 올바르지 않거나 조작되었거나, Header에 담겨있는 JWT 토큰의 만료기간이 지났다면 에러로 응답하게 만든다.

결과

  • 회원 가입으로 계정을 만든 후, 로그인을 했을 시 토큰으로 응답한다.

  • 헤더의 Authorization에 Access Token을 넣고 찾고 싶은 계정의 id를 경로에 넣고 get요청을 했을 때 계정 정보로 응답한다.

📒 나의 생각

  • 시큐리티와 JWT를 공부하고 적용하는데 어려움이 많았다. 지금도 구현하는데 집중했고 시큐리티에 대한 전반적인 이해가 완전히 되지 않았다. 시큐리티에 대해서도 깊게 공부해야 구현뿐만 아니라 응용도 가능할 것 같다.

0개의 댓글