JWT를 이용한 로그인 및 권한 검증
JWT를 이용하여 간단한 로그인과 회원가입 구현하고 User와 Admin을 권한 검증으로 구분하는 시간을 가져봅시다.
로그인 구현
DTO클래스 생성
LoginDto
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class LoginDto {
@NotNull
@Size(min = 3, max = 50)
private String username;
@NotNull
@Size(min = 3, max = 100)
private String password;
}
- Lombok 어노테이션 추가
- Valid관련 어노테이션 추가
- username, password 필드를 가지고 있는 DTO
TokenDto
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TokenDto {
private String token;
}
- 토큰 필드를 가지는 DTO
UserDto
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class UserDto {
@NotNull
@Size(min = 3, max = 50)
private String username;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@NotNull
@Size(min = 3, max = 100)
private String password;
@NotNull
@Size(min = 3, max = 50)
private String nickname;
}
- username, password, nickname 필드를 가지는 DTO
Repository 생성
UserRepository 인터페이스
public interface UserRepository extends JpaRepository<User, Long> {
@EntityGraph(attributePaths = "authorities")
Optional<User> findOneWithAuthoritiesByUsername(String username);
}
- UserEntity와 매핑됨
- JpaRepository를 extends 하는 것으로 findAll, save 등의 메소드를 기본적으로 사용할 수 있음
- findOneWithAuthoritiesByUsername : username을 기준으로 User정보를 가져올 때 권한 정보도 같이 가져오는 메소드
- @EntityGraph : 해당 쿼리가 수행될 때 Lazy조회가 아니라 Eager조회로 "authorities" 정보를 같이 가져옴
Login Api와 로직 생성
CustomUserDetailsService 생성
@Component("userDetailsService")
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
@Transactional
public UserDetails loadUserByUsername(final String username) {
return userRepository.findOneWithAuthoritiesByUsername(username)
.map(user -> createUser(username, user))
.orElseThrow(() -> new UsernameNotFoundException(username + " -> 데이터베이스에서 찾을 수 없습니다."));
}
private org.springframework.security.core.userdetails.User createUser(String username, User user) {
if (!user.isActivated()) {
throw new RuntimeException(username + " -> 활성화되어 있지 않습니다.");
}
List<GrantedAuthority> grantedAuthorities = user.getAuthorities().stream()
.map(authority -> new SimpleGrantedAuthority(authority.getAuthorityName()))
.collect(Collectors.toList());
return new org.springframework.security.core.userdetails.User(user.getUsername(),
user.getPassword(),
grantedAuthorities);
}
}
- UserDetailsService를 implements 하고 방금 만들었던 UserRpository를 주입 받음
- UserDetailsService의 loadUserByName 메소드를 Override
- 로그인 시 DB에서 유저 정보와 권한 정보를 가져옴
- 해당 정보를 기반으로 User가 활성화 상태라면 User의 권한 정보와 username, password를 가지고 userDetails.User객체를 생성하여 리턴
AuthController 생성
@RestController
@RequestMapping("/api")
public class AuthController {
private final TokenProvider tokenProvider;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
public AuthController(TokenProvider tokenProvider, AuthenticationManagerBuilder authenticationManagerBuilder) {
this.tokenProvider = tokenProvider;
this.authenticationManagerBuilder = authenticationManagerBuilder;
}
@PostMapping("/authenticate")
public ResponseEntity<TokenDto> authorize(@Valid @RequestBody LoginDto loginDto) {
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
String jwt = tokenProvider.createToken(authentication);
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + jwt);
return new ResponseEntity<>(new TokenDto(jwt), httpHeaders, HttpStatus.OK);
}
}
- TokenProvider와 AuthenticationManageBuilder를 주입 받음
private final TokenProvider tokenProvider;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
- 로그인 api경로는 /api/authenticate이며 POST 요청을 받음
@PostMapping("/authenticate")
public ResponseEntity<TokenDto> authorize(@Valid @RequestBody LoginDto loginDto)
- LoginDTO의 username, password를 파라미터로 받고 이를 통해서 AuthenticationToken객체를 생성
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
- AuthenticationToken을 이용해서 authenticate메소드가 실행될 때 CustomUserDetailsService에서 만들었던 loadUserByUsername메소드가 실행됨
- 이 결괏값을 가지고 Authentication 객체를 생성하고 SecurityContext에 저장
- 그 인증 정보를 기준으로 TokenProvider에서 만들었던 createToken메소드를 통해 JWT Token 생성
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
String jwt = tokenProvider.createToken(authentication);
- JWT Token을 Response Header에도 넣어주고 TokenDto를 이용하여 ResponseBody에도 넣어서 리턴
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + jwt);
return new ResponseEntity<>(new TokenDto(jwt), httpHeaders, HttpStatus.OK);
Postman을 실행시켜서 테스트
post요청 : localhost:8080/api/authenticate
{
"username":"admin",
"password":"admin"
}
Request
{
"token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImF1dGgiOiJST0xFX0FETUlOLFJPTEVfVVNFUiIsImV4cCI6MTY1NDA5MjM4MX0.v0iIeP0Wn5EzyooCzSDHZ3tOecMvF7wJ-CYrNuKKzM_Kv2V2osBk568JxECOAgRkElxTyYK8JeX8oIyRzvJ76g"
}
회원가입 API 생성
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을 return 해주는 간단한 유틸 성 메소드
- SecurityContext에 Authenticationro객체가 저장되는 시점은 이전에 만들었던 JwtFilter의 doFilter메소드에 Request가 들어오는 시점에서 SecurityContext에 Authentication 객체가 저장됨
- 이때 저장된 Authentication 객체가 꺼내짐
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);
}
}
- UserRepository, PasswordEncoder를 주입 받음
- signup메소드 : 회원가입 로직을 수행하는 메소드
- 파라미터로 받음 userDto안의 username을 기준으로 하여 DB에 username으로 저장되어있는 정보가 있는지 찾아봄(중복체크)
- 없으면 권한 정보를 만들고 권한 정보를 넣은 유저정보를 만들어서 UserReporitory에 save메소드를 통해 DB에 user정보와 권한정보를 저장
- 이때 User에게는 ROLE_USER권한이 들어가게 됨
- data.sql에서 server가 실행될 때 자동으로 생성되는 admin계정은 ROLE_USER, ROLE_ADMIN 권한을 둘 다 가지고 있음
- 회원가입을 통해 저장한 User는 ROLE_USER권한 하나만 가지고 있음
- 이 차이를 통해 권한 검증을 실행
- User의 권한 정보를 가져오는 두 개의 메소드
- getUserWithAuthorities : username에 해당하는 user객체와 권한 정보를 가져옴
- getMyUserWithAuthorities : 현재 Security Context에 저장된 username이 해당하는 User정보와 권한 정보만 가져옴
- 이 두 개의 허용 권한을 다르게 하여 권한 검증을 테스트 할 수 있음
권한검증 확인
UserController 생성
UserService 메소드들을 호출
@RestController
@RequestMapping("/api")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/hello")
public ResponseEntity<String> hello() {
return ResponseEntity.ok("hello");
}
@PostMapping("/test-redirect")
public void testRedirect(HttpServletResponse response) throws IOException {
response.sendRedirect("/api/user");
}
@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());
}
}
- signup API : UserDto 객체를 파라미터로 받아 UserService의 signup메소드를 호출하여 수행
- getMyUserInfo 메소드 : @PreAuthorize를 통해 USER, ADMIN 두가지 원한을 모두 호출할 수 있는 API
- getUserInfo 메소드 : @PreAuthorize를 통해 ADMIN 권한만 호출할 수 있는 메소드
Admin 계정의 토큰 생성을 위한 Postman 실행
POST 요청 : localhost:8080/api/authenticate
{
"username":"admin",
"password":"admin"
}
Request : 토큰 생성
{
"token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImF1dGgiOiJST0xFX0FETUlOLFJPTEVfVVNFUiIsImV4cCI6MTY1NDI1OTM4OH0.ftJ6ebiGk4f5JqX71VQAy69isduvNOfYQU1r9EQcuxTokteQgLakXSMn4Sfcsfgb1v_UGByxB2nHZJ_slJhIXA"
}
토큰 정보를 가져오기 위한 설정
이제 Admin계정의 권한으로 요청을 할 수 있습니다.
User회원가입을 위한 Postman실행
POST 요청 : localhost:8080/api/signup
{
"username":"sbs1621",
"password":"sbs1621",
"nickname":"nickname"
}
Request
{
"userId": 2,
"username": "sbs1621",
"password": "$2a$10$8o0sdQsC6vUuQsH6UaOUxe9CV576fmS8cR81c1jq7k8sB.SDEKFMK",
"nickname": "nickname",
"activated": true,
"authorities": [
{
"authorityName": "ROLE_USER"
}
]
}
Admin 계정의 토큰으로 User의 정보를 가져올 수 있음
GET 요청 : localhost:8080/api/user/sbs1621 (ROLE_ADMIN 권한을 가져야 가능한 경로)
Request
{
"userId": 2,
"username": "sbs1621",
"password": "$2a$10$LMFrZLxSDCNSdmwgC50gnusnp9zEA/f89Rqjc06NJhARCAFk0A.02",
"nickname": "nickname",
"activated": true,
"authorities": [
{
"authorityName": "ROLE_USER"
}
]
}
회원 정보가 잘 출력되는 것을 볼 수 있습니다.
User 계정의 토큰으로 User의 정보를 가져올 수 있을까?
Send 하여 토큰 발급
{
"token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJzYnMxNjIxIiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTY1NDI2MDM3NX0.BPX_X83vhGxXNKoPWbonfZhyTnhjrv73xsdymmp4dsc_aokLaexyEcQmDmzs1yAD2PKA4-Q7bo1QroMPp14Z6w"
}
GET 요청 : localhost:8080/api/user/sbs1621 (토큰 정보 가져오면서)
Request
{
"timestamp": "2022-06-01T05:41:12.563+00:00",
"status": 403,
"error": "Forbidden",
"path": "/api/user/sbs1621"
}
클라이언트의 접근을 거부하는 403 포비든 에러가 뜨는 것을 확인할 수 있다.
User 권한으로도 접근 가능한 호출
GET 요청 : localhost:8080/api/user
출력
{
"userId": 2,
"username": "sbs1621",
"password": "$2a$10$8o0sdQsC6vUuQsH6UaOUxe9CV576fmS8cR81c1jq7k8sB.SDEKFMK",
"nickname": "nickname",
"activated": true,
"authorities": [
{
"authorityName": "ROLE_USER"
}
]
}
지금까지 JWT의 기본 개념과 이를 이용하여 간단한 예제와 실습을 해봤습니다. 긴 글 봐주셔서 감사합니다.
Reference