[Spring] JWT토큰 발급하여 로그인,회원가입 기능 구현하기(2)

nana·2024년 9월 15일
0

Spring

목록 보기
4/9
post-thumbnail

개요

지난번엔 평문으로 비밀번호를 저장하고 JPA Entity를 이용해 제약조건을 설정하는 방법을 사용했다.
현업에서는 비밀번호를 그대로 저장하지 않고 암호화하여 대부분 저장하기에 암호화 된 형태로 저장하는 것으로 변경이 필요하다고 생각했다.

UserInfo


import jakarta.persistence.*;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;

@Entity
@Data //getter, setter 자동생성
@NoArgsConstructor //기본 생성자 추가
@AllArgsConstructor //모든 필드에 대한 생성자 추가
@Table(name="USER_INFO")
@Builder
public class UserInfo {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name="seq_no")
    private Integer seqNo;

    @Column(name="user_name", nullable = false, unique = true)
    //@Pattern(regexp = "[0-9a-z]+", message="Username Must Contain Only Lowered letters and Numbers") >> DTO로 변경
    @Size(min=4, max=10, message="size 4~10")
    private String userName;

    @Column(nullable = false)
    //@Pattern(regexp = "[0-9a-zA-Z]+", message="Username Must Contain Only Letters and Numbers") >> DTO로 변경
    private String password;

    @Column(name= "reg_id")
    private String regId;

    @Column(name= "reg_date")
    private String regDate;

    @Column(name="reg_ip")
    private String regIp;



}

우선 @Pattern를 없앴다.
암호화 된 형태로 api를 호출하면 $2a$10$0w.AA39kcQyqZM57YR/ajeJNRtvj1QldzrPvwswdjFYzDnHEkPwvq 요런 형태로 저장하려하기 때문에 제약조건에서 걸려 401 에러를 뱉어낸다.

UsesrDTO

@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(force = true)
public class UserDTO {

    @NotBlank
    @Pattern(regexp = "[0-9a-z]+", message="Username Must Contain Only Lowered letters and Numbers")
    @Size(min=4, max=10, message="size 4~10")
    private final String userName;

    @NotBlank
    @Pattern(regexp = "[0-9a-zA-Z]+", message="Username Must Contain Only Letters and Numbers")
    @Size(min=8, max=15, message="size 8~15")
    private final String password;

}

때문에 DTO로 변경하였다.
이 작업 때문에 Entity와 DTO의 차이에 대해 공부를 했는데 추후 포스팅 할 예정이다.

UserController


@RestController
@Slf4j
@RequestMapping("/auth")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;
    private final TokenMakerService tokenMakerService;
    private final AuthenticationManager authenticationManager;

    @PostMapping
    public ResponseEntity<String> registerUser(@RequestBody @Valid UserDTO user){
        log.info("Registering user: {}", user.getUserName());
        if (userService.registerUser(user).equals("REQUEST_SUCCESS")) {
            log.info("User registered successfully: {}", user.getUserName());
            return ResponseEntity.ok( "User registered successfully!");
        } else {
            log.warn("Username already exists: {}", user.getUserName());
            return ResponseEntity.badRequest().body("Username "+user.getUserName()+" already exists!");
        }
    }
    @PostMapping("/login")
    public ResponseEntity<?> userLogin(@RequestBody LoginDTO loginInfo, HttpServletResponse response){
        log.info("Login attempt for user: {}", loginInfo.getUserName());
        try {
            //세션 등록
            Authentication auth = authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(loginInfo.getUserName(), loginInfo.getPassword()));
            //AuthenticationManager : 인증 논리를 구현하기 위해 인증 공급자를 이용하여 인증 처리

            SecurityContextHolder.getContext().setAuthentication(auth);

            String jwtToken = tokenMakerService.createToken2(auth);
            log.info("Login Success for user: {}", loginInfo.getUserName());
            return ResponseEntity.ok(new JwtResponse(jwtToken));
        }
        catch (Exception e){
            log.warn("Invalid username or password for user: {}", loginInfo.getUserName());
            return ResponseEntity.status(401).body("Invalid username or password");
        }
        //response.setHeader("Authorization", "Bearer" + jwtToken);
        //사용자 인증 처리

    }

}
  • 주소 변경 : users > auth
  • UserInfo Entity @Valid지정 > UserDTO로 변경

userLogin

Authentication auth = authenticationManager.authenticate( 
new UsernamePasswordAuthenticationToken(loginInfo.getUserName(), loginInfo.getPassword()));
  • Spring Security는 기본적으로 세션 쿠키 방식의 인증이 이루어진다.
  • AbstractAuthenticationProcessingFilter를 상속받고있다.
    참고 사이트

🚨 막힌 부분

자꾸 여기 인증하는 부분에서 막혔는데
AuthenticationManager가 프록시로 감싸져 재귀 호출로 무한 루프에 빠지는 현상이 나타났다.

java.lang.StackOverflowError: null
at jdk.internal.reflect.GeneratedMethodAccessor25.invoke(Unknown Source) ~
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:354) ~[spring-aop-6.1.11.jar:6.1.11]
at

계속 이런 오류가 뜸.

원인은 UserDetailsService의 구현이 필요했다.

  • Spring Security에서 기본적으로 UserDetailsService를 통해 사용자 인증 정보를 로드한다.
  • UserDetailsService가 없으면 인증로직이 제대로 동작하지 않아 authenticationManager.authenticate() 에서 문제가 생길 수 있다.

UserDetailsService

  • UsernamePasswordAuthenticationToken에서 사용자의 이름(username) 을 기반으로 사용자를 찾아주는 역할.
  • Spring Security는 기본적으로 UserDetailsService를 통해 UserDetails 객체를 반환해야 인증 처리가 되는데, 이 부분이 빠져있으면 authenticationManager가 계속해서 자기 자신을 호출하는 재귀 호출에 빠질 수 있다.

=> 이걸 몰라서 거의 3-4시간은 헤맨듯 하다. ㅠㅠ

TokenMakerService

   public String createToken2(Authentication authentication){
        Date now = new Date();
        Date validity = new Date(now.getTime() + accessTokenExpMilliseconds);

        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        return "Bearer " + Jwts.builder()
                .setSubject(authentication.getName()) //주체설정
                .setIssuedAt(now)//토큰 발행 시간
                .setExpiration(validity) // 토큰 만료 시간
                .signWith(secretKey) // 비밀키로 서명
                .compact(); //JWT생성
    }

이전 소스와의 차이점은 파라미터를 String username으로 받는게 아닌 Authentication로 받아 토큰을 만들 때에도 .setSubject(authentication.getName())으로 주체를 설정했다.

UserService



@Service
@Slf4j
public class UserService {


   private final UserInfoRepository userRepository;
   private final BCryptPasswordEncoder passwordEncoder;

   public UserService(UserInfoRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
       this.userRepository = userRepository;
       this.passwordEncoder = passwordEncoder;
   }

   public String registerUser(UserDTO user){
       String clientIp = "";
   //    String encodedPassword = passwordEncoder.encode(user.getPassword());
       log.info("User registration started for: {}", user.getUserName());
       log.info("Login attempt for user: {}", user.getUserName());
       if(userRepository.findOneByUserName(user.getUserName()) != null)
           return ReturnCodes.EXISTS_USER;

       DateFormat now = new SimpleDateFormat("YYYY-MM-dd HH:mm:ss");

       log.info("password Encode : "+ passwordEncoder.encode(user.getPassword()));
       userRepository.save(UserInfo.builder()
               .userName(user.getUserName())
               .password(passwordEncoder.encode(user.getPassword()))
               .regDate(now.format(new Date()))
               .regIp(clientIp)
               .build());

       //userRepository.save(user);
       return "REQUEST_SUCCESS"; //회원가입 성공
       //비밀번호 암호화 한 뒤 전송 > 비교
   }

   public boolean validateUser(String username, String rawPassword) {
       Optional<UserInfo> optionalUser = userRepository.findByUserName(username);
       if (optionalUser.isPresent()) {
           UserInfo user = optionalUser.get();
           log.info("User found: {}", username);
           log.info("Comparing passwords: rawPassword = {}, storedPassword = {}", rawPassword, user.getPassword());
           return passwordEncoder.matches(rawPassword, user.getPassword());
       } else {
           log.warn("User not found: {}", username);
           return false;
       }
   }


}
  • password를 인코딩하였다.
    passwordEncoder.encode(user.getPassword())

CustomUserDetailService

@Service
public class CustomUserDetailService implements UserDetailsService {
    private final UserInfoRepository userInfoRepository;

    public CustomUserDetailService(UserInfoRepository userInfoRepository){
        this.userInfoRepository = userInfoRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserInfo userInfo = userInfoRepository.findByUserName(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));

        return User.builder()
                .username(userInfo.getUserName())
                .password(userInfo.getPassword())  // 이때 비밀번호는 암호화된 값이어야 함
                .roles("USER")  // 권한 설정
                .build();
    }
}

결과

정상적으로 토큰값을 불러오고있다.

참고 링크

profile
BackEnd Developer, 기록의 힘을 믿습니다.

0개의 댓글