본문 바로가기
프로젝트/낙낙(KnockKnock)

Spring Security JWT 유저, Oauth에 대한 대대적인 공사-1(Local User)

by 티코딩 2023. 9. 12.

1편 - (현재글) Local User (https://thcoding.tistory.com/114)

2편 - JWT (https://thcoding.tistory.com/115)

3편 - OAuth2 (https://thcoding.tistory.com/116)

4편 - OAuth2 test(https://thcoding.tistory.com/118)

 

 

이 포스팅을 적기에 앞서, 제너럴킹갓 DEEPLIFY 님께  감사하단 말 전합니다.

이분께서 정리해둔 포스팅은 나같은 초보들에게 내려주신 희망의 빛줄기이다.
그 빛의 포스팅은
https://deeplify.dev/back-end/spring/oauth2-social-login#%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-vue-project

 

[Spring Boot] OAuth2 소셜 로그인 가이드 (구글, 페이스북, 네이버, 카카오)

스프링부트를 이용하여 구글, 페이스북, 네이버, 카카오 OAuth2 로그인 구현하는 방법에 대해서 소개합니다.

deeplify.dev

 

일단 그전에 내가 만든 유저와 jwt토큰은 전혀 보안이 되고 있지 않았다.

나는 전혀 "보안"하고 있지 않았다.

 

거의다 끝났다 싶었는데, 지원님께서 "제코드에서 주석처리만 해제하면 되실거에요~"

라고 하셨는데, 음?

컨트롤러에서 거의 모든 부분이 이런식으로 되어있었다.

내가 유저를 마무리하기 전에 바람과같이 완성시키고 가신 지원님...

바로바로 저 UserPrincipal!!!

오잉? 저게 뭐더라? 했던 나 반성해..

부트캠프에서 진행했던 메인 프로젝트때도(그땐 지원님께서 유저만드심) 썼던 userprincipal이었다.

이게 대체 뭐냐면, gpt가라사대
Spring Framework에서 "User Principal"은 현재 인증된 사용자에 대한 정보를 나타내는 객체입니다. Spring Security와 같은 보안 기능을 사용할 때 주로 사용됩니다. User Principal 객체에는 사용자의 식별 정보와 권한 정보가 포함될 수 있습니다.

라고한다.

저 위의 예를 보면

이렇게 했을 때, 인증된 객체를 사용하는 것이다. 이렇게 되면 테스트할때, 로그인할때 받은 access token을 api 에 같이 보내주면 토큰을 검증하고, 원하는 결과를 보내준다.

 

나는 이걸 전혀 생각도 못하고 있었다.

젠장....

그래서 기존에 있던 유저를 싸악 갈아 엎었다.(눈물을 머금으며) 맨위에 포스팅과 거의 비슷하다.

그래서 새롭게 만든 유저 엔티티 부터 알아보자.

ㅇ User Entity

@Entity
@Table(name = "USERS")
@Getter
@Builder
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class User{
    @Id
    @Column(name = "USER_ID")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long userId;

    @Column(name = "ID", length = 64, unique = true)
    @NotNull
    @Size(max = 64)
    private String id;

    @Column(name = "USERNAME", length = 100)
    @Size(max = 100)
    private String username;

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

    @JsonIgnore
    @Column(name = "PASSWORD", length = 128)
    @NotNull
    @Size(max = 128)
    private String password;

    @Column(name = "BIRTH")
    @JsonFormat(pattern = "yyyy-MM-dd")
    private LocalDate birth;

    @Column(name = "PUSH_AGREE")
    private Boolean pushAgree;

    @Column(name = "EMAIL_VERIFIED_YN", length = 1)
    @NotNull
    @Size(min = 1, max = 1)
    private String emailVerifiedYn;

    @Column(name = "PROVIDER_TYPE", length = 20)
    @Enumerated(EnumType.STRING)
    @NotNull
    private ProviderType providerType;

    @Column(name = "ROLE_TYPE", length = 20)
    @Enumerated(EnumType.STRING)
    @NotNull
    private RoleType roleType;

    @Column(name = "CREATED_AT")
    @NotNull
    private LocalDateTime createdAt;

    @Column(name = "MODIFIED_AT")
    @NotNull
    private LocalDateTime modifiedAt;


    public User(
             Long userId,
             @Size(max = 64) String id,
             @Size(max = 512) String email,
             @Size(max = 1) String emailVerifiedYn,
             LocalDate birth,
             Boolean pushAgree,
             ProviderType providerType,
             RoleType roleType,
             LocalDateTime createdAt,
             LocalDateTime modifiedAt
    ) {
        this.userId = userId;
        this.id = id;
        this.password = "NO_PASS";
        this.email = email != null ? email : "NO_EMAIL";
        this.emailVerifiedYn = emailVerifiedYn;
        this.birth = birth;
        this.pushAgree = pushAgree;
        this.providerType = providerType;
        this.roleType = roleType;
        this.createdAt = createdAt;
        this.modifiedAt = modifiedAt;
    }

}

처음에 만들었을때 간단한 유저를 만들고 싶어서 userId, 이메일, 패스워드, emaiVerified, Status(batch)만 있었는데, 이번에 갈아엎으며 저렇게 만들어버렸다.

 

ㅇ UserRefreshToken

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "USER_REFRESH_TOKEN")
public class UserRefreshToken {
    @JsonIgnore
    @Id
    @Column(name = "REFRESH_TOKEN_SEQ")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long refreshTokenSeq;

    @Column(name = "USER_ID", length = 64, unique = true)
    @NotNull
    @Size(max = 64)
    private String userId;

    @Column(name = "REFRESH_TOKEN", length = 256)
    @NotNull
    @Size(max = 256)
    private String refreshToken;

    public UserRefreshToken(
            @NotNull @Size(max = 64) String userId,
            @NotNull @Size(max = 256) String refreshToken
    ) {
        this.userId = userId;
        this.refreshToken = refreshToken;
    }
}

db에 refresh token정보를 깔끔히 저장하는 클래스다.

여기서 확인해서 사용자를 구분하고, 권한을 준다.

 

ㅇ UserDto

public class UserDto {
    @Getter
    @Builder
    @AllArgsConstructor
    public static class Signup{
        @NotNull
        private String id;

        @NotBlank
        @NotNull
        @Email
        private String email;

        @NotNull
        @NotBlank
        @Pattern(regexp = "^(?=.*[!@#$%^&*]).{8,}$",
                message = "패스워드는 8자 이상이어야 하며, 특수문자를 최소 1개 포함해야 합니다.")
        private String password;

        private String username;

        @JsonFormat(pattern = "yyyy-MM-dd")
        private LocalDate birth;

        private boolean pushAgree;
    }
    @Getter
    @Builder
    @AllArgsConstructor
    public static class Login{
        @NotBlank
        @NotNull
        private String id;

        @NotNull
        @NotBlank
        private String password;
    }
    @Getter
    @Builder
    @AllArgsConstructor
    public static class Response{
        private Long userId;
        private String id;
        private String email;
        private String username;
        private LocalDate birth;
        private Boolean pushAgree;
        private String emailVerifiedYn;
        private ProviderType providerType;
        private RoleType roleType;
        private LocalDateTime createdAt;
        private LocalDateTime modifiedAt;
    }
    @Getter
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    public static class PasswordUpdate {
        @NotNull
        @NotBlank
        @Pattern(regexp = "^(?=.*[!@#$%^&*]).{8,}$",
                message = "패스워드는 8자 이상이어야 하며, 특수문자를 최소 1개 포함해야 합니다.")
        private String newPassword;
    }
    @Getter
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    public static class UpdateProfile {
        private String username;
        @JsonFormat(pattern = "yyyy-MM-dd")
        private LocalDate birth;
        private Boolean pushAgree;
    }
}

entity가 바뀌면서 dto도 바뀌었는데, 한번 보면 알것이다. 무슨작업에 무슨 파라미터가 필요한지에 관한 코드다. birth는 프론트분들이 생일알람을 만들거라고 하셔서 넣었는데, @JsonFormat으로 저 패턴대로 입력하게 한것이다.

 

ㅇ UserMapper

@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface UserMapper {
    UserDto.Response toResponseDto(User user);
    UserDto.Response userToUserDtoResponse(User user);
    User userDtoSignupToUser(UserDto.Signup requestBody);
    @Mappings({
            @Mapping(target = "password", source = "newPassword")
    })
    User userDtoPasswordToUser(UserDto.PasswordUpdate requestBody);

    User userDtoUpdateProfileToUser(UserDto.UpdateProfile requestBody);
}

나는 mapstruct를써서 쉽게쉽게 만들었다.

mapstruct가 만들어준걸 조금 보면,

mapper가 하는 역할은

  1. 데이터베이스 조회 결과를 객체로 변환: 데이터베이스에서 조회한 결과를 Java 객체로 매핑합니다. 이것은 SQL 쿼리나 JPA를 사용하여 데이터를 가져올 때 매우 일반적입니다. 예를 들어, 데이터베이스 테이블의 행을 Java 객체의 인스턴스로 변환하고, 이러한 객체를 애플리케이션에서 사용할 수 있도록 합니다.
  2. 객체를 데이터베이스에 저장: Java 객체를 데이터베이스에 삽입, 갱신 또는 삭제할 때 사용됩니다. 이것은 데이터베이스와 Java 객체 간의 매핑 규칙을 따라 데이터를 저장하고 업데이트할 수 있도록 해줍니다.
  3. DTO (Data Transfer Object) 변환: 데이터베이스에서 가져온 데이터를 클라이언트로 전송하기 전에 DTO로 변환하는 데 사용됩니다. DTO는 클라이언트와 서버 간의 데이터 전송을 간소화하고 필요한 정보만 전달하는 데 도움이 됩니다.
  4. 복잡한 매핑 작업: 데이터베이스와 Java 객체 간의 복잡한 매핑 작업을 수행할 때 사용됩니다. 예를 들어, 다양한 데이터 유형 간의 변환, 연관된 객체 간의 매핑, 데이터베이스 테이블과 객체 간의 관계를 처리하는 등의 작업을 수행할 수 있습니다.

이렇다. 간단히 DB에 쉽게 저장해주고, Db조회 결과를 객체로 변환해준다.

 

ㅇ UserRepository

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
    Optional<User> findById(String id);
    User findUserById(String id);
    Optional<User> findByUserId(Long userId);
}

ㅇ UserRefreshTokenRepository

@Repository
public interface UserRefreshTokenRepository extends JpaRepository<UserRefreshToken, Long> {
    UserRefreshToken findByUserId(String userId);
    UserRefreshToken findByUserIdAndRefreshToken(String userId, String refreshToken);
}

repository는 JpaRepository를 extend 해서 CRUD 작업을 수행할 수 있고, DB에서 원하는 정보로 원하는 데이터를 가져올 수 있다.

 

ㅇ UserService

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final AuthTokenProvider authTokenProvider;

    public User signup(User user){
        //중복 이메일,Id 체크
        verifyExistsUserEmail(user.getEmail());
        verifyExistsUserId(user.getId());
        //패스워드 encode
        String encryptedPassword = passwordEncoder.encode(user.getPassword());
        // 빌더 패턴을 사용하여 User 객체 생성
        User newUser = User.builder()
                .id(user.getId())
                .email(user.getEmail())
                .username(user.getUsername())
                .password(encryptedPassword)
                .birth(user.getBirth())
                .pushAgree(user.getPushAgree())
                .emailVerifiedYn("N")
                .providerType(ProviderType.LOCAL)
                .roleType(RoleType.USER)
                .createdAt(LocalDateTime.now())
                .modifiedAt(LocalDateTime.now())
                .build();
        //user 저장
        return userRepository.save(newUser);
    }

    public void updateUserPassword(Long userId, User user) {
        User verifiedUser = findUserByUserId(userId);

        if (passwordEncoder.matches(user.getPassword(), verifiedUser.getPassword()))
            throw new BusinessLogicException(ExceptionCode.NOT_CHANGED_PASSWORD);

        verifiedUser.setPassword(passwordEncoder.encode(user.getPassword()));

        userRepository.save(verifiedUser);
    }

    public void updateUserProfile(Long userId, User updatedUser){
        User existingUser = userRepository.findByUserId(userId)
                .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다."));
        existingUser.setUsername(updatedUser.getUsername());
        existingUser.setBirth(updatedUser.getBirth());
        existingUser.setPushAgree(updatedUser.getPushAgree());
        existingUser.setModifiedAt(LocalDateTime.now());
        userRepository.save(existingUser);
    }
	/*
    수정됨
    */
    public void deleteUser(String id) {
        User optionalUser = userRepository.findUserById(id);
        if (optionalUser==null) {
            throw new IllegalArgumentException("사용자를 찾을 수 없습니다.");
        }
        //유저와 관련된 태그 삭제
        List<Tag> userTags = tagRepository.findAllTagByUserId(optionalUser.getUserId());
        tagRepository.deleteAll(userTags);
		//유저와 관련된 스케줄 삭제
        List<Schedule> userSchedules = scheduleRepository.findAllByUserId(optionalUser.getUserId());
        scheduleRepository.deleteAll(userSchedules);
		//유저와 관련된 알림 삭제
        List<Notification> userNotifications = notificationRepository.findAllByUserId(optionalUser.getUserId());
        notificationRepository.deleteAll(userNotifications);
        userRepository.delete(optionalUser);
    }

    @Transactional(readOnly = true)
    public User findUserByUserId(Long userId) {
        Optional<User> byUserId = userRepository.findByUserId(userId);

        return byUserId.orElseThrow(() -> new BusinessLogicException(ExceptionCode.USER_NOT_FOUND));
    }

    @Transactional(readOnly = true)
    public void verifyExistsUserId(String id) {
        userRepository.findById(id).ifPresent((e) -> {
            throw new BusinessLogicException(ExceptionCode.ALREADY_EXISTS_ID);
        });
    }

    @Transactional(readOnly = true)
    public void verifyExistsUserEmail(String email) {
        userRepository.findByEmail(email).ifPresent((e) -> {
            throw new BusinessLogicException(ExceptionCode.ALREADY_EXISTS_EMAIL);
        });
    }

}

 

ㅇ UserController

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserController {
    private final UserService userService;
    private final UserMapper userMapper;

    /*
    유저 정보 불러오기 api
     */
    @GetMapping
    @PreAuthorize("isAuthenticated()")
    public ResponseEntity<?> getUser(@AuthenticationPrincipal UserPrincipal userPrincipal) {

        User user = userService.findUserByUserId(userPrincipal.getUserId());

        UserDto.Response response = userMapper.userToUserDtoResponse(user);

        return ResponseEntity.ok().body(ApiResponse.success("data", response));
    }
    /*
    회원 가입 api : Local
     */
    @PostMapping("/signup")
    public ResponseEntity<String> signup(@Validated @RequestBody UserDto.Signup requestBody) {
        User user = userService.signup(userMapper.userDtoSignupToUser(requestBody));

        return ResponseEntity.ok("이메일 인증을 해주세요.");
    }
    /*
    현재 패스워드 업데이트 api
     */
    @PatchMapping("/password")
    @PreAuthorize("isAuthenticated()")
    public ResponseEntity<?> updateUserPassword(
            @Valid @RequestBody UserDto.PasswordUpdate requestBody,
            @AuthenticationPrincipal UserPrincipal userPrincipal
    ) {
        userService.updateUserPassword(
                userPrincipal.getUserId(),
                userMapper.userDtoPasswordToUser(requestBody)
        );
        return ResponseEntity.ok().body(ApiResponse.success("업데이트 완료", requestBody));
    }
    /*
    패스워드 제외 사용자 정보 수정 api
     */
    @PatchMapping
    @PreAuthorize("isAuthenticated()")
    public ResponseEntity<?> updateUserProfile(
            @Valid @RequestBody UserDto.UpdateProfile requestBody,
            @AuthenticationPrincipal UserPrincipal userPrincipal
    ) {
        userService.updateUserProfile(
                userPrincipal.getUserId(),
                userMapper.userDtoUpdateProfileToUser(requestBody)
        );
        return ResponseEntity.ok().body(ApiResponse.success("사용자 정보 업데이트 완료", requestBody));
    }
    /*
    회원 탈퇴 api
     */
    @PreAuthorize("isAuthenticated()")
    @DeleteMapping("/delete")
    public ResponseEntity<String> deleteUser(HttpServletRequest request) {
        String accessToken = HeaderUtil.getAccessToken(request);
        return ResponseEntity.ok("회원 탈퇴가 성공적으로 이루어졌습니다.");
    }
}

@PreAuthorize("isAuthenticated()") 애너테이션과 token에 해당하는 user를 사용하기 때문에 파라미터로 @AuthenticationPrincipal UserPrincipal userPrincipal 를 받는걸 볼 수 있다. 이렇게 하면 UserService에서 사용되는 파라미터를 충족시킬 수 있다. 

원래 회원탈퇴는 db에서 회원의 status를 INACTIVE로 바꾸고 스케줄러를 돌려서 1년간 우리DB에서 관리하는걸로 만들었는데 mysql에서 오류가 나서 spring batch 와 status를 삭제하기로 결정했다...

그래서 현재의 db에서 바로 삭제시켜버리는 초간단한 delete api가 되었다. 너무 아쉬워서 다음에한번 다시 spring batch를 써보고싶다.

 

이것뿐만 아니라 JWT관련 코드와 Oauth 유저관련 코드들도 있다. 다음 포스팅에 이어서 작성해보겠다.