1편에 이어서 Gradle을 활용해 SpringBoot 환경에서 회원가입/로그인 환경을 구축해보려고 한다.
Spring Security + JWT
Spring Security와 JWT를 어떻게 하면 같이 사용할 수 있을까?
Gradle
- 먼저 Spring Security와 jwt 인증에 필요한 라이브러리들을 gradle에 추가해준다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'mysql:mysql-connector-java'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'org.jsoup:jsoup:1.13.1'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.2'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.2'
implementation group: 'org.hibernate', name: 'hibernate-spatial', version: '5.6.14.Final'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
프로젝트 내 현 gradle 상황이라 관련 없는 것도 섞여있다.
Jwt를 사용하기 위해 구현해야 할 것은 크게 기본적으로
- Jwt 토큰 제공을 위한 JwtTokenProvider
- HTTP Request에서 토큰을 읽어 들여 정상 토큰이면 Security Context에 저장하는JwtTokenFilter
이렇게 2개이다.
그리고 Spring Security에 적용하기 위해 구현해야 할 것은 기본적으로
- Jwt Filter를 Spring Security Filter Chain에 추가하기 위한 JwtSecurityConfig
- 기본적으로 Spring Security 설정을 위한 SecurityConfig
이다.
Authentication Token을 Authentication Manager가 넘겨받아
Authentication 객체를 생성하고, 이를 Provider에게 전달하여 Token을 생성하게 된다.
TokenProvider
@Slf4j
@Component
public class TokenProvider {
private static final String AUTHORITIES_KEY = "auth";
private static final String BEARER_TYPE = "bearer";
private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30; // 30분 - 1000 * 60 * 30
private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7; // 7일 - 1000 * 60 * 60 * 24 * 7
private final Key key;
public TokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public TokenDto generateTokenDto(Authentication authentication) {
// 권한들 가져오기
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
// Access Token 생성
Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
String accessToken = Jwts.builder()
.setSubject(authentication.getName()) // payload "sub": "name"
.claim(AUTHORITIES_KEY, authorities) // payload "auth": "ROLE_USER"
.setExpiration(accessTokenExpiresIn) // payload "exp": 1516239022 (예시)
.signWith(key, SignatureAlgorithm.HS512) // header "alg": "HS512"
.compact();
// Refresh Token 생성
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(key, SignatureAlgorithm.HS512)
.compact();
return TokenDto.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.accessTokenExpiresIn(accessTokenExpiresIn.getTime())
.refreshToken(refreshToken)
.build();
}
public Authentication getAuthentication(String accessToken) {
// 토큰 복호화
Claims claims = parseClaims(accessToken);
if (claims.get(AUTHORITIES_KEY) == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
// 클레임에서 권한 정보 가져오기
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// UserDetails 객체를 만들어서 Authentication 리턴
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
log.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
JWT 토큰을 발행하고, Payload에 들어간 클레임을 통해 User 객체를 생성하여 Authentication 객체를 반환한다.
HTTP Request Header로부터 토큰을 가져오고, 토큰을 검증하는 모든 기능을 JwtTokenProvider에 구현하였다.
JwtFilter
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";
private final TokenProvider tokenProvider;
// 실제 필터링 로직은 doFilterInternal 에 들어감
// JWT 토큰의 인증 정보를 현재 쓰레드의 SecurityContext 에 저장하는 역할 수행
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
// 1. Request Header 에서 토큰을 꺼냄
String jwt = resolveToken(request);
// 2. validateToken 으로 토큰 유효성 검사
// 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
// Request Header 에서 토큰 정보를 꺼내오기
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
}
OncePerRequestFilter를 상속받아 JwtTokenFilter를 구현함으로써 Request마다 1회만 실행되는 필터를 작성했다.
OncePerRequestFilter를 사용하면 doFIlter 메서드가 아닌 doFilterInternal 메서드를 Override 하여 정의해주어야 한다.
JwtTokenFilter는 사용자의 요청이 들어오면 Servlet Req, Res 객체가 생성되어 넘어오게 되는데,
Req 객체에서 JWT Token을 추출해서 token을 통해 정상 토큰인지 확인한 후 토큰을 통해 생성한 Authentication 객체를 SecurityContext에 저장해주는 역할을 수행한다.
Authentication 객체는 Spring Security에서 한 유저의 인증 정보를 가지고 있는 객체인데,
Spring Security는 사용자의 principal과 credential 정보를 Authentication 객체에 담아 생성한 후 보관한다.
(여기서 principal은 유저의 식별자, credential은 암호라고 생각하면 된다.)
이때, Authentication 객체를 만드는 과정은 다음과 같다.
jwtTokenProvider.getAuthentication
위 메서드로 token을 넘겨주면, token을 생성할 때 claim으로 넣어줬었던 유저의 이름을 UserDetailsService의 loadUserByUsername 메서드에 넘겨주고, 해당 유저가 존재하는지 확인한다.
존재한다면,
UsernamePasswordAuthenticationToken
위 객체를 생성하여 반환해주게 되는데, 이 객체는 Security Context에 저장될 Authentication 객체이다.
Security Context란 Authentication 객체를 보관하는 친구이며, 이 Security Context는 SecurityContextHolder를 통해 접근할 수 있다.
정리하자면, 사용자의 요청이 들어오면 헤더에 담아서 보낸 JWT Token을 통해 Authentication 객체를 생성하여
SecurityContextHolder.getContext().setAuthentication(auth);
다음과 같이 Security Context에 저장해둔 것이다.
JwtSecurityConfig
// 직접 만든 TokenProvider 와 JwtFilter 를 SecurityConfig 에 적용할 때 사용
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private final TokenProvider tokenProvider;
// TokenProvider 를 주입받아서 JwtFilter 를 통해 Security 로직에 필터를 등록
@Override
public void configure(HttpSecurity http) {
JwtFilter customFilter = new JwtFilter(tokenProvider);
http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
}
}
JwtTokenProvider를 주입받고 JwtTokenFilter를 생성한 후,
UsernamePasswordAuthenticationFilter가 동작하여 아이디, 패스워드를 통해 인증 요청을 진행하기 전에 제작한 Jwt Custom Filter를 추가하여 정상적으로 JWT 토큰을 통한 인증 방식이 작동하도록 해주었다.
SecurityConfig
@EnableWebSecurity // 기본적인 Web 보안 활성화
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final TokenProvider tokenProvider;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
// 인증이 되지 않은 유저가 요청을 보낼 때 동작하는 인터페이스
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
// 서버에 요청 시 액세스 불가능한 권한의 유저가 요청할 경우 동작하는 핸들러
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// CSRF 설정 Disable
http.csrf().disable()
.cors().disable()
.headers().frameOptions().disable()
.and()
// exception handling 할 때 우리가 만든 클래스를 추가
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
// h2-console 을 위한 설정을 추가
.and()
.headers()
.frameOptions()
.sameOrigin()
// 시큐리티는 기본적으로 세션을 사용
// 여기서는 세션을 사용하지 않기 때문에 세션 설정을 Stateless 로 설정
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 로그인, 회원가입 API 는 토큰이 없는 상태에서 요청이 들어오기 때문에 permitAll 설정
.and()
.authorizeRequests()
.antMatchers("/", "/**").permitAll()
.antMatchers("/users/logIn").permitAll()
.antMatchers("/users/sign-in").permitAll()
.anyRequest().authenticated() // 나머지 API 는 전부 인증 필요
// JwtFilter 를 addFilterBefore 로 등록했던 JwtSecurityConfig 클래스를 적용
.and()
.apply(new JwtSecurityConfig(tokenProvider));
}
}
WebSecurityConfigurerAdapter를 상속받은 config 클래스 (Security Config)에 @EnableWebSecurity 어노테이션을 달면 SpringSecurityFilterChain이 자동으로 포함된다.
jwtAccessDeniedHandler : 서버에 요청 시 액세스 불가능한 권한의 유저가 요청했을 경우 동작하는 핸들러
jwtAuthenticationEntryPoint : 인증이 되지 않은 유저가 요청을 보냈을 때 동작하는 인터페이스
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
// 필요한 권한이 없이 접근하려 할때 403
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
// 유효한 자격증명을 제공하지 않고 접근하려 할때 401
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
참고한 블로그에서는 Security Config 파일에 Spring security에서 인증을 담당하는 AuthenticationManager를 사용하여 원하는 시점에 인증을 처리하기 위해 추가로 Bean을 등록해주었다.
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration
) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
TokenModel
TokenDto
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TokenDto {
@ApiModelProperty(example = "권한 타입")
private String grantType;
@ApiModelProperty(example = "엑세스 토큰")
private String accessToken;
@ApiModelProperty(example = "리프레쉬 토큰")
private String refreshToken;
@ApiModelProperty(example = "엑세스 토큰 만료 시간")
private Long accessTokenExpiresIn;
public void setGrantType(String grantType) {
this.grantType = grantType;
}
}
RefreshTokenEntity
@Getter
@NoArgsConstructor
@Table(name = "refresh_token")
@Entity
public class RefreshTokenEntity {
@Id
private String keyId;
private String value;
public RefreshTokenEntity updateValue(String token) {
this.value = token;
return this;
}
@Builder
public RefreshTokenEntity(String key, String value) {
this.keyId = key;
this.value = value;
}
}
refresh token을 저장할 Entity이다.
TokenRepository
@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshTokenEntity, Long> {
Optional<RefreshTokenEntity> findByKeyId(String key);
}
CustomUserDetailsService
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
@Transactional
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
return userRepository.findByEmail(email)
.map(this::createUserDetails)
.orElseThrow(() -> new UsernameNotFoundException(email + " -> 데이터베이스에서 찾을 수 없습니다."));
}
// DB 에 User 값이 존재한다면 UserDetails 객체로 만들어서 리턴
private UserDetails createUserDetails(UserEntity user) {
GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(user.getRole().toString());
return new User(
String.valueOf(user.getEmail()),
user.getPwd(),
Collections.singleton(grantedAuthority)
);
}
}
로그인 인증 등 유저의 인증 정보가 담긴 객체를 증명하는 Service단이다.
UserModel
UserController
@ResponseBody
@ApiOperation(value = "회원가입", notes ="비밀번호 validation 규칙은 8글자 이상 16글자 이하, 문자 + 숫자 섞어서입니다!")
@PostMapping("/sign-in")
public BaseResponse<TokenDto> signIn(@RequestBody PostUserReq user) {
try {
TokenDto token = this.userService.signIn(user);
return new BaseResponse<>(token);
} catch (BaseException e) {
return new BaseResponse<>(e.getStatus());
}
}
PostUserReq
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class PostUserReq {
private Long userIdx;
private String name;
private String email;
private String pwd;
private Role role;
@Builder
public PostUserReq(Long userIdx, String name, String email, String pwd){
this.userIdx = userIdx;
this.name = name;
this.email = email;
this.pwd = pwd;
}
}
UserRepository
@Repository
public interface UserRepository extends JpaRepository <UserEntity, Long> {
UserEntity findAllByUserIdx(Long userIdx);
boolean existsByEmail(String email);
Optional<UserEntity> findByEmail(String email);
}
UserService
회원가입
// 회원가입
public TokenDto signIn(PostUserReq user) throws BaseException {
if(user.getEmail() == null || user.getPwd() == null){
throw new BaseException(BaseResponseStatus.POST_USERS_EMPTY);
}
if(!isRegexEmail(user.getEmail())){
throw new BaseException(BaseResponseStatus.POST_USERS_INVALID_EMAIL);
}
if(isHaveEmail(user.getEmail())){
throw new BaseException(BaseResponseStatus.DUPLICATE_EMAIL);
}
String password = user.getPwd();
if(!isRegexPwd(password)){
throw new BaseException(BaseResponseStatus.POST_USERS_INVALID_PWD);
}
try{
String encodedPwd = passwordEncoder.encode(user.getPwd());
user.setPwd(encodedPwd);
}catch (Exception e){
throw new BaseException(BaseResponseStatus.PASSWORD_ENCRYPTION_ERROR);
}
UserEntity userEntity = UserEntity.builder()
.name(user.getName())
.email(user.getEmail())
.pwd(user.getPwd())
.loginType(LoginType.BASIC)
.role(Role.ROLE_USER)
.build();
user.setPwd(password);
userEntity = userRepository.save(userEntity);
UserProfileEntity userProfileEntity = UserProfileEntity.builder()
.nickName("기본 닉네임")
.imgURL("기본 이미지")
.statusMsg("안녕하세요")
.userIdx(userEntity)
.build();
userProfileRepository.save(userProfileEntity);
return token(user);
}
토큰 발급
public TokenDto token(PostUserReq user){
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getEmail(), user.getPwd());
// 2. 실제로 검증 (사용자 비밀번호 체크) 이 이루어지는 부분
// authenticate 메서드가 실행이 될 때 CustomUserDetailsService 에서 만들었던 loadUserByUsername 메서드가 실행됨
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// 3. 인증 정보를 기반으로 JWT 토큰 생성
TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);
// 4. RefreshToken 저장
RefreshTokenEntity refreshToken = RefreshTokenEntity.builder()
.key(authentication.getName())
.value(tokenDto.getRefreshToken())
.build();
refreshTokenRepository.save(refreshToken);
// 5. 토큰 발급
return tokenDto;
}
refresh token 재발급
public TokenDto reissue(TokenDto tokenRequestDto) { //재발급
// 1. Refresh Token 검증
if (!tokenProvider.validateToken(tokenRequestDto.getRefreshToken())) {
throw new RuntimeException("Refresh Token 이 유효하지 않습니다.");
}
// 2. Access Token 에서 Member ID 가져오기
Authentication authentication = tokenProvider.getAuthentication(tokenRequestDto.getAccessToken());
// 3. 저장소에서 Member ID 를 기반으로 Refresh Token 값 가져옴
RefreshTokenEntity refreshToken = refreshTokenRepository.findByKeyId(authentication.getName())
.orElseThrow(() -> new RuntimeException("로그아웃 된 사용자입니다."));
// 4. Refresh Token 일치하는지 검사
if (!refreshToken.getValue().equals(tokenRequestDto.getRefreshToken())) {
throw new RuntimeException("토큰의 유저 정보가 일치하지 않습니다.");
}
// 5. 새로운 토큰 생성
TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);
// 6. 저장소 정보 업데이트
RefreshTokenEntity newRefreshToken = refreshToken.updateValue(tokenDto.getRefreshToken());
refreshTokenRepository.save(newRefreshToken);
// 토큰 발급
return tokenDto;
}
Postman을 통하여 body값에 User 생성에 필요한 값들을 담고 발행해주면,
정상적으로 회원가입에 성공했다!
다음에는 refresh token을 관리하는 방법 (refresh Token일 경우 필터 조건 처리하기, Redis 활용하기) 나 Spring Security에 관한 내용들에 대해 더 공부해볼 예정이다.
# 레퍼런스
'Backend > springboot' 카테고리의 다른 글
[JPA] N+1 문제 (0) | 2023.05.04 |
---|---|
[JPA] 영속성 전이 cascade, 고아 객체 (0) | 2023.01.30 |
[SpringBoot] JPA Spring Security + refresh token으로 회원가입 구현하기 (1) (0) | 2023.01.23 |
[SpringBoot] 트랜잭션 @Transactional 정리 및 관리 방법 (0) | 2023.01.12 |
[SpringBoot] 스프링부트 구글 로그인 API REST 방식으로 구현하기 (2) | 2023.01.11 |