카테고리 없음

스프링 시큐리티 이용해서 jwt로그인,회원가입 구현하기

_hanbxx_ 2023. 6. 28. 00:51
728x90

jwt로그인을 앞으로 더 활용할 예정일 것 같기도 하고, jwt로그인을 구현하면서 배운 점이 많기도 해서 정리를 해보고 싶다

refresh token은 구현을 아직 안해보았다.access token발급까지 해보았다.

개발환경

-SpringBoot 3.1.1이고 Spring Security 5.7.3버전을 사용하였다

-그냥 로그인, 회원가입 구현할때 구글링할 때 나오는 것들은 거의 다 5.7.0이하 여서 공부하기 힘들었다. WebSecurityConfigureAdapter를 확장해서 사용하는 것이 아니라 Bean을 만들어서 주입하는 방식으로 살짝 변경이 되어 그 변경된 내용대로 구현하였다 

-Java 17, Gradle을 이용해서 구현하였다

 

User

@Getter
@Builder
@Entity
@NoArgsConstructor
@AllArgsConstructor
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;


    @Column(nullable = false)
    private String name;

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

    @Column(nullable = false)
    private String password;

    @OneToMany(mappedBy = "user", fetch = FetchType.EAGER, cascade = CascadeType.ALL)
    @Builder.Default
    private List<Role> roles = new ArrayList<>();

    public void setRoles(List<Role> role) {
        this.roles = role;
        role.forEach(o -> o.setUser(this));
    }
 }

UserRepository

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

CustomUserDetails

Spring Security는 UserDetails를 참조하여 인증을 진행한다

public class CustomUserDetails implements UserDetails {
    private final User user;

    public CustomUserDetails(User user){
        this.user =user;
    }

    public final User getMember(){
        return user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return user.getRoles().stream().map(o -> new SimpleGrantedAuthority(
                o.getName()
        )).collect(Collectors.toList());
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getEmail();
    }

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

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

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

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

}

JpaUserDetailsService

Spring Security의 UserDetailsService는 UserDetails정보를 토대로 유저 정보를 불러올 때 사용된다.

Jpa를 이용하여 DB에서 유저 정보를 조회할 것이므로 이에 맞춰 구현해주면 된다

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        User user = userRepository.findByEmail(email).orElseThrow(
                ()-> new UsernameNotFoundException("Invalid authentication!")
        );

        return new CustomUserDetails(user);
    }

    private Collection < ? extends GrantedAuthority> mapRolesToAuthorities(Collection <Role> roles) {
        Collection < ? extends GrantedAuthority> mapRoles = roles.stream()
                .map(role -> new SimpleGrantedAuthority(role.getName()))
                .collect(Collectors.toList());
        return mapRoles;
    }





}

JWT설정하기

JwtProvider

@RequiredArgsConstructor
@Component
public class JwtProvider {

    @Value("${jwt.secret.key}")
    private String salt;
    private Key secretKey;

    //만료시간:1h
    private final long exp = 1000L * 60 * 60;

    private final CustomUserDetailsService customUserDetailsService;

    @PostConstruct
    protected void init() {
        secretKey = Keys.hmacShaKeyFor(salt.getBytes(StandardCharsets.UTF_8));
    }

    //토큰 생성
    public String createToken(String account, List<Role> roles) {
        Claims claims = Jwts.claims().setSubject(account);
        claims.put("rules", roles);
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() * exp))
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();
    }

    //권한 정보 획득
    //spring security인증과정을 권한 확인을 위한 구현
    public UsernamePasswordAuthenticationToken getAuthentication(String token){
        UserDetails userDetails = customUserDetailsService.loadUserByUsername(this.getEmail(token));
        return new UsernamePasswordAuthenticationToken(userDetails,"",userDetails.getAuthorities());
    }

    // 토큰에 담겨있는 유저 email 획득
    public String getEmail(String token) {
        return Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody().getSubject();
    }
    // Authorization Header를 통해 인증을 한다.
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("Authorization");
    }

    // 토큰 검증
    public boolean validateToken(String token) {
        try {
            // Bearer 검증
            if (!token.substring(0, "BEARER ".length()).equalsIgnoreCase("BEARER ")) {
                return false;
            } else {
                token = token.split(" ")[1].trim();
            }
            Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token);
            // 만료되었을 시 false
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }





}

JwtProvider는 JWT토큰을 생성,해석,및 검증하는 역할을 담당한다
- `@RequiredArgsConstructor`: 이 어노테이션은 `final`로 선언된 필드 또는 `@NonNull`이 붙은 필드를 가지고 생성자를 자동으로 생성한다. 이 클래스에서는 `CustomUserDetailsService`를 `final` 필드로 가지고 있으므로 이 필드를 사용하는 생성자가 자동으로 생성된다.

- `@Component`: 이 어노테이션은 스프링 컴포넌트 스캔에 의해 해당 클래스가 컴포넌트로 인식되도록 한다.

- `@Value("${jwt.secret.key}")`: 이 어노테이션은 `jwt.secret.key`라는 프로퍼티 값을 `salt` 필드에 주입한다. `@Value` 어노테이션은 스프링의 프로퍼티 값을 주입받을 때 사용한다.

- `Key secretKey`: `secretKey`는 JWT 서명에 사용되는 비밀키이다.

- `@PostConstruct`: 이 어노테이션이 지정된 메서드는 해당 클래스의 인스턴스가 생성된 후 자동으로 호출됩니다. `init()` 메서드는 `secretKey`를 `salt`를 사용하여 초기화한다. `Keys.hmacShaKeyFor()` 메서드를 사용하여 `salt`를 바이트 배열로 변환하고, 해당 바이트 배열을 기반으로 HMAC-SHA 알고리즘으로 `secretKey`를 생성한다.

- `createToken(String email, List<Role> roles)`: 이 메서드는 주어진 이메일(email)과 역할(roles)을 기반으로 JWT 토큰을 생성한다. 토큰에는 주제(subject)로 계정이 설정되고, "rules" 클레임에 역할 정보가 설정된다. 토큰의 발급 시간과 만료 시간도 설정하고, `secretKey`를 사용하여 서명한다. 생성된 JWT 토큰을 문자열로 반환한다.

- `getAuthentication(String token)`: 이 메서드는 주어진 토큰을 기반으로 권한 정보를 획득한다. `customUserDetailsService.loadUserByUsername()` 메서드를 사용하여 토큰에 담긴 이메일(email)을 기반으로 사용자 정보(UserDetails)를 가져온다. 그리고 `UsernamePasswordAuthenticationToken` 객체를 생성하여 인증 객체를 반환한다.

- `getEmail(String token)`: 이 메서드는 주어진 토큰에서 토큰에 담긴 사용자 이메일(email)을 추출하여 반환한다. `Jwts.parserBuilder().setSigningKey(secretKey)`를 사용하여 토큰을 파싱한 후, 토큰의 본문(claims)에서 주제(subject)를 추출하여 사용자 계정으로 반환한다.

- `resolveToken(HttpServletRequest request)`: 이 메서드는 주어진 HttpServletRequest에서 Authorization 헤더를 추출하여 토큰을 해석한다. 요청 헤더에서 "Authorization" 헤더 값을 가져와서 반환한다.

- `validateToken(String token)`: 이 메서드는 주어진 토큰의 유효성을 검증합니다. 토큰의 형식과 만료 여부를 확인하여 유효한 토큰인지 판단한다.
   - 토큰이 "BEARER "로 시작하지 않는 경우 `false`를 반환한다.
   - "BEARER "를 제거하고 토큰을 추출한다.
   - `Jwts.parserBuilder().setSigningKey(secretKey)`를 사용하여 토큰을 파싱한 후, 토큰의 클레임(claims)에서 만료 시간을 가져와 현재 시간과 비교하여 만료 여부를 확인한다. 토큰이 만료되었으면 `false`를 반환하고, 그렇지 않으면 `true`를 반환한다. 또한, 만료 여부를 확인하는 과정에서 예외가 발생하면 `false`를 반환한다.

 

JwtAuthenticationFilter

Filter를 적용함으로써 servlet에 도달하기 전에 검증을 하는 것이다

//OncePerRequestFilter은 단 한번의 요청에 단 한번만 동작하도록 보장된 필터이다
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtProvider jwtProvider;

    public JwtAuthenticationFilter(JwtProvider jwtProvider) {
        this.jwtProvider = jwtProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException, ServletException, IOException {
        String token = jwtProvider.resolveToken(request);

        if (token != null && jwtProvider.validateToken(token)) {
            // check access token
            token = token.split(" ")[1].trim();
            Authentication auth = jwtProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        filterChain.doFilter(request, response);
    }
}

doFilterInternal 메서드는 필터의 실제동작을 정의해주는 메서드이다. HttpServletRequest와 HttpServletResponse 객체를 매개변수로 받아, FilterChain객체를 통해 다음 필터로 체인을 전달한다.

해석된 토큰이 null이 아니고 token이 true를 반환하는 경우에만 다음 단계를 수행한다

token.split부분은 실제 토큰 부분을 추출하고 getAuthentication을 호출하여 인증 객체를 가져온다

securityContextHolder.getContext().setAuthentication(auth)를 사용하여 인증 객체를 현재 스레드의 SecurityContext에 설정한다

filterChain.doFilter(request,response)를 호출하여 다음 필터로 체인을 전달한다

 

내가 헷갈려서 정리하는 JwtProvider와 JwtAuthenticationFilter 관계

JwtAuthenticationFilter는 Spring Security의 필터 체인에서 JWT 토큰을 처리하고 사용자를 인증하기 위해 JwtProvider를 사용한다.

-JWT 토큰의 유효성을 검사하고 인증 정보를 설정: JwtAuthenticationFilter의 `doFilterInternal` 메서드에서는 `jwtProvider`를 사용하여 토큰을 해석하고 유효성을 검증한다. 토큰이 유효하면 JwtProvider의 `getAuthentication` 메서드를 호출하여 해당 토큰에 대한 인증 객체를 가져온다. 그리고 `SecurityContextHolder`를 사용하여 인증 객체를 현재 스레드의 SecurityContext에 설정한다.

- 다음 필터로 체인 전달: 인증 작업이 완료되면 `filterChain.doFilter(request, response)`를 호출하여 다음 필터로 체인을 전달한다. 이를 통해 요청이 계속 처리되고, Spring Security의 다른 필터 또는 요청 처리 로직으로 이동할 수 있다.


- JWT 토큰을 사용하여 인증: 보안 관련 정보가 JWT 토큰에 포함되어 있으며, 이를 사용하여 사용자 인증 및 권한 부여를 수행할 수 있다. JwtProvider는 JWT 토큰을 생성, 해석, 검증하는 기능을 제공하므로 JwtAuthenticationFilter에서 이를 활용하여 사용자 인증을 처리할 수 있다.

- Spring Security의 필터 체인에 통합: JwtAuthenticationFilter는 Spring Security의 필터 체인에 통합되어 있다. 이렇게 함으로써 JwtAuthenticationFilter는 다른 Spring Security 필터와 함께 동작하며, 요청 인증 및 보안 관련 작업을 수행할 수 있다. JwtAuthenticationFilter는 OncePerRequestFilter를 상속하므로 한 번의 요청에 대해 한 번만 실행되도록 보장된다.

즉, JwtAuthenticationFilter는 JwtProvider를 사용하여 JWT 토큰의 인증 처리를 수행하고, Spring Security의 필터 체인과 통합하여 보안 관련 작업을 처리하는 역할을 한다.

 

DTO & Service &Controller생성

본격적인 기능 구현을 위해 이 세가지를 작성하였다

SignResponse

@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class SignResponse {
    private Long id;

    private String name;

    private String email;

    private List<Role> roles = new ArrayList<>();

    private String token;

    public SignResponse(User user ) {
        this.id = user.getId();
        this.name = user.getName();
        this.email = user.getEmail();
        this.roles = (List<Role>) user.getRoles();
    }
}

SignRequest

@Getter
@Setter
public class SignRequest {
    private Long id;


    private String password;


    private String name;

    private String email;
}

UserService

@Service
@Transactional
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtProvider jwtProvider;
    private final RoleRepository roleRepository;

    public SignResponse login(SignRequest request) throws Exception {
       User user = userRepository.findByEmail(request.getEmail()).orElseThrow(() ->
                new BadCredentialsException("잘못된 계정정보입니다."));

        if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
            throw new BadCredentialsException("잘못된 계정정보입니다.");
        }

        return SignResponse.builder()
                .id(user.getId())
                .name(user.getName())
                .email(user.getEmail())
                .roles(user.getRoles())
                .token(jwtProvider.createToken(user.getEmail(), user.getRoles()))
                .build();

    }

    public boolean register(SignRequest request) throws Exception {
        try {
            User user = User.builder()
                    .password(passwordEncoder.encode(request.getPassword()))
                    .name(request.getName())
                    .email(request.getEmail())
                    .build();

            user.setRoles(Collections.singletonList(Role.builder().name("ROLE_USER").build()));

            userRepository.save(user);
        } catch (Exception e) {
            System.out.println(e.getMessage());
            throw new Exception("잘못된 요청입니다.");
        }
        return true;
    }

    public SignResponse getMember(String email) throws Exception {
        User user = userRepository.findByEmail(email)
                .orElseThrow(() -> new Exception("계정을 찾을 수 없습니다."));
        return new SignResponse(user);
    }





}

UserController

@RestController
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;
    private final UserRepository  userRepository;

    @PostMapping(value = "/login")
    public ResponseEntity<SignResponse> signin(@RequestBody SignRequest request) throws Exception {
        return new ResponseEntity<>(userService.login(request), HttpStatus.OK);
    }

    @PostMapping(value = "/register")
    public ResponseEntity<Boolean> signup(@RequestBody SignRequest request) throws Exception {
        return new ResponseEntity<>(userService.register(request), HttpStatus.OK);
    }
    @GetMapping("/user/get")
    public ResponseEntity<SignResponse> getUser(@RequestParam String email) throws Exception {
        return new ResponseEntity<>( userService.getMember(email), HttpStatus.OK);
    }



}

 

 

결과

/register

/login

/user/get?email=

728x90