Security + JWT 이메일 회원가입 및 로그인 구현

Elmo·2023년 12월 29일
0

프론트엔드, 백엔드를 둘 다 다뤄본 경험 상 우선 로그인 API가 돼야 도미노처럼 다른 기능들을 수월하게 구현할 수 있었다.
그래서 회원가입 및 로그인 API를 먼저 구현했다.
시큐리티랑 jwt 너무 헷갈려서 고생 많이 했다...

스프링부트 시큐리티 + jwt 방식

보통 시큐리티 + jwt시큐리티 + session 방식이 있음. 전자가 더 많이 사용됨

  • 스프링부트 시큐리티는 기본적으로 세션+쿠키 방식
  • 쿠키와 다르게 세션은 서버에 세션을 두고 클라이언트한테 세션 id 부여. 클라이언트는 쿠키에 세션 id를 담아 요청하고 서버는 세션에서 클라이언트 정보만 꺼내면됨. (브라우저 종료될 때 까지만 인증 유지 )

인증 : 로그인 정보와 일치한가? 인가 : 접근할 권한이 있는가?

  • 세션 방식은 인가 정보를 처음 로그인한 서버에 담고 있어서 분산 서버일 경우 무조건 해당 서버에 접근해야함. 서버의 부하가 커진다는 것
  • 세션방식은 웹에 한정돼있는 반면 jwt방식은 모바일에서도 원활하게 사용가능함
  • JWT토큰은 Header, Payload, Signature의 3 부분으로 이루어지고 점으로 구분됨.
  • 서버에 토큰 검증 클래스나 메소드만 존재하면 '인가' 과정까지 해결이 가능

패키지 구조

만들다보니 생각보다 많아졌다;;
일단 패키지 다 만들어 놓고 base와 user부분만 구현함.
base는 BaseResponse가 담겨있음.

User

기본적으로 Entity-Dto-Repository-Service-Controller의 구조를 가진다.

User Entity

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Entity
public class User extends BaseTimeEntity implements UserDetails {

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

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

    @Column(nullable = false,length = 45)
    private String nickname;

    @NotNull
    private String password;

    private String profileImage;

    @Column(name = "user_type")
    @Enumerated(EnumType.STRING)
    private UserType userType;

    private String refreshToken;

    @Enumerated(EnumType.STRING)
    private Role role;

    public void updateRefreshToken(String refreshToken) {
        this.refreshToken = refreshToken;
    }

    public void addUserAuthority() {
        this.role = Role.ROLE_USER;
    }

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

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        ArrayList<GrantedAuthority> auth = new ArrayList<>();
        auth.add(new SimpleGrantedAuthority(role.name()));
        return auth;
    }

    @Override
    public String getUsername() {
        return this.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;
    }
}

EmailLoginRequestDto

@Getter
@ToString
@NoArgsConstructor
public class EmailLoginRequestDto {
    @Email
    private String email;
    @NotNull
    private String password;
}

EmailRequestDto

@Data // getter/setter, requiredArgsController, ToString 등 합쳐놓은 세트
@Builder
@AllArgsConstructor
public class EmailRequestDto {

    @NotEmpty(message = "이메일을 입력해주세요")
    @Email
    private String email;

    @NotEmpty(message = "비밀번호를 입력해주세요")
    @Pattern(regexp = " ^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d~!@#$%^&*()+|=]{8,20}$",
            message = "8자 이상이며 최대 20자까지 허용. 반드시 숫자, 문자 포함")
    private String password;

    @NotEmpty(message = "닉네임을 입력해주세요")
    @Size(min=2, message = "닉네임은 최소 두 글자 이상입니다")
    private String nickname;

    private String profileImage;

    @Builder
    public User toEntity(){
        return User.builder()
                .email(email)
                .nickname(nickname)
                .password(password)
                .role(Role.ROLE_USER)
                .userType(UserType.EMAIL)
                .build();
    }
}

UserRepository

public interface UserRepository extends JpaRepository<User,Long> {
    Optional<User> findByEmail(String email);
}

Role

public enum Role {
    ROLE_USER,ROLE_ADMIN;
}

Security JWT

어휴.. 에러도 많이 겪고 몇 일을 여기에 매달렸다.

SecurityConfig

SecurityConfig 구현 과정에서 고민이 많았다.
로그인을 formLogin을 통해서 처리하게 된다면 타임리프를 이용하여 로그인 페이지를 구현해줘야한다. 처음에는 formLogin으로 설정하고 타임리프를 막 찾아보면서 프론트를 구현하려고 했는데 이게 더 시간이 오래걸릴거 같아서 때려침..

실제 프로젝트나 현업에서도 타임리프는 잘 사용하지 않고 프론트는 리액트 등을 이용하여 따로 구현하는 방식이 훨씬 많다고 한다. 본인도 리액트가 익숙하기 때문에 그냥 타임리프를 쓰지 않고 리액트로 빠르게 프론트를 구현하기로 했다.
타임리프가 특별히 api 호출할 필요없이 바로 변수랑 mapping이 가능해서 간편하긴한데 이것도 처음부터 새로 배우면서 익숙해지려고 하니깐 머리가 아팠다...

따라서 SecurityConfig에서 formLogin을 사용하지 않기로 했다.
구현하기 힘들었던 부분은 스프링부트 최신버전(3.2.0)을 사용하면서 새로운 규칙으로 설정해야되는 것이다.
검색했을 때 대부분의 블로그 글들은 예전 방식으로 설정하는 방법으로 올라와있어서 하나하나 비교하면서 바꾸느라 좀 애먹었다..

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig{
    private final JwtTokenProvider jwtTokenProvider;
    @Bean
    public SecurityFilterChain filterChain(final @NotNull HttpSecurity http) throws Exception {
        // 스프링부트 3.1.x~ 시큐리티 설정 방식이 변경됨. .and()를 사용하지 않음
        http.httpBasic(HttpBasicConfigurer::disable);
        http.csrf(AbstractHttpConfigurer::disable);
        http.sessionManagement(configurer-> // 세션 사용안해서 STATELESS 상태로 설정
                configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(authorize->
                authorize
                        .requestMatchers("/user/login/email","/user/join/email").permitAll()
                        .requestMatchers("/user/profile/**","/user/test").hasRole("USER")
                        .anyRequest().authenticated()
                )
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);


        return http.build();
    }

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

혹시 저처럼 헤매시는 분들은 이걸 참고하시길..
예전처럼 .and()를 사용하지 않아서 더 깔끔해진 거 같다.

SecurityUtil

@Slf4j
@Service
public class SecurityUtil {
    //SecurityContext에서 Authentication객체를 꺼내와서 이 객체를 통해 로그인한 username을 리턴해주는 간단한 유틸성 메소드
    public static String getLoginUsername(){
        UserDetails user = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        return user.getUsername();
    }
}

유틸성 메소드를 정의하는 SecurityUtil 클래스이다. getLoginUsername()을 통해 현재 로그인한 사용자의 정보를 알 수 있다.

이외 클래스

JwtTokenProvider
처음에 정한 secret-key와 만료시간을 이용해서 jwtToken을 생성하는 함수와 jwtToken의 유효성을 검증하는 함수를 정의하는 클래스이다. resolveToken() 함수는 클라이언트의 request로부터 헤더에서 클라이언트가 넣은 accessToken을 꺼내며 parseJwt() 함수는 token을 받아서 파싱하는 함수이다.

    public String createToken(String userPk, List<String> roles) {
        // 권한 가져오기
        Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위
        claims.put("roles", roles); // 정보는 key/value 쌍으로 저장됩니다.
        Date now = new Date();
        // Access Token 생성
        String accessToken = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + ACCESS_TOKEN_VALID_TIME))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        return accessToken;
    }
    // JWT 토큰에서 인증 정보 조회
    public Authentication getAuthentication(String token) {
        Claims claims = parseJwt(token);
        String s=claims.getSubject();

        UserDetails userDetails = customUserDetailService.loadUserByUsername(s);
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }
    // 토큰 정보를 검증하는 메서드
    public boolean validateToken(String token) {
        Claims claims = null;
        try {
            claims = parseJwt(token);
            return true;
        } catch (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;
    }

    public String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken)) {
            return bearerToken;
        }
        return null;
    }

    public Claims parseJwt(String jwt) {
        Claims claims = Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(jwt)
                .getBody();

        return claims;
    }

JwtAuthenticationFilter
토큰을 검증하고 SecurityContextHolder에 권한을 설정하는 필터이다.
또한 다음 필터를 실행시킨다.

🔥트러블 슈팅

정말 수 많은 에러를 고쳤기 때문에 몇 개 공유해봐여..

.TemplateInputException: Error resolving template [user/login/email], template might not exist or might not be accessible by any of the configured Template Resolvers

이 에러는 UserContoller 클래스에 어노테이션으로 @Controller를 설정해놓고 @ResponseBody를 쓰지 않아서 발생했다...json형태로 data를 반환하실 분들은 @RestController를 붙여주거나 아니면 @Controller로 설정하고 각 api 함수마다 @ResponseBody를 붙여주세요.

@RestController는 @Controller에 @ResponseBody가 추가된 것으로 생각하면 된다. 이는 REST API를 개발하는데 적합하며 @Controller는 주로 view를 반환하는데 사용되기 때문에 따로 @ResposneBody를 붙여야한다.
[참고] https://mangkyu.tistory.com/49

Authorization에 accessToken 넣고 호출 시 403 Forbidden 에러가 발생함

이건 User의 Role을 설정하는 부분에서 실수로 인해 발생했다.
나는 처음에 User의 Role을 위의 코드처럼 ENUM 클래스로 구현하고 USER , ADMIN으로 구분했다. 그리고 SecurityConfig에서 .hasRole("USER")을 붙여서 USER라는 Role을 가진 사용자의 접근을 허용하도록 했다. 같은 USER이므로 인식할 줄 알았는데...

SecurityConfig는 USER라고 명시해도 DB에는 ROLE_USER라고 저장되어야한다고 한다...

참고 블로그

이렇게 어찌저찌 우역곡절 끝에 이메일 회원가입 로그인을 완성했다. 이제 다음에는 RefreshToken 재발급을 구현하고, 차차 로그아웃 및 유저 정보 수정같은 CRUD API를 구현해야될 거 같다... 카카오 로그인은 좀 나중에 미루기로 ㅎㅎ^^

profile
엘모는 즐거워

0개의 댓글

관련 채용 정보