[SpringBoot] Spring Security JWT 인증과 인가 - (2) 로그인 인증 (Authentication)
이번 포스팅에서는 Spring Boot에서 JWT를 이용한 로그인 인증을 구현해보도록 하겠다.
JWT 토큰 인증 방식에 대해서는 아래의 포스팅을 참고하면 좋을 것 같다.
https://sjh9708.tistory.com/46
<이전 포스팅> Spring Security JWT 인증과 인가 - (1) 회원 가입
https://sjh9708.tistory.com/83
2024-01-14 : 유의사항
해당 포스팅은 Spring 2점 버전대를 기준으로 작성하였다.
2023년 11월 경, Spring 2점대 버전의 지원이 공식 중단되면서, 이제 웬만하면 Spring 3 버전대를 사용할 것을 Spring 진영에서 권장하고 있다.
Spring Security의 경우 사용 방식이 변화한 것이 있어, Spring 3에서의 JWT 인증과 인가에 대해서 따로 포스팅을 작성하였다. 만약 Spring 3점대 버전을 사용한다면 다음 포스팅을 참고하면 좋을 것 같다.
https://sjh9708.tistory.com/170
Spring Security에서 사용자 정보를 다루는 클래스와 인터페이스
우선 Spring Security에서 제공되는 아래의 클래스에 대해서 짚어보고 넘어가야 이해하기가 쉬울 것이다.
- User: User 클래스는 Spring Security에서 제공하는 디폴트 사용자 모델.
이 클래스는 UserDetails 인터페이스를 구현하고 있어 사용자의 인증 정보와 권한 정보를 제공한다.
User 클래스는 다음 정보들을 포함한다. -> Username(사용자명), Password(비밀번호), 권한 목록 등
해당 예제에서는 User를 Extend하여 추가적인 정보를 함께 넣으려고 한다. - UserDetails: 인증과 관련된 사용자 정보를 추상화한 인터페이스. User 클래스와 같이 사용자 정보와 권한 정보를 제공하는 기능을 정의한다.
- UserDetailsService: Spring Security에서 사용자 정보를 가져오기 위한 메서드를 정의한다.
사용자의 인증 정보를 데이터베이스나 다른 데이터 저장소에서 가져와서 UserDetails 객체로 변환하여 제공하는 역할
Spring Security는 제공받은 UserDetails를 기반으로 인증 여부를 판단한다.
일반적으로 Spring Security에서는 UserDetailsService를 구현하여 사용자 정보를 가져오고, 이 정보를 User 클래스, 혹은 유사한 클래스로 변환하여 인증 및 인가에 사용한다.
Spring Security에서 기본적으로 제공하는 User 클래스와 UserDetails 인터페이스는 그 자체로 사용 가능하긴 하다.
그렇지만 사용자 정보 구조를 변경하여 사용하려고 하거나, 데이터 저장소를 사용하는 경우에는 커스텀 UserDetails 구현체를 만들어 사용해야 한다.
커스텀 사용자 정보 정의하기
▶ CustomUserDetail.java
/common/auth/custom/CustomUserDetail.java
/**
* [커스텀 User 클래스]
* 인증에서 사용되는 User 클래스에서 일부 속성을 추가하여 재정의한 CustomUser
*/
public class CustomUserDetail extends User {
//username(Email), password, authorities
private final String nickname; //닉네임
private final Long memberId; //PK
public CustomUserDetail(String username, String password, Collection<? extends GrantedAuthority> authorities,
String nickname, long memberId) {
super(
username, password, authorities
);
this.nickname = nickname;
this.memberId = memberId;
}
public String getNickname() {
return nickname;
}
public Long getUserId() {
return memberId;
}
}
필자는 사용자 정보에 Username, Password, Authorities 뿐만 아니라, 닉네임과 데이터베이스상의 PK도 함께 포함시키고 싶었다.
Spring Security에서 제공하는 디폴트 사용자 모델인 User를 상속받아, 새로운 커스텀 사용자 모델을 만들었다.
▶ CustomUserDetailsService.java
/common/auth/custom/CustomUserDetailsService.java
/**
* [JWT 인증 커스텀 필터에서 사용될 재정의된 UserDetailsService]
*/
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
/**
* [JWT 토큰의 인증 계정 찾기]
* UserDetailsService의 메서드를 오버라이딩하여, MemberRepository의 로직으로 인증 계정을 찾음
*/
@Override
public CustomUserDetail loadUserByUsername(String username) throws UsernameNotFoundException {
return memberRepository.findByEmail(username)
.map(this::createUserDetails)
.orElseThrow(() -> new UsernameNotFoundException("해당하는 유저를 찾을 수 없습니다."));
}
/**
* [CustomUserDetail 형태로 반환]
* 해당 계정 정보가 존재 시, CustomUserDetail의 형태로 리턴
* @param [Member 찾은 멤버 엔티티 객체]
* @return [CustomUserDetail]
*/
private CustomUserDetail createUserDetails(Member member) {
Collection<? extends GrantedAuthority> authorities =
Collections.singleton(new SimpleGrantedAuthority("ROLE_" + member.getAccountType().toString()));
return new CustomUserDetail(
member.getEmail(),
member.getPassword(),
authorities,
member.getNickname(),
member.getMemberId());
}
}
- public CustomUserDetail loadUserByUsername(String username)
- 기본적으로 사용자명을 기반으로 사용자 정보가 존재하는지를 확인하는 메서드이다.
- 데이터베이스와 연동하여 사용해야 하므로 재정의 시켜주었다.
- 사용자명(이메일)을 받아와서 해당 사용자 정보를 데이터베이스의 Member에서 조회하여, 존재한다면 CustomUserDetail 객체로 변환하여 반환시켜준다.
- 검색된 사용자 정보를 기반으로 비밀번호 검증은 Spring Security의 AuthenticationProvider가 내부적으로 수행한다.
데이터베이스에서 사용자 정보를 가져와서 Spring Security에서 사용할 수 있는 UserDetails 객체로 변환하는 기능을 추가하기 위해 UserDetailsService을 커스텀 클래스로 구현하였다. 이를 통해 JWT 인증 필터 등에서 데이터베이스에서 조회한 사용자 정보를 활용할 수 있다.
JWT 토큰을 처리하는 클래스 작성
이제 JWT와 관련되어, 토큰을 생성하고, 검증하고, 추출하는 등의 로직들을 담당할 클래스를 작성할 것이다.
▶ application.yml
#...
jwt:
secret: VlwEyVBsYt9V7zq57TejMnVUyzblYcfPQye08f7MGVA9XkHa
JWT 토큰의 Secret Key를 임의로 설정해주었다.
▶ TokenInfoDto.java
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
@ApiModel(description = "로그인 요청 DTO")
public class TokenInfoDto {
@ApiModelProperty(value = "토큰 타입", required = true)
@NotNull
private String grantType;
@ApiModelProperty(value = "Access Token", required = true)
@NotNull
private String accessToken;
}
클라이언트에게 반환할 JWT DTO을 정의해주었다. grantType과 토큰을 포함한다.
▶ JwtTokenProvider.java
/common/auth/JwtTokenProvider.java
/**
* [JWT 관련 메서드를 제공하는 클래스]
*/
@Slf4j
@Component
public class JwtTokenProvider {
private final Key key;
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
/**
* [JWT 생성]
* 유저 정보를 통해 AccessToken을 생성
* @param [Authentication 인증 정보 객체]
* @return [TokenInfo]
*/
public TokenInfoDto generateToken(Authentication authentication) {
// 권한 가져오기
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
CustomUserDetail userDetails = (CustomUserDetail) authentication.getPrincipal();
Long userId = userDetails.getUserId();
String nickname = userDetails.getNickname();
long now = (new Date()).getTime();
// Access Token 생성
Date accessTokenExpiresIn = new Date(now + AuthConstant.ACCESS_TOKEN_EXPIRE_TIME);
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim("userId", userId)
.claim("nickname", nickname)
.claim("auth", authorities)
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return TokenInfoDto.builder()
.grantType("Bearer")
.accessToken(accessToken)
.build();
}
/**
* [JWT 복호화]
* JWT을 복호화하여 토큰에 들어있는 정보를 반환
* @param [String accessToken]
* @return [Authentication]
*/
public Authentication getAuthentication(String accessToken) {
// 토큰 복호화
Claims claims = parseClaims(accessToken);
if (claims.get("auth") == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
// 클레임에서 권한 정보 가져오기
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// UserDetails 객체를 만들어서 Authentication 리턴
CustomUserDetail userDetails = new CustomUserDetail(
claims.getSubject(),
"",
authorities,
(String)claims.get("nickname"),
Long.valueOf((Integer)claims.get("userId")));
return new UsernamePasswordAuthenticationToken(userDetails, "", authorities);
}
/**
* [JWT 검증]
* JWT을 검증하는 메서드
* @param [String token]
* @return [Boolean : Validate 여부]
*/
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.", e);
}
return false;
}
/**
* [JWT 클래임 추출]
* JWT 토큰 안의 Claim 정보를 추출
* @param [String accessToken]
* @return [Claims]
*/
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
/**
* [요청에서 토큰 추출]
* Request Header로부터 JWT 토큰을 추출
*/
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
return bearerToken.substring(7);
}
return null;
}
}
- JwtTokenProvider(@Value("${jwt.secret}") String secretKey)
- application.yml에서 비밀키를 받아온다. 해당 비밀키는 토큰 서명 및 검증에 사용된다.
- application.yml에서 비밀키를 받아온다. 해당 비밀키는 토큰 서명 및 검증에 사용된다.
- public TokenInfoDto generateToken(Authentication authentication)
- 사용자의 인증 정보를 이용하여 JWT 토큰을 생성하는 메서드
- Authentication 클래스는 인증 성공 여부와, UserDetails을 기반으로 생성된 사용자 정보를 포함하고 객체, 인증 처리 과정에서 사용된다.
- Authentication에 포함된 권한(Roles), PK, 닉네임을 추출한다. (권한은 앞 포스팅에서는 CONSUMER/SELLER(매장/고객)으로 설정하였다.)
- 추출한 정보를 기반으로 Jwts.builder()를 통하여 JWT를 생성한다.
- Subject에는 authentication.getName(), 즉 이메일을 포함한다.
- Claim에는 Member 테이블의 PK, 닉네임과, 권한을 포함할 것이다.
- Expire Time을 설정해주고, 서명하여 토큰을 생성한 후 TokenInfoDto 형태로 반환한다.
- public Authentication getAuthentication(String accessToken)
- JWT 토큰을 복호화하여 인증 정보를 가져오는 메서드
- parseClaims는 토큰을 복호화하여, 토큰 안에 담긴 정보들을 추출한다.
- UsernamePasswordAuthenticationToken : 사용자의 정보를 담은 토큰, 인가(Authorization) 구현 시 Request Header에 포함된 JWT 토큰을 복호화시켜서 해당 정보를 기반으로 사용자의 유효성을 검증할 때 사용될 예정이다.
- public boolean validateToken(String token)
- JWT 토큰의 유효성을 검증하는 메서드
- JWT 토큰의 유효성을 검증하는 메서드
- public String resolveToken(HttpServletRequest request)
- HTTP 요청 헤더에서 JWT 토큰을 추출하는 메서드. 요청 헤더의 "Authorization" 필드에서 "Bearer" 접두사와 함께 토큰을 가져온다.
- 인가(Authorization) 구현 시 Request Header에 포함된 JWT 토큰을 추출하기 위해서 사용된다.
Spring Security Config 변경
▶SecurityConfig.java
/common/auth/SecurityConfig.java
/**
* [Spring Security Config 클래스]
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtTokenProvider jwtTokenProvider;
private final CustomUserDetailsService customUserDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(customUserDetailsService)
.passwordEncoder(passwordEncoder()); // Set the BCryptPasswordEncoder for password encoding
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/swagger-resources/**").permitAll()
.antMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.anyRequest().permitAll();
http
.headers()
.frameOptions()
.sameOrigin();
http
.httpBasic().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.cors()
.and()
.formLogin().disable()
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
configure(AuthenticationManagerBuilder auth)를 추가적으로 구현해주었다.
이는, 기존 디폴트로 사용하는 UserDetailsService 대신 커스텀 클래스를 사용하기 위해서 작성했으며,
해싱 알고리즘으로는 DelegatingPasswordEncoder가 기본으로 사용되어, 사용자 인증 정보의 Password 부분을 검증했으나, 이를 BCryptPasswordEncoder로 변경하여 사용하기 위해서 추가 작성하였다.
로그인 API 작성
▶AuthApiController.java
@Api(tags = "인증 API")
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthApiController {
private final AuthService authService;
@ApiOperation(
value = "기본 로그인"
)
@PostMapping("/login")
public ResponseEntity<TokenInfoDto> login(@RequestBody LoginRequestDto memberLoginRequestDto) {
String memberId = memberLoginRequestDto.getEmail();
String password = memberLoginRequestDto.getPassword();
TokenInfoDto tokenInfo = authService.login(memberId, password);
return ResponseEntity.status(HttpStatus.OK).body(tokenInfo);
}
}
이제 로그인을 처리하는 API를 작성해보도록 하자.
LoginRequestDto에는 사용자의 이메일과 비밀번호를 입력받게 되어 있다.
▶AuthServiceImpl.java
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtTokenProvider jwtTokenProvider;
@Transactional
public TokenInfoDto login(String email, String password) {
// 1. Login Email/PW 를 기반으로 Authentication 객체 생성
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(email, password);
// 2. 실제 검증 (사용자 비밀번호 체크)
// authenticate 매서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드가 실행
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// 3. 인증 정보를 기반으로 JWT 생성
TokenInfoDto tokenInfo = jwtTokenProvider.generateToken(authentication);
return tokenInfo;
}
}
1. 로그인 시도된 이메일과 비밀번호를 인증을 처리하기 위한 형태인 UsernamePasswordAuthenticationToken 으로 변환해준다.
2. 실제 인증을 수행하며, Spring Security가 AuthenticationManagerBuilder를 통해 인증을 처리하게 된다. 과정은 아래와 같다.
2-1. CustomUserDetailsService에서 만든 loadUserByUsername 메서드가 실행되어 사용자가 존재하는지 확인
2-2. loadUserByUsername는 데이터베이스의 유저 정보를 CustomUserDetails 형태로 담아 반환
2-3. 반환된 데이터를 바탕으로 AuthenticationProvider가 입력 비밀번호와, 데이터베이스에서 가져온 사용자 정보의 비밀번호를 비 교하고, 일치하는지의 여부를 판단하여 인증 결과를 결정
3. 인증 결과를 바탕으로 JWT를 생성하여 반환한다.
API 테스트
이메일과 패스워드가 일치하는 경우
이메일이 존재하지 않는 경우
패스워드가 틀린 경우
Swagger 상에서 테스트 해보았을 때, 이메일과 패스워드가 모두 일치했을 때에는 JWT를 정상적으로 응답으로 받는 것을 확인할 수 있다.
반대로 존재하지 않는 이메일을 입력하거나, 이메일은 존재하지만 패스워드가 틀렸을 경우에는 403 Forbidden이 Http Status로 반환되는 것을 확인하여, JWT 로그인 인증 기능을 테스트 완료하였다.
다음 포스팅에서는 이제 다른 API들에 대해서 인증(Authentication)된 사용자에 대해서만 접근을 허용하도록 인가(Authorization)를 적용시켜보도록 하자.
https://sjh9708.tistory.com/85
'Backend > Spring' 카테고리의 다른 글
[SpringBoot] Spring Security : JWT Auth + Redis 적용하기 (Stateful vs Stateless에 대한 고민) (0) | 2023.08.28 |
---|---|
[SpringBoot] Spring Security JWT 인증과 인가 - (3) API 인가 (Authorization) (0) | 2023.08.14 |
[SpringBoot] Spring Security JWT 인증과 인가 - (1) 회원 가입 (0) | 2023.08.14 |
[SpringBoot] API 문서 생성 - Swagger 연동하기 (SpringFox) (0) | 2023.07.25 |
[SpringBoot] JPA 사용에 MariaDB 연결하기 (0) | 2023.07.25 |