5. [SpringBoot JWT 튜토리얼] 회원가입, 권한검증
# 회원가입 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 요청을 합니다.
- 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를 통해 응답하도록 코드를 수정하겠습니다.
- 해결법
@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"
}
]
}
이전과 다르게 패스워드 정보나 기타 필요없는 정보는 빼주고 리턴하고 싶은 정보만 반환하는 것을 볼 수 있다