sbs1621
부릉부릉 개발자
sbs1621
만나서 반갑습니당 🤍
Portfolio
Github
email
전체 방문자
오늘
어제
  • 분류 전체보기 (69)
    • Spring Boot (3)
      • Swagger (2)
      • Test Code (1)
    • Spring Security (4)
      • Json Web Token (4)
    • Algorithm (25)
      • Beakjoon (22)
      • Programmers (0)
      • 이것이 코딩테스트다 (3)
    • Kubernetes (0)
      • Docker (0)
    • Util (2)
      • Customizing (2)
    • Computer Sience (8)
      • Operating System (0)
      • Network (8)
    • IoT (2)
      • Arduino (2)
    • Daily Life (16)
      • 꿀팁 (1)
      • 일상 (6)
      • 해외여행 (4)
      • 회고록 (3)
      • 학교 (2)
    • Work (9)
      • ETRI (9)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

인기 글

최근 글

티스토리

hELLO · Designed By 정상우.
sbs1621

부릉부릉 개발자

[Spring] JWT(Json Web Token) 로그인 구현
Spring Security/Json Web Token

[Spring] JWT(Json Web Token) 로그인 구현

2022. 6. 7. 18:00

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

 

[무료] Spring Boot JWT Tutorial - 인프런 | 강의

Spring Boot, Spring Security, JWT를 이용한 튜토리얼을 통해 인증과 인가에 대한 기초 지식을 쉽고 빠르게 학습할 수 있습니다., - 강의 소개 | 인프런...

www.inflearn.com

 

    'Spring Security/Json Web Token' 카테고리의 다른 글
    • [Spring] JWT(Json Web Token) 예제
    • [Spring] JWT(Json Web Token) 초기설정
    • JWT(Json Web Token)란?

    티스토리툴바