JWT

5. [SpringBoot JWT 튜토리얼] 회원가입, 권한검증

임기웅변 2022. 12. 19. 18:25

# 회원가입 API생성

 

 

간단한 유틸리티 메소드를 만들기 위해 SecurityUtil 클래스를 util 패키지에 생성하겠습니다.

 

- SecurityUtil 클래스

public class SecurityUtil {

   private static final Logger logger = LoggerFactory.getLogger(SecurityUtil.class);

   private SecurityUtil() {
   }

   public static Optional<String> getCurrentUsername() {
      final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

      if (authentication == null) {
         logger.debug("Security Context에 인증 정보가 없습니다.");
         return Optional.empty();
      }

      String username = null;
      if (authentication.getPrincipal() instanceof UserDetails) {
         UserDetails springSecurityUser = (UserDetails) authentication.getPrincipal();
         username = springSecurityUser.getUsername();
      } else if (authentication.getPrincipal() instanceof String) {
         username = (String) authentication.getPrincipal();
      }

      return Optional.ofNullable(username);
   }

 

getCurrentUsername 메소드의 역활은 SecurityContext의 Authentication 객체를 이용해 username을 리턴해주는 간단한 유틸성 메소드입니다.

 

SecurityContext에 Authenticaion 객체가 저장되는 시점은 JwtFilter의 doFilter메소드에서 Request가 들어올때 SecurityContext에 Authenticaion 객체를 저장해서 사용하게 됩니다.

 

 

-UserService 클래스

 

회원가입, 유저 정보 조회등의 메소드를 만들기 위해 UserService 클래스를 생성하겠습니다.

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

    public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
    }

@Transactional
    public User signup(UserDto userDto) {
        if (userRepository.findOneWithAuthoritiesByUsername(userDto.getUsername()).orElse(null) != null) {
            throw new RuntimeException("이미 가입되어 있는 유저입니다.");
        }

        Authority authority = Authority.builder()
                .authorityName("ROLE_USER")
                .build();

        User user = User.builder()
                .username(userDto.getUsername())
                .password(passwordEncoder.encode(userDto.getPassword()))
                .nickname(userDto.getNickname())
                .authorities(Collections.singleton(authority))
                .activated(true)
                .build();

        return userRepository.save(user);
    }

    @Transactional(readOnly = true)
    public Optional<User> getUserWithAuthorities(String username) {
        return userRepository.findOneWithAuthoritiesByUsername(username);
    }

    @Transactional(readOnly = true)
    public Optional<User> getMyUserWithAuthorities() {
        return SecurityUtil.getCurrentUsername().flatMap(userRepository::findOneWithAuthoritiesByUsername);
    }
}

 

UserService 클래스는 UserRepository, PasswordEncoder를 주입받습니다.

 

singup 메소드는 username이 DB에 존재하지 않으면 Authority와 User 정보를 생성해서 UserRepository의 save메소드를 통해 DB에 정보를 저장합니다.

 

여기서 중요한 점 singup 메소드를 통해 가입한 회원은 USER ROLE을 가지고 있고 data.sql 에서 자동 생성되는 admin 계정은 USER, ADMIN ROLE을 가지고 있습니다 이 차이를 통해 권한검증 부분을 테스트 하겠습니다.

 

그리고 유저 권한정보를 가져오는 메소드가 2개 있습니다.

getUserWithAuthorities는 username을 파라미터로 받아서 어떠한 유저객체나 권한정보를 가져오고

getMyUserWithAuthorities는 현재 SecurityContext에 저장되어 있는 username의 정보만 가져옵니다.

 

이 두가지 메소드의 허용권한을 다르게 해서 권한검증에 대한 부분을 테스트하겠습니다.

 

# 권한검증 확인

 

- UserController 클래스

 

UserService의 메소드들을 호출할 UserController 클래스를 생성하겠습니다.

 

@RestController
@RequestMapping("/api")
public class UserController {
    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping("/signup")
    public ResponseEntity<User> signup(@Valid @RequestBody UserDto userDto) {
        return ResponseEntity.ok(userService.signup(userDto));
    }

    @GetMapping("/user")
    @PreAuthorize("hasAnyRole('USER','ADMIN')")
    public ResponseEntity<User> getMyUserInfo(HttpServletRequest request) {
        return ResponseEntity.ok(userService.getMyUserWithAuthorities().get());
    }

    @GetMapping("/user/{username}")
    @PreAuthorize("hasAnyRole('ADMIN')")
    public ResponseEntity<User> getUserInfo(@PathVariable String username) {
        return ResponseEntity.ok(userService.getUserWithAuthorities(username).get());
    }
}

 

  • @PreAuthorize
    • 해당 메서드가 호출되기 이전에 권한을 검사한다
  • hasAnyRole([role1, role2])
    • 현재 사용자의 권한이 파라미터의 권한 중 일치하는 것이 있는 경우 true 를 리턴

sinup 메소드는 UserDto를 매개변수로 받아서 UserService의 singup 메소드를 호출합니다.

 

getMyUserInfo 메소드는 @PreAuthorize를 통해서 USER, ADMIN 두가지 권한 모두 허용했고

 

getUserInfo 메소드는 ADMIN 권한만 호출할 수 있도록 설정했습니다

그리고 UserService에서 만들었던 username 매개변수를 기준으로 유저 정보와 권한 정보를 리턴하는 API가 되겠습니다.

 

 

 

# 회원가입 API 테스트

 

이제 우리가 만든 3개의 API를 Postman, H2 Console를 이용해 테스트해보겠습니다.

 

- 회원가입 요청

 

URL : http://localhost:8080/api/signup 경로로 POST 요청을 보냅니다.

 

 

 

회원가입 API에 대한 응답이 정상적으로 반환됬습니다 이제 가입된 유저정보를 H2 Console 에서 확인해보겠습니다.

 

 

추가한 유저 정보가 잘 등록된것을 볼수있습니다.

이제 권한이 다른 두 계정(admin, uesr)을 가지고 두 개의 API를 테스트해보겠습니다.

 

- 권한 API 테스트

 

먼저 ADMIN 권한만 허용했던 API를 테스트하겠습니다.

URL : http://localhost:8080/api/user/pobi 경로로 GET 요청을 합니다.

 

401 상태가 반환된것을 볼수있습니다.

 

 

- JWT Token 가져오기

 

ADMIN 계정을 로그인해서 token을 가져오겠습니다,

 

URL : http://localhost:8080/api/authenticate 경로에 POST 요청을 보냅니다.

 

  • Response - POST /api/authenticate
{
    "token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJwb2JpIiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTY1Mjk0MzcxOH0.dEj7MNV-OkRQ1TJvGKScQP8b9tPwVnp4LH3awd62oq5k4EA_LkpV-VpxRePAa1jaYefIuJJDVt1JzlPNaKbjsw"
}

 

그리고 해당 어드민 유저의 토큰을 HTTP Headers에 

Authorization : Bearer {jwt_token} 형식으로 담고 다시 권한 API 경로로 GET 요청을 보냅니다.

 

 

 

 

 

- ADMIN 권한 테스트

 

  • Response - GET /api/user/pobi
{
    "userId": 3,
    "username": "pobi",
    "password": "$2a$10$5EZfhJhs71UuDCsbfF7Kh.daGBP9mff2.kvTPgbvPxJKQsmqa1Zwa",
    "nickname": "pobi",
    "activated": true,
    "authorities": [
        {
            "authorityName": "ROLE_USER"
        }
    ]
}

 

/api/user/hoon 경로는 ROLE_ADMIN 권한을 가진 유저만 접근할 수 있는데.

정상적으로 응답이 된것을 확인할수있습니다.

 

why? admin 계정으로 Tests 탭에서 Response의 데이터를 전역변수에 저장해서 다른 Request에 사용할 수 있기에

현재 토큰은 admin 계정이어서 일반 ROLE_USER도 접근이 가능

 

 

위 사진처럼 Auth에 admin에서 생성한 토큰과 같은 이름을 넣어준다

 

 

 

 

- USER 권한 테스트

 

이번에는 pobi 계정의 토큰으로 이 API를 재호출 해보도록 하겠습니다.

기존에 로그인 API를 pobi 계정으로 요청하고, 토큰을 발급받습니다.

 

  • Response - POST /api/authenticate

 

pobi계정으로 POST 요청을 했고 해당 토큰을 이용해서 다시 API 를 호출하겠습니다.

(현재 토큰 정보는 ROLE_USER인 기본 유저 아이디값을 가지고 있다)

 

  • Response - GET/api/user/pobi
{
    "timestamp": "2022-05-18T07:30:01.519+00:00",
    "status": 403,
    "error": "Forbidden",
    "path": "/api/user/pobi"
}

 

pobi 계정의 토큰으로 요청을 해보면 403 Foribidden 에러가 반환된 것을 볼수있습니다.

why? /api/user/ 는 admin 전용 url 이기에

 

해당 403 Forbidden 에러는 저희가 작성한 JwtAccessDeniedHandler에 의해 발생됬습니다.

 

이번에는 USER권한을 허용해줬던 API를 pobi계정의 토큰으로 호출해보겠습니다.

 

  • Response - GET/user
{
    "userId": 3,
    "username": "pobi",
    "password": "$2a$10$5EZfhJhs71UuDCsbfF7Kh.daGBP9mff2.kvTPgbvPxJKQsmqa1Zwa",
    "nickname": "pobi",
    "activated": true,
    "authorities": [
        {
            "authorityName": "ROLE_USER"
        }
    ]
}

 

pobi 계정으로 발급받은 토큰으로 이 API 는 잘 호출되는 것을 볼수있습니다.

user뒤에 pobi를 붙이지 않은 이유는 토큰 값을 갖고있기 때문에 검증이 필요하지 않았고 /user 인 일반회원이기에 응답이 정상적으로 되는 것을 볼 수 있다

 

 

Response 시 DTO를 통해서만 받기

 

- 기존 문제점

 

추가적으로 지금까지 로직을 보시면 사용자 요청에 대해 응답을 Entity 그대로 전달하기 때문에 문제가있습니다.

문제점을 보기위해 Entity를 통해 반환을 하게 되면 어떤 결과를 나오는지 보겠습니다.

 

{
    "userId": 3,
    "username": "pobi",
    "password": "$2a$10$5EZfhJhs71UuDCsbfF7Kh.daGBP9mff2.kvTPgbvPxJKQsmqa1Zwa",
    "nickname": "pobi",
    "activated": true,
    "authorities": [
        {
            "authorityName": "ROLE_USER"
        }
    ]
}

 

해당 응답 결과처럼, 보시면은 중요한 정보들이 그대로 반환이 됩니다 그 이유는 UserService의 회원가입 로직을 처리하는 메소드가 User Entity 그대로 반환해주기 때문에 사용자 측에서는 해당 결과를 받게됩니다.

 

DTO를 통해 응답하도록 코드를 수정하겠습니다.

 

- 해결법

 

AuthorityDto 클래스 생성(권한정보에 대한 DTO 클래스)

 

@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class AuthorityDto {
    private String authorityName;
}

 

- userDto 수정

 

private Set<AuthorityDto> authorityDtoSet;

public static UserDto from(User user) {
    if(user == null) return null;

    return UserDto.builder()
            .username(user.getUsername())
            .nickname(user.getNickname())
            .authorityDtoSet(user.getAuthorities().stream()
                    .map(authority -> AuthorityDto.builder().authorityName(authority.getAuthorityName()).build())
                    .collect(Collectors.toSet()))
            .build();
}

 

from 메소드 User 객체를 매개변수로 받아서 해당 객체가 null이 아니면,

해당 객체를 UserDto로 생성해서 반환합니다.

 

+) authorityDtoSet의 경우에는 User 클래스에서 받아온 값이 Set 형태로 묶여있고 AuthorityDto 클래스에서 받아올 때는 String으로 받아오기 때문에 .collect(Collectors.toSet())) 을 주어서 형변환을 시켜줘서 String으로 저장되게 한다

 

 

- UserService 클래스 수정

 

User로 반환하던 이전 메소드들을 UserDto로 반환하도록 수정하겠습니다. (** 표시 주목)

@Transactional
    public **UserDto** signup(UserDto userDto) {
        if (userRepository.findOneWithAuthoritiesByUsername(userDto.getUsername()).orElse(null) != null) {
            throw new RuntimeException("이미 가입되어 있는 유저입니다.");
        }

        Authority authority = Authority.builder()
                .authorityName("ROLE_USER")
                .build();

        User user = User.builder()
                .username(userDto.getUsername())
                .password(passwordEncoder.encode(userDto.getPassword()))
                .nickname(userDto.getNickname())
                .authorities(Collections.singleton(authority))
                .activated(true)
                .build();

        return **UserDto.from**(userRepository.save(user));
    }

    @Transactional(readOnly = true)
    public **UserDto** getUserWithAuthorities(String username) {
        return **UserDto.from**(userRepository.findOneWithAuthoritiesByUsername(username)**.orElse(null))**;
    }

    @Transactional(readOnly = true)
    public **UserDto** getMyUserWithAuthorities() {
        return **UserDto.from**(SecurityUtil.getCurrentUsername().flatMap(userRepository::findOneWithAuthoritiesByUsername)**.orElse(null))**;
    }

 

 

회원가입 로직을 처리하는 signup 메소드는 기존 소스 그대로에서 UserDto.from 을 통해 User를 Dto로 생성해서 반환합니다.

 

나머지 두개의 권한 정보을 반환하는 메소드도 UserDto로 반환하도록 수정합니다.

 

기존에는 Optional을 통해서 null 예외처리를 해줬지만, 이젠 null 값이 들어오면 해당 값 그대로 리턴합니다.

 

 

- UserController

 

요청에 대해 User로 반환하던 이전 메소드들을 UserDto로 반환하도록 수정하겠습니다

 

@PostMapping("/signup")
    public ResponseEntity<**UserDto**> signup(@Valid @RequestBody UserDto userDto) {
        return ResponseEntity.ok(userService.signup(userDto));
    }

    @GetMapping("/user")
    @PreAuthorize("hasAnyRole('USER','ADMIN')")
    public ResponseEntity<**UserDto**> getMyUserInfo(HttpServletRequest request) {
        return ResponseEntity.ok(userService.getMyUserWithAuthorities());
    }

    @GetMapping("/user/{username}")
    @PreAuthorize("hasAnyRole('ADMIN')")
    public ResponseEntity<**UserDto**> getUserInfo(@PathVariable String username) {
        return ResponseEntity.ok(userService.getUserWithAuthorities(username));
    }

 

기존과 비슷하게 반환하는 객체를 UserDto로 변경해줍니다.

 

API 요청에 대해 Entity을 반환하는것이 아닌 Dto를 반환하는 코드로 변경을 완료했습니다.

 

 

- 결과값

 

  • /api/signup
{
    "username": "pobi",
    "nickname": "pobi",
    "authorityDtoSet": [
        {
            "authorityName": "ROLE_USER"
        }
    ]
}

 

이전과 다르게 패스워드 정보나 기타 필요없는 정보는 빼주고 리턴하고 싶은 정보만 반환하는 것을 볼 수 있다